opensky-cli 0.1.0__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.
- opensky/__init__.py +3 -0
- opensky/__main__.py +3 -0
- opensky/_vendor/__init__.py +0 -0
- opensky/_vendor/google_flights.py +489 -0
- opensky/airports.py +183 -0
- opensky/cache.py +49 -0
- opensky/cli.py +690 -0
- opensky/config.py +151 -0
- opensky/data/conflict_zones.json +203 -0
- opensky/data/demo_flights.json +3355 -0
- opensky/display.py +358 -0
- opensky/models.py +90 -0
- opensky/providers/__init__.py +81 -0
- opensky/providers/amadeus.py +150 -0
- opensky/providers/duffel.py +122 -0
- opensky/providers/google.py +111 -0
- opensky/safety.py +138 -0
- opensky/search.py +280 -0
- opensky_cli-0.1.0.dist-info/METADATA +231 -0
- opensky_cli-0.1.0.dist-info/RECORD +23 -0
- opensky_cli-0.1.0.dist-info/WHEEL +4 -0
- opensky_cli-0.1.0.dist-info/entry_points.txt +2 -0
- opensky_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from ratelimit import limits, sleep_and_retry
|
|
7
|
+
|
|
8
|
+
from opensky.models import FlightLeg, FlightResult
|
|
9
|
+
from opensky.providers import parse_iso_duration
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
API_BASE = "https://api.duffel.com"
|
|
14
|
+
|
|
15
|
+
CABIN_MAP: dict[str, str] = {
|
|
16
|
+
"economy": "economy",
|
|
17
|
+
"premium_economy": "premium_economy",
|
|
18
|
+
"business": "business",
|
|
19
|
+
"first": "first",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _convert_offer(offer: dict, currency: str) -> FlightResult:
|
|
24
|
+
"""Convert a Duffel offer dict to a FlightResult."""
|
|
25
|
+
price = float(offer.get("total_amount", 0))
|
|
26
|
+
offer_currency = offer.get("total_currency", currency)
|
|
27
|
+
|
|
28
|
+
legs: list[FlightLeg] = []
|
|
29
|
+
total_duration = 0
|
|
30
|
+
|
|
31
|
+
for slc in offer.get("slices", []):
|
|
32
|
+
for seg in slc.get("segments", []):
|
|
33
|
+
dep = seg.get("departing_at", "")
|
|
34
|
+
arr = seg.get("arriving_at", "")
|
|
35
|
+
dur = parse_iso_duration(seg.get("duration", ""))
|
|
36
|
+
total_duration += dur
|
|
37
|
+
|
|
38
|
+
carrier = seg.get("operating_carrier", {})
|
|
39
|
+
airline = carrier.get("iata_code", seg.get("marketing_carrier", {}).get("iata_code", ""))
|
|
40
|
+
flight_num = seg.get("operating_carrier_flight_number", seg.get("marketing_carrier_flight_number", ""))
|
|
41
|
+
|
|
42
|
+
legs.append(FlightLeg(
|
|
43
|
+
airline=airline,
|
|
44
|
+
flight_number=flight_num,
|
|
45
|
+
departure_airport=seg.get("origin", {}).get("iata_code", ""),
|
|
46
|
+
arrival_airport=seg.get("destination", {}).get("iata_code", ""),
|
|
47
|
+
departure_time=dep,
|
|
48
|
+
arrival_time=arr,
|
|
49
|
+
duration_minutes=dur,
|
|
50
|
+
))
|
|
51
|
+
|
|
52
|
+
return FlightResult(
|
|
53
|
+
price=price,
|
|
54
|
+
currency=offer_currency,
|
|
55
|
+
duration_minutes=total_duration,
|
|
56
|
+
stops=max(len(legs) - 1, 0),
|
|
57
|
+
legs=legs,
|
|
58
|
+
provider="duffel",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DuffelProvider:
|
|
63
|
+
name = "duffel"
|
|
64
|
+
|
|
65
|
+
def __init__(self, token: str):
|
|
66
|
+
self._token = token
|
|
67
|
+
self._client = httpx.Client(
|
|
68
|
+
base_url=API_BASE,
|
|
69
|
+
headers={
|
|
70
|
+
"Authorization": f"Bearer {token}",
|
|
71
|
+
"Duffel-Version": "v2",
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"Accept": "application/json",
|
|
74
|
+
},
|
|
75
|
+
timeout=30.0,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@sleep_and_retry
|
|
79
|
+
@limits(calls=10, period=1)
|
|
80
|
+
def search(
|
|
81
|
+
self,
|
|
82
|
+
origin: str,
|
|
83
|
+
dest: str,
|
|
84
|
+
date: str,
|
|
85
|
+
cabin: str,
|
|
86
|
+
currency: str,
|
|
87
|
+
max_stops: int | None,
|
|
88
|
+
) -> list[FlightResult]:
|
|
89
|
+
cabin_class = CABIN_MAP.get(cabin, "economy")
|
|
90
|
+
|
|
91
|
+
payload = {
|
|
92
|
+
"data": {
|
|
93
|
+
"slices": [
|
|
94
|
+
{
|
|
95
|
+
"origin": origin,
|
|
96
|
+
"destination": dest,
|
|
97
|
+
"departure_date": date,
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
"passengers": [{"type": "adult"}],
|
|
101
|
+
"cabin_class": cabin_class,
|
|
102
|
+
"currency": currency,
|
|
103
|
+
"return_offers": True,
|
|
104
|
+
"max_connections": max_stops if max_stops is not None else 2,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
resp = self._client.post("/air/offer_requests", json=payload)
|
|
109
|
+
resp.raise_for_status()
|
|
110
|
+
data = resp.json().get("data", {})
|
|
111
|
+
offers = data.get("offers", [])
|
|
112
|
+
|
|
113
|
+
results = [_convert_offer(o, currency) for o in offers]
|
|
114
|
+
|
|
115
|
+
# Client-side stops filter (Duffel max_connections is 0-2)
|
|
116
|
+
if max_stops is not None:
|
|
117
|
+
results = [r for r in results if r.stops <= max_stops]
|
|
118
|
+
|
|
119
|
+
return results
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
self._client.close()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from opensky._vendor.google_flights import (
|
|
6
|
+
FlightSearchFilters,
|
|
7
|
+
FlightSegment,
|
|
8
|
+
MaxStops,
|
|
9
|
+
PassengerInfo,
|
|
10
|
+
SeatType,
|
|
11
|
+
SearchFlights,
|
|
12
|
+
SortBy,
|
|
13
|
+
)
|
|
14
|
+
from opensky.models import FlightLeg, FlightResult
|
|
15
|
+
|
|
16
|
+
SEAT_MAP: dict[str, SeatType] = {
|
|
17
|
+
"economy": SeatType.ECONOMY,
|
|
18
|
+
"premium_economy": SeatType.PREMIUM_ECONOMY,
|
|
19
|
+
"premium": SeatType.PREMIUM_ECONOMY,
|
|
20
|
+
"business": SeatType.BUSINESS,
|
|
21
|
+
"first": SeatType.FIRST,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
STOPS_MAP: dict[int | None, MaxStops] = {
|
|
25
|
+
None: MaxStops.ANY,
|
|
26
|
+
0: MaxStops.NON_STOP,
|
|
27
|
+
1: MaxStops.ONE_STOP_OR_FEWER,
|
|
28
|
+
2: MaxStops.TWO_OR_FEWER_STOPS,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_filters(
|
|
33
|
+
origin: str,
|
|
34
|
+
dest: str,
|
|
35
|
+
date: str,
|
|
36
|
+
seat: SeatType = SeatType.ECONOMY,
|
|
37
|
+
stops: MaxStops = MaxStops.ANY,
|
|
38
|
+
) -> FlightSearchFilters:
|
|
39
|
+
return FlightSearchFilters(
|
|
40
|
+
flight_segments=[
|
|
41
|
+
FlightSegment(
|
|
42
|
+
departure_airport=[[origin, 0]],
|
|
43
|
+
arrival_airport=[[dest, 0]],
|
|
44
|
+
travel_date=date,
|
|
45
|
+
)
|
|
46
|
+
],
|
|
47
|
+
passenger_info=PassengerInfo(adults=1),
|
|
48
|
+
seat_type=seat,
|
|
49
|
+
stops=stops,
|
|
50
|
+
sort_by=SortBy.CHEAPEST,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _convert_result(raw: Any, currency: str) -> FlightResult:
|
|
55
|
+
legs = []
|
|
56
|
+
for leg in raw.legs:
|
|
57
|
+
legs.append(FlightLeg(
|
|
58
|
+
airline=leg.airline,
|
|
59
|
+
flight_number=leg.flight_number,
|
|
60
|
+
departure_airport=leg.departure_airport,
|
|
61
|
+
arrival_airport=leg.arrival_airport,
|
|
62
|
+
departure_time=leg.departure_datetime.isoformat(),
|
|
63
|
+
arrival_time=leg.arrival_datetime.isoformat(),
|
|
64
|
+
duration_minutes=leg.duration,
|
|
65
|
+
))
|
|
66
|
+
|
|
67
|
+
return FlightResult(
|
|
68
|
+
price=raw.price,
|
|
69
|
+
currency=currency,
|
|
70
|
+
duration_minutes=raw.duration,
|
|
71
|
+
stops=raw.stops,
|
|
72
|
+
legs=legs,
|
|
73
|
+
provider="google",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class GoogleProvider:
|
|
78
|
+
name = "google"
|
|
79
|
+
|
|
80
|
+
def __init__(self, currency: str = "EUR", proxy: str | None = None):
|
|
81
|
+
self._currency = currency
|
|
82
|
+
self._proxy = proxy
|
|
83
|
+
self._api: SearchFlights | None = None
|
|
84
|
+
|
|
85
|
+
def _get_api(self) -> SearchFlights:
|
|
86
|
+
if self._api is None:
|
|
87
|
+
self._api = SearchFlights(currency=self._currency, proxy=self._proxy)
|
|
88
|
+
return self._api
|
|
89
|
+
|
|
90
|
+
def search(
|
|
91
|
+
self,
|
|
92
|
+
origin: str,
|
|
93
|
+
dest: str,
|
|
94
|
+
date: str,
|
|
95
|
+
cabin: str,
|
|
96
|
+
currency: str,
|
|
97
|
+
max_stops: int | None,
|
|
98
|
+
) -> list[FlightResult]:
|
|
99
|
+
seat = SEAT_MAP.get(cabin, SeatType.ECONOMY)
|
|
100
|
+
stops = STOPS_MAP.get(max_stops, MaxStops.ANY)
|
|
101
|
+
filters = _build_filters(origin, dest, date, seat, stops)
|
|
102
|
+
api = self._get_api()
|
|
103
|
+
raw_results = api.search(filters)
|
|
104
|
+
if not raw_results:
|
|
105
|
+
return []
|
|
106
|
+
return [_convert_result(r, currency) for r in raw_results]
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
if self._api:
|
|
110
|
+
self._api.close()
|
|
111
|
+
self._api = None
|
opensky/safety.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import airportsdata
|
|
8
|
+
|
|
9
|
+
from opensky.models import ConflictZone, FlaggedAirport, RiskAssessment, RiskLevel
|
|
10
|
+
|
|
11
|
+
_BUNDLED_PATH = Path(__file__).parent / "data" / "conflict_zones.json"
|
|
12
|
+
_CACHE_PATH = Path.home() / ".cache" / "opensky" / "conflict_zones.json"
|
|
13
|
+
_CACHE_MAX_AGE = 7 * 24 * 3600 # 7 days
|
|
14
|
+
|
|
15
|
+
_airports_db: dict[str, dict] | None = None
|
|
16
|
+
_zones: list[ConflictZone] | None = None
|
|
17
|
+
_country_risk: dict[str, tuple[RiskLevel, str]] | None = None
|
|
18
|
+
_airport_risk: dict[str, tuple[RiskLevel, str]] | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_airports_db() -> dict[str, dict]:
|
|
22
|
+
global _airports_db
|
|
23
|
+
if _airports_db is None:
|
|
24
|
+
_airports_db = airportsdata.load("IATA")
|
|
25
|
+
return _airports_db
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def airport_country(iata: str) -> str | None:
|
|
29
|
+
db = _get_airports_db()
|
|
30
|
+
info = db.get(iata)
|
|
31
|
+
return info["country"] if info else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_zones_from_file(path: Path) -> list[ConflictZone]:
|
|
35
|
+
data = json.loads(path.read_text())
|
|
36
|
+
return [ConflictZone(**z) for z in data["zones"]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_zones(force_bundled: bool = False) -> list[ConflictZone]:
|
|
40
|
+
global _zones, _country_risk, _airport_risk
|
|
41
|
+
|
|
42
|
+
if _zones is not None and not force_bundled:
|
|
43
|
+
return _zones
|
|
44
|
+
|
|
45
|
+
# Prefer cached version if fresh
|
|
46
|
+
source = _BUNDLED_PATH
|
|
47
|
+
if not force_bundled and _CACHE_PATH.exists():
|
|
48
|
+
age = time.time() - _CACHE_PATH.stat().st_mtime
|
|
49
|
+
if age < _CACHE_MAX_AGE:
|
|
50
|
+
source = _CACHE_PATH
|
|
51
|
+
|
|
52
|
+
_zones = _load_zones_from_file(source)
|
|
53
|
+
|
|
54
|
+
# Build lookup tables
|
|
55
|
+
_country_risk = {}
|
|
56
|
+
_airport_risk = {}
|
|
57
|
+
for zone in _zones:
|
|
58
|
+
rl = zone.risk_level
|
|
59
|
+
for cc in zone.countries:
|
|
60
|
+
existing = _country_risk.get(cc)
|
|
61
|
+
if existing is None or rl > existing[0]:
|
|
62
|
+
_country_risk[cc] = (rl, zone.name)
|
|
63
|
+
for ap in zone.airports:
|
|
64
|
+
existing = _airport_risk.get(ap)
|
|
65
|
+
if existing is None or rl > existing[0]:
|
|
66
|
+
_airport_risk[ap] = (rl, zone.name)
|
|
67
|
+
|
|
68
|
+
return _zones
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_route(airports: list[str]) -> RiskAssessment:
|
|
72
|
+
load_zones()
|
|
73
|
+
assert _country_risk is not None and _airport_risk is not None
|
|
74
|
+
|
|
75
|
+
worst = RiskLevel.SAFE
|
|
76
|
+
flagged: list[FlaggedAirport] = []
|
|
77
|
+
|
|
78
|
+
for code in airports:
|
|
79
|
+
# Check specific airport first
|
|
80
|
+
ap_entry = _airport_risk.get(code)
|
|
81
|
+
if ap_entry:
|
|
82
|
+
rl, zone_name = ap_entry
|
|
83
|
+
country = airport_country(code) or "??"
|
|
84
|
+
flagged.append(FlaggedAirport(
|
|
85
|
+
code=code, country=country, zone_name=zone_name, risk_level=rl
|
|
86
|
+
))
|
|
87
|
+
if rl > worst:
|
|
88
|
+
worst = rl
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Check country
|
|
92
|
+
country = airport_country(code)
|
|
93
|
+
if country:
|
|
94
|
+
cc_entry = _country_risk.get(country)
|
|
95
|
+
if cc_entry:
|
|
96
|
+
rl, zone_name = cc_entry
|
|
97
|
+
flagged.append(FlaggedAirport(
|
|
98
|
+
code=code, country=country, zone_name=zone_name, risk_level=rl
|
|
99
|
+
))
|
|
100
|
+
if rl > worst:
|
|
101
|
+
worst = rl
|
|
102
|
+
|
|
103
|
+
return RiskAssessment(risk_level=worst, flagged_airports=flagged)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def zones_age_warning() -> str | None:
|
|
107
|
+
"""Return a warning string if the conflict zone data is stale, else None."""
|
|
108
|
+
load_zones()
|
|
109
|
+
|
|
110
|
+
# Check if using cached version
|
|
111
|
+
if _CACHE_PATH.exists():
|
|
112
|
+
age_days = (time.time() - _CACHE_PATH.stat().st_mtime) / 86400
|
|
113
|
+
if age_days > 7:
|
|
114
|
+
return f"Conflict zone data is {int(age_days)} days old. Run `opensky zones --update` to refresh."
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
# Using bundled -- check its age from metadata
|
|
118
|
+
data = json.loads(_BUNDLED_PATH.read_text())
|
|
119
|
+
updated = data.get("metadata", {}).get("updated", "")
|
|
120
|
+
if updated:
|
|
121
|
+
from datetime import datetime
|
|
122
|
+
try:
|
|
123
|
+
updated_dt = datetime.strptime(updated, "%Y-%m-%d")
|
|
124
|
+
age_days = (datetime.now() - updated_dt).days
|
|
125
|
+
if age_days > 14:
|
|
126
|
+
return f"Bundled conflict zone data is {age_days} days old. Run `opensky zones --update` to refresh."
|
|
127
|
+
except ValueError:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def save_cached_zones(data: str) -> None:
|
|
134
|
+
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
_CACHE_PATH.write_text(data)
|
|
136
|
+
# Force reload
|
|
137
|
+
global _zones
|
|
138
|
+
_zones = None
|
opensky/search.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
|
|
8
|
+
from opensky import cache
|
|
9
|
+
from opensky.config import ScanConfig
|
|
10
|
+
from opensky.models import FlightLeg, FlightResult, RiskLevel, ScoredFlight
|
|
11
|
+
from opensky.providers import FlightProvider, configured_providers
|
|
12
|
+
from opensky.safety import check_route
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Unset:
|
|
18
|
+
"""Sentinel for distinguishing 'not provided' from None."""
|
|
19
|
+
|
|
20
|
+
_UNSET = _Unset()
|
|
21
|
+
|
|
22
|
+
STOPS_INT: dict[str, int | None] = {
|
|
23
|
+
"any": None,
|
|
24
|
+
"non_stop": 0,
|
|
25
|
+
"one_stop_or_fewer": 1,
|
|
26
|
+
"two_or_fewer_stops": 2,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _deduplicate(results: list[FlightResult]) -> list[FlightResult]:
|
|
31
|
+
"""Deduplicate flights across providers.
|
|
32
|
+
|
|
33
|
+
Key = airline+flight_number per leg + departure date.
|
|
34
|
+
Same flight from two providers: keep the one with the lowest price
|
|
35
|
+
(prefer priced over unpriced).
|
|
36
|
+
"""
|
|
37
|
+
best: dict[str, FlightResult] = {}
|
|
38
|
+
for flight in results:
|
|
39
|
+
if not flight.legs:
|
|
40
|
+
continue
|
|
41
|
+
dep_date = flight.legs[0].departure_time[:10] if flight.legs[0].departure_time else ""
|
|
42
|
+
key = dep_date + "|" + "|".join(
|
|
43
|
+
f"{leg.airline}{leg.flight_number}:{leg.departure_airport}-{leg.arrival_airport}"
|
|
44
|
+
for leg in flight.legs
|
|
45
|
+
)
|
|
46
|
+
existing = best.get(key)
|
|
47
|
+
if existing is None:
|
|
48
|
+
best[key] = flight
|
|
49
|
+
else:
|
|
50
|
+
# Prefer priced over unpriced; then cheapest
|
|
51
|
+
if existing.price <= 0 and flight.price > 0:
|
|
52
|
+
best[key] = flight
|
|
53
|
+
elif flight.price > 0 and (existing.price <= 0 or flight.price < existing.price):
|
|
54
|
+
best[key] = flight
|
|
55
|
+
return list(best.values())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _route_string(origin: str, dest: str, legs: list[FlightLeg]) -> str:
|
|
59
|
+
from opensky.airports import city_name
|
|
60
|
+
|
|
61
|
+
airports = [origin]
|
|
62
|
+
for leg in legs:
|
|
63
|
+
if leg.departure_airport != airports[-1]:
|
|
64
|
+
airports.append(leg.departure_airport)
|
|
65
|
+
airports.append(leg.arrival_airport)
|
|
66
|
+
# Deduplicate consecutive
|
|
67
|
+
deduped = [airports[0]]
|
|
68
|
+
for a in airports[1:]:
|
|
69
|
+
if a != deduped[-1]:
|
|
70
|
+
deduped.append(a)
|
|
71
|
+
return " → ".join(city_name(code) for code in deduped)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _all_airports(legs: list[FlightLeg]) -> list[str]:
|
|
75
|
+
airports = []
|
|
76
|
+
for leg in legs:
|
|
77
|
+
airports.append(leg.departure_airport)
|
|
78
|
+
airports.append(leg.arrival_airport)
|
|
79
|
+
return list(dict.fromkeys(airports))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SearchEngine:
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
currency: str = "EUR",
|
|
86
|
+
proxy: str | None = None,
|
|
87
|
+
use_cache: bool = True,
|
|
88
|
+
seat: str = "economy",
|
|
89
|
+
stops: str = "any",
|
|
90
|
+
provider: str | None = None,
|
|
91
|
+
):
|
|
92
|
+
self.currency = currency
|
|
93
|
+
self.use_cache = use_cache
|
|
94
|
+
self.seat = seat
|
|
95
|
+
self.max_stops = STOPS_INT.get(stops, None)
|
|
96
|
+
self._providers: list[FlightProvider] = configured_providers(
|
|
97
|
+
currency=currency, proxy=proxy, only=provider,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def close(self) -> None:
|
|
101
|
+
for p in self._providers:
|
|
102
|
+
try:
|
|
103
|
+
p.close()
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
self._providers = []
|
|
107
|
+
|
|
108
|
+
def search_one(
|
|
109
|
+
self,
|
|
110
|
+
origin: str,
|
|
111
|
+
dest: str,
|
|
112
|
+
date: str,
|
|
113
|
+
) -> list[FlightResult]:
|
|
114
|
+
all_results: list[FlightResult] = []
|
|
115
|
+
|
|
116
|
+
for provider in self._providers:
|
|
117
|
+
ck = cache.cache_key(
|
|
118
|
+
origin, dest, date,
|
|
119
|
+
seat=self.seat, currency=self.currency,
|
|
120
|
+
stops=self.max_stops, provider=provider.name,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if self.use_cache:
|
|
124
|
+
cached = cache.get(ck)
|
|
125
|
+
if cached is not None:
|
|
126
|
+
all_results.extend(cached)
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
results = provider.search(
|
|
131
|
+
origin, dest, date,
|
|
132
|
+
cabin=self.seat,
|
|
133
|
+
currency=self.currency,
|
|
134
|
+
max_stops=self.max_stops,
|
|
135
|
+
)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
log.warning("Provider %s failed for %s->%s %s: %s", provider.name, origin, dest, date, e)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if self.use_cache:
|
|
141
|
+
cache.put(ck, results)
|
|
142
|
+
|
|
143
|
+
all_results.extend(results)
|
|
144
|
+
|
|
145
|
+
if len(self._providers) > 1:
|
|
146
|
+
all_results = _deduplicate(all_results)
|
|
147
|
+
|
|
148
|
+
return all_results
|
|
149
|
+
|
|
150
|
+
def search_scored(
|
|
151
|
+
self,
|
|
152
|
+
origin: str,
|
|
153
|
+
dest: str,
|
|
154
|
+
date: str,
|
|
155
|
+
risk_threshold: RiskLevel | None = RiskLevel.HIGH_RISK,
|
|
156
|
+
price_weight: float = 1.0,
|
|
157
|
+
duration_weight: float = 0.5,
|
|
158
|
+
transit_hours: dict[str, float] | None = None,
|
|
159
|
+
max_price: float = 0,
|
|
160
|
+
) -> list[ScoredFlight]:
|
|
161
|
+
results = self.search_one(origin, dest, date)
|
|
162
|
+
scored = []
|
|
163
|
+
|
|
164
|
+
for flight in results:
|
|
165
|
+
if max_price > 0 and flight.price > 0 and flight.price > max_price:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
airports = _all_airports(flight.legs)
|
|
169
|
+
risk = check_route(airports)
|
|
170
|
+
|
|
171
|
+
# Skip flights at or above the risk threshold
|
|
172
|
+
if risk_threshold is not None and risk.risk_level >= risk_threshold:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
route = _route_string(origin, dest, flight.legs)
|
|
176
|
+
th = (transit_hours or {}).get(dest, 0.0)
|
|
177
|
+
total_hours = (flight.duration_minutes / 60) + th
|
|
178
|
+
price = flight.price if flight.price > 0 else 99999
|
|
179
|
+
score = (price_weight * price) + (duration_weight * total_hours)
|
|
180
|
+
|
|
181
|
+
sf = ScoredFlight(
|
|
182
|
+
flight=flight,
|
|
183
|
+
origin=origin,
|
|
184
|
+
destination=dest,
|
|
185
|
+
date=date,
|
|
186
|
+
route=route,
|
|
187
|
+
risk=risk,
|
|
188
|
+
score=score,
|
|
189
|
+
transit_hours=th,
|
|
190
|
+
)
|
|
191
|
+
scored.append(sf)
|
|
192
|
+
|
|
193
|
+
return scored
|
|
194
|
+
|
|
195
|
+
def scan(
|
|
196
|
+
self,
|
|
197
|
+
config: ScanConfig,
|
|
198
|
+
workers: int = 3,
|
|
199
|
+
delay: float = 1.0,
|
|
200
|
+
on_progress: Callable[[int, int, int], None] | None = None,
|
|
201
|
+
on_result: Callable[[ScoredFlight], None] | None = None,
|
|
202
|
+
risk_threshold_override: RiskLevel | None | _Unset = _UNSET,
|
|
203
|
+
) -> list[ScoredFlight]:
|
|
204
|
+
dates = config.search.date_range.dates()
|
|
205
|
+
combos = [
|
|
206
|
+
(o, d, dt)
|
|
207
|
+
for o in config.search.origins
|
|
208
|
+
for d in config.search.destinations
|
|
209
|
+
for dt in dates
|
|
210
|
+
]
|
|
211
|
+
total = len(combos)
|
|
212
|
+
if risk_threshold_override is not _UNSET:
|
|
213
|
+
risk_threshold = risk_threshold_override
|
|
214
|
+
else:
|
|
215
|
+
risk_threshold = RiskLevel(config.safety.risk_threshold)
|
|
216
|
+
transit_hours = config.connections.transit_hours
|
|
217
|
+
pw = config.scoring.price_weight
|
|
218
|
+
dw = config.scoring.duration_weight
|
|
219
|
+
mp = config.search.max_price
|
|
220
|
+
|
|
221
|
+
all_scored: list[ScoredFlight] = []
|
|
222
|
+
completed = 0
|
|
223
|
+
errors = 0
|
|
224
|
+
consecutive_errors = 0
|
|
225
|
+
effective_delay = delay
|
|
226
|
+
|
|
227
|
+
def _search_combo(combo: tuple[str, str, str]) -> tuple[str, str, str, list[ScoredFlight] | None]:
|
|
228
|
+
origin, dest, date = combo
|
|
229
|
+
try:
|
|
230
|
+
results = self.search_scored(
|
|
231
|
+
origin, dest, date,
|
|
232
|
+
risk_threshold=risk_threshold,
|
|
233
|
+
price_weight=pw,
|
|
234
|
+
duration_weight=dw,
|
|
235
|
+
transit_hours=transit_hours,
|
|
236
|
+
max_price=mp,
|
|
237
|
+
)
|
|
238
|
+
return (origin, dest, date, results)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
log.warning("Search failed for %s->%s %s: %s", origin, dest, date, e)
|
|
241
|
+
return (origin, dest, date, None)
|
|
242
|
+
|
|
243
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
244
|
+
i = 0
|
|
245
|
+
while i < total:
|
|
246
|
+
batch = combos[i:i + workers]
|
|
247
|
+
futures = {executor.submit(_search_combo, c): c for c in batch}
|
|
248
|
+
|
|
249
|
+
for future in as_completed(futures):
|
|
250
|
+
origin, dest, date, results = future.result()
|
|
251
|
+
completed += 1
|
|
252
|
+
|
|
253
|
+
if results is None:
|
|
254
|
+
errors += 1
|
|
255
|
+
consecutive_errors += 1
|
|
256
|
+
if consecutive_errors >= 3:
|
|
257
|
+
effective_delay = min(effective_delay * 2, 10.0)
|
|
258
|
+
log.warning(
|
|
259
|
+
"Increasing delay to %.1fs after %d consecutive errors",
|
|
260
|
+
effective_delay, consecutive_errors,
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
if consecutive_errors > 0:
|
|
264
|
+
consecutive_errors = 0
|
|
265
|
+
effective_delay = max(effective_delay * 0.8, delay)
|
|
266
|
+
all_scored.extend(results)
|
|
267
|
+
|
|
268
|
+
if on_progress:
|
|
269
|
+
on_progress(completed, total, errors)
|
|
270
|
+
|
|
271
|
+
if results and on_result:
|
|
272
|
+
for sf in results:
|
|
273
|
+
on_result(sf)
|
|
274
|
+
|
|
275
|
+
i += len(batch)
|
|
276
|
+
if i < total:
|
|
277
|
+
time.sleep(effective_delay)
|
|
278
|
+
|
|
279
|
+
all_scored.sort(key=lambda x: x.score)
|
|
280
|
+
return all_scored
|