大多数监控市场、构建搜索索引或填充数据集的团队,往往从同一件事开始:从一组公开网页中抓取数据,并将其转化为干净的记录。真正的难点很少在于单个页面,而在于如何跨越数百个页面完成这一工作,同时不让请求遭到限速、封锁,或静默地返回残缺的 HTML。

本指南将向你展示如何用 Python 构建一个小型、可运行的网络爬虫。它通过 Crawling API 获取起始页面,提取其中的链接,追踪目标范围内的链接,解析每个页面上你想要的字段,去除重复项,并导出为干净的 JSON 和 CSV 格式。整个演示使用一个中性示例网站,你可以直接运行,然后将其指向自己的公开数据源。

一段话说清爬取与抓取的区别

这两个词经常被混用,但它们指的是不同的工作。爬取(Crawling)是发现:从一个或多个 URL 出发,跟随链接,向外扩展以找到值得访问的页面。抓取(Scraping)是提取:获取单个页面的 HTML,并从中提取你关心的特定字段,例如标题、价格或日期。真实的数据流水线两者都做。爬虫决定访问哪些页面,抓取器决定从每个页面中保留什么。本指南中的脚本就是一个爬虫,并在它访问的每个页面上都挂载了一个抓取器。

你将构建什么

一个 Python 脚本,接受起始 URL,通过跟踪范围内的链接来发现文章链接,通过 Crawling API 获取每个页面,并从每个页面提取一条结构化记录。示例使用 https://example.com 作为公开列表或博客索引的替代。每条记录包含以下字段:

  • 标题 页面的主标题。
  • URL 抓取该记录的规范链接。
  • 摘要 前导段落或 meta 描述。
  • 日期 页面公开展示的发布或更新日期。
  • 链接数 该页面发现的范围内链接数量。

为什么普通请求经常失败

最简单的方案是在裸 HTTP 客户端外面套一个循环:获取 URL,解析它,将链接加入队列,重复执行。它在玩具网站上可以运行,在真实网站上则因为两个原因而崩溃。

首先是渲染问题。许多现代页面发送的是薄 HTML 壳,通过 JavaScript 和 Ajax 在浏览器中加载真实内容。用普通客户端请求这个壳,你想要的链接和字段还不在 body 里,爬虫什么也发现不了、什么也解析不到。其次是封锁问题。网站会监控自动化流量:来自数据中心 IP 段的、缺少浏览器请求头的、或触发频率快于任何人类的请求,都会在到达内容之前遭到限速、IP 封锁,或被要求完成 CAPTCHA。

因此,一个能稳定大规模运行的爬虫,每次请求都需要两样东西:能渲染页面的浏览器,以及网站认为是真实访客的 IP。你可以自己用无头浏览器加上轮换住宅代理池来实现,但维持这套基础设施的健康运行才是大部分工作所在。Crawling API 将两者合并为一次调用:你发送 URL,它在可信 IP 后面渲染页面,并返回已渲染的 HTML 供你解析。

前置条件

开始之前需要准备好几样东西,都不需要太长时间。

基础 Python 知识。 你应该能够编写和运行脚本,并用 pip 安装包。如果解析部分对你来说比较陌生,BeautifulSoup 指南与本教程配合使用效果很好。

Python 3.8 或更高版本。python --version 确认。如果没有,请从 python.org 安装,或通过 Anaconda 等发行版安装,并确保 Python 在 PATH 中。

Crawlbase 账号和 token。 注册后打开控制台,从账号页面复制你的 token。Crawlbase 提供 1,000 次免费请求作为起步,足以完成本指南的全程练习。token 有两种类型:普通 token 获取静态 HTML,JavaScript token 则先在真实浏览器中渲染页面。静态页面使用普通 token,内容在客户端加载的页面使用 JavaScript token。请像对待密码一样保管 token,不要放入版本控制系统。

搭建项目

创建虚拟环境以隔离依赖,然后安装爬虫所需的两个库。

bash
python --version

python -m venv crawler_env
source crawler_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,用 crawler_env\Scripts\activate 代替 source 那行来激活环境。两个依赖各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,让你可以通过 CSS 选择器提取字段和链接。jsoncsv 随标准库附带,因此导出步骤无需额外安装。

第 1 步:通过 Crawlbase 获取页面

从可靠地获取单个页面开始。导入 CrawlingAPI 类,用你的 token 初始化,并请求起始 URL。在解析前检查 Crawlbase 的 pc_status,可以让错误显而易见,并为你提供一个干净的重试位置。

python
import time
from crawlbase import CrawlingAPI

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

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        response = api.get(page_url)
        if response["headers"]["pc_status"] == "200":
            return response["body"].decode("utf-8")
        if attempt < max_retries:
            print(f"Retrying ({attempt + 1}/{max_retries})...")
            time.sleep(1)
    print(f"Failed: {page_url} ({response['headers']['pc_status']})")
    return None

if __name__ == "__main__":
    html = fetch_html("https://example.com")
    print(html[:500] if html else "No HTML returned")

fetch_html 辅助函数是整个爬虫的骨干。它通过 Crawlbase 发送 URL,在获取失败时短暂暂停后最多重试两次,成功时返回解码后的 HTML,最终放弃时返回 None。运行 python crawler.py,你应该能看到真实的标记语言打印出来,这在你编写任何选择器之前就确认了请求路径的有效性。如果你的目标在客户端加载内容,请使用 JavaScript token 初始化,并将 {"ajax_wait": "true", "page_wait": 5000} 作为第二个参数传给 api.get,以便 API 在抓取页面前等待动态内容加载完成。

Crawlbase Crawling API

上面的 fetch_html 辅助函数依赖一件事:每个请求都会以已渲染的形式、从网站信任的 IP 地址返回。Crawling API 正是做到了这一点。有需要时它在真实浏览器中运行页面,在服务端轮换住宅 IP,并将已渲染的 HTML 交付给你,让你无需自己搭建无头浏览器集群和代理池。先从免费套餐开始,将其指向一个公开页面。

第 2 步:提取页面上的链接

发现的本质就是在循环中提取链接。将 HTML 加载到 BeautifulSoup,提取每个锚点的 href,并将相对路径针对其所在页面进行解析,使你始终处理绝对 URL。

python
from urllib.parse import urljoin, urldefrag
from bs4 import BeautifulSoup

def extract_links(html, base_url):
    soup = BeautifulSoup(html, "html.parser")
    links = set()
    for a in soup.select("a[href]"):
        href = a["href"].strip()
        if not href or href.startswith(("mailto:", "tel:", "javascript:")):
            continue
        absolute = urljoin(base_url, href)
        absolute, _ = urldefrag(absolute)
        links.add(absolute)
    return links

三个小决策使这个函数更健壮。该函数跳过 mailto:tel:javascript: 等非真实页面的锚点。它使用 urljoin,将 /articles/web-data 这样的相对 href 转化为基于其所在页面的完整 URL。它还调用 urldefrag 去除 #section 片段,因为 /page/page#top 是同一个文档,不应重复访问。返回 set 在进入队列之前就对单个页面上发现的链接进行了去重。

第 3 步:将爬取范围限定在目标内

若不加限制,爬虫会跟随链接离开你的目标网站,永无止境。解决办法是范围规则:只追踪与起始 URL 共享同一主机,且可选地位于你关心的路径前缀下的链接。这相当于爬虫版的"只在产品区活动,不要跑到帮助中心去"。

python
from urllib.parse import urlparse

def in_scope(url, root):
    root_parts = urlparse(root)
    url_parts = urlparse(url)
    if url_parts.scheme not in ("http", "https"):
        return False
    if url_parts.netloc != root_parts.netloc:
        return False
    return url_parts.path.startswith(root_parts.path)

in_scope 将每个候选 URL 与你的根 URL 进行比较。它拒绝任何非 HTTP 或 HTTPS 的链接、任何不同主机(netloc)上的链接,以及任何路径不以根路径开头的链接。将根设置为 https://example.com/ 可以爬取整个主机,设置为 https://example.com/blog/ 则只在一个区段内爬取。在此收紧范围,是控制你抓取多少内容的最重要杠杆。

第 4 步:解析每个页面上的字段

发现告诉你访问哪些页面,解析决定保留什么。从每个页面提取一条小而定义明确的记录,并对每个查找进行防御处理,让缺失的字段返回 None 而不是使运行崩溃。

python
def text_of(soup, selector):
    el = soup.select_one(selector)
    return el.get_text(strip=True) if el else None

def attr_of(soup, selector, attr):
    el = soup.select_one(selector)
    return el.get(attr) if el else None

def parse_page(html, url):
    soup = BeautifulSoup(html, "html.parser")
    summary = (
        attr_of(soup, 'meta[name="description"]', "content")
        or text_of(soup, "article p")
    )
    return {
        "url": url,
        "title": text_of(soup, "h1") or text_of(soup, "title"),
        "summary": summary,
        "date": attr_of(soup, "time[datetime]", "datetime"),
    }

两个辅助函数 text_ofattr_of 查询单个元素,返回其文本或某个属性,元素缺失时回退到 Noneparse_page 使用一组回退链:摘要优先使用 meta[name="description"] 标签,没有时退回到第一个 article 段落;标题使用 h1,没有 h1 时使用 <title> 标签。这些选择器特意保持通用,使脚本可以直接在示例网站上运行。对于真实目标,在浏览器开发者工具中打开页面,将它们替换为匹配该网站实际标记结构的选择器。

第 5 步:组装爬取循环

现在将各部分整合为一个广度优先爬虫。队列保存待访问的 URL,visited 集合防止同一页面被重复获取,max_pages 上限防止运行无休止地持续。对于它访问的每个页面,爬虫解析一条记录,统计范围内的链接数,并将新链接加入队列。

python
import csv
import json
import time
from collections import deque
from urllib.parse import urljoin, urldefrag, urlparse
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

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

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        response = api.get(page_url)
        if response["headers"]["pc_status"] == "200":
            return response["body"].decode("utf-8")
        if attempt < max_retries:
            time.sleep(1)
    return None

def extract_links(html, base_url):
    soup = BeautifulSoup(html, "html.parser")
    links = set()
    for a in soup.select("a[href]"):
        href = a["href"].strip()
        if not href or href.startswith(("mailto:", "tel:", "javascript:")):
            continue
        absolute, _ = urldefrag(urljoin(base_url, href))
        links.add(absolute)
    return links

def in_scope(url, root):
    r, u = urlparse(root), urlparse(url)
    return (
        u.scheme in ("http", "https")
        and u.netloc == r.netloc
        and u.path.startswith(r.path)
    )

def text_of(soup, selector):
    el = soup.select_one(selector)
    return el.get_text(strip=True) if el else None

def attr_of(soup, selector, attr):
    el = soup.select_one(selector)
    return el.get(attr) if el else None

def parse_page(html, url, link_count):
    soup = BeautifulSoup(html, "html.parser")
    summary = (
        attr_of(soup, 'meta[name="description"]', "content")
        or text_of(soup, "article p")
    )
    return {
        "url": url,
        "title": text_of(soup, "h1") or text_of(soup, "title"),
        "summary": summary,
        "date": attr_of(soup, "time[datetime]", "datetime"),
        "links": link_count,
    }

def crawl(start_url, max_pages=25):
    queue = deque([start_url])
    visited = set()
    records = []
    while queue and len(visited) < max_pages:
        url = queue.popleft()
        if url in visited:
            continue
        visited.add(url)
        html = fetch_html(url)
        if not html:
            continue
        found = {l for l in extract_links(html, url) if in_scope(l, start_url)}
        records.append(parse_page(html, url, len(found)))
        for link in found:
            if link not in visited:
                queue.append(link)
        print(f"[{len(visited)}/{max_pages}] {url}")
        time.sleep(2)
    return records

这是教科书式的广度优先爬取。visited 集合是爬取层面的去重守卫:URL 在获取之前就被添加进去,因此即使三个页面都链接到同一篇文章,也只会请求一次。max_pages 限制总工作量,范围过滤器防止队列被站外链接填满,两秒的 sleep 使运行速率保持合理,不至于轰炸服务器。print 行在运行时给你实时进度追踪。

第 6 步:去重并导出为 JSON 和 CSV

visited 集合已经防止了同一 URL 被获取两次,但重定向和尾部斜杠变体仍然可能产生描述同一页面的两条记录。导出前以 URL 为键做最后一次去重,可以合并这些情况。

python
def dedupe(records):
    seen = {}
    for record in records:
        seen[record["url"].rstrip("/")] = record
    return list(seen.values())

def save_outputs(records):
    with open("crawl_results.json", "w") as f:
        json.dump(records, f, indent=2)
    if not records:
        return
    with open("crawl_results.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=records[0].keys())
        writer.writeheader()
        writer.writerows(records)

def main():
    records = crawl("https://example.com", max_pages=25)
    records = dedupe(records)
    save_outputs(records)
    print(f"Saved {len(records)} pages")

if __name__ == "__main__":
    main()

dedupe 以去掉尾部斜杠的 URL 作为每条记录的键,使 /article/article/ 解析为同一条记录,且以后者为准。save_outputs 写入一个 JSON 文件和一个 CSV 文件,以第一条记录的键作为表头,将数据以你的下游工具所需的格式提供。将这两个函数添加在第 5 步的爬取循环下方,脚本就可以端到端运行了。

输出结果示例

运行完整脚本 python crawler.py,每个页面你会得到一条结构化记录,可以直接用于分析、写入数据库,或放入电子表格。

json
[
  {
    "url": "https://example.com/articles/web-data",
    "title": "A Practical Guide to Web Data",
    "summary": "How teams turn public pages into clean, structured records.",
    "date": "2024-09-18",
    "links": 12
  },
  {
    "url": "https://example.com/articles/crawling-basics",
    "title": "Crawling Basics",
    "summary": "Discovery, scope, and dedupe explained from first principles.",
    "date": "2024-08-02",
    "links": 9
  }
]

对应的 CSV 包含相同的列,每行一个页面,可以直接导入 pandas 或任何电子表格进行排序、过滤,或与其他数据集关联。如果你想进一步处理存储步骤,可以参考将抓取数据存储到云端将其加载到 SQL

扩大爬取规模

上面的脚本特意设计为单线程,便于阅读且易于保持礼貌。几个改动可以让它从演示版变成一个可以长期运行的任务。

  • 谨慎提高上限。 max_pages 是你的安全阀。分步提高它,并在投入大规模运行之前观察爬取发现了多少个范围内的链接。
  • 持久化前沿队列。 对于长时间爬取,将队列和 visited 集合写入磁盘,使中断的任务能够从断点恢复,而不是重新开始并重新获取所有内容。
  • 大量数据时使用异步方式。 当你需要数千个页面时,异步 Crawler 可以将请求排入队列并将结果推送到 webhook,你无需在页面渲染期间保持连接开启。

对于链接本身也在客户端加载的 JavaScript 密集型目标,切换到 JavaScript token 和等待选项后,同样的循环即可运行。详情请参阅爬取 JavaScript 网站

保持不被封锁

即使渲染和可信 IP 都处理好了,以下几个习惯可以让较长的爬取任务保持健康。

  • 控制请求速率。 循环中的两秒 sleep 是下限,不是上限。对于较大的任务,可以适当延长,同时避免以服务器最快响应速度爬取同一路径。
  • 依赖 IP 轮换。 住宅 IP 池将请求分散到多个真实用户地址,使单个地址不会触发速率限制。Crawling API 为你处理了这一点;如果你自行搭建,这是最需要做好的环节。
  • 关注状态码。 如果运行开始返回非 200 的 pc_status 值,说明当前速率或 IP 套餐已不够用。将此视为退出的信号,而非可忽略的噪音。

完整的操作手册,请参阅如何在不被封锁的情况下抓取网站

负责任地抓取

只抓取公开数据,并遵守你访问网站的规则。在开始之前阅读每个目标网站的服务条款和 robots.txt,将请求速率保持在合理范围内,不要给任何人的服务器造成压力,并远离需要登录或付费才能访问的内容。当你收集的页面包含个人数据时,GDPR 和 CCPA 等隐私法律适用于你存储和使用这些数据的方式,因此将字段范围限定在你真正需要的内容,避免收集与可识别个人相关联的详细信息。本指南中的代码使技术层面的工作成为可能;让项目站在这些规则正确一侧的责任在于你。

回顾

核心要点

  • 爬取和抓取是两项不同的工作。 爬虫通过跟踪链接来发现访问哪些页面;抓取器从每个页面提取你要保留的字段。
  • 渲染并通过可信 IP 路由。 普通客户端会错过客户端渲染的内容并遭到封锁;Crawling API 在一次调用中从可信 IP 返回已渲染的 HTML。
  • 范围和去重使爬取保持可控。 in_scope 检查防止任务偏离目标网站,visited 集合加上以 URL 为键的去重过程,消除重复工作和重复记录。
  • 防御性地解析。 对每个选择器进行防护,使缺失字段返回 None,一个异常页面不会终止整个运行。
  • 一次导出,随处使用。 同时写入 JSON 和 CSV,使同一数据集无需重新处理即可流入 pandas、数据库或电子表格。

常见问题

网络爬取和网络抓取有什么区别?

爬取是发现步骤:从一个或多个 URL 出发,跟踪链接找到值得访问的页面。抓取是提取步骤:获取单个页面的 HTML,提取标题或日期等特定字段。大多数真实的数据流水线同时执行两者,这正是本指南中脚本所做的事:爬取以发现页面,并从每个页面抓取一条记录。

为什么我的爬虫返回空的或不完整的 HTML?

通常是因为页面通过 JavaScript 在浏览器中渲染内容,所以初始 HTML 只是一个薄壳,你的链接和字段还不在其中。通过使用 JavaScript token 以及 ajax_waitpage_wait 选项,用 Crawling API 获取页面,这些选项会先渲染页面,再返回已渲染的标记供你解析。

如何防止爬虫离开我的目标网站?

使用范围规则。in_scope 函数将每个候选链接与你起始 URL 的主机和路径进行比较,拒绝任何不匹配的链接。将根路径设置得更窄,例如 https://example.com/blog/,可以将爬取限制在一个区段内而不是整个域。

爬虫如何避免重复访问同一个页面?

通过两层机制。visited 集合在每个 URL 被获取之前就记录下来,因此即使一个页面被多处链接,也只会请求一次。爬取结束后,一个以 URL 为键(规范化尾部斜杠)的去重过程,在记录进入 JSON 和 CSV 之前,合并任何仍然描述同一页面的记录。

我应该导出为 JSON 还是 CSV?

两者都导出,让下游工具来决定。JSON 保留了代码和 API 偏好的嵌套、有类型的结构,而 CSV 可以直接导入电子表格和 pandas。save_outputs 函数从同一批记录同时写入两种格式,使你不会被锁定在单一格式上。关于两者权衡的更多讨论,可以参考 JSON 与 CSV 的对比分析。

免费套餐可以爬取多少个页面?

Crawlbase 提供 1,000 次免费请求作为起步,且仅对成功的请求计费。爬虫获取的每个页面算作一次请求,因此脚本中的 max_pages 上限直接对应你的使用量。对于规模较大或定期执行的任务,异步 Crawler 可以在不保持连接开启的情况下扩展同样的方法。

开始构建

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

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

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