Files
boarding-pass/aerodatabox.py
T

151 lines
6.3 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"
)
req = urllib.request.Request(
url, headers={"x-rapidapi-key": config.AERODATABOX_API_KEY}
)
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")),
}