在解析第三方接口返回的XML数据时,最让人头疼的往往是节点藏得很深,还带有重复的标签名和命名空间。XPath全称XML Path Language,是一套基于树形结构的查询语言,它把XML文档看作文件夹,把元素和属性看作文件,通过类似目录的路径字符串来选取其中的节点或节点集。掌握它的语法规则,可以避免写一大堆循环和判断去爬DOM树。

XPath基础节点与路径表达式
XPath中有七种节点类型,最常用的是元素、属性、文本。路径表达式由步(step)组成,每一步用斜杠分隔。以斜杠开头的叫绝对路径,从文档根节点出发;不以斜杠开头的是相对路径,从当前上下文节点出发。比如/bookstore/book表示根下bookstore下的所有book元素,而book/title则表示当前节点子级的title。
除普通标签名外,还有几个特殊符号。星号*匹配任意元素节点,@*匹配任意属性,点号.表示当前节点,双点号..表示父节点。这些符号组合起来能写出很灵活的定位式。下面是一段XML示例以及用lxml定位节点的代码。
<bookstore>
<book category="web">
<title lang="en">XPath Guide</title>
<price>39.9</price>
</book>
<book category="xml">
<title lang="zh">XML入门</title>
<price>29.0</price>
</book>
</bookstore>
from lxml import etree
xml_text = '''<bookstore>
<book category="web"><title lang="en">XPath Guide</title><price>39.9</price></book>
<book category="xml"><title lang="zh">XML入门</title><price>29.0</price></book>
</bookstore>'''
tree = etree.fromstring(xml_text.encode('utf-8'))
# 绝对路径选取所有书名
titles = tree.xpath('/bookstore/book/title/text()')
print(titles) # 输出 ['XPath Guide', 'XML入门']
# 相对路径配合通配符,选取任意元素的lang属性
langs = tree.xpath('//@lang')
print(langs) # 输出 ['en', 'zh']
谓语与运算符过滤节点
谓语(predicate)写在方括号里,用来筛选满足条件的节点。它可以是索引数字、比较表达式或函数调用。注意XPath的索引从1开始,这和多数编程语言从0开始不同。例如/bookstore/book[1]取第一本书,/bookstore/book[last()]取最后一本书,/bookstore/book[price>30]取价格大于30的书。
运算符让谓语更强大。支持加减乘除、等于不等于、与(and)或(or)等。还可以用contains()、starts-with()等函数做模糊匹配。下例展示如何组合条件,并说明错误写法带来的坑:如果把>写成未转义的大于号,在XML内联表达式里会破坏结构,因此代码里必须转义。
# 选取分类为xml且价格低于30的书名
cheap_xml = tree.xpath('/bookstore/book[@category="xml" and price<30]/title/text()')
print(cheap_xml) # 输出 ['XML入门']
# 选取标题中含Guide字样的书的价格
guide_price = tree.xpath('//title[contains(text(),"Guide")]/following-sibling::price/text()')
print(guide_price) # 输出 ['39.9']
使用谓语时,尽量把过滤条件往路径前面放,能减少后续遍历规模。当XML带有命名空间时,还要用namespace-uri()或注册前缀的方式处理,否则直接用标签名会选不到节点。
轴与节点集进阶选取
轴(axis)定义了节点之间的树形关系方向,比如子节点、父节点、祖先、后代、兄弟等。语法是轴名称::节点测试[谓语]。常用轴有child::(默认可省略)、parent::(即..)、ancestor::、descendant::(即//)、following-sibling::等。轴的出现让横向和向上寻找变得简单。
比如想找某本书后面所有兄弟书,用following-sibling::book;想找某个title的父book的属性,用parent::book/@category。下面代码演示从price反向找父节点并读取分类,以及用descendant取所有深层title。
# 从价格29的书反向找父分类
cat = tree.xpath('//price[text()="29.0"]/parent::book/@category')
print(cat) # 输出 ['xml']
# 使用descendant轴获取所有title(不论层级)
all_titles = tree.xpath('descendant::title/text()')
print(all_titles) # 输出 ['XPath Guide', 'XML入门']
轴和谓语结合可以替代很多复杂逻辑。但在超大XML上频繁使用ancestor::这类向上轴会比较耗性能,因为解析器要回溯树结构。实际项目中建议先用//缩小范围,再在局部用轴做精细定位。
常见误区与实战建议
一个容易混淆的概念是//并不是匹配任意字符,而是代表descendant-or-self::node()/的简写,即从当前节点向下任意层找。很多人写成//.//book属于多余。另一个误区是用text()做等于判断时,XML里如果标签间有换行缩进,返回的是带空白的文本节点列表,应该用normalize-space()处理。
在浏览器控制台对HTML用XPath时,HTML虽不是严谨XML,但多数浏览器支持document.evaluate。建议先把复杂表达式拆成几段在控制台试跑,确认节点集数量符合预期,再写进脚本。下表列出几个易错点对照。
| 错误写法 | 正确写法 | 说明 |
|---|---|---|
| book[0] | book[1] | XPath索引从1开始 |
| //title/text()="Guide" | //title[normalize-space()="Guide"] | 避免空白文本节点干扰 |
| 用<未转义> | 用<转义 | 代码或表达式内大于小于号需转义 |
把上面这些规则串起来,你就能用极短的XPath表达式完成原本几十行DOM遍历代码的工作。无论是做接口报文抽取、爬虫解析,还是单元测试里校验生成XML,它都是一把高效的手术刀。