ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Playwright爬取动态渲染榜单:从Requests失效到完整实战

用Playwright爬取动态渲染榜单:从Requests失效到完整实战 上个月我在做一个城市热度分析的小项目第一步需要一份热门旅游城市榜。数据源我直接选了一家在线旅行平台的热门城市榜页面本想着前端有个现成的榜单用Python爬虫加requests应该几分钟就能搞定。结果打开页面一看HTML源码里全是框架容器榜单数据完全是JS异步渲染出来的requests拿到的只是一堆空壳。这种场景下要想拿到数据要么去逆向接口要么直接上Playwright这类能驱动真实浏览器的自动化工具。我选了后者最终用Playwright把热门城市榜完整跑了下来整个过程踩了若干坑今天把这套实战流程完整整理出来。先说明一下这篇文章只做技术交流与学习记录目标站点我统称为xx旅行平台不直接点名具体品牌。你如果要用这套思路去抓类似动态页面无论是榜单、列表还是资讯页原理都是一样的只是选择器需要按实际情况调整。1. 为什么这件事必须用PlaywrightRequests拿不到动态榜单数据1.1 热门城市榜的本质是“前端渲染 异步加载”我相信很多人一开始跟我一样都会有一个惯性思维先打开开发者工具在Network面板里找XHR接口直接把接口返回的JSON拿来用。这个思路没错但xx旅行平台这种量级的站点接口层面做了大量防护。常见的几种情况我都碰到了接口URL带动态签名参数而且签名算法混淆在压缩过的JS里返回的JSON字段是编码过的明文文本藏在某个加密结构里直接请求接口时服务端会校验请求头里的referer、cookie等上下文如果单纯为了这一个榜单去逆向签名算法本质上是和对方前端团队“斗智斗勇”。签名逻辑可能每周更新一次你花两天逆向完下个月开工发现又变了。所以我当时的判断是不值得。榜单数据虽然重要但它不是我项目的核心我只想稳定地拿到一份城市名称和热度数值而不是去研究加密对抗。1.2 Playwright的关键优势是“模拟真实用户操作”Playwright的核心思路和requests完全不同。requests是直接发HTTP请求服务器能识别出这是一个没有“人味”的客户端而Playwright是启动一个完整的Chromium浏览器实例由它加载页面、执行JS、渲染DOM然后你在这个浏览器里做各种操作比如滚动、点击、等待元素出现。用人话来说requests是打电话去问数据Playwright是真人进店里看数据。热门城市榜这种前端渲染的页面对Playwright来说就是天然适配——你能在浏览器里看见什么就能爬到什么。而且Playwright相比老的Selenium有几个非常明显的优势自带等待机制wait_for_selector这类API比Selenium的隐式等待稳妥得多支持CSS、XPath、文本选择器等多种定位方式定位表达式非常灵活有codegen录制功能可以自动生成操作代码快速验证页面交互路径可以录制视频、截图、追踪信息出问题方便复盘1.3 关于接口逆向的性价比说点实话我不反对接口逆向它速度快、耗资源少是爬虫工程师的必修课。但接口逆向有一个前提条件接口签名足够简单或者你有精力长期维护。如果一个榜单数据需要你去翻压缩混淆过的JS文件、打断点、还原签名逻辑那成本远高于跑一个浏览器实例。时间成本对比一下我实测的参考数据方案前期准备耗时单次运行耗时稳定性维护成本接口逆向3~6小时0.5秒低签名一变就失效高Playwright渲染15分钟20~40秒高只要页面不改版就能跑低对个人项目和中小型数据需求来说Playwright是明显划算的选择。慢是慢一点但胜在省心。2. 动手前的侦探工作热门城市榜在页面结构里长什么样2.1 先用Network面板确认数据是接口返回还是DOM渲染别急着写代码先手动打开目标页面用开发者工具做三件事看Network里的XHR请求判断是否存在直接返回榜单数据的接口看网页源码里是否存在城市名称如果源码里没有说明是JS渲染滚动页面观察是否有懒加载逻辑比如滚动到底部才请求第二批城市我这次遇到的页面结构是榜单区域本身在HTML里就有骨架但具体的城市名和热度数值是滚动到可视区域后才通过接口加载并渲染到DOM里的。这意味着爬虫策略需要分为两步先等基础页面渲染完成再模拟滚动触发懒加载最后提取DOM。这一步非常关键它直接决定了后面的代码结构。很多人上来就写page.goto()然后立刻提取数据结果发现什么也提不出来就是因为没做页面侦察。2.2 用Playwright的codegen录制快速拿到定位表达式确定页面结构之后先不要手写定位直接用Playwright自带的录制工具跑一遍能大幅节省调试时间。在终端里执行playwright codegen运行后会弹出一个带录制面板的浏览器你手动操作一遍页面滚动、点击加载更多、查看城市列表。每做一步录制面板都会自动生成对应代码里面可以直接看到可用的选择器。这一步对新手特别友好。比如我在录制过程中发现榜单里的城市名都带一个city-name的class热度值带hot-value的class那我的定位表达式就有了。不用自己去猜测页面类名拼写是否正确反而能省下大量试错时间。2.3 XPath的text()函数在文本定位里特别好用在做页面解析的时候除了CSS选择器XPath的text()函数是个很实用的技巧。如果你的目标元素没有稳定的class或id但它有固定的文本内容用文本定位最省事。举几个实际常用的写法# 精确匹配文本为北京的span page.locator(xpath//span[text()北京]) # 模糊匹配文本包含热度的元素 page.locator(xpath//div[contains(text(),热度)]) # 结合兄弟节点定位 page.locator(xpath//div[contains(class,city-item)]//span[contains(text(),城市)])这里有个坑需要提醒text()匹配的是当前节点的直接文本如果目标标签里嵌套了子标签文本可能分散到多个节点里text()就会匹配失败。遇到这种情况改用contains(text(), 关键词)或者直接使用Playwright独有的has_text参数更稳妥# 使用has_text按文本过滤适合嵌套结构 page.locator(.city-item, has_text北京)你可以先手动跑一跑这些表达式在浏览器控制台里用$x()验证XPath是否能选中目标元素确认无误后再写进代码。3. 环境准备里最容易翻车的三个坑3.1 pip install playwright不等于安装完成最常见的翻车经历是pip install playwright成功执行了但一运行代码就报错Executable doesnt exist提示找不到Chromium。原因很简单Playwright的Python包本身只是一层控制库真正的浏览器内核需要单独下载。安装完库之后必须执行playwright install chromium如果只需要Chromium这一个内核命令就只要装它不用把Firefox和WebKit全装一遍省空间也省时间。另外注意一下版本匹配问题。如果你之前装过老版Playwright建议先升级pip install --upgrade playwright playwright install --force chromium--force会强制重新下载浏览器内核避免版本不匹配导致的古怪问题。3.2 npx playwright install失败时的镜像方案如果你的环境是Node.js和Python混用可能会遇到npx playwright install失败的情况。下载超时或者被中断是最常见的原因毕竟浏览器压缩包体量不小从网络上下载偶尔会不稳定。Python版本的Playwright可以用环境变量指定下载镜像源实测下来很有效export PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright playwright install chromium这只影响浏览器内核的下载地址不影响正常使用。如果你在服务器或者CI环境里跑建议把这一行写进启动脚本里避免每次手动设置。3.3 Linux下缺失动态库的排查在纯净的Linux服务器上跑即使浏览器装好了启动时还会遇到一堆系统和动态库报错比如libnss3.so找不到、libatk缺失等。这类问题的根源是Chromium依赖一大堆系统运行库服务器不像桌面系统自带这些库。Playwright提供了一个一次性解决依赖的命令# Ubuntu/Debian系统 sudo playwright install-deps chromium # CentOS/RHEL系统 sudo playwright install-deps chromium这条命令会自动安装Chromium所需的系统库。需要注意的是在Docker环境里执行时必须确保基础镜像里有sudo和apt否则命令会执行失败。建议Docker镜像直接用Ubuntu 20.04以上的版本依赖问题会少很多。4. 核心代码实现滚动加载、元素定位与数据提取4.1 稳定的等待策略是爬虫的命根子写Playwright爬虫最容易犯的错误就是页面加载判断方式随意。最常见的错误是用time.sleep(10)这种固定等待网络一波动就歇菜。正确做法是利用Playwright自带的等待机制。以我的爬虫为例基础框架是这样的import csv from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch( headlessFalse, slow_mo200, ) context browser.new_context( user_agentMozilla/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}, localezh-CN, ) page context.new_page() page.goto(https://example-hotel-site.com/hot-city, wait_untilnetworkidle, timeout30000) page.wait_for_selector(.city-list, timeout10000)这里有两个关键选择我解释一下为什么这么写第一个是wait_untilnetworkidle。它表示等待页面及所有子资源加载完毕连续500毫秒内没有新的网络请求才继续。相比domcontentloaded它能更好地覆盖懒加载场景。但要注意如果某个页面有无限滚动流networkidle可能永远不会触发这时候就要改用domcontentloaded加显式等待元素出现。第二个是page.wait_for_selector(.city-list)。这是最可靠的等待方式它直接等待目标区域出现在DOM里比任何固定sleep都准确。如果页面结构里没有明确的容器class也可以等待某一个具体的城市元素。4.2 滚动加载怎么判断结束热门城市榜这类页面通常不会一次渲染全部城市而是滚到底部才加载下一批。我的做法是循环滚动并通过页面高度是否变化来判断是否到底last_height page.evaluate(document.body.scrollHeight) while True: page.mouse.wheel(0, 3000) page.wait_for_timeout(1200) new_height page.evaluate(document.body.scrollHeight) if new_height last_height: break last_height new_height这里有个细节值得注意轮子滚动步长不要太大我试过直接从0滚到页面最底部部分懒加载逻辑不会触发触发导致列表漏掉一批数据。小步多次滚动更接近真实用户行为加载也稳定得多。如果页面里明确有“加载更多”按钮那就更简单了循环点击按钮直到按钮消失即可load_more page.locator(text加载更多) while load_more.count() 0 and load_more.is_visible(): load_more.click() page.wait_for_timeout(1000)4.3 locator定位与XPath文本匹配的实战写法页面里的城市名和热度值是列表结构定位方式可以用CSS也可以用XPath。我个人习惯先用CSS定位列表项再在每个列表项内部精确定位目标字段items page.locator(.city-item).all() for item in items: city item.locator(.city-name).inner_text().strip() hot_value item.locator(.hot-value).inner_text().strip() print(city, hot_value)如果class名不明显也可以用XPath组合items page.locator(xpath//div[contains(class,city-item)]).all() for item in items: city item.locator(xpath.//span[contains(class,city-name)]).inner_text().strip() hot_value item.locator(xpath.//span[contains(class,hot-value)]).inner_text().strip()注意XPath子路径开头的.这个是重点。加.表示从当前元素往下找不加.//的话matches从根节点开始找大概率会匹配到页面其他地方的同名元素拿到错误数据。4.4 完整流程跑通数据落进CSV把所有环节拼起来一个能运行的完整爬虫大概长这样import csv from playwright.sync_api import sync_playwright def fetch_city_rank(): result [] with sync_playwright() as p: browser p.chromium.launch(headlessFalse, slow_mo200) context browser.new_context( user_agentMozilla/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}, localezh-CN, ) page context.new_page() page.goto(https://example-hotel-site.com/hot-city, wait_untilnetworkidle, timeout30000) try: page.wait_for_selector(.city-list, timeout10000) except Exception: print(页面元素加载超时请检查选择器) browser.close() return result # 滚动到底触发懒加载 last_height page.evaluate(document.body.scrollHeight) while True: page.mouse.wheel(0, 3000) page.wait_for_timeout(1200) new_height page.evaluate(document.body.scrollHeight) if new_height last_height: break last_height new_height items page.locator(.city-item).all() for item in items: city item.locator(.city-name).inner_text().strip() hot_value item.locator(.hot-value).inner_text().strip() result.append({city: city, value: hot_value}) browser.close() # 写入CSVutf-8-sig避免Excel打开乱码 if result: with open(city_rank.csv, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnames[city, value]) writer.writeheader() writer.writerows(result) return result if __name__ __main__: data fetch_city_rank() print(f共抓取 {len(data)} 个城市)这段代码里的URL和选择器是脱敏示例真实场景下你换成自己页面上的实际值就能跑。整体节奏就是等页面、滚到底、提取DOM、落盘。5. 数据的落库与去重榜单数据别急着塞CSV5.1 编码和Excel兼容是第一关很多人在写完CSV之后就踩坑用Excel打开中文全是乱码。原因很简单Python默认的utf-8编码写入CSVExcel默认用ANSI读取中文就炸了。解决方法是写入时指定utf-8-sig也就是带BOM的UTF-8Excel识别起来就没问题。代码里这么写with open(city_rank.csv, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnames[city, value]) writer.writeheader() writer.writerows(result)如果不想用CSV直接存JSON也行中文在JSON里天然是UTF-8不会乱码import json with open(city_rank.json, w, encodingutf-8) as f: json.dump(result, f, ensure_asciiFalse, indent2)我个人推荐小数据量场景直接上JSON结构清楚后续无论是做分析还是再处理都方便。5.2 去重、排序与增量对比榜单类数据有一个特点今天抓的榜和昨天抓的榜城市可能是同一批只是顺序和热度值变了。如果你只是每次抓完覆盖保存那就丢失了对比分析的价值。所以我一般会在落库之前做一个简单的去重和排序顺便保留一份历史版本import pandas as pd df pd.read_csv(city_rank.csv) df df.drop_duplicates(subset[city]) df df.sort_values(byvalue, ascendingFalse) df.to_csv(city_rank_clean.csv, indexFalse, encodingutf-8-sig) # 保存历史版本 import time timestamp time.strftime(%Y%m%d_%H%M%S) df.to_csv(fcity_rank_history_{timestamp}.csv, indexFalse, encodingutf-8-sig)然后你可以做排名变化对比比如计算两次抓取之间哪些城市排名上升了、哪些下降了。这个“增量对比”的思路在榜单类数据里特别实用能让爬虫从“抓数据”升级成“跟踪数据变化”。用pandas处理还有个好处是后续画图、做Top10展示都非常方便。榜单数据量不大pandas完全够用没必要上数据库。6. 为什么你的爬虫会被风控盯上以及怎么在不违规的前提下规避6.1 站在服务器视角看反爬从UA到行为指纹网上搜索“java controller层如何防护防止爬虫”这类热点时你会发现服务端反爬的思路其实是从工程层面做了多层校验。它们通常先看请求头再根据频率和行为特征判断请求方是真人还是脚本。以Playwright抓动态页面为例最容易触发风控的几个特征固定不变的外网出口IP短时间内高频访问User-Agent特征异常比如某些Python库自带的UA头行为模式太规律比如每隔精确的3秒点击一次访问路径单一没有任何鼠标移动、悬停等“人类动作”直接从空Cookie会话开始抓取没有正常浏览的历史过程服务端拿到这些特征后会综合评估风险分。风险分过高时最常见的手段是弹出验证码、返回异常页面、或者直接封IP一段时间。6.2 降频与伪造“人类感”的合规操作我的做法是三个字限、变、慢。限限制整体请求频率每个页面之间加随机延时time.sleep(random.uniform(2, 5))。榜单这种数据不是高频数据一天抓一次完全够用没必要追求秒级速度。变每次启动时在多个常见的浏览器UA里随机选择同时设置随机的viewport尺寸。这些操作在浏览器层面是合法的相当于“换了一台设备访问”。慢开启slow_mo参数让Playwright的每个操作有200~500毫秒的间隔。这个参数原本是调试用的但实际使用中它确实能降低行为规律性我习惯保留在200毫秒左右。这些措施的本质只有一个——降低脚本的行为规律性让它看起来更像真人。这不是在教人突破什么安全机制而是正常的技术调优手段就像正常人不会每秒刷新一次页面一样。6.3 关于“Playwright过瑞数”这类方案我的态度搜索热词里有一堆“playwright过瑞数”相关的讨论我能理解大家为什么对这个感兴趣毕竟遇到动态防护很头疼。但说实话我不建议在这个方向钻牛角尖。数字风控产品比如瑞数的核心能力是采集浏览器指纹和行为特征然后综合判断请求是否来自自动化工具。强行绕过的思路本质上是在和设备指纹检测对抗这类方案的稳定性极差风控策略每周更新一次你的绕过方案就失效一次每次失效又要花大量时间重写。对个人项目来说这个成本完全得不偿失。更靠谱的应对方案其实是这几种降低采集频率每天或每周抓一次尽量避免触发频控检查目标网站是否提供官方开放接口有的话优先用官方接口如果只是需要榜单排行数据直接用商业数据服务或者换一个数据源必要时把采集请求分散到多个出口IP控制单个出口的请求量记住爬虫是手段不是目的。拿到数据、完成业务分析才是目的。过度执着于对抗风控很容易陷入技术圈套里出不来。7. 踩过的坑和最后一点经验7.1 一张表记录我的避坑清单这次跑热门城市榜我把踩过的坑和解决方案整理成了表格基本涵盖了Playwright爬虫最常遇见的几类问题坑点现象解决办法忘记playwright install报错找不到浏览器执行文件安装Python包后顺手执行playwright install chromiumLinux缺系统库浏览器启动失败提示缺libnss3等执行sudo playwright install-deps chromiumnetworkidle等待过久页面有持续请求时卡住不动改用domcontentloaded加wait_for_selector滚动加载不触发只抓到一部分城市小步多次滚动不要一次性滚到底XPath子路径漏加.匹配到页面其他同名单词子路径开头写.//中文写入CSV乱码Excel打开的榜单是乱码编码用utf-8-sig用time.sleep(10)固定等待网络波动导致数据抓不全用wait_for_selector替代固定sleep7.2 我惯用的工作流最后分享一个我做了无数次爬虫任务之后沉淀下来的流程适用于大部分动态页面的抓取需求第一步手动打开页面用Network面板确认数据是接口渲染还是DOM渲染。第二步用playwright codegen录制一遍完整的交互路径比如滚动、点击加载按钮顺手拿到选择器。第三步把录制产物转换成平铺的Python代码把固定等待全部替换成条件等待。第四步先跑一次最小数据集验证能稳定提取字段再放开全量数据。第五步落库时加下去重、排序、历史版本保留让数据具备后续分析的价值。这套流程最核心的地方在于不要用暴力方式去抓页面而是先理解页面怎么工作人是怎么操作的然后再用Playwright去模拟这个过程。模拟得越自然数据稳定性越高后续维护成本越低。这次抓取热门城市榜的完整经验就是这些。如果你也在做类似的动态页面数据采集建议严格按照“先侦察、再录制、后编码”的顺序来能省下大量不必要的调试时间。等跑通一个页面之后你就发现Playwright这类工具真正的价值所在了。
返回列表