返回知识中心
入门

Web scraping JavaScript:渲染、分页与滚动

Web scraping 一个 JavaScript 站点却没有数据:先判断是选择器错、可直接调的 XHR,还是必须渲染,再处理分页与滚动。

BotBrowser Team

文档中心

想直接进入 入门 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

大多数「抓取脚本坏了」的报告描述的是同样的三秒困惑:请求返回了 HTTP 200,HTML 是真的,而字段不在里面。有用的下一步不是装一个无头浏览器,因为其中很大一部分情况可以用一个你自己就能发的请求解决,而浏览器会把一个又快又稳的任务变成慢任务。诊断是有顺序的,这个顺序就是这篇要讲的东西。BotBrowser 为确实需要浏览器的情况提供一套已声明的渲染环境,让页面算出的布局与目标期望的布局一致;它不判断三种成因中哪一种成立,也不会让第三方站点返回不同的数据。

从空字段出发的判断路径:先查原始响应,再查网络日志,再判断是否需要渲染,最后才在直接请求、渲染页面与滚动或分页循环之间选择。

摘要

  • 在伸手拿浏览器之前先读原始响应。如果 curl 里已经有这个取值,问题在你的选择器,不在渲染。
  • 如果取值不在,去网络日志里找携带它的 JSON 或 HTML 请求;直接调用那个请求通常比渲染更快、更便宜、更稳定。
  • 只有第三种情况需要浏览器,而它再分三种:水合之后才出现的数据、页面在加载后自己发出的请求所取回的数据,以及只有交互之后才出现的数据。序列化进初始 HTML 的状态对象不在其中,因为不用浏览器就能读到它。
  • 分页与无限滚动是同一个问题的两种形态:触发点不是 URL 变化,所以一个等待导航的循环会悄悄重复读第一页。
  • 用页面产生的信号来停止,而不是用你假定的计数。「没有下一页按钮」与「容器不再增长」是两个不同信号,而且各自都可能出错。

目录

先读原始响应

第一个测量只要一条命令,而且能解决大部分争论。用同一个 URL、同一组浏览器会发的请求头抓一次,然后在字节里搜索你期望的取值。

curl -sSL -H 'accept-language: en-US,en;q=0.9' 'https://example.test/list?page=1' -o /tmp/page-1.html
grep -c 'data-product-id' /tmp/page-1.html

三种结果对应三件不同的事。计数非零说明取值就在响应里,出错的是解析器,那是选择器或编码的缺陷,与渲染无关。计数为零说明服务器发来的是外壳,下一步是找出谁把它填满。HTTP 状态不是 200 则两者都不是,而响应体通常会说明原因。

同一个检查反过来也成立:一个看起来存在却解析不对的字段。一个被转义、被包在 JSON 字符串里、或跨两个相邻节点拆开的取值,能通过幼稚的子串测试,却过不了严格的选择器,这就是为什么计数比肉眼看更可信。

找到携带数据的那个请求

当外壳是空的,取值通常来自两个地方之一:页面加载后发出的一次网络请求,或者被序列化进初始 HTML 的状态对象。两者都不需要浏览器就能读。

网络这一种,在普通浏览器里打开一次页面,把网络面板过滤到 XHR 或 fetch,找一个响应体包含该字段的请求。把它复制成 curl 并与页面请求比对。差异通常是一个请求头、一个 cookie,或者一个页面在运行时算出来的查询参数,而每一项差异都可以单独验证。

import requests

session = requests.Session()
session.headers.update({"accept": "application/json", "accept-language": "en-US,en;q=0.9"})
listing = session.get("https://example.test/api/list?offset=0&limit=24", timeout=30)
listing.raise_for_status()
rows = listing.json()["items"]
print(len(rows), rows[0]["id"])

状态这一种,在原始 HTML 里搜携带 JSON 的 script 标签,常见标识符有 __NEXT_DATA__、__NUXT__,或内联的 window.__STATE__。如果字段在那个 blob 里,页面已经把数据交给你了,解析器可以直接读。

两个测试决定这个直接请求值不值得保留。重放两次并比较字节数,然后在工作负载需要时换一个网络位置再重放一次。两次相同请求之间形态会变的响应不是稳定来源,而渲染页面也修不好这一点,只会把这种不稳定藏进一条更慢的流水线后面。

还有第四种结果值得单独一句,因为它看起来像取值缺失,其实不是。有些页面按请求方不同而给出不同答案:同一个 URL 对呈现了合理客户端的浏览器返回完整文档,对什么都不呈现的请求返回一个桩。那不是渲染问题,也不是选择器问题;那是服务器端的决定,可观察的差异在于响应体本身的形态不同,而不是稍后才被填上。记录几次请求的响应大小与响应体的短哈希,就足以把这种情况与一个本会水合的外壳区分开。

这个区分对成本的影响和对正确性的影响一样大。每页重放一个 JSON 请求的任务,在一台小机器上每分钟能跑几百页;同一个任务走浏览器,大部分时间花在启动、导航与绘制上。在一个本可以用直接请求解决的场景里选择浏览器路径,是整个流程里最昂贵的错误,而且它很隐蔽,因为慢的那个版本也能跑通。

什么时候真的需要渲染

第三种情况是真实的,也是浏览器存在的理由。它还会再分成三种形态,各自需要不同的等待策略,选错就会产生那个经典的「本地能跑、CI 失败」报告。

形态数据在哪里出现该等什么
水合框架启动并替换外壳之后只有水合后的树才有的节点
延迟请求页面在加载后发出的请求之后该请求的响应,然后是它填充的节点
交互触发点击、切换标签或改变筛选之后该交互本该产生的那个节点

等 networkidle 完全区分不了这三种形态。Playwright 把它定义为至少半秒钟内没有网络连接,所以它在任何安静的页面上都会结束:一个交互触发的页面在有人交互之前一直安静,而一个安排在稍后触发的延迟请求在等待开始时同样安静。在一个把请求安排在短暂延迟之后的 fixture 上,等待在请求出现之前就结束了,提取读到零行。等一个固定延时尚在快机器上覆盖全部三种,在负载下一种都覆盖不了,这就是固定延时会成为「评审通过、生产失败」最常见原因的理由。

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(executable_path="/path/to/botbrowser/chrome")
    context = browser.new_context(locale="en-US", timezone_id="America/New_York")
    page = context.new_page()
    page.goto("https://example.test/list", wait_until="domcontentloaded")
    page.wait_for_selector("[data-product-id]", state="attached", timeout=30_000)
    rows = page.eval_on_selector_all(
        "[data-product-id]",
        "nodes => nodes.map(n => n.getAttribute('data-product-id'))",
    )
    print(len(rows), rows[:3])
    browser.close()

wait_for_selector 里的选择器应当与提取时用的那个一致。等一个通用容器会外壳一出现就通过,而提取随后读到零行;等承载取值的那条属性则会大声失败,那才是你想要的结果。

渲染页面还需要一个「留下什么」的决定。水合之后的 DOM 比产生它的 HTML 更大,每一步都天真地保存 page.content() 会把一次 40 页的运行变成几个 GB 的近重复标记。保留提取出的行,加上一条很小的溯源记录(URL、产生数据的那次请求、时间戳、渲染模式),既足以复核一个结果,又便宜到每页都能留。

这条溯源记录也是让回归可诊断的东西。当下个月某个字段消失,问题在于页面变了、渲染器变了,还是请求变了,而只有同时写下这三者的记录才能回答。只存提取行的运行无法区分三者,而通常的应对是重新渲染一切然后祈祷。

不改变 URL 的分页

URL 参数那一种最容易:递增参数、再次请求,在响应重复或列表为空时停止。较难的两种是 API 游标,以及一个就地改动 DOM 的按钮。

游标这一种,下一个请求需要上一个响应里的某个取值,所以循环必须携带状态而不是数页数。对着一个游标端点递增 page=2 的循环,要么再次返回第一页,要么报错,而两种结果看起来都像解析失败而不是分页失败。

按钮这一种失败方向相反。URL 不变,所以一个等待导航事件的循环会一直等下去或超时,而一个点完立刻读的循环拿到的是上一页。可靠的模式是从当前页捕获一个取值,点击,然后等到那个取值不再存在。

from playwright.sync_api import TimeoutError as PlaywrightTimeoutError

def read_ids(page):
    return page.eval_on_selector_all(
        "[data-product-id]",
        "n => n.map(x => x.getAttribute('data-product-id'))",
    )

def click_and_classify(page, previous_ids, timeout_ms=15_000, poll_ms=100, end_confirm_ms=3_000):
    """Click next, then return "changed" or "end-marker", or raise.

    "end-marker" is returned only when the page's own control reports the end and
    keeps reporting it for end_confirm_ms. A control that is disabled while the
    next page loads is not an end, and an unchanged list is not proof of an end,
    so anything else raises instead of returning a completion the page never gave.
    """
    button = page.query_selector("[data-testid=next-page]")
    if button is None or button.is_disabled():
        return "end-marker"
    button.click()
    budget = timeout_ms
    marker_for = 0
    while budget > 0:
        page.wait_for_timeout(poll_ms)
        budget -= poll_ms
        if read_ids(page) != previous_ids:
            return "changed"
        button = page.query_selector("[data-testid=next-page]")
        if button is None or button.is_disabled():
            marker_for += poll_ms
            if marker_for >= end_confirm_ms:
                return "end-marker"
        else:
            # Re-enabled without repainting: the next page is still loading.
            marker_for = 0
    raise PlaywrightTimeoutError(
        f"no list change and no end marker for {end_confirm_ms} ms within {timeout_ms} ms"
    )

seen = set()
reason = "loop guard"
for _ in range(200):
    ids = read_ids(page)
    new_ids = [i for i in ids if i not in seen]
    if not new_ids:
        reason = "no new ids"
        break
    seen.update(new_ids)
    if click_and_classify(page, ids) == "end-marker":
        reason = "end marker held"
        break
print(len(seen), reason)

停止规则才是会安静出错的那部分。「没有下一页按钮」与「没有更多结果」并不等同:一个被禁用的按钮与一个不存在的按钮,都表现为 query_selector 返回 None 或 is_disabled() 返回真,除非记录原因,否则两者看起来一模一样。一个藏着下一页按钮的无限滚动容器会一直循环下去,这就是循环除了软信号之外还需要硬上限的原因。

分类才是容易搞错的那部分,陷阱在于三种不同情形下的静止看起来一模一样:列表已经结束、下一页还在加载、点击根本没有被记录。最后一页通常会禁用自己那个按钮而不是渲染新的一组,所以被禁用的控件被当作结束标记,而不是「没有变化」;但一个页面在取下一页时禁用控件,会在整个请求期间产生同样的状态。确认窗口就是把它们分开的东西,它必须长于任务中见过的最慢重绘,而不是长于最快的那次。这个窗口也是方法的局限,不是细节:比窗口更慢的加载仍然会被读成结束,修法是先测量目标上最慢的重绘再决定这个数字,而不是照搬别人页面上的默认值。既没有重绘也没有持续保持的标记出现时,调用就抛错,因为没有变化的列表并不能证明列表结束了,而依据这种证据报告完成的运行错在没人会注意的方向:它看起来已经完成。

还有一种分页形态出现得足够频繁,值得点名:通过表单提交而不是链接来翻页的列表。URL 也许会变,但页面是从一个 POST 响应重绘的,所以跟着链接走的循环找不到可跟的东西。修法与按钮那一种同一原理:提交,然后等到上一个结果集里的某个取值消失,并记录产生新结果的那次请求。

排序与上述全部相互作用。一个按会在任务运行期间变化的字段(例如价格或时间)排序的列表,会在页与页之间重排,即使分页逻辑完全正确也会产生重复与跳过。为每一行记录一个稳定标识符并按它去重,才能把一次重排后的运行变成正确的集合,而不是一个看起来合理、实际有洞的结果。

无限滚动与它的停止规则

无限滚动用哨兵替代按钮。驱动它的是两种机制,失败方式不同:一种是比较 scrollTop 与 scrollHeight 的滚动监听,另一种是在哨兵进入视口时触发的 IntersectionObserver。观察器版本总会先给一次初始回调,所以屏幕外的哨兵会报告一条 ratio 为 0 的非相交条目,之后在哨兵进入 root 之前保持沉默。这就是为什么一个按固定像素数滚动窗口的脚本,在一个由容器而非窗口承载滚动的布局上什么也加载不出来:观察器会一直报告哨兵从未进入。

可靠的循环滚动真正拥有滚动的那个元素,等条目数变化,并在连续若干次尝试计数不变时停止。

stable_rounds = 0
last_count = page.eval_on_selector_all("[data-product-id]", "n => n.length")
while stable_rounds < 3:
    count = page.eval_on_selector_all("[data-product-id]", "n => n.length")
    if count == last_count:
        stable_rounds += 1
    else:
        stable_rounds = 0
        last_count = count
    page.eval_on_selector("[data-scroll-root]", "el => el.scrollBy(0, el.clientHeight * 2)")
    page.wait_for_timeout(600)
print("rows:", last_count, "stopped after", stable_rounds, "unchanged rounds")

连续三轮不变是一个启发式,不是证明,而记录应当这么写。一个慢后端可以在三轮不变之后,第四轮才加载出更多,所以计数要和停止原因一起写进输出。

内存是第二个限制。一个增长到几千行以上的列表会让之后每一次 eval_on_selector_all 都更慢,页面还可能开始丢弃屏幕外的节点,这意味着后面读到的东西比页面实际拥有的更少。每批到达就写盘,能让一次运行保持有界,并让部分结果可检查。

渲染环境会改变哪些内容落在视口内,而这恰好只对上面的一种形态有意义。一个懒加载列表在哨兵进入视口时触发它的观察器,所以比目标期望更小的视口每次滚动加载得更少,更大的视口加载得更多。布局以 CSS 像素计算,所以单靠 device pixel ratio 不会改变固定视口内能放下多少行;改变这个容纳量的是 CSS 视口大小、缩放级别,或改变布局视口而不只是改变栅格密度的设备模拟缩放。BotBrowser 记录并提供配置文件携带的屏幕尺寸与 device pixel ratio,这正是让这些输入成为可声明而不是偶然的原因;它不判断页面是否需要渲染,也不保证目标返回它保留的数据。

这是唯一一处「抓取脚本坏了」与「渲染环境不同」从外面看起来一模一样的地方。同一份代码、同一个目标,一次运行在某台机器上提前停止、在另一台上跑完,通常是 CSS 视口、缩放级别或布局视口的差异,而不是检测响应;区分的方法是同时记录视口与行数。当视口相同而计数仍然不同,剩下的解释是该会话下目标行为不同,那是另一场调查。

两个操作习惯能让一次渲染任务长期保持诚实。第一,在提取到零行时失败,而不是写一个空文件:一个产出只有表头没有数据行的文件,在仪表盘上看起来是成功的,而一个抛错的运行看起来像缺陷。第二,每次运行保留一个记录下来的页面,连同响应体与提取出的行一起保存,这样后来的读者不必重跑整个任务就能复现服务器发来的内容与提取产出的内容之间的差异。

两个习惯都不需要浏览器。当浏览器在路径里时两者都更有价值,因为响应与 DOM 之间的差距正是那个在你这边代码完全没变的情况下会变化的部分。为每一页记录原始响应、渲染模式与行数的团队,能用一次查询回答「是页面变了还是我们变了」;只记录行数的团队根本回答不了。

实践结论

渲染之前先诊断:读原始响应,再查网络日志,最后才打开浏览器。需要渲染时,等承载取值的那个节点,而不是等外壳或等时钟。分页与滚动时,从当前页捕获一个取值并等它变化或消失,并记录循环停止的原因,这样一次被截断的运行会以被截断的样子呈现出来。浏览器选择准则 讲如何在这些工作负载上比较候选,SERP 吞吐规划指南 讲同一个循环在规模上跑时什么会变。

最后一段关于并发,因为它对分页逻辑的改变比人们预期的更大。两个 worker 对同一个列表分页,各自看到的是一个移动的窗口,而游标端点会按它的实现方式给它们重叠或跳过的切片。分页是对一个可能在遍历途中变化的序列做有状态的遍历,所以安全的形态是每个序列一个遍历者,或者用一个分区查询让每个 worker 拥有互不相交的切片。记录哪一页由哪个 worker 产出,能把一次重叠变成一次观察,而不是一个谜。

来源

#网页抓取#JavaScript 渲染#分页#无限滚动#Playwright

本文属于指南: 浏览器自动化入门:首次运行地图

在 BotBrowser 里跑一遍同样的检查

加载 profile,让刚读到的这些信号在浏览器内部保持一致,并在正式上线前先在自己的机器上确认结果。