"""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.parse 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 _utc_offset(local: str | None) -> str | None: """Pull the UTC offset out of an AeroDataBox 'local' string ('2026-06-22 13:45+10:00' -> '+10:00'). This is the airport's *actual* offset on the day of the flight (so DST is already accounted for), and it's the only timezone information this pipeline has - the bundled airports.csv carries IATA/ICAO/name only. scheduler.py needs it to convert the FlightAware track's UTC timestamps into the airport-local wall clock AirTrail expects.""" if not local: return None try: dt = datetime.strptime(local, "%Y-%m-%d %H:%M%z") except ValueError: return None offset = dt.strftime("%z") # '+1000' return f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else None def _get(url: str): """GET an AeroDataBox endpoint and return the decoded JSON, or None for a "no content" answer. Raises AeroDataBoxError for anything retryable. 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: if resp.status == 204: return None 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 if not raw: return None try: return json.loads(raw.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError) as e: raise AeroDataBoxError(f"bad AeroDataBox response: {e}") from e def lookup_aircraft_icao(registration: str) -> str | None: """Registration (e.g. 'VH-8VE') -> ICAO type code (e.g. 'B38M'). Costs one extra API unit per flight. Needed because AirTrail's `aircraft` field is matched against the `icao` column of its own aircraft table (getAircraftByIcao, confirmed in its source), so the plain model string AeroDataBox puts on the flight record ('Boeing 737 MAX 8') is not something AirTrail can resolve.""" if not config.AERODATABOX_API_KEY or not registration: return None data = _get(f"{BASE_URL}/aircrafts/reg/{urllib.parse.quote(registration)}") if not isinstance(data, dict): return None # AirTrail tries model before icaoCode against the same column, so both # are worth passing on in that order. for key in ("icaoCode", "model"): value = (data.get(key) or "").strip() if value: return value return None def _first_offset(leg: dict) -> str | None: """The UTC offset for one leg, from whichever of its timestamps exists. All of a leg's times share the same airport and day, so any of them gives the same offset.""" for key in ("scheduledTime", "revisedTime", "actualTime", "runwayTime"): offset = _utc_offset((leg.get(key) or {}).get("local")) if offset: return offset return None 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) data = _get( f"{BASE_URL}/flights/number/{cleaned}/{flight_date.isoformat()}" "?dateLocalRole=Both&withAircraftImage=false&withLocation=false" ) 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")), # Each leg's real UTC offset on the day, taken from whichever local # timestamp that leg actually has. Not sent to AirTrail directly - # scheduler.py uses these to put the FlightAware track's UTC times # into the same airport-local terms as everything else here. "departureUtcOffset": _first_offset(dep), "arrivalUtcOffset": _first_offset(arr), # Straight off the flight record, no extra call. aircraftReg is a # free-text field in AirTrail; the ICAO *type* code needs a separate # registration lookup (see lookup_aircraft_icao). "aircraftReg": ((flight.get("aircraft") or {}).get("reg") or "").strip() or None, "departureTerminal": (dep.get("terminal") or "").strip() or None, "departureGate": (dep.get("gate") or "").strip() or None, "arrivalTerminal": (arr.get("terminal") or "").strip() or None, "arrivalGate": (arr.get("gate") or "").strip() or None, }