4、HTML解析基础:HTML结构、XPath语法、lxml库的使用
做数据采集,说白了就是跟网页打交道。你拿到一个页面,怎么把想要的数据摘出来?这就得靠解析了。我个人习惯把解析比作「拆快递」——你得先知道包裹长什么样,再找到下刀的位置,最后把东西取出来。
今天咱们就聊聊HTML解析这回事。我会从HTML结构讲起,再到XPath语法,最后上手lxml库。嗯,这三样东西,你玩明白了,采集数据就成功了一半。
4.1 HTML结构——你得先看懂网页的骨架
HTML是什么?超文本标记语言。说白了,就是用一堆标签把内容包起来,告诉浏览器「这是标题、这是段落、这是链接」。
一个典型的HTML文档长这样:
<!DOCTYPE html>
<html>
<head>
<title>页面标题</title>
</head>
<body>
<h1>一级标题</h1>
<p>这是一个段落。</p>
<a href="https://example.com">这是一个链接</a>
</body>
</html>
你想想看,这就像一棵树。最上面是<html>,下面分两个大枝干:<head>和<body>。<head>里放的是元数据,比如标题、编码;<body>里才是我们真正要抓的内容。
核心概念:DOM树
浏览器会把HTML解析成一棵文档对象模型(DOM)树。每个标签都是一个节点,节点之间有父子关系、兄弟关系。我们做数据采集,本质上就是在这棵树上找到目标节点。
我在项目中遇到过不少新手,上来就对着HTML字符串用正则表达式硬抠数据。结果页面一改版,正则就废了。我的建议是:别跟字符串较劲,用解析器操作DOM树,这才是正道。
4.2 XPath语法——定位数据的GPS
XPath,全称XML Path Language。它最初是为XML设计的,但HTML也是XML的亲戚,所以完全能用。
XPath的核心思想很简单:用路径表达式在DOM树里找节点。就像你用地址找房子一样——「北京市海淀区中关村大街xx号」这种写法,换成XPath就是/html/body/div[1]/p。
4.2.1 基本语法
| 表达式 | 含义 | 示例 |
|---|---|---|
/ |
从根节点选取 | /html/body |
// |
从任意位置选取 | //div(所有div) |
. |
当前节点 | ./p |
.. |
父节点 | ../div |
@ |
选取属性 | //a/@href |
举个例子。假设页面里有这么一段:
<div class="article">
<h2>标题</h2>
<p class="content">正文内容</p>
<a href="detail.html">详情</a>
</div>
我想取<p>里的文本,XPath可以这么写:
//div[@class="article"]/p[@class="content"]/text()
拆开看://div找所有div,[@class="article"]筛选class为article的,/p取子节点p,[@class="content"]再筛选,最后/text()拿文本。
小技巧:我个人习惯先用浏览器的开发者工具(F12)验证XPath。在Elements面板选中元素,右键→Copy→Copy XPath,能快速拿到一个参考路径。但注意,浏览器生成的路径往往很啰嗦,我会手动精简一下。
4.2.2 常用函数和运算符
XPath还支持一些函数,让定位更灵活:
text():获取文本内容contains(@class, "keyword"):class包含某个关键词starts-with(@id, "prefix"):id以某前缀开头position():节点位置,比如//li[position()=2]取第二个li|:并集运算符,比如//h1 | //h2同时选h1和h2
我曾经遇到过一个坑:某个网站的class名里带了空格,比如class="item active"。直接用[@class="item active"]没问题,但如果你用contains(@class, "item"),就得小心了——它会把class="item-something"也匹配上。所以,精确匹配用等号,模糊匹配用contains,但要注意边界。
4.3 lxml库——Python里的解析利器
理论说完了,咱们上手干活。Python里解析HTML的库不少,BeautifulSoup、lxml、html.parser……我个人最常用的是lxml。为什么?因为它快,而且XPath支持得最好。
4.3.1 安装和基本使用
pip install lxml
装好之后,写个最简单的例子:
from lxml import etree
# 假设这是我们从网页抓到的HTML
html_str = """
<html>
<body>
<div class="content">
<h1>文章标题</h1>
<p>第一段文字</p>
<p>第二段文字</p>
</div>
</body>
</html>
"""
# 解析HTML
tree = etree.HTML(html_str)
# 用XPath提取数据
title = tree.xpath('//h1/text()')[0]
paragraphs = tree.xpath('//p/text()')
print(f"标题: {title}")
print(f"段落: {paragraphs}")
输出结果:
标题: 文章标题
段落: ['第一段文字', '第二段文字']
你看,就这么几行代码,数据就取出来了。lxml的etree.HTML()会自动补全不规范的HTML标签,比如缺了</p>它会帮你补上。这一点在实战中特别有用——很多网站的HTML写得并不规范。
4.3.2 从文件或网络加载
实际项目中,我们通常不会把HTML硬编码在代码里。要么从文件读,要么从网络请求拿。
# 从文件读取
tree = etree.parse('page.html', etree.HTMLParser())
# 从网络请求(配合requests库)
import requests
resp = requests.get('https://example.com')
tree = etree.HTML(resp.text)
注意:用etree.parse()时,一定要指定etree.HTMLParser()作为解析器。否则默认用XML解析器,遇到不规范的HTML会报错。我曾经因为这个bug排查了半天,最后发现是解析器没指定——嗯,这种低级错误犯过一次就够了。
4.3.3 实战:提取文章列表
假设我们要采集一个博客的文章列表,HTML结构大概是这样:
<div class="blog-list">
<article>
<h2><a href="/post/1">第一篇文章</a></h2>
<p class="date">2024-01-15</p>
<p class="summary">这是摘要...</p>
</article>
<article>
<h2><a href="/post/2">第二篇文章</a></h2>
<p class="date">2024-01-16</p>
<p class="summary">这是另一篇摘要...</p>
</article>
</div>
用lxml提取所有文章信息:
from lxml import etree
tree = etree.HTML(html_str)
articles = tree.xpath('//div[@class="blog-list"]/article')
for article in articles:
title = article.xpath('.//a/text()')[0]
link = article.xpath('.//a/@href')[0]
date = article.xpath('.//p[@class="date"]/text()')[0]
summary = article.xpath('.//p[@class="summary"]/text()')[0]
print(f"标题: {title}")
print(f"链接: {link}")
print(f"日期: {date}")
print(f"摘要: {summary}")
print("---")
这里有个细节:在循环里,我用的是article.xpath('.//a/text()'),注意那个点号.。它表示从当前article节点开始查找,而不是从根节点。如果不加点号,//a会从整个文档的根节点找,结果可能不对。
避坑指南:我曾经在写XPath时,忘了在循环里加.,结果每次循环都返回所有文章的第一个链接。排查了半天才发现是相对路径和绝对路径的问题。记住:在循环里处理子节点时,XPath一定要用相对路径(加.)。
4.4 总结与建议
HTML解析这块,说难不难,说简单也不简单。核心就三点:
- 理解DOM树结构——这是基础,你得知道页面是怎么组织的
- 掌握XPath语法——这是工具,用得越熟,定位越准
- 熟练使用lxml——这是武器,Python生态里解析HTML的首选
我的建议是:刚开始别追求一步到位。先拿几个简单的页面练手,把XPath的基本语法跑通。遇到复杂的页面,多用浏览器的开发者工具调试。等你处理过几十个不同结构的页面后,自然就熟练了。
下一章,我们会讲正则表达式在数据清洗中的应用。到时候你会发现,解析和清洗是相辅相成的——解析拿到的数据往往很粗糙,需要清洗才能用。嗯,咱们到时候细聊。