layover 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ """Parsing a Google Flights results page (ORACLES §3).
2
+
3
+ Written against a recorded fixture — `tests/fixtures/google_flights/` — and pinned by
4
+ a golden test, so CI re-runs it against real bytes without touching the network.
5
+
6
+ **Why the accessibility labels rather than the class names.** A results row carries
7
+ its own description for screen readers: *"Leaves Budapest Ferenc Liszt International
8
+ Airport at 8:35 PM on Sunday, October 4 and arrives at Suvarnabhumi Airport at 3:25 PM
9
+ on Monday, October 5"*, *"Layover (1 of 1) is a 2 hr 20 min layover at Istanbul
10
+ Airport"*, *"1002 US dollars"*. Those strings are the site's contract with assistive
11
+ technology; obfuscated class names are its contract with nobody. They churn far less.
12
+
13
+ **What a row does and does not say.** It states the whole itinerary's price, its
14
+ endpoints, its total duration, and each layover's airport and length. It does *not*
15
+ state flight numbers or per-segment times — those live in the details panel behind a
16
+ second request. Two consequences, both recorded on every offer rather than hidden:
17
+
18
+ * segment times for connecting itineraries are **interpolated** (flight time split
19
+ evenly between segments); the layover durations themselves are exact, and they are
20
+ what the connection logic actually uses;
21
+ * on a round-trip search the price is the **round-trip total** shown against an
22
+ outbound option, with the inbound not yet chosen. That is what Google means by it,
23
+ so that is what the offer says.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import html as html_module
29
+ import re
30
+ from dataclasses import dataclass, field
31
+ from datetime import UTC, date, datetime, timedelta
32
+
33
+ from layover.models import Leg, Offer, POSContext, Protection, Segment, TripShape
34
+ from layover.money import Money
35
+ from layover.oracles.base import OracleQuery
36
+
37
+ _ROW = re.compile(r'<li class="pIav2d"')
38
+ _ARIA = re.compile(r'aria-label="([^"]{4,400})"')
39
+ _NBSP = " "
40
+
41
+ _LEAVES = re.compile(
42
+ r"Leaves (?P<from>.+?) at (?P<dep>\d{1,2}:\d{2}\s?(?:AM|PM)) on "
43
+ r"(?P<dep_day>[A-Za-z]+, [A-Za-z]+ \d{1,2}) and arrives at (?P<to>.+?) at "
44
+ r"(?P<arr>\d{1,2}:\d{2}\s?(?:AM|PM)) on (?P<arr_day>[A-Za-z]+, [A-Za-z]+ \d{1,2})"
45
+ )
46
+ _PRICE = re.compile(r"^(?P<amount>[\d,]+) (?P<currency>[A-Za-z ]+?)s?$")
47
+ _DURATION = re.compile(r"Total duration (?P<h>\d+) hr(?: (?P<m>\d+) min)?")
48
+ _STOPS = re.compile(r"(?P<n>\d+) stop flight|Nonstop flight")
49
+ _LAYOVER = re.compile(
50
+ r"Layover \((?P<i>\d+) of (?P<n>\d+)\) is a (?:(?P<h>\d+) hr )?(?:(?P<m>\d+) min )?"
51
+ r"layover at (?P<airport>.+?)(?: in .+)?\."
52
+ )
53
+ _CODE_CELL = re.compile(r">([A-Z]{3})</(?:span|div)>")
54
+ # "1 stop in IST", "2 stops in WAW, AUH" — the row's own summary of where it stops,
55
+ # in order. Preferred over scraping three-letter cells, which also match airline
56
+ # codes: an early version routed a KLM itinerary "BUD-KLM-BKK".
57
+ _STOPS_IN = re.compile(r"\d+ stops? in ([A-Z]{3}(?:,\s*[A-Z]{3})*)")
58
+ # The row's own summary line, which states carrier and stop count in one place:
59
+ # "From 1065 US dollars round trip total. 1 stop flight with Emirates. Leaves …"
60
+ _SUMMARY = re.compile(
61
+ r"From (?P<amount>[\d,]+) (?P<currency>[A-Za-z ]+?) (?P<trip>round trip total|total)\. "
62
+ r"(?:(?P<stops>\d+) stop|Nonstop) flight with (?P<carrier>[^.]+)\."
63
+ )
64
+
65
+ CURRENCY_WORDS = {
66
+ "US dollar": "USD",
67
+ "euro": "EUR",
68
+ "British pound": "GBP",
69
+ "Hungarian forint": "HUF",
70
+ "Turkish lira": "TRY",
71
+ "Thai baht": "THB",
72
+ "Indian rupee": "INR",
73
+ "Swiss franc": "CHF",
74
+ "Canadian dollar": "CAD",
75
+ "Australian dollar": "AUD",
76
+ }
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class ParsedRow:
81
+ """Exactly what one results row states — no more, and nothing inferred."""
82
+
83
+ price: float | None = None
84
+ currency: str | None = None
85
+ carrier: str = ""
86
+ depart_at: datetime | None = None
87
+ arrive_at: datetime | None = None
88
+ total_minutes: int | None = None
89
+ stops: int | None = None
90
+ layovers: list[tuple[str, int]] = field(default_factory=list)
91
+ """(airport code or name, minutes) in order."""
92
+ airport_codes: list[str] = field(default_factory=list)
93
+ stop_codes: list[str] = field(default_factory=list)
94
+ """Intermediate airports, in order, as the row itself lists them."""
95
+ round_trip_total: bool = False
96
+ """Google prices a round trip against its outbound half; the row says which."""
97
+ labels: list[str] = field(default_factory=list)
98
+
99
+ @property
100
+ def usable(self) -> bool:
101
+ return None not in (self.price, self.depart_at, self.arrive_at)
102
+
103
+
104
+ def _clean(text: str) -> str:
105
+ return html_module.unescape(text).replace(_NBSP, " ").replace("\xa0", " ").strip()
106
+
107
+
108
+ def _parse_clock(value: str, day_text: str, year: int) -> datetime | None:
109
+ """ "8:35 PM" + "Sunday, October 4" -> a datetime, given the search year.
110
+
111
+ Google omits the year, so it comes from the query. A December search that lands
112
+ in January would be off by a year, which is why the caller passes the departure
113
+ date rather than "now".
114
+
115
+ **On timezones.** These are *local* clock times at each airport, and mapping them
116
+ to instants needs an airport timezone table (OurAirports, DESIGN §13 — M1 work).
117
+ Until then the local clock is preserved and labelled UTC, and arrival instants are
118
+ derived from the row's stated total duration rather than from clock arithmetic —
119
+ so elapsed time, layover lengths and departure-hour effects are all correct, and
120
+ only the arrival's *local* hour is unrepresented. Every offer says so.
121
+ """
122
+ try:
123
+ month_day = day_text.split(",", 1)[1].strip()
124
+ stamp = datetime.strptime(f"{month_day} {year} {value}", "%B %d %Y %I:%M %p")
125
+ except (ValueError, IndexError):
126
+ return None
127
+ return stamp.replace(tzinfo=UTC)
128
+
129
+
130
+ def parse_rows(html: str, *, year: int) -> list[ParsedRow]:
131
+ """Split the page into rows and read each one's labels."""
132
+ starts = [m.start() for m in _ROW.finditer(html)]
133
+ rows: list[ParsedRow] = []
134
+ for index, start in enumerate(starts):
135
+ end = starts[index + 1] if index + 1 < len(starts) else min(len(html), start + 20000)
136
+ rows.append(_parse_row(html[start:end], year=year))
137
+ return rows
138
+
139
+
140
+ def _parse_row(chunk: str, *, year: int) -> ParsedRow:
141
+ row = ParsedRow()
142
+ row.labels = [_clean(label) for label in _ARIA.findall(chunk)]
143
+
144
+ for label in row.labels:
145
+ if match := _SUMMARY.search(label):
146
+ # Preferred: one label states price, stop count and carrier together.
147
+ row.price = float(match["amount"].replace(",", ""))
148
+ word = match["currency"].strip()
149
+ row.currency = CURRENCY_WORDS.get(word, CURRENCY_WORDS.get(word.rstrip("s"), word))
150
+ row.stops = int(match["stops"]) if match["stops"] else 0
151
+ row.carrier = _clean(match["carrier"])
152
+ row.round_trip_total = match["trip"] == "round trip total"
153
+ if (match := _LEAVES.search(label)) and row.depart_at is None:
154
+ row.depart_at = _parse_clock(match["dep"], match["dep_day"], year)
155
+ row.arrive_at = _parse_clock(match["arr"], match["arr_day"], year)
156
+ elif (match := _PRICE.match(label)) and row.price is None:
157
+ word = match["currency"].strip()
158
+ row.price = float(match["amount"].replace(",", ""))
159
+ row.currency = CURRENCY_WORDS.get(word, CURRENCY_WORDS.get(word.rstrip("s"), word))
160
+ elif match := _DURATION.search(label):
161
+ row.total_minutes = int(match["h"]) * 60 + int(match["m"] or 0)
162
+ elif match := _STOPS.search(label):
163
+ row.stops = int(match["n"]) if match["n"] else 0
164
+ elif match := _LAYOVER.search(label):
165
+ minutes = int(match["h"] or 0) * 60 + int(match["m"] or 0)
166
+ row.layovers.append((_clean(match["airport"]), minutes))
167
+
168
+ stops_in = _STOPS_IN.search(chunk)
169
+ if stops_in:
170
+ row.stop_codes = [code.strip() for code in stops_in.group(1).split(",")]
171
+ row.airport_codes = list(dict.fromkeys(_CODE_CELL.findall(chunk)))
172
+ if not row.carrier:
173
+ fallback = re.search(
174
+ r">([A-Z][A-Za-z\' ]{3,30}(?:Airlines|Airways|Air|Jet|KLM))<", chunk
175
+ )
176
+ if fallback:
177
+ row.carrier = _clean(fallback.group(1))
178
+ return row
179
+
180
+
181
+ # ── rows to offers ───────────────────────────────────────────────────────────
182
+
183
+
184
+ def _airport_code(name_or_code: str, known: list[str]) -> str:
185
+ """Map "Istanbul Airport" to IST when the row's code cells make it obvious."""
186
+ if len(name_or_code) == 3 and name_or_code.isupper():
187
+ return name_or_code
188
+ for code in known:
189
+ if code[0] == name_or_code[0].upper():
190
+ return code
191
+ return name_or_code[:3].upper()
192
+
193
+
194
+ def _segments(row: ParsedRow, query: OracleQuery) -> list[Segment]:
195
+ """Build the segment chain, exact where the row is exact.
196
+
197
+ What the row states exactly: the departure clock time, the total duration, and
198
+ each layover's airport and length. What it does not state: per-segment times. So
199
+ flying time (total minus layovers) is split evenly between segments — an
200
+ approximation that moves nothing the connection logic depends on, since the gaps
201
+ themselves are exact. Offers built this way carry a note saying so.
202
+ """
203
+ if row.depart_at is None:
204
+ return []
205
+
206
+ total = row.total_minutes
207
+ if total is None and row.arrive_at is not None:
208
+ total = int((row.arrive_at - row.depart_at).total_seconds() / 60)
209
+ if not total or total <= 0:
210
+ return []
211
+
212
+ codes = row.stop_codes or [
213
+ code
214
+ for code in row.airport_codes
215
+ if code not in {query.origin.upper(), query.destination.upper()}
216
+ ]
217
+ # The stop list is authoritative when it disagrees with the summary label.
218
+ stops = len(codes) if codes else (row.stops if row.stops is not None else 0)
219
+ depart = row.depart_at
220
+
221
+ if stops == 0 or not row.layovers:
222
+ return [
223
+ Segment(
224
+ carrier=row.carrier or "??",
225
+ flight_number="",
226
+ origin=query.origin,
227
+ destination=query.destination,
228
+ depart=depart,
229
+ arrive=depart + timedelta(minutes=total),
230
+ cabin=query.cabin,
231
+ )
232
+ ]
233
+
234
+ layover_total = sum(minutes for _, minutes in row.layovers)
235
+ flying = max(30.0, total - layover_total)
236
+ per_segment = flying / (stops + 1)
237
+
238
+ points = [query.origin]
239
+ for index in range(stops):
240
+ if index < len(codes):
241
+ points.append(codes[index])
242
+ elif index < len(row.layovers):
243
+ points.append(_airport_code(row.layovers[index][0], codes))
244
+ else: # pragma: no cover — a stop with neither a code nor a label
245
+ points.append("???")
246
+ points.append(query.destination)
247
+
248
+ layover_minutes = [minutes for _, minutes in row.layovers]
249
+ segments: list[Segment] = []
250
+ cursor = depart
251
+ for index in range(len(points) - 1):
252
+ arrive = cursor + timedelta(minutes=per_segment)
253
+ segments.append(
254
+ Segment(
255
+ carrier=row.carrier or "??",
256
+ flight_number="",
257
+ origin=points[index],
258
+ destination=points[index + 1],
259
+ depart=cursor,
260
+ arrive=arrive,
261
+ cabin=query.cabin,
262
+ )
263
+ )
264
+ if index < len(layover_minutes):
265
+ cursor = arrive + timedelta(minutes=layover_minutes[index])
266
+ else:
267
+ cursor = arrive
268
+ return segments
269
+
270
+
271
+ def row_to_offer(
272
+ row: ParsedRow,
273
+ query: OracleQuery,
274
+ *,
275
+ oracle_id: str = "google_flights",
276
+ pos: POSContext | None = None,
277
+ ) -> Offer | None:
278
+ if not row.usable:
279
+ return None
280
+ segments = _segments(row, query)
281
+ if not segments:
282
+ return None
283
+
284
+ pos = pos or query.pos
285
+ notes = [
286
+ "google-flights row: flight numbers not shown at row level",
287
+ "local clock times labelled UTC; arrival derived from stated duration "
288
+ "(needs an airport timezone table)",
289
+ ]
290
+ if len(segments) > 1:
291
+ notes.append("segment times interpolated; layover durations are exact")
292
+ if row.round_trip_total or query.trip_shape is TripShape.RETURN:
293
+ notes.append("round-trip total priced against this outbound; inbound not yet chosen")
294
+ if row.currency and pos.currency and row.currency != pos.currency:
295
+ notes.append(f"row currency {row.currency} differs from requested {pos.currency}")
296
+
297
+ return Offer(
298
+ legs=[Leg(segments=segments)],
299
+ price=Money(amount=row.price, currency=row.currency or pos.currency),
300
+ protection=Protection.SINGLE_TICKET,
301
+ pos=pos,
302
+ oracle_id=oracle_id,
303
+ observed_at=datetime.now(UTC),
304
+ fare_notes=notes,
305
+ )
306
+
307
+
308
+ def parse_page(
309
+ html: str,
310
+ query: OracleQuery,
311
+ *,
312
+ oracle_id: str = "google_flights",
313
+ pos: POSContext | None = None,
314
+ year: int | None = None,
315
+ ) -> list[Offer]:
316
+ """The adapter's entry point: a results page in, normalized offers out."""
317
+ search_year = year or (query.depart or date.today()).year
318
+ offers: list[Offer] = []
319
+ seen: set[tuple] = set()
320
+ for row in parse_rows(html, year=search_year):
321
+ offer = row_to_offer(row, query, oracle_id=oracle_id, pos=pos)
322
+ if offer is None:
323
+ continue
324
+ # Google lists the same itinerary twice — once under "best flights", once
325
+ # under "other flights". Two rows, one product: counting both would flatter
326
+ # every routing that appears in both sections.
327
+ #
328
+ # NOT `offer.identity_key` here: that keys on flight numbers, which a results
329
+ # row does not carry, so two different Turkish itineraries on the same date
330
+ # would collide. Clock times and price distinguish them; this is a
331
+ # within-page dedup, and cross-oracle identity still needs flight numbers
332
+ # from the details panel (verification, DESIGN §6).
333
+ segments = offer.segments
334
+ key = (
335
+ offer.price.amount,
336
+ tuple((s.carrier, s.origin, s.destination) for s in segments),
337
+ segments[0].depart,
338
+ segments[-1].arrive,
339
+ )
340
+ if key in seen:
341
+ continue
342
+ seen.add(key)
343
+ offers.append(offer)
344
+ return offers
@@ -0,0 +1,322 @@
1
+ """Google Flights via the `tfs` protobuf URL (ORACLES §4).
2
+
3
+ The M0 keystone: broad coverage, calendar pricing, and cheap enough to be the source
4
+ of the bounds the solver prunes with. It is also an undocumented internal encoding on
5
+ a consumer page, which is stated plainly in ORACLES §7 rather than glossed — and the
6
+ reason `google_flights_browser` exists as a peer rather than a someday.
7
+
8
+ **Status of this adapter.** Both halves are verified against real output. The encoder
9
+ produces a `tfs` Google parses; the parser is written against a recorded fixture
10
+ (`tests/fixtures/google_flights/`) and pinned by a golden test that runs offline.
11
+
12
+ One thing to know before trusting a result: **from an EU address every request lands
13
+ on `consent.google.com`**, which this adapter reports as `FailureKind.BLOCKED` with
14
+ what to do about it, rather than pretending to have searched. The fixture above was
15
+ captured through a non-EU exit — which also means it is a *US* point of sale, and the
16
+ offers say so. Getting home-market prices from a Hungarian IP needs a browser profile
17
+ that has answered the consent dialog (ORACLES §2); that is still the browser oracle's
18
+ job, and this adapter cannot do it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ import time
26
+ from datetime import date
27
+ from typing import Any
28
+
29
+ from layover.models import Cabin, Offer, POSContext, TripShape
30
+ from layover.oracles.api.gf_parse import parse_page
31
+ from layover.oracles.api.protobuf import Message
32
+ from layover.oracles.base import (
33
+ FailureKind,
34
+ Oracle,
35
+ OracleCapabilities,
36
+ OracleQuery,
37
+ QueryResult,
38
+ )
39
+ from layover.oracles.transport import HttpTransport, is_eu_egress
40
+
41
+ BASE_URL = "https://www.google.com/travel/flights"
42
+
43
+ # Enum values in the tfs schema. Not published anywhere; derived from the encoding
44
+ # and confirmed by Google accepting the result. Wrong values here produce a *valid*
45
+ # query for the wrong thing, which is why the tests pin them.
46
+ SEAT = {Cabin.ECONOMY: 1, Cabin.PREMIUM: 2, Cabin.BUSINESS: 3}
47
+ TRIP = {TripShape.RETURN: 1, TripShape.ONE_WAY: 2, TripShape.MULTI_CITY: 3}
48
+ PASSENGER_ADULT = 1
49
+
50
+ _FIELD_DATE = 2
51
+ _FIELD_FROM = 13
52
+ _FIELD_TO = 14
53
+ _FIELD_MAX_STOPS = 5
54
+ _FIELD_LEGS = 3
55
+ _FIELD_PASSENGERS = 8
56
+ _FIELD_SEAT = 9
57
+ _FIELD_TRIP = 19
58
+
59
+
60
+ def _airport(code: str) -> Message:
61
+ return Message().string(2, code.upper())
62
+
63
+
64
+ def _leg(origin: str, destination: str, day: date, max_stops: int | None) -> Message:
65
+ return (
66
+ Message()
67
+ .string(_FIELD_DATE, day.isoformat())
68
+ .varint(_FIELD_MAX_STOPS, max_stops)
69
+ .message(_FIELD_FROM, _airport(origin))
70
+ .message(_FIELD_TO, _airport(destination))
71
+ )
72
+
73
+
74
+ def build_tfs(query: OracleQuery) -> str:
75
+ """Encode a query as the base64url protobuf Google expects.
76
+
77
+ A return trip is two legs in one message — not two queries — which is what lets a
78
+ single request price a round trip the way the site does.
79
+ """
80
+ info = Message()
81
+ info.message(
82
+ _FIELD_LEGS, _leg(query.origin, query.destination, query.depart, query.max_stops)
83
+ )
84
+ if query.trip_shape is TripShape.RETURN:
85
+ if query.return_on is None: # pragma: no cover — OracleQuery validates this
86
+ raise ValueError("return query without a return date")
87
+ info.message(
88
+ _FIELD_LEGS,
89
+ _leg(query.destination, query.origin, query.return_on, query.max_stops),
90
+ )
91
+ for _ in range(query.adults):
92
+ info.varint(_FIELD_PASSENGERS, PASSENGER_ADULT)
93
+ info.varint(_FIELD_SEAT, SEAT.get(query.cabin, 1))
94
+ info.varint(_FIELD_TRIP, TRIP.get(query.trip_shape, 2))
95
+ return info.to_base64()
96
+
97
+
98
+ def build_url(query: OracleQuery) -> str:
99
+ """The full search URL, including the point-of-sale parameters (§9).
100
+
101
+ Three knobs, and measurement rather than assumption about what each does — the
102
+ POS layer's whole job is knowing which one actually moves a given site's prices:
103
+
104
+ * `curr` **does** the work. Ask in the currency you want to compare in, because
105
+ Google's own conversion and a later ECB conversion are not the same number, and
106
+ double-converting adds noise to every comparison.
107
+ * `gl` (the storefront market) **made no difference at all**: `gl=hu` and `gl=us`
108
+ returned byte-identical prices (198/234/250/266/393 EUR) for the same query from
109
+ the same exit, measured 2026-08-05. Recorded in ORACLES §4 so nobody spends
110
+ query budget probing it again.
111
+ * `hl` is **forced to English**, and is not a POS knob here. It only sets the UI
112
+ language, and the parser reads English accessibility labels — a Hungarian page
113
+ parses to zero offers, which is exactly the `empty_anomaly` that revealed this.
114
+ Language and market are separate things and are kept separate.
115
+ """
116
+ pos: POSContext = query.pos
117
+ market = (pos.site_market or pos.ip_country or "us").lower()
118
+ params = [
119
+ f"tfs={build_tfs(query)}",
120
+ f"curr={pos.currency}",
121
+ "hl=en",
122
+ f"gl={market}",
123
+ ]
124
+ return f"{BASE_URL}?{'&'.join(params)}"
125
+
126
+
127
+ # ── response handling ────────────────────────────────────────────────────────
128
+
129
+ _CONSENT_MARKERS = ("consent.google.com", "Before you continue", "consent.youtube.com")
130
+ _BLOCK_MARKERS = ("Our systems have detected unusual traffic", "/sorry/index")
131
+
132
+
133
+ def classify_response(status_code: int, url: str, body: str) -> FailureKind | None:
134
+ """Decide what a response actually is, before trying to parse it.
135
+
136
+ The distinction that matters: a consent wall and a bot wall are both "no results",
137
+ but the first is fixed by a browser profile that has answered the dialog and the
138
+ second means back off. Treating either as an empty market would be a lie the
139
+ health monitor then acts on.
140
+ """
141
+ if status_code == 429:
142
+ return FailureKind.RATE_LIMITED
143
+ if status_code in (403, 451):
144
+ return FailureKind.BLOCKED
145
+ if status_code >= 500:
146
+ return FailureKind.TIMEOUT
147
+ haystack = f"{url}\n{body[:4000]}"
148
+ if any(marker in haystack for marker in _CONSENT_MARKERS):
149
+ return FailureKind.BLOCKED
150
+ if any(marker in haystack for marker in _BLOCK_MARKERS):
151
+ return FailureKind.BLOCKED
152
+ return None
153
+
154
+
155
+ _CALLBACK = re.compile(r"AF_initDataCallback\(")
156
+
157
+
158
+ def _balanced_slice(text: str, start: int) -> str | None:
159
+ """The JSON value beginning at `start`, found by counting brackets.
160
+
161
+ A regex cannot do this: the payload contains nested arrays, quoted brackets and no
162
+ terminator of its own. Counting is boring and correct.
163
+ """
164
+ opener = text[start]
165
+ closer = {"[": "]", "{": "}"}.get(opener)
166
+ if closer is None:
167
+ return None
168
+ depth = 0
169
+ in_string = False
170
+ escaped = False
171
+ for index in range(start, len(text)):
172
+ char = text[index]
173
+ if in_string:
174
+ if escaped:
175
+ escaped = False
176
+ elif char == "\\":
177
+ escaped = True
178
+ elif char == '"':
179
+ in_string = False
180
+ continue
181
+ if char == '"':
182
+ in_string = True
183
+ elif char == opener:
184
+ depth += 1
185
+ elif char == closer:
186
+ depth -= 1
187
+ if depth == 0:
188
+ return text[start : index + 1]
189
+ return None
190
+
191
+
192
+ def extract_data_blobs(html: str) -> list[Any]:
193
+ """The page's embedded state, from its `AF_initDataCallback` calls.
194
+
195
+ Not used for itineraries — those are server-rendered into the rows and read from
196
+ their accessibility labels (`gf_parse`). Kept because the blobs carry the
197
+ currency, market and locale tables the POS layer will want (§9), and because the
198
+ first thing to check when the parser breaks is whether the page still ships them.
199
+ """
200
+ blobs: list[Any] = []
201
+ for match in _CALLBACK.finditer(html):
202
+ marker = html.find("data:", match.end())
203
+ if marker == -1:
204
+ continue
205
+ index = marker + len("data:")
206
+ while index < len(html) and html[index] in " \t\r\n":
207
+ index += 1
208
+ if index >= len(html):
209
+ continue
210
+ payload = _balanced_slice(html, index)
211
+ if payload is None:
212
+ continue
213
+ try:
214
+ blobs.append(json.loads(payload))
215
+ except json.JSONDecodeError:
216
+ continue
217
+ return blobs
218
+
219
+
220
+ def parse_offers(
221
+ html: str,
222
+ query: OracleQuery,
223
+ *,
224
+ oracle_id: str = "google_flights",
225
+ pos: POSContext | None = None,
226
+ ) -> list[Offer]:
227
+ """Turn a results page into normalized offers (see `gf_parse` for the how)."""
228
+ return parse_page(html, query, oracle_id=oracle_id, pos=pos)
229
+
230
+
231
+ class GoogleFlightsOracle(Oracle):
232
+ """The protobuf-URL adapter."""
233
+
234
+ id = "google_flights"
235
+ capabilities = OracleCapabilities(
236
+ trip_shapes=frozenset({TripShape.ONE_WAY, TripShape.RETURN}),
237
+ date_range_query=False,
238
+ nearby_airports=False,
239
+ baggage_filter=False,
240
+ pos_controls=frozenset({"market_param", "currency_param", "locale"}),
241
+ latency_est_s=2.0,
242
+ block_risk="low",
243
+ money_cost_per_query=0.0,
244
+ bookable="deep_link",
245
+ )
246
+
247
+ def __init__(
248
+ self,
249
+ *,
250
+ client: Any | None = None,
251
+ transport: HttpTransport | None = None,
252
+ timeout: float = 20.0,
253
+ ) -> None:
254
+ self._client = client
255
+ self._transport = transport
256
+ self._timeout = timeout
257
+
258
+ def _get(self, url: str) -> tuple[int, str, str]:
259
+ if self._client is not None: # test double
260
+ response = self._client.get(url, follow_redirects=True)
261
+ return response.status_code, str(response.url), response.text
262
+ transport = self._transport or HttpTransport(timeout=self._timeout)
263
+ response = transport.get(url)
264
+ return response.status_code, response.url, response.text
265
+
266
+ def search(self, query: OracleQuery) -> QueryResult:
267
+ if not self.capabilities.can_answer(query):
268
+ return QueryResult.failed(
269
+ query, self.id, FailureKind.UNSUPPORTED, f"cannot do {query.trip_shape}"
270
+ )
271
+
272
+ url = build_url(query)
273
+ started = time.monotonic()
274
+ try:
275
+ status, final_url, body = self._get(url)
276
+ except Exception as exc:
277
+ return QueryResult.failed(
278
+ query,
279
+ self.id,
280
+ FailureKind.TIMEOUT,
281
+ f"{type(exc).__name__}: {exc}",
282
+ latency_ms=(time.monotonic() - started) * 1000,
283
+ )
284
+ latency_ms = (time.monotonic() - started) * 1000
285
+
286
+ failure = classify_response(status, final_url, body)
287
+ if failure is not None:
288
+ detail = f"HTTP {status} at {final_url[:120]}"
289
+ if "consent" in final_url:
290
+ egress = self._transport.egress_country if self._transport else None
291
+ where = f" (egress: {egress})" if egress else ""
292
+ detail += f" — consent wall{where}. "
293
+ detail += (
294
+ "Expected from an EU exit: leave the EU (a VPN exit, or a "
295
+ "`pos.toml` proxy) or use a browser profile that has answered "
296
+ "the dialog (ORACLES §2, §6)."
297
+ if is_eu_egress(egress) is not False
298
+ else "Unexpected from a non-EU exit — the page may have changed."
299
+ )
300
+ return QueryResult.failed(query, self.id, failure, detail, latency_ms=latency_ms)
301
+
302
+ observed_pos = (
303
+ self._transport.pos_with_egress() if self._transport is not None else query.pos
304
+ )
305
+ offers = parse_offers(body, query, oracle_id=self.id, pos=observed_pos)
306
+ if not offers:
307
+ # Never "no flights": either the market is empty or we are broken, and on
308
+ # a route with any liquidity it is us.
309
+ return QueryResult.failed(
310
+ query,
311
+ self.id,
312
+ FailureKind.EMPTY_ANOMALY,
313
+ "no itineraries parsed from a 200 response",
314
+ latency_ms=latency_ms,
315
+ )
316
+ return QueryResult(
317
+ query=query,
318
+ oracle_id=self.id,
319
+ offers=offers,
320
+ latency_ms=latency_ms,
321
+ raw=body,
322
+ )