Playwright 动态列表采集:网络响应、滚动加载与游标分页

在 Playwright 中观察列表数据响应,驱动滚动加载,按游标判断结束并按业务 ID 合并,避免虚拟列表、重复项和失败响应造成漏采。

亿牛云技术团队2026年10月28日4 分钟阅读

无限滚动页面不一定把所有记录同时留在 DOM 中。滚动很久以后再一次性读取节点,可能只拿到最后一个窗口;看到条数不变,也可能是网络失败,而不是到了末尾。

本文提供一个完整的浏览器教学页面和三段分页响应。我们在动作前注册响应等待,逐段收集数据,用 next_cursor 判断结束,按商品 ID 合并。材料包含一条跨页重复,便于检查去重是否掩盖了覆盖问题。

安装与范围

使用 Python 3.12:

python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install playwright
python -m playwright install chromium

示例用浏览器路由返回正文材料,不访问 example.16yun.cn 的真实服务。动态列表只有四个业务商品,详情地址没有被访问,不能把列表结果称为详情采集完成。

先定义浏览器会收到的数据

创建 cursor_list.py:

import asyncio
import json
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from playwright.async_api import async_playwright
 
PAGES = {
    "": {"items": [{"id": "p1", "name": "商品一"}, {"id": "p2", "name": "商品二"}], "next_cursor": "second"},
    "second": {"items": [{"id": "p2", "name": "商品二"}, {"id": "p3", "name": "商品三"}], "next_cursor": "last"},
    "last": {"items": [{"id": "p4", "name": "商品四"}], "next_cursor": None},
}
HTML = """
<style>.card {height:600px;border:1px solid #ddd}</style>
<h1>教学列表</h1><div id="list"></div><p id="state"></p>
<script>
let cursor = '', loading = false, ended = false;
async function load() {
  if (loading || ended) return;
  loading = true;
  try {
    const response = await fetch('/api/items?cursor=' + encodeURIComponent(cursor));
    if (!response.ok) throw new Error('API failed');
    const data = await response.json();
    const list = document.querySelector('#list');
    for (const item of data.items) {
      const card = document.createElement('div');
      card.className = 'card'; card.textContent = item.name;
      list.appendChild(card);
    }
    cursor = data.next_cursor; ended = cursor === null;
    list.dataset.next = ended ? 'END' : cursor;
  } catch (error) {
    document.querySelector('#state').textContent = '加载失败';
  } finally { loading = false; }
}
window.addEventListener('scroll', () => {
  if (innerHeight + scrollY >= document.body.scrollHeight - 10) load();
});
load();
</script>
"""

游标是服务给出的不透明标识,不解析 second 或 last 推算页码。真实接口可能使用长字符串或签名,原样携带,不能自行加一。

捕获响应并驱动后续滚动

继续在同一文件追加:

async def main():
    records, cursors = {}, set()
    async with async_playwright() as pw:
        browser = await pw.chromium.launch()
        try:
            context = await browser.new_context(viewport={"width": 1000, "height": 400})
            page = await context.new_page()
 
            async def respond(route):
                parsed = urlsplit(route.request.url)
                if parsed.path == "/api/items":
                    cursor = parse_qs(parsed.query, keep_blank_values=True).get("cursor", [""])[0]
                    data = PAGES.get(cursor)
                    await route.fulfill(
                        status=200 if data is not None else 400,
                        content_type="application/json", body=json.dumps(data or {"error": "cursor"}),
                    )
                else:
                    await route.fulfill(content_type="text/html; charset=utf-8", body=HTML)
 
            await page.route("https://example.16yun.cn/**", respond)
            def is_items(response):
                return urlsplit(response.url).path == "/api/items" and response.request.method == "GET"
 
            async with page.expect_response(is_items) as first:
                await page.goto("https://example.16yun.cn/")
            response = await first.value
            for _ in range(10):
                if response.status != 200:
                    raise ValueError("分页请求失败")
                data = await response.json()
                if not isinstance(data.get("items"), list):
                    raise ValueError("分页响应不是预期结构")
                for item in data["items"]:
                    if not isinstance(item.get("id"), str) or not item.get("name"):
                        raise ValueError("商品 ID 或名称缺失")
                    if item["id"] in records and records[item["id"]] != item:
                        raise ValueError("同一 ID 跨页数据冲突")
                    records[item["id"]] = item
                next_cursor = data.get("next_cursor")
                marker = "END" if next_cursor is None else next_cursor
                await page.wait_for_function(
                    "marker => document.querySelector('#list').dataset.next === marker",
                    arg=marker,
                )
                if next_cursor is None:
                    break
                if not isinstance(next_cursor, str) or next_cursor in cursors:
                    raise ValueError("分页游标无效或循环")
                cursors.add(next_cursor)
                async with page.expect_response(is_items, timeout=15000) as pending:
                    await page.mouse.wheel(0, 5000)
                response = await pending.value
            else:
                raise ValueError("超出本轮分页预算")
        finally:
            await browser.close()
    Path("items.json").write_text(
        json.dumps({"source_kind": "teaching_material", "items": list(records.values())},
                   ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print({"unique_items": len(records)})
 
asyncio.run(main())

执行:

python cursor_list.py

预期接收三段分页材料,合并成 p1 至 p4 四条商品。第二段重复 p2 不增加唯一数,但不妨碍继续处理 next_cursor。这个数量来自正文材料,不是对线上站点的完整性测量。

为什么必须在动作之前等待响应

先滚动、再注册 expect_response,可能错过很快返回的请求。示例把滚动动作放在等待上下文中,保证先建立观察再触发加载。用法见 Playwright 网络文档。

接口响应结束后,页面渲染仍可能没完成。教学页将 next 标记写入 DOM 后再继续滚动,避免在列表尚未增高时重复滚到同一位置。真实站点应找到等价的加载完成或内容更新证据,而不是照搬该标记。

实际页面还可能有预取、广告和多个列表接口。路径匹配只是本例范围;迁移时应结合请求方法、目标 API、筛选参数与预期游标,避免把无关响应接到当前任务。

结束条件不应只看行数

明确的 null 游标是本例结束依据。重复页、空批次、网络失败或可见 DOM 不增长,都不能直接当成结束。

如果接口允许“空 items 但还有下一游标”,应按接口合同继续,并保留预算和循环检测。若它不允许这种状态,则应作为数据异常,不静默跳过。

虚拟化列表的 DOM 条数受窗口控制。保存已经观察到的响应,比等最后一次从 DOM 抓全更有解释性,但仍要确认接口里的条目确实属于当前筛选和用户可见范围。

常见问题与修复

滚动后等不到响应。 检查是否真的到达触发区、页面是否仍在 loading、观察路径是否正确,以及是否已到末尾。先看页面状态,不把超时改成“采集结束”。

同一游标反复出现。 保存当前请求与响应,停止循环,检查筛选参数、接口游标和重试逻辑。无限滚动不能靠无限重试解决。

记录越来越多但都是重复。 检查响应对应的游标和业务 ID。去重能控制输出,但不能证明发现了新页。

网络响应有字段,页面没有。 可能是隐藏数据、预取或另一列表。先确认任务是否应使用它,不自动把响应里所有字段都纳入业务数据。

页面不是 GET 分页。 POST body 也是分页合同的一部分,等待谓词和请求复用都要同步调整。不能只替换 URL。

中断后旧文件还在。 当前例子完成后才导出,没有持久恢复。为批次记录状态,不能用旧 items.json 判断失败运行的结果。

动态列表的可靠结束来自接口与页面状态,而不是滚动次数。游标、响应和业务记录之间的对应关系,才是排查漏采的基础。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。