1. 从一次自动化采集卡住说起
做数据采集的朋友大概率都遇到过这种场景:页面明明加载出来了,内容也渲染了,但脚本一执行滚动操作,页面就像被钉住一样纹丝不动,或者滑两下就弹出一个验证码框,要求你完成某种交互才能继续。我最近在做一个内容聚合类的小工具,需要批量抓取某内容平台PC端的信息流数据,就结结实实撞上了这堵墙。这个平台的信息流是典型的无限滚动加载,不滚动就拿不到新数据,而滚动行为本身又被一套行为验证机制盯着,稍微机械一点就触发拦截。
这篇文章要聊的,就是怎么用pyppeteer这个工具,在PC端把这类无限滚动页面的滑动操作做稳,同时把过程中可能遇到的验证码拦截问题处理掉。核心关键词是pyppeteer、PC端滑动、无限滚动加载、行为验证、验证码处理。适合有一定Python基础、做过基础爬虫、但被动态渲染和反自动化机制卡住的同学。如果你还在用requests硬怼接口,或者用selenium但总觉得不够隐蔽,这篇内容应该能给你一些新思路。
先说清楚一件事:这里讨论的是技术实现层面的滑动模拟与验证码识别思路,目的是让自动化脚本的行为更接近真人,从而顺利完成数据采集任务。所有操作都应在遵守目标平台服务条款和 robots 协议的前提下进行,采集的数据仅用于个人学习研究,不要用于商业用途或对平台造成压力。这一点必须先摆明,后面所有的技术细节都建立在这个前提上。
pyppeteer是 Puppeteer 的 Python 移植版,底层通过 DevTools 协议控制 Chromium,相比selenium的 WebDriver 方案,它在指纹伪装和事件模拟上更接近真实浏览器行为。但工具只是基础,真正决定成败的是你对目标页面加载机制和验证机制的理解。下面我会从整体设计思路开始,一步步拆到具体代码和参数,把踩过的坑都摊开讲。
2. 整体方案设计与核心思路拆解
2.1 为什么选 pyppeteer 而不是 selenium
先回答一个很多人会问的问题:滑动操作而已,selenium也能做,为什么非要换pyppeteer?我最初也是用selenium的,ActionChains配合move_by_offset做滑动,简单直接。但实测下来有两个致命问题。
第一个是webdriver特征。selenium启动的浏览器,在navigator.webdriver这个属性上会暴露自己,虽然可以通过CDP命令或者修改启动参数来掩盖,但痕迹比较多,目标平台的风控系统很容易识别。pyppeteer虽然也有自动化特征,但它的控制方式更底层,通过 DevTools 协议直接发指令,浏览器层面感知到的“自动化”信号更少。
第二个是事件模拟的精细度。selenium的ActionChains做滑动,本质上是把一系列鼠标事件打包发送,时间间隔和轨迹都比较机械。而pyppeteer可以精确控制每一个mouse.move事件的坐标、时间戳和步长,能模拟出带加速度和抖动的真人轨迹。这一点在对抗行为验证时非常关键。
当然pyppeteer也有缺点,比如社区维护不如selenium活跃,某些 API 文档不全,遇到问题查资料费劲。但就滑动场景而言,它的优势是决定性的。
2.2 无限滚动加载的三种触发机制
在动手写代码之前,得先搞清楚目标页面的滚动加载是怎么触发的。我实测下来,这类信息流页面主要有三种机制。
第一种是滚动事件监听。页面监听scroll事件,当滚动条距离底部小于某个阈值时,触发数据请求。这种最简单,只要让页面滚动到底部附近就行。
第二种是IntersectionObserver 监听。页面在列表底部放一个哨兵元素,用IntersectionObserver观察它是否进入视口,进入就加载。这种机制下,光滚动还不够,得确保哨兵元素真正出现在可视区域内。
第三种是虚拟列表。页面只渲染可视区域内的元素,滚动时动态替换 DOM 节点。这种最麻烦,因为滚动位置和实际数据索引不是线性关系,需要额外计算。
我遇到的目标页面是第一种和第二种的混合:既有scroll事件监听,又在底部放了哨兵元素。所以滑动策略必须同时满足两个条件——滚动条要动,哨兵元素要进入视口。
2.3 行为验证的触发条件分析
验证码不是随便弹的,它有一套触发逻辑。根据我的观察和测试,以下几个行为最容易触发验证。
- 滚动速度异常:真人滑动有加速和减速过程,如果脚本以恒定速度滚动,或者瞬间跳到底部,风控系统会标记为异常。
- 滚动间隔固定:每次滚动的间隔时间完全一致,比如严格每 2 秒滚一次,这种规律性太强。
- 鼠标轨迹缺失:只滚动不移动鼠标,或者鼠标移动轨迹是直线,缺少真人那种微小的抖动和偏移。
- 请求频率过高:短时间内触发太多次数据加载请求,超过正常浏览速度。
理解了这些触发条件,滑动策略的设计目标就明确了:让滚动行为在速度、间隔、轨迹三个维度上都接近真人。具体怎么做,下一章详细拆。
2.4 验证码出现后的处理策略
即使滑动做得再像真人,也不能保证百分之百不触发验证。所以方案里必须包含验证码处理环节。常见的处理策略有三种。
第一种是规避,通过降低操作频率、增加随机性来减少触发概率。这是最理想的,但无法完全避免。
第二种是识别并自动完成。如果验证码是简单的滑块或点选,可以通过图像识别或轨迹模拟来自动通过。但这需要额外的识别模型和大量的调试。
第三种是人工介入。检测到验证码后暂停脚本,弹出提示,等人工完成后继续。这种方式最稳妥,但无法做到全自动。
我的方案是以规避为主,识别为辅,人工兜底。具体来说,滑动策略做到足够拟人化,把触发概率降到很低;同时加入验证码检测逻辑,一旦发现验证码元素出现,先尝试自动处理简单类型,处理不了就暂停等待人工。这样兼顾了效率和稳定性。
3. 核心细节解析与实操要点
3.1 pyppeteer 环境搭建与浏览器指纹伪装
环境搭建本身不复杂,但有几个细节直接影响后续的稳定性。首先是安装,直接pip install pyppeteer就行。但第一次运行时会自动下载 Chromium,国内网络环境下可能很慢甚至失败。我的做法是手动下载对应版本的 Chromium,然后通过executablePath参数指定路径。
import asyncio from pyppeteer import launch async def main(): browser = await launch( executablePath='/path/to/chromium', headless=False, args=[ '--no-sandbox', '--disable-blink-features=AutomationControlled', '--disable-infobars', '--window-size=1920,1080', ] ) page = await browser.newPage() await page.setViewport({'width': 1920, 'height': 1080}) await page.evaluateOnNewDocument(''' Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); ''') # 后续操作 await browser.close() asyncio.get_event_loop().run_until_complete(main())这里有几个关键点。headless=False是必须的,无头模式下的浏览器指纹和正常模式差异很大,很容易被识别。--disable-blink-features=AutomationControlled这个参数能去掉一部分自动化特征。evaluateOnNewDocument里的代码是在每个新页面加载前执行的,用来覆盖navigator.webdriver属性。
注意:
evaluateOnNewDocument必须在page.goto之前调用,否则对已经加载的页面无效。这个坑我踩过,当时调了半天以为代码没生效,后来发现是调用顺序问题。
另外,userAgent也要设置成常见的真实浏览器 UA,不要用默认的。默认 UA 里会带HeadlessChrome字样,一眼就能看出来。
3.2 页面加载完成的判断标准
滑动操作必须在页面加载完成后进行,否则滑了也是白滑。但“加载完成”这个状态很难精确定义,因为信息流页面是异步加载的,load事件触发时可能只渲染了骨架屏。
我的做法是等待特定元素出现,而不是等load事件。具体来说,先goto页面,然后用waitForSelector等待信息流列表的第一个卡片元素出现。这个元素出现了,说明页面主体内容已经渲染,可以开始滑动了。
await page.goto(url, {'waitUntil': 'networkidle0', 'timeout': 60000}) await page.waitForSelector('.feed-card', {'timeout': 30000})waitUntil: networkidle0表示网络空闲时才认为导航完成,比默认的load更严格。但有些页面有长轮询或心跳请求,永远达不到networkidle0,这时候就得用domcontentloaded配合waitForSelector。
实操心得:如果
waitForSelector经常超时,先检查选择器是否正确。可以在浏览器开发者工具里用document.querySelector验证一下。另外,有些平台的类名是动态生成的,每次刷新都不一样,这种情况就得用更通用的选择器,比如[data-testid]或者基于元素结构的nth-child。
3.3 滑动轨迹的拟人化设计
这是整个方案的核心。滑动轨迹要满足三个条件:有加速减速、有微小抖动、步长不均匀。
先说什么叫有加速减速。真人滑动鼠标滚轮或者拖动滚动条时,起始阶段速度逐渐加快,中间达到最大速度,接近目标位置时又逐渐减慢。用数学语言描述,就是速度曲线呈 S 形。我用的方案是分段模拟:把整个滑动距离分成三段,第一段加速,第二段匀速,第三段减速。
具体实现上,不是一次性把滚动条从 A 移到 B,而是分成很多小步,每一步计算一个位移量,位移量按照 S 形曲线变化。下面是一个简化版的实现思路。
import random import math def generate_scroll_steps(total_distance, steps=30): """生成带加速减速的滚动步长序列""" distances = [] for i in range(steps): # 使用正弦曲线模拟速度变化 progress = i / steps speed_factor = math.sin(progress * math.pi) # 0 到 1 再到 0 # 加入随机抖动 jitter = random.uniform(0.8, 1.2) step = (total_distance / steps) * speed_factor * jitter distances.append(step) # 归一化,确保总距离正确 scale = total_distance / sum(distances) return [d * scale for d in distances]这段代码生成的步长序列,中间大两头小,符合加速减速的规律。jitter参数加入随机性,避免每次滑动轨迹完全一样。
再说微小抖动。真人操作鼠标时,即使想走直线,手也会不自觉地抖动。这种抖动幅度很小,但频率不低。在滚动场景下,表现为滚动方向上的微小往复。可以在每一步的位移上叠加一个小的随机偏移,偏移量控制在总距离的 1% 到 3% 之间。
最后是步长不均匀。上面代码里的jitter已经实现了这一点。但要注意,抖动不能太大,否则滚动会显得很突兀。我实测下来,jitter在 0.8 到 1.2 之间比较合适,超过这个范围就容易看出异常。
3.4 滚动间隔的随机化处理
滚动间隔的随机化比轨迹更容易被忽视,但同样重要。如果每次滚动的间隔都是固定的 2 秒,风控系统很容易通过时间序列分析识别出来。
我的做法是基础间隔加随机扰动。基础间隔根据页面加载速度设定,比如 1.5 秒。然后在这个基础上加一个随机值,范围在 -0.5 秒到 +1 秒之间。这样间隔就在 1 秒到 2.5 秒之间波动,看起来更自然。
import asyncio import random async def random_delay(base=1.5, jitter=0.75): delay = base + random.uniform(-jitter, jitter) delay = max(0.5, delay) # 确保不小于 0.5 秒 await asyncio.sleep(delay)除了间隔本身,还可以加入偶尔的长停顿。真人浏览时不会一直匀速滚动,有时候会停下来看某条内容,停顿三五秒甚至更久。可以在滑动序列中随机插入长停顿,比如每滑动 5 到 8 次,插入一个 3 到 8 秒的停顿。
注意:长停顿的插入位置也要随机,不要固定在每第 5 次。可以用一个概率控制,比如每次滑动后有 15% 的概率触发长停顿。
3.5 验证码检测与初步处理
验证码检测的核心是及时发现验证码元素出现。不同平台的验证码 DOM 结构不同,但通常有一些共同特征,比如包含captcha、verify、slider等关键词的类名或 ID。
我的做法是在每次滑动后,用page.querySelector检查页面上是否存在验证码相关的元素。如果存在,就暂停滑动,进入验证码处理流程。
async def check_captcha(page): captcha_selectors = [ '.captcha-container', '[class*="verify"]', '[class*="slider"]', '#captcha', ] for selector in captcha_selectors: element = await page.querySelector(selector) if element: return True return False检测到验证码后,先判断类型。如果是简单的滑块验证,可以尝试用轨迹模拟来自动完成。但说实话,现在主流平台的行为验证都很复杂,纯靠轨迹模拟通过的难度很大。我的建议是不要在这上面死磕,检测到验证码后直接暂停,保存当前状态,等人工处理。
人工处理完后,脚本从暂停处继续。这样虽然牺牲了全自动性,但稳定性高得多。毕竟我们的目标是拿到数据,不是炫技。
4. 实操过程与核心环节实现
4.1 完整滑动流程的代码实现
把前面讲的各个模块串起来,就是一个完整的滑动流程。下面是我实际使用的代码结构,做了简化,去掉了业务相关的部分。
import asyncio import random import math from pyppeteer import launch class FeedScraper: def __init__(self): self.browser = None self.page = None self.scroll_count = 0 async def init_browser(self): self.browser = await launch( headless=False, args=[ '--no-sandbox', '--disable-blink-features=AutomationControlled', '--window-size=1920,1080', ] ) self.page = await self.browser.newPage() await self.page.setViewport({'width': 1920, 'height': 1080}) await self.page.evaluateOnNewDocument(''' Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); ''') async def load_page(self, url): await self.page.goto(url, {'waitUntil': 'networkidle0', 'timeout': 60000}) await self.page.waitForSelector('.feed-card', {'timeout': 30000}) def generate_scroll_steps(self, total_distance, steps=30): distances = [] for i in range(steps): progress = i / steps speed_factor = math.sin(progress * math.pi) jitter = random.uniform(0.8, 1.2) step = (total_distance / steps) * speed_factor * jitter distances.append(step) scale = total_distance / sum(distances) return [d * scale for d in distances] async def smooth_scroll(self, distance): steps = self.generate_scroll_steps(distance) for step in steps: await self.page.evaluate(f'window.scrollBy(0, {step})') await asyncio.sleep(random.uniform(0.01, 0.03)) async def random_delay(self, base=1.5, jitter=0.75): delay = base + random.uniform(-jitter, jitter) delay = max(0.5, delay) await asyncio.sleep(delay) async def check_captcha(self): selectors = ['.captcha-container', '[class*="verify"]', '[class*="slider"]'] for selector in selectors: element = await self.page.querySelector(selector) if element: return True return False async def scroll_loop(self, max_scrolls=50): for i in range(max_scrolls): if await self.check_captcha(): print(f'第 {i} 次滑动后检测到验证码,暂停等待人工处理') input('请手动完成验证后按回车继续...') await asyncio.sleep(2) distance = random.randint(600, 900) await self.smooth_scroll(distance) self.scroll_count += 1 await self.random_delay() # 随机长停顿 if random.random() < 0.15: pause = random.uniform(3, 8) print(f'随机长停顿 {pause:.1f} 秒') await asyncio.sleep(pause) # 检查是否到达底部 at_bottom = await self.page.evaluate(''' () => { return window.innerHeight + window.scrollY >= document.body.scrollHeight - 100; } ''') if at_bottom: print('已到达页面底部') break async def run(self, url): await self.init_browser() await self.load_page(url) await self.scroll_loop() await self.browser.close() if __name__ == '__main__': scraper = FeedScraper() asyncio.get_event_loop().run_until_complete( scraper.run('https://example.com/feed') )这段代码可以直接跑,把 URL 和选择器换成目标平台的就行。几个关键参数说明一下:distance每次在 600 到 900 像素之间随机,这个范围是根据 1080p 屏幕的高度定的,大约是屏幕高度的 60% 到 85%。steps=30表示每次滑动分成 30 小步,步数越多轨迹越平滑,但执行时间也越长。实测 30 步在流畅度和速度之间比较平衡。
4.2 滚动距离与屏幕高度的关系计算
滚动距离不是随便定的,它直接影响触发加载的成功率。如果滚得太少,哨兵元素没进入视口,不会触发加载;滚得太多,又显得不自然。
我的计算逻辑是这样的:假设屏幕高度是H,信息流卡片的平均高度是C,那么一次滚动至少要让一个新的卡片完全进入视口。所以最小滚动距离应该大于C。但为了确保哨兵元素被触发,滚动距离最好接近H的 70% 到 90%。
以 1080p 屏幕为例,H = 1080,那么滚动距离在 750 到 970 之间比较合适。我取的是 600 到 900,稍微保守一点,因为有些平台的卡片高度较大,滚太多反而会跳过哨兵元素。
实操心得:如果不确定卡片高度,可以在开发者工具里量一下。或者先用一个较大的滚动距离测试,看是否能稳定触发加载,然后再逐步调整。我最初用的是固定 800 像素,后来发现有些页面卡片高度超过 800,导致哨兵元素被跳过,改成随机范围后就稳定了。
4.3 验证码出现时的状态保存与恢复
验证码出现后,脚本暂停,但页面状态不能丢。我的做法是在暂停前把当前滚动位置和已采集的数据保存下来。滚动位置用window.scrollY获取,数据存在内存或临时文件里。
async def save_state(self): scroll_y = await self.page.evaluate('window.scrollY') state = { 'scroll_y': scroll_y, 'scroll_count': self.scroll_count, 'url': self.page.url, } with open('scraper_state.json', 'w') as f: json.dump(state, f) print(f'状态已保存:滚动位置 {scroll_y},滑动次数 {self.scroll_count}')恢复的时候,先重新加载页面,然后滚动到保存的位置,再继续滑动。注意,重新加载后页面内容可能已经变化,滚动位置对应的内容可能不同。所以恢复后不要直接继续滑动,而是先检查一下当前内容是否和保存时一致,不一致的话可能需要重新开始。
这个机制在实际使用中救了我好几次。有一次脚本跑了半小时,突然弹验证码,如果没有状态保存,前面的数据就全丢了。
4.4 数据提取与去重逻辑
滑动的目的是为了加载更多数据,所以每滑动几次就要提取一次当前页面的数据。提取的时机很关键,太频繁会影响性能,太稀疏会漏数据。
我的做法是每次滑动后都提取一次,但只提取新出现的卡片。怎么判断哪些是新的?给每个卡片生成一个唯一标识,比如用卡片里的内容 ID 或者内容文本的哈希值。提取时先获取所有卡片的标识,和已保存的标识对比,只处理新的。
async def extract_new_items(self, seen_ids): items = await self.page.evaluate(''' () => { const cards = document.querySelectorAll('.feed-card'); return Array.from(cards).map(card => ({ id: card.getAttribute('data-id') || card.innerText.slice(0, 50), text: card.innerText, })); } ''') new_items = [] for item in items: if item['id'] not in seen_ids: seen_ids.add(item['id']) new_items.append(item) return new_items这里用>async def wait_for_feed(self): selectors = ['.feed-card', '[class*="feed-item"]', '[data-testid="feed"]'] for selector in selectors: try: await self.page.waitForSelector(selector, {'timeout': 10000}) return selector except Exception: continue raise Exception('所有选择器都失效了,需要更新')
另外,尽量用结构性选择器而不是类名。比如div[role="list"] > div:nth-child(1)这种,比.feed-card更稳定,因为类名可能会变,但 DOM 结构相对稳定。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 滑动后页面不动 | 滚动容器不是 window | 检查 scrollY 是否变化 | 找到真正的滚动容器,对其执行 scrollBy |
| 滑动后不加载新内容 | 哨兵元素未进入视口 | 用 getBoundingClientRect 检查位置 | 增大滚动距离或调整滚动方向 |
| 频繁弹出验证码 | 滑动行为太机械 | 观察滑动速度和间隔 | 降低速度、增加随机性、加入鼠标移动 |
| 选择器超时 | 页面结构变化 | 在开发者工具验证选择器 | 使用多重选择器或结构性选择器 |
| 数据提取为空 | 卡片元素未渲染完成 | 检查 DOM 中是否有卡片 | 增加等待时间或等待特定子元素 |
| 脚本跑一段时间后卡死 | 内存泄漏或页面崩溃 | 查看浏览器进程状态 | 定期重启浏览器或清理页面 |
独家避坑技巧:如果脚本需要长时间运行,建议每采集 500 条数据就重启一次浏览器。
pyppeteer长时间运行后,内存占用会持续增长,页面响应变慢,最终可能导致崩溃。重启浏览器虽然麻烦,但能保证稳定性。我试过连续跑 4 小时不重启,结果内存吃到 8G,页面直接卡死。后来改成每 500 条重启一次,连续跑 12 小时都没问题。
5.5 关于验证码处理的几点务实建议
最后聊聊验证码。我知道很多人想做到全自动识别和通过,但现实是,主流平台的行为验证越来越复杂,纯靠图像识别和轨迹模拟的通过率很低。与其在这上面投入大量时间,不如把精力放在降低触发概率上。
我的策略是:滑动行为做到足够拟人化,把验证码触发概率压到最低;同时做好检测和状态保存,一旦触发就暂停等人工。这样虽然需要偶尔人工介入,但整体效率比死磕自动识别高得多。
如果确实需要自动处理,建议从简单的滑块验证入手,用 OpenCV 做缺口识别,然后用前面讲的轨迹生成算法模拟拖动。但要注意,拖动轨迹比滚动轨迹要求更高,因为滑块验证通常会分析拖动的加速度曲线和抖动特征。这块内容展开又是一大篇,这里就不细说了。
另外,验证码的处理策略要根据平台调整。有些平台的验证码只是偶尔出现,人工处理完全可接受;有些平台则非常频繁,那就得考虑换采集方式,比如用官方 API 或者降低采集频率。技术方案要服务于实际需求,不要为了自动化而自动化。
我在实际使用这套方案的过程中,最大的体会是:拟人化不是加几个随机数就完事了,而是要从速度、轨迹、间隔、停顿多个维度同时入手。单独调整某一个维度,效果有限;多个维度一起调整,才能把触发概率降到可接受的水平。还有就是,状态保存机制一定要有,这是长时间运行脚本的生命线。踩过几次数据丢失的坑之后,我现在写任何采集脚本,第一件事就是加状态保存。