"""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")), }