HTML 解析:BeautifulSoup 与 lxml
下载页面只是体力活,难点在于从嵌套标签里稳定取出你要的字段。本章先讲解析器怎么选,再走一遍 bs4 与 lxml.etree 的常用路径,最后给出一个可直接运行的抓取加解析函数,并说清缺失节点、编码偏差与 HTML 实体这三类最常见的坑。
先选解析器
BeautifulSoup(html, 解析器) 的第二个参数决定用谁解析、容错到什么程度、要不要额外装包:
| 解析器 | 速度 | 脏 HTML 容错 | 额外依赖 | 适用场景 |
|---|---|---|---|---|
lxml | 快 | 好,会补全缺失的闭合标签 | lxml(C 扩展) | 生产环境默认选择 |
html.parser | 慢 | 一般,破损结构容易错位 | 无,标准库自带 | 离线小脚本、装不了依赖的机器 |
html5lib | 最慢 | 最好,严格按 HTML5 规范解析 | html5lib | 结构极乱、需与浏览器结果对齐 |
装依赖:pip install requests beautifulsoup4 lxml。默认用 lxml,一份依赖同时提供 bs4 的树遍历 API 和原生 XPath;只有编译不了 C 扩展时才退到 html.parser。
定位节点与取值
find 与 find_all 按标签和属性找,select 走 CSS 选择器,取文本与属性各有对应写法。class 是 Python 保留字,所以只能写 class_="title":
| 写法 | 示例 | 特点 |
|---|---|---|
| 单个查找 | soup.find("a", class_="title") | 返回第一个匹配,找不到是 None |
| 批量查找 | soup.find_all("a", class_="title", limit=10) | 返回列表,先用 limit 看结构再决定要不要抓全 |
| CSS 选择 | soup.select("div.item > a.title") | 表达式紧凑,嵌套层级写起来更短 |
| 取文本与属性 | tag.get_text(strip=True)、tag.get("href") | strip=True 顺手去空白,get 不会抛 KeyError |
soup = BeautifulSoup(html, "lxml")
links = soup.find_all("a", class_="title", limit=10) # limit 先取前 10 条看结构
print(links[0].attrs["data-id"], links[0].get("href")) # attrs 是属性的原始字典
print(links[0].get_text(strip=True)) # attrs["class"] 始终是列表
缺节点必须先判 None
链式取值是崩任务的头号原因,页面没有 h1 时 soup.find("h1").get_text() 直接抛 AttributeError:
for li in soup.select("ul.list li"):
a = li.find("a", class_="title")
if a is None:
continue # 结构变了或是广告位,跳过而不是崩掉
print(a.get_text(strip=True), a.get("href") or "")
字段级容错与整页容错要分开:单个字段缺失只丢一条记录,不该让整个任务停下。
需要 XPath 时用 lxml.etree
按文本、按兄弟节点、按位置定位时,XPath 比 bs4 顺手:
from lxml import etree
tree = etree.fromstring('<div class="l"><p>第一</p><p>第二</p></div>') # str 或 bytes 都行
print(tree.xpath("//p/text()")) # ['第一', '第二']
print(tree.xpath("//p[last()]/text()")) # ['第二']
el = tree.xpath('//div[@class="l"]/p[1]')[0]
print(etree.tostring(el, encoding="unicode")) # <p>第一</p>
etree.tostring(el, encoding="unicode") 返回 str,写成 encoding="utf-8" 返回的是 bytes,两者拼接会抛 TypeError。
编码被响应头带偏
response.text 依赖响应头里的 charset,不少站点声明 ISO-8859-1 实际返回 UTF-8,中文于是全乱。三种修法按情况选:response.encoding = response.apparent_encoding 按内容猜编码,已知确切编码就直接 response.content.decode("utf-8") 绕过响应头,解码报错时加 errors="replace" 让少量坏字节不阻断解析。注意 apparent_encoding 要遍历内容做统计,大页面明显变慢;从正则截出来的裸字符串还要自己 html.unescape(raw) 还原 &、 实体,并把还原出的 \xa0 换成普通空格,否则去重与搜索会出现看似相同却匹配不上的字符串。
完整可运行示例
下面这段不联网也能跑,内嵌了带实体的 HTML,把「取页面、修编码、解析、提取字段」串成一条链:
"""离线可跑的抓取 + 解析示例:python parse_demo.py"""
import html, json, time
import requests
from bs4 import BeautifulSoup
SAMPLE = """<ul class="list">
<li><a class="title" href="/p/1" data-id="1">Python & 爬虫</a><span class="price">¥39</span></li>
<li><a class="title" href="/p/2" data-id="2">数据清洗入门</a><span class="price">¥49</span></li>
<li><span class="price">¥0</span></li>
</ul>"""
def fetch(url, retries=3, interval=1.0):
"""抓页面:带重试、限速与编码修正,失败返回空串。"""
ua = {"User-Agent": "Mozilla/5.0 (compatible; RuilinCrawler/1.0)"}
for i in range(1, retries + 1):
try:
r = requests.get(url, timeout=10, headers=ua)
r.raise_for_status()
r.encoding = r.apparent_encoding if r.encoding is None else r.encoding
return r.text
except requests.RequestException as e:
print(f"第 {i} 次失败:{e}")
time.sleep(interval * i) # 退避,控制对站点的压力
return ""
def parse_list(text, base="https://example.com"):
"""解析列表页,缺关键字段的条目直接跳过。"""
soup, rows = BeautifulSoup(text, "lxml"), []
for li in soup.select("ul.list > li"):
a = li.find("a", class_="title")
if a is None: # 没有标题的条目多半是广告位
continue
href = (base.rstrip("/") + a["href"]) if (a.get("href") or "").startswith("/") else (a.get("href") or "")
price = li.find("span", class_="price")
rows.append({"id": a.get("data-id") or "",
"title": html.unescape(a.get_text(strip=True)).replace("\xa0", " "),
"url": href,
"price": price.get_text(strip=True) if price else ""})
return rows
if __name__ == "__main__":
rows = parse_list(fetch("https://example.com/list") or SAMPLE) # 离线时用内嵌 HTML
valid = [r for r in rows if all(r.get(k) for k in ("id", "title", "url"))]
print(json.dumps(valid, ensure_ascii=False, indent=2))
常见坑与调试方法
| 坑 | 表现 | 处理 |
|---|---|---|
| 链式取值 | AttributeError: 'NoneType' | 先判 None,循环内 continue |
该用 find_all 却用 find | 只拿到第一条 | 先确认到底要不要全部结果 |
忘加 limit | 几万条一次性解析,内存与耗时暴涨 | 先 limit=50 看清结构再放开 |
混用 str 与 bytes | TypeError: can only concatenate str | 入口统一解码,内部只传 str |
调试顺序固定三步:先看 response.status_code 与 len(response.text) 确认页面拿到了,再打印 soup.prettify()[:2000] 看解析出的结构是否符合预期,最后才逐字段打印。跳过前两步直接调选择器,是最浪费时间的做法。
要点对照
| 需求 | 推荐写法 |
|---|---|
| 解析器 | 默认 bs4.BeautifulSoup(html, "lxml") |
| 按 CSS 取节点 | soup.select("ul.list > li") |
| 取文本 | tag.get_text(strip=True) |
| 需要 XPath | lxml.etree.fromstring(html) 加 .xpath(...) |
合规提醒落在采集范围而不在解析技术:动手前确认目标站点 robots.txt 与服务条款是否允许抓取该路径,控制请求间隔与并发上限,不采集个人隐私和付费内容,不绕过登录、付费墙、验证码等访问控制,数据使用遵守《个人信息保护法》《数据安全法》《著作权法》。
小结:解析器默认选 lxml,取数用 select 与 find_all 组合并先判 None 再取文本或属性,编码不可信时用 apparent_encoding 或 content.decode 修正,把「取页面—修编码—解析—校验字段」做成一个函数,采集范围则始终以 robots.txt 与站点条款为界。