BeautifulSoup 与 lxml 完全指南

BeautifulSoup4 和 lxml 是 Python 爬虫中最常用的两个 HTML/XML 解析库。BeautifulSoup 提供简洁的 Python 风格 API,lxml 基于 C 语言实现,性能极高,二者常配合使用。 创建 BeautifulSoup 对象时必须指定解析器。不同解析器在速度、容错性和依赖上有所差异。 推荐在生产爬虫中统一使用 lxml 解析器。 返回第一个匹配的 Tag 对象,未找到返回 None。 返回所有匹配的 Tag 列表,未找到返回空列表 。 tag.string 和 tag.text 行为有细微差别,需注意区分。

分享

官方文档:https://www.crummy.com/software/BeautifulSoup/bs4/doc/ | https://lxml.de/
适用版本:beautifulsoup4 4.12+,lxml 5.x(2026-05-07 整理)

BeautifulSoup4 和 lxml 是 Python 爬虫中最常用的两个 HTML/XML 解析库。BeautifulSoup 提供简洁的 Python 风格 API,lxml 基于 C 语言实现,性能极高,二者常配合使用。

安装

pip install beautifulsoup4
pip install lxml
pip install html5lib   # 可选,用于 html5lib 解析器

BeautifulSoup4

解析器选择

创建 BeautifulSoup 对象时必须指定解析器。不同解析器在速度、容错性和依赖上有所差异。

解析器 用法字符串 速度 容错性 依赖 适用场景
Python 内置 html.parser 无(标准库) 轻量脚本,无额外依赖
lxml HTML lxml 需安装 lxml 生产环境首选
lxml XML lxml-xml / xml 低(严格) 需安装 lxml 解析标准 XML
html5lib html5lib 极高 需安装 html5lib 需还原浏览器解析行为

推荐在生产爬虫中统一使用 lxml 解析器。

创建对象

from bs4 import BeautifulSoup

html = "<html><body><p class='title'>Hello</p></body></html>"

# 从字符串创建
soup = BeautifulSoup(html, "lxml")

# 从文件对象创建
with open("page.html", "r", encoding="utf-8") as f:
    soup = BeautifulSoup(f, "lxml")

# 从 requests 响应创建
import requests
resp = requests.get("https://example.com")
resp.encoding = resp.apparent_encoding  # 修正编码
soup = BeautifulSoup(resp.text, "lxml")

查找元素

find()

返回第一个匹配的 Tag 对象,未找到返回 None

参数 类型 默认值 说明
name str / list / re.Pattern / True 标签名,如 "a"["a","p"]、正则
attrs dict {} 属性过滤,如 {"class": "title"}
recursive bool True 是否递归搜索所有后代;False 只搜索直接子节点
string str / re.Pattern None 匹配标签的文本内容
**kwargs 快捷属性过滤,如 class_="title"
# 按标签名查找
tag = soup.find("p")

# 按属性查找(class 是保留字,用 class_)
tag = soup.find("p", class_="title")
tag = soup.find("p", attrs={"class": "title"})

# 按 id 查找
tag = soup.find(id="main")

# 只在直接子节点中查找
tag = soup.body.find("p", recursive=False)

# 按文本内容查找
import re
tag = soup.find("p", string=re.compile(r"Hello"))

find_all()

返回所有匹配的 Tag 列表,未找到返回空列表 []

参数 类型 默认值 说明
name str / list / re.Pattern / True find()
attrs dict {} 属性过滤
recursive bool True 是否递归
string str / re.Pattern None 文本内容匹配
limit int None 返回结果数量上限
**kwargs 快捷属性过滤
# 查找所有 <a> 标签
links = soup.find_all("a")

# 查找多种标签
tags = soup.find_all(["h1", "h2", "h3"])

# 用正则匹配标签名(匹配所有 h 开头的标签)
tags = soup.find_all(re.compile(r"^h\d"))

# 限制返回数量
first_five = soup.find_all("p", limit=5)

# 查找含特定 class 的所有元素(多 class 时传列表)
items = soup.find_all("li", class_="item active")

# True 匹配所有带该属性的标签
tags = soup.find_all(href=True)

CSS 选择器

select()

参数 类型 默认值 说明
selector str CSS 选择器字符串
limit int None 返回结果数量上限(bs4 4.x 支持)

select_one()

参数 类型 默认值 说明
selector str CSS 选择器字符串,返回第一个匹配或 None
# 类选择器
items = soup.select(".item")

# ID 选择器
main = soup.select_one("#main")

# 后代选择器
links = soup.select("div.content a")

# 直接子元素
cells = soup.select("table > tr > td")

# 属性选择器
inputs = soup.select('input[type="text"]')

# 伪类(部分版本支持)
first = soup.select_one("li:first-child")

# 多选择器
tags = soup.select("h1, h2, h3")

属性访问

tag = soup.find("a")

# 标签名
print(tag.name)          # "a"

# 所有属性(dict)
print(tag.attrs)         # {"href": "https://...", "class": ["nav", "link"]}

# 获取属性值(不存在时抛 KeyError)
print(tag["href"])

# 安全获取(不存在返回 None 或默认值)
print(tag.get("href"))
print(tag.get("data-id", ""))

# class 属性始终返回列表
print(tag["class"])      # ["nav", "link"]

# 修改属性
tag["href"] = "https://new-url.com"
del tag["class"]

文本提取

tag.stringtag.text 行为有细微差别,需注意区分。

tag = soup.find("p")

# string:当且仅当标签只有一个直接文本子节点时有值,否则为 None
print(tag.string)

# text(等同于 get_text()):递归拼接所有文本
print(tag.text)

get_text()

参数 类型 默认值 说明
separator str "" 多个文本节点之间的分隔符
strip bool False 是否去除每段文本首尾空白
types tuple (NavigableString,) 参与拼接的节点类型
# 用换行符分隔,去除首尾空白
text = tag.get_text(separator="\n", strip=True)

# 只获取直接文本(不含子标签)
from bs4 import NavigableString
direct = "".join(
    str(child) for child in tag.children
    if isinstance(child, NavigableString)
).strip()

树形导航

tag = soup.find("p")

# 父节点(单个 Tag)
print(tag.parent)

# 所有祖先(生成器)
for ancestor in tag.parents:
    print(ancestor.name)

# 直接子节点(生成器,包含 NavigableString)
for child in tag.children:
    print(child)

# 所有后代(生成器)
for desc in tag.descendants:
    print(desc)

# 下一个同级节点(可能是 NavigableString 空白)
print(tag.next_sibling)
print(tag.previous_sibling)

# 跳过空白,获取下一个同级 Tag
print(tag.find_next_sibling("p"))
print(tag.find_previous_sibling("p"))

# 文档顺序的下一个节点
print(tag.next_element)
print(tag.previous_element)

特性 Tag NavigableString
含义 HTML/XML 标签节点 标签内的纯文本节点
str() 返回含标签的 HTML 字符串 返回纯文本
tag.string 返回唯一文本子节点 本身即字符串内容
是否可迭代 可(迭代子节点) 不可迭代
类型判断 isinstance(node, Tag) isinstance(node, NavigableString)
from bs4 import Tag, NavigableString

for child in soup.body.children:
    if isinstance(child, Tag):
        print(f"标签: {child.name}")
    elif isinstance(child, NavigableString):
        text = child.strip()
        if text:
            print(f"文本: {text}")

修改文档树

# append:在子节点末尾追加
new_tag = soup.new_tag("span", attrs={"class": "badge"})
new_tag.string = "NEW"
tag.append(new_tag)

# insert:在指定位置插入(0 为最前)
tag.insert(0, soup.new_tag("b"))

# replace_with:用新内容替换整个节点
tag.replace_with(soup.new_tag("div"))

# decompose:从树中移除并销毁节点
tag.decompose()

# extract:从树中移除并返回节点(可再利用)
removed = tag.extract()

lxml

解析 HTML

from lxml import html

# 从字符串解析
tree = html.fromstring("<html><body><p>Hello</p></body></html>")

# 从 URL 解析(内部使用 urllib)
tree = html.parse("https://example.com").getroot()

# 配合 requests
import requests
resp = requests.get("https://example.com")
tree = html.fromstring(resp.content)   # 传 bytes,让 lxml 自行处理编码

# 获取根元素
doc = html.document_fromstring(resp.content)

XPath 选择

element.xpath(expr) 返回列表,元素可以是 HtmlElement 或字符串(取决于表达式)。

常用 XPath 表达式速查

表达式 说明 示例
//tag 文档任意位置的标签 //a
/tag 根节点的直接子标签 /html/body
./tag 当前节点的直接子标签 ./td
.//tag 当前节点的所有后代标签 .//span
@attr 属性值 //a/@href
[@attr] 含该属性的节点 //img[@src]
[@attr='val'] 属性等于某值 //div[@id='main']
[contains(@attr,'val')] 属性包含某子串 //div[contains(@class,'item')]
[starts-with(@attr,'val')] 属性以某值开头 //a[starts-with(@href,'https')]
text() 文本节点 //p/text()
normalize-space() 去除多余空白 normalize-space(//title/text())
[position()=1] / [1] 第一个元素(XPath 下标从 1 开始) (//tr)[1]
last() 最后一个 //tr[last()]
[position()>1] 跳过第一个 //tr[position()>1]
parent::* 父节点 //td[@class='price']/parent::tr
following-sibling::tag 后续同级节点 //dt/following-sibling::dd[1]
| 并集(多路径) //h1|//h2
from lxml import html
import requests

resp = requests.get("https://example.com")
tree = html.fromstring(resp.content)

# 获取所有链接的 href
hrefs = tree.xpath("//a/@href")

# 获取含特定 class 的 div 下的文本
texts = tree.xpath("//div[contains(@class,'content')]//p/text()")

# 获取表格所有行(跳过表头)
rows = tree.xpath("//table[@id='data']//tr[position()>1]")
for row in rows:
    cells = row.xpath(".//td/text()")
    print(cells)

# 用 re:test 匹配(需注册命名空间)
# lxml 支持 EXSLT 扩展函数
results = tree.xpath(
    "//a[re:test(@href, r'product/\d+')]",
    namespaces={"re": "http://exslt.org/regular-expressions"}
)

CSS 选择器(lxml.cssselect)

from lxml import html
from lxml.cssselect import CSSSelector

tree = html.fromstring(content)

# 方式一:直接用 cssselect 方法
items = tree.cssselect("div.item > a.title")

# 方式二:预编译选择器(多次使用时性能更好)
sel = CSSSelector("table.data tr td:nth-child(2)")
cells = sel(tree)

etree.tostring()

将 Element 序列化回字符串。

参数 类型 默认值 说明
element Element 要序列化的元素
encoding str / type "ASCII" 输出编码;传 "unicode" 返回 str 而非 bytes
method str "xml" 序列化方式:"xml" / "html" / "text"
pretty_print bool False 是否格式化缩进输出
with_tail bool True 是否包含元素的 tail 文本
xml_declaration bool False 是否添加 XML 声明头
from lxml import etree, html

tree = html.fromstring("<div><p>Hello <b>World</b></p></div>")

# 返回 bytes
raw = etree.tostring(tree, method="html")

# 返回 str(推荐)
text = etree.tostring(tree, encoding="unicode", method="html", pretty_print=True)

# 只提取文本
plain = etree.tostring(tree, method="text", encoding="unicode")

lxml.etree 解析 XML

from lxml import etree

# 从字符串解析
root = etree.fromstring(b"<root><item id='1'>text</item></root>")

# 从文件解析
tree = etree.parse("data.xml")
root = tree.getroot()

# 遍历
for elem in root.iter("item"):
    print(elem.get("id"), elem.text)

# 命名空间处理
ns = {"ns": "http://example.com/ns"}
items = root.xpath("//ns:item", namespaces=ns)

# 构建 XML
builder = etree.TreeBuilder()
builder.start("root", {})
builder.start("item", {"id": "1"})
builder.data("text")
builder.end("item")
builder.end("root")
root = builder.close()

性能对比

下表基于解析同一份约 500KB 的 HTML 文件(实际数据因文档结构而异)。

解析器 相对速度 内存占用 容错能力 备注
lxml 最快(1x) C 扩展,生产首选
html.parser 中(约 3-5x 慢) 纯 Python,无依赖
html5lib 最慢(约 10-20x 慢) 极高 完全还原浏览器行为

实战场景

分页爬取:定位下一页链接

import requests
from bs4 import BeautifulSoup

def get_next_page_url(soup, base_url):
    # 方式一:BeautifulSoup CSS 选择器
    next_btn = soup.select_one("a.next, a[rel='next'], li.next > a")
    if next_btn and next_btn.get("href"):
        href = next_btn["href"]
        if href.startswith("http"):
            return href
        return base_url.rstrip("/") + "/" + href.lstrip("/")
    return None

def crawl_all_pages(start_url):
    url = start_url
    while url:
        resp = requests.get(url, timeout=10)
        soup = BeautifulSoup(resp.content, "lxml")
        # 处理当前页数据
        items = soup.select("div.item")
        for item in items:
            print(item.get_text(strip=True))
        url = get_next_page_url(soup, start_url)

提取表格数据转为列表

from lxml import html

def parse_table(tree, table_selector="table"):
    tables = tree.cssselect(table_selector)
    if not tables:
        return []

    result = []
    table = tables[0]

    # 提取表头
    headers = [
        th.text_content().strip()
        for th in table.xpath(".//thead//th | .//tr[1]//th")
    ]

    # 提取数据行
    rows = table.xpath(".//tbody//tr") or table.xpath(".//tr[position()>1]")
    for row in rows:
        cells = [td.text_content().strip() for td in row.xpath(".//td")]
        if headers and len(cells) == len(headers):
            result.append(dict(zip(headers, cells)))
        else:
            result.append(cells)

    return result

处理 HTML 编码问题

import requests
from bs4 import BeautifulSoup

resp = requests.get(url)

# 方式一:让 requests 自动检测编码
resp.encoding = resp.apparent_encoding
soup = BeautifulSoup(resp.text, "lxml")

# 方式二:传 bytes 给 lxml,由 lxml 从 <meta charset> 推断编码(更可靠)
from lxml import html
tree = html.fromstring(resp.content)

# 方式三:手动指定编码
soup = BeautifulSoup(resp.content.decode("gbk", errors="replace"), "lxml")

# 方式四:使用 chardet
import chardet
detected = chardet.detect(resp.content)
encoding = detected.get("encoding", "utf-8")
soup = BeautifulSoup(resp.content.decode(encoding, errors="replace"), "lxml")

踩坑与注意事项

find_all 返回空列表的常见原因

  1. 动态渲染内容:页面通过 JavaScript 动态插入的 DOM 节点,requests 获取的原始 HTML 中不存在。解决方式是使用 Selenium完全指南Playwright完全指南 等浏览器自动化工具。

  2. 解析器差异:不同解析器对 HTML 容错行为不同,同一段 HTML 用 html.parserlxml 解析后的树结构可能不同,导致选择器路径失效。固定使用同一解析器。

  3. class 多值匹配find("div", class_="a b") 要求标签同时含有 class ab,顺序无关;但 soup.select(".a.b") 同样有效。若只匹配其中一个用 soup.select(".a").

  4. 命名空间干扰:解析含命名空间的 XML/XHTML 时,标签名会带前缀,需在 XPath 中声明命名空间或用 local-name() 忽略。

  5. 属性大小写:HTML 属性名不区分大小写,但解析器通常统一转为小写,使用大写属性名查找会失败。

JavaScript 渲染内容

BeautifulSoup 和 lxml 均只能解析静态 HTML,无法执行 JavaScript。遇到以下情况说明页面为动态渲染:

  • 浏览器中能看到数据,但 requests 获取的 HTML 中找不到
  • HTML 源码中存在 <script> 标签包裹的 JSON 数据或 React/Vue 挂载点

解决方案参见 Playwright完全指南Selenium完全指南,或分析网络请求直接调用数据接口(参见 Python爬虫工程师面试题)。

编码检测

  • resp.text 使用 requests 内部的编码检测,对 GBK/GB2312 网站常出现乱码。
  • 优先传 resp.content(bytes)给 lxml,lxml 会读取 <meta charset> 自行处理。
  • BeautifulSoup 的 UnicodeDammit 也可辅助检测编码:
from bs4 import UnicodeDammit
dammit = UnicodeDammit(resp.content)
print(dammit.original_encoding)
soup = BeautifulSoup(dammit.unicode_markup, "lxml")

lxml XPath 下标从 1 开始

XPath 的位置下标从 1 开始,不同于 Python 的 0。//tr[1] 是第一行,//tr[0] 选不到任何元素。

BeautifulSoup 修改后重新序列化

修改文档树后,用 str(soup)soup.prettify() 输出 HTML,但 lxml 序列化用 etree.tostring(),两者不可混用。


最佳实践

lxml 作为 BeautifulSoup 的解析器以获得最佳性能BeautifulSoup(html, 'lxml') 比内置的 html.parser 快 2-5 倍,同时能处理不规范的 HTML(自动补全标签)。html5lib 最兼容但最慢,仅在解析 HTML5 特殊结构时使用。

批量提取数据用 find_all + 列表推导,不要逐个 findfind_all 一次遍历 DOM 树返回所有匹配节点,逐个 find 在大型文档上性能差,且代码冗长。

# 推荐
prices = [tag.text.strip() for tag in soup.find_all('span', class_='price')]

# 不推荐
price1 = soup.find('span', class_='price').text
price2 = soup.find_all('span', class_='price')[1].text

优先用 CSS 选择器(select/select_one)提取,代码更简洁:CSS 选择器语法对前端开发者更直观,且支持组合选择器、伪类等复杂条件,比 find(attrs={...}) 更易读。

# 等价于 find(class_='card') 但更简洁
cards = soup.select('.card')
title = soup.select_one('h2.article-title > a')

lxml XPath 用于结构复杂的 XML/HTML,BeautifulSoup 用于简单 HTML 提取:XPath 支持轴(ancestor::、following-sibling::)和条件表达式,处理嵌套关系复杂的文档更强大;BeautifulSoup API 更 Pythonic,日常爬虫场景首选。


常见陷阱

陷阱:find 返回 None 导致 AttributeError

现象: 代码在某些页面正常,在另一些页面抛出 AttributeError: 'NoneType' object has no attribute 'text'

原因: find() 在找不到元素时返回 None,直接链式访问 .text 会报错。页面结构不稳定、A/B 测试变体或反爬导致元素缺失时,就会触发此错误。

解决: 始终检查返回值是否为 None,或用 select_one + 条件表达式。

tag = soup.find('span', class_='price')
price = tag.text.strip() if tag else None

陷阱:lxml XPath 下标从 1 开始

现象: tree.xpath('//tr[0]') 返回空列表,//tr[1] 才是第一行。

原因: XPath 位置谓词(position predicate)从 1 开始计数,这与 Python 的 0 索引不同,初学者容易混淆。

解决: XPath 中用 [1] 选第一个,[last()] 选最后一个;Python 层面的索引用列表切片 result[0]

陷阱:编码问题导致中文乱码

现象: 提取的中文字段是乱码,或 soup.prettify() 输出包含 \xa0 等转义字符。

原因: requests.get(url).text 使用 chardet 自动检测编码,可能检测错误;BeautifulSoup 也可能误判编码。

解决: 显式指定编码,或用 response.content(bytes)传给 BeautifulSoup,让其自己从 meta charset 检测。

resp = requests.get(url)
resp.encoding = 'utf-8'  # 显式指定
soup = BeautifulSoup(resp.text, 'lxml')

参见

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog