$ ls ~yifei/notes/

Python 爬虫利器——lxml 和 xpath 表达式

Posted on:

Last modified:

最近要做下微信爬虫,之前写个小东西都是直接用正则提取数据就算了,如果需要更稳定的提取数据,还是使用 xpath 定位元素比较可靠。周末没事,从爬虫的角度研究了一下 python xml/html 相关的库。

Python 标准库中自带了 xml 模块,但是性能不够好,而且缺乏一些人性化的 API。相比之下,第三方库 lxml 是用 Cython 实现的,而且增加了很多实用的功能,可谓爬虫处理网页数据的一件利器。

严格来说,html 并不是 xml 的一种,两者都是 SGML 的衍生。不过 lxml 对于 xml 和 html 都有很好的支持,可以分别使用 lxml.etreelxml.html两个模块。

解析

网页下载以后是 bytes 形式,需要构造 DOM 树:

In [1]: html = b"""
   ...: <p>hello<span id="world">world</span>tail</p>
   ...: """

In [2]: import lxml.html

In [3]: doc = lxml.html.fromstring(html)

In [4]: doc
Out[4]: <Element p at 0x1059aa408>

读取节点的信息

lxml 生成的树是一个设计很精妙的结构,

  1. 可以把它当做一个对象访问当前节点自身的文本节点,
  2. 也可以把他当做一个数组,元素就是他的子节点,
  3. 还可以把它当做一个字典,从而遍历它的属性。

下面演示了 lxml 的常见用法:

In [6]: doc.tag
Out[6]: "p"

# 直接当作数组读取子元素
In [7]: doc[0].tag
Out[7]: "span"

# 遍历一个节点的所有属性
In [11]: for k, v in doc[0].items():
    ...:     print(k, v)
id world

# 使用 get 读取属性
In [12]: doc[0].get("id")
Out[12]: "world"

# attrib 直接读取所有属性
In [13]: doc[0].attrib
Out[13]: {"id": "world"}

遍历树的方法

doc 是一个树形结构,可以通过一些方法访问树中的其他节点:

In [14]: doc.getroottree()  # 返回树的根
Out[14]: <lxml.etree._ElementTree at 0x105360708>

In [19]: doc.getroottree().getroot()  # 返回根节点,这里是 lxml 自动生成的 html 节点
Out[19]: <Element html at 0x10599da98>

In [20]: doc.getparent()  # lxml 自动生成的 body 节点
Out[20]: <Element body at 0x1059a87c8>

# p 的前一个节点,在这里是空的
In [21]: doc.getprevious()

# p 的后一个节点,在这里也是空的
In [22]: doc.getnext()

# 再次转换成文本
In [25]: lxml.html.tostring(doc, pretty_print=True, encoding="utf-8")
Out[25]: b"<p>hello<span id="world">world</span></p>\n"

注意因为我们给的是一个 html 的片段(<p>...</p>),所以 lxml 自动生成了 html 和 body 等节点以构成完整的 html 文档。但是 fromstring 返回的还是对应的节点,而不是 html 节点。

如果需要显式地指定生成一个 html 片段文档还是完整文档,可以分别使用:lxml.html.fragment_fromstringlxml.html.document_fromstring 两个方法。

lxml 还有其他一些方法,都列在下面了:

修改文档树结构

  • Element.append(Element) 添加一个子元素
  • Element.set('attr', value) 设置属性
  • HtmlElement.drop_tree() 删除当前节点下的所有节点,但是保留 text
  • HtmlElement.drop_tag() 删除当前节点,但是保留它的子节点和 text

查找遍历元素

  • HtmlElement.find_class(class_name) 按照 class 查找 tag
  • HtmlElement.get_element_by_id(id, *default) 按照 id 查找元素
  • HtmlElement.classes 返回类
  • Element.iter(tag_name) 遍历所有后系元素,可以使用 *
  • ElementTree.getelementpath(Element)
  • Element.getroottree() 返回对应的树
  • ElementTree.getpath(Element) 返回一个元素的 xpath
  • ElementTree.getroot() 返回根节点
  • HtmlElement.iterlinks() 遍历所有连接

修正链接

  • HtmlElement.make_links_absolute(base_url=None, resolve_base_href=True) 把所有连接变成绝对链接,这个特别消耗资源,谨慎调用。
  • HtmlElement.resolve_base_href() 按照 base 标签解析链接,如果存在的话
  • HtmlElement.rewrite_links(link_repl_func) 替换所有的链接

使用 XPath 读取元素或属性

XPath 实在太强大了,在定位元素方面绝对秒杀 CSS 选择器。在 lxml 中,节点和树都具有 xpath 函数。lxml 中的 xpath 方法总是返回一个数组,即使选取结果只有一个元素。

In [44]: p.xpath("//span")
Out[44]: [<Element span at 0x107be96d0>]

xpath 表达式的语法不在这里赘述,可以查看本站的相关教程。

lxml 中的 xpath 函数支持变量,这样省了自己拼接字符串了

print(root.xpath("$text", text="Hello World!"))
Hello World!

lxml 还支持几个函数 find/findall,他们使用 ElementPath,是一种类似 xpath 的语言,感觉很是奇怪,lxml 的文档描述他是 xpath 的一个子集,不建议使用。

读取文本

在 lxml 中读取一个 html 节点的文本有四种方式,各有利弊:

  • 直接使用 Element.text 和 Element.tail。只适用于没有子元素的简单元素。
  • 使用 xpath 的 text() 函数,同样是只能读取自身的文本,不能读取子元素的。相当于 text + tail
  • 使用元素的 text_content 方法,这种方法是推荐的,唯一的问题是,会把格式丢掉,只剩下纯文本。
  • 使用 lxml.html.tostring(node) 方法,这样就直接把
In [8]: doc = lxml.html.fragment_fromstring("<p>Hello<br/>World</p>")

# 读取当前节点包含的文本,只包含自身的文本,并不包含子标签,所以并不常用
In [9]: doc.text
Out[9]: 'Hello'

# 特别需要注意的是,往往还需要使用 tail,来读取最后一个字标签后的文本
In [42]: doc[0].tail
Out[42]: 'World'

In [40]: doc.xpath("./text()")
Out[40]: ['Hello', 'World']

In [10]: doc.text_content()
Out[10]: 'HelloWorld'

In [43]: lxml.html.tostring(doc)
Out[43]: b'<p>Hello<br>World</p>'

另外,包含 text() 的 xpath 返回的是_ElementUnicodeResult, 而不是 str,这个类型是 Element 和 str 的子类,一般情况下也可以当作 str 用,但是因为还引用了树中的结构,所以是不可以 pickle 的。有两个解决方法:

  • 显式转换成 str: str(s)
  • 使用 xpath(smart_strings=False) 直接返回字符串

常见问题和 bug

不合法的 html

lxml 在遇到正文包含小于号<的时候会出问题,直接把后面的文档都丢了。虽然按照标准,<应该编码为 &lt;,但是浏览器兼容性比较好,不会有问题。

网页不规范,有多个标签,浏览器可以解析,但是 lxml 不可以解析。case:http://ggzyjy.quanzhou.gov.cn/govProcurement/govProcurementDetail.do?bltId=178813&centerId=-1

对于这些不规范的网页,可以使用 html5parser,这个 parser 非常慢,但是兼容性要好很多

from lxml.html import html5parser

处理 html entity

在网页中经常出现 <, &amp;, &0x0026; 这些特殊字符,这是 html 实体字符转义,用于防止 XSS 攻击。Python3 标准库中包含了 html.entities 模块,可以用于转义和反转义这些字符。

html.entities.entitydefs 中包含了名称到符号的映射:比如{"amp": "&amp;"}
html.entities.name2codepoint 中包含了名称到数字的映射:比如 {"amp": 0x0026}
html.entities.codepoint2name 中包含了数字到名称的映射:比如 {0x0026: "amp"}

处理 emoji 的一个 bug

lxml.html.fromstring 无法正确处理 emoji str,但是 encode 成 bytes 之后没有问题……

# 注意最后的 encode
In [70]: lxml.html.tostring(lxml.html.fromstring('<!DOCTYPE html>\n<html"><title>😄</title></html>'.encode()))
Out[70]: b'<html><head><title>&#240;&#159;&#152;&#132;</title></head></html>'

In [71]: lxml.html.tostring(lxml.html.fromstring('<!DOCTYPE html>\n<html"><title>😄</title></html>'))
Out[71]: b'<html><body><p>!   D   O   C   T   Y   P   E       h   t   m   l   &gt;   \n   </p></body></html>'

参考

  1. http://lxml.de/xpathxslt.html#xpath-return-values

© 2016-2022 Yifei Kong. Powered by ynotes

All contents are under the CC-BY-NC-SA license, if not otherwise specified.

Opinions expressed here are solely my own and do not express the views or opinions of my employer.