我们在此虽仅涉及微小利益,但仍值得说明:我们是 Geonode,向从事数据抓取工作的人士出售代理服务,因此在技术支持对话中,选择器的问题屡见不鲜。在进行比较之前,值得强调的是:无论选择哪种方式,都不会影响你是否会被封禁。 从表面上看,选择器问题和网络问题似乎如出一辙——两者都会导致“我的爬虫停止返回数据”——但它们其实毫无关联。如果页面完整加载,而你的表达式未匹配到任何内容,这就是选择器问题,任何基础设施层面的调整都无法解决。
简要说明
| CSS | XPath | |
|---|---|---|
| 按类、ID、属性选择 | 是,简洁 | 是,笨拙 |
| 后代和子节点关系 | 是 | 是 |
| 按文本内容选择 | 否 | 是 |
| 导航至父节点或祖先节点 | 部分支持,通过 :has() | 是,直接 |
| 导航至上一个同级节点 | 部分支持,通过 :has() | 是,直接 |
| 位置选择 | :nth-child() 家族 | position()、last()、谓词 |
| 字符串函数 | 否 | 是 |
| 查询带命名空间的 XML | 否 | 是 |
| 可读性 | 更好 | 更差 |
| 工具和浏览器支持 | 普遍支持 | 1.0 版本普遍支持 |
其中两行最为关键。CSS 完全无法根据文本内容进行选择,这使其无法满足最常见的提取模式——根据旁边的标签查找值。而且 XPath 更难阅读,当一年后同事需要维护你的代码时,这一点的重要性远超人们的想象。
CSS 的优势
类和属性的选择,而且优势非常明显。
div.product-card
a[href^="https://"]
input[type="checkbox"]:checked
ul > li:first-child
section.content p:not(.footnote)
相应的 XPath 表达式更长,而且对于类来说,写起来确实很别扭:
//div[contains(concat(' ', normalize-space(@class), ' '), ' product-card ')]
//a[starts-with(@href, 'https://')]
//ul/li[1]
第一个是类匹配的惯用写法,之所以存在,是因为 @class
是一个由单个空格分隔的字符串,而 XPath 并不支持其中的令牌概念。 一个简单的 contains(@class, 'product-card')
也会匹配 product-card-large
和 old-product-card
,因此带填充的版本才是正确的。它的长度是 div.product-card
的四倍,且更难扫描。
如果你的选择条件是类、ID、属性及结构关系,请使用 CSS。 这已涵盖绝大多数实际的元素选择工作,若为此选择 XPath,就意味着为了那些你并未用到的功能而牺牲可读性。
CSS 在工具支持方面也更具优势。每个浏览器的元素检查器都能原生生成 CSS 选择器,大多数测试框架默认使用它们,而且 document.querySelectorAll
无需任何辅助工具即可在任何地方使用。
XPath 的优势
文本匹配,这是 CSS 完全无法实现的:
//button[normalize-space()='Continue']
//a[contains(., 'Download')]
//dt[normalize-space()='Price']/following-sibling::dd[1]
最后那个模式——查找标签,获取相邻值——是结构化页面提取的核心功能,而 CSS 无法通过表达式实现这一点,因为 CSS 无法访问文本内容。 正是这一项能力,使得XPath在那些原本全面使用CSS的代码库中依然被广泛用于数据抓取。
祖先导航:
//span[@class='price']/ancestor::div[contains(@class,'card')][1]
从某个值向上遍历至包含该值的容器。:has()
为CSS提供了一种实现方式,其局限性将在下文讨论。
基于内容的相对位置逻辑:
//h2[normalize-space()='Specifications']/following-sibling::table[1]
特定标题后的第一个表格。:nth-child()
仅统计同级元素中的位置;它无法表达“位于文本为 X 的元素之后”这一条件。
字符串函数。 normalize-space()
、substring-before()
、translate()
及其他函数允许你在表达式内部进行运算。其中 normalize-space()
尤为重要,因为真实的 HTML 经过了美化处理,若直接与 "\n In stock\n"
进行精确文本比较将会失败。
带命名空间的 XML。 如果你查询的是 XML 而不是 HTML——例如网站地图、RSS 源或 SOAP 响应——那么 CSS 并不是合适的工具。XPath 专为此设计,且命名空间处理是其设计的一部分。
:has() 带来了怎样的变化
在这项比较中,最重大的进展是 ``,而许多文章都早于这一变化。
MDN 文档 将 :has() 描述为“当作为参数传递的任何相对选择器以该元素为锚点时,与至少一个元素匹配,则该元素即为目标”,从而提供“一种相对于参考元素选择父元素或前序兄弟元素的方法”。 其基线状态为“广泛可用”,自 2023 年 12 月起已获得各浏览器的支持。
因此,CSS 现在可以表达以前无法实现的内容:
div.card:has(span.sold-out) /* a card containing a sold-out marker */
h1:has(+ p) /* an h1 immediately followed by a p */
li:has(~ li.active) /* an li with a later active sibling */
tr:has(td.error) /* a row containing an error cell */
这涵盖了人们以前需要使用 XPath 实现的大部分功能——父元素选择以及基于兄弟元素的条件选择。
文档中记载了三项限制,值得了解。:has()“不能嵌套在另一个:has()中”。伪元素“在:has()中不是有效选择器”,也不能作为其有效锚点。其特异性为“其参数中特异性最高的选择器的特异性”,这与:is()和:not()的行为一致。
对于数据提取而言,最重要的限制并未出现在该列表中::has() 仍然无法匹配文本。 div:has(span) 有效;div:has(span:contains('Price')) 不存在,因为 :contains() 并非标准 CSS 选择器。该选择器曾被提议但已被放弃,且浏览器未对其进行实现。因此,无论 :has() 如何规定,标签-值模式仍属于 XPath 的范畴。
并列翻译
要快速掌握其中的权衡关系,最有效的方法是观察同一意图通过这两种方式表达的效果。
| 意图 | CSS | XPath |
|---|---|---|
| 具有类名的元素 | div.card | //div[contains(concat(' ',normalize-space(@class),' '),' card ')] |
| 具有 ID 的元素 | #main | //*[@id='main'] |
| 属性存在 | a[href] | //a[@href] |
| 属性以...开头 | a[href^="/docs"] | //a[starts-with(@href,'/docs')] |
| 属性包含... | a[href*="pdf"] | //a[contains(@href,'pdf')] |
| 直接子元素 | ul > li | //ul/li |
| 任意后代 | div p | //div//p |
| 第一个子元素 | li:first-child | //li[1] |
| 最后一个子元素 | li:last-child | //li[last()] |
| 第 n 个子元素 | li:nth-child(3) | //li[3] |
| 下一个同级元素 | h2 + p | //h2/following-sibling::p[1] |
| 任何后续同级元素 | h2 ~ p | //h2/following-sibling::p |
| 否定 | p:not(.footnote) | //p[not(contains(@class,'footnote'))] |
| 匹配项的父元素 | div:has(> span.price) | //span[contains(@class,'price')]/.. |
| 包含文本 | 不可行 | //p[contains(., 'Price')] |
| 精确文本 | 不可行 | //p[normalize-space()='Price'] |
| 标签旁的值 | 不可行 | //dt[normalize-space()='Price']/following-sibling::dd[1] |
纵观下表,规律显而易见。对于父行上方的所有内容,CSS 更简洁明了,而选择 XPath 则是在毫无收益的情况下选择了冗长。对于底部的三行,CSS 列甚至完全不存在。
有两处转换值得重新审视。“class”这一行在整个对比中形成了最鲜明的反差——九个字符对七十多个字符——而XPath版本并非为了冗长而冗长,因为简写形式 contains(@class,'card') 确实比 card-large 和 discard 匹配范围更广。 而“第一个子元素”这一行暗藏陷阱:li:first-child 和 //li[1] 在此处结果一致,但 //li[1] 是针对每个父元素应用的谓词,因此它会选取文档中每个列表下的第一个 li。CSS 表达毫无歧义;若仅需选取一个,XPath 则需使用 (//li)[1]。
一个真实的混合示例
实际操作中,提取产品页面时是这样的:
# CSS for the structural work — clear and adequate
cards = tree.cssselect('div.product-grid > article.product-card')
for card in cards:
name = card.cssselect('h3.product-name')[0].text_content().strip()
image = card.cssselect('img.product-image')[0].get('src')
# XPath for the label-value pairs, which CSS cannot express
price = card.xpath(
".//dt[normalize-space()='Price']/following-sibling::dd[1]"
)[0].text_content().strip()
stock = card.xpath(
".//span[contains(., 'in stock') or contains(., 'out of stock')]"
)
其中有三个地方值得借鉴。
每个 XPath 表达式开头的 .。 .//dt 仅在当前卡片内搜索;//dt 则会从根节点开始搜索整个文档,并为每张卡片返回页面上第一个匹配的标签。这是混合提取代码中最常见的错误之一,它会导致每条记录返回相同的值——看似合理、统一,实则错误。
在 CSS 足以胜任的地方就使用 CSS。 网格和卡片的选择、标题、图片。如果用 XPath 编写这些内容,不仅会增加代码长度,还会降低可读性。
仅在必要时才使用 XPath。 价格由其标签标识,库存状态由其文本标识。 这两者都无法用 CSS 表达,而它们正是该文件中包含 XPath 的根本原因。
最终生成的爬虫代码中,每个表达式都尽可能简短,读者可以一目了然地分辨出哪些部分依赖于结构、哪些部分依赖于内容——这也相当于一张地图,标明了网站发生变化时哪些部分会出现故障。
性能
通常无关紧要,偶尔却起决定性作用。
CSS 在浏览器中通常运行得更快,因为渲染引擎会对选择器匹配进行深度优化——这是渲染过程中的关键路径。而 XPath 则采用一种更通用的评估模型。
在绝大多数人处理的规模下,这种差异通常无关紧要。无论采用哪种方式,在单个页面上进行几百次选择操作都微不足道;网络请求的开销要大几个数量级。
何时会产生影响:
preceding 和 following 轴的开销确实很大。 它们会在上下文节点之前或之后扫描整个文档。在遍历大量节点的循环中,这种开销会呈二次增长。如果某个 XPath 表达式明显很慢,请检查它是否使用了上述轴之一——preceding-sibling 和 following-sibling 受同级节点数量的限制,因此性能良好。
** 嵌套表达式开头的// 会从根节点重新开始搜索。** //div//span 的开销比表面看起来更大,而循环遍历 div 元素时使用的 .//span 通常正是你想要的效果。
:has() 在大型文档中可能开销较大,因为引擎必须对每个候选元素评估内部选择器。用于内容提取时没问题;但在应用于大型页面的样式表中需注意这一点。
对于使用 lxml 或类似工具进行的服务器端解析,两者差异微乎其微,因此应以可读性为考量标准。
可用性与版本陷阱
导致“在在线测试器中能运行,但在我的代码中却不行”这一问题的陷阱。
浏览器通过 document.evaluate 实现 XPath 1.0,而 Selenium 则使用浏览器的引擎。XPath 2.0 和 3.1 的功能——matches()、replace()、lower-case()、ends-with() 以及序列——在该环境中不可用。任何使用这些功能的代码片段都会失败。
lxml 实现了 XPath 1.0 作为其通用 API,此外还支持 EXSLT 扩展,包括用于正则表达式的 re:test()。因此,在 Python 中有效的基于正则表达式的表达式在 Selenium 中将无法正常工作。
解析库对 CSS 的支持通常是通过一个转换层实现的,该层会在内部将 CSS 转换为 XPath。这种方式对于常见的选择器效果良好,但对于较新的选择器则效果欠佳——服务器端解析器对 :has() 的支持程度差异很大,因此在浏览器中能正常工作的选择器,在您的爬虫中可能无法正常工作。
**实用准则:**请在实际运行的环境中测试你的选择器,而不是在浏览器控制台或在线工具中。最常见的两个意外情况是:XPath 2.0 函数在 Selenium 中失效,以及 :has() 在 Python 解析器中失效。
实际应用中的选择
一种几乎能解决所有情况的决策流程。
从 CSS 开始。 它更易于阅读,工具支持更完善,且足以满足类、ID、属性及结构选择的需求——而这些已涵盖了绝大多数选择情况。
当需要匹配文本时,切换到 XPath。 这是主要原因,也是决定性因素:没有任何 CSS 语法结构能读取文本内容。
当需要进行超出 :has() 覆盖范围的祖先导航时,请切换到 XPath,特别是当你需要定位几层之上的特定祖先,而非对已知父元素进行条件匹配时。
处理 XML 时请切换到 XPath。 命名空间和基于文档顺序的操作正是它所专为设计的。
**在同一个代码库中同时使用这两种方法。**这既正常又合理,绝非妥协。一个爬虫程序,将 90% 的简单任务交给 CSS 处理,而将 10% 的复杂任务交给 XPath 处理,其维护起来比只依赖其中一种方法要容易得多。
还有一种比任何单一选择都更宝贵的习惯:**在编写选择器之前,先检查是否存在嵌入的结构化数据。**许多页面会在 <script type="application/ld+json"> 块中包含 JSON-LD,因为它驱动着搜索功能,而解析这些数据比解析渲染后的标记要稳定得多。即使页面经过重新设计导致所有选择器失效,这种数据依然能够保留下来。
两者都无法解决的问题
两者都存在同样的脆弱性,而这正是真正耗费时间的地方。
结构化选择器会无声地失效。 有人插入了一列,结果 td[3] 匹配到的却成了另一个单元格。系统不会抛出任何错误;你的数据就这样悄无声息地出错了。尽可能基于文本或标识符进行定位,并验证提取内容的结构——如果价格应该呈现为价格的形式,就对其进行检查。
**两者均无法处理客户端渲染的内容。**如果标记是在加载后由 JavaScript 组装的,两者在初始 HTML 中都找不到任何内容。这是需要浏览器或底层 API 解决的获取问题,而非选择器的问题。
两者均无法在页面重新设计后自动适应。 缓解措施是将选择器集中存放在一处,这样在页面变更后,更新工作只需一小时而非一整天;同时保存已解析的 HTML,以便在出现故障时对比新旧版本。
**此外,两者均不受页面获取方式的影响。**这正是我们讨论的重点:如果文档完整且你的表达式未匹配到任何内容,那么无论基础设施如何变化,结果都不会改变。
大家还常问
XPath 比 CSS 选择器更好吗?
总体而言,两者没有孰优孰劣之分。XPath 功能更强大——它可以匹配文本、遍历祖先节点并使用字符串函数。CSS 则更易于阅读,且得到工具链的更好支持。建议默认使用 CSS,仅在 CSS 无法表达的特定场景下才使用 XPath。
CSS 选择器能根据文本进行选择吗?
不能。目前尚无针对文本内容的标准 CSS 选择器——:contains() 虽曾被提议,但从未被采纳,且浏览器也未实现该规范。这是 CSS 与 XPath 之间最大的功能差距,也是 XPath 在数据提取工作中依然被广泛使用的主要原因。
:has() 能否取代 XPath?
部分可以。它提供了 CSS 父元素选择和兄弟元素条件匹配功能,这些功能此前仅由 XPath 支持。但它并未增加文本匹配功能,因此标签-值模式仍需依赖 XPath。此外,它不支持嵌套,且在服务器端解析库中的支持情况各不相同。
XPath 和 CSS 哪个更快?
在浏览器中,CSS 通常更快,因为选择器匹配经过了渲染优化的处理。与网络延迟相比,这种速度差异通常可以忽略不计。XPath 真正变慢的地方在于 preceding 和 following 轴,因为它们需要扫描整个文档。
我可以在 Selenium 中使用 XPath 2.0 吗?
不可以。浏览器通过 document.evaluate 实现了 XPath 1.0,而 Selenium 使用的是浏览器的引擎。因此,matches()、lower-case() 和 ends-with() 等函数不可用。这通常是代码片段在在线测试器中能运行,但在测试套件中却失败的原因。
如何选择父元素?
在 XPath 中,使用 parent:: 或 ..。在 CSS 中,:has() 提供了一种条件形式——div:has(> span.price) 会选择 div 而不是 span。若要选择几层之上的特定祖先元素,XPath 的 ancestor:: 轴更为直接。
进行网页抓取时,应该使用 CSS 还是 XPath?
两者都用,在同一个代码库中。CSS 用于类、ID 和属性选择,这占了大部分情况。当需要匹配文本时则使用 XPath——通过标签查找值是典型的用例,而 CSS 没有对应的实现方式。
当网站发生变化时,哪种方式更稳定?
从本质上讲,两者都不稳定——稳定性取决于你锚定的对象,而非使用的语言。锚定在文本或data-*属性上可以经受住网站改版;而锚定在位置上则无法经受住任何改版。这两种语言都允许你采用这两种方式。
总结
这一比较归结为两个不对称之处:CSS 无法解析文本,而 XPath 更难阅读。其余的都是细节。
因此,实际应用规则很简单:默认使用 CSS,因为大多数选择都是基于类、ID 或属性的,而 CSS 能清晰地表达这些。只有在需要 XPath 独特功能时才使用它——首当其冲的是文本匹配,其次是祖先导航和字符串函数。 混合使用这两种方式很正常,而且一个让它们各展所长的代码库,比只选其一的代码库更容易维护。
:has() 确实缩小了两者之间的差距,在适用场景下值得采用:自 2023 年末起广泛支持的纯 CSS 父元素选择和兄弟节点条件。 只需注意它不处理文本,且服务器端解析器对它的支持不如浏览器端那么统一。
因此,在信任任何表达式之前,请先检查您的运行环境。浏览器和 Selenium 中的 XPath 1.0、lxml 中的 EXSLT 扩展功能、解析库中对 :has() 变量的支持——选择器最常见的意外情况并非语法错误,而是某项功能仅存在于您当前运行环境之外的其他地方。
