凡是动手写过 Python 爬虫的,十有八九都遇过这种页面:第一屏能抓到,往下翻也正常,但翻到某一个位置之后浏览器地址栏压根不变,内容却一批接一批自动加载出来,这就是典型的“无限滚动”页面。这类页面没有传统意义上的第 2 页、第 3 页,你想靠改 URL 参数批量翻页完全行不通,很多人就是卡死在这一步。今天这篇把处理“无限滚动 + 自动点击 + 内容抓取”的完整思路拆开讲,从怎么观察页面请求,到用浏览器自动化工具模拟滚动和点击,再到数据去重与存储,最后聊几个我实际踩过的坑。适合刚学会 requests 但被动态页面劝退的新手,也适合想把采集流程做成稳定工具的工程师参考——看完你能独立写出自己的瀑布流采集脚本。
1. 技术拆解:无限滚动页面为什么难抓
1.1 无限滚动页面的几种实现机制
传统分页页面很好识别:URL 里带 page 或 pn 之类的参数,上一页下一页都有明确链接。这种页面的正文内容基本都在服务端渲染完成,你用 requests 请求一次,拿到的 HTML 里就有全套数据,解析起来非常直接。
无限滚动是另一种形态。服务端只把第一批内容发过来,客户端 JavaScript 监听滚动事件,当用户滚动到接近页面底部的一个阈值时,前端代码立即发一个异步请求(XHR 或 fetch 都常见)去服务端要下一批数据,拿到 JSON 之后再用 JS 往 DOM 里动态插入。也就是说,真正的“下一页”逻辑根本不在服务端,而是藏在浏览器里的一段 JS 里。
这个机制差异决定了普通爬虫为什么会失效。requests 模拟的是一次普通 HTTP 请求,拿到的只是首次加载的静态 HTML,后续数据是浏览器本地执行 JS 之后发起的二次请求,requests 本身没有机会去触发它。所以传统写法在无限滚动页面上,抓来抓去始终只有开头那一小段内容,而且性能表现也容易让人误判为“网站数据太少”。
无限滚动还有一种常见变体——“加载更多”按钮。内容不会自己出来,必须有人点一下按钮,前端才会发起下一次异步请求。更复杂一点的页面是滚动和按钮混着来,前面几轮滚动就自动加载,滚到某一个层级之后必须点按钮才能继续。另外还有很多详情页里的“展开全文”“查看全部评论”,原理一模一样,都是前端事件驱动二次请求。
搞清楚机制之后,解决思路自然而然就出来了:要拿全量数据,要么模拟浏览器行为(滚动、点击),要么直接定位那个二次请求,把接口参数补全后自己循环请求。这两条路线,对应的就是后文要谈的不同技术选型。
1.2 三大技术方案的选型分析
方案一:纯 requests + 逆向接口。这是最理想的做法,直接在开发者工具里观察滚动时发出的异步请求,找到返回 JSON 的 XHR 接口,分析参数后用 requests 循环翻页。没有浏览器开销,速度最快,维护成本也最低。但前提是接口好找、参数好补。很多内容平台的接口都带了签名参数、时间戳、加密 token 之类的东西,简单的还能算出来,复杂的加密短时间根本逆向不动,这时候就需要换方案。
方案二:Selenium + ChromeDriver。Selenium 是老牌方案,生态成熟,网上资料多,但配置确实烦琐:需要下载跟浏览器版本严格匹配的 driver,Chrome 一升级,driver 就容易失效,运行速度也偏慢。处理现代页面时,它的等待机制还不够智能,“页面元素还没加载好就去点击”这类问题经常出现,得靠开发者自己写显式等待去补。
方案三:Playwright。这是我这几年处理动态页面的主力工具。它解决了 Selenium 的不少痛点:自带浏览器内核下载,不用单独去翻 driver;内置了页面操作的自动等待机制,点击前会等元素稳定;选择器引擎把相对定位、文本定位、角色定位都做了封装。更关键的是它能方便地监听网络请求,抓无限滚动页面时,可以一边滚动一边把服务端真正返回的 JSON 保存下来,这个能力在日常调试中真的能省很多事。
选型逻辑归纳起来就一句话:能走接口优先走接口,接口被加密拦住就退到浏览器渲染方案;上渲染方案优先考虑 Playwright,不用被 Selenium 的存量惯性绑住。
| 方案 | 配置成本 | 渲染能力 | 请求观察 | 速度 | 稳定性 |
|---|---|---|---|---|---|
| requests | 低 | 无 | 需手动分析 | 最快 | 依赖接口可用性 |
| Selenium | 中 | 完整 | 需配合抓包 | 慢 | 一般,经常超时 |
| Playwright | 低 | 完整 | 内置监听 | 中 | 高,自动等待可靠 |
2. 环境准备:不再为浏览器驱动头疼
2.1 五分钟搭好可复用的开发环境
先说基础环境。在命令行输入python --version确认本机 Python 版本,无限滚动抓取建议 Python 3.8 以上,3.10 或 3.11 都很合适,新版本对异步支持更好,后面做并发也方便。
然后创建虚拟环境,这是我一直坚持的习惯,别图省事直接装在全局环境里。不同项目依赖版本经常冲突,虚拟环境隔离之后,换项目直接删掉重建就行:
python -m venv venv # Windows: venv\Scripts\activate # macOS / Linux: source venv/bin/activate激活环境后,安装依赖:
pip install playwright beautifulsoup4 lxml requests再下载 Playwright 对应的浏览器内核:
playwright install chromium如果官方源下载慢,可以把 playwright 的下载源切换到国内镜像,具体配置在它的官方文档里有,属于常规操作。装完后用playwright --version验证一下环境。IDE 这块,PyCharm 就把项目解释器指到 venv 路径;VS Code 需要装 Python 插件,然后在左下角选择解释器,这个步骤对调试特别关键——无限滚动这种逻辑绕的脚本,断点调试能替你省下一大把猜测时间。
2.2 Playwright 的几个关键能力
为什么我不用 Selenium 而选 Playwright?展开讲四个对无限滚动场景特别有价值的能力。
第一,自动等待。page.click()、page.locator().click()这些操作默认会等待元素可见、可用再执行,不用像 Selenium 老写法那样堆一堆time.sleep去猜网络耗时。滚动页面的加载时延本身就不稳定,自动等待不会因为网络慢就误判失败。
第二,内置多浏览器内核。chromium、firefox、webkit 都能跑,跨内核覆盖能发现一些只在特定内核下出现的问题,比如某些页面在 firefox 下点击事件绑定方式不同。
第三,命令playwright codegen。它会打开浏览器录制你的鼠标操作并自动生成代码,这对定位选择器、熟悉页面结构帮助巨大。遇到难搞的按钮,我一般先录制一遍,再把录到的选择器拿过来手动调整。
第四,网络监听。page.on("response")可以实时捕获所有接口响应,这样你不用频繁切到 DevTools 去人工找接口,直接在代码里把滚动过程中的 JSON 存下来,效率差很多。
第五,通过page.evaluate()执行自定义 JavaScript。一些特殊场景,比如页面监听的是scroll事件而不是按钮点击,直接注入原生 JS 去控制滚动更精准。
3. 核心实现:自动滚动、点击与抓取的完整流程
3.1 第一步:先在开发者工具里找接口
开工之前先定策略:不要一上来就写代码,先打开浏览器开发者工具(F12),切到 Network 面板,把过滤条件勾到 Fetch/XHR,然后在页面上手动往下慢慢滚动几次。观察 Network 面板里新增了哪些请求,找到返回内容和列表数据对应的接口,点开一个请求看 Response 预览,确认返回格式是 JSON 还是 HTML 片段。
找到接口后,看它的 Query String 部分。常见的参数有page、cursor、offset、lastId、timestamp、sign。前几种是明文的,直接在代码里改就行,sign这种一般是 JS 现场算出来的签名,如果没法轻易复现,那就别在逆向接口上死磕,直接转到渲染方案。
如果接口参数是纯数字翻页,那么用 requests 就能解决,上一段示例代码帮忙贴出来:
import requests import time headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } for page in range(1, 11): url = "https://example.com/api/feed" params = {"page": page, "limit": 20} resp = requests.get(url, headers=headers, params=params, timeout=10) if resp.status_code != 200: break data = resp.json() items = data.get("list", []) if not items: break for item in items: print(item.get("id"), item.get("title")) time.sleep(0.5) # 控制节奏,别把服务端打崩注意,如果接口参数里带签名,或者需要先登录拿到 token,那 requests 方案的成本会明显上升。这时候我建议改成 Playwright 渲染方案,虽然是笨办法,但往往是最省心、最不容易被参数加密卡住的路子。
3.2 第二步:Playwright 模拟滚动加载和按钮点击
Playwright 方案的核心逻辑是一段 while 循环,模拟真实用户行为:打开页面,滚动到底部,等待内容加载完成,如果出现“加载更多”按钮就点击,然后继续判断是否有新内容出现;如果没有新内容,或者达到最大轮数,立刻停止。
下面这份代码是我常用的模板,你换成自己的目标站点后,重点改三处:页面 URL、内容卡片的选择器、按钮的选择器:
from playwright.sync_api import sync_playwright MAX_ROUNDS = 30 STABLE_THRESHOLD = 3 # 连续几轮没有任何新内容就退出 def get_card_count(page): # 根据目标页面的 DOM 结构调整,这里以常见的卡片列表为例 return page.locator("div.card-item").count() def main(): with sync_playwright() as p: browser = p.chromium.launch(headless=False) # 调试时建议先 False page = browser.new_page( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) page.goto("https://example.com/feed", timeout=60000) # 先触发第一轮滚动,确保页面底部异步请求发出 page.mouse.wheel(0, 3000) page.wait_for_timeout(2000) stable_round = 0 last_count = get_card_count(page) for round_no in range(MAX_ROUNDS): # 滚动到页面底部 page.mouse.wheel(0, 12000) page.wait_for_timeout(1500) # 如果出现“加载更多”按钮,优先点击 load_more = page.locator("button:has-text('加载更多')") if load_more.count() > 0 and load_more.is_visible(): try: load_more.click(timeout=3000) page.wait_for_timeout(1500) except Exception as e: print(f"点击加载更多失败: {e}") # 统计当前卡片数量,判断是否还有新数据 current_count = get_card_count(page) print(f"第 {round_no + 1} 轮,当前卡片数: {current_count}") if current_count == last_count: stable_round += 1 if stable_round >= STABLE_THRESHOLD: print("连续多轮没有新内容,停止滚动") break else: stable_round = 0 last_count = current_count # 页面最终内容 html = page.content() print(f"最终卡片数量: {get_card_count(page)}") browser.close() return html if __name__ == "__main__": html = main()几个关键细节需要说明一下。
滚动这里,page.mouse.wheel(0, 8000)是模拟鼠标滚轮向下滚动 8000 像素,量给大一点能确保触发页面的滚动监听。也可以用page.evaluate("window.scrollTo(0, document.body.scrollHeight)")直接拉到页面底部,两种方式效果类似,但前者更接近真人行为,对反爬策略更友好。
加载完成判断,我刻意没有用固定 sleep,而是用“卡片数量连续几轮不变”作为停止条件。这个思路本质上是把“外部状态观察”做成了轮询器,相比死等 N 秒靠谱得多——你永远不知道服务端这次响应要 500ms 还是 3s,给自己设一个稳定阈值,既不会漏数据也不会死循环。
按钮点击的容错也很重要。很多“加载更多”按钮到了最后一页会变成灰色 disabled 状态,点击前先判断is_visible()和is_enabled(),能省掉很多无谓的异常。另外代码里我用page.locator("button:has-text('加载更多')")这种文本定位方式,比依赖一串动态 class 名稳得多,因为很多前端框架的 class 名是构建时随机生成的。
3.3 第三步:内容解析、去重与存储
渲染完成之后,页面 HTML 已经包含所有动态加载的内容,用 BeautifulSoup 解析即可:
from bs4 import BeautifulSoup import json def parse_items(html): soup = BeautifulSoup(html, "lxml") items = [] for card in soup.select("div.card-item"): title_tag = card.select_one("h3.title") link_tag = card.select_one("a.link") time_tag = card.select_one("span.time") if title_tag is None: continue items.append({ "title": title_tag.get_text(strip=True), "url": link_tag.get("href") if link_tag else "", "publish_time": time_tag.get_text(strip=True) if time_tag else "", }) return items如果目标页面的内容结构复杂,或者解析规则经常变,我建议配合 Playwright 的网络监听,直接从接口响应里拿 JSON。这个方法稳得多,因为 JSON 里的字段比 DOM 里的标签稳定。代码也很简单:
collected = [] def on_response(response): if "api/feed" in response.url and response.status == 200: try: data = response.json() collected.extend(data.get("list", [])) except Exception: pass page.on("response", on_response)把这段监听挂上之后,不管页面是滚动加载还是点击加载,只要接口返回一次,collected里就会自动追加一批原始数据。这种方式对前端渲染异常、标签调整都不敏感,是我个人最喜欢的方式。
去重一定要做,尤其是滚动加点击混合的页面,经常会重复渲染同一批数据。去重的唯一标识最好用内容自身的链接,或者服务端分配的 ID。如果是新闻资讯没有稳定 ID,就把“标题 + 发布时间”拼起来取 MD5:
import hashlib def make_sign(item): raw = f"{item['title']}-{item['publish_time']}".encode("utf-8") return hashlib.md5(raw).hexdigest() seen = set() unique_items = [] for item in items: sign = make_sign(item) if sign not in seen: seen.add(sign) unique_items.append(item)存储层面,数据量不超过几千条的话,直接落 CSV 或 JSON 文件就行;要做增量更新、按条件查询的话,建议用 SQLite,建表时加个唯一索引,彻底根除重复写入问题。SQLite 是 Python 标准库自带的功能,零配置、单文件、够用到百万量级。
4. 常见问题与排查技巧实录
4.1 元素定位不到,selector 明明有却找不到
这个问题在无限滚动页面上极其常见,遇到时先别怀疑选择器写错,大概率是下面几个原因。
第一,页面内容包在 iframe 里。桌面端页面为了隔离三方内容,经常把评论、推荐位放在 iframe 里,Playwright 的操作默认不会穿进 iframe。解法是先用page.frame_locator("iframe")拿到 iframe 内的定位器,再去做滚动和点击。
第二,Shadow DOM。很多现代组件库为了样式隔离用了 Shadow DOM,常规选择器穿透不进去。Playwright 对 Shadow DOM 支持还可以,但嵌套 Shadow root 时依然有坑,真要抓这种页面,优先考虑从接口 JSON 拿数据,别硬在 DOM 里翻。
第三,懒加载导致元素不存在。滚动之前,页面上根本没这个元素,得先滚到它附近才会被创建。所以点击某些按钮前要确保:
- 元素先
scroll_into_view_if_needed() - 再用 Playwright 的
wait_for_selector等它出现 - 最后才执行点击。
有个小技巧:如果元素路径太深,直接用文本定位或者page.get_by_role("button", name="加载更多"),比一层层找嵌套标签省心得多。
4.2 滚动停不下来或者数据一直重复
滚动停不下来的常见原因有两个:页面本身是真正无限流,理论上永远有新数据(比如某些纯时间流信息);或者你的停止条件写得太宽松。建议加两个硬性保护:最大滚动轮数(MAX_ROUNDS)和一个连续空转阈值(STABLE_THRESHOLD)。代码里我把最大轮数设成 30,连续 3 轮没新数据就退出,这两个参数在极端情况下能防止脚本跑到天荒地老。
数据重复则是另一类问题。很多页面在滚动时不会清掉旧 DOM,而是把新内容追加到尾部,你用“卡片总数”判断是否加载成功时没问题;但如果你是从接口响应里收集 JSON,接口每滚动一次可能返回同一批数据,这时候必须做去重。去重不能只靠 set 存对象,因为 HTML 和 JSON 表示同一个内容的方式不同,务必要用统一字段加工出签名再做去重。
4.3 点击“加载更多”按钮无效
按钮明明可见,点击后页面没有任何反应,这我遇到过无数回。排查顺序有讲究。
先检查按钮是不是 disabled 状态,很多框架的按钮到边界之后还显示,但点不动。代码里判断is_enabled()可以提前止损。
然后看一下按钮是否被遮挡。页面上出现了悬浮区、广告层、Cookie 提示条,都可能导致 Playwright 点击时提示“intercepts pointer events”。解决方法是先关闭弹窗/悬浮层,或者用click(force=True)强制点击。但注意 force 只是跳过可见性检查,并不绕过 JS 里的事件绑定逻辑,某些极端情况还得手动派发点击事件。
再有一种情况,页面的点击事件不是绑定在 button 上,而是绑定在它的父级 div 上,或者绑定的是整块卡片的 click handler,这时候你点击按钮本身可能什么都不会触发。解法是改用 JS 直接派发事件:
page.locator("button:has-text('加载更多')").evaluate( "(el) => el.click()" )这种方法能直接触发原生 click 事件,绕开一部分前端框架自带的拦截逻辑。
4.4 反爬风控,以及必须说的合规运行
无限滚动页面的数据加载接口往往都有风控。短时间请求次数过多,轻则返回验证码页面,重则封 IP。
规避风控的第一原则是控制频率。requests 方案里给请求加随机延时,Playwright 方案里不要连续滚动,每轮之间留出 1 到 2 秒的随机等待。有条件的话可以准备一组不同浏览器的 User-Agent 轮流用,但注意不要频繁切换,否则反而容易触发设备指纹风控。
同样重要的还有合规性。做爬虫不是不能做,但必须在合理边界内:先看目标网站根目录下的robots.txt,明确哪些路径允许爬取;不采集个人敏感信息,不贩卖数据;不对目标站点发起超出正常用户行为的高频请求。尤其是抓取个人账号数据、联系方式这一类,既有法律风险也有道德风险,我自己的原则是不碰。做技术练手,用公开的开源数据或者自己的测试站点最好。
5. 性能优化与工程化扩展
5.1 从一次性脚本到稳定可复用的采集团队
单个脚本能跑通,算完成了 30% 的工作。真正要日常稳定使用,接下来该做工程化改造。
第一,把代码拆模块。页面加载逻辑、网页解析逻辑、存储逻辑分成不同文件,后续某一个页面的 DOM 结构变了,只需要改解析模块,不需要动滚动逻辑。
第二,完善日志。别用 print 打天下,用 Python 的logging模块,输出到文件。跑了多久、滚到第几轮、抓了多少条、哪一轮出现异常,这些信息在排查问题时都是救命的信息。
第三,增加断点续跑。脚本中途挂了重启,不该从头再来,应该把已抓取的签名保存到本地文件里,重新跑的时候直接加载已有签名集合做去重。这个改动不大,但对上千条数据的采集任务收益非常明显。
第四,设置失败重试机制。单轮请求失败不代表全部失败,捕获异常后等几秒重试一次,重试超过三次再放弃,并记录失败上下文。
5.2 异步并发与分布式爬虫的扩展路径
单浏览器实例滚动抓取速度有限,数据量上来之后可以用 Playwright 的异步 API 开多个 page 同时抓不同的列表页。注意这里不是无限提高并发,通常同时开 3 到 5 个浏览器实例就差不多是上限了,再多容易触发风控,本机资源也吃紧。
如果走纯接口方案,用concurrent.futures.ThreadPoolExecutor控制并发请求数,这里线程池的收益比单线程循环高很多,但要让每个线程的请求间隔错开,避免同一时刻集中打爆目标服务器。
再往上走就是分布式采集架构。经典做法是 Scrapy 负责页面调度与解析,SQLite 或 Redis 做任务队列与去重集合,多个 worker 机器同时消费 URL 任务。scrapy-redis 这个成熟方案可以做到 URL 去重下沉到 Redis,多机共享同一个待抓取队列,数据产出进同一个存储。但说句实在话,项目规模没有到几百万条数据,真的不必上分布式,架构复杂度会把你拖垮。
还需要提一个行业趋势。现在各大平台的前端加密参数越来越重,接口签名逻辑越来越复杂,很多团队开始尝试用大模型辅助分析 JavaScript 加密逻辑,自动生成逆向代码,这类“大模型逆向爬虫”在社区里讨论度很高。但对多数实际任务来说,我更推荐先用浏览器渲染方案绕开参数逆向,毕竟 Playwright 模拟的就是真实浏览器,绝大多数加密参数根本不需要解。只有渲染方案也拿不到数据的时候,再去考虑逆向的事,而且要评估好合规成本与技术难度。
最后分享几个我的使用习惯
这阵子频繁写无限滚动抓取脚本,沉淀了几个小习惯,分享给大家。
第一,所有参数以常量形式放在文件开头统一管理。MAX_ROUNDS、STABLE_THRESHOLD、超时时间、选择器,写成一个 config 段。改需求的时候不用在函数体里到处找魔法数字。
第二,页面结构不稳定时,优先从接口 JSON 拿数据。DOM 解析只是兜底方案,接口字段和 DOM 标签哪一个更稳定?大多数时候是前者。
第三,抓取时建立一种“观察者思维”。不要假设页面会按你想的状态加载,每一轮都做好“没有新内容就退出”的准备。一个无限滚动页面,它的服务端状态随时可能变化,你的脚本要有足够的容错空间。
我实际使用的过程中,最大的教训是不设上限的滚动脚本。曾经脚本开着没管,第二天早上发现它还在页面上疯狂滚动,数据重复了几万条。那次之后我所有的采集脚本都强制加最大轮数和连续空转阈值,宁可少抓几条也不失控。
再提醒一句:玩爬虫,工具选型只是第一步,真正的技术含量在于如何稳定、高效地解决目标问题,同时在合规区间内运行。这些边界别去碰,技术路就走得长远。