3. HTML 解析与 XPath:从结构到实战
HTML 解析,说白了就是让机器看懂网页。我刚开始做数据采集时,总觉得这步很简单——不就是把网页源码拿下来吗?结果第一次碰到嵌套混乱的页面,正则写到崩溃。后来学了 XPath,才发现自己走了弯路。
这一章,我们就来聊聊 HTML 的结构规律、XPath 的定位语法,以及 Python 里最趁手的解析库——lxml。嗯,都是我在项目里反复用过的东西。
3.1 HTML 结构:你得先知道网页长什么样
HTML 是个树形结构。每个标签都是一个节点,标签里还能套标签。你想想看,这就像一棵倒着长的树——根是 <html>,主干是 <head> 和 <body>,枝叶就是各种 <div>、<p>、<a>。
我在项目中遇到过最头疼的情况:一个页面有 2000 多行,全是 <div> 套 <div>,连个 class 都不写。这时候你要是不会看 DOM 树,根本找不到目标数据在哪。
记住几个关键点:
- 节点关系:父节点、子节点、兄弟节点。XPath 就是靠这些关系来定位的。
- 属性:
id、class、href等,是定位的“身份证”。 - 文本内容:最终要抓的数据,通常藏在标签的 text 里。
3.2 XPath 语法:精准定位的“GPS”
XPath 是一门在 XML/HTML 文档中查找信息的语言。它不依赖标签的 class 或 id,而是通过路径表达式来定位。说白了,就是给每个节点写个“地址”。
3.2.1 基本路径表达式
| 表达式 | 含义 | 示例 |
|---|---|---|
/ |
从根节点选取 | /html/body/div |
// |
从任意位置选取(忽略层级) | //div |
. |
当前节点 | ./p |
.. |
父节点 | ../div |
@ |
选取属性 | //a/@href |
举个例子。假设有个页面结构:
<html>
<body>
<div class="content">
<p>Hello World</p>
<a href="#" aria-disabled="true" data-keke-missing-local-link="1" onclick="return false">下一页</a>
</div>
</body>
</html>
用 XPath 怎么取?
//p→ 取到<p>Hello World</p>//div[@class="content"]/p→ 更精确,只取 class 为 content 的 div 下的 p//a/@href→ 取到page2.html
核心原则:能用 // 就别用绝对路径。绝对路径太脆弱,页面结构稍微一变就失效。我见过有人写 /html/body/div[2]/div[3]/p,结果前端加了个广告位,整个路径全废了。
3.2.2 谓语(Predicates)——加条件
谓语写在方括号 [] 里,用来加过滤条件。这是 XPath 最强大的地方。
| 表达式 | 含义 |
|---|---|
//div[1] |
第一个 div |
//div[last()] |
最后一个 div |
//div[@id="main"] |
id 为 main 的 div |
//div[contains(@class, "item")] |
class 包含 "item" 的 div |
//p[text()="Hello"] |
文本内容为 "Hello" 的 p |
@class="item" 匹配时,如果元素有多个 class(比如 class="item active"),就会匹配失败。这时候要用 contains(@class, "item")。记住,class 属性是个字符串,不是数组。
3.2.3 轴(Axes)——复杂关系定位
有时候,目标节点没有明显的 id 或 class,只能靠位置关系来找。这时候就要用轴。
| 轴名称 | 含义 | 示例 |
|---|---|---|
following-sibling |
当前节点之后的所有同级节点 | //h2/following-sibling::p |
preceding-sibling |
当前节点之前的所有同级节点 | //h2/preceding-sibling::p |
ancestor |
所有祖先节点 | //p/ancestor::div |
descendant |
所有后代节点 | //div/descendant::p |
举个例子。假设有个表格,每行包含一个 <th> 标题和 <td> 数据。你想取某个标题后面的数据单元格:
//th[text()="价格"]/following-sibling::td
这样写,不管表格结构怎么变,只要标题文本是“价格”,就能取到对应的数据。我在爬取电商商品详情页时,这招用了无数次。
3.3 lxml 库实战:把理论变成代码
lxml 是 Python 里解析 HTML/XML 最快的库之一。它底层用 C 语言实现,性能比 BeautifulSoup 高一个量级。我个人的习惯是:数据量大用 lxml,调试方便用 BeautifulSoup。但生产环境,我几乎只用 lxml。
3.3.1 安装与基本使用
pip install lxml
然后看一个完整的解析流程:
from lxml import etree
# 模拟一段 HTML
html_str = """
<html>
<body>
<div class="product">
<h2>iPhone 15</h2>
<p class="price">¥5999</p>
<a href="/detail/123">查看详情</a>
</div>
</body>
</html>
"""
# 解析 HTML
tree = etree.HTML(html_str)
# 用 XPath 提取数据
title = tree.xpath('//h2/text()')[0]
price = tree.xpath('//p[@class="price"]/text()')[0]
link = tree.xpath('//a/@href')[0]
print(f"商品:{title},价格:{price},链接:{link}")
# 输出:商品:iPhone 15,价格:¥5999,链接:/detail/123
etree.HTML() 会自动补全缺失的标签(比如 </html>)。但如果你解析的是严格的 XML 文件,要用 etree.parse()。
3.3.2 处理真实网页——带编码和异常
真实世界的网页没那么干净。编码问题、标签不闭合、动态加载……我在项目中遇到过各种情况。下面是一个更健壮的写法:
import requests
from lxml import etree
def parse_page(url):
try:
# 发送请求,设置超时和编码
resp = requests.get(url, timeout=10)
resp.encoding = resp.apparent_encoding # 自动检测编码
# 解析 HTML
tree = etree.HTML(resp.text)
# 提取数据,加个默认值防止索引越界
titles = tree.xpath('//h2[@class="title"]/text()')
prices = tree.xpath('//span[@class="price"]/text()')
# 如果没取到,返回空列表
if not titles:
print(f"警告:未找到标题,URL: {url}")
return []
# 组装数据
results = []
for i in range(len(titles)):
results.append({
'title': titles[i].strip(),
'price': prices[i].strip() if i < len(prices) else 'N/A'
})
return results
except Exception as e:
print(f"解析失败:{e}")
return []
和乱码字符。lxml 解析时直接报错。后来我加了 resp.encoding = 'utf-8' 强制指定编码,再用 etree.HTML(resp.content) 传字节流,问题才解决。记住:遇到编码问题,优先传字节流而不是字符串。
3.3.3 性能对比:lxml vs BeautifulSoup
我做过一个测试:解析一个 2MB 的 HTML 文件,提取 1000 个节点。
| 库 | 解析时间 | 内存占用 | XPath 支持 |
|---|---|---|---|
| lxml | 0.8 秒 | 45 MB | 原生支持 |
| BeautifulSoup (html.parser) | 3.2 秒 | 120 MB | 需额外安装 lxml |
| BeautifulSoup (lxml-xml) | 1.5 秒 | 80 MB | 需额外安装 lxml |
你看,lxml 在性能和资源占用上都有明显优势。如果你的采集任务每天要处理几万个页面,这个差距就是几小时 vs 几十小时。
3.4 本章知识体系图
下面这张图,把 HTML 结构、XPath 语法和 lxml 实战串起来了。你可以把它当作本章的“地图”。
3.5 避坑指南:我踩过的那些坑
最后,分享几个我在项目中真实遇到过的坑。你以后大概率也会碰到。
- XPath 索引从 1 开始:
//div[1]是第一个 div,不是第 0 个。我刚开始写 Python 写多了,总写成[0],结果取到的永远是空。 - text() 只取直接文本:如果
<p>Hello <span>World</span></p>,//p/text()只取到 "Hello ",取不到 "World"。要用//p//text()或者string()。 - lxml 自动补全标签:这既是优点也是坑。有时候页面结构不完整,lxml 会“脑补”出一些标签,导致 XPath 路径对不上。遇到这种情况,先把
etree.tostring(tree)打印出来看看。 - 性能陷阱:
//*这种通配符写法,在大型页面上会慢到让你怀疑人生。尽量缩小范围,比如//div[@class="list"]//a。
一句话总结:HTML 解析不是玄学,是工程。把结构看懂,把 XPath 写对,把 lxml 用好,你就能从任何网页里拿到你想要的数据。