Skip to content

feat: add Google Flights Explore (GetExploreDestinations) support - #226

Open
alexechoi wants to merge 5 commits into
punitarani:mainfrom
alexechoi:feat/explore-destinations
Open

feat: add Google Flights Explore (GetExploreDestinations) support#226
alexechoi wants to merge 5 commits into
punitarani:mainfrom
alexechoi:feat/explore-destinations

Conversation

@alexechoi

@alexechoi alexechoi commented Aug 11, 2026

Copy link
Copy Markdown

Summary

Adds support for Google Flights Explore — the "where can I fly cheaply?" feature at google.com/travel/explore. One request to GetExploreDestinations returns 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

  • Models (fli/models/google_flights/explore.py): ExploreSearchFilters with the wire format documented slot-by-slot (HAR-confirmed vs inferred provenance on every slot), an ExploreRegion enum of live-verified region mids (ANYWHERE, EUROPE, ASIA, …), ExplorePlace for raw /m/... mid passthrough, and ExploreDestination/ExploreResult result models.
  • Search (fli/search/explore.py): SearchExplore, mirroring the SearchDates pattern (same client, locale params, wire parsing).
  • Decoders (fli/search/_decoders.py): shape-based chunk classification + a destinations←prices left join on the knowledge-graph mid. Responses stream both record kinds across many wrb.fr chunks (up to 24 observed for large regions) in no guaranteed order, and ~25% of destinations come back unpriced — the join keeps them with price=None.
  • MCP tool search_explore, following the existing 4-part tool pattern. Priced results carry a flights_url deep link that chains into search_flights.
  • Dev tooling: scripts/probe_explore.py — the live probe matrix used to resolve every open question, with findings recorded in its docstring; an explore scenario in scripts/capture_fixtures.py.
  • Tests: positional 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: README, CLAUDE.md, docs/guides/mcp.md, and a runnable example.

Reverse-engineering notes

The endpoint is the same FlightsFrontendService family, with two deviations from the endpoints fli already calls:

  1. Same-origin check: bare requests fail with an opaque wrb.fr error [13]. Any one of x-same-domain / origin / referer fixes it (verified individually). No cookies, no at XSRF token, no f.sid/bl needed — SearchExplore sends x-same-domain: 1 + origin, and the module docstring records a full escalation ladder should Google tighten this.
  2. Locations are knowledge-graph mids with a type code (["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_date is 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 with DateSearchFilters.format()'s filters record, so this is the same underlying proto message.

Testing

  • uv run pytest --ignore=tests/search — 456 passed
  • Offline explore/wire/snapshot tests — all passing
  • tests/search/test_search_explore_live.py — 2 passed against the live API (destinations + currency-knob checks)
  • make lint — clean
  • Live manual runs: LHR→Anywhere returned 91 destinations (71 priced, Barcelona £36 cheapest); MCP tool verified end-to-end with a price cap and deep links

Note: tests/search/test_parallel_search.py has 7 failures on current main from hardcoded 2026-08-01 travel 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 explore CLI command, fli-js parity, the H028ib place-autocomplete RPC for free-text destination resolution, and GetExploreDestinationFlightDetails for 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.

  • Introduces ExploreSearchFilters, region/place types, and Explore result models.
  • Adds SearchExplore with multi-chunk decoding and locale-aware requests.
  • Exposes search_explore through MCP with result sorting, limiting, and deep links.
  • Adds captured fixtures, live probes, examples, and offline/live tests.

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

Filename Overview
fli/models/google_flights/explore.py Adds the Explore wire model and result types, but leaves trip-window shape and cross-field invariants unchecked.
fli/search/_decoders.py Adds shape-based destination and price decoders with a mid-based left join.
fli/search/explore.py Adds multi-chunk Explore request orchestration but collapses upstream and parsing failures into None.
fli/mcp/server.py Adds the MCP tool and serialization; round-trip results receive one-way deep links and parse failures become successful empty responses.
tests/search/test_search_explore.py Provides strong wire and accumulation coverage while codifying None for malformed/error responses.
examples/python/explore_anywhere.py Adds a runnable Explore example whose entry point lacks the required return annotation.

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_url
Loading

Fix All in Cursor Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
### Issue 1
fli/mcp/server.py:992-999
**Round-trip links become one-way**

When `search_explore` runs with `round_trip=True`, this call omits `return_date`, so `flights_url` opens a one-way Google Flights search that cannot reproduce the quoted round-trip fare.

### Issue 2
fli/mcp/server.py:1047-1055
**Parse failures report empty success**

When Google returns an error envelope, malformed framing, or an unrecognized response shape, `SearchExplore.search` returns `None` and this branch reports `success: true` with no destinations, preventing clients from distinguishing a failed request from a valid empty search.

### Issue 3
fli/mcp/server.py:1017-1021
**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.

### Issue 4
examples/python/explore_anywhere.py:13
**Example omits return annotation**

The new `main` function lacks the repository-required return type annotation, weakening static-analysis coverage and demonstrating a signature that does not follow the documented typing standard.

```suggestion
def main() -> None:
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat: add Google Flights Explore (GetExp..." | Re-trigger Greptile

Greptile also left 4 inline comments on this PR.

Context used (5)

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
Comment thread fli/mcp/server.py
Comment thread fli/mcp/server.py
Comment thread fli/mcp/server.py
Comment on lines +1017 to +1021
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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Cursor Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread examples/python/explore_anywhere.py Outdated
olivierbarbosa added a commit to olivierbarbosa/fli that referenced this pull request Aug 11, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Google Explore

1 participant