Skill: jackzhao3925/flight-monitor (version 1.1.1)

--- name: flight-monitor description: 机票查询与价格监控技能。支持单程/往返查询、价格阈值提醒、定时监控、手机推送通知（Bark/Server酱/PushDeer）。触发词示例：查一下北京到三亚的机票、帮我看看上海飞成都下周六往返票、监控杭州到西安3月26日机票低于500提醒我、查看所有机票监控任务。 --- # Flight Monitor Skill 查询国内外实时机票价格，设置低价提醒，支持定时监控。无需浏览器自动化，无需 API 密钥。 --- ## Quick Start ### 单程查询 ``` 查一下北京到三亚 3 月 25 日的机票价格 Check flights from BJS to SYX on 2026-03-25 查询杭州到西安 3 月 26 日，低于 500 元的机票 ``` ### 往返查询 ``` 帮我看看上海飞成都，4 月 1 日去 4 月 5 日回 上海到成都下周六往返票 ``` ### 设置价格监控与推送 ``` 监控赣州到哈尔滨 4 月 10 日，每天查一次，低于 700 提醒我 Monitor BJS to SYX on 2026-03-25, daily, alert if under ¥1500 ``` ### 管理监控任务 ``` 查看我所有的机票监控任务 暂停北京到三亚的监控 删除杭州到西安的监控任务 ``` ### 配置手机推送 ``` 设置推送 Bark Key: xxxxx ``` --- ## Core Workflow ### Step 1 — Parse User Intent Extract these parameters from the user's message: | Parameter | Required | Example | |-----------|----------|---------| | 出发城市 dep | Yes | 北京 / BJS | | 到达城市 arr | Yes | 三亚 / SYX | | 出发日期 date | Yes | 2026-04-10 | | 返程日期 return_date | Round-trip only | 2026-04-15 | | 最高价 max_price | No | 700 | | 监控频率 freq | For monitoring | daily / 6h | City name→code resolution is handled automatically by the scripts. For cities not recognized, check `references/city_codes.md`. ### Step 2 — Query Flight Data **⚠️ CRITICAL DATE RULE: Always use the user's exact travel date. NEVER substitute today's date.** #### Step 2a — Run search_flights.py (always do this first) ```bash python scripts/search_flights.py --from <dep> --to <arr> --date <YYYY-MM-DD> python scripts/search_flights.py --from KOW --to HRB --date 2026-04-10 --max-price 700 python scripts/search_flights.py --from SHA --to CTU --date 2026-04-01 --return-date 2026-04-05 ``` The script tries three data sources in order and returns one of: | Result `source` | What it means | What you do next | |----------------|---------------|------------------| | `ctrip` | Full flight list fetched directly — **done** | Format the `flights` list into a table. **Skip all web searches.** | | `zbape` | Lowest price summary only (no schedule) | Show the price summary + booking links. **Skip all web searches.** | | `fallback` | APIs unavailable | Do **exactly ONE** `web_search` using the `search_query` field. Then stop. **Do NOT fetch any URLs.** | #### Step 2b — Fallback web_search (only when source == "fallback") Use the exact query string from `search_query` in the script output: ``` web_search("<value of search_query field>") ``` Parse snippets for: airline name, flight number, departure time, arrival time, price. **Stop after this one search**, regardless of result quality. Use whatever data the snippets contain. #### HARD RULES — no exceptions - **NEVER call `web_fetch`** on any URL (携程/去哪儿/Skyscanner are all JS-rendered; fetching wastes time and returns nothing useful). - **NEVER run more than 1 `web_search`** per query direction (outbound / return). - **NEVER try multiple search engines** or reformulate the query if the first search returns partial data. - If data is incomplete after one search, present what you have and note the limitation. ### Step 3 — Record Price History After querying, always record the result for trend tracking: ```bash python scripts/price_history.py append \ --from KOW --to HRB --date 2026-04-10 \ --price <lowest_price_found> --flight "<flight_number>" --threshold 700 # View history for a route python scripts/price_history.py show --from KOW --to HRB --date 2026-04-10 # List all routes with history python scripts/price_history.py list ``` History files: `~/.workbuddy/flight-monitor/{DEP}-{ARR}-{DATE}.json` ### Step 4 — Check Alert & Send Notification After recording, check if the price is at or below threshold: ```bash python scripts/price_history.py show --from KOW --to HRB --date 2026-04-10 ``` If the output contains "低价提醒" (price ≤ threshold): ```bash python scripts/notify.py \ --title "机票低价提醒 KOW→HRB" \ --body "赣州→哈尔滨 2026-04-10 最低价 ¥<price>，低于阈值¥700！推荐航班 <flight>" \ --url "https://flights.ctrip.com/itinerary/oneway/kow-hrb?depdate=2026-04-10" ``` notify.py will automatically use whichever push service is configured. If no push service is configured, it will print instructions to set one up. ### Step 5 — Set Up Monitoring (when user requests recurring checks) ```bash # Add a daily monitor with price alert python scripts/monitor_manager.py add \ --from KOW --to HRB --date 2026-04-10 \ --freq daily --threshold 700 # Every 6 hours python scripts/monitor_manager.py add \ --from HGH --to SIA --date 2026-03-26 --freq 6h # List all monitors python scripts/monitor_manager.py list # Pause a monitor python scripts/monitor_manager.py pause --id flight-KOW-HRB-2026-04-10 # Remove a monitor python scripts/monitor_manager.py remove --id flight-KOW-HRB-2026-04-10 # Show details / manual trigger hint (does NOT execute any shell command) python scripts/monitor_manager.py run --id flight-KOW-HRB-2026-04-10 ``` Monitor tasks are saved as TOML files under `~/.workbuddy/automations/`. **Security notes:** - `monitor_manager.py` does **not** import `os` and contains **no** `os.system()` / `subprocess` calls. - All `--id` arguments are validated against a strict whitelist regex (`flight-[A-Z]+-[A-Z]+-YYYY-MM-DD`) before any filesystem access, preventing path traversal attacks. - The `run` subcommand only prints a safe hint — it never executes external commands. --- ## Push Notification Setup Three free services are supported. Configure one: ### Option A — Bark (iOS only, recommended for iPhone users) 1. Install Bark app from App Store 2. Open the app → copy your device key 3. Configure: `python scripts/notify.py --setup bark --key <YOUR_KEY>` ### Option B — Server酱 (WeChat notification, Android & iOS) 1. Visit https://sct.ftqq.com/ and login with GitHub 2. Copy your SendKey 3. Configure: `python scripts/notify.py --setup serverchan --key <YOUR_SENDKEY>` ### Option C — PushDeer (open source, Android & iOS) 1. Install PushDeer app or use web version at https://www.pushdeer.com/ 2. Create a device and copy the push key 3. Configure: `python scripts/notify.py --setup pushdeer --key <YOUR_KEY>` Configuration is saved to `~/.workbuddy/flight-monitor/notify_config.json`. --- ## Frequency Options | Input | Schedule | Label | |-------|----------|-------| | `hourly` / `1h` | Every 1 hour | 每1小时 | | `2h` | Every 2 hours | 每2小时 | | `3h` | Every 3 hours | 每3小时 | | `6h` | Every 6 hours | 每6小时 | | `12h` / `twice-daily` | Every 12 hours | 每12小时 | | `daily` / `morning` | Daily at 09:00 CST | 每天9:00 | **Recommended frequencies by lead time:** | Lead Time | Recommended Freq | |-----------|-----------------| | 1 month+ | daily | | 2 weeks | 12h | | 1 week | 6h | | 3 days | 3h | --- ## Output Format ### Query Result (shown to user) Always present results in the following table format. For connecting flights, the "中转" column must list the connection city and layover wait time (e.g. "转上海 1h20m"). ```markdown ## 机票查询：赣州(KOW) → 哈尔滨(HRB) **出行日期：** 2026-04-10（⚠️ 以下均为该日期价格） **价格筛选：** ≤ ¥700 | 航班 | 价格(含税) | 出发 | 到达 | 全程时长 | 中转（城市 + 等待时间） | |------|-----------|------|------|----------|------------------------| | 祥鹏 8L9549 | ¥620 | 09:35 | 13:30 | 3h55m | 直飞 | | 东航 MU2993+MU5619 | ¥650 | 20:10 | 00:35+1 | 7h25m | 转上海虹桥 1h50m | 最低价：¥620 [立即预订 →](https://flights.ctrip.com/itinerary/oneway/kow-hrb?depdate=2026-04-10) ``` **Column definitions:** - `全程时长`: total elapsed time from departure to final arrival (including layover) - `中转（城市 + 等待时间）`: for connecting flights, list connection city + layover wait duration; write "直飞" for non-stop ### Low-Price Alert (sent to phone) ``` 🔔 机票低价提醒！ 赣州(KOW) → 哈尔滨(HRB) | 出行日期：2026-04-10 当前最低价：¥620（低于阈值 ¥700） 推荐航班：祥鹏 8L9549 09:35 → 13:30（直飞） 立即预订：https://flights.ctrip.com/... ``` --- ## Notes - **⚠️ Date discipline:** Always search for the user's exact travel date. The travel date is NOT today's date. - **Query flow:** Always start with `search_flights.py`. Only fall back to `web_search` when the script returns `source: fallback`. Maximum 1 search per direction. - **Never use web_fetch:** All major OTA sites (携程, 去哪儿, Skyscanner) are JavaScript-rendered. Fetching them returns empty or useless content and wastes tokens. - **Data source priority:** Ctrip AJAX API (richest, no key) → zbape.com (key required, lowest-price only) → single web_search fallback. - **zbape key (optional):** Run `python scripts/search_flights.py --setup-zbape <KEY>` once. Adds a useful lowest-price-per-date layer when the Ctrip API is rate-limited. - **Push notifications:** Configure once, alerts fire automatically on every monitoring cycle when price drops below threshold. - **City codes:** See `references/city_codes.md` for the full list. --- ## File Overview ``` flight-monitor/ ├── SKILL.md ← This file ├── scripts/ │ ├── search_flights.py ← PRIMARY: Multi-source flight query (Ctrip API → zbape → fallback) │ ├── query_flights.py ← Legacy: Search query generator + booking links │ ├── price_history.py ← Append/read/alert price history records │ ├── monitor_manager.py ← Add/pause/remove scheduled monitors │ └── notify.py ← Mobile push notifications (Bark/Server酱/PushDeer) └── references/ └── city_codes.md ← Full city name → IATA code table ``` ### Optional: zbape API key (adds lowest-price-per-date data when Ctrip API fails) ```bash python scripts/search_flights.py --setup-zbape <YOUR_KEY> # Get a free key at: https://api.zbape.com/doc/54 ```

path: scripts/monitor_manager.py
content: #!/usr/bin/env python3
"""
monitor_manager.py — Manage scheduled flight price monitoring tasks.

Uses WorkBuddy automation system (TOML-based) under:
  ~/.workbuddy/automations/

Supports cron-like schedule expressions compatible with both WorkBuddy and OpenClaw.

Usage:
    python monitor_manager.py add   --from BJS --to SYX --date 2026-03-25 --freq daily --threshold 1500
    python monitor_manager.py add   --from HGH --to SIA --date 2026-03-26 --freq 6h
    python monitor_manager.py list
    python monitor_manager.py pause --id flight-BJS-SYX-2026-03-25
    python monitor_manager.py remove --id flight-BJS-SYX-2026-03-25
    python monitor_manager.py run   --id flight-BJS-SYX-2026-03-25  # manual trigger
"""

import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path

try:
    import tomllib          # Python 3.11+
except ImportError:
    try:
        import tomli as tomllib
    except ImportError:
        tomllib = None       # fallback: manual parsing

AUTOMATIONS_DIR = Path.home() / ".workbuddy" / "automations"
SCRIPTS_DIR = Path(__file__).parent

# ── Frequency → cron expression ─────────────────────────────────────────────
FREQ_MAP = {
    "hourly": "FREQ=HOURLY;INTERVAL=1",
    "1h": "FREQ=HOURLY;INTERVAL=1",
    "2h": "FREQ=HOURLY;INTERVAL=2",
    "3h": "FREQ=HOURLY;INTERVAL=3",
    "6h": "FREQ=HOURLY;INTERVAL=6",
    "12h": "FREQ=HOURLY;INTERVAL=12",
    "daily": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU;BYHOUR=9;BYMINUTE=0",
    "morning": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU;BYHOUR=9;BYMINUTE=0",
    "twice-daily": "FREQ=HOURLY;INTERVAL=12",
}

FREQ_LABELS = {
    "hourly": "每小时", "1h": "每1小时", "2h": "每2小时",
    "3h": "每3小时", "6h": "每6小时", "12h": "每12小时",
    "daily": "每天9:00", "morning": "每天早上9:00", "twice-daily": "每天早晚各1次",
}


_TASK_ID_RE = re.compile(r'^flight-[A-Z]{2,4}-[A-Z]{2,4}-\d{4}-\d{2}-\d{2}$')

# Individual field validators (whitelist approach)
_CITY_CODE_RE = re.compile(r'^[A-Za-z]{2,4}$')
_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
_FREQ_RE = re.compile(r'^(hourly|1h|2h|3h|6h|12h|daily|morning|twice-daily)$')


def _validate_city_code(code: str) -> bool:
    """Only allow 2-4 letter IATA city/airport codes."""
    return bool(_CITY_CODE_RE.match(code))


def _validate_date(date: str) -> bool:
    """Only allow dates in YYYY-MM-DD format with plausible values."""
    if not _DATE_RE.match(date):
        return False
    try:
        datetime.strptime(date, "%Y-%m-%d")
        return True
    except ValueError:
        return False


def _safe_resolve_under(path: Path, base: Path) -> bool:
    """Return True only if resolved path is strictly under base directory."""
    try:
        path.resolve().relative_to(base.resolve())
        return True
    except ValueError:
        return False


def validate_task_id(task_id: str) -> bool:
    """
    Whitelist check: only allow IDs of the form flight-XXX-XXX-YYYY-MM-DD.
    Prevents path traversal (e.g. '../../etc/passwd') and shell metacharacters.
    """
    return bool(_TASK_ID_RE.match(task_id))


def make_task_id(dep: str, arr: str, date: str) -> str:
    return f"flight-{dep.upper()}-{arr.upper()}-{date}"


def add_monitor(dep: str, arr: str, date: str, freq: str,
                threshold: float = None, return_date: str = None) -> str:
    # ── Input validation ────────────────────────────────────────────────────
    if not _validate_city_code(dep):
        return f"❌ 无效的出发城市代码 `{dep}`（只允许 2-4 位字母 IATA 代码，如 BJS、SHA）"
    if not _validate_city_code(arr):
        return f"❌ 无效的到达城市代码 `{arr}`（只允许 2-4 位字母 IATA 代码，如 SYX、CTU）"
    if not _validate_date(date):
        return f"❌ 无效的日期 `{date}`（格式应为 YYYY-MM-DD，如 2026-04-10）"
    if return_date and not _validate_date(return_date):
        return f"❌ 无效的返程日期 `{return_date}`（格式应为 YYYY-MM-DD）"
    if freq not in FREQ_MAP:
        return f"❌ 无效的频率 `{freq}`，支持：{', '.join(FREQ_MAP.keys())}"

    task_id = make_task_id(dep, arr, date)
    # Double-check the generated ID still passes whitelist (belt-and-suspenders)
    if not validate_task_id(task_id):
        return f"❌ 生成的任务 ID `{task_id}` 未通过安全校验"

    task_dir = AUTOMATIONS_DIR / task_id
    # Path boundary check: ensure target is strictly inside AUTOMATIONS_DIR
    if not _safe_resolve_under(task_dir, AUTOMATIONS_DIR):
        return f"❌ 安全错误：任务目录超出允许范围"

    task_dir.mkdir(parents=True, exist_ok=True)

    rrule = FREQ_MAP.get(freq, FREQ_MAP["daily"])
    threshold_note = f"，低于¥{threshold}提醒" if threshold else ""
    label_freq = FREQ_LABELS.get(freq, freq)
    route_label = f"{dep.upper()} → {arr.upper()}"

    # Build the monitoring prompt — with strict date locking
    threshold_note = f"，若最低价 ≤ ¥{threshold} 则触发低价提醒" if threshold else ""
    rt_note = f"，同时关注 {return_date} 返程价格" if return_date else ""
    notify_step = (
        f"4. 若触发低价提醒，运行推送通知：python {SCRIPTS_DIR / 'notify.py'} "
        f"--title \"机票低价提醒 {dep}→{arr}\" "
        f"--body \"{dep}→{arr} 出行日期{date} 最低价¥<最低价>，低于阈值¥{threshold}！推荐航班<航班号>\" "
        f"--url \"https://flights.ctrip.com/itinerary/oneway/{dep.lower()}-{arr.lower()}?depdate={date}\""
    ) if threshold else ""

    prompt = (
        f"【机票价格监控】出行日期：{date}（注意：搜索时必须使用此出行日期，不能用今天日期）。"
        f"请按以下步骤执行：\n"
        f"1. 运行：python {SCRIPTS_DIR / 'search_flights.py'} --from {dep} --to {arr} --date {date}"
        + (f" --max-price {threshold}" if threshold else "")
        + f"\n2. 若脚本 source 为 fallback，使用 search_query 字段执行 web_search（仅一次），"
        f"搜索词必须包含出行日期 {date}{rt_note}{threshold_note}。"
        f"\n3. 运行：python {SCRIPTS_DIR / 'price_history.py'} append "
        f"--from {dep} --to {arr} --date {date} --price <最低价> --flight \"<航班号>\""
        + (f" --threshold {threshold}" if threshold else "")
        + (f"\n{notify_step}" if notify_step else "")
    )

    toml_content = f'''# Flight Monitor Task
# Auto-generated by flight-monitor skill

[automation]
name = "机票监控 {route_label} {date}"
prompt = """{prompt}"""
rrule = "{rrule}"
status = "ACTIVE"

[meta]
dep = "{dep.upper()}"
arr = "{arr.upper()}"
date = "{date}"
freq = "{freq}"
threshold = {threshold if threshold else "null"}
return_date = "{return_date or ""}"
created_at = "{datetime.now().isoformat()}"
'''

    toml_file = task_dir / "automation.toml"
    toml_file.write_text(toml_content, encoding="utf-8")

    return (
        f"✅ 已添加监控任务\n\n"
        f"- **ID:** `{task_id}`\n"
        f"- **航线:** {route_label}\n"
        f"- **出行日期:** {date}\n"
        f"- **监控频率:** {label_freq}{threshold_note}\n"
        f"- **配置文件:** `{toml_file}`\n\n"
        f"> 提示：任务已写入 WorkBuddy 自动化目录，下次触发时将自动执行。"
    )


def list_monitors() -> str:
    AUTOMATIONS_DIR.mkdir(parents=True, exist_ok=True)
    tasks = [
        d for d in AUTOMATIONS_DIR.iterdir()
        if d.is_dir() and d.name.startswith("flight-")
    ]
    if not tasks:
        return "暂无机票监控任务。"

    lines = ["## 当前机票监控任务", "", "| ID | 航线 | 日期 | 频率 | 阈值 | 状态 |",
             "|----|------|------|------|------|------|"]
    for task_dir in sorted(tasks):
        toml_file = task_dir / "automation.toml"
        if not toml_file.exists():
            continue
        try:
            content = toml_file.read_text(encoding="utf-8")
            meta = _simple_toml_parse(content, "meta")
            auto = _simple_toml_parse(content, "automation")
            dep = meta.get("dep", "")
            arr = meta.get("arr", "")
            date = meta.get("date", "")
            freq = FREQ_LABELS.get(meta.get("freq", ""), meta.get("freq", ""))
            threshold = meta.get("threshold", "−")
            status = auto.get("status", "ACTIVE")
            lines.append(
                f"| `{task_dir.name}` | {dep}→{arr} | {date} | {freq} "
                f"| {f'¥{threshold}' if threshold and threshold != 'null' else '−'} | {status} |"
            )
        except Exception as e:
            lines.append(f"| `{task_dir.name}` | (解析失败: {e}) | | | | |")
    return "\n".join(lines)


def pause_monitor(task_id: str) -> str:
    if not validate_task_id(task_id):
        return f"❌ 无效的任务 ID `{task_id}`（格式应为 flight-DEP-ARR-YYYY-MM-DD）"
    toml_file = AUTOMATIONS_DIR / task_id / "automation.toml"
    if not toml_file.exists():
        return f"❌ 未找到任务 `{task_id}`"
    content = toml_file.read_text(encoding="utf-8")
    updated = content.replace('status = "ACTIVE"', 'status = "PAUSED"')
    toml_file.write_text(updated, encoding="utf-8")
    return f"⏸ 已暂停任务 `{task_id}`"


def remove_monitor(task_id: str) -> str:
    if not validate_task_id(task_id):
        return f"❌ 无效的任务 ID `{task_id}`（格式应为 flight-DEP-ARR-YYYY-MM-DD）"
    task_dir = AUTOMATIONS_DIR / task_id
    if not task_dir.exists():
        return f"❌ 未找到任务 `{task_id}`"
    for f in task_dir.iterdir():
        f.unlink()
    task_dir.rmdir()
    return f"🗑 已删除任务 `{task_id}`"


def run_monitor(task_id: str) -> str:
    """
    Show the monitoring prompt for a task.

    NOTE: This function intentionally does NOT execute any shell commands.
    Monitoring tasks are driven by the WorkBuddy automation scheduler which
    reads the TOML prompt directly — no subprocess or os.system() calls are
    needed or used here.
    """
    if not validate_task_id(task_id):
        return f"❌ 无效的任务 ID `{task_id}`（格式应为 flight-DEP-ARR-YYYY-MM-DD）"
    toml_file = AUTOMATIONS_DIR / task_id / "automation.toml"
    if not toml_file.exists():
        return f"❌ 未找到任务 `{task_id}`"
    content = toml_file.read_text(encoding="utf-8")
    meta = _simple_toml_parse(content, "meta")
    dep = meta.get("dep", "")
    arr = meta.get("arr", "")
    date = meta.get("date", "")
    threshold = meta.get("threshold", "")
    threshold_arg = f" --max-price {threshold}" if threshold and threshold != "null" else ""

    return (
        f"🔍 任务详情：{dep} → {arr}，出行日期 {date}\n\n"
        f"如需手动执行一次查询，请运行：\n"
        f"```\n"
        f"python scripts/search_flights.py --from {dep} --to {arr} --date {date}{threshold_arg}\n"
        f"```\n"
        f"（此脚本仅做查询，不执行任何系统命令。）"
    )


def _simple_toml_parse(content: str, section: str) -> dict:
    """Minimal TOML section parser (no dependency on tomllib)."""
    result = {}
    in_section = False
    for line in content.splitlines():
        stripped = line.strip()
        if stripped.startswith(f"[{section}]"):
            in_section = True
            continue
        if stripped.startswith("[") and in_section:
            break
        if in_section and "=" in stripped and not stripped.startswith("#"):
            key, _, val = stripped.partition("=")
            key = key.strip()
            val = val.strip().strip('"').strip("'")
            result[key] = val
    return result


def main():
    parser = argparse.ArgumentParser(description="Flight monitor task manager")
    sub = parser.add_subparsers(dest="cmd")

    p_add = sub.add_parser("add")
    p_add.add_argument("--from", dest="dep", required=True)
    p_add.add_argument("--to", dest="arr", required=True)
    p_add.add_argument("--date", required=True)
    p_add.add_argument("--freq", default="daily",
                       help="Frequency: hourly/1h/2h/3h/6h/12h/daily/twice-daily")
    p_add.add_argument("--threshold", type=float)
    p_add.add_argument("--return-date", dest="return_date")

    p_pause = sub.add_parser("pause")
    p_pause.add_argument("--id", required=True)

    p_remove = sub.add_parser("remove")
    p_remove.add_argument("--id", required=True)

    p_run = sub.add_parser("run")
    p_run.add_argument("--id", required=True)

    sub.add_parser("list")

    args = parser.parse_args()

    if args.cmd == "add":
        print(add_monitor(args.dep, args.arr, args.date, args.freq,
                          args.threshold, args.return_date))
    elif args.cmd == "list":
        print(list_monitors())
    elif args.cmd == "pause":
        print(pause_monitor(args.id))
    elif args.cmd == "remove":
        print(remove_monitor(args.id))
    elif args.cmd == "run":
        run_monitor(args.id)
    else:
        parser.print_help()


if __name__ == "__main__":
    import io
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
    main()


path: scripts/notify.py
content: #!/usr/bin/env python3
"""
notify.py — Mobile push notifications for flight price alerts.

Supports three free push services (configure one):
  - Bark       (iOS, install Bark app from App Store)
  - Server酱   (WeChat notification, Android & iOS, https://sct.ftqq.com)
  - PushDeer   (open source Android & iOS, https://www.pushdeer.com)

Usage:
    # Send a notification
    python notify.py --title "低价提醒" --body "北京→三亚 ¥500" --url "https://..."

    # Configure push service
    python notify.py --setup bark --key <YOUR_BARK_KEY>
    python notify.py --setup serverchan --key <YOUR_SENDKEY>
    python notify.py --setup pushdeer --key <YOUR_PUSHKEY>

    # Show current config
    python notify.py --status
"""

import argparse
import json
import sys
import urllib.request
import urllib.parse
import urllib.error
from pathlib import Path

CONFIG_DIR = Path.home() / ".workbuddy" / "flight-monitor"
CONFIG_FILE = CONFIG_DIR / "notify_config.json"


# ── Config helpers ────────────────────────────────────────────────────────────

def load_config() -> dict:
    if CONFIG_FILE.exists():
        try:
            return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        except Exception:
            pass
    return {}


def save_config(cfg: dict):
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    CONFIG_FILE.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")


# ── Push backends ─────────────────────────────────────────────────────────────

def push_bark(key: str, title: str, body: str, url: str = None) -> bool:
    """
    Bark push (iOS only).
    API: https://bark.day.app/{key}/{title}/{body}?url=...
    """
    t = urllib.parse.quote(title, safe="")
    b = urllib.parse.quote(body, safe="")
    api_url = f"https://api.day.app/{key}/{t}/{b}"
    if url:
        api_url += "?url=" + urllib.parse.quote(url, safe="")
    try:
        req = urllib.request.Request(api_url, headers={"User-Agent": "flight-monitor/1.0"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            result = json.loads(resp.read().decode())
            return result.get("code") == 200
    except Exception as e:
        print(f"  Bark error: {e}", file=sys.stderr)
        return False


def push_serverchan(key: str, title: str, body: str, url: str = None) -> bool:
    """
    Server酱 / ServerChan3 push (WeChat notification).
    API: https://sctapi.ftqq.com/{SENDKEY}.send
    """
    desp = body
    if url:
        desp += f"\n\n[立即预订]({url})"
    data = urllib.parse.urlencode({
        "title": title,
        "desp": desp,
    }).encode("utf-8")
    api_url = f"https://sctapi.ftqq.com/{key}.send"
    try:
        req = urllib.request.Request(api_url, data=data,
                                     headers={"Content-Type": "application/x-www-form-urlencoded"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            result = json.loads(resp.read().decode())
            return result.get("data", {}).get("errno", -1) == 0
    except Exception as e:
        print(f"  Server酱 error: {e}", file=sys.stderr)
        return False


def push_pushdeer(key: str, title: str, body: str, url: str = None) -> bool:
    """
    PushDeer push (open source, Android & iOS).
    API: https://api2.pushdeer.com/message/push
    """
    text = f"**{title}**\n\n{body}"
    if url:
        text += f"\n\n[立即预订]({url})"
    data = urllib.parse.urlencode({
        "pushkey": key,
        "title": title,
        "desp": text,
        "type": "markdown",
    }).encode("utf-8")
    api_url = "https://api2.pushdeer.com/message/push"
    try:
        req = urllib.request.Request(api_url, data=data,
                                     headers={"Content-Type": "application/x-www-form-urlencoded"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            result = json.loads(resp.read().decode())
            return result.get("code") == 0
    except Exception as e:
        print(f"  PushDeer error: {e}", file=sys.stderr)
        return False


# ── Main send function ────────────────────────────────────────────────────────

def send_notification(title: str, body: str, url: str = None) -> bool:
    """
    Send a push notification using the configured service.
    Returns True on success, False on failure.
    """
    cfg = load_config()
    service = cfg.get("service")

    if not service:
        print("⚠️  未配置推送服务。")
        print()
        print("请选择以下任一方式配置：")
        print()
        print("  【Bark】适合 iPhone 用户，App Store 搜索 Bark 安装后获取 Key：")
        print("    python scripts/notify.py --setup bark --key <YOUR_KEY>")
        print()
        print("  【Server酱】适合希望收到微信通知的用户，访问 https://sct.ftqq.com 获取 SendKey：")
        print("    python scripts/notify.py --setup serverchan --key <YOUR_SENDKEY>")
        print()
        print("  【PushDeer】开源推送，Android & iOS，访问 https://www.pushdeer.com 获取 Key：")
        print("    python scripts/notify.py --setup pushdeer --key <YOUR_KEY>")
        return False

    key = cfg.get("key", "")
    print(f"📤 正在通过 {service} 发送推送通知…")

    if service == "bark":
        ok = push_bark(key, title, body, url)
    elif service == "serverchan":
        ok = push_serverchan(key, title, body, url)
    elif service == "pushdeer":
        ok = push_pushdeer(key, title, body, url)
    else:
        print(f"  未知推送服务: {service}", file=sys.stderr)
        return False

    if ok:
        print(f"✅ 推送成功（{service}）")
    else:
        print(f"❌ 推送失败（{service}）— 请检查 Key 是否正确，或网络是否可用")
    return ok


# ── Setup ─────────────────────────────────────────────────────────────────────

def setup(service: str, key: str):
    valid = {"bark", "serverchan", "pushdeer"}
    if service not in valid:
        print(f"❌ 不支持的服务: {service}，可选: {', '.join(valid)}")
        sys.exit(1)
    cfg = load_config()
    cfg["service"] = service
    cfg["key"] = key
    save_config(cfg)
    print(f"✅ 已配置推送服务：{service}")
    print(f"   Key: {key[:6]}{'*' * max(0, len(key) - 6)}")
    print()
    print("发送测试推送…")
    ok = send_notification(
        "Flight Monitor 测试通知",
        "推送配置成功！机票低价时将通过此渠道通知您。",
    )
    if not ok:
        print("⚠️  测试推送失败，请检查 Key 是否正确。")


def show_status():
    cfg = load_config()
    if not cfg:
        print("未配置推送服务。运行 `python notify.py --setup <service> --key <key>` 配置。")
        return
    service = cfg.get("service", "未知")
    key = cfg.get("key", "")
    masked = key[:6] + "*" * max(0, len(key) - 6)
    print(f"推送服务：{service}")
    print(f"Key：{masked}")
    print(f"配置文件：{CONFIG_FILE}")


# ── CLI ───────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="Mobile push notification for flight alerts")
    parser.add_argument("--title", help="Notification title")
    parser.add_argument("--body", help="Notification body text")
    parser.add_argument("--url", help="URL to open when notification is tapped")
    parser.add_argument("--setup", metavar="SERVICE",
                        help="Configure push service: bark / serverchan / pushdeer")
    parser.add_argument("--key", help="API key / SendKey for the push service")
    parser.add_argument("--status", action="store_true", help="Show current push config")
    args = parser.parse_args()

    if args.status:
        show_status()
        return

    if args.setup:
        if not args.key:
            print("❌ --setup requires --key")
            sys.exit(1)
        setup(args.setup, args.key)
        return

    if not args.title or not args.body:
        print("❌ --title and --body are required for sending notifications")
        parser.print_help()
        sys.exit(1)

    ok = send_notification(args.title, args.body, args.url)
    sys.exit(0 if ok else 1)


if __name__ == "__main__":
    import io
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
    main()


path: scripts/price_history.py
content: #!/usr/bin/env python3
"""
price_history.py — Read and write flight price history records.

Storage layout:
  ~/.workbuddy/flight-monitor/{DEP}-{ARR}-{DATE}.json

Usage:
    # Append a price record
    python price_history.py append --from BJS --to SYX --date 2026-03-25 --price 1280 --flight CA1234

    # Show history for a route
    python price_history.py show --from BJS --to SYX --date 2026-03-25

    # List all monitored routes
    python price_history.py list
"""

import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path

HISTORY_DIR = Path.home() / ".workbuddy" / "flight-monitor"

# ── Input validators (whitelist approach) ───────────────────────────────────
_CITY_CODE_RE = re.compile(r'^[A-Za-z]{2,4}$')
_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')


def _validate_city_code(code: str) -> bool:
    """Only allow 2-4 letter IATA city/airport codes."""
    return bool(_CITY_CODE_RE.match(code))


def _validate_date(date: str) -> bool:
    """Only allow dates in YYYY-MM-DD format with plausible values."""
    if not _DATE_RE.match(date):
        return False
    try:
        datetime.strptime(date, "%Y-%m-%d")
        return True
    except ValueError:
        return False


def _validate_inputs(dep: str, arr: str, date: str) -> str | None:
    """
    Validate all three route parameters. Returns an error message string if
    validation fails, or None if all inputs are safe.
    """
    if not _validate_city_code(dep):
        return f"❌ 无效的出发城市代码 `{dep}`（只允许 2-4 位字母 IATA 代码，如 BJS、SHA）"
    if not _validate_city_code(arr):
        return f"❌ 无效的到达城市代码 `{arr}`（只允许 2-4 位字母 IATA 代码，如 SYX、CTU）"
    if not _validate_date(date):
        return f"❌ 无效的日期 `{date}`（格式应为 YYYY-MM-DD，如 2026-04-10）"
    return None


def _safe_resolve_under(path: Path, base: Path) -> bool:
    """Return True only if the resolved path is strictly under base directory."""
    try:
        path.resolve().relative_to(base.resolve())
        return True
    except ValueError:
        return False


def _route_key(dep: str, arr: str, date: str) -> str:
    return f"{dep.upper()}-{arr.upper()}-{date}"


def _history_file(dep: str, arr: str, date: str) -> Path:
    HISTORY_DIR.mkdir(parents=True, exist_ok=True)
    target = HISTORY_DIR / f"{_route_key(dep, arr, date)}.json"
    if not _safe_resolve_under(target, HISTORY_DIR):
        raise ValueError(f"安全错误：路径 `{target}` 超出允许的存储目录范围")
    return target


def load_history(dep: str, arr: str, date: str) -> dict:
    err = _validate_inputs(dep, arr, date)
    if err:
        raise ValueError(err)
    f = _history_file(dep, arr, date)
    if f.exists():
        return json.loads(f.read_text(encoding="utf-8"))
    return {
        "route": f"{dep.upper()} → {arr.upper()}",
        "date": date,
        "records": [],
        "threshold": None,
    }


def save_history(dep: str, arr: str, date: str, data: dict):
    err = _validate_inputs(dep, arr, date)
    if err:
        raise ValueError(err)
    f = _history_file(dep, arr, date)
    f.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


def append_record(dep: str, arr: str, date: str, price: float,
                  flight: str = "", threshold: float = None) -> dict:
    err = _validate_inputs(dep, arr, date)
    if err:
        raise ValueError(err)
    data = load_history(dep, arr, date)
    if threshold is not None:
        data["threshold"] = threshold

    prev_price = data["records"][-1]["price"] if data["records"] else None
    change = None
    if prev_price is not None:
        change = price - prev_price

    record = {
        "ts": datetime.now().strftime("%Y-%m-%d %H:%M"),
        "price": price,
        "flight": flight,
        "change": change,
    }
    data["records"].append(record)
    save_history(dep, arr, date, data)
    return data


def format_history(data: dict) -> str:
    records = data.get("records", [])
    route = data.get("route", "")
    date = data.get("date", "")
    threshold = data.get("threshold")

    lines = [
        f"# {route} 机票价格历史",
        "",
        f"- 航线：{route}",
        f"- 出行日期：{date}",
    ]
    if threshold:
        lines.append(f"- 低价阈值：¥{threshold}")
    lines.append("")

    if not records:
        lines.append("暂无记录。")
        return "\n".join(lines)

    lines += [
        "## 价格记录",
        "",
        "| 查询时间 | 价格 | 航班 | 变化 |",
        "|----------|------|------|------|",
    ]
    for r in records:
        change_str = ""
        if r.get("change") is not None:
            diff = r["change"]
            change_str = f"↑¥{diff}" if diff > 0 else (f"↓¥{abs(diff)}" if diff < 0 else "−")
        lines.append(f"| {r['ts']} | ¥{r['price']} | {r.get('flight','')} | {change_str} |")

    prices = [r["price"] for r in records]
    lines += [
        "",
        f"**最低价：** ¥{min(prices)}  |  **最高价：** ¥{max(prices)}  |  **最新价：** ¥{prices[-1]}",
    ]

    # Trigger alert
    if threshold and prices[-1] <= threshold:
        lines += [
            "",
            f"> 🔔 **低价提醒！** 当前价格 ¥{prices[-1]} 已低于阈值 ¥{threshold}",
        ]

    return "\n".join(lines)


def list_routes() -> str:
    HISTORY_DIR.mkdir(parents=True, exist_ok=True)
    files = list(HISTORY_DIR.glob("*.json"))
    if not files:
        return "暂无监控记录。"

    lines = ["## 当前监控路线", "", "| 航线 | 日期 | 最新价 | 阈值 | 记录次数 |",
             "|------|------|--------|------|----------|"]
    for f in sorted(files):
        try:
            data = json.loads(f.read_text(encoding="utf-8"))
            records = data.get("records", [])
            latest = f"¥{records[-1]['price']}" if records else "−"
            threshold = f"¥{data['threshold']}" if data.get("threshold") else "−"
            lines.append(
                f"| {data.get('route','')} | {data.get('date','')} | {latest} | {threshold} | {len(records)} |"
            )
        except Exception:
            pass
    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="Flight price history manager")
    sub = parser.add_subparsers(dest="cmd")

    p_append = sub.add_parser("append")
    p_append.add_argument("--from", dest="dep", required=True)
    p_append.add_argument("--to", dest="arr", required=True)
    p_append.add_argument("--date", required=True)
    p_append.add_argument("--price", type=float, required=True)
    p_append.add_argument("--flight", default="")
    p_append.add_argument("--threshold", type=float)

    p_show = sub.add_parser("show")
    p_show.add_argument("--from", dest="dep", required=True)
    p_show.add_argument("--to", dest="arr", required=True)
    p_show.add_argument("--date", required=True)

    sub.add_parser("list")

    args = parser.parse_args()

    if args.cmd == "append":
        try:
            data = append_record(args.dep, args.arr, args.date, args.price,
                                 args.flight, args.threshold)
            print(format_history(data))
        except ValueError as e:
            print(str(e))
            sys.exit(1)
    elif args.cmd == "show":
        try:
            data = load_history(args.dep, args.arr, args.date)
            print(format_history(data))
        except ValueError as e:
            print(str(e))
            sys.exit(1)
    elif args.cmd == "list":
        print(list_routes())
    else:
        parser.print_help()


if __name__ == "__main__":
    import io
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
    main()


path: scripts/query_flights.py
content: #!/usr/bin/env python3
"""
flight-monitor: Query flight prices using web search.

This script uses the AI agent's web_search capability as its data source.
When called, it prints a structured prompt that the AI should use to search
for current flight prices, then formats the result.

For programmatic/direct use it also supports a lightweight scrape of
publicly available flight summary pages.

Usage (direct):
    python query_flights.py --from BJS --to SYX --date 2026-03-25
    python query_flights.py --from 北京 --to 三亚 --date 2026-03-25 --max-price 1500
    python query_flights.py --from SHA --to CTU --date 2026-04-01 --return-date 2026-04-05
    python query_flights.py --from BJS --to SYX --date 2026-03-25 --json
"""

import argparse
import json
import sys
import urllib.request
import urllib.parse
import urllib.error
from datetime import datetime

# ── City code mapping ────────────────────────────────────────────────────────
CITY_CODES = {
    "北京": "BJS", "上海": "SHA", "广州": "CAN", "深圳": "SZX",
    "杭州": "HGH", "成都": "CTU", "三亚": "SYX", "西安": "SIA",
    "昆明": "KMG", "重庆": "CKG", "武汉": "WUH", "南京": "NKG",
    "厦门": "XMN", "青岛": "TAO", "大连": "DLC", "沈阳": "SHE",
    "长沙": "CSX", "郑州": "CGO", "哈尔滨": "HRB", "乌鲁木齐": "URC",
    "贵阳": "KWE", "福州": "FOC", "南昌": "KHN", "太原": "TYN",
    "海口": "HAK", "兰州": "LHW", "西宁": "XNN", "呼和浩特": "HET",
    "石家庄": "SJW", "南宁": "NNG", "合肥": "HFE", "济南": "TNA",
    "天津": "TSN", "烟台": "YNT", "温州": "WNZ", "宁波": "NGB", "赣州": "KOW",
    "东京": "TYO", "大阪": "OSA", "首尔": "SEL", "新加坡": "SIN",
    "曼谷": "BKK", "香港": "HKG", "台北": "TPE", "澳门": "MFM",
    "纽约": "NYC", "洛杉矶": "LAX", "伦敦": "LON", "巴黎": "PAR",
    "悉尼": "SYD", "迪拜": "DXB",
}
CITY_NAMES = {v: k for k, v in CITY_CODES.items()}

# Airline name → Chinese name
AIRLINE_CN = {
    "CA": "中国国航", "MU": "中国东航", "CZ": "中国南航",
    "HU": "海南航空", "3U": "四川航空", "ZH": "深圳航空",
    "9C": "春秋航空", "HO": "吉祥航空", "GS": "天津航空",
    "MF": "厦门航空", "KN": "中国联航", "VD": "中国联航",
    "EU": "成都航空", "BK": "奥凯航空", "8L": "祥鹏航空",
    "NS": "幸福航空", "Y8": "山东航空", "SC": "山东航空",
    "GJ": "长龙航空", "TV": "九元航空",
}


def resolve_city(s: str) -> tuple:
    """Return (code, name). Input can be Chinese name or IATA code."""
    upper = s.strip().upper()
    if upper in CITY_NAMES:
        return upper, CITY_NAMES[upper]
    orig = s.strip()
    if orig in CITY_CODES:
        return CITY_CODES[orig], orig
    for cn, code in CITY_CODES.items():
        if cn in orig:
            return code, cn
    return upper, upper  # fallback


def build_booking_urls(dep_code: str, arr_code: str, date: str,
                       return_date: str = None) -> dict:
    """Build booking page URLs for major OTAs."""
    d = date.replace("-", "")
    dep_l = dep_code.lower()
    arr_l = arr_code.lower()
    urls = {
        "携程 Ctrip": f"https://flights.ctrip.com/itinerary/oneway/{dep_l}-{arr_l}?depdate={date}",
        "飞猪 Fliggy": (
            f"https://sjipiao.fliggy.com/flight_search_result.htm"
            f"?tripType=0&depCity={dep_code}&arrCity={arr_code}"
            f"&depDate={date}&depCityName={CITY_NAMES.get(dep_code,'')}"
            f"&arrCityName={CITY_NAMES.get(arr_code,'')}"
        ),
        "去哪儿 Qunar": f"https://flight.qunar.com/site/oneway.htm?searchDepartureAirport={CITY_NAMES.get(dep_code,dep_code)}&searchArrivalAirport={CITY_NAMES.get(arr_code,arr_code)}&searchDepartureDate={date}",
        "Google Flights": f"https://www.google.com/travel/flights?q=flights+from+{dep_code}+to+{arr_code}+on+{date}",
    }
    if return_date:
        urls["携程往返 Ctrip RT"] = (
            f"https://flights.ctrip.com/itinerary/roundtrip/{dep_l}-{arr_l}"
            f"?depdate={date}&retdate={return_date}"
        )
    return urls


def generate_search_prompt(dep_code: str, dep_name: str,
                            arr_code: str, arr_name: str,
                            date: str, max_price: float = None,
                            return_date: str = None) -> dict:
    """
    Returns a structured result that instructs the AI to perform a web search
    for live flight prices. This is the primary data-gathering method.
    """
    price_note = f"，筛选低于¥{max_price}的" if max_price else ""
    rt_note = f"；同时查询 {return_date} 返程" if return_date else ""
    query = (
        f"{dep_name}到{arr_name} {date} 机票价格{price_note}{rt_note} "
        f"最低价 航班号 出发时间"
    )
    query_en = (
        f"cheapest flights {dep_code} to {arr_code} {date} price economy"
    )
    booking_urls = build_booking_urls(dep_code, arr_code, date, return_date)

    return {
        "status": "search_required",
        "dep": f"{dep_name}({dep_code})",
        "arr": f"{arr_name}({arr_code})",
        "date": date,
        "return_date": return_date,
        "max_price": max_price,
        "search_queries": [query, query_en],
        "booking_urls": booking_urls,
        "instruction": (
            f"Please use web_search to find current flight prices for {dep_name}→{arr_name} "
            f"on {date}. Search for: \"{query}\". "
            f"Extract: airline names, flight numbers, departure times, arrival times, prices. "
            + (f"Filter results to show only flights ≤¥{max_price}. " if max_price else "")
            + "Then format the results as a markdown table."
        ),
    }


def format_search_result(result: dict) -> str:
    """Format the search_required result as user-facing markdown."""
    booking_urls = result.get("booking_urls", {})
    date = result.get("date", "")
    dep = result.get("dep", "")
    arr = result.get("arr", "")
    max_price = result.get("max_price")
    return_date = result.get("return_date")

    lines = [
        f"## 机票查询：{dep} → {arr}",
        "",
        f"**出发日期：** {date}",
    ]
    if return_date:
        lines.append(f"**返程日期：** {return_date}")
    if max_price:
        lines.append(f"**价格筛选：** ≤ ¥{max_price}")
    lines += [
        "",
        "### 实时查询",
        "",
        "> 正在通过搜索引擎获取最新机票价格……",
        "",
        "### 立即预订",
        "",
    ]
    for name, url in booking_urls.items():
        lines.append(f"- [{name}]({url})")

    sq = result.get("search_queries", [])
    if sq:
        lines += ["", "### 推荐搜索词", ""]
        for q in sq:
            lines.append(f"- `{q}`")

    return "\n".join(lines)


def query(dep: str, arr: str, date: str,
          max_price: float = None, return_date: str = None) -> dict:
    dep_code, dep_name = resolve_city(dep)
    arr_code, arr_name = resolve_city(arr)
    return generate_search_prompt(dep_code, dep_name, arr_code, arr_name,
                                  date, max_price, return_date)


def main():
    import io
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

    parser = argparse.ArgumentParser(description="Flight price query helper")
    parser.add_argument("--from", dest="dep", required=True,
                        help="Departure city (Chinese name or IATA code)")
    parser.add_argument("--to", dest="arr", required=True,
                        help="Arrival city (Chinese name or IATA code)")
    parser.add_argument("--date", required=True,
                        help="Departure date YYYY-MM-DD")
    parser.add_argument("--return-date", dest="return_date",
                        help="Return date for round trip")
    parser.add_argument("--max-price", dest="max_price", type=float,
                        help="Maximum price filter (RMB)")
    parser.add_argument("--json", dest="as_json", action="store_true",
                        help="Output raw JSON")
    args = parser.parse_args()

    result = query(args.dep, args.arr, args.date, args.max_price, args.return_date)

    if args.as_json:
        print(json.dumps(result, ensure_ascii=False, indent=2))
    else:
        print(format_search_result(result))
        print()
        print("---")
        print("INSTRUCTION FOR AI AGENT:")
        print(result.get("instruction", ""))


if __name__ == "__main__":
    main()


path: scripts/search_flights.py
content: #!/usr/bin/env python3
"""
search_flights.py — Flight search via direct API calls.

Priority order:
  1. Ctrip (携程) internal AJAX API  — richest data, no key needed
  2. zbape.com cheapest-price API    — requires API key in config
  3. Fallback                        — returns booking deep-links + search query
                                       for the AI to do a single web_search

Usage:
    python search_flights.py --from SHA --to CTU --date 2026-03-21
    python search_flights.py --from 上海 --to 成都 --date 2026-03-21 --return-date 2026-03-28
    python search_flights.py --from BJS --to SYX --date 2026-04-10 --max-price 800
    python search_flights.py --from SHA --to CTU --date 2026-03-21 --json
    python search_flights.py --setup-zbape <YOUR_KEY>
"""

import argparse
import json
import os
import sys
import time
import urllib.request
import urllib.parse
import urllib.error
import io
from datetime import datetime, date

# ── Config file path ─────────────────────────────────────────────────────────
CONFIG_DIR = os.path.expanduser("~/.workbuddy/flight-monitor")
CONFIG_FILE = os.path.join(CONFIG_DIR, "api_config.json")

# ── City code mapping ────────────────────────────────────────────────────────
CITY_CODES = {
    "北京": "BJS", "上海": "SHA", "广州": "CAN", "深圳": "SZX",
    "杭州": "HGH", "成都": "CTU", "三亚": "SYX", "西安": "SIA",
    "昆明": "KMG", "重庆": "CKG", "武汉": "WUH", "南京": "NKG",
    "厦门": "XMN", "青岛": "TAO", "大连": "DLC", "沈阳": "SHE",
    "长沙": "CSX", "郑州": "CGO", "哈尔滨": "HRB", "乌鲁木齐": "URC",
    "贵阳": "KWE", "福州": "FOC", "南昌": "KHN", "太原": "TYN",
    "海口": "HAK", "兰州": "LHW", "西宁": "XNN", "呼和浩特": "HET",
    "石家庄": "SJW", "南宁": "NNG", "合肥": "HFE", "济南": "TNA",
    "天津": "TSN", "烟台": "YNT", "温州": "WNZ", "宁波": "NGB",
    "赣州": "KOW", "珠海": "ZUH", "汕头": "SWA", "湛江": "ZHA",
    "拉萨": "LXA", "林芝": "LZY", "日喀则": "RKZ", "银川": "INC",
    "包头": "BAV", "赤峰": "CIF", "通辽": "TGO", "满洲里": "NZH",
    "丽江": "LJG", "西双版纳": "JHG", "大理": "DLU", "腾冲": "TCZ",
    "三明": "SML", "龙岩": "LCX", "泉州": "JJN", "南平": "NPE",
    "东京": "TYO", "大阪": "OSA", "首尔": "SEL", "新加坡": "SIN",
    "曼谷": "BKK", "香港": "HKG", "台北": "TPE", "澳门": "MFM",
    "纽约": "NYC", "洛杉矶": "LAX", "伦敦": "LON", "巴黎": "PAR",
    "悉尼": "SYD", "迪拜": "DXB",
}
CITY_NAMES = {v: k for k, v in CITY_CODES.items()}

# Airline code → full name
AIRLINE_CN = {
    "CA": "中国国航", "MU": "中国东航", "CZ": "中国南航",
    "HU": "海南航空", "3U": "四川航空", "ZH": "深圳航空",
    "9C": "春秋航空", "HO": "吉祥航空", "GS": "天津航空",
    "MF": "厦门航空", "KN": "中国联航", "EU": "成都航空",
    "BK": "奥凯航空", "8L": "祥鹏航空", "NS": "幸福航空",
    "SC": "山东航空", "GJ": "长龙航空", "TV": "九元航空",
    "FM": "上海航空", "OQ": "中国快运", "G5": "西部航空",
}


# ── Helpers ──────────────────────────────────────────────────────────────────

def load_config() -> dict:
    if os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    return {}


def save_config(cfg: dict):
    os.makedirs(CONFIG_DIR, exist_ok=True)
    with open(CONFIG_FILE, "w", encoding="utf-8") as f:
        json.dump(cfg, f, ensure_ascii=False, indent=2)


def resolve_city(s: str) -> tuple:
    """Return (iata_code, chinese_name)."""
    upper = s.strip().upper()
    if upper in CITY_NAMES:
        return upper, CITY_NAMES[upper]
    orig = s.strip()
    if orig in CITY_CODES:
        return CITY_CODES[orig], orig
    for cn, code in CITY_CODES.items():
        if cn in orig:
            return code, cn
    # Unknown city: return as-is
    return upper, upper


def airline_name(code: str) -> str:
    prefix = code[:2].upper()
    return AIRLINE_CN.get(prefix, code)


def fmt_duration(dep_str: str, arr_str: str) -> str:
    """Calculate HhMm from two 'HH:MM' or 'YYYY-MM-DD HH:MM:SS' strings."""
    try:
        fmt_long = "%Y-%m-%d %H:%M:%S"
        fmt_short = "%H:%M"
        def parse_t(s):
            s = s.strip()
            if len(s) > 8:
                return datetime.strptime(s[:19], fmt_long)
            return datetime.strptime(s, fmt_short)
        d = parse_t(dep_str)
        a = parse_t(arr_str)
        if a < d:
            # next day arrival
            from datetime import timedelta
            a += timedelta(days=1)
        mins = int((a - d).total_seconds() // 60)
        return f"{mins // 60}h{mins % 60:02d}m"
    except Exception:
        return "—"


def build_booking_urls(dep_code: str, arr_code: str, dep_date: str,
                       return_date: str = None) -> dict:
    # Only use ASCII IATA codes in URLs; fall back to city name search if unknown
    dep_l = dep_code.lower() if dep_code.isascii() else ""
    arr_l = arr_code.lower() if arr_code.isascii() else ""
    dep_cn = CITY_NAMES.get(dep_code, dep_code)
    arr_cn = CITY_NAMES.get(arr_code, arr_code)

    if dep_l and arr_l:
        ctrip_url = f"https://flights.ctrip.com/itinerary/oneway/{dep_l}-{arr_l}?depdate={dep_date}"
    else:
        ctrip_url = (
            f"https://flights.ctrip.com/itinerary/oneway/"
            f"?depdate={dep_date}&dcity={dep_cn}&acitiy={arr_cn}"
        )
    urls = {
        "携程": ctrip_url,
        "去哪儿": (
            f"https://flight.qunar.com/site/oneway.htm"
            f"?searchDepartureAirport={dep_cn}&searchArrivalAirport={arr_cn}"
            f"&searchDepartureDate={dep_date}"
        ),
        "飞猪": (
            f"https://sjipiao.fliggy.com/flight_search_result.htm"
            f"?tripType=0&depCity={dep_code if dep_code.isascii() else dep_cn}"
            f"&arrCity={arr_code if arr_code.isascii() else arr_cn}&depDate={dep_date}"
        ),
    }
    if return_date:
        if dep_l and arr_l:
            urls["携程往返"] = (
                f"https://flights.ctrip.com/itinerary/roundtrip/{dep_l}-{arr_l}"
                f"?depdate={dep_date}&retdate={return_date}"
            )
        else:
            urls["携程往返"] = ctrip_url  # fallback to one-way link
    return urls


# ── Source 1: Ctrip AJAX API ─────────────────────────────────────────────────

CTRIP_API = "https://flights.ctrip.com/itinerary/api/12808/products"

def _ctrip_payload(dep_code: str, arr_code: str, dep_date: str,
                   dep_name: str = "", arr_name: str = "") -> dict:
    return {
        "flightWay": "Oneway",
        "classType": "ALL",
        "hasChild": False,
        "hasBaby": False,
        "searchIndex": 1,
        "airportParams": [
            {
                "dcity": dep_code,
                "acity": arr_code,
                "dcityname": dep_name or CITY_NAMES.get(dep_code, dep_code),
                "acityname": arr_name or CITY_NAMES.get(arr_code, arr_code),
                "date": dep_date,
            }
        ],
        "selectedInfos": None,
    }


def _ctrip_headers(dep_code: str, arr_code: str, dep_date: str) -> dict:
    if dep_code.isascii() and arr_code.isascii():
        referer = (
            f"https://flights.ctrip.com/itinerary/oneway/"
            f"{dep_code.lower()}-{arr_code.lower()}?depdate={dep_date}"
        )
    else:
        referer = "https://flights.ctrip.com/"
    return {
        "Content-Type": "application/json",
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/124.0.0.0 Safari/537.36"
        ),
        "Referer": referer,
        "Origin": "https://flights.ctrip.com",
        "Accept": "application/json, text/plain, */*",
        "Accept-Language": "zh-CN,zh;q=0.9",
    }


def _parse_ctrip_response(data: dict) -> list:
    """
    Extract flight list from Ctrip API response.
    Returns list of dicts with keys:
      flight_no, airline, dep_time, arr_time, duration,
      dep_airport, arr_airport, stops, price, cabin
    """
    flights = []
    try:
        itineraries = (
            data.get("data", {})
                .get("flightItineraryList", [])
        )
        # fallback key variants
        if not itineraries:
            itineraries = data.get("data", {}).get("itineraryList", [])

        for itin in itineraries:
            # ── Price ──
            price_list = itin.get("priceList", [])
            min_price = None
            cabin_name = "经济舱"
            for p in price_list:
                cabin = p.get("cabin", "")
                # Only economy (Y/S/M/...) or lowest
                price_val = p.get("price") or p.get("adultPrice")
                if price_val is not None:
                    if min_price is None or price_val < min_price:
                        min_price = price_val
                        cabin_name = p.get("cabinTypeName", "经济舱")

            # ── Flight legs ──
            legs = itin.get("flightSegments", []) or itin.get("legs", [])
            if not legs:
                continue

            leg = legs[0]
            flight_list = leg.get("flightList", leg.get("flights", [leg]))

            if not flight_list:
                continue

            first = flight_list[0]
            last = flight_list[-1]

            fn = first.get("flightNumber", first.get("flightNo", ""))
            airline_code = fn[:2] if fn else ""
            dep_time = first.get("departureDate", first.get("dtime", ""))
            arr_time = last.get("arrivalDate", last.get("atime", ""))
            dep_ap = first.get("departureAirportInfo", {})
            arr_ap = last.get("arrivalAirportInfo", {})
            dep_airport_name = (
                dep_ap.get("airportName", "")
                if isinstance(dep_ap, dict) else ""
            )
            arr_airport_name = (
                arr_ap.get("airportName", "")
                if isinstance(arr_ap, dict) else ""
            )

            stops = "直飞" if len(flight_list) == 1 else f"经停({len(flight_list)-1}次)"

            # Extract HH:MM from datetime string
            def hhmm(s):
                if not s:
                    return "—"
                s = str(s).strip()
                if "T" in s:
                    s = s.split("T")[1][:5]
                elif " " in s:
                    s = s.split(" ")[1][:5]
                return s[:5]

            flights.append({
                "flight_no": fn,
                "airline": airline_name(fn) if fn else "—",
                "dep_time": hhmm(dep_time),
                "arr_time": hhmm(arr_time),
                "duration": fmt_duration(dep_time, arr_time),
                "dep_airport": dep_airport_name,
                "arr_airport": arr_airport_name,
                "stops": stops,
                "price": min_price,
                "cabin": cabin_name,
            })

    except Exception as e:
        pass  # Return whatever was collected

    # Sort by price ascending
    flights.sort(key=lambda x: x.get("price") or 99999)
    return flights


def fetch_ctrip(dep_code: str, arr_code: str, dep_date: str,
                dep_name: str = "", arr_name: str = "") -> dict:
    """
    Query Ctrip AJAX API.
    Returns {"source": "ctrip", "flights": [...], "error": None/str}
    """
    payload = _ctrip_payload(dep_code, arr_code, dep_date, dep_name, arr_name)
    headers = _ctrip_headers(dep_code, arr_code, dep_date)

    try:
        body = json.dumps(payload).encode("utf-8")
        req = urllib.request.Request(
            CTRIP_API,
            data=body,
            headers=headers,
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=15) as resp:
            raw = resp.read().decode("utf-8")
        data = json.loads(raw)
        flights = _parse_ctrip_response(data)
        if flights:
            return {"source": "ctrip", "flights": flights, "error": None}
        # API returned but no parseable flights (may be JS challenge or empty)
        return {"source": "ctrip", "flights": [], "error": "empty_response"}
    except urllib.error.HTTPError as e:
        return {"source": "ctrip", "flights": [], "error": f"HTTP {e.code}"}
    except Exception as e:
        return {"source": "ctrip", "flights": [], "error": str(e)}


# ── Source 2: zbape.com cheapest-price API ───────────────────────────────────

ZBAPE_API = "https://api.zbape.com/api/plane/query"

def fetch_zbape(dep_name: str, arr_name: str, zbape_key: str) -> dict:
    """
    Query zbape cheapest-price API.
    Returns {"source": "zbape", "lowest_price": int, "error": None/str}
    Note: zbape only returns lowest price per date, not full flight list.
    """
    try:
        params = urllib.parse.urlencode({
            "key": zbape_key,
            "star": dep_name,
            "end": arr_name,
        })
        url = f"{ZBAPE_API}?{params}"
        req = urllib.request.Request(
            url,
            headers={"User-Agent": "Mozilla/5.0"},
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            raw = resp.read().decode("utf-8")
        data = json.loads(raw)
        if data.get("code") == 200:
            return {
                "source": "zbape",
                "lowest_outbound": data["data"].get("lowest_outbound_price"),
                "lowest_return": data["data"].get("lowest_return_price"),
                "outbound_info": data["data"].get("outbound_info", {}),
                "error": None,
            }
        return {"source": "zbape", "error": data.get("msg", "unknown")}
    except Exception as e:
        return {"source": "zbape", "error": str(e)}


# ── Source 3: Fallback ───────────────────────────────────────────────────────

def make_fallback(dep_code: str, dep_name: str,
                  arr_code: str, arr_name: str,
                  dep_date: str, return_date: str = None,
                  max_price: float = None) -> dict:
    """
    Returns a single optimised web_search query string + booking URLs.
    The AI should do exactly ONE web_search with this query, then stop.
    """
    price_note = f" 低于{int(max_price)}元" if max_price else ""
    rt_note = f" 往返{return_date}" if return_date else ""
    # Favour ly.com snippets which tend to include price/flight data
    query = (
        f"{dep_name}到{arr_name} {dep_date}{rt_note} 机票{price_note} "
        f"最低价 航班号 出发时间 site:ly.com OR site:flights.ctrip.com"
    )
    urls = build_booking_urls(dep_code, arr_code, dep_date, return_date)
    return {
        "source": "fallback",
        "search_query": query,
        "booking_urls": urls,
        "instruction": (
            f"[FALLBACK] Direct API unavailable. "
            f"Do ONE web_search with this exact query: \"{query}\". "
            f"Extract flight numbers, prices, times from the snippets. "
            f"Do NOT fetch any URLs. Format results as a markdown table."
        ),
    }


# ── Main query orchestrator ──────────────────────────────────────────────────

def search(dep: str, arr: str, dep_date: str,
           return_date: str = None, max_price: float = None) -> dict:
    """
    Full flight search. Returns unified result dict:
    {
      "dep": "上海(SHA)", "arr": "成都(CTU)",
      "date": "2026-03-21", "return_date": "...",
      "max_price": 800,
      "source": "ctrip" | "zbape" | "fallback",
      "flights": [...],          # present for ctrip source
      "search_query": "...",     # present for fallback source
      "booking_urls": {...},
      "error": None | "..."
    }
    """
    dep_code, dep_name = resolve_city(dep)
    arr_code, arr_name = resolve_city(arr)
    cfg = load_config()

    result_base = {
        "dep": f"{dep_name}({dep_code})",
        "arr": f"{arr_name}({arr_code})",
        "date": dep_date,
        "return_date": return_date,
        "max_price": max_price,
        "booking_urls": build_booking_urls(dep_code, arr_code, dep_date, return_date),
    }

    # ── Try Source 1: Ctrip ──
    ctrip_result = fetch_ctrip(dep_code, arr_code, dep_date, dep_name, arr_name)
    if ctrip_result["flights"]:
        flights = ctrip_result["flights"]
        # Apply max_price filter
        if max_price:
            flights = [f for f in flights if f.get("price") and f["price"] <= max_price]
        result_base.update({
            "source": "ctrip",
            "flights": flights,
            "error": None,
        })
        # If round-trip, also fetch return leg
        if return_date:
            ret_result = fetch_ctrip(arr_code, dep_code, return_date, arr_name, dep_name)
            ret_flights = ret_result.get("flights", [])
            if max_price:
                ret_flights = [f for f in ret_flights
                               if f.get("price") and f["price"] <= max_price]
            result_base["return_flights"] = ret_flights
        return result_base

    # ── Try Source 2: zbape (price trend only, no schedule) ──
    zbape_key = cfg.get("zbape_key")
    if zbape_key:
        zb = fetch_zbape(dep_name, arr_name, zbape_key)
        if not zb.get("error"):
            result_base.update({
                "source": "zbape",
                "lowest_outbound": zb.get("lowest_outbound"),
                "lowest_return": zb.get("lowest_return"),
                "outbound_info": zb.get("outbound_info", {}),
                "note": (
                    "zbape API仅提供最低价格，不含具体航班时刻。"
                    "请点击下方预订链接查看完整航班。"
                ),
                "error": None,
            })
            return result_base

    # ── Source 3: Fallback ──
    fb = make_fallback(dep_code, dep_name, arr_code, arr_name,
                       dep_date, return_date, max_price)
    result_base.update(fb)
    return result_base


# ── Output formatters ────────────────────────────────────────────────────────

def format_flights_table(flights: list, title: str = "") -> str:
    if not flights:
        return f"{title}\n\n> 未找到符合条件的航班。\n"
    lines = []
    if title:
        lines.append(title)
        lines.append("")
    lines.append("| 航班 | 价格(含税) | 出发 | 到达 | 全程时长 | 中转 |")
    lines.append("|------|-----------|------|------|----------|------|")
    for f in flights:
        price_str = f"¥{f['price']}" if f.get("price") else "—"
        lines.append(
            f"| {f['airline']} {f['flight_no']} "
            f"| {price_str} "
            f"| {f['dep_time']} "
            f"| {f['arr_time']} "
            f"| {f['duration']} "
            f"| {f['stops']} |"
        )
    return "\n".join(lines)


def format_result(result: dict) -> str:
    dep = result["dep"]
    arr = result["arr"]
    dep_date = result["date"]
    return_date = result.get("return_date")
    max_price = result.get("max_price")
    source = result.get("source", "unknown")
    booking_urls = result.get("booking_urls", {})

    lines = [f"## 机票查询：{dep} → {arr}", ""]
    lines.append(f"**出发日期：** {dep_date}")
    if return_date:
        lines.append(f"**返程日期：** {return_date}")
    if max_price:
        lines.append(f"**价格筛选：** ≤ ¥{max_price}")
    lines.append(f"**数据来源：** {source}")
    lines.append("")

    if source == "ctrip":
        flights = result.get("flights", [])
        lines.append(
            format_flights_table(flights, f"### 去程：{dep} → {arr}，{dep_date}")
        )
        if flights:
            min_p = min(f["price"] for f in flights if f.get("price"))
            ctrip_url = booking_urls.get("携程", "#")
            lines.append(f"\n最低价：¥{min_p}  [立即预订 →]({ctrip_url})")

        if return_date:
            ret_flights = result.get("return_flights", [])
            lines.append("")
            lines.append(
                format_flights_table(ret_flights, f"### 返程：{arr} → {dep}，{return_date}")
            )
            if ret_flights:
                min_rp = min(f["price"] for f in ret_flights if f.get("price"))
                lines.append(f"\n返程最低价：¥{min_rp}")

    elif source == "zbape":
        low_out = result.get("lowest_outbound")
        low_ret = result.get("lowest_return")
        lines.append(f"### 价格概览")
        lines.append("")
        if low_out:
            lines.append(f"- 去程最低价（近期）：**¥{low_out}**")
        if low_ret:
            lines.append(f"- 返程最低价（近期）：**¥{low_ret}**")
        lines.append("")
        lines.append(f"> {result.get('note', '')}")

    elif source == "fallback":
        lines.append("### 搜索指引")
        lines.append("")
        lines.append(f"> {result.get('instruction', '')}")
        lines.append("")
        lines.append(f"**推荐搜索词：**")
        lines.append(f"```\n{result.get('search_query', '')}\n```")

    lines.append("")
    lines.append("### 立即预订")
    lines.append("")
    for name, url in booking_urls.items():
        lines.append(f"- [{name}]({url})")

    return "\n".join(lines)


# ── Setup helpers ────────────────────────────────────────────────────────────

def setup_zbape(key: str):
    cfg = load_config()
    cfg["zbape_key"] = key
    save_config(cfg)
    print(f"zbape API key 已保存到 {CONFIG_FILE}")


# ── CLI ──────────────────────────────────────────────────────────────────────

def main():
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

    parser = argparse.ArgumentParser(
        description="search_flights: Query flight info via direct API calls"
    )
    parser.add_argument("--from", dest="dep",
                        help="Departure city (Chinese or IATA code)")
    parser.add_argument("--to", dest="arr",
                        help="Arrival city (Chinese or IATA code)")
    parser.add_argument("--date",
                        help="Departure date (YYYY-MM-DD)")
    parser.add_argument("--return-date", dest="return_date",
                        help="Return date for round trip (YYYY-MM-DD)")
    parser.add_argument("--max-price", dest="max_price", type=float,
                        help="Maximum price filter (RMB)")
    parser.add_argument("--json", dest="as_json", action="store_true",
                        help="Output raw JSON instead of formatted markdown")
    parser.add_argument("--setup-zbape", dest="zbape_key", metavar="KEY",
                        help="Save zbape API key to config")

    args = parser.parse_args()

    # Setup mode
    if args.zbape_key:
        setup_zbape(args.zbape_key)
        return

    if not (args.dep and args.arr and args.date):
        parser.print_help()
        sys.exit(1)

    result = search(
        dep=args.dep,
        arr=args.arr,
        dep_date=args.date,
        return_date=args.return_date,
        max_price=args.max_price,
    )

    if args.as_json:
        print(json.dumps(result, ensure_ascii=False, indent=2))
    else:
        print(format_result(result))

        # If fallback, print the AI instruction prominently
        if result.get("source") == "fallback":
            print()
            print("---")
            print("INSTRUCTION FOR AI AGENT:")
            print(result.get("instruction", ""))


if __name__ == "__main__":
    main()


path: references/city_codes.md
content: # City IATA Codes Reference

Complete mapping of Chinese cities (and major international destinations) to IATA airport codes
used by the `query_flights.py` script.

## Domestic China

| City | Code | Notes |
|------|------|-------|
| 北京 Beijing | BJS | Covers both PEK (Capital) and PKX (Daxing) |
| 上海 Shanghai | SHA | Covers both PVG (Pudong) and SHA (Hongqiao) |
| 广州 Guangzhou | CAN | Baiyun Airport |
| 深圳 Shenzhen | SZX | Bao'an Airport |
| 杭州 Hangzhou | HGH | Xiaoshan Airport |
| 成都 Chengdu | CTU | Tianfu / Shuangliu |
| 三亚 Sanya | SYX | Phoenix Airport |
| 西安 Xi'an | SIA | Xianyang Airport |
| 昆明 Kunming | KMG | Changshui Airport |
| 重庆 Chongqing | CKG | Jiangbei Airport |
| 武汉 Wuhan | WUH | Tianhe Airport |
| 南京 Nanjing | NKG | Lukou Airport |
| 厦门 Xiamen | XMN | Gaoqi Airport |
| 青岛 Qingdao | TAO | Jiaodong Airport |
| 大连 Dalian | DLC | Zhoushuizi Airport |
| 沈阳 Shenyang | SHE | Taoxian Airport |
| 长沙 Changsha | CSX | Huanghua Airport |
| 郑州 Zhengzhou | CGO | Xinzheng Airport |
| 哈尔滨 Harbin | HRB | Taiping Airport |
| 乌鲁木齐 Urumqi | URC | Diwopu Airport |
| 贵阳 Guiyang | KWE | Longdongbao Airport |
| 福州 Fuzhou | FOC | Changle Airport |
| 南昌 Nanchang | KHN | Changbei Airport |
| 太原 Taiyuan | TYN | Wusu Airport |
| 海口 Haikou | HAK | Meilan Airport |
| 兰州 Lanzhou | LHW | Zhongchuan Airport |
| 西宁 Xining | XNN | Caojiapu Airport |
| 呼和浩特 Hohhot | HET | Baita Airport |
| 石家庄 Shijiazhuang | SJW | Zhengding Airport |
| 南宁 Nanning | NNG | Wuxu Airport |
| 合肥 Hefei | HFE | Xinqiao Airport |
| 济南 Jinan | TNA | Yaoqiang Airport |
| 天津 Tianjin | TSN | Binhai Airport |
| 烟台 Yantai | YNT | Penglai Airport |
| 温州 Wenzhou | WNZ | Longwan Airport |
| 宁波 Ningbo | NGB | Lishe Airport |
| 赣州 Ganzhou | KOW | Huangjin Airport |

## International (Common Destinations)

| City | Code |
|------|------|
| 东京 Tokyo | TYO |
| 大阪 Osaka | OSA |
| 首尔 Seoul | SEL |
| 新加坡 Singapore | SIN |
| 曼谷 Bangkok | BKK |
| 香港 Hong Kong | HKG |
| 台北 Taipei | TPE |
| 澳门 Macau | MFM |
| 纽约 New York | NYC |
| 洛杉矶 Los Angeles | LAX |
| 伦敦 London | LON |
| 巴黎 Paris | PAR |
| 悉尼 Sydney | SYD |
| 迪拜 Dubai | DXB |

## Adding New Cities

To add a city not in the list, update the `CITY_CODES` dict in `query_flights.py`:

```python
CITY_CODES["城市名"] = "CODE"
```


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