返回博客

Beautiful Soup 解析:实用技巧与示例

-
目录
-

Beautiful Soup 是一种广泛使用的 Python 库,用于读取、搜索并提取 HTML 和 XML 中的信息,能将杂乱的标记转换为可遍历的树状结构,适用于网络数据抓取、内容分析、测试和数据清理等场景。

它的主要优势在于简单易上手的 API、灵活的搜索方法、对 CSS 选择器的支持,以及可与多种解析器后端配合使用的能力。如果您需要在不手动处理原始标签的情况下采集 HTML 数据,Beautiful Soup 是一个简单易用的入门选择。

安装 Beautiful Soup

在解析 HTML 之前,您需要先安装 Python 和 Beautiful Soup 软件包,打开终端或命令提示符并执行以下命令:

pip install beautifulsoup4

该软件包的名称为 beautifulsoup4,而通常导入的类名为 BeautifulSoup:

from bs4 import BeautifulSoup

本指南中会反复出现 from bs4 import BeautifulSoup 这行代码,因为它是大多数脚本的标准起点。

对于基础用法,Python 内置的 html.parser 已经足够:

from bs4 import BeautifulSoup
html = "<html><body><h1>Hello</h1></body></html>"
soup = BeautifulSoup(html, "html.parser")
print(soup.h1.get_text())

soup 变量即为您的 Beautiful Soup 对象。此后您无需再将页面视为一长串字符串,而是可以搜索其 HTML 结构、在父子节点间移动、读取属性并提取文本。

选择合适的解析器:html.parser 与 lxml、html5lib 的比较

Beautiful Soup 本身并非 HTML 解析器,而是在多种解析库之上提供了一套易用的 API。所选解析器会影响性能、依赖要求以及对标记错误的修正方式,且每种 HTML 解析器的容错规则也略有不同。

使用 lxml:

from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")

使用 html5lib:

from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html5lib")

如需处理 XML,请安装 lxml,并将解析器参数设为 "xml" 或 "lxml-xml"。这一区别十分重要,因为 XML 规则与 HTML 规则不同,尤其是在自闭合标签和大小写敏感性方面。

这几种解析器并无绝对优劣之分:html.parser 使用方便,lxml 在性能方面表现出色,而 html5lib 则擅长按照 HTML5 规范进行容错处理。

关键在于保持一致性:面对损坏的 HTML 标记,不同解析器可能会生成不同的树状结构。如果您的爬虫依赖页面中特定的路径,请显式指定解析器,而非使用默认设置。

解析 HTML 文档

环境配置完成后,整体流程十分简单:输入内容、创建 BeautifulSoup 对象、查找所需部分,并将其转换为可用数据。无论输入来自文件、字符串还是网络上的页面,这一流程都是通用的。

加载 HTML 文件或字符串

我们从一个简单的内存字符串示例开始:

from bs4 import BeautifulSoup
 
html = """
<html>
  <body>
    <h1>Proxy Guide</h1>
    <p class="summary">A short introduction.</p>
  </body>
</html>
"""
 
soup = BeautifulSoup(html, "html.parser")
print(soup.h1.get_text(strip=True))

Beautiful Soup 会读取 HTML 文档并构建树状结构,随后您可以像访问属性一样访问第一个匹配的 HTML 标签,如 soup.h1 所示。

对于本地文件,打开文件并将文件句柄传递给 Beautiful Soup:

from bs4 import BeautifulSoup
 
with open("page.html", "r", encoding="utf-8") as file:
    soup = BeautifulSoup(file, "html.parser")
 
print(soup.title.get_text(strip=True))

当您已下载页面,并希望将采集过程与数据解析分开时,这种方式尤为适用。使用已保存的文件还有助于调试,因为每次运行时输入内容都保持不变。

如需解析 URL,可将 Beautiful Soup 与 Requests 库结合使用:

import requests
from bs4 import BeautifulSoup
 
url = "https://example.com/"
response = requests.get(url, timeout=15)
response.raise_for_status()
 
soup = BeautifulSoup(response.content, "html.parser")
print(soup.title.get_text(strip=True))

Requests 负责获取页面,待响应返回后再由 Beautiful Soup 负责解析 HTML。raise_for_status() 会在 HTTP 响应失败时终止脚本,而 timeout 则可防止请求无限等待网络响应。

需要注意的是,Requests 只会下载服务器返回的内容。如果所需内容是在加载 HTML 文档后由 JavaScript 动态生成的,该内容可能并不会出现在文档本身之中。

这种情况下,建议先分析响应内容,再对选择器是否有效下结论。

遍历解析树

解析 HTML 代码后,您可以通过多种方式遍历生成的树状结构,其中最快捷的是直接访问 HTML 标签:

title = soup.h1
first_paragraph = soup.p

该方法会返回找到的第一个 HTML 标签,适用于已知结构的网页,但大多数爬虫场景需要更精确的查找方式。

.find() 方法会返回第一个匹配结果:

article = soup.find("article")

您还可以按属性进行筛选:

price = soup.find("span", class_="price")
product = soup.find("div", id="product-42")

由于 class 是 Python 的关键字,Beautiful Soup 在方法参数中使用 class_ 来代替。

如需获取所有匹配的元素,可使用 .find_all():

links = soup.find_all("a")
 
for link in links:
    print(link.get_text(strip=True))

find_all() 会返回一个类似列表的结果,您可以对其进行遍历,也可以传入数量限制和属性筛选条件:

cards = soup.find_all("div", class_="product-card", limit=10)

CSS 选择器是另一种实用的方式:使用 .select_one() 获取第一个匹配结果,使用 .select() 获取全部匹配结果:

main_price = soup.select_one("article.product .price")
items = soup.select("ul.results > li.item")

如果您已经通过浏览器开发者工具熟悉了页面的 HTML 结构,使用 CSS 选择器会格外方便:类名以 . 表示,ID 以 # 表示,后代关系的写法也与样式表中的用法类似。

您还可以借助 .parent、.children、.next_sibling、.previous_sibling 等 Beautiful Soup 属性来遍历树状结构,例如:

heading = soup.find("h2")
section = heading.parent
 
for child in section.children:
    print(child)

当目标元素的位置取决于相邻的 HTML 元素,而非唯一的 class 或 ID 时,可以使用这些关系进行定位。

从 HTML 中提取数据

找到目标节点只完成了一半工作,接下来还需要将其转换为干净的 HTML 数据。

如需提取可见文本,可使用 .get_text():

headline = soup.find("h1")
 
if headline:
    text = headline.get_text(" ", strip=True)
    print(text)

分隔符 " " 可以防止嵌套标签中的文本连在一起,而 strip=True 则会去除多余的空白字符。

属性的使用方式类似字典:

link = soup.find("a", class_="download")
 
if link:
    href = link.get("href")
    print(href)

使用 .get() 比直接通过 link["href"] 索引更安全,因为属性缺失时它会返回 None,而不会抛出 KeyError。

您可以将多个字段提取为结构化的解析数据:

cards = soup.select(".product-card")
products = []
 
for card in cards:
    name_tag = card.select_one(".name")
    price_tag = card.select_one(".price")
    link_tag = card.select_one("a")
 
    products.append({
        "name": name_tag.get_text(strip=True) if name_tag else None,
        "price": price_tag.get_text(strip=True) if price_tag else None,
        "url": link_tag.get("href") if link_tag else None,
    })
 
print(products)

由于网站会不断变化,防御性编程十分重要:某个 HTML 标签缺失并不意味着整个程序都要崩溃,应先判断是否存在匹配结果,再调用相应方法。

畸形页面也会带来类似的挑战:Beautiful Soup 会尝试构建可用的树状结构,但最终结果取决于所选的解析器。

如果提取出的 HTML 数据突然出现异常,可以打印或保存树状结构中的相关部分,并与原始响应进行比对,排查此类错误。

实用示例

单独了解每项技巧固然有帮助,但只有将它们结合使用,整个流程才会更加清晰。下面列举几个常见任务:提取商品数据、修复不规范的标记,以及处理表格、图片和列表。

从网页中抓取数据

假设某个页面包含如下重复的商品卡片:

<article class="product">
  <h2 class="name">Residential Proxy Plan</h2>
  <span class="price">$10</span>
  <a href="/plans/residential">View plan</a>
</article>

一个基础的爬虫可以获取页面、定位每张卡片,并收集所需字段:

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
 
url = "https://example.com/products"
response = requests.get(url, timeout=15)
response.raise_for_status()
 
soup = BeautifulSoup(response.content, "lxml")
products = []
 
for card in soup.select("article.product"):
    name = card.select_one(".name")
    price = card.select_one(".price")
    link = card.select_one("a[href]")
 
    products.append({
        "name": name.get_text(strip=True) if name else None,
        "price": price.get_text(strip=True) if price else None,
        "url": urljoin(url, link["href"]) if link else None,
    })
 
for product in products:
    print(product)

这个示例体现了几个良好的习惯:首先,代码在每张商品卡片内部进行查找,而不是反复扫描整个 HTML 文档。

其次,它会妥善处理缺失的部分;第三,urljoin() 会将相对链接转换为绝对 URL。

接下来,您可以将解析得到的数据保存为 CSV、JSON、存入数据库,或传递给其他应用程序。应尽可能将提取逻辑与存储逻辑分开,这样代码会更易于测试和维护。

在实际网站中,选择器可能会发生变化。相比可能频繁改版的大量布局类名,更推荐使用具有明确含义且稳定的属性,例如商品 ID 或 data-* 属性。

处理畸形 HTML

实际页面并非总是规范的,来看下面这段有问题的代码片段:

<div class="card">
  <h2>Example product
  <p>Available now
</div>

其中 <h2> 和 <p> 标签没有正确闭合,但 Beautiful Soup 仍然可以构建出可用的树状结构:

from bs4 import BeautifulSoup
 
broken_html = """
<div class="card">
  <h2>Example product
  <p>Available now
</div>
"""
 
soup = BeautifulSoup(broken_html, "html5lib")
 
print(soup.prettify())
print(soup.find("h2").get_text(" ", strip=True))

在这里,html5lib 会按照 HTML5 规范的解析规则,根据需要插入或调整节点位置;而对于同样的畸形输入,lxml 和 html.parser 可能会生成不同的树状结构。

这并不意味着其中一种结果始终正确、其他都是错的——畸形标记本身就可能存在多种合理的解析方式。

在对准确性要求较高的场景下,建议用具有代表性的页面测试各个解析器,选择生成的 HTML 结构符合您提取逻辑预期的那一种。

提取文本前,您还可以先清除不需要的标签:

for tag in soup(["script", "style", "noscript"]):
    tag.decompose()
 
clean_text = soup.get_text(" ", strip=True)

.decompose() 会将选中的节点从树状结构中移除,这在将页面转换为可读文本,或为后续数据解析准备内容时非常实用。

提取表格、图片和列表

表格数据通常按行列排列,假设您想提取某个价格表中的所有单元格:

table = soup.find("table", id="pricing")
rows = []
 
if table:
    for tr in table.find_all("tr"):
        cells = tr.find_all(["th", "td"])
        row = [cell.get_text(" ", strip=True) for cell in cells]
        if row:
            rows.append(row)
 
print(rows)

这会生成一个嵌套列表。如果第一行是表头,您之后可以将其转换为字典或 pandas DataFrame。

图片信息主要依赖属性获取:

images = []
 
for img in soup.find_all("img"):
    images.append({
        "src": img.get("src"),
        "alt": img.get("alt", ""),
    })

需要注意的是,src 可能是相对路径,懒加载图片通常使用 data-src 等属性,而响应式网站则会使用 srcset,请检查 HTML 标签以确定图片 URL 实际所在的属性。

列表的处理则更加简单:

features = [
    li.get_text(" ", strip=True)
    for li in soup.select("ul.features > li")
]
 
print(features)

这里的核心思路是先在 HTML 代码中找到重复出现的模式,再从中提取字段,这比在整个页面上逐一搜索每个值要高效得多。

进阶解析技巧

熟悉基础用法后,您可以进一步缩小搜索范围,省去不必要的工作。

Beautiful Soup 的进阶用法通常涉及限定搜索范围、组合多个搜索条件,或将解析器妥善集成到请求层中。

处理嵌套结构

嵌套的页面元素可能会导致写出脆弱的 CSS 选择器,例如:

soup.select("body > main > div > div > section > div > article > h2")

这种选择器目前或许有效,但页面布局稍作调整就可能失效。更好的做法是先找到合适的容器元素,再在其内部进行查找:

catalog = soup.find("section", id="catalog")
 
if catalog:
    for card in catalog.select(".product"):
        name = card.select_one(".name")
        if name:
            print(name.get_text(strip=True))

您也可以使用 .find_all() 进行递归搜索,或者在只需要直接子元素时设置 recursive=False:

menu = soup.find("ul", class_="main-menu")
 
if menu:
    top_level_items = menu.find_all("li", recursive=False)

当嵌套列表在多个层级中包含相同的 HTML 元素时,这种方式会很有用。

另一种方式是通过元素间的关系进行移动:

label = soup.find("span", string="Price")
value = label.find_next("span") if label else None

如果页面包含带标签的值,但该值本身没有唯一的类名,就可以采用这种方法。不过在使用关系推理时需要格外谨慎,因为多插入一项元素就可能导致结果完全不同。

在搜索中使用正则表达式

Beautiful Soup 可以将搜索与 Python 的 re 模块结合使用。当标签名、属性或文本遵循某种模式而非单一精确值时,这种方式很有用。

例如,查找从 h1 到 h3 的标题:

import re
 
headings = soup.find_all(re.compile(r"^h[1-3]$"))

查找 URL 以 /products/ 开头的链接:

product_links = soup.find_all(
    "a",
    href=re.compile(r"^/products/")
)

或者匹配文本:

price_text = soup.find_all(
    string=re.compile(r"\$\d+(?:\.\d{2})?")
)

正则表达式最适合用作有针对性的过滤手段,不要试图把整个 HTML 文档都变成一个庞大的正则表达式问题。

Beautiful Soup 本身已经能够理解嵌套标记、属性和树状结构关系,因此仅在真正符合模式特征的部分使用正则表达式即可。

将 Beautiful Soup 与 Requests 结合使用

在实时网络爬取场景中,Requests 与 BeautifulSoup 是一组很好的组合:Requests 负责处理 HTTP 部分,BeautifulSoup 负责处理返回的标记内容。

以下是一种通用模式的示例:

import requests
from bs4 import BeautifulSoup
 
def fetch_soup(url):
    headers = {
        "User-Agent": "ExampleResearchBot/1.0"
    }
    response = requests.get(
        url,
        headers=headers,
        timeout=(5, 20),
    )
    response.raise_for_status()
    return BeautifulSoup(response.content, "lxml")
 
soup = fetch_soup("https://example.com/")
print(soup.title.get_text(strip=True))

通过元组形式的 timeout,可以分别设置连接超时和读取超时的数值。对于较大型的项目,还可以使用 requests.Session() 来复用连接并保留 Cookie。

如果需要在获得许可的情况下发送多次请求,可以在 Requests 模块中通过 proxies 参数使用代理服务器。

例如 MarsProxies 等供应商提供住宅代理及其他类型的代理服务器,可用于 Python 中发起请求。请注意控制请求频率,并遵守目标网站的相关政策。

一个简单的代理设置示例:

proxies = {
    "http": "http://username:password@host:port",
    "https": "http://username:password@host:port",
}
 
response = requests.get(
    "https://example.com/",
    proxies=proxies,
    timeout=15,
)

请求层与解析层应当保持分离。如果服务器返回的是登录页面、CAPTCHA 验证提示、错误响应或 JavaScript 空壳页面,无论如何调整选择器,都无法恢复本就不存在于响应中的 HTML 数据。

最佳实践与优化建议

Beautiful Soup 本身使用简单,但随着脚本逐渐复杂,一些设计上的选择就会变得至关重要。

首先要有意识地选择解析器:对性能要求较高的项目,可以尝试 lxml;如果输入标记格式不规范,可以对比 html5lib 与其他解析器的表现。开发环境与生产环境应使用同一种解析器,以保证结果一致。

然后,缩小搜索范围。找到父容器后,再在其内部使用 .find() 或 .select() 进行进一步搜索。这样在有限的树结构中查找,比每次都遍历整个 HTML 文件更轻松,速度也可能更快。

选择能够经受住改版考验的选择器。相较于与版式设计相关、层层嵌套的复杂选择器,ID、语义化的类名、data-* 属性以及有意义的元素关系通常更加稳健可靠。

此外,除非 HTML 标签的位置本身就代表网页的某个语义部分,否则不应依赖标签的排列位置。

直接处理缺失值。这种做法:

price = card.select_one(".price")
value = price.get_text(strip=True) if price else None

比假设所有卡片信息都完整更为稳妥。您可以选择记录不完整的卡片、跳过它们,或将其设为 None。

排查此类错误时,应查看实际的响应内容。务必保存 response.text,打印一部分 soup.prettify() 的结果,并在修改选择器之前确认目标数据确实存在。问题可能源于类名变更、同意协议页面、HTTP 错误、地区差异,甚至是通过 JavaScript 加载的数据。

对于体积非常大的页面,可以考虑使用 SoupStrainer 只解析所需部分:

from bs4 import BeautifulSoup, SoupStrainer
 
only_articles = SoupStrainer("article")
 
soup = BeautifulSoup(
    html,
    "lxml",
    parse_only=only_articles
)

如果所需信息只集中在网页的某一特定区域,这样做有助于避免对解析过程进行不必要的处理。

尽量让解析流程中的各个阶段保持独立,包括获取源数据、构建树结构、提取字段、规范化处理,以及最终存储解析结果。

同时要注意合规抓取。请务必了解目标网站 robots.txt 文件中关于抓取的说明,以及其服务条款和与您的使用场景相关的任何 API 或数据访问限制。robots.txt 文件只是说明了网站希望被抓取的方式,并不代表完整的法律许可。

如果网站提供合适的 API,通常比解析 HTML 页面更加稳健可靠,因为网页是面向用户设计的,随时可能发生变化,而 API 则是专为软件对接而构建的。

结语

Beautiful Soup 为 Python 开发者提供了一种便捷的方式,可将标记内容转换为可搜索的树状结构,并从中提取有用的片段。

整个流程包括获取或加载 HTML 文件、选择合适的解析器、生成 soup 对象、通过标签搜索或 CSS 选择器查找 HTML 元素,以及对获取的输出结果进行清洗,最终得到结构化数据。

要获得可靠的输出结果,需要关注稳定的选择器、对缺失字段的稳健处理、合适的解析器选型,以及网络请求与数据解析流程之间的合理分离。

尽可能使用正则表达式,将搜索范围限定在特定容器内,并在输出结果异常时检查源响应内容。

更好的做法是在获得许可的情况下,用一个示例项目进行实践:先选定一个页面,挑选若干字段,记录输出结果,然后再逐步处理分页、异常处理、会话以及(如有需要的)代理等更复杂的内容。

一旦掌握了这套工作流程,Beautiful Soup 就能帮助您解决各种 HTML 数据提取问题,而不会让代码变得复杂。

了解更多
-

相关文章