XPath与CSS选择器:在网页抓取中使用哪个
Advanced Data Extraction Specialist
TL;DR:
- CSS 和 XPath 对于普通提取返回相同的结果:来自
article.product_pod h3 a::attr(title)和//article[@class='product_pod']/h3/a/@title的相同二十本书名在同一页面返回。 - XPath 是这两者中唯一可以沿着树向 上 行走的。
parent::,ancestor::,preceding-sibling::和following-sibling::每个在该页面上匹配了 20 个节点;CSS 对这些节点没有等价物。 - “CSS 更快”是一个浏览器事实,而不是 Python 事实。在 parsel 中,相同的提取通过 CSS 花费了 487.1 毫秒,而通过 XPath 花费了 309.1 毫秒,在 2000 次迭代中,因为 cssselect 在运行之前将每个 CSS 查询编译成 XPath。
a:contains('Sharp')在 parsel 中返回一个匹配,并在真实的 Chrome 页面中抛出SyntaxError,这就是为什么一个选择器可以在抓取器中工作而在开发者工具中失败的原因。- 当标记从未到达时,两种语言都无关紧要;选择器只能查询实际构建的 DOM。
- 两种语言都读取你的抓取层解码的字节,所以一个没有字符集的页面可以返回
£47.82,而浏览器显示£47.82。 - 在 Scrapeless 免费计划 上针对完全渲染的页面运行任一语法。
打开任何产品列表的检查器,复制 Chrome 提供的选择器,粘贴到 Python 抓取器中,通常就能工作。有趣的情况是它不能工作的地方——那个选择器在一个引擎中有效而在另一个中产生语法错误,或者你写的整理好的 CSS 变得比你避免的 XPath 更慢。
这两种语言重叠很大,因此有用的比较在边缘处:每种语言能表达什么、每种语言的成本,以及哪个引擎实际上在执行查询。
下面的每个测量都来自一个为抓取实践而构建的公共页面——在 books.toscrape.com 上的一个二十本书类别列表——使用 lxml 6.0.2,cssselect 1.3.0 和 parsel 1.10.0 进行解析。
两种语言中的相同查询
对于常见的目标,这两种语言是彼此的直接翻译。下面的表格配对在测试页面上选择相同节点集的查询。
| 目标 | CSS | XPath |
|---|---|---|
| 任何标签 | article |
//article |
| 类别 | .product_pod |
//*[contains(concat(' ',normalize-space(@class),' '),' product_pod ')] |
| ID | #messages |
//*[@id='messages'] |
| 后代 | article h3 a |
//article//h3//a |
| 直接子元素 | div > span |
//div/span |
| 属性值 | a[title] |
//a[@title] |
| 第 N 个子元素 | li:nth-child(2) |
//li[2] |
| 属性文本 | a::attr(href) |
//a/@href |
来自该表格的三个配对,在实时页面上运行,返回匹配的节点集:
python
from parsel import Selector
import requests
url = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
response = requests.get(url, timeout=30)
response.raise_for_status()
sel = Selector(text=response.content.decode("utf-8"))
pairs = [
("article.product_pod h3 a::attr(href)", "//article[@class='product_pod']/h3/a/@href"),
("p.star-rating::attr(class)", "//p[contains(@class,'star-rating')]/@class"),
("div.image_container img::attr(alt)", "//div[@class='image_container']//img/@alt"),
]
for css_query, xpath_query in pairs:
css_hits = sel.css(css_query).getall()
xpath_hits = sel.xpath(xpath_query).getall()
print(len(css_hits), len(xpath_hits), css_hits == xpath_hits)
每对打印 20 20 True。当两种语言都能表达目标时,选择的是可读性而不是能力。
只有 XPath 能表达的内容
CSS 向下选择。它从祖先移动到后代,绝不向上移动,因此规则可以说“这个卡片内的价格”,但不能说“包含这个价格的卡片”。
XPath 带有轴,而其中四个完全没有 CSS 等价物。每个在测试页面上匹配了 20 个节点:
python
axes = [
("parent::", "//p[@class='price_color']/parent::div/@class"),
("ancestor::", "//h3/ancestor::article/@class"),
("preceding-sibling::", "//div[@class='product_price']/preceding-sibling::h3/a/@title"),
("following-sibling::", "//h3/following-sibling::div[@class='product_price']//p[@class='price_color']/text()"),
]
for label, query in axes:
hits = sel.xpath(query).getall()
print(f"{label:20s} {len(hits):2d} hits {hits[0]!r}")
text
parent:: 20 hits 'product_price'
ancestor:: 20 hits 'product_pod'
preceding-sibling:: 20 hits 'Sharp Objects'
following-sibling:: 20 hits '£47.82'
最后一个模式值得保留。“找到标题,然后获取随之而来的价格”是兄弟关系,在 CSS 中表达意味着单独选择价格并按索引重新连接两个列表——这在其中一个卡片缺少价格时悄悄地产生错误对。
W3C 选择器第 4 级规范 定义了 CSS 语法,向上的遍历是设计中缺失的:这个语言是为样式构建的,渲染器是从上到下解析规则的。XPath 1.0 推荐 定义了十三个轴,因为它是为在文档树中寻址任意节点而构建的。
:contains 分割
文本匹配是这两种语言以产生令人困惑的错误报告的方式分歧的地方。
XPath 一直有 contains()。CSS 在早期草案中有 :contains() 伪类,并在规范稳定之前被删除。问题是 Python 的 cssselect 仍然实现了它。
python
print(len(sel.css("a:contains('Sharp')").getall())) # 1
print(len(sel.xpath("//a[contains(., 'Sharp')]").getall())) # 1
两者打印 1。现在在真实的浏览器页面内相同的两个查询,通过 DOM 的自身引擎评估:
python
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
JS = """() => {
const out = {};
try { out.css_class = document.querySelectorAll('article.product_pod h3 a').length; }
catch (e) { out.css_class = 'ERR ' + e.name; }
try { out.css_contains = document.querySelectorAll("a:contains('Sharp')").length; }
catch (e) { out.css_contains = 'ERR ' + e.name + ': ' + e.message.slice(0, 60); }
const xp = (q) => document.evaluate(q, document, null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength;
out.xpath_class = xp("//article[@class='product_pod']/h3/a");
out.xpath_contains = xp("//a[contains(., 'Sharp')]");
out.xpath_parent = xp("//p[@class='price_color']/parent::div");
out.xpath_ancestor = xp("//h3/ancestor::article");
return out;
}"""
url = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
endpoint = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
"token": os.environ["SCRAPELESS_API_KEY"], "sessionTTL": 300, "proxyCountry": "US",
})
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(endpoint)
page = browser.new_page()
page.goto(url, wait_until="domcontentloaded")
for key, value in page.evaluate(JS).items():
print(f"{key:16s} {value}")
browser.close()
text
css_class 20
css_contains ERR SyntaxError: Failed to execute 'querySelectorAll' on 'Document': 'a:conta
xpath_class 20
xpath_contains 1
xpath_parent 20
xpath_ancestor 20
CSS 文本匹配抛出。XPath 文本匹配返回其一个节点。因此,在 Python 抓取器中通过测试的选择器在有人将其粘贴到开发者工具中进行检查时会引发语法错误——对于在控制台中验证选择器并在发布前进行验证的任何人而言,情况正好相反。
两件值得注意的事情从那次运行中得出。浏览器 XPath 没有退化:parent:: 和 ancestor:: 通过 DOM 的 document.evaluate 接口 各解析了 20 个节点。而 :contains 是一个库扩展,而不是语言特性,因此它的传播范围仅限于库的范围。
如果选择器必须在两个地方都有效,//a[contains(., 'Sharp')] 是可移植的形式。
哪一个实际上更快
普遍的答案是 CSS 更快。这在浏览器中是正确的,那里 querySelectorAll 是一个本地快速路径,而在 Python 中是相反的。
在 lxml 中没有 CSS 引擎。每个 CSS 查询都通过 cssselect 转换为 XPath,XPath 引擎执行它,正如 Scrapy 选择器文档 直接表示的那样。编写 CSS 帮助了翻译步骤。
python
import time
N = 2000
start = time.perf_counter()
for _ in range(N):
sel.css("article.product_pod h3 a::attr(title)").getall()
css_ms = (time.perf_counter() - start) * 1000
start = time.perf_counter()
for _ in range(N):
sel.xpath("//article[@class='product_pod']/h3/a/@title").getall()
xpath_ms = (time.perf_counter() - start) * 1000
print(f"CSS {css_ms:8.1f} ms ({css_ms / N * 1000:6.1f} us/call)")
print(f"XPath {xpath_ms:8.1f} ms ({xpath_ms / N * 1000:6.1f} us/call)")
text
CSS 487.1 ms ( 243.5 us/call)
XPath 309.1 ms ( 154.5 us/call)
CSS 的成本是相同输出的 XPath 时间的 1.58 倍。打印翻译显示了它的去向:
python
from cssselect import GenericTranslator
print(GenericTranslator().css_to_xpath("article.product_pod h3 a"))
text
descendant-or-self::article[@class and contains(concat(' ', normalize-space(@class), ' '), ' product_pod ')]/descendant-or-self::*/h3/descendant-or-self::*/a
手动编写的 //article[@class='product_pod']/h3/a 比较一个属性。生成的形式规范化空白并在每个候选节点上填充类列表,因为它必须在携带多个类的元素上是正确的。那个防御性谓词是 1.58 倍。
在优化之前把这个放在比例中:在一个 50 KB 的页面上,每次调用的间隙大约为 89 微秒。单个 HTTP 请求的成本要高出数千倍。选择器语言并不是 scraper 时间的去处,而可读性通常是更好的交易——数字只在对缓存 HTML 的热解析循环中才重要。
两种语言共享的陷阱
选择器返回你获取层解码的内容。当服务器发送 text/html 而没有 charset 时,请求根据 HTTP 语义规范的媒体类型规则 回退到 ISO-8859-1,而在线路上的字节是 UTF-8:
python
import requests
from parsel import Selector
url = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
response = requests.get(url, timeout=30)
response.raise_for_status()
print(response.headers.get("content-type"))
print(response.encoding)
print(response.apparent_encoding)
print(Selector(text=response.text).css("p.price_color::text").get())
print(Selector(text=response.content.decode("utf-8")).css("p.price_color::text").get())
text
text/html
ISO-8859-1
utf-8
'£47.82'
'£47.82'
选择器在两次中都是正确的。显式解码 response.content 而不是信任 response.text,才使得提取的价格可用——而没有选择器语言的更改能解决这个问题。
在对客户端渲染的页面测试选择器时需要一个真实的浏览器。Scrapeless 免费计划 覆盖了足够的会话,以便尝试针对一个实时 DOM 的两种语法。
两种语言都无助的地方
两种语言都查询 DOM。都没有构建一个。
当列表在客户端渲染时,普通 HTTP 客户端接收到的 HTML 包含外壳而不是记录,因此正确的选择器返回零个节点,并看起来像选择器的错误。修复在选择器的上游:首先渲染页面,然后查询它。Scrapeless 抓取浏览器 提供了一个 CDP 上的云浏览器,因此浏览器组装的页面就是你的选择器运行的页面——这正是上述在浏览器中抓取的数字的捕获方式。
python
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
url = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
cdp_endpoint = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
"token": os.environ["SCRAPELESS_API_KEY"],
"sessionTTL": 300,
"proxyCountry": "US",
})
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(cdp_endpoint)
page = browser.new_page()
page.goto(url, wait_until="domcontentloaded")
titles = page.eval_on_selector_all(
"article.product_pod h3 a", "els => els.map(e => e.title)"
)
print(len(titles), titles[0])
browser.close()
text
20 Sharp Objects
一旦 DOM 存在,选择器问题又回到了样式问题。关于选择层本身的详细介绍,我们的 parsel 指南 涵盖了一个 lxml 支持的树上的两种方言,而 定价 列出了渲染会话的费用。
选择 CSS 何时,选择 XPath 何时
| 情况 | 选择 |
|---|---|
| 类、id、属性或后代匹配 | CSS |
| 选择器与前端团队共享 | CSS |
规则必须在 DevTools 或 querySelectorAll 中运行 |
CSS 或可移植 XPath |
| 根据容器的内容选择 | XPath |
| 将标签与其后面的值配对 | XPath |
| 根据可见文本匹配 | XPath |
| 向上找到祖先 | XPath |
| 解析 XML、RSS 或网站地图 | XPath |
| 在 lxml 中对缓存 HTML 进行热循环 | XPath |
当目标可以向下寻址并且查询将被编写样式表的人阅读时,选择 CSS。它更短,而在测试页面上它选择了所有普通字段,没有损失。
当你需要的关系是结构性而不是层次性时——在树上向上、跨兄弟,或基于文本时,选择 XPath。它也是 lxml 和 parsel 内部的诚实默认值,因为它是实际执行的内容。
大多数工作爬虫在每个字段上混合使用这两种选择器,而不是选择一方,parsel 在同一树上同时接受这两种,因此决定是针对每个选择器,而不是每个项目。
结论
CSS 和 XPath 在一般情况下回答相同的问题,这二十个标题从两者返回的结果是相同的。重要的差异比通常的框架所暗示的要小得多:XPath 可以向上遍历并匹配文本,CSS 不能;在浏览器中,CSS 更快,而在 lxml 中则更慢,因为它首先编译成 XPath;而 :contains 在一个引擎中有效,而在另一个引擎中抛出异常,这也是大多数“在我的爬虫中有效,但在控制台中无效”混淆的源头。
根据选择器选择,在两个地方必须运行的查询中保持可移植的形式,并在指责任何语言导致混乱字符之前解码响应。
准备好测试选择器以处理在查询之前就渲染的页面吗? 从 Scrapeless 免费计划开始 并指向一个实时 DOM。
常见问题
问:XPath 比 CSS 选择器快吗?
这取决于引擎。在 lxml 和 parsel 中,XPath 更快,因为 CSS 在执行之前编译成 XPath — 测量的间隔为 309.1 毫秒对 487.1 毫秒,在同一提取的 2,000 次迭代中。在浏览器中,querySelectorAll 是一个原生快速路径,而 CSS 胜出。无论哪种方式,差异都是微秒级的,远低于 HTTP 请求的成本。
问:CSS 选择器能匹配元素文本吗?
在浏览器中不能。document.querySelectorAll("a:contains('x')") 报错 SyntaxError,因为 :contains() 在选择器规范稳定之前被弃用了。Python 的 cssselect 仍然实现了它,因此相同的查询在 parsel 中有效。对于在两者中表现相同的选择器,使用 //a[contains(., 'x')]。
问:CSS 选择器可以选择父元素吗?
不可以。CSS 只向下选择,因此没有 parent:: 或 ancestor:: 的对应物。XPath 可以处理这两者,在测试页面中 //h3/ancestor::article 匹配了所有 20 个卡片。:has() 伪类允许你根据其子孙 过滤 元素,这涵盖了一些相同的意图,但它仍然返回外部元素,而不是从内部元素向上遍历。
问:为什么我的选择器在 DevTools 中有效但在 Python 中返回空值?
两个常见原因。页面使用 JavaScript 渲染内容,因此你的 HTTP 客户端收到的 HTML 中从未包含浏览器稍后构建的节点 — 选择器是正确的,但 DOM 不在那里。或者选择器使用了仅浏览器或仅库的扩展。在更改选择器之前,比较原始响应体与检查器显示的内容。
问:我应该在 Scrapy 中使用 CSS 还是 XPath?
两者,按字段。Scrapy 在相同的 parsel 选择器上暴露 response.css() 和 response.xpath(),并可以将它们串联在一起。对于简单的类和属性匹配使用 CSS,对于文本匹配或向上遍历则切换到 XPath,而不是将 CSS 规则扭曲以适应。
问:XPath 选择器在所有浏览器中都有效吗?
是的,通过 document.evaluate 而不是 querySelectorAll。这是一个独立的 DOM 接口,轴完全支持 — parent:: 和 ancestor:: 在上述运行中各返回了 20 个节点。Chrome DevTools 中的 $x() 帮助程序包装了用于控制台使用的相同接口。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



