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 — json for another agent/script to consume,
table for 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
--depart one day earlier; search --depart on X itself first and check
the actual arrive field 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
--return flag 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
tfs
protobuf 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_js does price = 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-patches
parse_js at import time (see patch_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 future fast-flights release
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
None by 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.
- 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/--return fails silently, not loudly — validate
it yourself. Passing 15-09-2026 (an unambiguous non-ISO string) to
fast_flights.FlightQuery didn't raise; it silently resolved to
2026-09-09 and 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/--return as strict YYYY-MM-DD and --from/--to
as 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 0 silently returned real results for a booking with nobody
on it; --adults -1 and --adults 50 both 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-window with no -, an out-of-range hour
(25:00-26:00), or plain garbage all raised an unhandled
ValueError/traceback from inside parse_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, because in_window only checked
start <= t <= end — impossible when start > end. Fixed: in_window
now special-cases start > end as a wraparound window.
--return dated before --depart was silently accepted and searched
anyway. Now rejected with a clear ordering error.
- A
--currency value fast-flights doesn't recognize (e.g. a typo like
XXX) 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
--limit and negative --max-stops produced confusing
but "successful" output (e.g. --limit -1 silently 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 via uv run.
1---2name: google-flight3description: Search Google Flights for prices via a single deterministic HTTP GET request — no browser, no GUI/CSS rendering, no screenshots. Use this before reaching for browser automation (kimi-webbridge, browser-harness) whenever the task is "what does this route cost", not "show me the page" or "book this seat".4---56# google-flight78A minimalist alternative to browser automation for one job: get a flight price fast.910Where `kimi-webbridge` / `browser-harness` drive a real page (open a tab, wait for11render, click through an autocomplete dropdown, scroll a virtualized calendar,12screenshot to verify), this skill sends one HTTP GET to Google Flights' search13endpoint and parses the JSON payload embedded in the returned HTML. No browser14process, no CSS/layout, no screenshots — just a request and a parse. That makes15it both **faster** (~1-3s vs 10s+ for a single browser `navigate`) and16**lighter on RAM** (no Chromium tab at all).1718## When to use this vs. a browser tool1920| Situation | Use |21|---|---|22| "What's a TPE→MAD round trip cost around these dates?" | **this skill** |23| 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) |24| Need to actually book / fill in passenger details / pay | browser tool — this skill is read-only |25| The route keeps raising `IndexError`/`TypeError` (see below) | browser tool as fallback |2627## Usage2829```bash30uv run scripts/search_flights.py --from TPE --to MAD --depart 2026-10-22 --return 2026-10-31 --currency TWD31```3233`uv run` reads the PEP 723 header at the top of the script and installs its34pinned dependencies (`fast-flights`, `typing_extensions`) into an ephemeral35env on first run — nothing to `pip install` by hand, nothing to leave36installed afterward. Battery included.3738Key flags:3940- `--from` / `--to` — IATA airport codes (e.g. `TPE`, `MAD`), validated as41 3 letters and case-normalized; must differ from each other42- `--depart YYYY-MM-DD` — required, strictly validated (see Lessons #6)43- `--return YYYY-MM-DD` — omit for one-way; must not be before `--depart`44- `--adults` — 1-9 (Google Flights' own UI cap), default 145- `--currency` — e.g. `TWD`, `USD` (blank lets Google pick); validated46 against the ~70 codes fast-flights actually supports47- `--max-stops N` — filters client-side (the library's own server-side stop48 filter crashes the parser on some routes, see below — don't pass it49 upstream); must be >= 050- `--depart-window "HH:MM-HH:MM"` / `--arrive-window "HH:MM-HH:MM"` — filter51 the outbound leg's clock-time departure/arrival, e.g. `--arrive-window52 "12:00-18:00"` for "must land in the afternoon". Supports windows that53 cross midnight, e.g. `"22:00-04:00"` for red-eyes.54- `--format json|table` — `json` for another agent/script to consume,55 `table` for a human to read56- `--limit N` — cap results (sorted by price ascending), must be >= 15758On success it exits 0 and prints results. On failure (bad input, no results,59or the upstream parser choking) it prints a JSON `{"error": ...}` to60stdout/stderr and exits non-zero — check the exit code, don't just check61stdout is non-empty.6263## Lessons this distills (from manually driving Google Flights in a real browser first)64651. **Long-haul date math is a trap.** A 19-hour TPE→MAD flight departing66 Taipei at 00:30 can land in Madrid the *same calendar day* — Taipei is far67 enough ahead of Madrid that the timezone gain outpaces the flight time. If68 you want "arrives afternoon on date X", don't assume you need to search69 `--depart` one day earlier; search `--depart` on X itself first and check70 the actual `arrive` field the script prints. Verify, don't guess.712. **Round-trip here means "outbound options + total price", not two paired72 legs.** Google Flights' own UI is two-step: pick an outbound flight, *then*73 it shows you return options for that specific fare. This script's74 `--return` flag reproduces step one only — you get a real round-trip total75 price, but the itinerary printed is the outbound leg; the specific return76 flight isn't resolved. If you need the actual return time pinned down,77 treat this script's price as a first-pass estimate and confirm the return78 leg with a browser tool.793. **The reverse-engineered parser is fragile, in two different ways —80 and one of them was fixable.** It decodes an undocumented, unstable `tfs`81 protobuf param and parses embedded JSON that Google can reshape at any82 time.83 - *Whole-query failure, root-caused and patched*: a sweep of ~20 routes84 found `fast_flights.parser.parse_js` does `price = k[1][0][1]` for85 every raw itinerary entry with no bounds check — one entry missing86 price data (common on TPE→LHR, TPE→BNE, TPE→JNB, and intermittently87 others) crashed the parse for the *entire* result set, discarding every88 other good entry along with it. That was a fixable bug in a 15-line89 loop, not an inherent fragility, so this script monkey-patches90 `parse_js` at import time (see `patch_resilient_parser()`) to skip only91 the bad entry and keep the rest. Pass rate on the swept routes went92 from 33/40 to 40/40 after the patch. If a future `fast-flights` release93 changes the parser's internal shape, the patch fails closed (falls back94 to stock behavior with a stderr note) rather than breaking the script.95 - *Per-result failure*: even with the patch, one specific result can96 still have its leg date/time left as `None` by Google's payload — seen97 on TPE→SYD. This isn't a parse crash, it blows up later while98 formatting that one result. The script catches this per-row99 (`IncompleteLegData`), drops just that malformed result, and reports100 how many were dropped — a route returning 5 good results and skipping101 1 bad one is normal, not a sign something's broken.102 A caught error now means "this specific route/date is still unresolvable103 after the patch," not "the library is generally unreliable" — treat it as104 fall back to a browser tool for that one query.1054. **Don't trust server-side `max_stops`.** Passing it into the upstream106 query crashes the parser outright on some routes (empirical finding, not107 documented upstream). This script filters stops client-side instead —108 always fetch unfiltered, then narrow down in Python.1095. **Results aren't identical to what you'd see logged into Chrome.**110 Google Flights personalizes by session/cookies/locale/IP. A price and111 itinerary set fetched this way is a fast, real, but *independent* sample —112 don't be surprised if a browser session run seconds later shows a113 different cheapest option. For anything price-sensitive, sample both and114 take the lower bound as your target, not either single number as gospel.1156. **A malformed `--depart`/`--return` fails silently, not loudly — validate116 it yourself.** Passing `15-09-2026` (an unambiguous non-ISO string) to117 `fast_flights.FlightQuery` didn't raise; it silently resolved to118 `2026-09-09` and returned real, plausible-looking results for the *wrong119 date*, with nothing in the output signaling anything was off. This is far120 more dangerous than a crash — a crash gets noticed. This script now121 validates `--depart`/`--return` as strict `YYYY-MM-DD` and `--from`/`--to`122 as 3-letter codes *before* calling into the library, specifically because123 the library won't catch this class of error for you.1247. **A wide fuzz/stress pass caught more of the same pattern — silent125 nonsense accepted, or a clean crash for a case that should be a one-line126 input error.** Swept ~90 more combinations: 24 non-TPE routes spanning127 every continent (round-trip, `avg ~1.6s`), plus deliberately bad input.128 Findings, now all fixed with upfront validation instead of relying on the129 library:130 - `--adults 0` silently returned real results for a booking with nobody131 on it; `--adults -1` and `--adults 50` both raised an uncaught132 exception deep inside the library instead of a clean error. Now133 rejected upfront: must be 1-9 (Google's own UI cap).134 - `--depart-window`/`--arrive-window` with no `-`, an out-of-range hour135 (`25:00-26:00`), or plain garbage all raised an unhandled136 `ValueError`/traceback from inside `parse_window`. Now caught and137 reported as a clean input error.138 - A window that crosses midnight (`22:00-04:00`, for red-eyes) used to139 silently match nothing, because `in_window` only checked140 `start <= t <= end` — impossible when `start > end`. Fixed: `in_window`141 now special-cases `start > end` as a wraparound window.142 - `--return` dated before `--depart` was silently accepted and searched143 anyway. Now rejected with a clear ordering error.144 - A `--currency` value fast-flights doesn't recognize (e.g. a typo like145 `XXX`) was silently echoed back as the result's currency label even146 though Google may have priced in something else entirely — a147 mislabeled-price risk, not just a cosmetic one. Now validated against148 the ~70 currency codes the library actually supports.149 - Negative/zero `--limit` and negative `--max-stops` produced confusing150 but "successful" output (e.g. `--limit -1` silently returned all-but-151 the-last row via Python slice semantics) instead of telling you the152 input didn't make sense. Now rejected upfront.153 - Three determinism runs of the identical query back-to-back returned154 identical prices — the "deterministic" claim holds for same-session,155 back-to-back calls; it's cross-session/day drift (point 5) that varies.156157## Files158159- `scripts/search_flights.py` — the whole skill. Self-contained (PEP 723160 inline deps), runs via `uv run`.