历史代码分析。 本文分析 2022 年携程 Selenium V2 的异常恢复、请求匹配和数据关联,并提供离线响应解析示例。Selenium Wire 已归档,相关页面和私有接口不作为当前可用的数据采集入口。
一、V2 与初版的关系
2022 年 V2 源码将部分操作压缩到类方法,城市切换失败或元素异常时重新进入页面,再抓取响应。它仍使用页面触发、Selenium Wire 捕获 batchSearch、分别处理航段与报价、最后合并 CSV 的链路。


初版分析侧重元素定位和浏览器生命周期,本篇看恢复边界、请求身份与处理链。代码更短不证明更稳定。
二、恢复逻辑仍有边界问题
changecity() 出错后递归调用自己,超过计数再进入 getpage(),其他异常也重新进入页面方法。相互递归使调用栈、日期、错误计数和已完成任务难以一致恢复,并没有明确总时间上限。
getpage() 在失败时关闭窗口后继续依赖原 driver,也没有明确恢复所有状态。新建浏览器、继续查询、跳过任务和结束程序是不同决策,不能都靠递归入口实现。捕获异常却不传播状态,会让主循环不知道哪些任务失败。
城市输入中的 while city not in field.value 也可能永远循环。日期、自动补全值、可点击状态与验证码要分别等待和判断,不能无限输入或不断刷新。

旧元素 ID 不保证覆盖当前所有验证;需要交互时,应停止本轮并按页面允许流程处理。
三、捕获必须绑定具体查询
wait_for_request() 可能返回已经捕获的匹配请求,不能只看 URL 就认定是本次搜索。V2 检查了请求体城市,但没有充分核对日期、舱位和本轮时间;旧日期结果可能被写入新文件。
历史 Selenium Wire 的正确组织是触发前清理旧捕获,在受限时间内找完整匹配的请求,确认 response 存在,核对 HTTP 和业务状态。捕获说明
这个方法能用于自有测试页面,但上游已停止维护。固定依赖版本只说明一种安装组合,不能证明线上接口契约不变。
四、可以独立运行的离线解码
一律按 gzip 解压会在未压缩、deflate 或其他编码的响应上失败。以下只处理本地 bytes;编码来自对应响应的 Content-Encoding,捕获工具已经解码时不要重复解压。
import gzip
import json
import zlib
from decimal import Decimal
def decode_json_body(body, content_encoding='identity'):
encoding = content_encoding.strip().lower()
if encoding in ('', 'identity'):
decoded = body
elif encoding == 'gzip':
decoded = gzip.decompress(body)
elif encoding == 'deflate':
try:
decoded = zlib.decompress(body)
except zlib.error:
decoded = zlib.decompress(body, -zlib.MAX_WBITS)
else:
raise ValueError(f'本示例未实现此编码:{encoding}')
return json.loads(decoded.decode('utf-8-sig'))
def extract_historical_itineraries(payload):
# 2022 年样本结构,不是当前接口 schema。
data = payload['data']
if data['context']['flag'] != 0:
raise ValueError('业务错误,不当作零航班')
itineraries = data['flightItineraryList']
if not isinstance(itineraries, list):
raise TypeError('flightItineraryList 必须为列表')
return itineraries
def price_rows(itineraries):
rows = []
seen = set()
for item in itineraries:
itinerary_id = item['itineraryId']
if not itinerary_id or itinerary_id in seen:
raise ValueError('行程 ID 缺失或重复')
seen.add(itinerary_id)
for index, quote in enumerate(item['priceList']):
amount = Decimal(str(quote['adultPrice']))
if not amount.is_finite() or amount < 0:
raise ValueError('价格无效')
rows.append({'itineraryId': itinerary_id, 'quote_index': index,
'cabin': quote['cabin'], 'adultPrice': str(amount)})
return rows
if __name__ == '__main__':
fixture = {'data': {'context': {'flag': 0}, 'flightItineraryList': [
{'itineraryId': 'demo-flight-2022-08-13', 'priceList': [
{'adultPrice': 500, 'cabin': 'Y'},
{'adultPrice': 800, 'cabin': 'Y'},
{'adultPrice': 1200, 'cabin': 'C'}]}]}}
raw = json.dumps(fixture, ensure_ascii=False).encode('utf-8')
payload = decode_json_body(gzip.compress(raw), 'gzip')
print(price_rows(extract_historical_itineraries(payload)))
未知编码、无效 JSON、缺字段和业务错误明确失败;只有字段存在且是空列表才属于这个历史结构里的零结果。Brotli 和多层编码需要另做实现,不能不支持时悄悄按普通 UTF-8 读。Python gzip、JSON 文档
五、V2 的价格与航段处理
proc_flightSegments() 只读第一个 flightSegments 与第一个 flightList,筛选又按 transferCount 删除中转。这只保留了当时的部分直飞结果,不是完整航班库。多段行程应记录行程 ID、段序号、日期与机场。
proc_priceList() 按下划线截断行程 ID 成航班号,再以 flightNo 合并,可能连错日期和行程。上例保留完整 ID 与报价序号;关联航段还需确认真实数据的键与一对多关系。
价格和折扣应来自同一报价。独立选最大价格和最大折扣推算原价,或把最低价格与最小折扣放在同一行,可能制造不存在的组合。缺少明确原价时保留未知,价格低也不代表税费、退改、行李和供应商口径可比。
V2 分线程处理航段与报价,却没有统一传播子线程失败。主流程应收集异常与结果,校验键、数量和字段再写出;结构化解析未必需要线程,线程结束不表示采集成功。


六、后续验证应保存的样本
保留查询条件、获取时间、原始响应和解析版本,用离线样本覆盖旧日期响应、业务拒绝、空列表、多航段、多报价、缺字段和重复行程键。网络与解析失败分别计数,不让恢复逻辑抹掉失败记录。
历史 V2 源码用于追溯。离线示例只验证给定响应的解析,不提供当前携程页面查询、验证码处理或价格有效性的保证。
评论区