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.string 和 tag.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)
NavigableString 与 Tag 的区别
| 特性 | 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 返回空列表的常见原因
-
动态渲染内容:页面通过 JavaScript 动态插入的 DOM 节点,requests 获取的原始 HTML 中不存在。解决方式是使用 Selenium完全指南 或 Playwright完全指南 等浏览器自动化工具。
-
解析器差异:不同解析器对 HTML 容错行为不同,同一段 HTML 用
html.parser和lxml解析后的树结构可能不同,导致选择器路径失效。固定使用同一解析器。 -
class 多值匹配:
find("div", class_="a b")要求标签同时含有 classa和b,顺序无关;但soup.select(".a.b")同样有效。若只匹配其中一个用soup.select(".a"). -
命名空间干扰:解析含命名空间的 XML/XHTML 时,标签名会带前缀,需在 XPath 中声明命名空间或用
local-name()忽略。 -
属性大小写: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 + 列表推导,不要逐个 find:find_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')