mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
feat: add flight ticket search skill
This commit is contained in:
parent
91eeaf607a
commit
8f1044046f
2 changed files with 602 additions and 0 deletions
247
flight-ticket-search/SKILL.md
Normal file
247
flight-ticket-search/SKILL.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
---
|
||||
name: flight-ticket-search
|
||||
description: Google Flights 공개 검색 표면을 무료로 조회해 항공권 후보, 예약 검색 링크, 날짜/월/연도별 최저가·평균가 비교를 보수적으로 제공한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: travel
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Flight Ticket Search
|
||||
|
||||
## What this skill does
|
||||
|
||||
`fast-flights` 기반으로 Google Flights의 공개 검색 결과를 조회해 항공권 후보를 정리한다. API key, 로그인, 결제, CAPTCHA 우회 없이 무료 공개 표면만 사용한다.
|
||||
|
||||
제공 기능:
|
||||
|
||||
- 편도/왕복 항공권 검색
|
||||
- Google Flights 예약 검색 링크 생성
|
||||
- 상위 후보 가격, 항공사, 출도착 시간, 소요시간, 경유 수 정리
|
||||
- 날짜 범위, 월별, 연도별 샘플 비교
|
||||
- 최저가, 평균가, 최고가 및 `low`/`typical`/`high` 가격 band 요약
|
||||
|
||||
예약 링크는 특정 판매자 결제 deep link가 아니라 **Google Flights 검색 결과 링크**다. 실제 구매·결제·좌석 선택은 사용자가 브라우저에서 직접 진행해야 한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "항공권 조회해줘"
|
||||
- "인천에서 나리타 다음 달 최저가"
|
||||
- "6월 ICN-NRT 월별 비교"
|
||||
- "올해랑 내년 6월 1일 항공권 가격 비교"
|
||||
- "서울에서 도쿄 왕복 예약 링크 줘"
|
||||
- "ICN-LAX 비즈니스 가격 대략 비교해줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 실제 예약/결제/취소/좌석지정 자동화
|
||||
- 로그인 회원가, 카드 할인, 쿠폰, 마일리지 적용가 확정
|
||||
- CAPTCHA, fingerprint, bot-block 우회
|
||||
- 스카이스캐너 직접 조회. 현재 `skyscanner.net`은 기본 접속부터 CAPTCHA/403이 걸리므로 안정 skill provider로 쓰지 않는다.
|
||||
|
||||
## Required inputs
|
||||
|
||||
최소 입력:
|
||||
|
||||
- 출발 공항 IATA 코드: `ICN`, `GMP`, `PUS` 등
|
||||
- 도착 공항 IATA 코드: `NRT`, `HND`, `LAX` 등
|
||||
- 출발일 `YYYY-MM-DD` 또는 비교할 월/범위
|
||||
|
||||
선택 입력:
|
||||
|
||||
- 왕복 귀국일 `YYYY-MM-DD`
|
||||
- 성인 수, 기본 1명
|
||||
- 좌석 등급: `economy`, `premium-economy`, `business`, `first`
|
||||
- 비교 샘플 방식: 월별 `weekly` 또는 `daily`
|
||||
|
||||
사용자가 도시명만 말하면 IATA 코드를 추론하되 애매하면 확인한다. 흔한 기본값은 다음처럼 처리한다.
|
||||
|
||||
- 서울/인천 국제선: `ICN`
|
||||
- 서울 국내선/제주: `GMP` 우선, 사용자가 인천을 말하면 `ICN`
|
||||
- 도쿄: 나리타 `NRT` 또는 하네다 `HND` 중 사용자가 지정하지 않으면 둘 중 하나를 확인한다.
|
||||
- 제주: `CJU`
|
||||
|
||||
## Helper script
|
||||
|
||||
이 skill은 저장소 내 helper를 직접 실행한다.
|
||||
|
||||
```bash
|
||||
python3 flight-ticket-search/scripts/flight_ticket_search.py --help
|
||||
```
|
||||
|
||||
최초 실행 시 `~/.cache/k-skill/flight-ticket-search/venv`에 `fast-flights==2.2`를 설치하고 그 venv로 재실행한다. 저장소에는 의존성 vendoring이나 API key를 넣지 않는다.
|
||||
|
||||
## Single search
|
||||
|
||||
편도:
|
||||
|
||||
```bash
|
||||
python3 flight-ticket-search/scripts/flight_ticket_search.py search \
|
||||
--from ICN \
|
||||
--to NRT \
|
||||
--date 2026-06-01 \
|
||||
--adults 1 \
|
||||
--seat economy \
|
||||
--limit 5 \
|
||||
--format markdown
|
||||
```
|
||||
|
||||
왕복:
|
||||
|
||||
```bash
|
||||
python3 flight-ticket-search/scripts/flight_ticket_search.py search \
|
||||
--from ICN \
|
||||
--to NRT \
|
||||
--date 2026-06-01 \
|
||||
--return-date 2026-06-08 \
|
||||
--adults 1 \
|
||||
--seat economy \
|
||||
--limit 5
|
||||
```
|
||||
|
||||
응답 주요 필드:
|
||||
|
||||
- `meta.booking_search_url` — Google Flights 예약 검색 링크
|
||||
- `meta.price_band` — Google이 표시하는 `low`/`typical`/`high` 가격 band
|
||||
- `stats.min_price`, `stats.avg_price`, `stats.max_price`
|
||||
- `flights[].name`, `departure`, `arrival`, `duration`, `stops`, `price_text`
|
||||
- `flights[].quality` — `complete` 또는 `partial`
|
||||
|
||||
## Monthly comparison
|
||||
|
||||
월별 비교는 지정 월의 날짜들을 실제 검색해 각 날짜의 최저가/평균가를 비교한다.
|
||||
|
||||
빠른 기본값은 주 1회 샘플링이다.
|
||||
|
||||
```bash
|
||||
python3 flight-ticket-search/scripts/flight_ticket_search.py compare-month \
|
||||
--from ICN \
|
||||
--to NRT \
|
||||
--month 2026-06 \
|
||||
--sample weekly \
|
||||
--limit 5
|
||||
```
|
||||
|
||||
일별 전체 조회가 필요하면 `--sample daily`를 쓴다. 다만 28~31회 요청이 발생하므로 rate limit을 위해 `--sleep`을 1.5초 이상 유지한다.
|
||||
|
||||
```bash
|
||||
python3 flight-ticket-search/scripts/flight_ticket_search.py compare-month \
|
||||
--from ICN \
|
||||
--to NRT \
|
||||
--month 2026-06 \
|
||||
--sample daily \
|
||||
--sleep 2 \
|
||||
--limit 10
|
||||
```
|
||||
|
||||
월별 비교 응답:
|
||||
|
||||
- `stats.min_price` — 샘플 날짜 중 최저가
|
||||
- `stats.avg_of_daily_min` — 날짜별 최저가의 평균
|
||||
- `stats.max_of_daily_min` — 날짜별 최저가 중 최고값
|
||||
- `cheapest_dates[]` — 가장 싼 날짜와 예약 검색 링크
|
||||
- `rows[]` — 날짜별 성공/실패 및 요약
|
||||
|
||||
## Custom range comparison
|
||||
|
||||
사용자가 "다음주부터 2주간", "6월 1일부터 20일까지"처럼 범위를 주면 날짜 범위 비교를 사용한다.
|
||||
|
||||
```bash
|
||||
python3 flight-ticket-search/scripts/flight_ticket_search.py compare-range \
|
||||
--from ICN \
|
||||
--to BKK \
|
||||
--start-date 2026-06-01 \
|
||||
--end-date 2026-06-20 \
|
||||
--step-days 3 \
|
||||
--limit 5
|
||||
```
|
||||
|
||||
`--step-days 1`은 일별 비교, `7`은 주별 비교다.
|
||||
|
||||
## Year comparison
|
||||
|
||||
연도 비교는 같은 월일을 여러 연도에 대해 조회한다.
|
||||
|
||||
```bash
|
||||
python3 flight-ticket-search/scripts/flight_ticket_search.py compare-years \
|
||||
--from ICN \
|
||||
--to NRT \
|
||||
--years 2026,2027 \
|
||||
--month-day 06-01 \
|
||||
--limit 5
|
||||
```
|
||||
|
||||
주의: Google Flights가 너무 먼 미래 날짜를 표시하지 않으면 해당 연도는 실패로 기록한다. 실패한 날짜를 숨기지 말고 `failures`에 같이 보고한다.
|
||||
|
||||
## Reservation link policy
|
||||
|
||||
- `booking_search_url`은 Google Flights 검색 URL이다.
|
||||
- 특정 항공사/OTA 결제 단계 deep link를 자동 추출하거나 클릭하지 않는다.
|
||||
- 결제·예약 확정·로그인·여권 정보 입력은 skill 범위 밖이다.
|
||||
- 사용자가 예약까지 원하면 링크를 열어 직접 확인하도록 안내한다.
|
||||
|
||||
## Response style
|
||||
|
||||
대표님에게는 짧게 핵심부터 보고한다.
|
||||
|
||||
좋은 형식:
|
||||
|
||||
```text
|
||||
ICN → NRT / 2026-06-01 / 성인 1명 / economy
|
||||
가격 band: typical
|
||||
최저/평균/최고: ₩129,800 / ₩254,000 / ₩684,400
|
||||
예약 검색 링크: <url>
|
||||
|
||||
1. Jeju Air — 09:45 → 12:15 / 2h30m / 직항 / ₩129,800
|
||||
2. Air Seoul — 09:20 → 11:50 / 2h30m / 직항 / ₩143,500
|
||||
3. Air Premia — 08:50 → 11:20 / 2h30m / 직항 / ₩160,800
|
||||
```
|
||||
|
||||
월별 비교:
|
||||
|
||||
```text
|
||||
ICN → NRT / 2026-06 weekly 샘플
|
||||
최저: 6/1 ₩129,800
|
||||
샘플 평균: ₩142,300
|
||||
비싼 날: 6/22 ₩188,000
|
||||
|
||||
싼 날짜 TOP 3
|
||||
1. 2026-06-01 — ₩129,800
|
||||
2. 2026-06-08 — ₩135,000
|
||||
3. 2026-06-15 — ₩144,000
|
||||
```
|
||||
|
||||
파싱 누락 후보는 숨기지 말고 이렇게 표시한다.
|
||||
|
||||
```text
|
||||
항공편 상세 확인 불가 — 시간 확인 불가 / 가격 ₩228,700
|
||||
※ Google Flights 응답에서 항공사·시간 파싱이 일부 누락됐습니다.
|
||||
```
|
||||
|
||||
## Failure modes
|
||||
|
||||
- Google Flights HTML/프론트엔드 구조 변경으로 항공사명·시간 파싱이 비거나 깨질 수 있다.
|
||||
- 일부 노선은 가격만 나오고 항공편 상세가 `partial`로 떨어질 수 있다.
|
||||
- 잘못된 IATA 코드, 동일 출도착 공항, 실제 항공편이 없는 구간은 실패한다.
|
||||
- 너무 먼 미래 날짜는 upstream에서 결과가 없을 수 있다.
|
||||
- 비교 기능은 날짜별 실시간 조회라 요청 수가 많다. daily 월별 비교는 30회 안팎의 요청이 발생한다.
|
||||
- `fast-flights` fallback이 외부 fetch helper를 쓰는 경우 401 `no token provided`가 날 수 있다. 동일 입력의 실사용성이 낮은 케이스면 사전 validation으로 막고, 정상 노선이면 잠시 후 재시도한다.
|
||||
|
||||
## Verified discovery notes
|
||||
|
||||
2026-05-10 로컬 프로브 기준:
|
||||
|
||||
- Skyscanner home/API: CAPTCHA/403 blocked로 직접 provider 부적합.
|
||||
- Kiwi Tequila API: 무료 계정 API key 필요. 기본 무료/no-key 경로는 아님.
|
||||
- Google Flights + `fast-flights==2.2`: 국내선/일본/동남아/미국/유럽/호주/남미 일부 성공.
|
||||
- 추가 테스트 성공: `ICN-CJU`, `ICN-NRT`, `ICN-PVG`, `ICN-SIN`, `ICN-BKK`, `ICN-DXB`, `ICN-LAX`, `ICN-JFK`, `ICN-LHR`, `ICN-CDG`, `ICN-FRA`, `ICN-HKG`, `ICN-TPE`, `ICN-SYD`, `ICN-GRU`, `ICN↔NRT`, `GMP↔CJU`, business, 성인 2명.
|
||||
- 정상 실패/차단 대상: `GMP-ICN`, `ICN-ICN`, invalid airport code.
|
||||
|
||||
## Done when
|
||||
|
||||
- 출발/도착/날짜/좌석/인원 조건을 확인했다.
|
||||
- 단일 검색이면 상위 후보와 예약 검색 링크를 제공했다.
|
||||
- 비교 검색이면 샘플 방식과 최저/평균/최고, 싼 날짜 TOP을 제공했다.
|
||||
- 가격은 조회 시점 기준이며 실제 결제가는 달라질 수 있음을 표시했다.
|
||||
- 로그인/결제/CAPTCHA 우회는 하지 않았다.
|
||||
355
flight-ticket-search/scripts/flight_ticket_search.py
Normal file
355
flight-ticket-search/scripts/flight_ticket_search.py
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Free flight ticket search helper for the k-skill flight-ticket-search skill.
|
||||
|
||||
Uses fast-flights (Google Flights public surface scraper) in an isolated user cache venv.
|
||||
No API key, login, CAPTCHA bypass, purchase, or booking automation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from urllib.parse import urlencode
|
||||
|
||||
PINNED_FAST_FLIGHTS = "fast-flights==2.2"
|
||||
CACHE_ROOT = Path.home() / ".cache" / "k-skill" / "flight-ticket-search"
|
||||
VENV_DIR = CACHE_ROOT / "venv"
|
||||
|
||||
|
||||
def ensure_runtime() -> None:
|
||||
"""Install fast-flights into a private cache venv, then re-exec there."""
|
||||
if os.environ.get("FLIGHT_TICKET_SEARCH_BOOTSTRAPPED") == "1":
|
||||
return
|
||||
try:
|
||||
import fast_flights # noqa: F401
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
py = VENV_DIR / "bin" / "python"
|
||||
pip = VENV_DIR / "bin" / "pip"
|
||||
if not py.exists():
|
||||
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.check_call([sys.executable, "-m", "venv", str(VENV_DIR)])
|
||||
subprocess.check_call([str(pip), "install", "--disable-pip-version-check", "-q", PINNED_FAST_FLIGHTS])
|
||||
env = os.environ.copy()
|
||||
env["FLIGHT_TICKET_SEARCH_BOOTSTRAPPED"] = "1"
|
||||
os.execve(str(py), [str(py), __file__, *sys.argv[1:]], env)
|
||||
|
||||
|
||||
def parse_date(s: str) -> date:
|
||||
return datetime.strptime(s, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def iter_dates(start: date, end: date, step_days: int) -> Iterable[date]:
|
||||
d = start
|
||||
while d <= end:
|
||||
yield d
|
||||
d += timedelta(days=step_days)
|
||||
|
||||
|
||||
def parse_price(price_text: str | None) -> int | None:
|
||||
if not price_text or "unavailable" in price_text.lower():
|
||||
return None
|
||||
digits = re.sub(r"[^0-9]", "", price_text)
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def money_krw(value: int | float | None) -> str:
|
||||
if value is None:
|
||||
return "확인 불가"
|
||||
return f"₩{int(round(value)):,}"
|
||||
|
||||
|
||||
def validate_airport(code: str, field: str) -> str:
|
||||
code = code.strip().upper()
|
||||
if not re.fullmatch(r"[A-Z]{3}", code):
|
||||
raise SystemExit(f"{field} must be a 3-letter IATA airport code, got: {code!r}")
|
||||
return code
|
||||
|
||||
|
||||
def build_query_url(flight_data: list[Any], trip: str, adults: int, seat: str) -> str:
|
||||
from fast_flights.flights_impl import Passengers, TFSData
|
||||
|
||||
tfs = TFSData.from_interface(
|
||||
flight_data=flight_data,
|
||||
trip=trip,
|
||||
passengers=Passengers(adults=adults),
|
||||
seat=seat,
|
||||
).as_b64().decode("utf-8")
|
||||
params = {"tfs": tfs, "hl": "en", "tfu": "EgQIABABIgA", "curr": "KRW"}
|
||||
return "https://www.google.com/travel/flights?" + urlencode(params)
|
||||
|
||||
|
||||
def make_flight_data(from_airport: str, to_airport: str, outbound: str, return_date: str | None = None) -> tuple[list[Any], str]:
|
||||
from fast_flights import FlightData
|
||||
|
||||
origin = validate_airport(from_airport, "from")
|
||||
dest = validate_airport(to_airport, "to")
|
||||
if origin == dest:
|
||||
raise SystemExit("from and to airports must be different")
|
||||
data = [FlightData(date=outbound, from_airport=origin, to_airport=dest)]
|
||||
if return_date:
|
||||
data.append(FlightData(date=return_date, from_airport=dest, to_airport=origin))
|
||||
return data, "round-trip"
|
||||
return data, "one-way"
|
||||
|
||||
|
||||
def fetch_flights(flight_data: list[Any], trip: str, adults: int, seat: str) -> Any:
|
||||
from fast_flights import Passengers, get_flights
|
||||
|
||||
return get_flights(
|
||||
flight_data=flight_data,
|
||||
trip=trip,
|
||||
passengers=Passengers(adults=adults),
|
||||
seat=seat,
|
||||
fetch_mode="fallback",
|
||||
)
|
||||
|
||||
|
||||
def normalize_flight(f: Any) -> dict[str, Any]:
|
||||
raw = asdict(f)
|
||||
price_value = parse_price(raw.get("price"))
|
||||
raw["price_value"] = price_value
|
||||
raw["price_text"] = money_krw(price_value) if price_value is not None else raw.get("price") or "확인 불가"
|
||||
raw["quality"] = "complete" if raw.get("name") and raw.get("departure") and raw.get("arrival") else "partial"
|
||||
return raw
|
||||
|
||||
|
||||
def summarize_result(res: Any, query_url: str, limit: int) -> dict[str, Any]:
|
||||
flights = [normalize_flight(f) for f in res.flights]
|
||||
priced = [f for f in flights if f["price_value"] is not None]
|
||||
complete = [f for f in priced if f["quality"] == "complete"]
|
||||
best_pool = complete or priced
|
||||
best_pool = sorted(best_pool, key=lambda x: x["price_value"] if x["price_value"] is not None else 10**18)
|
||||
values = [f["price_value"] for f in priced if f["price_value"] is not None]
|
||||
return {
|
||||
"meta": {
|
||||
"provider": "google-flights-fast-flights",
|
||||
"source": "Google Flights public search surface via fast-flights",
|
||||
"price_band": getattr(res, "current_price", ""),
|
||||
"currency": "KRW",
|
||||
"queried_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"booking_search_url": query_url,
|
||||
"note": "예약 링크는 특정 판매자 결제 deep link가 아니라 Google Flights 검색 결과 링크입니다.",
|
||||
},
|
||||
"stats": {
|
||||
"result_count": len(flights),
|
||||
"priced_count": len(priced),
|
||||
"complete_count": len(complete),
|
||||
"min_price": min(values) if values else None,
|
||||
"avg_price": statistics.mean(values) if values else None,
|
||||
"max_price": max(values) if values else None,
|
||||
},
|
||||
"flights": best_pool[:limit],
|
||||
}
|
||||
|
||||
|
||||
def command_search(args: argparse.Namespace) -> dict[str, Any]:
|
||||
data, trip = make_flight_data(args.from_airport, args.to_airport, args.date, args.return_date)
|
||||
res = fetch_flights(data, trip, args.adults, args.seat)
|
||||
url = build_query_url(data, trip, args.adults, args.seat)
|
||||
out = summarize_result(res, url, args.limit)
|
||||
out["query"] = {
|
||||
"from": args.from_airport.upper(),
|
||||
"to": args.to_airport.upper(),
|
||||
"date": args.date,
|
||||
"return_date": args.return_date,
|
||||
"trip": trip,
|
||||
"adults": args.adults,
|
||||
"seat": args.seat,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def scan_dates(args: argparse.Namespace, dates: list[date]) -> dict[str, Any]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for idx, d in enumerate(dates):
|
||||
try:
|
||||
data, trip = make_flight_data(args.from_airport, args.to_airport, d.isoformat(), None)
|
||||
res = fetch_flights(data, trip, args.adults, args.seat)
|
||||
url = build_query_url(data, trip, args.adults, args.seat)
|
||||
summary = summarize_result(res, url, args.limit)
|
||||
rows.append({
|
||||
"date": d.isoformat(),
|
||||
"ok": True,
|
||||
"min_price": summary["stats"]["min_price"],
|
||||
"avg_price": summary["stats"]["avg_price"],
|
||||
"priced_count": summary["stats"]["priced_count"],
|
||||
"price_band": summary["meta"]["price_band"],
|
||||
"top": summary["flights"][: min(3, args.limit)],
|
||||
"booking_search_url": url,
|
||||
})
|
||||
except Exception as e: # keep scans robust
|
||||
rows.append({"date": d.isoformat(), "ok": False, "error": f"{type(e).__name__}: {str(e)[:300]}"})
|
||||
if idx != len(dates) - 1 and args.sleep > 0:
|
||||
time.sleep(args.sleep)
|
||||
prices = [r["min_price"] for r in rows if r.get("ok") and r.get("min_price") is not None]
|
||||
ok_rows = [r for r in rows if r.get("ok")]
|
||||
cheapest = sorted((r for r in ok_rows if r.get("min_price") is not None), key=lambda r: r["min_price"])[: args.limit]
|
||||
return {
|
||||
"meta": {
|
||||
"provider": "google-flights-fast-flights",
|
||||
"currency": "KRW",
|
||||
"queried_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"sampled_dates": len(rows),
|
||||
"successful_dates": len(ok_rows),
|
||||
"note": "월/연도 비교는 지정 날짜들을 실제 조회해 산출한 샘플 기반 비교입니다. Google Flights 가격은 수시 변동됩니다.",
|
||||
},
|
||||
"query": {
|
||||
"from": args.from_airport.upper(),
|
||||
"to": args.to_airport.upper(),
|
||||
"adults": args.adults,
|
||||
"seat": args.seat,
|
||||
},
|
||||
"stats": {
|
||||
"min_price": min(prices) if prices else None,
|
||||
"avg_of_daily_min": statistics.mean(prices) if prices else None,
|
||||
"max_of_daily_min": max(prices) if prices else None,
|
||||
},
|
||||
"cheapest_dates": cheapest,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def command_compare_month(args: argparse.Namespace) -> dict[str, Any]:
|
||||
month_start = datetime.strptime(args.month + "-01", "%Y-%m-%d").date()
|
||||
if month_start.month == 12:
|
||||
month_end = date(month_start.year + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
month_end = date(month_start.year, month_start.month + 1, 1) - timedelta(days=1)
|
||||
step = 1 if args.sample == "daily" else 7
|
||||
dates = list(iter_dates(month_start, month_end, step))
|
||||
if args.max_dates:
|
||||
dates = dates[: args.max_dates]
|
||||
out = scan_dates(args, dates)
|
||||
out["query"]["month"] = args.month
|
||||
out["query"]["sample"] = args.sample
|
||||
return out
|
||||
|
||||
|
||||
def command_compare_range(args: argparse.Namespace) -> dict[str, Any]:
|
||||
start = parse_date(args.start_date)
|
||||
end = parse_date(args.end_date)
|
||||
if end < start:
|
||||
raise SystemExit("end-date must be on or after start-date")
|
||||
step = args.step_days
|
||||
dates = list(iter_dates(start, end, step))
|
||||
if args.max_dates:
|
||||
dates = dates[: args.max_dates]
|
||||
out = scan_dates(args, dates)
|
||||
out["query"]["start_date"] = args.start_date
|
||||
out["query"]["end_date"] = args.end_date
|
||||
out["query"]["step_days"] = step
|
||||
return out
|
||||
|
||||
|
||||
def command_compare_years(args: argparse.Namespace) -> dict[str, Any]:
|
||||
years = [int(x) for x in re.split(r"[, ]+", args.years.strip()) if x]
|
||||
if not years:
|
||||
raise SystemExit("years is required, e.g. 2026,2027")
|
||||
mmdd = args.month_day
|
||||
dates = [datetime.strptime(f"{year}-{mmdd}", "%Y-%m-%d").date() for year in years]
|
||||
out = scan_dates(args, dates)
|
||||
out["query"]["years"] = years
|
||||
out["query"]["month_day"] = mmdd
|
||||
return out
|
||||
|
||||
|
||||
def print_markdown(payload: dict[str, Any]) -> None:
|
||||
meta = payload.get("meta", {})
|
||||
query = payload.get("query", {})
|
||||
stats = payload.get("stats", {})
|
||||
print(f"provider: {meta.get('provider')}")
|
||||
print(f"queried_at: {meta.get('queried_at')}")
|
||||
print(f"query: {json.dumps(query, ensure_ascii=False)}")
|
||||
print()
|
||||
if "flights" in payload:
|
||||
print(f"price_band: {meta.get('price_band')}")
|
||||
print(f"min/avg/max: {money_krw(stats.get('min_price'))} / {money_krw(stats.get('avg_price'))} / {money_krw(stats.get('max_price'))}")
|
||||
print(f"booking_search_url: {meta.get('booking_search_url')}")
|
||||
print("\nflights:")
|
||||
for i, f in enumerate(payload.get("flights", []), 1):
|
||||
print(f"{i}. {f.get('name') or '항공편 상세 확인 불가'} | {f.get('departure') or '시간 확인 불가'} -> {f.get('arrival') or '시간 확인 불가'} | {f.get('duration') or '소요시간 확인 불가'} | stops={f.get('stops')} | {f.get('price_text')}")
|
||||
else:
|
||||
print(f"sampled/success: {meta.get('sampled_dates')} / {meta.get('successful_dates')}")
|
||||
print(f"min / avg(daily min) / max(daily min): {money_krw(stats.get('min_price'))} / {money_krw(stats.get('avg_of_daily_min'))} / {money_krw(stats.get('max_of_daily_min'))}")
|
||||
print("\ncheapest_dates:")
|
||||
for i, r in enumerate(payload.get("cheapest_dates", []), 1):
|
||||
print(f"{i}. {r.get('date')} | min={money_krw(r.get('min_price'))} | avg={money_krw(r.get('avg_price'))} | band={r.get('price_band')} | {r.get('booking_search_url')}")
|
||||
failures = [r for r in payload.get("rows", []) if not r.get("ok")]
|
||||
if failures:
|
||||
print("\nfailures:")
|
||||
for r in failures[:5]:
|
||||
print(f"- {r.get('date')}: {r.get('error')}")
|
||||
if meta.get("note"):
|
||||
print(f"\nnote: {meta.get('note')}")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description="Free Google Flights-based flight search and comparison helper")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
def add_common(sp: argparse.ArgumentParser) -> None:
|
||||
sp.add_argument("--from", dest="from_airport", required=True, help="IATA origin airport, e.g. ICN")
|
||||
sp.add_argument("--to", dest="to_airport", required=True, help="IATA destination airport, e.g. NRT")
|
||||
sp.add_argument("--adults", type=int, default=1)
|
||||
sp.add_argument("--seat", choices=["economy", "premium-economy", "business", "first"], default="economy")
|
||||
sp.add_argument("--limit", type=int, default=5)
|
||||
sp.add_argument("--sleep", type=float, default=1.5, help="seconds between comparison queries")
|
||||
sp.add_argument("--format", choices=["json", "markdown"], default="markdown")
|
||||
|
||||
s = sub.add_parser("search", help="single one-way or round-trip search")
|
||||
add_common(s)
|
||||
s.add_argument("--date", required=True, help="YYYY-MM-DD outbound date")
|
||||
s.add_argument("--return-date", help="YYYY-MM-DD return date for round trip")
|
||||
s.set_defaults(func=command_search)
|
||||
|
||||
m = sub.add_parser("compare-month", help="sample a whole month and rank cheapest dates")
|
||||
add_common(m)
|
||||
m.add_argument("--month", required=True, help="YYYY-MM")
|
||||
m.add_argument("--sample", choices=["weekly", "daily"], default="weekly")
|
||||
m.add_argument("--max-dates", type=int, default=0, help="cap dates for quick tests; 0 means no cap")
|
||||
m.set_defaults(func=command_compare_month)
|
||||
|
||||
r = sub.add_parser("compare-range", help="compare a custom date range")
|
||||
add_common(r)
|
||||
r.add_argument("--start-date", required=True)
|
||||
r.add_argument("--end-date", required=True)
|
||||
r.add_argument("--step-days", type=int, default=7)
|
||||
r.add_argument("--max-dates", type=int, default=0)
|
||||
r.set_defaults(func=command_compare_range)
|
||||
|
||||
y = sub.add_parser("compare-years", help="compare the same month-day across years")
|
||||
add_common(y)
|
||||
y.add_argument("--years", required=True, help="comma separated years, e.g. 2026,2027")
|
||||
y.add_argument("--month-day", required=True, help="MM-DD, e.g. 06-01")
|
||||
y.add_argument("--max-dates", type=int, default=0)
|
||||
y.set_defaults(func=command_compare_years)
|
||||
return p
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ensure_runtime()
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
if getattr(args, "max_dates", 0) == 0:
|
||||
args.max_dates = None
|
||||
payload = args.func(args)
|
||||
if args.format == "json":
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print_markdown(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue