返回博客
指南
Andrei OgiolanLast updated on May 7, 20262 min read

用 Python 对 JavaScript 表进行网络抓取:从隐藏的 API 到 Playwright

用 Python 对 JavaScript 表进行网络抓取:从隐藏的 API 到 Playwright
简而言之:在 Python 中抓取 JavaScript 表格时通常无需使用无头浏览器。打开开发者工具,找到为表格提供数据的 JSON 接口,使用 requests进行重放,并进行分页;仅当网络请求需要签名、加密或以其他方式被严格封锁时,才退而求其次使用 Playwright。

你写出了显而易见的代码。 requests.get(url),将 HTML 传递给 BeautifulSoup,从 <table>。脚本运行完毕,文件保存到磁盘,结果 CSV 却是空的。欢迎来到 JavaScript 表格的网络爬取世界——你浏览器中看到的行,在服务器实际返回的文档中并不存在。

静态表格将数据封装在初始 HTML。动态表格(也称为 AJAX 或 JavaScript 渲染的表格)则先发送一个近乎空白的壳体,随后页面中的脚本会调用 JSON 接口,并在加载完成后将行数据注入 DOM。如果你不执行该脚本,就看不到这些行。为了解决这个通常微不足道的问题而启动一个完整的浏览器,未免有些大材小用。

本指南将采用更简便的途径。我们将从决策流程图开始,帮助您不再纠结于该使用 requests 还是浏览器引擎,随后逐步演示如何在开发者工具中定位底层 JSON 接口,在 Python 中重现该接口(包含分页和身份验证),将其解析为结构清晰的行数据,并导出为 CSV、JSON Lines 或 SQLite 格式。Playwright 仅作为针对隐藏网络调用的网站的实际备选方案,而非默认工具。完成后,您将获得一个脚本,下个季度无需从头重写即可直接运行。

为何 JavaScript 表格会让标准爬虫失效

当你调用 requests.get() 时,返回的是服务器在任何浏览器代码执行前发送的文档。该文档包含布局、导航、空的网格容器以及一堆 JavaScript 代码。此时行数据尚未生成。浏览器执行脚本,脚本获取 JSON 有效载荷,只有此时表格才会被填充。

BeautifulSoup 会忠实地解析所接收的内容,即一个 <table> ,其中没有 <tr> 子元素。你的选择器无法匹配任何内容,循环执行次数为零,写入器生成的 CSV 文件仅有表头而无数据。JavaScript 表格的网页抓取正是在此处悄然失败,因为从技术上讲,每一层都运行正常。

编写代码前先确定数据提取路径

在打开编辑器之前,请进行一分钟的决策树分析。排序至关重要,因为每个步骤的维护成本都高于前一步。

  1. 官方 API 或 CSV 导出。许多仪表盘都提供了下载按钮或有文档记录的接口。请直接使用它们。对于只需凭密钥即可请求获取的数据,你绝不应该去爬取。
  2. 隐藏的 XHR 或 Fetch JSON。大多数现代网格数据都是通过 JSON 调用加载的,你可以在开发者工具中看到。这应该成为你抓取 JavaScript 表格时的默认方案。数据负载结构化,模式稳定,而且你可以跳过整个渲染层。
  3. 静态 <table> 数据已存在于源代码中。如果行数据已存在于 view-source: (无需脚本),请使用 pandas.read_html() 进行快速处理,或 requests 配合 BeautifulSoup 并 lxml 配合使用。
  4. 无头浏览器渲染。仅当网络路径经过签名、GraphQL 具有严格的源检查、通过 WebSocket 提供数据,或普通 HTTP 客户端无法访问时,才使用 Playwright。

大多数文章都先教路径 4。这本末倒置。如果存在隐藏的 JSON 端点,它能提供比任何无头浏览器都更干净的数据和更小的失败面。

使用开发者工具定位隐藏的 JSON 端点

确认表格是否由 JavaScript 动态生成的最快方法是检查原始页面源代码,而非渲染后的 DOM。右键点击页面,选择“查看源代码”,并搜索表格中可见的样本值(如姓名、薪资、唯一 ID)。如果搜索结果为空,则该行是在加载后注入的,您看到的是一张由 JavaScript 渲染的网格。

现在找出传输该数据的请求。本指南中使用的参考示例是公开的 DataTables AJAX 演示: datatables.net/examples/data_sources/ajax.html。打开开发者工具,切换到“网络”选项卡,并按“Fetch/XHR”过滤。重新加载页面以捕获完整的网络流量,然后触发排序或分页操作。第二个操作是关键:排序操作后最大的数据包几乎总是携带新行数据的那个。

点击该请求,打开“响应”选项卡,确认 JSON 结构是否符合预期。交叉核对“头部”信息,检查请求方法、查询参数、Cookie 以及任何自定义令牌(X-CSRF-Token, Authorization)。对于棘手的目标,右键点击请求并选择“复制为 cURL”。这将保留头部信息、Cookie 以及精确的请求主体,这样你就可以将其粘贴到转换器中,无需手动输入任何内容即可初始化你的 Python 代码。请严格过滤:一个简单的搜索框在发出真实请求之前可能会触发十次自动完成请求。

在 Python 中重放捕获的请求

一旦获取了 URL 和请求头,Python 端的工作量就很小了。从最简形式开始,仅在服务器报错时才添加请求头。

import requests

URL = "https://datatables.net/examples/ajax/data/objects.txt"

headers = {
    "User-Agent": "Mozilla/5.0 (compatible; tables-scraper/1.0)",
    "Accept": "application/json, text/javascript, */*; q=0.01",
}

response = requests.get(URL, headers=headers, timeout=15)
response.raise_for_status()
payload = response.json()

有两点需要特别强调。首先, raise_for_status() 是不可或缺的,因为反机器人系统常会返回带HTTP 200状态码的HTML,而缺少状态检查会导致软封锁变成数据损坏。其次,切勿直接从开发者工具中复制粘贴个人会话Cookie。该Cookie会过期,将个人上下文泄露到代码库中,并使脚本与特定用户绑定。建议优先使用公共头部,随后添加真实的登录流程,并使用 requests.Session

对于需要在多个端点间进行异步扇出(fan-out)的工作流,HTTPX 是一个即插即用的替代方案,它拥有几乎完全相同的同步 API 以及一流的异步支持。请将其视为一种选项而非强制建议; requests 在 2026 年,它仍然是一个非常不错的默认选择。

将 JSON 有效载荷解析为整洁的行

DataTables 示例返回一个顶级字典,其中包含一个 data 键,该键包含一个列表的列表。实际 API 各不相同:有些返回对象列表,有些将行封装在 resultsitems中,还有些将它们埋藏在 payload.table.rows。先检查一次数据结构,然后编写防御性代码。

rows = payload.get("data", [])
records = []
for r in rows:
    records.append({
        "name":       r[0],
        "position":   r[1],
        "office":     r[2],
        "extn":       r[3],
        "start_date": r[4],
        "salary":     r[5],
    })

如果端点返回的是对象列表而非位置数组,请将索引替换为 r.get("name"), r.get("position"),以此类推。使用 .get() 代替 r["name"] ,可避免在后端添加或重命名字段时陷入 KeyError 后端添加或重命名字段时带来的麻烦。将这种映射集中处理一次,集中在一个地方,这样管道的其余部分就能与一个稳定的内部模式进行交互,而不是依赖上游 API 本周决定返回的内容。

处理分页、查询参数和身份验证

真正的端点很少会在一次调用中返回所有行。DataTables 的服务器端协议使用 draw, start, length, order[0][column],以及 search[value];标准参数列表详见 DataTables 服务器端处理手册。其他后端则采用游标分页(?cursor=eyJ...)、偏移量分页(?page=3&per_page=100) 或 next_url 响应中嵌入的字段。

import time

session = requests.Session()
session.headers.update(headers)

start, length, rows = 0, 100, []
while True:
    r = session.get(URL, params={"draw": 1, "start": start, "length": length}, timeout=15)
    if r.status_code == 429:
        time.sleep(2 ** (start // length))  # crude exponential backoff
        continue
    r.raise_for_status()
    page = r.json().get("data", [])
    if not page:
        break
    rows.extend(page)
    start += length

如果该端点位于登录页面之后,请先使用 session.post() ,并让 Cookie 容器承载会话。对于受 CSRF 保护的 POST 请求,请从隐藏字段或 XSRF-TOKEN cookie 中提取令牌,并将其作为请求头转发。切勿直接粘贴静态cookie字符串。该cookie会在次日过期,并导致此后每次定时任务运行失败。

将行数据导出为 CSV、JSON Lines 或 SQLite

选择下游工具实际支持的输出格式。CSV 适用于电子表格,JSON Lines 更适合流式摄取以及 LLM 或 RAG 管道,而 SQLite 则是最轻量且便于分析师使用的选项,即使系统重启也能保持数据完整。

import csv, json, sqlite3

# CSV with named headers (clearer than raw csv.writer)
with open("rows.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=records[0].keys())
    writer.writeheader()
    writer.writerows(records)

# JSON Lines
with open("rows.jsonl", "w", encoding="utf-8") as f:
    for r in records:
        f.write(json.dumps(r, ensure_ascii=False) + "\n")

# SQLite
con = sqlite3.connect("rows.db")
con.execute("CREATE TABLE IF NOT EXISTS staff (name TEXT, position TEXT, office TEXT, extn TEXT, start_date TEXT, salary TEXT)")
con.executemany("INSERT INTO staff VALUES (:name, :position, :office, :extn, :start_date, :salary)", records)
con.commit(); con.close()

csv.DictWriter 这几行额外代码是值得的,因为标题行能与字典键保持同步;这样就无需记住哪一列是索引列 3。同样的 records 列表同时驱动这三个写入器,因此在生产环境中切换格式只需修改一行代码。

备用方案:当网络断开时,使用 Playwright 渲染表格

有些网站确实不允许你接触 JSON。几秒钟就过期的签名 URL、带有严格 Origin 验证的 GraphQL 端点、通过 WebSocket 驱动的网格,以及少数定制化配置,都会迫使你转而在真实浏览器中渲染页面。Python 版的 Playwright 是完成此任务的强大现代默认选择,尽管在旧版技术栈上 Selenium 仍是一个合理的选择。

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example.com/grid", wait_until="networkidle")
    page.wait_for_selector("table.grid tbody tr")
    rows = page.locator("table.grid tbody tr").all_text_contents()
    browser.close()

在任何用于网络爬取 JavaScript 表格的备用方案中,需警惕一个陷阱:DataTables、AG Grid 和 TanStack Table 等客户端网格库通常采用渲染虚拟化机制,这意味着在任意时刻,只有当前视口内可见的行才会被加载到 DOM 中。确切的行数取决于视口大小和库的配置,因此不要依赖简单的 tr 收集方法来捕获所有数据。应通过循环滚动容器,使用 MutationObserver,或调用库自带的分页 API,直到行数不再增加。

Web 爬取 JavaScript 表格的常见陷阱

Web 爬取 JavaScript 表格时,大多数失败都是无声的。脚本运行,文件被写入,直到仪表盘显示异常,才有人发现数据有误。请注意以下情况:

  • 按索引选择表格。 tables[2] 一旦市场部在网格上方添加了一个比较控件,这种方法就会失效。请改用标题文本、ID 或唯一表头进行匹配。
  • 虚拟化网格。对 DataTables、AG Grid 或 TanStack Table 进行简单的抓取时,只能捕获可见视口中的行,而数千行数据则处于未加载状态。请通过 API 计数或分页请求来确认行总数。
  • 按区域设置格式化的数字。 1.000,50 在欧洲格式中表示 1000.50,但 Python 的 float() 会将其解读为 1.0。在进行类型转换前请先规范化字符串。
  • 日期中的时区。 "2025-04-01" 若未指定时区,解析结果将默认设为 UTC 午夜,导致每日聚合数据向下错位一行。
  • 货币符号和千位分隔符。 "$1,234" 无法转换为浮点数。请先去除非数字字符。
  • 过期 Cookie。粘贴的会话 Cookie 有效期为一天,之后会静默返回 401 状态码,某些服务器会将其包装成 HTTP 200 的 HTML 响应。
  • 反机器人 200 状态码。WAF 可能返回状态码为 200 的验证码挑战页面。 r.json() 会抛出异常,但仅当你记得调用它时。

验证并监控数据提取管道

数据抓取并非在“CSV文件生成”时即告完成,而是在次日您信任该文件时才算真正完成。 在写入器之后添加一个简易验证层:确保行数在昨日运行结果的合理范围内;若任何必填列的空值率超过阈值(1% 至 5% 较为合适),则发出明显报错;并将列集与保存的清单进行比对,以便重命名字段能标记出模式漂移,而非污染下游的连接操作。对零行运行情况单独发出警报。大多数网页抓取 JavaScript 表格管道的崩溃源于无声的缩减,而非显而易见的崩溃。

关键要点

  • Web 爬取 JavaScript 表格的默认路径是隐藏的 JSON 端点,而非无头浏览器。在编写任何代码之前,请先使用决策树。
  • 开发者工具的“网络”标签页配合触发排序或分页操作,是快速定位实际传输数据行的请求的最快途径。
  • 以无状态方式重放请求:使用公共头部、 raise_for_status()登录时使用真实会话,切勿手动粘贴个人 Cookie。
  • 分页模式各异(DataTables draw/start/length、光标、偏移量);应将循环而非单次请求视为工作单元。
  • 当网络路径经过签名、加密或不存在时,Playwright 才是合适的工具,且仅在此情况下适用。注意那些仅加载视口行数的虚拟化网格。
  • 一个可在下个季度重运行的管道应包含行数断言、空值率阈值以及列清单,而不仅仅是今天能运行的 CSV 文件。

常见问题

为什么 requests.get() 对于 JavaScript 表格会返回空行?

因为 requests 不执行 JavaScript。它首先下载服务器提供的文档,该文档包含页面框架和脚本包,但不包含数据行。数据行是由客户端代码调用 JSON 接口后添加的。您的解析器看到的是空 <table> ,因此返回空结果。

我真的需要 Selenium 或 Playwright 来抓取动态表格吗?

通常不需要。如果开发者工具显示了一个用于初始化网格的 JSON 请求,只需使用 requestshttpx 重放该请求,比使用浏览器更快、更经济且更可靠。仅当调用经过签名、采用严格源检查的 GraphQL、基于 WebSocket,或无法通过普通 HTTP 客户端访问时,才使用 Playwright。

如何抓取需要登录或 CSRF 令牌的 JavaScript 表格?

使用 requests.Session ,这样 Cookie 会在不同请求间保持有效。将凭据提交至登录端点,然后从隐藏输入框或 XSRF-TOKEN cookie 中读取 CSRF 值,并将其作为数据请求的头部字段转发。切勿硬编码从您自己的浏览器复制的会话 cookie。

如果隐藏的 API 每次只返回一页数据行怎么办?

循环处理。检查请求参数(start, length, cursor, page, offset),并递增这些参数,直到响应返回零行数据或 has_more: false 标志。针对 HTTP 429 状态码添加指数退避机制,并设置硬性请求上限,以防止服务器端错误导致爬虫陷入无限循环。

结论

当你不再将渲染后的页面视为“绝对真理”时,JavaScript 表格的网页抓取就不再令人望而生畏。浏览器只是渲染器;网格背后的 JSON 接口才是真正的数据源。在开发者工具中找到该接口,使用 requests进行重放,正确分页,验证输出结果,你便能获得一个能在下次页面重构中存活的脚本,而非一个默默用空行填满数据仓库的脚本。

将无头浏览器留给真正需要它的场景。涉及签名网络请求、WebSocket 数据源或强力反爬虫保护的网站会迫使你使用它,而这正是备用方案至关重要的时刻。当你确实需要使用浏览器时,请务必采用虚拟化渲染,验证行总数,并保持监控层正常运行。

如果您不愿亲自维护代理轮换、浏览器指纹和验证码处理程序,WebScrapingAPI 可以部署在您现有 requests 代码前端,从那些原本会阻止直接访问的网站返回干净的 HTML 或 JSON 数据,同时保持上层的解析和分页逻辑不变。无论你选择哪种方案,操作指南都是一样的:选择最经济且有效的数据提取路径,并确保脚本足够“诚实”,能在无法正常工作时及时告知你。

关于作者
Andrei Ogiolan, 全栈开发工程师 @ WebScrapingAPI
Andrei Ogiolan全栈开发工程师

安德烈·奥吉奥兰(Andrei Ogiolan)是 WebScrapingAPI 的全栈开发工程师,他在产品各领域均有贡献,并协助为该平台构建可靠的工具和功能。

开始构建

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

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