☰
用Playwright爬取动态渲染榜单:从Requests失效到完整实战
2026/10/8 2:52:25 网站建设 项目流程

上个月,我在做一个城市热度分析的小项目,第一步需要一份热门旅游城市榜。数据源我直接选了一家在线旅行平台的热门城市榜页面,本想着前端有个现成的榜单,用Python爬虫加requests应该几分钟就能搞定。结果打开页面一看,HTML源码里全是框架容器,榜单数据完全是JS异步渲染出来的,requests拿到的只是一堆空壳。这种场景下要想拿到数据,要么去逆向接口,要么直接上Playwright这类能驱动真实浏览器的自动化工具。我选了后者,最终用Playwright把热门城市榜完整跑了下来,整个过程踩了若干坑,今天把这套实战流程完整整理出来。

先说明一下,这篇文章只做技术交流与学习记录,目标站点我统称为xx旅行平台,不直接点名具体品牌。你如果要用这套思路去抓类似动态页面,无论是榜单、列表还是资讯页,原理都是一样的,只是选择器需要按实际情况调整。

1. 为什么这件事必须用Playwright:Requests拿不到动态榜单数据

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 doesn't 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_HOST=https://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( headless=False, slow_mo=200, ) context = browser.new_context( user_agent="Mozilla/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}, locale="zh-CN", ) page = context.new_page() page.goto("https://example-hotel-site.com/hot-city", wait_until="networkidle", timeout=30000) page.wait_for_selector(".city-list", timeout=10000)

这里有两个关键选择,我解释一下为什么这么写:

第一个是wait_until="networkidle"。它表示等待页面及所有子资源加载完毕,连续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(headless=False, slow_mo=200) context = browser.new_context( user_agent="Mozilla/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}, locale="zh-CN", ) page = context.new_page() page.goto("https://example-hotel-site.com/hot-city", wait_until="networkidle", timeout=30000) try: page.wait_for_selector(".city-list", timeout=10000) 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() # 写入CSV,utf-8-sig避免Excel打开乱码 if result: with open("city_rank.csv", "w", encoding="utf-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. 数据的落库与去重:榜单数据别急着塞CSV

5.1 编码和Excel兼容是第一关

很多人在写完CSV之后就踩坑:用Excel打开,中文全是乱码。原因很简单,Python默认的utf-8编码写入CSV,Excel默认用ANSI读取,中文就炸了。

解决方法是写入时指定utf-8-sig,也就是带BOM的UTF-8,Excel识别起来就没问题。代码里这么写:

with open("city_rank.csv", "w", encoding="utf-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", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2)

我个人推荐小数据量场景直接上JSON,结构清楚,后续无论是做分析还是再处理都方便。

5.2 去重、排序与增量对比

榜单类数据有一个特点:今天抓的榜和昨天抓的榜,城市可能是同一批,只是顺序和热度值变了。如果你只是每次抓完覆盖保存,那就丢失了对比分析的价值。

所以我一般会在落库之前做一个简单的去重和排序,顺便保留一份历史版本:

import pandas as pd df = pd.read_csv("city_rank.csv") df = df.drop_duplicates(subset=["city"]) df = df.sort_values(by="value", ascending=False) df.to_csv("city_rank_clean.csv", index=False, encoding="utf-8-sig") # 保存历史版本 import time timestamp = time.strftime("%Y%m%d_%H%M%S") df.to_csv(f"city_rank_history_{timestamp}.csv", index=False, encoding="utf-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 chromium
Linux缺系统库浏览器启动失败,提示缺libnss3等执行sudo playwright install-deps chromium
networkidle等待过久页面有持续请求时卡住不动改用domcontentloaded加wait_for_selector
滚动加载不触发只抓到一部分城市小步多次滚动,不要一次性滚到底
XPath子路径漏加.匹配到页面其他同名单词子路径开头写.//
中文写入CSV乱码Excel打开的榜单是乱码编码用utf-8-sig
用time.sleep(10)固定等待网络波动导致数据抓不全用wait_for_selector替代固定sleep

7.2 我惯用的工作流

最后分享一个我做了无数次爬虫任务之后沉淀下来的流程,适用于大部分动态页面的抓取需求:

第一步,手动打开页面,用Network面板确认数据是接口渲染还是DOM渲染。第二步,用playwright codegen录制一遍完整的交互路径,比如滚动、点击加载按钮,顺手拿到选择器。第三步,把录制产物转换成平铺的Python代码,把固定等待全部替换成条件等待。第四步,先跑一次最小数据集,验证能稳定提取字段,再放开全量数据。第五步,落库时加下去重、排序、历史版本保留,让数据具备后续分析的价值。

这套流程最核心的地方在于:不要用暴力方式去抓页面,而是先理解页面怎么工作,人是怎么操作的,然后再用Playwright去模拟这个过程。模拟得越自然,数据稳定性越高,后续维护成本越低。

这次抓取热门城市榜的完整经验就是这些。如果你也在做类似的动态页面数据采集,建议严格按照“先侦察、再录制、后编码”的顺序来,能省下大量不必要的调试时间。等跑通一个页面之后,你就发现Playwright这类工具真正的价值所在了。

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

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

立即咨询