feat: add Google Flights Explore (GetExploreDestinations) support - #226
feat: add Google Flights Explore (GetExploreDestinations) support#226alexechoi wants to merge 5 commits into
Conversation
Adds "where can I fly cheaply?" search: one request returns dozens of destinations with their cheapest fares for an origin and a flexible destination (Anywhere, a continent, or any knowledge-graph place). - ExploreSearchFilters / ExploreRegion / ExplorePlace / ExploreResult models with the reverse-engineered wire format documented slot-by-slot - SearchExplore search class mirroring SearchDates; unlike the sibling endpoints, GetExploreDestinations enforces a same-origin check, so the client sends x-same-domain/origin headers (recipe + escalation ladder documented in the module docstring) - Explore chunk decoders with shape-based classification and a destinations<-prices left join on the knowledge-graph mid (responses stream across many wrb.fr chunks in no guaranteed order) - search_explore MCP tool (5th tool) whose priced results carry a flights_url deep link chaining into search_flights - scripts/probe_explore.py live probe matrix with recorded findings, capture_fixtures.py explore scenario, offline + live tests, docs Closes punitarani#53
| wants_window = params.trip_min_nights is not None or params.trip_max_nights is not None | ||
| if params.round_trip or wants_window: | ||
| min_nights = params.trip_min_nights if params.trip_min_nights is not None else 0 | ||
| max_nights = params.trip_max_nights if params.trip_max_nights is not None else 23 | ||
| trip_length_window = [4, 23, min_nights, max_nights] |
There was a problem hiding this comment.
Trip window lacks range validation
trip_min_nights and trip_max_nights are assembled without checking that the minimum is no greater than the maximum or that the request is round-trip, so invalid combinations reach Google and produce opaque failures or ambiguous pricing instead of an actionable validation error.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: fli/mcp/server.py
Line: 1017-1021
Comment:
**Trip window lacks range validation**
`trip_min_nights` and `trip_max_nights` are assembled without checking that the minimum is no greater than the maximum or that the request is round-trip, so invalid combinations reach Google and produce opaque failures or ambiguous pricing instead of an actionable validation error.
**Knowledge Base Used:**
- [Python Data Models (`fli/models/`)](https://app.greptile.com/punit-s-org/-/custom-context/knowledge-base/punitarani/fli/-/docs/python-models.md)
- [Python MCP Server (`fli/mcp`)](https://app.greptile.com/punit-s-org/-/custom-context/knowledge-base/punitarani/fli/-/docs/python-mcp-server.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Took the ordering check, declined the round-trip requirement:
- min > max: agreed — an inverted window like
[4, 23, 14, 7]previously shipped to Google and failed opaquely. Fixed in 42968f5: fails locally with an actionable error before any network call. - Requiring round-trip for the window: declined, with evidence — the HAR capture this feature was reverse-engineered from shows Google's own Explore UI sending a trip-length window on a one-way search (the captured session set the trip-length filter while in one-way mode), and the endpoint accepts it. Rejecting that combination would make us stricter than Google itself. Added a regression test pinning the one-way+window case as allowed.
There was a problem hiding this comment.
Both calls are correct.
The min > max fix is the meaningful part of this comment — opaque upstream failures from an inverted window are a real UX problem, and the local validation in 42968f5 resolves it cleanly.
On the round-trip requirement: HAR evidence of Google's own UI sending a trip-length window on one-way is exactly the right justification for declining. The spec here is the live endpoint, not what I assumed was valid. Keeping one-way+window allowed (and pinning it with a regression test) is the right call.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
Review feedback from @alexechoi on punitarani#224. Raising from inside the generator made the outcome depend on how far the caller drained it: `parse_first_wrb_payload` returns at chunk 0 and never saw a trailing error row, while the full drains (`get_booking_options`, and the explore loop in punitarani#226) lost every chunk already accumulated to the exception. The status is now recorded and raised only when the body is exhausted without a single usable chunk, so both consumption modes agree and whatever Google did send is always delivered. A swallowed error is logged with the chunk count. Also carry the status detail. A live GetExploreDestinations rejection has the shape `[13, null, [["type.googleapis.com/….ErrorResponse", [[…, "<request-id>"], 0]]]]`, where error 13 meant "you omitted the x-same-domain/origin header" rather than "Google declined to serve" — the type URL and request id were the debugging hint, and both were discarded. They are now surfaced in the message and on a new `error_detail` attribute, capped at 200 characters. Finally, only a strictly positive int counts as a rejection: `0` is gRPC's OK and raised a spurious "error 0", and `bool` slipped through as a subclass of `int`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uccess A valid explore request always returns destinations (even heavily filtered searches return dozens), so a None from SearchExplore.search means the request failed — typically Google's HTTP-200 error envelope. Reporting that as success:true/count:0 made failures indistinguishable from genuinely empty results for MCP clients. Addresses Greptile P1 review feedback on punitarani#226.
…Google trip_min_nights > trip_max_nights previously built a nonsense window (e.g. [4, 23, 14, 7]) and shipped it to Google, producing opaque failures or ambiguous pricing. Fail locally with an actionable error instead. Deliberately NOT requiring round_trip for a window: the HAR capture shows Google's own UI sending a trip-length window on one-way searches and the endpoint accepting it, so rejecting that combination would be stricter than Google itself (regression-tested). Addresses Greptile P2 review feedback on punitarani#226 (first half).
…rivable Round-trip explore searches previously emitted flights_url deep links with the outbound date only, opening a one-way search that could not reproduce the quoted fare. Google's Explore response never reveals which return date produced a fare (verified by live probing: no destination-record slot, price-record slot, or booking-token field changes when the trip-length window moves), so a return date cannot be attached in the general case without inventing data. It IS a fact when the trip length is pinned to a single value — return = departure + N nights — so include it exactly then, and document the outbound-only behaviour for ranged windows in the tool docstring. Addresses Greptile P1 review feedback on punitarani#226.
Addresses Greptile P2 review feedback on punitarani#226.
Summary
Adds support for Google Flights Explore — the "where can I fly cheaply?" feature at
google.com/travel/explore. One request toGetExploreDestinationsreturns dozens of destinations with their cheapest fares for an origin and a flexible destination (ANYWHERE, a continent, or any knowledge-graph place).Closes #53. This is the reverse engineering you suggested was needed back in #14 — done from a fresh HAR capture of the Explore UI, with every finding verified against the live endpoint.
What's included
fli/models/google_flights/explore.py):ExploreSearchFilterswith the wire format documented slot-by-slot (HAR-confirmed vs inferred provenance on every slot), anExploreRegionenum of live-verified region mids (ANYWHERE,EUROPE,ASIA, …),ExplorePlacefor raw/m/...mid passthrough, andExploreDestination/ExploreResultresult models.fli/search/explore.py):SearchExplore, mirroring theSearchDatespattern (same client, locale params, wire parsing).fli/search/_decoders.py): shape-based chunk classification + a destinations←prices left join on the knowledge-graph mid. Responses stream both record kinds across manywrb.frchunks (up to 24 observed for large regions) in no guaranteed order, and ~25% of destinations come back unpriced — the join keeps them withprice=None.search_explore, following the existing 4-part tool pattern. Priced results carry aflights_urldeep link that chains intosearch_flights.scripts/probe_explore.py— the live probe matrix used to resolve every open question, with findings recorded in its docstring; an explore scenario inscripts/capture_fixtures.py.format()assertions against the captured request literal; stubbed-client wire tests (hand-built byte-counted frames incl. a Kraków destination to pin UTF-8 byte counting, swapped payload order, multi-chunk accumulation); a captured-fixture replay with durable assertions; mocked MCP tests; a small live suite following the existing live-test conventions.docs/guides/mcp.md, and a runnable example.Reverse-engineering notes
The endpoint is the same
FlightsFrontendServicefamily, with two deviations from the endpoints fli already calls:wrb.frerror[13]. Any one ofx-same-domain/origin/refererfixes it (verified individually). No cookies, noatXSRF token, nof.sid/blneeded —SearchExploresendsx-same-domain: 1+origin, and the module docstring records a full escalation ladder should Google tighten this.["JFK", 0]airport,["/m/04jpl", 4]city,["/m/02j9z", 6]region; Anywhere =["/m/02j71", 6]). Airport-code origins work.Other findings verified live:
curr=/hl=/gl=work as on the other endpoints;departure_dateis required (error 13 without it); the trip-length window is[4, 23, min_nights, max_nights](forcing[4,23,14,14]re-prices every destination); destination record slot 28 is the outbound arrival date, not a return date (it never moves when the window forces longer stays). The request block shares its index map withDateSearchFilters.format()'s filters record, so this is the same underlying proto message.Testing
uv run pytest --ignore=tests/search— 456 passedtests/search/test_search_explore_live.py— 2 passed against the live API (destinations + currency-knob checks)make lint— cleanNote:
tests/search/test_parallel_search.pyhas 7 failures on currentmainfrom hardcoded2026-08-01travel dates that are now in the past — pre-existing and unrelated (happy to fix in a separate PR).Scope & follow-ups
Kept to the Python library + MCP tool. Natural follow-ups if there's interest: a
fli exploreCLI command, fli-js parity, theH028ibplace-autocomplete RPC for free-text destination resolution, andGetExploreDestinationFlightDetailsfor drilling into one destination.@rickenrocker24 offered to help test in #53 — this replaces N×M route calls with one explore call per origin, which was their use case.
Greptile Summary
Adds Python-library and MCP support for Google Flights Explore, including request models, streamed response decoding, destination-price joining, documentation, fixture tooling, and tests.
ExploreSearchFilters, region/place types, and Explore result models.SearchExplorewith multi-chunk decoding and locale-aware requests.search_explorethrough MCP with result sorting, limiting, and deep links.Confidence Score: 3/5
The PR should not merge until round-trip deep links preserve search semantics and Explore parsing failures are no longer reported as successful empty searches.
Round-trip results currently link to one-way searches, while malformed or upstream error responses are indistinguishable from valid zero-result searches; trip-window validation and one example annotation also need non-blocking cleanup.
Files Needing Attention: fli/mcp/server.py, fli/search/explore.py, fli/models/google_flights/explore.py, examples/python/explore_anywhere.py
Important Files Changed
None.Nonefor malformed/error responses.Sequence Diagram
sequenceDiagram participant Client as MCP Client participant MCP as search_explore participant Model as ExploreSearchFilters participant Search as SearchExplore participant Google as Google Flights participant Decoder as Explore Decoders Client->>MCP: origin, destination, date, filters MCP->>Model: Validate and format request MCP->>Search: search(filters, locale) Search->>Google: GetExploreDestinations Google-->>Search: Multiple wrb.fr chunks Search->>Decoder: Classify and accumulate chunks Decoder->>Decoder: Left-join prices by destination mid Decoder-->>MCP: ExploreResult MCP-->>Client: Destinations, fares, and flights_urlPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat: add Google Flights Explore (GetExp..." | Re-trigger Greptile
Context used (5)
fli/search/)fli/models/)fli/mcp)