Beautiful Soup 是一个从 HTML 和 XML 文件中提取数据的 Python 库。它配合你喜欢的解析器,提供符合直觉的方式来导航、搜索和修改解析树。它通常能为程序员节省数小时甚至数天的工作。
# 1. 安装(命令行)
# pip install beautifulsoup4 lxml
# 2. 使用
from bs4 import BeautifulSoup
html_doc = """<html><head><title>The Dormouse's story</title></head>
<body>
<p class="title"><b>The Dormouse's story</b></p>
<p class="story">Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1">Elsie</a>,
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a> and
<a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>;
and they lived at the bottom of a well.</p>
<p class="story">...</p>
</body></html>"""
# 构造Soup对象 — 这就是"煮汤"
soup = BeautifulSoup(html_doc, 'lxml')
# 立即体验:5行代码完成三大常见任务
print("📌 标题:", soup.title.string) # 提取标题
print("📌 第一个链接:", soup.a['href']) # 提取第一个链接的URL
print("📌 所有链接:", [a['href'] for a in soup.find_all('a')]) # 所有链接
print("📌 纯文本:\n", soup.get_text()) # 提取所有文本
print("📌 格式化预览:\n", soup.prettify()[:200]) # 格式化输出前200字符
输出预览:
📌 标题: The Dormouse's story
📌 第一个链接: http://example.com/elsie
📌 所有链接: ['http://example.com/elsie', 'http://example.com/lacie', 'http://example.com/tillie']
📌 纯文本:
The Dormouse's story
...
📌 格式化预览:
<html>
<head>
<title>
The Dormouse's story
</title>
</head>
✅ 你已成功煮出第一锅汤! 下面我们深入理解背后的每一个细节。
graph TD
START["开始:我需要 BeautifulSoup"] --> Q1{"我有 pip 吗?"}
Q1 -->|"✅ 有"| PIP["pip install beautifulsoup4<br/>(推荐,99%场景)"]
Q1 -->|"❌ 没有"| Q2{"我用 Debian/Ubuntu 吗?"}
Q2 -->|"✅ 是"| APT["apt-get install python3-bs4<br/>(系统包管理器)"]
Q2 -->|"❌ 不是"| Q3{"能下载源码吗?"}
Q3 -->|"✅ 能"| SRC["下载 tarball → python setup.py install<br/>(源码安装)"]
Q3 -->|"❌ 不能"| EMBED["复制 bs4/ 目录到项目代码中<br/>(内嵌方式,无需安装)"]
PIP --> PARSER_Q{"需要什么解析器?"}
APT --> PARSER_Q
SRC --> PARSER_Q
EMBED --> PARSER_Q
PARSER_Q -->|"快速原型/无外部依赖"| HP["html.parser<br/>(Python内置,零依赖)"]
PARSER_Q -->|"生产环境/追求速度"| LXML["pip install lxml<br/>(推荐,速度最快)"]
PARSER_Q -->|"解析XML文档"| LXML_XML["lxml-xml / xml<br/>(唯一支持XML的解析器)"]
PARSER_Q -->|"极端容错/浏览器级解析"| H5LIB["pip install html5lib<br/>(最宽松,速度最慢)"]
style START fill:#e1f5fe
style PIP fill:#c8e6c9
style LXML fill:#fff9c4
style HP fill:#e8eaf6
style LXML_XML fill:#fce4ec
style H5LIB fill:#f3e5f5
graph TD
A["拿到一段 HTML/XML 标记文本"] --> B{"文档类型?"}
B -->|"HTML"| C{"质量如何?"}
B -->|"XML"| XML["必须使用 lxml-xml<br/>BeautifulSoup(markup, 'xml')"]
C -->|"格式良好/标准HTML"| LXML_HTML["🥇 lxml (推荐)<br/>BeautifulSoup(markup, 'lxml')<br/>✅ 速度最快<br/>⚠️ 需要C库依赖"]
C -->|"破损严重/浏览器解析"| H5LIB["🥈 html5lib<br/>BeautifulSoup(markup, 'html5lib')<br/>✅ 像浏览器一样解析<br/>⚠️ 速度很慢"]
C -->|"不确定/追求零依赖"| HP["🥉 html.parser<br/>BeautifulSoup(markup, 'html.parser')<br/>✅ Python内置<br/>⚠️ 容错能力有限"]
LXML_HTML --> RESULT["获得 BeautifulSoup 对象"]
H5LIB --> RESULT
HP --> RESULT
XML --> RESULT
RESULT --> NAV["🌳 导航:soup.title, soup.a, .parent, .children"]
RESULT --> SEARCH["🔍 搜索:find(), find_all(), select()"]
RESULT --> MODIFY["✏️ 修改:.string, .append(), .decompose()"]
RESULT --> OUTPUT["📤 输出:prettify(), str(), get_text()"]
style A fill:#e1f5fe
style LXML_HTML fill:#fff9c4
style H5LIB fill:#f3e5f5
style HP fill:#e8eaf6
style RESULT fill:#c8e6c9
graph TD
INPUT["📥 输入源<br/>• 字符串 str<br/>• 文件句柄 file<br/>• bytes 字节<br/>• URL (通过requests)"] --> UNICODE["🔤 Unicode, Dammit<br/>编码检测与转换<br/>━━━━━━━━━━━━━<br/>from_encoding: 指定编码<br/>exclude_encodings: 排除错误猜测<br/>→ 输出: Unicode 字符串"]
UNICODE --> ENTITY["🔣 HTML实体转换<br/>━━━━━━━━━━━━━<br/>&eacute; → é<br/>&lt; → <<br/>&amp; → &<br/>所有标准实体自动处理"]
ENTITY --> STRAINER{"parse_only<br/>(SoupStrainer)?"}
STRAINER -->|"❌ 未指定"| FULL_PARSE["📋 完整解析<br/>解析整个文档树"]
STRAINER -->|"✅ 已指定"| SELECTIVE["🎯 选择性解析<br/>仅解析匹配的标签<br/>⚠️ html5lib不支持"]
FULL_PARSE --> BUILDER["🔧 解析器 (Builder)<br/>━━━━━━━━━━━━━<br/>features参数决定:<br/>• 'lxml' → LXMLTreeBuilder<br/>• 'html.parser' → HTMLParserTreeBuilder<br/>• 'html5lib' → HTML5TreeBuilder<br/>• 'xml'/'lxml-xml' → LXMLTreeBuilderForXML"]
SELECTIVE --> BUILDER
BUILDER --> ATTR_HANDLING["🏷️ 属性处理<br/>━━━━━━━━━━━━━<br/>multi_valued_attributes:<br/> class='a b' → ['a','b']<br/> id='a b' → 'a b'<br/>on_duplicate_attribute:<br/> 'replace'/'ignore'/自定义函数"]
ATTR_HANDLING --> INSTANTIATE["🏗️ 对象实例化<br/>━━━━━━━━━━━━━<br/>element_classes:<br/> 自定义Tag/NavigableString子类<br/>默认: Tag + NavigableString"]
INSTANTIATE --> SOUP["🍲 BeautifulSoup 对象<br/>━━━━━━━━━━━━━<br/>• 完整的嵌套树结构<br/>• .original_encoding: 原始编码<br/>• .contains_replacement_characters<br/>• 所有导航/搜索/修改API就绪"]
style INPUT fill:#e1f5fe
style UNICODE fill:#fff3e0
style ENTITY fill:#fce4ec
style SOUP fill:#c8e6c9
style BUILDER fill:#fff9c4
BeautifulSoup 是一个 HTML/XML 解析与数据提取库,它不是解析器本身——它是一个 解析器包装器(Parser Wrapper)。
核心定位:
痛点场景 → BS4 解决方案:
杂乱HTML解析:网上抓取的HTML格式混乱、标签未闭合、嵌套错误 → BS4自动修复
编码地狱:GBK/UTF-8/Latin-1混用 → Unicode, Dammit自动检测并转换
数据提取繁琐:正则表达式难以匹配嵌套结构 → 树形导航API直观操作
实体乱码:é 在页面上显示为 é 但提取到的却是原始实体 → 自动转换
BS4的独特优势:
✅ 多解析器透明切换 — 同一套API,换解析器只需改一个参数
✅ TreeBuilder架构 — 新增解析器只需实现TreeBuilder接口
✅ 同一文档,HTML/XML两种视角 — 'lxml' vs 'xml'
✅ BeautifulSoup对象即文档根 — 整体操作便捷
全书的代码示例都将基于同一份 HTML 文档——来自《爱丽丝梦游仙境》的"三姐妹"片段:
html_doc = """<html><head><title>The Dormouse's story</title></head>
<body>
<p class="title"><b>The Dormouse's story</b></p>
<p class="story">Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1">Elsie</a>,
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a> and
<a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>;
and they lived at the bottom of a well.</p>
<p class="story">...</p>
</body></html>"""
文档结构一览:
html
├── head
│ └── title → "The Dormouse's story"
└── body
├── p.title
│ └── b → "The Dormouse's story"
├── p.story
│ ├── "Once upon a time..."
│ ├── a#link1.sister[href="...elsie"] → "Elsie"
│ ├── ","
│ ├── a#link2.sister[href="...lacie"] → "Lacie"
│ ├── " and "
│ ├── a#link3.sister[href="...tillie"] → "Tillie"
│ └── "; and they lived..."
└── p.story → "..."
四种安装路径:
路径1:pip 安装(推荐 ⭐)
pip install beautifulsoup4
# Python 3 环境下推荐:
pip3 install beautifulsoup4
⚠️ 注意:包名是 beautifulsoup4,不是 BeautifulSoup!后者是BS3。
路径2:系统包管理器(Debian/Ubuntu)
apt-get install python3-bs4
路径3:源码安装
# 1. 下载源码 tarball: http://www.crummy.com/software/BeautifulSoup/download/4.x/
# 2. 解压后执行:
python setup.py install
路径4:内嵌使用(无安装)
# 将源码中的 bs4/ 目录直接复制到你的项目目录中
# 然后正常导入即可
from bs4 import BeautifulSoup
# 推荐:安装 lxml(速度快,功能全)
pip install lxml
# 或者:
apt-get install python-lxml
# 备选:安装 html5lib(浏览器级容错)
pip install html5lib
# 或者:
apt-get install python3-html5lib
# Python内置:html.parser — 无需安装,直接使用
解析器速查表:
BeautifulSoup 构造函数的完整签名:
BeautifulSoup(
markup, # ① 必填:HTML/XML 标记文本
features=None, # ② 解析器选择
builder=None, # ③ TreeBuilder实例(高级)
parse_only=None, # ④ SoupStrainer 选择性解析
from_encoding=None, # ⑤ 指定源编码
exclude_encodings=None, # ⑥ 排除错误编码猜测
multi_valued_attributes=None, # ⑦ 多值属性行为
on_duplicate_attribute='replace',# ⑧ 重复属性处理
element_classes=None # ⑨ 自定义元素类
)
参数详解:
① markup — 输入源,支持多种类型:
② features — 解析器标识符:
"lxml" — 使用 lxml 的 HTML 解析器
"html.parser" — 使用 Python 内置解析器
"html5lib" — 使用 html5lib
"xml" 或 "lxml-xml" — 使用 lxml 的 XML 解析器
None(默认)— 自动选择最佳可用解析器(优先级:lxml > html5lib > html.parser)
③ builder — 直接传入 TreeBuilder 实例(高级用法):
from bs4.builder import LXMLTreeBuilder
soup = BeautifulSoup(markup, builder=LXMLTreeBuilder())
④ parse_only — SoupStrainer 选择性解析:
from bs4 import SoupStrainer
only_a = SoupStrainer("a")
soup = BeautifulSoup(html_doc, 'html.parser', parse_only=only_a)
# 只解析 <a> 标签,大幅提升性能
⚠️ parse_only 在使用 html5lib 解析器时不生效!
⑤ from_encoding — 手动指定源文档编码:
# 当 Unicode, Dammit 猜错编码时使用
soup = BeautifulSoup(markup, 'html.parser', from_encoding="iso-8859-8")
⑥ exclude_encodings — 排除错误编码猜测(BS 4.4.0+):
# 告诉Unicode, Dammit不要尝试这些编码
soup = BeautifulSoup(markup, 'html.parser', exclude_encodings=["iso-8859-7"])
⑦ multi_valued_attributes — 多值属性处理(BS 4.8.0+):
# 默认:class 属性按空格拆分为列表,id 保持字符串
soup = BeautifulSoup('<a class="cls1 cls2" id="id1 id2">', 'html.parser')
soup.a['class'] # ['cls1', 'cls2']
soup.a['id'] # 'id1 id2'
# 全部作为单值字符串:
soup = BeautifulSoup(markup, 'html.parser', multi_valued_attributes=None)
soup.a['class'] # 'cls1 cls2'
⑧ on_duplicate_attribute — 重复属性处理(BS 4.9.1+):
markup = '<a href="http://url1/" href="http://url2/">'
# 默认 'replace':使用最后出现的值
soup = BeautifulSoup(markup, 'html.parser')
soup.a['href'] # 'http://url2/'
# 'ignore':使用第一个值
soup = BeautifulSoup(markup, 'html.parser', on_duplicate_attribute='ignore')
soup.a['href'] # 'http://url1/'
# 自定义函数:收集所有值
def accumulate(attrs_so_far, key, value):
if not isinstance(attrs_so_far[key], list):
attrs_so_far[key] = [attrs_so_far[key]]
attrs_so_far[key].append(value)
soup = BeautifulSoup(markup, 'html.parser', on_duplicate_attribute=accumulate)
soup.a['href'] # ['http://url1/', 'http://url2/']
⑨ element_classes — 自定义 Tag/String 子类:
from bs4 import Tag, NavigableString
class MyTag(Tag): pass
class MyString(NavigableString): pass
soup = BeautifulSoup("<div>text</div>", 'html.parser',
element_classes={Tag: MyTag, NavigableString: MyString})
from bs4 import BeautifulSoup
soup = BeautifulSoup(html_doc, 'lxml')
# 快速验证方式
print(type(soup)) # <class 'bs4.BeautifulSoup'>
print(soup.original_encoding) # 原始编码(str已Unicode则为None)
print(soup.title) # <title>The Dormouse's story</title>
print(soup.title.name) # 'title'
print(soup.title.string) # 'The Dormouse's story'
print(soup.title.parent.name) # 'head'
print(soup.p) # 第一个 <p> 标签
print(soup.p['class']) # 'title'
print(soup.find_all('a')) # 所有 <a> 标签列表
print(soup.prettify())
# <html>
# <head>
# <title>
# The Dormouse's story
# </title>
# </head>
# <body>
# <p class="title">
# <b>
# The Dormouse's story
# </b>
# </p>
# <p class="story">
# Once upon a time there were three little sisters; and their names were
# <a class="sister" href="http://example.com/elsie" id="link1">
# Elsie
# </a>
# ,
# <a class="sister" href="http://example.com/lacie" id="link2">
# Lacie
# </a>
# and
# <a class="sister" href="http://example.com/tillie" id="link3">
# Tillie
# </a>
# ; and they lived at the bottom of a well.
# </p>
# <p class="story">
# ...
# </p>
# </body>
# </html>
HTML实体自动转换:
# 实体 → Unicode字符
print(BeautifulSoup("Sacré bleu!", "html.parser"))
# Sacré bleu!
# bytes 输入自动检测编码
markup = b"<h1>Sacr\xc3\xa9 bleu!</h1>" # UTF-8 编码的 é
soup = BeautifulSoup(markup, 'html.parser')
soup.h1 # <h1>Sacré bleu!</h1>
soup.h1.string # 'Sacré bleu!'
# 查看自动检测到的编码
soup.original_encoding # 'utf-8'
# 已为 Unicode 的输入
soup = BeautifulSoup("<h1>Sacré bleu!</h1>", 'html.parser')
soup.original_encoding # None
编码纠错:
# 当 Unicode, Dammit 猜错编码时
markup = b"<h1>\xed\xe5\xec\xf9</h1>" # 实际是 ISO-8859-8 (希伯来语)
# 自动检测可能出错
soup = BeautifulSoup(markup, 'html.parser')
print(soup.h1) # <h1>νεμω</h1> — 猜成了 ISO-8859-7 (希腊语)!
# 方式1:使用 from_encoding 明确指定
soup = BeautifulSoup(markup, 'html.parser', from_encoding="iso-8859-8")
print(soup.h1) # <h1>םולש</h1> ✅
# 方式2:使用 exclude_encodings 排除错误猜测
soup = BeautifulSoup(markup, 'html.parser', exclude_encodings=["iso-8859-7"])
print(soup.h1) # <h1>םולש</h1> ✅
print(soup.original_encoding) # WINDOWS-1255 (兼容超集)
输出编码行为:
所有输出自动转为 UTF-8
prettify() 输出的 <meta charset> 标签会被改写为 utf-8
原始 Latin-1 文档经 BS4 处理后输出为 UTF-8
场景:从新闻网站抓取并提取所有新闻标题和链接。
"""
新闻标题提取器
需求:给定一段HTML,提取所有<a>标签的文本和href属性
适用:任何包含链接列表的HTML页面
"""
from bs4 import BeautifulSoup
# 模拟抓取的新闻页面HTML
news_html = """
<html>
<head><title>今日新闻</title></head>
<body>
<div class="news-list">
<h2 class="section-title">国内新闻</h2>
<ul>
<li><a href="/news/001" class="news-link">全国经济总量突破新高</a></li>
<li><a href="/news/002" class="news-link">科技创新大会在京召开</a></li>
</ul>
<h2 class="section-title">国际新闻</h2>
<ul>
<li><a href="/news/003" class="news-link">全球气候变化峰会达成新协议</a></li>
<li><a href="/news/004" class="news-link">国际体育赛事圆满落幕</a></li>
</ul>
</div>
</body>
</html>
"""
def extract_news(html, parser='lxml'):
"""
从HTML中提取新闻标题和链接
Args:
html: HTML字符串
parser: 解析器名称
Returns:
list[dict]: [{'title': ..., 'url': ...}, ...]
"""
soup = BeautifulSoup(html, parser)
news_items = []
for link in soup.find_all('a', class_='news-link'):
news_items.append({
'title': link.get_text(strip=True),
'url': link.get('href')
})
return news_items
# 运行提取
news = extract_news(news_html)
for i, item in enumerate(news, 1):
print(f"{i}. {item['title']}")
print(f" URL: {item['url']}")
print()
# 输出:
# 1. 全国经济总量突破新高
# URL: /news/001
# 2. 科技创新大会在京召开
# URL: /news/002
# 3. 全球气候变化峰会达成新协议
# URL: /news/003
# 4. 国际体育赛事圆满落幕
# URL: /news/004
场景:一个目录下有多个HTML报告文件,需要批量提取摘要信息。
"""
批量HTML文件解析助手
需求:扫描目录,解析每个HTML文件,提取<title>和所有文本长度
适用:日志分析、报表批量处理、SEO审计
"""
import os
import glob
from bs4 import BeautifulSoup
def batch_parse_html(directory, pattern="*.html", parser='lxml'):
"""
批量解析目录中的HTML文件
Args:
directory: 目标目录路径
pattern: 文件匹配模式
parser: 解析器名称
Returns:
list[dict]: 每个文件的解析结果
"""
results = []
files = glob.glob(os.path.join(directory, pattern))
for filepath in files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f, parser)
# 提取基本信息
title = soup.title.string if soup.title else "无标题"
text_content = soup.get_text()
link_count = len(soup.find_all('a'))
original_enc = soup.original_encoding or "已是Unicode"
results.append({
'file': os.path.basename(filepath),
'title': title.strip(),
'text_length': len(text_content),
'link_count': link_count,
'encoding': original_enc,
'status': '✅ 成功'
})
except Exception as e:
results.append({
'file': os.path.basename(filepath),
'status': f'❌ 失败: {str(e)}'
})
return results
def print_report(results):
"""打印格式化的解析报告"""
print(f"{'文件名':<30} {'标题':<25} {'文本长度':>8} {'链接数':>6} {'状态'}")
print("-" * 90)
for r in results:
if r['status'].startswith('✅'):
print(f"{r['file']:<30} {r['title'][:25]:<25} {r['text_length']:>8} {r['link_count']:>6} {r['status']}")
else:
print(f"{r['file']:<30} {'—':<25} {'—':>8} {'—':>6} {r['status']}")
# ===== 使用演示 =====
if __name__ == "__main__":
# 创建测试用的HTML文件
test_dir = "./test_html_reports"
os.makedirs(test_dir, exist_ok=True)
# 写入测试文件
test_files = {
"report_2024Q1.html": "<html><head><title>Q1报告</title></head><body><p>第一季度营收增长15%</p><a href='/detail'>详情</a></body></html>",
"report_2024Q2.html": "<html><head><title>Q2报告</title></head><body><p>第二季度营收增长22%</p></body></html>",
}
for filename, content in test_files.items():
with open(os.path.join(test_dir, filename), 'w', encoding='utf-8') as f:
f.write(content)
# 批量解析
results = batch_parse_html(test_dir)
print_report(results)
# 输出示例:
# 文件名 标题 文本长度 链接数 状态
# ------------------------------------------------------------------------------------------
# report_2024Q1.html Q1报告 14 1 ✅ 成功
# report_2024Q2.html Q2报告 14 0 ✅ 成功
症状:
$ pip install BeautifulSoup
安装成功但导入后发现是 Beautiful Soup 3,API完全不同!
根因:PyPI上有两个包:
BeautifulSoup → BS3(旧版,已停止维护)
beautifulsoup4 → BS4(正确版本)
解决:
pip uninstall BeautifulSoup # 卸载BS3
pip install beautifulsoup4 # 安装BS4
症状:
soup = BeautifulSoup(html, 'lxml')
# FeatureNotFound: Couldn't find a tree builder with the features you
# requested: lxml. Do you need to install a parser library?
根因:features 参数指定的解析器未安装。
解决:
pip install lxml
# 或者改用内置解析器:
soup = BeautifulSoup(html, 'html.parser')
预防:使用 diagnose() 函数检查环境:
from bs4.diagnose import diagnose
# 导入时会自动打印环境中所有已安装的解析器
症状:解析后的文本出现乱码,如 Sacré bleu! 而非 Sacré bleu!。
根因:Unicode, Dammit 自动检测编码时猜错了。
排查:
soup = BeautifulSoup(markup, 'html.parser')
print(soup.original_encoding) # 查看检测到了什么编码
解决方案(按优先级):
方案A — 使用 from_encoding 明确指定:
soup = BeautifulSoup(markup, 'html.parser', from_encoding="正确的编码")
方案B — 使用 exclude_encodings 排除错误猜测(BS 4.4.0+):
soup = BeautifulSoup(markup, 'html.parser', exclude_encodings=["错误的编码"])
方案C — 使用 requests.content(bytes)而非 .text:
# ❌ requests 可能猜错编码
soup = BeautifulSoup(requests.get(url).text, 'lxml')
# ✅ 让 BeautifulSoup 的 Unicode, Dammit 处理
soup = BeautifulSoup(requests.get(url).content, 'lxml')
症状:
soup = BeautifulSoup(open("large_file.html"), 'lxml')
# 文件句柄泄漏!大文件场景下可能导致资源耗尽
解决:
# ✅ 使用 with 语句确保文件自动关闭
with open("large_file.html") as f:
soup = BeautifulSoup(f, 'lxml')
更优雅的方式:使用 pathlib:
from pathlib import Path
content = Path("large_file.html").read_text(encoding='utf-8')
soup = BeautifulSoup(content, 'lxml')
症状:解析 XML 文档时,自闭合标签被错误处理,XML声明丢失。
错误做法:
soup = BeautifulSoup("<a><b/></a>", "html.parser")
# 输出: <a><b></b></a> — <b/>被当成了不完整的HTML标签
正确做法:
soup = BeautifulSoup("<a><b/></a>", "xml")
# 输出: <?xml version="1.0" encoding="utf-8"?>
# <a><b/></a>
# — 自闭合标签保留,XML声明自动添加
关键差异总结:
症状:
ImportError: cannot import name 'etree' from 'lxml'
或安装时报 C 库编译错误。
根因:lxml 依赖 libxml2 和 libxslt C 库,在某些系统上可能编译失败。
解决方案(按优先级):
方案A — Windows 用户直接安装预编译wheel:
pip install lxml
# Windows通常不需要编译,wheel包含预编译二进制
方案B — Linux用户安装系统依赖后再装:
# Ubuntu/Debian
sudo apt-get install python3-lxml libxml2-dev libxslt-dev
# 或者直接用apt安装
sudo apt-get install python3-lxml
# CentOS/RHEL
sudo yum install python3-lxml libxml2-devel libxslt-devel
方案C — 如果 lxml 实在装不上,回退到 html.parser:
soup = BeautifulSoup(html, 'html.parser') # 零依赖
症状:设置了 parse_only=SoupStrainer("a") 但似乎没生效,整个文档都被解析了。
根因:parse_only 在使用 html5lib 解析器时不生效! html5lib 会不断重新排列解析树,如果某部分文档没有进入解析树,它就会崩溃。
解决:
# ❌ parse_only + html5lib 无效
soup = BeautifulSoup(html, 'html5lib', parse_only=only_a_tags)
# ✅ 改用其他解析器
soup = BeautifulSoup(html, 'html.parser', parse_only=only_a_tags)
# 或
soup = BeautifulSoup(html, 'lxml', parse_only=only_a_tags)
Q1:以下哪个是正确的 BS4 安装命令?
A. pip install BeautifulSoup
B. pip install bs4
C. pip install beautifulsoup4
D. pip install BeautifulSoup4
答案
C。beautifulsoup4 是正确的包名。A 安装的是 BS3,B 和 D 是无效包名。
Q2:以下代码有什么问题?
from bs4 import BeautifulSoup
soup = BeautifulSoup("<xml><data/></xml>", "html.parser")
答案
XML 文档应使用 XML 解析器而非 HTML 解析器。HTML 解析器会将自闭合标签 <data/> 错误处理为 <data></data>。正确写法:
soup = BeautifulSoup("<xml><data/></xml>", "xml")
# 需要先安装 lxml: pip install lxml
Q3:soup.original_encoding 返回 None 表示什么?
答案
表示传入 BeautifulSoup 的文档已经是 Unicode 字符串(Python str 类型),不需要进行编码检测和转换。只有传入 bytes 类型时,Unicode, Dammit 才会工作并设置 original_encoding。
Q4:写出三种指定解析器的方式,并说明各自的适用场景。
答案
# 方式1:指定解析器名称
BeautifulSoup(markup, "lxml") # 生产环境,追求速度
BeautifulSoup(markup, "html.parser") # 快速原型,零依赖
BeautifulSoup(markup, "html5lib") # 极端容错需求
# 方式2:指定标记类型
BeautifulSoup(markup, "html") # 使用最佳HTML解析器
BeautifulSoup(markup, "xml") # 使用XML解析器(需lxml)
BeautifulSoup(markup, "html5") # 强制html5lib
# 方式3:使用 features 默认值
BeautifulSoup(markup) # 自动选择最佳(lxml > html5lib > html.parser)
Q5:以下HTML中,<a> 标签的 class 属性被重复定义。写出使用默认行为、'ignore' 和自定义函数三种方式解析后,soup.a['class'] 的值分别是什么?
<a class="cls1 cls2" class="cls3 cls4">
答案
markup = '<a class="cls1 cls2" class="cls3 cls4">'
# 默认行为 ('replace'): 使用最后出现的值
soup = BeautifulSoup(markup, 'html.parser')
soup.a['class'] # ['cls3', 'cls4'] — 注意:class是多值属性,会拆分
# 'ignore': 使用第一个值
soup = BeautifulSoup(markup, 'html.parser', on_duplicate_attribute='ignore')
soup.a['class'] # ['cls1', 'cls2']
# 自定义函数:收集所有值
def collect_all(attrs, key, value):
if not isinstance(attrs[key], list):
attrs[key] = [attrs[key]]
attrs[key].extend(value if isinstance(value, list) else [value])
soup = BeautifulSoup(markup, 'html.parser', on_duplicate_attribute=collect_all)
soup.a['class'] # ['cls1', 'cls2', 'cls3', 'cls4']
Q6:当使用 html.parser / lxml / html5lib 三种解析器分别解析 <a></p> 时,结果分别是什么?为什么会不同?
答案
不同是因为输入无效,各解析器有不同的错误恢复策略,没有绝对"正确"的处理方式(虽然html5lib最接近浏览器行为)。
Q7:编写一个函数 get_all_text(soup),接收 BeautifulSoup 对象,返回去除所有标签后的纯文本字符串,且多个空白字符合并为一个空格。
答案
import re
def get_all_text(soup):
"""提取所有文本,合并多余空白"""
text = soup.get_text()
# 合并多个空白字符为一个空格
text = re.sub(r'\s+', ' ', text)
return text.strip()
# 测试
soup = BeautifulSoup(html_doc, 'lxml')
print(get_all_text(soup))
# The Dormouse's story The Dormouse's story Once upon a time there were three
# little sisters; and their names were Elsie, Lacie and Tillie; and they lived
# at the bottom of a well. ...
Q8:编写代码,使用 SoupStrainer 只解析"三姐妹"文档中的 <a> 标签,并输出它们的文本和 href 属性。
答案
from bs4 import BeautifulSoup, SoupStrainer
# 创建过滤器:只解析 <a> 标签
only_a = SoupStrainer("a")
# 选择性解析(注意:不能用html5lib)
soup = BeautifulSoup(html_doc, 'html.parser', parse_only=only_a)
# 输出结果
for tag in soup.find_all('a'):
print(f"文本: {tag.string}, URL: {tag['href']}")
# 输出:
# 文本: Elsie, URL: http://example.com/elsie
# 文本: Lacie, URL: http://example.com/lacie
# 文本: Tillie, URL: http://example.com/tillie
Q9:你从网上抓取了一个 HTML 页面,发现中文显示为乱码。soup.original_encoding 显示为 'windows-1252',但页面实际上是 GBK 编码。请写出修复代码。
答案
# 方案A:使用 from_encoding 明确指定编码
response = requests.get(url)
soup = BeautifulSoup(response.content, 'lxml', from_encoding='gbk')
# 方案B(BS 4.4.0+):排除错误的编码猜测
soup = BeautifulSoup(response.content, 'lxml', exclude_encodings=['windows-1252'])
# 方案C:先用正确的编码手动解码
response = requests.get(url)
response.encoding = 'gbk' # 覆盖 requests 的编码检测
soup = BeautifulSoup(response.text, 'lxml')
# 推荐方案A或B,因为让BS4的Unicode, Dammit处理bytes比依赖requests的编码检测更可靠
恭喜!完成第01章的学习后,你已经掌握:
下一章预告:[第02章:对象体系 — Tag、NavigableString与BeautifulSoup] 将深入探讨BS4的核心对象模型,理解解析树的内部结构。
"The Fish-Footman began by producing from under his arm a great letter, nearly as large as himself." — Alice in Wonderland
Beautiful Soup 就像那个鱼脚仆从,从 HTML 的混乱中为你取出一封整洁的信。
本章基于 Beautiful Soup 4.15.0 官方文档编写 | 文档版本 v5.0 | 2026-06-17
版本:v5.0 | 基于 Beautiful Soup 4.15.0 | 字数:≈9500字
本章深入剖析 Beautiful Soup 的四类核心 Python 对象,揭示它们如何将 HTML/XML 文档映射为可编程的树形数据结构。理解对象体系是掌握 Beautiful Soup 全部导航、搜索和修改能力的基础。
from bs4 import BeautifulSoup
# ========== 1. 创建文档 ==========
soup = BeautifulSoup('<b class="boldest">Extremely bold</b>', 'html.parser')
# ========== 2. Tag 对象:标签 ==========
tag = soup.b # 获取 <b> 标签
print(tag.name) # → 'b'(标签名)
tag.name = 'blockquote' # 改名!→ <blockquote class="boldest">...
print(tag['class']) # → ['boldest'](多值属性返回列表!)
tag['id'] = 'main' # 添加属性
del tag['class'] # 删除属性
print(tag.get('href', 'default')) # → 'default'(安全取值,无 KeyError)
# ========== 3. NavigableString:文本节点 ==========
text = tag.string # → 'Extremely bold'
print(type(text)) # → bs4.element.NavigableString
plain = str(text) # 转为普通 Python str(避免内存泄露!)
text.replace_with("No longer bold") # 原地替换文本
# ========== 4. BeautifulSoup:文档根 ==========
print(soup.name) # → '[document]'(特殊名称)
print(soup.attrs) # → {}(无属性)
# 但可以像 Tag 一样使用所有搜索方法:
soup.find_all('b') # 正常工作!
# ========== 5. Comment:注释 ==========
soup2 = BeautifulSoup('<b><!--Hey, buddy.--></b>', 'html.parser')
comment = soup2.b.string
print(type(comment)) # → bs4.element.Comment
print(comment) # → 'Hey, buddy.'(不含注释标记)
# prettify() 输出时会自动加上 <!-- --> 格式
# ========== 6. 多值属性控制 ==========
soup3 = BeautifulSoup('<p class="body strikeout"></p>', 'html.parser')
print(soup3.p['class']) # → ['body', 'strikeout'] ← 列表!
soup3_xml = BeautifulSoup('<p class="body strikeout"></p>', 'xml')
print(soup3_xml.p['class']) # → 'body strikeout' ← 字符串!(XML无多值)
# 强制全部字符串:
soup4 = BeautifulSoup('<p class="body strikeout"></p>', 'html.parser',
multi_valued_attributes=None)
print(soup4.p['class']) # → 'body strikeout'
# 统一列表接口:
print(soup3.p.get_attribute_list('class')) # → ['body', 'strikeout']
15秒记忆口诀: Tag 是标签(有 .name .attrs),NavigableString 是文本(用 str() 转),BeautifulSoup 是文档根(名 [document]),Comment 是注释(NavigableString 子类)。多值属性 HTML 返回列表、XML 返回字符串,用 get_attribute_list() 统一。
classDiagram
class PageElement {
<<abstract>>
+setup()
+extract()
+replace_with()
+insert_before()
+insert_after()
+parent
+next_element
+previous_element
+next_sibling
+previous_sibling
}
class Tag {
+name: str
+attrs: dict
+string: NavigableString
+strings: generator
+stripped_strings: generator
+contents: list
+children: generator
+descendants: generator
+parent: Tag
+parents: generator
+find(name, attrs, ...)
+find_all(name, attrs, ...)
+select(css_selector)
+get(key, default)
+get_attribute_list(key)
+has_attr(key)
+decompose()
+unwrap()
+prettify()
}
class NavigableString {
+parent: Tag
+next_element
+previous_element
+replace_with()
+extract()
-不支持contents
-不支持string
-不支持find()
}
class BeautifulSoup {
+name = '[document]'
+attrs = {}
+find(name, attrs, ...)
+find_all(name, attrs, ...)
+select(css_selector)
+prettify()
}
PageElement <|-- Tag : 继承
PageElement <|-- NavigableString : 继承
Tag <|-- BeautifulSoup : 继承
str <|-- NavigableString : Python字符串行为
class Comment
class Stylesheet
class Script
class Template
class Declaration
class Doctype
class CData
class ProcessingInstruction
NavigableString <|-- Comment
NavigableString <|-- Stylesheet
NavigableString <|-- Script
NavigableString <|-- Template
NavigableString <|-- Declaration
NavigableString <|-- Doctype
NavigableString <|-- CData
NavigableString <|-- ProcessingInstruction
关键设计洞察:
PageElement 是所有可导航对象的抽象基类,提供树遍历基础设施(.parent、.next_element、.replace_with() 等)
Tag 和 NavigableString 都继承自 PageElement,共享导航能力,但 NavigableString 不包含子节点
BeautifulSoup 继承自 Tag,所以它就是一个特殊的 Tag,只是 .name='[document]' 表示它是文档根
NavigableString 继承自 Python 内置 str,所以它就是字符串,可以进行比较、切片等操作
所有特殊字符串类都是 NavigableString 的子类,在输出时附带特殊格式化
flowchart TD
A["解析 HTML 属性<br/>如 class='body strikeout'"] --> B{解析器模式?}
B -->|HTML 解析器| C{multi_valued_attributes<br/>参数设置?}
B -->|XML 解析器| D{multi_valued_attributes<br/>参数设置?}
C -->|None(用户强制字符串)| E["存储为字符串<br/>'body strikeout'"]
C -->|未设置(默认)| F{"该属性在<br/>DEFAULT_CDATA_LIST_ATTRIBUTES<br/>列表中?"}
F -->|是(如 class, rel, headers)| G["存储为列表<br/>['body', 'strikeout']"]
F -->|否(如 id, href)| H["存储为字符串<br/>'my id'"]
D -->|None(默认)| I["存储为字符串<br/>'body strikeout'<br/>XML 中无多值属性概念"]
D -->|自定义字典如 {'*': 'class'}| J["存储为列表<br/>['body', 'strikeout']<br/>强制该属性为多值"]
G --> K["tag.get_attribute_list('class')<br/>→ ['body', 'strikeout']<br/>统一列表接口"]
H --> K
E --> K
I --> K
J --> K
K --> L["序列化输出时<br/>列表值用空格合并<br/>→ class='body strikeout'"]
流程关键点:
HTML 解析器默认遵循 HTML 规范:只有 class、rel、rev、accept-charset、headers、accesskey 等少数属性被视为多值
XML 解析器默认不识别任何多值属性——XML 规范中属性值就是普通字符串
multi_valued_attributes=None 会禁用所有多值处理,所有属性都当作字符串
get_attribute_list() 提供统一列表接口,无论内部存储形式如何,总是返回列表
classDiagram
class `Python str` {
<<built-in>>
不可变字符串
}
class `PageElement` {
<<abstract>>
树节点基类
}
class `NavigableString` {
文本节点
+parent
+next_element
+replace_with()
+extract()
-contents ❌
-string ❌
-find() ❌
}
class `Comment` {
HTML 注释
<!-- ... -->
prettify 自动加标记
}
class `Stylesheet` {
[BS 4.9+]
<style> 内 CSS
}
class `Script` {
[BS 4.9+]
<script> 内 JS
}
class `Template` {
[BS 4.9+]
<template> 内 HTML
}
class `Declaration` {
XML 声明
<?xml version='1.0'?>
}
class `Doctype` {
DOCTYPE 声明
<!DOCTYPE html>
}
class `CData` {
XML CDATA 段
<![CDATA[...]]>
}
class `ProcessingInstruction` {
XML 处理指令
<?...?>
}
`Python str` <|-- `NavigableString`
`PageElement` <|-- `NavigableString`
`NavigableString` <|-- `Comment`
`NavigableString` <|-- `Stylesheet`
`NavigableString` <|-- `Script`
`NavigableString` <|-- `Template`
`NavigableString` <|-- `Declaration`
`NavigableString` <|-- `Doctype`
`NavigableString` <|-- `CData`
`NavigableString` <|-- `ProcessingInstruction`
note for `NavigableString` "双重继承:<br/>Python str + PageElement<br/>= 可导航的字符串"
继承树说明:
NavigableString 采用双重继承:同时继承 Python str(获得所有字符串操作)和 PageElement(获得树导航能力)
HTML 专用子类(Stylesheet/Script/Template)在 BS 4.9.0 引入,便于过滤页面主体内容时忽略 CSS/JS/模板
XML 专用子类(Declaration/Doctype/CData/ProcessingInstruction)在序列化时自动添加 XML 标记语法
Comment 横跨 HTML 和 XML 两种场景,是最常见的特殊字符串子类
Beautiful Soup 的核心设计理念是将 HTML/XML 文档映射为一棵由四类 Python 对象构成的树。每种对象在树中扮演明确的角色:
在典型的三姐妹文档中,对象树的结构如下:
BeautifulSoup ([document])
└── Tag (html)
├── Tag (head)
│ └── Tag (title)
│ └── NavigableString ("The Dormouse's story")
└── Tag (body)
├── Tag (p, class="title")
│ └── Tag (b)
│ └── NavigableString ("The Dormouse's story")
├── Tag (p, class="story")
│ ├── NavigableString ("Once upon a time...")
│ ├── Tag (a, href="...elsie")
│ │ └── NavigableString ("Elsie")
│ ├── NavigableString (",")
│ ├── Tag (a, href="...lacie")
│ │ └── NavigableString ("Lacie")
│ ├── NavigableString (" and")
│ ├── Tag (a, href="...tillie")
│ │ └── NavigableString ("Tillie")
│ └── NavigableString (";and they lived...")
└── Tag (p, class="story")
└── NavigableString ("...")
关键认识: 每一个文本片段都是一个独立的 NavigableString 对象。"Once upon a time..." 和 "Elsie" 虽然都属于同一个 <p> 标签,但它们是两个不同的树节点,各自拥有独立的导航属性。
所有可导航对象都继承自 PageElement(位于 bs4.element 模块)。这个基类不直接使用,但它定义了对象在树中移动所需的所有基础设施:
# PageElement 提供的核心方法和属性(适用于 Tag 和 NavigableString)
element.parent # 父节点
element.next_sibling # 下一个兄弟节点
element.previous_sibling # 上一个兄弟节点
element.next_element # 文档顺序的下一个元素(深度优先)
element.previous_element # 文档顺序的上一个元素
element.replace_with() # 原地替换自身
element.extract() # 从树中移除自身并返回
element.insert_before() # 在自身前插入
element.insert_after() # 在自身后插入
这一设计使得 Tag 和 NavigableString 在导航层面完全统一——你可以用同样的 API 在标签和文本节点之间遍历文档。
NavigableString 是 Beautiful Soup 对象体系中最精妙的设计。它继承了 Python 内置 str:
from bs4 import BeautifulSoup
soup = BeautifulSoup('<p>Hello World</p>', 'html.parser')
text = soup.p.string
# 它就是字符串
print(text) # Hello World
print(text.upper()) # HELLO WORLD
print(text[0:5]) # Hello
print(text == "Hello World") # True
print(len(text)) # 11
# 但它也是树节点
print(type(text)) # <class 'bs4.element.NavigableString'>
print(text.parent.name) # p
双重继承示意:
NavigableString
├── 继承自 str → 所有字符串操作(切片、比较、格式化...)
└── 继承自 PageElement → 所有树导航操作(.parent、.next_element...)
许多初学者困惑于:为什么同一个 .string 属性,有时返回 NavigableString,有时返回 None?
soup = BeautifulSoup('<p>Hello</p>', 'html.parser')
print(type(soup.p.string)) # <class 'bs4.element.NavigableString'>
# ✅ 只有一个子节点(文本),返回它
soup2 = BeautifulSoup('<p>Hello<b>World</b></p>', 'html.parser')
print(soup2.p.string) # None
# ❌ 有多个子节点(文本 + Tag + 文本),.string 无法确定返回哪个
规则: .string 仅在标签有且仅有一个 NavigableString 子节点时返回该字符串;否则返回 None。如需获取所有文本,应使用 .strings(生成器)或 .get_text()。
from bs4 import BeautifulSoup
soup = BeautifulSoup('<a href="http://example.com" class="external link" id="main-link">Click</a>', 'html.parser')
a_tag = soup.a
# —— 读取属性 ——
# 方式1:字典式访问(属性不存在时抛出 KeyError)
print(a_tag['href']) # 'http://example.com'
print(a_tag['class']) # ['external', 'link'] ← 注意:class 返回列表!
# 方式2:.get() 安全访问(属性不存在时返回 None 或默认值)
print(a_tag.get('href')) # 'http://example.com'
print(a_tag.get('title')) # None
print(a_tag.get('title', 'N/A')) # 'N/A'
# 方式3:直接访问 .attrs 字典
print(a_tag.attrs) # {'href': 'http://example.com', 'class': ['external', 'link'], 'id': 'main-link'}
# —— 修改属性 ——
a_tag['href'] = 'https://new-url.com' # 修改已有属性
a_tag['target'] = '_blank' # 添加新属性
print(a_tag) # <a href="https://new-url.com" class="external link" id="main-link" target="_blank">Click</a>
# —— 删除属性 ——
del a_tag['id'] # 字典式删除
print(a_tag) # <a href="https://new-url.com" class="external link" target="_blank">Click</a>
# —— 批量设置 ——
a_tag.attrs.update({'rel': 'nofollow', 'aria-label': 'External Link'})
print(a_tag.attrs.keys()) # dict_keys(['href', 'class', 'target', 'rel', 'aria-label'])
class 是 HTML 中最常见的多值属性,需要特别注意其列表形式:
soup = BeautifulSoup('<div class="container fluid primary"></div>', 'html.parser')
div = soup.div
# ✅ 正确:class 返回列表
classes = div['class'] # ['container', 'fluid', 'primary']
# 判断是否含有某个类
print('fluid' in div['class']) # True
print('missing' in div['class']) # False
# 添加类
div['class'].append('highlight')
print(div['class']) # ['container', 'fluid', 'primary', 'highlight']
# 移除类
div['class'].remove('fluid')
print(div['class']) # ['container', 'primary', 'highlight']
# 设为新值(可以是字符串或列表)
div['class'] = 'single-class'
print(div['class']) # ['single-class'] ← 自动转为列表
div['class'] = ['a', 'b', 'c']
print(div['class']) # ['a', 'b', 'c']
# ⚠️ 注意:不能直接赋值单个字符串来"覆盖"
div['class'] = 'only-one'
print(div['class']) # ['only-one'] ← 不是 'only-one'!
# 输出时的合并行为
print(div) # <div class="only-one"></div> ← 序列化时自动合并
class 操作要点速查:
from bs4 import BeautifulSoup
soup = BeautifulSoup('<p>Original <b>bold</b> text</p>', 'html.parser')
p = soup.p
# —— 获取文本 ——
print(p.get_text()) # 'Original bold text' ← 递归获取所有文本
print(p.string) # None ← 包含多个子节点
print(p.b.string) # 'bold' ← 单个文本子节点
# —— 替换 NavigableString ——
first_text = p.contents[0] # NavigableString('Original ')
first_text.replace_with("Modified ") # 原地替换
print(p) # <p>Modified <b>bold</b> text</p>
# —— 替换整个标签内容 ——
p.b.string.replace_with("emphasized")
print(p) # <p>Modified <b>emphasized</b> text</p>
# —— 用 Tag 替换文本 ——
new_span = soup.new_tag('span')
new_span.string = "replacement"
last_text = p.contents[-1] # NavigableString(' text')
last_text.replace_with(new_span)
print(p) # <p>Modified <b>emphasized</b><span>replacement</span></p>
# —— 重要:str() 转换避免内存泄露 ——
text = p.b.string # NavigableString
plain_str = str(text) # 普通 Python str
# 如果 text 被长期引用,整个 BeautifulSoup 树都不会被 GC 回收!
HTML 注释是隐藏信息的重要来源(条件注释、TODO 标记、SEO 信息),但容易与普通文本混淆:
from bs4 import BeautifulSoup, Comment
markup = '''
<html>
<body>
<!-- TODO: fix this section -->
<p>Visible text</p>
<!--[if IE]>Special content<![endif]-->
<!-- Copyright 2024 -->
</body>
</html>
'''
soup = BeautifulSoup(markup, 'html.parser')
# —— 方法1:用 isinstance 检查 ——
for element in soup.descendants:
if isinstance(element, Comment):
print(f"注释: {element}")
# 输出:
# 注释: TODO: fix this section
# 注释: [if IE]>Special content<![endif]
# 注释: Copyright 2024
# —— 方法2:用 find_all 按类型过滤 ——
comments = soup.find_all(string=lambda text: isinstance(text, Comment))
for c in comments:
print(c.strip())
# —— 方法3:提取后转为 Python str ——
comment_texts = [str(c) for c in comments]
print(comment_texts)
# [' TODO: fix this section ', '[if IE]>Special content<![endif]', ' Copyright 2024 ']
# —— 注意!Comment 作为文本被遍历时不含标记 ——
print(soup.body.contents[0]) # ' TODO: fix this section '(不含 <!-- -->)
print(soup.prettify()) # 但 prettify 输出时自带 <!-- --> 格式
from bs4 import BeautifulSoup
# —— 创建文档 ——
doc = BeautifulSoup("<document><content/>INSERT FOOTER HERE</document>", "xml")
# —— 文档根属性 ——
print(doc.name) # '[document]'
print(doc.attrs) # {}
print(type(doc)) # <class 'bs4.BeautifulSoup'>
# —— 像 Tag 一样使用 ——
content = doc.find("content")
print(content) # <content/>
# —— 文档间元素迁移 ——
footer = BeautifulSoup("<footer>Here's the footer</footer>", "xml")
placeholder = doc.find(text="INSERT FOOTER HERE")
placeholder.replace_with(footer)
print(doc)
# <?xml version="1.0" encoding="utf-8"?>
# <document><content/><footer>Here's the footer</footer></document>
# —— 文档自身也可被替换 ——
# 因为 BeautifulSoup 继承自 Tag,它也有 replace_with()
multi_valued_attributes 字典格式说明:
# 格式:{tag_name: attribute_name} 或 {'*': attribute_name}
# '*' 表示匹配所有标签
# 示例1:仅 class 为多值
{'*': 'class'}
# 示例2:rel 和 class 为多值
{'*': ['rel', 'class']}
# 示例3:仅 a 标签的 rel 为多值
{'a': 'rel'}
# 默认值(来自 builder_registry.lookup('html').DEFAULT_CDATA_LIST_ATTRIBUTES):
# 等价于 {'*': ['class', 'rel', 'rev', 'accept-charset', 'headers', 'accesskey']}
场景: 从旧网站抓取的 HTML 内容中,需要将所有链接从 http://oldsite.com 重写为 https://newsite.com,同时给所有外部链接添加 target="_blank" 和 rel="nofollow noopener"。
from bs4 import BeautifulSoup
from urllib.parse import urlparse
def rewrite_html_links(html_content, old_domain, new_domain):
"""
批量修改 HTML 中的链接属性和目标域。
功能:
1. 重写 href/src 中的域名
2. 外部链接添加 target='_blank' 和 rel='nofollow noopener'
3. 移除废弃的 id 属性
4. 返回修改后的 HTML 字符串
"""
soup = BeautifulSoup(html_content, 'html.parser')
# 处理所有带 href 的标签
for tag in soup.find_all(href=True):
old_href = tag['href']
# 域名重写
if old_domain in old_href:
new_href = old_href.replace(old_domain, new_domain)
# 同时将 HTTP 升级为 HTTPS
new_href = new_href.replace('http://', 'https://')
tag['href'] = new_href
# 判断是否为外部链接
parsed = urlparse(tag.get('href', ''))
if parsed.netloc and parsed.netloc != urlparse(new_domain).netloc:
tag['target'] = '_blank'
# rel 是多值属性,需用列表操作
tag['rel'] = ['nofollow', 'noopener']
# 处理所有带 src 的标签(图片、脚本等)
for tag in soup.find_all(src=True):
if old_domain in tag['src']:
tag['src'] = tag['src'].replace(old_domain, new_domain)
tag['src'] = tag['src'].replace('http://', 'https://')
# 移除所有废弃的 id(带 'old-' 前缀)
for tag in soup.find_all(id=True):
if tag['id'].startswith('old-'):
del tag['id']
return str(soup)
# ===== 测试 =====
test_html = '''
<html>
<body>
<a href="http://oldsite.com/about" id="old-about-link">About</a>
<a href="http://oldsite.com/contact">Contact</a>
<a href="https://external.com/page">External</a>
<img src="http://oldsite.com/logo.png" id="old-logo">
</body>
</html>
'''
result = rewrite_html_links(test_html, 'oldsite.com', 'newsite.com')
print(result)
输出:
<html><body>
<a href="https://newsite.com/about">About</a>
<a href="https://newsite.com/contact">Contact</a>
<a href="https://external.com/page" rel="nofollow noopener" target="_blank">External</a>
<img src="https://newsite.com/logo.png"/>
</body></html>
关键技巧回顾:
使用 tag.find_all(href=True) 只遍历有 href 属性的标签
rel 是多值属性,赋值时可用列表 ['nofollow', 'noopener'],序列化时自动合并
用 del tag['id'] 删除属性而非赋空值(保留 id="" 不如直接删除)
使用 urlparse 判断内外链,避免硬编码字符串匹配
场景: 爬取的网页中,注释常包含 TODO 信息、作者标注、SEO 关键词等有价值的内容。需要一个工具自动提取、分类并统计。
import re
from bs4 import BeautifulSoup, Comment
from collections import defaultdict
class CommentExtractor:
"""
智能 HTML 注释提取与分析器。
功能:
- 提取所有注释并分类(TODO/条件注释/版权/SEO/其他)
- 统计注释数量和分布
- 输出结构化报告
"""
PATTERNS = {
'TODO/FIXME': re.compile(r'(TODO|FIXME|HACK|XXX|NOTE)', re.IGNORECASE),
'条件注释': re.compile(r'\[if\s+(IE|lt|gt|lte|gte)', re.IGNORECASE),
'版权声明': re.compile(r'(Copyright|©|©|All rights reserved)', re.IGNORECASE),
'SEO关键词': re.compile(r'^\s*[\w\s,]{10,}\s*$'), # 纯关键词长文本
'模板标记': re.compile(r'\{%.*?%\}|\{\{.*?\}\}'), # Jinja/Twig 模板语法
}
def __init__(self, html_content):
self.soup = BeautifulSoup(html_content, 'html.parser')
self.comments = []
self.categories = defaultdict(list)
self._extract_and_classify()
def _extract_and_classify(self):
"""提取所有 Comment 并分类"""
for element in self.soup.descendants:
if isinstance(element, Comment):
text = str(element).strip()
if not text:
continue
comment_info = {
'text': text,
'parent_tag': element.parent.name if element.parent else None,
'position': self._get_position(element),
'category': '未分类'
}
# 分类匹配
for category, pattern in self.PATTERNS.items():
if pattern.search(text):
comment_info['category'] = category
break
self.comments.append(comment_info)
self.categories[comment_info['category']].append(comment_info)
def _get_position(self, element):
"""获取注释在当前父标签的子节点中的位置"""
parent = element.parent
if parent:
try:
return parent.contents.index(element)
except ValueError:
return -1
return -1
def generate_report(self):
"""生成结构化分析报告"""
total = len(self.comments)
header = f"""
╔══════════════════════════════════════════════════════════╗
║ HTML 注释提取分析报告 ║
╠══════════════════════════════════════════════════════════╣
║ 总注释数: {total:<45} ║
╚══════════════════════════════════════════════════════════╝
"""
sections = [header]
for category, items in sorted(self.categories.items()):
pct = len(items) / total * 100 if total > 0 else 0
sections.append(f"\n{'='*60}")
sections.append(f" 📌 {category} ({len(items)} 条, {pct:.1f}%)")
sections.append(f"{'='*60}")
for i, item in enumerate(items[:5], 1): # 每类最多展示5条
preview = item['text'][:80] + ('...' if len(item['text']) > 80 else '')
sections.append(f" {i}. [{item['parent_tag']}] {preview}")
if len(items) > 5:
sections.append(f" ... 还有 {len(items) - 5} 条省略")
# 按父标签统计
tag_stats = defaultdict(int)
for c in self.comments:
tag_stats[c['parent_tag']] += 1
sections.append(f"\n{'='*60}")
sections.append(f" 📊 按父标签分布")
sections.append(f"{'='*60}")
for tag, count in sorted(tag_stats.items(), key=lambda x: -x[1]):
sections.append(f" <{tag}>: {count} 条")
return '\n'.join(sections)
def get_todos(self):
"""获取所有 TODO 类注释"""
return self.categories.get('TODO/FIXME', [])
def get_comments_by_parent(self, tag_name):
"""按父标签名筛选注释"""
return [c for c in self.comments if c['parent_tag'] == tag_name]
# ===== 测试 =====
test_html = '''
<html>
<head>
<!-- SEO: beautifulsoup, python, html parser, web scraping -->
<title>Test Page</title>
<!-- TODO: Add meta description -->
</head>
<body>
<!-- Copyright 2024 Test Corp. All rights reserved. -->
<div class="main">
<!-- FIXME: This layout breaks on mobile -->
<p>Content here</p>
<!-- HACK: Temporary workaround for IE -->
<!--[if lt IE 9]>
<script src="html5shiv.js"></script>
<![endif]-->
</div>
<footer>
<!-- TODO: Add social media links -->
<!-- Template marker: {% include 'footer.html' %} -->
</footer>
<!-- Just a regular comment -->
</body>
</html>
'''
extractor = CommentExtractor(test_html)
print(extractor.generate_report())
print(f"\n>>> TODO 项: {len(extractor.get_todos())} 条")
print(f">>> footer 中的注释: {len(extractor.get_comments_by_parent('footer'))} 条")
运行输出示例:
╔══════════════════════════════════════════════════════════╗
║ HTML 注释提取分析报告 ║
╠══════════════════════════════════════════════════════════╣
║ 总注释数: 9 ║
╚══════════════════════════════════════════════════════════╝
============================================================
📌 TODO/FIXME (4 条, 44.4%)
============================================================
1. [head] TODO: Add meta description
2. [div] FIXME: This layout breaks on mobile
3. [div] HACK: Temporary workaround for IE
4. [footer] TODO: Add social media links
...
>>> TODO 项: 4 条
>>> footer 中的注释: 2 条
设计要点:
通过 isinstance(element, Comment) 精确识别注释,而非依赖正则匹配 <!--.*-->
调用 str(comment) 获取纯文本内容(不含注释标记)
利用 element.parent 追溯注释的上下文标签,提供定位信息
使用 soup.descendants 全树遍历确保覆盖深层嵌套的注释
分类系统可扩展:添加新的正则模式即可支持新类别
症状: 程序处理大量 HTML 后内存持续增长,即使已经结束 Beautiful Soup 的使用。
原因: NavigableString 对象持有对整个 BeautifulSoup 解析树的引用。如果你长期引用一个 NavigableString,整个解析树都无法被垃圾回收。
# ❌ 错误做法
soup = BeautifulSoup(large_html, 'html.parser')
all_texts = []
for tag in soup.find_all('p'):
all_texts.append(tag.string) # tag.string 是 NavigableString
# 即使 soup 已经不用了,all_texts 中的 NavigableString 仍然持有整棵树!
# ✅ 正确做法
soup = BeautifulSoup(large_html, 'html.parser')
all_texts = []
for tag in soup.find_all('p'):
if tag.string:
all_texts.append(str(tag.string)) # 转换为普通 Python str
# 现在 soup 可以正常被 GC 回收
诊断方法:
import sys
text = soup.p.string # NavigableString
print(sys.getrefcount(text)) # 查看引用计数
plain = str(text) # 转为 str
print(sys.getrefcount(plain)) # 引用计数独立
症状: 对同一个标签,tag['class'] 返回列表,tag['id'] 返回字符串。代码中未做类型判断导致 AttributeError。
# ❌ 错误——假设所有属性都是字符串
soup = BeautifulSoup('<div class="a b" id="main"></div>', 'html.parser')
div = soup.div
div['class'].upper() # AttributeError: 'list' object has no attribute 'upper'
div['id'].upper() # 'MAIN' — 正常工作
# ✅ 方案1:始终使用 get_attribute_list() 获得一致的类型
for attr_name in ['class', 'id', 'style']:
values = div.get_attribute_list(attr_name) # 始终返回 list
print(f"{attr_name}: {values}")
# ✅ 方案2:显式类型判断
attr_value = div.get('class')
if isinstance(attr_value, list):
print(' '.join(attr_value)) # 合并输出
else:
print(attr_value)
# ✅ 方案3:使用 multi_valued_attributes=None 强制字符串
soup2 = BeautifulSoup('<div class="a b" id="main"></div>', 'html.parser',
multi_valued_attributes=None)
div2 = soup2.div
print(div2['class']) # 'a b' ← 始终是字符串
症状: 用 XML 解析器解析 HTML 后,tag['class'] 返回字符串而非列表,导致依赖列表 API 的代码崩溃。
# ❌ 意外行为
soup = BeautifulSoup('<div class="a b c"></div>', 'xml')
div = soup.div
print(div['class']) # 'a b c' ← 字符串!
'c' in div['class'] # True(但含义不同,是子串匹配而非列表成员)
# ✅ 解决方案:手动启用多值
class_is_multi = {'*': 'class'}
soup2 = BeautifulSoup('<div class="a b c"></div>', 'xml',
multi_valued_attributes=class_is_multi)
div2 = soup2.div
print(div2['class']) # ['a', 'b', 'c'] ← 列表!
'c' in div2['class'] # True ← 正确的列表成员检测
# ✅ 或者直接使用 HTML 解析器
soup3 = BeautifulSoup('<div class="a b c"></div>', 'html.parser')
soup3.div['class'] # ['a', 'b', 'c']
根本原因: XML 规范中属性就是纯字符串,没有"多值属性"概念。BS 在 XML 模式下尊重这一设计。如果你需要解析的实际上是 HTML(即使命名空间看起来像 XML),使用 'html.parser' 或 'lxml'。
症状: 代码因 KeyError 意外崩溃,因为你用了 tag['attr'] 访问可能不存在的属性。
soup = BeautifulSoup('<a>No href here</a>', 'html.parser')
a = soup.a
# ❌ 直接崩溃
href = a['href'] # KeyError: 'href'
# ✅ 安全返回 None
href = a.get('href') # None
# ✅ 带默认值
href = a.get('href', '#') # '#'
# ⚠️ 注意:.get() 返回的可能是空字符串,不是 None
soup2 = BeautifulSoup('<a href="">Empty href</a>', 'html.parser')
a2 = soup2.a
print(a2.get('href')) # ''(空字符串,不是 None)
print(a2.get('href') is None) # False
# ✅ 更健壮的判断
href = a2.get('href')
if href: # 同时排除 None 和空字符串
print(f"有效链接: {href}")
最佳实践: 永远用 .get() 代替 tag['key'] 访问可能存在或不存在的属性。.get() 返回 None 而不是抛异常,让代码更健壮。
症状: 遍历文档时,注释内容意外出现在提取的文本中。
html = '''
<div>
<!-- 这条注释不应被提取 -->
<p>Real content</p>
<!--[if IE]>IE only<![endif]-->
</div>
'''
soup = BeautifulSoup(html, 'html.parser')
# ❌ .get_text() 会包含注释内容
print(soup.get_text())
# 输出包含:
# 这条注释不应被提取
# Real content
# [if IE]>IE only<![endif]
# ❌ .strings 也会遍历到 Comment
for s in soup.strings:
print(repr(s))
# ' 这条注释不应被提取 ' ← Comment 类型,不是普通文本!
# 'Real content'
# ✅ 正确过滤
from bs4 import Comment
# 方法1:用 get_text 的类型参数
print(soup.get_text(strip=True, types=[NavigableString]))
# 注意:get_text() 没有直接的 types 参数,此处为示意
# 方法2:手动过滤
texts = [str(s) for s in soup.strings if not isinstance(s, Comment)]
print(texts)
# 方法3:用 stripped_strings 也会遇到同样问题
# 需要自行过滤
from bs4 import NavigableString
texts2 = [s for s in soup.stripped_strings if not isinstance(s, Comment)]
print(texts2) # ['Real content']
说明: .get_text()、.strings、.stripped_strings 都会遍历 Comment,因为它们都是 NavigableString 的子类。需要手动用 isinstance(x, Comment) 过滤。
症状: 对字符串对象调用 .find() 不会报错……但也不会找到任何内容。
soup = BeautifulSoup('<p>Hello <b>World</b></p>', 'html.parser')
text = soup.p.contents[0] # NavigableString('Hello ')
# ❌ 不会报错,但永远找不到
result = text.find('b') # 实际上调用的是 Python str.find()!
print(result) # -1(str.find 的返回值)
# 为什么?因为 NavigableString 继承自 str,
# str.find(sub) 是字符串查找,返回索引或 -1
# ✅ 正确做法:在父标签上搜索
p = soup.p
bold = p.find('b') # <b>World</b>
print(bold)
诊断口诀:
NavigableString.find() 实际调用的是 str.find()(查找子字符串位置)
Tag 的 .find() 才是 Beautiful Soup 的搜索方法
字符串没有 .find_all()、.contents、.string——调用会抛出 AttributeError
症状: 赋值时传入单个字符串,期望覆盖整个属性值,但 Beautiful Soup 会自动转为列表。
soup = BeautifulSoup('<p class="a b c"></p>', 'html.parser')
p = soup.p
# ⚠️ 单字符串赋值,自动转为列表
p['class'] = 'single'
print(p['class']) # ['single'] ← 不是 'single'!
print(type(p['class'])) # <class 'list'>
# ✅ 显式赋列表
p['class'] = ['single']
print(p['class']) # ['single']
# ⚠️ 赋空字符串
p['class'] = ''
print(p['class']) # [''] ← 含有一个空字符串的列表!
# ✅ 删除属性而非赋空值
del p['class']
print(p.get('class')) # None
print(p) # <p></p> ← 属性完全消失
# ⚠️ 对非多值属性赋列表
p['id'] = ['main', 'content']
print(p['id']) # ['main', 'content'] ← 列表!
print(p) # <p id="main content"></p> ← 序列化时合并
规则总结:
多值属性赋值字符串 → 自动包装为 [string]
非多值属性赋列表 → 保留为列表,序列化时用空格连接
如果要"清空"属性值 → 使用 del tag['attr'] 而非赋空值
属性值为 '' 时标签序列化为 attr="",删除属性则完全不出现
症状: 用 soup.name 比对标签名时代码异常。
soup = BeautifulSoup('<html><body><p>Hello</p></body></html>', 'html.parser')
# ⚠️ soup.name 不是 'html'
print(soup.name) # '[document]'
# ✅ 实际根元素是 soup.contents[0]
root = soup.contents[0]
print(root.name) # 'html'
# ⚠️ soup.find('html') 能找到 html 标签
print(soup.find('html')) # <html>...</html>
# 但不能用 soup.html 获取(因为没有名为 html 的属性是该 Tag 的直接子节点)
# 实际上 soup.html 在大多数解析器中能用,因为 Document 树结构是 soup → html → head/body
# ✅ 通用的文档根获取方式
if soup.name == '[document]':
actual_root = soup.find('html') or soup.find(True) # 第一个标签
Q1(基础) 给定 <a href="/page" class="nav active" id="link1">Home</a>,用代码实现:
读取 href 属性值
判断 class 列表中是否包含 "active"
将 href 改为 "/new-page",并添加 rel="nofollow" 属性
Q2(基础) NavigableString 和 Python 内置 str 之间有什么关系?为什么要调用 str(navigable_string) 转换?
Q3(中级) 以下代码的输出是什么?解释原因。
soup = BeautifulSoup('<p>Hello<b>World</b></p>', 'html.parser')
print(soup.p.string)
Q4(中级) 用两种不同方式实现:从 HTML 文档中提取所有不以 <!-- 开头的文本节点(即排除注释)。
Q5(中级) 以下代码中,tag['class'] 的返回值类型是什么?为什么?
soup1 = BeautifulSoup('<p class="a b"></p>', 'html.parser')
soup2 = BeautifulSoup('<p class="a b"></p>', 'xml')
soup3 = BeautifulSoup('<p class="a b"></p>', 'html.parser', multi_valued_attributes=None)
print(type(soup1.p['class']))
print(type(soup2.p['class']))
print(type(soup3.p['class']))
Q6(中级) 编写一个函数 swap_tags(html, old_tag, new_tag),将 HTML 中所有指定标签的名称替换为另一个(例如将所有 <b> 替换为 <strong>,但保留内容和属性不变)。
Q7(高级) 设计一个 HTMLSanitizer 类,实现以下功能:
移除所有 <script> 和 <style> 标签及其内容
移除所有 HTML 注释
移除所有标签的 onclick、onerror 等事件属性(以 on 开头的属性)
保留其他所有内容和结构
Q8(高级) 解释为什么以下代码可能导致内存泄露,并修正:
def extract_titles(html_pages):
all_titles = []
for page in html_pages:
soup = BeautifulSoup(page, 'html.parser')
all_titles.append(soup.title.string)
return all_titles
Q9(高级) 编写代码检测一个 BeautifulSoup 解析树中是否存在循环引用(例如某个标签的 parent 指向自身)。Beautiful Soup 是否有可能出现这种情况?解释你的判断依据。
A1:
soup = BeautifulSoup('<a href="/page" class="nav active" id="link1">Home</a>', 'html.parser')
a = soup.a
# 读取 href
print(a['href']) # '/page'
print(a.get('href')) # '/page'(更安全)
# 判断 class 中是否有 'active'
print('active' in a.get('class', [])) # True
# 修改 href + 添加 rel
a['href'] = '/new-page'
a['rel'] = ['nofollow']
print(a) # <a class="nav active" href="/new-page" id="link1" rel="nofollow">Home</a>
A2: NavigableString 继承自 Python 内置 str,所以它可以像普通字符串一样使用(比较、切片、格式化等)。但它同时继承了 PageElement,持有对整个解析树的引用。如果长期保留 NavigableString 而不转为 str,解析树将无法被垃圾回收,导致内存泄露。因此,当不再需要树导航功能时,应调用 str(navigable_string) 将其转为普通 Python 字符串。
A3: 输出 None。因为 <p> 标签内有两个子节点:NavigableString('Hello') 和 Tag('b')。.string 仅在标签恰好只有一个 NavigableString 子节点时返回该字符串,否则返回 None。
A4:
from bs4 import BeautifulSoup, Comment
def get_non_comment_texts(html):
soup = BeautifulSoup(html, 'html.parser')
# 方法1:用 .strings + 类型过滤
texts1 = [s for s in soup.strings if not isinstance(s, Comment)]
# 方法2:用 find_all + string 参数
texts2 = soup.find_all(string=lambda s: not isinstance(s, Comment))
return texts1
# 测试
html = '<div>Hello<!--world--><p>Content</p><!--end--></div>'
print(get_non_comment_texts(html))
# ['Hello', 'Content']
A5:
# soup1: <class 'list'> — HTML 解析器默认将 class 视为多值属性
# soup2: <class 'str'> — XML 解析器不识别任何多值属性
# soup3: <class 'str'> — multi_valued_attributes=None 禁用所有多值处理
A6:
def swap_tags(html, old_tag, new_tag):
soup = BeautifulSoup(html, 'html.parser')
for tag in soup.find_all(old_tag):
tag.name = new_tag # 直接修改 .name 属性即可
return str(soup)
# 测试
html = '<p>This is <b>bold</b> and <b>important</b></p>'
print(swap_tags(html, 'b', 'strong'))
# <p>This is <strong>bold</strong> and <strong>important</strong></p>
A7:
from bs4 import BeautifulSoup, Comment, Stylesheet, Script
class HTMLSanitizer:
def __init__(self, html):
self.soup = BeautifulSoup(html, 'html.parser')
def sanitize(self):
# 1. 移除 script 和 style 标签
for tag in self.soup.find_all(['script', 'style']):
tag.decompose() # 彻底销毁(含内容)
# 2. 移除所有注释
for element in self.soup.descendants:
if isinstance(element, Comment):
element.extract()
# 3. 移除事件属性(以 'on' 开头)
for tag in self.soup.find_all(True): # True 匹配所有标签
for attr in list(tag.attrs.keys()):
if attr.startswith('on'):
del tag[attr]
return str(self.soup)
# 测试
html = '''
<html>
<head><script>alert('xss')</script><style>body{}</style></head>
<body onclick="malicious()">
<!-- hidden comment -->
<p onmouseover="bad()">Safe content</p>
</body>
</html>
'''
sanitizer = HTMLSanitizer(html)
print(sanitizer.sanitize())
A8: soup.title.string 返回的是 NavigableString 对象,它持有对整个 soup 解析树的引用。all_titles 列表保留了所有这些 NavigableString,导致每个页面的完整解析树都无法被垃圾回收。修正:
def extract_titles(html_pages):
all_titles = []
for page in html_pages:
soup = BeautifulSoup(page, 'html.parser')
if soup.title and soup.title.string:
all_titles.append(str(soup.title.string)) # ← 转为 str
return all_titles
A9: Beautiful Soup 在正常情况下不会产生循环引用。解析树是严格的树结构(有向无环图),.parent 始终指向直接父节点,不会指向自身。但可以通过手动赋值制造循环:
soup = BeautifulSoup('<div><p>text</p></div>', 'html.parser')
div = soup.div
p = soup.p
# 人为制造循环(不推荐!)
# 这实际上不会生效,因为 BS 的 parent setter 有保护机制
检测函数:
def detect_cycle(root, visited=None):
if visited is None:
visited = set()
if id(root) in visited:
return True
visited.add(id(root))
if hasattr(root, 'contents'):
for child in root.contents:
if detect_cycle(child, visited):
return True
return False
在实践中,Beautiful Soup 的 parent setter 会阻止形成循环,且 extract() 等方法能正确切断引用链。只要不手动修改内部属性,不会遇到循环引用问题。
章节总结: 本章深入剖析了 Beautiful Soup 的四类核心对象——Tag(标签)、NavigableString(文本)、BeautifulSoup(文档根)和 Comment(注释)——以及从 NavigableString 派生的八种特殊字符串子类。掌握了这些对象的属性系统、互操作方式和常见陷阱,你就为后续的树导航和树搜索打下了坚实的基础。下一章将深入探索如何在这棵对象树中自由穿行。