返回博客
指南
Sorin-Gabriel MaricaLast updated on May 12, 20264 min read

BeautifulSoup 教程:从零开始构建真正的 Python 抓取器

BeautifulSoup 教程:从零开始构建真正的 Python 抓取器
简而言之:本篇 BeautifulSoup 教程将带您逐步构建一个完整的 Python 爬虫程序,从 pip install 到一个经过强化处理的脚本,该脚本能够对 Hacker News 进行分页抓取,导出为 CSV 和 JSON 格式,并且保持足够的礼貌以避免被封禁。每个代码片段均可运行,同时我们也会明确指出哪些情况下 BeautifulSoup 并非最佳选择。

如果你会编写 for Python 循环,并且曾盯着网页心想“我想把这些数据导入电子表格”,那么本 BeautifulSoup 教程正是为你量身打造。 Beautiful Soup 是一个 Python 库,用于将 HTML 和 XML 解析为树结构,您可以通过熟悉且类似 jQuery 的方法对其进行查询。它不抓取页面,不运行 JavaScript,也不伪装成浏览器。它只处理原始标记,并提供一个简洁的 API 供您提取所需的内容。

计划很明确。我们将搭建一个全新的环境,使用 requests 库抓取一个真实的列表页面,使用 BeautifulSoup 进行解析,同时利用 find_allCSS 选择器定位元素,跟随分页机制遍历多页,并将结果写入 CSV 和 JSON 文件。在此过程中,我们将融入用户代理轮换、重试和速率限制机制,因为任何忽略反机器人防御措施的教程,一旦应用于真实网站就会立即失效。到最后,您将获得一个可直接复制粘贴运行的爬虫程序,并清楚地了解何时继续使用 BeautifulSoup,何时该升级到更强大的工具。

什么是 BeautifulSoup 以及何时使用它

BeautifulSoup( bs4 PyPI 上的包,目前处于 4.x 系列)是一个解析库,既不是爬虫,也不是浏览器。你向它提供一段 HTML 字符串,它便返回一个解析树,你可以通过标签、属性、CSS 选择器或关系在其中进行导航。这就是它的全部功能。 任何涉及 HTTP 请求、Cookie、会话、JavaScript 执行或队列的内容都属于其他工具的范畴,正是这种职责分离,使得 BeautifulSoup 在发布十多年后,依然是处理静态页面的首选工具。

将其置于一个谱系中会更有助于理解。 requests 此外,BeautifulSoup 拥有最轻量级的配置:当所需数据已包含在服务器返回的 HTML 中,且你只需爬取少量页面而非数百万个页面时,它便是绝佳选择。 当你需要一个具备管道、去重和并发功能的完整爬取框架时,Scrapy 是最佳选择。当页面是仅在 JavaScript 运行后才组装内容的单页应用时,SeleniumPlaywright 才是合适工具。如果你能通过 curl 请求该 URL 并在响应正文中看到数据,BeautifulSoup 几乎总是最简单的解决方案。

环境配置:Python、Requests 和 BeautifulSoup4

请使用虚拟环境,以免本项目污染您的全局 site-packages 目录。Python 3.9 及更高版本均可顺利运行本 BeautifulSoup 教程,固定版本号可确保此处的代码片段可复现。

python -m venv .venv
source .venv/bin/activate   # on Windows: .venv\Scripts\activate
pip install requests==2.32.3 beautifulsoup4==4.12.3 lxml==5.2.2

requests 负责处理 HTTP 层, beautifulsoup4 是解析器 API 本身, lxml 是一个可选但强烈推荐的 C 语言后端解析器。若未安装 BeautifulSoup,系统将回退至标准库的 html.parser (若未安装 lxml,但 C 解析器在处理大型文档时速度显著更快,且对混乱的标记更宽容。若需支持编译 C 扩展较为困难的 Python 环境,请省略 lxml ,虽然会损失一些速度,但功能不受影响。

在 Python REPL 中进行快速测试:

import requests, bs4
print(requests.__version__, bs4.__version__)

如果两个版本都能正常输出且无错误,说明你已准备就绪。将剩余代码保存到名为 hn_scraper.py ,并使用 python hn_scraper.py.

使用 Requests 抓取 HTML

BeautifulSoup 需要字节数据进行解析。 requests 库是获取字节流最便捷的方式。选择一个可以礼貌访问的真实目标:Hacker News 是经典之选,因为其首页是结构可预测的纯服务器渲染 HTML,且反机器人保护非常轻量,非常适合学习。

import requests

URL = "https://news.ycombinator.com/news"
HEADERS = {
    "User-Agent": "Mozilla/5.0 (compatible; LearningScraper/1.0)",
    "Accept-Language": "en-US,en;q=0.9",
}

response = requests.get(URL, headers=HEADERS, timeout=15)
response.raise_for_status()        # blows up on 4xx/5xx
html_bytes = response.content      # bytes, not str

有两点值得特别注意。首先,务必检查状态码。一个返回“访问被拒”页面却无声无息的 403 状态码,会被完美地解析成一个 BeautifulSoup 对象,但其中根本不包含你真正想要的数据,结果你会浪费一个下午的时间,针对错误的页面调试选择器。 raise_for_status() 能让这种错误显而易见。

其次,优先使用 response.content 而非 response.text.text 会强制使用 requests ,而该编码有时并不准确。 .content 返回的是原始字节流,而 BeautifulSoup 更擅长从 <meta charset> tag 或文档本身中嗅探出实际编码。在仅含英语的网站上,这种差异通常无关紧要,但一旦你抓取包含带重音字符的内容,差异就变得至关重要。

创建 BeautifulSoup 对象并选择解析器

获得字节数据后,通过将其传递给 BeautifulSoup 构造函数并指定解析器名称,即可构建解析树。Beautiful Soup 官方文档列出了三个值得了解的解析器。

解析器

速度

对损坏 HTML 的容错性

备注

html.parser

尚可

良好

标准库,无需安装。

lxml

最快

良好

C 扩展; pip install lxml.

html5lib

最慢

最佳

纯 Python;模拟浏览器处理损坏标记的方式。

在本 BeautifulSoup 教程中,我们将使用 lxml ,因为它运行迅速,且如今已广泛内置于各类平台。仅当 html5lib 仅当网站存在真正格式错误的 HTML 时才使用 lxml ,并回退到 html.parser

from bs4 import BeautifulSoup

soup = BeautifulSoup(html_bytes, "lxml")
print(soup.title.string)            # "Hacker News"
print(soup.prettify()[:300])        # peek at the formatted DOM

soup.title.string 之所以可行,是因为 BeautifulSoup 将顶级标签作为属性暴露出来。 get_text(strip=True) 当你无法确定某个标签是包含纯文本还是嵌套子元素时,是更安全的通用替代方案,而 prettify() 在探索过程中非常宝贵,因为它会显示你实际查询的缩进树。

定位元素:find、find_all 和 select

BeautifulSoup 提供了三种定位节点的常用方法: find, find_allselect. find 返回第一个匹配项(或 None). find_all 返回所有匹配项的列表。 select 以及 select_one 使用 CSS 选择器字符串,我们将在下一小节中介绍。

按标签查找。这是最简单的形式。 soup.find_all("a") 返回页面上的所有锚点。

links = soup.find_all("a")
print(len(links), "anchors found")

按类查找。使用关键字 class_ 后跟一个下划线,因为 class 在 Python 中是保留字。这会让几乎所有初学者感到困惑。

rows = soup.find_all("tr", class_="athing")          # Hacker News story rows
titles = soup.find_all("span", class_="titleline")

按 ID 查找。直接传入 id= 。ID 应当是唯一的,因此 find 通常正是你所需要的。

main = soup.find(id="hnmain")

按属性查找。任何任意属性都可以传递到 attrs 字典中。这就是你定位 data-* 属性、 aria-* 属性,或任何非标签、ID 或类的内容。

rows = soup.find_all("tr", attrs={"data-row-type": "story"})

通过可调用对象过滤。当需要关键字无法捕获的逻辑时,请传递一个 lambda 表达式。该函数接收每个标签,并返回 True 以保留该标签。

def is_external_link(tag):
    return tag.name == "a" and tag.get("href", "").startswith("http")

external = soup.find_all(is_external_link)

你还可以将一个 lambda 函数传递给 string 参数,以文本内容进行过滤。不区分大小写的子字符串匹配是一个常见用例:

python_links = soup.find_all("a", string=lambda s: s and "python" in s.lower())

一条实用的经验法则:使用 findfind_all 。一旦需要结合类名、父元素和位置,请切换到 CSS 选择器。它们更易于阅读,也更容易从浏览器开发者工具中复制出来。

使用 select() 和 select_one() 深入解析 CSS 选择器

select() 支持与 document.querySelectorAll中使用的相同 CSS 选择器字符串。这意味着后代组合符、子元素组合符、属性选择器、伪类以及链式类名均可正常使用。

# Descendant: any .titleline inside a tr.athing, at any depth
titles = soup.select("tr.athing .titleline")

# Direct child: only immediate children
direct = soup.select("tr.athing > td.title > span.titleline")

# Attribute selector: links to PDFs
pdfs = soup.select("a[href$='.pdf']")

# Positional: every fifth story row
every_fifth = soup.select("tr.athing:nth-of-type(5n)")

# Multiple classes at once
emphasized = soup.select("span.titleline.featured")

以下是这两个 API 之间的实际映射关系。

find_all form

select form

find_all("a", class_="storylink")

select("a.storylink")

find_all("div", id="main")

select("div#main")

find_all("input", attrs={"type": "hidden"})

select("input[type='hidden']")

在本次 BeautifulSoup 教程中,选择器绝非可有可无的配角,而是核心的维护策略。当标记结构发生变化时,让爬虫程序保持正常运行的诀窍在于:将选择器定义为模块顶部的命名常量。当网站重命名类名时,你只需修改一行代码,而非遍历整个代码库进行搜索。

STORY_ROW = "tr.athing"
TITLE_LINK = "span.titleline > a"
RANK = "span.rank"

建议养成以下习惯:从 Chrome 开发者工具中复制一个有效的选择器(右键点击元素,选择“复制”>“复制选择器”),然后将自动生成的链式选择器精简为最短版本,同时确保它仍能唯一标识目标元素。当标记结构发生变化时,冗长的选择器最先失效;而简短的命名选择器则能经受住小幅改版。

遍历 DOM:父元素、兄弟元素和子元素

有时你能明确识别出的元素并非你真正想要的元素。一个常见场景是:你可以轻松定位一个唯一的 <span class="rank"> ,但标题和链接却位于一个同级节点中。与其编写易出错的复合选择器,不如遍历树结构。

每个 BeautifulSoup 标签都提供了导航属性:

  • .parent: 直接包含该元素的标签。
  • .parents: 一个生成器,返回所有祖先节点直至文档根节点。
  • .next_sibling 以及 .previous_sibling: 同一深度上的相邻节点(可能是空格)。
  • .find_next("tag") 以及 .find_previous("tag"): 跳过空格节点,查找下一个真正的标签。
  • .children 以及 .descendants: 直接子节点或所有嵌套节点。

一个示例。假设你抓取了Hacker News上的所有 .titleline span 标签,并希望针对每个 span,获取其所在的行以及下一行(该行包含评分和作者信息)。

for title_span in soup.select("span.titleline"):
    row = title_span.find_parent("tr")               # the .athing row
    meta_row = row.find_next_sibling("tr")           # the subtext row
    score = meta_row.find("span", class_="score")
    print(title_span.get_text(strip=True), score.get_text() if score else "-")

这本质上是在可读性与健壮性之间的权衡。链式 CSS 选择器更简洁,但当页面根据上下文将相同数据封装在不同容器中时,遍历树结构通常更具鲁棒性。当单个查询无法表达所需的关系时,请采用遍历方法。

端到端项目:抓取 Hacker News 的排名、标题和 URL

是时候不再展示孤立的代码片段,而是构建爬虫的核心了。Hacker News的首页将每条新闻渲染为一个 tr.athing 一行显示,其中排名位于 span.rank中,标题和外部链接位于 span.titleline > a中,而一个同级行则承载着评分和作者信息。我们的任务是将每条新闻转换为一个字典。

这是解析器的第一个版本。请注意它不进行数据抓取;它接受一个 HTML 字符串并返回结构化的记录。将抓取和解析分离,使您能够使用测试用例 HTML 对解析器进行单元测试,而无需访问网络。

from bs4 import BeautifulSoup

def parse_stories(html: bytes) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    stories = []
    for row in soup.select("tr.athing"):
        rank_tag = row.select_one("span.rank")
        link_tag = row.select_one("span.titleline > a")
        if not (rank_tag and link_tag):
            continue                                # skip malformed rows
        stories.append({
            "rank": rank_tag.get_text(strip=True).rstrip("."),
            "title": link_tag.get_text(strip=True),
            "url": link_tag.get("href", ""),
            "id": row.get("id"),
        })
    return stories

以下几个细节看似简单,实则至关重要。 rank_tag.get_text(strip=True).rstrip(".") 处理 Hacker News 在每个排名后显示的尾随句点("1." 变为 "1"). link_tag.get("href", "") 会返回空字符串而非抛出 KeyError ,这种单字符的改动正是将脆弱的爬虫转变为健壮爬虫的关键。而早期的 continue 则能在网站偶尔插入不符合模式的广告行或赞助商占位符时,保持循环的正常运行。

将解析器与抓取器结合:

import requests

def fetch(url: str) -> bytes:
    headers = {"User-Agent": "LearningScraper/1.0"}
    response = requests.get(url, headers=headers, timeout=15)
    response.raise_for_status()
    return response.content

if __name__ == "__main__":
    stories = parse_stories(fetch("https://news.ycombinator.com/news"))
    for story in stories[:5]:
        print(story["rank"], story["title"])

运行此代码应能输出当前页面上排名前五的头条新闻。不到三十行代码,您就拥有了一个可运行的单页爬虫。本 BeautifulSoup 教程的剩余部分将添加分页、导出、重试以及优化措施,使脚本在针对真实网站运行时能坚持一小时而非一分钟。

处理分页与多页爬取

Hacker News 通过查询参数实现分页: ?p=2, ?p=3,以此类推。每页底部都包含一个 <a class="morelink"> 锚点,指向下一页。检测该锚点是最简洁的终止条件,因为无论网站使用顺序分页、游标标记还是偏移量参数,它都能正常工作。

import time
from urllib.parse import urljoin

BASE = "https://news.ycombinator.com/"

def scrape_all(start_url: str, max_pages: int = 5, delay: float = 1.5) -> list[dict]:
    url = start_url
    pages_done = 0
    all_stories: list[dict] = []

    while url and pages_done < max_pages:
        html = fetch(url)
        all_stories.extend(parse_stories(html))

        soup = BeautifulSoup(html, "lxml")
        more = soup.select_one("a.morelink")
        url = urljoin(BASE, more["href"]) if more else None

        pages_done += 1
        time.sleep(delay)
    return all_stories

有三个细节值得特别说明。 urljoin(BASE, more["href"]) 是关于如何将 news?p=2 转换为真正的绝对 URL, requests 需要。 max_pages 上限是一个安全网,以防止存在缺陷的停止条件无限期运行。此外 time.sleep(delay) 是成本最低的速率限制器;等我们讲到防阻塞机制时,会用更智能的方案替换它。

这种分页模式的应用范围远不止 Hacker News。只要下一页在标记中是真正的锚点,你就可以将不同的选择器插入 select_one ,而循环的其余部分保持不变。对于采用无限滚动分页的网站,仅靠 BeautifulSoup 无法解决,我们将在本 BeautifulSoup 教程后面的 JavaScript 章节中探讨这一限制。

将抓取的数据导出为 CSV 和 JSON

一旦获得字典列表,将其导出到磁盘就是机械性的操作。分析师普遍期待的两种格式是 CSV 和 JSON,没有理由不在同一工作流中同时生成这两种格式。

import csv, json
from pathlib import Path

def export(records: list[dict], out_dir: str = "out") -> None:
    out = Path(out_dir)
    out.mkdir(exist_ok=True)

    csv_path = out / "stories.csv"
    with csv_path.open("w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["rank", "title", "url", "id"])
        writer.writeheader()
        writer.writerows(records)

    json_path = out / "stories.json"
    with json_path.open("w", encoding="utf-8") as f:
        json.dump(records, f, ensure_ascii=False, indent=2)

有几个编码方面的注意事项值得特别强调。如果数据将在 Windows 上的 Excel 中打开,请为 CSV 文件使用 encoding="utf-8-sig" ,因为 BOM 会告知 Excel 该文件采用 UTF-8 编码(若缺少 BOM,带重音的字符将显示为乱码)。传递 newline="" ` open ,以避免在 Windows 上生成空行。对于 JSON, ensure_ascii=False 会保留非ASCII字符的原始形式,而非使用 \uXXXX 转义,从而使输出内容更易于人类阅读。

对于习惯在笔记本中工作的分析师, pandas.DataFrame(records).to_csv("stories.csv", index=False) 是单行替代方案。虽然它更耗资源,但当你本就要对同一数据进行探索性分析时,使用起来会很顺手。

常见陷阱:缺失元素、编码问题和 NoneType 错误

在任何 BeautifulSoup 教程代码中,你最常遇到的错误莫过于 AttributeError: 'NoneType' object has no attribute 'get_text'。这通常意味着 findselect_one 返回 None,而你随后试图对其调用方法。解决方法是:在进行方法链操作前务必先进行检查。

# Brittle
title = row.find("span", class_="titleline").a.get_text()

# Defensive
line = row.find("span", class_="titleline")
anchor = line.find("a") if line else None
title = anchor.get_text(strip=True) if anchor else None

以下两个相关习惯能为你节省数小时:

  • 使用 .get(attr, default) 代替 tag[attr]。当属性缺失时,索引会抛出 KeyError 异常,而 .get 则会静默返回默认值并让循环继续。
  • 始终 .get_text(strip=True) 而非 .string. .stringNone ,这使得它出人意料地脆弱。

编码是第二个经典陷阱。如果你向 BeautifulSoup response.text ,而网站在 Content-Type ,就会出现乱码。若传入 response.content (字节数组)则能让 BeautifulSoup 从文档中嗅出真实的编码。

最后,在开发过程中,请针对保存好的 HTML 模板编写选择器。将原始 response.content ,并在本地进行迭代。这样你的爬虫就容易进行单元测试,而且你不必每次修改选择器时都去轰炸目标网站。

在保持礼貌的同时突破反爬虫防御

即使是友好的目标网站,也会封锁那些从单一 IP 地址发送数千次相同请求的爬虫。保持礼貌既是工程层面的考量,也是应尽的责任。以下五种技术足以满足你的大部分需求。

1. 轮换用户代理。真实的浏览器指纹加上少量逼真的 User-Agent 字符串,足以让简单的过滤器忽略你。每次请求选择一个。

import random
UAS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) Safari/17.0",
    "Mozilla/5.0 (X11; Linux x86_64) Firefox/124.0",
]
headers = {"User-Agent": random.choice(UAS)}

2. 采用带抖动(jitter)的速率限制。平滑的 time.sleep(1) 本身就是一种指纹。添加随机抖动,使请求节奏看起来像人类操作。

time.sleep(random.uniform(1.0, 2.5))

3. 采用指数退避重试。临时性故障(5xx状态码、连接重置、超时)是常态。为请求添加退避机制,以免一次小故障导致整个任务失败。

def fetch_with_retry(url, headers, attempts=4):
    for i in range(attempts):
        try:
            r = requests.get(url, headers=headers, timeout=15)
            if r.status_code == 200:
                return r.content
            if r.status_code in (429, 503):
                time.sleep(2 ** i)
                continue
            r.raise_for_status()
        except requests.RequestException:
            time.sleep(2 ** i)
    raise RuntimeError(f"giving up on {url}")

4. 轮换代理。若家庭 IP 已无法满足需求,请通过住宅或数据中心代理池转发请求。 requests 接受一个 proxies={"http": ..., "https": ...} 参数;轮换逻辑位于上一层。

5. 阅读 robots.txt 及服务条款。Google 的 robots.txt 文档是了解该协议的绝佳入门指南。遵守 Disallow 指令在各地虽不具法律约束力,但这是区分礼貌爬虫与恶意爬虫的界限,而无视这些指令往往会导致项目被列入封禁名单。

当网站采用强大的反机器人系统(如 Cloudflare 的机器人管理器、PerimeterX、DataDome)时,自行构建所有这些功能的成本将超过使用托管解锁服务的成本。我们的 Scraper API 通过单一接口处理轮询、验证码和重试,因此本教程中的 BeautifulSoup 解析代码完全保持不变,仅需更改请求层。

当 BeautifulSoup 力不从心时:JavaScript 渲染的页面

BeautifulSoup 解析的是服务器发送的内容。如果服务器发送了一个几乎空白的 HTML 框架,而页面仅在浏览器中运行 JavaScript 后才组装内容,BeautifulSoup 会照常解析该框架,却无法找到任何有用的信息。这是本 BeautifulSoup 教程能为您提供的功能所面临的唯一重大限制,值得您留意其表现症状。

以下迹象表明您正在查看单页应用:

  • view-source: 显示一个微小的 <div id="root"></div> 和一长串 <script> 标签,但浏览器中渲染的页面却内容丰富。
  • 您的爬虫看到的 DOM 与开发者工具(DevTools)所见不同。开发者工具显示的是实时 DOM,其中包含 JavaScript 注入的节点; requests 而你只看到初始响应。
  • “网络”标签页显示了一连串 XHRfetch 请求。

你有三个不错的选择:

  • 找到该 API。观察网络标签页。如果页面正在从后端获取 JSON,请直接通过 requests ,完全跳过渲染过程。这通常是最快且最稳定的方案。
  • 驱动真实浏览器。使用 Playwright 或 Selenium 加载页面,等待数据加载完成后,将渲染好的 HTML 传递给 BeautifulSoup 进行解析。
  • 使用托管浏览器 API。若您希望使用浏览器而无需管理基础设施,云浏览器接口会返回渲染后的 HTML,您可继续使用已编写的 find_all/select 代码进行解析。

最终脚本:整合数据获取、解析、分页和导出

以下是 BeautifulSoup 教程代码的整合版本。它支持分页、重试、带抖动机制的速率限制、轮换用户代理,并可导出 CSV 和 JSON 格式。

import csv, json, random, time
from pathlib import Path
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

BASE = "https://news.ycombinator.com/"
START = urljoin(BASE, "news")
UAS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) Safari/17.0",
]

def fetch(url, attempts=4):
    for i in range(attempts):
        try:
            r = requests.get(url, headers={"User-Agent": random.choice(UAS)}, timeout=15)
            if r.status_code == 200:
                return r.content
            if r.status_code in (429, 503):
                time.sleep(2 ** i); continue
            r.raise_for_status()
        except requests.RequestException:
            time.sleep(2 ** i)
    raise RuntimeError(f"failed: {url}")

def parse_stories(html):
    soup = BeautifulSoup(html, "lxml")
    out = []
    for row in soup.select("tr.athing"):
        rank = row.select_one("span.rank")
        link = row.select_one("span.titleline > a")
        if not (rank and link):
            continue
        out.append({
            "rank": rank.get_text(strip=True).rstrip("."),
            "title": link.get_text(strip=True),
            "url": link.get("href", ""),
            "id": row.get("id"),
        })
    return out

def next_page(html):
    soup = BeautifulSoup(html, "lxml")
    more = soup.select_one("a.morelink")
    return urljoin(BASE, more["href"]) if more else None

def crawl(start, max_pages=3):
    url, pages, rows = start, 0, []
    while url and pages < max_pages:
        html = fetch(url)
        rows.extend(parse_stories(html))
        url = next_page(html)
        pages += 1
        time.sleep(random.uniform(1.0, 2.5))
    return rows

def export(rows, out_dir="out"):
    out = Path(out_dir); out.mkdir(exist_ok=True)
    with (out / "stories.csv").open("w", newline="", encoding="utf-8-sig") as f:
        w = csv.DictWriter(f, fieldnames=["rank", "title", "url", "id"])
        w.writeheader(); w.writerows(rows)
    with (out / "stories.json").open("w", encoding="utf-8") as f:
        json.dump(rows, f, ensure_ascii=False, indent=2)

if __name__ == "__main__":
    rows = crawl(START)
    export(rows)
    print(f"saved {len(rows)} stories")

将其放入 hn_scraper.py,运行 python hn_scraper.py,你应该会看到三页文章被写入 out/stories.csv ,以及 out/stories.json.

BeautifulSoup 教程的下一步

现在你已经拥有了一个完整的静态网站抓取工具,但这个解析器同样适用于更庞大的工作流。以下是三个合理的下一步:

  • 当需要爬取数千个页面、去除重复 URL、管理并发以及运行定时任务时,请升级使用 Scrapy。Scrapy 采用类似的选择器语法,因此你在本 BeautifulSoup 教程中建立的思维模式可以无缝迁移。
  • 当数据被 JavaScript 封装时,添加无头浏览器。Playwright 和 Selenium 都允许你先渲染页面,随后使用 BeautifulSoup 解析渲染后的 HTML,这样既能保留现有的解析代码,又能沿用 CSS 选择器。
  • 当数据获取层成为瓶颈时,将其外包。托管式爬取 API 会处理代理、请求头和验证码破解,这样你就可以专注于迭代选择器,而非指纹识别。

无论选择哪种方案,都要保持本文建立的“解析与抓取分离”原则。这是唯一能让爬虫在网站不可避免的改版中存活的设计选择,也是本指南中的代码能随着需求增长而持续复用的关键。

关键要点

  • BeautifulSoup 仅用于解析 HTML,仅此而已。将其与 requests 处理静态页面,并使用真实浏览器处理 JavaScript 渲染的页面。
  • CSS 选择器的可扩展性优于链式 find_all 调用。请在模块顶部将其定义为命名常量,这样标记变更只需一行代码即可修复。
  • 务必防范 Nonefind_parent ,优先采用 .get("attr", "") 而非索引操作,并在方法调用链前进行检查。
  • 分页是终止条件。检测下一页锚点,使用 urljoin,并使用 max_pages ,以防止程序因错误而无限运行。
  • 礼貌即工程。用户代理轮换、抖动睡眠、指数退避以及遵守 robots.txt 都是基础实践,而非可选的润色,对于任何你打算运行多次的 BeautifulSoup 教程而言。

常见问题

BeautifulSoup 的 html.parser、lxml 和 html5lib 之间有什么区别?

html.parser 随 Python 一起提供,无需安装,但它是这三者中最慢的。 lxml lxml 是一个 C 扩展,在实际应用中速度最快,并且能很好地处理大多数格式错误的 HTML;请使用 pip install lxml. html5lib 是纯 Python 实现,也是最宽容的,它模仿真实浏览器从损坏的标记中恢复的方式,代价是速度明显较慢。

何时应使用 BeautifulSoup、Scrapy、Selenium 或 Playwright?

对于一次性脚本和静态页面,使用 BeautifulSoup 即可,此时您可以通过 requests。当您需要具备并发处理、管道和跨数千个 URL 调度能力的真正爬虫时,请使用 Scrapy。当页面依赖 JavaScript 渲染内容时,请使用 Selenium 或 Playwright,随后可选择将渲染后的 HTML 交还给 BeautifulSoup 进行解析。

BeautifulSoup 能否独立抓取由 JavaScript 渲染的页面?

不能。BeautifulSoup 仅解析其接收到的 HTML,且 requests 返回初始服务器响应,不会执行 JavaScript。对于单页应用或页面加载后注入的内容,您需要使用无头浏览器(Playwright、Selenium 或云浏览器端点)先渲染 DOM。渲染完成后,您仍可将该 HTML 传递给 BeautifulSoup 进行解析。

如何在使用 BeautifulSoup 抓取时避免 IP 被封?

轮换 User-Agent 字符串,在请求间加入随机延迟,并对临时错误采用指数退避策略进行重试。对于大规模抓取,请通过轮换的住宅或数据中心代理路由流量。遵守 robots.txt 并避免抓取受登录限制的内容。像 Cloudflare 这样强力的反机器人系统通常需要使用托管解锁工具,而非自行调整请求头。

使用 BeautifulSoup 抓取网站是否合法?

该库本身仅解析文本,其合法性并非关键问题。具体抓取行为是否合法,通常取决于目标网站的服务条款、您所在司法管辖区的适用版权法和计算机滥用法,以及数据是否属于《通用数据保护条例》(GDPR)或《加州消费者隐私法案》(CCPA)等法规所定义的个人数据。本文仅提供一般性信息,不构成法律建议;若涉及个人数据、付费墙或商业分发,请咨询律师。

结论

您从以下代码开始学习本 BeautifulSoup 教程 pip install ,最终开发出一个支持分页、重试、轮换用户代理,并能导出干净 CSV 和 JSON 格式的爬虫。脚本的整体架构比任何单个代码片段都更为重要:将数据获取与解析分离,使用命名 CSS 选择器定位元素,对每个链式属性访问都进行 None,并将反封锁措施作为构建流程的组成部分而非事后补救。网站会不断改版,解析器会持续遭遇封锁,而那些经得起时间考验的代码库,正是从第一天起就恪守这种分离原则的。

如果抓取层开始消耗比解析层更多的时间,那就是将其卸载的信号。WebScrapingAPI 通过单一接口处理代理轮换、头部指纹识别和验证码破解,因此你可以保留此处编写的 BeautifulSoup 代码,只需替换向其提供 HTML 的请求即可。祝你好运,愿你的选择器始终保持绿色。

关于作者
Sorin-Gabriel Marica, 全栈开发工程师 @ WebScrapingAPI
Sorin-Gabriel Marica全栈开发工程师

索林·马里卡(Sorin Marica)是 WebScrapingAPI 的全栈及 DevOps 工程师,负责开发产品功能并维护确保平台平稳运行的基础设施。

开始构建

准备好扩展您的数据收集规模了吗?

加入2,000多家企业,使用WebScrapingAPI在无需任何基础设施开销的情况下,以企业级规模提取网络数据。