
直接开始。玩爬虫的人多少都遇到过这种场景目标网站数据明明就摆在页面上但用 requests 怎么抓都只能拿到首页那一小段往下翻页面心跳都跟着滚轮一起加速可手里的代码就是纹丝不动。小红书这种重 JS 交互的平台更是典型中的典型。它的内容流不是传统分页而是无限瀑布流滚动到底部触发加载请求参数里还混着加密签名从来不给你一个干净的 JSON 接口。我这次要拆解的项目就是专门针对这种场景的一套思路用 Playwright 驱动真实浏览器模拟用户滚动操作让平台自己把数据一层层加载出来再从 DOM 里把卡片内容逐个摘出来。这套方案不属于“低调抓包”那一路而是正面刚——直接用自动化工具假装真人把反爬的墙从根上绕过去。整个过程只依赖浏览器本身不用逆向任何加密算法代码量不大但细节坑极多。先说清楚这篇文章纯粹是技术交流目的是拆解瀑布流加载机制和浏览器自动化操控的通用原理不鼓励、不支持任何人将爬虫用于商业牟利、大规模抓取用户隐私或破坏平台正常运营。做技术实验样本自己造别往别人家生产环境里打。1. 内容整体设计与思路拆解1.1 为什么 requests 那套传统方案在这里行不通写过爬虫的人对 requests 加正则这组合都熟但遇到小红书这类站点这套组合拳基本是打在棉花上。第一层问题是数据根本不在初始 HTML 里。打开小红书首页浏览器拿到手的是一堆 JS 脚本和少量骨架标签真正的笔记卡片、作者信息、点赞数这些数据都是页面加载后由 JS 发异步请求去服务器取的然后用动态脚本把数据渲染成 DOM。用 requests 去抓拿到的只是“壳”和“骨架”。第二层问题就算你用抓包工具拦截到那些异步请求也很难仿造出来。页面发出去的请求 Header 里有签名参数有的还带加密的 token这些由前端脚本动态计算每个请求都在变化。你要是手动分析算法费神费力而且人家改一版逻辑之前的逆向成果直接作废。第三层问题是频率控制。传统方案开多线程跑 requests很容易在短时间内制造大量相同 UA、相同 IP 的请求触发平台风控。轻则验证码重则封 IP做了一个多小时数据全废。1.2 Playwright 方案为什么是“暴力”且解耦的Playwright 对爬虫工程师来说是另一个物种的工具。它不是 HTTP 客户端而是一个完整的浏览器自动化框架能同时操控 Chromium、WebKit 和 Firefox。用 Playwright 做爬虫相当于在浏览器里放了一个“机器手指”页面以为是真人操作实际上动作全是脚本控制的。最核心的优势在于它彻底绕开了“逆向数据请求”这个环节。页面要数据是浏览器自己发请求加签名的这个签名生成逻辑藏在压缩后的 JS bundle 里根本不用我们管。我们只需要做三件事用 Playwright 打开页面模拟用户滚动滚轮到页面底部在 DOM 里摘取已经渲染好的卡片内容。这种策略的代价是“重”一个浏览器实例要占几百 MB 内存跑得慢而且并发能力有限。但换来的是极高的稳定性和部署效率。项目时间紧、不想扣 JS、不想算加密参数的时候这条路性价比是最高的。1.3 无限瀑布流加载机制的核心是什么无限瀑布流这个词听起来玄乎拆开了就三个小机制滚动监听页面监听 window 的 scroll 事件判断当前滚动位置是否快接近页面底部比如距离底部只剩 300 像素时就触发加载。异步加载触发逻辑调用后端 API拿到新一批笔记数据再动态生成新的卡片节点追加到内容列表里。DOM 差异化因为每次加载追加的都是新节点整个页面 DOM 会无限增长所以爬虫采集时必须在“追加前”和“追加后”之间正确识别新节点否则不是漏数据就是重复采集。理解了这三个机制整个脚本的骨架就出来了先滚动再等待加载再采集再滚动循环往复直到没有新内容为止。这个设计里最关键的一点是“等待”。很多初学爬虫的人忽略了这个环节滚动完马上就去抓 DOM结果发现新数据还没渲染出来抓了个空。真实实现的时候必须反复用 wait_for_selector 或轮询的方式确认新内容已经出现在页面上再去采集数据。2. 核心细节解析与实操要点2.1 页面加载策略到底该等哪个事件Playwright 打开页面有几种等待策略常见的包括 load、domcontentloaded、networkidle。load 事件表示页面的所有资源包括图片、脚本、样式都加载完了。domcontentloaded 则更快一些只在 HTML 被完全加载和解析后触发。networkidle 要求网络连接在指定时间内没有新请求表示页面处于相对安静状态。爬小红书首页我一开始用的 load页面能打开但有个问题首次进入时页面会弹登录框或者做各种初始化请求load 事件并不代表内容流已经渲染完了。首页的卡片数据往往在 load 触发之后才异步加载回来。实操下来更可靠的做法是先设置一个基础等待等选择器 [data-v-*] 这类笔记卡片节点出现再做后续操作。换句话说不要只信加载事件要信你想要的数据节点是否真实出现在 DOM 里。这一步是很多爬虫跑一会儿就断片的隐形原因。另外浏览器启动时加一个真实的 User-Agent 和 Viewport 是必须的。默认的 headless 模式 UA 里自带 HeadlessChrome 字样部分网站识别之后直接给一个简化页面甚至验证码页面。我的做法是先从正常浏览器里复制一份 UA然后在 launch 时通过 args 和 context 设置进去。2.2 滚动的姿势一次滚多少间隔多久无限瀑布流加载的关键动作是滚动但滚动不是“一口气滚到底”。真实用户的滚动是分段进行的每段之间有停顿这个停顿给了页面触发异步请求、渲染新内容的时间。如果脚本里用 page.mouse.wheel 一次滚过整个页面高度瀑布流的监听逻辑根本不会在滚动途中触发多次加载只会当滚动到页面底部时只加载一次然后页面高度变长你还要再滚效率极低。而且快速滚动容易被浏览器性能监控标记行为模式偏离正常用户。更合理的做法是固定步长滚动。比如设置每 800 毫秒滚动 1200 像素import time from playwright.sync_api import sync_playwright def scroll_step(page, step1200, interval0.8): page.evaluate(fwindow.scrollBy(0, {step})) time.sleep(interval)这样滚动两三次之后页面就触发了加载请求新卡片会追加出来。等新卡片渲染完成后滚动距离又重新变长了脚本需要持续判断页码高度是否增长。注意间隔时间不能太短比如低于 500 毫秒加载请求还没回来滚动就到页面底部了触发的加载逻辑可能会被浏览器判定为“非正常操作”。太长了也不行总采集时间会被拉得很长。我在测试中常用的节奏是 800 到 1200 毫秒之间具体要看页面内容的复杂度和网络延迟。2.3 采集策略DOM 定位与去重瀑布流页面的卡片结构不同站点不同版本差异很大。小红书新版页面的卡片外层结构较长用 ID 或 class 直接定位是不可能的因为很多 class 名是动态混淆的。实践中的稳定方案是用相对稳定的属性选择器组合。例如先定位笔记卡片列表的容器再在容器里做循环处理。我这里用一个简化的示例来模拟cards page.query_selector_all(.note-item) for card in cards: title_el card.query_selector(.title) author_el card.query_selector(.author) if title_el and author_el: data { title: title_el.inner_text(), author: author_el.inner_text(), }这只是演示思路实际项目里选择器要自己调。瀑布流采集的一个行业共识是优先用容器定位卡片然后在卡片内继续做相对查找避免全页面搜索带来的误匹配。去重是必做的。因为滚动过程中某个卡片可能在当前视口里被采集一次滚动后它掉到屏外再滚回来时如果你从头到尾重新遍历它的 DOM 节点还在页面里就会被重复抓到。最简单的方式是维护一个已采集的笔记 ID 集合每次循环后把新抓到的 ID 加入集合只在 ID 尚未出现在集合里时才记录数据。还有一种做法是在滚动后只采集新增 DOM 节点区域内的卡片。实现方式是记录滚动前页面总高度然后从高度偏移处开始定位后续节点。但这种做法对 DOM 结构要求高实际用下来不如维护 ID 集合可靠。2.4 数据存储与增量处理数据量小的时候直接写入 JSON 文件最省事。但瀑布流一滚动就是几十屏数据轻松上几千条这时候 JSON 文件读写就变成瓶颈了而且 JSON 无法做增量更新。我建议直接用 SQLite。Python 自带的 sqlite3 库就能支持不用额外装东西保存成单个文件方便后续做分析或者导出 CSV。另外如果采集任务可能分多次跑那数据表里必须加一个唯一索引采到重复 ID 时直接跳过真正做到断点续采。建表语句参考CREATE TABLE IF NOT EXISTS notes ( note_id TEXT PRIMARY KEY, title TEXT, author TEXT, likes INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP );写入时用 INSERT OR IGNORE自动跳过重复的主键。这样即使中途中断下次重跑也不会在库里产生重复记录。3. 实操过程与核心环节实现3.1 环境准备与最小脚本框架先装 Playwrightpip install playwright playwright install chromium注意第二次命令会下载 Chromium 的浏览器内核大概一百多兆需要点时间而且国内网络环境可能会慢设置代理或者换镜像源可以解决。然后是最小脚本框架。我以模拟某内容平台为例说明整体的循环控制流程。因为各家选择器不同我写成参数可配置的模板方便读者改成自己的场景。import time import sqlite3 from playwright.sync_api import sync_playwright BASE_URL https://example.com/explore SCROLL_STEP 1200 SCROLL_INTERVAL 1.0 MAX_SCROLLS 50 def get_conn(): conn sqlite3.connect(articles.db) conn.execute( CREATE TABLE IF NOT EXISTS items ( item_id TEXT PRIMARY KEY, title TEXT, author TEXT, detail_url TEXT ) ) return conn def insert_item(conn, item_id, title, author, detail_url): conn.execute( INSERT OR IGNORE INTO items (item_id, title, author, detail_url) VALUES (?, ?, ?, ?), (item_id, title, author, detail_url), ) def run(): conn get_conn() with sync_playwright() as p: browser p.chromium.launch(headlessTrue) context browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, viewport{width: 1280, height: 800}, ) page context.new_page() page.goto(BASE_URL, timeout60000) page.wait_for_timeout(3000) seen_ids set() same_height_count 0 for _ in range(MAX_SCROLLS): # 采集当前页面内的卡片 cards page.query_selector_all(.card-item) new_count 0 for card in cards: item_id card.get_attribute(data-id) if not item_id or item_id in seen_ids: continue title card.query_selector(.title).inner_text() author card.query_selector(.author).inner_text() detail_url card.query_selector(a).get_attribute(href) insert_item(conn, item_id, title, author, detail_url) seen_ids.add(item_id) new_count 1 conn.commit() # 滚动前记录高度 prev_height page.evaluate(document.body.scrollHeight) page.evaluate(fwindow.scrollBy(0, {SCROLL_STEP})) page.wait_for_timeout(int(SCROLL_INTERVAL * 1000)) # 滚动后检查高度是否变化 current_height page.evaluate(document.body.scrollHeight) if current_height prev_height: same_height_count 1 if same_height_count 3: print(已到达底部停止滚动) break else: same_height_count 0 browser.close() conn.close() print(f采集完成共 {len(seen_ids)} 条新记录) if __name__ __main__: run()这段代码的核心逻辑分四个环节采集当前视口和目标区域的卡片写入数据库并做去重滚动页面判断页面高度是否变化连续三次无变化则判定到底。这里的 same_height_count 逻辑很关键。因为有些场景下滚动到底后页面高度不会再变化但页面可能会提示“没有更多了”此时如果不停下来脚本会在原地反复滚动浪费时间和资源。3.2 模拟用户滚动的节奏控制刚才的脚本一步滚 1200 像素但在某些页面上1200 像素可能跨越了多个卡片的高度加载时机的触发点容易被跨过。更贴近真实用户的做法是步长更小比如 600 像素然后滚动后等待更短。步长小触发加载逻辑的命中率就高步长太大可能滚一次就触发了加载但加载回来的卡片没有被及时采集就被下一轮滚动带过去这倒不会丢数据但会让采集更新出现较大的时间差。我个人在实际项目里会做一个自适应方案先获取当前页面高度和视口高度然后计算可视区底部到页面底部的距离只有当这个距离小于某个阈值时才滚动否则就等待新内容加载。def near_bottom(page, threshold300): res page.evaluate( () window.innerHeight window.scrollY document.body.scrollHeight - arguments[0], threshold, ) return res这种判断逻辑更接近浏览器的真实行为会让触发加载的时机刚好落在页面底部附近而不是盲目地滚一段距离就停。当然代价是代码逻辑更复杂一些需要循环里做更多的判断分支。3.3 选择器定位如何挑最稳定的那个 class 或属性玩 Playwright 的都知道最痛苦的不是写控制逻辑而是分析页面结构。每次网站前端发版class 名大概率会变甚至是混淆后的随机字符串。这时候如果写死 class脚本很容易就废了。解决办法有三个方向选择语义化标签比如 article、section 这类即便前端调整样式结构标签一般不会大改选择 data 开头的数据属性这类属性通常是给内部 JavaScript 用的带有业务含义例如>context browser.new_context(storage_statecookies.json)首次运行时用 playwright 的 persistent context 打开一个有头浏览器手动登录一次登录完成后把 context 的 storage_state 导出到文件。后续脚本运行时直接加载这个文件浏览器里就自动带上了登录态不需要处理扫码、验证码等环节。导出状态代码context.storage_state(pathcookies.json)注意cookie 和 localStorage 里都会保存 session过期了就重新导一次。对于验证码拦截单靠 Playwright 很难完全绕开。比较现实的思路是设置滚动间隔随机化在 0.7 到 1.5 秒之间随机等待降低行为特征的一致性同时限制采集总时长超过 5 到 10 分钟就重启浏览器避免长时间高频操作被风控标记。更进阶的方案是接入打码平台但这类方案本身有合规风险我个人不推荐用于任何未经授权的采集场景。4.3 页面加载速度与滚动节奏不匹配滚动速度快加载速度慢就会出现一种现象滚动到页面底部后页面短暂空白卡片还没渲染出来。此时采集模块如果立刻执行一个卡片都抓不到白白空转一轮。解决办法是引入显式等待。在采集前验证页面是否已经出现了新增卡片而且新增卡片数量和加载前相比有增长。比如def wait_for_new_cards(page, previous_count, timeout10): start time.time() while time.time() - start timeout: current_count page.evaluate(document.querySelectorAll(.card-item).length) if current_count previous_count: return True time.sleep(0.5) return False这个等待逻辑会反复查询当前卡片总数只有确认数量增加后才继续。如果超过超时时间仍然没有新增就认为已经到底或者接口出错了。另外一个细节是滚动加载的请求是异步的可能在滚动结束后 1 到 3 秒才返回如果等待时间不够很可能出现误判“到底”的情况。因此我建议在判断到底之前至少等待两轮加载周期确认页面高度连续 N 次无变化且卡片数量无增长才算真正结束。4.4 采集过程中元素数量膨胀导致的性能暴跌从 100 条卡片增长到 500 条卡片时页面渲染压力会逐渐凸显。Playwright 的 query_selector_all 在遍历大 DOM 时耗时也会增加。如果脚本每轮都从页面顶部遍历所有卡片会做大量无意义的工作效率越来越低。一个比较有效的优化是只处理页面底部的新增卡片区域。记录历史采集到的最新卡片位置下一轮只定位到这个位置之后的节点。实现方式可以是给卡片加一个自定义属性标记已采集过的卡片在 DOM 里设置>new_cards page.query_selector_all(.card-item:not([data-scrapedtrue]))采集处理后给节点设置这个属性card.evaluate((el) el.setAttribute(data-scraped, true), card)这样每次遍历的节点数量就是纯粹的新增节点数量遍历压力大大降低而且不会重采。4.5 反爬升级如何应对动态渲染结构改版页面结构改版是所有爬虫方案都躲不开的坎。今天能跑的脚本隔一周可能选择器全部失效。我应对改版的策略是把选择器独立成配置项脚本冻结在代码里选择器全部提取到外部配置文件或变量区域改版时只改配置。定期在测试环境里跑一个 smoke test确认能采集到至少一条数据否则报警提示需要人工介入修改选择器。核心采集逻辑尽量用相对定位和层级关系而不是写死一条长长的选择器链这样结构微调时影响范围可控。Playwright 的 trace viewer 功能可以用来录制定位过程这个对排查选择器失效问题很有帮助。在代码里开 trace 录制然后追踪每一步 operate 前后的 DOM 快照可以直观看到脚本操作的时候页面到底处在什么状态。5. 部署形态与补充思考5.1 单机脚本式运行最顺手这个项目的应用场景如果是个人学习或中小规模数据验证我最推荐的部署形态就是单机脚本。Python 直接跑 cron 任务或者手动触发配置好数据库就完事维护成本几乎为零。缺点是只能单点运行如果采集量大了单浏览器的速率很容易成为瓶颈。不过考虑到文章主题本就是把 Playwright 用于快速解决动态渲染页面的问题单机形态足够覆盖九成场景。5.2 异步与并发多浏览器实例要谨慎不要轻易并行多个浏览器跑同一个账号或同一出口地址。不同实例的 UA、指纹、Cookie 如果一致很容易被识别为同一批自动机。如果一定要提高吞吐量正确的做法是每个实例配置独立的浏览器上下文和独立的指纹信息并且严格控制总请求频率。这一点上Playwright 的 sync API 比 async API 更适合新手逻辑链路清晰不容易出现协程泄漏和请求乱序的问题。高性能场景再考虑 async但需要更强的工程能力。5.3 合规与边界思考写到这里还是要再次强调边界。瀑布流页面的自动化操控本质是一种绕过站点常规交互逻辑的行为。如果目标站点明确禁止自动化访问、禁止未经授权的数据采集那么这种技术方案就不应该被用于对其实施采集。本项目的价值在于解决一个通用技术问题——如何在浏览器自动化场景下处理动态加载的无限列表。技术本身是中性的但使用技术的人要守规矩。我的个人实践原则是三不碰不碰需要登录才能查看的用户私有数据不碰明确声明禁止爬取的站点不碰会导致目标站点服务异常的频率和流量。其他情况下凡是公开页面、合理频率、个人学习研究用途的自动化采集我认为可以放心做技术实验。6. 最后留几个小技巧第一滚动间隔别用固定值。写成 random.uniform(0.8, 1.6) 这种随机分布行为模式比固定值更接近真人触发风控的概率低很多。第二调试过程中尽量用 headed 模式也就是 headlessFalse。浏览器窗口会弹出来你在屏幕上可以直接看到滚动的每一步到底是在等待内容、还是已经到了页面底部一目了然。调试稳定后再切回 headless 模式节省资源。第三数据库记录里加一个批次号字段。每次跑任务时生成一个随机批次号万一采集任务出问题可以通过批次号快速删除本次产生的脏数据不会污染历史数据。ALTER TABLE items ADD COLUMN batch_id TEXT;爬取前给每批数据打个标记后续排查数据问题时能节省好几倍时间。第四注意别被目标站点拖死。Playwright 打开页面后默认会加载大量图片、视频、广告脚本这些资源不仅拖慢速度还占用本地带宽和内存。可以在浏览器上下文里拦截不需要的请求类型def block_unnecessary(route): if route.request.resource_type in (image, media, font): route.abort() else: route.continue_() page.route(**/*, block_unnecessary)图片和字体对数据采集没有任何价值拦截掉之后页面加载速度和滚动流畅度都会有明显提升采集效率能快上两倍。这个方案做到这里从技术角度已经完整了。剩下的就是你自己动手去调选择器、调节奏、调等待逻辑把通用框架变成能跑通你目标页面的可运行脚本。瀑布流的核心挑战永远是“时机”——什么时候该滚什么时候该等什么时候算到底这三个问题的答案只有你在真实页面里反复试过才能真正掌握。