diff --git a/CLAUDE.md b/CLAUDE.md index 11eea749..b5a1a005 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ uv run mkdocs build # Build static docs 3. **Search Engine** (`fli/search/`) - `SearchFlights`: Core flight search using Google Flights API - `SearchDates`: Find cheapest dates within date ranges + - `SearchExplore`: Discover cheap destinations from an origin (Google Flights Explore) - Direct API integration (no web scraping) 4. **Data Models** (`fli/models/`) @@ -73,7 +74,7 @@ uv run mkdocs build # Build static docs - All models use Pydantic for validation 5. **MCP Server** (`fli/mcp/`) - - FastMCP-based server with four tools: `search_flights`, `search_dates`, `get_booking_options`, `find_airports` + - FastMCP-based server with five tools: `search_flights`, `search_dates`, `search_explore`, `get_booking_options`, `find_airports` - Industry-standard parameter naming: `origin`, `destination`, `cabin_class`, `max_stops` - Per-flight booking deep-link URLs (`tfs` protobuf) in every search result - Prompt templates for guided searches @@ -96,7 +97,7 @@ uv run mkdocs build # Build static docs ## Key Files and Entry Points - `fli/cli/main.py` - CLI entry point and command registration -- `fli/mcp/server.py` - MCP server with `search_flights` and `search_dates` tools +- `fli/mcp/server.py` - MCP server with `search_flights`, `search_dates`, and `search_explore` tools - `fli/core/parsers.py` - Shared parsing utilities - `fli/core/builders.py` - Shared filter building utilities - `fli/search/flights.py` - Core flight search implementation @@ -145,6 +146,28 @@ Find cheapest travel dates within a range. **Response:** Each date result carries a `booking_url` deep-linking to Google Flights for that specific date (and return date for round trips). +### `search_explore` +Discover where you can fly cheaply when the destination is flexible +(Google Flights Explore / `GetExploreDestinations`). One call returns dozens +of destinations with their cheapest fares. + +**Key Parameters:** +- `origin` - Airport IATA code (e.g. 'JFK') or a city knowledge-graph mid (e.g. '/m/04jpl') +- `departure_date` - Date in YYYY-MM-DD format (required — the endpoint errors without one) +- `destination` - ANYWHERE (default), EUROPE, SOUTHERN_EUROPE, ASIA, AFRICA, + NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, a raw `/m/...` mid, or an IATA code +- `round_trip` / `trip_min_nights` / `trip_max_nights` - Round-trip pricing with a + trip-length window (nights, 0-23) +- `max_price`, `cabin_class`, `max_stops`, `airlines`, `exclude_airlines`, + `alliance`, `exclude_alliance`, `max_flight_duration` - Same semantics as `search_flights` +- `currency` / `language` / `country` - Same locale knobs as `search_flights` +- `sort_by_price` (default true), `limit` + +**Response:** `destinations[]` with name, country, price (null when Google +found no fare), airline, stops, duration, `destination_airport`, dates, +coordinates, an image URL, and a `flights_url` deep link. Chain a result's +`destination_airport` into `search_flights` for bookable itineraries. + ### `get_booking_options` Get bookable fares (vendor names, prices, and direct booking URLs) for a single itinerary. Runs a fresh search, selects the flight identified by diff --git a/README.md b/README.md index afbf1a54..565b28a4 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,13 @@ fli-mcp-http # serves at http://127.0.0.1:8000/mcp/ ### MCP Tools Available -The MCP server provides two main tools: +The MCP server provides these main tools: | Tool | Description | |----------------------|-------------------------------------------------------------| | **`search_flights`** | Search for flights on a specific date with detailed filters | | **`search_dates`** | Find the cheapest travel dates across a flexible date range | +| **`search_explore`** | Discover cheap destinations from an origin ("fly anywhere") | #### `search_flights` Parameters @@ -101,6 +102,38 @@ The MCP server provides two main tools: | `sort_by_price` | bool | Sort results by price (lowest first) | | `passengers` | int | Number of adult passengers | +#### `search_explore` Parameters + +Powered by Google Flights Explore (`GetExploreDestinations`): one call returns +dozens of destinations with their cheapest fares when the destination is +flexible. + +| Parameter | Type | Description | +|-----------------------|--------|--------------------------------------------------------------| +| `origin` | string | Airport IATA code (e.g. 'JFK') or a city mid (e.g. '/m/04jpl') | +| `destination` | string | ANYWHERE (default), EUROPE, SOUTHERN_EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, a knowledge-graph mid, or an IATA code | +| `departure_date` | string | Departure date in YYYY-MM-DD format (required) | +| `round_trip` | bool | Price round trips instead of one-ways | +| `trip_min_nights` | int | Minimum trip length in nights (round trips, 0-23) | +| `trip_max_nights` | int | Maximum trip length in nights (round trips, 0-23) | +| `max_price` | int | Maximum fare cap | +| `cabin_class` | string | ECONOMY, PREMIUM_ECONOMY, BUSINESS, or FIRST | +| `max_stops` | string | ANY, NON_STOP, ONE_STOP, or TWO_PLUS_STOPS | +| `airlines` | list | Filter by airline codes (e.g., ['BA', 'AA']) | +| `exclude_airlines` | list | Airline IATA codes to **exclude** | +| `alliance` | list | Restrict to alliances: ONEWORLD, SKYTEAM, STAR_ALLIANCE | +| `exclude_alliance` | list | Alliance names to **exclude** | +| `max_flight_duration` | int | Maximum flight duration in minutes | +| `currency` | string | ISO 4217 currency code (e.g. 'EUR', 'JPY') | +| `language` | string | BCP-47 language code (e.g. 'en-GB') | +| `country` | string | ISO 3166-1 alpha-2 country code (e.g. 'GB') | +| `sort_by_price` | bool | Sort destinations by price (default true) | +| `limit` | int | Maximum number of destinations to return | + +Each priced destination includes the airline, stops, duration, destination +airport, and a `flights_url` deep link; pass the `destination_airport` to +`search_flights` for bookable itineraries. + ## Quick Start ```bash diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index a6c85d94..e9da9f16 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -182,6 +182,79 @@ Find the cheapest travel dates between two airports within a date range. Each date result carries a `booking_url` deep-linking to Google Flights for that specific date (and return date for round trips). +### `search_explore` + +Discover where you can fly cheaply when the destination is flexible — powered +by Google Flights Explore (`GetExploreDestinations`). One call returns dozens +of destinations with their cheapest fares for an origin and a broad +destination like `ANYWHERE` or a continent. + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `origin` | string | Yes | - | Airport IATA code (e.g., 'JFK') or a city knowledge-graph mid (e.g., '/m/04jpl' for London) | +| `departure_date` | string | Yes | - | Departure date in YYYY-MM-DD format | +| `destination` | string | No | ANYWHERE | ANYWHERE, EUROPE, SOUTHERN_EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, a knowledge-graph mid (e.g., '/m/02j9z'), or an IATA code | +| `round_trip` | bool | No | false | Price round trips instead of one-ways | +| `trip_min_nights` | int | No | null | Minimum trip length in nights (round trips, 0-23) | +| `trip_max_nights` | int | No | null | Maximum trip length in nights (round trips, 0-23) | +| `max_price` | int | No | null | Maximum fare cap | +| `cabin_class` | string | No | ECONOMY | ECONOMY, PREMIUM_ECONOMY, BUSINESS, or FIRST | +| `max_stops` | string | No | ANY | ANY, NON_STOP, ONE_STOP, or TWO_PLUS_STOPS | +| `airlines` | list | No | null | Filter by airline codes (e.g., ['BA', 'AA']) | +| `exclude_airlines` | list | No | null | Airline IATA codes to **exclude** | +| `alliance` | list | No | null | Restrict to ONEWORLD / SKYTEAM / STAR_ALLIANCE | +| `exclude_alliance` | list | No | null | Alliance(s) to **exclude** | +| `max_flight_duration` | int | No | null | Maximum flight duration in minutes | +| `currency` | string | No | null | ISO 4217 currency code (`curr=`) | +| `language` | string | No | null | BCP-47 language code (`hl=`) | +| `country` | string | No | null | ISO 3166-1 alpha-2 country (`gl=`) | +| `sort_by_price` | bool | No | true | Sort destinations by price (lowest first) | +| `limit` | int | No | null | Maximum number of destinations to return | + +**Example Response:** + +```json +{ + "success": true, + "origin": "LHR", + "origin_name": "London", + "destination": "EUROPE", + "region_name": "Europe", + "departure_date": "2026-09-10", + "trip_type": "ONE_WAY", + "count": 77, + "priced_count": 55, + "destinations": [ + { + "name": "Edinburgh", + "country": "United Kingdom", + "mid": "/m/02m77", + "price": 21.0, + "currency": "GBP", + "departure_date": "2026-09-10", + "arrival_date": "2026-09-10", + "airline": "RK", + "airline_name": "Ryanair UK", + "stops": 0, + "duration_minutes": 80, + "destination_airport": "EDI", + "latitude": 55.953252, + "longitude": -3.188267, + "image_url": "https://encrypted-tbn3.gstatic.com/images?q=...", + "flights_url": "https://www.google.com/travel/flights?q=Flights%20from%20LHR%20to%20EDI%20on%202026-09-10" + } + ] +} +``` + +Some destinations come back without a price (`price: null`) — Google found no +itinerary matching the filters for them. Follow up with +[`search_flights`](#search_flights) using a result's `destination_airport` and +`departure_date` for bookable itineraries; each priced destination also +carries a ready-made `flights_url` deep link. + ### `get_booking_options` Get bookable fares — vendor names, prices, and **direct booking URLs** — for a diff --git a/examples/python/explore_anywhere.py b/examples/python/explore_anywhere.py new file mode 100644 index 00000000..3d3f48f8 --- /dev/null +++ b/examples/python/explore_anywhere.py @@ -0,0 +1,46 @@ +"""Explore search: find the cheapest destinations from an origin. + +Uses Google Flights Explore to answer "where can I fly cheaply?" — one +request returns dozens of destinations with their cheapest fares. +""" + +from datetime import datetime, timedelta + +from fli.models import Airport, ExploreRegion, ExploreSearchFilters +from fli.search import SearchExplore + + +def main() -> None: + """Search for the cheapest places to fly from London to anywhere in Europe.""" + departure_date = (datetime.now() + timedelta(days=45)).strftime("%Y-%m-%d") + + filters = ExploreSearchFilters( + origin=Airport.LHR, + destination=ExploreRegion.EUROPE, # or ANYWHERE, ASIA, a raw ExplorePlace mid... + departure_date=departure_date, + ) + + result = SearchExplore().search(filters, currency="GBP") + if result is None: + print("Search failed") + return + + priced = sorted( + (d for d in result.destinations if d.price is not None), + key=lambda d: d.price, + ) + print( + f"{len(result.destinations)} destinations from {result.origin_name} " + f"on {departure_date} ({len(priced)} priced)\n" + ) + for destination in priced[:10]: + print( + f"{destination.name:15} {destination.country or '':15} " + f"£{destination.price:>6.0f} {destination.airline_name or destination.airline}" + f" -> {destination.destination_airport}" + f" ({destination.stops} stops, {destination.duration_minutes} min)" + ) + + +if __name__ == "__main__": + main() diff --git a/fli/mcp/__init__.py b/fli/mcp/__init__.py index 7f60fa95..dfd4e712 100644 --- a/fli/mcp/__init__.py +++ b/fli/mcp/__init__.py @@ -8,18 +8,22 @@ try: from fli.mcp.server import ( DateSearchParams, + ExploreSearchParams, FlightSearchParams, mcp, run, run_http, search_dates, + search_explore, search_flights, ) __all__ = [ "DateSearchParams", + "ExploreSearchParams", "FlightSearchParams", "search_dates", + "search_explore", "search_flights", "mcp", "run", diff --git a/fli/mcp/server.py b/fli/mcp/server.py index a4e286fa..aee5c4cc 100755 --- a/fli/mcp/server.py +++ b/fli/mcp/server.py @@ -35,11 +35,15 @@ Airport, BagsFilter, DateSearchFilters, + ExplorePlace, + ExploreRegion, + ExploreSearchFilters, FlightSearchFilters, PassengerInfo, + PriceLimit, TripType, ) -from fli.search import SearchDates, SearchFlights +from fli.search import SearchDates, SearchExplore, SearchFlights class FlightSearchConfig(BaseSettings): @@ -259,6 +263,78 @@ class DateSearchParams(BaseModel): ) +class ExploreSearchParams(BaseModel): + """Parameters for exploring destinations from an origin (flexible destination).""" + + origin: str = Field( + description=( + "Departure airport IATA code (e.g., 'JFK'), or a Google knowledge-graph " + "mid for a city (e.g., '/m/04jpl' for London)." + ) + ) + destination: str = Field( + "ANYWHERE", + description=( + "Where to explore: ANYWHERE, EUROPE, SOUTHERN_EUROPE, ASIA, AFRICA, " + "NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, a raw knowledge-graph mid " + "(e.g., '/m/02j9z'), or an airport IATA code." + ), + ) + departure_date: str = Field(description="Departure date in YYYY-MM-DD format (required)") + round_trip: bool = Field(False, description="Price round trips instead of one-ways") + trip_min_nights: int | None = Field( + None, ge=0, le=23, description="Minimum trip length in nights (round trips)" + ) + trip_max_nights: int | None = Field( + None, ge=0, le=23, description="Maximum trip length in nights (round trips)" + ) + max_price: int | None = Field(None, gt=0, description="Maximum price cap for fares") + cabin_class: str = Field( + CONFIG.default_cabin_class, + description="Cabin class: ECONOMY, PREMIUM_ECONOMY, BUSINESS, or FIRST", + ) + max_stops: str = Field( + "ANY", description="Maximum stops: ANY, NON_STOP, ONE_STOP, or TWO_PLUS_STOPS" + ) + airlines: list[str] | None = Field( + None, description="Filter by airline IATA codes (e.g., ['BA', 'AA'])" + ) + exclude_airlines: list[str] | None = Field( + None, description="Airline IATA codes to EXCLUDE from results." + ) + alliance: list[str] | None = Field( + None, description="Restrict to alliances: 'ONEWORLD', 'SKYTEAM', 'STAR_ALLIANCE'." + ) + exclude_alliance: list[str] | None = Field( + None, description="Alliance names to EXCLUDE from results." + ) + max_flight_duration: int | None = Field( + None, gt=0, description="Maximum flight duration in minutes" + ) + passengers: int = Field( + CONFIG.default_passengers, ge=1, description="Number of adult passengers" + ) + currency: str | None = Field( + None, + description=( + "ISO 4217 currency code (e.g. 'USD', 'EUR', 'GBP') to bill prices in. " + "When omitted, Google picks based on locale (usually USD)." + ), + ) + language: str | None = Field( + None, + description="Optional BCP-47 language code (e.g. 'en-GB') passed to Google as `hl`.", + ) + country: str | None = Field( + None, + description=( + "Optional ISO 3166-1 alpha-2 country code (e.g. 'GB') for Google's `gl` param." + ), + ) + sort_by_price: bool = Field(True, description="Sort destinations by price (lowest first)") + limit: int | None = Field(None, gt=0, description="Maximum number of destinations to return") + + # ============================================================================= # Result Serialization # ============================================================================= @@ -861,6 +937,192 @@ def _execute_date_search(params: DateSearchParams) -> dict[str, Any]: return {"success": False, "error": f"Search failed: {str(e)}", "dates": []} +def _parse_explore_origin(value: str) -> Airport | ExplorePlace: + """Resolve an explore origin: airport IATA code or a city knowledge-graph mid.""" + cleaned = value.strip() + if cleaned.startswith(("/m/", "/g/")): + # City/metro type — the only origin mid type observed in captures. + return ExplorePlace(mid=cleaned, type_code=4) + return resolve_airport(cleaned) + + +def _parse_explore_destination(value: str) -> ExploreRegion | ExplorePlace | Airport: + """Resolve an explore destination: region name, knowledge-graph mid, or IATA code.""" + cleaned = value.strip() + if cleaned.startswith(("/m/", "/g/")): + return ExplorePlace(mid=cleaned) + region_key = cleaned.upper().replace(" ", "_").replace("-", "_") + if region_key in ExploreRegion.__members__: + return ExploreRegion[region_key] + try: + return resolve_airport(cleaned) + except ParseError: + regions = ", ".join(ExploreRegion.__members__) + raise ParseError( + f"Unknown explore destination '{value}'. Use one of: {regions}; " + "a knowledge-graph mid like '/m/02j9z'; or an airport IATA code." + ) from None + + +def _serialize_explore_destination( + destination: Any, + origin_label: str | None, + locale: tuple[str | None, str | None, str | None], + exact_nights: int | None = None, +) -> dict[str, Any]: + """Serialize one explore destination for tool output. + + ``exact_nights`` is set only when a round-trip search pinned the trip + length to a single value — the one case where the return date is + derivable as fact. Google's Explore response does not reveal the chosen + return date otherwise (verified by live probing: no destination-record + slot, price-record slot, or booking-token field moves when the + trip-length window changes). + """ + currency, language, country = locale + entry: dict[str, Any] = { + "name": destination.name, + "country": destination.country, + "mid": destination.mid, + "price": destination.price, + "currency": destination.currency, + "departure_date": destination.departure_date, + "arrival_date": destination.arrival_date, + "airline": destination.airline, + "airline_name": destination.airline_name, + "stops": destination.stops, + "duration_minutes": destination.duration_minutes, + "destination_airport": destination.destination_airport, + "latitude": destination.latitude, + "longitude": destination.longitude, + "image_url": destination.hero_image_url or destination.thumbnail_url, + } + if origin_label and destination.destination_airport and destination.departure_date: + link_return_date = None + if exact_nights is not None: + departure = datetime.strptime(destination.departure_date, "%Y-%m-%d") + link_return_date = (departure + timedelta(days=exact_nights)).strftime("%Y-%m-%d") + entry["flights_url"] = google_flights_url( + origin_label, + destination.destination_airport, + destination.departure_date, + link_return_date, + currency=currency, + language=language, + country=country, + ) + return entry + + +def _execute_explore_search(params: ExploreSearchParams) -> dict[str, Any]: + """Execute an explore search and return formatted results.""" + try: + origin = _parse_explore_origin(params.origin) + destination = _parse_explore_destination(params.destination) + cabin_class = parse_cabin_class(params.cabin_class) + max_stops = parse_max_stops(params.max_stops) + airlines = parse_airlines(params.airlines) + airlines_exclude = parse_airlines(params.exclude_airlines) + alliances = parse_alliances(params.alliance) + alliances_exclude = parse_alliances(params.exclude_alliance) + currency = parse_currency(params.currency) + + trip_length_window = None + 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 + if min_nights > max_nights: + raise ParseError( + f"trip_min_nights ({min_nights}) cannot exceed " + f"trip_max_nights ({max_nights})" + ) + trip_length_window = [4, 23, min_nights, max_nights] + + filters = ExploreSearchFilters( + trip_type=TripType.ROUND_TRIP if params.round_trip else TripType.ONE_WAY, + passenger_info=PassengerInfo(adults=params.passengers), + origin=origin, + destination=destination, + departure_date=params.departure_date, + stops=max_stops, + seat_type=cabin_class, + price_limit=PriceLimit(max_price=params.max_price) if params.max_price else None, + airlines=airlines, + airlines_exclude=airlines_exclude, + alliances=alliances, + alliances_exclude=alliances_exclude, + max_duration=params.max_flight_duration, + trip_length_window=trip_length_window, + ) + + result = SearchExplore().search( + filters, + currency=currency, + language=params.language, + country=params.country, + ) + + if result is None: + # A valid explore request always returns destinations, so an + # unparseable response means the request failed (e.g. Google's + # HTTP-200 error envelope) — report it as such rather than as an + # empty result set. + return { + "success": False, + "error": ( + "Explore search returned no parseable response from Google. " + "This usually indicates a rejected request rather than zero " + "matching destinations; check the filters and try again." + ), + "destinations": [], + } + + destinations = list(result.destinations) + if params.sort_by_price: + destinations.sort(key=lambda d: (d.price is None, d.price or 0)) + + limit = params.limit or CONFIG.max_results + if limit: + destinations = destinations[:limit] + + origin_label = ( + origin.name.removeprefix("_") if isinstance(origin, Airport) else result.origin_name + ) + locale = (params.currency, params.language, params.country) + # The return date is only knowable when the trip length is pinned to + # a single value; Google's response never reveals it otherwise. + exact_nights = ( + params.trip_min_nights + if params.round_trip + and params.trip_min_nights is not None + and params.trip_min_nights == params.trip_max_nights + else None + ) + serialized = [ + _serialize_explore_destination(d, origin_label, locale, exact_nights) + for d in destinations + ] + + return { + "success": True, + "origin": params.origin, + "origin_name": result.origin_name, + "destination": params.destination, + "region_name": result.region_name, + "departure_date": params.departure_date, + "trip_type": "ROUND_TRIP" if params.round_trip else "ONE_WAY", + "count": len(serialized), + "priced_count": sum(1 for d in serialized if d["price"] is not None), + "destinations": serialized, + } + + except ParseError as e: + return {"success": False, "error": str(e), "destinations": []} + except Exception as e: + return {"success": False, "error": f"Search failed: {str(e)}", "destinations": []} + + # ============================================================================= # MCP Tools # ============================================================================= @@ -1142,6 +1404,144 @@ def _search_dates_from_params(params: DateSearchParams) -> dict[str, Any]: return _execute_date_search(params) +@mcp.tool( + annotations={ + "title": "Explore Destinations", + "readOnlyHint": True, + "idempotentHint": True, + }, +) +def search_explore( + origin: Annotated[ + str, + Field( + description="Departure airport IATA code (e.g., 'JFK'), or a Google " + "knowledge-graph mid for a city (e.g., '/m/04jpl' for London)" + ), + ], + departure_date: Annotated[str, Field(description="Departure date in YYYY-MM-DD format")], + destination: Annotated[ + str, + Field( + description="Where to explore: ANYWHERE, EUROPE, SOUTHERN_EUROPE, ASIA, " + "AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, a knowledge-graph mid " + "(e.g., '/m/02j9z'), or an airport IATA code" + ), + ] = "ANYWHERE", + round_trip: Annotated[ + bool, + Field(description="Price round trips instead of one-ways"), + ] = False, + trip_min_nights: Annotated[ + int | None, + Field(description="Minimum trip length in nights (round trips)", ge=0, le=23), + ] = None, + trip_max_nights: Annotated[ + int | None, + Field(description="Maximum trip length in nights (round trips)", ge=0, le=23), + ] = None, + max_price: Annotated[ + int | None, + Field(description="Maximum price cap for fares", gt=0), + ] = None, + cabin_class: Annotated[ + str, + Field(description="Cabin class: ECONOMY, PREMIUM_ECONOMY, BUSINESS, FIRST"), + ] = CONFIG.default_cabin_class, + max_stops: Annotated[ + str, + Field(description="Maximum stops: ANY, NON_STOP, ONE_STOP, TWO_PLUS_STOPS"), + ] = "ANY", + airlines: Annotated[ + list[str] | None, + Field(description="Filter by airline IATA codes (e.g., ['BA', 'AA'])"), + ] = None, + exclude_airlines: Annotated[ + list[str] | None, + Field(description="Airline IATA codes to EXCLUDE from results."), + ] = None, + alliance: Annotated[ + list[str] | None, + Field(description="Restrict to alliances: ONEWORLD, SKYTEAM, STAR_ALLIANCE."), + ] = None, + exclude_alliance: Annotated[ + list[str] | None, + Field(description="Alliance names to EXCLUDE from results."), + ] = None, + max_flight_duration: Annotated[ + int | None, + Field(description="Maximum flight duration in minutes", gt=0), + ] = None, + passengers: Annotated[ + int | None, + Field(description="Number of adult passengers", ge=1), + ] = None, + currency: Annotated[ + str | None, + Field(description="ISO 4217 currency code (USD, EUR, GBP, JPY...) for prices."), + ] = None, + language: Annotated[ + str | None, + Field(description="Optional BCP-47 language code (e.g., 'en-GB') for the `hl` URL param."), + ] = None, + country: Annotated[ + str | None, + Field(description="Optional ISO 3166-1 alpha-2 country code (e.g., 'GB')."), + ] = None, + sort_by_price: Annotated[ + bool, + Field(description="Sort destinations by price (lowest first)"), + ] = True, + limit: Annotated[ + int | None, + Field(description="Maximum number of destinations to return", gt=0), + ] = None, +) -> dict[str, Any]: + """Discover where you can fly cheaply when the destination is flexible. + + Use this when the user asks "where can I go?", "cheapest places to fly", + or gives a broad destination like a continent instead of a city. One call + returns dozens of destinations with their cheapest fares (some come back + unpriced — Google found no itinerary matching the filters for them). + + Follow up with `search_flights` using a result's `destination_airport` + and `departure_date` for bookable itineraries; each priced destination + also carries a `flights_url` deep link. For round-trip searches the + link includes the return date only when the trip length is pinned + (trip_min_nights == trip_max_nights); otherwise it pre-fills the + outbound date only, because Google's Explore response does not reveal + which return date produced the quoted fare. + """ + params = ExploreSearchParams( + origin=origin, + destination=destination, + departure_date=departure_date, + round_trip=round_trip, + trip_min_nights=trip_min_nights, + trip_max_nights=trip_max_nights, + max_price=max_price, + cabin_class=cabin_class, + max_stops=max_stops, + airlines=airlines, + exclude_airlines=exclude_airlines, + alliance=alliance, + exclude_alliance=exclude_alliance, + max_flight_duration=max_flight_duration, + passengers=passengers or CONFIG.default_passengers, + currency=currency, + language=language, + country=country, + sort_by_price=sort_by_price, + limit=limit, + ) + return _execute_explore_search(params) + + +def _search_explore_from_params(params: ExploreSearchParams) -> dict[str, Any]: + """Entry point for tests that call the tool via a params object.""" + return _execute_explore_search(params) + + @mcp.tool( annotations={ "title": "Get Booking Options", diff --git a/fli/models/__init__.py b/fli/models/__init__.py index 89acec1f..6e1df787 100644 --- a/fli/models/__init__.py +++ b/fli/models/__init__.py @@ -8,6 +8,12 @@ Currency, DateSearchFilters, EmissionsFilter, + ExploreDestination, + ExplorePlace, + ExplorePlaceType, + ExploreRegion, + ExploreResult, + ExploreSearchFilters, FlightLeg, FlightResult, FlightSearchFilters, @@ -33,6 +39,12 @@ "Currency", "DateSearchFilters", "EmissionsFilter", + "ExploreDestination", + "ExplorePlace", + "ExplorePlaceType", + "ExploreRegion", + "ExploreResult", + "ExploreSearchFilters", "FlightLeg", "FlightResult", "FlightSearchFilters", diff --git a/fli/models/google_flights/__init__.py b/fli/models/google_flights/__init__.py index 7e63ef41..18cec3a7 100644 --- a/fli/models/google_flights/__init__.py +++ b/fli/models/google_flights/__init__.py @@ -19,6 +19,14 @@ TripType, ) from .dates import DateSearchFilters +from .explore import ( + ExploreDestination, + ExplorePlace, + ExplorePlaceType, + ExploreRegion, + ExploreResult, + ExploreSearchFilters, +) from .flights import FlightSearchFilters __all__ = [ @@ -31,6 +39,12 @@ "Currency", "DateSearchFilters", "EmissionsFilter", + "ExploreDestination", + "ExplorePlace", + "ExplorePlaceType", + "ExploreRegion", + "ExploreResult", + "ExploreSearchFilters", "FlightLeg", "FlightResult", "FlightSearchFilters", diff --git a/fli/models/google_flights/explore.py b/fli/models/google_flights/explore.py new file mode 100644 index 00000000..e177769b --- /dev/null +++ b/fli/models/google_flights/explore.py @@ -0,0 +1,336 @@ +"""Models for the Google Flights Explore (``GetExploreDestinations``) endpoint. + +Explore is the https://www.google.com/travel/explore feature: search from an +origin to "Anywhere" (or a region) and get back many destinations, each with +the cheapest found fare. + +Unlike the other FlightsFrontendService endpoints, Explore locations are +Google knowledge-graph entities (``/m/...`` mids) with a type code, not just +IATA codes: + + ``["JFK", 0]`` airport (same encoding as the other endpoints) + ``["/m/04jpl", 4]`` city / metro area (London) + ``["/m/0250wj", 6]`` region / continent (Southern Europe) + +The wire format below was reverse-engineered from a HAR capture of the +Explore UI (2026-08) in which filters were toggled one at a time, isolating +every payload position; positions were cross-checked against the ``tfs=`` +protobuf in each request's Referer. Slots marked "HAR-confirmed" were +observed changing in the capture; slots marked "inferred" follow the shared +FlightsFrontendService message layout (see ``DateSearchFilters.format()``) +but were not directly exercised. +""" + +import json +import urllib.parse +from datetime import datetime +from enum import Enum + +from pydantic import ( + BaseModel, + Field, + NonNegativeFloat, + NonNegativeInt, + PositiveInt, + field_validator, + model_validator, +) + +from fli.models.airline import Airline +from fli.models.airport import Airport +from fli.models.google_flights.base import ( + Alliance, + BagsFilter, + MaxStops, + PassengerInfo, + PriceLimit, + SeatType, + TripType, +) + + +class ExplorePlaceType(Enum): + """Location type codes accepted by the Explore endpoint. + + ``AIRPORT`` matches the encoding used by every other fli endpoint; + ``CITY`` and ``REGION`` were both observed in the HAR capture. + """ + + AIRPORT = 0 + CITY = 4 + REGION = 6 + + +class ExploreRegion(Enum): + """Curated knowledge-graph mids for well-known Explore destinations. + + ``ANYWHERE`` (``/m/02j71`` = Earth) and the continent mids come from + Google's place autocomplete (``H028ib``); ``EUROPE`` and + ``SOUTHERN_EUROPE`` were additionally confirmed in live Explore requests + in the HAR capture. All values are verified live by + ``scripts/probe_explore.py`` before release. + """ + + ANYWHERE = "/m/02j71" + EUROPE = "/m/02j9z" + SOUTHERN_EUROPE = "/m/0250wj" + ASIA = "/m/0j0k" + AFRICA = "/m/0dg3n1" + NORTH_AMERICA = "/m/059g4" + SOUTH_AMERICA = "/m/06n3y" + OCEANIA = "/m/05nrg" + + +class ExplorePlace(BaseModel): + """A raw knowledge-graph place for Explore origins/destinations. + + Use this to pass any Google mid not covered by :class:`ExploreRegion` + (e.g. a city or country resolved externally). ``type_code`` follows + :class:`ExplorePlaceType` (4 = city/metro, 6 = region/country/continent). + """ + + mid: str + type_code: int = ExplorePlaceType.REGION.value + + @field_validator("mid") + @classmethod + def validate_mid(cls, v: str) -> str: + """Ensure the mid looks like a knowledge-graph identifier.""" + if not (v.startswith("/m/") or v.startswith("/g/")): + raise ValueError("mid must be a knowledge-graph id starting with /m/ or /g/") + return v + + +ExploreLocation = Airport | ExplorePlace | ExploreRegion + + +def _place_token(place: ExploreLocation) -> list: + """Serialize a location into the ``[identifier, type]`` wire pair.""" + if isinstance(place, Airport): + return [place.name.removeprefix("_"), ExplorePlaceType.AIRPORT.value] + if isinstance(place, ExploreRegion): + return [place.value, ExplorePlaceType.REGION.value] + return [place.mid, place.type_code] + + +class ExploreSearchFilters(BaseModel): + """Filters for a Google Flights Explore search. + + Explore shares the FlightsFrontendService request message with the other + endpoints but wraps it differently (see :meth:`format`). + + ``departure_date`` is required — the endpoint returns an opaque error 13 + without one (the UI's "flexible dates" mode uses request slots this + capture did not exercise). + + ``trip_length_window`` is ``[4, 23, min_nights, max_nights]``: the + leading ``4, 23`` are constants observed in every capture; the trailing + pair is the UI's trip-length slider. Live probing confirmed the pair + changes round-trip fares (e.g. forcing exactly 14 nights re-prices every + destination). Round-trip mode is experimental: without a window forcing a + minimum stay, quoted fares match one-way prices. + """ + + trip_type: TripType = TripType.ONE_WAY + passenger_info: PassengerInfo = Field(default_factory=PassengerInfo) + origin: Airport | ExplorePlace + destination: ExploreRegion | ExplorePlace | Airport = ExploreRegion.ANYWHERE + departure_date: str + stops: MaxStops = MaxStops.ANY + seat_type: SeatType = SeatType.ECONOMY + price_limit: PriceLimit | None = None + airlines: list[Airline] | None = None + airlines_exclude: list[Airline] | None = None + alliances: list[Alliance] | None = None + alliances_exclude: list[Alliance] | None = None + max_duration: PositiveInt | None = None + bags: BagsFilter | None = None + trip_length_window: list[int] | None = None + + @field_validator("trip_type") + @classmethod + def validate_trip_type(cls, v: TripType) -> TripType: + """Explore only supports one-way and round-trip searches.""" + if v == TripType.MULTI_CITY: + raise ValueError("Explore does not support multi-city trips") + return v + + @field_validator("departure_date") + @classmethod + def validate_departure_date(cls, v: str) -> str: + """Ensure the departure date is well-formed and not in the past.""" + parsed = datetime.strptime(v, "%Y-%m-%d").date() + if parsed < datetime.now().date(): + raise ValueError("Departure date cannot be in the past") + return v + + @model_validator(mode="after") + def validate_origin_destination(self) -> "ExploreSearchFilters": + """Ensure origin and destination are not the same place.""" + if _place_token(self.origin) == _place_token(self.destination): + raise ValueError("Origin and destination cannot be the same place") + return self + + def format(self) -> list: + """Format filters into the Explore API request structure. + + Returns the nested list payload for ``GetExploreDestinations``. + The outer wrapper and the inner request block (slot [3]) were mapped + from the HAR capture; the inner block shares its layout with + ``DateSearchFilters.format()``'s filters record, confirming it is the + same underlying proto message. + """ + + def airline_token(airline: Airline) -> str: + return airline.name.removeprefix("_") + + # Airline / alliance include list — same merged-token shape as the + # other endpoints (segment[4]); HAR-confirmed with ["ONEWORLD"]. + include_tokens: list[str] = [] + if self.airlines: + include_tokens.extend( + airline_token(a) for a in sorted(self.airlines, key=lambda x: x.value) + ) + if self.alliances: + include_tokens.extend(sorted(a.value for a in self.alliances)) + airlines_filters = include_tokens or None + + # Airline / alliance exclude list — slice[5]; inferred from the shared + # message layout (not exercised in the capture). + exclude_tokens: list[str] = [] + if self.airlines_exclude: + exclude_tokens.extend( + airline_token(a) for a in sorted(self.airlines_exclude, key=lambda x: x.value) + ) + if self.alliances_exclude: + exclude_tokens.extend(sorted(a.value for a in self.alliances_exclude)) + exclude_filters = exclude_tokens or None + + # Explore slice — 8 slots, all HAR-confirmed. + formatted_slice = [ + [[_place_token(self.origin)]], # 0: origin [[[mid_or_code, type]]] + [[_place_token(self.destination)]], # 1: destination [[[mid, type]]] + self.trip_length_window, # 2: trip-length window (semantics unconfirmed) + self.stops.value, # 3: stops (0=any, 1=nonstop, ...) + airlines_filters, # 4: airline / alliance INCLUDE list + exclude_filters, # 5: airline / alliance EXCLUDE list + self.departure_date, # 6: departure date YYYY-MM-DD + [self.max_duration] if self.max_duration else None, # 7: max duration (mins) + ] + + # Bags — HAR shows [carry_on, checked] here (idx-337 delta: setting + # "1 carry-on bag" produced [1, 0]); note this is the REVERSE of the + # [checked, carry_on] order used by GetCalendarGraph (dates.py). + bags_filter = [int(self.bags.carry_on), self.bags.checked_bags] if self.bags else None + + # Inner request block — mirrors DateSearchFilters' filters record. + inner = [ + None, # 0: no observed effect + None, # 1: no observed effect + self.trip_type.value, # 2: trip type (HAR: 2=one-way) + None, # 3: no observed effect + [], # 4: reserved slot, always [] in captures + self.seat_type.value, # 5: seat class + [ + self.passenger_info.adults, + self.passenger_info.children, + self.passenger_info.infants_on_lap, + self.passenger_info.infants_in_seat, + ], # 6: passengers (same order as the other endpoints) + [None, self.price_limit.max_price] if self.price_limit else None, # 7: max price + None, # 8: no observed effect + None, # 9: no observed effect + bags_filter, # 10: bags [carry_on, checked] + None, # 11: no observed effect + None, # 12: no observed effect + [formatted_slice], # 13: slices (Explore always has exactly one) + None, # 14: no observed effect + None, # 15: no observed effect + None, # 16: no observed effect + 1, # 17: constant in every captured request + None, # 18: no observed effect + None, # 19: no observed effect + None, # 20: no observed effect + None, # 21: no observed effect + None, # 22: no observed effect + None, # 23: no observed effect + 1, # 24: constant in every captured request + 1, # 25: constant in every captured request + ] + + # Outer wrapper — 12 slots, mirrored from the capture. [10] is the + # UI's map viewport in px and [11] the request trigger (2=initial + # load, 3=filter change); both are sent as observed constants. + return [ + [], # 0 + None, # 1 + None, # 2 + inner, # 3: the search request + None, # 4 + 1, # 5: constant in every captured request + None, # 6 + 0, # 7: constant in every captured request + None, # 8 + 0, # 9: constant in every captured request + [447, 712], # 10: map viewport [width, height] px + 3, # 11: request trigger + ] + + def encode(self) -> str: + """URL encode the formatted filters for the API request.""" + formatted_filters = self.format() + # First convert the formatted filters to a JSON string + formatted_json = json.dumps(formatted_filters, separators=(",", ":")) + # Then wrap it in a list with null + wrapped_filters = [None, formatted_json] + # Finally, encode the whole thing + return urllib.parse.quote(json.dumps(wrapped_filters, separators=(",", ":"))) + + +class ExploreDestination(BaseModel): + """A single Explore result card: a destination and its cheapest fare. + + Fields are left-joined from the two response payloads (destinations and + prices) on ``mid``; price-side fields are ``None`` when Google found no + fare for the destination under the current filters. + + ``arrival_date`` is when the cheapest outbound itinerary lands (one day + after ``departure_date`` for overnight flights) — live probing confirmed + it is NOT a round-trip return date (it never moves when the trip-length + window forces longer stays). + """ + + mid: str + name: str + country: str | None = None + latitude: float | None = None + longitude: float | None = None + thumbnail_url: str | None = None + hero_image_url: str | None = None + departure_date: str | None = None + arrival_date: str | None = None + price: NonNegativeFloat | None = None + currency: str | None = None + airline: str | None = None + airline_name: str | None = None + stops: NonNegativeInt | None = None + duration_minutes: PositiveInt | None = None + layover_minutes: NonNegativeInt | None = None + destination_airport: str | None = None + origin_mid: str | None = None + booking_token: str | None = None + + @property + def price_unknown(self) -> bool: + """True when Google returned the destination without a fare.""" + return self.price is None + + +class ExploreResult(BaseModel): + """The full result of an Explore search.""" + + region_name: str | None = None + origin_name: str | None = None + price_slider_min: float | None = None + price_slider_max: float | None = None + destinations: list[ExploreDestination] = Field(default_factory=list) diff --git a/fli/search/__init__.py b/fli/search/__init__.py index 91dc068f..f493469e 100644 --- a/fli/search/__init__.py +++ b/fli/search/__init__.py @@ -5,11 +5,13 @@ SearchHTTPError, SearchTimeoutError, ) +from .explore import SearchExplore from .flights import SearchFlights __all__ = [ "SearchFlights", "SearchDates", + "SearchExplore", "DatePrice", "SearchClientError", "SearchTimeoutError", diff --git a/fli/search/_decoders.py b/fli/search/_decoders.py index 481c5577..e6088285 100644 --- a/fli/search/_decoders.py +++ b/fli/search/_decoders.py @@ -22,6 +22,8 @@ Airport, Amenities, BookingOption, + ExploreDestination, + ExploreResult, FlightLeg, FlightResult, Layover, @@ -468,3 +470,156 @@ def _extract_fare_name(row: list) -> str | None: if isinstance(label, str) and label: return label return None + + +# --------------------------------------------------------------------------- +# Explore (GetExploreDestinations) decoding +# --------------------------------------------------------------------------- +# +# One GetExploreDestinations HTTP response streams SEVERAL ``wrb.fr`` chunks: +# destination chunks carry geo/name/image records at chunk[3][0] plus response +# metadata, price chunks carry fare records at chunk[4][0]. Large regions +# split both kinds across many chunks (24 observed for Oceania), and chunk +# order is not guaranteed — callers must classify every chunk by shape and +# accumulate, then left-join prices onto destinations on the knowledge-graph +# mid at record[0]. + +_KG_PREFIXES = ("/m/", "/g/") + + +def _get_path(node: Any, *path: int) -> Any: + """Chain :func:`safe_get` over a positional path.""" + for idx in path: + node = safe_get(node, idx) + return node + + +def _is_mid(v: Any) -> bool: + return isinstance(v, str) and v.startswith(_KG_PREFIXES) + + +def _as_float(v: Any) -> float | None: + if isinstance(v, bool): + return None + return float(v) if isinstance(v, int | float) else None + + +def _explore_destination_records(chunk: Any) -> list: + """Return the destination records in a chunk (may be empty).""" + records = _get_path(chunk, 3, 0) + if not isinstance(records, list): + return [] + return [ + r + for r in records + if isinstance(r, list) and _is_mid(safe_get(r, 0)) and as_str(safe_get(r, 2)) + ] + + +def _explore_price_records(chunk: Any) -> list: + """Return the price records in a chunk (may be empty).""" + records = _get_path(chunk, 4, 0) + if not isinstance(records, list): + return [] + return [r for r in records if isinstance(r, list) and _is_mid(safe_get(r, 0))] + + +def is_explore_destinations_chunk(chunk: Any) -> bool: + """Return True when the chunk carries Explore destination records.""" + return bool(_explore_destination_records(chunk)) + + +def is_explore_prices_chunk(chunk: Any) -> bool: + """Return True when the chunk carries Explore price records.""" + return bool(_explore_price_records(chunk)) + + +def parse_explore_destinations_chunk(chunk: Any) -> tuple[dict[str, Any], list[ExploreDestination]]: + """Decode a destinations chunk into (metadata, partial destinations). + + The returned :class:`ExploreDestination` objects carry only the + destination-side fields; price-side fields are filled in later by + :func:`merge_explore_payloads`. Malformed records are skipped with a + logged warning, mirroring :func:`parse_flight_row`'s philosophy. + """ + meta: dict[str, Any] = { + "region_name": as_str(_get_path(chunk, 2, 0)), + "origin_name": as_str(_get_path(chunk, 6, 0, 0)), + "price_slider_min": _as_float(_get_path(chunk, 5, 0, 0, 1)), + "price_slider_max": _as_float(_get_path(chunk, 5, 0, 1, 1)), + } + + destinations: list[ExploreDestination] = [] + for record in _explore_destination_records(chunk): + try: + destinations.append( + ExploreDestination( + mid=record[0], + name=record[2], + country=as_str(safe_get(record, 4)), + latitude=_as_float(_get_path(record, 1, 0)), + longitude=_as_float(_get_path(record, 1, 1)), + thumbnail_url=as_str(safe_get(record, 3)), + hero_image_url=as_str(safe_get(record, 7)), + departure_date=as_str(safe_get(record, 11)), + arrival_date=as_str(safe_get(record, 28)), + ) + ) + except (ValueError, TypeError, IndexError): + logger.warning("Skipping malformed explore destination record", exc_info=True) + return meta, destinations + + +def parse_explore_prices_chunk( + chunk: Any, default_currency: str | None = None +) -> dict[str, dict[str, Any]]: + """Decode a prices chunk into ``{mid: price fields}``. + + Records without a numeric fare (Google keeps a placeholder row when no + itinerary satisfies the filters) are omitted, so destinations they refer + to surface as unpriced after the merge. + """ + prices: dict[str, dict[str, Any]] = {} + for record in _explore_price_records(chunk): + price = _as_float(_get_path(record, 1, 0, 1)) + if price is None: + continue + token = as_str(_get_path(record, 1, 1)) + summary = safe_get(record, 6) + duration = as_int(_get_path(summary, 3)) + prices[record[0]] = { + "price": price, + "currency": extract_currency_from_price_token(token) or default_currency, + "booking_token": token, + "airline": as_str(_get_path(summary, 0)), + "airline_name": as_str(_get_path(summary, 1)), + "stops": as_non_negative_int(_get_path(summary, 2)), + "duration_minutes": duration if duration and duration > 0 else None, + "layover_minutes": as_non_negative_int(_get_path(summary, 8)), + "destination_airport": as_str(_get_path(summary, 5)), + "origin_mid": as_str(_get_path(summary, 6)), + } + return prices + + +def merge_explore_payloads( + meta: dict[str, Any], + destinations: list[ExploreDestination], + prices: dict[str, dict[str, Any]], +) -> ExploreResult: + """Left-join price fields onto destinations and build the final result. + + Destinations without a matching price record are kept with + ``price=None`` — a typical response prices only ~75% of destinations. + """ + merged = [ + dest.model_copy(update=prices[dest.mid]) if dest.mid in prices else dest + for dest in destinations + ] + return ExploreResult( + region_name=meta.get("region_name"), + origin_name=meta.get("origin_name"), + price_slider_min=meta.get("price_slider_min"), + price_slider_max=meta.get("price_slider_max"), + destinations=merged, + ) diff --git a/fli/search/explore.py b/fli/search/explore.py new file mode 100644 index 00000000..e8dba18a --- /dev/null +++ b/fli/search/explore.py @@ -0,0 +1,110 @@ +"""Google Flights Explore search implementation. + +Explore answers "where can I fly cheaply?" — one request returns dozens of +destinations (each with its cheapest found fare) for an origin and a broad +destination such as :attr:`fli.models.ExploreRegion.ANYWHERE` or a continent. + +Request recipe (probed live via ``scripts/probe_explore.py``, 2026-08): +unlike the sibling endpoints, ``GetExploreDestinations`` enforces a +same-origin check — at least one of ``x-same-domain`` / ``origin`` / +``referer`` must be present or every request fails with an opaque ``wrb.fr`` +error ``[13]``. Nothing else from the browser's ceremony is needed: no +cookies, no ``at`` XSRF token, no ``f.sid``/``bl``/``soc-*``/``rt=c`` query +params, and the ``curr=``/``hl=``/``gl=`` locale params work as on every +other endpoint. Escalation ladder should this break in future: + +1. current shape (``x-same-domain: 1`` + ``origin`` headers) +2. add ``referer: https://www.google.com/travel/explore`` +3. add ``soc-app=162&soc-platform=1&soc-device=1&rt=c`` query params +4. currency via ``x-goog-ext-259736195-jspb: [hl, gl, curr, 1, null, + [tz_minutes], null, null, 1, []]`` if ``curr=`` stops working +5. priming ``GET /travel/explore`` to harvest cookies + the ``SNlM0e`` + (``at``) token from ``WIZ_global_data`` +""" + +import logging + +from fli.models import ExploreResult, ExploreSearchFilters +from fli.search._decoders import ( + is_explore_destinations_chunk, + is_explore_prices_chunk, + merge_explore_payloads, + parse_explore_destinations_chunk, + parse_explore_prices_chunk, +) +from fli.search._urls import with_locale_params +from fli.search._wire import iter_wrb_chunks +from fli.search.client import get_client + +logger = logging.getLogger(__name__) + + +class SearchExplore: + """Explore search: one origin, a broad destination, many priced results.""" + + BASE_URL = "https://www.google.com/_/FlightsFrontendUi/data/travel.frontend.flights.FlightsFrontendService/GetExploreDestinations" + DEFAULT_HEADERS = { + "content-type": "application/x-www-form-urlencoded;charset=UTF-8", + # Same-origin signals — required by this endpoint (see module docstring). + "x-same-domain": "1", + "origin": "https://www.google.com", + } + + def __init__(self): + """Initialize the search client for explore searches.""" + self.client = get_client() + + def search( + self, + filters: ExploreSearchFilters, + currency: str | None = None, + language: str | None = None, + country: str | None = None, + ) -> ExploreResult | None: + """Search destinations and prices for an Explore query. + + Args: + filters: Explore search parameters (origin, destination region, date, ...) + currency: Optional ISO 4217 currency code passed via the ``curr`` URL param. + language: Optional BCP-47 language code passed via the ``hl`` URL param. + country: Optional ISO 3166-1 alpha-2 country code passed via the ``gl`` URL param. + + Returns: + An :class:`ExploreResult` with one entry per destination (price fields + are None for destinations Google returned without a fare), or None if + the response could not be parsed. + + """ + encoded_filters = filters.encode() + url = with_locale_params(self.BASE_URL, currency, language, country) + + response = self.client.post( + url=url, + data=f"f.req={encoded_filters}", + impersonate="chrome", + allow_redirects=True, + headers=self.DEFAULT_HEADERS, + ) + response.raise_for_status() + + # Destination and price records stream across MANY wrb.fr chunks in + # no guaranteed order (24 chunks observed for large regions), so + # classify every chunk by shape and accumulate before joining. + meta: dict = {} + destinations: list = [] + prices: dict = {} + for chunk in iter_wrb_chunks(response.text): + if is_explore_destinations_chunk(chunk): + chunk_meta, chunk_destinations = parse_explore_destinations_chunk(chunk) + for key, value in chunk_meta.items(): + if meta.get(key) is None: + meta[key] = value + destinations.extend(chunk_destinations) + if is_explore_prices_chunk(chunk): + prices.update(parse_explore_prices_chunk(chunk, default_currency=currency)) + + if not destinations: + logger.warning("Explore search returned no parseable destination chunks") + return None + + return merge_explore_payloads(meta, destinations, prices) diff --git a/scripts/capture_fixtures.py b/scripts/capture_fixtures.py index 535ad37b..6178efb1 100644 --- a/scripts/capture_fixtures.py +++ b/scripts/capture_fixtures.py @@ -1,4 +1,4 @@ -"""Capture live GetShoppingResults / GetCalendarGraph responses for snapshot tests. +"""Capture live GetShoppingResults / GetExploreDestinations responses for snapshot tests. Usage:: @@ -26,6 +26,8 @@ Airline, Airport, Alliance, + ExplorePlace, + ExploreSearchFilters, FlightSearchFilters, FlightSegment, LayoverRestrictions, @@ -35,7 +37,7 @@ SortBy, TripType, ) -from fli.search import SearchFlights +from fli.search import SearchExplore, SearchFlights from fli.search._urls import with_locale_params from fli.search.client import get_client @@ -124,6 +126,19 @@ def _seg(dep: Airport, arr: Airport, days: int = 45) -> list[FlightSegment]: ), } +# Explore scenarios hit GetExploreDestinations, which additionally requires +# SearchExplore's same-origin headers (see fli/search/explore.py). +EXPLORE_SCENARIOS: dict[str, tuple[Callable[[], ExploreSearchFilters], str]] = { + "explore_lon_southern_europe": ( + lambda: ExploreSearchFilters( + origin=ExplorePlace(mid="/m/04jpl", type_code=4), # London + destination=ExplorePlace(mid="/m/0250wj", type_code=6), # Southern Europe + departure_date=_future(30), + ), + "USD", + ), +} + def main() -> int: """CLI entry point — capture fixtures listed in ``SCENARIOS``.""" @@ -144,20 +159,33 @@ def main() -> int: out_dir.mkdir(parents=True, exist_ok=True) client = get_client() - selected = args.scenario or list(SCENARIOS) + # name -> (filters factory, currency, endpoint URL, extra headers). + registry: dict[str, tuple[Callable[[], object], str, str, dict | None]] = { + name: (factory, currency, SearchFlights.BASE_URL, None) + for name, (factory, currency) in SCENARIOS.items() + } + registry.update( + { + name: (factory, currency, SearchExplore.BASE_URL, SearchExplore.DEFAULT_HEADERS) + for name, (factory, currency) in EXPLORE_SCENARIOS.items() + } + ) + + selected = args.scenario or list(registry) for name in selected: - if name not in SCENARIOS: + if name not in registry: print(f"!! Unknown scenario: {name}") continue - factory, currency = SCENARIOS[name] + factory, currency, base_url, headers = registry[name] filters = factory() encoded = filters.encode() - url = with_locale_params(SearchFlights.BASE_URL, currency, None, None) + url = with_locale_params(base_url, currency, None, None) r = client.post( url=url, data=f"f.req={encoded}", impersonate="chrome", allow_redirects=True, + **({"headers": headers} if headers else {}), ) path = out_dir / f"{name}.bin" path.write_bytes(r.content) diff --git a/scripts/probe_explore.py b/scripts/probe_explore.py new file mode 100644 index 00000000..509ed53a --- /dev/null +++ b/scripts/probe_explore.py @@ -0,0 +1,295 @@ +"""Live probe matrix for the GetExploreDestinations (Explore) endpoint. + +Dev tool — hits the live Google API. Run variants with:: + + uv run python scripts/probe_explore.py # full matrix, tier 1 + uv run python scripts/probe_explore.py --only minimal # one variant + uv run python scripts/probe_explore.py --tier 2 # escalate headers + uv run python scripts/probe_explore.py --save-fixture # write baseline .bin + +The matrix resolves the open reverse-engineering questions recorded in the +Explore plan (see fli/models/google_flights/explore.py): + + R1 do fli's minimal headers work, or is browser ceremony required? + R2 does the ``curr=`` URL param control the currency? + R3 are ``["JFK", 0]`` airport-code origins accepted? + R4 what mid/type does "Anywhere" need? + R5 what do trip-length-window values mean; does round-trip work? + R6 region enum seeds — does each curated mid return results? + +Escalation tiers (``--tier``): 1 = fli's bare request shape; 2 = + the +``x-same-domain``/``origin``/``referer`` headers; 3 = + the +``soc-*``/``rt=c``/``_reqid`` query params; 4 = + currency via the +``x-goog-ext-259736195-jspb`` header. + +FINDINGS (probed live 2026-08-11): + R1 tier 1 fails with an opaque wrb.fr error ``[13]``. Tier 2 works — + and ANY ONE of the three headers is sufficient on its own (it is a + same-origin check). No cookies, no ``at`` XSRF token, no + ``f.sid``/``bl``/``soc-*``/``rt=c`` needed. SearchExplore sends + ``x-same-domain: 1`` + ``origin``. + R2 ``curr=`` works (Malta 132 USD / 114 EUR, plausible FX ratio). + R3 ``["JFK", 0]`` airport origins work. + R4 Anywhere = ``["/m/02j71", 6]``; type 4 fails with error 13. A null + destination slot also works (defaults to a nearby region). + R5 ``departure_date`` is REQUIRED (error 13 without it). Window is + ``[4, 23, min_nights, max_nights]``: forcing ``[4,23,14,14]`` + re-prices every destination; dest[28] never moves -> it is the + outbound ARRIVAL date, not a return date. + R6 every curated ExploreRegion mid returns results. Responses stream + across up to 24 wrb.fr chunks — parse ALL chunks, not the first two. +""" + +from __future__ import annotations + +import argparse +import json +import random +import sys +import urllib.parse +from datetime import datetime, timedelta +from pathlib import Path + +from fli.models import ( + Airport, + Alliance, + BagsFilter, + ExplorePlace, + ExploreRegion, + ExploreSearchFilters, + MaxStops, + PriceLimit, + TripType, +) +from fli.search._urls import with_locale_params +from fli.search._wire import iter_wrb_chunks +from fli.search.client import get_client + +BASE_URL = ( + "https://www.google.com/_/FlightsFrontendUi/data/" + "travel.frontend.flights.FlightsFrontendService/GetExploreDestinations" +) +FIXTURE_PATH = ( + Path(__file__).parent.parent / "tests/search/fixtures/explore_lon_southern_europe.bin" +) + +LONDON = ExplorePlace(mid="/m/04jpl", type_code=4) +SOUTHERN_EUROPE = ExplorePlace(mid="/m/0250wj", type_code=6) + + +def future(days: int = 30) -> str: + """Return a YYYY-MM-DD date ``days`` from now.""" + return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") + + +def encode_payload(payload: list) -> str: + """Encode a raw formatted payload the same way ExploreSearchFilters.encode does.""" + inner = json.dumps(payload, separators=(",", ":")) + return urllib.parse.quote(json.dumps([None, inner], separators=(",", ":"))) + + +def safe_get(tree, *path, default=None): + """Walk a positional path into nested lists, returning ``default`` on any miss.""" + for key in path: + try: + tree = tree[key] + except (IndexError, KeyError, TypeError): + return default + return tree + + +def extract(chunks: list) -> tuple[list, dict]: + """Shape-based extraction of destination and price records from chunks.""" + dests: list = [] + prices: dict = {} + for chunk in chunks: + dest_records = safe_get(chunk, 3, 0) + if isinstance(dest_records, list) and any( + isinstance(safe_get(r, 0), str) + and safe_get(r, 0, default="").startswith(("/m/", "/g/")) + and isinstance(safe_get(r, 2), str) + for r in dest_records + if isinstance(r, list) + ): + dests.extend(r for r in dest_records if isinstance(r, list)) + price_records = safe_get(chunk, 4, 0) + if isinstance(price_records, list): + for r in price_records: + mid = safe_get(r, 0) + if isinstance(mid, str) and mid.startswith(("/m/", "/g/")): + prices[mid] = r + return dests, prices + + +def run_variant( + name: str, + payload: list, + *, + tier: int, + currency: str | None = "USD", + language: str | None = "en", + country: str | None = "US", + save_fixture: bool = False, +) -> None: + """POST one probe payload at the given escalation tier and print the outcome.""" + url = with_locale_params(BASE_URL, currency, language, country) + headers: dict[str, str] = {} + if tier >= 2: + headers.update( + { + "x-same-domain": "1", + "origin": "https://www.google.com", + "referer": "https://www.google.com/travel/explore", + } + ) + if tier >= 3: + reqid = random.randint(10000, 999999) + url += f"&soc-app=162&soc-platform=1&soc-device=1&rt=c&_reqid={reqid}" + if tier >= 4: + headers["x-goog-ext-259736195-jspb"] = json.dumps( + ["en", "US", currency or "USD", 1, None, [0], None, None, 1, []], + separators=(",", ":"), + ) + + client = get_client() + print(f"\n=== {name} (tier {tier}, curr={currency}) ===") + try: + response = client.post( + url=url, + data=f"f.req={encode_payload(payload)}", + impersonate="chrome", + allow_redirects=True, + **({"headers": headers} if headers else {}), + ) + except Exception as e: # noqa: BLE001 — probe tool, report and continue + print(f" REQUEST FAILED: {type(e).__name__}: {e}") + return + + body = response.text + chunks = list(iter_wrb_chunks(body)) + dests, prices = extract(chunks) + priced = [d for d in dests if safe_get(d, 0) in prices] + print(f" status={response.status_code} bytes={len(body)} chunks={len(chunks)}") + print(f" destinations={len(dests)} priced={len(priced)}") + for d in priced[:3]: + mid = safe_get(d, 0) + p = prices[mid] + print( + f" {safe_get(d, 2)!r:20} {safe_get(d, 4)!r:16}" + f" price={safe_get(p, 1, 0, 1)}" + f" airline={safe_get(p, 6, 0)}" + f" stops={safe_get(p, 6, 2)}" + f" dur={safe_get(p, 6, 3)}min" + f" apt={safe_get(p, 6, 5)}" + f" dep={safe_get(d, 11)} ret={safe_get(d, 28)}" + ) + unpriced = [d for d in dests if safe_get(d, 0) not in prices] + if unpriced: + print(f" (+{len(unpriced)} unpriced, e.g. {safe_get(unpriced[0], 2)!r})") + if save_fixture: + FIXTURE_PATH.parent.mkdir(parents=True, exist_ok=True) + FIXTURE_PATH.write_bytes(body.encode("utf-8") if isinstance(body, str) else body) + print(f" fixture saved -> {FIXTURE_PATH}") + + +def build_variants() -> dict[str, dict]: + """Return the probe matrix: name -> {payload, kwargs}.""" + har_full = ExploreSearchFilters( + origin=LONDON, + destination=SOUTHERN_EUROPE, + departure_date=future(30), + price_limit=PriceLimit(max_price=900), + bags=BagsFilter(carry_on=True), + trip_length_window=[4, 23, 0, 23], + stops=MaxStops.NON_STOP, + alliances=[Alliance.ONEWORLD], + max_duration=600, + ) + minimal = ExploreSearchFilters( + origin=LONDON, destination=SOUTHERN_EUROPE, departure_date=future(30) + ) + airport_origin = ExploreSearchFilters( + origin=Airport.JFK, destination=ExploreRegion.EUROPE, departure_date=future(30) + ) + anywhere = ExploreSearchFilters( + origin=LONDON, destination=ExploreRegion.ANYWHERE, departure_date=future(30) + ) + round_trip = ExploreSearchFilters( + origin=LONDON, + destination=SOUTHERN_EUROPE, + trip_type=TripType.ROUND_TRIP, + departure_date=future(30), + trip_length_window=[4, 23, 0, 23], + ) + round_trip_fortnight = ExploreSearchFilters( + origin=LONDON, + destination=SOUTHERN_EUROPE, + trip_type=TripType.ROUND_TRIP, + departure_date=future(30), + trip_length_window=[4, 23, 14, 14], + ) + + gb_locale = {"currency": "GBP", "language": "en-GB", "country": "GB"} + variants: dict[str, dict] = { + "har_full": {"payload": har_full.format(), "kwargs": gb_locale}, + "minimal": {"payload": minimal.format(), "kwargs": {"save_fixture": True}}, + "curr_usd": {"payload": minimal.format(), "kwargs": {"currency": "USD"}}, + "curr_eur": {"payload": minimal.format(), "kwargs": {"currency": "EUR"}}, + "airport_origin": {"payload": airport_origin.format(), "kwargs": {}}, + "anywhere_t6": {"payload": anywhere.format(), "kwargs": {}}, + "round_trip": {"payload": round_trip.format(), "kwargs": {}}, + "round_trip_fortnight": {"payload": round_trip_fortnight.format(), "kwargs": {}}, + } + + # Payload mutations that the model can't (or refuses to) express. + no_date = minimal.format() + no_date[3][13][0][6] = None # confirmed: error 13 without a date + variants["no_date"] = {"payload": no_date, "kwargs": {}} + + anywhere_t4 = anywhere.format() + anywhere_t4[3][13][0][1] = [[["/m/02j71", 4]]] # confirmed: error 13 + variants["anywhere_t4"] = {"payload": anywhere_t4, "kwargs": {}} + + dest_null = anywhere.format() + dest_null[3][13][0][1] = None + variants["dest_null"] = {"payload": dest_null, "kwargs": {}} + + # Curated region enum verification (R6). + for region in ExploreRegion: + if region in (ExploreRegion.ANYWHERE, ExploreRegion.SOUTHERN_EUROPE): + continue + f = ExploreSearchFilters(origin=LONDON, destination=region, departure_date=future(30)) + variants[f"region_{region.name.lower()}"] = {"payload": f.format(), "kwargs": {}} + + return variants + + +def main() -> int: + """Run the selected probe variants.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tier", type=int, default=1, choices=[1, 2, 3, 4]) + parser.add_argument("--only", type=str, help="comma-separated variant names") + parser.add_argument( + "--save-fixture", action="store_true", help="save the 'minimal' variant body" + ) + args = parser.parse_args() + + variants = build_variants() + selected = args.only.split(",") if args.only else list(variants) + unknown = [name for name in selected if name not in variants] + if unknown: + print(f"Unknown variants: {unknown}. Available: {list(variants)}") + return 1 + + for name in selected: + spec = variants[name] + kwargs = dict(spec["kwargs"]) + if not args.save_fixture: + kwargs.pop("save_fixture", None) + run_variant(name, spec["payload"], tier=args.tier, **kwargs) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/mcp/test_mcp_explore.py b/tests/mcp/test_mcp_explore.py new file mode 100644 index 00000000..ad2bb0e8 --- /dev/null +++ b/tests/mcp/test_mcp_explore.py @@ -0,0 +1,238 @@ +"""Unit tests for the search_explore MCP tool (SearchExplore is mocked).""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pytest + +from fli.mcp.server import ( + ExploreSearchParams, + _parse_explore_destination, + _parse_explore_origin, + _search_explore_from_params, +) +from fli.models import ( + Airport, + ExploreDestination, + ExplorePlace, + ExploreRegion, + ExploreResult, +) + +DEPARTURE_DATE = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d") + + +def _make_result() -> ExploreResult: + return ExploreResult( + region_name="Europe", + origin_name="New York", + price_slider_min=50, + price_slider_max=1500, + destinations=[ + ExploreDestination( + mid="/m/0491y", + name="Kraków", + country="Poland", + price=118, + currency="USD", + airline="FR", + airline_name="Ryanair", + stops=0, + duration_minutes=140, + destination_airport="KRK", + departure_date=DEPARTURE_DATE, + arrival_date=DEPARTURE_DATE, + ), + ExploreDestination(mid="/m/056_y", name="Unpriced Town"), + ExploreDestination( + mid="/m/04v3q", + name="Malta", + country="Malta", + price=45, + currency="USD", + airline="W9", + destination_airport="MLA", + departure_date=DEPARTURE_DATE, + ), + ], + ) + + +@pytest.fixture +def mock_search(monkeypatch): + """Patch SearchExplore in the server module; returns the mock class.""" + mock_cls = MagicMock() + mock_cls.return_value.search.return_value = _make_result() + monkeypatch.setattr("fli.mcp.server.SearchExplore", mock_cls) + return mock_cls + + +class TestDestinationParsing: + def test_region_names(self): + assert _parse_explore_destination("ANYWHERE") is ExploreRegion.ANYWHERE + assert _parse_explore_destination("europe") is ExploreRegion.EUROPE + assert _parse_explore_destination("Southern Europe") is ExploreRegion.SOUTHERN_EUROPE + assert _parse_explore_destination("north-america") is ExploreRegion.NORTH_AMERICA + + def test_raw_mid(self): + place = _parse_explore_destination("/m/05qtj") + assert isinstance(place, ExplorePlace) + assert place.mid == "/m/05qtj" + + def test_airport_code(self): + assert _parse_explore_destination("LHR") is Airport.LHR + + def test_garbage_reports_options(self, mock_search): + params = ExploreSearchParams( + origin="JFK", destination="NOT_A_PLACE", departure_date=DEPARTURE_DATE + ) + result = _search_explore_from_params(params) + assert result["success"] is False + assert "ANYWHERE" in result["error"] + assert result["destinations"] == [] + + def test_origin_mid_is_city_typed(self): + origin = _parse_explore_origin("/m/04jpl") + assert isinstance(origin, ExplorePlace) + assert origin.type_code == 4 + + def test_origin_airport(self): + assert _parse_explore_origin("jfk") is Airport.JFK + + +class TestExecuteExploreSearch: + def test_success_shape_and_price_sort(self, mock_search): + params = ExploreSearchParams(origin="JFK", departure_date=DEPARTURE_DATE) + result = _search_explore_from_params(params) + + assert result["success"] is True + assert result["count"] == 3 + assert result["priced_count"] == 2 + assert result["region_name"] == "Europe" + assert result["origin_name"] == "New York" + # Cheapest first, unpriced destinations last. + assert [d["name"] for d in result["destinations"]] == [ + "Malta", + "Kraków", + "Unpriced Town", + ] + malta = result["destinations"][0] + assert malta["price"] == 45 + assert malta["destination_airport"] == "MLA" + assert "flights_url" in malta + assert "JFK" in malta["flights_url"] + # Unpriced entries have no airport/date, so no deep link. + assert "flights_url" not in result["destinations"][2] + + def test_unsorted_keeps_response_order(self, mock_search): + params = ExploreSearchParams( + origin="JFK", departure_date=DEPARTURE_DATE, sort_by_price=False + ) + result = _search_explore_from_params(params) + assert [d["name"] for d in result["destinations"]] == [ + "Kraków", + "Unpriced Town", + "Malta", + ] + + def test_limit_applies_after_sort(self, mock_search): + params = ExploreSearchParams(origin="JFK", departure_date=DEPARTURE_DATE, limit=1) + result = _search_explore_from_params(params) + assert result["count"] == 1 + assert result["destinations"][0]["name"] == "Malta" + + def test_round_trip_builds_window(self, mock_search): + params = ExploreSearchParams( + origin="JFK", + departure_date=DEPARTURE_DATE, + round_trip=True, + trip_min_nights=7, + trip_max_nights=14, + ) + result = _search_explore_from_params(params) + assert result["success"] is True + assert result["trip_type"] == "ROUND_TRIP" + filters = mock_search.return_value.search.call_args.args[0] + assert filters.trip_length_window == [4, 23, 7, 14] + + def test_trip_window_min_exceeding_max_is_rejected(self, mock_search): + """An inverted nights window must fail locally, not reach Google.""" + params = ExploreSearchParams( + origin="JFK", + departure_date=DEPARTURE_DATE, + trip_min_nights=14, + trip_max_nights=7, + ) + result = _search_explore_from_params(params) + assert result["success"] is False + assert "trip_min_nights" in result["error"] + mock_search.return_value.search.assert_not_called() + + def test_window_without_round_trip_is_allowed(self, mock_search): + """Google accepts a trip-length window on one-way searches (HAR-observed).""" + params = ExploreSearchParams( + origin="JFK", + departure_date=DEPARTURE_DATE, + trip_min_nights=0, + trip_max_nights=7, + ) + result = _search_explore_from_params(params) + assert result["success"] is True + filters = mock_search.return_value.search.call_args.args[0] + assert filters.trip_length_window == [4, 23, 0, 7] + + def test_exact_trip_length_adds_return_date_to_link(self, mock_search): + """When min==max nights the return date is derivable as fact.""" + params = ExploreSearchParams( + origin="JFK", + departure_date=DEPARTURE_DATE, + round_trip=True, + trip_min_nights=7, + trip_max_nights=7, + ) + result = _search_explore_from_params(params) + expected_return = ( + datetime.strptime(DEPARTURE_DATE, "%Y-%m-%d") + timedelta(days=7) + ).strftime("%Y-%m-%d") + malta = result["destinations"][0] + assert "through" in malta["flights_url"] + assert expected_return in malta["flights_url"] + + def test_ranged_trip_length_keeps_outbound_only_link(self, mock_search): + """Google never reveals the chosen return date, so we must not invent one.""" + params = ExploreSearchParams( + origin="JFK", + departure_date=DEPARTURE_DATE, + round_trip=True, + trip_min_nights=7, + trip_max_nights=14, + ) + result = _search_explore_from_params(params) + malta = result["destinations"][0] + assert "flights_url" in malta + assert "through" not in malta["flights_url"] + + def test_none_result_is_reported_as_error(self, mock_search): + """An unparseable response is a failed request, not an empty result set.""" + mock_search.return_value.search.return_value = None + params = ExploreSearchParams(origin="JFK", departure_date=DEPARTURE_DATE) + result = _search_explore_from_params(params) + assert result["success"] is False + assert "no parseable response" in result["error"] + assert result["destinations"] == [] + + def test_search_exception_reported(self, mock_search): + mock_search.return_value.search.side_effect = RuntimeError("boom") + params = ExploreSearchParams(origin="JFK", departure_date=DEPARTURE_DATE) + result = _search_explore_from_params(params) + assert result["success"] is False + assert "boom" in result["error"] + assert result["destinations"] == [] + + def test_bad_origin_is_parse_error(self, mock_search): + params = ExploreSearchParams(origin="NOT_AN_AIRPORT", departure_date=DEPARTURE_DATE) + result = _search_explore_from_params(params) + assert result["success"] is False + assert result["destinations"] == [] diff --git a/tests/mcp/test_mcp_http.py b/tests/mcp/test_mcp_http.py index 4173b86d..db4aae78 100644 --- a/tests/mcp/test_mcp_http.py +++ b/tests/mcp/test_mcp_http.py @@ -14,7 +14,13 @@ from fli.mcp.server import mcp -EXPECTED_TOOLS = {"search_flights", "search_dates", "find_airports", "get_booking_options"} +EXPECTED_TOOLS = { + "search_flights", + "search_dates", + "search_explore", + "find_airports", + "get_booking_options", +} # --------------------------------------------------------------------------- diff --git a/tests/models/test_explore_search_filters.py b/tests/models/test_explore_search_filters.py new file mode 100644 index 00000000..3b89c6d0 --- /dev/null +++ b/tests/models/test_explore_search_filters.py @@ -0,0 +1,278 @@ +from datetime import datetime, timedelta + +import pytest +from pydantic import ValidationError + +from fli.models import ( + Airline, + Airport, + Alliance, + BagsFilter, + ExplorePlace, + ExploreRegion, + ExploreSearchFilters, + MaxStops, + PassengerInfo, + PriceLimit, + SeatType, + TripType, +) + + +def get_future_date(days: int = 30) -> str: + """Generate a future date string in YYYY-MM-DD format.""" + return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") + + +DEPARTURE_DATE = get_future_date(30) + +TEST_CASES = [ + { + # Replicates the richest captured HAR request (idx 337): London city + # -> Southern Europe with every filter set. The expected literal below + # is the decoded f.req inner payload from the capture (date swapped + # for a future one). + "name": "HAR capture replication (all filters)", + "search": ExploreSearchFilters( + origin=ExplorePlace(mid="/m/04jpl", type_code=4), + destination=ExplorePlace(mid="/m/0250wj", type_code=6), + trip_type=TripType.ONE_WAY, + passenger_info=PassengerInfo(adults=1), + seat_type=SeatType.ECONOMY, + price_limit=PriceLimit(max_price=900), + bags=BagsFilter(carry_on=True, checked_bags=0), + trip_length_window=[4, 23, 0, 23], + stops=MaxStops.NON_STOP, + alliances=[Alliance.ONEWORLD], + departure_date=DEPARTURE_DATE, + max_duration=600, + ), + "formatted": [ + [], + None, + None, + [ + None, + None, + 2, + None, + [], + 1, + [1, 0, 0, 0], + [None, 900], + None, + None, + [1, 0], + None, + None, + [ + [ + [[["/m/04jpl", 4]]], + [[["/m/0250wj", 6]]], + [4, 23, 0, 23], + 1, + ["ONEWORLD"], + None, + DEPARTURE_DATE, + [600], + ] + ], + None, + None, + None, + 1, + None, + None, + None, + None, + None, + None, + 1, + 1, + ], + None, + 1, + None, + 0, + None, + 0, + [447, 712], + 3, + ], + }, + { + "name": "Minimal: airport origin to ANYWHERE", + "search": ExploreSearchFilters(origin=Airport.JFK, departure_date=DEPARTURE_DATE), + "formatted": [ + [], + None, + None, + [ + None, + None, + 2, + None, + [], + 1, + [1, 0, 0, 0], + None, + None, + None, + None, + None, + None, + [ + [ + [[["JFK", 0]]], + [[["/m/02j71", 6]]], + None, + 0, + None, + None, + DEPARTURE_DATE, + None, + ] + ], + None, + None, + None, + 1, + None, + None, + None, + None, + None, + None, + 1, + 1, + ], + None, + 1, + None, + 0, + None, + 0, + [447, 712], + 3, + ], + }, + { + "name": "Airlines and region enum destination", + "search": ExploreSearchFilters( + origin=Airport.LHR, + destination=ExploreRegion.EUROPE, + departure_date=DEPARTURE_DATE, + airlines=[Airline.BA, Airline.AA], + airlines_exclude=[Airline.FR], + ), + "formatted": [ + [], + None, + None, + [ + None, + None, + 2, + None, + [], + 1, + [1, 0, 0, 0], + None, + None, + None, + None, + None, + None, + [ + [ + [[["LHR", 0]]], + [[["/m/02j9z", 6]]], + None, + 0, + ["AA", "BA"], + ["FR"], + DEPARTURE_DATE, + None, + ] + ], + None, + None, + None, + 1, + None, + None, + None, + None, + None, + None, + 1, + 1, + ], + None, + 1, + None, + 0, + None, + 0, + [447, 712], + 3, + ], + }, +] + + +@pytest.mark.parametrize("test_case", TEST_CASES, ids=[tc["name"] for tc in TEST_CASES]) +def test_explore_search_filters_format(test_case): + """Test explore filters format() against expected wire payloads.""" + assert test_case["search"].format() == test_case["formatted"] + + +def test_encode_wraps_and_urlencodes(): + """encode() must produce the double-encoded f.req value.""" + filters = ExploreSearchFilters(origin=Airport.JFK, departure_date=DEPARTURE_DATE) + encoded = filters.encode() + assert encoded.startswith("%5Bnull%2C%22%5B") # [null,"[... + assert "%20" not in encoded # compact separators, no spaces + + +def test_multi_city_rejected(): + with pytest.raises(ValidationError, match="multi-city"): + ExploreSearchFilters( + origin=Airport.JFK, departure_date=DEPARTURE_DATE, trip_type=TripType.MULTI_CITY + ) + + +def test_departure_date_required(): + """The endpoint errors without a date, so the model requires one upfront.""" + with pytest.raises(ValidationError, match="departure_date"): + ExploreSearchFilters(origin=Airport.JFK) + + +def test_past_departure_date_rejected(): + past = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d") + with pytest.raises(ValidationError, match="past"): + ExploreSearchFilters(origin=Airport.JFK, departure_date=past) + + +def test_bad_mid_rejected(): + with pytest.raises(ValidationError, match="knowledge-graph"): + ExplorePlace(mid="LON") + + +def test_same_origin_destination_rejected(): + with pytest.raises(ValidationError, match="same place"): + ExploreSearchFilters( + origin=ExplorePlace(mid="/m/02j9z", type_code=6), + destination=ExploreRegion.EUROPE, + departure_date=DEPARTURE_DATE, + ) + + +def test_bags_order_is_carry_on_first(): + """HAR-confirmed: explore bags slot is [carry_on, checked] (reverse of dates).""" + filters = ExploreSearchFilters( + origin=Airport.JFK, + departure_date=DEPARTURE_DATE, + bags=BagsFilter(carry_on=False, checked_bags=2), + ) + assert filters.format()[3][10] == [0, 2] diff --git a/tests/search/fixtures/explore_lon_southern_europe.bin b/tests/search/fixtures/explore_lon_southern_europe.bin new file mode 100644 index 00000000..31dfd543 --- /dev/null +++ b/tests/search/fixtures/explore_lon_southern_europe.bin @@ -0,0 +1,3 @@ +)]}' + +[["wrb.fr",null,"[[null,[[1786480053335614,37837043,355584851],null,null,null,null,[[1]]],0,\"tYV7av69FPOxhcIP05bHqQE\",\"HWdh--3N70dkACyoPABJ---------wfcpz6AAAAAGp7hbUFGKgwA\"],null,[\"Southern Europe\",[[47.092,29.654],[27.4985,-31.4647999]]],[[[\"/m/04v3q\",[35.937496,14.375416],\"Malta\",\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcRwbBf3fi2ekvqxctM9PgaoK5orqlFHkA3gJxlfcET84taMlZWR8nITlBjgl8JsYbeAp92p_980xkJSCXg3uFIUCMGm2gkhT0JgK6RpuyQ\",\"Malta\",1,2,\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcQ-z_olp6K0ffhaDb3XLl38zqmmdD7QaS_QWU-E72HNrHLzUERBqJZeU1P7cks4PVVNnsJXhNxXy_yVrhKYFYQbhIlolkhFIAtclP3LZA\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/04llb\",[38.7222524,-9.1393366],\"Lisbon\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcSyawcRLrmBvKF5v_iI1GRfKol4CVG-HHrDPDEegUsNEEkU4Lyqh7eND09wpxyiomJPz8I0tzsKwUjsHzcbDdP0FkZaMGI6v4BQP_szctI\",\"Portugal\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcTi-3njUkXEYdomku6zLNILg9DH5_v50zXJVvsDvf22pr60vFZIn0TCKhcPTkmQwQdLQSgml-e83PrkO4Rc-iTqJyxAbTLD2WkDu8zg4w\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0n2z\",[37.9838096,23.7275388],\"Athens\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcSmJkiOdIVOF53mr9JMwnqlK5hkya-M5KdrBWikAZYurZcFmau4UJtCRTtyjZ6TWA_OwfNTUkXYVMHaHDqs296njYJi2W6XqtzZT5knHV0\",\"Greece\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQogyXj6m-TGp0s3FAMCqU7-77RCsQQ4FuuMVBLHVkalZqIn-qbF2WCSKc0yLLyqN4KkK2RpLUBGJnr6amiZ4_CzyIEOCyqghnkB8GdtQ\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-11\"],[\"/m/070t9\",[36.3931562,25.4615092],\"Santorini\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcSyNKNJ2dWyVtoxutL0meRkV__p1bHObCl7jH-Vjr-YzmdpXTWK-w4T-2YqTpReeX_CBXJ0HhcaSewHrqiNhX1RN3taVA-Gc2Cb_CUQwcU\",\"Greece\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcQ6OQ143mXHrW6p0pePDsw5ZDLeuMzIppuzJOmbEhH2eXzzQZnyRdnBMYcnLY8jhWjUO4uNK22HVnMSiN7lQ2Enbg_R8lkH5Hnl_vqajQ\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0fbwq\",[42.6506606,18.0944238],\"Dubrovnik\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcRu_4y-GXz3RVHaAAl492ovbvX9IXQVFpiunUwrWSacOF2Dqpi1YXPa4dy-KgOTH6IGpDgZc_1Qhtre-j4Gtj_JwXG5MhbWXWnw4G-kdj4\",\"Croatia\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQPngpnZYa7mJeXbr5mVem90Fvs9bvJ0IbBLT3MytW8sNGMO-KLYudnEvINd3drJvgyUaNkqgm8uynyshQy_g4pONh9fLZewJ8JgtVaDw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/06c62\",[41.8967068,12.4822025],\"Rome\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcT_zjoStCMuCBP2B6UetQCgZ3YA_l_loIbnjkgdTJKxOUEF9oiM-M_6j16sIHULKGY56ntMAcKtdNcFTECtkmwInm9JOPVrkyb3ZDSN7R8\",\"Italy\",1,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcQbPDHRJ1mpe3Ciqhm0quXXrKsDcz2TFBAJZrdC9aINdpgSwAxbdlGVr0n7KCOhIkfKc3kK3BTeeb-muzbqKNxrwqugqC0U0Rh7IhLxyg\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/01f62\",[41.3874374,2.1686496],\"Barcelona\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcTWgPjqj_1YU0y1tutOBWZUVblErbRwCnxLJrfA9Kj0tY6bsjNXpxSugeh-jrAOfoS2xjh89Ky0l67FAyB5jg4T_myP8QegywU17KtlBlw\",\"Spain\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcSR2Zry5fT8CDZ7BzRos141fuxjtJEVf2oicTxcSGYPDETRNheDbbishAau7MbwyXyFJkgxtES2fk7TbwQ8aVuTGSxWLprnqK9yCfu0_g\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/056_y\",[40.4167279,-3.7032905],\"Madrid\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcS07uY1Ff10piuoeygVkLQngkw20s-oavGPhgwXf2XDzf-7A9E2YBR0ECsvIE8VsDZHTft_ISqisNlrWLpoMpTgyTBzxolFFtoj4kjgBA4\",\"Spain\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcSiaNqWAE3IUqPuNUQrVgXskJXc71yyaquPJak6TnwikJRj-aoeySqD_fdKJ0S2GvCaY2imsxQV5I7Dav9boXFtPB8447h7NhsU-gJGng\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/07_pf\",[45.440379,12.3159547],\"Venice\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcRxN3yqGrJv57EQv0qNL0z3vpWJGNNR-nH7qEha0PAITt5TXUP5ZrbULBkfloRr_tckkWoCXR_DVsiyl11HaCHf_JTts5fiH8K3VufW6RI\",\"Italy\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQXybzmSZQ2U8gyRpB-ICGxEpXs7OMkl6hUVhBzpsnZAcAQ1PFDaxtZoTh0WRmQJpK5j64a_FNmZ0HLYx3hkToP1KZEM6gynSyFLAJEKw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/031y2\",[43.7699685,11.2576706],\"Florence\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcSYfAoKb2XfYiKwhMl59Q3VbXs5fSNGxoHXG8a707mXAiMu-c8WwzYpPlMgYid5HtZKUtPKYoDMv6jG_LuN5zoxh563ClfsfDTOF96U2hE\",\"Italy\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQYl3u2KPEF9mn-698OBtX19WEDpT0ed1EhT12hey5VRkWMCYIvtZ_mlGmE_6Q_1CgyuxGRRGnfn9SHlntudL017v5tKQmt6AJr4pQvdA\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0947l\",[45.468503,9.1824027],\"Milan\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcSHE0NMVivOze770pvbtVLxB93AiPARHypZtEyJ_qf9xnny2LFYJzgf8Tgb73XNvg7D2GT2uwqpWqa5SgAHuEpPQubBJOXP0UB3rYNuzmE\",\"Italy\",1,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcSLJv3t_EuAFFBnBY-w2C-c9ceu7OiVJaqEp8G_NYf9u57k4sPpA3_NopX-vOApDaJs988YwlgRBDRwK16i_s2ch7_p70dyJB5z27DTNg\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0pmn7\",[41.1579438,-8.6291053],\"Porto\",\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcTtE2JP9BsPAqqvRemBTQf7akt_qyBriSbJxoc1fIiCLc521DyBnZYyP-TNKh2HUYjgEQNnDCIFnw-f6hNzMejJVkVLdllkD6USFBhp4AM\",\"Portugal\",1,2,\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcRVJGYgm2p3tZ7Wqsm8Jrw6hnHiwvT9Ymhm4G0RcePvYqwWrjWCxW1CFko2Hv-oqa1-Mv-9UOFVRS3_FVx176rDUHgKRo_m3LYsmbyPIQ\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/09f3c\",[37.3890924,-5.9844589],\"Seville\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcRLDSf6MO9e0veuvsOxoiKVn44GHAK3zOnMlFF5Qr-qXa9kTU865KRDCGQxFlyDiVuLUxIoaaCBRbEXFSKUrxA3HdUn2VNJP6ruCdejvOo\",\"Spain\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcT8jpZgilUelUwc6D5N2sSpLtqC0yTCvDUiCbqZoGTQnShWM5IAApjyRp59PGEkIyqiHSeyVh8zCu-tekW-hPSRMF2ZpW-5jlFS9EdNLw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/02gtbh\",[37.4414601,25.3667218],\"Mykonos\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcSdU3ESogED9PoWMR7N1b_1YPBvuZeUUueaeUmTpdMO27fAUTKLAovt3dhkCSlRZI2u7umpArOI9r8zzWwDgktVlVi12HE8UIw0ow7aXxg\",\"Greece\",1,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcRQfJTKBNS1BnMT8zOTrD6UhswoPSIWsRZXqofG-kG2VwWHE8DrXrHsLY825-wpoqkwsRkXfirFF9zISQG7Uh46E758rSkkz_00b_2uhw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-11\"],[\"/g/1233f22n\",[40.8517746,14.2681244],\"Naples\",\"https://encrypted-tbn3.gstatic.com/licensed-image?q\\u003dtbn:ANd9GcShA5nmWmvUTqHcdl6YY3a36yZtB_SX5aKEQg1SEQGlooSF1FZzahIZZmcUktBkrNjx_HSJw2bfFZWnNyVacqDmhVY6G1Pp_QBCmegrXrY\",\"Italy\",1,2,\"https://encrypted-tbn3.gstatic.com/licensed-image?q\\u003dtbn:ANd9GcTqG5ywCaWZL7Yjf7LU_zRbxz0q2MYKtFSKzgfCxBghmbbSJthI-RWe3idQ3odcCWJXa9sENUMFTb-shqMTpdma20Jt2qFbLaPx28WG2g\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0h3tv\",[39.4738338,-0.3756348],\"València\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcQyS13hGvJv5A6xkIVIIo0XG8H_Qku7wPwey0mYjZaU7Lpr4Fj3R4KizYTzbdaahbBYVHzexZrdx5SsfF9TCQRcPf04Ta0wZ1UWRQI7I8I\",\"Spain\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcQgZ_gjHrXaC67LG5tkAwE946mpzdFkUnyCiUqMLnewZhycbIUUm2TZPbiEgv11qzCvh9HgiwQoUd7-mxqd_ljxY2Nvm0MZU7WGY38_Pg\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/01nqb1\",[28.2915637,-16.6291304],\"Tenerife\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcQu3BVNyujwZiqDo6X8suKQG5YlC92UJhS8wWFxGkcL2fJ8WJ8Fzp8sW8s6s8XfieySNRWW4ZALJU1KrIMq6RRsKIznO0-zRTKkwj90jDE\",\"Spain\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcTcAwpJWM3TZsD5ghrOo9iPn7BaeD0bpScseuCvUbHB3Xn9SHnAHLbMqpMkTywjr_H4sjNZDwMni-smyY7cadkXfi87yQiQAU32f08XOg\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/09jq0\",[37.1824607,-3.6011676],\"Granada\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcTLwf_MtrbYh6gCgAvBzvJxa1h9rI9wpd29dnHGfTKQ5UnAPJlFFMWbrxOJeHYn6ZnMxw17_xfXysa9DkEmmzGXq0fTz9w9_cCu4tpFcXw\",\"Spain\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcTYEO_F7A9Cppu2iF314xuyHZ3KGLtjMzYnX1pfKb9oaaYugedtI4nvgzrACcZD8DYGHFFQc8FEjYO3SiHFOd5Io6SuBatK0Dju0HjlTg\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/04_z1\",[32.7607074,-16.9594723],\"Madeira\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQAFbztmmA3CqoB9sH6uupyuuFwiuDxFz3XqmzCwzrziQiLRJ-v9f2ED7eZMaxDz_9xXLOJ6ot9dew5CbNoQYUJ6IjwSRhsucBrH178A-c\",\"Portugal\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQtDPgfnLMFTHGKtW187bEHbJPGnXRBFQQB4roD2doVwnG81gkb3_Ud9Sv2bzj5nnbTP42bOudRw0Tufs8LceqJW1amxCWzdat1hhQ0jA\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/01g_k3\",[43.5147118,16.4435148],\"Split\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcSU-HoBVIUpAwIY4Docw_S2Sz3xB0v8KE4CdjrrNsO86AFhN7nmkdONDWN2vdOPtRK0OQLxoJujxpmVQ1drZTklZFVcyDxkXnVxy4vfmZQ\",\"Croatia\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcRm_7HtSiZ4LfqO6Zrj90tLRnl8Cg7k7Uy2bHhUQFZ49n903YsvibliOs9cvotP03sZkU1V8hbsnx8LSfAWvrkciqO3ExDdo-LMjarmKw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/096g3\",[44.494887,11.3426163],\"Bologna\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcToLvzEjbH4XrmLi_HsmcJ6ly4UuOI49jv9eivy-IGKuHqdX_IASXBMGGFn-zresf2YgMpnE9PMWieFQ4Xy61d9U4o2CSIUvd-skLWsLE0\",\"Italy\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcTSp9gOSO1OgmR0bjo0YzzEU9NRsQSgI98SakMM7hYAh8MH5usOC1NNypwDVS2lx33sauDP9UGJAwo2HImcVm8gSe-vIS5p_P1UihJ1mA\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0fhzy\",[45.8150108,15.981919],\"Zagreb\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcRtaQn8Bs3xdEqO1TqAPvpAk2ms0UqiJmzr_uNJPUBn3N5LyYprByHW2FIgMm5AJltMshmmyGMhJJkOvK1PKh56wdCQ6hLQ4Bh4xjwFhhE\",\"Croatia\",1,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcRjbufZP-6pynBMFL4PQRrg_dh8uki5zkF_I3eo-2gVgJJfnKFZQkjTqw-XHPc_Z2oKii6rei6yilwrhNUskGoIEWmJ3p46oCO_oCGCvQ\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/01n43d\",[45.4383659,10.9917136],\"Verona\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcTcTz-K3cpVf1t8wge4QoQqnbxBB_IU5KwSP0ud6rnCqA0gwY8_ae4egc7053q9GNnHO3_1p3hWBwIw0TPAjERZhBA8p89Ea_fg2X7v7UE\",\"Italy\",1,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcQUP7gaApBp9SzOQkw2CbDsCbSifawbCno4FdGHjfoN_jP2n0hlyd2YZAfTBXjUDt6O4wRd2alGbYeCq2Z8CZneQInDFjRd4tdY_yy0cA\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/09pxc\",[38.1156864,13.3614635],\"Palermo\",\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcQjGZLHHUmSSP1SfZVbCoW-CBZdsdP1JDDo8r8WPilr2TMST1KeBPjzqFehONT-EgMLm2-26zTCg5K_JTsVqZWWqlQZxtHZTBi-I0XLems\",\"Italy\",1,2,\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcTlqbgfxtP8xdbOzt1znySpczgRbRn56yKa-DlMbbFKt3RO2L1jnD8Oe9xp-A7GLFDRLle0aDPdYesE4O1j8r72KjjnAfvikk6ovoU3yg\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/02l6l8\",[43.318237,-1.9817051],\"Donostia / San Sebastián\",\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcSnNaHCGJhqHTc2lKq_DrITVqG-PHksltmCOE-0A7jAdwRD9neiTbF791XJ41jAoqeFi91YwbT2f4loiih4n-AdHeJJMONpNGy9TFHfVCM\",\"Spain\",1,2,\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcTx22T0nhaBGqo6zMb3msYFYSUL1_LlFnHLDXLPD_4lKmXUSJ0rgGC6dqbsHSKm1SwIHHyu_gogh42D-zyKDnRG1mwMabrPyzrxxv5bCA\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0jwz5\",[39.5726541,2.6568551],\"Palma\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcRnFiK86qew3CNN2l5q338GpAu4rLMMrIyT9Z0bYCT09RMH42z0BjcbpBJju0_dqGGhGT-n_PkU2UhzkrHvKIXFR7n023-uVzNGvq-hi2M\",\"Spain\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcSG7waSnNK8FMBocuLKKQXKfytT-rHtXXX5xhzYMymb0uuv1dy_YR0AVdHyXmAqycGBaYx7R4NgKm4oA3LhPiTRTx1VzkI6hScRQ3bIRQ\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/01s8wm\",[29.0468535,-13.5899733],\"Lanzarote\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcSd4sJ7U-lJIDZFqhp0XtAwk7d9avREg7pm6WcCDeJLLBkuwrPh7xVI0SwobqTfe6lFfEAoiRxZlt9Gw245FmMFNmAHGqPmvJ1dacTwJe0\",\"Spain\",1,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcQ0Z1qcyLmGnQu6EHVG1EZrIu3PvTL5R3pjCiR-O-4XHsXNEYhaLHTq36VrAgE0vMRZizFHu6i1xZigutbVqjahjbJj2QNCv8vIcdWreA\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0htqt\",[43.2633799,-2.9348121],\"Bilbao\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcQjIPeZrjFfho88cTADdRX3thWIG-qjftJ_SiFkQJQrE-5lj-zbECr0LNFFKvo63ubYgHbqC7ML_laEznv922S2HGB7OXQtR7BZe_d37JY\",\"Spain\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcSv_MBj4uQ5EI2gsbcEsRRdSa69ZhwHU-_EEtfe3hbzc0FnbaSSSS05ZmrfV9f5Dl5Lr-SgsJrZBjq6GPIlQyBN8XhBM3Njvx94hUve-w\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/064xp\",[43.7228385,10.4017581],\"Pisa\",\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcQEzitT6Q9wMPQclK8t13GR-OD9rHjJg3wJMhi7SY-wra48yPjcOecMi0CvL3keML2Rqe8d95H1qKv4RO1hu1Yzlpg32iob8cSUXS8JvEk\",\"Italy\",1,2,\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcQpnoVxOOkoXwQd9QB7iFC9gbytSPMXw-83rjTorjDvKVfXAiHKfxg8ACylNrjUJbB589OSPtCmMXMVqg\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/07mgr\",[45.0703155,7.6868552],\"Turin\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcTUbvE2BdI19BdgfuDQtlcEPuVc2B3yhb1W8ppKk23K1_10Hvey_LtRR2VOLl89iPxBjWILeJ-RW50iGQOdASlhklTLR5H9SW7U8VZaePI\",\"Italy\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcSJXcS11ENA_SJ2gsQt9XrUTTVa_TZlsZzD9JOuR3VYnneN24YwhaaiQnK4vIq0_fsTildK0Oc124577_F1-hTe0zwPmje12UCg_Gr8cg\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0b2mc\",[40.6400629,22.9444191],\"Thessaloniki\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcRFMwegBJ7pvVwmI2JnZktuOtZY7sn-9C3_CE1uqMZ-RKFn3CTuOY1NIoaViNlnHj5PkjnQTWGgXSa6VP5qkv7097fIqmwwGbWg-Jn7hjQ\",\"Greece\",1,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcQGpVNvS-LGlIyhvjwKqDCmPAkq2fLx9sfPjHzBBEIn-GezrMkx4v0rfcjs-E_S_bZR31IY1Q_l1AUxQ0fw_UJucKNSOFDrVa0fW4NE0A\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0fhzf\",[44.8125449,20.46123],\"Belgrade\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQnmYhkEaFceXnutQ6xmbt9SFgJy5KNdWpGogMGY-st4v7FppM1PnhLxm8Om9a_gV_KWCXDuCzdn726V5-abIpj7YcfA4UiW8oPld-7LaM\",\"Serbia\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQSCBEjGpUyzi8g-emX6qqi4E1BxmTuK5TB3GIqZ-c1fvC7t7fY_WfoiPpLSRLElm2D_FiS2Naunv2G440ePqk_lQqKfIZsLpxi7snKNg\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0f1rr\",[37.8893025,-4.7792753],\"Córdoba\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcT_W4xrsuNqfFf7ybD1N4Xz_d3ptYJ_gLtROS_1gLvbF3a_gySRBr8iAbg-5WqeKUNb3TM--_am1GK7qNJ1qPlL7Mk154OEPhrjPsVdtCg\",\"Spain\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcSwtCAoNWxkwDarTwG7Fc6Y1lwrz0KeyeS3ibBDollBaL-SFdg3wPid6d4jvAREja8HKdmLyChksoLb_XY6HGkDCx1xYN6A4-zvZa3JSQ\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0hknf\",[44.4071448,8.9347381],\"Genoa\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcT0Rk6QMWdVdZ71-fD8x5f55ygiauzJenGjOfPNomYeSj5Rj__dzwU7y0sCamtzXJFy85wtHwh-luS2T-NHIA5AxGZhyUQ2hB-OVc3S3vE\",\"Italy\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcS7ZN8sKXNXtpqvU_NzVyP3aev-WXYkp7FNelw_3rv4pCEhCYeAXQQPvXniNFl8VWnhfGLskaPw3eQ2JDd6wUiBletRhUSLmYAl0MwFpQ\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0zc6\",[38.3457685,-0.4909444],\"Alicante\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcRe7aeyiw56EQp_ZKRSepGqx2yOW6w6ngu2ad_qOsnZ6yTKKaZX6YhtMsmtfbeKj9AFSsn2pyJE6fO4oHBmCTu0d2VQD_uzfApmjQbi-mw\",\"Spain\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcRFFamsOedDi3SwVaD-yoaOh0CgsPl9xSqdmHjXpdNNOo7QpEwqtTeJ-FBqxmMgRy2JuN3M-5g5-yW_e9Mazsi3knn6_Y65SAlwNgOwKw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/01qqt8\",[44.119371,15.2313648],\"Zadar\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcSwd3YybpBia3NoNi0IrRKtNqyNZTYytC4zQGSXYqRRgRGFnlvmHVrrNpaBwt3wfWD_L8Xy6NlLXJZoYSK72yNoLSMG0uNlvhnSoSsllNI\",\"Croatia\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcT-Tp2Te2qPKLUCpRwBkbrOaheJWJPsN9l9c5PsPvljregTeG3-FoNm06SP5SwEFWSjsNS46vgiw1aJ-yVJtQUXVD0f48F-5GFbR0Gobw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/035hm\",[36.140751,-5.353585],\"Gibraltar\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcT5_cX7eeklLBAApdVoEBHTnRH6XegmMbR9wRm4QmIxvdXHXlFxdlaDCwduFCDR936zLS7Uu0OJ4AcU6TpuNCe7nkVH1D53p3U4BmAthtg\",\"Gibraltar\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcTf7pSlT1NVdivH0-ciCMkSqGKaX55d8_8hV9v0uPnoFoMXEQS9i4BDtdLZ-REx8j9rwVw6bzyloAaJ1PAw-HRjx92JapnAKNQbVNbV8A\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/01t0n2\",[37.7870331,20.8998759],\"Zakynthos\",\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcT-nvDmlJo-cnW6p5TJuGPl6MnvCLWAqq7npA3W7uaS5XQ2iR5JTvax9oIkkNf05tARsTH92PdvfercausSLa0MyB7j3t1W-L37rQTATGA\",\"Greece\",1,2,\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcT4W8jTChKcXSzIz4gEm4sfYHXiCgFNyZAmCrT449-tjqDdGI1fezOXpc_UZg9rBLMXS4KF5u8tOpl-GzHAxIguIAH0M9hGbPB3dOhTqw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-11\"],[\"/m/031hm\",[28.3587436,-14.053676],\"Fuerteventura\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcRhIagm7K8d2RHrVlvowlyam1O3jqvW-vMzVPDtthP6btz9c0IWmO5znv3ShTbFiiWYtL1eoVYn1hdvh7Qec7ao4VbyT1FljtWYbBEn6Qo\",\"Spain\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcSK2MKOlg2arqufEISEtDDtSvsidrCy6F5LNoBoXViwLHY2YD5CDGJLp1xU7x1Dvmj-GBCGPA8D5OImTSRJjVANI8ALZU9lh3Awbhm_8w\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/0g86h\",[35.5137828,24.0203104],\"Chania\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQYtG0e-_KIej_xOFQ2vyehzDZlxmkXxpV7eWuW8qh0o6D-qe8CIp2rLHNN6fbQUL-XhUE3QVx98Wy9tB1pz1GHwHRmW-Al30OnwlHWD68\",\"Greece\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQjawBpslsMiordDmZS7kE7PyFN4besZNWlW5AFIdpqNmY0YC_Ryw1yX8q8ftJNPB9UhEl36SmpKGPk9zIBnZcJBWAWQRTQJaiJjV5URA\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-11\"],[\"/m/07m_f\",[41.3275459,19.8186982],\"Tiranë\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcSEv4M02W-0U4a9IRPjWmRU7d9YTo3bxtn-_jZ06Upqzup9pQHwP1xnVi3yOaJyH2w326TuD2iFBaxtIITt3e7CTx0vNpOBdOtuYSyEw6I\",\"Albania\",1,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcQup0ILAZswwhY9CCN2wvAOU0hK1ksdv08gtwLSTf9hBfVWZ5fnrDMbI24Qc8IR-sTWdokqKkxXOj5qOAECmttblDe71_pmRH8MiAfNng\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/06wr9\",[42.8768606,-8.5441729],\"Santiago de Compostela\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcTjr0UvKoGv9EzQIVhG0fJY21xgrfAhzWgOnazqJ7J1vTaO7CN__oCYRfSE4BijMXbIRF0sdOC_2SCORvrR6vo7uMbLGjWRFszim-WGF78\",\"Spain\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcQcfNsqfaOu1_1ieH1p-0GLkBwBqdeChT4DlJJMSrD1XefFLn6WfE6lgiUFvNLH9J6ryjvVFF8xiLxYEYmvEHd9qRckTMFp7vRgoXwmZw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/029v4j\",[37.0164626,-7.9351983],\"Faro\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQEC1IiikAri9Wv_UUVGlFbaY7YhLK1FCqiD-drtOtnLxPUW2JcImMZPiFiR9vKb_gYnpZD1wnc0tAzaadGSQ2ZO3Sq4OhL7C-u1V8sS1c\",\"Portugal\",2,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcTjW-6WDhvlQyE5e1Vdx_9FVar-qu7qgMURqBBDBw7Sx9ef_MgUmf0cMjbFGM9ML_KIYzZBFkan4cDkRCYdbYlRzU6h6lzKgrH1NjXLYQ\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/0g7wz\",[41.9832846,2.8247119],\"Girona\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQyNKcQ4QcXZvN0VvVbb8uEEEayXcyNyqufb5zmMIAlA1MjWxowACHJsIiRdHN3QN-FX7ygb_1MpjUtHkjcy8pEw0pDBYzKSDIi7XWrYOU\",\"Spain\",2,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcRBFunb152j5xEaSJ7JKfBMsAqIqQr7iYF3IYXRSkTzTbajmFjrmIyZS8yzC6WBeh-50SPUcXdD0BHtBrdKZhyB1xR_WoWKC_3nuwbETw\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/02phc1\",[37.7394207,-25.6686725],\"Ponta Delgada\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcT2Drt1HVIzF9LdgxFzRlmvD9sz2yjUH1JzY3YE6a5gRjNrt37w4GSDOOVkBYMKy6tiLGlHwAc_Mxo9Iav-hMZRnRKneoNX-UQZwE-mjZg\",\"Portugal\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQ30pFU8FJe_IRWRQ4to8CJb3aJ7diZS9ux_RPOC69mtHyRB0M6LKRxlcGHvIoqBGcHtYNejCKv20G-a1TCpDNkPyFJsdnMqjkLnB9IjQ\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/096c16\",[37.7547857,26.9777701],\"Samos\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcRq2kNe9Gfz3EqWzsCS_NgB0VluY1tqn_c16hOTiVefR7IBdoD-Ee2xx3Hmoqwy0khg5hXAGI2XI04_yyzOoFY5O8X_5XU_XOCBl-SqhyI\",\"Greece\",2,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcRGUFYKewENoG03zAE4R23reHqpopzY61gar1g6biHCKj7WMBDOzF0fK0ysvQwqMZ-GgCkc1YILjttgf7DIpbceQ5DgInU7f6890vfdBQ\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/03f_dg\",[37.0366386,22.1143716],\"Kalamata\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQiKhXbbiShnLwEk2rErrGkqcDuzbII1L7ZLG2c1m7QCJL7jTj_RMxmWGhJA2UtK2Emg2QJwPCZjBSSFE9G37YE674l8wy2zlRPPpjSlP4\",\"Greece\",2,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcTcLvELJj33W5SC_DXKqjjCVzKwgH41FFsIMzPxtb1IRjYSptwLI_a3Dp-ASpWxNFdHCzjTyM1mFEKwj92IKvFWEW9PQM9T7Q-0ocRgng\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/031_r6\",[38.7216415,-27.2205771],\"Terceira Island\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcT-saC_qyu8xGeN_Z-QWavvs8gDVG0RrRoNvtvpS6glqXT3pzcy4aRMD4EeOykGLpIy7VMYY71epV7D5kK-zsmu6rS-5mreWck8sm5E2ds\",\"Portugal\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcS7sM2ToxiIpbbQjqdhF0sVEBymQWUofmrnd0CHHIsaAVbAAjHOCSvoAWJxAsfs954DHWsj3nhienp5GWW5yQvTDj4Lc8MGDKkqKqPS4g\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/01qz7h\",[38.4580494,-28.3228165],\"Pico Island\",\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcTeWnTAfMcAft0ktX5u5wxG7YCyirhKJniDNvq4R0BXhdXqoE1ohqEWSX1I5dY4ShwFKen__A8ST2G9yfTaZvdQVoVcvXWEPYVkDATMLCk\",\"Portugal\",2,2,\"https://encrypted-tbn2.gstatic.com/images?q\\u003dtbn:ANd9GcTtw9Sy9lfHJ1fPv79LC0WN75N112R7so3j76obLj93PXjobgpC7JRu983nloip84hvPR0gRFgqou7jnVRs-0JKSNieztSW_IdpNMPadQ\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/039ql7\",[33.0759749,-16.3346061],\"Porto Santo Island\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcTNRBLhqwd3t4U6vuRhQNBbyUmURUhv2saLYBAnfNKgq2UavrYtgVT39dRqfiA1nGDEIxuQlbcqEGBjqZlxYIx1pExBSOciULVXGsLDxN8\",\"Portugal\",1,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcQMF-diyQM0WSaqoyolF_A0ZxMlAZo5V57I3StwZoDWxsQEjaTbicjiwCCVgMXBJTziUdir_4K2yuqlAk3lWeGMeJIqHHvnQFl_kUEKPw\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-11\"],[\"/m/0413lf\",[36.832037,11.9439104],\"Pantelleria\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcRM5R2bn1LKmvx2PhAevhMVK-uSUjTh472S_It8w3tdBhOxmEV_Vs1fjhmNa2HqYvWvT1FQu4sUQ36o6VG6FZWrdPtBeWyIoHoF76s9Nqc\",\"Italy\",2,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcSx4fvPpAGZlNTmMfUZRMzyBVvrTXyw1_Pqra36VyH5Wv6XvJ-AEB1GFAaiJSSFSn6EhO6vOcXjnfxqfA\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/013517\",[45.5549624,18.6955144],\"Osijek\",\"https://encrypted-tbn1.gstatic.com/licensed-image?q\\u003dtbn:ANd9GcQetgCuanZIdpfR9LDyNfC_2lYTN3EqOsM16Nz1rlVlZNg-nz8ievpDW_XF1aF0J0YLPd4joEQUvRU3NaNHVo1N_8cXJQvjpk-sNLYBJyk\",\"Croatia\",2,2,\"https://encrypted-tbn1.gstatic.com/licensed-image?q\\u003dtbn:ANd9GcRiT4eNc7kjglKLFc8HKg0qZZ9DzotAa6HAzGIQh8uxXDB-VGN8eOJOLJLk7vfRCc-9MOyyAuLKFdfIZumb8okSDXKvPBfinG6IH6CrkA\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/027cfd\",[39.4474713,-31.193945],\"Flores Island\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcSRy4CWclrjW7LmiqQ0XaRnAoh4Tqup7QWIeOIPW0A2uSen6F5KmQ3L01jrHJsFxJkH6vtxjokOe1RYSZMGeEppaFdqU0k2Z-SiBbS705k\",\"Portugal\",2,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcQE91OaqUUoR0Q9XLXUxyd6e1Ib8sBOyUGV8W1pBLEq504lIMiCB6wp7xpbxXZLoVe2uKy2RUoZ2bh-BQ\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/07sfsn\",[28.6839962,-17.7645438],\"Santa Cruz de La Palma\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcSnMLuhaBkek3lTaIMGXIOehGVZc31p_lLpWBq-unMQ7T3_z37XgsOgslonLvdg-qUhD2J8On17T3lGmUStDO2zQs6FHPsQfTlkQqmbbXc\",\"Spain\",1,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcRVSAj3GehERz1nxhJf9-m0ueVg3FnC5he_aZmqjN0y96WcqdwSuauUo4LHFqd60KK8r5YPmXO6-WcDro4yYqh1DO1SsS57LuN6K2lzYA\",null,null,null,\"2026-09-10\",null,null,false,null,null,null,null,null,false,null,1,null,null,null,null,null,\"2026-09-10\"],[\"/m/05d91z\",[36.9788003,-25.1059054],\"Santa Maria Island\",\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcTMlnMtLHhPMdHJg6VGRFfnLVCVF_THV8Q6440hG27ew2ONxTueUplQNJtsdkiDKg0lbI2GjvcDqfCnGQPsX2qjyuZYddLSq6H08SCu4-8\",\"Portugal\",2,2,\"https://encrypted-tbn1.gstatic.com/images?q\\u003dtbn:ANd9GcRdlqLBB85jf3PIpeHhPMYmuKx3I2vHIOdYKECHdZ8RTWpM3axNURCJFUOssgIuCWCSGdPTVfuY07ts5fwzNhdcvufDNmUg_5qLyTThuA\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/020l00\",[35.5086218,12.59292],\"Lampedusa\",\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQZX9_2LOY541kjbklghOo_quj4bJk9OnwcM9ARlRUt6S3Kw0tBu--WPlpMW4d2IlQuScxrRyjJAgchqV1b8jwIjs006a4kWNE0ZcyzXls\",\"Italy\",2,2,\"https://encrypted-tbn0.gstatic.com/images?q\\u003dtbn:ANd9GcQfaCZ16zdboxl1pZnuF7Nh2QVoqn61dELBW3kqwrVOb4ZvUwO5OnrtyC2mnbIvv0nJxVEx1TXNy_fEDVscuGwL13eEizqtHea-DRwbiw\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/026zw1\",[39.7023111,-31.1080244],\"Corvo Island\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcT39U6W05MOEzOS7PHDYFI_uUh7C1V0rfIJV01I_qA8yDR2wZsVFa_f02q3wMcYYea0giW0NauiV8nRxpjs2Jxv1_ony6im-_Wub0IuAFE\",\"Portugal\",2,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcRbgWBlMz0Cc2giTK_ZmAi4-WgmFLyfBOoCm3ZDpwHhqqwJnzNCR1uSlwf3BoyWQlyywF3EWjs2VIn0UR1PO1-qGXNK8fmQZbusTcQ-kw\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1],[\"/m/015rz7\",[28.1235475,-15.4362575],\"Las Palmas de Gran Canaria\",\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcTnhFcoIhpeZpfhet607F1tuZGZ_iLeoO7NuVNQdPyoBhwSUE_DItOFnbpxU09aEh01lS24kho--EydZPuk3u1-Bza3VqgDyPAIK-OrFAM\",\"Spain\",2,2,\"https://encrypted-tbn3.gstatic.com/images?q\\u003dtbn:ANd9GcQWBzqCaRVZ__UWj440385uDQmRQN0Rv45XXAxP8xm5DTUC1ai4w2NCsSXT284OOs4t-SrtKHHKSdP2X7I3AZzdagVApL_LfVMcdth89g\",null,null,null,null,null,null,false,null,null,null,null,null,false,null,1]]],null,[[[null,50],[null,1500]],[[[\"ONEWORLD\",\"Oneworld\"],[\"SKYTEAM\",\"SkyTeam\"],[\"STAR_ALLIANCE\",\"Star Alliance\"]]],null,null,null,null,[[[\"/g/11bc58l13w\",\"Outdoors\"],[\"/m/0b3yr\",\"Beaches\"],[\"/m/09cmq\",\"Museums\"],[\"/m/03g3w\",\"History\"],[\"/m/071k0\",\"Skiing\"]]]],[[\"London\",[51.5072178,-0.1275862],\"/m/04jpl\",\"/m/04jpl\",\"ChIJdd4hrwug2EcRmSrV3Vo6llI\"]],null,[[1786480053335614,37837043,355584851],null,null,null,null,[[0]]]]"],["wrb.fr",null,"[[null,null,1,\"tYV7av69FPOxhcIP05bHqQE\"],null,null,null,[[[\"/m/0g86h\",[[null,142],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tQ0hROjIwMjYtMDktMTBfGgoI524QAhoDVVNEOClw524\\u003d\"],null,null,null,null,[\"LS\",\"Jet2\",0,245,null,\"CHQ\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0fbwq\",[[null,38],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tREJWOjIwMjYtMDktMTBfGgoIxR0QAhoDVVNEOClwxR0\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,155,null,\"DBV\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/07sfsn\",[[null,142],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctU1BDOjIwMjYtMDktMTBfGgoI524QAhoDVVNEOClw524\\u003d\"],null,null,null,null,[\"BY\",\"TUI Airways\",0,260,null,\"SPC\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/01qqt8\",[[null,100],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tWkFEOjIwMjYtMDktMTBfGgoIi04QAhoDVVNEOClwi04\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,140,null,\"ZAD\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0n2z\",[[null,83],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tQVRIOjIwMjYtMDktMTBfGgoIr0AQAhoDVVNEOClwr0A\\u003d\"],null,null,null,null,[\"W9\",\"Wizz Air\",0,240,null,\"ATH\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/02l6l8\",[[null,186],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctRUFTOjIwMjYtMDktMTBfGgsI9pABEAIaA1VTRDgpcPaQAQ\\u003d\\u003d\"],null,null,null,null,[\"VY\",\"Vueling\",1,325,null,\"EAS\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/01t0n2\",[[null,75],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tWlRIOjIwMjYtMDktMTBfGgoIhjoQAhoDVVNEOClwhjo\\u003d\"],null,null,null,null,[\"LS\",\"Jet2\",0,215,null,\"ZTH\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/031_r6\",[[null,229],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctVEVSOjIwMjYtMDktMTBfGgsIn7IBEAIaA1VTRDgpcJ+yAQ\\u003d\\u003d\"],null,null,null,null,[\"TP\",\"Tap Air Portugal\",1,1245,null,\"TER\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/01nqb1\",[[null,41],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tVEZTOjIwMjYtMDktMTBfGgoI7h8QAhoDVVNEOClw7h8\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,270,null,\"TFS\",\"/m/04jpl\",null,120],null,null,true,1,2,null,null,4],[\"/m/0fhzy\",[[null,43],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tWkFHOjIwMjYtMDktMTBfGgoIvSEQAhoDVVNEOClwvSE\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,135,null,\"ZAG\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0jwz5\",[[null,29],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tUE1JOjIwMjYtMDktMTBfGgoIlBYQAhoDVVNEOClwlBY\\u003d\"],null,null,null,null,[\"W9\",\"Wizz Air\",0,155,null,\"PMI\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0hknf\",[[null,121],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tR09BOjIwMjYtMDktMTBfGgoIhV4QAhoDVVNEOClwhV4\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,125,null,\"GOA\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/g/1233f22n\",[[null,53],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tTkFQOjIwMjYtMDktMTBfGgoIrikQAhoDVVNEOClwrik\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,160,null,\"NAP\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/096g3\",[[null,100],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tQkxROjIwMjYtMDktMTBfGgoIi04QAhoDVVNEOClwi04\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,130,null,\"BLQ\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/031hm\",[[null,58],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctRlVFOjIwMjYtMDktMTBfGgoI7CwQAhoDVVNEOClw7Cw\\u003d\"],null,null,null,null,[\"U2\",\"easyJet\",0,255,null,\"FUE\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/04v3q\",[[null,132],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctTUxBOjIwMjYtMDktMTBfGgoI0mYQAhoDVVNEOClw0mY\\u003d\"],null,null,null,null,[\"VY\",\"Vueling\",1,430,null,\"MLA\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0b2mc\",[[null,61],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tU0tHOjIwMjYtMDktMTBfGgoInS8QAhoDVVNEOClwnS8\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,195,null,\"SKG\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/02phc1\",[[null,244],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMSFItUERMOjIwMjYtMDktMTBfGgsIqb4BEAIaA1VTRDgpcKm+AQ\\u003d\\u003d\"],null,null,null,null,[\"TP\",\"Tap Air Portugal\",1,1075,null,\"PDL\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/01s8wm\",[[null,45],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctQUNFOjIwMjYtMDktMTBfGgoI6SIQAhoDVVNEOClw6SI\\u003d\"],null,null,null,null,[\"U2\",\"easyJet\",0,260,null,\"ACE\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/09jq0\",[[null,179],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctR1JYOjIwMjYtMDktMTBfGgsIoosBEAIaA1VTRDgpcKKLAQ\\u003d\\u003d\"],null,null,null,null,[\"VY\",\"Vueling\",1,460,null,\"GRX\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/070t9\",[[null,100],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctSlRSOjIwMjYtMDktMTBfGgoIi04QAhoDVVNEOClwi04\\u003d\"],null,null,null,null,[\"U2\",\"easyJet\",0,240,null,\"JTR\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0zc6\",[[null,50],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tQUxDOjIwMjYtMDktMTBfGgoI3yYQAhoDVVNEOClw3yY\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,160,null,\"ALC\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/056_y\",[[null,75],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tTUFEOjIwMjYtMDktMTBfGgoIjzoQAhoDVVNEOClwjzo\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,150,null,\"MAD\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/02gtbh\",[[null,71],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tSk1LOjIwMjYtMDktMTBfGgoI7zYQAhoDVVNEOClw7zY\\u003d\"],null,null,null,null,[\"W9\",\"Wizz Air\",0,235,null,\"JMK\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0h3tv\",[[null,60],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tVkxDOjIwMjYtMDktMTBfGgoIti4QAhoDVVNEOClwti4\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,145,null,\"VLC\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/07mgr\",[[null,36],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tVFJOOjIwMjYtMDktMTBfGgoItxsQAhoDVVNEOClwtxs\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,115,null,\"TRN\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/01g_k3\",[[null,113],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tU1BVOjIwMjYtMDktMTBfGgoIylcQAhoDVVNEOClwylc\\u003d\"],null,null,null,null,[\"W9\",\"Wizz Air\",0,160,null,\"SPU\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/07_pf\",[[null,44],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tVkNFOjIwMjYtMDktMTBfGgoI4iEQAhoDVVNEOClw4iE\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,125,null,\"VCE\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/064xp\",[[null,64],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tUFNBOjIwMjYtMDktMTBfGgoIzDEQAhoDVVNEOClwzDE\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,125,null,\"PSA\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/09pxc\",[[null,87],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tUE1POjIwMjYtMDktMTBfGgoIqkMQAhoDVVNEOClwqkM\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,175,null,\"PMO\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/039ql7\",[[null,254],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMSFItUFhPOjIwMjYtMDktMTBfGgsI28UBEAIaA1VTRDgpcNvFAQ\\u003d\\u003d\"],null,null,null,null,[\"TP\",\"Tap Air Portugal\",1,920,null,\"PXO\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/06wr9\",[[null,68],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMSFItU0NROjIwMjYtMDktMTBfGgoIgTUQAhoDVVNEOClwgTU\\u003d\"],null,null,null,null,[\"VY\",\"Vueling\",0,130,null,\"SCQ\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/01n43d\",[[null,103],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctVlJOOjIwMjYtMDktMTBfGgoImVAQAhoDVVNEOClwmVA\\u003d\"],null,null,null,null,[\"U2\",\"easyJet\",0,120,null,\"VRN\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0htqt\",[[null,83],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tQklPOjIwMjYtMDktMTBfGgoIr0AQAhoDVVNEOClwr0A\\u003d\"],null,null,null,null,[\"W9\",\"Wizz Air\",0,120,null,\"BIO\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/01f62\",[[null,33],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tQkNOOjIwMjYtMDktMTBfGgoIqRkQAhoDVVNEOClwqRk\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,130,null,\"BCN\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/031y2\",[[null,177],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMQ1ktRkxSOjIwMjYtMDktMTBfGgsIgIoBEAIaA1VTRDgpcICKAQ\\u003d\\u003d\"],null,null,null,null,[\"AZ\",\"ITA\",2,630,null,\"FLR\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/07m_f\",[[null,68],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctVElBOjIwMjYtMDktMTBfGgoI4TQQAhoDVVNEOClw4TQ\\u003d\"],null,null,null,null,[\"W9\",\"Wizz Air\",0,180,null,\"TIA\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/04_z1\",[[null,108],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctRk5DOjIwMjYtMDktMTBfGgoI8lMQAhoDVVNEOClw8lM\\u003d\"],null,null,null,null,[\"U2\",\"easyJet\",0,235,null,\"FNC\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/06c62\",[[null,50],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tRkNPOjIwMjYtMDktMTBfGgoIhScQAhoDVVNEOClwhSc\\u003d\"],null,null,null,null,[\"W4\",\"Wizz Air\",0,155,null,\"FCO\",\"/m/04jpl\",null,60],null,null,true,1,2,null,null,4],[\"/m/0fhzf\",[[null,45],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tQkVHOjIwMjYtMDktMTBfGgoI6SIQAhoDVVNEOClw6SI\\u003d\"],null,null,null,null,[\"W9\",\"Wizz Air\",0,170,null,\"BEG\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/035hm\",[[null,163],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctR0lCOjIwMjYtMDktMTBfGgoIlH8QAhoDVVNEOClwlH8\\u003d\"],null,null,null,null,[\"U2\",\"easyJet\",0,180,null,\"GIB\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0pmn7\",[[null,53],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tT1BPOjIwMjYtMDktMTBfGgoIkykQAhoDVVNEOClwkyk\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,145,null,\"OPO\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/0947l\",[[null,29],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tQkdZOjIwMjYtMDktMTBfGgoIlBYQAhoDVVNEOClwlBY\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,115,null,\"BGY\",\"/m/04jpl\",null,60],null,null,true,1,2,null,null,4],[\"/m/04llb\",[[null,97],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNTVE4tTElTOjIwMjYtMDktMTBfGgoIyEsQAhoDVVNEOClwyEs\\u003d\"],null,null,null,null,[\"FR\",\"Ryanair\",0,170,null,\"LIS\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4],[\"/m/09f3c\",[[null,50],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tU1ZROjIwMjYtMDktMTBfGgoIhScQAhoDVVNEOClwhSc\\u003d\"],null,null,null,null,[\"W9\",\"Wizz Air\",0,170,null,\"SVQ\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4]]],null,null,null,[[1786480053335614,37837043,355584851],null,null,null,null,[[2]]]]"],["wrb.fr",null,"[[null,null,2,\"tYV7av69FPOxhcIP05bHqQE\"],null,null,null,[[[\"/m/0f1rr\",[[null,171],\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMR1ctT0RCOjIwMjYtMDktMTBfGgsIioUBEAIaA1VTRDgpcIqFAQ\\u003d\\u003d\"],null,null,null,null,[\"VY\",\"Vueling\",1,330,null,\"ODB\",\"/m/04jpl\",null,0],null,null,true,1,2,null,null,4]]],null,null,\"CjRIVW12MVJYU29WMU1BQUFXU1FCRy0tLS0tLS0tLXdmYXUxNkFBQUFBR3A3aGJVRnJaWXFBEhNMVE4tUE1JOjIwMjYtMDktMTBfGgoIlBYQAhoDVVNEOClwlBY\\u003d\",[[1786480053335614,37837043,355584851],null,null,null,null,[[3]]]]"],["di",1151],["af.httprm",1151,"-1273874126241185248",1]] \ No newline at end of file diff --git a/tests/search/test_search_explore.py b/tests/search/test_search_explore.py new file mode 100644 index 00000000..b6301c6b --- /dev/null +++ b/tests/search/test_search_explore.py @@ -0,0 +1,266 @@ +"""Offline tests for the Explore search wire parsing and payload join. + +The hand-built bodies replicate the ``GetExploreDestinations`` framing: +``)]}'`` prefix + byte-counted length-prefixed ``wrb.fr`` chunks (see +``fli/search/_wire.py``). Length headers count UTF-8 BYTES — the Kraków +destination below pins that behaviour for non-ASCII names. +""" + +import json +from pathlib import Path +from typing import Any + +import pytest + +from fli.search._decoders import ( + is_explore_destinations_chunk, + is_explore_prices_chunk, + parse_explore_destinations_chunk, + parse_explore_prices_chunk, +) +from fli.search.explore import SearchExplore + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + +# --------------------------------------------------------------------------- +# Wire-body builders +# --------------------------------------------------------------------------- + + +class _FakeResponse: + __slots__ = ("text", "status_code") + + def __init__(self, text: str): + self.text = text + self.status_code = 200 + + def raise_for_status(self) -> None: + return None + + +class _FakeClient: + def __init__(self, text: str): + self._text = text + self.calls: list[dict[str, Any]] = [] + + def post(self, url: str, **kwargs: Any) -> _FakeResponse: + self.calls.append({"url": url, **kwargs}) + return _FakeResponse(self._text) + + +def _frame(inner: Any) -> str: + """One length-prefixed wrb.fr frame carrying ``inner`` as its payload.""" + outer = json.dumps([["wrb.fr", None, json.dumps(inner, separators=(",", ":"))]]) + # The length header counts the payload bytes plus the newline on each + # side of it (the reader consumes `length - 1` after the header's \n). + return f"{len(outer.encode('utf-8')) + 2}\n{outer}\n" + + +def _body(*inners: Any) -> str: + return ")]}'\n\n" + "".join(_frame(inner) for inner in inners) + + +def _dest_record( + mid: str, + name: str, + country: str | None = None, + departure: str | None = "2026-09-10", + arrival: str | None = "2026-09-10", +) -> list: + record: list = [None] * 29 + record[0] = mid + record[1] = [10.5, 20.25] + record[2] = name + record[3] = "https://thumb.example/t.jpg" + record[4] = country + record[7] = "https://hero.example/h.jpg" + record[11] = departure + record[28] = arrival + return record + + +def _dest_chunk(records: list, region: str = "Europe", origin: str = "London") -> list: + return [ + [None, [1, 2, 3], 0, "search_token", "session_token"], + None, + [region, [[47.0, 29.6], [27.4, -31.4]]], + [records], + None, + [[[None, 50], [None, 1500]]], + [[origin, [51.5, -0.12], "/m/04jpl", "/m/04jpl", "ChIJplaceid"]], + "continuation_token", + None, + ] + + +def _price_record( + mid: str, + price: float | None, + airline: str = "FR", + stops: int = 0, + duration: int = 100, + airport: str = "XXX", +) -> list: + record: list = [None] * 15 + record[0] = mid + record[1] = [[None, price], "token-not-a-real-protobuf"] + record[6] = [airline, f"{airline} Air", stops, duration, None, airport, "/m/04jpl", None, 0] + record[9] = price is not None + return record + + +def _price_chunk(records: list) -> list: + return [[None, None, 1, "search_token"], None, None, None, [records], None, None, None, None] + + +# --------------------------------------------------------------------------- +# Chunk classification +# --------------------------------------------------------------------------- + + +def test_chunk_classification(): + dest = _dest_chunk([_dest_record("/m/0491y", "Kraków", "Poland")]) + price = _price_chunk([_price_record("/m/0491y", 18)]) + assert is_explore_destinations_chunk(dest) + assert not is_explore_prices_chunk(dest) + assert is_explore_prices_chunk(price) + assert not is_explore_destinations_chunk(price) + for junk in (None, [], [None] * 9, ["wrb.fr"], 42): + assert not is_explore_destinations_chunk(junk) + assert not is_explore_prices_chunk(junk) + + +def test_malformed_destination_records_skipped(): + good = _dest_record("/m/0491y", "Kraków", "Poland") + no_name = _dest_record("/m/xxxx", "ignored") + no_name[2] = None + not_a_mid = _dest_record("KRK", "Kraków") + chunk = _dest_chunk([good, no_name, not_a_mid, "not-a-list", None]) + meta, destinations = parse_explore_destinations_chunk(chunk) + assert [d.mid for d in destinations] == ["/m/0491y"] + assert meta["region_name"] == "Europe" + assert meta["origin_name"] == "London" + assert meta["price_slider_min"] == 50 + assert meta["price_slider_max"] == 1500 + + +def test_price_record_without_fare_omitted(): + chunk = _price_chunk([_price_record("/m/0491y", 18), _price_record("/m/04v3q", None)]) + prices = parse_explore_prices_chunk(chunk, default_currency="USD") + assert set(prices) == {"/m/0491y"} + assert prices["/m/0491y"]["price"] == 18 + assert prices["/m/0491y"]["currency"] == "USD" # fake token -> fallback + assert prices["/m/0491y"]["destination_airport"] == "XXX" + + +# --------------------------------------------------------------------------- +# End-to-end parse through SearchExplore (stubbed client) +# --------------------------------------------------------------------------- + + +def _search_with_body(body: str) -> tuple[Any, _FakeClient]: + from datetime import datetime, timedelta + + from fli.models import Airport, ExploreSearchFilters + + search = SearchExplore() + fake = _FakeClient(body) + search.client = fake + filters = ExploreSearchFilters( + origin=Airport.LHR, + departure_date=(datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d"), + ) + return search.search(filters, currency="USD"), fake + + +DESTS = [ + _dest_record("/m/0491y", "Kraków", "Poland"), + _dest_record("/m/04v3q", "Malta", "Malta"), + _dest_record("/m/056_y", "Unpriced Town", "Nowhere"), +] +PRICES = [ + _price_record("/m/0491y", 18, airline="FR", airport="KRK"), + _price_record("/m/04v3q", 132, airline="VY", stops=1, duration=430, airport="MLA"), +] + + +@pytest.mark.parametrize("order", ["dest_first", "price_first"]) +def test_two_payload_join_in_either_order(order): + chunks = [_dest_chunk(DESTS), _price_chunk(PRICES)] + if order == "price_first": + chunks.reverse() + result, fake = _search_with_body(_body(*chunks)) + + assert result is not None + assert result.region_name == "Europe" + assert result.origin_name == "London" + assert [d.name for d in result.destinations] == ["Kraków", "Malta", "Unpriced Town"] + + krakow = result.destinations[0] + assert krakow.price == 18 + assert krakow.airline == "FR" + assert krakow.destination_airport == "KRK" + assert krakow.country == "Poland" + assert krakow.latitude == 10.5 + + unpriced = result.destinations[2] + assert unpriced.price is None + assert unpriced.price_unknown + assert unpriced.airline is None + + # The endpoint's same-origin requirement must always be satisfied. + headers = fake.calls[0]["headers"] + assert headers["x-same-domain"] == "1" + assert headers["origin"] == "https://www.google.com" + + +def test_records_accumulate_across_many_chunks(): + """Large regions stream records across many chunks (24 observed live).""" + body = _body( + _dest_chunk(DESTS[:1]), + _price_chunk(PRICES[:1]), + _dest_chunk(DESTS[1:], region="Europe"), + _price_chunk(PRICES[1:]), + ) + result, _ = _search_with_body(body) + assert [d.name for d in result.destinations] == ["Kraków", "Malta", "Unpriced Town"] + assert [d.price for d in result.destinations] == [18, 132, None] + + +def test_unparseable_body_returns_none(): + result, _ = _search_with_body(")]}'\n\nnot json at all") + assert result is None + + +def test_error_13_body_returns_none(): + """The opaque error envelope Google sends for bad requests.""" + body = ")]}'\n\n" + json.dumps([["wrb.fr", None, None, None, None, [13]]]) + result, _ = _search_with_body(body) + assert result is None + + +# --------------------------------------------------------------------------- +# Captured-fixture replay (durable assertions only — see snapshot drift policy) +# --------------------------------------------------------------------------- + + +def test_fixture_replay_lon_southern_europe(): + fixture = FIXTURES_DIR / "explore_lon_southern_europe.bin" + if not fixture.exists(): + pytest.skip("explore fixture not captured") + + result, _ = _search_with_body(fixture.read_text(encoding="utf-8")) + assert result is not None + assert result.region_name + assert result.origin_name + assert len(result.destinations) >= 20 + priced = [d for d in result.destinations if d.price is not None] + unpriced = [d for d in result.destinations if d.price is None] + assert len(priced) >= 1 + assert len(unpriced) >= 1 + for d in result.destinations: + assert d.mid.startswith(("/m/", "/g/")) + assert d.name + for d in priced: + assert d.price > 0 + assert d.currency diff --git a/tests/search/test_search_explore_live.py b/tests/search/test_search_explore_live.py new file mode 100644 index 00000000..2df5379c --- /dev/null +++ b/tests/search/test_search_explore_live.py @@ -0,0 +1,79 @@ +"""Live-API integration tests for the Explore search. + +These hit Google's live ``GetExploreDestinations`` endpoint and confirm the +request recipe (same-origin headers + ``curr`` param) still works and that +responses parse into sensible results. + +As live network tests they may be skipped or re-run on flake; do not include +in pre-commit CI. +""" + +from datetime import datetime, timedelta + +import pytest +from tenacity import retry, stop_after_attempt, wait_exponential + +from fli.models import ExplorePlace, ExploreSearchFilters +from fli.search import SearchExplore + +LONDON = ExplorePlace(mid="/m/04jpl", type_code=4) +SOUTHERN_EUROPE = ExplorePlace(mid="/m/0250wj", type_code=6) + + +def _future(days: int) -> str: + return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") + + +def _filters() -> ExploreSearchFilters: + return ExploreSearchFilters( + origin=LONDON, + destination=SOUTHERN_EUROPE, + departure_date=_future(45), + ) + + +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), reraise=True) +def _search_with_retry(client: SearchExplore, filters: ExploreSearchFilters, **kw): + result = client.search(filters, **kw) + if result is None or not result.destinations: + raise ValueError("Empty explore result, retrying...") + return result + + +@pytest.fixture +def client(): + return SearchExplore() + + +def test_explore_returns_many_priced_destinations(client): + result = _search_with_retry(client, _filters(), currency="USD") + + assert result.origin_name + assert len(result.destinations) >= 20 + priced = [d for d in result.destinations if d.price is not None] + assert len(priced) >= 1 + for d in result.destinations: + assert d.mid.startswith(("/m/", "/g/")) + assert d.name + for d in priced: + assert d.price > 0 + assert d.destination_airport + assert d.departure_date + + +def test_currency_param_changes_prices(client): + usd = _search_with_retry(client, _filters(), currency="USD") + gbp = _search_with_retry(client, _filters(), currency="GBP") + + usd_prices = {d.mid: d.price for d in usd.destinations if d.price is not None} + gbp_prices = {d.mid: d.price for d in gbp.destinations if d.price is not None} + common = set(usd_prices) & set(gbp_prices) + assert len(common) >= 5 + + # GBP has been worth more than USD for decades — if that inverts, the + # currency knob is broken long before this assertion is. + cheaper_in_gbp = sum(1 for mid in common if gbp_prices[mid] < usd_prices[mid]) + assert cheaper_in_gbp > len(common) / 2 + + currencies = {d.currency for d in usd.destinations if d.price is not None} + assert currencies == {"USD"}