
每年换季朋友圈总有人问我去哪玩合适。我作为和数据打交道的工程师第一反应不是翻攻略而是打开携程看它当季正在集中推广哪些自由行目的地。平台的流量倾斜就是一张投票表——被放到热门榜前列、标了当季精选的目的地大概率是此刻综合热度最高的选择。所以我就把用 Python 抓取携程当季热门自由行目的地这套流程完整跑了一遍从需求拆解到数据落地中间还解决了动态加载和反爬识别的问题整个过程全部记录在这篇实战笔记里。整套方案并没有用野路子而是系统性地用了 Playwright 这个浏览器自动化工具来做动态页面的数据拦截。相比传统的 requests 正则解析它省去了大量逆向成本。我会把每个关键决策背后的原因也讲清楚方便你以后换个站点也能举一反三。1. 需求拆解先搞清楚当季热门自由行目的地到底藏在哪1.1 分清入口自由行Tab和跟团游Tab是两回事开写代码之前一定先确认一件事你看到的是自由行入口不是跟团游。携程度假频道里有两个完全不同的产品线自由行强调机票酒店/酒店的打包组合热门程度受自由行订单量、搜索量、点评数综合影响跟团游的热门榜单则是地接团销量排序。我们要的是前者。如果打开页面发现满屏导游带队、景点大巴那就是走错 Tab 了。在页面上自由行通常以 Tab 形式存在和跟团游私家团并列。切到自由行 Tab 之后页面上会有一块热门目的地或者当季精选的推荐位。这一步最好人工确认一次因为不同入口对应的接口路径完全不同抓错入口会导致后续所有数据都偏离主题。1.2 用开发者工具定位真正的数据接口不要一上来就写代码。先把页面打开按 F12 切到开发者工具点开 Network 面板把过滤条件选成 Fetch/XHR。然后刷新页面或者手动切换 Tab你会看到一批请求陆续进来名称通常包含 destination、rank、hot、popular 这些英文关键词。逐个点击请求看右侧的 Preview 标签。如果返回的是 JSON并且里面能看到 itemList、rankingList、destinationId 这类字段那这就是你要的数据接口。把它的 URL 看一眼就行但后面写 response 拦截时不建议写死完整 URL——接口地址大概率会随着运营活动变化写死反而容易翻车更靠谱的做法是判断 URL 里有没有稳定的关键词。1.3 确定字段清单缺了 destinationId 后面会很痛苦动手编码前我强烈建议先列出目标字段。以热门目的地榜单为例至少要拿到destinationId目的地的唯一标识去重和增量抓取都靠它countryName、cityName国家、城市自由行会涉及境外目的地rank榜单排名代表平台热度score综合评分recommendReason推荐理由通常是运营语料priceLow起步价自由行套餐经常有区间commentCount点评数衡量人气的一个维度这些字段有些接口有有些接口要拼两个请求才有。先确认好缺失项后面落库清洗时才不会手忙脚乱。我见过很多新手上来就把整个原始 JSON 塞进存储里最后查数据时发现字段名五花八门同一目的地出现七八个版本。字段清单最好在写采集代码之前就定死后面每一步都在朝这个目标收敛。1.4 最容易踩的入口误区有朋友问过我为什么抓回来全是三亚成都这些热门城市但看不出和自由行的关联。问题就出在入口选的是全品类总榜而不是自由行 Tab。总榜代表的是所有产品的综合热度没有品类维度你拿到手根本分不清哪些是自由行。判断方法也不难数据结构里如果有 travelType 或 productType 字段并且值对应 free_travel 或类似标识那才是自由行否则只能靠页面上自由行Tab 对应的接口去区分。这个验证步骤看起来不起眼却能省掉后期大量返工。走查一遍入口结构比你多写一百行解析代码都值。2. requests 裸奔方案为什么总在携程面前翻车2.1 你看到的HTML只是一张没有内容的空壳很多 Python 爬虫入门教程都会教你 requests BeautifulSoup 的组合这套组合对静态博客、新闻列表确实好用但拿到携程这种商业平台就不灵了。你 requests.get 首页打印响应的 text会发现大量空 div 和 script 标签目标数据毛都看不到。原因很简单页面数据不是服务器在首次 HTML 响应里直接给出的而是浏览器执行完 JS 之后再发起若干 XHR 请求去填充的。requests 没有 JS 引擎自然只拿到一张空壳。这个现象也解释了为什么某些教程里用正则硬抠 HTML 时会抠出一堆 undefined 或 null——因为那些变量在初始 HTML 里本来就不存在是留给 JS 运行时赋值的。你拿一个没有执行能力的工具去套它等于让士兵徒手拆坦克。2.2 接口链路的三道关卡Header、Cookie、签名参数就算你通过开发者工具拿到了那个真正返回热门榜单的接口 URL直接甩给 requests 也大概率会被挡下来。商业平台的反爬一般会在接口链路上布置三道关卡第一道是 Header 校验。Origin、Referer、Accept、Accept-Language 哪一个不对都可能直接 403。浏览器会自带这些头但 requests 默认只带基本的 User-Agent你需要在 headers 里手工补齐而且一次补齐不算完平台更新策略你就得跟着维护。第二道是 Cookie。很多接口要求先访问首页由服务器种下会话 Cookie然后带着这个 Cookie 再请求接口。甚至某些页面还需要执行一小段 JS 来刷新 Cookie 的有效期如果不先触发这个动作接口直接返回 401 或者空数组。第三道是签名参数。接口 URL 或请求体里经常被拼上一串由 JS 运行时计算出来的签名参数比如把时间戳、固定 key、页面路径做某种摘要运算。这类参数通常还有时效性你手工抓包复制过来过几分钟就失效。requests 要解决它就必须把签名算法逆向出来并复现投入产出比极低。2.3 requests 与 Playwright 的选型对比我自己做了一个选型对比贴在下面给大家参考对比维度requests BeautifulSoupPlaywright动态渲染支持不支持需要手动模拟所有 XHR天然支持浏览器完整执行 JSJS 签名参数需要逆向算法维护成本高浏览器自动生成几乎无感页面结构变化选择器需要跟着改监听 response对页面改动敏感度低验证码处理基本无能为力可暂停脚本人工介入开发与维护成本初期低后期高初期稍高后期更稳典型适用页面静态站点、简单接口动态单页、强反爬的硬骨头选中 Playwright 并不是说 requests 一无是处。如果目标页面的数据就藏在静态 HTML 里requests 仍然是最快、最省资源的方案。但是抓当季热门自由行目的地这类榜单几乎必然要面对动态加载和反爬逻辑与其费劲逆向不如直接把一个真实浏览器搬到程序里。工具没有高下之分只有匹配不匹配。2.4 requests 也不是完全没用我不建议把 requests 全盘否定。如果你抓取目标是开放 API 或纯静态页面用 requests 只要几十行就能搞定稳定性反而比 Playwright 更高资源占用也忽略不计。我在整个流程里也会用 requests 去拉取静态 JS 文件、下载榜单封面缩略图这些场景完全没必要让浏览器出马。所以更准确的说法是按页面性质选工具。页面里带着动态 token 和复杂交互就用 Playwright简单静态资源就用 requests两者搭配使用才是工程效率最高的做法。3. 环境准备与首次启动5分钟跑通 Playwright 最小脚本3.1 Python 环境与 Playwright 安装先确认本机 Python 版本在 3.8 以上。在 Windows 上打开命令行输入 python --version在 macOS/Linux 上输入 python3 --version。老项目如果还在用 2.7 就不要挣扎了直接新建一个新环境。然后安装依赖pip install playwright playwright install chromium第二行会下载浏览器内核几百兆耐心等。如果你在公司内网或者网络环境特殊下载不到可以考虑用国内镜像源先把包装上再单独处理浏览器内核的下载问题。这属于环境问题和代码本身没关系卡住了先把网络环境搞定不要急着怀疑代码。这里我特别建议在你的项目目录下新建一个虚拟环境不要直接 pip install 到系统 Python。很多同学追新包把系统环境搞乱最后连 import playwright 都报错。用 venv 或 virtualenv 隔离能省掉一堆麻烦。3.2 常见报错chrome-headless-shell.exe doesnt exist安装过程中或者第一次运行时有概率碰到一个很迷惑的报错说 chrome-headless-shell.exe doesnt exist。我自己的经历是Playwright 新版本默认下载的是专门的 headless shell 版本和旧版本下载的完整 Chromium 混在一起之后就容易出这种问题。解决方式比较直接pip install -U playwright playwright install --force chromium如果还是报错就在 launch 时切换成系统已有的 Chromebrowser p.chromium.launch(channelchrome, headlessFalse)只要本机装了 Chrome 浏览器channelchrome 会直接复用系统浏览器不再依赖内置的 headless shell。这个参数是很多新手不知道的救命稻草建议记下来。3.3 最小脚本打开携程首页并验证页面标题安装完成后先写一个最小脚本验证整条链路能走通。新建 main.py内容如下from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch( headlessFalse, args[--disable-blink-featuresAutomationControlled] ) context browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36, viewport{width: 1366, height: 768}, localezh-CN, timezone_idAsia/Shanghai, ) page context.new_page() page.goto(https://vacations.ctrip.com/, timeout60000) print(page.title()) browser.close()第一轮跑我强烈建议 headlessFalse让浏览器窗口真实弹出来。原因有两个一是首次打开时如果遇到协议弹窗、验证码你能直接看到并用鼠标处理二是 headless 模式的浏览器特征更明显容易被识别成机器人窗口模式更像真人操作。确认能打印出页面标题后再决定是否改成 headless 跑批。4. 核心链路用 response 监听拦截拿到结构化 JSON而不是啃 DOM4.1 注册 response 监听把接口返回的 JSON 直接截获Playwright 里最优雅的数据获取方式不是解析 DOM而是监听网络响应。当页面里的 JS 自动请求热门榜单接口时response 事件会先触发Playwright 允许我们在这一刻读走 JSON 数据。注意 page.on(response) 的注册顺序必须在 page.goto 之前否则会漏掉页面加载初期的请求。captured [] def on_response(response): if destination not in response.url and rank not in response.url and hot not in response.url: return if json not in response.headers.get(content-type, ): return try: data response.json() except Exception: return if data: captured.append(data) page.on(response, on_response) page.goto(https://vacations.ctrip.com/, timeout60000) page.wait_for_timeout(8000)这里 filter 用的三个关键词可以根据实际抓包结果调整原则是宁可多拦几个请求也不要把目标接口漏掉。拿到 response 之后先别急着解析直接 print 一份 JSON 到控制台核对字段是否齐全。4.2 滚动与翻页触底加载的新请求也要接住热门榜单常见两种加载方式一种是点击下一页另一种是滚动到底部自动加载。Playwright 模拟滚动很简单for _ in range(8): page.mouse.wheel(0, 1000) page.wait_for_timeout(1500)每滚一段距离就等 1.5 秒给 AJAX 请求留出返回时间。滚动 8 次之后如果还没有新数据可以判断为到底了。这个循环放在监听回调之后滚动的过程中如果触发了新的热门榜单请求on_response 会继续把数据收集进 captured 列表不需要额外代码。如果目标榜单用的是分页 Tab那就需要循环点击下一页按钮每次点击后 wait_for_timeout 等接口响应再用同样的回调累积数据。这两者的共同点是单一入口 URL 只对应第一屏数据真正完整的数据是你主动制造的用户操作行为换回来的。4.3 字段映射把英文接口字段整理成中文目标字段接口返回的 JSON 结构基本都是英文驼峰命名直接拿出去很难用。我会先快速建一个字段映射的字典然后按目标列整理import pandas as pd rows [] for data in captured: item_list data.get(data, {}).get(itemList, []) if not item_list: item_list data.get(result, {}).get(items, []) for node in item_list: rows.append({ destination_id: str(node.get(destinationId) or ), country: node.get(countryName, ), city: node.get(cityName, ), rank: node.get(rank, 0), score: node.get(score, 0), reason: node.get(recommendReason, ), price: node.get(priceLow, node.get(minPrice, 0)), comments: node.get(commentCount, 0), }) df pd.DataFrame(rows)这里的字段路径用的是常见结构实际项目一定要以抓包 Preview 里看到的层级为准。字段名对不上时宁可先打印原始字典再逐级往下找也不要瞎猜。这个阶段最有价值的动作是 print 和人工核对代码反而是次要的。4.4 兜底方案接口密文时用 XPath 与 text() 提取 DOM偶尔会遇到接口返回的是加密密文或者字段结构混乱JSON 解析拿不到人话。这时候别急着逆向回到 DOM 层试试。无论接口怎么加密最终用户可见的文字一定出现在渲染后的页面 DOM 里。用 XPath 做文本定位非常高效特别是 text() 函数items page.locator(xpath//div[contains(class,dest-item)]//h3).all_text_contents() label page.locator(xpath//span[contains(text(),热门)]).first.text_content()text() 是 XPath 1.0 的字符串函数配合 contains() 可以完成模糊匹配适合提取热门推荐这类运营标签。DOM 层的缺点是拿不到 score、priceLow 这类藏在属性里的结构化数值所以它只是兜底不应成为首选。但遇到难啃的站点时兜底方案能保证你不至于颗粒无收。5. 破局方案让 Playwright 浏览器看起来像一个真实用户5.1 默认 Playwright 为什么容易被识别很多平台的反爬系统在页面里嵌入了一段 JS执行时会检查 navigator.webdriver 属性、window.chrome 对象、plugins 列表、User-Agent 等特征。默认的 Playwright 启动参数会暴露自动化身份一旦被识别轻则返回空数据重则直接进入风控验证流程。我习惯在 launch 阶段就加上禁用自动化特征标记的参数browser p.chromium.launch( headlessFalse, args[ --disable-blink-featuresAutomationControlled, --no-sandbox, ] )--disable-blink-featuresAutomationControlled 是去掉 webdriver 相关标识最直接的一步。注意这不能保证 100% 绕过检测但能明显降低被 WAF 主动标记的频率。把它理解成降低噪声的手段而不是万能钥匙。5.2 浏览器上下文伪装UA、viewport、locale、timezone除了启动参数new_context 里的伪装更细致。真实用户的浏览器一定带着合适的语言、时区、屏幕尺寸和操作系统信息。我在最小脚本里已经演示过基础写法这里再补充几个容易漏的点locale 设为 zh-CN否则页面可能返回英文版或繁体版接口字段都可能跟着变timezone_id 设为 Asia/Shanghai避免时区偏移被检测color_scheme 可以根据设备设置不要什么都不管如果目标页面检测 WebGL 渲染信息可以后续再处理个人使用场景一般不至于走到这一步这些参数本质上是在告诉你所谓反反爬并不是什么黑魔法它的目标是让程序行为无限贴近真人。你模拟得越完整触发验证的次数就越少。与其四处找必过脚本不如老老实实把这些基础伪装写对。5.3 先做人机预演再进入目标页面程序一启动就直奔目标页这个行为模式本身就很可疑。真实用户通常是先打开首页停顿一会移动鼠标再点击进入频道页。我自己的脚本里会加一段预演逻辑page.goto(https://vacations.ctrip.com/, timeout60000) page.wait_for_timeout(1500) page.mouse.move(200, 180) page.mouse.move(420, 260, steps6) page.wait_for_timeout(1200) page.mouse.click(360, 240) page.wait_for_timeout(3000)这里的坐标并不是随机数最好先用窗口模式跑一次记录你真实浏览时鼠标自然经过的路径。手动模拟的鼠标轨迹虽然只有简单几步但对于拦截是否真人的检测点已经足够。5.4 验证码弹窗的处理原则先说明白验证码的设计初衷就是阻止自动化程序绕过它属于对抗行为。我的标准做法是降低触发概率而不是研究绕过。在脚本里遇到验证码时我会这样处理verify page.locator(xpath//div[contains(class,verify)]) if verify.count() 0: print(检测到验证码请手动完成验证脚本会等待 120 秒) verify.first.wait_for(statehidden, timeout120000)把窗口模式保留由真人去点选或拖动滑块验证通过后页面元素消失脚本再继续执行。如果频繁出现验证码说明采集频率已经超线正确做法是停手而不是用任何手段批量突破。5.5 遇到 JS 虚拟机反爬时的降级思路有些站点会在初始化 HTML 里注入一套 JS 虚拟机逻辑接口 URL 是运行时拼接的响应体也经过压缩或二次编码直接站在接口层根本无从下手。碰到这种情况我不建议投入时间去逆向它的加密算法——你今天逆向完了平台明天改个版本又白干。降级路线很明确回到 DOM。渲染后的页面一定有用户可见的文字和链接用 Playwright 把整个榜单区域的 DOM 快照抓下来再配合 XPath 提取名称、排名、推荐理由虽然缺少部分结构化数值但至少能拿到可读信息。另外也可以关注页面预埋的初始化数据很多前端框架会把首屏数据放在 window.INITIAL_STATE之类的变量里通过 page.evaluate 取出来经常比接口层更干净。6. 数据清洗与落地让榜单数据能直接拿去用6.1 原始数据常见的脏数据形态拦截接口拿到的数据不是能直接用的干净表格我见过的问题有三类一是同一目的地出现在多次滚动加载里被重复收集二是 cityName 可能是海南省三亚市这种完整行政名携带省市后缀三是 score、price、commentCount 在 JSON 里有的是字符串有的是数字还有的是 null。所以清洗不是锦上添花而是必须做的一步。不清理直接分析得到的榜单排名会因为重复项失真价格字段也做不了排序和区间统计。6.2 清洗、去重与标准化用 pandas 大概十几行就能搞定df pd.DataFrame(rows) df df.drop_duplicates(subset[destination_id], keepfirst) df[city] df[city].str.replace(省, , regexFalse).str.replace(市, , regexFalse) df[score] pd.to_numeric(df[score], errorscoerce) df[price] pd.to_numeric(df[price], errorscoerce) df[comments] pd.to_numeric(df[comments], errorscoerce) df df[df[destination_id] ! ] df df.sort_values(rank, na_positionlast).reset_index(dropTrue)去重按 destination_id这是最稳定的唯一键。replace(省) 和 replace(市) 是为了把行政后缀去掉让三亚直接可读。to_numeric 配合 errorscoerce 可以把缺省值安全转成 NaN不会中断流程。NaN 后续分析里可以直接丢弃或填充比字符串版本的null好用太多。6.3 保存为 utf-8-sig 的 CSV 并支持增量合并保存时最容易踩的坑是中文乱码。普通 utf-8 编码用文本编辑器打开没问题但 Excel 默认解析会乱。解决办法是编码用 utf-8-sigdf.to_csv(hot_destinations.csv, indexFalse, encodingutf-8-sig)如果你打算每天跑一次观察榜单变化就需要增量合并。思路是先把旧文件读进来再用 destination_id 去重保留新数据import os if os.path.exists(hot_destinations.csv): old_df pd.read_csv(hot_destinations.csv, dtype{destination_id: str}) df pd.concat([old_df, df], ignore_indexTrue) df df.drop_duplicates(subset[destination_id], keeplast) df.to_csv(hot_destinations.csv, indexFalse, encodingutf-8-sig)这样跑一周之后你手里就有了一份带历史版本的目的地热度快照可以对比某个目的地排名有没有上升。这个价值比单纯抓一次大得多。额外的如果后续想做可视化建议再导一份 JSON 备用df.to_dict(records) 可以直接转成数组然后落盘很少被教程提到但实际做数据展示时非常方便。7. 合规与风控爬虫能跑起来也要跑得有边界7.1 robots 与平台条款先确认允许什么技术上能抓不代表应该抓。起步之前先去目标网站的 robots.txt 看一圈了解哪些路径允许搜索引擎和普通用户访问。公开页面的聚合信息可以作为个人学习素材但平台明确声明禁止采集的数据就不要碰。登录后页面、付费内容、需要验证码才能看的资源都属于明确的人机隔离区不应该出现在爬虫目标里。我的原则很简单只做个人学习用途只抓公开榜单页不碰任何需要登录或破解验证机制的数据。采集目的写清楚比跑通了代码再心虚强得多。7.2 频率控制的黄金法则反爬系统最敏感的是请求频率。我在实际项目中总结了几条可执行规则先找批量接口一次返回几十上百条数据的接口是最优解不要一页页翻单线程串行请求request 之间的间隔不要低于 3 秒一次完整采集控制在百次请求以内跑完就收工遇到 403 或连续超时直接停脚本不要自动重试成百上千次不要同时开多个窗口或并发协程去抓同一平台按这个节奏跑目标是让采集行为在平台眼里约等于一个普通用户的正常浏览而不是给服务器添堵。很多人把爬虫翻车归咎于魔法不够强其实九成是频率控制没做好把自己玩成了流量攻击。7.3 抓到的数据可以做什么、不可以做什么抓回来的热门目的地榜单适合自己出行前做参考也适合在个人博客里做一次脱敏后的热度分析。但有几个明确的不可以不可以转售或打包成商业数据产品不可以公开传播原始接口返回数据尤其是包含平台内部字段标识的部分不可以把爬虫做成高频监控服务长时间占用平台资源不可以利用抓取数据冒充平台结论这些边界其实和代码能力无关完全取决于你把它用在什么地方。个人学习、适度分析和合规分享是爬虫技术最体面的使用方式。最后说句实在话。我最初做这套小工具只是想给周末出行做个参考跑通之后最大的收获不是那几十条榜单数据而是把动态页面的数据链路完整摸了一遍。如果你也是个人使用建议把频率控制在一次跑完就够的量级别总想着全天候盯着榜单。数据拿到了出去玩得开心这才是爬虫服务生活而不是绑架生活的正确姿势。