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

资讯详情

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

携程机票爬虫实战:Selenium+JS签名+反检测全链路解析

携程机票爬虫实战:Selenium+JS签名+反检测全链路解析 简介本资源是一套基于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.webdriver、window.chrome、plugins.length等 17 个 JS 属性组合判断自动化环境。原生 Selenium 启动的 Chrome 会暴露navigator.webdriver true且window.chrome为 undefined触发风控拦截。常见误用是加--disable-blink-featuresAutomationControlled参数但这仅隐藏部分特征无法覆盖document.documentElement.getAttribute(webdriver)等 DOM 层检测。正确做法是使用undetected_chromedriver2v3.1.5它在启动时自动注入 JS 补丁重写navigator对象属性、伪造插件列表、屏蔽 webdriver 属性并支持手动注入自定义混淆脚本。提示undetected_chromedriver2不是undetected-chromedriverv1/v2后者已停止维护且不兼容 Chrome 115。安装命令必须指定版本pip install undetected-chromedriver23.1.5.post12.1.1 安装依赖与驱动初始化pip install undetected-chromedriver23.1.5.post1 beautifulsoup4 lxml requestsimport 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(optionsoptions) 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 classsearch-input placeholder出发地但实际绑定事件的是其父级div classsearch-input-wrapper到达城市输入框同理class 为search-input的第二个节点日期选择器div classdate-picker-trigger点击后弹出日历组件搜索按钮button classsearch-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用户输入出发城市三字码SHAarrivalCitystring用户输入到达城市三字码BJSdepartureDatestring日历选择格式YYYY-MM-DDtimestampintJS 生成当前毫秒时间戳需与请求头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, jsonpayload, headersheaders, 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:mmimport 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, encodingutf-8) as f: json.dump(flights, f, ensure_asciiFalse, indent2) 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 等关键头字段触发基础校验失败正确做法是构建三层防御会话层复用 driver 的 cookies 和 localStorage维持登录态即使未登录也复用搜索会话网络层使用代理池轮换 IP每 5 次请求切换一次代理行为层随机化请求间隔3~8 秒、动态生成 Referer、模拟鼠标移动轨迹4.1.1 用 requests.Session 复用会话与 Cookiesession 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;q0.9,en;q0.8, Origin: https://flights.ctrip.com, Referer: https://flights.ctrip.com/online/list/oneway-sha-bjs }) # 后续所有请求都用 session 发送自动携带 cookies 和 headers response session.post(url, jsonpayload)requests.Session自动管理 Cookie jar 和连接池比每次新建 requests 更高效且能继承 driver 的会话上下文如cticket、_abex等关键风控 cookie。4.2 代理池配置与请求间隔随机化代理需满足两个条件HTTP/HTTPS 支持、支持 Referer 头透传。推荐使用可信商业代理如芝麻代理、讯代理避免免费代理因 IP 污染被携程直接拉黑。import random import time PROXY_LIST [ http://user:passproxy1.example.com:8080, http://user:passproxy2.example.com:8080, # ... 更多代理 ] def get_random_proxy(): return random.choice(PROXY_LIST) def make_flight_request(session, payload, proxyNone): 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] fhttps://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, jsonpayload, headersheaders, proxiesproxies, timeout15 ) 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 DataFrame5.1 处理携程返回数据中的典型脏数据问题原始 JSON 中存在三类高频脏数据价格字段缺失部分低价航班price为null需设为 -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, encodingutf-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, enginepyarrow, compressionsnappy, indexFalse ) # 快速验证读取前 5 行 test_df pd.read_parquet(ctrip_flights_20240615.parquet, enginepyarrow) 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替代但压缩率略低。本文还有配套的精品资源点击获取
返回列表