Merge pull request #224 from taeyoung1005/feat/flight-ticket-search

feat: 항공권 조회 스킬 추가
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-12 19:21:34 +09:00 committed by GitHub
commit fc8edd61df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 759 additions and 55 deletions

View file

@ -0,0 +1,11 @@
---
"toss-securities": minor
---
Improve toss-securities session-expiry handling and diagnostics.
- Add `auth doctor` wiring and `checkSession()` helper.
- Add `TossSessionExpiredError` for clearer invalid-session failures.
- Promote silent empty-array responses from portfolio/watchlist into explicit session-expired errors when `auth doctor` says session is invalid.
- Add `search/stocks 403` upstream hinting for quote failures.
- Extend tests and README to document behavior and `tossctl >= 0.3.6` recommendation.

View 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 우회는 하지 않았다.

View file

@ -0,0 +1,501 @@
#!/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 shutil
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
py = VENV_DIR / "bin" / "python"
def candidate_python_executables() -> list[str]:
candidates = [sys.executable]
candidates.extend(
found for name in ("python3.13", "python3.12", "python3.11", "python3")
if (found := shutil.which(name))
)
seen: set[str] = set()
unique: list[str] = []
for candidate in candidates:
resolved = str(Path(candidate).resolve())
if resolved not in seen:
seen.add(resolved)
unique.append(candidate)
return unique
def create_venv() -> None:
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
errors: list[str] = []
for python in candidate_python_executables():
shutil.rmtree(VENV_DIR, ignore_errors=True)
try:
subprocess.check_call([python, "-m", "venv", str(VENV_DIR)])
return
except (OSError, subprocess.CalledProcessError) as exc:
errors.append(f"{python}: {exc}")
raise RuntimeError(
"Unable to create flight-ticket-search venv with available Python interpreters: "
+ "; ".join(errors)
)
def venv_has_fast_flights() -> bool:
if not py.exists():
return False
return subprocess.run(
[str(py), "-c", "import fast_flights"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
if not py.exists():
create_venv()
if not venv_has_fast_flights():
try:
subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"])
subprocess.check_call([
str(py),
"-m",
"pip",
"install",
"--disable-pip-version-check",
"-q",
PINNED_FAST_FLIGHTS,
])
except (OSError, subprocess.CalledProcessError):
# Recover from interrupted or pip-less cache venvs before surfacing a hard failure.
shutil.rmtree(VENV_DIR, ignore_errors=True)
create_venv()
subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"])
subprocess.check_call([
str(py),
"-m",
"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 positive_int(value: str) -> int:
parsed = int(value)
if parsed < 1:
raise argparse.ArgumentTypeError("must be a positive integer")
return parsed
def nonnegative_int(value: str) -> int:
parsed = int(value)
if parsed < 0:
raise argparse.ArgumentTypeError("must be zero or a positive integer")
return parsed
def nonnegative_float(value: str) -> float:
parsed = float(value)
if parsed < 0:
raise argparse.ArgumentTypeError("must be zero or a positive number")
return parsed
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]:
origin = validate_airport(from_airport, "from")
dest = validate_airport(to_airport, "to")
if origin == dest:
raise SystemExit("from and to airports must be different")
outbound_date = parse_date(outbound)
from fast_flights import FlightData
data = [FlightData(date=outbound_date.isoformat(), from_airport=origin, to_airport=dest)]
if return_date:
inbound_date = parse_date(return_date)
if inbound_date < outbound_date:
raise SystemExit("return-date must be on or after date")
data.append(FlightData(date=inbound_date.isoformat(), 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 validate_date_text(value: str, field: str) -> date:
try:
return parse_date(value)
except ValueError as exc:
raise SystemExit(f"{field} must be YYYY-MM-DD, got: {value!r}") from exc
def validate_month_text(value: str) -> None:
try:
datetime.strptime(value + "-01", "%Y-%m-%d")
except ValueError as exc:
raise SystemExit(f"month must be YYYY-MM, got: {value!r}") from exc
def validate_month_day_text(value: str) -> None:
try:
datetime.strptime("2000-" + value, "%Y-%m-%d")
except ValueError as exc:
raise SystemExit(f"month-day must be MM-DD, got: {value!r}") from exc
def preflight_validate_args(args: argparse.Namespace) -> None:
validate_airport(args.from_airport, "from")
validate_airport(args.to_airport, "to")
if args.from_airport.strip().upper() == args.to_airport.strip().upper():
raise SystemExit("from and to airports must be different")
if args.command == "search":
outbound = validate_date_text(args.date, "date")
if args.return_date:
inbound = validate_date_text(args.return_date, "return-date")
if inbound < outbound:
raise SystemExit("return-date must be on or after date")
elif args.command == "compare-month":
validate_month_text(args.month)
elif args.command == "compare-range":
start = validate_date_text(args.start_date, "start-date")
end = validate_date_text(args.end_date, "end-date")
if end < start:
raise SystemExit("end-date must be on or after start-date")
elif args.command == "compare-years":
validate_month_day_text(args.month_day)
try:
years = [int(x) for x in re.split(r"[, ]+", args.years.strip()) if x]
except ValueError as exc:
raise SystemExit("years must be comma-separated numbers, e.g. 2026,2027") from exc
if not years:
raise SystemExit("years is required, e.g. 2026,2027")
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=positive_int, default=1)
sp.add_argument("--seat", choices=["economy", "premium-economy", "business", "first"], default="economy")
sp.add_argument("--limit", type=positive_int, default=5)
sp.add_argument("--sleep", type=nonnegative_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=nonnegative_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=positive_int, default=7)
r.add_argument("--max-dates", type=nonnegative_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=nonnegative_int, default=0)
y.set_defaults(func=command_compare_years)
return p
def main() -> None:
parser = build_parser()
args = parser.parse_args()
preflight_validate_args(args)
ensure_runtime()
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()

View file

@ -40,7 +40,6 @@ npm install toss-securities
세션 만료 관련:
- `account summary` 등은 만료 시 에러를 던집니다.
- 일부 커맨드(`portfolio positions`, `watchlist list`)는 upstream에서 빈 배열(`[]`)을 반환할 수 있어, 이 패키지는 기본적으로 `auth doctor`를 추가 확인해 만료를 `TossSessionExpiredError`로 승격합니다.
- 이 승격은 `auth doctor`가 파싱 가능한 JSON을 반환하고 `session.valid === false`로 명시 확인될 때만 발생합니다. `auth doctor` 실패, 파싱 불가 출력, 또는 `session.valid !== false`는 세션 만료 판정으로 취급하지 않습니다.
- 필요하면 `verifySessionOnEmpty: false`로 기존 빈 배열 동작을 유지할 수 있습니다.
대응되는 대표 CLI 는 `tossctl account summary --output json`, `tossctl quote get TSLA --output json`, `tossctl watchlist list --output json` 입니다.

View file

@ -157,33 +157,6 @@ printf '{"ok":true}\\n'
assert.deepEqual(passthrough.data, []);
});
test("portfolio empty array is preserved when auth doctor does not confirm invalid session", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-empty-valid-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "portfolio" ] && [ "$4" = "positions" ]; then
printf '[]\\n'
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
printf '{"session":{"valid":true}}\\n'
exit 0
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
const result = await getPortfolioPositions({ env });
assert.deepEqual(result.data, []);
});
test("portfolio blank stdout with invalid auth doctor is promoted to TossSessionExpiredError", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-blank-"));
const binDir = path.join(tempDir, "bin");
@ -273,33 +246,6 @@ printf '{"ok":true}\\n'
assert.deepEqual(passthrough.data, []);
});
test("watchlist empty array is preserved when auth doctor does not confirm invalid session", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-watchlist-empty-valid-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "watchlist" ] && [ "$4" = "list" ]; then
printf '[]\\n'
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
printf '{"session":{"valid":true}}\\n'
exit 0
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
const result = await listWatchlist({ env });
assert.deepEqual(result.data, []);
});
test("quote 403 includes upstream hint", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-403-"));
const binDir = path.join(tempDir, "bin");