SPA 网页抓取渲染策略:API、Hydration、Playwright 与浏览器降级

发布时间
阅读时长约 5 分钟

Key Takeaways

一篇面向工程实践的 SPA 抓取决策指南:从 HTML、Hydration、XHR/GraphQL 到 Playwright 浏览器回退,并覆盖无限滚动、就绪判断、证据留存与成本控制。

🧩
直接结论: 抓取 SPA 不应该默认启动浏览器。先判断数据是否已经存在于初始 HTML、Hydration 状态或公开接口中;只有必须执行 JavaScript、触发交互或观察渲染后 DOM 时,才使用 Playwright。

SPA 只是前端交互模型,不等于“HTML 一定没有数据”。React、Vue、Next.js 等应用可能采用 CSR、SSR、SSG、流式渲染或混合模式。真正决定采集方案的是:目标数据在哪一层出现,以及必须执行哪些动作才能得到它。

先用一个决策树判断

这个顺序的目标不是“绕过浏览器”,而是减少不必要的 CPU、内存、带宽和失败面。

第一步:检查初始 HTML

先用普通 HTTP 客户端获取页面:

bash
curl --location \
  --connect-timeout 10 \
  --max-time 30 \
  --output page.html \
  'https://example.com/product/123'

检查:

  • 商品名、价格或正文是否已经存在
  • <script type="application/ld+json">
  • 页面中的 JSON 状态
  • canonical、语言、市场信息
  • 是否只是一个空壳 <div id="root"></div>

如果业务数据已经在 HTML 中,就没有必要为了同一份数据启动 Chromium。

SSR、CSR 与 Hydration 的区别

SSR/SSG:服务器先生成可见 HTML,浏览器收到后可以直接看到主要内容。

CSR:初始 HTML 可能只有壳,JavaScript 运行后再请求数据并生成 DOM。

Hydration:服务器已经生成 HTML,客户端 JavaScript 再把交互逻辑“接上去”。React 官方 hydrateRoot 文档明确描述了这一过程:React hydrateRoot

因此看到 React 并不意味着必须浏览器抓取;很多 React 页面在 JavaScript 执行前已经拥有足够数据。

第二步:寻找 Hydration 或内嵌状态

常见位置包括:

  • __NEXT_DATA__
  • window.__INITIAL_STATE__
  • JSON script block
  • RSC/框架序列化数据
  • JSON-LD

不要只按变量名硬编码。先保存原始 HTML,再定位包含目标业务字段的数据块,并验证它与页面显示一致。

一个简单的 Python 示例:

python
import json
from bs4 import BeautifulSoup
import requests

url = 'https://example.com'
response = requests.get(url, timeout=(10, 20))
response.raise_for_status()

soup = BeautifulSoup(response.text, 'html.parser')
script = soup.find('script', id='__NEXT_DATA__')

if script and script.string:
    data = json.loads(script.string)
    print(data.keys())

不要假设所有 Next.js 页面都有相同结构;框架版本和路由模式会变化。

第三步:识别 XHR、fetch 和 GraphQL

如果数据不在初始 HTML,浏览器通常会通过网络请求加载它。

重点观察:

  • XHR/fetch URL
  • GraphQL endpoint 和 operation name
  • query 参数
  • request body
  • 必须的公开 cookie / locale 参数
  • 分页 cursor
  • 响应 Schema

如果接口是公开且自动访问得到授权,直接请求通常比重放完整浏览器更稳定。

但不要把浏览器里看到的接口自动视为“允许无限调用”。仍应检查授权、服务条款、速率和数据使用边界。

API-first 的验证标准

从接口获取数据后,至少验证:

json
{
  "requestedUrl": "https://example.com/product/123",
  "source": "public-json-endpoint",
  "status": 200,
  "schemaVersion": "v1",
  "recordId": "123",
  "market": "US",
  "valid": true
}

不要只因为 JSON 能解析就认为结果正确。需要检查业务主键、市场、币种、时间和关键字段。

什么时候必须使用 Playwright

浏览器更适合这些情况:

  • 数据必须执行 JavaScript 才产生
  • 需要点击、选择、滚动或切换 tab
  • 内容依赖浏览器状态
  • 需要获取渲染后布局或可见文本
  • API 调用依赖复杂前端状态且没有稳定、授权的直接接口
  • 需要保存 trace、截图或 DOM 作为 QA 证据

Playwright 最小渲染模板

typescript
import { chromium } from 'playwright';

const browser = await chromium.launch({ headless: true });

try {
  const context = await browser.newContext();
  try {
    const page = await context.newPage();

    const response = await page.goto('https://example.com', {
      waitUntil: 'domcontentloaded',
      timeout: 45_000,
    });

    if (!response) throw new Error('No navigation response');

    const product = page.locator('[data-testid="product-card"]').first();
    await product.waitFor({ state: 'visible', timeout: 15_000 });

    console.log(await product.innerText());
  } finally {
    await context.close();
  }
} finally {
  await browser.close();
}

Playwright 官方文档把 networkidle 标记为 DISCOURAGED,建议使用能够代表页面业务就绪的断言或元素信号,而不是等待网络完全安静:Playwright Page

不要把 networkidle 当成 SPA 的完成条件

现代 SPA 常常持续存在:

  • analytics
  • polling
  • WebSocket
  • 推荐流
  • 广告
  • telemetry

页面可能永远达不到你想象中的“网络静止”。更可靠的是等待:

  • 产品卡出现
  • 表格行数达到预期
  • loading skeleton 消失
  • 某个 API response 返回
  • 页面状态文字变为 Ready

等待特定接口

typescript
const responsePromise = page.waitForResponse(response =>
  response.url().includes('/api/products') && response.status() === 200
);

await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
const apiResponse = await responsePromise;
const payload = await apiResponse.json();

这种方法适合把“DOM 抓取”转成“浏览器负责建立状态,代码读取结构化响应”。

无限滚动不能靠固定 sleep

固定 sleep(3000) 会在快页面浪费时间,在慢页面丢数据。

更好的循环条件:

  1. 记录当前业务记录数。
  2. 滚动或点击“加载更多”。
  3. 等待记录数增加、cursor 变化或接口响应。
  4. 连续多轮没有新增则停止。
  5. 设置最大页数、最大时间和最大字节数。

伪代码:

typescript
let stableRounds = 0;
let previousCount = 0;

for (let round = 0; round < 30; round++) {
  const cards = page.locator('[data-testid="item"]');
  const current = await cards.count();

  stableRounds = current === previousCount ? stableRounds + 1 : 0;
  if (stableRounds >= 2) break;

  previousCount = current;
  await page.mouse.wheel(0, 2500);
  await cards.last().waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {});
}

真实项目还应结合网络响应或业务游标,而不是只看 DOM 数量。

Lazy loading 与虚拟列表

有些列表只在 viewport 中保留少量 DOM 节点。此时 locator.count() 可能不会随总数据量增加。

识别信号:

  • DOM 数量稳定但内容不断替换
  • 滚动条高度变化
  • API cursor 持续推进
  • React/Vue virtualized list

这种页面应优先从网络数据层获取完整记录,并把 DOM 用作抽样验证。

代理应该在哪一层加入

如果 SPA 数据通过 HTTP API 即可获取,可以在 HTTP 客户端配置代理;如果必须浏览器渲染,再在 Browser 或 BrowserContext 层配置代理。

Playwright 官方支持 HTTP(S) 和 SOCKS5,并支持 browser/context 两级配置:Playwright Network

不要为了“使用代理”而强制把本来可以 HTTP 完成的任务升级到浏览器。

资源拦截要谨慎

图片、字体和媒体通常可以降低带宽,但不要无脑阻止所有 script、XHR 或 CSS。SPA 的核心数据和渲染逻辑可能依赖这些资源。

建议先记录资源类型和业务影响,再决定是否拦截。

页面分类比 HTTP 200 更重要

SPA 常见假成功:

  • HTTP 200 + 登录页
  • HTTP 200 + CAPTCHA/挑战页
  • HTTP 200 + consent page
  • HTTP 200 + skeleton 永不消失
  • HTTP 200 + 错地区页面

定义业务分类:

plain text
expected
login_required
challenge
consent
wrong_market
empty_state
parser_mismatch
transport_error

只有 expected 才应该计入有效结果。

证据与回归

每次解析器发布建议保存:

  • 原始 URL
  • 最终 URL
  • 抓取时间
  • HTTP 状态
  • 数据来源层:HTML / hydration / API / browser
  • 关键响应样本
  • 内容 hash
  • parser version
  • 必要时的 screenshot/trace

当站点前端改版时,你可以判断到底是数据源变了、选择器变了,还是路由被拒绝。

成本模型

plain text
HTTP HTML < embedded JSON/API < browser render

这不是绝对性能排名,但通常是一个合理的工程起点。浏览器增加 CPU、内存、启动时间和资源请求,因此应该服务于“必须执行 JavaScript/交互”的需求。

最终指标建议使用:

plain text
valid_records / attempt
bytes / valid_record
browser_seconds / valid_record
retry_amplification
parser_failure_rate

上线检查清单

  1. 是否先检查了 HTML?
  2. 是否检查了 hydration/JSON-LD?
  3. 是否确认了接口访问授权?
  4. 浏览器是否真的是必要条件?
  5. 是否等待业务信号而非固定 sleep?
  6. 是否避免依赖 networkidle
  7. 无限滚动是否有确定停止条件?
  8. 是否区分 HTTP 成功与业务成功?
  9. 是否保存足够证据用于 parser 回归?
  10. 是否设置最大时间、重试和流量预算?

相关内容

AV
技术团队背书同行架构师审查 & 实测校验

Alex Vance

核心基础设施架构团队

本文由 BytesFlows 工程团队审查。代码示例适用于合规的公开网页数据采集、QA 自动化、SEO 监测与市场研究工作流。实际基准表现可能因目标站点防爬策略、地理位置、客户端运行环境及请求频率而异。