Python 中的 BeautifulSoup 是大多数人在需要从杂乱的 HTML 文档中提取结构化数据时首先想到的库。它将原始页面转化为可导航的 Python 对象树,然后提供一套简洁易读的 API,用于查找你关心的元素并读取其文本或属性。你不需要学习查询语言或编写解析器;只需用标签名、属性或 CSS 选择器描述你想要的内容,BeautifulSoup 就会将其返回给你。

本指南是对该 API 的实践导览。我们将安装 BeautifulSoup 并配置一个高性能解析器,从示例标记构建一个 soup 对象,然后逐步讲解 findfind_all、CSS 选择器方法 selectselect_one、通过父节点和兄弟节点遍历树结构,以及读取文本与属性。最后,我们用一个真实的示例完成整个流程,提取一组记录并处理分页。整个过程中始终要牢记一点:BeautifulSoup 只负责解析。它不会获取 URL 或执行 JavaScript,因此你传入它的 HTML 必须已经包含你想要的数据。

BeautifulSoup 能做什么,不能做什么

BeautifulSoup 是一个解析库。你将 HTML 或 XML 字符串传给它,它构建一棵可供搜索和导航的树。这就是它的全部职责。它不建立网络连接,不执行脚本,也不知道浏览器会渲染出什么。你提取的所有内容必须存在于你传入的标记中。

这个边界很重要,因为爬取的两个半段是相互独立的关注点。获取页面是一个问题,解析它是另一个问题。对于静态页面,你可以将 BeautifulSoup 与 requests 库配合使用来获取 HTML。对于通过 JavaScript 在客户端构建内容的页面,普通的请求会返回一个近乎空白的外壳,BeautifulSoup 什么都找不到。这种情况我们稍后再讲。现在,把 BeautifulSoup 看作流水线中的解析半段,其他什么都不是。

安装 BeautifulSoup 和解析器

BeautifulSoup 本身包含在 beautifulsoup4 包中。它还需要一个解析器来完成读取 HTML 的实际工作。标准库包含 html.parser,无需额外依赖,对大多数任务来说已经足够。为了速度和对残缺标记的容忍度,还可以安装 lxml 并将其用作解析器。

bash
python -m venv bs_env
source bs_env/bin/activate

pip install beautifulsoup4 lxml requests

在 Windows 上,用 bs_env\Scripts\activate 替换 source 那行来激活环境。requests 的安装是可选的;我们只在实际示例中用它来获取静态页面。一切就绪后,你从 bs4 导入该类,而非从与库同名的包导入。

创建 soup 对象

构建 soup 对象需要两个参数:标记和解析器名称。为了在不请求真实网站的情况下跟随操作,先从内联 HTML 字符串开始,使输入可预测。

python
from bs4 import BeautifulSoup

html = """
<html>
  <body>
    <h1 id="title">Books</h1>
    <ul class="catalog">
      <li class="book"><a href="/b/1">Dune</a><span class="price">12.99</span></li>
      <li class="book"><a href="/b/2">Neuromancer</a><span class="price">9.50</span></li>
    </ul>
  </body>
</html>
"""

soup = BeautifulSoup(html, "lxml")
print(soup.title)  # None here; no <title> in the markup
print(soup.h1.get_text())  # Books

如果没有安装 lxml,将 "lxml" 替换为 "html.parser";其余 API 完全相同。通过名称访问标签(如 soup.h1)是返回第一个匹配元素的快捷方式,适合快速检查,但功能有限,真正的搜索通过下面的方法进行。

有意识地选择解析器

你选择的解析器会影响残缺 HTML 的修复方式。html.parser 内置且无依赖。lxml 更快,对格式错误的页面更宽容,而现实中的大多数页面都存在格式问题。html5lib 与浏览器完全一致地解析,但速度较慢。当两个解析器对一个棘手页面产生不同结果时,这通常就是原因,所以要显式指定解析器而非让 BeautifulSoup 猜测。

find 与 find_all

两个主力方法是 findfind_allfind 返回第一个匹配的元素,若无匹配则返回 Nonefind_all 返回所有匹配元素的列表,若无匹配则为空列表。两者都接受标签名和可选的过滤条件。

python
first_book = soup.find("li")
print(first_book.a.get_text())  # Dune

all_books = soup.find_all("li")
print(len(all_books))  # 2

for book in all_books:
    print(book.a.get_text())

过滤条件可以缩小搜索范围。你可以按 CSS class、id、任意属性或属性字典进行匹配。由于 class 是 Python 的保留字,BeautifulSoup 使用末尾带下划线的关键字参数 class_

python
# By class
prices = soup.find_all("span", class_="price")

# By id
heading = soup.find(id="title")

# By any attribute, via the attrs dict
links = soup.find_all("a", attrs={"href": True})

# Limit how many you get back
one_link = soup.find_all("a", limit=1)

你也可以传入标签名列表以匹配其中任意一个,或传入编译好的正则表达式按模式匹配标签名或属性值。对于大多数爬取场景,class 和属性过滤器已经足够,而下面介绍的 CSS 选择器方法在嵌套条件下通常更简洁。

使用 CSS 选择器的 select 与 select_one

如果你已经熟悉 CSS 选择器,selectselect_one 让你直接复用这些知识。select 返回所有匹配元素的列表;select_one 返回第一个匹配元素,若无匹配则返回 None。它们接受你在样式表中或传给 document.querySelectorAll 时书写的选择器语法。

python
# Descendant: every <a> inside a .book li
titles = soup.select("li.book a")

# First price under the catalog list
first_price = soup.select_one("ul.catalog .price")

# Attribute selector
internal = soup.select("a[href^='/b/']")

# By id
heading = soup.select_one("#title")

当目标由其在树中的位置定义时,选择器尤为出色,比如"第二个列表项内的链接"。一长串 find 调用的可读性远不如等效的单行选择器。是偏好 find_all 还是 select,大多数时候是个人口味问题;两者对大多数任务可以互换,一个脚本里通常会同时用到两种。关于选择器风格的深入对比,参阅使用 XPath 和 CSS 选择器进行网络爬取

遍历树结构

找到一个元素后,你可以相对于它在树中移动,而不必从顶层重新搜索。每个标签都暴露了其父节点、子节点和兄弟节点,这正是当你想要的数据位于已找到的元素附近时所需要的。

python
price = soup.select_one(".price")

# Up: the <li> that contains this price
row = price.parent

# Down: direct children, ignoring whitespace text nodes
children = [c for c in row.children if c.name]

# Sideways: the <a> just before the price in the same <li>
title_link = price.find_previous_sibling("a")
print(title_link.get_text())  # Dune

几点注意事项可以避免混淆。.children.contents 包含文本节点,例如标签之间的空白,因此按 c.name 过滤只保留真实元素。.find_next_sibling.find_previous_sibling 会自动跳过那些文本节点,并接受标签名进行匹配。使用 .find_parent 可向上走到特定祖先节点,而非仅限于直接父节点。相对导航是处理有用值位于已找到的稳定标签附近的页面最可靠的方式。

获取文本和属性

提取归结为两件事:元素内的文本和属性的值。对于文本,get_text 返回一个元素及其所有后代的字符串内容,拼接在一起。传入 strip=True 可去除周围空白,这几乎是你总想要的。

python
link = soup.select_one("li.book a")

# Text content
print(link.get_text(strip=True))  # Dune

# Attribute by key; raises KeyError if absent
print(link["href"])  # /b/1

# Safe attribute read with a default
print(link.get("title", ""))

用方括号读取属性(如 link["href"])在属性缺失时会抛出 KeyError,因此在属性可能不存在时,应优先使用 link.get("href")。文本与属性的区别常让初学者感到困惑:链接的可见标签来自 get_text,而其目标 URL 来自 href 属性,两者毫无关系。

防范缺失元素

当选择器找不到任何内容时,findselect_one 返回 None,对 None 调用 .get_text() 会抛出 AttributeError。真实页面并不一致:并非每一行都有价格,并非每张卡片都有评分。在读取元素之前检查它是否存在,或封装一个在查找失败时返回 None 的小型辅助函数,这样一个缺失字段就不会导致整个运行崩溃。

实战示例:提取一组记录

现在将这些知识整合到一个专为练习爬取而构建的静态页面上。网站 quotes.toscrape.com 提供纯服务端渲染的 HTML,因此 requests 可以直接获取,BeautifulSoup 可以直接解析。每条引言位于一个 div.quote 块中,包含文本、作者和标签列表,这是真实爬取工作中常见的重复记录的简洁替代。

python
import requests
from bs4 import BeautifulSoup

def parse_quotes(html):
    soup = BeautifulSoup(html, "lxml")
    records = []
    for block in soup.select("div.quote"):
        text_el = block.select_one("span.text")
        author_el = block.select_one("small.author")
        tags = [t.get_text(strip=True) for t in block.select("a.tag")]
        records.append({
            "quote": text_el.get_text(strip=True) if text_el else None,
            "author": author_el.get_text(strip=True) if author_el else None,
            "tags": tags,
        })
    return records

url = "https://quotes.toscrape.com/"
resp = requests.get(url, timeout=15)
if resp.status_code == 200:
    for row in parse_quotes(resp.text):
        print(row)

这里的模式是你可以在各种场景中复用的:用 select 选择重复的容器,然后在每个容器内运行第二层范围限定的查询来提取单个字段。将每个字段的查询限定在 block 而非整个文档范围内,才能避免第二行的作者泄漏到第一行。在调用 get_text 前检查每个元素,意味着缺少作者的引言返回 None 而不是让循环崩溃。

处理分页

单页只是演示;完整的数据集通常跨越多页。练习网站通过 li.next > a 元素链接下一页,因此循环很直观:解析当前页,查找下一页链接,将其相对于基础 URL 解析为绝对路径,链接消失时停止。

python
import time
from urllib.parse import urljoin

base = "https://quotes.toscrape.com/"
next_url = base
all_rows = []

while next_url:
    resp = requests.get(next_url, timeout=15)
    if resp.status_code != 200:
        break

    soup = BeautifulSoup(resp.text, "lxml")
    all_rows.extend(parse_quotes(resp.text))

    next_link = soup.select_one("li.next a")
    next_url = urljoin(base, next_link["href"]) if next_link else None
    time.sleep(1)

print(f"Collected {len(all_rows)} quotes")

两个细节让这段代码更健壮。urljoin 将像 /page/2/ 这样的相对 href 转为完整 URL,无需字符串拼接,即使路径形式改变也能正常工作。time.sleep(1) 将请求分散开来,避免给服务器造成压力,这既是一种礼貌,也是保持在速率限制之下最简单的方式。关于端到端获取和结构化数据的更完整处理,参阅如何使用 Python 爬取网站

BeautifulSoup 不够用时:JavaScript 页面

以上所有内容都假设数据存在于你获取的 HTML 中。很多现代网站并非如此。它们发送一个最小化的 HTML 外壳,并在浏览器中通过 JavaScript 构建真实内容,在页面加载后从后台 API 调用中拉取数据。用 requests 获取这类页面,传给 BeautifulSoup 的主体只有空容器,记录本该在的地方什么都没有。BeautifulSoup 工作正常;数据从来就不在那个字符串里。

你有两种出路。你可以用 Selenium 或 Playwright 这样的工具自己运行真实浏览器,等待内容渲染完成,然后将渲染后的 page_source 传给 BeautifulSoup。这种方法可行,但意味着要运行和维护一个浏览器集群,而且在受保护的网站上还需要管理代理和挑战。另一种方法是将获取和渲染步骤外包给一个返回完整 HTML 的服务,然后用你已经写好的同一份 BeautifulSoup 代码解析该 HTML。无论哪种方式,解析层都不会改变,唯一的区别在于你如何获取 HTML。关于这种分工的更多内容,参阅如何使用 Python 爬取 JavaScript 页面

Crawlbase Crawling API

BeautifulSoup 只负责解析;它无法渲染 JavaScript 页面,也无法帮你突破激进的封锁。Crawling API 替你完成获取和渲染的那一半:将一个 URL 和 JS token 发给它,它在轮换住宅 IP 背后的真实浏览器中运行页面,并返回完整的 HTML。然后你用本指南中完全相同的 BeautifulSoup 代码来解析该 HTML。先在免费套餐上试试。

这是这种配合的形态。获取通过带 JavaScript token 的 Crawling API 进行,返回的主体直接流入你现有的解析器。

python
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"})

response = api.get("https://example.com/spa-page", {"ajax_wait": "true", "page_wait": 4000})

if response["status_code"] == 200:
    html = response["body"].decode("utf-8")
    soup = BeautifulSoup(html, "lxml")
    # same find/select calls as before
    print(soup.select_one("h1").get_text(strip=True))

如果你更愿意将自己的客户端通过轮换 IP 路由,而非调用托管端点,Smart AI Proxy 可以作为直连代理提供住宅轮换;对于预解析的 JSON,Crawling API 可以在不使用任何 BeautifulSoup 的情况下,为受支持的网站返回结构化字段。

回顾

核心要点

  • BeautifulSoup 只负责解析。 它从你已有的 HTML 构建可搜索的树;它不会获取 URL 或执行 JavaScript。
  • 安装 beautifulsoup4 加一个解析器。 使用 html.parser 实现零依赖,或使用 lxml 获得速度和对残缺标记的容忍度,并显式指定解析器。
  • 掌握四个方法。 findfind_all 按标签和过滤条件搜索;selectselect_one 按 CSS 选择器搜索。对大多数任务来说两者可以互换。
  • 分别读取文本和属性。 get_text(strip=True) 给出可见内容;element["href"]element.get("href") 给出属性值。
  • 限定范围、防范空值并处理分页。 选择重复的容器,在其内部查询每个字段,检查 None,并配合 urljoin 和适当的延迟跟随下一页链接。
  • 对于 JavaScript 页面,修复获取环节。 配合 Crawling API 或无头浏览器获取渲染后的 HTML,然后用同样的 BeautifulSoup 代码进行解析。

常见问题

如何在 Python 中安装 BeautifulSoup?

pip install beautifulsoup4 安装。导入名称与包名不同:在代码中写 from bs4 import BeautifulSoup。BeautifulSoup 还需要一个解析器来完成工作。内置的 html.parser 无需额外安装,但用 pip install lxml 安装 lxml 可以获得更快、更宽容的解析器,对真实页面来说很值得。

find 和 find_all 有什么区别?

find 返回符合条件的单个第一个元素,若无匹配则返回 Nonefind_all 返回所有匹配元素的列表,若无匹配则为空列表。当你期望只有一个元素(如页面的主标题)时用 find,当你在收集多个元素(如列表中的每一行)时用 find_all。对应的 CSS 选择器版本是 select_oneselect

如何获取元素内的文本与属性?

使用 element.get_text(strip=True) 获取可见文本内容,包括来自嵌套标签的文本,并去除周围空白。使用 element["href"] 读取属性值,或在属性可能缺失时使用 element.get("href") 安全读取并设置默认值。链接的标签和目标 URL 是分开的:标签是文本,URL 是 href 属性。

为什么 BeautifulSoup 在某些页面上返回空结果?

几乎总是因为你解析的 HTML 中不包含该数据。许多网站通过 JavaScript 在浏览器中渲染内容,因此普通的请求返回空外壳,BeautifulSoup 正确地什么都找不到。BeautifulSoup 不执行 JavaScript。要处理这类页面,需要先获取渲染后的 HTML,可以使用 Selenium 或 Playwright 这样的无头浏览器,或使用 Crawling API,然后用同样的代码解析渲染后的 HTML。

BeautifulSoup 自己能处理分页吗?

不能,因为 BeautifulSoup 不获取页面。你需要用循环处理分页:解析当前页,使用 BeautifulSoup 查找下一页链接,用 HTTP 客户端获取该 URL,然后重复,直到没有下一页链接。用 urllib.parse.urljoin 解析相对链接,并在请求之间添加短暂延迟,避免对服务器造成过大压力。

应该使用 lxml 还是 html.parser 作为解析器?

尽量使用 lxml:它更快,能更优雅地处理格式错误的 HTML,这覆盖了现实世界中的大多数页面。在想要零额外依赖且页面格式正确时使用内置的 html.parser。对于必须像浏览器一样精确解析的标记,html5lib 最为准确,但速度较慢。始终显式传入解析器名称,以保证跨机器行为一致。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量