Skill: qizha/flyclaw-bak (version 1.0.0)

--- name: flyclaw (Flight N-in-1 Search Zero Login) description: Multi-source flight aggregation — tickets, nonstop, round-trip, cabin. 航班机票/零登录/零API, zero login, zero account, zero API key. Pure Python, no browser. 机票价格/航班动态/直飞筛选. version: 0.4.1 icon: ✈️ author: [REDACTED_SECRET] license: Apache-2.0 acceptLicenseTerms: "Apache-2.0" --- # FlyClaw ✈️ - 航班信息聚合查询工具 / Flight Information Aggregation Tool ## 概述 / Overview **五源航班聚合查询 — 零登录、零账号、零 API Key，轻量 Python 实现。** **5-source flight aggregation — zero login, zero account, zero API key. Lightweight Python, no browser automation.** 基于多源聚合架构，通过开源库及免费公开 API 获取航班动态、价格、时刻、实时位置等信息。支持中英文城市名和 IATA 代码输入。 Multi-source aggregation via open-source libraries and free public APIs for flight dynamics, prices, schedules, and real-time positions. Supports Chinese/English city names and IATA codes. **GitHub**：[https://github.com/AI4MSE/FlyClaw](https://github.com/AI4MSE/FlyClaw) ## 零 API Key 依赖 / Zero API Key 无需注册任何账号或提供任何 API Key 即可使用全部核心功能。用户本地化掌控所有，程序不收集、不存储任何用户个人信息。同时规避浏览器模拟等复杂、不可靠和低效问题。 No registration or API key required. All data stays local — no personal data collected or stored. No browser automation overhead. ## 触发方式 / Trigger 用户说"查航班 CA981"、"上海飞纽约多少钱"、"PVG 到 JFK 明天的航班"、"往返机票 上海到新加坡"、"商务舱 北京到伦敦"、"所有航班包括转机"、"直飞" 等即可自动执行。默认查询行为是直飞+经济舱。 Trigger when user says "query flight CA981", "flights from Shanghai to New York", "round-trip PVG to SIN", "business class Beijing to London", "nonstop flights", "all flights including connecting", etc. Default behavior is nonstop + economy. **智能转换规则 / Smart Conversion Rules**： - 用户说"所有航班"/"包括转机"/"包括非直飞" → `--stops any` - 用户说"直飞"/"不要转机" → `--stops 0`（默认） - 用户说"最多一次中转" → `--stops 1` - 用户说"最多两次中转" → `--stops 2` ## 数据来源 / Data Sources - **飞猪 (Fliggy)**：国内外航班价格（中国国内覆盖最全，人民币计价） - **Google Flights**：国内外国际航班价格、时刻 - **Skiplagged**：国内外国际航班价格、时刻 - **FlightRadar24**：航班动态、实时状态、延误、机型 - **Airplanes.live / ADSB.lol**：ADS-B 实时位置 多源并发查询，智能合并互补。**插件式架构，支持无限扩展**——每个数据源为独立模块。特别感谢以上公开数据源为公益和大众需求提供的便利！ ## 功能 / Features 1. **按航班号查询**：查询指定航班的动态信息（状态、时刻、延误、机型等） 2. **按航线搜索**：查询两地之间的航班列表（含价格、经停、时长等） 3. **城市级搜索**：输入城市名自动搜索该城市所有机场（如"上海"→PVG+SHA） 4. **高级搜索**：往返搜索、多旅客配置、舱位选择、排序、直飞过滤 5. **中英文输入**：支持中文城市名、英文名、IATA 代码，7912 机场全覆盖 ## 重要：输出格式与多日查询 / Output Format & Multi-Day Queries **默认输出为 JSON**（stdout），直接 `json.loads()` 即可解析，示例： ```json [{"flight_number": "CA981", "price": 472.0, "origin_iata": "PVG", "destination_iata": "GVA", ...}] ``` 无结果时返回 `[]`。错误和日志仅输出到 stderr，不影响 JSON 解析。**价格默认为人民币（CNY）**，每条记录含 `currency` 字段标注货币。可用 `--currency usd` 统一转换为美元，或 `--currency cny`（默认）。汇率可在 `config.yaml` 中配置（默认 7.25）。可用 `-o table` 切换为人类可读表格。 **多日查询**：search 命令每次只查一天。查询一周最低价等场景，需拆成多个日期**并发执行**，分别获取 JSON 结果后自行合并比较。 ## 调用方式 / Usage ### 按航班号查询 ```bash python flyclaw.py query --flight CA981 ``` ### 按航线搜索 ```bash python flyclaw.py search --from 上海 --to 纽约 --date 2026-04-01 ``` ### 往返搜索 ```bash python flyclaw.py search --from PVG --to LAX --date 2026-04-15 --return 2026-04-25 ``` ### 商务舱 + 2 人 ```bash python flyclaw.py search --from PVG --to JFK --date 2026-04-15 --cabin business -a 2 ``` ### 直飞 + 按价格排序 ```bash python flyclaw.py search --from PVG --to SIN --date 2026-04-15 --stops 0 --sort cheapest ``` ### 包含经停航班 ```bash python flyclaw.py search --from PVG --to JFK --date 2026-04-15 --stops any ``` ### 按日期过滤查询结果 ```bash python flyclaw.py query --flight CA981 --date 2026-04-01 python flyclaw.py query --flight CA981 --date today ``` ### 关闭智能查价 默认启用智能查价，会自动在航班号查询时补充价格信息。关闭后可节约查询时间。 ```bash python flyclaw.py query --flight CA981 --no-relay ``` ### 搜索参数 | 参数 | 短标志 | 默认值 | 说明 | |------|--------|-------|------| | `--from` | — | （必填） | 出发地 | | `--to` | — | （必填） | 目的地 | | `--date` / `-d` | — | — | 出行日期 YYYY-MM-DD | | `--return` / `-r` | — | — | 返程日期（启用往返搜索） | | `--adults` / `-a` | — | 1 | 成人旅客数 | | `--children` | — | 0 | 儿童旅客数 | | `--infants` | — | 0 | 婴儿旅客数 | | `--cabin` / `-C` | — | economy | economy/premium/business/first | | `--limit` / `-l` | — | 不限制 | 最大结果数（不指定则返回全部） | | `--sort` / `-s` | — | — | cheapest/fastest/departure/arrival | | `--stops` | — | 0 | 经停：0=直飞/1/2/any=不限 | ### 通用参数 - `-o table`：表格格式输出（默认为 JSON） - `-v`：详细模式，显示数据来源和舱位 ## 输入示例 / Input Examples | 用户说 | 解析为 | 说明 | |--------|--------|------| | "上海" | PVG + SHA | 城市级：搜索所有上海机场 | | "PVG" | PVG | 精确到浦东机场 | | "浦东" | PVG | 别名精确匹配 | | "纽约" | JFK + EWR + LGA | 城市级：搜索所有纽约机场 | | "北京" | PEK + PKX + NAY | 城市级：搜索所有北京机场 | | "Shanghai" | PVG + SHA | 英文城市名同样支持 | ## 安装配置 / Installation ```bash pip install requests pyyaml curl_cffi flights # 注意：不要安装 mcp、fast-flights、playwright 等调试模块，会增加安装时间且普通使用不需要 ``` **文件位置**：主程序 `flyclaw.py`，配置 `config.yaml`，机场缓存 `cache/airports.json`。 **依赖**：Python 3.11+、`requests`（Apache-2.0）、`pyyaml`（MIT）、`curl_cffi`（MIT）、`flights`（MIT）。 ## 安全性 / Security - **零 API Key 依赖**：程序运行不需要用户提供任何 API Key 或注册任何账号 - 程序不收集、不存储任何用户个人信息 - 所有网络请求仅用于查询公开航班数据 ## 免责声明 / Disclaimer - 本工具基于多源聚合架构，通过开源库及免费公开 API 获取数据 - 仅供学习研究用途，请遵守当地法律法规 - Google Flights 在部分地区可能不可用 - 价格数据来自多个数据源，不同来源的价格可能存在差异（含税/不含税、舱位差异等），仅供参考 --- ## 作者 / Author 公益技能，免费开源。 / Community-driven, open-source, free for everyone. - **Email**: [REDACTED_SECRET] - **小红书 / Xiaohongshu**: @深度连接 - **GitHub**: [AI4MSE](https://github.com/AI4MSE) **许可证 / License**：[Apache-2.0](LICENSE)

path: README.md
content: # FlyClaw - 航班信息聚合查询 CLI 工具

**FlyClaw** 是一个轻量级命令行工具，基于多源聚合架构，通过开源库及免费公开 API 获取数据，在终端中聚合查询航班信息（航班动态、价格、时刻等），原生支持中英文查询，原生OpenClaw技能，覆盖中国国内及国际航班。轻量化python实现，非浏览器模拟的复杂和低效方式。

**FlyClaw** is a lightweight command-line tool that aggregates flight information (dynamics, prices, schedules, etc.) using a multi-source aggregation architecture powered by open-source libraries and free public APIs, with native Chinese/English query support and native OpenClaw skill integration, covering both Chinese domestic and international flights. Lightweight Python implementation — no browser automation, no complexity, no overhead.

核心价值：单一来源不稳定、不完整、覆盖有限 -- FlyClaw 的目标是**聚合、去重、补齐、呈现**。

Core value: A single data source is unreliable, incomplete, and limited in coverage -- FlyClaw's value lies in **aggregation, deduplication, gap-filling, and presentation**.

[English Readme](README_EN.md)

**作者**：[REDACTED_SECRET] 小红书@深度连接
**GitHub**：[https://github.com/AI4MSE/FlyClaw](https://github.com/AI4MSE/FlyClaw)
**许可证**：[Apache-2.0](LICENSE)

## 功能特性

- **零 API Key 依赖**：无需注册账号，开箱即用，安全，同时规避浏览器模拟等复杂、不可靠和低速问题
- **多数据源聚合**：飞猪(Fliggy)、Google Flights、Skiplagged、FR24、Airplanes.live 五大数据源并发查询，通过开源库及免费公开 API 获取航班动态、价格、实时位置。**插件式架构，支持无限扩展**——每个数据源为独立模块（`sources/` 目录下一个文件）。特别感谢以上公开数据源为公益和大众需求提供的便利！
- **多源价格互补**：飞猪 + Google Flights + Skiplagged 三源并发查价，支持往返搜索、多旅客、舱位选择、经停控制
- **统一货币输出**：默认输出人民币（CNY），可用 `--currency usd` 切换为美元，每条记录含 `currency` 字段标注
- **城市级智能搜索**：中英文城市名 / IATA 代码混合输入，自动展开至所有机场（"上海"→PVG+SHA），自动过滤已关闭/货运专用机场
- **7000+ 个机场缓存**：覆盖全球 99% 有 IATA 代码的机场，含中英文名称及别名（AI 翻译，如有错误欢迎纠正）
- **智能重试 & 快速返回**：瞬态错误自动重试，有结果后提前返回不等慢源
- **共享代码去重**：自动识别并合并 Codeshare 航班，默认仅显示主运营航班

## 快速开始



### 安装（OpenClaw）

技能文件 SKILL.md (中文）  SKILL_EN.md(英文）
技能市场安装

```bash
clawclub install flyclaw
```
或告知小龙虾本github地址让它帮忙自动安装

- 安装说明

<img src="img/OpenClaw_Install_Instruction_CHS.jpg" width="350"/>

安装完成后建议先让小龙虾阅读技能文档再使用：

- Debug调试说明（偶发问题）

<img src="img/OpenClaw_Install_Debug1_CHS.jpg" width="350"/> <img src="img/OpenClaw_Install_Debug2_CHS.jpg" width="350"/>

- 实际使用示例示意（查航班、查动态等，更多等你挖掘！）

<img src="img/OpenClaw_Demo1_CHS.jpg" width="350"/> <img src="img/OpenClaw_Demo2_CHS.jpg" width="350"/>


### 安装（非OpenClaw方式）

```bash
# 创建 conda 环境
conda create -n flyclaw python=3.11 -y
conda activate flyclaw

# 安装核心依赖
pip install requests pyyaml curl_cffi flights
```
### 环境要求

- Python 3.11+
- conda 环境（推荐）

### 配置

默认配置文件 `config.yaml` 已包含推荐值，通常无需修改即可使用：

```yaml
sources:
  fr24:
    enabled: true
    priority: 1
    timeout: 10
  google_flights:
    enabled: true
    priority: 2
    timeout: 15
    serpapi_key: ""  # 留空则跳过 SerpAPI；填入则自动启用
    retry: 2           # 空结果时智能重试次数（0 = 禁用）
    retry_delay: 0.5   # 初始重试等待秒数
    retry_backoff: 2.0  # 重试等待倍数（0.5 → 1.0 → 2.0s）
  skiplagged:
    enabled: true
    priority: 2
    timeout: 12
    retry: 4           # 空结果时智能重试次数（0 = 禁用）
    retry_delay: 0.5   # 初始重试等待秒数
    retry_backoff: 2.0  # 重试等待倍数（0.5 → 1.0 → 2.0s）
    mcp_enabled: false  # MCP 实验性后端（默认关闭）；true = 优先尝试 MCP，失败则回退 REST
    mcp_url: "https://mcp.skiplagged.com/mcp"  # MCP 服务器地址
  airplanes_live:
    enabled: true
    priority: 3
    timeout: 6
  fliggy_mcp:
    enabled: true
    priority: 2
    timeout: 10
    api_key: ""          # 留空使用内置默认 key
    sign_secret: ""      # 留空使用内置默认 secret
  fast_flights:
    enabled: false  # 可选：仅在 --compare 模式下启用
    timeout: 15
cache:
  dir: cache
  airport_update_days: 99999  # 默认禁用自动更新（使用内置预建数据）；0 = 禁用，30 = 每月更新
  airport_update_url: ""   # 自定义更新地址；留空则使用内置默认地址
query:
  timeout: 20      # 全局查询超时秒数（各数据源超时仍独立生效）
  return_time: 12   # 智能返回：有结果后提前返回（秒）；0 = 禁用
  filter_inactive_airports: true  # 多机场查询时自动过滤已关闭/非商用机场
  route_relay: true           # 启用航线中继：发现航班航线后自动查询价格源
  relay_timeout: 8            # 中继专用超时秒数（快速失败）
  relay_engines:              # 航线中继价格查询使用的引擎
    google_flights: true
    skiplagged: true
price_priority:            # 价格优先级（独立于数据字段优先级）
  - fliggy_mcp
  - google_flights
  - skiplagged
output:
  format: json  # table / json
  language: both  # cn / en / both
  currency: cny  # cny / usd / original（默认 CNY 统一输出）
  exchange_rate_cny_usd: 7.25  # CNY↔USD 汇率
```

### 使用示例

```bash
# 按航班号查询（多源并发）
conda run -n flyclaw python flyclaw.py query --flight CA981

# 按航班号查询，过滤指定日期
conda run -n flyclaw python flyclaw.py query --flight CA981 --date 2026-04-01

# 按航线搜索（含价格）——城市级搜索自动覆盖所有机场
conda run -n flyclaw python flyclaw.py search --from 上海 --to 纽约 --date 2026-04-01
# 上海(PVG+SHA) → 纽约(JFK+EWR+LGA) 所有机场组合

# 往返搜索
conda run -n flyclaw python flyclaw.py search --from PVG --to LAX --date 2026-04-15 --return 2026-04-25

# 商务舱 + 2 人
conda run -n flyclaw python flyclaw.py search --from PVG --to JFK --date 2026-04-15 --cabin business -a 2

# 直飞 + 按价格排序 + JSON 输出
conda run -n flyclaw python flyclaw.py search --from PVG --to NRT --date 2026-04-15 --stops 0 --sort cheapest -o json

# 包含经停航班
conda run -n flyclaw python flyclaw.py search --from PVG --to JFK --date 2026-04-15 --stops any

# 关闭绕行查价
conda run -n flyclaw python flyclaw.py query --flight CA981 --no-relay

# 英文输入同样支持
conda run -n flyclaw python flyclaw.py search --from Shanghai --to "New York" --date 2026-04-01

# 切换为美元输出
conda run -n flyclaw python flyclaw.py search --from 上海 --to 北京 --date 2026-04-15 --currency usd

# 详细模式（显示数据来源和舱位）
conda run -n flyclaw python flyclaw.py query --flight CA981 -v

# 自定义超时
conda run -n flyclaw python flyclaw.py query --flight CA981 --timeout 10 --return-time 5

# 更新机场数据
conda run -n flyclaw python flyclaw.py update-airports --url http://example.com/airports.json
```

### 搜索参数

| 参数 | 短标志 | 默认值 | 说明 |
|------|--------|-------|------|
| `--from` | — | （必填） | 出发地（IATA/中文/英文） |
| `--to` | — | （必填） | 目的地 |
| `--date` | `-d` | — | 出行日期 YYYY-MM-DD |
| `--return` | `-r` | — | 返程日期（启用往返搜索） |
| `--adults` | `-a` | 1 | 成人旅客数 |
| `--children` | — | 0 | 儿童旅客数 |
| `--infants` | — | 0 | 婴儿旅客数 |
| `--cabin` | `-C` | economy | 舱位：economy/premium/business/first |
| `--limit` | `-l` | 不限制 | 最大结果数（默认返回全部，指定后截断） |
| `--sort` | `-s` | — | 排序：cheapest/fastest/departure/arrival |
| `--stops` | — | 0 | 经停控制：0=直飞/1/2/any=不限 |
| `--currency` | — | cny | 输出货币：cny/usd/original |
### 调试功能（开发者用，普通用户无需关注）

以下功能仅用于开发调试，需安装额外可选依赖，普通用户无需安装。**OpenClaw 技能用户不要安装** mcp、fast-flights、playwright 等调试依赖。

**MCP 后端**为实验功能，默认关闭（`mcp_enabled: false`），有额外握手延迟，普通用户无需开启。

**交叉验证**：`--compare` 标志，用 fast-flights v3 对比 fli 的查询结果，支持航班号匹配：

```bash
# 需安装: pip install fast-flights>=3.0rc0
conda run -n flyclaw python flyclaw.py search --from PVG --to CAN --date 2026-04-01 --compare
```

**浏览器基准验证**：`--browser` 配合 `--compare`，启动 Playwright 浏览器三方独立验证：

```bash
# 需安装: pip install playwright && playwright install chromium
conda run -n flyclaw python flyclaw.py search --from PVG --to CAN --date 2026-04-01 --compare --browser
```

未安装 fast-flights / playwright 时，`--compare` / `--browser` 会提示安装方法，不影响正常使用。

### 输出示例

```
  CA981  (Air China)
  北京(PEK) → 纽约(JFK)
  Departure: 2026-04-01 13:00    Arrival: 2026-04-01 14:30
  Price: $856 | Stops: 0 | Duration: 840min
```

往返搜索输出：
```
  CA981  (Air China)
  上海(PVG) → 洛杉矶(LAX)
  Departure: 2026-04-15 10:00    Arrival: 2026-04-15 14:00
  Price: $2400 (round-trip) | Stops: 0 | Duration: 840min
  ── Return ──
  CA982  (Air China)
  Departure: 2026-04-25 18:00    Arrival: 2026-04-26 22:00
  Stops: 0 | Duration: 900min
```

## 数据来源

FlyClaw 基于多源聚合架构，通过开源库及免费公开 API 获取数据。无需注册账号或提供 API Key。使用相关数据请遵守当地法律和规定。


## 依赖及许可证

| 依赖 | 版本要求 | 许可证 | 用途 |
|------|----------|--------|------|
| requests | >=2.28.0 | Apache-2.0 | HTTP 请求 |
| pyyaml | >=6.0 | MIT | YAML 配置解析 |
| curl_cffi | >=0.5.0 | MIT | Skiplagged API 请求 |
| flights (fli) | latest | MIT | Google Flights 查询（必需，GF 默认启用） |
| mcp | >=1.26.0 | MIT | Skiplagged MCP 后端（实验功能，默认关闭，普通用户无需安装） |
| fast-flights | >=3.0rc0 | MIT | --compare 交叉验证（可选，调试用） |
| playwright | latest | Apache-2.0 | --compare --browser 浏览器验证（可选，调试用） |

Python 版本要求：3.11+


## 免责声明

- 本工具基于多源聚合架构，通过开源库及免费公开 API 获取数据
- **无需注册账号或提供任何 API Key** 即可使用全部核心功能
- 本工具仅供学习研究用途，请遵守当地法律法规
- Google Flights 在部分地区（如中国大陆）可能不可用
- 价格数据来自多个数据源（飞猪、Google Flights、Skiplagged），不同来源的价格可能存在差异（含税/不含税、舱位差异、货币汇率等），仅供参考
- 程序不收集、不存储、不传输任何用户个人信息

## License

[Apache-2.0](LICENSE) |  [REDACTED_SECRET]


path: README_EN.md
content: # FlyClaw - Flight Information Aggregation CLI Tool

**FlyClaw** is a lightweight command-line tool that aggregates flight information (dynamics, prices, schedules, etc.) using a multi-source aggregation architecture powered by open-source libraries and free public APIs, with native Chinese/English query support and native OpenClaw skill integration, covering both Chinese domestic and international flights. Lightweight Python implementation — no browser automation, no complexity, no overhead.

Core value: A single data source is unreliable, incomplete, and limited in coverage -- FlyClaw's value lies in **aggregation, deduplication, gap-filling, and presentation**.

[中文说明](README.md)

**Author**: [REDACTED_SECRET]  Xiaohongshu @深度连接
**GitHub**: [https://github.com/AI4MSE/FlyClaw](https://github.com/AI4MSE/FlyClaw)
**License**: [Apache-2.0](LICENSE)

## Features

- **Zero API Key Required**: No account registration needed, works out of the box, secure — and avoids the complexity, unreliability, and slowness of browser automation
- **Multi-Source Aggregation**: Fliggy, Google Flights, Skiplagged, FR24, Airplanes.live — five data sources queried concurrently, fetching flight status, prices, and live positions via open-source libraries and free public APIs. **Plugin architecture, infinitely extensible** — each data source is an independent module (one file under `sources/`). Special thanks to the above open data sources for providing convenience for public benefit and common needs!
- **Complementary Price Sources**: Fliggy + Google Flights + Skiplagged queried concurrently, with support for round-trip search, multiple passengers, cabin class selection, and stopover control
- **Unified Currency Output**: Defaults to CNY (Chinese Yuan), switchable to USD via `--currency usd`. Each record includes a `currency` field
- **City-Level Smart Search**: Accepts mixed Chinese/English city names and IATA codes, auto-expands to all airports ("Shanghai" → PVG+SHA), auto-filters closed/cargo-only airports
- **7000+ Airport Cache**: Covers 99% of IATA-coded airports worldwide, with Chinese/English names and aliases (AI-translated — corrections welcome)
- **Smart Retry & Early Return**: Auto-retries on transient errors, returns results early without waiting for slow sources
- **Codeshare Deduplication**: Automatically identifies and merges codeshare flights, showing only the operating carrier by default

## Quick Start

### Installation (OpenClaw)

Skill files: SKILL.md (Chinese) / SKILL_EN.md (English)
Install from the skill marketplace:

```bash
clawclub install flyclaw
```
Or share this GitHub URL with your OpenClaw assistant to install automatically.

### Installation (Non-OpenClaw)

```bash
# Create conda environment
conda create -n flyclaw python=3.11 -y
conda activate flyclaw

# Install core dependencies
pip install requests pyyaml curl_cffi flights
```

### Requirements

- Python 3.11+
- conda environment (recommended)

### Configuration

The default `config.yaml` includes recommended values and works out of the box:

```yaml
sources:
  fr24:
    enabled: true
    priority: 1
    timeout: 10
  google_flights:
    enabled: true
    priority: 2
    timeout: 15
    serpapi_key: ""  # Leave empty to skip SerpAPI; fill in to auto-enable
    retry: 2           # Smart retry on empty responses (0 = disable)
    retry_delay: 0.5   # Initial retry wait seconds
    retry_backoff: 2.0  # Retry wait multiplier (0.5 → 1.0 → 2.0s)
  skiplagged:
    enabled: true
    priority: 2
    timeout: 12
    retry: 4           # Smart retry on empty responses (0 = disable)
    retry_delay: 0.5   # Initial retry wait seconds
    retry_backoff: 2.0  # Retry wait multiplier (0.5 → 1.0 → 2.0s)
    mcp_enabled: false  # MCP experimental backend (default off); true = try MCP first then REST
    mcp_url: "https://mcp.skiplagged.com/mcp"  # MCP server URL
  airplanes_live:
    enabled: true
    priority: 3
    timeout: 6
  fast_flights:
    enabled: false  # Optional: only used with --compare flag
    timeout: 15

cache:
  dir: cache
  airport_update_days: 99999  # Auto-update disabled by default (pre-built data); 0 = disable, 30 = monthly
  airport_update_url: ""   # Custom update URL; empty = use built-in default URL

query:
  timeout: 20      # Global query timeout in seconds (per-source timeouts still apply)
  return_time: 12   # Smart return: return early when results available (seconds); 0 = disable
  filter_inactive_airports: true  # Filter out closed/non-commercial airports from multi-airport queries
  route_relay: true           # Enable route relay: auto-query price sources when flight route discovered
  relay_timeout: 8            # Relay-specific timeout in seconds (fast fail)
  relay_engines:              # Which engines to use for route relay price lookup
    google_flights: true
    skiplagged: true

output:
  format: json  # table / json
  language: both  # cn / en / both
```

### Usage Examples

```bash
# Query by flight number (multi-source concurrent)
conda run -n flyclaw python flyclaw.py query --flight CA981

# Query by flight number, filter by date
conda run -n flyclaw python flyclaw.py query --flight CA981 --date 2026-04-01

# Search by route (with prices) — city-level search covers all airports automatically
conda run -n flyclaw python flyclaw.py search --from Shanghai --to "New York" --date 2026-04-01
# Shanghai(PVG+SHA) → New York(JFK+EWR+LGA) all airport combinations

# Round-trip search
conda run -n flyclaw python flyclaw.py search --from PVG --to LAX --date 2026-04-15 --return 2026-04-25

# Business class + 2 adults
conda run -n flyclaw python flyclaw.py search --from PVG --to JFK --date 2026-04-15 --cabin business -a 2

# Nonstop + sort by price + JSON output
conda run -n flyclaw python flyclaw.py search --from PVG --to NRT --date 2026-04-15 --stops 0 --sort cheapest -o json

# Include connecting flights
conda run -n flyclaw python flyclaw.py search --from PVG --to JFK --date 2026-04-15 --stops any

# Disable route relay
conda run -n flyclaw python flyclaw.py query --flight CA981 --no-relay

# Chinese input is also supported
conda run -n flyclaw python flyclaw.py search --from 上海 --to 纽约 --date 2026-04-01

# Verbose mode (show data sources and cabin info)
conda run -n flyclaw python flyclaw.py query --flight CA981 -v

# Custom timeout
conda run -n flyclaw python flyclaw.py query --flight CA981 --timeout 10 --return-time 5

# Update airport data
conda run -n flyclaw python flyclaw.py update-airports --url http://example.com/airports.json
```

### Search Parameters

| Parameter | Short | Default | Description |
|-----------|-------|---------|-------------|
| `--from` | — | (required) | Origin (IATA/Chinese/English) |
| `--to` | — | (required) | Destination |
| `--date` | `-d` | — | Travel date YYYY-MM-DD |
| `--return` | `-r` | — | Return date (enables round-trip) |
| `--adults` | `-a` | 1 | Number of adult passengers |
| `--children` | — | 0 | Number of children |
| `--infants` | — | 0 | Number of infants |
| `--cabin` | `-C` | economy | Cabin: economy/premium/business/first |
| `--limit` | `-l` | No limit | Maximum results (returns all by default, truncates when specified) |
| `--sort` | `-s` | — | Sort: cheapest/fastest/departure/arrival |
| `--stops` | — | 0 | Stop control: 0=nonstop/1/2/any |
### Debug Features (for developers, regular users can ignore)

These features are for development debugging only and require additional optional dependencies. **OpenClaw skill users should NOT install** mcp, fast-flights, or playwright debug dependencies.

**MCP backend** is experimental and disabled by default (`mcp_enabled: false`). It adds ~8s handshake latency and is not needed for regular use.

**Cross-verification**: `--compare` flag compares fli vs fast-flights v3 results with flight number matching:

```bash
# Requires: pip install fast-flights>=3.0rc0
conda run -n flyclaw python flyclaw.py search --from PVG --to CAN --date 2026-04-01 --compare
```

**Browser baseline verification**: `--browser` with `--compare` launches Playwright browser for three-way verification:

```bash
# Requires: pip install playwright && playwright install chromium
conda run -n flyclaw python flyclaw.py search --from PVG --to CAN --date 2026-04-01 --compare --browser
```

Without fast-flights / playwright installed, `--compare` / `--browser` will show install instructions without affecting normal usage.

### Sample Output

```
  CA981  (Air China)
  Beijing(PEK) -> New York(JFK)
  Departure: 2026-04-01 13:00    Arrival: 2026-04-01 14:30
  Price: $856 | Stops: 0 | Duration: 840min
```

Round-trip output:
```
  CA981  (Air China)
  Shanghai(PVG) -> Los Angeles(LAX)
  Departure: 2026-04-15 10:00    Arrival: 2026-04-15 14:00
  Price: $2400 (round-trip) | Stops: 0 | Duration: 840min
  ── Return ──
  CA982  (Air China)
  Departure: 2026-04-25 18:00    Arrival: 2026-04-26 22:00
  Stops: 0 | Duration: 900min
```

## Data Sources

FlyClaw uses a multi-source aggregation architecture powered by open-source libraries and free public APIs. No API key required. Please comply with local laws when using the data.

## Dependencies and Licenses

| Dependency | Version | License | Purpose |
|-----------|---------|---------|---------|
| requests | >=2.28.0 | Apache-2.0 | HTTP requests |
| pyyaml | >=6.0 | MIT | YAML config parsing |
| curl_cffi | >=0.5.0 | MIT | Skiplagged API requests |
| flights (fli) | latest | MIT | Google Flights queries (required, GF enabled by default) |
| mcp | >=1.26.0 | MIT | Skiplagged MCP backend (experimental, default off, not needed for regular use) |
| fast-flights | >=3.0rc0 | MIT | --compare cross-verification (optional, debug) |
| playwright | latest | Apache-2.0 | --compare --browser browser verification (optional, debug) |

Python: 3.11+

## Disclaimer

- Multi-source aggregation powered by open-source libraries and free public APIs
- **No API key required** for all core features
- For study and research purposes only. Please comply with local laws
- Google Flights may not be available in some regions (e.g. mainland China)
- Prices from different data sources (Google Flights, Skiplagged) may vary (tax-inclusive/exclusive, cabin differences) — for reference only
- No personal data is collected, stored, or transmitted

## License

[Apache-2.0](LICENSE) | [REDACTED_SECRET]


path: SKILL_EN.md
content: ---
name: flyclaw (Flight N-in-1 Search Zero Login)
description: Multi-source flight aggregation — query, search, round-trip, cabin class, nonstop filter. Zero login, zero account, zero API key. Lightweight Python, no browser automation. 四源航班聚合查询，零登录零账号零API，支持往返/舱位/直飞筛选，查航班/机票价格/航班动态.
version: 0.4.1
icon: ✈️
author: [REDACTED_SECRET]
license: Apache-2.0
acceptLicenseTerms: "Apache-2.0"
---

# FlyClaw ✈️ - Flight Information Aggregation Tool

## Overview

**5-source flight aggregation — zero login, zero account, zero API key. Lightweight Python, no browser automation.**

Multi-source aggregation via open-source libraries and free public APIs for flight dynamics, prices, schedules, and real-time positions. Supports Chinese/English city names and IATA codes.

**GitHub**: [https://github.com/AI4MSE/FlyClaw](https://github.com/AI4MSE/FlyClaw)

## Zero API Key

No registration or API key required for all core features. All data stays local — no personal data collected or stored. No browser automation overhead.

## Trigger

Trigger when user says "query flight CA981", "flights from Shanghai to New York", "round-trip PVG to SIN", "business class Beijing to London", "nonstop flights", "all flights including connecting", etc. Default behavior is nonstop + economy.

**Smart conversion rules**:
- "all flights" / "including connecting" → `--stops any`
- "nonstop" / "direct only" → `--stops 0` (default)
- "one stop max" → `--stops 1`
- "two stops max" → `--stops 2`

## Data Sources

- **Fliggy (飞猪)**: Domestic/International flight prices (best China domestic coverage, CNY pricing)
- **Google Flights**: Domestic/International flight prices, schedules (via open-source fli library)
- **Skiplagged**: Domestic/International flight prices, schedules
- **FlightRadar24**: Flight dynamics, real-time status, delays, aircraft type
- **Airplanes.live / ADSB.lol**: ADS-B real-time positions

Multi-source concurrent queries with smart merging. **Plugin architecture, infinitely extensible** — each data source is an independent module; adding a new source requires only one new file with zero changes to the main program. Special thanks to the above open data sources for providing convenience for public benefit and common needs!

## Features

1. **Query by flight number**: Flight status, schedule, delays, aircraft type
2. **Search by route**: Flight listings with prices, stops, duration
3. **City-level search**: City name input searches all airports in that city (e.g., "Shanghai" → PVG+SHA)
4. **Advanced search**: Round-trip, multi-passenger, cabin class, sorting, nonstop filter
5. **Chinese/English input**: Chinese city names, English names, IATA codes — 7,912 airports covered


## Important: Output Format & Multi-Day Queries

**Default output is JSON** (stdout), parse directly with `json.loads()`:
```json
[{"flight_number": "CA981", "price": 472.0, "origin_iata": "PVG", "destination_iata": "GVA", ...}]
```
Empty result returns `[]`. Errors and logs go to stderr only — never mixed into JSON. **Prices default to CNY (Chinese Yuan)**, with each record containing a `currency` field. Use `--currency usd` to convert all prices to USD, or `--currency cny` (default). Exchange rate configurable in `config.yaml` (default 7.25). Use `-o table` for human-readable output.

**Multi-day queries**: The search command queries one date at a time. For scenarios like "cheapest day this week", split into multiple dates, run them **concurrently**, then merge and compare the JSON results yourself.

## Usage

### Query by Flight Number

```bash
python flyclaw.py query --flight CA981
```

### Search by Route

```bash
python flyclaw.py search --from PVG --to JFK --date 2026-04-01
```

### Round-Trip Search

```bash
python flyclaw.py search --from PVG --to LAX --date 2026-04-15 --return 2026-04-25
```

### Business Class + 2 Adults

```bash
python flyclaw.py search --from PVG --to JFK --date 2026-04-15 --cabin business -a 2
```

### Nonstop + Sort by Price

```bash
python flyclaw.py search --from PVG --to SIN --date 2026-04-15 --stops 0 --sort cheapest
```

### Include Connecting Flights

```bash
python flyclaw.py search --from PVG --to JFK --date 2026-04-15 --stops any
```

### Filter Query Results by Date

```bash
python flyclaw.py query --flight CA981 --date 2026-04-01
python flyclaw.py query --flight CA981 --date today
```

### Disable Smart Pricing

Smart pricing is enabled by default — it automatically supplements price information during flight number queries. Disable to save query time.

```bash
python flyclaw.py query --flight CA981 --no-relay
```

### Search Parameters

| Parameter | Short | Default | Description |
|-----------|-------|---------|-------------|
| `--from` | — | (required) | Origin |
| `--to` | — | (required) | Destination |
| `--date` / `-d` | — | — | Travel date YYYY-MM-DD |
| `--return` / `-r` | — | — | Return date (enables round-trip) |
| `--adults` / `-a` | — | 1 | Adult passengers |
| `--children` | — | 0 | Child passengers |
| `--infants` | — | 0 | Infant passengers |
| `--cabin` / `-C` | — | economy | economy/premium/business/first |
| `--limit` / `-l` | — | No limit | Max results (returns all if not specified) |
| `--sort` / `-s` | — | — | cheapest/fastest/departure/arrival |
| `--stops` | — | 0 | Stops: 0=nonstop/1/2/any |

### Common Arguments

- `-o table`: Table output (default: JSON)
- `-v`: Verbose mode (shows data sources and cabin class)

## Installation

```bash
pip install requests pyyaml curl_cffi flights
# Do NOT install mcp, fast-flights, or playwright — they are debug modules, not needed for normal use
```

**Dependencies**: Python 3.11+, `requests` (Apache-2.0), `pyyaml` (MIT), `curl_cffi` (MIT), `flights` (MIT).

## Security

- **Zero API key dependency**: No API key or account registration required
- No personal data is collected, stored, or transmitted
- All network requests are solely for querying public flight data

## Disclaimer

- Multi-source aggregation powered by open-source libraries and free public APIs
- For study and research purposes only. Please comply with local laws
- Google Flights may not be available in some regions
- Prices from different data sources may vary (tax-inclusive/exclusive, cabin differences) — for reference only

---

## Author

Community-driven, open-source skill — free for everyone.

- **Email**: [REDACTED_SECRET]
- **Xiaohongshu (小红书)**: @深度连接
- **GitHub**: [AI4MSE](https://github.com/AI4MSE)

**License**: [Apache-2.0](LICENSE)


path: __init__.py
content: __version__ = "0.4.1"


path: _meta.json
content: ownerId: kn72nsza95zf8gmzprfepq5tgs81rjbq
slug: flyclaw
version: 0.4.1
publishedAt: 1774616795797

path: airport_manager.py
content: """Airport code resolver with Chinese/English/IATA input support.

Loads cache/airports.json and builds reverse lookup indices so that user
input in any form (IATA, Chinese city, English city, alias) resolves to
the correct IATA code.
"""

import json
import logging
import os
import re
import time

import requests

_PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
_AIRPORTS_FILE = os.path.join(_PROJECT_DIR, "cache", "airports.json")
_INACTIVE_FILE = os.path.join(_PROJECT_DIR, "cache", "inactive_airports.json")

logger = logging.getLogger("flyclaw.airport")

# Default (primary international) airport for multi-airport cities.
_CITY_DEFAULT = {
    "上海": "PVG",
    "shanghai": "PVG",
    "北京": "PEK",
    "beijing": "PEK",
    "成都": "CTU",
    "chengdu": "CTU",
    "纽约": "JFK",
    "newyork": "JFK",
    "new york": "JFK",
    "伦敦": "LHR",
    "london": "LHR",
    "巴黎": "CDG",
    "paris": "CDG",
}


class AirportManager:
    """Resolve user input (Chinese/English/IATA) to IATA airport code."""

    def __init__(self, data_path: str = _AIRPORTS_FILE,
                 inactive_path: str = _INACTIVE_FILE):
        self._data_path = data_path
        self._airports: dict[str, dict] = {}
        # Lookup tables: lowercased key -> list of IATA codes
        self._iata_index: dict[str, str] = {}         # "pvg" -> "PVG"
        self._city_cn_index: dict[str, list[str]] = {} # "上海" -> ["PVG","SHA"]
        self._city_en_index: dict[str, list[str]] = {} # "shanghai" -> ["PVG","SHA"]
        self._alias_index: dict[str, str] = {}         # "浦东" -> "PVG"
        self._name_cn_index: dict[str, str] = {}       # partial Chinese names
        self._inactive_set: set[str] = set()           # inactive airport IATA codes
        self._load(data_path)
        self._load_inactive(inactive_path)

    def _load(self, path: str) -> None:
        with open(path, "r", encoding="utf-8") as f:
            self._airports = json.load(f)

        for iata, info in self._airports.items():
            # IATA index (case-insensitive)
            self._iata_index[iata.lower()] = iata

            # City CN index
            city_cn = info.get("city_cn", "")
            if city_cn:
                self._city_cn_index.setdefault(city_cn, []).append(iata)

            # City EN index (lowercased, with and without spaces)
            city_en = info.get("city_en", "")
            if city_en:
                key = city_en.lower()
                self._city_en_index.setdefault(key, []).append(iata)
                # Also index without spaces: "new york" -> "newyork"
                no_space = key.replace(" ", "")
                if no_space != key:
                    self._city_en_index.setdefault(no_space, []).append(iata)

            # Alias index
            for alias in info.get("aliases", []):
                self._alias_index[alias.lower()] = iata

            # Chinese airport name (partial match index)
            name_cn = info.get("name_cn", "")
            if name_cn:
                self._name_cn_index[name_cn] = iata

    def _load_inactive(self, path: str) -> None:
        """Load inactive airport codes from JSON file."""
        try:
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
            airports = data.get("airports", {})
            self._inactive_set = {code.upper() for code in airports}
            logger.debug("Loaded %d inactive airports", len(self._inactive_set))
        except (OSError, json.JSONDecodeError) as e:
            logger.debug("Inactive airports file not loaded: %s", e)
            self._inactive_set = set()

    def is_active(self, code: str) -> bool:
        """Return True if airport has commercial passenger flights."""
        return code.upper() not in self._inactive_set

    def resolve_all(self, query: str, *, filter_inactive: bool = True) -> list[str]:
        """Resolve user input to all matching IATA codes.

        - IATA code (PVG) → ["PVG"] (exact match, never filtered)
        - Alias (浦东) → ["PVG"] (exact match, never filtered)
        - City name (上海) → ["PVG", "SHA"] (city-level, inactive filtered)
        - City name (Shanghai) → ["PVG", "SHA"]
        - Unknown → []

        When filter_inactive=True, city-level results exclude airports in
        the inactive list (closed, non-commercial, etc.).  If all airports
        would be filtered, returns the original unfiltered list as fallback.
        Direct IATA / alias matches are never filtered.
        """
        q = query.strip()
        if not q:
            return []

        q_lower = q.lower()
        q_nospace = re.sub(r"\s+", "", q_lower)

        # 1. Direct IATA match → single airport (never filter)
        if q_nospace in self._iata_index:
            return [self._iata_index[q_nospace]]

        # 2. Alias match → single airport (never filter)
        if q_lower in self._alias_index:
            return [self._alias_index[q_lower]]
        if q_nospace in self._alias_index:
            return [self._alias_index[q_nospace]]

        # 3. Chinese city name → all airports in that city
        if q in self._city_cn_index:
            return self._filter_inactive(self._city_cn_index[q], filter_inactive)

        # 4. English city name (with spaces) → all airports
        if q_lower in self._city_en_index:
            return self._filter_inactive(self._city_en_index[q_lower], filter_inactive)

        # 5. English city name (without spaces) → all airports
        if q_nospace in self._city_en_index:
            return self._filter_inactive(self._city_en_index[q_nospace], filter_inactive)

        # 6. Partial Chinese name match → single airport (never filter)
        for name, iata in self._name_cn_index.items():
            if q in name:
                return [iata]

        return []

    def _filter_inactive(self, codes: list[str], do_filter: bool) -> list[str]:
        """Filter inactive airports from a city-level result list.

        Returns the original list if filtering is disabled or would remove all.
        """
        if not do_filter or not self._inactive_set:
            return list(codes)
        filtered = [c for c in codes if c not in self._inactive_set]
        if not filtered:
            # Safety fallback: don't return empty
            return list(codes)
        return filtered

    def resolve(self, query: str) -> str | None:
        """Resolve user input to IATA code (backward compatible).

        Returns the primary airport for multi-airport cities via _CITY_DEFAULT.
        Returns None if unresolvable.
        """
        results = self.resolve_all(query)
        if not results:
            return None

        # For multi-airport cities, prefer the default primary airport
        q = query.strip()
        q_lower = q.lower()
        q_nospace = re.sub(r"\s+", "", q_lower)
        for key in (q, q_lower, q_nospace):
            if key in _CITY_DEFAULT:
                default = _CITY_DEFAULT[key]
                if default in results:
                    return default
        return results[0]

    def check_staleness(self, update_days: int) -> bool:
        """Check if airport data file is stale and needs updating.

        Returns True if the data file is older than *update_days* days.
        Returns False if update_days <= 0 (auto-update disabled) or file
        is still fresh.
        """
        if update_days <= 0:
            return False
        try:
            mtime = os.path.getmtime(self._data_path)
        except OSError:
            return True  # file missing → stale
        return (time.time() - mtime) >= update_days * 86400

    @staticmethod
    def _validate_airport_data(data: dict) -> bool:
        """Validate that *data* looks like a valid airports dict.

        Requirements:
        - Must be a dict with at least 10 entries
        - Keys must be 3-char uppercase (IATA format)
        - Values must contain name_cn, name_en, city_cn, city_en keys
          (values may be empty strings for airports without translations)
        """
        if not isinstance(data, dict) or len(data) < 10:
            return False
        required_fields = {"name_cn", "name_en", "city_cn", "city_en"}
        for key, val in data.items():
            if not (isinstance(key, str) and len(key) == 3 and key.isupper()):
                return False
            if not isinstance(val, dict):
                return False
            if not required_fields.issubset(val.keys()):
                return False
        return True

    def update_from_url(self, url: str) -> bool:
        """Download airport data from *url* and merge into existing data.

        - Empty URL → return False
        - Validates downloaded data structure
        - Merges: keeps existing entries, adds new entries, fills missing fields
        - Writes via atomic replace
        - Rebuilds indices on success
        - Any failure → logs warning, returns False, existing data untouched
        """
        if not url:
            return False
        try:
            resp = requests.get(url, timeout=30)
            resp.raise_for_status()
            new_data = resp.json()
        except Exception as e:
            logger.warning("Failed to download airport data from %s: %s", url, e)
            return False

        if not self._validate_airport_data(new_data):
            logger.warning("Downloaded airport data failed validation")
            return False

        # Merge: preserve existing, add new, fill missing fields
        merged = self._airports.copy()
        for iata, info in new_data.items():
            if iata in merged:
                # Fill missing fields in existing entry
                for field, val in info.items():
                    if not merged[iata].get(field) and val:
                        merged[iata][field] = val
            else:
                merged[iata] = info

        # Atomic write via temp file + os.replace
        try:
            tmp_path = self._data_path + ".tmp"
            with open(tmp_path, "w", encoding="utf-8") as f:
                json.dump(merged, f, ensure_ascii=False, indent=2)
            os.replace(tmp_path, self._data_path)
        except Exception as e:
            logger.warning("Failed to write airport data: %s", e)
            return False

        # Rebuild indices
        self._airports = {}
        self._iata_index = {}
        self._city_cn_index = {}
        self._city_en_index = {}
        self._alias_index = {}
        self._name_cn_index = {}
        self._load(self._data_path)

        logger.info("Airport data updated: %d entries", len(self._airports))
        return True

    def get_info(self, iata: str) -> dict | None:
        """Return full airport info dict, or None."""
        return self._airports.get(iata.upper())

    def get_display_name(self, iata: str) -> str:
        """Return display name like '上海浦东(PVG)'.

        Falls back to city_en when city_cn is empty.
        """
        info = self.get_info(iata)
        if not info:
            return iata
        city = info.get("city_cn", "") or info.get("city_en", "")
        # Use first alias as short name if available
        aliases = info.get("aliases", [])
        short = ""
        for a in aliases:
            if not a.isascii():
                short = a
                break
        name_part = f"{city}{short}" if short and short != city else city
        if not name_part:
            return iata
        return f"{name_part}({iata})"


# Module-level singleton
airport_manager = AirportManager()


path: cache/airlines_iata_icao.json
content: {
  "04": "ABV",
  "0A": "GNT",
  "0B": "JOR",
  "0D": "DWT",
  "0J": "JCS",
  "0P": "PYB",
  "10": "CNN",
  "13": "EAV",
  "1A": "AGT",
  "1E": "RGG",
  "1F": "CIF",
  "1H": "HEY",
  "1I": "AMB",
  "1L": "OSY",
  "1T": "RNX",
  "20": "RNE",
  "2B": "ARD",
  "2D": "AOG",
  "2F": "FTA",
  "2G": "CRG",
  "2J": "VBW",
  "2K": "GLG",
  "2L": "OAW",
  "2M": "MDV",
  "2N": "NTJ",
  "2P": "GAP",
  "2Q": "SNC",
  "2S": "SDY",
  "2T": "HAM",
  "2U": "GIP",
  "2W": "WLC",
  "2Y": "AOW",
  "2Z": "CGN",
  "3C": "CEA",
  "3D": "PMK",
  "3G": "AYZ",
  "3J": "WZP",
  "3K": "JSA",
  "3L": "ISK",
  "3N": "URG",
  "3P": "TNM",
  "3Q": "CYH",
  "3R": "GAI",
  "3T": "URN",
  "3U": "CSC",
  "3V": "TAY",
  "3W": "EMX",
  "3X": "GUI",
  "47": "VVN",
  "4A": "AKL",
  "4B": "BTQ",
  "4C": "ARE",
  "4D": "ASD",
  "4F": "ECE",
  "4G": "GZP",
  "4H": "UBD",
  "4K": "AAS",
  "4L": "MJX",
  "4M": "DSM",
  "4N": "ANT",
  "4R": "HHI",
  "4S": "FNC",
  "4T": "BHP",
  "4U": "GWI",
  "4Y": "RBU",
  "5A": "AIP",
  "5B": "BSX",
  "5C": "ICL",
  "5D": "SLI",
  "5F": "CIR",
  "5G": "SSV",
  "5H": "FFV",
  "5J": "CEB",
  "5K": "HFY",
  "5L": "RSU",
  "5M": "SIB",
  "5N": "AUL",
  "5T": "MPE",
  "5V": "UKW",
  "5W": "AEU",
  "5X": "UPS",
  "5Y": "GTI",
  "5Z": "BML",
  "6A": "CHP",
  "6B": "BLX",
  "6E": "IGO",
  "6F": "MKD",
  "6G": "AWW",
  "6H": "ISR",
  "6I": "IBZ",
  "6J": "SNJ",
  "6K": "RIT",
  "6N": "NRD",
  "6P": "ISG",
  "6Q": "SLL",
  "6R": "DRU",
  "6U": "UKR",
  "6V": "VGA",
  "6W": "SOV",
  "6Z": "UKS",
  "76": "SJS",
  "77": "ZCS",
  "78": "XAN",
  "7B": "KJC",
  "7C": "JJA",
  "7E": "AWU",
  "7F": "FAB",
  "7G": "SFJ",
  "7H": "ERR",
  "7K": "KGL",
  "7L": "ERO",
  "7M": "ZTF",
  "7N": "CNA",
  "7O": "GAL",
  "7P": "BTV",
  "7R": "BRB",
  "7T": "AGV",
  "7V": "ROB",
  "8A": "BMM",
  "8B": "GFI",
  "8C": "ATN",
  "8D": "EXV",
  "8E": "BRG",
  "8F": "FFR",
  "8H": "HFR",
  "8J": "JFU",
  "8L": "CGP",
  "8M": "MXL",
  "8N": "NKF",
  "8P": "PCO",
  "8Q": "BAJ",
  "8R": "TIB",
  "8U": "AAW",
  "8V": "ACP",
  "8W": "PWF",
  "8Y": "PBU",
  "8Z": "LER",
  "9E": "FLG",
  "9F": "TLM",
  "9I": "TKY",
  "9K": "KAP",
  "9L": "CJC",
  "9Q": "PBA",
  "9R": "VAP",
  "9S": "CQH",
  "9T": "ABS",
  "9U": "MLD",
  "9W": "JAI",
  "9X": "ACL",
  "9Y": "KZK",
  "A2": "CIU",
  "A3": "AEE",
  "A4": "SWD",
  "A5": "RLA",
  "A6": "LPV",
  "A7": "MPD",
  "A8": "BGL",
  "A9": "TGZ",
  "AA": "AAL",
  "AB": "BER",
  "AC": "ACA",
  "AD": "PRZ",
  "AE": "MDA",
  "AF": "AFR",
  "AG": "ABR",
  "AH": "DAH",
  "AI": "AIC",
  "AJ": "NIG",
  "AK": "AXM",
  "AL": "SYX",
  "AM": "AMX",
  "AN": "AAA",
  "AO": "AUZ",
  "AP": "AIB",
  "AQ": "AAH",
  "AR": "ARG",
  "AS": "ASA",
  "AT": "RAM",
  "AU": "AUT",
  "AV": "AVA",
  "AW": "SCH",
  "AX": "LOF",
  "AY": "FIN",
  "AZ": "AZA",
  "B2": "BRU",
  "B3": "BLV",
  "B4": "BCF",
  "B5": "FLT",
  "B6": "JBU",
  "B7": "UIA",
  "B8": "ERT",
  "B9": "BGD",
  "BA": "BAW",
  "BB": "SBS",
  "BC": "SKY",
  "BD": "BMA",
  "BE": "BEE",
  "BF": "RSR",
  "BG": "BBC",
  "BI": "RBA",
  "BJ": "LBT",
  "BK": "PDC",
  "BL": "PIC",
  "BM": "BYE",
  "BN": "BNF",
  "BO": "BOU",
  "BP": "BOT",
  "BQ": "ROM",
  "BR": "EVA",
  "BS": "BIH",
  "BT": "BTI",
  "BU": "BRA",
  "BV": "BPA",
  "BW": "BWA",
  "BX": "CST",
  "BY": "TOM",
  "BZ": "BDA",
  "C0": "CLW",
  "C2": "CAP",
  "C3": "KIS",
  "C4": "IMX",
  "C5": "UCA",
  "C6": "CJA",
  "C7": "RLE",
  "C8": "WDY",
  "C9": "RUS",
  "CA": "CCA",
  "CB": "CCC",
  "CC": "ABD",
  "CD": "LLR",
  "CE": "NTW",
  "CF": "SDR",
  "CG": "TOK",
  "CH": "BMJ",
  "CI": "CAL",
  "CJ": "CBF",
  "CK": "CKK",
  "CL": "CLH",
  "CM": "CMP",
  "CN": "WWD",
  "CO": "COA",
  "CP": "CDN",
  "CQ": "EXL",
  "CS": "CMI",
  "CT": "CAT",
  "CU": "CUB",
  "CV": "CVA",
  "CW": "CWM",
  "CX": "CPA",
  "CY": "CYP",
  "CZ": "CSN",
  "D1": "MDO",
  "D3": "DAO",
  "D4": "LID",
  "D5": "DAU",
  "D6": "ILN",
  "D7": "RDN",
  "D8": "DJB",
  "D9": "DNV",
  "DA": "GRG",
  "DB": "BZH",
  "DC": "GAO",
  "DD": "NOK",
  "DE": "CFG",
  "DF": "MJG",
  "DG": "SRQ",
  "DH": "DVA",
  "DI": "BAG",
  "DJ": "PBN",
  "DK": "ELA",
  "DL": "DAL",
  "DN": "SGG",
  "DO": "DOA",
  "DP": "FCA",
  "DR": "BIE",
  "DT": "DTA",
  "DU": "HMS",
  "DV": "LTF",
  "DW": "UCR",
  "DX": "DTR",
  "DY": "NAX",
  "E0": "ESS",
  "E2": "KMP",
  "E3": "DMO",
  "E4": "RSO",
  "E5": "BRZ",
  "E7": "ESF",
  "E8": "ELG",
  "E9": "CXS",
  "EA": "EAL",
  "EC": "TWN",
  "ED": "ABQ",
  "EE": "EAY",
  "EF": "EFA",
  "EG": "JAA",
  "EH": "AKX",
  "EI": "EIN",
  "EJ": "NEA",
  "EK": "UAE",
  "EL": "ANK",
  "EM": "AEB",
  "EN": "DLA",
  "EO": "LHN",
  "EP": "IRC",
  "EQ": "TAE",
  "ER": "DHL",
  "ES": "DHX",
  "ET": "ETH",
  "EU": "EEA",
  "EV": "ASQ",
  "EW": "EWG",
  "EX": "SDO",
  "EY": "ETD",
  "EZ": "EIA",
  "F1": "FBL",
  "F2": "FLM",
  "F3": "FSW",
  "F4": "NBK",
  "F5": "COZ",
  "F6": "RCK",
  "F7": "BBO",
  "F9": "FFT",
  "FA": "SFR",
  "FB": "LZB",
  "FC": "WBA",
  "FD": "AIQ",
  "FE": "WCP",
  "FF": "TOW",
  "FG": "AFG",
  "FH": "FUA",
  "FI": "ICE",
  "FJ": "FJI",
  "FK": "WTA",
  "FL": "TRS",
  "FM": "CSH",
  "FO": "ATM",
  "FP": "FRE",
  "FQ": "TCW",
  "FR": "RYR",
  "FS": "STU",
  "FT": "SRH",
  "FU": "FXX",
  "FV": "SDM",
  "FW": "IBX",
  "FX": "FDX",
  "FY": "FFM",
  "FZ": "FDB",
  "G0": "GHB",
  "G1": "IKA",
  "G2": "VXG",
  "G3": "CIX",
  "G4": "AAY",
  "G6": "BSR",
  "G7": "GNF",
  "G8": "AGB",
  "G9": "ABY",
  "GA": "GIA",
  "GB": "ABX",
  "GC": "GNR",
  "GD": "AHA",
  "GE": "TNA",
  "GF": "GBA",
  "GG": "GUY",
  "GH": "GLP",
  "GI": "IKA",
  "GJ": "MXC",
  "GL": "GRL",
  "GM": "SVK",
  "GN": "AGN",
  "GO": "KZU",
  "GP": "GES",
  "GQ": "BSY",
  "GR": "AUR",
  "GS": "UPA",
  "GT": "GBL",
  "GV": "ARF",
  "GW": "KIL",
  "GX": "JXX",
  "GY": "TMG",
  "GZ": "RAR",
  "H2": "SKU",
  "H4": "IIN",
  "H5": "HOA",
  "H6": "HAG",
  "H8": "KHB",
  "H9": "HAD",
  "HA": "HAL",
  "HB": "HAR",
  "HC": "ATI",
  "HD": "ADO",
  "HE": "LGW",
  "HF": "HLF",
  "HG": "NLY",
  "HH": "AHO",
  "HJ": "AXF",
  "HK": "FSC",
  "HM": "SEY",
  "HN": "HVY",
  "HO": "DJA",
  "HP": "AWE",
  "HQ": "HMY",
  "HR": "CUA",
  "HT": "AHW",
  "HU": "CHH",
  "HV": "TRA",
  "HW": "FHE",
  "HX": "CRK",
  "HY": "UZB",
  "HZ": "SOZ",
  "I2": "IBS",
  "I4": "FWA",
  "I5": "IDS",
  "I6": "SEQ",
  "I7": "PMW",
  "I9": "AEY",
  "IA": "IAW",
  "IB": "IBE",
  "IC": "IAC",
  "ID": "ITK",
  "IE": "SOL",
  "IF": "ISW",
  "IG": "ISS",
  "II": "CSQ",
  "IJ": "GWL",
  "IK": "ITX",
  "IL": "ILW",
  "IM": "MNJ",
  "IN": "MAK",
  "IO": "IAA",
  "IP": "JOL",
  "IQ": "AUB",
  "IR": "IRA",
  "IT": "KFR",
  "IV": "JET",
  "IW": "AOM",
  "IX": "AXB",
  "IY": "IYE",
  "IZ": "AIZ",
  "J2": "AHY",
  "J3": "PLR",
  "J4": "BFL",
  "J6": "AOC",
  "J7": "CVC",
  "J8": "BVT",
  "J9": "GIF",
  "JA": "BON",
  "JB": "JBA",
  "JC": "JEX",
  "JD": "JAS",
  "JE": "MNO",
  "JF": "JAF",
  "JH": "NES",
  "JI": "MDW",
  "JJ": "TAM",
  "JK": "JKK",
  "JL": "JAL",
  "JM": "AJM",
  "JN": "XLA",
  "JO": "JAZ",
  "JP": "ADR",
  "JQ": "JST",
  "JR": "SER",
  "JS": "KOR",
  "JT": "LNI",
  "JU": "JAT",
  "JV": "BLS",
  "JW": "APW",
  "JX": "JEC",
  "JY": "TCI",
  "JZ": "SKX",
  "K1": "KOQ",
  "K2": "ELO",
  "K4": "CKS",
  "K5": "ABE",
  "K6": "BRV",
  "K7": "KBR",
  "K8": "ZAK",
  "K9": "KRI",
  "KA": "HDA",
  "KB": "DRK",
  "KC": "KZR",
  "KD": "KNI",
  "KE": "KAL",
  "KF": "BLF",
  "KG": "BNX",
  "KH": "KHK",
  "KI": "AAG",
  "KJ": "LAJ",
  "KK": "KKK",
  "KL": "KLM",
  "KM": "AMC",
  "KO": "AER",
  "KP": "KIA",
  "KQ": "KQA",
  "KR": "CWK",
  "KS": "PEN",
  "KT": "CWS",
  "KU": "KAC",
  "KV": "MVD",
  "KX": "CAY",
  "KY": "EQL",
  "KZ": "NCA",
  "L2": "LYC",
  "L3": "JOS",
  "L4": "LJJ",
  "L5": "LTR",
  "L6": "MAI",
  "L7": "LPN",
  "L8": "LXG",
  "L9": "TLW",
  "LA": "LAN",
  "LB": "LLB",
  "LC": "VLO",
  "LD": "AHK",
  "LE": "LTY",
  "LF": "NDC",
  "LG": "LGL",
  "LH": "DLH",
  "LI": "LIA",
  "LJ": "SLA",
  "LK": "LXR",
  "LL": "GRO",
  "LM": "LAM",
  "LN": "LAA",
  "LO": "LOT",
  "LP": "LPE",
  "LQ": "LAQ",
  "LR": "LRC",
  "LS": "EXS",
  "LT": "LTU",
  "LU": "LXP",
  "LV": "LBC",
  "LW": "NMI",
  "LX": "SWR",
  "LY": "ELY",
  "M0": "MNG",
  "M2": "MZS",
  "M3": "TUS",
  "M4": "NOV",
  "M5": "KEN",
  "M6": "AJT",
  "M7": "MAA",
  "M8": "TMW",
  "M9": "MSI",
  "MA": "MAH",
  "MB": "EXA",
  "MC": "RCH",
  "MD": "MDG",
  "ME": "MEA",
  "MF": "CXA",
  "MG": "CCP",
  "MH": "MAS",
  "MI": "SLK",
  "MJ": "LPR",
  "MK": "MAU",
  "ML": "MAV",
  "MN": "CAW",
  "MO": "AUH",
  "MP": "MPH",
  "MQ": "EGF",
  "MR": "MRT",
  "MS": "MSR",
  "MT": "TCX",
  "MU": "CES",
  "MV": "RML",
  "MW": "MYD",
  "MX": "MXA",
  "MY": "MXJ",
  "MZ": "MNA",
  "N2": "DAG",
  "N3": "OMS",
  "N4": "MTC",
  "N5": "KGZ",
  "N6": "JEV",
  "N7": "ROK",
  "N8": "HGK",
  "NA": "NAL",
  "NB": "SNB",
  "NC": "NJS",
  "NE": "ESK",
  "NF": "AVN",
  "NG": "LDA",
  "NH": "ANA",
  "NI": "PGA",
  "NJ": "NGB",
  "NK": "NKS",
  "NL": "SAI",
  "NM": "DRD",
  "NN": "MOV",
  "NO": "AUS",
  "NP": "NIA",
  "NQ": "AJX",
  "NR": "PIR",
  "NT": "IBB",
  "NU": "JTA",
  "NV": "CRF",
  "NW": "NWA",
  "NX": "AMU",
  "NY": "FXI",
  "NZ": "ANZ",
  "O1": "OAB",
  "O6": "ONE",
  "O7": "OZJ",
  "O8": "OHK",
  "OA": "OAL",
  "OB": "ASZ",
  "OD": "ULA",
  "OE": "AOT",
  "OF": "FIF",
  "OH": "COM",
  "OI": "ORC",
  "OJ": "OLA",
  "OK": "CSA",
  "OL": "OLT",
  "OM": "MGL",
  "ON": "RON",
  "OO": "SKW",
  "OP": "CHK",
  "OQ": "CQN",
  "OR": "TFL",
  "OS": "AUA",
  "OT": "PEL",
  "OU": "CTN",
  "OV": "ELL",
  "OW": "EXK",
  "OX": "OEA",
  "OY": "OAE",
  "OZ": "AAR",
  "P5": "RPB",
  "P7": "ESL",
  "P8": "PTN",
  "P9": "PGP",
  "PA": "FCL",
  "PC": "FAJ",
  "PD": "POE",
  "PE": "AEL",
  "PF": "PNW",
  "PG": "BKP",
  "PH": "PAO",
  "PI": "PDT",
  "PJ": "SPM",
  "PK": "PIA",
  "PL": "PLI",
  "PM": "TOS",
  "PN": "CHB",
  "PO": "PAC",
  "PQ": "PNF",
  "PR": "PAL",
  "PS": "PSX",
  "PT": "CCI",
  "PU": "PUA",
  "PV": "PNR",
  "PW": "PRF",
  "PX": "ANG",
  "PY": "SLM",
  "PZ": "LAP",
  "Q3": "MBN",
  "Q4": "SAE",
  "Q5": "MLA",
  "Q6": "CDP",
  "Q8": "PEC",
  "Q9": "AFU",
  "QB": "GFG",
  "QC": "CRD",
  "QD": "QCL",
  "QE": "ECC",
  "QF": "QFA",
  "QH": "FLZ",
  "QI": "CIM",
  "QK": "JZA",
  "QL": "RLN",
  "QM": "AML",
  "QN": "ARR",
  "QO": "MPX",
  "QQ": "UTY",
  "QR": "QTR",
  "QS": "QSC",
  "QT": "TPA",
  "QU": "UGX",
  "QV": "LAO",
  "QW": "BWG",
  "QX": "QXE",
  "QY": "BCS",
  "QZ": "AWQ",
  "R0": "RPK",
  "R2": "ORB",
  "R3": "SYL",
  "R5": "JAV",
  "R7": "OCA",
  "R8": "KGA",
  "R9": "CAM",
  "RA": "RNA",
  "RB": "SBK",
  "RC": "FLI",
  "RD": "RYN",
  "RE": "REA",
  "RF": "FWL",
  "RG": "VRN",
  "RH": "RPH",
  "RI": "MDL",
  "RJ": "RJA",
  "RK": "RKA",
  "RL": "RFJ",
  "RM": "RNY",
  "RN": "RAB",
  "RO": "ROT",
  "RP": "CHQ",
  "RQ": "KMF",
  "RR": "RFR",
  "RS": "ICT",
  "RU": "ABW",
  "RV": "CPN",
  "RW": "RPA",
  "RX": "AEH",
  "RY": "RAY",
  "S0": "OKS",
  "S2": "RSH",
  "S3": "BBR",
  "S4": "RZO",
  "S5": "TCF",
  "S7": "SBI",
  "S8": "CSU",
  "S9": "HSA",
  "SA": "SAA",
  "SB": "ACI",
  "SC": "CDG",
  "SD": "SUD",
  "SE": "SEU",
  "SF": "DTH",
  "SG": "JGO",
  "SH": "FLY",
  "SI": "SIH",
  "SJ": "SJY",
  "SK": "SAS",
  "SL": "RSL",
  "SM": "SRL",
  "SN": "DAT",
  "SO": "HKA",
  "SP": "SAT",
  "SQ": "SIA",
  "SR": "SWR",
  "SS": "CRL",
  "ST": "GMI",
  "SU": "AFL",
  "SV": "SVA",
  "SW": "NMB",
  "SX": "SRK",
  "SY": "SCX",
  "T2": "TCG",
  "T3": "EZE",
  "T4": "HEJ",
  "T5": "TUA",
  "T6": "TVR",
  "T7": "TJT",
  "T9": "TRZ",
  "TA": "TAT",
  "TB": "TUB",
  "TC": "ATC",
  "TD": "LUR",
  "TE": "LIL",
  "TF": "SCW",
  "TG": "THA",
  "TH": "TSE",
  "TI": "TOL",
  "TJ": "TJA",
  "TK": "THY",
  "TL": "ANO",
  "TN": "THT",
  "TO": "PSD",
  "TP": "TAP",
  "TQ": "TDM",
  "TR": "TGW",
  "TS": "TSC",
  "TT": "KLA",
  "TU": "TAR",
  "TV": "VEX",
  "TW": "TWA",
  "TX": "FWI",
  "TY": "TPC",
  "TZ": "SCO",
  "U1": "ABI",
  "U2": "EZY",
  "U3": "AIA",
  "U4": "PMT",
  "U5": "GWY",
  "U6": "SVR",
  "U7": "JUS",
  "U8": "RNV",
  "U9": "TAK",
  "UA": "UAL",
  "UB": "UBA",
  "UD": "HER",
  "UE": "NAS",
  "UF": "UKM",
  "UG": "TUI",
  "UI": "ECA",
  "UJ": "LMU",
  "UK": "BUZ",
  "UL": "ALK",
  "UM": "AZW",
  "UN": "TSO",
  "UO": "HKE",
  "UP": "BHS",
  "UQ": "OCM",
  "US": "USA",
  "UT": "UTA",
  "UU": "REU",
  "UX": "AEA",
  "UY": "UYC",
  "UZ": "BRQ",
  "V0": "VCV",
  "V1": "VIA",
  "V2": "AKT",
  "V3": "KRP",
  "V4": "REK",
  "V5": "VLI",
  "V7": "SNG",
  "V8": "VAS",
  "V9": "BTC",
  "VA": "VAU",
  "VC": "VCX",
  "VD": "KPA",
  "VE": "AVE",
  "VF": "VLU",
  "VG": "VLM",
  "VH": "VNP",
  "VI": "VDA",
  "VJ": "JTY",
  "VK": "VGN",
  "VL": "VIM",
  "VM": "VOA",
  "VN": "HVN",
  "VO": "TYR",
  "VP": "VSP",
  "VQ": "VKH",
  "VR": "TCV",
  "VS": "VIR",
  "VT": "VTA",
  "VU": "VUN",
  "VV": "AEW",
  "VW": "TAO",
  "VX": "VRD",
  "VY": "FOS",
  "VZ": "MYT",
  "W2": "CWA",
  "W3": "ARA",
  "W4": "BES",
  "W5": "IRM",
  "W6": "WZZ",
  "W8": "CJT",
  "W9": "AAB",
  "WA": "KLC",
  "WB": "RWD",
  "WC": "ISV",
  "WD": "DSR",
  "WE": "CWC",
  "WF": "WIF",
  "WG": "SWG",
  "WH": "CNW",
  "WJ": "WEB",
  "WK": "AFB",
  "WL": "FQR",
  "WN": "SWA",
  "WO": "WOA",
  "WP": "MKU",
  "WQ": "PQW",
  "WR": "HRH",
  "WS": "WJA",
  "WU": "WAU",
  "WV": "SWV",
  "WW": "BMI",
  "WX": "BCY",
  "WY": "OMA",
  "WZ": "RWZ",
  "X3": "HLX",
  "X5": "OTJ",
  "X7": "CHF",
  "XA": "XAU",
  "XB": "NXB",
  "XE": "BTA",
  "XF": "VLK",
  "XG": "CLI",
  "XJ": "MES",
  "XK": "CCM",
  "XL": "LNE",
  "XM": "SMX",
  "XO": "CXH",
  "XP": "CXP",
  "XQ": "SXS",
  "XS": "SIT",
  "XT": "AXL",
  "XW": "SXR",
  "XX": "GFY",
  "XY": "KNE",
  "Y4": "VOI",
  "Y5": "PCE",
  "Y8": "YZR",
  "Y9": "IRK",
  "YC": "YCC",
  "YE": "YEL",
  "YL": "LLM",
  "YM": "MGX",
  "YO": "TYS",
  "YP": "AEF",
  "YS": "RAE",
  "YT": "TGA",
  "YV": "ASH",
  "YW": "ANE",
  "YX": "MEP",
  "YY": "VWA",
  "YZ": "YZZ",
  "Z3": "SMJ",
  "Z4": "OOM",
  "Z5": "IIR",
  "Z6": "ZTT",
  "Z7": "ADK",
  "Z8": "AZN",
  "ZA": "CYD",
  "ZB": "BUB",
  "ZC": "KGO",
  "ZE": "AZE",
  "ZG": "VVM",
  "ZH": "CSZ",
  "ZI": "AAF",
  "ZK": "GLA",
  "ZL": "RXA",
  "ZM": "IWA",
  "ZN": "ZNA",
  "ZP": "AZQ",
  "ZQ": "LOC",
  "ZS": "SMY",
  "ZT": "AWC",
  "ZU": "HCY",
  "ZV": "AMW",
  "ZW": "AWI",
  "ZX": "GGN",
  "ZY": "ADE"
}

path: cache/flight_routes.json
content: {
  "CZ8860": {
    "origin": "HGH",
    "destination": "PKX",
    "last_seen": 1774825760
  },
  "CZ8790": {
    "origin": "HGH",
    "destination": "PKX",
    "last_seen": 1774825760
  },
  "HU7678": {
    "origin": "HGH",
    "destination": "PEK",
    "last_seen": 1774825760
  },
  "CA1729": {
    "origin": "HGH",
    "destination": "PEK",
    "last_seen": 1774825760
  },
  "JD5907": {
    "origin": "HGH",
    "destination": "PKX",
    "last_seen": 1774825760
  },
  "CA1727": {
    "origin": "HGH",
    "destination": "PEK",
    "last_seen": 1774825760
  },
  "GJ8987": {
    "origin": "HGH",
    "destination": "PEK",
    "last_seen": 1774825760
  }
}

path: cache/inactive_airports.json
content: {
  "_comment": "Airports with no commercial passenger flights (closed, non-commercial, cargo-only, etc.). Used to filter multi-airport city queries to avoid timeouts on non-functional airports. Users can manually edit this file to add/remove entries.",
  "_updated": "2026-03-08",
  "airports": {
    "NAY": {
      "name": "Beijing Nanyuan Airport",
      "city": "Beijing",
      "reason": "Closed 2019-09-25. All flights transferred to PKX (Daxing).",
      "since": "2019-09-25"
    },
    "SIA": {
      "name": "Xi'an Xiguan Airport",
      "city": "Xi'an",
      "reason": "Closed 1991. All flights transferred to XIY (Xianyang).",
      "since": "1991-09-01"
    },
    "THF": {
      "name": "Berlin Tempelhof Airport",
      "city": "Berlin",
      "reason": "Closed 2008-10-30. Site converted to public park.",
      "since": "2008-10-30"
    },
    "TXL": {
      "name": "Berlin-Tegel International Airport",
      "city": "Berlin",
      "reason": "Closed 2020-11-08. All flights transferred to BER (Brandenburg).",
      "since": "2020-11-08"
    },
    "HEW": {
      "name": "Athens Hellinikon Airport",
      "city": "Athens",
      "reason": "Closed 2001-03-28. Replaced by ATH (Eleftherios Venizelos).",
      "since": "2001-03-28"
    },
    "ISL": {
      "name": "Istanbul Ataturk Airport",
      "city": "Istanbul",
      "reason": "Passenger ops closed 2019-04-06. Replaced by IST (Istanbul Airport).",
      "since": "2019-04-06"
    },
    "BKA": {
      "name": "Moscow Bykovo Airport",
      "city": "Moscow",
      "reason": "Passenger operations ended 2010. General aviation only.",
      "since": "2010-10-18"
    },
    "CKL": {
      "name": "Moscow Chkalovskiy Airport",
      "city": "Moscow",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "OSF": {
      "name": "Moscow Ostafyevo International Airport",
      "city": "Moscow",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "DIA": {
      "name": "Doha International Airport",
      "city": "Doha",
      "reason": "Passenger flights moved to DOH (Hamad International) 2014-05-27.",
      "since": "2014-05-27"
    },
    "NYS": {
      "name": "New York Skyports Seaplane Base",
      "city": "New York",
      "reason": "Seaplane base only. Not a conventional airport for flight search.",
      "since": "N/A"
    },
    "GML": {
      "name": "Kyiv Gostomel/Antonov Airport",
      "city": "Kiev",
      "reason": "No commercial passenger flights.",
      "since": "2022-02-24"
    },
    "KBP": {
      "name": "Kyiv Boryspil International Airport",
      "city": "Kiev",
      "reason": "Closed since 2022-02-24. Airspace closed.",
      "since": "2022-02-24"
    },
    "IEV": {
      "name": "Kyiv Zhuliany International Airport",
      "city": "Kiev",
      "reason": "Closed since 2022-02-24. Airspace closed.",
      "since": "2022-02-24"
    },
    "BQH": {
      "name": "London Biggin Hill Airport",
      "city": "London",
      "reason": "Business aviation only. No scheduled commercial passenger service.",
      "since": "N/A"
    },
    "NHT": {
      "name": "Northolt Airport",
      "city": "London",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "LBG": {
      "name": "Paris-Le Bourget Airport",
      "city": "Paris",
      "reason": "Business/private aviation only since 1980. No commercial passenger flights.",
      "since": "1980"
    },
    "YMX": {
      "name": "Montreal Mirabel International Airport",
      "city": "Montreal",
      "reason": "Passenger flights ended 2004-10-31. Now cargo-only.",
      "since": "2004-10-31"
    },
    "YKZ": {
      "name": "Toronto Buttonville Municipal Airport",
      "city": "Toronto",
      "reason": "Closed 2023-11-24. Land being redeveloped.",
      "since": "2023-11-24"
    },
    "YZD": {
      "name": "Toronto Downsview Airport",
      "city": "Toronto",
      "reason": "Closed April 2024. Former de Havilland facility, being redeveloped.",
      "since": "2024-04-01"
    },
    "YXD": {
      "name": "Edmonton City Centre Airport",
      "city": "Edmonton",
      "reason": "Closed 2013-11-30. Land redeveloped as Blatchford community.",
      "since": "2013-11-30"
    },
    "HEM": {
      "name": "Helsinki-Malmi Airport",
      "city": "Helsinki",
      "reason": "Closed to aviation 2021-03-14. Land planned for development.",
      "since": "2021-03-14"
    },
    "ETH": {
      "name": "Eilat Airport",
      "city": "Eilat",
      "reason": "Closed 2019-03-18. Replaced by ETM (Ramon Airport).",
      "since": "2019-03-18"
    },
    "VDA": {
      "name": "Ovda Airport",
      "city": "Eilat",
      "reason": "Closed. No commercial passenger flights.",
      "since": "2019-03-31"
    },
    "BPM": {
      "name": "Hyderabad Begumpet Airport",
      "city": "Hyderabad",
      "reason": "Commercial flights ended 2008. Replaced by HYD (Rajiv Gandhi Intl).",
      "since": "2008-03-23"
    },
    "VBC": {
      "name": "Mandalay Chanmyathazi Airport",
      "city": "Mandalay",
      "reason": "Replaced by MDL (Mandalay International) in 2000. Abandoned.",
      "since": "2000"
    },
    "MHP": {
      "name": "Minsk-1 Airport",
      "city": "Minsk",
      "reason": "Commercial flights ended 2012. Airport closed 2015. Demolished 2019.",
      "since": "2012-10-26"
    },
    "DAX": {
      "name": "Dazhou Heshi Airport",
      "city": "Dazhou",
      "reason": "Commercial flights ended 2022-05-18. Replaced by DZH (Jinya Airport).",
      "since": "2022-05-18"
    },
    "YHJ": {
      "name": "Nanchang Yaohu Airport",
      "city": "Nanchang",
      "reason": "Test flight / general aviation only. No commercial passenger service.",
      "since": "N/A"
    },
    "WHU": {
      "name": "Wuhu Wanli Airport",
      "city": "Wuhu",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "NHD": {
      "name": "Al Minhad Airport",
      "city": "Dubai",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "DET": {
      "name": "Coleman A. Young Municipal Airport",
      "city": "Detroit",
      "reason": "General aviation only since ~2000. No scheduled commercial service.",
      "since": "2000"
    },
    "ECV": {
      "name": "Madrid Cuatro Vientos Airport",
      "city": "Madrid",
      "reason": "Flight training and helicopters only. No commercial passenger flights.",
      "since": "N/A"
    },
    "TOJ": {
      "name": "Madrid Torrejon Airport",
      "city": "Madrid",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "ANK": {
      "name": "Ankara Etimesgut Airport",
      "city": "Ankara",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "IGL": {
      "name": "Cigli Airport",
      "city": "Izmir",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "ESK": {
      "name": "Eskisehir Airport",
      "city": "Eskisehir",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "BXN": {
      "name": "Bodrum-Imsik Airport",
      "city": "Bodrum",
      "reason": "No commercial flights since late 1990s. Replaced by BJV (Milas-Bodrum).",
      "since": "1997"
    },
    "XFW": {
      "name": "Hamburg-Finkenwerder Airport",
      "city": "Hamburg",
      "reason": "Private Airbus factory airfield. No public scheduled services.",
      "since": "N/A"
    },
    "MTS": {
      "name": "Matsapha Airport",
      "city": "Manzini",
      "reason": "Commercial flights ended 2014-09-29. Replaced by SHO (King Mswati III).",
      "since": "2014-09-29"
    },
    "QRA": {
      "name": "Johannesburg Rand Airport",
      "city": "Johannesburg",
      "reason": "General aviation only since 1940. No commercial passenger flights.",
      "since": "1940"
    },
    "GSE": {
      "name": "Gothenburg City Airport (Save)",
      "city": "Gothenburg",
      "reason": "Closed to airline traffic 2015-01-18. Runway damage.",
      "since": "2015-01-18"
    },
    "LAD": {
      "name": "Luanda Quatro de Fevereiro Airport",
      "city": "Luanda",
      "reason": "Commercial ops transferred to NBJ (Dr. Antonio Agostinho Neto) by Oct 2025.",
      "since": "2025-10"
    },
    "CSO": {
      "name": "Magdeburg-Cochstedt Airport",
      "city": "Magdeburg",
      "reason": "No scheduled commercial flights since 2013. Now drone test center.",
      "since": "2013"
    },
    "PCB": {
      "name": "Pondok Cabe Airport",
      "city": "Jakarta",
      "reason": "No commercial passenger flights.",
      "since": "N/A"
    },
    "NHA": {
      "name": "Nha Trang Airport",
      "city": "Nha Trang",
      "reason": "No commercial passenger flights. CXR (Cam Ranh) serves Nha Trang.",
      "since": "N/A"
    },
    "TIP": {
      "name": "Tripoli International Airport",
      "city": "Tripoli",
      "reason": "Closed. No commercial passenger flights. MJI serves Tripoli.",
      "since": "2014-07"
    },
    "ALY": {
      "name": "Alexandria El Nouzha Airport",
      "city": "Alexandria",
      "reason": "Closed 2011. All flights transferred to HBE (Borg El Arab).",
      "since": "2011"
    }
  }
}


path: config.py
content: """FlyClaw configuration loader.

Reads config.yaml and provides defaults for all settings.
"""

import json
import logging
import os
import yaml

logger = logging.getLogger("flyclaw.config")

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(PROJECT_DIR, "config.yaml")
AIRLINES_FILE = os.path.join(PROJECT_DIR, "cache", "airlines_iata_icao.json")

# ---------------------------------------------------------------------------
# Network constants
# ---------------------------------------------------------------------------
USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
    "AppleWebKit/537.36 (KHTML, like Gecko) "
    "Chrome/131.0.0.0 Safari/537.36"
)
FR24_API_BASE = "https://api.flightradar24.com/common/v1"
AIRPLANESLIVE_API_BASE = "https://api.airplanes.live/v2"
ADSB_LOL_API_BASE = "https://api.adsb.lol/v2"

# Default airport data update URL (set to GitHub raw URL after project is published)
AIRPORT_UPDATE_URL = ""

# ---------------------------------------------------------------------------
# IATA → ICAO airline code mapping (for ADS-B callsign conversion)
# ---------------------------------------------------------------------------
IATA_TO_ICAO_AIRLINE = {
    # China
    "CA": "CCA",   # Air China
    "MU": "CES",   # China Eastern
    "CZ": "CSN",   # China Southern
    "3U": "CSC",   # Sichuan Airlines
    "HU": "CHH",   # Hainan Airlines
    "ZH": "CSZ",   # Shenzhen Airlines
    "MF": "CXA",   # Xiamen Airlines
    "FM": "CSH",   # Shanghai Airlines
    "KN": "CUA",   # China United Airlines
    "GS": "GCR",   # Tianjin Airlines
    "TV": "TBA",   # Tibet Airlines
    "SC": "CDG",   # Shandong Airlines
    "PN": "CHB",   # West Air
    "9C": "CQH",   # Spring Airlines
    "HO": "DKH",   # Juneyao Airlines
    "EU": "UEA",   # Chengdu Airlines
    "GJ": "CDC",   # Loong Air
    "QW": "CQN",   # Qingdao Airlines
    # Asia
    "JL": "JAL",   # Japan Airlines
    "NH": "ANA",   # ANA
    "KE": "KAL",   # Korean Air
    "OZ": "AAR",   # Asiana Airlines
    "SQ": "SIA",   # Singapore Airlines
    "CX": "CPA",   # Cathay Pacific
    "TG": "THA",   # Thai Airways
    "VN": "HVN",   # Vietnam Airlines
    "BR": "EVA",   # EVA Air
    "CI": "CAL",   # China Airlines (Taiwan)
    # North America
    "AA": "AAL",   # American Airlines
    "UA": "UAL",   # United Airlines
    "DL": "DAL",   # Delta Air Lines
    "WN": "SWA",   # Southwest Airlines
    "AC": "ACA",   # Air Canada
    "B6": "JBU",   # JetBlue
    "AS": "ASA",   # Alaska Airlines
    # Europe
    "BA": "BAW",   # British Airways
    "LH": "DLH",   # Lufthansa
    "AF": "AFR",   # Air France
    "KL": "KLM",   # KLM
    "SK": "SAS",   # SAS
    "AY": "FIN",   # Finnair
    "IB": "IBE",   # Iberia
    "TK": "THY",   # Turkish Airlines
    "LX": "SWR",   # Swiss International
    "OS": "AUA",   # Austrian Airlines
    # Middle East / Oceania
    "EK": "UAE",   # Emirates
    "QR": "QTR",   # Qatar Airways
    "EY": "ETD",   # Etihad Airways
    "QF": "QFA",   # Qantas
    "NZ": "ANZ",   # Air New Zealand
    "SV": "SVA",   # Saudia
}


def _load_airline_mapping() -> dict[str, str]:
    """Load IATA→ICAO airline mapping from JSON file.

    Falls back to built-in IATA_TO_ICAO_AIRLINE dict if file is missing
    or corrupt.
    """
    try:
        with open(AIRLINES_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
        if isinstance(data, dict) and len(data) > 0:
            return data
    except (OSError, json.JSONDecodeError, TypeError) as e:
        logger.debug("Airline mapping file not available (%s), using built-in", e)
    return IATA_TO_ICAO_AIRLINE


_AIRLINE_MAPPING = _load_airline_mapping()


def iata_flight_to_icao_callsign(flight_number: str) -> str | None:
    """Convert IATA flight number (e.g. 'CA981') to ICAO callsign (e.g. 'CCA981').

    Returns None if the airline code is not in the mapping.
    """
    if not flight_number:
        return None
    fn = flight_number.strip().upper()
    # Try 2-char airline prefix first, then 1-char (unlikely but safe)
    for prefix_len in (2, 1):
        airline = fn[:prefix_len]
        numeric = fn[prefix_len:]
        if airline in _AIRLINE_MAPPING and numeric:
            return f"{_AIRLINE_MAPPING[airline]}{numeric}"
    return None

# ---------------------------------------------------------------------------
# Default config (used when config.yaml is missing or incomplete)
# ---------------------------------------------------------------------------
DEFAULTS = {
    "sources": {
        "fr24": {"enabled": True, "priority": 1, "timeout": 10},
        "google_flights": {
            "enabled": True,
            "priority": 2,
            "timeout": 15,
            "serpapi_key": "",
            "retry": 2,
            "retry_delay": 0.5,
            "retry_backoff": 2.0,
        },
        "skiplagged": {
            "enabled": True, "priority": 2, "timeout": 12,
            "retry": 4, "retry_delay": 0.5, "retry_backoff": 2.0,
            "mcp_enabled": False,
            "mcp_url": "https://mcp.skiplagged.com/mcp",
        },
        "fliggy_mcp": {
            "enabled": True, "priority": 2, "timeout": 10,
            "api_key": "", "sign_secret": "",
        },
        "airplanes_live": {"enabled": True, "priority": 3, "timeout": 6},
        "fast_flights": {"enabled": False, "timeout": 15},
    },
    "cache": {"dir": "cache", "airport_update_days": 99999, "airport_update_url": ""},
    "query": {
        "timeout": 20, "return_time": 12, "route_relay": True,
        "relay_timeout": 8,
        "filter_inactive_airports": True,
        "relay_engines": {"google_flights": True, "skiplagged": True},
        "sufficient_source": "fliggy_mcp",
    },
    "output": {
        "format": "json", "language": "both",
        "currency": "cny",
        "exchange_rate_cny_usd": 7.25,
    },
}


def _deep_merge(base: dict, override: dict) -> dict:
    """Merge *override* into *base*, returning a new dict."""
    result = base.copy()
    for key, val in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(val, dict):
            result[key] = _deep_merge(result[key], val)
        else:
            result[key] = val
    return result


def get_config(config_path: str | None = None) -> dict:
    """Load configuration from YAML, falling back to defaults."""
    path = config_path or CONFIG_FILE
    cfg = {}
    if os.path.exists(path):
        with open(path, "r", encoding="utf-8") as f:
            cfg = yaml.safe_load(f) or {}
    return _deep_merge(DEFAULTS, cfg)


path: config.yaml
content: # FlyClaw Configuration
# 航班信息聚合查询工具配置文件
# Do not suggest to change default parameters unless you are clear what you are acting
# 一般不建议修改默认参数

sources:
  fr24:
    enabled: true
    priority: 1
    timeout: 10
  google_flights:
    enabled: true
    priority: 2
    timeout: 15
    serpapi_key: ""  # Leave empty to skip SerpAPI; fill in to auto-enable
    retry: 2           # Smart retry on empty responses (0 = disable)
    retry_delay: 0.5   # Initial retry wait seconds
    retry_backoff: 2.0  # Retry wait multiplier (0.5 → 1.0 → 2.0s)
  skiplagged:
    enabled: true
    priority: 2
    timeout: 12
    retry: 4           # Smart retry on empty responses (0 = disable)
    retry_delay: 0.5   # Initial retry wait seconds
    retry_backoff: 2.0  # Retry wait multiplier (0.5 → 1.0 → 2.0s)
    mcp_enabled: false  # MCP experimental backend (default off); true = try MCP first then REST
    mcp_url: "https://mcp.skiplagged.com/mcp"  # MCP server URL
  airplanes_live:
    enabled: true
    priority: 3
    timeout: 6
  fliggy_mcp:
    enabled: true
    priority: 2
    timeout: 10
    api_key: ""          # Leave empty to use built-in default key (from official open-source CLI)
    sign_secret: ""      # Leave empty to use built-in default secret
  fast_flights:
    enabled: false  # Optional: only used with --compare flag
    timeout: 15

cache:
  dir: cache
  airport_update_days: 99999  # Auto-update disabled by default (pre-built data); 0 = disable, 30 = monthly
  airport_update_url: ""   # Custom update URL; empty = use built-in default URL

query:
  timeout: 20      # Global query timeout in seconds (per-source timeouts still apply)
  return_time: 12   # Smart return: return early when results available (seconds); 0 = disable
  filter_inactive_airports: true  # Filter out closed/non-commercial airports from multi-airport queries
  route_relay: true           # Enable route relay: auto-query price sources when flight route discovered
  relay_timeout: 8            # Relay-specific timeout in seconds (fast fail)
  relay_engines:              # Which engines to use for route relay price lookup
    google_flights: true
    skiplagged: true
  sufficient_source: fliggy_mcp  # Fast return: exit immediately when this source returns priced results
                                  # Empty string = disable, use return_time instead

output:
  format: json  # table / json
  language: both  # cn / en / both
  currency: cny  # cny / usd / original — default CNY; AI can pass --currency usd
  exchange_rate_cny_usd: 7.25  # Used when --currency converts between CNY and USD


path: flyclaw.py
content: """FlyClaw - Flight information aggregation CLI tool.

Usage:
    flyclaw query --flight CA981
    flyclaw search --from PVG --to JFK --date 2026-04-01
"""

import argparse
import json
import logging
import sys
import time as _time
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED

from __init__ import __version__
import config as cfg
from airport_manager import airport_manager
from route_cache import route_cache
from sources.fr24 import FR24Source
from sources.google_flights import GoogleFlightsSource
from sources.airplaneslive import AirplanesLiveSource

__version__ = __version__

logger = logging.getLogger("flyclaw")


# ---------------------------------------------------------------------------
# Field merge logic
# ---------------------------------------------------------------------------

def _normalize_flight_number(fn: str) -> str:
    """Normalize flight number by stripping leading zeros from numeric part.

    Examples: IJ004→IJ4, CA0981→CA981, 3U0088→3U88.
    Handles airline codes like "3U" (digit + letter).
    Needed because Fliggy pads numeric part with zeros while GF/SK do not.
    """
    if not fn:
        return fn
    # Airline code is 2-3 chars (may start with digit, e.g. "3U", "9C")
    # Find the boundary: last non-digit before the all-digit tail
    # Strategy: find the longest trailing digit-only suffix
    i = len(fn)
    while i > 0 and fn[i - 1].isdigit():
        i -= 1
    if i == 0 or i == len(fn):
        return fn  # All digits or no digits — can't normalize
    prefix = fn[:i]
    numeric = fn[i:]
    stripped = numeric.lstrip("0") or "0"
    return prefix + stripped


def _get_source_priority(record: dict) -> int:
    """Return the configured priority for a record's source.

    Lower number = higher priority.  Unknown sources get 999.
    """
    source = record.get("source", "")
    conf = cfg.get_config()
    sources_cfg = conf.get("sources", {})
    for name, scfg in sources_cfg.items():
        if name == source:
            return scfg.get("priority", 999)
    return 999


def _extract_date(iso_str: str | None) -> str:
    """Extract date part (YYYY-MM-DD) from ISO-8601 timestamp."""
    if not iso_str:
        return ""
    return iso_str[:10] if len(iso_str) >= 10 else ""


def _merge_records(records: list[dict], *, date_aware: bool = False) -> list[dict]:
    """Merge records from different sources for the same flight.

    Strategy: normalize flight numbers, sort by source priority (high-priority
    first), then group by flight_number and fill missing fields from
    lower-priority sources.  Records without flight_number are kept as-is.

    After standard merge, picks the **lowest price** across all sources for
    each merged flight (comparing in USD for cross-currency fairness).

    Args:
        date_aware: If True, group by flight_number + date (from
            scheduled_departure).  This preserves multi-day records
            returned by FR24 for the same flight number.
    """
    # Normalize flight numbers before merge (IJ004→IJ4, CA0981→CA981)
    for rec in records:
        fn = rec.get("flight_number")
        if fn:
            rec["flight_number"] = _normalize_flight_number(fn)

    sorted_records = sorted(records, key=_get_source_priority)

    grouped: dict[str, dict] = {}       # merge_key → merged record
    raw_groups: dict[str, list[dict]] = {}  # merge_key → all original records
    ungrouped: list[dict] = []

    for rec in sorted_records:
        fn = rec.get("flight_number", "")
        if not fn:
            ungrouped.append(rec)
            continue

        if date_aware:
            date_part = _extract_date(rec.get("scheduled_departure"))
            key = f"{fn}|{date_part}"
        else:
            key = fn

        # Keep raw records for price_priority override
        raw_groups.setdefault(key, []).append(rec)

        if key not in grouped:
            grouped[key] = rec.copy()
        else:
            # Fill-in: existing non-empty values take priority
            existing = grouped[key]
            for k, val in rec.items():
                if k == "source":
                    # Combine unique sources
                    sources = set(existing.get("source", "").split(","))
                    sources.add(val)
                    sources.discard("")
                    existing["source"] = ",".join(sorted(sources))
                elif not existing.get(k) and val:
                    existing[k] = val

    result = list(grouped.values()) + ungrouped

    # --- Lowest price selection ---
    # When multiple sources provide prices for the same flight, pick the
    # lowest price (comparing in USD for cross-currency fairness).
    # The original currency + price of the cheapest source is kept.
    conf = cfg.get_config()
    rate = conf.get("output", {}).get("exchange_rate_cny_usd", 7.25)

    for merged_rec in result:
        fn = merged_rec.get("flight_number", "")
        if not fn:
            continue
        if date_aware:
            dep = merged_rec.get("scheduled_departure", "")
            date_part = dep[:10] if dep and len(dep) >= 10 else ""
            group_key = f"{fn}|{date_part}"
        else:
            group_key = fn
        raw = raw_groups.get(group_key, [])
        if len(raw) <= 1:
            continue  # No merge happened, nothing to override

        # Find the lowest price across all sources (compare in USD)
        best_price_usd = None
        best_record = None
        for r in raw:
            p = r.get("price")
            if p is None or p == 0:
                continue
            cur = r.get("currency", "USD").upper()
            price_usd = p / rate if cur == "CNY" else p
            if best_price_usd is None or price_usd < best_price_usd:
                best_price_usd = price_usd
                best_record = r

        # Write lowest price (keep original currency of cheapest source)
        merged_rec.pop("price", None)
        merged_rec.pop("currency", None)
        if best_record:
            merged_rec["price"] = best_record["price"]
            merged_rec["currency"] = best_record.get("currency", "USD")

    # Sort by date descending when date_aware (most recent first)
    if date_aware:
        result.sort(
            key=lambda r: _extract_date(r.get("scheduled_departure")),
            reverse=True,
        )

    return result


def _deduplicate_codeshares(records: list[dict]) -> list[dict]:
    """Detect and remove codeshare duplicates, keeping operating carrier.

    Flights with identical (dep_time, arr_time, origin, dest, stops) are
    treated as codeshare variants of the same physical flight.
    """
    if not records:
        return records

    groups: dict[tuple, list[dict]] = {}
    ungrouped: list[dict] = []

    for rec in records:
        dep = rec.get("scheduled_departure", "")
        arr = rec.get("scheduled_arrival", "")
        orig = rec.get("origin_iata", "")
        dest = rec.get("destination_iata", "")
        stops = rec.get("stops")
        if dep and arr and orig and dest:
            key = (dep, arr, orig, dest, stops)
            groups.setdefault(key, []).append(rec)
        else:
            ungrouped.append(rec)

    result = []
    for group in groups.values():
        if len(group) == 1:
            result.append(group[0])
        else:
            main = _pick_main_flight(group)
            result.append(main)
    return result + ungrouped


def _pick_main_flight(group: list[dict]) -> dict:
    """Pick operating carrier from codeshare group, merge fields."""
    scored = []
    for rec in group:
        score = sum(1 for v in rec.values() if v is not None and v != "")
        if rec.get("price") is not None:
            score += 5
        scored.append((score, rec))
    scored.sort(key=lambda x: x[0], reverse=True)
    main = scored[0][1].copy()

    alt_fns = [r["flight_number"] for _, r in scored[1:]
               if r.get("flight_number") and r["flight_number"] != main.get("flight_number")]
    if alt_fns:
        main["codeshare_variants"] = alt_fns

    for _, rec in scored[1:]:
        for k, v in rec.items():
            if k in ("flight_number", "source", "codeshare_variants"):
                continue
            if not main.get(k) and v:
                main[k] = v
    return main


# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------

def _apply_currency_conversion(records: list[dict], args, conf: dict) -> None:
    """Apply currency conversion in-place based on --currency arg / config.

    Ensures every priced record has a ``currency`` field, then converts
    CNY↔USD when the target currency is not ``original``.
    """
    # Ensure currency field exists on all priced records
    for r in records:
        if r.get("price") is not None:
            r.setdefault("currency", "USD")

    out_currency = (
        getattr(args, "currency", None)
        or conf.get("output", {}).get("currency", "original")
    )
    if out_currency not in ("usd", "cny"):
        return  # "original" — keep each source's native currency

    rate = conf.get("output", {}).get("exchange_rate_cny_usd", 7.25)
    if rate <= 0:
        return

    for r in records:
        if r.get("price") is None:
            continue
        cur = r.get("currency", "USD").upper()
        if out_currency == "usd" and cur == "CNY":
            r["price"] = round(r["price"] / rate, 2)
            r["currency"] = "USD"
        elif out_currency == "cny" and cur == "USD":
            r["price"] = round(r["price"] * rate, 2)
            r["currency"] = "CNY"


def _format_table(records: list[dict], verbose: bool = False) -> str:
    """Format records as a human-readable table."""
    if not records:
        return "No flights found."

    lines = []
    sep = "-" * 90

    for i, rec in enumerate(records):
        if i > 0:
            lines.append(sep)

        fn = rec.get("flight_number", "N/A")
        airline = rec.get("airline", "") or ""
        status = rec.get("status", "") or ""
        origin = rec.get("origin_city", rec.get("origin_iata", ""))
        dest = rec.get("destination_city", rec.get("destination_iata", ""))
        dep = _format_time(rec.get("scheduled_departure"))
        arr = _format_time(rec.get("scheduled_arrival"))
        aircraft = rec.get("aircraft_type", "") or ""
        price = rec.get("price")
        delay = rec.get("delay_minutes")
        source = rec.get("source", "")
        stops = rec.get("stops")

        header = f"  {fn}"
        codeshare_alts = rec.get("codeshare_variants", [])
        if codeshare_alts:
            header += f"  (+{len(codeshare_alts)} codeshare)"
        if airline:
            header += f"  ({airline})"
        if status:
            header += f"  [{status}]"
        lines.append(header)

        lines.append(f"  {origin} → {dest}")
        lines.append(f"  Departure: {dep}    Arrival: {arr}")

        extras = []
        if aircraft:
            extras.append(f"Aircraft: {aircraft}")
        trip_type = rec.get("trip_type", "")
        price_label = "(round-trip)" if trip_type == "round_trip" else ""
        if price is not None:
            extras.append(f"Price: ${price} {price_label}".strip())
        if stops is not None:
            extras.append(f"Stops: {stops}")
        duration = rec.get("duration_minutes")
        if duration is not None:
            extras.append(f"Duration: {duration}min")
        if delay is not None:
            extras.append(f"Delay: {delay}min")
        altitude = rec.get("altitude_ft")
        speed = rec.get("ground_speed_kts")
        if altitude is not None:
            extras.append(f"Alt: {altitude}ft")
        if speed is not None:
            extras.append(f"Speed: {speed}kts")
        if extras:
            lines.append(f"  {' | '.join(extras)}")

        # Round-trip return flight block
        if trip_type == "round_trip":
            ret_fn = rec.get("return_flight_number", "")
            ret_airline = rec.get("return_airline", "")
            ret_dep = _format_time(rec.get("return_departure"))
            ret_arr = _format_time(rec.get("return_arrival"))
            ret_stops = rec.get("return_stops")
            ret_dur = rec.get("return_duration_minutes")

            lines.append("  ── Return ──")
            ret_header = f"  {ret_fn}" if ret_fn else "  N/A"
            if ret_airline:
                ret_header += f"  ({ret_airline})"
            lines.append(ret_header)
            lines.append(f"  Departure: {ret_dep}    Arrival: {ret_arr}")
            ret_extras = []
            if ret_stops is not None:
                ret_extras.append(f"Stops: {ret_stops}")
            if ret_dur is not None:
                ret_extras.append(f"Duration: {ret_dur}min")
            if ret_extras:
                lines.append(f"  {' | '.join(ret_extras)}")

        if verbose:
            cabin_class = rec.get("cabin_class", "")
            if cabin_class:
                lines.append(f"  Cabin: {cabin_class}")
            lines.append(f"  Source: {source}")

    return "\n".join(lines)


def _format_time(iso_str: str | None) -> str:
    """Format ISO timestamp to readable form."""
    if not iso_str:
        return "--"
    # Show date + time portion
    return iso_str.replace("T", " ")[:19]


# ---------------------------------------------------------------------------
# CLI commands
# ---------------------------------------------------------------------------

def cmd_update_airports(args):
    """Handle 'update-airports' subcommand: manually update airport data."""
    conf = cfg.get_config()
    cache_cfg = conf["cache"]

    # URL priority: CLI --url > config.yaml > AIRPORT_UPDATE_URL constant
    url = getattr(args, "url", None) or cache_cfg.get("airport_update_url", "") or cfg.AIRPORT_UPDATE_URL
    if not url:
        print("Error: no update URL provided. Use --url or set airport_update_url in config.yaml",
              file=sys.stderr)
        sys.exit(1)

    print(f"Updating airport data from: {url}")
    ok = airport_manager.update_from_url(url)
    if ok:
        print("Airport data updated successfully.")
    else:
        print("Airport data update failed. See log for details.", file=sys.stderr)
        sys.exit(1)


def _auto_update_airports():
    """Check and auto-update airport data at startup (silent on failure)."""
    try:
        conf = cfg.get_config()
        cache_cfg = conf["cache"]
        update_days = cache_cfg.get("airport_update_days", 30)
        url = cache_cfg.get("airport_update_url", "") or cfg.AIRPORT_UPDATE_URL

        if not url or update_days <= 0:
            return

        if airport_manager.check_staleness(update_days):
            logger.info("Airport data is stale, auto-updating...")
            airport_manager.update_from_url(url)
    except Exception as e:
        logger.debug("Auto-update check skipped: %s", e)


# ---------------------------------------------------------------------------
# Concurrent query infrastructure
# ---------------------------------------------------------------------------

def _query_fr24_flight(flight_number: str, timeout: int) -> list[dict]:
    """Wrapper: query FR24 by flight number."""
    src = FR24Source(timeout=timeout)
    return src.query_by_flight(flight_number)


def _query_fr24_route(origin: str, dest: str, date: str | None, timeout: int) -> list[dict]:
    """Wrapper: query FR24 by route."""
    src = FR24Source(timeout=timeout)
    return src.query_by_route(origin, dest, date)


def _query_gf_route(
    origin: str | list[str], dest: str | list[str], date: str,
    timeout: int, serpapi_key: str,
    *, return_date: str | None = None, adults: int = 1,
    children: int = 0, infants: int = 0,
    cabin: str = "economy", stops: int | str = 0,
    sort: str | None = None, limit: int | None = None,
    retry: int = 3,
    retry_delay: float = 0.5, retry_backoff: float = 2.0,
) -> list[dict]:
    """Wrapper: query Google Flights by route.

    origin/dest can be str or list[str] for multi-airport city searches.
    """
    src = GoogleFlightsSource(timeout=timeout, serpapi_key=serpapi_key,
                              retry=retry,
                              retry_delay=retry_delay,
                              retry_backoff=retry_backoff)
    return src.query_by_route(
        origin, dest, date,
        return_date=return_date, adults=adults, children=children,
        infants=infants, cabin=cabin, stops=stops,
        sort=sort, limit=limit,
    )


def _query_gf_flight(flight_number: str, timeout: int, serpapi_key: str) -> list[dict]:
    """Wrapper: query Google Flights by flight number (via route cache)."""
    src = GoogleFlightsSource(timeout=timeout, serpapi_key=serpapi_key)
    return src.query_by_flight(flight_number)


def _query_al_flight(flight_number: str, timeout: int) -> list[dict]:
    """Wrapper: query Airplanes.live by flight number."""
    src = AirplanesLiveSource(timeout=timeout)
    return src.query_by_flight(flight_number)


def _query_fliggy_route(
    origin_name: str, dest_name: str, date: str, timeout: int,
    *, cabin: str = "economy", stops: int | str = 0,
    limit: int | None = None, return_date: str | None = None,
    api_key: str = "", sign_secret: str = "",
) -> list[dict]:
    """Wrapper: query Fliggy MCP by route (ThreadPoolExecutor compatible)."""
    from sources.fliggy_mcp import FliggyMCPSource
    src = FliggyMCPSource(
        timeout=timeout,
        [REDACTED_SECRET] or None,
        sign_secret=sign_secret or None,
    )
    return src.query_by_route(
        origin_name, dest_name, date,
        cabin=cabin, stops=stops, limit=limit,
        return_date=return_date, timeout=timeout,
    )


def _query_sk_route(origin: str, dest: str, date: str, timeout: int,
                    *, stops: int | str = 0, limit: int | None = None,
                    retry: int = 3, cabin: str = "economy",
                    mcp_enabled: bool = False, mcp_url: str = "",
                    return_date: str | None = None,
                    retry_delay: float = 0.5,
                    retry_backoff: float = 2.0) -> list[dict]:
    """Wrapper: query Skiplagged by route (ThreadPoolExecutor compatible)."""
    from sources.skiplagged import SkiplaggedSource
    src = SkiplaggedSource(timeout=timeout, retry=retry,
                           mcp_enabled=mcp_enabled, mcp_url=mcp_url,
                           retry_delay=retry_delay,
                           retry_backoff=retry_backoff)
    return src.query_by_route(origin, dest, date, stops=stops, limit=limit,
                              cabin=cabin, return_date=return_date)


def _query_sk_route_multi(
    origins: list[str], dests: list[str], date: str, timeout: int,
    *, stops: int | str = 0, limit: int | None = None,
    retry: int = 3, cabin: str = "economy",
    mcp_enabled: bool = False, mcp_url: str = "",
    retry_delay: float = 0.5, retry_backoff: float = 2.0,
) -> list[dict]:
    """Query Skiplagged for all origin x dest combinations.

    SK API accepts single IATA codes only.  For city-level searches
    (e.g. Shanghai = PVG + SHA) we iterate all origin x dest pairs,
    deduplicate by flight_number, and return the cheapest results.

    Uses concurrent queries (max 2 workers) when multiple pairs exist.
    Research (v0.2.3 Step 1) confirmed SK has no rate limiting at 2s intervals.
    """
    from sources.skiplagged import SkiplaggedSource
    src = SkiplaggedSource(timeout=timeout, retry=retry,
                           mcp_enabled=mcp_enabled, mcp_url=mcp_url,
                           retry_delay=retry_delay,
                           retry_backoff=retry_backoff)
    pairs = [(o, d) for o in origins for d in dests]

    if len(pairs) <= 1:
        # Single pair — no concurrency needed
        all_results: list[dict] = []
        for o, d in pairs:
            try:
                recs = src.query_by_route(o, d, date, stops=stops, limit=limit,
                                          cabin=cabin)
                all_results.extend(recs)
            except Exception as e:
                logger.warning("Skiplagged %s→%s failed: %s", o, d, e)
        return all_results[:limit] if limit is not None else all_results

    # Multiple pairs — concurrent with max 2 workers
    def _fetch_pair(pair):
        o, d = pair
        try:
            return src.query_by_route(o, d, date, stops=stops, limit=limit,
                                      cabin=cabin)
        except Exception as e:
            logger.warning("Skiplagged %s→%s failed: %s", o, d, e)
            return []

    all_results = []
    seen: set[str] = set()
    with ThreadPoolExecutor(max_workers=min(2, len(pairs))) as executor:
        futures = {executor.submit(_fetch_pair, p): p for p in pairs}
        for future in futures:
            try:
                recs = future.result(timeout=timeout + 5)
                for r in recs:
                    fn = r.get("flight_number", "")
                    if fn and fn not in seen:
                        seen.add(fn)
                        all_results.append(r)
                    elif not fn:
                        all_results.append(r)
            except Exception as e:
                p = futures[future]
                logger.warning("Skiplagged %s→%s future failed: %s",
                               p[0], p[1], e)
    all_results.sort(key=lambda r: r.get("price") or float("inf"))
    return all_results[:limit] if limit is not None else all_results


def _iata_to_city_cn(iata: str) -> str:
    """Return Chinese city name for an IATA code, fallback to the code itself.

    Fliggy API accepts both IATA codes and Chinese city names, and handles
    city-level search natively (e.g. "上海" covers both PVG and SHA).
    """
    info = airport_manager.get_info(iata)
    if info:
        return info.get("city_cn") or info.get("city_en") or iata
    return iata


def _execute_concurrent_queries(
    tasks: list[tuple],
    global_timeout: int,
    return_time: int | None = None,
    sufficient_sources: list[str] | None = None,
) -> list[dict]:
    """Execute query tasks concurrently and collect results.

    Args:
        tasks: list of (name, callable, args_tuple) triples
        global_timeout: max seconds to wait for all tasks
        return_time: if set, return early once this many seconds have
            elapsed AND at least one result is available.  ``None`` or
            0 disables smart-return (wait for all / global timeout).
        sufficient_sources: if set, exit immediately when a task whose
            name matches any entry returns results with prices.  This
            enables "fast return" when a reliable source (e.g. Fliggy)
            responds quickly with priced data.

    Returns:
        Combined list of result dicts from all successful tasks.
        Partial results returned if global timeout or return_time expires.
    """
    if not tasks:
        return []

    results = []
    start = _time.monotonic()
    executor = ThreadPoolExecutor(max_workers=len(tasks))

    future_to_name = {}
    for name, fn, args in tasks:
        future = executor.submit(fn, *args)
        future_to_name[future] = name

    pending = set(future_to_name.keys())
    early_exit = False
    while pending:
        elapsed = _time.monotonic() - start
        remaining = global_timeout - elapsed
        if remaining <= 0:
            logger.warning(
                "Global timeout (%ds) reached, returning partial results",
                global_timeout,
            )
            early_exit = True
            break

        # If smart return is enabled and we already have results,
        # use a short wait so we can exit at the return_time boundary.
        wait_timeout = remaining
        if return_time and results:
            time_to_return = max(0, return_time - elapsed)
            wait_timeout = min(remaining, time_to_return) if time_to_return > 0 else 0

        if wait_timeout <= 0 and return_time and results:
            logger.info(
                "Smart return at %.1fs with %d results (%d sources pending)",
                elapsed, len(results), len(pending),
            )
            early_exit = True
            break

        done, pending = wait(pending, timeout=wait_timeout, return_when=FIRST_COMPLETED)

        for future in done:
            name = future_to_name[future]
            try:
                data = future.result()
                results.extend(data)
                logger.info("%s: %d results", name, len(data))

                # Sufficient source: exit immediately if a trusted source
                # returned priced results (no need to wait for others)
                if sufficient_sources and data and not early_exit:
                    name_lower = name.lower()
                    for ss in sufficient_sources:
                        # Match: config "fliggy_mcp" matches task "Fliggy"
                        ss_key = ss.lower().replace("_", "")
                        if ss_key in name_lower.replace("_", "") or name_lower in ss_key:
                            has_price = any(
                                r.get("price") is not None for r in data
                            )
                            if has_price:
                                logger.info(
                                    "Sufficient source '%s': %d results with "
                                    "prices, skipping remaining sources",
                                    name, len(data),
                                )
                                early_exit = True
                                break
            except Exception as e:
                logger.warning("%s query failed: %s", name, e)

        if early_exit:
            break

        # Smart return: if return_time reached and we have results
        if (
            return_time
            and results
            and pending
            and (_time.monotonic() - start) >= return_time
        ):
            logger.info(
                "Smart return at %.1fs with %d results (%d sources pending)",
                _time.monotonic() - start, len(results), len(pending),
            )
            early_exit = True
            break

    if early_exit:
        executor.shutdown(wait=False, cancel_futures=True)
    else:
        executor.shutdown(wait=False)

    return results


# ---------------------------------------------------------------------------
# Error / degradation warnings
# ---------------------------------------------------------------------------

def _classify_source_failures(task_names: list[str], results: list[dict]) -> dict:
    """Classify source failures for user-facing warnings.

    Args:
        task_names: list of task names submitted (e.g. ["FR24", "GoogleFlights", "Skiplagged"])
        results: combined results from all tasks

    Returns:
        {"google_failed": bool, "sk_failed": bool, "all_failed": bool}
    """
    has_google_task = any("google" in n.lower() for n in task_names)
    has_google_result = any(
        "google" in r.get("source", "").lower() for r in results
    )
    google_failed = has_google_task and not has_google_result

    has_sk_task = any("skiplagged" in n.lower() for n in task_names)
    has_sk_result = any(
        "skiplagged" in r.get("source", "").lower() for r in results
    )
    sk_failed = has_sk_task and not has_sk_result

    has_fliggy_task = any("fliggy" in n.lower() for n in task_names)
    has_fliggy_result = any(
        "fliggy" in r.get("source", "").lower() for r in results
    )
    fliggy_failed = has_fliggy_task and not has_fliggy_result

    all_failed = len(results) == 0 and len(task_names) > 0

    return {
        "google_failed": google_failed,
        "sk_failed": sk_failed,
        "fliggy_failed": fliggy_failed,
        "all_failed": all_failed,
    }


def _print_degradation_warnings(failures: dict) -> None:
    """Print user-friendly warnings to stderr based on failure classification."""
    if failures.get("all_failed"):
        print(
            "[Error] 请检查网络连接 / Please check your network connection",
            file=sys.stderr,
        )
    elif failures.get("google_failed"):
        print(
            "[Note] Google 相关服务在您所在地区可能不可用或网络不佳",
            file=sys.stderr,
        )
    if failures.get("sk_failed"):
        print(
            "[Note] Skiplagged 数据源暂时不可用 / Skiplagged source temporarily unavailable",
            file=sys.stderr,
        )
    if failures.get("fliggy_failed"):
        print(
            "[Note] 飞猪数据源暂时不可用 / Fliggy source temporarily unavailable",
            file=sys.stderr,
        )


# ---------------------------------------------------------------------------
# Route relay helpers
# ---------------------------------------------------------------------------

def _extract_relay_route(results: list[dict], flight_number: str) -> dict | None:
    """Extract origin/destination from Phase 1 results for relay query.

    Returns {"origin": "PVG", "destination": "JFK"} or None if route
    cannot be determined.
    """
    fn_upper = flight_number.strip().upper()
    for r in results:
        if r.get("flight_number", "").upper() == fn_upper:
            o = r.get("origin_iata", "")
            d = r.get("destination_iata", "")
            if o and d:
                return {"origin": o, "destination": d}

    # Fallback: any record with both origin and destination
    for r in results:
        o = r.get("origin_iata", "")
        d = r.get("destination_iata", "")
        if o and d:
            return {"origin": o, "destination": d}

    return None


def _extract_relay_date(results: list[dict], user_date: str | None) -> str:
    """Determine the best date for relay price queries.

    Priority:
    1. User-specified --date (if provided and not 'today')
    2. Nearest future scheduled_departure from Phase 1 results
    3. Today as fallback
    """
    from datetime import date as date_type, datetime
    today = date_type.today().isoformat()

    # 1. User explicitly specified a date
    if user_date and user_date != "today":
        return user_date
    if user_date == "today":
        return today

    # 2. Find nearest future departure from results
    best_date = None
    for r in results:
        dep = r.get("scheduled_departure", "")
        if dep and len(dep) >= 10:
            d = dep[:10]
            if d >= today:
                if best_date is None or d < best_date:
                    best_date = d
    if best_date:
        return best_date

    # 3. Fallback
    return today


def _has_price(results: list[dict]) -> bool:
    """Check if any result has price data."""
    return any(r.get("price") is not None for r in results)


# ---------------------------------------------------------------------------
# CLI commands (query / search)
# ---------------------------------------------------------------------------

def cmd_query(args):
    """Handle 'query' subcommand: query by flight number.

    Implements two-phase route relay:
    Phase 1: FR24 + Airplanes.live + GoogleFlights(cache) — all concurrent
    Phase 2: If relay ON + route discovered + no price → query Google Flights
             with the discovered route to get price data.
    """
    if getattr(args, "date", None) and args.date != "today":
        import re
        if not re.match(r"^\d{4}-\d{2}-\d{2}$", args.date):
            print(f"Error: invalid date format '{args.date}', expected YYYY-MM-DD",
                  file=sys.stderr)
            sys.exit(1)

    conf = cfg.get_config()
    sources_cfg = conf["sources"]
    query_cfg = conf["query"]

    # Relay config
    relay_enabled = query_cfg.get("route_relay", True)
    if getattr(args, "no_relay", False):
        relay_enabled = False
    relay_timeout = query_cfg.get("relay_timeout", 8)

    base_timeout = getattr(args, "timeout", None) or query_cfg["timeout"]
    return_time = getattr(args, "return_time", None)
    if return_time is None:
        return_time = query_cfg.get("return_time")

    # Phase 1: concurrent query all sources
    tasks = []

    if sources_cfg["fr24"]["enabled"]:
        timeout = sources_cfg["fr24"]["timeout"]
        tasks.append(("FR24", _query_fr24_flight, (args.flight, timeout)))

    if sources_cfg["google_flights"]["enabled"]:
        timeout = sources_cfg["google_flights"]["timeout"]
        serpapi_key = sources_cfg["google_flights"].get("serpapi_key", "")
        tasks.append(("GoogleFlights", _query_gf_flight, (args.flight, timeout, serpapi_key)))

    if sources_cfg["airplanes_live"]["enabled"]:
        timeout = sources_cfg["airplanes_live"]["timeout"]
        tasks.append(("AirplanesLive", _query_al_flight, (args.flight, timeout)))

    task_names = [t[0] for t in tasks]
    phase1_start = _time.monotonic()
    results = _execute_concurrent_queries(tasks, base_timeout, return_time)
    phase1_elapsed = _time.monotonic() - phase1_start

    # Update route cache from Phase 1 results
    route_cache.update_from_records(results)
    route_cache.save()

    # Phase 2: Route relay (multi-engine: GF + SK)
    if relay_enabled and results and not _has_price(results):
        route = _extract_relay_route(results, args.flight)
        if route:
            remaining = relay_timeout
            relay_date = _extract_relay_date(
                results, getattr(args, "date", None)
            )
            relay_cfg = query_cfg.get(
                "relay_engines", {"google_flights": True, "skiplagged": True}
            )
            relay_tasks = []
            if (relay_cfg.get("google_flights", True)
                    and sources_cfg["google_flights"]["enabled"]):
                gf_timeout = sources_cfg["google_flights"]["timeout"]
                serpapi_key = sources_cfg["google_flights"].get("serpapi_key", "")
                relay_tasks.append((
                    "GoogleFlights-Relay", _query_gf_route,
                    (route["origin"], route["destination"], relay_date,
                     gf_timeout, serpapi_key),
                ))
            if (relay_cfg.get("skiplagged", True)
                    and sources_cfg.get("skiplagged", {}).get("enabled", False)):
                sk_cfg = sources_cfg["skiplagged"]
                sk_timeout = sk_cfg["timeout"]
                import functools
                sk_relay = functools.partial(
                    _query_sk_route,
                    route["origin"], route["destination"], relay_date, sk_timeout,
                    retry=sk_cfg.get("retry", 3),
                    retry_delay=sk_cfg.get("retry_delay", 0.5),
                    retry_backoff=sk_cfg.get("retry_backoff", 2.0),
                    mcp_enabled=sk_cfg.get("mcp_enabled", False),
                    mcp_url=sk_cfg.get("mcp_url", ""),
                )
                relay_tasks.append((
                    "Skiplagged-Relay", lambda: sk_relay(), (),
                ))
            if relay_tasks and remaining > 2:
                logger.info(
                    "Route relay: %d engine(s) for %s→%s date=%s (%.1fs remaining)",
                    len(relay_tasks), route["origin"],
                    route["destination"], relay_date, remaining,
                )
                task_names.extend(t[0] for t in relay_tasks)
                relay_results = _execute_concurrent_queries(
                    relay_tasks, int(remaining), return_time=1
                )
                results.extend(relay_results)

    # Degradation warnings
    failures = _classify_source_failures(task_names, results)
    _print_degradation_warnings(failures)

    merged = _merge_records(results, date_aware=True)

    if not getattr(args, "show_codeshare", False):
        merged = _deduplicate_codeshares(merged)

    if getattr(args, "date", None):
        filter_date = args.date
        if filter_date == "today":
            from datetime import date as date_type
            filter_date = date_type.today().isoformat()
        merged = [r for r in merged
                  if _extract_date(r.get("scheduled_departure")) == filter_date]

    _apply_currency_conversion(merged, args, conf)

    if args.output == "json":
        print(json.dumps(merged, indent=2, ensure_ascii=False, default=str))
    else:
        print(_format_table(merged, verbose=args.verbose))


def _validate_search_args(args):
    """Validate search arguments, print error and exit on failure."""
    import re
    date_re = re.compile(r"^\d{4}-\d{2}-\d{2}$")

    if args.return_date and not args.date:
        print("Error: --return requires --date", file=sys.stderr)
        sys.exit(1)

    if getattr(args, "compare", False) and not args.date:
        print("Error: --compare requires --date", file=sys.stderr)
        sys.exit(1)

    if getattr(args, "browser", False) and not getattr(args, "compare", False):
        print("Error: --browser requires --compare", file=sys.stderr)
        sys.exit(1)

    if args.date and not date_re.match(args.date):
        print(f"Error: invalid date format '{args.date}', expected YYYY-MM-DD",
              file=sys.stderr)
        sys.exit(1)

    if args.return_date:
        if not date_re.match(args.return_date):
            print(f"Error: invalid return date format '{args.return_date}', expected YYYY-MM-DD",
                  file=sys.stderr)
            sys.exit(1)
        if args.return_date < args.date:
            print("Error: return date must be on or after departure date",
                  file=sys.stderr)
            sys.exit(1)

    if args.adults < 1:
        print("Error: --adults must be at least 1", file=sys.stderr)
        sys.exit(1)

    if args.limit is not None and args.limit < 1:
        print("Error: --limit must be at least 1", file=sys.stderr)
        sys.exit(1)


def cmd_search(args):
    """Handle 'search' subcommand: search by route."""
    _validate_search_args(args)

    # Resolve airport codes from user input (city-level: may return multiple)
    conf = cfg.get_config()
    filter_inactive = conf["query"].get("filter_inactive_airports", True)
    origins = airport_manager.resolve_all(args.from_station,
                                          filter_inactive=filter_inactive)
    dests = airport_manager.resolve_all(args.to_station,
                                        filter_inactive=filter_inactive)

    if not origins:
        print(f"Error: cannot resolve origin '{args.from_station}'", file=sys.stderr)
        sys.exit(1)
    if not dests:
        print(f"Error: cannot resolve destination '{args.to_station}'", file=sys.stderr)
        sys.exit(1)

    origin_str = ",".join(origins)
    dest_str = ",".join(dests)
    if len(origins) > 1 or len(dests) > 1:
        logger.info("City-level search: %s → %s", origin_str, dest_str)
    else:
        logger.info("Resolved: %s → %s", origin_str, dest_str)

    sources_cfg = conf["sources"]
    global_timeout = getattr(args, "timeout", None) or conf["query"]["timeout"]
    return_time = getattr(args, "return_time", None)
    if return_time is None:
        return_time = conf["query"].get("return_time")

    # Limit: only truncate when user specifies --limit
    stops_val = int(args.stops) if args.stops != "any" else "any"
    effective_limit = args.limit  # None if user didn't specify

    # Keyword arguments for Google Flights advanced search
    gf_cfg = sources_cfg["google_flights"]
    gf_kw = dict(
        return_date=args.return_date, adults=args.adults,
        children=args.children, infants=args.infants,
        cabin=args.cabin, stops=stops_val,
        sort=args.sort, limit=effective_limit,
        retry=gf_cfg.get("retry", 3),
        retry_delay=gf_cfg.get("retry_delay", 0.5),
        retry_backoff=gf_cfg.get("retry_backoff", 2.0),
    )

    # For multi-airport: pass list to Google Flights, single to FR24
    gf_origin = origins if len(origins) > 1 else origins[0]
    gf_dest = dests if len(dests) > 1 else dests[0]

    # SK config
    sk_cfg = sources_cfg.get("skiplagged", {})

    tasks = []

    # FR24 (single airport only — use primary)
    if sources_cfg["fr24"]["enabled"]:
        timeout = sources_cfg["fr24"]["timeout"]
        tasks.append(("FR24", _query_fr24_route, (origins[0], dests[0], args.date, timeout)))

    # Google Flights (supports multi-airport)
    if sources_cfg["google_flights"]["enabled"] and args.date:
        timeout = sources_cfg["google_flights"]["timeout"]
        serpapi_key = sources_cfg["google_flights"].get("serpapi_key", "")
        import functools
        gf_task = functools.partial(
            _query_gf_route, gf_origin, gf_dest, args.date, timeout, serpapi_key,
            **gf_kw,
        )
        tasks.append(("GoogleFlights", lambda: gf_task(), ()))

    # Skiplagged (price complement — each airport pair as independent future)
    if sk_cfg.get("enabled", False) and args.date:
        sk_timeout = sk_cfg["timeout"]
        sk_kw = dict(
            stops=stops_val, limit=effective_limit,
            retry=sk_cfg.get("retry", 3),
            retry_delay=sk_cfg.get("retry_delay", 0.5),
            retry_backoff=sk_cfg.get("retry_backoff", 2.0),
            cabin=args.cabin,
            return_date=args.return_date,
            mcp_enabled=sk_cfg.get("mcp_enabled", False),
            mcp_url=sk_cfg.get("mcp_url", ""),
        )
        import functools
        for o in origins:
            for d in dests:
                pair_task = functools.partial(
                    _query_sk_route, o, d, args.date, sk_timeout, **sk_kw,
                )
                label = f"Skiplagged-{o}\u2192{d}" if len(origins) * len(dests) > 1 else "Skiplagged"
                tasks.append((label, lambda t=pair_task: t(), ()))

    # Fliggy MCP (city-level: one request covers all airports)
    fg_cfg = sources_cfg.get("fliggy_mcp", {})
    if fg_cfg.get("enabled", False) and args.date:
        import functools
        fg_origin = _iata_to_city_cn(origins[0])
        fg_dest = _iata_to_city_cn(dests[0])
        fg_timeout = fg_cfg.get("timeout", 10)
        fg_task = functools.partial(
            _query_fliggy_route,
            fg_origin, fg_dest, args.date, fg_timeout,
            cabin=args.cabin, stops=stops_val,
            limit=effective_limit, return_date=args.return_date,
            [REDACTED_SECRET]"api_key", ""),
            sign_secret=fg_cfg.get("sign_secret", ""),
        )
        tasks.append(("Fliggy", lambda t=fg_task: t(), ()))

    task_names = [t[0] for t in tasks]
    ss_cfg = conf["query"].get("sufficient_source", "")
    sufficient = [ss_cfg] if ss_cfg else None
    results = _execute_concurrent_queries(
        tasks, global_timeout, return_time,
        sufficient_sources=sufficient,
    )

    # Degradation warnings
    failures = _classify_source_failures(task_names, results)
    _print_degradation_warnings(failures)

    # Update route cache from results
    route_cache.update_from_records(results)
    route_cache.save()

    merged = _merge_records(results)

    if not getattr(args, "show_codeshare", False):
        merged = _deduplicate_codeshares(merged)

    # When searching with stops (not nonstop-only) and no explicit --sort,
    # sort by stops ascending: nonstop first, then 1-stop, 2-stop...
    if args.stops != "0" and args.sort is None:
        merged.sort(key=lambda r: (
            r.get("stops") if r.get("stops") is not None else 999
        ))

    # Save full merged for fair compare before truncation
    full_merged = list(merged)

    if effective_limit is not None:
        merged = merged[:effective_limit]

    _apply_currency_conversion(merged, args, conf)

    if args.output == "json":
        print(json.dumps(merged, indent=2, ensure_ascii=False, default=str))
    else:
        print(_format_table(merged, verbose=args.verbose))

    # Cross-verification via fast-flights (optional) — uses full_merged
    if getattr(args, "compare", False):
        _run_compare(
            origin=origins[0],
            destination=dests[0],
            date=args.date,
            fli_records=full_merged,
            adults=args.adults,
            children=args.children,
            infants=args.infants,
            cabin=args.cabin,
            max_stops=stops_val if stops_val != "any" else None,
            use_browser=getattr(args, "browser", False),
            output_format=args.output,
        )


def _run_compare(
    origin, destination, date, fli_records, *,
    adults=1, children=0, infants=0, cabin="economy",
    max_stops=None, use_browser=False, output_format="table",
):
    """Cross-verify fli results with fast-flights (and optionally browser baseline)."""
    try:
        from sources.fast_flights_source import FastFlightsSource, _check_ff
    except ImportError:
        print("\n[compare] fast-flights module not found", file=sys.stderr)
        return

    if not _check_ff():
        print(
            "\n[compare] fast-flights not installed. "
            "Install with: pip install fast-flights>=3.0rc0",
            file=sys.stderr,
        )
        return

    from compare import match_flights, compute_diffs
    from compare import format_comparison_table, format_comparison_json

    ff_source = FastFlightsSource()
    ff_records, gf_url = ff_source.query_by_route(
        origin, destination, date,
        adults=adults, children=children, infants=infants, cabin=cabin,
        max_stops=max_stops,
    )

    comparison = match_flights(fli_records, ff_records)
    diffs = compute_diffs(comparison["matched"])

    print()  # blank line separator
    if output_format == "json":
        print(json.dumps(
            format_comparison_json(comparison, diffs, gf_url),
            indent=2, ensure_ascii=False, default=str,
        ))
    else:
        print(format_comparison_table(comparison, diffs, gf_url))

    # Browser baseline (optional, --browser flag)
    if use_browser:
        try:
            from baseline import BrowserBaseline, _check_playwright
        except ImportError:
            print("\n[compare] baseline module not found", file=sys.stderr)
            return
        if not _check_playwright():
            print(
                "\n[compare] playwright not installed. "
                "Install with: pip install playwright && playwright install chromium",
                file=sys.stderr,
            )
            return
        from compare import compare_against_baseline, format_baseline_table

        baseline = BrowserBaseline()
        baseline_records = baseline.verify_route(
            origin, destination, date, cabin=cabin, adults=adults,
        )
        if baseline_records:
            result = compare_against_baseline(fli_records, ff_records, baseline_records)
            print()
            if output_format == "json":
                print(json.dumps(result, indent=2, ensure_ascii=False, default=str))
            else:
                print(format_baseline_table(result))
        else:
            print("\n[compare] Browser baseline returned no results", file=sys.stderr)


# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="flyclaw",
        description=f"FlyClaw - Flight Information Aggregation CLI (v{__version__})",
    )
    parser.add_argument(
        "--version", action="version", version=f"%(prog)s {__version__}"
    )
    parser.add_argument(
        "-v", "--verbose", action="store_true", default=False,
        help="Enable verbose output",
    )
    parser.add_argument(
        "-o", "--output", choices=["table", "json"], default=None,
        help="Output format (default: from config.yaml)",
    )

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # Shared arguments for subparsers
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument(
        "-v", "--verbose", action="store_true", default=False,
        help="Enable verbose output",
    )
    common.add_argument(
        "-o", "--output", choices=["table", "json"], default=None,
        help="Output format (default: from config.yaml)",
    )
    common.add_argument(
        "--currency", choices=["original", "usd", "cny"], default=None,
        help="Output currency: original (keep source currency), usd, cny (overrides config)",
    )
    common.add_argument(
        "--timeout", type=int, default=None,
        help="Global query timeout in seconds (overrides config)",
    )
    common.add_argument(
        "--return-time", type=int, default=None, dest="return_time",
        help="Smart return time in seconds (overrides config; 0 = disable)",
    )

    # query subcommand
    query_p = subparsers.add_parser(
        "query", help="Query by flight number", parents=[common],
    )
    query_p.add_argument(
        "--flight", "-f", required=True,
        help="Flight number (e.g. CA981)",
    )
    query_p.add_argument(
        "--no-relay", action="store_true", default=False, dest="no_relay",
        help="Disable route relay (skip Phase 2 Google Flights price lookup)",
    )
    query_p.add_argument(
        "--date", "-d", default=None,
        help="Filter results by date (YYYY-MM-DD or 'today')",
    )
    query_p.add_argument(
        "--show-codeshare", action="store_true", default=False,
        dest="show_codeshare",
        help="Show all codeshare variants (default: deduplicated)",
    )
    query_p.set_defaults(func=cmd_query)

    # search subcommand
    search_p = subparsers.add_parser(
        "search", help="Search by route", parents=[common],
    )
    search_p.add_argument(
        "--from", dest="from_station", required=True,
        help="Origin airport/city (IATA code, Chinese, or English)",
    )
    search_p.add_argument(
        "--to", dest="to_station", required=True,
        help="Destination airport/city",
    )
    search_p.add_argument(
        "--date", "-d", required=False, default=None,
        help="Travel date (YYYY-MM-DD)",
    )
    search_p.add_argument(
        "--return", "-r", dest="return_date", default=None,
        help="Return date for round-trip (YYYY-MM-DD)",
    )
    search_p.add_argument(
        "--adults", "-a", type=int, default=1,
        help="Number of adult passengers (default: 1)",
    )
    search_p.add_argument(
        "--children", type=int, default=0,
        help="Number of child passengers (default: 0)",
    )
    search_p.add_argument(
        "--infants", type=int, default=0,
        help="Number of infant passengers (default: 0)",
    )
    search_p.add_argument(
        "--cabin", "-C", default="economy",
        choices=["economy", "premium", "business", "first"],
        help="Cabin class (default: economy)",
    )
    search_p.add_argument(
        "--limit", "-l", type=int, default=None,
        help="Max results (default: 99 for nonstop, 20 for with-stops)",
    )
    search_p.add_argument(
        "--sort", "-s", default=None,
        choices=["cheapest", "fastest", "departure", "arrival"],
        help="Sort results by criteria",
    )
    search_p.add_argument(
        "--stops", type=str, default="0",
        choices=["0", "1", "2", "any"],
        help="Max stops: 0=nonstop (default), 1=one stop, 2=two stops, any=all",
    )
    search_p.add_argument(
        "--compare", action="store_true", default=False,
        help="Cross-verify with fast-flights (optional dependency)",
    )
    search_p.add_argument(
        "--browser", action="store_true", default=False,
        help="Use Playwright browser baseline for verification (requires --compare)",
    )
    search_p.add_argument(
        "--show-codeshare", action="store_true", default=False,
        dest="show_codeshare",
        help="Show all codeshare variants (default: deduplicated)",
    )
    search_p.set_defaults(func=cmd_search)

    # update-airports subcommand
    update_p = subparsers.add_parser(
        "update-airports", help="Update airport data from remote URL",
    )
    update_p.add_argument(
        "--url", required=False, default=None,
        help="URL to download airport data from (overrides config)",
    )
    update_p.set_defaults(func=cmd_update_airports)

    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()

    log_level = logging.DEBUG if getattr(args, "verbose", False) else logging.INFO
    logging.basicConfig(
        level=log_level,
        format="%(asctime)s [%(levelname)s] %(message)s",
        datefmt="%H:%M:%S",
        stream=sys.stderr,
    )

    if not args.command:
        parser.print_help()
        sys.exit(1)

    # Resolve output format: CLI flag > config.yaml > "table"
    if getattr(args, "output", None) is None:
        conf = cfg.get_config()
        args.output = conf.get("output", {}).get("format", "table")

    # Auto-update airport data (skip for update-airports command itself)
    if args.command != "update-airports":
        _auto_update_airports()

    args.func(args)


if __name__ == "__main__":
    main()


path: pyproject.toml
content: [build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.backends._legacy:_Backend"

[project]
name = "flyclaw"
version = "0.4.1"
description = "Flight information aggregation CLI tool"
readme = "README.md"
license = {text = "Apache-2.0"}
authors = [{name = "nuaa02", email = "[REDACTED_SECRET]"}]
requires-python = ">=3.11"
dependencies = [
    "requests>=2.28.0",
    "pyyaml>=6.0",
    "flights>=0.7.0",
]

[project.scripts]
flyclaw = "flyclaw:main"

[tool.pytest.ini_options]
markers = [
    "live: tests that make real API calls (deselect with '-m not live')",
]


path: requirements.txt
content: requests>=2.28.0
pyyaml>=6.0
curl_cffi>=0.5.0
flights>=0.7.0
# Optional: mcp>=1.26.0 (MCP backend for Skiplagged, enable via mcp_enabled: true)
# Optional: fast-flights>=3.0rc0 (--compare)
# Optional: playwright (--compare --browser)


path: route_cache.py
content: """Flight route cache for indirect flight-number lookups.

Maintains a mapping of flight_number → {origin, destination, last_seen}
so that sources which only support route queries (e.g. Google Flights)
can participate in flight-number lookups by first resolving the route
from the cache.

Cache file: cache/flight_routes.json
"""

import json
import logging
import os
import time

import config as cfg

logger = logging.getLogger("flyclaw.route_cache")

_DEFAULT_TTL = 30 * 86400  # 30 days in seconds


class RouteCache:
    """In-memory + on-disk route cache."""

    def __init__(
        self,
        cache_path: str | None = None,
        ttl: int = _DEFAULT_TTL,
    ):
        self.cache_path = cache_path or os.path.join(
            cfg.PROJECT_DIR, cfg.DEFAULTS["cache"]["dir"], "flight_routes.json"
        )
        self.ttl = ttl
        self._data: dict[str, dict] = {}
        self._dirty = False
        self._load()

    def _load(self):
        """Load cache from disk."""
        if os.path.exists(self.cache_path):
            try:
                with open(self.cache_path, "r", encoding="utf-8") as f:
                    self._data = json.load(f)
            except (json.JSONDecodeError, OSError) as e:
                logger.warning("Route cache load failed: %s", e)
                self._data = {}

    def get(self, flight_number: str) -> dict | None:
        """Look up route for a flight number.

        Returns {"origin": "PEK", "destination": "JFK"} or None if
        missing or expired.
        """
        fn = flight_number.strip().upper()
        entry = self._data.get(fn)
        if not entry:
            return None
        if time.time() - entry.get("last_seen", 0) > self.ttl:
            return None
        return {"origin": entry["origin"], "destination": entry["destination"]}

    def update(self, flight_number: str, origin: str, destination: str):
        """Update a single route entry."""
        fn = flight_number.strip().upper()
        if not fn or not origin or not destination:
            return
        self._data[fn] = {
            "origin": origin.upper(),
            "destination": destination.upper(),
            "last_seen": int(time.time()),
        }
        self._dirty = True

    def update_from_records(self, records: list[dict]):
        """Batch-update cache from query result records."""
        for rec in records:
            fn = rec.get("flight_number", "")
            origin = rec.get("origin_iata", "")
            dest = rec.get("destination_iata", "")
            if fn and origin and dest:
                self.update(fn, origin, dest)

    def save(self):
        """Write cache to disk (atomic via temp file)."""
        if not self._dirty:
            return
        os.makedirs(os.path.dirname(self.cache_path), exist_ok=True)
        tmp = self.cache_path + ".tmp"
        try:
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(self._data, f, ensure_ascii=False, indent=2)
            os.replace(tmp, self.cache_path)
            self._dirty = False
            logger.debug("Route cache saved (%d entries)", len(self._data))
        except OSError as e:
            logger.warning("Route cache save failed: %s", e)


# Module-level singleton
route_cache = RouteCache()


path: sources/airplaneslive.py
content: """Airplanes.live ADS-B data source with ADSB.lol transparent fallback.

Queries the Airplanes.live free REST API for real-time aircraft position
data.  If Airplanes.live fails, automatically falls back to ADSB.lol
(identical API structure).

Best for: live tracking (altitude, speed, heading, position).
Does NOT provide: flight schedules, prices, or departure/arrival times.

API docs: https://airplanes.live
Rate limit: 1 request/second, no registration required.
"""

import logging

import requests

import config as cfg

logger = logging.getLogger("flyclaw.airplaneslive")


class AirplanesLiveSource:
    """Airplanes.live ADS-B data source with ADSB.lol fallback."""

    def __init__(self, timeout: int = 8):
        self.timeout = timeout

    def query_by_flight(self, flight_number: str) -> list[dict]:
        """Query by IATA flight number (e.g. 'CA981').

        Converts to ICAO callsign internally.  Tries Airplanes.live first,
        falls back to ADSB.lol on failure.  Returns empty list if the
        airline code is not in the mapping or the flight is not currently
        airborne.
        """
        callsign = cfg.iata_flight_to_icao_callsign(flight_number)
        if not callsign:
            logger.debug(
                "No ICAO callsign mapping for %s, skipping", flight_number
            )
            return []

        # Try primary: Airplanes.live
        results = self._try_api(
            cfg.AIRPLANESLIVE_API_BASE, callsign, flight_number
        )
        if results:
            return results

        # Fallback: ADSB.lol
        logger.info("Airplanes.live returned no results, trying ADSB.lol")
        results = self._try_api(
            cfg.ADSB_LOL_API_BASE, callsign, flight_number
        )
        return results

    def _try_api(
        self, base_url: str, callsign: str, flight_number: str
    ) -> list[dict]:
        """Try querying a single ADS-B API endpoint.

        Returns list of parsed records, or empty list on failure.
        """
        url = f"{base_url}/callsign/{callsign}"
        try:
            logger.debug("ADS-B request: %s", url)
            resp = requests.get(url, timeout=self.timeout)
            resp.raise_for_status()
            data = resp.json()
        except requests.RequestException as e:
            logger.warning("ADS-B request failed (%s): %s", base_url, e)
            return []
        except ValueError as e:
            logger.warning("ADS-B JSON parse error (%s): %s", base_url, e)
            return []

        aircraft_list = data.get("ac")
        if not aircraft_list:
            logger.info("ADS-B (%s): no aircraft found for %s", base_url, callsign)
            return []

        results = []
        for ac in aircraft_list:
            try:
                results.append(self._parse_aircraft(ac, flight_number))
            except Exception as e:
                logger.debug("Skipping malformed aircraft record: %s", e)
        return results

    def query_by_route(
        self, origin: str, destination: str, date: str | None = None
    ) -> list[dict]:
        """ADS-B data does not support route-based queries."""
        logger.debug("Airplanes.live does not support route queries, skipping")
        return []

    @staticmethod
    def _parse_aircraft(ac: dict, flight_number: str) -> dict:
        """Convert raw ADS-B aircraft record to standard schema."""
        return {
            "flight_number": flight_number.upper(),
            "airline": "",
            "origin_iata": "",
            "origin_city": "",
            "destination_iata": "",
            "destination_city": "",
            "scheduled_departure": None,
            "scheduled_arrival": None,
            "actual_departure": None,
            "actual_arrival": None,
            "status": "In Air",
            "aircraft_type": ac.get("t", ""),
            "delay_minutes": None,
            "price": None,
            "source": "airplanes_live",
            # ADS-B extended fields
            "altitude_ft": ac.get("alt_baro"),
            "ground_speed_kts": ac.get("gs"),
            "heading": ac.get("track"),
            "latitude": ac.get("lat"),
            "longitude": ac.get("lon"),
            "squawk": ac.get("squawk", ""),
            "hex_code": ac.get("hex", ""),
        }


path: sources/fliggy_mcp.py
content: """Fliggy (飞猪) MCP data source — official Alibaba flight search API.

Best for: flight prices (especially Chinese domestic routes),
route-based search with comprehensive coverage.

Does NOT provide: real-time flight tracking, aircraft type, delay info,
flight number queries.

Key characteristics:
- Best domestic China coverage (covers routes GF/SK often miss)
- Supports international routes (including intl-to-intl)
- ~0.5s response time (domestic), 2-4s (international)
- Prices always in CNY (Chinese Yuan)
- No authentication required (uses public default key from official CLI)
- City-level search: one request covers all airports in a city
- HMAC-SHA256 signed requests (reverse-engineered from @fly-ai/flyai-cli v1.0.6)
"""

import base64
import gzip
import hashlib
import hmac
import json
import logging
import secrets
import time

import requests

from airport_manager import airport_manager

logger = logging.getLogger("flyclaw.fliggy_mcp")

# ---------------------------------------------------------------------------
# Constants (from @fly-ai/flyai-cli v1.0.6 open-source bundle)
# ---------------------------------------------------------------------------
MCP_URL = "https://flyai.open.fliggy.com/mcp"
DEFAULT_API_KEY = "[REDACTED_SECRET]"
DEFAULT_SIGN_SECRET = [REDACTED_SECRET]
X_TTID = "ai2c(sk.clawhub)"
SIGN_VER = "7"

# Cabin class mapping (English → Chinese for client-side filtering)
CABIN_CN_MAP = {
    "economy": "经济舱",
    "premium": "超级经济舱",
    "business": "商务舱",
    "first": "头等舱",
}


# ---------------------------------------------------------------------------
# Signing helpers
# ---------------------------------------------------------------------------

def _sha256_hex(s: str) -> str:
    """SHA-256 hex digest of a UTF-8 string."""
    return hashlib.sha256(s.encode("utf-8")).hexdigest()


def _normalize_auth(auth: str) -> str:
    """Normalize Authorization header value (ensure 'Bearer ' prefix)."""
    auth = (auth or "").strip()
    if not auth:
        return ""
    return auth if auth.startswith("Bearer ") else f"Bearer {auth}"


def _make_signature(
    method: str, pathname: str, timestamp_ms: str,
    nonce: str, body: str, auth: str, sign_secret: str,
) -> str:
    """Compute HMAC-SHA256 signature in base64url (no padding).

    sign_string = METHOD\\nPATHNAME\\nTIMESTAMP\\nNONCE\\nSHA256(body)\\nSHA256(auth)
    """
    body_hash = _sha256_hex(body)
    auth_hash = _sha256_hex(_normalize_auth(auth))
    sign_string = f"{method}\n{pathname}\n{timestamp_ms}\n{nonce}\n{body_hash}\n{auth_hash}"
    sig = hmac.new(
        sign_secret.encode("utf-8"),
        sign_string.encode("utf-8"),
        hashlib.sha256,
    ).digest()
    return base64.urlsafe_b64encode(sig).rstrip(b"=").decode()


def _make_ff_ctx() -> str:
    """Build minimal x-ff-ctx header (base64 of gzip of JSON context)."""
    ctx = json.dumps({"platform": "linux"}).encode("utf-8")
    return base64.b64encode(gzip.compress(ctx)).decode()


# ---------------------------------------------------------------------------
# Source class
# ---------------------------------------------------------------------------

class FliggyMCPSource:
    """Fliggy MCP flight search data source."""

    def __init__(
        self,
        timeout: int = 10,
        api_key: str | None = None,
        sign_secret: str | None = None,
    ):
        self.timeout = timeout
        self.[REDACTED_SECRET] or DEFAULT_API_KEY
        self.sign_secret = sign_secret or DEFAULT_SIGN_SECRET

    # ------------------------------------------------------------------
    # Public interface
    # ------------------------------------------------------------------

    def query_by_flight(self, flight_number: str) -> list[dict]:
        """Fliggy does not support flight number queries."""
        return []

    def query_by_route(
        self,
        origin: str,
        destination: str,
        date: str,
        *,
        return_date: str | None = None,
        cabin: str = "economy",
        stops: int | str = 0,
        limit: int | None = None,
        timeout: int | None = None,
    ) -> list[dict]:
        """Search flights via Fliggy MCP API.

        Args:
            origin: IATA code or Chinese city name (e.g. "PVG", "上海")
            destination: IATA code or Chinese city name
            date: departure date YYYY-MM-DD
            return_date: return date for round-trip (None = one-way)
            cabin: economy/premium/business/first (client-side filtering)
            stops: 0/1/2/"any" (client-side filtering)
            limit: max results (None = API default ~10)
            timeout: per-request timeout override
        """
        try:
            return self._do_query(
                origin, destination, date,
                return_date=return_date, cabin=cabin,
                stops=stops, limit=limit,
                timeout=timeout or self.timeout,
            )
        except Exception as e:
            logger.warning("Fliggy MCP query failed: %s", e)
            return []

    # ------------------------------------------------------------------
    # Internal
    # ------------------------------------------------------------------

    def _call_mcp(self, tool_name: str, arguments: dict, timeout: int) -> dict | None:
        """Call the Fliggy MCP server (tools/call).

        Returns parsed JSON response or None on failure.
        """
        pathname = "/mcp"
        body = json.dumps({
            "jsonrpc": "2.0",
            "id": 1,
            "method": "tools/call",
            "params": {"name": tool_name, "arguments": arguments},
        })
        timestamp_ms = str(int(time.time() * 1000))
        nonce = secrets.token_hex(16)
        auth = f"Bearer {self.api_key}"
        sig = _make_signature(
            "POST", pathname, timestamp_ms, nonce,
            body, auth, self.sign_secret,
        )

        headers = {
            "Content-Type": "application/json",
            "Accept": "application/json",
            "Authorization": auth,
            "x-ff-ctx": _make_ff_ctx(),
            "x-ttid": X_TTID,
            "User-Agent": "flyai-cli/1.0.0",
            "x-flyai-ts": timestamp_ms,
            "x-flyai-sign-ver": SIGN_VER,
            "x-flyai-sign-alg": "hmac-sha256",
            "x-flyai-nonce": nonce,
            "x-flyai-sign": sig,
        }

        resp = requests.post(MCP_URL, headers=headers, data=body, timeout=timeout)
        resp.raise_for_status()
        rj = resp.json()

        # Extract text content from MCP response
        content = rj.get("result", {}).get("content", [{}])
        if not content:
            return None
        text = content[0].get("text")
        if not text:
            return None
        return json.loads(text)

    def _do_query(
        self,
        origin: str, destination: str, date: str,
        *, return_date: str | None = None,
        cabin: str = "economy", stops: int | str = 0,
        limit: int | None = None, timeout: int = 10,
    ) -> list[dict]:
        """Execute search and parse results to FlightRecord dicts."""
        api_args = {
            "origin": origin,
            "destination": destination,
            "depDate": date,
        }
        if return_date:
            api_args["backDate"] = return_date
        if limit is not None:
            api_args["limit"] = limit

        data = self._call_mcp("search_flight", api_args, timeout)
        if not data:
            logger.debug("Fliggy: empty response for %s→%s", origin, destination)
            return []

        items = data.get("data", {}).get("itemList")
        if not items:
            msg = data.get("message", "")
            if msg:
                logger.debug("Fliggy: %s", msg)
            return []

        records = []
        for item in items:
            rec = self._parse_item(item, return_date is not None)
            if rec is None:
                continue
            # Client-side stops filter (server-side journeyType param is broken)
            if stops != "any":
                max_stops = int(stops)
                if rec.get("stops", 0) > max_stops:
                    continue
            # Client-side cabin filter (server-side seatClassName param is broken)
            if cabin != "economy":
                expected_cn = CABIN_CN_MAP.get(cabin, "")
                actual_cn = rec.get("cabin_class", "")
                if expected_cn and actual_cn and expected_cn != actual_cn:
                    continue
            records.append(rec)

        return records

    def _parse_item(self, item: dict, is_round_trip: bool) -> dict | None:
        """Parse a single Fliggy API item into a FlightRecord dict."""
        journeys = item.get("journeys", [])
        if not journeys or not journeys[0].get("segments"):
            return None

        # Outbound journey
        outbound = journeys[0]
        segs = outbound["segments"]
        first_seg = segs[0]
        last_seg = segs[-1]

        # Parse price
        try:
            price = float(item.get("ticketPrice", 0))
        except (ValueError, TypeError):
            price = None
        if price == 0:
            price = None

        # Build flight number: for multi-segment, use first segment
        flight_number = first_seg.get("marketingTransportNo", "")

        # Parse times: "2026-04-10 09:05:00" → "2026-04-10T09:05"
        dep_raw = first_seg.get("depDateTime", "")
        arr_raw = last_seg.get("arrDateTime", "")
        dep_iso = dep_raw.replace(" ", "T")[:16] if dep_raw else None
        arr_iso = arr_raw.replace(" ", "T")[:16] if arr_raw else None

        # Duration
        try:
            duration = int(outbound.get("totalDuration") or first_seg.get("duration", 0))
        except (ValueError, TypeError):
            duration = None

        # City names from airport_manager (more reliable than Fliggy's)
        origin_iata = first_seg.get("depStationCode", "")
        dest_iata = last_seg.get("arrStationCode", "")
        origin_info = airport_manager.get_info(origin_iata)
        dest_info = airport_manager.get_info(dest_iata)

        rec = {
            "flight_number": flight_number,
            "airline": first_seg.get("marketingTransportName", ""),
            "origin_iata": origin_iata,
            "origin_city": (origin_info or {}).get("city_cn", "")
                           or first_seg.get("depCityName", ""),
            "destination_iata": dest_iata,
            "destination_city": (dest_info or {}).get("city_cn", "")
                                or last_seg.get("arrCityName", ""),
            "scheduled_departure": dep_iso,
            "scheduled_arrival": arr_iso,
            "actual_departure": None,
            "actual_arrival": None,
            "status": "",
            "aircraft_type": "",
            "delay_minutes": None,
            "price": price,
            "currency": "CNY",
            "stops": len(segs) - 1,
            "duration_minutes": duration,
            "cabin_class": first_seg.get("seatClassName", ""),
            "source": "fliggy_mcp",
        }

        # Round trip: parse return journey
        if is_round_trip and len(journeys) >= 2:
            ret = journeys[1]
            ret_segs = ret.get("segments", [])
            if ret_segs:
                ret_first = ret_segs[0]
                ret_last = ret_segs[-1]
                ret_dep = ret_first.get("depDateTime", "")
                ret_arr = ret_last.get("arrDateTime", "")
                try:
                    ret_dur = int(ret.get("totalDuration") or ret_first.get("duration", 0))
                except (ValueError, TypeError):
                    ret_dur = None
                rec["trip_type"] = "round_trip"
                rec["return_flight_number"] = ret_first.get("marketingTransportNo", "")
                rec["return_airline"] = ret_first.get("marketingTransportName", "")
                rec["return_departure"] = ret_dep.replace(" ", "T")[:16] if ret_dep else None
                rec["return_arrival"] = ret_arr.replace(" ", "T")[:16] if ret_arr else None
                rec["return_stops"] = len(ret_segs) - 1
                rec["return_duration_minutes"] = ret_dur
        elif is_round_trip:
            rec["trip_type"] = "round_trip"

        return rec


path: sources/fr24.py
content: """FlightRadar24 non-official API data source.

Queries the FR24 public flight list endpoint.  Best for international
flights: departure/arrival times, delay, aircraft, status.
"""

import logging
import time
from datetime import datetime, timezone

import requests

import config as cfg
from airport_manager import airport_manager

logger = logging.getLogger("flyclaw.fr24")

_HEADERS = {
    "User-Agent": cfg.USER_AGENT,
    "Origin": "https://www.flightradar24.com",
    "Referer": "https://www.flightradar24.com/",
    "Accept": "application/json",
}

_FLIGHT_LIST_URL = f"{cfg.FR24_API_BASE}/flight/list.json"


def _ts_to_iso(ts) -> str | None:
    """Convert unix timestamp to ISO-8601 string, or None."""
    if ts is None:
        return None
    try:
        return datetime.fromtimestamp(int(ts), tz=timezone.utc).isoformat()
    except (ValueError, TypeError, OSError):
        return None


def _parse_flight(raw: dict) -> dict:
    """Normalise one FR24 flight record into the standard schema."""
    ident = raw.get("identification", {}) or {}
    number_info = ident.get("number", {}) or {}
    flight_number = number_info.get("default", "")

    airport = raw.get("airport", {}) or {}
    origin = airport.get("origin", {}) or {}
    dest = airport.get("destination", {}) or {}

    origin_iata = (origin.get("code") or {}).get("iata", "")
    dest_iata = (dest.get("code") or {}).get("iata", "")

    origin_city = (origin.get("position", {}).get("region", {}) or {}).get("city", "")
    dest_city = (dest.get("position", {}).get("region", {}) or {}).get("city", "")

    # Enrich with AirportManager display names
    origin_display = airport_manager.get_display_name(origin_iata) if origin_iata else origin_city
    dest_display = airport_manager.get_display_name(dest_iata) if dest_iata else dest_city

    time_info = raw.get("time", {}) or {}
    sched = time_info.get("scheduled", {}) or {}
    real = time_info.get("real", {}) or {}

    status_info = raw.get("status", {}) or {}
    status_text = status_info.get("text", "")

    aircraft_info = raw.get("aircraft", {}) or {}
    model = (aircraft_info.get("model") or {})
    aircraft_text = model.get("text") or model.get("code") or ""

    airline_info = raw.get("airline", {}) or {}
    airline_name = ""
    if airline_info:
        airline_name = airline_info.get("name", "") or ""

    # Calculate delay
    delay = None
    sched_dep = sched.get("departure")
    real_dep = real.get("departure")
    if sched_dep and real_dep:
        delay = (int(real_dep) - int(sched_dep)) // 60

    return {
        "flight_number": flight_number,
        "airline": airline_name,
        "origin_iata": origin_iata,
        "origin_city": origin_display,
        "destination_iata": dest_iata,
        "destination_city": dest_display,
        "scheduled_departure": _ts_to_iso(sched.get("departure")),
        "scheduled_arrival": _ts_to_iso(sched.get("arrival")),
        "actual_departure": _ts_to_iso(real.get("departure")),
        "actual_arrival": _ts_to_iso(real.get("arrival")),
        "status": status_text,
        "aircraft_type": aircraft_text,
        "delay_minutes": delay,
        "price": None,
        "source": "fr24",
    }


class FR24Source:
    """FlightRadar24 data source."""

    def __init__(self, timeout: int = 10):
        self.timeout = timeout

    def query_by_flight(self, flight_number: str) -> list[dict]:
        """Query by flight number (e.g. 'CA981')."""
        params = {
            "query": flight_number.upper(),
            "fetchBy": "flight",
            "page": 1,
            "limit": 25,
        }
        return self._fetch(params)

    def query_by_route(
        self, origin: str, destination: str, date: str | None = None
    ) -> list[dict]:
        """Query by route. FR24 flight list API only supports flight/reg
        queries, not airport-based route queries. We return empty here;
        route search relies on Google Flights."""
        # FR24's public endpoint doesn't support airport-based route lookup.
        # Route queries are handled by Google Flights source.
        logger.debug("FR24 does not support route queries, skipping")
        return []

    def _fetch(self, params: dict) -> list[dict]:
        """Execute HTTP request and parse results."""
        try:
            logger.debug("FR24 request: %s", params)
            resp = requests.get(
                _FLIGHT_LIST_URL,
                headers=_HEADERS,
                params=params,
                timeout=self.timeout,
            )
            resp.raise_for_status()
            data = resp.json()
        except requests.RequestException as e:
            logger.warning("FR24 request failed: %s", e)
            return []
        except ValueError as e:
            logger.warning("FR24 JSON parse error: %s", e)
            return []

        try:
            items = data["result"]["response"]["data"]
            if not items:
                return []
        except (KeyError, TypeError):
            logger.warning("Unexpected FR24 response structure")
            return []

        results = []
        for item in items:
            try:
                results.append(_parse_flight(item))
            except Exception as e:
                logger.debug("Skipping malformed FR24 record: %s", e)
        return results


path: sources/google_flights.py
content: """Google Flights data source with fallback strategy.

Level 1: fli library (Python wrapper for Google Flights)
Level 2: SerpAPI (requires API key in config.yaml)
"""

import logging
from datetime import datetime, date as date_type

import requests

import config as cfg
from airport_manager import airport_manager
from route_cache import route_cache

logger = logging.getLogger("flyclaw.google_flights")

# Lazy imports for optional dependencies
_fli_available = None


def _check_fli():
    global _fli_available
    if _fli_available is None:
        try:
            from fli.search import SearchFlights  # noqa: F401
            _fli_available = True
        except ImportError:
            _fli_available = False
    return _fli_available


def _format_city(iata: str) -> str:
    """Get display name for an IATA code."""
    return airport_manager.get_display_name(iata) if iata else ""


# Mapping from user-facing cabin names to fli SeatType enum values.
# Imported lazily (fli might not be installed), so we store the int values.
_CABIN_MAP = {
    "economy": 1,       # SeatType.ECONOMY
    "premium": 2,       # SeatType.PREMIUM_ECONOMY
    "business": 3,      # SeatType.BUSINESS
    "first": 4,         # SeatType.FIRST
}

_SORT_MAP = {
    "cheapest": 2,      # SortBy.CHEAPEST
    "fastest": 5,       # SortBy.DURATION
    "departure": 3,     # SortBy.DEPARTURE_TIME
    "arrival": 4,       # SortBy.ARRIVAL_TIME
}


class GoogleFlightsSource:
    """Google Flights data source with multi-level fallback."""

    def __init__(self, timeout: int = 15, serpapi_key: str = "",
                 retry: int = 3,
                 retry_delay: float = 0.5, retry_backoff: float = 2.0):
        self.timeout = timeout
        self.serpapi_key = serpapi_key
        self.max_retries = retry
        self.retry_delay = retry_delay
        self.retry_backoff = retry_backoff

    def query_by_flight(self, flight_number: str) -> list[dict]:
        """Query by flight number using route cache.

        Looks up the flight's route in the cache, then delegates to
        query_by_route.  Returns empty if route is unknown.
        """
        route = route_cache.get(flight_number)
        if not route:
            logger.info(
                "Google Flights: no cached route for %s, skipping",
                flight_number,
            )
            return []

        today = date_type.today().isoformat()
        results = self.query_by_route(
            route["origin"], route["destination"], today
        )

        fn_upper = flight_number.strip().upper()
        matched = [
            r for r in results
            if r.get("flight_number", "").upper() == fn_upper
        ]
        return matched if matched else results

    def query_by_route(
        self, origin: str | list[str], destination: str | list[str], date: str,
        *, return_date: str | None = None, adults: int = 1,
        children: int = 0, infants: int = 0,
        cabin: str = "economy", stops: int | str = 0,
        sort: str | None = None, limit: int | None = None,
    ) -> list[dict]:
        """Search flights by route. Tries fli library first, then SerpAPI.

        origin/destination can be a single IATA code or a list of codes
        for multi-airport city searches.
        """
        kw = dict(return_date=return_date, adults=adults, children=children,
                  infants=infants, cabin=cabin, stops=stops,
                  sort=sort, limit=limit)

        # Level 1: fli library
        if _check_fli():
            results = self._query_via_fli(origin, destination, date, **kw)
            if results:
                return results
            logger.info("fli library returned no results, trying next level")

        # Level 2: SerpAPI
        if self.serpapi_key:
            results = self._query_via_serpapi(origin, destination, date, **kw)
            if results:
                return results
            logger.info("SerpAPI returned no results")

        logger.info("Google Flights: no results from any level")
        return []

    def _query_via_fli(
        self, origin: str | list[str], destination: str | list[str], date: str,
        *, return_date: str | None = None, adults: int = 1,
        children: int = 0, infants: int = 0,
        cabin: str = "economy", stops: int | str = 0,
        sort: str | None = None, limit: int | None = None,
    ) -> list[dict]:
        """Level 1: fli Python library with smart retry on empty results."""
        import time as _time

        attempts = self.max_retries + 1
        for attempt in range(attempts):
            results = self._query_via_fli_single(
                origin, destination, date,
                return_date=return_date, adults=adults,
                children=children, infants=infants,
                cabin=cabin, stops=stops, sort=sort, limit=limit,
            )
            if results:
                return results
            if attempt < attempts - 1:
                delay = self.retry_delay * (self.retry_backoff ** attempt)
                logger.info(
                    "fli empty response attempt %d, retrying in %.1fs...",
                    attempt + 1, delay,
                )
                _time.sleep(delay)
        return []

    def _query_via_fli_single(
        self, origin: str | list[str], destination: str | list[str], date: str,
        *, return_date: str | None = None, adults: int = 1,
        children: int = 0, infants: int = 0,
        cabin: str = "economy", stops: int | str = 0,
        sort: str | None = None, limit: int | None = None,
    ) -> list[dict]:
        """Single fli query attempt (no retry).

        origin/destination can be str or list[str] for multi-airport queries.
        """
        try:
            from fli.models.google_flights.base import Airport as FliAirport
            from fli.models.google_flights.flights import (
                FlightSearchFilters,
                FlightSegment,
                PassengerInfo,
            )
            from fli.search import SearchFlights
        except ImportError:
            return []

        # Normalize to lists
        origins = origin if isinstance(origin, list) else [origin]
        dests = destination if isinstance(destination, list) else [destination]

        # Build multi-airport departure/arrival lists
        dep_airports = []
        for o in origins:
            try:
                dep_airports.append([FliAirport[o.upper()], 0])
            except KeyError:
                logger.warning("Airport code not in fli enum: %s", o)

        arr_airports = []
        for d in dests:
            try:
                arr_airports.append([FliAirport[d.upper()], 0])
            except KeyError:
                logger.warning("Airport code not in fli enum: %s", d)

        if not dep_airports or not arr_airports:
            return []

        try:
            from fli.models.google_flights.base import MaxStops, SortBy
            from fli.models import SeatType, TripType

            outbound_seg = FlightSegment(
                departure_airport=dep_airports,
                arrival_airport=arr_airports,
                travel_date=date,
            )
            segments = [outbound_seg]

            is_round_trip = return_date is not None
            if is_round_trip:
                return_seg = FlightSegment(
                    departure_airport=arr_airports,
                    arrival_airport=dep_airports,
                    travel_date=return_date,
                )
                segments.append(return_seg)

            seat_val = _CABIN_MAP.get(cabin, 1)
            seat_type = SeatType(seat_val)
            _STOPS_MAP = {
                0: MaxStops.NON_STOP,
                1: MaxStops.ONE_STOP_OR_FEWER,
                2: MaxStops.TWO_OR_FEWER_STOPS,
                "any": MaxStops.ANY,
            }
            stops_enum = _STOPS_MAP.get(stops, MaxStops.NON_STOP)
            sort_val = _SORT_MAP.get(sort, 0)
            sort_by = SortBy(sort_val)

            fli_trip_type = TripType.ROUND_TRIP if is_round_trip else TripType.ONE_WAY
            # fli top_n: use limit if set, otherwise 200 as practical max
            fli_top_n = limit if limit is not None else 200
            if is_round_trip:
                fli_top_n = min(fli_top_n, 5)

            filters = FlightSearchFilters(
                passenger_info=PassengerInfo(
                    adults=adults, children=children,
                    infants_in_seat=infants,
                ),
                flight_segments=segments,
                seat_type=seat_type,
                stops=stops_enum,
                sort_by=sort_by,
                trip_type=fli_trip_type,
            )
            sf = SearchFlights()
            raw_results = sf.search(filters, top_n=fli_top_n)
        except Exception as e:
            logger.warning("fli search failed: %s", e)
            return []

        if not raw_results:
            return []

        # Primary airports for display fallback
        primary_origin = origins[0].upper()
        primary_dest = dests[0].upper()

        results = []
        for r in raw_results:
            if limit is not None and len(results) >= limit:
                break
            try:
                if is_round_trip and isinstance(r, tuple) and len(r) == 2:
                    parsed = self._parse_fli_round_trip(
                        r[0], r[1], primary_origin, primary_dest, cabin
                    )
                else:
                    parsed = self._parse_fli_result(r, primary_origin, primary_dest)
                    parsed["trip_type"] = "one_way"
                    parsed["cabin_class"] = cabin
                results.append(parsed)
            except Exception as e:
                logger.debug("Skipping fli result: %s", e)
        return results

    def _parse_fli_result(self, result, origin: str, dest: str) -> dict:
        """Convert fli FlightResult to standard schema.

        origin/dest are fallback values; actual airports are extracted
        from leg data when available.
        """
        legs = result.legs
        if not legs:
            return self._empty_record(origin, dest)

        first_leg = legs[0]
        last_leg = legs[-1]

        flight_number = ""
        airline = ""
        if first_leg.airline:
            airline = first_leg.airline.value or ""
        if first_leg.flight_number:
            # Construct full flight number from airline code + number
            airline_code = first_leg.airline.name if first_leg.airline else ""
            flight_number = f"{airline_code}{first_leg.flight_number}"

        dep_dt = first_leg.departure_datetime
        arr_dt = last_leg.arrival_datetime

        # Extract actual origin/dest from legs when available
        actual_origin = origin
        actual_dest = dest
        if hasattr(first_leg, 'departure_airport') and first_leg.departure_airport:
            dep_ap = first_leg.departure_airport
            if hasattr(dep_ap, 'name'):
                actual_origin = dep_ap.name
        if hasattr(last_leg, 'arrival_airport') and last_leg.arrival_airport:
            arr_ap = last_leg.arrival_airport
            if hasattr(arr_ap, 'name'):
                actual_dest = arr_ap.name

        return {
            "flight_number": flight_number,
            "airline": airline,
            "origin_iata": actual_origin,
            "origin_city": _format_city(actual_origin),
            "destination_iata": actual_dest,
            "destination_city": _format_city(actual_dest),
            "scheduled_departure": dep_dt.isoformat() if dep_dt else None,
            "scheduled_arrival": arr_dt.isoformat() if arr_dt else None,
            "actual_departure": None,
            "actual_arrival": None,
            "status": "",
            "aircraft_type": "",
            "delay_minutes": None,
            "price": result.price,
            "stops": result.stops,
            "duration_minutes": result.duration,
            "source": "google_flights",
        }

    def _parse_fli_round_trip(
        self, outbound, inbound, origin: str, dest: str, cabin: str
    ) -> dict:
        """Convert a fli round-trip pair to standard schema."""
        record = self._parse_fli_result(outbound, origin, dest)
        record["trip_type"] = "round_trip"
        record["cabin_class"] = cabin

        # Return flight info
        ret_legs = inbound.legs
        if ret_legs:
            first_ret = ret_legs[0]
            last_ret = ret_legs[-1]
            ret_fn = ""
            ret_airline = ""
            if first_ret.airline:
                ret_airline = first_ret.airline.value or ""
                airline_code = first_ret.airline.name if first_ret.airline else ""
                if first_ret.flight_number:
                    ret_fn = f"{airline_code}{first_ret.flight_number}"

            ret_dep = first_ret.departure_datetime
            ret_arr = last_ret.arrival_datetime

            record["return_flight_number"] = ret_fn
            record["return_airline"] = ret_airline
            record["return_departure"] = ret_dep.isoformat() if ret_dep else None
            record["return_arrival"] = ret_arr.isoformat() if ret_arr else None
            record["return_stops"] = inbound.stops
            record["return_duration_minutes"] = inbound.duration
        else:
            record["return_flight_number"] = ""
            record["return_airline"] = ""
            record["return_departure"] = None
            record["return_arrival"] = None
            record["return_stops"] = None
            record["return_duration_minutes"] = None

        # Price is the total for the pair
        record["price"] = outbound.price

        return record

    def _query_via_serpapi(
        self, origin: str | list[str], destination: str | list[str], date: str,
        *, return_date: str | None = None, adults: int = 1,
        children: int = 0, infants: int = 0,
        cabin: str = "economy", stops: int | str = 0,
        sort: str | None = None, limit: int | None = None,
    ) -> list[dict]:
        """Level 2: SerpAPI Google Flights endpoint.

        Multi-airport: comma-separated IATA codes for departure_id/arrival_id.
        """
        _SERPAPI_CABIN = {
            "economy": "1", "premium": "2", "business": "3", "first": "4",
        }
        # Normalize to lists then join with comma for SerpAPI
        origins = origin if isinstance(origin, list) else [origin]
        dests = destination if isinstance(destination, list) else [destination]
        dep_id = ",".join(o.upper() for o in origins)
        arr_id = ",".join(d.upper() for d in dests)

        is_round_trip = return_date is not None
        try:
            params = {
                "engine": "google_flights",
                "departure_id": dep_id,
                "arrival_id": arr_id,
                "outbound_date": date,
                "type": "1" if is_round_trip else "2",
                "currency": "USD",
                "hl": "en",
                "api_key": self.serpapi_key,
                "adults": str(adults),
                "travel_class": _SERPAPI_CABIN.get(cabin, "1"),
            }
            if is_round_trip:
                params["return_date"] = return_date
            if children > 0:
                params["children"] = str(children)
            if infants > 0:
                params["infants_in_seat"] = str(infants)
            if stops != "any":
                params["stops"] = str(stops)
            resp = requests.get(
                "https://serpapi.com/search",
                params=params,
                timeout=self.timeout,
            )
            resp.raise_for_status()
            data = resp.json()
        except Exception as e:
            logger.warning("SerpAPI request failed: %s", e)
            return []

        best = data.get("best_flights", [])
        other = data.get("other_flights", [])
        all_flights = best + other
        if limit is not None:
            all_flights = all_flights[:limit]

        trip_type_str = "round_trip" if is_round_trip else "one_way"
        primary_origin = origins[0].upper()
        primary_dest = dests[0].upper()
        results = []
        for flight in all_flights:
            try:
                parsed = self._parse_serpapi_result(
                    flight, primary_origin, primary_dest
                )
                parsed["trip_type"] = trip_type_str
                parsed["cabin_class"] = cabin
                results.append(parsed)
            except Exception as e:
                logger.debug("Skipping SerpAPI result: %s", e)
        return results

    def _parse_serpapi_result(
        self, flight: dict, origin: str, dest: str
    ) -> dict:
        """Convert SerpAPI flight result to standard schema."""
        legs = flight.get("flights", [])
        first = legs[0] if legs else {}
        last = legs[-1] if legs else {}

        dep_airport = first.get("departure_airport", {})
        arr_airport = last.get("arrival_airport", {})

        return {
            "flight_number": first.get("flight_number", ""),
            "airline": first.get("airline", ""),
            "origin_iata": dep_airport.get("id", origin),
            "origin_city": _format_city(dep_airport.get("id", origin)),
            "destination_iata": arr_airport.get("id", dest),
            "destination_city": _format_city(arr_airport.get("id", dest)),
            "scheduled_departure": dep_airport.get("time"),
            "scheduled_arrival": arr_airport.get("time"),
            "actual_departure": None,
            "actual_arrival": None,
            "status": "",
            "aircraft_type": first.get("airplane", ""),
            "delay_minutes": None,
            "price": flight.get("price"),
            "stops": len(flight.get("layovers", [])),
            "duration_minutes": flight.get("total_duration"),
            "source": "google_flights",
        }

    @staticmethod
    def _empty_record(origin: str, dest: str) -> dict:
        return {
            "flight_number": "",
            "airline": "",
            "origin_iata": origin,
            "origin_city": _format_city(origin),
            "destination_iata": dest,
            "destination_city": _format_city(dest),
            "scheduled_departure": None,
            "scheduled_arrival": None,
            "actual_departure": None,
            "actual_arrival": None,
            "status": "",
            "aircraft_type": "",
            "delay_minutes": None,
            "price": None,
            "source": "google_flights",
        }


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)