HTML 解析:BeautifulSoup 与 lxml

下载页面只是体力活,难点在于从嵌套标签里稳定取出你要的字段。本章先讲解析器怎么选,再走一遍 bs4lxml.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

定位节点与取值

findfind_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

链式取值是崩任务的头号原因,页面没有 h1soup.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) 还原 &amp;&nbsp; 实体,并把还原出的 \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 &amp; 爬虫</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 看清结构再放开
混用 strbytesTypeError: can only concatenate str入口统一解码,内部只传 str

调试顺序固定三步:先看 response.status_codelen(response.text) 确认页面拿到了,再打印 soup.prettify()[:2000] 看解析出的结构是否符合预期,最后才逐字段打印。跳过前两步直接调选择器,是最浪费时间的做法。

要点对照

需求推荐写法
解析器默认 bs4.BeautifulSoup(html, "lxml")
按 CSS 取节点soup.select("ul.list > li")
取文本tag.get_text(strip=True)
需要 XPathlxml.etree.fromstring(html).xpath(...)

合规提醒落在采集范围而不在解析技术:动手前确认目标站点 robots.txt 与服务条款是否允许抓取该路径,控制请求间隔与并发上限,不采集个人隐私和付费内容,不绕过登录、付费墙、验证码等访问控制,数据使用遵守《个人信息保护法》《数据安全法》《著作权法》。

小结:解析器默认选 lxml,取数用 selectfind_all 组合并先判 None 再取文本或属性,编码不可信时用 apparent_encodingcontent.decode 修正,把「取页面—修编码—解析—校验字段」做成一个函数,采集范围则始终以 robots.txt 与站点条款为界。

笔记加载中…