XPath简单入门

XPath简介

XPath 是一种用作解析 XML 并提取所要信息的语法,就像 CSS 选择器一样。XPath 也可以用于解析其它类 XML 的文档结构,例如 HTML 。在本节中也主要以 HTML 为例演示 Path 的各种解析结果。

XPath 应用较为广泛,目前主流浏览器的开发者工具均支持使用 XPath 语法查找页面元素。XPath 解析速度较快,可以实现处理属性等许多 CSS 选择器无法实现的功能,但是语法较为复杂。

本节使用 Python 的第三方库 lxml ,该库提供了丰富的工具用于解析 XML 和 HTML ,其中就包括 XPath 。lxml 底层使用 libxml2 等 C 语言库实现,因此处理速度非常快。

lxml 可以使用 pip 安装:

$ pip install lxml

解析 HTML 需要用到 lxml 中的 etree 模块:

from lxml import etree

使用 etree 模块中的函数 HTML() ,可以用来将一段 HTML 文本字符串解析为存储 HTML 结构信息的类:

html = etree.HTML(html_str)
print(type(html))

该函数对 HTML 支持很好,甚至可以处理有一定破损的 HTML 。以上代码执行结果为:

$ py lxml-xpath.py <class 'lxml.etree._Element'>

可以使用该类的 .xpath() 方法来使用 XPath 语法解析。得到的结果是一个列表,包含解析得到的所有结果。

接下来正式介绍 XPath 的语法。

XPath语法

选取节点

HTML 等类 XML 文档都是使用树状的结构来表示文档。例如,对于以下简单的 HTML 文档:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>page</title>
</head>
<body>
    <p>This is a simple <strong>HTML</strong> page</p>
</body>
</html>

那么它的所有元素构成的树状结构可以表示为:

元素节点可以分为标签节点和文本节点。XPath 中通过一个斜杠 / 开头的标签名

/tag

来在文档树中向下匹配某一标签节点 tag 。通过堆叠这样的表示符号,即可表示若干节点的层级关系。

注意,使用该语法时必须要从根节点处开始搜索,例如:

html.xpath("/html/body/table/tbody/tr/td/a")
# [<Element a at 0x23ada3b7c40>, ...]

使用单个斜杠只能表示相邻的层级关系,但如果使用两个斜杠:

//tag

就可以跨越表示相差任意深度的层级关系。因此以上匹配(在不引起歧义的话)也可以由下列形式表达:

html.xpath("//td/a")
html.xpath("/html//td/a")
html.xpath("/html//table//tr/td/a")

也可以将其拆分理解,标签名表示一个具体的标签节点,而单个斜杠 / 表示两个节点的只能具有父子关系,而两个斜杠 // 表示两个节点具有祖孙关系,中间可以相差若干级。

除了使用具体的标签名外,还可以使用两个特殊的节点表示语法:

可以使用一个点

.

来代表当前节点,或使用两个点

..

来代表上一级节点。例如,以下可以搜索出与 img 标签同级的 figcaption 标签:

html.xpath("/html/body/article//img/../figcaption")

这种表示节点关系的语法很像 Unix 的文件路径。


在使用 lxml 处理文档的时候,很多时候都不能只使用一个 XPath 表达式就选中所有符合的元素,经常需要在处理到某一步后对结果做一些过滤后,再对符合条件的元素进一步处理。

每次调用 .xpath() 方法得到的都是一个列表,其中包含所有匹配的节点。可以在过滤后对其中的节点对象再次应用 .xpath() 方法,但此时注意需要表明“从当前节点开始匹配”,即不能以单个斜杠 / 开头。以下演示了错误的情况和几种正确的表示方法:

for table in html.xpath('//article//table'):
    table.xpath('/thead//th')                   # incorrect
    # []
    table.xpath('./tbody//td')                  # correct, and recommended
    # [<Element td at 0x202b8dab080>, ...]
    table.xpath('tbody//td')                    # correct
    # [<Element td at 0x202b8dab080>, ...]
    table.xpath('//article//table/tbody//td')   # correct, but unnecessary
    # [<Element td at 0x202b8dab080>, ...]

这种表示形式有点像 Unix 的相对路径表示,也是当前节点 . 常用的一种场合。

在实际应用中,相比搜寻节点更需要提取节点包含的文本信息。前面说过文本也可以看作一种节点,这种节点可以使用

text()

代表一个标签元素内的文本内容。lxml 会将每一项包含的文本以字符串的形式放入返回的列表中。

在使用 text() 的时候要注意,元素包含的缩进及换行也算在元素的文本内。有些元素的部分文本放在它的子元素内,例如这种:

<p>This is a simple <strong>HTML</strong> page</p>

那么需要使用两个斜杠才能将这部分内容一并提取出来,并且提取出的结果被子元素分割了:

html.xpath('//article/p//text()')
# ['This is a simple ', 'HTML', ' page']

最后,总结一下本小节涉及的选取节点的基本路径符号:

表达式 描述
tag 标签名代表的某个具体的节点
text() 文本节点
/ 所属的子节点;若用在开头,则表示位于根下一层的节点
// 所属的后代节点,可以是任意深度
. 当前节点
.. 当前节点的父节点

谓语

上面介绍了 XPath 选取节点的方法,但很多时候并不希望选取路径上的全部节点元素,而是只选取具有特定性质的元素。

XPath 中的谓语(predicate)用于限定查找符合特定条件的节点元素。谓语以

tag[]

这样的方括号形式出现在标签名后面。方括号内可以加上具体的谓词。

一个简单的谓词为:

n

代表选择文档中从上至下匹配路径的第 n 个标签。例如,以下表达式可以提取文章中第一段的所有文本:

html.xpath('//article/p[1]//text()')

与之类似的谓词是

last()

代表匹配到的最后一个元素。在介绍了 XPath 的运算符后,可以使用简单运算来得到第任意个元素。当然这一过程也可以交给 Python 处理,写成如下形式:

html.xpath('//article/p')[0].xpath('.//text()')

注意,尽管这两个谓词都只得到一个元素,但是该方法的返回结果仍然是一个列表。

标签名

tag

也可以做谓词使用,代表选择匹配到的标签需要含有该子标签(只能是父子关系)。例如,以下提取包含链接的单元格文本:

html.xpath('//article/table//td[a]//text()')

更为常用的限定与属性相关。XPath 表示属性的符号为:

@attribute

如果直接将属性作为谓词放入方括号中,那么代表标签需要存在该属性。更常用的做法是判断属性的值是否符合要求,那么这个时候就需要使用以下语法:

@attribute="value"

例如,通过 class 属性检索合适的节点是一种很常见的做法:

html.xpath('//article//div[@class="item"]')

考虑到这是一种很常用的语法,因此这里提前介绍了一个关系运算符。

属性除了作为谓词,还可以直接用作节点,获取某个属性对应的值。例如获取链接对应的 URL :

html.xpath('//article/a/@href')
# ['https://pypi.org/', '#content', '/jobs/', ...]

这种情况和文本节点一样不可能再会有子节点和谓语,因此只会出现在最后。当然再对它们应用子节点和谓语也不会发生异常,只是结果可能未必符合预期。

谓语还常与通配符一起使用。在 XPath 中,通配符

*

可以表示任意标签节点,而通配符

@*

可以表示任意属性节点。使用 id 属性寻找元素也是一种常用的做法,此时就可以配合通配符,例如:

html.xpath('//*[@id="content"][1]')

注意这里同时使用了两个谓语串联限定,串联谓语也是从左向右顺序结合。如果想要表示所有的节点(包括标签节点、文本节点和属性节点),那么可以使用

node()

来表示。

由于 HTML 是一种不够规范的标记语言,在编写属性的时候可能会出现多个属性值或有额外空白符。例如,对于以下文档片段:

<pre class="codeblock python">
    ...
</pre>

在 CSS 选择器中,可以使用 pre.codeblock 来选中并修改所有代码块的样式。但是 XPath 的解析却很严谨,这会造成因为值没有完全匹配而忽略的情况:

html.xpath('//pre[@class="codeblock"]')
# []

此时可以使用以下谓词来处理这类标签:

contains(node, "value")

只要节点 node 对应的值内含有 “ value ” 字符串,那么该标签就会被匹配。

这种节点可以是属性节点也可以是文本节点,例如对 class 属性的处理一般为:

html.xpath('//pre[contains(@class, "codeblock")]')
# [<Element pre at 0x278d422a480>, ...]

类似地,也可以使用

starts-with(node, "value")

作为谓词,来选取所有以特定字符串开头的属性值或文本内容。

最后总结一下涉及的语法。注意,所有谓词均可看作布尔值:

表达式 描述
谓词放在 node[] 的方括号内,表示限定
node 包含该类子节点
n 位于该位置
last() 位于最后一个位置
contains(node, "value") 节点值包含该字符串
starts-with(node, "value") 节点值以该字符串开头
@attribute 属性节点
* 任意标签节点
@* 任意属性节点
node() 任意节点

运算符

之前说到 XPath 的谓词可以看作布尔值,因此可以使用或、与、非做逻辑运算,通过多个条件获得需要匹配的标签。

逻辑运算“与”“或”“非”的关键字分别为:

and
or
not()

为了防止歧义,逻辑非需要像函数一样加上括号。以下是两个使用示例:

html.xpath('//a[not(@href)]')
html.xpath('//div[contains(@id, "content") and 1]')
# different from [contains(@id, "content")][1]

除了逻辑运算外,XPath 也有自身的运算符。这些运算符都作为谓语的一部分使用。

例如数学运算:

运算符 功能 运算符 功能
+ 加法 ceiling() 向上取整
- 减法 floor() 向下取整
* 乘法 mod 取余
div 除法

例如,要选取倒数第二个列表项,可以使用:

#   (Emmet markup) ol>li*7{$}
html.xpath('//ol/li[last() - 1]/text()')
# ['6']

再如,如果要选择中间项,可以使用:

html.xpath('//ol/li[floor(last() div 2 + 0.5)]/text()')
# ['4']

除了算术运算符外,还可以使用以下关系运算符:

= != > >= > >=

关系运算符常用在对纯 XML 文档的解析上,因为 XML 经常使用某个标签存储一个数值。不过关系运算符可以配合以下函数

position()

该函数泛指位置,配合关系运算符就可以选取特定位置范围的标签,例如:

html.xpath('//ol/li[position() < 4]/text()')
# ['1', '2', '3']

或者以下函数

count(node)

用于统计符合条件的子节点个数,例如:

html.xpath('//ol[count(li) >= 4]')
# [<Element ol at 0x2628154a480>]

表达式组合应用得当可以处理很多关系,例如像 CSS 选择器的伪类一样选择偶数位置的节点:

html.xpath('//ol/li[position() = ((position() mod 2) = 0)]/text()')
# ['2', '4', '6']

以上涉及的表达式都用在谓语内,还可以使用符号

|

用在两个表达式内,表示对两个表达式匹配的元素取并集。例如,以下可以选取所有的链接:

html.xpath('//@src | //@href')

(Axes)是 XPath 中的一个更高级的概念。轴用于从文档树中定位和当前节点具有一定位置关系的那些节点,相比基本的 XPath 表达式可以表示更复杂的位置关系。

轴的使用通过以下语法表示:

axisname::node

其中 axisname 表示限定位置关系,node 表示限定该位置上的节点类型。这些位置关系可以有:

名称 效果 名称 效果
ancestor 选取所有祖先 ancestor-or-self 选取所有祖先及自身
descendant 选取所有后代 descendant-or-self 选取所有后代及自身
following 选取文档中位于其之后的所有节点 following-sibling 选取之后的所有兄弟节点
preceding 选取文档中位于其之前的所有节点 preceding-sibling 选取之前的所有兄弟节点
child 选取所有子节点 attribute 选取节点的所有属性
parent 选取所有父节点 self 选取当前节点
namespace 选取所有命名空间节点

例如,对于以下 HTML 结构:

<article>
    <h2>...</h2>
    <p>...</p>
    <h2 id="xpath-tutorial"></h2>
    <p>...</p>
    <p>...</p>
</article>

如果想提取 #xpath-tutorial 的二级标题后的第一段,假设使用一般的同级节点表示方法:

html.xpath('//h2[@id="xpath-tutorial"]/../p')
# [<Element p at 0x24c5f54a480>,
#  <Element p at 0x24c5f54a1c0>,
#  <Element p at 0x24c5f54a4c0>]

那么没法定位到这样一个结果。而使用轴就可以很清楚地表示这种位置关系:

html.xpath('//h2[@id="xpath-tutorial"]/following-sibling::p[1]')
# [<Element p at 0x209deaca3c0>]

就像它的名称一样,轴是一种很强大的定位关系。

lxml的其余内容

其它XPath语法

前文说了在 lxml 中,通过 .xpath() 方法都会返回匹配节点组成的列表。然而 lxml 还支持这样的一些 XPath 语法,它们的返回结果是单个值。因此这些语法并不一定在浏览器的调试界面中能得出结果。

例如,当 count() 作用于一个表达式时,会返回包含的结果个数,并且在 lxml 中结果以浮点数形式给出:

html.xpath('count(//ol/li[text()])')
# 7.0

再如,string-length() 可以返回一个文本的字符个数,并且结果可以被用在关系运算中:

html.xpath('//article/p[string-length() > 10]')
# [<Element p at 0x23224bea380>, ...]

还有一个比较好用的 local-name() 用于获取节点的标签名,有时在使用通配符时会用到:

html.xpath('local-name(//*[@id="content"])')
# "article"

XPath 还有许多类似这样的语法,不过并不是很常用,因此这里就不介绍了。

最后需要说明的是,本节的主要目的是为了介绍 XPath 语法,而不是介绍使用 lxml 处理 XML 文档的方法,因此可能涉及一些虽然很强大,但实际上并不直接使用的 XPath 语法。就像正则表达式不能表示所有的情况一样,XPath 尽管很强大,但也不是万能的,在适当的时候还是应该辅以编程语言的功能协助处理。例如提取一个元素包含标签、属性以及文本的所有结构,使用纯 XPath 比较难实现,但是 lxml 就提供了 Python 函数来处理这个问题:

etree.tostring(html.xpath('//article/p')[0])
# b'<p>This is a simple <strong>HTML</strong> page</p>\n        '

此外,一昧地追求复杂的 XPath 表达式可能会让代码变得晦涩难懂,并不是一种合理的使用方式。

参考资料/延伸阅读

https://www.w3.org/TR/2017/REC-xpath-31-20170321/
XPath 3.1 标准

https://devhints.io/xpath
一份 XPath 语法小抄

京ICP备2021034974号
contact me by hello@frozencandles.fun