关于本文作者的简要说明。我们是 Geonode,主要向从事数据提取的人士销售代理服务,因此 XPath 与我们的业务相关,但并非其组成部分。 需要特别说明的是,选择器问题和代理问题表现截然不同,混淆二者会白白浪费数小时——被拦截的请求会返回验证页面或错误状态码,而表达式错误则会返回一个完全正常的页面,但查询结果为空。 如果 HTML 内容存在,但你的 XPath 未能找到任何内容,说明网络没有问题,且当前页面就是正确的位置。
基本语法
| 表达式 | 选择范围 |
|---|---|
/html/body/div | 从根节点开始的绝对路径 |
//div | 文档中任意位置的任意 div |
//div/p | 作为 div 的直接子节点的 p 元素 |
//div//p | 位于 div 内部任意深度的 p 元素 |
. | 当前上下文节点 |
.. | 上下文节点的父节点 |
* | 任意元素 |
@href | href 属性 |
//@href | 文档中的每个 href 属性 |
text() | 上下文节点的文本节点子节点 |
node() | 任意节点,包括文本和注释 |
//a | //link | 并集 — 由任一表达式匹配到的所有内容 |
必须牢记 / 与 // 之间的区别。单斜杠表示“直接子节点”;双斜杠表示“任意深度的后代节点”。//div/p 会遗漏被 section 包裹的段落;而 //div//p 则能找到它。
绝对路径——/html/body/div[2]/div[1]/span——是浏览器“复制 XPath”功能生成的结果,也将在下次重新设计时失效。建议从可识别的起点开始,并采用相对路径进行导航。
谓词
方括号用于过滤节点集。大部分有用的操作都在这里进行。
| 表达式 | 选择对象 |
|---|---|
//div[1] | 每个父节点下,其同级节点中第一个 div |
(//div)[1] | 整个文档中第一个 div |
//div[last()] | 其同级节点中最后一个 div |
//div[position() < 4] | 前三个 |
//a[@href] | 具有 href 属性的链接 |
//a[@href='/about'] | 具有该精确 href 的链接 |
//div[@class and @id] | 同时具有这两个属性的元素 |
//p[text()] | 至少有一个文本节点子元素的段落 |
//div[p] | 包含至少一个 p 子元素的 div |
//div[not(@hidden)] | 不具有 hidden 属性的 div |
//td[.='42'][@class='qty'] | 两个依次应用的谓词 |
//div[1] 与 (//div)[1] 是 XPath 中最常见的混淆点。 前者是针对每个父节点应用的谓词,因此它会选取 每个 包含 div 的父节点下的第一个该节点——这可能涉及多个节点。 后者则按文档顺序将所有 div 节点收集到一个节点集合中,并取其中的第一个,即恰好一个节点。两者都有用,但不能互换使用。
谓词是链式应用的,每个谓词都作用于前一个谓词的结果。//td[@class='price'][1] 表示“具有 class 属性为 price 的单元格中的第一个”,从左到右读取。
轴
轴以上下文节点为基准进行导航。大多数人使用三个轴,偶尔也会用到其他轴。
| 轴 | 选择范围 |
|---|---|
child:: | 直接子节点 — 默认选项,通常省略 |
descendant:: | 任意深度的所有后代 |
parent:: | 父节点 |
ancestor:: | 直至根节点的所有祖先 |
ancestor-or-self:: | 祖先及该节点本身 |
following-sibling:: | 后序同级节点 |
preceding-sibling:: | 早序同级节点 |
following:: | 文档顺序中该节点之后的所有内容(不包括后代) |
preceding:: | 之前的所有内容,不包括祖先 |
attribute:: | 属性 — 简写为 @ |
self:: | 节点本身 |
其中四个是 反向轴 — ancestor、ancestor-or-self、preceding 和 preceding-sibling — 在这些轴上,位置编号是倒序的。preceding-sibling::p[1] 表示最近的前一个段落,而非文档中的第一个段落。 若需按文档顺序获取,请用圆括号包裹。我们在 XPath preceding-sibling 中对此进行了详细说明。
following 和 preceding 的作用范围比其同级兄弟方法更广,但速度也更慢,且它们分别会排除祖先和后代。仅当关系确实较为宽松时才应使用它们。
字符串函数
这些是主力函数。
| 函数 | 作用 |
|---|---|
contains(a, b) | 如果 a 包含 b 作为子字符串,则返回 True |
starts-with(a, b) | 如果 a 以 b 开头,则返回 True |
normalize-space(s) | 去除两端空格并合并内部空格 |
string-length(s) | 字符计数 |
substring(s, start, len) | 子字符串,从1开始计数 |
substring-before(a, b) | b 首次出现之前的所有内容 |
substring-after(a, b) | b 首次出现之后的所有内容 |
translate(s, from, to) | 逐字符替换 |
concat(a, b, ...) | 连接字符串 |
string(node-set) | 仅返回第一个节点的字符串值 |
三条有助于避免实际错误的注意事项。
substring() 的索引从 1 开始。 substring('hello', 1, 3) 返回 hel。每个人都曾因此出过错。
normalize-space() 应作为您进行任何文本比较时的默认封装函数。 真实的 HTML 经过美化处理,因此单元格的字符串值通常是 "\n In stock\n" 而不是 "In stock"。不带参数时,它作用于上下文节点。
XPath 1.0 中不存在 ends-with()、lower-case() 以及正则表达式。 若需实现不区分大小写,标准解决方法是使用 translate() 并显式指定字母范围。lxml 等服务器端库支持 EXSLT 扩展(包括 re:test());但浏览器和 Selenium 不支持。
数值和布尔函数
| 函数 | 功能 |
|---|---|
count(node-set) | 节点数量 |
position() | 上下文节点的位置 |
last() | 上下文节点集的大小 |
number(s) | 转换为数值 |
sum(node-set) | 求数值之和 |
round(), floor(), ceiling() | 如名称所示 |
not(expr) | 布尔否定 |
boolean(expr) | 转换为布尔值 |
true(), false() | 布尔常量 |
比较运算符包括 =、!=、<、>、<=、>=,组合运算符为 and 和 or。请注意,在 XML 环境中,< 必须转义为 <,这就是为什么有时会看到使用 position() < 4 书写的表达式。
一个值得了解的细节:将节点集与某个值进行比较属于存在性测试。如果任意一个段落等于“Price”,则 //p = 'Price' 为真。这通常是你在谓词内部所期望的结果,但几乎绝不会是你在顶级层级所期望的结果。
频繁出现的模式
真正能派上用场的表达式。
正确匹配类 — 简单的 contains(@class, 'btn')
也会匹配 btn-primary
和 unbtn
:
//div[contains(concat(' ', normalize-space(@class), ' '), ' btn ')]
根据标签查找值 — 这是最常见的提取需求:
//dt[normalize-space()='Price']/following-sibling::dd[1]
//th[normalize-space()='Weight']/following-sibling::td[1]
//td[preceding-sibling::td[1]='SKU']
通过某个单元格查找行,然后提取另一列:
//tr[td[normalize-space()='SKU-1234']]/td[3]
根据容器内部内容查找容器:
//div[contains(@class,'card')][.//span[contains(., 'Sold out')]]
查找标题后的第一个元素:
//h2[normalize-space()='Specifications']/following-sibling::table[1]
不区分大小写的文本匹配:
//p[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'abcdefghijklmnopqrstuvwxyz'), 'price')]
排除而非包含 — 通常更清晰:
//tr[not(contains(@class,'header'))]
//li[not(contains(normalize-space(),'Advertisement'))]
匹配两种元素类型中的任意一种:
//*[self::button or self::a][normalize-space()='Continue']
跳过空值:
//td[string-length(normalize-space()) > 0]
从匹配的元素中提取属性:
//a[normalize-space()='Download']/@href
陷阱
按导致用户浪费时间的频率排序。
节点集转为字符串时只会取第一个节点。 contains(//p, 'Price') 会将整个 //p 节点集转换为字符串,但仅提取第一个段落并忽略其余部分。应改为对每个节点分别应用谓词://p[contains(., 'Price')]。这是最常见的 XPath 错误。
. 和 text() 是不同的。 元素的字符串值是其所有后代文本节点的拼接;text() 仅返回直接的文本节点子元素,而字符串转换会取其中的第一个。除非您明确希望排除嵌套内容,否则请使用 .。
反向轴编号。 preceding-sibling::td[1] 表示“最近的”,而非“第一个”。
空格。 //td[.='In stock'] 在格式化后的 HTML 中会失败。normalize-space() 可解决此问题。
区分大小写。 XPath 1.0 中的所有内容都区分大小写,包括 XML 文档中的元素名称。
默认命名空间。 在具有默认命名空间的 XML 中,//item 无法匹配任何内容——你必须注册一个前缀并使用它。HTML 解析器通常会为你省去这一步;而 XML 解析器则不会。
浏览器开发者工具中的绝对路径。 它们编码了某个特定时刻的精确结构,一旦发生任何变化就会失效。
使用 contains() 时匹配范围过广。 匹配 "Price" 也会匹配 "Historic Price"。若需表示相等,请使用 normalize-space() = 'Price'。
注入不可信的字符串。 将用户输入插值到表达式中即构成 XPath 注入。如果库支持,请使用变量绑定。XPath 1.0 无法对字符串字面量中的引号进行转义,因此包含两个引号字符的值需要使用 concat()。
XPath 1.0 与后续版本的对比
这一点值得了解,因为你在网上找到的代码片段可能无法在实际应用中正常工作。
XPath 1.0 是浏览器通过 document.evaluate 实现的规范,也是 Selenium 所使用的版本,同时 lxml 的通用 API 也提供了该版本的支持。 它仅包含上述列出的函数,没有其他功能。
XPath 2.0 和 3.1 增加了正则表达式(matches(), replace())、大小写转换函数(upper-case(), lower-case())、ends-with()、序列类型、for 表达式以及许多其他功能。 这些功能可在 XSLT 2.0 及以上版本的处理器和部分 XML 工具中使用,但浏览器不支持。
实用准则:如果某个表达式使用了上述表格中未列出的函数,请先确认您的环境是否支持该函数,再排查失败原因。 “在在线 XPath 测试工具中能运行,但在 Selenium 中却不行”的情况,几乎总是由这个原因造成的。
对于 XPath 1.0 留下的空白,解决方案通常是借助宿主语言。先用 XPath 提取数据,然后在 Python 或 JavaScript 中应用正则表达式,同时还可以检查哪些内容被匹配到了。
编写经得起重新设计考验的选择器
速查表告诉你哪些做法是可行的;本节则探讨该从这些选项中选择哪一种,因为一个能用上一年的选择器与一个下周二就会失效的选择器之间的区别,完全取决于你以什么作为锚点。
以含义为锚点,而非位置。 (//table)[3]/tr[2]/td[4] 编码的是某个时刻页面确切的布局。如果有人在上面添加了一个表格,所有数字都会出错——但系统不会报错,因为该表达式仍然能匹配到某些内容。//th[normalize-space()='Weight']/following-sibling::td[1] 编码的是一种关系,这种关系在元素重新排序后依然有效,因为标签会随值一起移动。
优先使用稳定的属性,而非生成的属性。 开发者选择的 ID 和 data-* 属性远比类名更持久,因为类名会在任何人修改样式时发生变化。许多现代前端框架会生成哈希类名——例如 css-1x9dj2k——这些类名在每次构建时都会改变;若以此作为锚点,必然会导致功能失效。
在编写任何选择器之前,请检查是否嵌入了结构化数据。 许多页面会在 <script type="application/ld+json"> 块中包含 JSON-LD,因为它能驱动搜索功能。解析这些数据比解析渲染后的 HTML 稳定得多,因为它专为机器可读而设计,且完全不受视觉重设计的影响。 花三十秒检查这一点,可以省去一个下午的选择器维护工作。
验证所提取数据的结构。 正是这一习惯,区分了会“高调”报错的处理流程与会“静默”报错的处理流程。如果价格应符合某种货币格式,就必须进行验证。 如果某个分类页面从未少于二十个商品,那么将少于二十个的情况视为错误而非正常结果。一旦选择器开始匹配错误的元素,就会产生看似合理、格式正确但实际错误的数据——而整个过程中不会抛出任何异常。
将选择器集中管理。 散落在代码库各处的四十个 XPath 字符串,就意味着四十处独立的维护隐患。若将其集中到一个命名规范明确的模块中,它们便成为依赖关系的映射图,重构后的更新只需一小时而非一整天。
并针对保存的 HTML 进行测试。 存储每个已解析页面的副本意味着,当选择器失效时,你可以将旧标记与新标记进行比较,从而准确查明发生了哪些变化。为调试而重新抓取页面不仅速度较慢、消耗带宽,还可能获取到与失败页面不同的页面。
何时应改用 CSS
XPath 功能更强大,但可读性较差。对于大部分选择操作而言,CSS 是更合适的默认选择。
在以下情况下使用 CSS: 根据类名、ID、属性或后代关系进行选择。div.card > p.price 比等效的 XPath 表达式更清晰,得到工具的支持更广泛,且通常运行速度更快。
何时使用 XPath: 需要匹配文本内容(这是 CSS 完全无法实现的);需要导航到父元素或祖先元素;需要基于同级元素的位置逻辑进行选择,而 :nth-child 无法表达这种逻辑;或者查询的是 XML 而非 HTML。
请注意,CSS 已弥合了部分差距。:has() 在现代浏览器中提供了对兄弟元素和后代元素的条件选择,因此 dt:has(+ dd) 现可表达。CSS 目前仍无法实现基于文本的选择,而文本匹配恰恰是标签-值模式所必需的。
在同一个代码库中混合使用这两种方法是可行的且明智的:90% 的简单情况使用 CSS,10% 的复杂情况使用 XPath。
大家还问
XPath 有什么用途?
用于在 XML 和 HTML 文档中导航和选择节点。实际应用中,它常用于网页抓取、浏览器测试自动化,以及查询 XML 配置文件和数据文件。它能够表达 CSS 选择器无法表达的关系——如父节点、兄弟节点、文本内容等。
XPath 中 / 和 // 有什么区别?
单斜杠(/)选择直接子节点;双斜杠(//)选择任意深度的后代节点。//div/p 匹配其直接父节点为 div 的段落,而 //div//p 匹配 div 内部任意位置的段落。
为什么我的 XPath 表达式 contains() 返回空结果?
最常见的原因是您传入了一个节点集。XPath 会按文档顺序取第一个节点并舍弃其余节点,将节点集转换为字符串,因此 contains(//p, 'x') 只会检查第一个段落。请改写为 //p[contains(., 'x')]。
如何在 XPath 中按类名进行选择?
请使用 //div[contains(concat(' ', normalize-space(@class), ' '), ' name ')],该方法会在属性值前后添加空格,确保仅匹配完整的令牌。普通的 contains(@class, 'btn') 也会匹配 btn-primary。如果仅按类名进行选择,CSS 选择器通常更清晰且默认更准确。
XPath 支持正则表达式吗?
XPath 1.0 不支持,而浏览器和 Selenium 实现的正是该版本。XPath 2.0 及更高版本添加了 matches() 和 replace(),而 lxml 等服务器端库则支持 EXSLT 的 re:test()。对于浏览器自动化,请先使用 XPath 提取数据,然后在宿主语言中应用正则表达式。
//div[1] 和 (//div)[1] 有什么区别?
//div[1] 针对每个父元素应用谓词,选择每个父元素下第一个 div 元素(可能包含多个节点)。(//div)[1] 按文档顺序收集所有 div 元素并取第一个,即仅有一个节点。
XPath 是否区分大小写?
是的,全程区分——包括元素名、属性名和字符串比较。XPath 1.0 没有 lower-case() 函数,因此若要实现不区分大小写的匹配,需使用 translate() 并显式指定大写和小写字母。
我应该使用 XPath 还是 CSS 选择器?
对于类、ID、属性及后代关系,应使用 CSS——它更清晰且支持更广泛。当需要匹配文本内容、导航至祖先节点,或表达 CSS 无法实现的位置逻辑时,则应使用 XPath。在同一个代码库中同时使用两者是常见的做法。
总结
XPath 的核心功能其实很简单:使用 // 在任意位置进行搜索,使用方括号中的谓词进行过滤,使用 @ 处理属性,加上几个字符串函数,以及用于相对于可识别元素进行导航的兄弟轴。
养成以下三个习惯可以避免大部分麻烦:将文本比较用 normalize-space() 包裹,因为真实的 HTML 格式经过美化,精确匹配可能会失败;在谓词内部应用 contains(),以便对每个节点分别进行评估,因为节点集的字符串转换会默认只取第一个结果。 此外,应优先基于文本或标识符进行定位,而非基于位置,因为结构会发生变化,而文本通常保持不变。
最后,请务必记住你正在为哪个版本编写代码。 浏览器和 Selenium 支持的是 XPath 1.0 —— 不支持正则表达式、不支持 lower-case(),也不支持 ends-with() —— 而且,一个在在线测试器中能正常运行的表达式,即使完全不使用上述功能,仍可能因列表中更靠后的原因而失败。当你需要 1.0 版本所缺乏的功能时,请先使用 XPath 进行数据提取,其余操作则在主语言中完成。
