前言
新手学爬虫,第一个真正的坎不是"怎么发请求",而是"怎么从一坨 HTML 里把想要的数据抠出来"。标准库自带的xml.etree.ElementTree能做 XML 解析,但它对网页这种容错要求高、结构不规范的 HTML 支持有限,也不支持完整的 XPath。lxml 就是在这个位置上补上短板的第三方库。
lxml 的定位可以一句话概括:它是 ElementTree 的增强版——API 尽量向后兼容 ElementTree,同时带来三样东西:基于 C 库 libxml2 的解析实现、完整的 XPath 1.0 支持、以及对残缺 HTML 的容错解析。
必须说清两件事:
第一,lxml 是第三方库,本机没有安装,本文代码无法运行验证,只能逐行人工推演。具体 API 与参数以 lxml 官方文档为准。
第二,爬虫要合规。遵守目标站点的 robots.txt、使用可识别的真实 User-Agent、严格限速、只取公开页面、不绕过任何登录墙或反爬机制。本文只讲解析,不讲任何规避手段。
一、先分清:解析 XML 和解析 HTML 是两件事
这是新手最容易踩的认知坑:lxml 的 XML 解析器和 HTML 解析器是两套入口。
XML 是严格格式,标签必须闭合、大小写敏感、必须有且仅有一个根节点。用lxml.etree解析 XML 时,格式不对就直接抛异常。
HTML 是"能容忍的杂乱格式":标签可以不闭合(比如单独的换行标签、图片标签)、可以嵌套错乱、可以有重复属性。用 XML 解析器去啃网页,最常见的症状是"明明浏览器里能看,程序却报解析错误"。
所以规则是:XML 用lxml.etree,HTML 用lxml.html。官方文档里也明确写了,lxml.html 是基于 lxml 的 HTML 解析器,专门提供面向 HTML 元素的 API 和常用处理工具。
| 对比项 | lxml.etree | lxml.html |
|---|
| 目标格式 | 严格 XML | 容错 HTML |
| 格式错误时 | 直接报错 | 尽量修复并建树 |
| 典型入口 | fromstring/parse | document_fromstring/fromstring |
| 是否处理命名空间 | 是,核心议题 | 通常不涉及 |
| 常见用途 | 配置文件、接口返回、RSS | 网页抓取后的抽取 |
二、XML 侧:三个必须记住的入口
fromstring从字符串解析,返回根 Element。
parse从文件或类文件对象解析,返回的是ElementTree 对象,不是 Element。这个区别新手经常搞错:拿到 ElementTree 后要再取.getroot()才是根节点。官方文档特意提示了这一点。
XML行为类似fromstring。
# 适用于 Python 3.8+(需要第三方库 lxml;本机未安装,无法运行)
from lxml import etree
xml_text = "<catalog><book id='1'><title>A</title></book></catalog>"
root = etree.fromstring(xml_text) # 返回 Element
print(root.tag, root[0].tag)
tree = etree.parse("catalog.xml") # 返回 ElementTree
root2 = tree.getroot() # 再取根节点命名空间(namespace)是 XML 侧最大的门槛。现实里的 XML 常常带命名空间,写成xmlns="urn:example:library"或xmlns:x="..."。带命名空间后,root.find("title")会找不到任何东西——因为标签的真实名字其实是那段命名空间 URI 加上title这个组合。
两种处理方式:
方式一:在 XPath 里声明前缀映射。lxml 的 XPath 支持namespaces关键字参数,传一个"前缀 → URI"的字典。官方文档强调了一个反直觉的点:你在这里用的前缀和文档里写的前缀毫无关系。文档可以随意定义自己的前缀,甚至用默认前缀,都不影响你的映射。
方式二:用local-name()绕开前缀。//*[local-name() = 'title']只匹配标签的本地名,忽略命名空间。但要注意:XPath 没有"默认命名空间"的概念,所以空前缀无法用于前缀映射;如果你不确定目标文档的命名空间 URI,local-name()是更省事的写法,代价是失去了命名空间的区分能力(不同命名空间下同名标签会被一网打尽)。
# 适用于 Python 3.8+(需要第三方库 lxml;本机未安装,无法运行)
from lxml import etree
xml_text = (
"<root xmlns:n='urn:example:library'>"
"<n:item>hello</n:item>"
"</root>"
)
root = etree.fromstring(xml_text.encode("utf-8"))
# 方式一:声明前缀映射(前缀名任取,与文档里的 n 无关)
found = root.xpath("//p:item/text()", namespaces={"p": "urn:example:library"})
# 方式二:用 local-name() 忽略命名空间
found2 = root.xpath("//*[local-name() = 'item']/text()")
print(found, found2)另外要留意 lxml 与 ElementTree 在"元素名字"上的表示差异:ElementTree 系(含 lxml 的 ElementTree 兼容层)用 Clark 记法{命名空间}标签名来表示带命名空间的元素,而 XPath 用前缀。两者混着读代码时容易懵。
三、HTML 侧:容错解析才是重点
HTML 用lxml.html。官方文档列出的入口函数有:
document_fromstring(string):把字符串解析成一个完整的 HTML 文档,根节点是 html 元素;fragment_fromstring(string, create_parent=False):解析一个 HTML片段,片段需要有单个外层元素,否则可以用create_parent指定一个容器标签包起来;fragments_fromstring(string):解析出多个片段;fromstring(string):按内容自动在document_fromstring和fragment_fromstring之间选择。
# 适用于 Python 3.8+(需要第三方库 lxml;本机未安装,无法运行)
from lxml import html
page = """
<html><body>
<div class="item"><a href="/p/1">标题一</a></div>
<div class="item"><a href="/p/2">标题二</a></div>
</body></html>
"""
doc = html.document_fromstring(page)
titles = doc.xpath('//div[@class="item"]/a/text()')
links = doc.xpath('//div[@class="item"]/a/@href')
print(titles, links)@class="item"这种写法有一个现实陷阱:class 属性可以包含多个类名(class="item active"),精确相等匹配就会漏掉。稳妥写法是用contains(concat(" ", normalize-space(@class), " "), " item ")这类表达式,或者改用 CSS 选择器接口(lxml 提供了基于 cssselect 的接口,属于另一套 API)。
四、大数据量与合规姿势
解析大文件用iterparse。官方文档给出的做法是:etree.iterparse(source, events=("start", "end"))逐事件产出(event, element)元组,处理完一个元素就调用element.clear(keep_tail=True)把它从树上摘掉,从而把内存压住。注意文档里的关键提示:只有end事件才保证该元素已经被完整解析,所以在start事件里读text是不安全的。
对爬虫场景,更重要的是网络侧的纪律:设置超时、请求之间 sleep 限速、失败指数退避、遵守 robots.txt、被拒绝就退避而不是换身份硬闯。解析再快,越过合规红线也是不能做的事。
# 适用于 Python 3.8+(需要第三方库 lxml;本机未安装,无法运行)
from lxml import etree
# 流式解析大 XML,避免整棵树驻留内存
context = etree.iterparse("big.xml", events=("end",), tag="record")
for event, element in context:
value = element.findtext("name")
print(value)
element.clear(keep_tail=True) # 处理完即释放注意:上面的 tag 参数与 events 过滤细节请以 lxml 官方文档为准。
如果记不准,宁可采用"全部事件都收、在循环里判断 element.tag"的写法。常见坑点
- 用
lxml.etree去解析网页 HTML。
❌ 把抓回来的 HTML 直接丢给etree.fromstring,遇到未闭合标签就抛异常。 ✅ HTML 一律用lxml.html,它基于容错的 HTML 解析器,能处理残缺标签。
- 把
etree.parse的返回值当根节点。
❌root = etree.parse(f)后直接root.tag,得到的是 ElementTree 而非 Element。 ✅ 记得调用.getroot();而fromstring返回的才是根 Element。
- 忽略命名空间导致 XPath 查不到东西。
❌ 文档带xmlns时仍写//title,结果返回空列表。 ✅ 要么用namespaces传前缀映射,要么用//*[local-name() = 'title']。
- 以为 XPath 里的前缀要跟文档里一致。
❌ 因为文档用的是n:,就把映射字典的键硬写成n,以为必须对应。 ✅ 前缀名是自己取的,与文档定义无关;但空前缀不能用于映射,因为 XPath 没有默认命名空间的概念。
- 用
@class="item"精确匹配多类名。
❌ 页面写的是class="item active",精确匹配全部漏掉。 ✅ 用contains(concat(" ", normalize-space(@class), " "), " item "),或改用 CSS 选择器。
fromstring与document_fromstring用错。
❌ 解析一个没有外层包裹的 HTML 片段时直接用document_fromstring。 ✅ 片段用fragment_fromstring(必要时配合create_parent)或fragments_fromstring。
- 大文件整树加载导致内存爆掉。
❌ 用etree.parse读几百 MB 的 XML,然后遍历。 ✅ 用etree.iterparse,处理完的元素调用clear(keep_tail=True)及时释放。
- 在
start事件里读元素文本。
❌ 认为收到start事件时元素的文本已经就绪。 ✅ 只有end事件保证元素解析完整,取值应在end分支里做。
总结
| 场景 | 用哪个入口 | 关键提醒 |
|---|
| 严格 XML 字符串 | etree.fromstring | 返回根 Element |
| XML 文件 | etree.parse | 返回 ElementTree,要.getroot() |
| 大 XML | etree.iterparse | 只在end事件取值,及时clear |
| 网页 HTML | lxml.html.document_fromstring | 不要用etree解 HTML |
| HTML 片段 | fragment_fromstring/fragments_fromstring | 注意create_parent的作用 |
| 带命名空间 | namespaces=或local-name() | XPath 没有默认命名空间 |
入门 lxml 只要抓住一句话:XML 和 HTML 分开处理,命名空间是 XML 侧最大的坑。剩下的是 XPath 表达式的熟练度,以及——别忘了解析之外,抓取行为本身必须合规。所有具体的函数签名与参数,请以 lxml 官方文档的当前版本为准。