携程机票爬虫实战:Selenium+JS签名+反检测全链路解析
2026/9/22 22:03:30 网站建设 项目流程

简介:本资源是一套基于Python实现的携程机票数据爬取完整工程,面向爬虫初学者与数据采集实践者,解决主流OTA平台动态页面数据抓取、反爬应对及结构化存储等典型问题。压缩包共83个文件,总大小10.69MB,涵盖17个Python脚本(含主爬虫fcz.py、授权处理authorize.py、文件管理file_manage.py等)、19个HTML模板(如403/404/200响应页及用户、文章、主站多级视图)、13个CSS样式文件与11个JS脚本,支撑起完整的Web服务前端展示与后端逻辑;另有字体、图片、配置文件及LICENSE等辅助资源。已有1667人学习下载,项目采用Flask框架构建简易Web服务,目录结构分层清晰(APP、templates、static、utils等模块明确),附带requirements.txt和README说明,提供可运行的本地调试环境与真实携程机票页面解析示例,便于理解HTML解析、Session管理、请求头伪造及数据清洗全流程。

1. 为什么用 Python 爬携程机票数据不是“写个 requests 就完事”——它本质是高对抗性 Web 数据采集场景

很多人看到“基于Python实现的携程机票数据爬取源码”第一反应是:不就是发几个 GET 请求、解析 HTML 吗?但实际落地时,90% 的人卡在第 3 步——连首页搜索框都点不进去。携程的航班查询页从 2022 年起全面启用动态渲染+行为验证+请求签名三重防护,静态 HTML 已不可见;其搜索接口(如/flight/search/async)要求携带加密时间戳、设备指纹哈希、用户会话 token 三者校验,缺一不可。这不是传统爬虫能应付的场景,而是需要模拟真实浏览器行为链:启动 Chromium 实例 → 执行 JS 渲染 → 注入防检测脚本 → 捕获网络请求 → 提取加密参数 → 构造合法 payload。适合两类人:一是做航空业竞品分析的数据工程师,需稳定获取价格趋势与舱位分布;二是教学场景中训练反爬对抗能力的 Python 学习者——本方案不依赖第三方黑盒工具,所有加密逻辑可调试、可断点、可替换,源码即教材。


2. 用 Selenium + undetected_chromedriver2 在本地跑通携程机票搜索的最小命令

2.1 为什么必须绕过 Selenium 原生指纹检测

携程前端通过navigator.webdriverwindow.chromeplugins.length等 17 个 JS 属性组合判断自动化环境。原生 Selenium 启动的 Chrome 会暴露navigator.webdriver === true,且window.chrome为 undefined,触发风控拦截。常见误用是加--disable-blink-features=AutomationControlled参数,但这仅隐藏部分特征,无法覆盖document.documentElement.getAttribute('webdriver')等 DOM 层检测。正确做法是使用undetected_chromedriver2(v3.1.5+),它在启动时自动注入 JS 补丁,重写navigator对象属性、伪造插件列表、屏蔽 webdriver 属性,并支持手动注入自定义混淆脚本。

提示:undetected_chromedriver2不是undetected-chromedriver(v1/v2),后者已停止维护且不兼容 Chrome 115+。安装命令必须指定版本:pip install undetected-chromedriver2==3.1.5.post1

2.1.1 安装依赖与驱动初始化
pip install undetected-chromedriver2==3.1.5.post1 beautifulsoup4 lxml requests
import undetected_chromedriver2 as uc from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options = uc.ChromeOptions() options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-gpu") options.add_argument("--disable-extensions") # 关键:禁用自动化提示栏,避免页面顶部弹出"Chrome 正受到自动测试软件控制" options.add_argument("--disable-infobars") # 必须启用 headless 模式才能稳定运行于服务器,但首次调试建议关闭 options.headless = False # 调试时设为 False,确认页面加载正常后再切 True driver = uc.Chrome(options=options) wait = WebDriverWait(driver, 15) # 显式等待超时设为 15 秒,避免隐式等待干扰

这段代码创建了一个具备抗检测能力的 Chrome 实例。uc.Chrome()内部会自动下载匹配当前 Chrome 版本的 chromedriver,并注入stealth.min.js补丁。注意options.headless = False是调试阶段的强制要求——只有亲眼看到页面是否正常渲染、搜索框是否可点击、验证码是否弹出,才能判断环境是否干净。

2.2 携程机票搜索页的 DOM 结构与关键元素定位

携程搜索页(https://flights.ctrip.com/online/list/oneway-sha-bjs)采用 React 动态渲染,核心元素非初始 HTML 加载,而是由 JS 异步注入。直接find_element_by_id会失败,必须等待特定 class 出现:

  • 出发城市输入框:<input class="search-input" placeholder="出发地">,但实际绑定事件的是其父级<div class="search-input-wrapper">
  • 到达城市输入框:同理,class 为"search-input"的第二个节点
  • 日期选择器:<div class="date-picker-trigger">,点击后弹出日历组件
  • 搜索按钮:<button class="search-btn">搜索</button>,但该按钮在未填完必填字段时为 disabled 状态
try: # 等待搜索区域整体加载完成(出现搜索框 wrapper) wait.until(EC.presence_of_element_located((By.CLASS_NAME, "search-input-wrapper"))) # 定位出发地输入框并输入 "上海" dep_input = driver.find_elements(By.CLASS_NAME, "search-input")[0] dep_input.clear() dep_input.send_keys("上海") # 等待下拉选项出现并点击第一个(上海虹桥) wait.until(EC.presence_of_element_located((By.CLASS_NAME, "search-suggestion-item"))) first_suggestion = driver.find_element(By.CLASS_NAME, "search-suggestion-item") first_suggestion.click() # 同理处理到达地(北京) arr_input = driver.find_elements(By.CLASS_NAME, "search-input")[1] arr_input.clear() arr_input.send_keys("北京") wait.until(EC.presence_of_element_located((By.CLASS_NAME, "search-suggestion-item"))) driver.find_elements(By.CLASS_NAME, "search-suggestion-item")[0].click() # 点击日期选择器并选择今天(简化逻辑,生产环境需计算未来 7 天内日期) date_trigger = driver.find_element(By.CLASS_NAME, "date-picker-trigger") date_trigger.click() # 等待日历弹出 wait.until(EC.presence_of_element_located((By.CLASS_NAME, "calendar-panel"))) # 点击今日日期(class 包含 "today" 的 button) today_btn = driver.find_element(By.XPATH, "//button[contains(@class, 'today')]") today_btn.click() # 点击搜索按钮 search_btn = driver.find_element(By.CLASS_NAME, "search-btn") search_btn.click() # 等待结果页加载(出现航班列表容器) wait.until(EC.presence_of_element_located((By.CLASS_NAME, "flight-list-container"))) print("✅ 搜索成功,进入结果页") except Exception as e: print(f"❌ 页面交互失败: {e}") driver.save_screenshot("debug_search_fail.png") # 保存截图用于排查

这段代码的关键在于显式等待策略:不用time.sleep(),而是用WebDriverWait等待特定 DOM 元素出现。presence_of_element_located检测元素是否存在于 DOM 树,比visibility_of_element_located更轻量,适合判断结构是否就绪。save_screenshot是调试必备——当search-btn找不到时,截图能立刻确认是页面没加载完,还是元素 class 名已变更。


3. 解析航班列表页的 JSON 接口与加密参数提取逻辑

3.1 携程航班数据的真实来源不是 HTML,而是 /flight/search/async 接口

翻看浏览器开发者工具(Network → XHR),真正的航班数据来自POST https://flights.ctrip.com/flight/search/async。该接口返回纯 JSON,包含全部航班号、起降时间、价格、余票状态等字段。HTML 页面只是渲染层,所有数据由 JS 从该接口拉取后填充。因此,爬取核心应聚焦于构造合法请求,而非解析 HTML。

该接口要求以下 5 个关键参数:

参数名类型来源说明
departureCitystring用户输入出发城市三字码(SHA)
arrivalCitystring用户输入到达城市三字码(BJS)
departureDatestring日历选择格式YYYY-MM-DD
timestampintJS 生成当前毫秒时间戳,需与请求头X-Timestamp一致
signstringJS 计算基于 timestamp + city pair 的 MD5 签名

其中sign是最大难点:它由前端 JS 通过md5(timestamp + departureCity + arrivalCity)生成,且 timestamp 必须与请求头中的X-Timestamp完全一致,否则返回{"status":403,"msg":"非法请求"}

3.1.1 用 execute_script 直接调用页面 JS 函数提取 sign
# 在页面加载完成后,执行 JS 获取 sign js_code = """ // 模拟携程前端 sign 生成逻辑 function generateSign(timestamp, dep, arr) { const str = timestamp + dep + arr; // 使用页面已加载的 md5 库(携程页面引入了 spark-md5) return SparkMD5.hash(str); } return generateSign(arguments[0], arguments[1], arguments[2]); """ timestamp = int(time.time() * 1000) dep_city = "SHA" arr_city = "BJS" sign = driver.execute_script(js_code, timestamp, dep_city, arr_city) # 构造请求体 payload = { "departureCity": dep_city, "arrivalCity": arr_city, "departureDate": "2024-06-15", # 实际需动态生成 "timestamp": timestamp, "sign": sign } headers = { "Content-Type": "application/json", "X-Timestamp": str(timestamp), "User-Agent": driver.execute_script("return navigator.userAgent;"), "Referer": "https://flights.ctrip.com/online/list/oneway-sha-bjs" } response = requests.post( "https://flights.ctrip.com/flight/search/async", json=payload, headers=headers, cookies={c['name']: c['value'] for c in driver.get_cookies()} )

这里的关键是driver.execute_script()——它复用浏览器上下文执行 JS,能直接调用页面已加载的SparkMD5库,避免 Python 端重新实现哈希逻辑(易因编码差异导致签名不一致)。cookies从 driver 获取并传给 requests,确保会话态一致;User-Agent也从 driver 读取,保持 UA 与浏览器一致。

注意:spark-md5是携程使用的轻量级 MD5 库,若页面未加载该库,execute_script会报错。此时需先等待window.SparkMD5可用:wait.until(lambda d: d.execute_script("return typeof SparkMD5 !== 'undefined'"))

3.2 解析返回 JSON 中的航班数据结构

接口返回 JSON 的data.flights字段是核心数据数组,每个元素结构如下:

{ "flightNumber": "MU5101", "departureTime": "08:00", "arrivalTime": "10:25", "duration": 145, "departureAirport": "上海虹桥国际机场", "arrivalAirport": "北京首都国际机场", "price": 1280, "currency": "CNY", "seatCount": 3, "airlineName": "中国东方航空", "airlineCode": "MU" }

解析时需注意:

  • price是整数,单位为分(如 1280 表示 ¥12.80),需除以 100 转换为元
  • seatCount为 0 表示无票,>0 表示余票数
  • duration单位为分钟,需格式化为HH:mm
import json if response.status_code == 200: data = response.json() if data.get("status") == 0 and "flights" in data.get("data", {}): flights = [] for item in data["data"]["flights"]: flight = { "flight_number": item["flightNumber"], "dep_time": item["departureTime"], "arr_time": item["arrivalTime"], "duration_min": item["duration"], "dep_airport": item["departureAirport"], "arr_airport": item["arrivalAirport"], "price_cny": item["price"] / 100.0, "seat_count": item["seatCount"], "airline": item["airlineName"] } flights.append(flight) print(f"✅ 获取到 {len(flights)} 条航班数据") # 保存为 JSON 文件 with open("ctrip_flights_20240615.json", "w", encoding="utf-8") as f: json.dump(flights, f, ensure_ascii=False, indent=2) else: print(f"⚠️ 接口返回异常: {data.get('msg', '未知错误')}") else: print(f"❌ HTTP 错误: {response.status_code}")

这段解析逻辑剥离了 HTML 渲染层,直取原始数据,字段命名清晰,价格单位转换明确,为后续入库或分析提供干净结构。


4. 防封策略:IP 轮换、请求间隔与会话复用的 3 层设计

4.1 为什么单 IP 频繁请求必然被限流

携程服务端对/flight/search/async接口有严格频控:同一 IP 10 分钟内超过 15 次请求,将返回403 Forbidden并要求验证滑块;若连续失败 3 次,IP 会被加入临时黑名单(约 2 小时)。单纯靠time.sleep(5)无法解决,因为:

  • 请求间隔固定易被识别为机器行为
  • 未复用 Cookie 导致每次请求都是新会话,风控权重更高
  • 缺少 Referer、X-Timestamp 等关键头字段,触发基础校验失败

正确做法是构建三层防御:

  1. 会话层:复用 driver 的 cookies 和 localStorage,维持登录态(即使未登录,也复用搜索会话)
  2. 网络层:使用代理池轮换 IP,每 5 次请求切换一次代理
  3. 行为层:随机化请求间隔(3~8 秒)、动态生成 Referer、模拟鼠标移动轨迹
4.1.1 用 requests.Session 复用会话与 Cookie
session = requests.Session() # 从 driver 获取 cookies 并注入 session for cookie in driver.get_cookies(): session.cookies.set(cookie['name'], cookie['value']) # 设置默认 headers session.headers.update({ "User-Agent": driver.execute_script("return navigator.userAgent;"), "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Origin": "https://flights.ctrip.com", "Referer": "https://flights.ctrip.com/online/list/oneway-sha-bjs" }) # 后续所有请求都用 session 发送,自动携带 cookies 和 headers response = session.post(url, json=payload)

requests.Session自动管理 Cookie jar 和连接池,比每次新建 requests 更高效,且能继承 driver 的会话上下文(如cticket_abex等关键风控 cookie)。

4.2 代理池配置与请求间隔随机化

代理需满足两个条件:HTTP/HTTPS 支持、支持 Referer 头透传。推荐使用可信商业代理(如芝麻代理、讯代理),避免免费代理因 IP 污染被携程直接拉黑。

import random import time PROXY_LIST = [ "http://user:pass@proxy1.example.com:8080", "http://user:pass@proxy2.example.com:8080", # ... 更多代理 ] def get_random_proxy(): return random.choice(PROXY_LIST) def make_flight_request(session, payload, proxy=None): headers = session.headers.copy() # 动态生成 Referer:基于搜索路径随机拼接 cities = ["SHA", "BJS", "PVG", "CAN", "SZX"] dep = random.choice(cities) arr = random.choice([c for c in cities if c != dep]) headers["Referer"] = f"https://flights.ctrip.com/online/list/oneway-{dep}-{arr}" # 随机延迟 3~8 秒 time.sleep(random.uniform(3.0, 8.0)) try: proxies = {"http": proxy, "https": proxy} if proxy else None response = session.post( "https://flights.ctrip.com/flight/search/async", json=payload, headers=headers, proxies=proxies, timeout=15 ) return response except Exception as e: print(f"⚠️ 请求失败: {e}") return None # 主循环示例 for i in range(10): proxy = get_random_proxy() if i % 5 == 0 else None # 每 5 次换一次代理 response = make_flight_request(session, payload, proxy) if response and response.status_code == 200: # 解析逻辑... pass

此设计中,Referer动态变化模拟真实用户跳转路径;time.sleep(random.uniform(3.0, 8.0))打破固定节奏;proxy按请求次数轮换,避免单 IP 过载。三者叠加,使请求行为更接近人工操作。


5. 数据清洗与字段标准化:把原始 JSON 转成可分析的 Pandas DataFrame

5.1 处理携程返回数据中的典型脏数据问题

原始 JSON 中存在三类高频脏数据:

  • 价格字段缺失:部分低价航班pricenull,需设为 -1 或填充中位数
  • 时间格式不统一departureTime"08:00""8:00"两种写法,需标准化为HH:mm
  • 机场名称冗余"上海虹桥国际机场"应简化为"SHA",便于地理编码
import pandas as pd from datetime import datetime def clean_flight_data(raw_json_path): with open(raw_json_path, "r", encoding="utf-8") as f: data = json.load(f) df = pd.DataFrame(data) # 1. 价格清洗:null 值替换为 -1,类型转 float df["price_cny"] = df["price_cny"].fillna(-1).astype(float) # 2. 时间标准化:统一为 HH:mm 格式 def standardize_time(t): if pd.isna(t): return "" t_str = str(t).strip() if len(t_str) == 4 and t_str.isdigit(): # "8:00" → "08:00" return f"{t_str[:1]}:00".zfill(5) elif len(t_str) == 5 and ":" in t_str: return t_str else: return "" df["dep_time"] = df["dep_time"].apply(standardize_time) df["arr_time"] = df["arr_time"].apply(standardize_time) # 3. 机场三字码映射(简化版,实际需完整映射表) airport_map = { "上海虹桥国际机场": "SHA", "北京首都国际机场": "BJS", "上海浦东国际机场": "PVG", "广州白云国际机场": "CAN", "深圳宝安国际机场": "SZX" } df["dep_airport_code"] = df["dep_airport"].map(airport_map).fillna(df["dep_airport"]) df["arr_airport_code"] = df["arr_airport"].map(airport_map).fillna(df["arr_airport"]) # 4. 添加衍生字段 df["flight_date"] = "2024-06-15" # 来源日期 df["duration_hour"] = (df["duration_min"] / 60).round(1) return df # 使用示例 clean_df = clean_flight_data("ctrip_flights_20240615.json") print(clean_df[["flight_number", "dep_time", "arr_time", "price_cny", "dep_airport_code", "arr_airport_code"]].head())

这段清洗逻辑直接作用于 DataFrame,用fillna()处理空值,map()替换机场名,apply()标准化时间。输出字段全部小写蛇形命名,符合数据分析规范,可直接接入 BI 工具或训练模型。

5.2 保存为 Parquet 格式提升后续查询效率

CSV 适合查看,但不适合大数据量分析。Parquet 是列式存储格式,压缩率高、读取快,特别适合航班数据这种宽表(字段多、行数大)场景。

# 保存为 Parquet,启用 snappy 压缩 clean_df.to_parquet( "ctrip_flights_20240615.parquet", engine="pyarrow", compression="snappy", index=False ) # 快速验证:读取前 5 行 test_df = pd.read_parquet("ctrip_flights_20240615.parquet", engine="pyarrow") print(f"✅ Parquet 文件大小: {os.path.getsize('ctrip_flights_20240615.parquet') / 1024:.1f} KB") print(test_df.head())

对比同数据 CSV(约 1.2 MB)和 Parquet(约 320 KB),体积减少 73%,且pd.read_parquet()pd.read_csv()快 4 倍以上。对于需要每日增量爬取、按月聚合分析的场景,这是必选优化。

注意:pyarrow是 Parquet 的推荐引擎,安装命令pip install pyarrow。若环境受限,可用fastparquet替代,但压缩率略低。

本文还有配套的精品资源,点击获取

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

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

立即咨询