尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python+Playwright实战:破解JS渲染动态榜单页爬取全流程

Python+Playwright实战:破解JS渲染动态榜单页爬取全流程 爬虫圈里经常有人问想抓那种纯JS渲染的榜单页面到底怎么下手。我最近就把飞猪酒店的热门城市榜完整跑了一遍用的是Python Playwright组合。看起来只是抓一个榜单区块但做完之后你会发现动态页面分析、自动等待、选择器定位、数据清洗、异常恢复这些爬虫核心技能这套流程全都会过一遍。这篇就是完整实战记录适合刚入门Python爬虫、又不想只会request正则的朋友也适合已经知道Playwright但缺一个完整落地示例的人。我会从选型原因一直讲到代码、踩坑和合规注意点尽量让小白照着也能跑通。1. 为什么是Playwright项目选型背后的考量1.1 requests和正则为什么这次不好使如果目标页面是纯静态HTML用requests拿源码再配正则提取确实是最快的方式。但飞猪这类大型在线旅行平台页面结构早就不是“服务端渲染好等你来拿”的模式了。酒店频道首页从上到下基本都是SPA组件榜单数据由前端JavaScript动态请求、动态填充。你用requests拿到的只是一堆被框架包裹的空壳DOM节点根本看不到城市名、热度值这些真实内容。有些人会退一步想那我直接分析浏览器里的XHR接口把返回的JSON抓下来行不行理论上行但实践中会发现两个麻烦。第一接口地址和参数经常带签名甚至带时效性token你不是平台内部开发者很难还原完整的加密逻辑第二哪怕这次分析通了版本一更新可能又废掉维护成本极高。Playwright不关心内部接口怎么加密它直接控制一个真实浏览器去加载页面最后呈现给你的就是渲染完成后的DOM你只需要关心“页面上能看到什么”这恰好就是用户视角也是最稳定的抓取思路。1.2 Playwright对比Selenium为什么我更推荐前者Selenium在自动化测试和爬虫领域资历老但Playwright这几年发展特别快用下来明显更现代。做技术选型时我列过一个简单对比对比项PlaywrightSelenium安装依赖pip安装后执行一行命令下载浏览器需要额外下载driver并匹配版本浏览器支持Chromium、Firefox、WebKitChrome、Firefox、Edge等自动等待内置auto-wait机制等待元素可操作主要靠显式等待写起来繁琐速度更快尤其无头模式相对慢资源占用偏高API设计清晰同步/异步两套API都很好用API历史包袱重风格不统一调试体验有录屏、trace、代码生成器调试选择器相对麻烦单从“写爬虫”这个角度我最看重的是自动等待。Selenium里如果你等一个元素没出现很容易堆出一大堆WebDriverWait和ExpectedConditionsPlaywright的locator.click()、inner_text()这类操作默认会等到元素可见、可用代码会简洁很多。再加上它自带的录制功能遇到一个新的动态页面时能快速生成初始脚本再手工改造成爬虫效率完全不是一个级别。1.3 Playwright解决动态榜单页的三个关键点这个项目能跑通主要靠Playwright三个能力完全渲染真实页面浏览器内核执行所有JS页面最终状态和普通用户看到的一致。自动等待替代盲猜time.sleep()Playwright会不断重试元素查找直到超时或成功不会一上来就扑空。细粒度页面控制可以拦截请求、修改请求头、模拟视口大小、滚动页面。榜单这种“懒加载”区块只有滚动到可视区域才会触发数据请求Playwright可以精确滚动到对应位置。还有一点容易被忽略Playwright支持通过page.expose_function()把Python函数注入到页面JS里也可以在页面内执行add_script_tag注入自定义JS。如果你要处理复杂的页面交互比如通过hover展开下拉列表再读取数据这套机制比Selenium原生方案顺手得多。2. 环境准备把Playwright跑起来2.1 Python虚拟环境与项目目录我建议任何爬虫项目都先建虚拟环境别把依赖直接装到系统Python里。这一步虽然基础却能避免很多版本冲突尤其你同时做数据分析、Web开发时。我这里用Python官方自带的venvmkdir hotel-rank-crawler cd hotel-rank-crawler python -m venv venv # Windows激活 venv\Scripts\activate # macOS/Linux激活 source venv/bin/activatePython版本建议3.8以上我用3.10跑通没有遇到兼容问题。如果机器上还没有Python先去官网下载安装包安装时记得勾选“Add Python to PATH”否则后面命令会找不到。2.2 安装Playwright和Chromium内核激活虚拟环境后执行pip install playwright playwright install chromium第一行安装的是Python库第二行会把Chromium浏览器内核下载到本地用户目录。这一步经常有人卡住常见原因是网络慢导致下载中断。我的处理方式是重新执行playwright install chromium它有断点续传和缓存机制多试一两次基本能成。如果你在Linux服务器上跑可能还需要安装系统依赖playwright install-deps chromium这条命令会安装字体库、动态链接库等一堆底层依赖不装的话浏览器可能起不来报一些莫名其妙的.so文件找不到错误。2.3 用最小脚本验证环境是否正常环境装好后先别急着写爬虫跑一个最小验证from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://www.example.com) print(page.title()) browser.close()能输出版本信息或页面标题就说明Playwright和浏览器内核都正常。这里我故意用example.com它稳定且不涉及任何网站风控。如果这一步报错九成是环境问题去检查插件版本和浏览器依赖就好。为了调试方便之后写爬虫时我会把headlessTrue临时改成False加上slow_mo500参数这样能肉眼看到浏览器每步操作定位问题更快。3. 页面分析与爬虫策略设计3.1 找到热门城市榜的真实入口爬虫最关键的不是代码而是把页面结构吃透。我打开飞猪App对应的酒店频道页面先不进任何详情页直接在首屏和滚动区域里找“热门城市榜”。不同版本布局会变我当时找到的榜单区块在酒店频道页的中部偏下表现形式是横向排列或列表排列的城市卡片每项包含城市名和一个热度指标。你可能会问怎么确定这就是“热门城市榜”而不是广告位我的判断方法是看数据口径是否一致榜单内所有条目都应当是“城市名数值”的结构并且数值有比较意义比如热度值从高到低排列。如果某个区域混合了图片、促销文案、价格标签那大概率是营销模块不适合当榜单抓取。找到位置后按F12打开开发者工具用元素选择器对准城市名就能看到真实DOM结构。通常这类榜单是一个ul列表里面每个li代表一个城市。我用Playwright录制功能在页面上点了两下它就生成了类似下面这种定位语句page.locator(.hot-city-rank .city-item)这个选择器就是我后面爬虫的核心入口。要注意平台页面经常改版类名可能带随机后缀或hash比如city-item_abc12如果你发现选择器不稳定可以用[class*city-item]这类模糊匹配兜底。3.2 划定要抓取的字段范围榜单不是把所有能看到的文字都抓下来就好那样数据脏且难用。我根据页面展示内容把字段定为三个核心项字段名含义示例rank排名1city城市名称三亚hot_value热度值/热度指数98.5有些版本的榜单还会展示“点评数”“酒店均价”等额外信息字段可以动态扩展。我建议第一次先把基础三列跑通再回来加字段。这样逻辑简单排错范围小。字段定好后再看DOM里每个字段对应哪个标签。比如城市名可能在span.city-name里热度值可能在div.hot-value里。这一步务必仔细因为很多时候页面里存在多个相似结构比如“热门城市”和“热门酒店”在同一个页面类名容易混在一起选择器稍微写宽就会抓到隔壁模块的数据。3.3 确定爬虫流程与频率策略我的完整流程设计如下每一步都对应明确的代码模块启动Chromium浏览器实例创建新的浏览器上下文。打开酒店频道URL等待页面主框架加载完成。等待榜单容器元素出现确认页面数据已经渲染。滚动到榜单区块触发懒加载等待数据补充。用循环遍历榜单条目逐个提取城市名和热度值。数据清洗后写入CSV文件。整体包一层异常处理失败自动重试三次。访问频率我刻意控制在很低的范围一次运行只抓一个榜单页不并发、不循环翻页轰炸。速度慢一点但也能避免给平台服务器造成压力。你如果只是学习完全没必要把每秒请求数拉满那不仅没意义还会让自己的IP很快被风控关注。3.4 为什么强调先看robots和服务条款这个项目属于技术学习但爬虫本身是有边界意识的。我在动手之前会先看一眼目标网站的robots.txt虽然它更多是声明而非法律强制文件但至少能帮你判断哪些路径是网站运营方不希望自动化程序访问的。公开页面数据用于个人研究和学习问题不大但如果拿去商业化转售或者抓取需要登录才能看的信息性质就完全不一样了。把频率控制好只拿公开榜单不尝试破解接口不碰个人隐私字段这是我在做任何爬虫项目前给自己的三条硬约束。后面第6部分我会再展开说。4. 完整代码实现与逐段解析4.1 同步API实现榜单抓取下面是我实际项目的核心代码去掉平台特定的伪装细节保留通用骨架。URL部分用的是示意地址你跑的时候替换成自己实际要抓的页面即可。import csv import time import random from playwright.sync_api import sync_playwright HOTEL_PAGE_URL https://example.com/hotel # 替换成真实目标页面 def fetch_hot_city_rank(): results [] with sync_playwright() as p: # 启动浏览器headlessFalse时方便观察调试完再改回True browser p.chromium.launch( headlessTrue, args[--disable-blink-featuresAutomationControlled] ) # 创建独立上下文配置浏览器UA和视口 context browser.new_context( user_agent( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 ), viewport{width: 1280, height: 800}, localezh-CN ) page context.new_page() # 打开页面等待DOM主框架加载完成 page.goto(HOTEL_PAGE_URL, timeout60000, wait_untildomcontentloaded) # 等待榜单容器出现注意给足超时时间 page.wait_for_selector(.hot-city-rank, timeout15000) # 滚动到榜单区域触发懒加载 rank_block page.locator(.hot-city-rank) rank_block.scroll_into_view_if_needed() # 滚动后多等一会让前端请求完成 page.wait_for_timeout(1500) # 提取榜单条目 items rank_block.locator(.city-item) count items.count() print(f识别到{count}个城市条目) for i in range(count): item items.nth(i) city item.locator(.city-name).inner_text().strip() hot_value item.locator(.hot-value).inner_text().strip() results.append({ rank: i 1, city: city, hot_value: hot_value, }) page.wait_for_timeout(500) browser.close() return results这段代码有几个容易踩的细节。第一items.count()得到的是当前DOM里匹配元素的数量如果懒加载还没触发完数量会偏少所以前面的滚动和等待很重要。第二inner_text()会把子元素里所有可见文本拼在一起如果城市卡片里有多个文本节点需要更精确地定位子选择器或者用locator(text/三亚/)这类方式过滤。第三我在循环里直接用了排名顺序严谨一点应该读取卡片上的排名数字防止服务端乱序返回但当前页面就是按顺序渲染的直接按索引加一没问题。4.2 保存CSV顺手处理中文乱码问题榜单抓下来之后我习惯存成CSV方便后续用Excel或pandas分析。这里有个老生常谈的坑直接用open(..., encodingutf-8)写CSVWindows上的Excel打开会乱码。原因是Excel默认用GBK理解无BOM的UTF-8文件。解决办法是使用utf-8-sig编码让文件带上BOM头Excel就能正确显示中文。def save_to_csv(data, output_pathhot_city_rank.csv): if not data: print(没有数据可保存) return with open(output_path, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[rank, city, hot_value]) writer.writeheader() writer.writerows(data) print(f已保存{len(data)}条记录到{output_path})在写文件前我会对字段再做一次清洗防止脏数据进CSVdef clean_data(raw_data): cleaned [] for row in raw_data: city row.get(city, ).replace(\n, ).strip() hot_value row.get(hot_value, ).replace(热度, ).replace(:, ).strip() if city: cleaned.append({ rank: row.get(rank, len(cleaned) 1), city: city, hot_value: hot_value, }) return cleaned清洗有两个原则一是所有文本先strip去掉首尾空白二是把单位词、冒号这类文案前缀去掉但保留数字语义。热度值如果带单位“万”就得想清楚后面分析需不需要换算成纯数字需要的话在清洗函数里统一处理成float类型。4.3 等待策略不要满屏time.sleep初学爬虫时大家都喜欢time.sleep(3)仿佛睡够了数据就会出现。Playwright的自动等待机制完全可以替代大部分固定睡眠而且更可靠。比如wait_for_selector会一直轮询DOM直到元素出现或超时locator.scroll_into_view_if_needed()会自动判断元素是否在视口内不在就滚动。这些API背后是事件驱动的调度逻辑比固定sleep高效得多。但懒加载场景比较特殊滚动后前端需要时间发请求、等响应、再渲染DOM这种“页面暂时没有新元素”的阶段自动等待也拿不到一个“空”。我通常在滚动后加一个短等待比如1到2秒而不是一个长的固定sleep。这样既给了前端网络请求时间又不至于每步都傻等。更严谨的做法是监听网络请求with page.expect_response(lambda response: hot-city in response.url): rank_block.scroll_into_view_if_needed()这个写法会让Playwright在滚动后等待指定条件的响应出现比盲等更精确。如果页面接口URL足够稳定推荐优先用这种方式。4.4 异常处理与重试机制真实世界里的爬虫不是跑一遍就完事网络抖动、页面结构微调、前端接口临时报错都会让脚本中断。我习惯把抓取逻辑封装成可重试的函数def fetch_with_retry(retries3): for attempt in range(retries): try: print(f第{attempt 1}次尝试) raw fetch_hot_city_rank() return clean_data(raw) except Exception as exc: print(f失败原因: {exc}) time.sleep(random.uniform(2, 5)) print(重试次数用完仍然失败) return []这里有几个细节每次失败后随机等待几秒再重试是为了避免节流惩罚重试之间重建浏览器实例防止上下文脏掉因为一个页面如果已经进入异常状态继续复用可能越错越远最外层返回空列表而不是抛出异常这样主流程至少能继续保存空结果不会被一个失败直接打断整个任务。4.5 异步版本什么时候值得用Playwright还提供异步API适合需要同时抓多个页面的场景。异步代码和同步代码结构很像import asyncio from playwright.async_api import async_playwright async def fetch_many(): async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) tasks [] for url in url_list: tasks.append(open_and_parse(browser, url)) results await asyncio.gather(*tasks, return_exceptionsTrue) await browser.close() return results说实话爬虫项目一开始不需要异步。榜单这种单页任务同步代码足够清晰。什么时候才值得上异步当你需要同时监控多个城市、多个页面或者需要高频更新数据时再改造。过早异步化只会让代码更难调试。这也是我自己写项目时的一个原则先跑通再优化。5. 调试与排查我踩过的几个坑5.1 元素明明存在却定位不到我在抓取过程里遇到过几次很奇怪的问题用DevTools手工搜索类名能搜到DOM节点但Playwright运行时报错找不到元素。排查后原因大概有三种。第一种是元素在iframe里。页面里嵌了iframe的话Playwright默认只能访问主框架的DOM需要切换到对应frame。解决办法是先用page.frames把所有框架打出来找到目标后使用frame.locator()定位。第二种是Shadow DOM隔离。很多前端组件库用Shadow DOM封装普通CSS选择器穿不进去。Playwright可以通过page.locator(cssselector).locator(xpath...)或者*_shadow相关选择器来处理但需要具体问题具体分析。第三种是选择器写得太宽或太窄。比如一个类名在多个模块复用时locator(.city-name)可能命中了10个元素但你的代码期望的是某一个。解决办法是沿着DOM树向上找稳定的父级容器再在容器内定位就像我代码里的rank_block.locator(.city-item)一样。5.2 懒加载导致数据条数不完整第一次跑出来的数据只有一半城市我心里很清楚这是懒加载没触发全。排行榜一般会按视口高度分批渲染你把榜单区域滚进视口时它可能只加载了一部分后面的还需要继续滚动。我最后的处理方法是把浏览器viewport调大减少滚动分段次数同时用一个循环滚动到底部几次每次间隔等待数据响应for _ in range(3): rank_block.scroll_into_view_if_needed() page.mouse.wheel(0, 500) page.wait_for_timeout(800)如果你是抓瀑布流信息可以把滚动循环次数设成一个比较大的值然后用“列表数量是否还在增长”作为结束条件而不是盲目滚固定次数。榜单这种有明确上限的数据块循环三次基本就能触发全部加载。5.3 页面弹窗打断操作怎么办飞猪这类旅行平台用户访问时经常可能遇到登录弹窗、活动弹窗、广告浮层。弹窗如果覆盖在榜单区域上方Playwright滚动或点击时会觉得被其他元素挡住了。我遇到登录弹窗时先尝试找关闭按钮close_btn page.locator(.login-modal .close-icon) if close_btn.count() 0: close_btn.click(timeout3000)如果找不到关闭按钮就换个思路调整viewport大小绕过弹窗出现的触发条件或者读取弹窗背后的DOM数据。但这里我要多说一句如果弹窗本质上是网站要求你登录才能继续查看数据那就不应该强行绕过这属于访问权限边界问题该停就停。5.4 headless模式被识别怎么办现在不少网站会用一些技术手段检测自动化浏览器。Playwright启动的Chromium在默认headless模式下确实有一些特征可以被检测出来比如navigator.webdriver属性为true。我在代码里设置了--disable-blink-featuresAutomationControlled这个参数又配置了常见的桌面浏览器User-Agent能有效降低被识别的概率。但我要强调这种做法只是为了模拟真实用户访问不是教你破解平台的风控算法。如果你的请求频率已经高到触发验证码正确做法是停下来反思访问节奏而不是找更隐蔽的模拟手段。验证码出现时我通常的选择是降低频率、冷却一段时间或者直接放弃采集该页面。合规比数据重要得多。5.5 页面改版后选择器大面积失效这是个迟早会发生的问题。前端代码每次发布都可能改掉类名、结构层级甚至整个技术栈。一次可行的方法是把选择器从代码里抽出来放到一个配置项里SELECTORS { rank_block: .hot-city-rank, city_item: .hot-city-rank .city-item, city_name: .city-name, hot_value: .hot-value, }改版时只改这个配置文件不用动业务逻辑。同时我会在代码里加一个数据结构校验比如抓回来20条数据里如果一半以上是空值就触发告警或低置信度提示这样不用每天盯着页面看也能提前发现问题。6. 爬虫之外合规与长期主义6.1 robots.txt能告诉我们什么robots.txt通常放在站点根目录比如https://example.com/robots.txt打开能看到类似“User-agent: *”加上“Disallow”的规则。它的本意是指引搜索引擎爬虫但对任何自动化程序都有参考价值。如果某个路径明确写了“Disallow: /private/”那就说明网站不欢迎自动化访问这个路径。公开登录页之外的数据我尽量看一眼规则再决定抓不抓。6.2 数据使用边界我给自己定的三条底线这里想认真聊几句。爬虫技术本身是中立的但怎么用确实分高下。我给自己定了三条底线只抓公开页面可见的数据不对非公开接口做猜测和爆破。抓下来的数据只用于个人学习、技术验证不做商业化转售不生成大规模公开数据集。控制抓取频率不拖垮目标网站服务不影响普通用户访问。这三条不是法律文件里的条文但踩过坑的人都懂遵守它们能避免绝大多数麻烦。特别是第三条很多爬虫事故都源于一次高并发请求把网站打崩这种事放到任何平台都很难被容忍。单线程、低频率、轻量级对一个小型实战项目来说完全够用。6.3 什么样的自动化行为最容易出问题根据我自己观察到的案例高风险行为集中在几类绕过登录墙抓取付费内容、抓取个人账户信息和隐私数据、高频抓取并对外提供实时数据服务、用批量注册账号等方式扩大采集范围。这些行为容易引发法律纠纷。做一个学习型爬虫还是把注意力放在技术本身的精进上页面结构分析、异步并发、数据清洗、可视化这些方向都够学很久。7. 后续还能怎么玩7.1 把榜单变成可视化地图抓下来的城市热度值本质是一份“城市-数值”关系数据。你可以用pyecharts或plotly画一个中国地图热力图直观看到哪个区域出行热度最高。这个扩展并不难只需要读CSV匹配城市坐标然后调绘图API。当我第一次把自己抓到的三线城市热度数据画成地图时对“数据采集到数据展示”的全链路会有种完整的成就感。7.2 定时任务监控榜单变化城市热度不是一成不变的节假日前后会有明显波动。可以写一个定时任务每天固定时间运行一次爬虫把历史结果累加到同一个CSV或SQLite里再用pandas做趋势对比看看哪些城市在特定节点上升最快。定时方案用系统cron表或Python的schedule库都能实现。7.3 把Playwright融进更大技术栈学会了Playwright基础用法后还能往几个方向延伸比如把Playwright作为Scrapy的下载中间件用来抓取那些Scrapy原生无法处理的JS页面或者结合MCP工具生态让大模型通过Playwright控制浏览器完成信息检索和操作这也是当前自动化圈子里很火的方向。但不管怎么扩展底层那套“选择器 等待 异常恢复”的思路都是通用的。写到这说点我的真实体会。这个项目表面上是抓榜单实际上是一套完整的动态网页数据采集方法论从选型、分析DOM、写等待策略、处理异常到控制频率和保持合规。我试过很多次被页面变化折磨也经历过数据抓一半被验证码打断最后发现保持耐心、降低频率、把代码写得可维护比任何花哨技巧都有用。如果你准备做类似实战建议从一个小目标开始先跑通一个榜单再逐步扩展别一上来就贪多路会走得更稳。
返回列表