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
layover/cli.py
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
"""The command line (DESIGN §11).
|
|
2
|
+
|
|
3
|
+
M0's slice: read an intent, ask one oracle, normalize, price every result under the
|
|
4
|
+
owner's own valuations, and print the ranking with the reason attached. No solver yet
|
|
5
|
+
— no split probes, no date sweep, no budget allocation — so this asks exactly one
|
|
6
|
+
question per run and says so.
|
|
7
|
+
|
|
8
|
+
What it will not do: print a number it cannot defend. Every row shows the money that
|
|
9
|
+
leaves the account beside the generalized cost that ordered it, plus what had to be
|
|
10
|
+
assumed to get there.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from functools import partial
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from layover.cost.delay import DelayTable
|
|
25
|
+
from layover.cost.generalized import (
|
|
26
|
+
CostKind,
|
|
27
|
+
PricingContext,
|
|
28
|
+
StopoverRequest,
|
|
29
|
+
align,
|
|
30
|
+
)
|
|
31
|
+
from layover.currency import RateCache, default_cache_path
|
|
32
|
+
from layover.intent import TravelIntent, load_intent
|
|
33
|
+
from layover.models import POSContext, TripPlan, TripShape
|
|
34
|
+
from layover.oracles.api.azair import AzairOracle, parse_combinations
|
|
35
|
+
from layover.oracles.api.google_flights import GoogleFlightsOracle
|
|
36
|
+
from layover.oracles.api.serpapi_flights import (
|
|
37
|
+
PriceInsight,
|
|
38
|
+
SerpApiGoogleFlights,
|
|
39
|
+
airport_countries,
|
|
40
|
+
)
|
|
41
|
+
from layover.oracles.base import OracleQuery
|
|
42
|
+
from layover.oracles.consent import CookieJar, NotAConsentCookie, parse_cookie
|
|
43
|
+
from layover.oracles.knowledge import OracleKnowledge
|
|
44
|
+
from layover.oracles.transport import HttpTransport, check_egress, is_eu_egress
|
|
45
|
+
from layover.prefs.profile import PreferenceProfile, load_preferences
|
|
46
|
+
from layover.prefs.travelers import Party, Traveler, load_travelers
|
|
47
|
+
from layover.session import default_session_root, gc, open_session
|
|
48
|
+
from layover.solver import search
|
|
49
|
+
from layover.solver.budget import QueryBudget, Question, Quote, route
|
|
50
|
+
|
|
51
|
+
REPO_CONFIG = Path(__file__).resolve().parents[2] / "config"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _config_paths() -> list[Path]:
|
|
55
|
+
return [
|
|
56
|
+
REPO_CONFIG / "preferences.toml",
|
|
57
|
+
Path.home() / ".config" / "layover" / "preferences.toml",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _party_for(intent: TravelIntent) -> Party:
|
|
62
|
+
"""Load the travellers named by the intent; fall back to a nameless adult.
|
|
63
|
+
|
|
64
|
+
A missing `travelers.toml` is normal on a first run — it means no passports and
|
|
65
|
+
no lounge quota are known, and the report will say so rather than inventing them.
|
|
66
|
+
"""
|
|
67
|
+
known = load_travelers(Path.home() / ".config" / "layover" / "travelers.toml")
|
|
68
|
+
travelers = [known.get(name, Traveler(id=name)) for name in intent.party]
|
|
69
|
+
return Party(travelers=travelers or [Traveler(id="owner")])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _pricing_context(
|
|
73
|
+
intent: TravelIntent, prefs: PreferenceProfile, party: Party
|
|
74
|
+
) -> PricingContext:
|
|
75
|
+
stopovers = {
|
|
76
|
+
place: StopoverRequest(
|
|
77
|
+
place=place,
|
|
78
|
+
min_nights=spec.min_nights,
|
|
79
|
+
max_nights=spec.max_nights,
|
|
80
|
+
ideal_nights=spec.ideal_nights,
|
|
81
|
+
cost_at_tolerance=spec.cost_at_tolerance,
|
|
82
|
+
tolerance_nights=spec.tolerance_nights,
|
|
83
|
+
net_at_ideal=spec.net_at_ideal,
|
|
84
|
+
)
|
|
85
|
+
for place, spec in intent.stopover_requests.items()
|
|
86
|
+
}
|
|
87
|
+
return PricingContext(
|
|
88
|
+
prefs=prefs,
|
|
89
|
+
party=party,
|
|
90
|
+
destinations=frozenset(place.code for place in intent.destinations),
|
|
91
|
+
constraints=intent.hard_constraints,
|
|
92
|
+
prefs_by_leg={
|
|
93
|
+
index: prefs.with_overrides(leg.preferences)
|
|
94
|
+
for index, leg in intent.legs.items()
|
|
95
|
+
if leg.preferences
|
|
96
|
+
},
|
|
97
|
+
baggage_profile=intent.baggage_profile,
|
|
98
|
+
stopovers=stopovers,
|
|
99
|
+
date_costs=intent.date_costs(),
|
|
100
|
+
journey=intent.stop_windows(),
|
|
101
|
+
duration_cost=intent.duration_cost,
|
|
102
|
+
stopover_length_scale=prefs.flexibility.length_cost_at_tolerance,
|
|
103
|
+
preferred_nights=intent.duration.preferred_nights,
|
|
104
|
+
delay_table=_delay_table(),
|
|
105
|
+
regimes=_regimes(),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _regimes():
|
|
110
|
+
"""Transit regimes, when the reference data is present (§8.6)."""
|
|
111
|
+
from layover.cost.borders import RegimeTable
|
|
112
|
+
|
|
113
|
+
root = REPO_CONFIG.parent / "reference"
|
|
114
|
+
regimes = root / "transit_regimes.toml"
|
|
115
|
+
authorizations = root / "authorizations.toml"
|
|
116
|
+
if regimes.is_file() and authorizations.is_file():
|
|
117
|
+
return RegimeTable.from_toml(regimes, authorizations)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _delay_table() -> DelayTable:
|
|
122
|
+
path = REPO_CONFIG.parent / "reference" / "delay_profiles.toml"
|
|
123
|
+
return DelayTable.from_toml(path) if path.is_file() else DelayTable.fallback()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _search(args: argparse.Namespace) -> int:
|
|
127
|
+
intent = load_intent(Path(args.intent))
|
|
128
|
+
prefs = load_preferences(_config_paths(), overrides=intent.preferences)
|
|
129
|
+
# Intents load before preferences do, so a window that did not price its own edges
|
|
130
|
+
# is told the owner's standing number now (§8.4).
|
|
131
|
+
intent = intent.with_flexibility_default(
|
|
132
|
+
prefs.flexibility.date_cost_at_tolerance,
|
|
133
|
+
prefs.flexibility.length_cost_at_tolerance,
|
|
134
|
+
)
|
|
135
|
+
party = _party_for(intent)
|
|
136
|
+
context = _pricing_context(intent, prefs, party)
|
|
137
|
+
rates = RateCache(default_cache_path()).get()
|
|
138
|
+
target = intent.normalize_currency
|
|
139
|
+
depart, back = intent.date_pairs()[0]
|
|
140
|
+
|
|
141
|
+
pos = POSContext(
|
|
142
|
+
currency=args.currency or target, locale=args.locale, site_market=args.market
|
|
143
|
+
)
|
|
144
|
+
probe = HttpTransport(pos=pos)
|
|
145
|
+
egress = probe.detect_egress_country()
|
|
146
|
+
if egress is None:
|
|
147
|
+
egress = probe.detect_egress_country() # one retry: these lookups are flaky
|
|
148
|
+
# serpapi fetches on our behalf, so our own exit does not label its prices.
|
|
149
|
+
refusal = check_egress(egress, override=args.allow_unknown_exit or args.oracle == "serpapi")
|
|
150
|
+
if refusal:
|
|
151
|
+
print(f"refusing: {refusal}")
|
|
152
|
+
return 3
|
|
153
|
+
if egress is None:
|
|
154
|
+
print("exit: unknown — recording anyway (--allow-unknown-exit)")
|
|
155
|
+
print(f"exit: {egress or 'unknown'}", end="")
|
|
156
|
+
print(f" oracle: {args.oracle} party: {len(party.travelers)}")
|
|
157
|
+
|
|
158
|
+
knowledge = OracleKnowledge()
|
|
159
|
+
if args.recheck_coverage:
|
|
160
|
+
knowledge.ttl_days = 0 # ask again about everything, however recently learned
|
|
161
|
+
|
|
162
|
+
with open_session(
|
|
163
|
+
Path(args.sessions or default_session_root()),
|
|
164
|
+
args.session or datetime.now().strftime("%Y%m%d-%H%M%S"),
|
|
165
|
+
intent={"file": str(args.intent)},
|
|
166
|
+
normalize_currency=target,
|
|
167
|
+
) as store:
|
|
168
|
+
budget = QueryBudget(
|
|
169
|
+
max_queries=intent.budget.max_queries_total,
|
|
170
|
+
max_money=args.max_money,
|
|
171
|
+
currency=target,
|
|
172
|
+
)
|
|
173
|
+
attempts, quotes = (
|
|
174
|
+
_choose_oracles(intent, pos, args, store, knowledge, budget)
|
|
175
|
+
if args.oracle == "auto"
|
|
176
|
+
else ([args.oracle], {})
|
|
177
|
+
)
|
|
178
|
+
# The oracles answer a wider question than the intent asked: AZair's flexi
|
|
179
|
+
# search has no "earliest return" parameter at all, and nothing lets us say
|
|
180
|
+
# "land before midnight". So the windows' hard edges are enforced here (§8.4).
|
|
181
|
+
keep = partial(within_windows, intent)
|
|
182
|
+
|
|
183
|
+
result = None
|
|
184
|
+
for index, name in enumerate(attempts):
|
|
185
|
+
if index:
|
|
186
|
+
print(f" falling back to {name}")
|
|
187
|
+
ask = _asker(
|
|
188
|
+
name, intent, pos, egress, store, rates, target, args, context, knowledge
|
|
189
|
+
)
|
|
190
|
+
result = search.run(
|
|
191
|
+
_baseline_query(intent, pos, args, depart, back),
|
|
192
|
+
ask,
|
|
193
|
+
context,
|
|
194
|
+
budget,
|
|
195
|
+
quotes.get(name) or _quote_for(name),
|
|
196
|
+
admissible=keep,
|
|
197
|
+
permits=_stay_permits(intent),
|
|
198
|
+
probe=args.probe and _probe_would_learn(name),
|
|
199
|
+
)
|
|
200
|
+
if result.ranked:
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
if result is None or not result.ranked:
|
|
204
|
+
print(" no oracle could answer — see the notes above")
|
|
205
|
+
return 2
|
|
206
|
+
|
|
207
|
+
for finding in result.findings:
|
|
208
|
+
spent = f"{finding.queries} quer{'y' if finding.queries == 1 else 'ies'}"
|
|
209
|
+
print(f" {finding.phase}: {spent} — {finding.note}")
|
|
210
|
+
for note in budget.notes:
|
|
211
|
+
print(f" note: {note}")
|
|
212
|
+
print(f" {result.improvement()} ({budget.describe()})")
|
|
213
|
+
|
|
214
|
+
_print_table(
|
|
215
|
+
result.ranked,
|
|
216
|
+
target=target,
|
|
217
|
+
rates_as_of=rates.as_of if rates else None,
|
|
218
|
+
rows=args.rows,
|
|
219
|
+
explain=args.explain,
|
|
220
|
+
)
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
ORACLES = {
|
|
225
|
+
"azair": AzairOracle,
|
|
226
|
+
"google": GoogleFlightsOracle,
|
|
227
|
+
"serpapi": SerpApiGoogleFlights,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _quote_for(name: str) -> Quote:
|
|
232
|
+
"""What one query to a named oracle costs when the router was not consulted.
|
|
233
|
+
|
|
234
|
+
`--oracle serpapi` skips routing, and treating a credit as free there would let a
|
|
235
|
+
probe spend money the budget was told not to spend."""
|
|
236
|
+
oracle = ORACLES.get(name)
|
|
237
|
+
money = oracle.capabilities.money_cost_per_query if oracle else 0.0
|
|
238
|
+
return Quote(name, 1, money, True, "single query")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _probe_would_learn(name: str) -> bool:
|
|
242
|
+
"""Whether return-vs-one-ways can tell this oracle anything it did not say.
|
|
243
|
+
|
|
244
|
+
AZair's baseline answer is *already* composed one-ways, so the probe would spend
|
|
245
|
+
two queries rediscovering the plans it just returned. A probe that cannot learn
|
|
246
|
+
anything is not cheap, it is wasted (DESIGN §6).
|
|
247
|
+
"""
|
|
248
|
+
oracle = ORACLES.get(name)
|
|
249
|
+
return not (oracle and oracle.capabilities.composes_one_ways)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _stay_permits(intent: TravelIntent):
|
|
253
|
+
"""The destination stop's length bounds, so a probe cannot invent a longer trip."""
|
|
254
|
+
stops = intent.journey
|
|
255
|
+
destination = stops[1] if len(stops) > 2 else None
|
|
256
|
+
return destination.permits if destination is not None else None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _baseline_query(intent, pos, args, depart, back) -> OracleQuery:
|
|
260
|
+
"""One query describing the whole question, windows included where they help."""
|
|
261
|
+
dates = intent.outbound.dates
|
|
262
|
+
inbound_dates = intent.inbound.dates if intent.inbound else None
|
|
263
|
+
return OracleQuery(
|
|
264
|
+
origin=intent.origins[0].code,
|
|
265
|
+
destination=intent.destinations[0].code,
|
|
266
|
+
depart=dates[0],
|
|
267
|
+
trip_shape=intent.trip_shape,
|
|
268
|
+
return_on=inbound_dates[0] if inbound_dates else None,
|
|
269
|
+
depart_until=dates[-1],
|
|
270
|
+
return_until=inbound_dates[-1] if inbound_dates else None,
|
|
271
|
+
min_nights=intent.duration.min_nights,
|
|
272
|
+
max_nights=intent.duration.max_nights,
|
|
273
|
+
adults=len(intent.party),
|
|
274
|
+
cabin=intent.cabin,
|
|
275
|
+
max_stops=args.max_stops,
|
|
276
|
+
pos=pos,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _asker(name, intent, pos, egress, store, rates, target, args, context, knowledge):
|
|
281
|
+
"""One callable per oracle: send this query, return what it offered.
|
|
282
|
+
|
|
283
|
+
The solver deals in questions, not in adapters — so the difference between a
|
|
284
|
+
windowed AZair request and a per-date SerpAPI one lives here and nowhere else.
|
|
285
|
+
"""
|
|
286
|
+
if name == "azair":
|
|
287
|
+
return partial(
|
|
288
|
+
_search_azair, intent, pos, egress, store, rates, target, args, knowledge
|
|
289
|
+
)
|
|
290
|
+
if name == "serpapi":
|
|
291
|
+
return partial(
|
|
292
|
+
_search_serpapi, intent, pos, store, rates, target, args, context, knowledge
|
|
293
|
+
)
|
|
294
|
+
return partial(_search_google, intent, pos, egress, store, rates, target, knowledge)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
COOLDOWN_MINUTES = {"blocked": 120.0, "captcha": 120.0, "rate_limited": 30.0}
|
|
298
|
+
COVERAGE_EVIDENCE_DAYS = 3
|
|
299
|
+
"""How wide a query must be before an empty answer says anything about coverage."""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _asks_about_a_window(query) -> bool:
|
|
303
|
+
span = (query.depart_until or query.depart) - query.depart
|
|
304
|
+
return span.days + 1 >= COVERAGE_EVIDENCE_DAYS
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _remember(knowledge: OracleKnowledge, result, route_name: str, query=None) -> None:
|
|
308
|
+
"""Write the durable consequences of one query to the notebook (DESIGN §5).
|
|
309
|
+
|
|
310
|
+
Three kinds of fact, and only three: this source cannot answer about this route,
|
|
311
|
+
this source is unhappy with us right now, this source is fine. None of them is a
|
|
312
|
+
price, so none of them is hoarding.
|
|
313
|
+
"""
|
|
314
|
+
kind = str(result.failure).split(".")[-1].lower() if result.failure else ""
|
|
315
|
+
if result.ok:
|
|
316
|
+
knowledge.record_coverage(result.oracle_id, route_name, covered=True)
|
|
317
|
+
knowledge.record_success(result.oracle_id)
|
|
318
|
+
elif kind == "empty_market":
|
|
319
|
+
# An answer, not a failure: the site looked and there is nothing it sells.
|
|
320
|
+
# But only a *wide* question is evidence about coverage. A single date with
|
|
321
|
+
# nothing on it means "not that Tuesday", and writing that down as "does not
|
|
322
|
+
# fly there" would poison every later session for a route the site serves —
|
|
323
|
+
# the exact failure the store is supposed to prevent, inverted.
|
|
324
|
+
if query is None or _asks_about_a_window(query):
|
|
325
|
+
knowledge.record_coverage(
|
|
326
|
+
result.oracle_id, route_name, covered=False, detail=result.detail[:200]
|
|
327
|
+
)
|
|
328
|
+
elif kind in COOLDOWN_MINUTES:
|
|
329
|
+
knowledge.record_failure(
|
|
330
|
+
result.oracle_id, kind, cooldown_minutes=COOLDOWN_MINUTES[kind]
|
|
331
|
+
)
|
|
332
|
+
elif kind:
|
|
333
|
+
knowledge.record_failure(result.oracle_id, kind)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _choose_oracles(intent, pos, args, store, knowledge: OracleKnowledge, budget):
|
|
337
|
+
"""Ask the cheapest oracle that can answer, and show the reckoning.
|
|
338
|
+
|
|
339
|
+
The comparison that matters is the cost of answering the *question*, not of one
|
|
340
|
+
query: AZair prices a whole window in one request, while the per-date oracles pay
|
|
341
|
+
for every date pair — which for a 5×5 window is the difference between one query
|
|
342
|
+
and twenty-five (DESIGN §6).
|
|
343
|
+
"""
|
|
344
|
+
depart, back = intent.date_pairs()[0]
|
|
345
|
+
question = Question(
|
|
346
|
+
query=OracleQuery(
|
|
347
|
+
origin=intent.origins[0].code,
|
|
348
|
+
destination=intent.destinations[0].code,
|
|
349
|
+
depart=depart,
|
|
350
|
+
trip_shape=intent.trip_shape,
|
|
351
|
+
return_on=back if intent.trip_shape is TripShape.RETURN else None,
|
|
352
|
+
adults=len(intent.party),
|
|
353
|
+
cabin=intent.cabin,
|
|
354
|
+
pos=pos,
|
|
355
|
+
),
|
|
356
|
+
date_pairs=len(intent.date_pairs()),
|
|
357
|
+
)
|
|
358
|
+
available = [AzairOracle(), GoogleFlightsOracle(), SerpApiGoogleFlights()]
|
|
359
|
+
# What this session already learned: an oracle that said "not covered" for this
|
|
360
|
+
# route has answered, and asking again would spend the budget on a known answer.
|
|
361
|
+
excluded = {
|
|
362
|
+
event["oracle_id"]: "answered 'not covered' earlier this session"
|
|
363
|
+
for event in store.health_events()
|
|
364
|
+
if event["kind"] == "empty_market" and question.route in (event["detail"] or "")
|
|
365
|
+
}
|
|
366
|
+
# ...and what *earlier* sessions learned. AZair told us it does not fly to Bangkok
|
|
367
|
+
# in August; a fresh session in September should not have to spend a query
|
|
368
|
+
# rediscovering that, and a source cooling off from a block is still cooling.
|
|
369
|
+
for oracle_id in knowledge.excluded_for(question.route):
|
|
370
|
+
fact = knowledge.coverage(oracle_id, question.route)
|
|
371
|
+
age = f", learned {fact.age_days()}d ago" if fact else ""
|
|
372
|
+
excluded[oracle_id] = f"does not cover {question.route}{age}"
|
|
373
|
+
for oracle in available:
|
|
374
|
+
until = knowledge.cooling(oracle.id)
|
|
375
|
+
if until and oracle.id not in excluded:
|
|
376
|
+
failure = knowledge.health(oracle.id).get("last_failure", "a failure")
|
|
377
|
+
excluded[oracle.id] = f"cooling until {until:%H:%M} after {failure}"
|
|
378
|
+
routing = route(available, question, budget, excluded=excluded)
|
|
379
|
+
|
|
380
|
+
print(
|
|
381
|
+
f"routing {question.route}, {question.date_pairs} date pair(s), "
|
|
382
|
+
f"budget {budget.max_queries} queries / {budget.max_money:,.2f} "
|
|
383
|
+
f"{budget.currency}:"
|
|
384
|
+
)
|
|
385
|
+
print(routing.explain(budget.currency))
|
|
386
|
+
for note in budget.notes:
|
|
387
|
+
print(f" note: {note}")
|
|
388
|
+
if routing.chosen is None:
|
|
389
|
+
print(" nothing affordable can answer this — raise --max-money or the budget")
|
|
390
|
+
return [], {}
|
|
391
|
+
names = {
|
|
392
|
+
"azair": "azair",
|
|
393
|
+
"google_flights": "google",
|
|
394
|
+
"serpapi_google_flights": "serpapi",
|
|
395
|
+
}
|
|
396
|
+
# The whole preference order, not just the winner: an oracle that comes back
|
|
397
|
+
# blocked has not answered the question, and the budget was drawn up for the
|
|
398
|
+
# question rather than for one attempt at it. The quotes travel with it, so a
|
|
399
|
+
# probe can be declined up front instead of abandoned halfway through.
|
|
400
|
+
order = [names[option.oracle_id] for option in routing.fallbacks]
|
|
401
|
+
quotes = {names[option.oracle_id]: option for option in routing.fallbacks}
|
|
402
|
+
return order, quotes
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def within_windows(intent: TravelIntent, plan: TripPlan) -> bool:
|
|
406
|
+
"""Does every moment the intent has a window for fall inside it?
|
|
407
|
+
|
|
408
|
+
Window bounds are hard by construction (§8.4): outside them the trip does not
|
|
409
|
+
exist. Dropping such a plan is not fussiness — pricing it would rank a date the
|
|
410
|
+
owner ruled out *above* the ones they asked for, since a moment outside a window
|
|
411
|
+
has no flexibility cost to pay and therefore looks free.
|
|
412
|
+
|
|
413
|
+
Checked at the planned stops (`align`), so a connection is never mistaken for a
|
|
414
|
+
departure the owner chose.
|
|
415
|
+
"""
|
|
416
|
+
journey = intent.journey
|
|
417
|
+
moments = align(plan, [stop.place for stop in journey])
|
|
418
|
+
for stop, actual in zip(journey, moments, strict=False):
|
|
419
|
+
for when, window in ((actual.arrive, stop.arrive), (actual.depart, stop.depart)):
|
|
420
|
+
if when is not None and window is not None and window.cost_of(when) is None:
|
|
421
|
+
return False
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _search_azair(intent, pos, egress, store, rates, target, args, knowledge, query):
|
|
426
|
+
"""One windowed query; AZair prices the whole date range at once.
|
|
427
|
+
|
|
428
|
+
Each direction is asked about its *own* window. Collapsing both into one span —
|
|
429
|
+
earliest outbound to latest return — is a wider question than the intent asked,
|
|
430
|
+
and the extra answers are not free: a return outside the inbound window is
|
|
431
|
+
infeasible, but it arrives priced like any other and outranks the real ones,
|
|
432
|
+
because a date the window never covered has no flexibility cost to pay (§8.4).
|
|
433
|
+
"""
|
|
434
|
+
transport = HttpTransport(pos=pos, egress_country=egress)
|
|
435
|
+
print(f"searching {query.describe()}")
|
|
436
|
+
result = AzairOracle(transport=transport).search(query)
|
|
437
|
+
record = store.record_query(
|
|
438
|
+
oracle_id=result.oracle_id,
|
|
439
|
+
dedup_key=query.dedup_key,
|
|
440
|
+
request={"query": query.describe()},
|
|
441
|
+
status=result.status,
|
|
442
|
+
failure_kind=str(result.failure) if result.failure else None,
|
|
443
|
+
latency_ms=result.latency_ms,
|
|
444
|
+
)
|
|
445
|
+
_remember(knowledge, result, f"{query.origin}-{query.destination}", query)
|
|
446
|
+
if not result.ok:
|
|
447
|
+
store.record_health(result.oracle_id, result.status, result.detail[:200])
|
|
448
|
+
print(f" {result.status}: {result.detail}")
|
|
449
|
+
return []
|
|
450
|
+
|
|
451
|
+
combinations = parse_combinations(result.raw, query, pos=transport.pos_with_egress())
|
|
452
|
+
plans: list[TripPlan] = []
|
|
453
|
+
for combination, offers in combinations:
|
|
454
|
+
for offer in offers:
|
|
455
|
+
offer.price_normalized = (
|
|
456
|
+
rates.try_convert(offer.price.amount, offer.price.currency, target)
|
|
457
|
+
if rates
|
|
458
|
+
else offer.price.amount
|
|
459
|
+
)
|
|
460
|
+
store.record_offers(record.id, offers)
|
|
461
|
+
plans.append(TripPlan(offers=offers, plan_id=combination.result_id))
|
|
462
|
+
print(
|
|
463
|
+
f" {len(combinations)} combinations, {sum(len(o) for _c, o in combinations)} "
|
|
464
|
+
f"tickets in {result.latency_ms:.0f}ms — one query covered the whole window"
|
|
465
|
+
)
|
|
466
|
+
return plans
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _search_serpapi(intent, pos, store, rates, target, args, context, knowledge, query):
|
|
470
|
+
"""Google's data, fetched by someone with a contract to do it.
|
|
471
|
+
|
|
472
|
+
No exit to worry about — they fetch, so the consent wall never appears — but every
|
|
473
|
+
query is a credit, which is why the cap is checked before the request and the
|
|
474
|
+
remaining allowance is printed after it.
|
|
475
|
+
"""
|
|
476
|
+
oracle = SerpApiGoogleFlights(monthly_cap=args.serpapi_cap)
|
|
477
|
+
print(
|
|
478
|
+
f"searching {query.describe()} ({oracle.remaining} of "
|
|
479
|
+
f"{args.serpapi_cap} credits left this month)"
|
|
480
|
+
)
|
|
481
|
+
result = oracle.search(query)
|
|
482
|
+
record = store.record_query(
|
|
483
|
+
oracle_id=result.oracle_id,
|
|
484
|
+
dedup_key=query.dedup_key,
|
|
485
|
+
request={"query": query.describe()},
|
|
486
|
+
status=result.status,
|
|
487
|
+
failure_kind=str(result.failure) if result.failure else None,
|
|
488
|
+
latency_ms=result.latency_ms,
|
|
489
|
+
)
|
|
490
|
+
_remember(knowledge, result, f"{query.origin}-{query.destination}", query)
|
|
491
|
+
if not result.ok:
|
|
492
|
+
store.record_health(result.oracle_id, result.status, result.detail[:200])
|
|
493
|
+
print(f" {result.status}: {result.detail}")
|
|
494
|
+
return []
|
|
495
|
+
for offer in result.offers:
|
|
496
|
+
offer.price_normalized = (
|
|
497
|
+
rates.try_convert(offer.price.amount, offer.price.currency, target)
|
|
498
|
+
if rates
|
|
499
|
+
else offer.price.amount
|
|
500
|
+
)
|
|
501
|
+
store.record_offers(record.id, result.offers)
|
|
502
|
+
print(
|
|
503
|
+
f" {len(result.offers)} offers in {result.latency_ms:.0f}ms, "
|
|
504
|
+
f"{oracle.remaining} credits left"
|
|
505
|
+
)
|
|
506
|
+
# The response names each airport's country, so border costs stop needing a
|
|
507
|
+
# hand-written mapping for the airports a search actually touches (§8.6).
|
|
508
|
+
countries = airport_countries(result.raw or {})
|
|
509
|
+
if countries:
|
|
510
|
+
context.transit_countries = {**countries, **context.transit_countries}
|
|
511
|
+
insight = PriceInsight.from_payload(result.raw or {})
|
|
512
|
+
if insight:
|
|
513
|
+
print(f" source price context: {insight.describe(target)}")
|
|
514
|
+
return [
|
|
515
|
+
TripPlan(offers=[offer], plan_id=f"s{index}")
|
|
516
|
+
for index, offer in enumerate(result.offers)
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _search_google(intent, pos, egress, store, rates, target, knowledge, query):
|
|
521
|
+
"""One date pair per query; Google is asked about a single itinerary shape."""
|
|
522
|
+
cookies, problems = CookieJar.load().usable_for("google.com", egress=egress)
|
|
523
|
+
for problem in problems:
|
|
524
|
+
print(f" consent cookie: {problem}")
|
|
525
|
+
transport = HttpTransport(pos=pos, cookies=cookies, egress_country=egress)
|
|
526
|
+
print(f"searching {query.describe()}")
|
|
527
|
+
result = GoogleFlightsOracle(transport=transport).search(query)
|
|
528
|
+
record = store.record_query(
|
|
529
|
+
oracle_id=result.oracle_id,
|
|
530
|
+
dedup_key=query.dedup_key,
|
|
531
|
+
request={"query": query.describe()},
|
|
532
|
+
status=result.status,
|
|
533
|
+
failure_kind=str(result.failure) if result.failure else None,
|
|
534
|
+
latency_ms=result.latency_ms,
|
|
535
|
+
)
|
|
536
|
+
_remember(knowledge, result, f"{query.origin}-{query.destination}", query)
|
|
537
|
+
if not result.ok:
|
|
538
|
+
store.record_health(result.oracle_id, result.status, result.detail[:200])
|
|
539
|
+
print(f" {result.status}: {result.detail}")
|
|
540
|
+
return []
|
|
541
|
+
for offer in result.offers:
|
|
542
|
+
offer.price_normalized = (
|
|
543
|
+
rates.try_convert(offer.price.amount, offer.price.currency, target)
|
|
544
|
+
if rates
|
|
545
|
+
else offer.price.amount
|
|
546
|
+
)
|
|
547
|
+
store.record_offers(record.id, result.offers)
|
|
548
|
+
print(f" {len(result.offers)} offers in {result.latency_ms:.0f}ms")
|
|
549
|
+
return [
|
|
550
|
+
TripPlan(offers=[offer], plan_id=f"g{index}")
|
|
551
|
+
for index, offer in enumerate(result.offers)
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _print_table(ranked, *, target: str, rates_as_of, rows: int, explain: int) -> None:
|
|
556
|
+
"""The ranking, with the arithmetic visible rather than trailing one row.
|
|
557
|
+
|
|
558
|
+
Each row shows what its generalized cost is made of, because a number you cannot
|
|
559
|
+
take apart is a number you have to trust — and the point of this project is not
|
|
560
|
+
having to.
|
|
561
|
+
"""
|
|
562
|
+
kinds = [
|
|
563
|
+
("fare", (CostKind.FARE, CostKind.ANCILLARY)),
|
|
564
|
+
("time", (CostKind.TRAVEL_TIME, CostKind.EXPECTED_DELAY)),
|
|
565
|
+
("wait", (CostKind.INVOLUNTARY_WAIT, CostKind.LOUNGE)),
|
|
566
|
+
("stop", (CostKind.STOPOVER,)),
|
|
567
|
+
("flex", (CostKind.DATE_FLEXIBILITY, CostKind.DURATION_DEVIATION)),
|
|
568
|
+
("brdr", (CostKind.BORDER, CostKind.GROUND_TRANSFER)),
|
|
569
|
+
("disc", (CostKind.DISCOMFORT,)),
|
|
570
|
+
("risk", (CostKind.RISK_PREMIUM,)),
|
|
571
|
+
]
|
|
572
|
+
header = f" {'#':>3} {'total':>7} {'money':>6}"
|
|
573
|
+
header += "".join(f"{name:>6}" for name, _ in kinds)
|
|
574
|
+
header += " tk route"
|
|
575
|
+
print("\n" + header)
|
|
576
|
+
print(" " + "─" * (len(header) + 12))
|
|
577
|
+
|
|
578
|
+
for index, (cost, plan) in enumerate(ranked[:rows], start=1):
|
|
579
|
+
segments = plan.segments
|
|
580
|
+
route = "-".join([segments[0].origin] + [s.destination for s in segments])
|
|
581
|
+
parts = "".join(
|
|
582
|
+
f"{sum(cost.by_kind(k).mid for k in group):>6.0f}" for _name, group in kinds
|
|
583
|
+
)
|
|
584
|
+
money = plan.money_out_of_pocket
|
|
585
|
+
flag = " " if cost.feasible else "!"
|
|
586
|
+
# How a plan was arrived at belongs on its row: two itineraries with the same
|
|
587
|
+
# fare are not the same offer when one of them is two separate bookings.
|
|
588
|
+
shape = f" [{plan.label}]" if plan.label else ""
|
|
589
|
+
print(
|
|
590
|
+
f" {index:>2}{flag} {cost.generalized_cost.mid:>7,.0f} "
|
|
591
|
+
f"{(money or 0):>6.0f}{parts} {len(plan.offers):>2} {route}{shape}"
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
print(
|
|
595
|
+
f"\n columns are the generalized cost broken into its parts, all in {target}", end=""
|
|
596
|
+
)
|
|
597
|
+
if rates_as_of:
|
|
598
|
+
print(f" (ECB {rates_as_of})", end="")
|
|
599
|
+
print(".")
|
|
600
|
+
print(" fare+time+wait+stop+flex+brdr+disc+risk = total. tk = tickets to buy.")
|
|
601
|
+
print(" '!' = a hard constraint or a connection rule was broken.")
|
|
602
|
+
|
|
603
|
+
for index, (cost, plan) in enumerate(ranked[:explain], start=1):
|
|
604
|
+
segments = plan.segments
|
|
605
|
+
route = "-".join([segments[0].origin] + [s.destination for s in segments])
|
|
606
|
+
print(f"\n #{index} {route} — {cost.generalized_cost.mid:,.0f} {target}")
|
|
607
|
+
for line in cost.lines:
|
|
608
|
+
print(f" {line}")
|
|
609
|
+
if cost.risks:
|
|
610
|
+
for risk in cost.risks:
|
|
611
|
+
print(f" risk: {risk.kind} — {risk.detail}")
|
|
612
|
+
for reason in cost.infeasible_reasons:
|
|
613
|
+
print(f" INFEASIBLE: {reason}")
|
|
614
|
+
for opportunity in cost.opportunities:
|
|
615
|
+
print(f" opportunity: {opportunity.question()}")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _consent(args: argparse.Namespace) -> int:
|
|
619
|
+
"""Import or check the consent cookie that lets an EU address see results.
|
|
620
|
+
|
|
621
|
+
Nothing here fabricates a consent value. You answer Google's dialog in your own
|
|
622
|
+
browser; this reuses that answer, which is also why "reject all" works fine.
|
|
623
|
+
"""
|
|
624
|
+
jar = CookieJar.load()
|
|
625
|
+
here = HttpTransport().detect_egress_country()
|
|
626
|
+
|
|
627
|
+
if args.import_cookie:
|
|
628
|
+
try:
|
|
629
|
+
name, value = parse_cookie(args.import_cookie)
|
|
630
|
+
except NotAConsentCookie as exc:
|
|
631
|
+
print(f"refused: {exc}")
|
|
632
|
+
return 2
|
|
633
|
+
jar.set("google.com", name, value, captured_egress=args.captured_from or here)
|
|
634
|
+
jar.save()
|
|
635
|
+
print(f"stored {name} for google.com in {jar.path} (0600)")
|
|
636
|
+
print(f" captured from: {args.captured_from or here or 'unknown'}")
|
|
637
|
+
print(" (capture from a logged-out private window — a cookie taken from a")
|
|
638
|
+
print(" signed-in browser shares a token with your account's session)")
|
|
639
|
+
|
|
640
|
+
cookies, problems = jar.usable_for("google.com", egress=here)
|
|
641
|
+
for problem in problems:
|
|
642
|
+
print(f" warning: {problem}")
|
|
643
|
+
if not cookies:
|
|
644
|
+
print(
|
|
645
|
+
"no consent cookie stored.\n"
|
|
646
|
+
" In your browser: open google.com, DevTools -> Application -> Cookies,\n"
|
|
647
|
+
" copy the value of SOCS, then:\n"
|
|
648
|
+
' layover consent --import "SOCS=<value>"\n'
|
|
649
|
+
" (Answer the dialog however you like — a rejection works too.)"
|
|
650
|
+
)
|
|
651
|
+
return 1
|
|
652
|
+
|
|
653
|
+
if not cookies:
|
|
654
|
+
return 1
|
|
655
|
+
transport = HttpTransport(cookies=cookies, egress_country=here)
|
|
656
|
+
query = GoogleFlightsOracle().canary_query()
|
|
657
|
+
result = GoogleFlightsOracle(transport=transport).search(query)
|
|
658
|
+
age = jar.age_days("google.com")
|
|
659
|
+
print(
|
|
660
|
+
f" cookies: {', '.join(sorted(cookies))}"
|
|
661
|
+
+ (f" (imported {age}d ago)" if age is not None else "")
|
|
662
|
+
)
|
|
663
|
+
print(f" egress: {here or 'unknown'} (EU: {is_eu_egress(here)})")
|
|
664
|
+
print(f" canary {query.describe()}: {result.status}")
|
|
665
|
+
if result.ok:
|
|
666
|
+
print(f" -> {len(result.offers)} offers. The wall is cleared from this address.")
|
|
667
|
+
return 0
|
|
668
|
+
print(f" -> {result.detail[:200]}")
|
|
669
|
+
return 1
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _compare_pos(args: argparse.Namespace) -> int:
|
|
673
|
+
"""Ask the same question from several storefronts and report whether it matters.
|
|
674
|
+
|
|
675
|
+
**What this can and cannot show.** Google Flights is metasearch: it displays
|
|
676
|
+
fares, it does not sell them, so its `gl` parameter is a display setting rather
|
|
677
|
+
than a point of sale. Real POS pricing — airlines file fares with point-of-sale
|
|
678
|
+
conditions, so the same seat sits in different buckets per market — happens where
|
|
679
|
+
a transaction completes, and has to be measured at a seller (Kiwi, AZair, an
|
|
680
|
+
airline's own country site). A null result here means "this parameter is
|
|
681
|
+
display-only", never "point of sale does not matter".
|
|
682
|
+
|
|
683
|
+
The A/B/A ordering is the part that makes any of it a measurement: prices move on
|
|
684
|
+
their own, so the baseline is asked twice and any gap smaller than the observed
|
|
685
|
+
drift is reported as inconclusive rather than as a finding.
|
|
686
|
+
"""
|
|
687
|
+
intent = load_intent(Path(args.intent))
|
|
688
|
+
depart, back = intent.date_pairs()[0]
|
|
689
|
+
target = intent.normalize_currency
|
|
690
|
+
rates = RateCache(default_cache_path()).get()
|
|
691
|
+
here = HttpTransport().detect_egress_country()
|
|
692
|
+
cookies, _ = CookieJar.load().usable_for("google.com", egress=here)
|
|
693
|
+
|
|
694
|
+
markets = [m.strip() for m in args.markets.split(",") if m.strip()]
|
|
695
|
+
currency = args.currency
|
|
696
|
+
|
|
697
|
+
def run(market: str) -> dict[tuple, float]:
|
|
698
|
+
pos = POSContext(currency=currency, locale="en-US", site_market=market)
|
|
699
|
+
query = OracleQuery(
|
|
700
|
+
origin=intent.origins[0].code,
|
|
701
|
+
destination=intent.destinations[0].code,
|
|
702
|
+
depart=depart,
|
|
703
|
+
trip_shape=intent.trip_shape,
|
|
704
|
+
return_on=back if intent.trip_shape is TripShape.RETURN else None,
|
|
705
|
+
cabin=intent.cabin,
|
|
706
|
+
pos=pos,
|
|
707
|
+
)
|
|
708
|
+
transport = HttpTransport(pos=pos, cookies=cookies, egress_country=here)
|
|
709
|
+
result = GoogleFlightsOracle(transport=transport).search(query)
|
|
710
|
+
if not result.ok:
|
|
711
|
+
print(f" gl={market:<4} {result.status}: {result.detail[:90]}")
|
|
712
|
+
return {}
|
|
713
|
+
prices: dict[tuple, float] = {}
|
|
714
|
+
for offer in result.offers:
|
|
715
|
+
normalized = (
|
|
716
|
+
rates.try_convert(offer.price.amount, offer.price.currency, target)
|
|
717
|
+
if rates
|
|
718
|
+
else None
|
|
719
|
+
)
|
|
720
|
+
if normalized is None:
|
|
721
|
+
continue
|
|
722
|
+
prices[
|
|
723
|
+
(
|
|
724
|
+
offer.segments[0].carrier,
|
|
725
|
+
offer.segments[0].depart.strftime("%H:%M"),
|
|
726
|
+
len(offer.segments),
|
|
727
|
+
)
|
|
728
|
+
] = normalized
|
|
729
|
+
print(f" gl={market:<4} curr={currency} {len(prices)} comparable offers")
|
|
730
|
+
return prices
|
|
731
|
+
|
|
732
|
+
print(
|
|
733
|
+
f"exit: {here or 'unknown'} route: {intent.origins[0].code}-"
|
|
734
|
+
f"{intent.destinations[0].code} {depart}"
|
|
735
|
+
)
|
|
736
|
+
baseline_market = markets[0]
|
|
737
|
+
first = run(baseline_market)
|
|
738
|
+
others = {market: run(market) for market in markets[1:]}
|
|
739
|
+
again = run(baseline_market) # the same question, last — to measure drift
|
|
740
|
+
|
|
741
|
+
drift = _median_gap(first, again)
|
|
742
|
+
print(f"\n drift between two identical requests: {drift:+.2%}")
|
|
743
|
+
floor = max(args.noise, abs(drift) * 2)
|
|
744
|
+
print(f" anything under {floor:.1%} is noise, not a finding\n")
|
|
745
|
+
|
|
746
|
+
for market, prices in others.items():
|
|
747
|
+
gap = _median_gap(first, prices)
|
|
748
|
+
shared = len(set(first) & set(prices))
|
|
749
|
+
if not shared:
|
|
750
|
+
print(f" gl={market:<4} no itineraries in common — inconclusive")
|
|
751
|
+
elif abs(gap) < floor:
|
|
752
|
+
print(f" gl={market:<4} {shared:>2} shared, {gap:+.2%} — display-only")
|
|
753
|
+
else:
|
|
754
|
+
print(f" gl={market:<4} {shared:>2} shared, {gap:+.2%} — DIFFERENT, probe it")
|
|
755
|
+
|
|
756
|
+
print(
|
|
757
|
+
"\n Remember what this measures: a metasearch page's display parameter.\n"
|
|
758
|
+
" Point-of-sale *pricing* is real (airlines file fares per market) but lives\n"
|
|
759
|
+
" where a booking completes — measure it at Kiwi, AZair or an airline site."
|
|
760
|
+
)
|
|
761
|
+
return 0
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _median_gap(a: dict[tuple, float], b: dict[tuple, float]) -> float:
|
|
765
|
+
"""Median relative difference over itineraries both runs returned."""
|
|
766
|
+
shared = sorted(set(a) & set(b))
|
|
767
|
+
if not shared:
|
|
768
|
+
return 0.0
|
|
769
|
+
gaps = sorted((b[k] - a[k]) / a[k] for k in shared if a[k])
|
|
770
|
+
return gaps[len(gaps) // 2] if gaps else 0.0
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _capture(args: argparse.Namespace) -> int:
|
|
774
|
+
"""Wait for an exit to come up, take a measurement, write it down, exit.
|
|
775
|
+
|
|
776
|
+
This exists because of a plain practical constraint: a full-tunnel VPN takes the
|
|
777
|
+
whole machine with it, so you cannot be on the exit you want to measure *and* be
|
|
778
|
+
talking to whoever asked for the measurement. So the process waits, notices the
|
|
779
|
+
exit appear, does its work unattended, and leaves a file behind.
|
|
780
|
+
|
|
781
|
+
Two runs of the same query, back to back, so the file carries its own drift
|
|
782
|
+
figure — a later comparison against another exit can then say whether a gap is an
|
|
783
|
+
exit effect or just prices moving.
|
|
784
|
+
"""
|
|
785
|
+
intent = load_intent(Path(args.intent))
|
|
786
|
+
depart, back = intent.date_pairs()[0]
|
|
787
|
+
target = intent.normalize_currency
|
|
788
|
+
out_dir = Path(args.out)
|
|
789
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
790
|
+
|
|
791
|
+
print(f"waiting for an exit (up to {args.timeout:.0f}s) — bring the VPN up now")
|
|
792
|
+
deadline = time.monotonic() + args.timeout
|
|
793
|
+
detected = None
|
|
794
|
+
while time.monotonic() < deadline:
|
|
795
|
+
detected = HttpTransport().detect_egress()
|
|
796
|
+
if args.wait_for:
|
|
797
|
+
if detected.country and args.wait_for.lower() in detected.country.lower():
|
|
798
|
+
break
|
|
799
|
+
elif is_eu_egress(detected.country) is False:
|
|
800
|
+
break
|
|
801
|
+
print(f" still {detected.country or 'unknown'} …")
|
|
802
|
+
time.sleep(args.poll)
|
|
803
|
+
else:
|
|
804
|
+
country = detected.country if detected else "unknown"
|
|
805
|
+
print(f" gave up: still {country}. Nothing captured.")
|
|
806
|
+
return 1
|
|
807
|
+
|
|
808
|
+
egress = detected.country
|
|
809
|
+
refusal = check_egress(detected.country)
|
|
810
|
+
if refusal:
|
|
811
|
+
# A capture is a measurement *of the exit*; without one there is nothing to
|
|
812
|
+
# measure and a file would only mislead whoever compares it later.
|
|
813
|
+
print(f" refusing to capture: {refusal}")
|
|
814
|
+
return 1
|
|
815
|
+
print(f" exit is {detected.describe()}. Measuring.")
|
|
816
|
+
if not detected.verified:
|
|
817
|
+
print(" NOTE: the exit is not cross-checked — treat any comparison as weaker.")
|
|
818
|
+
rates = RateCache(default_cache_path()).get()
|
|
819
|
+
pos = POSContext(currency=target, locale="en-US", site_market=args.market)
|
|
820
|
+
query = OracleQuery(
|
|
821
|
+
origin=intent.origins[0].code,
|
|
822
|
+
destination=intent.destinations[0].code,
|
|
823
|
+
depart=depart,
|
|
824
|
+
trip_shape=intent.trip_shape,
|
|
825
|
+
return_on=back if intent.trip_shape is TripShape.RETURN else None,
|
|
826
|
+
cabin=intent.cabin,
|
|
827
|
+
pos=pos,
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
runs = []
|
|
831
|
+
for index in range(2): # the same question twice: its own drift control
|
|
832
|
+
transport = HttpTransport(pos=pos, egress_country=egress)
|
|
833
|
+
result = GoogleFlightsOracle(transport=transport).search(query)
|
|
834
|
+
prices = {}
|
|
835
|
+
if result.ok:
|
|
836
|
+
for offer in result.offers:
|
|
837
|
+
normalized = (
|
|
838
|
+
rates.try_convert(offer.price.amount, offer.price.currency, target)
|
|
839
|
+
if rates
|
|
840
|
+
else None
|
|
841
|
+
)
|
|
842
|
+
if normalized is None:
|
|
843
|
+
continue
|
|
844
|
+
key = "|".join(
|
|
845
|
+
[
|
|
846
|
+
offer.segments[0].carrier,
|
|
847
|
+
offer.segments[0].depart.strftime("%m-%d %H:%M"),
|
|
848
|
+
str(len(offer.segments)),
|
|
849
|
+
]
|
|
850
|
+
)
|
|
851
|
+
prices[key] = round(normalized, 2)
|
|
852
|
+
runs.append(
|
|
853
|
+
{
|
|
854
|
+
"at": datetime.now().isoformat(timespec="seconds"),
|
|
855
|
+
"status": result.status,
|
|
856
|
+
"detail": result.detail[:200],
|
|
857
|
+
"prices": prices,
|
|
858
|
+
}
|
|
859
|
+
)
|
|
860
|
+
print(f" run {index + 1}: {result.status}, {len(prices)} priced itineraries")
|
|
861
|
+
if index == 0:
|
|
862
|
+
time.sleep(args.gap)
|
|
863
|
+
|
|
864
|
+
capture = {
|
|
865
|
+
"egress": egress,
|
|
866
|
+
"egress_ip": detected.ip,
|
|
867
|
+
"egress_verified": detected.verified,
|
|
868
|
+
"egress_sources": [
|
|
869
|
+
{"url": url, "country": country, "ip": ip} for url, country, ip in detected.sources
|
|
870
|
+
],
|
|
871
|
+
"eu": is_eu_egress(egress),
|
|
872
|
+
"query": query.describe(),
|
|
873
|
+
"currency": target,
|
|
874
|
+
"market_param": args.market,
|
|
875
|
+
"rates_as_of": str(rates.as_of) if rates else None,
|
|
876
|
+
"runs": runs,
|
|
877
|
+
}
|
|
878
|
+
label = args.label or (egress or "unknown").lower().replace(" ", "-")
|
|
879
|
+
path = out_dir / f"{label}-{datetime.now():%Y%m%d-%H%M%S}.json"
|
|
880
|
+
path.write_text(json.dumps(capture, indent=2, sort_keys=True))
|
|
881
|
+
print(f" wrote {path}")
|
|
882
|
+
print(" you can drop the VPN now.")
|
|
883
|
+
return 0
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _compare_captures(args: argparse.Namespace) -> int:
|
|
887
|
+
"""Compare two captures taken from different exits.
|
|
888
|
+
|
|
889
|
+
Reports a gap only when it is larger than the drift each capture measured on
|
|
890
|
+
itself. Prices move on their own; without that control this comparison would
|
|
891
|
+
manufacture findings.
|
|
892
|
+
"""
|
|
893
|
+
a = json.loads(Path(args.first).read_text())
|
|
894
|
+
b = json.loads(Path(args.second).read_text())
|
|
895
|
+
|
|
896
|
+
def drift_of(capture) -> float:
|
|
897
|
+
runs = capture["runs"]
|
|
898
|
+
if len(runs) < 2:
|
|
899
|
+
return 0.0
|
|
900
|
+
return _median_gap_str(runs[0]["prices"], runs[1]["prices"])
|
|
901
|
+
|
|
902
|
+
a_prices, b_prices = a["runs"][0]["prices"], b["runs"][0]["prices"]
|
|
903
|
+
shared = sorted(set(a_prices) & set(b_prices))
|
|
904
|
+
drift = max(abs(drift_of(a)), abs(drift_of(b)))
|
|
905
|
+
floor = max(args.noise, drift * 2)
|
|
906
|
+
|
|
907
|
+
def label(capture) -> str:
|
|
908
|
+
mark = "" if capture.get("egress_verified") else " [egress unverified]"
|
|
909
|
+
ip = f", {capture['egress_ip']}" if capture.get("egress_ip") else ""
|
|
910
|
+
return f"{capture['egress']}{ip}{mark} ({capture['runs'][0]['at']})"
|
|
911
|
+
|
|
912
|
+
print(f" {label(a)}\n vs {label(b)}")
|
|
913
|
+
print(f" query: {a['query']}")
|
|
914
|
+
if a["query"] != b["query"]:
|
|
915
|
+
print(f" WARNING: different queries — {b['query']}")
|
|
916
|
+
print(f" self-measured drift: {drift:.2%}; anything under {floor:.1%} is noise\n")
|
|
917
|
+
|
|
918
|
+
if not shared:
|
|
919
|
+
print(" no itineraries in common — inconclusive (schedules or dates differ)")
|
|
920
|
+
return 1
|
|
921
|
+
gap = _median_gap_str(a_prices, b_prices)
|
|
922
|
+
for key in shared[:10]:
|
|
923
|
+
change = (b_prices[key] - a_prices[key]) / a_prices[key]
|
|
924
|
+
print(f" {key:<44} {a_prices[key]:>8.0f} -> {b_prices[key]:>8.0f} {change:+7.2%}")
|
|
925
|
+
verdict = (
|
|
926
|
+
"the exit did not move prices"
|
|
927
|
+
if abs(gap) < floor
|
|
928
|
+
else "PRICES DIFFER BY EXIT — the IP is a real POS knob here"
|
|
929
|
+
)
|
|
930
|
+
print(f"\n {len(shared)} shared, median {gap:+.2%}: {verdict}")
|
|
931
|
+
return 0
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _median_gap_str(a: dict[str, float], b: dict[str, float]) -> float:
|
|
935
|
+
shared = sorted(set(a) & set(b))
|
|
936
|
+
gaps = sorted((b[k] - a[k]) / a[k] for k in shared if a[k])
|
|
937
|
+
return gaps[len(gaps) // 2] if gaps else 0.0
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _status(args: argparse.Namespace) -> int:
|
|
941
|
+
root = Path(args.sessions or default_session_root())
|
|
942
|
+
stores = sorted(root.glob("*.sqlite")) if root.is_dir() else []
|
|
943
|
+
print(f"session store: {root} ({len(stores)} session(s))")
|
|
944
|
+
for path in stores[-5:]:
|
|
945
|
+
with open_session(path.parent, path.stem) as store:
|
|
946
|
+
events = store.health_events()
|
|
947
|
+
print(
|
|
948
|
+
f" {path.stem}: {store.query_count()} queries, {len(store.offers())} offers, "
|
|
949
|
+
f"{len(events)} health event(s), created {store.created_at:%Y-%m-%d %H:%M}"
|
|
950
|
+
)
|
|
951
|
+
for event in events[-3:]:
|
|
952
|
+
print(f" {event['oracle_id']}: {event['kind']} — {event['detail'][:70]}")
|
|
953
|
+
return 0
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _knowledge(args: argparse.Namespace) -> int:
|
|
957
|
+
"""Show the notebook — the one piece of state that is meant to outlive sessions.
|
|
958
|
+
|
|
959
|
+
Worth being able to read, because it is also the thing that will silently stop a
|
|
960
|
+
search from asking a source. A wrong entry here looks like an oracle that quietly
|
|
961
|
+
does nothing, which is exactly the failure mode DESIGN §5 says to keep visible.
|
|
962
|
+
"""
|
|
963
|
+
knowledge = OracleKnowledge(Path(args.path) if args.path else None)
|
|
964
|
+
if args.forget:
|
|
965
|
+
dropped = knowledge.forget(args.forget)
|
|
966
|
+
print(f"forgot {dropped} fact(s) about {args.forget}")
|
|
967
|
+
return 0
|
|
968
|
+
|
|
969
|
+
print(f"knowledge: {knowledge.path} (coverage facts expire after {knowledge.ttl_days}d)")
|
|
970
|
+
gaps = knowledge.known_gaps()
|
|
971
|
+
print(f" coverage gaps in force ({len(gaps)}):")
|
|
972
|
+
for fact in gaps:
|
|
973
|
+
print(f" {fact.oracle_id:<24} {fact.route:<10} learned {fact.age_days()}d ago")
|
|
974
|
+
if not gaps:
|
|
975
|
+
print(" none — every oracle will be asked about every route")
|
|
976
|
+
for oracle_id in knowledge.oracles():
|
|
977
|
+
health = knowledge.health(oracle_id)
|
|
978
|
+
if not health:
|
|
979
|
+
continue
|
|
980
|
+
cooling = knowledge.cooling(oracle_id)
|
|
981
|
+
state = f"cooling until {cooling:%Y-%m-%d %H:%M}" if cooling else "available"
|
|
982
|
+
fails = health.get("consecutive_failures", 0)
|
|
983
|
+
last = health.get("last_failure", "—")
|
|
984
|
+
print(f" {oracle_id:<24} {state}, {fails} consecutive failure(s), last: {last}")
|
|
985
|
+
return 0
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _gc(args: argparse.Namespace) -> int:
|
|
989
|
+
removed = gc(Path(args.sessions or default_session_root()), retention_days=args.days)
|
|
990
|
+
print(f"removed {len(removed)} session(s) older than {args.days} days")
|
|
991
|
+
for path in removed:
|
|
992
|
+
print(f" {path.name}")
|
|
993
|
+
if not args.all:
|
|
994
|
+
print(" (reports are user-owned documents and were left alone; --all removes them)")
|
|
995
|
+
return 0
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def load_dotenv(paths: list[Path] | None = None) -> list[str]:
|
|
999
|
+
"""Read `KEY=value` lines from a gitignored `.env` (DESIGN §13).
|
|
1000
|
+
|
|
1001
|
+
Secrets come from the environment, never from a config file — but typing
|
|
1002
|
+
`export SERPAPI_KEY=…` into every shell is the kind of friction that ends with
|
|
1003
|
+
someone pasting a key into a TOML. Existing environment variables always win, so
|
|
1004
|
+
a file can never silently override what you set on purpose.
|
|
1005
|
+
"""
|
|
1006
|
+
loaded: list[str] = []
|
|
1007
|
+
for path in paths or [Path(".env"), Path.home() / ".config" / "layover" / ".env"]:
|
|
1008
|
+
if not path.is_file():
|
|
1009
|
+
continue
|
|
1010
|
+
for line in path.read_text().splitlines():
|
|
1011
|
+
line = line.strip()
|
|
1012
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
1013
|
+
continue
|
|
1014
|
+
name, _, value = line.partition("=")
|
|
1015
|
+
name, value = name.strip(), value.strip().strip("\"'")
|
|
1016
|
+
if name and name not in os.environ:
|
|
1017
|
+
os.environ[name] = value
|
|
1018
|
+
loaded.append(name)
|
|
1019
|
+
return loaded
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1023
|
+
load_dotenv()
|
|
1024
|
+
parser = argparse.ArgumentParser(prog="layover", description=__doc__.splitlines()[0])
|
|
1025
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1026
|
+
|
|
1027
|
+
search = sub.add_parser("search", help="run one search and rank what comes back")
|
|
1028
|
+
search.add_argument("intent", help="path to an intent TOML file")
|
|
1029
|
+
search.add_argument("--session", help="session id (default: a timestamp)")
|
|
1030
|
+
search.add_argument("--sessions", help="session store directory")
|
|
1031
|
+
search.add_argument("--currency", help="ask the oracle in this currency")
|
|
1032
|
+
search.add_argument("--locale", default="en-US")
|
|
1033
|
+
search.add_argument("--market", default="us")
|
|
1034
|
+
search.add_argument(
|
|
1035
|
+
"--oracle",
|
|
1036
|
+
default="azair",
|
|
1037
|
+
choices=("auto", "azair", "google", "serpapi"),
|
|
1038
|
+
help=(
|
|
1039
|
+
"auto: the cheapest oracle that can answer, shown with its reckoning. "
|
|
1040
|
+
"azair: European sellers, no exit needed. google: displayed fares, needs "
|
|
1041
|
+
"a non-EU exit or a consent cookie. serpapi: paid, one credit per query."
|
|
1042
|
+
),
|
|
1043
|
+
)
|
|
1044
|
+
search.add_argument(
|
|
1045
|
+
"--max-money",
|
|
1046
|
+
type=float,
|
|
1047
|
+
default=0.0,
|
|
1048
|
+
help="money this search may spend on paid oracles (default 0 = free only)",
|
|
1049
|
+
)
|
|
1050
|
+
search.add_argument(
|
|
1051
|
+
"--serpapi-cap",
|
|
1052
|
+
type=int,
|
|
1053
|
+
default=0,
|
|
1054
|
+
help="monthly credit cap for serpapi (0 = off; the free tier is 250/month)",
|
|
1055
|
+
)
|
|
1056
|
+
search.add_argument("--rows", type=int, default=15, help="rows in the table")
|
|
1057
|
+
search.add_argument("--explain", type=int, default=3, help="itemize the top N rows in full")
|
|
1058
|
+
search.add_argument("--max-stops", type=int, default=1)
|
|
1059
|
+
search.add_argument(
|
|
1060
|
+
"--allow-unknown-exit",
|
|
1061
|
+
action="store_true",
|
|
1062
|
+
help="record prices even when the exit cannot be determined",
|
|
1063
|
+
)
|
|
1064
|
+
search.add_argument(
|
|
1065
|
+
"--no-probe",
|
|
1066
|
+
dest="probe",
|
|
1067
|
+
action="store_false",
|
|
1068
|
+
help="baseline only: skip the return-vs-two-one-ways probe (saves 2 queries)",
|
|
1069
|
+
)
|
|
1070
|
+
search.add_argument(
|
|
1071
|
+
"--recheck-coverage",
|
|
1072
|
+
action="store_true",
|
|
1073
|
+
help="ignore remembered coverage gaps and ask every oracle again",
|
|
1074
|
+
)
|
|
1075
|
+
search.set_defaults(func=_search)
|
|
1076
|
+
|
|
1077
|
+
known = sub.add_parser("knowledge", help="what we remember about the oracles")
|
|
1078
|
+
known.add_argument("--forget", metavar="ORACLE", help="drop everything about one oracle")
|
|
1079
|
+
known.add_argument("--path", help="knowledge file (default: state dir)")
|
|
1080
|
+
known.set_defaults(func=_knowledge)
|
|
1081
|
+
|
|
1082
|
+
consent = sub.add_parser(
|
|
1083
|
+
"consent", help="import/check the Google consent cookie (EU addresses)"
|
|
1084
|
+
)
|
|
1085
|
+
consent.add_argument(
|
|
1086
|
+
"--import", dest="import_cookie", metavar="SOCS=...", help="a consent cookie"
|
|
1087
|
+
)
|
|
1088
|
+
consent.add_argument(
|
|
1089
|
+
"--captured-from",
|
|
1090
|
+
help="exit the cookie was captured from (default: where you are now)",
|
|
1091
|
+
)
|
|
1092
|
+
consent.set_defaults(func=_consent)
|
|
1093
|
+
|
|
1094
|
+
compare = sub.add_parser(
|
|
1095
|
+
"compare-pos", help="does the point of sale actually change the price?"
|
|
1096
|
+
)
|
|
1097
|
+
compare.add_argument("intent")
|
|
1098
|
+
compare.add_argument("--markets", default="us,hu,tr", help="first is the baseline")
|
|
1099
|
+
compare.add_argument("--currency", default="EUR")
|
|
1100
|
+
compare.add_argument("--noise", type=float, default=0.02, help="FX-noise threshold")
|
|
1101
|
+
compare.set_defaults(func=_compare_pos)
|
|
1102
|
+
|
|
1103
|
+
capture = sub.add_parser(
|
|
1104
|
+
"capture", help="wait for a VPN exit, measure unattended, write a file"
|
|
1105
|
+
)
|
|
1106
|
+
capture.add_argument("intent")
|
|
1107
|
+
capture.add_argument("--out", default="captures")
|
|
1108
|
+
capture.add_argument("--wait-for", help="country substring, e.g. Brazil")
|
|
1109
|
+
capture.add_argument("--timeout", type=float, default=300.0)
|
|
1110
|
+
capture.add_argument("--poll", type=float, default=10.0)
|
|
1111
|
+
capture.add_argument("--gap", type=float, default=20.0, help="seconds between runs")
|
|
1112
|
+
capture.add_argument("--market", default="us")
|
|
1113
|
+
capture.add_argument("--label")
|
|
1114
|
+
capture.set_defaults(func=_capture)
|
|
1115
|
+
|
|
1116
|
+
compare_caps = sub.add_parser("compare-captures", help="did the exit change the prices?")
|
|
1117
|
+
compare_caps.add_argument("first")
|
|
1118
|
+
compare_caps.add_argument("second")
|
|
1119
|
+
compare_caps.add_argument("--noise", type=float, default=0.02)
|
|
1120
|
+
compare_caps.set_defaults(func=_compare_captures)
|
|
1121
|
+
|
|
1122
|
+
status = sub.add_parser("status", help="what recent sessions did")
|
|
1123
|
+
status.add_argument("--sessions")
|
|
1124
|
+
status.set_defaults(func=_status)
|
|
1125
|
+
|
|
1126
|
+
collect = sub.add_parser("gc", help="remove aged-out session stores")
|
|
1127
|
+
collect.add_argument("--sessions")
|
|
1128
|
+
collect.add_argument("--days", type=int, default=14)
|
|
1129
|
+
collect.add_argument("--all", action="store_true", help="reports too")
|
|
1130
|
+
collect.set_defaults(func=_gc)
|
|
1131
|
+
|
|
1132
|
+
args = parser.parse_args(argv)
|
|
1133
|
+
return int(args.func(args))
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1137
|
+
sys.exit(main())
|