159 lines
6.7 KiB
Python
159 lines
6.7 KiB
Python
"""Fetch scheduled + actual gate departure/arrival times, plus actual
|
|
takeoff/landing (runway) times, from AeroDataBox (RapidAPI) - the same
|
|
provider AirTrail's own "Search" button uses internally when you fill in
|
|
a flight manually.
|
|
|
|
Confirmed directly against AirTrail's source (johanohly/AirTrail):
|
|
- src/lib/server/utils/flight-lookup/aerodatabox.ts (endpoint, headers,
|
|
response shape, and the actualTime -> revisedTime -> scheduledTime
|
|
fallback order for "best known actual time")
|
|
- src/lib/zod/flight.ts (target field names: departure/departureScheduled/
|
|
arrival/arrivalScheduled expect full-seconds ISO-8601 datetimes)
|
|
|
|
Why this exists as well as flightaware.py: FlightAware's public history
|
|
page (scraped there) only gives us a GPS track - no gate/schedule times.
|
|
BCBP boarding passes don't carry a time-of-day at all. AeroDataBox is the
|
|
only piece that can fill in actual scheduled/gate-actual times without
|
|
requiring a logged-in AirTrail session (its own lookup is cookie-authed,
|
|
not reachable with our API key).
|
|
|
|
VERIFIED (2026-09-12, real key, Free Tier): a flight 4 days in the past
|
|
(VA559, 2026-09-08) returned full data - scheduledTime/revisedTime/
|
|
runwayTime on both legs. Free Tier quota is 400 API units / period, 1
|
|
unit per lookup here, so the daily one-flight job has plenty of headroom.
|
|
A missing/empty response should still fail soft (see lookup_gate_times'
|
|
return of None) rather than block the FlightAware track or the
|
|
review/approve flow - just in case an individual flight falls outside
|
|
whatever window AeroDataBox actually indexed.
|
|
|
|
Bonus found in that same real response: AeroDataBox also returns
|
|
departure.runwayTime / arrival.runwayTime - actual takeoff/landing off
|
|
the runway, not just gate times. AirTrail's own aerodatabox.ts never
|
|
reads this field at all (confirmed against its source above - it types
|
|
departure/arrival with only scheduledTime/revisedTime/actualTime, no
|
|
runwayTime), which is why AirTrail's own Search button leaves
|
|
Takeoff/Landing blank even after a successful search. We use it here for
|
|
takeoffActual/landingActual - preferred in scheduler.py over the
|
|
FlightAware-track-derived guess when present, since it's an authoritative
|
|
field rather than an inferred one (the track only tells us when the
|
|
transponder started/stopped squawking, which is close to but not exactly
|
|
wheels-up/touchdown).
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import date, datetime
|
|
|
|
import config
|
|
|
|
BASE_URL = "https://aerodatabox.p.rapidapi.com"
|
|
|
|
# Fields this module can fill in build_save_payload(), keyed the same way
|
|
# AirTrail's /api/flight/save expects them.
|
|
GATE_FIELDS = (
|
|
"departureScheduled",
|
|
"departure",
|
|
"arrivalScheduled",
|
|
"arrival",
|
|
"takeoffActual",
|
|
"landingActual",
|
|
)
|
|
|
|
|
|
class AeroDataBoxError(Exception):
|
|
"""Network/config/HTTP error - safe to retry on a later run, same
|
|
treatment as FlightAwareTransientError."""
|
|
|
|
|
|
def _sanitize(flight_number: str) -> str:
|
|
return re.sub(r"[\s-]", "", flight_number).upper()
|
|
|
|
|
|
def _parse_local(local: str | None) -> str | None:
|
|
"""AeroDataBox 'local' strings look like '2026-06-22 13:45+10:00' -
|
|
already carrying their own UTC offset, no separate airport-timezone
|
|
lookup needed. Reformats to the seconds-precision ISO-8601 string
|
|
AirTrail's zod schema (z.string().datetime({offset: true})) requires,
|
|
e.g. '2026-06-22T13:45:00+10:00'."""
|
|
if not local:
|
|
return None
|
|
try:
|
|
dt = datetime.strptime(local, "%Y-%m-%d %H:%M%z")
|
|
except ValueError:
|
|
return None
|
|
return dt.isoformat(timespec="seconds")
|
|
|
|
|
|
def lookup_gate_times(flight_number: str, flight_date: date) -> dict | None:
|
|
"""Returns a dict with some/all of GATE_FIELDS set (None for anything
|
|
AeroDataBox didn't have), or None if the lookup failed outright or
|
|
matched nothing. Never raises for "no data" - only for genuine
|
|
network/config failures (AeroDataBoxError), mirroring flightaware.py's
|
|
distinction between "definitively not found" and "try again later"."""
|
|
if not config.AERODATABOX_API_KEY:
|
|
return None
|
|
|
|
cleaned = _sanitize(flight_number)
|
|
url = (
|
|
f"{BASE_URL}/flights/number/{cleaned}/{flight_date.isoformat()}"
|
|
"?dateLocalRole=Both&withAircraftImage=false&withLocation=false"
|
|
)
|
|
# A default urllib request (no User-Agent) gets HTTP 403 from AeroDataBox's
|
|
# Cloudflare-fronted gateway even with a perfectly valid, subscribed key -
|
|
# confirmed live, side by side, same key/URL: bare urllib -> 403, same
|
|
# request + this UA -> 200 with real data. See config.BROWSER_USER_AGENT.
|
|
req = urllib.request.Request(
|
|
url,
|
|
headers={
|
|
"x-rapidapi-key": config.AERODATABOX_API_KEY,
|
|
"User-Agent": config.BROWSER_USER_AGENT,
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
raw = resp.read()
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 204:
|
|
return None
|
|
raise AeroDataBoxError(f"HTTP {e.code} from AeroDataBox") from e
|
|
except urllib.error.URLError as e:
|
|
raise AeroDataBoxError(f"error reaching AeroDataBox: {e}") from e
|
|
|
|
try:
|
|
data = json.loads(raw.decode("utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
raise AeroDataBoxError(f"bad AeroDataBox response: {e}") from e
|
|
|
|
if not isinstance(data, list) or not data:
|
|
return None
|
|
|
|
# BCBP gives no time-of-day, so - same limitation as the FlightAware
|
|
# step - we can't disambiguate multiple same-day legs of the same
|
|
# flight number from the boarding pass alone. Take the first result,
|
|
# same as AirTrail's own lookup falls back to when nothing else to
|
|
# filter on (from/to aren't sent here, unlike AirTrail's UI search,
|
|
# since AeroDataBox's API doesn't support filtering by them server-side
|
|
# anyway - see the PR that added client-side from/to filtering upstream).
|
|
flight = data[0]
|
|
dep = flight.get("departure") or {}
|
|
arr = flight.get("arrival") or {}
|
|
|
|
return {
|
|
"departureScheduled": _parse_local((dep.get("scheduledTime") or {}).get("local")),
|
|
"departure": _parse_local(
|
|
(dep.get("actualTime") or {}).get("local")
|
|
or (dep.get("revisedTime") or {}).get("local")
|
|
or (dep.get("scheduledTime") or {}).get("local")
|
|
),
|
|
"arrivalScheduled": _parse_local((arr.get("scheduledTime") or {}).get("local")),
|
|
"arrival": _parse_local(
|
|
(arr.get("actualTime") or {}).get("local")
|
|
or (arr.get("revisedTime") or {}).get("local")
|
|
or (arr.get("scheduledTime") or {}).get("local")
|
|
),
|
|
"takeoffActual": _parse_local((dep.get("runwayTime") or {}).get("local")),
|
|
"landingActual": _parse_local((arr.get("runwayTime") or {}).get("local")),
|
|
}
|