☰
Playwright网络流监听:无限滚动页面JSON爬取实战
2026/10/1 12:38:40 网站建设 项目流程

以前刚学爬虫那会儿,一碰到无限滚动页面就头皮发麻。requests 拿回来的 HTML 干干净净,笔记一条都看不见,页面里全是 JS 渲染出来的内容。后来入了 Playwright 的坑,总算能从浏览器手里拿到 DOM 了,可新的麻烦又来了:滚动速度慢、元素定位脆、抓一半还频繁重复。

直到我把目光从页面挪到网络请求上,思路才彻底打开。无限滚动的本质,是前端通过 XHR/fetch 往后台发请求,后端返回 JSON 后由页面动态渲染。那我还费劲解析 DOM 干嘛?直接在浏览器收到响应的瞬间,把 JSON 截下来不就行了?基于这个思路,我用 Playwright 的网络流监听能力,稳稳定抓小红薯(小红书)搜索页的无限滚动笔记,整个过程不碰逆向、不碰 DOM,拿到的全部是结构化 JSON。

这篇东西适合被动态页面爬取折磨过、又不太想陷入 JS 逆向泥潭的读者。里面有完整可跑的代码、真实的踩坑记录,还有一套可以迁移到其他 SPA 站点的通用思路。

1. 无限滚动页面的本质:为什么数据永远藏在请求里

1.1 一个肉眼可见却拿不到的页面

你先打开小红书搜索页,输入“美食”,页面刷刷往下滑,笔记一条接一条冒出来,无穷无尽。这时候你复制一下页面源码,或者用 requests.get 去请求同一个 URL,得到的只是一堆 script 标签和空壳 div,笔记正文、作者、点赞数统统不存在。

这不是玄学,而是现代前端框架的加载方式决定的。React、Vue 这类框架拿到页面骨架后,根本不需要服务端把数据塞进 HTML。它会在浏览器里执行一段 JS,调一个后台接口,拿到 JSON 后再动态生成 DOM。所以你看到的页面是“数据渲染后的结果”,把结果倒推到源码层面,当然什么都找不到。

1.2 三类常规方案各自的死穴

在这个问题上,常见的替代方案我都试过,各有各的坑:

方案核心思路主要死穴
requests + 静态 HTML 解析直接下载页面源码SPA 页面源码里根本没有数据
Selenium/Playwright + DOM 解析模拟滚动后按 class 定位元素慢、脆、容易重复,依赖元素结构
分析 XHR 后用 requests 伪造请求找到接口后直接构造请求要逆向签名参数,后端一改就崩

如果你也经历过滚动半天、定位控件翻车、最后把同样的笔记抓了五六遍这种事,那问题不大,只是思路该换了。

1.3 监听网络流的核心思路

浏览器本来就要替我们发请求、算签名、收响应、渲染页面。监听网络流的思路特别简单:数据在服务器返回给浏览器的那一刻,我把响应体复制一份留给自己。

这相当于你站在餐厅后厨的出菜口,服务员端什么菜你直接拍个照,不用等客人吃完再去盘子里翻骨头。网络流监听拿到的,是 HTTPS 解密之后的原始响应,大部分情况下就是格式化好的 JSON。最关键的:浏览器发出的请求自带签名、Cookie、UA 等完整环境,你完全不需要去逆向前端加密算法。这就是它比“伪造 XHR”省心无数倍的原因。

2. 环境准备:Playwright 安装与网络事件基础

2.1 安装别再漏掉浏览器内核

很多人卡在第一步,是因为 pip 装完 playwright 之后没装浏览器内核,一启动就报错。正确操作是两条命令:

pip install playwright playwright install chromium

第一个命令装的是 Python 库,第二个命令下载 Chromium 浏览器内核。如果只玩网页爬虫,没必要playwright install把 Firefox、WebKit 全家桶都拉下来,一个 chromium 就够,体积小、加载快。Linux 服务器上如果下载完了启动还报缺动态库,再补一句:

playwright install-deps

这会把系统缺的底层运行库一次性补齐。Windows 和 macOS 一般不需要。

2.2 启动浏览器时的关键上下文选项

启动代码我习惯这样配:

from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch( headless=False, slow_mo=800 ) context = browser.new_context( viewport={"width": 1280, "height": 800} ) page = context.new_page()

headless=False是调试期的首选。有头模式能让你亲眼看到页面加载、滚动、验证码弹窗的完整过程,比盲写代码高效得多。slow_mo=800表示每个操作之间延迟 800 毫秒,既方便观察,也能让页面加载节奏接近真人。

browser.new_context()里可以传非常多的参数。viewport 控制窗口尺寸,某些页面在小屏和特定手机 UA 下会返回不同的数据;locale、timezone_id会影响服务端对请求环境的判断;后面抓小红书时,context 里还要挂 storage_state 保存登录态。这些参数看起来不起眼,但经常决定你第一个请求能不能正常返回数据。

2.3 page.on("response") 到底在监听什么

Playwright 对网络事件的支持很完整。page.on("request", handler)在每个请求发出前触发,page.on("response", handler)在每个响应回到浏览器时触发。我们的核心玩法就是第二个:

def handle_response(response): print(response.url, response.status) page.on("response", handle_response)

回调里的response对象带着完整响应信息,包括 URL、状态码、响应头、响应体。需要说明的是,response.json()只能读一次,读取后 body 就消费掉了,所以不要在回调里反复调用response.text()又response.json(),拿到数据立刻缓存到外部列表里才是正确姿势。

3. 核心实战:拦截 feed 接口,让滚动变成数据生产线

3.1 打开 DevTools 找到笔记数据的真实接口

写代码之前,先花五分钟做手工侦查。打开浏览器,按 F12 进入 DevTools,切到 Network 面板,然后手动在新标签里打开小红书搜索页,往下滚动一两次。你会看到滚动瞬间冒出好几个 XHR 请求,把它们的响应内容挨个点开看一眼,找到那个返回 JSON 里带data.items、而且每一条 item 都对得上页面上笔记卡片的接口。

找接口就三看:

  • 看 URL:路径里通常带feed、search这类功能单词,关键字越短越好匹配。
  • 看返回:Preview 面板里直接是 JSON 对象,data.items展开就是笔记数组。
  • 看时机:每滚动一次就新增一个请求,时机和页面加载对得上。

小红书接口改版比较勤,我写死在代码里的 URL 关键字不一定永远有效,但这套“打开 DevTools 找接口”的流程是永远有效的。

3.2 第一版脚本:监听 + 滚动 + 收集 JSON

下面是一份可以直接跑的完整脚本。这个版本的目标是去小红书搜索页抓“美食”笔记,一边滚动一边截获 feed 接口返回的 JSON,并在内存里完成去重:

import random from playwright.sync_api import sync_playwright KEYWORD = "美食" TARGET_COUNT = 100 API_KEYWORD = "feed" # 以你 DevTools 里看到的实际接口关键字为准 all_items = [] seen_ids = set() def extract_note(item): """兼容不同字段版本""" if "note_card" in item: return item["note_card"] if "note" in item: return item["note"] return None def handle_response(response): url = response.url if API_KEYWORD not in url: return content_type = response.headers.get("content-type", "") if "json" not in content_type: return try: body = response.json() except Exception: return data = body.get("data") if not data: return items = data.get("items") or [] for item in items: note = extract_note(item) if not note: continue note_id = note.get("note_id") or item.get("id") if not note_id or note_id in seen_ids: continue seen_ids.add(note_id) all_items.append(note) with sync_playwright() as p: browser = p.chromium.launch(headless=False, slow_mo=800) context = browser.new_context(viewport={"width": 1280, "height": 800}) page = context.new_page() page.on("response", handle_response) page.goto( f"https://www.xiaohongshu.com/search_result?keyword={KEYWORD}", wait_until="domcontentloaded" ) print("页面加载中,等待首屏数据...") page.wait_for_timeout(3000) no_new_count = 0 scroll_round = 0 while len(all_items) < TARGET_COUNT: scroll_round += 1 before = len(all_items) page.mouse.wheel(0, random.randint(1500, 3000)) page.wait_for_timeout(random.randint(1200, 2200)) print(f"第{scroll_round}轮滚动,累计笔记数:{len(all_items)}") if len(all_items) == before: no_new_count += 1 else: no_new_count = 0 if no_new_count >= 6: print("连续多轮无新数据,提前结束") break browser.close() print(f"共去重后保留笔记 {len(all_items)} 条")

这里有两个细节值得留意。第一是content_type判断,feed 接口返回的是 JSON,但滚动过程中浏览器还会加载图片、字体、脚本资源,这些资源用response.json()直接解析会抛异常,先看响应头能省不少 try/except。第二是滚动距离我用了随机数,1500 到 3000 像素之间浮动,等待时间也在 1.2 到 2.2 秒之间浮动,这样页面加载节奏更接近真人,能明显降低触发风控的概率。

3.3 去重与停止条件:别等翻到天荒地老

无限滚动数据有两个讨厌的规律:第一,同一批笔记会在多次滚动里反复出现;第二,页面加载是有上限的,滚到后面接口可能返回空数组甚至不再发起请求。

去重我用seen_ids集合解决。每个笔记都有一个唯一 ID,可能叫note_id,也可能直接叫id,两个都取一下,只要出现过就跳过。实际跑下来你会发现,越到后面重复率越高,很可能一次滚动返回的 20 条里有 15 条已经见过。如果没有这步去重,入库数据会膨胀得毫无意义。

停止条件要看“有没有新数据”而不是“滚了多少次”。代码里before = len(all_items)记录本轮滚动前的数量,滚动后如果数量没变,说明这次加载没有带来新笔记,计数器加一;一旦连续 6 轮都没新数据,就认为已经到底或者被限流了,直接退出。这个条件比固定滚动 50 次科学,因为它能自动适应接口返回量的变化。

4. 实战里绕不开的坎:登录态、签名参数与频控验证码

4.1 有登录没登录,抓到的完全是两种数据

小红书对未登录访客的展示策略我实测过:能打开页面,也能滚动,但搜索接口返回的数据量明显变少,而且用户信息、点赞数这些字段会缺失。更麻烦的是,滚几轮就会弹出登录框,把滚动操作卡死。

解决方法是把登录态持久化。第一次启动时用单独的脚本手动过一次登录流程:

with sync_playwright() as p: browser = p.chromium.launch(headless=False) context = browser.new_context(viewport={"width": 1280, "height": 800}) page = context.new_page() page.goto("https://www.xiaohongshu.com/") input("请在浏览器中完成登录,然后按回车...") context.storage_state(path="state.json") browser.close()

拿到state.json之后,正式抓取脚本里把 context 的创建改成:

context = browser.new_context( storage_state="state.json", viewport={"width": 1280, "height": 800} )

这样每次启动都自动带登录态,既不用反复扫码,也能拿到完整字段的数据。需要注意,登录态有有效期,过一阵子失效了重新跑一次上面的登录脚本就行。

4.2 签名参数不用逆向,让浏览器自己搞定

小红书这类平台对接口请求有加签机制,会在请求头里塞x-s、x-t、x-s-common之类由前端 JS 动态计算的签名参数。直接抓接口然后用 requests 复现的话,你大概率卡在这一步:签名算法是压缩混淆过的 JS,断点调试、补环境、维护算法,随便一步都够折腾一个月。

Playwright 监听方案的精妙之处就在这里——这些签名是浏览器自己生成的。滚动发生后,页面内部调接口时已经完成了加签,我们只是被动地接收响应。你不需要懂任何逆向知识,不需要复制任何加密函数,浏览器替你扛了反爬最难的那一部分。

4.3 验证码和频控:硬碰硬只会把账号搞脏

节奏快了、无痕环境切换频繁了,滑块验证码就会找上门。页面上出现滑块元素,后续滚动会自动停摆。我的经验是:不要试图用自动化去破解滑块,性价比极低,而且会导致账号进入更严厉的风控名单。

更务实的做法是让脚本的节奏再慢一点。随机延迟从 1.5 秒拉长到 3 秒,每次滚动距离不要超过一屏,单次任务抓 100 条就收手,换关键词休息一会儿再继续。如果这样还是频繁出验证码,那就停下来等半小时,或者干脆分时段跑。

我还试过把slow_mo从 800 调到 1200,肉眼看起来滚动过程更像真人手滑。这类调整虽然没有精确公式,但“比真人慢一点、比机器人乱一点”的方向总是没错的。

5. 从响应 JSON 到干净数据:字段解析与入库

5.1 摸清响应体结构:字段版本可能说变就变

抓下来的 JSON 保存在了all_items列表里,每个元素是一篇笔记的完整卡片。以我抓到的版本为例,结构大致长这样:

{ "data": { "items": [ { "id": "64abxxxxxx", "note_card": { "note_id": "64abxxxxxx", "display_title": "这家店我吃了二十年,味道没变过", "user": {"nickname": "干饭人小王"}, "interact_info": { "liked_count": "1.2万", "collected_count": "856", "comment_count": "43" }, "type": "normal" } } ] } }

必须说明的是,这个结构是示例性质,小红书的字段随时可能调整。写解析逻辑时,关键字段要全部用get加默认值兜底,不要用item["note_card"]["display_title"]这种硬索引,否则页面字段一变化,脚本立刻崩给你看。

5.2 提取字段:笔记 ID、标题、作者与互动数据

我写了一个清洗函数,专门处理小红书互动数据里的中文计量单位。liked_count返回的可能是"1.2万",也可能是"856",直接塞进数据库排序会很痛苦:

def parse_count(raw): if not raw: return 0 raw = str(raw).strip() if raw.endswith("万"): return int(float(raw[:-1]) * 10000) if raw.endswith("+"): raw = raw[:-1] return int(raw) if raw.isdigit() else 0

配合这个函数,从note对象里把核心字段一次性拿全:

note_id = note.get("note_id") title = note.get("display_title", "") author = (note.get("user") or {}).get("nickname", "") info = note.get("interact_info") or {} likes = parse_count(info.get("liked_count")) collects = parse_count(info.get("collected_count")) comments = parse_count(info.get("comment_count")) note_type = note.get("type", "normal")

user和interact_info有可能缺失,所以都用了or {}保底,然后get拿内部字段。这类防御性写法在爬虫代码里非常重要,因为你永远不知道哪条数据会里少一个字段。

5.3 落盘 CSV 与轻量去重统计

保存我用 CSV,Python 内置 csv 模块就够了,完全没必要为了写个文件去装 pandas:

import csv def write_csv(path="xiaohongshu_notes.csv"): with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["note_id", "标题", "作者", "点赞", "收藏", "评论", "类型"]) for note in all_items: info = note.get("interact_info") or {} writer.writerow([ note.get("note_id", ""), note.get("display_title", ""), (note.get("user") or {}).get("nickname", ""), parse_count(info.get("liked_count")), parse_count(info.get("collected_count")), parse_count(info.get("comment_count")), note.get("type", "normal"), ]) print(f"已写入 {path},共 {len(all_items)} 条笔记")

编码用了utf-8-sig而不是utf-8,这样用 Excel 直接打开 CSV 时中文不会乱码。文件打开后你会看到每一列对应一篇笔记,后续做数据分析、词频统计、博主排行都方便。

6. 从这次实践中学到的通用套路与边界思考

6.1 三步法:任何 SPA 页面都可以这样拆

这次抓小红书给我最大的收获,不是某个接口的 URL,而是一套万能流程。以后再遇到任何动态渲染页面,我先不急着写代码,而是按照三步走:

  1. 打开 DevTools,手动触发页面加载,找到返回业务数据的那个 JSON 接口。
  2. 写出只看 URL 关键字的 response 监听回调,把响应体里的核心数组提取出来。
  3. 用滚动、点击、翻页等方式触发更多请求,直到收集量达标。

这套方法不只适用于小红书。视频平台的评论列表、电商的商品搜索、资讯类的信息流,只要底层是 XHR 加载 JSON,都可以用同样的监听思路解决。区别只在于 URL 关键字和数据字段名的映射。

6.2 合法使用与频率自律

技术本身没有立场,但使用方式决定了边界。写这类爬虫脚本,我始终给自己立几条规矩:只抓公开可见的信息,不碰需要突破权限才能访问的数据;抓取强度控制在远低于正常用户浏览的水平,绝不对目标服务器造成压力;抓下来的数据只用于个人学习验证,不作为商用变现的产品。

这不是形式主义,而是保护自己的最好方式。爬虫技术首先要学会的是“克制”。一个高并发的脚本跑起来会消耗目标站点的服务资源,一旦被视为攻击行为,无论技术上多完美,性质都会变。

6.3 一点个人体会

前两天我又遇到一个无限滚动页面,下意识就想打开 DevTools 找接口,那一刻我才意识到,监听网络流已经成了我爬虫的第一反应。跟解析 DOM 相比,它省掉了无数定位选择器的痛苦;跟伪造请求相比,它又免掉了逆向签名的绝望。让浏览器去干重活,我们只负责在数据管道上接水,这个思路的性价比实在太高。

如果你正在被动态页面的爬取卡住,建议先别急着补 JS 逆向知识,打开 DevTools 看一眼 Network 面板。也许你离干净的结构化数据,就差一个page.on("response")的距离。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询