每年换季,朋友圈总有人问我去哪玩合适。我作为和数据打交道的工程师,第一反应不是翻攻略,而是打开携程看它当季正在集中推广哪些自由行目的地。平台的流量倾斜就是一张投票表——被放到热门榜前列、标了"当季精选"的目的地,大概率是此刻综合热度最高的选择。所以我就把"用 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 + BeautifulSoup | Playwright |
|---|---|---|
| 动态渲染支持 | 不支持,需要手动模拟所有 XHR | 天然支持,浏览器完整执行 JS |
| JS 签名参数 | 需要逆向算法,维护成本高 | 浏览器自动生成,几乎无感 |
| 页面结构变化 | 选择器需要跟着改 | 监听 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 doesn't exist
安装过程中或者第一次运行时有概率碰到一个很迷惑的报错,说 chrome-headless-shell.exe doesn't exist。我自己的经历是,Playwright 新版本默认下载的是专门的 headless shell 版本,和旧版本下载的完整 Chromium 混在一起之后就容易出这种问题。解决方式比较直接:
pip install -U playwright playwright install --force chromium如果还是报错,就在 launch 时切换成系统已有的 Chrome:
browser = p.chromium.launch(channel="chrome", headless=False)只要本机装了 Chrome 浏览器,channel="chrome" 会直接复用系统浏览器,不再依赖内置的 headless shell。这个参数是很多新手不知道的救命稻草,建议记下来。
3.3 最小脚本:打开携程首页并验证页面标题
安装完成后,先写一个最小脚本验证整条链路能走通。新建 main.py,内容如下:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch( headless=False, args=["--disable-blink-features=AutomationControlled"] ) context = browser.new_context( user_agent="Mozilla/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}, locale="zh-CN", timezone_id="Asia/Shanghai", ) page = context.new_page() page.goto("https://vacations.ctrip.com/", timeout=60000) print(page.title()) browser.close()第一轮跑我强烈建议 headless=False,让浏览器窗口真实弹出来。原因有两个:一是首次打开时如果遇到协议弹窗、验证码,你能直接看到并用鼠标处理;二是 headless 模式的浏览器特征更明显,容易被识别成机器人,窗口模式更像真人操作。确认能打印出页面标题后,再决定是否改成 headless 跑批。
4. 核心链路:用 response 监听拦截拿到结构化 JSON,而不是啃 DOM
4.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/", timeout=60000) 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( headless=False, args=[ "--disable-blink-features=AutomationControlled", "--no-sandbox", ] )--disable-blink-features=AutomationControlled 是去掉 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/", timeout=60000) page.wait_for_timeout(1500) page.mouse.move(200, 180) page.mouse.move(420, 260, steps=6) 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(state="hidden", timeout=120000)把窗口模式保留,由真人去点选或拖动滑块,验证通过后页面元素消失,脚本再继续执行。如果频繁出现验证码,说明采集频率已经超线,正确做法是停手,而不是用任何手段批量突破。
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"], keep="first") df["city"] = df["city"].str.replace("省", "", regex=False).str.replace("市", "", regex=False) df["score"] = pd.to_numeric(df["score"], errors="coerce") df["price"] = pd.to_numeric(df["price"], errors="coerce") df["comments"] = pd.to_numeric(df["comments"], errors="coerce") df = df[df["destination_id"] != ""] df = df.sort_values("rank", na_position="last").reset_index(drop=True)去重按 destination_id,这是最稳定的唯一键。replace("省") 和 replace("市") 是为了把行政后缀去掉,让"三亚"直接可读。to_numeric 配合 errors="coerce" 可以把缺省值安全转成 NaN,不会中断流程。NaN 后续分析里可以直接丢弃或填充,比字符串版本的"null"好用太多。
6.3 保存为 utf-8-sig 的 CSV 并支持增量合并
保存时最容易踩的坑是中文乱码。普通 utf-8 编码用文本编辑器打开没问题,但 Excel 默认解析会乱。解决办法是编码用 utf-8-sig:
df.to_csv("hot_destinations.csv", index=False, encoding="utf-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_index=True) df = df.drop_duplicates(subset=["destination_id"], keep="last") df.to_csv("hot_destinations.csv", index=False, encoding="utf-8-sig")这样跑一周之后,你手里就有了一份带历史版本的目的地热度快照,可以对比某个目的地排名有没有上升。这个价值比单纯抓一次大得多。额外的,如果后续想做可视化,建议再导一份 JSON 备用,df.to_dict("records") 可以直接转成数组然后落盘,很少被教程提到,但实际做数据展示时非常方便。
7. 合规与风控:爬虫能跑起来,也要跑得有边界
7.1 robots 与平台条款:先确认允许什么
技术上能抓,不代表应该抓。起步之前先去目标网站的 robots.txt 看一圈,了解哪些路径允许搜索引擎和普通用户访问。公开页面的聚合信息可以作为个人学习素材,但平台明确声明禁止采集的数据就不要碰。登录后页面、付费内容、需要验证码才能看的资源,都属于明确的人机隔离区,不应该出现在爬虫目标里。
我的原则很简单:只做个人学习用途,只抓公开榜单页,不碰任何需要登录或破解验证机制的数据。采集目的写清楚,比跑通了代码再心虚强得多。
7.2 频率控制的黄金法则
反爬系统最敏感的是请求频率。我在实际项目中总结了几条可执行规则:
- 先找批量接口,一次返回几十上百条数据的接口是最优解,不要一页页翻
- 单线程串行请求,request 之间的间隔不要低于 3 秒
- 一次完整采集控制在百次请求以内,跑完就收工
- 遇到 403 或连续超时,直接停脚本,不要自动重试成百上千次
- 不要同时开多个窗口或并发协程去抓同一平台
按这个节奏跑,目标是让采集行为在平台眼里约等于一个普通用户的正常浏览,而不是给服务器添堵。很多人把爬虫翻车归咎于魔法不够强,其实九成是频率控制没做好,把自己玩成了流量攻击。
7.3 抓到的数据可以做什么、不可以做什么
抓回来的热门目的地榜单,适合自己出行前做参考,也适合在个人博客里做一次脱敏后的热度分析。但有几个明确的"不可以":
- 不可以转售或打包成商业数据产品
- 不可以公开传播原始接口返回数据,尤其是包含平台内部字段标识的部分
- 不可以把爬虫做成高频监控服务,长时间占用平台资源
- 不可以利用抓取数据冒充平台结论
这些边界其实和代码能力无关,完全取决于你把它用在什么地方。个人学习、适度分析和合规分享,是爬虫技术最体面的使用方式。
最后说句实在话。我最初做这套小工具,只是想给周末出行做个参考,跑通之后最大的收获不是那几十条榜单数据,而是把动态页面的数据链路完整摸了一遍。如果你也是个人使用,建议把频率控制在"一次跑完就够"的量级,别总想着全天候盯着榜单。数据拿到了,出去玩得开心,这才是爬虫服务生活而不是绑架生活的正确姿势。