
先说一个结论用 Python 写爬虫遇到美团团购热门榜单这种页面光靠requests直接抓 HTML 是会碰一鼻子灰的。因为这类页面的核心数据根本不在服务端返回的 HTML 里而是由 JS 异步加载、经过编码和压缩后动态渲染出来的。这时候Playwright 网络监听就成了一条非常稳的路径。所谓“窃听”说白了也不是什么黑科技就是让 Playwright 启动一个真正的浏览器环境把页面打开然后站在浏览器旁边把浏览器向接口发起的请求和拿到的响应内容都“录”下来再从中筛选出自己需要的榜单数据。本篇文章会从项目思路、环境准备、核心代码、问题排查到最后的数据落盘把整套流程按我可以直接复现的标准整理出来。适合已经有 Python 基础、想入门动态页面爬虫、或者刚好需要抓取同类型“接口异步渲染”项目的朋友。整个过程中我会把我实际踩过的坑和最后的解决办法都写出来这篇不是教科书是操作记录。1. 项目思路拆解为什么选 Playwright而不是“直接读接口”1.1 美团热门榜单页面的真实加载方式美团这类大型平台页面早期版本可能还是服务端模板渲染但现在已经基本全面转成了前后端分离的动态页面。你在浏览器地址栏输入网址回车服务端返回的 HTML 只有一堆空壳 div 和框架脚本真正的榜单数据是页面加载完成后由 JS 脚本自动向后端接口发起 XHR 或 Fetch 请求拿到 JSON 数据后再渲染到对应的 DOM 节点上。这意味着什么意味着如果你用requests.get去拿页面拿到的内容里是找不到店名、销量、评分这些关键字段的。至少在我实际测试的时候直接请求首页拿到的 HTML 里几乎只有固定框架和初始配置项。真正有价值的数据都在后续异步请求的 Response 里。这时候你可能想到那我不直接调接口不就行了比如打开浏览器 F12 开发者工具切到 Network 面板能很清楚地看到页面发出的请求列表找到那个返回 JSON 的接口地址然后用requests去请求它。听起来很顺利但实际操作会遇到几个硬骨头。接口地址本身带了一堆签名参数比如token、sig、timestamp之类的这些参数是前端 JS 根据一定规则动态生成的你不好直接伪造。不少接口对请求头有严格校验User-Agent、Referer、Cookie 缺一不可有的还会校验浏览器指纹特征。如果请求频率稍微快一点很容易触发风控策略返回一串加密结果或者直接给你一个验证页面。与其去逆向 JS 签名算法不如换个思路让浏览器自己去完成这些操作。Playwright 控制的是一个真实的 Chromium 浏览器它加载页面时会自动带上完整的浏览器指纹、自动执行 JS、自动携带 Cookie而且它会像正常用户一样发出请求。我们要做的只是监听这个过程把浏览器发出的请求和收到的数据记录下来这就是“窃听”的本质。1.2 “监听接口”和“页面解析”的本质区别很多初学者提到爬虫第一反应是用 BeautifulSoup 或者 XPath 去解析 HTML 里的节点。这个思路本身没错但用在动态页面上会很吃力。麻烦主要体现在几个地方页面节点结构经常变化今天写的.shop-name选择器明天可能就改成了div.item-title维护成本高。点击“加载更多”或者滚动页面之后新的数据会继续通过接口加载但你需要额外分析一次 DOM 结构的变化。动态渲染的图片、评分、地址等字段往往被处理成各种嵌套节点解析代码写起来又长又容易错。而监听接口就不一样。接口返回的数据是结构化的 JSON里面是成片的title、score、month_sales这类字段。你拿到 JSON 后直接按 key 取值逻辑清晰代码也不容易跟着页面改版而崩。等于是绕开了前台的文字渲染直接拿到了“源数据”。生活里有个很形象的类比。你去餐厅点菜菜名写在黑板菜单上黑板经常换有时候字还糊了。但如果你直接去后厨窗口看服务员取餐单单子上写得清清楚楚菜品编号、份数、口味备注。“接口监听”就是站到后厨窗口去而不是每天研究黑板菜单变了多少。1.3 这个方案适合什么场景Playwright 监听接口的方案并不是要替代所有爬虫手段而是最适合下面这几类场景。目标页面是典型的 SPA 应用数据全部由 JS 异步加载。目标数据接口有较复杂的签名或加密参数逆向成本高。需要处理登录态、验证码或复杂交互点击、滚动、切换城市再采集。面对的是“榜单、列表、聚合页”这种由后台接口驱动的内容。像本地生活、电商平台、新媒体平台的热门榜单基本都是这类加载方式。用 Playwright 监听等于是把“破译接口加密参数”这件事完全交给了浏览器自己把重心集中在数据处理上。当然代价就是性能比纯requests低毕竟是开了真浏览器所以它更适合中小规模、注重稳定性的爬取任务。2. 环境准备从 Python 到 Chromium 内核2.1 搭建 Python 环境与安装 Playwright开始写代码之前先把环境准备好。我自己用的是 Python 3.10Playwright 支持 Python 3.8 以上的版本所以这一步基本不用担心兼容性问题。建议先给项目建一个独立的虚拟环境避免依赖互相污染。命令行操作是这样的mkdir meituan_spider cd meituan_spider python -m venv venv在 Windows 上激活环境命令是venv\Scripts\activatemacOS 或 Linux 上则是source venv/bin/activate激活之后用 pip 安装 Playwrightpip install playwright这里要特别提醒一句只装 playwright 这个 Python 包是不够的它只是一个控制层真正干活的是浏览器内核。接下来必须执行playwright install chromium这一步会下载 Chromium 浏览器到本机。下载过程依赖网络如果下载慢或者中断一般重新跑一遍命令就行。国内环境下如果下载速度不理想可以设置镜像环境变量再执行但这个属于环境优化不是必需项我不展开讲。2.2 安装浏览器内核时最常见的两个报错我当初在配置环境时就遇到过一个非常典型的坑。playwright install chromium命令执行完看着终端提示安装成功但一运行代码就报错说找不到可执行文件。排查了一圈发现是因为安装在服务器上的时候我用的是普通用户权限而 Playwright 默认把浏览器内核安装到了当前用户目录下后续代码却是用另一个用户跑的环境变量自然找不到。另一个更常见的报错发生在 Linux 服务器上运行时会提示Missing dependencies后面跟着一串系统库的名字。这是因为 Chromium 在渲染时需要一些系统级的动态库这些库没有装全浏览器进程起不来。解决办法也很直接执行playwright install-deps chromium它会自动检测并安装缺失的依赖库。如果在某些精简版系统上执行这条命令失败那就只能根据报错信息用apt-get或者yum手动补装对应的库。2.3 代码目录组织习惯虽然这是一个小项目我依然建议把代码拆成几个文件不要全部塞在一个脚本里。长期维护的感受是一个清晰的项目结构比什么都重要。meituan_spider/ ├── venv/ ├── config.json ├── main.py ├── listener.py ├── parser.py └── output/main.py启动入口负责初始化浏览器和调度流程。listener.py定义监听器类专门处理page.on(response)事件。parser.py从监听到的接口数据中提取榜单字段。config.json保存城市、目标 URL、等待时间等配置项。output/存放最终的 CSV 或 Excel 文件。这个拆分方式的好处是后续想换目标页面、改数据解析规则或者把脚本扩展成分布式任务都不需要动其他部分。作为一个给自己用的爬虫工具这种轻量级分层已经够用。3. 核心实现用 Playwright 监听网络响应并提取榜单数据3.1 先抓接口定位真正的数据源写监听代码之前我建议先手动操作一遍浏览器把要抓的数据接口认清楚。打开 Chrome按 F12 进入开发者工具切到 Network 面板勾选 Fetch/XHR 筛选然后刷新美团热门榜单页面。这时候你会看到一排请求。我当时的做法是逐个看 preview 和 response找到返回 JSON 里包含店铺名、评分、销量这些字段的接口。美团这类接口通常会有一批类似/group/v1/poi/、/top/路径的请求接口返回的 JSON 里会有poiList、sales、avgScore之类的关键词。找到接口后你会发现 URL 里带着cityId、offset、limit这种参数。cityId对应城市offset是分页偏移量limit是每页数量。记住这些参数后面解析请求 URL 时会用到。为什么要先手动抓一遍接口因为监听代码里面的筛选条件必须建立在你已经知道“哪个接口才有我想要的数据”这个前提下。否则监听器把所有响应都记录到内存里数据量大不说还会混入很多无用的埋点上报请求。3.2 监听 response 的完整 Playwright 代码下面是我在这个项目里使用的核心代码。这个版本并不复杂但已经能完成“打开页面 → 监听接口 → 拿到 JSON”这条主链路。import json import re from playwright.sync_api import sync_playwright def main(): captured_data [] def handle_response(response): url response.url # 过滤出我们关心的接口路径 if re.search(r/group/v\d/poi|top|poiList, url): try: res_json response.json() if isinstance(res_json, dict): captured_data.append({ url: url, data: res_json }) except Exception: pass with sync_playwright() as p: browser p.chromium.launch( headlessFalse, slow_mo200 ) context browser.new_context( viewport{width: 1280, height: 800}, localezh-CN ) page context.new_page() page.on(response, handle_response) page.goto(https://www.meituan.com/s/, timeout60000) page.wait_for_timeout(5000) # 模拟几次下拉触发分页加载 for _ in range(3): page.mouse.wheel(delta_x0, delta_y800) page.wait_for_timeout(2000) browser.close() # 将抓到的请求保存到本地文件 with open(captured.json, w, encodingutf-8) as f: json.dump(captured_data, f, ensure_asciiFalse, indent2) print(f共捕获 {len(captured_data)} 条接口数据) if __name__ __main__: main()这段代码有几个地方我想提一下。设置headlessFalse是为了开发调试时能直观看到浏览器在做什么。等到确定逻辑没问题了再改成headlessTrue放到服务器上跑。slow_mo200是让每一步操作稍微放慢一点减少被风控盯上的概率也让电脑性能差的时候不至于把页面状态搞乱。在筛选接口时我用的正则写得很宽泛。这不是懒而是因为美团接口路径在不同城市、不同页面上可能不一样写太死容易漏。反正后面解析数据的时候还会根据 JSON 结构再过滤一层。还有一个细节是页面加载后的等待时间。我用的是wait_for_timeout简单直接但不够严谨。更稳妥的做法是监听特定的接口 URL等它响应后立刻继续而不是傻等固定时间。这个我会在后面的“让脚本更稳”部分专门讲。3.3 对响应内容做筛选、解析与清洗监听器把接口响应全部记录到captured_data之后下一步就是从里面筛出榜单数据。这个过程看起来只是“取字段”实际上有个关键步骤是梳理 JSON 层级。以美团热门榜单为例接口返回的大结构一般是{ code: 0, data: { poiList: [ { title: 某某火锅店, avgScore: 4.8, monthSales: 1520, areaName: 某某区, backCateName: 火锅, price: 88 } ] } }当然真实的嵌套结构会比这个复杂字段名也可能不完全一样但大概就是这个方向。解析时要注意有些字段存在缺省情况比如新店可能没有评分或者销量字段是 0需要做好空值处理。下面是我写的解析函数它从监听数据中提取出一个标准的榜单列表。def parse_captured_data(captured_data): results [] for item in captured_data: data item.get(data, {}) poi_list data.get(data, {}).get(poiList) or [] for poi in poi_list: results.append({ title: poi.get(title, ).strip(), score: poi.get(avgScore, 0), month_sales: poi.get(monthSales, 0), area: poi.get(areaName, ), category: poi.get(backCateName, ), avg_price: poi.get(price, 0) }) return results这里avgScore和price我直接取了原始值。实际使用中你可以根据需求保留整数或者保留一位小数也可以用round处理。榜单如果要排序可以在清洗之后按month_sales降序排一下这样导出的数据直接就是热门度从高到低。3.4 数据落盘输出 CSV / Excel数据解析完成落到本地是一个很自然的动作。CSV 是最通用的格式方便后续用 Excel 打开也可以直接导入数据库。我用pandas来做这一步代码很短import pandas as pd df pd.DataFrame(parse_captured_data(captured_data)) df df.sort_values(bymonth_sales, ascendingFalse) df.to_csv(output/meituan_top_list.csv, indexFalse, encodingutf-8-sig)这里要提醒一个编码细节用encodingutf-8-sig而不是utf-8。Windows 上的 Excel 默认用 GBK 编码打开 CSV如果你用 UTF-8 直接保存Excel 打开时很容易出现中文乱码。utf-8-sig会在文件头部加入 BOM 标记Excel 就能正常识别了。如果你的需求是存成 Excel需要多装一个库pip install openpyxl然后写入df.to_excel(output/meituan_top_list.xlsx, indexFalse)4. 实操过程中的问题与排查记录写代码的时候感觉一切理所当然真正跑起来才发现一大半时间都花在排查问题上。这一章我整理了在这个项目里遇到的最典型的四类问题每一个都是真实踩过的坑。4.1 页面拿到的是空骨架接口没有按预期触发第一次运行脚本页面确实打开了但过一会儿发现captured_data是空的浏览器页面一直停在加载状态。排查下来定位到两个原因。第一是网络环境的问题。目标页面的部分静态资源和接口来自不同的域名在访问速度较慢的网络里接口请求超时了页面自然是空的。解决办法很简单把page.goto后面的等待时间拉长或者显式等待特定接口出现。第二是页面需要先执行某些初始化动作。比如美团页面在加载后会弹出城市选择浮层如果这个浮层没有被关闭后面的列表接口可能不会触发。这时候需要在goto之后增加一个定位器点击“关闭”按钮的动作。用 Playwright 处理这类情况非常顺手page.locator(.city-select-close).click(timeout3000)当然如果浮层没出现这一步会超时。更好的写法是用try包起来超时不影响主流程。4.2 接口返回内容“看起来”对但解析不出任何数据有一次监听器正常捕获了接口URL 也匹配上了但解析出来永远是空列表。我把捕获的 JSON 打到日志里发现接口返回的data字段不是poiList而是一个加密过的字符串或者是一个包含大量加密字段的 JSON 结构。这种情况通常是风控逻辑在起作用。它并不总是直接拒绝请求而是选择返回假数据、加密数据让爬虫拿到一层无意义的壳。针对这个情况我做了两件事。一是放慢访问节奏把人工模拟的行为做得更自然。二是增加随机等待时间避免固定间隔的机械操作。还有一个办法是检查 Cookie确保浏览器在访问页面之前已经带上了必要的会话 Cookie而不是一个完全干净的浏览器环境。这里我想强调一点遇到反爬策略时优先考虑放慢速度、完善请求头、保持正常的操作行为这些是合理的技术调试手段。不要一上来就想着用各种偏门工具绕过验证既不安全也不稳定而且可能给自己惹麻烦。4.3 无头模式在服务器上跑不起来提示缺系统依赖把开发好的脚本放到 Linux 服务器上跑是我在这个项目里遇到的最耗时的一个问题。明明本地一切正常服务器上一执行就报错提示缺少各种.so动态库。原因我在环境准备那一节已经提过解决命令就是playwright install-deps chromium但如果你的服务器操作系统版本比较老或者安装源没有更新这个命令也可能会失败。我当时的做法是直接看报错信息一个一个库手动装。通常需要的就是libnss3、libatk、libatk-bridge、libcups、libdrm、libxkbcommon、libxcomposite、libxdamage、libxrandr这些。装完之后Chromium 就能在无头模式下正常启动了。另外如果你是用 root 用户跑脚本建议在launch的时候加上参数browser p.chromium.launch( headlessTrue, args[--no-sandbox] )不加这个参数root 用户经常会遇到沙箱权限问题。4.4 动态页面元素点击失效分页加载不出来还有一次页面本身加载正常但模拟鼠标滚轮滚动之后下面的榜单列表始终没有变长监听器也没有收到新的接口返回。排查之后发现美团的热门榜单页并不是简单地靠滚动触底加载而是需要点击“展开更多”按钮才触发分页。解决办法是调整操作逻辑在滚动之后主动查找“展开”按钮找到就点击然后继续等待数据。Playwright 提供了wait_for_selector和click的组合使用起来很灵活page.wait_for_selector(text展开更多, timeout5000) page.click(text展开更多) page.wait_for_timeout(3000)如果页面里真的没有这个按钮wait_for_selector会超时那就跳过这个分页。为了避免超时中断主流程同样建议用try包住或者先判断元素是否存在再操作。5. 把脚本做得更稳的细节习惯5.1 把等待逻辑写成“等接口”而不是“等时间”前面提到wait_for_timeout这种固定等待方式虽然简单但不够健壮。网络快的时候浪费时间慢的时候数据还没返回就继续执行了。我在后续优化里把核心等待逻辑改成了“等接口”。具体写法是在触发某个操作点击、滚动之前先用page.expect_response把期望监听的接口 URL 包起来。这个方法的强大之处在于它会阻塞代码直到捕获到符合条件的响应或者到达超时时间。with page.expect_response( lambda resp: poiList in resp.url, timeout10000 ) as response_info: page.click(text展开更多) response response_info.value这个写法比固定sleep严谨太多。它是事件驱动的接口一返回就立即继续性能更好稳定性也更高。5.2 控制抓取频率给目标站点留出缓冲“窃听”本身只是监听正常流量但如果不控制总体抓取频率把一个页面上百个分页接口在几秒内全部触发对目标和自己的服务器都是压力。我自己的经验是每翻几页就随机暂停几秒间隔做得不规则一点更接近真实用户的行为。这里分享一个简单的随机等待写法import random import time time.sleep(random.uniform(2, 5))规则就是不要等待固定的秒数给每次间歇加一点随机数。这是很多爬虫项目里非常基础但又非常影响稳定性的细节。5.3 把城市和分页参数做成配置项美团的热门榜单往往跟城市强相关。如果你想把脚本复用到多个城市不要在代码里把城市参数写死。我的做法是维护一个简单的配置文件城市、目标 URL、等待时间全部抽出来方便随时修改。config.json的内容大致长这样{ city_id: 1, target_url: https://www.meituan.com/s/, max_pages: 5, wait_min: 2, wait_max: 5 }然后在代码里读取配置动态拼接城市参数、控制分页数量。这样即使后续要扩展到十个城市也只需要改配置文件完全不需要动监听和解析逻辑。5.4 注意数据边界不要碰不该碰的东西最后这条是我最想强调的。做爬虫尤其是做这种带有“窃听”色彩的爬虫一定要守住数据边界。像美团热门榜单这种公开内容展示给所有用户看的排名数据采集来做学习分析这是没有问题的。但如果在接口里看到了跟用户隐私强相关的字段比如手机号、住址、订单详情、聊天记录一律不要记录、不要解析、不要落盘直接跳过。技术能力是一回事技术底线是另一回事。一个爬虫项目能不能长期做下去技术含量只是其中一部分更重要的是对数据边界的敬畏。能在拿到数据之后删掉不该拿的部分才是一个成熟的爬虫工程师应该有的操作。写在最后的经验这个项目做完我个人最深的体会是Playwright 监听接口这个方案真正解决的问题不是“怎么爬”而是“怎么稳定地爬”。它把浏览器渲染、JS 执行、签名生成这些复杂的环节全部透明化让你把精力集中到数据处理上。我后来把同样的方案用到其他几个平台的榜单采集上基本只需要改 URL 和解析字段整体框架完全不用换。最后再分享一个优化小技巧如果你每次运行脚本都重新走一遍页面加载流程会比较浪费时间。你完全可以把登录态和数据请求做成分离式第一次运行时把 Cookie 保存下来后续直接复用把时间从几十秒压缩到几秒。Playwright 提供了context.storage_state(pathstate.json)来保存状态加载时只需要browser.new_context(storage_statestate.json)就能恢复。因为这种方式对目标站点的压力更小对环境也更友好算是我目前最推荐的一种“温和型”爬虫姿态。