actual times via AeroDataBox + FlightAware runway fallback
This commit is contained in:
+150
@@ -0,0 +1,150 @@
|
||||
"""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")),
|
||||
}
|
||||
Reference in New Issue
Block a user