google-flight
A minimalist alternative to browser automation for one job: get a flight price fast.
Where kimi-webbridge / browser-harness drive a real page (open a tab, wait for
render, click through an autocomplete dropdown, scroll a virtualized calendar,
screenshot to verify), this skill sends one HTTP GET to Google Flights' search
endpoint and parses the JSON payload embedded in the returned HTML. No browser
process, no CSS/layout, no screenshots — just a request and a parse. That makes
it both faster (~1-3s vs 10s+ for a single browser navigate) and
lighter on RAM (no Chromium tab at all).
When to use this vs. a browser tool
| Situation | Use |
|---|---|
| "What's a TPE→MAD round trip cost around these dates?" | this skill |
| Need the exact paired return-flight time, not just a price | this skill for the price, then a browser tool to pin down the return leg (see Limitations) |
| Need to actually book / fill in passenger details / pay | browser tool — this skill is read-only |
| The route keeps raising IndexError/TypeError (see below) | browser tool as fallback |
Usage
uv run scripts/search_flights.py --from TPE --to MAD --depart 2026-10-22 --return 2026-10-31 --currency TWD
uv run reads the PEP 723 header at the top of the script and installs its
pinned dependencies (fast-flights, typing_extensions) into an ephemeral
env on first run — nothing to pip install by hand, nothing to leave
installed afterward. Battery included.
Key flags:
--from/--to— IATA airport codes (e.g.TPE,MAD), validated as 3 letters and case-normalized; must differ from each other--depart YYYY-MM-DD— required, strictly validated (see Lessons #6)--return YYYY-MM-DD— omit for one-way; must not be before--depart--adults— 1-9 (Google Flights' own UI cap), default 1--currency— e.g.TWD,USD(blank lets Google pick); validated against the ~70 codes fast-flights actually supports--max-stops N— filters client-side (the library's own server-side stop filter crashes the parser on some routes, see below — don't pass it upstream); must be >= 0--depart-window "HH:MM-HH:MM"/--arrive-window "HH:MM-HH:MM"— filter the outbound leg's clock-time departure/arrival, e.g.--arrive-window "12:00-18:00"for "must land in the afternoon". Supports windows that cross midnight, e.g."22:00-04:00"for red-eyes.--format json|table—jsonfor another agent/script to consume,tablefor a human to read--limit N— cap results (sorted by price ascending), must be >= 1
On success it exits 0 and prints results. On failure (bad input, no results,
or the upstream parser choking) it prints a JSON {"error": ...} to
stdout/stderr and exits non-zero — check the exit code, don't just check
stdout is non-empty.
Lessons this distills (from manually driving Google Flights in a real browser first)
- Long-haul date math is a trap. A 19-hour TPE→MAD flight departing
Taipei at 00:30 can land in Madrid the same calendar day — Taipei is far
enough ahead of Madrid that the timezone gain outpaces the flight time. If
you want "arrives afternoon on date X", don't assume you need to search
--departone day earlier; search--departon X itself first and check the actualarrivefield the script prints. Verify, don't guess. - Round-trip here means "outbound options + total price", not two paired
legs. Google Flights' own UI is two-step: pick an outbound flight, then
it shows you return options for that specific fare. This script's
--returnflag reproduces step one only — you get a real round-trip total price, but the itinerary printed is the outbound leg; the specific return flight isn't resolved. If you need the actual return time pinned down, treat this script's price as a first-pass estimate and confirm the return leg with a browser tool. - The reverse-engineered parser is fragile, in two different ways —
and one of them was fixable. It decodes an undocumented, unstable
tfsprotobuf param and parses embedded JSON that Google can reshape at any time.- Whole-query failure, root-caused and patched: a sweep of ~20 routes
found
fast_flights.parser.parse_jsdoesprice = k[1][0][1]for every raw itinerary entry with no bounds check — one entry missing price data (common on TPE→LHR, TPE→BNE, TPE→JNB, and intermittently others) crashed the parse for the entire result set, discarding every other good entry along with it. That was a fixable bug in a 15-line loop, not an inherent fragility, so this script monkey-patchesparse_jsat import time (seepatch_resilient_parser()) to skip only the bad entry and keep the rest. Pass rate on the swept routes went from 33/40 to 40/40 after the patch. If a futurefast-flightsrelease changes the parser's internal shape, the patch fails closed (falls back to stock behavior with a stderr note) rather than breaking the script. - Per-result failure: even with the patch, one specific result can
still have its leg date/time left as
Noneby Google's payload — seen on TPE→SYD. This isn't a parse crash, it blows up later while formatting that one result. The script catches this per-row (IncompleteLegData), drops just that malformed result, and reports how many were dropped — a route returning 5 good results and skipping 1 bad one is normal, not a sign something's broken. A caught error now means "this specific route/date is still unresolvable after the patch," not "the library is generally unreliable" — treat it as fall back to a browser tool for that one query.
- Whole-query failure, root-caused and patched: a sweep of ~20 routes
found
- Don't trust server-side
max_stops. Passing it into the upstream query crashes the parser outright on some routes (empirical finding, not documented upstream). This script filters stops client-side instead — always fetch unfiltered, then narrow down in Python. - Results aren't identical to what you'd see logged into Chrome. Google Flights personalizes by session/cookies/locale/IP. A price and itinerary set fetched this way is a fast, real, but independent sample — don't be surprised if a browser session run seconds later shows a different cheapest option. For anything price-sensitive, sample both and take the lower bound as your target, not either single number as gospel.
- A malformed
--depart/--returnfails silently, not loudly — validate it yourself. Passing15-09-2026(an unambiguous non-ISO string) tofast_flights.FlightQuerydidn't raise; it silently resolved to2026-09-09and returned real, plausible-looking results for the wrong date, with nothing in the output signaling anything was off. This is far more dangerous than a crash — a crash gets noticed. This script now validates--depart/--returnas strictYYYY-MM-DDand--from/--toas 3-letter codes before calling into the library, specifically because the library won't catch this class of error for you. - A wide fuzz/stress pass caught more of the same pattern — silent
nonsense accepted, or a clean crash for a case that should be a one-line
input error. Swept ~90 more combinations: 24 non-TPE routes spanning
every continent (round-trip,
avg ~1.6s), plus deliberately bad input. Findings, now all fixed with upfront validation instead of relying on the library:--adults 0silently returned real results for a booking with nobody on it;--adults -1and--adults 50both raised an uncaught exception deep inside the library instead of a clean error. Now rejected upfront: must be 1-9 (Google's own UI cap).--depart-window/--arrive-windowwith no-, an out-of-range hour (25:00-26:00), or plain garbage all raised an unhandledValueError/traceback from insideparse_window. Now caught and reported as a clean input error.- A window that crosses midnight (
22:00-04:00, for red-eyes) used to silently match nothing, becausein_windowonly checkedstart <= t <= end— impossible whenstart > end. Fixed:in_windownow special-casesstart > endas a wraparound window. --returndated before--departwas silently accepted and searched anyway. Now rejected with a clear ordering error.- A
--currencyvalue fast-flights doesn't recognize (e.g. a typo likeXXX) was silently echoed back as the result's currency label even though Google may have priced in something else entirely — a mislabeled-price risk, not just a cosmetic one. Now validated against the ~70 currency codes the library actually supports. - Negative/zero
--limitand negative--max-stopsproduced confusing but "successful" output (e.g.--limit -1silently returned all-but- the-last row via Python slice semantics) instead of telling you the input didn't make sense. Now rejected upfront. - Three determinism runs of the identical query back-to-back returned identical prices — the "deterministic" claim holds for same-session, back-to-back calls; it's cross-session/day drift (point 5) that varies.
Files
scripts/search_flights.py— the whole skill. Self-contained (PEP 723 inline deps), runs viauv run.