无限滚动抓取:停止猜测需要滚动多少次
Advanced Data Extraction Specialist
TL;DR:
- 一个无限滚动页面几乎没有任何HTML内容。此处使用的演示提供了2,671 字节,包含零个引用元素;每个项目随后到达。
- 固定滚动次数是每个人都在使用的失误。五次滚动返回了100个中的60个项目——没有错误,没有警告,干净地退出,丢失了40%的数据。
- 直到项目数量停止增长再滚动。这样返回了所有100个。
- 滚动次数不是站点的属性。同一个循环在本地需要11次滚动操作,而在云浏览器上需要3次,都是到达100。
- 页面向
/api/quotes?page=N发出了10个请求。直接读取该端点返回相同的100个项目并报告has_next,使得循环知道何时完成。 - Scrapeless免费计划覆盖了本指南中的云浏览器运行。
无限滚动默默地破坏了爬虫。请求成功了,选择器匹配,脚本退出为零——而数据集却不完整。运行中没有任何信息告诉你错过了多少,因为页面从未承诺过有多少数据。
本指南在一个实时演示页面上测量这一点。它构建了大多数教程提供的循环,准确显示了它丢失的内容,替换为一个有真实停止条件的循环,然后找到页面始终发出的请求。
服务器实际发送的内容
在没有浏览器的情况下获取页面是没有任何内容可以解析的:
python
def served_html() -> tuple[int, int]:
request = urllib.request.Request(SCROLL_URL, headers={"User-Agent": UA})
with urllib.request.urlopen(request, timeout=45) as response:
html = response.read().decode("utf-8", "replace")
return len(html), html.count('class="quote"')
text
滚动页面字节数 : 2671
HTML中的引用元素数 : 0
2,671 字节,甚至没有一个项目。标记是一层壳;内容在页面加载后通过脚本抓取,通常在一个哨兵元素靠近底部时进入视野——这就是交叉观察器规范所定义的机制。一个CSS选择器不能比这更清晰地失败——它什么都不匹配,因为那里还什么都没有。
安装
bash
pip install playwright
playwright install chromium
第二个命令下载浏览器二进制文件;仅有的pip包不会启动。验证运行使用的是Playwright 1.59.0。
每个人都在使用的循环
标准方法是在固定次数内滚动,然后读取页面。page.mouse.wheel()触发一个真实的滚轮事件,正是UI事件规范所定义的,而这正是页面自身的监听器所等待的:
python
def scroll_fixed(page, times: int, pause: float) -> int:
for _ in range(times):
page.mouse.wheel(0, 20000)
time.sleep(pause)
return page.locator("div.quote").count()
对实时页面进行五次滚动:
text
五次滚动后的引用数 : 60
经过秒数 : 5.1
六十个项目。页面共保存了一百个。脚本没有抛出任何错误,选择器有效,运行在表面上看起来成功——这也正是使这个错误版本成本高昂的原因。因为它曾经成功过,所以选择五次,而每次之后的运行都默默地继承了40%的短缺。
增加滚动次数也不是解决办法。这是以牺牲数据采集为代价的浪费滚动,它依然对一个可以随时更改其批量大小的站点进行了猜测。
继续滚动直到停止增长
你实际上想要的条件是可以观察到的:只要项目数量仍在增加,就继续滚动,一旦它保持稳定就停止。
python
def scroll_until_stable(page, pause: float, no_growth_limit: int = 2) -> tuple[int, int]:
seen = page.locator("div.quote").count()
scrolls = 0
stable = 0
while stable < no_growth_limit:
page.mouse.wheel(0, 20000)
time.sleep(pause)
scrolls += 1
count = page.locator("div.quote").count()
if count == seen:
stable += 1
else:
stable = 0
seen = count
return seen, scrolls
no_growth_limit决定你在相信页面已完成之前需要多少证据。一次平稳读数并不算证据——仍在进行的批次与列表的末尾看起来是一样的。要求两次连续的平稳读数只需多一次滚动,即可消除这种模糊性。
text
收集的引用数 : 100
执行的滚动操作数 : 11
经过秒数 : 11.1
页面发出的API调用数 : 10
第一个API URL : https://quotes.toscrape.com/api/quotes?page=1
全部100,循环发现了计数,而不是假设它。注意,经过的时间主要是这个循环选择等待的暂停——十一次滚动,每次一秒。这是循环的特性,而不是站点的特性。
滚动次数并不是站点的属性
运行相同的功能对比云浏览器和本地Chromium:
text
收集的报价 : 100
执行的滚动动作 : 3
相同的功能,相同的页面,相同的100项——3次滚动动作而不是11次。视口高度以及每批数据到达的速度决定了一个滚动事件推进页面的程度,而这两者都不在你的控制之下。滚动时所测量的视口尺寸是由CSSOM视图模块指定的。任何硬编码的滚动计数都是根据某一台机器的窗口大小进行校准的。
该运行使用了Scrapeless抓取浏览器,Playwright通过Chrome DevTools协议连接。只有连接行发生了变化:
python
endpoint = (
"wss://browser.scrapeless.com/api/v2/browser"
f"?token={os.environ['SCRAPELESS_API_KEY']}&sessionTTL=180&proxyCountry=ANY"
)
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(endpoint, timeout=90000)
page = browser.new_page()
page.goto(SCROLL_URL, wait_until="domcontentloaded")
cloud_total, cloud_scrolls = scroll_until_stable(page, pause=1.0)
connect_over_cdp替代了chromium.launch(),并通过套接字与Chrome DevTools协议进行通信,而不是与本地进程进行通信,因此滚动循环、选择器和提取保持完全不变。将密钥保留在环境变量中,命名为SCRAPELESS_API_KEY;抓取浏览器介绍文档记录了其余的会话参数。
开始操作只需一分钟——创建一个免费的Scrapeless账户,免费计划涵盖此运行。
观察页面请求内容
滚动循环是请求页面获取数据的一种方式。监听请求显示了它获取了什么:
python
calls: list[str] = []
context = browser.new_context(user_agent=UA)
context.on("request", lambda r: calls.append(r.url) if "/api/quotes" in r.url else None)
十个请求,第一个是https://quotes.toscrape.com/api/quotes?page=1。页面正在对JSON端点进行分页并渲染结果;滚动仅仅是个触发器。
该端点可以直接读取,它回答了滚动循环必须推测的问题:
python
def read_api() -> tuple[int, int]:
quotes = 0
page = 1
while True:
request = urllib.request.Request(API_URL.format(page=page), headers={"User-Agent": UA})
with urllib.request.urlopen(request, timeout=45) as response:
payload = json.loads(response.read().decode())
quotes += len(payload["quotes"])
if not payload["has_next"]:
return quotes, page
page += 1
text
收集的报价 : 100
请求的API页面 : 10
经过的秒数 : 3.8
同样的100项,has_next作为一个明确的终止条件,而不是“计数停止变化”。响应已经结构化,因此在你和字段之间没有选择器——这也意味着在标记样式重新设计时没有选择器会中断。
在编写任何滚动循环之前检查这点是值得的。它并不总是存在:很多网站在滚动时进行服务器端渲染,签名请求,或返回HTML片段而不是JSON。当它存在时,它是更耐用的目标,而浏览器仍然是向你展示它存在的工具。对于其他分页形式——下一步按钮、页码、加载更多——完整的分页指南涵盖了每种类型。
运行它
bash
export SCRAPELESS_API_KEY="your-api-key"
python3 scroll_demo.py
验证运行的完整输出:
text
playwright 1.59.0
--- 服务器实际发送的内容 ---
滚动页面字节 : 2671
HTML中的报价元素 : 0
--- 固定数量的滚动 ---
5次滚动后的报价 : 60
经过的秒数 : 5.1
--- 滚动直到计数停止增长 ---
收集的报价 : 100
执行的滚动动作 : 11
经过的秒数 : 11.1
页面发出的API调用 : 10
第一个API URL : https://quotes.toscrape.com/api/quotes?page=1
--- Scraping Browser上的相同循环 ---
收集的报价 : 100
执行的滚动动作 : 3
--- 直接读取相同的API ---
收集的报价 : 100
请求的API页面 : 10
经过的秒数 : 3.8
浏览器/ API 时间比率 : 3 倍
最后一行的比率将浏览器循环的实际时间与直接读取进行比较。大多数浏览器端的耗时是故意在滚动之间暂停一秒钟,因此将其视为轮询页面获取从未报告的条件的成本——而不是浏览器本身的基准。
故障排除
计数永不停息。 有些页面循环他们的内容。将循环限制在最大滚动计数并进行稳定性检查,将达到该上限视为检查页面的信号,而不是视为完成的运行。
滚动后仍然没有项目。 容器可能滚动而不是窗口。通过 page.locator(...).hover() 滚动元素本身,然后使用 page.mouse.wheel(...),或者在最后一个项目上调用 scroll_into_view_if_needed()。
计数增长后页面变为空白。 长列表通常是虚拟化的,因此行在离开视口时会从 DOM 中移除。收集项目时进行,而不是在结束时全部读取。
在有界面模式下可以工作,而在无头模式下不可以。 两者之间的视口大小不同,这改变了每个滚轮事件的移动距离以及加载器是否进入视图。在上下文中设置明确的视口。
playwright._impl._errors.Error: 可执行文件不存在。 浏览器二进制文件从未下载。运行 playwright install chromium。
结论
无限滚动将“我是否获取了一切?”变成了你的抓取程序必须自己回答的问题,而固定的滚动计数通过推测来回答。这里的测量显示这些成本:五次滚动中从 100 个条目中获取到 60 个,滚动计数从 11 变为 3,仅仅是移动到不同的浏览器所致。
根据观察到的条件循环——连续读取中项目计数稳定——而不是你选择的数字。在编写循环之前,观察页面的请求:当内容以 JSON 形式到达并带有 has_next 标志时,页面已经告诉你如何知道何时完成。
准备尝试吗?从 Scrapeless 免费计划开始,并查看当前定价以适应更高的使用量。
常见问题解答
问:我应该滚动多少次?
不要选择一个数字。上述测量在两个浏览器上使用一次循环,需要在一个上滚动 11 次,在另一个上滚动 3 次以获取相同的 100 个项目,因为视口高度和批处理时间决定了每个滚轮事件的移动距离。当项目计数仍在增长时循环,停止在其稳定两次后。
问:我怎么知道页面是否加载完所有内容?
连续两次读取中项目计数不变是实际信号,因为单次平坦读取与仍在爬取中的批次无法区分。如果页面的基础请求暴露出诸如 has_next 的标志,那就是明确的答案,而不是推测。
问:我是否真的需要浏览器来处理无限滚动?
通常不需要。这里的演示页面从一个可以直接读取所有 100 个项目的 JSON 接口加载其内容。浏览器仍然是你发现该接口的方式——附加请求监听器,滚动一次并读取 URL。需要在滚动时进行服务器端渲染或对请求进行签名的网站确实需要浏览器。
问:为什么我的抓取程序返回的项目数量少于页面显示的数量?
要么循环提早停止,要么列表是虚拟化的,行在滚动出视图时从 DOM 中移除。在每次滚动后打印项目计数:一个先上升后下降的计数表明虚拟化,一个在循环结束时仍在上升的计数表明循环停止得太早。
问:wait_until="networkidle"能够解决这个问题吗?
不可以。它等待初始加载稳定,这在任何滚动触发提取之前就发生了。批处理是在响应滚动事件时到达的,因此页面可以处于空闲状态并几乎为空——这正是开始时测量的 2671 字节状态。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



