侧边栏壁纸
博主头像
いちごみるく

行动起来,活在当下

  • 累计撰写 70 篇文章
  • 累计创建 48 个标签
  • 累计收到 55 条评论

目 录CONTENT

文章目录

Selenium 携程机票采集 V2:历史恢复逻辑与响应解析

历史代码分析。 本文分析 2022 年携程 Selenium V2 的异常恢复、请求匹配和数据关联,并提供离线响应解析示例。Selenium Wire 已归档,相关页面和私有接口不作为当前可用的数据采集入口。

一、V2 与初版的关系

2022 年 V2 源码将部分操作压缩到类方法,城市切换失败或元素异常时重新进入页面,再抓取响应。它仍使用页面触发、Selenium Wire 捕获 batchSearch、分别处理航段与报价、最后合并 CSV 的链路。

2022 年城市输入界面

当时的请求观察

初版分析侧重元素定位和浏览器生命周期,本篇看恢复边界、请求身份与处理链。代码更短不证明更稳定。

二、恢复逻辑仍有边界问题

changecity() 出错后递归调用自己,超过计数再进入 getpage(),其他异常也重新进入页面方法。相互递归使调用栈、日期、错误计数和已完成任务难以一致恢复,并没有明确总时间上限。

getpage() 在失败时关闭窗口后继续依赖原 driver,也没有明确恢复所有状态。新建浏览器、继续查询、跳过任务和结束程序是不同决策,不能都靠递归入口实现。捕获异常却不传播状态,会让主循环不知道哪些任务失败。

城市输入中的 while city not in field.value 也可能永远循环。日期、自动补全值、可点击状态与验证码要分别等待和判断,不能无限输入或不断刷新。

V2 当时的验证界面

旧元素 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 源码用于追溯。离线示例只验证给定响应的解析,不提供当前携程页面查询、验证码处理或价格有效性的保证。

11

评论区