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.
- layover/__init__.py +3 -0
- layover/cli.py +1137 -0
- layover/cost/__init__.py +1 -0
- layover/cost/borders.py +449 -0
- layover/cost/connection.py +173 -0
- layover/cost/constraints.py +213 -0
- layover/cost/curve.py +145 -0
- layover/cost/delay.py +173 -0
- layover/cost/generalized.py +1207 -0
- layover/cost/lounge.py +110 -0
- layover/currency.py +166 -0
- layover/intent.py +1016 -0
- layover/interact/__init__.py +1 -0
- layover/interact/assumptions.py +120 -0
- layover/models.py +363 -0
- layover/money.py +112 -0
- layover/oracles/__init__.py +1 -0
- layover/oracles/api/__init__.py +1 -0
- layover/oracles/api/azair.py +480 -0
- layover/oracles/api/gf_parse.py +344 -0
- layover/oracles/api/google_flights.py +322 -0
- layover/oracles/api/protobuf.py +110 -0
- layover/oracles/api/serpapi_flights.py +415 -0
- layover/oracles/base.py +224 -0
- layover/oracles/consent.py +199 -0
- layover/oracles/knowledge.py +208 -0
- layover/oracles/record.py +98 -0
- layover/oracles/transport.py +429 -0
- layover/prefs/__init__.py +1 -0
- layover/prefs/profile.py +333 -0
- layover/prefs/travelers.py +125 -0
- layover/session.py +328 -0
- layover/solver/__init__.py +1 -0
- layover/solver/budget.py +208 -0
- layover/solver/probes.py +105 -0
- layover/solver/search.py +166 -0
- layover-0.1.0.dev0.dist-info/METADATA +365 -0
- layover-0.1.0.dev0.dist-info/RECORD +41 -0
- layover-0.1.0.dev0.dist-info/WHEEL +4 -0
- layover-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- layover-0.1.0.dev0.dist-info/licenses/LICENSE +28 -0
|
@@ -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
|
+
)
|