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,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} )?(\d{1,2}:\d{2}) ([^<]*?) '
|
|
77
|
+
+ _CODE
|
|
78
|
+
+ r"</span>\s*"
|
|
79
|
+
r'<span class="to"[^>]*>(\d{1,2}:\d{2}) ([^<]*?) '
|
|
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
|
+
)
|