
以前刚学爬虫那会儿一碰到无限滚动页面就头皮发麻。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( headlessFalse, slow_mo800 ) context browser.new_context( viewport{width: 1280, height: 800} ) page context.new_page()headlessFalse是调试期的首选。有头模式能让你亲眼看到页面加载、滚动、验证码弹窗的完整过程比盲写代码高效得多。slow_mo800表示每个操作之间延迟 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(headlessFalse, slow_mo800) context browser.new_context(viewport{width: 1280, height: 800}) page context.new_page() page.on(response, handle_response) page.goto( fhttps://www.xiaohongshu.com/search_result?keyword{KEYWORD}, wait_untildomcontentloaded ) 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(headlessFalse) context browser.new_context(viewport{width: 1280, height: 800}) page context.new_page() page.goto(https://www.xiaohongshu.com/) input(请在浏览器中完成登录然后按回车...) context.storage_state(pathstate.json) browser.close()拿到state.json之后正式抓取脚本里把 context 的创建改成context browser.new_context( storage_statestate.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 与轻量去重统计保存我用 CSVPython 内置 csv 模块就够了完全没必要为了写个文件去装 pandasimport csv def write_csv(pathxiaohongshu_notes.csv): with open(path, w, newline, encodingutf-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而是一套万能流程。以后再遇到任何动态渲染页面我先不急着写代码而是按照三步走打开 DevTools手动触发页面加载找到返回业务数据的那个 JSON 接口。写出只看 URL 关键字的 response 监听回调把响应体里的核心数组提取出来。用滚动、点击、翻页等方式触发更多请求直到收集量达标。这套方法不只适用于小红书。视频平台的评论列表、电商的商品搜索、资讯类的信息流只要底层是 XHR 加载 JSON都可以用同样的监听思路解决。区别只在于 URL 关键字和数据字段名的映射。6.2 合法使用与频率自律技术本身没有立场但使用方式决定了边界。写这类爬虫脚本我始终给自己立几条规矩只抓公开可见的信息不碰需要突破权限才能访问的数据抓取强度控制在远低于正常用户浏览的水平绝不对目标服务器造成压力抓下来的数据只用于个人学习验证不作为商用变现的产品。这不是形式主义而是保护自己的最好方式。爬虫技术首先要学会的是“克制”。一个高并发的脚本跑起来会消耗目标站点的服务资源一旦被视为攻击行为无论技术上多完美性质都会变。6.3 一点个人体会前两天我又遇到一个无限滚动页面下意识就想打开 DevTools 找接口那一刻我才意识到监听网络流已经成了我爬虫的第一反应。跟解析 DOM 相比它省掉了无数定位选择器的痛苦跟伪造请求相比它又免掉了逆向签名的绝望。让浏览器去干重活我们只负责在数据管道上接水这个思路的性价比实在太高。如果你正在被动态页面的爬取卡住建议先别急着补 JS 逆向知识打开 DevTools 看一眼 Network 面板。也许你离干净的结构化数据就差一个page.on(response)的距离。