尧图精选

Python+Playwright抓取动态页面:从XHR截获到数据入库完整实战

🕒 发布时间:2026/10/2 4:27:58 📁 来源:尧图网络
做爬虫这些年被问得最多的问题不是“哪个库好用”而是“遇到纯前端渲染的页面到底怎么抓”。很多人用 requests 拿不到数据、用 Selenium 又觉得太重太慢折腾一圈最后发现真正顺手的方案其实是把 Playwright 当“一个会操作浏览器的人”来用而不是当一个“发请求的库”来用。这篇文章就拿 Python Playwright 抓取某旅行平台热门城市榜做例子把从页面分析、环境搭建、代码实现到稳定性优化的完整过程拆开讲透。先说明一点本项目定位纯技术交流用来学习动态页面自动化采集的原理不针对任何真实平台的业务数据做商业化使用也强烈不建议拿这套代码去频繁请求在线服务。1. 项目整体设计与技术选型思路1.1 为什么最终选择 Playwright 而不是 requests 或 Scrapy先交代背景。酒店热门城市榜这类数据最让人头疼的地方是它不像老式网站那样在 HTML 里直接给出完整内容而是由 JavaScript 在页面加载完成后动态渲染出来的。你用 requests 去请求那个 URL拿回来的 HTML 里可能只有一堆空壳 div 和几个初始化脚本真正有价值的数据要么藏在二次异步请求里要么经过前端框架的加工后才显示在页面上。这种情况有两条路。第一条是顺着网络请求去挖数据接口理论上效率最高只要在浏览器开发者工具里找到那个返回 JSON 的 XHR 请求就可以用 requests 模拟。但这种接口通常带着签名参数或者跟加密逻辑绑定一旦对方做了请求校验你光是逆向前端加密逻辑就能耗掉大半天而且这种逆向手段在某些场景下边界比较模糊。第二条就是直接用浏览器自动化工具让代码驱动一个真实的浏览器内核去访问页面它加载 HTML、执行 JS、发请求、渲染结果你只需要等页面“稳定下来”之后去提取内容。老实说在第二套方案里Selenium 是老前辈但这些年用下来它的生态和 API 设计确实有点跟不上趟。Selenium 需要额外维护 WebDriver版本跟浏览器版本没对齐就报 session not created而且执行速度偏慢等待策略也不够智能。Playwright 是微软出品的自动化框架它默认就带了浏览器内核的管理能力不需要单独装 DriverAPI 设计也明显更现代比如page.goto()里直接支持wait_untilnetworkidle这种就是为爬虫场景准备的。另外 Playwright 支持同步和异步两种模式还能在同一个浏览器实例里隔离多个上下文这种设计在做多任务采集时非常顺手。1.2 项目边界技术交流与合规采集的分寸写完标题我就给自己定了个规矩这个项目只用来演示技术原理不针对任何真实站点做持续采集。做爬虫最怕的是“越用越顺手顺手就过界”。实际开发中我一直守着几条底线第一是查看目标网站的 robots.txt 和服务条款明确不允许采集的内容不碰第二是控制请求频率绝不并发轰炸设置合理的延时把压力控制在单个用户正常浏览的水平第三是只采集公开可见的信息绝不碰需要登录才能看到的数据更不碰任何个人隐私字段第四是采集到的数据只用于本地学习测试不对外发布、不包装成付费服务。这个边界其实是这类项目能走多远的关键。你学自动化采集真正值钱的是“理解动态页面的渲染机制、学会定位和分析元素、搭建稳定的采集链路”这套迁移能力而不是某一个网站的某一个接口。带着这个出发点后面所有技术细节都会变得特别纯粹——你在练的是 Playwright 本身。2. 环境准备与 Playwright 核心机制解析2.1 Python 环境与 Playwright 安装的完整步骤我用的开发环境是 Windows 11 Python 3.11其实 Python 3.8 以上都能跑但建议用 3.10 以上新版 Playwright 对类型注解的依赖更充分配合起来更省心。安装分三步# 第一步创建并激活虚拟环境避免依赖混乱 python -m venv venv venv\Scripts\activate # 第二步安装 Playwright Python 包 pip install playwright # 第三步安装 Playwright 对应的浏览器内核 playwright install chromium第三步是最容易卡壳的地方。playwright install chromium会从微软的 CDN 下载浏览器二进制包在国内网络环境下经常超时或者卡在 Downloading 阶段。我的处理办法是先确认 pip 安装的版本号然后通过环境变量切换下载源或者更干脆一点检查%USERPROFILE%\AppData\Local\ms-playwright目录下的文件是否完整。如果校验不通过删掉目录重新执行安装命令即可。还有个小坑藏在执行时机里。很多人习惯直接python进交互模式之后再去import playwright如果这时候报 ModuleNotFoundError基本可以断定是虚拟环境和全局环境混用了。切到虚拟环境再试问题基本消失。装好之后建议跑一个最简单的冒烟测试开一个浏览器访问 example.com 然后截图确认整条链路通顺。from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(https://example.com) page.screenshot(pathsmoke.png) browser.close()如果这一步能跑通并且能看到smoke.png你的环境就彻底没问题了。后续所有精力都可以放在页面逻辑上而不用再跟环境问题纠缠。2.2 浏览器上下文、等待策略与选择器三个必须吃透的概念Playwright 有三套核心概念是理解整套爬虫代码的钥匙它们比任何 API 细节都重要。第一个是浏览器上下文Browser Context。一个Browser实例可以创建多个互相隔离的Context你可以把 Context 理解成一个独立的“匿名用户会话”每个 Context 都有自己的 localStorage、cookie 和缓存。做爬虫时这是个非常好的隔离机制——一个上下文跑一个任务数据互不干扰出错了直接关掉这个上下文不影响主流程。一般我会把浏览器启动一次然后为每个城市榜单任务单独创建上下文既干净又节约资源。第二个是等待策略。动态页面最麻烦的就是“不知道数据什么时候渲染完”Playwright 提供了三招解决page.wait_for_selector()等某个元素出现、page.wait_for_load_state()等页面加载状态变化、page.expect_response()等某个网络请求完成。三招的使用场景完全不同。比如榜单数据是异步接口返回来再渲染的那wait_for_selector()就比较靠谱你等的是“渲染完成”的结果如果你想直接截获接口数据expect_response()更高效你在等“数据到达”的瞬间。切忌上来就time.sleep(5)这种固定死等网络状态一波动固定延时要么不够要么浪费时间。第三个是选择器机制。Playwright 支持 CSS 选择器和 XPath但它真正厉害的是推荐你用“可读性优先的定位方式”比如page.get_by_text(热门城市)、page.get_by_role(button, name查看榜单)。这类定位在页面结构变化时不容易失效项目维护成本会低很多。我个人的习惯是优先用文本和角色定位实在定位不到的再用 XPath 兜底全局别出现大段依赖div[id...]的脆弱代码。3. 页面数据定位与核心爬虫逻辑实现3.1 用“网络面板”找真正的数据源头写页面操作代码之前先花十分钟把页面背后的网络通信摸清楚这是整个项目最值钱的一步。打开目标榜单页按 F12 切到 Network 面板勾选 Fetch/XHR 过滤然后刷新页面。你会看到一堆异步请求一眼扫过去找到名字里带 rank、city、hotel 这几个关键字的请求点开它的 Preview 标签页如果看到的是整齐的 JSON恭喜你数据源头找到了。这里有个特别重要的判断点到底应该用 Playwright 老老实实操作 DOM 拿数据还是直接截获这个 XHR 接口的响应我的经验是看数据量级和结构复杂度。如果页面只展示前十名而且展示逻辑简单那截获 XHR 最省事——响应体就是标准 JSON字段清清楚楚不需要和 HTML 结构较劲如果需要模拟用户点击、滚动、切换城市这些交互动作并且数据分布在多个接口里那就走 DOM 提取路线虽然慢但更贴近真实用户行为。项目里最终选择了两者结合先用 Playwright 模拟用户进入榜单页并触发数据加载然后用expect_response截获 XHR 响应把 JSON 直接拿过来解析。这种思路的好处是几乎不受前端样式改版影响因为接口字段一般比 DOM 结构稳定得多。from playwright.sync_api import sync_playwright 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/122.0.0.0 Safari/537.36, viewport{width: 1280, height: 720}, localezh-CN ) page context.new_page() # 先打开页面确保基础资源加载完成 page.goto(https://example.com/rank, wait_untildomcontentloaded, timeout30000) # 通过 expect_response 截获榜单接口数据 with page.expect_response(lambda resp: rank in resp.url and city in resp.url) as resp_info: page.get_by_role(button, name查看榜单).click() response resp_info.value data response.json() print(data) browser.close()代码里的lambda过滤器是关键它会在所有经过页面的网络响应里找 URL 同时包含 rank 和 city 的请求找到之后再通过resp_info.value拿到完整的响应对象。这种方式比我之前用 Selenium 时硬等元素然后拿 innerText 再正则提取体感不知道高到哪里去了。3.2 从 XHR 响应到结构化数据字段清洗与层级展开拿到 JSON 之后真正的工作才刚开始。接口返回的数据结构通常是多层嵌套以榜单数据为例常见格式是“城市信息 热门酒店列表 价格区间 评分数据”混在一起。直接json.loads()之后丢给 DataFrame 是不现实的需要做字段梳理。我的习惯是先把数据结构打印到终端人眼过一遍把嵌套层次搞清楚然后写成独立的解析函数用类型标注固定住每个字段的预期类型。比如城市名是字符串、排名是整数、均价是浮点数、热门酒店是列表这样后续处理数据时不容易出现冷不丁的 None 把整条链路打断的问题。def parse_rank_data(data: dict) - list[dict]: 把接口返回的 JSON 解析成一维的榜单记录列表。 results [] cities data.get(data, {}).get(cities, []) for city in cities: records { city_name: city.get(name), rank: city.get(rank), avg_price: city.get(avg_price), hotel_count: len(city.get(hotels, [])) } results.append(records) return results字段清洗阶段最容易踩坑的是字段名风格不统一有的接口用 camelCase有的用 snake_case解析函数里最好提前统一成一种风格后面存库和做分析都省心。另外要小心空值。接口经常出现某个城市没有均价信息这时候avg_price就是 None如果直接写进 CSV 会出现一堆空行处理时要么填充一个显式的哨兵值要么在解析函数里过滤掉这种记录。3.3 数据存储方案对比JSON、CSV 与 SQLite 的取舍榜单数据量不算大一次采集撑死几百条记录存储方案可以走轻量路线。我自己同时试过三种方案总结下来是JSON 最适合做调试和快速验证改完解析逻辑直接 dump 一份看效果CSV 最通用Excel 直接打开就能人工检查非常适合和业务方做沟通SQLite 则适合需要反复查询和增量更新的场景支持后续做历史趋势分析。考虑到后面要做多城市、多时间点的对比分析最终选了 SQLite。建表非常宽裕三张表就够城市排名表、酒店明细表、采集任务日志表。城市排名表存每次抓取的城市名、排名、均价酒店明细表存每个酒店的名称、价格、评分任务日志表记录每次采集的时间点和数据量方便回溯。CREATE TABLE IF NOT EXISTS city_rank ( id INTEGER PRIMARY KEY AUTOINCREMENT, city_name TEXT NOT NULL, rank INTEGER NOT NULL, avg_price REAL, hot_count INTEGER, crawl_time TEXT NOT NULL );用 SQLite 有个隐藏好处就是天然的幂等性控制。同一个城市同一天采集了两次通过crawl_time字段就能区分出哪次是最新数据去重逻辑也简单。对比之下CSV 文件去重就得每次手动读全量再写回非常难受。4. 反爬应对与采集稳定性优化4.1 常见反爬手段盘点从 Headers 检测到行为分析做浏览器自动化采集避不开“对方反爬”这个话题。常见的手段我梳理了一下基本分为五个层次Headers 校验、浏览器指纹识别、行为检测、JS 环境校验、频率限制。Headers 校验最好理解就是检查请求头里的 User-Agent、Referer、Accept-Language 合不合理你用 Playwright 默认的浏览器配置基本能天然通过因为它是真实浏览器内核发的请求但如果你是直接拿 requests 模拟反倒容易在这一层就被拦下。浏览器指纹识别是更高级的玩法通过 Canvas 指纹、WebGL 渲染信息、字体列表、屏幕分辨率来判断你是不是“无头浏览器”。Playwright 自带的无头模式确实容易被识别所以项目里多数时候我选择headlessFalse跑代价是多一个浏览器窗口换来的是指纹层面的真实性。某些更严格的站点甚至会检测navigator.webdriver标志Playwright 默认会把这个标志设为 True想解决的话需要引入 stealth 插件但我个人建议非必要不用因为一旦动了这类参数技术交流的边界就开始模糊了。行为检测是最防不胜防的。真实用户不会每秒钟点十次按钮也不会鼠标轨迹笔直从一个点滑到另一个点。Playwright 的page.mouse和page.keyboard可以模拟输入但模拟的轨迹总归比真实用户“直”很多。项目里我选择的策略是给每个步骤之间加随机延时并且把点击位置设置成带随机偏移的坐标这些微小的随机性虽然不能保证100%通过高级行为检测但能极大降低被标记的概率。4.2 核心优化随机延时、重试机制与任务级幂等稳定性优化是这类项目的重头戏。写过爬虫的朋友都懂代码第一次跑通根本不值得庆祝真正考验人的是它能不能连续跑一百次不挂。我踩过的坑主要集中在三个地方第一个是没有随机延时。固定sleep(3)的问题在于如果网络慢3 秒根本不够数据还没渲染出来你就开始提取拿到空列表然后整个任务就崩了。如果网络快3 秒又太浪费。更好的做法是封装一个随机延时函数在 1.5 到 3.5 秒之间波动既模拟了人类操作节奏又给网络波动留了缓冲。import random import time def human_delay(min_sec: float 1.5, max_sec: float 3.5) - None: time.sleep(random.uniform(min_sec, max_sec))第二个是重试机制。任何自动化采集都逃不过偶发的元素超时、网络闪断、接口 5xx。重要经验是不要在代码里写特别长的timeout应该写短一点的超时时间然后配合重试。比如定位榜单按钮设置 5 秒超时失败了整个任务重试三次每次重试前把页面重新加载一遍。这种“快速失败 整体重试”的模式比“一次等 60 秒然后崩溃”要稳得多。def safe_click(page, selector: str, retries: int 3): for attempt in range(retries): try: page.click(selector, timeout5000) return True except Exception as exc: print(f第 {attempt 1} 次点击失败: {exc}) human_delay(2, 4) raise RuntimeError(f连续 {retries} 次点击失败: {selector})第三个是任务级幂等。也就是同一个任务重复执行时不会产生重复数据也不会把上一次的脏数据带进来。我在代码里用“采集批次号”来标识每一次任务同一个批次里写到的数据会先清理再插入不同批次之间用crawl_time区分。这样即使脚本意外中断重跑也不会出现同一天有两条看似一样但数值略不同的记录堆积的问题排查数据非常舒服。5. 常见问题与排查技巧实录5.1 环境与安装类问题第一个高频问题就是playwright install失败。前面提过下载源超时是一类另一类是装完 chromium 之后启动报错提示缺系统依赖库。在 Windows 上一般是 VC 运行库没装到微软官网装最新的 Visual C Redistributable 就能解决。在 Linux 服务器上则要执行playwright install-deps命令它会帮你把所有系统级依赖一次性装好。别小看这个命令很多人在 Docker 环境里折腾半天最后一条命令全解决。第二个比较隐蔽的问题是 Python 包版本和浏览器版本错位。Playwright 是强绑定浏览器版本的如果你用 pip 更新了 playwright 包但没重新执行playwright install新版本可能无法驱动旧的浏览器内核。遇到启动时报 “browser executable path doesn’t exist” 时先别急着怀疑代码重复执行一次安装命令往往就好了。5.2 元素定位与动态加载问题动态页面的元素定位翻车率最高的就是“一刷新选择器就失效了”。最常见的原因是前端框架Vue、React在数据变化时会重绘整个 DOM 树你原本定位到的节点被删掉重建了这时候如果你还拿着旧节点的引用去操作肯定报错。一个深有体会的建议是定位元素尽量靠近用户视角。比如你要点“查看榜单”按钮就先找包含这个文本的元素而不是写一个又长又脆的 XPath 路径。Playwright 的get_by_text在这种场景下是真的好用。还有一个高频情况是页面存在 iframe。如果榜单详情是在 iframe 里打开普通的页面选择器是摸不到的。Playwright 处理 iframe 的方式比较优雅直接用frame_locator进去frame page.frame_locator(#rank-iframe) frame.get_by_text(热门城市).click()5.3 数据缺失与异常响应排查排在第三类的是数据完整性问题。接口偶尔会返回部分数据比如十个城市只返回七个前端页面上也是七个不仔细看根本发现不了。我的排查方法是每跑完一次任务检查收集到的记录数是否在预期范围内不在就发警报日志然后自动重跑一次。这个简单的“条数自检”机制救了我很多次因为很多时候数据不是完全没抓到而是悄悄变少了事后看数据库才发现某一天的数据有明显缺口。还有一类异常是接口返回了 200 但内容里带着错误提示比如“操作频繁”之类的文本。这种时候response.json()不会抛异常但解析出来的字段全为空。处理办法是在解析函数里加个前置校验判断返回内容里是否存在明确的错误标志如果有就直接抛自定义异常外层重试逻辑接住后自动加长延时再试。6. 后续扩展方向与个人操作体会6.1 定时调度与多源扩展项目跑通之后自然就会想让它“自动化起来”。最简单的定时方案是在任务外面套一层循环加上时间判断到点就执行采集任务完成之后写一个完成日志。更工程化的做法是用 APScheduler 做定时调度或者干脆部署到服务器上配 cron。但不管用哪种都建议把任务设计成可以安全中断的也就是上一节说的幂等机制这样就算中途被打断重跑也不会产生脏数据。数据积累到一定程度还可以考虑把榜单数据的历史变化可视化出来用 pandas 读取 SQLite 之后画折线图、柱状图观察不同城市热门度的季节性变化。这种分析才是采集数据真正发挥价值的地方单纯把数据存起来吃灰没有任何意义。6.2 我对 Playwright 爬虫定位的几句心里话项目做到最后技术上的结论其实已经不重要了我更想分享的是心态层面的东西。Playwright 这类工具天然带着“双刃剑”属性会玩的人可以用它实现 Web 自动化测试、页面监控、数据采集、报表生成这些非常有价值的事情但也会有人拿它做违背平台意愿的事情。我自己这些年一直给自己划清晰的线学习技术原理可以模拟真实用户行为做验证可以但采集公开数据只要控制频率、遵守规则就没问题数据拿去赚钱、拿去导流、拿去骚扰用户这些一律不碰。在实际操作中我的感受是Playwright 真正拉开你和普通爬虫脚本差距的地方不在于你会不会写page.goto()而在于你是否真正理解了浏览器上下文隔离、等待策略选型和响应截获的时机。这三个点吃透了几乎所有动态页面采集项目都能举一反三。希望这篇拆解能给正在学 Python 爬虫、想深入玩转 Playwright 的朋友一些真实可用的参考。最后说个小技巧调试定位问题时一定要打开headlessFalse加slow_mo300慢慢观察浏览器的每一步操作你会比看一百遍报错日志更快找到问题所在。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →