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,480 @@
1
+ """AZair: European LCC one-way combinatorics (ORACLES §4).
2
+
3
+ The oracle that answers the question Google structurally cannot. Google is
4
+ metasearch — it displays fares — so its storefront parameter changed prices by 0.0%
5
+ across three continents. AZair sells to European buyers, quotes in EUR or HUF, and
6
+ composes its results out of **separate one-way tickets** from different carriers,
7
+ which is precisely the split-ticket structure this project exists to evaluate.
8
+
9
+ Three things make it a better citizen of our data model than Google:
10
+
11
+ * **Flight numbers.** Every leg carries one (`FR8859`), so `identity_key` is real and
12
+ cross-oracle matching becomes possible.
13
+ * **Ticket boundaries are explicit.** Each result's `tp` attribute carries JSON —
14
+ `{"tp":136,"d":[31.92,27.99],"b":[51.99,23.99]}` — where `d` and `b` are the
15
+ per-ticket prices out and back. Two entries means two bookings, and that is exactly
16
+ what the cost model needs to know: the airline owes you nothing between them, bags
17
+ are not through-checked, and the buffer rules apply (DESIGN §8.9).
18
+ * **Native date ranges.** One `flexi` query prices a whole window with a
19
+ min/max-nights constraint — the cheap bound generator the solver wants (§6),
20
+ instead of one request per date pair.
21
+
22
+ Read from the page's own structured attributes and stable class names rather than
23
+ layout: `tp` for money, `.result` for grouping, `.legPrice` / `.airline iata**` /
24
+ the flightradar link for each ticket.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import html as html_module
30
+ import json
31
+ import re
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from datetime import UTC, date, datetime, timedelta
35
+ from typing import Any
36
+ from urllib.parse import urlencode
37
+
38
+ from layover.models import Leg, Offer, POSContext, Protection, Segment, TripShape
39
+ from layover.money import Money
40
+ from layover.oracles.base import (
41
+ FailureKind,
42
+ Oracle,
43
+ OracleCapabilities,
44
+ OracleQuery,
45
+ QueryResult,
46
+ )
47
+ from layover.oracles.transport import HttpTransport
48
+
49
+ BASE_URL = "https://www.azair.eu/azfin.php"
50
+
51
+ # Markers that say "this *is* a results page", whatever it found. Their presence with
52
+ # zero rows is a real answer — the route is not in AZair's network — while their
53
+ # absence means we are looking at something we do not understand.
54
+ _RESULTS_SHELL = ("Soonest depart", "Latest return")
55
+
56
+ COVERAGE = "european low-cost network"
57
+ """What AZair actually knows about, measured 2026-08-05.
58
+
59
+ BUD-LIS returns 180 combinations; BUD-DXB and BUD-BKK return an empty results page
60
+ under the identical window and duration — including with the destination spelled as
61
+ the site's own autocomplete does. So the limit is coverage, not query shape: AZair
62
+ combines European LCC one-ways, and Bangkok is outside that network however the
63
+ question is phrased. The solver should not spend budget asking it about long-haul."""
64
+
65
+ _RESULT = re.compile(r'<div class="result ')
66
+ _TP = re.compile(r"tp='([^']+)'")
67
+ # "There" is `caption tam`, "Back" is `caption sem` — same element, different class.
68
+ _DIRECTION = re.compile(r'class="caption (?:tam|sem)">(There|Back)</span>')
69
+ _DATE = re.compile(r'class="date">\w{2,3} (\d{2})/(\d{2})/(\d{2})<')
70
+ _DURCHA = re.compile(r'class="durcha">(\d+):(\d+) h / (no change|\d+ changes?)<')
71
+ # Airport codes sometimes carry a nested tooltip span ("MXP<span
72
+ # class="aeroDetail">Milan (Malpensa) MXP</span>"), so the code group stops at the
73
+ # first three letters and anything nested is skipped rather than assumed absent.
74
+ _CODE = r'<span class="code">([A-Z]{3})(?:<span class="aeroDetail">[^<]*</span>)?</span>'
75
+ _TICKET = re.compile(
76
+ r'class="from"[^>]*>(?:\w{2}&nbsp;)?(\d{1,2}:\d{2})&nbsp;([^<]*?)&nbsp;'
77
+ + _CODE
78
+ + r"</span>\s*"
79
+ r'<span class="to"[^>]*>(\d{1,2}:\d{2})&nbsp;([^<]*?)&nbsp;'
80
+ + _CODE
81
+ + r'.*?flightradar24">([A-Z0-9]+)</a>'
82
+ r'.*?class="airline iata([A-Z0-9]{2})">([^<]+)</span>'
83
+ r'.*?class="legPrice">([^\d]*)([\d.,]+)</span>',
84
+ re.DOTALL,
85
+ )
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class Ticket:
90
+ """One separately-bookable flight, as AZair lists it."""
91
+
92
+ depart_time: str
93
+ origin: str
94
+ origin_city: str
95
+ arrive_time: str
96
+ destination: str
97
+ destination_city: str
98
+ flight_number: str
99
+ carrier: str
100
+ carrier_name: str
101
+ price: float
102
+
103
+
104
+ @dataclass(slots=True)
105
+ class Direction:
106
+ """One direction of a result: a date, a duration, and its tickets."""
107
+
108
+ label: str
109
+ day: date | None
110
+ minutes: int | None
111
+ changes: int
112
+ tickets: list[Ticket] = field(default_factory=list)
113
+
114
+
115
+ @dataclass(slots=True)
116
+ class AzairResult:
117
+ """A whole combination as offered: out and back, with per-ticket prices."""
118
+
119
+ result_id: str
120
+ total: float
121
+ out_prices: list[float]
122
+ back_prices: list[float]
123
+ directions: list[Direction] = field(default_factory=list)
124
+
125
+ @property
126
+ def ticket_count(self) -> int:
127
+ return len(self.out_prices) + len(self.back_prices)
128
+
129
+ @property
130
+ def is_split(self) -> bool:
131
+ """More than one ticket per direction means self-transfers inside a leg."""
132
+ return len(self.out_prices) > 1 or len(self.back_prices) > 1
133
+
134
+
135
+ def build_url(query: OracleQuery, *, currency: str | None = None) -> str:
136
+ """Compose a `flexi` search.
137
+
138
+ The window is the point: `depdate`..`arrdate` with `minDaysStay`/`maxDaysStay`
139
+ prices every date pair in range in one request. Sending a single date would throw
140
+ away the capability that makes this oracle cheap.
141
+ """
142
+ depart_from = query.depart
143
+ depart_to = query.depart_until or query.return_on or query.depart
144
+ is_return = query.trip_shape is TripShape.RETURN
145
+
146
+ params: dict[str, Any] = {
147
+ "searchtype": "flexi",
148
+ "tp": "0",
149
+ "isOneway": "return" if is_return else "oneway",
150
+ "srcAirport": f"{query.origin} [{query.origin}]",
151
+ "srcTypedText": query.origin.lower(),
152
+ "srcFreeTypedText": "",
153
+ "srcMC": "",
154
+ "dstAirport": f"{query.destination} [{query.destination}]",
155
+ "dstTypedText": query.destination.lower(),
156
+ "dstFreeTypedText": "",
157
+ "dstMC": "",
158
+ "depdate": depart_from.isoformat(),
159
+ "arrdate": (query.return_until or depart_to).isoformat(),
160
+ "aid": "0",
161
+ "minDaysStay": str(query.min_nights if query.min_nights is not None else 0),
162
+ "maxDaysStay": str(query.max_nights if query.max_nights is not None else 30),
163
+ "samedep": "true",
164
+ "samearr": "true",
165
+ # Their own minimum connection time. Left at the site default: our buffer
166
+ # rules (§8.9) are stricter and applied afterwards, so filtering here would
167
+ # only hide options we might have wanted to see rejected for a stated reason.
168
+ "minHourStay": "0:45",
169
+ "maxHourStay": "23:20",
170
+ "minHourOutbound": "0:00",
171
+ "maxHourOutbound": "24:00",
172
+ "minHourInbound": "0:00",
173
+ "maxHourInbound": "24:00",
174
+ "autoprice": "true",
175
+ "adults": str(query.adults),
176
+ "children": "0",
177
+ "infants": "0",
178
+ "maxChng": str(query.max_stops if query.max_stops is not None else 1),
179
+ "currency": (currency or query.pos.currency or "EUR").upper(),
180
+ "lang": "en",
181
+ }
182
+ for index in range(7): # every weekday allowed unless the intent says otherwise
183
+ params[f"dep{index}"] = "true"
184
+ params[f"arr{index}"] = "true"
185
+ return f"{BASE_URL}?{urlencode(params)}"
186
+
187
+
188
+ # ── parsing ──────────────────────────────────────────────────────────────────
189
+
190
+
191
+ def _clean(text: str) -> str:
192
+ return html_module.unescape(text).replace("\xa0", " ").strip()
193
+
194
+
195
+ def parse_results(html: str) -> list[AzairResult]:
196
+ """Split the page into results and read each one's money and tickets."""
197
+ starts = [m.start() for m in _RESULT.finditer(html)]
198
+ results: list[AzairResult] = []
199
+ for index, start in enumerate(starts):
200
+ end = starts[index + 1] if index + 1 < len(starts) else len(html)
201
+ parsed = _parse_result(html[start:end])
202
+ if parsed is not None:
203
+ results.append(parsed)
204
+ return results
205
+
206
+
207
+ def _parse_result(chunk: str) -> AzairResult | None:
208
+ money = _TP.search(chunk)
209
+ if not money:
210
+ return None
211
+ try:
212
+ payload = json.loads(_clean(money.group(1)))
213
+ except json.JSONDecodeError:
214
+ return None
215
+
216
+ result_id = (re.search(r'id="([^"]+)"', chunk) or [None, ""])[1]
217
+ result = AzairResult(
218
+ result_id=result_id,
219
+ total=float(payload.get("tp", 0)),
220
+ out_prices=[float(x) for x in payload.get("d", [])],
221
+ back_prices=[float(x) for x in payload.get("b", [])],
222
+ )
223
+
224
+ # The block is a sequence of "There"/"Back" sections; slice on the captions.
225
+ marks = [(m.start(), m.group(1)) for m in _DIRECTION.finditer(chunk)]
226
+ for index, (start, label) in enumerate(marks):
227
+ end = marks[index + 1][0] if index + 1 < len(marks) else len(chunk)
228
+ result.directions.append(_parse_direction(chunk[start:end], label))
229
+ return result
230
+
231
+
232
+ def _parse_direction(chunk: str, label: str) -> Direction:
233
+ day = None
234
+ if match := _DATE.search(chunk):
235
+ d, m, y = (int(x) for x in match.groups())
236
+ day = date(2000 + y, m, d)
237
+
238
+ minutes = changes = None
239
+ if match := _DURCHA.search(chunk):
240
+ minutes = int(match.group(1)) * 60 + int(match.group(2))
241
+ changes = 0 if match.group(3) == "no change" else int(match.group(3).split()[0])
242
+
243
+ direction = Direction(label=label, day=day, minutes=minutes, changes=changes or 0)
244
+ for match in _TICKET.finditer(chunk):
245
+ (
246
+ dep_time,
247
+ dep_city,
248
+ dep_code,
249
+ arr_time,
250
+ arr_city,
251
+ arr_code,
252
+ flight,
253
+ carrier,
254
+ carrier_name,
255
+ _symbol,
256
+ price,
257
+ ) = match.groups()
258
+ direction.tickets.append(
259
+ Ticket(
260
+ depart_time=dep_time,
261
+ origin=dep_code,
262
+ origin_city=_clean(dep_city),
263
+ arrive_time=arr_time,
264
+ destination=arr_code,
265
+ destination_city=_clean(arr_city),
266
+ flight_number=flight,
267
+ carrier=carrier,
268
+ carrier_name=_clean(carrier_name),
269
+ price=float(price.replace(",", "")),
270
+ )
271
+ )
272
+ return direction
273
+
274
+
275
+ # ── results to offers ────────────────────────────────────────────────────────
276
+
277
+
278
+ def _at(day: date, clock: str, *, after: datetime | None = None) -> datetime:
279
+ """A local clock time on a day, rolling past midnight when the clock goes back.
280
+
281
+ Times are local at each airport and labelled UTC, as with Google: mapping them to
282
+ instants needs an airport timezone table (M1). Same-airport gaps — the layovers
283
+ the connection rules actually use — are exact regardless, since both ends share a
284
+ timezone.
285
+ """
286
+ hour, minute = (int(part) for part in clock.split(":"))
287
+ stamp = datetime(day.year, day.month, day.day, hour, minute, tzinfo=UTC)
288
+ if after is not None and stamp < after:
289
+ stamp += timedelta(days=1)
290
+ return stamp
291
+
292
+
293
+ def result_to_offers(
294
+ result: AzairResult,
295
+ query: OracleQuery,
296
+ *,
297
+ oracle_id: str = "azair",
298
+ pos: POSContext | None = None,
299
+ ) -> list[Offer]:
300
+ """One `Offer` per separately-bookable ticket.
301
+
302
+ This is the honest shape: AZair combines one-ways, each with its own price and its
303
+ own booking link, so each is its own product. The composer then sees the
304
+ connections *between* them as what they are — separate tickets, nobody's problem
305
+ but yours — and prices the exposure without anyone having to remember to.
306
+ """
307
+ pos = pos or POSContext(currency=query.pos.currency)
308
+ offers: list[Offer] = []
309
+
310
+ for direction in result.directions:
311
+ if direction.day is None or not direction.tickets:
312
+ continue
313
+ cursor: datetime | None = None
314
+ for position, ticket in enumerate(direction.tickets):
315
+ depart = _at(direction.day, ticket.depart_time, after=cursor)
316
+ arrive = _at(direction.day, ticket.arrive_time, after=depart)
317
+ cursor = arrive
318
+ segment = Segment(
319
+ carrier=ticket.carrier,
320
+ flight_number=ticket.flight_number.removeprefix(ticket.carrier),
321
+ origin=ticket.origin,
322
+ destination=ticket.destination,
323
+ depart=depart,
324
+ arrive=arrive,
325
+ cabin=query.cabin,
326
+ )
327
+ notes = [
328
+ f"azair {result.result_id}: {direction.label.lower()} leg, "
329
+ f"ticket {position + 1} of {len(direction.tickets)}",
330
+ f"{ticket.carrier_name} — separately booked, priced per ticket",
331
+ "local clock times labelled UTC (needs an airport timezone table)",
332
+ ]
333
+ if result.is_split:
334
+ notes.append(
335
+ "part of a multi-ticket combination: no through-checked bags, "
336
+ "and no airline owes you the connection"
337
+ )
338
+ offers.append(
339
+ Offer(
340
+ legs=[Leg(segments=[segment])],
341
+ price=Money(amount=ticket.price, currency=pos.currency),
342
+ protection=Protection.SINGLE_TICKET, # each ticket, on its own
343
+ pos=pos,
344
+ oracle_id=oracle_id,
345
+ observed_at=datetime.now(UTC),
346
+ fare_notes=notes,
347
+ )
348
+ )
349
+ return offers
350
+
351
+
352
+ def parse_combinations(
353
+ html: str,
354
+ query: OracleQuery,
355
+ *,
356
+ oracle_id: str = "azair",
357
+ pos: POSContext | None = None,
358
+ ) -> list[tuple[AzairResult, list[Offer]]]:
359
+ """Results with their tickets kept together.
360
+
361
+ The solver will compose plans itself (M4), but AZair has already done the work of
362
+ finding combinations that no single airline sells — so until then its grouping is
363
+ worth keeping rather than flattening and re-deriving.
364
+ """
365
+ out: list[tuple[AzairResult, list[Offer]]] = []
366
+ for result in parse_results(html):
367
+ offers = result_to_offers(result, query, oracle_id=oracle_id, pos=pos)
368
+ if offers:
369
+ out.append((result, offers))
370
+ return out
371
+
372
+
373
+ def parse_page(
374
+ html: str,
375
+ query: OracleQuery,
376
+ *,
377
+ oracle_id: str = "azair",
378
+ pos: POSContext | None = None,
379
+ ) -> list[Offer]:
380
+ offers: list[Offer] = []
381
+ for _result, group in parse_combinations(html, query, oracle_id=oracle_id, pos=pos):
382
+ offers.extend(group)
383
+ return offers
384
+
385
+
386
+ class AzairOracle(Oracle):
387
+ """The European LCC combinator."""
388
+
389
+ id = "azair"
390
+ capabilities = OracleCapabilities(
391
+ trip_shapes=frozenset({TripShape.ONE_WAY, TripShape.RETURN}),
392
+ date_range_query=True,
393
+ composes_one_ways=True,
394
+ nearby_airports=True,
395
+ virtual_interline=True,
396
+ baggage_filter=False,
397
+ pos_controls=frozenset({"currency_param"}),
398
+ latency_est_s=3.0,
399
+ coverage=COVERAGE,
400
+ block_risk="low",
401
+ money_cost_per_query=0.0,
402
+ bookable="deep_link",
403
+ )
404
+
405
+ def __init__(
406
+ self, *, transport: HttpTransport | None = None, timeout: float = 60.0
407
+ ) -> None:
408
+ self._transport = transport
409
+ self._timeout = timeout
410
+
411
+ def search(self, query: OracleQuery) -> QueryResult:
412
+ if not self.capabilities.can_answer(query):
413
+ return QueryResult.failed(
414
+ query, self.id, FailureKind.UNSUPPORTED, f"cannot do {query.trip_shape}"
415
+ )
416
+
417
+ transport = self._transport or HttpTransport(pos=query.pos, timeout=self._timeout)
418
+ url = build_url(query)
419
+ started = time.monotonic()
420
+ try:
421
+ response = transport.get(url, headers={"Referer": "https://www.azair.eu/"})
422
+ except Exception as exc:
423
+ return QueryResult.failed(
424
+ query,
425
+ self.id,
426
+ FailureKind.TIMEOUT,
427
+ f"{type(exc).__name__}: {exc}",
428
+ latency_ms=(time.monotonic() - started) * 1000,
429
+ )
430
+
431
+ if response.status_code == 429:
432
+ return QueryResult.failed(query, self.id, FailureKind.RATE_LIMITED, "HTTP 429")
433
+ if response.status_code >= 400:
434
+ return QueryResult.failed(
435
+ query, self.id, FailureKind.BLOCKED, f"HTTP {response.status_code}"
436
+ )
437
+
438
+ offers = parse_page(
439
+ response.text, query, oracle_id=self.id, pos=transport.pos_with_egress()
440
+ )
441
+ if not offers:
442
+ # Two very different "no offers", and conflating them would have the
443
+ # health monitor cooling an oracle that answered correctly.
444
+ looks_like_results = all(mark in response.text for mark in _RESULTS_SHELL)
445
+ if looks_like_results:
446
+ return QueryResult.failed(
447
+ query,
448
+ self.id,
449
+ FailureKind.EMPTY_MARKET,
450
+ f"AZair returned a results page with nothing on it: "
451
+ f"{query.origin}-{query.destination} is outside its coverage "
452
+ f"({COVERAGE}) or has no combination in this window",
453
+ latency_ms=response.latency_ms,
454
+ )
455
+ return QueryResult.failed(
456
+ query,
457
+ self.id,
458
+ FailureKind.EMPTY_ANOMALY,
459
+ f"no results shell in {len(response.text)} bytes — the page is not "
460
+ f"what this parser expects",
461
+ latency_ms=response.latency_ms,
462
+ )
463
+ return QueryResult(
464
+ query=query,
465
+ oracle_id=self.id,
466
+ offers=offers,
467
+ latency_ms=response.latency_ms,
468
+ raw=response.text,
469
+ )
470
+
471
+ def canary_query(self) -> OracleQuery:
472
+ today = date.today()
473
+ return OracleQuery(
474
+ origin="BUD",
475
+ destination="LON",
476
+ depart=today + timedelta(days=30),
477
+ depart_until=today + timedelta(days=37),
478
+ trip_shape=TripShape.ONE_WAY,
479
+ pos=POSContext(currency="EUR"),
480
+ )