直接开始。玩爬虫的人多少都遇到过这种场景:目标网站数据明明就摆在页面上,但用 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, step=1200, interval=0.8): page.evaluate(f"window.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 环境准备与最小脚本框架先装 Playwright:pip 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(headless=True) context = browser.new_context( 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", viewport={"width": 1280, "height": 800}, ) page = context.new_page() page.goto(BASE_URL, timeout=60000) 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(f"window.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, threshold=300): 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_state="cookies.json")首次运行时,用 playwright 的 persistent context 打开一个有头浏览器,手动登录一次,登录完成后,把 context 的 storage_state 导出到文件。后续脚本运行时,直接加载这个文件,浏览器里就自动带上了登录态,不需要处理扫码、验证码等环节。导出状态代码:context.storage_state(path="cookies.json")注意:cookie 和 localStorage 里都会保存 session,过期了就重新导一次。对于验证码拦截,单靠 Playwright 很难完全绕开。比较现实的思路是设置滚动间隔随机化,在 0.7 到 1.5 秒之间随机等待,降低行为特征的一致性;同时限制采集总时长,超过 5 到 10 分钟就重启浏览器,避免长时间高频操作被风控标记。更进阶的方案是接入打码平台,但这类方案本身有合规风险,我个人不推荐用于任何未经授权的采集场景。4.3 页面加载速度与滚动节奏不匹配滚动速度快,加载速度慢,就会出现一种现象:滚动到页面底部后,页面短暂空白,卡片还没渲染出来。此时采集模块如果立刻执行,一个卡片都抓不到,白白空转一轮。解决办法是引入显式等待。在采集前验证页面是否已经出现了新增卡片,而且新增卡片数量和加载前相比有增长。比如:def wait_for_new_cards(page, previous_count, timeout=10): 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-scraped='true'])")采集处理后,给节点设置这个属性: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 模式,也就是 headless=False。浏览器窗口会弹出来,你在屏幕上可以直接看到滚动的每一步,到底是在等待内容、还是已经到了页面底部,一目了然。调试稳定后再切回 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)图片和字体对数据采集没有任何价值,拦截掉之后,页面加载速度和滚动流畅度都会有明显提升,采集效率能快上两倍。这个方案做到这里,从技术角度已经完整了。剩下的就是你自己动手去调选择器、调节奏、调等待逻辑,把通用框架变成能跑通你目标页面的可运行脚本。瀑布流的核心挑战永远是“时机”——什么时候该滚,什么时候该等,什么时候算到底,这三个问题的答案,只有你在真实页面里反复试过,才能真正掌握。
直接开始。玩爬虫的人多少都遇到过这种场景:目标网站数据明明就摆在页面上,但用 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, step=1200, interval=0.8): page.evaluate(f"window.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 环境准备与最小脚本框架先装 Playwright:pip 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(headless=True) context = browser.new_context( 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", viewport={"width": 1280, "height": 800}, ) page = context.new_page() page.goto(BASE_URL, timeout=60000) 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(f"window.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, threshold=300): 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_state="cookies.json")首次运行时,用 playwright 的 persistent context 打开一个有头浏览器,手动登录一次,登录完成后,把 context 的 storage_state 导出到文件。后续脚本运行时,直接加载这个文件,浏览器里就自动带上了登录态,不需要处理扫码、验证码等环节。导出状态代码:context.storage_state(path="cookies.json")注意:cookie 和 localStorage 里都会保存 session,过期了就重新导一次。对于验证码拦截,单靠 Playwright 很难完全绕开。比较现实的思路是设置滚动间隔随机化,在 0.7 到 1.5 秒之间随机等待,降低行为特征的一致性;同时限制采集总时长,超过 5 到 10 分钟就重启浏览器,避免长时间高频操作被风控标记。更进阶的方案是接入打码平台,但这类方案本身有合规风险,我个人不推荐用于任何未经授权的采集场景。4.3 页面加载速度与滚动节奏不匹配滚动速度快,加载速度慢,就会出现一种现象:滚动到页面底部后,页面短暂空白,卡片还没渲染出来。此时采集模块如果立刻执行,一个卡片都抓不到,白白空转一轮。解决办法是引入显式等待。在采集前验证页面是否已经出现了新增卡片,而且新增卡片数量和加载前相比有增长。比如:def wait_for_new_cards(page, previous_count, timeout=10): 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-scraped='true'])")采集处理后,给节点设置这个属性: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 模式,也就是 headless=False。浏览器窗口会弹出来,你在屏幕上可以直接看到滚动的每一步,到底是在等待内容、还是已经到了页面底部,一目了然。调试稳定后再切回 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)图片和字体对数据采集没有任何价值,拦截掉之后,页面加载速度和滚动流畅度都会有明显提升,采集效率能快上两倍。这个方案做到这里,从技术角度已经完整了。剩下的就是你自己动手去调选择器、调节奏、调等待逻辑,把通用框架变成能跑通你目标页面的可运行脚本。瀑布流的核心挑战永远是“时机”——什么时候该滚,什么时候该等,什么时候算到底,这三个问题的答案,只有你在真实页面里反复试过,才能真正掌握。