抓取无限滚动信息流的最佳方式通常根本不是滚动。大多数信息流背后是一个分页请求,常常基于游标,直接重放该请求比驱动浏览器更快、更便宜、更可靠。这种方法在如何可靠地抓取分页和无限滚动中有详细介绍。
本指南针对的是那种方法行不通的情况:请求带有短期有效的令牌签名;响应是页面在客户端解码的不透明数据块;信息流只在某个元素进入视口时才加载;“下一页”是一个绑定到你无法重建的状态的按钮。在这些情况下,你必须在真实渲染中处理滚动和点击操作,而这其中有若干容易出错的地方。
示例使用 Shifter Web Scraping API,它在无头 Chrome 中运行页面,并在抓取前接受一连串浏览器操作。
四种动态分页方式
在写任何代码之前先确定你遇到的是哪一种,因为每一种需要不同的技术。
| 模式 | 触发下一批数据的方式 | 技术 |
|---|---|---|
| 滚动触发的信息流 | 底部附近的元素进入视口 | 滚动到哨兵元素,等待,重复 |
| 加载更多按钮 | 点击一个会追加条目的按钮 | 点击,等待新条目,重复 |
| URL 状态分页 | 页面在你浏览结果时更新 URL | 直接获取这些 URL,无需滚动 |
| 虚拟化列表 | 有滚动,但旧行会随着新行出现从 DOM 中移除 | 分窗口抓取,或使用底层请求 |
第三种值得先检查,因为处理成本最低。在普通浏览器中滚动一个信息流,观察地址栏。如果 page 或 offset 参数发生变化,说明该网站已经给了你一个可分页的 URL,每一页都是一个普通请求。
第四种是那种会悄无声息丢失数据的情况,下文单独有一节讨论。
正确地渲染和等待
每种动态技术都始于相同的两个控制项。render_js=1 在无头 Chrome 中运行页面,费用与静态抓取相同,每次成功请求消耗一个积分。wait_for_css 会让抓取暂停,直到 DOM 中存在某个选择器,这样你就不会在页面尚未填充数据时提取内容。渲染相关的控制项记录在渲染 JavaScript中。
要仔细选择等待选择器。仅等待列表容器是不够的,因为许多框架会先渲染一个空容器,之后再填充数据。应该等待列表中的第一个条目。
滚动触发的信息流:滚动到哨兵元素
浏览器操作放在 js_instructions 中,这是一个按顺序在抓取前执行的步骤 JSON 数组。文档中记录的操作有 scrollTo、click 和 wait。
滚动触发信息流的模式是:滚动到列表之后的一个元素,等待下一批数据加载,然后重复。由于列表在不断增长,该元素每次都会往下移动,因此再次滚动到它会触发下一次加载。
import json
import os
import requests
API = "https://scrape.shifter.io/v1"
instructions = [
{"action": "click", "selector": "button.accept-cookies"},
{"action": "scrollTo", "selector": "footer", "timeout": 5000, "block": "start"},
{"action": "wait", "duration": 2000},
{"action": "scrollTo", "selector": "footer", "timeout": 5000, "block": "start"},
{"action": "wait", "duration": 2000},
{"action": "scrollTo", "selector": "footer", "timeout": 5000, "block": "start"},
{"action": "wait", "duration": 2000},
]
rules = {
"items": {
"selector": "article.card",
"type": "list",
"item": {
"link": {"selector": "a.card-link", "output": "@href"},
"name": {"selector": "h3", "output": "text"},
"price": {"selector": ".price", "output": "text"},
},
}
}
params = {
"api_key": os.environ["SHIFTER_API_KEY"],
"url": "https://shop.example.com/category/shoes",
"render_js": 1,
"wait_for_css": "article.card",
"js_instructions": json.dumps(instructions),
"extract_rules": json.dumps(rules),
}
resp = requests.get(API, params=params, timeout=120)
resp.raise_for_status()
items = resp.json()["items"]
其中有几个细节是有意为之的。
首先关闭 cookie 提示条,因为遮罩层可能会拦截滚动或点击操作。滚动之间的等待时间是为了让网络请求有时间完成、DOM 有时间更新;等待时间太短就会在新一批数据到达前进行抓取。带有 list 类型的 extract_rules 会将每个卡片作为一个 JSON 对象返回,因此你这边不需要解析器,而 requests 会自动为你对两个 JSON 参数进行 URL 编码。提取语法记录在提取规则中。
在大规模运行之前先在样本页面上测试该操作链,并根据该网站实际的加载速度调整滚动步数和等待时长。
加载更多按钮:点击,然后等待增长
“加载更多”按钮是同样的循环,只是触发方式不同:
[
{"action": "click", "selector": "button.load-more", "timeout": 3000},
{"action": "wait", "duration": 2000},
{"action": "click", "selector": "button.load-more", "timeout": 3000},
{"action": "wait", "duration": 2000}
]
有两点容易让人踩坑。按钮选择器在加载过程中往往会改变状态,获得一个 disabled 类或出现一个加载动画,因此如果等待时间不够长,第二次点击可能会在按钮重新可点击之前就触发。而且当没有更多内容可加载时,该按钮通常会消失,这是一个有用的完成信号,但也意味着你的操作链需要能够容忍最后几次点击其实无对象可操作。同样,要针对真实网站进行测试。
单次渲染的时间预算
以上所有操作都发生在一个有时间限制的浏览器会话内。wait_for_css 默认在 30 秒后超时,timeout 则限制了浏览器在该页面上可以花费的最长时间。一个包含数千条目的信息流不会在一次渲染内加载完成,无论你链接多少个滚动步骤都不行。
因此应该拆分问题,而不是拉长操作链。
缩小结果集。 筛选条件、排序方式和分类筛选面板通常会产生更小的信息流。二十个各自能完整加载的窄信息流,比一个永远加载不完的庞大信息流更可靠。
通过带筛选条件的 URL 分页。 许多网站会将筛选条件与 URL 状态结合起来,这就把一个无限的滚动变成了一组有限的普通请求。
当信息流确实很长且无法缩小时,回退到底层请求。 不带渲染地获取它;对于返回 JSON 的端点,auto_parser=1 会返回已解析的响应体。
虚拟化列表:悄然丢失的数据
一些信息流,尤其是非常长的那些,使用了列表虚拟化技术。只有视口附近的行才存在于 DOM 中。当你向下滚动时,顶部的行会被移除。
把一个虚拟化列表滚动到底部再进行抓取,你得到的只是最后一屏的条目,而不是你滚动经过的所有条目。不会报任何错误。提取结果看起来干净、合理,但却是不完整的。
在信任任何输出之前先检测这种情况。运行滚动操作链后,检查第一屏的条目是否仍然存在于抓取结果中。如果它们已经消失,说明该列表是虚拟化的,先滚动再抓取的方式行不通。此时应改用 URL 状态分页或底层请求。
跨请求的动态分页
当每一页都是依赖服务端状态(例如与你的会话绑定的游标)的独立请求时,应使用 session_id 在整个遍历过程中保持该状态不变。一个会话会在多个请求之间保持 cookie、浏览器状态和上游 IP,并在空闲 10 分钟后过期。在一个会话的整个生命周期内保持 country 不变,因为在遍历过程中切换可能会使与地区绑定的 cookie 失效。详情见会话与代理。
params.update({
"session_id": "shoes-walk-07",
"country": "de",
})
要让遍历持续进行。如果你的代码在两页之间执行某个缓慢操作,导致会话闲置,会话就会过期并破坏游标。
确认你获取了全部数据
一个提前停止的爬虫,看起来和一个已完成的爬虫一模一样。要在每次运行中都建立完整性检查。
- 与页面显示的总数进行比对。 许多信息流会显示结果数量。如果页面显示 1,284 而你提取到了 960,说明你提前停止了。
- 基于一个稳定的键去重,例如条目的链接或 ID,绝不要基于位置去重。滚动信息流经常会重新渲染条目,而位置会随着内容插入而变化。
- 检查结束标记。 “加载更多”按钮消失或出现”没有更多结果”的提示,是积极的确认信号。如果在你最后一步之后仍未出现,说明操作链太短了。
- 随时间跟踪完整性。 一个昨天产出了 1,200 条条目、今天只产出 400 条的分类,通常是其标记结构或加载行为发生了变化,而不是库存本身变化了。
积分和延迟:按站点选择方案
| 方案 | 积分 | 延迟 | 可靠性风险 |
|---|---|---|---|
| 重放底层请求 | 直接发送则不消耗积分;通过 API 每页消耗一个积分 | 低 | 请求可能带签名或不透明 |
| URL 状态分页 | 每页一个积分 | 低到中等 | 需要可分页的 URL |
| 一次渲染中的滚动或点击链 | 整个链条一个积分 | 高 | 时间预算、虚拟化问题 |
一次滚动经过多批数据的渲染只消耗一个积分,而将同样的多批数据作为独立请求重放则每个都要消耗一个积分。代价是延迟和时间预算:长操作链更慢,也更容易超时。对于底层请求不可行的中等规模信息流,使用滚动操作链;其他情况则优先考虑另外两种方案。
常见问题
是否应该始终为无限滚动渲染 JavaScript?
不需要。先检查是否存在 URL 状态分页,以及是否有可重放的底层请求。渲染是备用方案,而不是默认方案。
操作链应该包含多少个滚动步骤?
以该网站在时间预算内加载你所需批次数据所需要的步数为准。在样本页面上进行测量,如果所需步数超出了预算,就应该缩小信息流范围而不是增加步数。
为什么我提取到的条目比我滚动经过的要少?
几乎总是因为列表虚拟化,行会随着滚动离开 DOM。检查第一屏的条目是否能保留到抓取时。
每个滚动步骤是否都会消耗一个积分?
不会。整个操作链在一个请求内运行,一次成功的请求只消耗一个积分。
结论
无限滚动本质上是游标分页加上前面的一层浏览器。当你能够直接触达游标时,就应该这样做。当无法做到时,就在渲染内部处理它:等待第一个条目而不是容器本身,滚动到哨兵元素或点击”加载更多”并在步骤之间留出足够的等待时间,通过缩小信息流范围来遵守时间预算,在信任抓取结果之前测试是否存在虚拟化,并根据网站自身给出的总数来验证完整性。
关于何时使用带渲染的 API 请求才是正确工具的问题,请参阅何时需要用于 JavaScript 密集型网站的 Web Scraping API。该产品位于带 JS 渲染的 Web Scraping API页面,方案详情见定价页面。