数据抓取

如何在规模上可靠地抓取分页与无限滚动

分页正是抓取器无声丢数据的地方:跳过的页、重复计数、过早停下。如何把每个条目恰好取一次,并知道你到底有没有取完。

Chris Collins

Chris Collins

2026年8月6日 · 1 分钟阅读

分页看起来像一次抓取里那个无关紧要的部分。把 page=2 加一,循环到没有”下一页”按钮为止,完事。它也正是抓取器比在任何别处都更频繁地无声丢数据的地方,而且这种丢失是安静的:你采到第一到第八页,漏掉第九到第四十页——因为站点给你封了顶——却什么错也不报。或者你把条目重复计数了,因为列表在你脚下移动了。或者你过早停下,因为加载暂停了、而你把它当成了结尾。把最后一个条目恰好取一次、并知道你取到了它,这就是全部的游戏。

问题有两种形态——经典分页和无限滚动——而它们共享一个核心问题:我怎么把一切恰好采一次、并知道我到底有没有采完?下面就是对这两者如何回答它。

经典分页:搞清你手上的是哪一种

在写循环之前,先辨认分页机制,因为三种常见类型里有两种带着会腐坏你数据的坑。

按偏移或页码的分页?page=5?offset=100&limit=25)最常见、也最危险。有两件事会出错。第一,漂移:如果底层列表在两次请求之间发生变化——而在一个活跃站点上它会变——被推到顶部的新条目会把一切往下挤,于是第二页现在和第一页重叠了、有个条目从缝里溜走了。绝不按位置去重;按一个稳定的唯一 ID 去重。第二,深分页的封顶:许多站点拒绝服务超过第 100 页或偏移 10000 的部分,于是尾部靠偏移根本够不着。当你撞上那堵墙,你没法一页页翻到底,你得用另一种方式给数据分区——按日期区间、类别、或价格带——并在每个切片之内分页,好让任何切片都不超过那个封顶。

按游标或键集的分页?after=<token>)是稳健的那种。响应递给你一个通往下一批的游标,你跟着它走,当它缺席时你就停下。它对漂移免疫,因为游标指向数据里一个稳定的位置、而不是一个会移动的偏移。只要站点提供它就优先用它,并且绝不试图去构造或猜测一个游标——把它当作不透明的,只跟随人家给你的那一个。

cursor, seen = None, set()
while True:
resp = fetch(url, params={"after": cursor} if cursor else {})
for item in resp["items"]:
if item["id"] not in seen: # 按稳定 id 去重,绝不按位置
seen.add(item["id"]); yield item
cursor = resp.get("next_cursor")
if not cursor: # 缺席的游标才是真正的结束信号
break

这里最有用的一招,是去看网络层、而不是看渲染出来的 HTML。在浏览器里打开目标,观察 XHR/fetch 请求,你会非常经常地发现页面背后坐着一个带游标分页的干净 JSON 端点。直接调它,比一页页地抓 HTML 更快、更省带宽、也可靠得多。

无限滚动:它通常是伪装的游标分页

无限滚动几乎从不需要一个真浏览器。在底层,滚动只是触发同一个分页请求,而如果你在网络标签里找到那个底层请求,你就能拿它的游标像调 API 一样直接调它、把浏览器整个跳过——这在带宽和时间上都便宜得惊人。

当站点确实要求渲染时,用 PlaywrightPuppeteer 来驱动它,并尊重三个抓住每个人的坑。

第一,搞清楚是什么触发一次加载:滚动位置、一个靠近底部的 IntersectionObserver 哨兵、或一个”加载更多”按钮。触发对的那个。

第二,等新的一批真的到达,而不是等一个固定的计时器。一个 sleep(2) 是一个竞态:有时内容加载好了,有时没有,而在一个慢的代理跳转上往往没有。等到条目计数增加、或网络变空闲。

prev = 0
while True:
page.mouse.wheel(0, 20000) # 触发下一批
page.wait_for_function( # 等真正的到达,而不是一个计时器
"n => document.querySelectorAll('.item').length > n", arg=prev)
items = page.query_selector_all('.item')
if len(items) == prev: # 真等过之后仍无增长
break # ……但见下面的"终止"
prev = len(items)

第三,也是无声截断最多数据的那个:虚拟化列表。像 react-window 这样的库会把屏幕外的行从 DOM 里移除以保持快速,所以如果你滚到底、然后才去抓 DOM,你拿到的是最后那个可见窗口、别的什么都没有。你必须在滚动过程中、条目出现时就把它们抽出来,而不是在结尾一次性抓。

知道你到底有没有采完

终止是最难的部分,因为”加载停了”有两个看起来一模一样、却截然不同的成因:你到了真正的结尾,或者站点在序列中途给你限了速、悄悄不再多给了。把后者当成前者,正是你如何交付一份缺了尾巴的数据集。

三道防线。在宣布结尾之前,把一次卡住的加载重试几次,好让一批慢的不被当成”采完了”。当站点暴露一个总数时,对着它比较——一个”1240 条结果”的头部就是一份校验和:如果你采到了 900,你是被截断了、而不是采完了。并且把一个恰在一阵请求爆发之后就停下的加载,当成一次可疑的软封锁、而不是结尾,并用一个新鲜身份去重试那条尾巴、而不是接受部分数据。这和管线监控里那些填充率与预期计数检查、被造出来要在一整轮里抓住的,是同一个无声失败问题。

去重与完整性,永远

有两个习惯,决定了”一份完整的数据集”和”一份看起来合理却不完整的数据集”之间的差别。按一个稳定的唯一 ID 去重,绝不按页码、顺序、或整行内容的哈希——因为顺序会移动、而行会被轻微编辑。并且,凡是站点给你一个总数的地方,就追踪”已采对预期”的计数,好让截断表现为一个对不上的数字、而不是一个直到很久以后才有人注意到的缺口。

代理在哪里发挥作用

一个分页序列通常是一个逻辑会话,而它也应当看起来像一个。用一个粘性会话,好让单个查询的每一页都从同一个 IP 出口:在序列中途轮换,可能触发那些期待”一个访客连贯地翻页”的反爬系统,而在个性化或按地理变化的站点上,它甚至可能在页与页之间返回不一致的结果。给每个查询它自己的粘性会话,并在查询之间轮换、而不是在一个查询之内。

深分页也意味着在一个很短的窗口里对单个主机发很多请求,而那恰恰是你被限速和封锁的地方。给序列定好节奏,遇到 429 就退避、而不是猛击,并倚靠一个干净的池、好让你一开始就更少被挑战。因为一次序列中途的封锁会伪装成结尾,更好的 IP 信誉在这里一举两得:它既减少截断,又在与预期计数检查结合时,让你确实撞上的那些截断变得可见、而不是无声。

底线

分页不是那个无关紧要的部分,它正是完整性被赢下或输掉的地方。辨认真正的机制,并优先用游标分页或底层的 JSON 端点、而不是偏移加 HTML。按稳定 ID 去重,绝不按位置。对无限滚动,能调底层请求就调,必须渲染时,等真正的加载而不是计时器,并在条目出现时就抽出它们、好让虚拟化列表吃不掉你的数据。最重要的是,把”加载停了”当作可疑的,直到一次预期计数检查、或一条被重试的尾巴,证明它真的是结尾;并让每个分页查询跑在它自己的粘性会话上,好让序列保持连贯。

做到这些,你就采下整份列表、恰好一次,并知道你做到了。把序列指向一个干净的住宅 gateway,好让尾巴不变成一堵封锁之墙,而按 GB 计价让你能爬深分页、而不必有一个按请求的计价表跟你对着干。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始