现代网络中很多值得采集的数据从不出现在页面源码里。随着滚动而填充的商品网格、筛选条件改变后才更新的表格、布局加载后才显示数字的仪表盘,这些都通过 AJAX(Asynchronous JavaScript and XML)在后台获取内容并注入到已加载的页面中。这种机制带来了流畅的用户体验,却悄悄地让最简单的爬虫失效。

本指南将教你如何用 Python 抓取 AJAX 驱动的网站数据。你将构建一个小型、可运行的爬虫,通过 Crawling API 渲染页面,等待异步内容到达,捕获页面通过 XHR 加载的数据,解析后导出为干净的 JSON 和 CSV 文件。本教程使用一个中性的占位符列表页,你可以跟随整个流程后将相同的方法指向自己的目标。

你将构建什么

一个 Python 脚本,通过 Crawlbase 获取 AJAX 页面,读取页面异步加载的数据,并将每条数据整理成结构化记录。示例中使用的是一个通用公开列表页,每张卡片包含名称、价格和类别。我们从每个条目中提取以下字段:

  • Name 每张列表卡片上显示的标题。
  • Price 该条目渲染的数字价格。
  • Category 该条目所属的分组或标签。
  • Link 该条目详情页的 URL。

你将看到两种获取相同数据的途径:直接复制 AJAX 请求,以及渲染完整页面。两者最终都汇入相同的导出步骤。

普通请求为何在 AJAX 页面上失效

用普通 HTTP 客户端请求一个 AJAX 驱动的 URL,你得到的是状态码 200,却几乎得不到你所需的数据。原因在于时序问题。服务器发送一个轻量的 HTML 骨架,浏览器执行页面的 JavaScript,然后脚本才触发后台请求(即 AJAX 调用),将真实内容返回并注入 DOM。普通的 requests.get 只停在骨架层:它从不运行 JavaScript,因此从不触发后续请求,最终解析到的正文基本上是空的布局。

绕过这一问题有两种正规方式。第一种是找到页面在后台调用的 AJAX 端点并直接请求,由于跳过了渲染步骤,速度较快。第二种是在真实浏览器中渲染整个页面,等待异步内容加载完成后再解析最终的 HTML,当端点有签名或难以复现时适合采用这种方式。爬取 JavaScript 网站指南对渲染侧有深入介绍。

普通 token 与 JS token

Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 会先在真实浏览器中渲染页面。由于 AJAX 内容只在脚本运行后才出现,此处需使用 JS token,并配合 ajax_waitpage_wait,让 API 等待后台请求完成后再捕获页面。

前提条件

在编写任何代码之前,需要准备好以下几项。

基础 Python 知识。 你应当能够编写和运行 Python 脚本,并使用 pip 安装包。如果你对解析还不熟悉,BeautifulSoup 指南与本教程配合阅读效果很好。

Python 3.8 或更高版本。 通过 python --version 确认你的版本。如果尚未安装,请从 python.org 下载,并确保 Python 已加入系统 PATH。

Crawlbase 账号与 JS token。 注册账号,打开控制台,复制你的 JavaScript(JS)token。Crawlbase 初始提供 1,000 次免费请求,完全够用于本教程。请像保护密码一样保管 token,切勿提交到版本控制。

搭建项目

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

bash
python --version

python -m venv ajax_env
source ajax_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,用 ajax_env\Scripts\activate 代替 source 那行来激活环境。两个依赖各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 用于在渲染完整页面的路径中解析返回的 HTML。jsoncsv 均为标准库,导出步骤无需额外安装。

步骤 1:识别 AJAX 请求

在编写任何代码前,先找到页面发出的后台请求。在 Chrome 中打开目标页,右键选择"检查"(或按 Ctrl+Shift+I),切换到 Network 标签。筛选 XHR,这样可以将 XMLHttpRequest 和 fetch 调用与图片、样式表隔离开来,然后重新加载页面。内容填充时,承载它的请求就会出现。点击它查看请求 URL、查询参数以及返回的 JSON。

对于此处使用的占位符示例,页面从如下 JSON 端点加载数据:

bash
https://example.com/api/items?page=1&limit=20

该端点以干净的 JSON 而非渲染的 HTML 返回页面所展示的相同数据。当这样的请求可以访问时,直接请求它是最简单的路径。当它有签名、依赖会话或难以复现时,则改为渲染页面。两种路径在下文均有介绍。

步骤 2:通过 Crawlbase 获取 AJAX 端点

即使是干净的 JSON 端点,来自数据中心 IP 的自动化流量也可能遭到限速或封锁。通过 Crawlbase 路由请求可以获得可信 IP 和内置轮换,使请求看起来像真实访客。导入 CrawlingAPI 类,用你的 token 初始化,然后请求端点。在解析前检查 pc_status 可以让失败信号响亮而不是静默。

python
import json
from crawlbase import CrawlingAPI

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

endpoint = "https://example.com/api/items?page=1&limit=20"

def fetch_json(url):
    response = api.get(url)
    if response["headers"]["pc_status"] == "200":
        return json.loads(response["body"].decode("utf-8"))
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

if __name__ == "__main__":
    data = fetch_json(endpoint)
    print(data if data else "No data returned")

python ajax_scraper.py 运行脚本,你应当看到浏览器本应加载的原始 JSON,只需一次调用,无需渲染任何内容。这在编写任何解析逻辑之前,就确认了端点可以访问。

Crawlbase Crawling API

上面的 fetch 操作无需运行浏览器或管理 IP 即可访问 AJAX 端点,这正是 Crawling API 的职责所在。对于干净的 JSON 端点使用普通 token,需要渲染完整页面时则使用 JS token 并配合 ajax_waitpage_wait。它在服务端轮换住宅 IP 并返回处理完成的内容,让你无需自己运行无头浏览器集群和代理池。先在免费层用它请求一个公开页面试试。

步骤 3:解析 JSON 响应

端点返回结构化 JSON,无需解析 HTML。遍历对象到条目列表并提取所需字段。具体的键名取决于你的目标,请参照步骤 1 的响应进行映射。对于占位符示例,条目位于 items 键下,每条包含 namepricecategoryurl

python
def parse_items(data):
    records = []
    for item in data.get("items", []):
        records.append({
            "name": item.get("name"),
            "price": item.get("price"),
            "category": item.get("category"),
            "link": item.get("url"),
        })
    return records

使用 dict.get 而非方括号访问意味着缺失的键返回 None 而不是抛出 KeyError,这样一条格式异常的记录不会中断整个运行。将步骤 2 的 JSON 传入 parse_items,你将得到一个整洁的记录列表,可随时导出。

步骤 4:没有干净端点时渲染完整页面

有时 AJAX 请求有签名、依赖会话 cookie,或分散在多个请求中,复现它的成本高于其价值。这种情况下,使用 JS token 渲染整个页面,让 Crawlbase 等待异步内容,然后像对待任何静态页面一样用 BeautifulSoup 解析完成的标记。

python
from bs4 import BeautifulSoup

RENDER_OPTIONS = {
    "ajax_wait": "true",
    "page_wait": 5000,
}

def fetch_rendered(url):
    response = api.get(url, RENDER_OPTIONS)
    if response["headers"]["pc_status"] == "200":
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

def parse_cards(html):
    soup = BeautifulSoup(html, "html.parser")
    records = []
    for card in soup.select("div.item-card"):
        link = card.select_one("a.item-link")
        records.append({
            "name": text_of(card, "h2.item-name"),
            "price": text_of(card, "span.item-price"),
            "category": text_of(card, "span.item-category"),
            "link": link["href"] if link else None,
        })
    return records

这里两个等待选项承担了主要工作。ajax_wait 告知 API 等待异步内容加载完成,page_wait 在加载后再增加固定的毫秒数停顿,以便延迟渲染的卡片在捕获前出现。五秒是合理的起点;如果条目返回较少,可适当增加。parse_cards 辅助函数读取每个 div.item-card 并映射相同的四个字段,因此其输出与 parse_items 完全一致。此处使用的 text_of 辅助函数在下方的完整脚本中定义。

步骤 5:处理分页并组装脚本

一页内容很少是完整的数据集。大多数 AJAX 列表通过查询参数(此处为 page)分页,因此你需要循环遍历页码,从每页收集记录,直到某页返回空内容时停止。将该循环与 fetch、parse 和 export 步骤组合成一个可运行的脚本。

python
import csv
import json
import time
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})
BASE = "https://example.com/api/items?limit=20&page="

def fetch_json(url):
    response = api.get(url)
    if response["headers"]["pc_status"] == "200":
        return json.loads(response["body"].decode("utf-8"))
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

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

def parse_items(data):
    records = []
    for item in data.get("items", []):
        records.append({
            "name": item.get("name"),
            "price": item.get("price"),
            "category": item.get("category"),
            "link": item.get("url"),
        })
    return records

def collect_all(max_pages=5):
    all_records = []
    for page in range(1, max_pages + 1):
        data = fetch_json(f"{BASE}{page}")
        if not data:
            break
        records = parse_items(data)
        if not records:
            break
        all_records.extend(records)
        time.sleep(2)
    return all_records

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

def main():
    records = collect_all(max_pages=5)
    save_outputs(records)
    print(f"Saved {len(records)} items")

if __name__ == "__main__":
    main()

脚本遍历 AJAX 端点的最多五页,将每页解析为记录,一旦某页返回空内容即停止,并以两秒间隔为循环限速。save_outputs 同时写入 JSON 文件和 CSV 文件,以第一条记录的键作为表头。如果目标没有干净的端点,可用步骤 4 中的 fetch_renderedparse_cards 替换 fetch_jsonparse_items;导出步骤无需更改。

输出结果示例

python ajax_scraper.py 运行完整脚本,你将得到每条数据的干净结构化记录,可用于分析、入库或导入电子表格。

json
[
  {
    "name": "Wireless Keyboard",
    "price": "49.00",
    "category": "Accessories",
    "link": "https://example.com/items/wireless-keyboard"
  },
  {
    "name": "Standing Desk",
    "price": "299.00",
    "category": "Furniture",
    "link": "https://example.com/items/standing-desk"
  }
]

对应的 CSV 包含相同的列,每行一条数据,可直接导入 pandas 或任何电子表格,按价格区间或类别筛选。要进一步分析,使用 pandas 分析数据的指南从本文导出步骤衔接,JSON vs CSV 则介绍各格式的适用场景。

大规模运行时保持不被封锁

即使有渲染和可信 IP 的加持,AJAX 目标仍会监测爬虫特征流量。养成几个习惯有助于长时间运行的稳健性。

  • 控制请求节奏。 在紧密循环中频繁发送请求是最快被限速的方式。上文两秒的停顿是下限,而非上限;对于较大的任务应适当增大间隔。
  • 依赖 IP 轮换。 住宅 IP 池将请求分散到众多真实用户地址上,使单个地址不会触发速率限制。Crawling API 已为你处理这一切。
  • 关注状态码。 运行过程中开始返回非 200 的 pc_status 值,说明当前速率或 IP 层级已不够用。将其视为需要降速的信号,而非可忽略的噪声。

对于大规模抓取,异步 Crawler 会将请求入队并通过 webhook 交付结果,适合在不占用开放连接的情况下运行大量 AJAX 页面。完整的策略手册请参见如何在不被封锁的情况下抓取网站用 Python 抓取 JavaScript 页面

负责任地进行爬取

将工作范围限定在公开数据,并以目标网站的规则为边界。在将爬虫指向任何网站前,请先阅读其服务条款和 robots.txt,只采集任何访客无需账号即可看到的数据。保持合理的请求频率以免给服务器造成压力,绝不触碰登录墙后的内容,也不尝试绕过任何身份验证机制。若数据涉及可识别的个人,GDPR 或 CCPA 等隐私法律即适用,因此除非有明确的合法依据,请避免采集个人或联系人信息。若目标网站为你所需的数据提供了官方 API,通常比爬取渲染页面更简洁、更持久。

回顾

核心要点

  • AJAX 内容在骨架之后加载。 普通请求停在初始 HTML 处,从不运行获取真实数据的脚本,导致解析到的正文几乎为空。
  • 两种路径可获取相同数据。 直接复制后台 XHR 端点速度更快;当端点有签名或难以复现时,使用 JS token 渲染完整页面。
  • 等待内容加载。 在渲染路径中,ajax_waitpage_wait 使 Crawlbase 等待异步调用完成后再捕获页面。
  • 规范化后再导出。 将两种路径映射到相同的记录结构,通过查询参数分页,然后用同一个函数将结果写入 JSON 和 CSV。
  • 负责任地爬取。 遵守服务条款和 robots.txt,只处理公开数据,保持合理的请求节奏,涉及个人数据时遵守 GDPR 或 CCPA 规定。

常见问题

什么是 AJAX,为什么它让爬取更难?

AJAX(Asynchronous JavaScript and XML)是一种让页面在后台获取内容并更新部分 DOM 而无需重新加载的技术。它使爬取更难,因为数据不在初始 HTML 中,只有在浏览器运行页面 JavaScript、后台调用返回结果后才会到达。普通 HTTP 请求从不运行该 JavaScript,因此捕获到的只是一个缺少真实内容的薄骨架。

不渲染浏览器能抓取 AJAX 内容吗?

通常可以。在开发者工具的 Network 标签中筛选 XHR,找到承载数据的请求。若该端点可以直接访问,你可以直接请求并解析其返回的 JSON,速度比渲染页面更快。当端点有签名、依赖会话或分散在多个调用中时,使用 JS token 渲染更为可靠。

我需要普通 token 还是 JS token?

取决于路径。对于在 Network 标签中找到的干净 JSON 端点,普通 token 已足够,因为无需渲染。若要加载内容只在脚本运行后才出现的完整页面,则使用 JS token,并配合 ajax_waitpage_wait,让 Crawlbase 等待异步调用完成后再捕获 HTML。

ajax_wait 和 page_wait 实际上做什么?

ajax_wait 告知 API 等到页面的异步请求加载完成,而不是在初始 HTML 到达时立即捕获。page_wait 在加载后增加固定的毫秒数停顿,覆盖那些稍有延迟才渲染的内容。五秒是合理的起点;若条目返回较少则调高,一旦确认页面更快稳定则调低。

解析列表为空,哪里出了问题?

按顺序检查三件事。首先确认 pc_status 返回了 200;非 200 值意味着请求失败。其次,在端点路径中重新检查 JSON 键,它们可能与此处使用的占位符键名不同。第三,在渲染路径中,增大 page_wait 并对照实时页面重新核对 CSS 选择器,因为生成标记上的类名可能随时更改。

如何将其扩展到多页?

循环遍历分页参数,当某页不返回任何条目时停止,就像上面的 collect_all 函数所做的,并在请求之间保持短暂停顿。对于大规模任务,切换到异步 Crawler,将请求入队并通过 webhook 交付结果,而不是占用开放连接,并依靠内置 IP 轮换确保单个地址不会触发速率限制。

开始构建

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

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

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