我们的立场很简单:我们是 Geonode,我们销售代理服务,这与类选择毫无关系。 唯一相关的一点是,基于类的选择器是最不稳定的类型,而这种不稳定性从外部看完全就像一个网络问题——请求成功了,页面也加载了,但你的数据提取却返回了空结果或错误结果。 如果你正在调试一个停止工作的爬虫,在检查页面抓取过程的任何细节之前,请先确认类名是否发生了变化。对于使用现代构建管道的网站,类名可能会在每次部署时发生变化。
“Class” 属性的问题
class 包含一个以空格分隔的标记列表,而 XPath 并不支持这种列表概念。它只将此视为一个字符串。
<div class="card featured large">...</div>
对 XPath 而言,@class 就是字符串 "card featured large"。XPath 没有内置方法来判断“card 是否是其中一个标记”,而这正是 CSS 选择器通过 .card 原生支持的功能。
这一差距导致了两种失败模式。
精确匹配过于严格:
//div[@class='card']
仅匹配 class="card",不匹配其他任何内容,也不允许有额外的空格。它会漏掉 class="card featured"、class="featured card" 和 class=" card "。在实际页面中,它会漏掉你想要的大部分内容。
子字符串匹配过于宽松:
//div[contains(@class, 'card')]
它能正确匹配 class="card",但也会匹配 class="card-large"、class="postcard"、class="discard" 和 class="card-footer"。在包含任何相关命名的页面上,它返回的结果集比你请求的更广——而你的代码会默默地取第一个结果。
第二个问题更为危险,因为它会产生结果。虽然返回了内容,看起来也合理,但实际选中的却是错误的元素。
正确的表达方式
标准解法会在两侧添加空格,以确保只有完整的令牌才能匹配:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')]
让我们一步步分析:
**normalize-space(@class)
** 会去除首尾空格,并将内部连续的空格压缩为单个空格。" card featured "
变为 "card featured"
。
**concat(' ', ..., ' ')
** 将结果用空格包裹,从而得到 " card featured "
。现在每个令牌的两侧都由空格分隔。
**contains(..., ' card ')
** 查找被空格包围的目标。
请对照导致简单版本出错的案例进行验证:
| 类属性 | 填充后的字符串 | 是否包含 ' card '
? |
|---|---|---|
| card
| " card "
| 是 |
| card featured
| " card featured "
| 是 |
| featured card
| " featured card "
| 是 |
| card-large
| " card-large "
| 否 |
| discard
| " discard "
| 否 |
| postcard footer
| " postcard footer "
| 否 |
| card
| " card "
| 是 |
所有情况均正确。normalize-space()
这一步并非多余——如果没有它,包含双空格的class="card featured"
会生成" card featured "
,该链接虽然仍包含" card "
且恰好能正常工作,但包含换行的class="card\nfeatured"
则无法正常工作。
这确实有些冗长。将其封装起来:
def has_class(name):
return f"contains(concat(' ', normalize-space(@class), ' '), ' {name} ')"
tree.xpath(f"//div[{has_class('card')}]")
const hasClass = name =>
`contains(concat(' ', normalize-space(@class), ' '), ' ${name} ')`;
任何使用 XPath 进行严肃数据提取的代码库,最终都会出现某种形式的此类辅助函数。写一次总比在二十个表达式中有一个因填充错误而出错要好。
多个类
结合使用 and
:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')
and contains(concat(' ', normalize-space(@class), ' '), ' featured ')]
这正是冗长性真正令人头疼的地方——用 140 个字符表达的内容,CSS 只需 div.card.featured
就能实现。
对于“两个类中的任意一个”,请使用 or
:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')
or contains(concat(' ', normalize-space(@class), ' '), ' tile ')]
对于“具有这个类但不具有那个类”,请使用
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')
and not(contains(concat(' ', normalize-space(@class), ' '), ' hidden '))]
当涉及两个或更多类条件时,请认真考虑是否可以使用 CSS 选择器。div.card.featured:not(.hidden)
实现了相同的逻辑,但字符数仅为前者的五分之一,而且所有主流解析库都同时支持 CSS 选择器和 XPath。并没有规定要求你必须在整个文件中统一使用一种语言。
生成的和带哈希的类名
这是现代开发中出现的一个复杂问题,它改变了之前的建议。
许多前端构建工具会生成带作用域的类名以避免冲突——例如 CSS Modules、styled-components 以及各种 CSS-in-JS 库。 生成的结果如下:
<div class="ProductCard_container__3xK9p">...</div>
<div class="css-1x9dj2k">...</div>
。每当组件样式发生变化时,哈希部分就会改变,这在实际应用中意味着在多次部署时都会发生变化。锚定在完整名称上的选择器会无预警地失效。
有三种处理方法,按优先级排序如下。
锚定在稳定的前缀上(如果工具生成了前缀):
//div[starts-with(@class, 'ProductCard_container__')]
CSS Modules 通常会生成 ComponentName_elementName__hash
,因此最后两个下划线之前的部分在不同构建中是稳定的。在适用情况下,这种方法效果很好。
**改用 data-*
属性。** 许多应用程序会包含 data-testid
、data-test
或类似的属性,其目的正是为了给自动化工具提供一个稳定的锚点:
//div[@data-testid='product-card']
如果存在此类属性,请直接使用。它比任何类名都更稳定,因为它是经过刻意选择而非自动生成的。
完全基于其他元素作为锚点。 可以相对于标题、文本内容或元素类型进行定位。//h2[normalize-space()='Featured']/following-sibling::div[1]
并不关心类名的具体名称。
至于完全不透明的情况——css-1x9dj2k
且没有任何稳定组件——基于类的选择根本行不通,若强行采用,生成的爬虫每周都会出错。请在页面上寻找结构化数据,或者查找页面本身调用的 API。
类名顺序与空格
有两点容易让人犯错,值得在此明确说明。
属性中的类名顺序没有意义。 class="card featured"
和 class="featured card"
在浏览器和 CSS 中是等效的。带填充的连接(padded-concat)语法能正确处理这两种情况;而精确匹配则无法可靠地处理任何一种情况。 如果你发现自己写出的表达式依赖于顺序,那说明有问题。
空格可以是任何形式。 制表符和换行符都是类属性中的有效分隔符,且它们会出现在手动格式化的 HTML 中:
<div class="card
featured">
normalize-space()
会将所有内容合并,这就是为什么该惯用法包含它。 如果使用 concat(' ', @class, ' ')
且未进行规范化处理,该表达式在上述标记上会失败,而这种失败在渲染后的页面中是不可见的。
区分大小写。 在作为 XHTML 解析的 HTML 文档中,类名区分大小写;而在标准模式的 HTML 中,CSS 匹配时不区分大小写——但 XPath 字符串比较始终区分大小写。 如果页面同时包含 Card
和 card
,带填充符的惯用表达式会将它们视为不同的元素。XPath 1.0 没有 lower-case()
函数,因此解决方法是使用 translate()
并显式指定字母集,此时表达式会变得完全难以阅读,显然 CSS 选择器是更好的选择。
获取类的祖先或后代
类选择通常是一种手段,而非目的。两种模式涵盖了其中大部分情况。
从类到其内部元素:
//div[contains(concat(' ',normalize-space(@class),' '),' card ')]//span[@class='price']
或者,在已经包含 card 元素的代码中,使用相对表达式——此时开头的点(.)至关重要:
for card in tree.xpath(f"//div[{has_class('card')}]"):
price = card.xpath(".//span[@class='price']/text()")
.//span
会在 card 元素内部进行搜索。 //span
则从根节点开始搜索整个文档,这会返回页面上每个 card 元素的第一个价格。 这会产生统一、看似合理但实际上错误的输出结果,也是数据提取代码中最常见的错误之一。
从某个元素到其容器:
//span[@class='price']/ancestor::div[contains(concat(' ',normalize-space(@class),' '),' card ')][1]
[1]
参数至关重要:ancestor::
采用反向轴,因此位置 1 表示最近的匹配祖先,而非最外层的祖先。如果不使用该参数,将会获取所有匹配的祖先,而在存在嵌套容器的情况下,这通常并非预期结果。
针对特定库的说明
语法在各处都是一样的;但周围的 API 却不尽相同,而一些库之间的差异会导致本可避免的混淆。
lxml(Python)。 同时支持这两种语言,而 cssselect
是课堂作业的务实选择:
from lxml import html
tree = html.fromstring(source)
tree.cssselect('div.card.featured') # clear
tree.xpath(f"//div[{has_class('card')}]") # when inside a larger expression
cssselect
会在内部将 CSS 转换为 XPath,因此对于其支持的选择器而言,两者的功能是等效的。请注意,它与 lxml 本身是独立的包,需要单独安装。
BeautifulSoup(Python)。 完全不支持 XPath —— 这一事实常让来自其他生态系统的开发者感到惊讶。它提供了用于 CSS 选择器的 select()
以及自带的 find_all(class_='card')
API,后者原生支持正确的标记匹配。若您需要专门使用 XPath,则必须使用 lxml。
Selenium。 同时支持 By.XPATH
和 By.CSS_SELECTOR
,并分别利用浏览器的引擎进行解析。这意味着仅支持 XPath 1.0,且 CSS 支持范围取决于浏览器的支持情况——包括 :has()
。
Playwright。 会根据字符串自动检测定位器类型,因此 page.locator('div.card')
和 page.locator('//div[@id="x"]')
均可直接使用,无需前缀。 它还提供了基于文本的定位器——page.getByText()
、page.getByRole()
——这些定位器覆盖了此前人们需要借助 XPath 文本匹配功能实现的大部分需求,且比这两种语言都更易于阅读。
Scrapy。 在选择器上提供了 .css()
和 .xpath()
方法,并且它们支持链式调用,这确实非常有用:
for card in response.css('div.card'):
price = card.xpath(".//dt[normalize-space()='Price']/following-sibling::dd[1]/text()").get()
在同一个表达式链中,结构解析使用 CSS,标签查找使用 XPath。请注意 XPath 开头的 .
—— Scrapy 的链式选择器与其他所有情况一样,都存在“绝对路径与相对路径”的陷阱。
浏览器控制台。 $x('//div[@class="card"]')
可在 Chrome 和 Firefox 的开发者工具中评估 XPath,而 document.querySelectorAll('div.card')
则用于处理 CSS。在将表达式写入代码之前,花十秒钟在这里测试一下是值得的,但需要注意的是:浏览器的 DOM 是经过 JavaScript 处理后的,而你的解析器处理的 DOM 则不是——在控制台中有效的表达式,在原始 HTML 中可能找不到任何结果。
何时应改用 CSS
直白地说,因为对于这项具体任务,答案通常是肯定的。
当你的筛选条件是类名时,请使用 CSS。 div.card、div.card.featured、div.card:not(.hidden) —— 这些写法都更清晰、更简洁,且默认情况下是正确的。CSS 将 class 属性视为标记列表,而这正是 XPath 所缺乏的。
当类仅是附带条件,且真实条件是 CSS 无法表达的内容(尤其是文本内容)时,请使用 XPath。//div[contains(@class,'card')][.//span[contains(., 'Sold out')]] 需要使用 XPath 来满足文本条件,而类属性只是顺带包含的。
混合使用。 每款主流解析库都同时支持这两种方式。lxml 提供了 cssselect 和 xpath;Selenium 支持这两种定位策略;Playwright 同样支持。使用 CSS 进行结构选择、用 XPath 处理文本条件并非妥协,而是能产生最简洁且易于阅读的代码的方案。
XPath 类选择的唯一适用场景是:当您需要在更大的 XPath 表达式内部使用它,且无法在表达式中途切换语言时。这确实是一种限制,也是该惯用法存在的原因——但其适用范围比实际应用中常见的 XPath 类匹配要窄得多。
大家还问
如何在XPath中按类名选择元素?
使用//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')]。填充符确保仅匹配完整的类名标记。单纯的@class='card'会遗漏带有附加类的元素,而contains(@class,'card')还会匹配card-large和discard。
为什么 contains(@class, 'name') 会匹配错误的元素?
因为这只是一个简单的子字符串测试,不考虑单词边界。class 属性是一个以空格分隔的列表,但 XPath 将其视为单个字符串,因此 contains(@class,'card') 会匹配任何包含这四个字符的类名(无论出现在何处)。
如何在 XPath 中选择具有两个类的元素?
将两个带填充的连接条件与 and 结合使用。这种方法有效,且代码长度约为 140 个字符。当类条件达到两个或更多时,CSS 选择器 —— div.card.featured —— 会清晰得多,而且大多数解析库都支持使用 CSS 选择器。
在 XPath 中类名的顺序重要吗?
对于“带填充的连接”这种惯用法,类名的顺序并不重要,因为它会在属性中的任何位置匹配该标记。但在精确比较时,顺序就很重要,这也是 @class='card featured' 不是理想选择的几个原因之一。类名的顺序在 HTML 中毫无意义,因此依赖类名顺序的选择器无异于埋下隐患。
如何处理随机生成的类名?
如果工具生成了类名,请使用 starts-with() 锚定在稳定的前缀上;如果应用程序提供了 data-testid 属性,请使用该属性;或者完全锚定在其他内容上,例如文本或结构。对于完全不透明且没有稳定部分的哈希类名,基于类的选择是不可行的。
按类选择时,XPath 和 CSS 哪个更好?
显然是 CSS。它将 class 属性视为标记列表(这正是其本质),因此 div.card 既简洁又准确。而 XPath 需要一个长达七十个字符的惯用语才能表达相同的意思。 当您还需要实现 CSS 无法实现的功能(例如匹配文本)时,请使用 XPath。
如何在 XPath 中通过类名查找父元素?
//span[@class='price']/ancestor::div[contains(concat(' ',normalize-space(@class),' '),' card ')][1]。[1] 会选择最近的匹配祖先,因为 ancestor:: 是一个反向轴,其中位置 1 表示最近而非最外层。
为什么我的相对 XPath 对每个元素返回的值都一样?
因为你在循环中使用了 // 而不是 .//。以 // 开头的表达式会从文档根节点开始搜索,无论上下文如何,因此每次迭代都会找到整个页面上的第一个匹配项。而 .// 则在当前元素内部进行搜索,这才是你想要的效果。
总结
在类选择方面,XPath 显露出其年代感。类属性是一个标记列表,而 XPath 没有标记操作,因此要表达“具有此类”这一含义,就需要在字符串中插入空格,然后搜索包含这些空格的标记——这需要 70 个字符才能实现,而 CSS 仅用 9 个字符就能表达。
请掌握这种惯用写法,因为在更复杂的 XPath 表达式中你会用到它;同时将其封装在辅助函数中,这样只需编写一次,而非重复二十次。normalize-space() 这一步绝不可省略:真实的标记语言中,类属性可能包含换行符和制表符,而未经规范化的表达式会因这些字符而隐式失败。
但更有用的结论其实是:尽量避免使用这种惯用法。如果你的选择条件仅限于类名,那就直接编写 CSS 选择器。所有主流库都同时支持这两种方式,在同一个文件中混合使用很常见,而且根据表达式选择合适的工具,能产生更简洁、同事也能读懂的代码。
并且,请将类名视为最不稳定的锚点。 生成的和哈希生成的名称在部署时会发生变化;即使是手动编写的名称,只要有人重新设计组件,也会随之改变。一个 data-testid、一个标题的文本,或者一个元素与某个可识别对象的关系,都比类名更持久——而当类名消失时,其故障模式并非报错,而是产生静默、语法正确且为空的输出。
