diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index bb7b74f..ea3300a 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ HX1800001800000018000001 /* PassportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1800001800000018000002 /* PassportView.swift */; }; HX1900001900000019000001 /* AircraftStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1900001900000019000002 /* AircraftStatsView.swift */; }; HX2000002000000020000001 /* EnrichAircraftTypesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX2000002000000020000002 /* EnrichAircraftTypesView.swift */; }; + HX2100002100000021000001 /* FlightAwareLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX2100002100000021000002 /* FlightAwareLookup.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -181,6 +182,7 @@ HX1800001800000018000002 /* PassportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportView.swift; sourceTree = ""; }; HX1900001900000019000002 /* AircraftStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftStatsView.swift; sourceTree = ""; }; HX2000002000000020000002 /* EnrichAircraftTypesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnrichAircraftTypesView.swift; sourceTree = ""; }; + HX2100002100000021000002 /* FlightAwareLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightAwareLookup.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -326,6 +328,7 @@ HX1000001000000010000002 /* AirframeMetadataService.swift */, HX1100001100000011000002 /* CSVFlightImporter.swift */, HX1300001300000013000002 /* HistoryFilters.swift */, + HX2100002100000021000002 /* FlightAwareLookup.swift */, ); path = Services; sourceTree = ""; @@ -527,6 +530,7 @@ HX1800001800000018000001 /* PassportView.swift in Sources */, HX1900001900000019000001 /* AircraftStatsView.swift in Sources */, HX2000002000000020000001 /* EnrichAircraftTypesView.swift in Sources */, + HX2100002100000021000001 /* FlightAwareLookup.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Flights/Services/AircraftDatabase.swift b/Flights/Services/AircraftDatabase.swift index e6ad061..9d75b44 100644 --- a/Flights/Services/AircraftDatabase.swift +++ b/Flights/Services/AircraftDatabase.swift @@ -65,6 +65,60 @@ final class AircraftDatabase: @unchecked Sendable { Self.typeNames[code.uppercased()] ?? code } + /// Normalize either an IATA aircraft code (e.g. "73H") or an ICAO + /// type designator (e.g. "B738") to the ICAO form the rest of the + /// app expects. Schedule feeds (route-explorer) hand out IATA; + /// FR24's live feed and FlightAware both hand out ICAO. We want + /// one canonical form on disk. + func normalizedICAO(forCode code: String) -> String { + let upper = code.uppercased() + if Self.typeNames[upper] != nil { return upper } // already ICAO + return Self.iataToICAO[upper] ?? upper + } + + /// Common IATA → ICAO mappings for aircraft codes we'll actually + /// see from schedule data. Not exhaustive — covers the bulk of + /// commercial fleet types. Anything missing falls through as-is. + private static let iataToICAO: [String: String] = [ + // Airbus narrowbody + "319": "A319", "31N": "A19N", + "320": "A320", "32A": "A320", "32B": "A320", "32N": "A20N", "32S": "A320", + "321": "A321", "32Q": "A21N", + "318": "A318", + // Airbus widebody + "330": "A332", "332": "A332", "333": "A333", "338": "A338", "339": "A339", + "340": "A343", "343": "A343", "346": "A346", + "350": "A359", "359": "A359", "35K": "A35K", "351": "A359", "358": "A359", + "380": "A388", "388": "A388", + // A220 + "221": "BCS1", "223": "BCS3", + // Boeing 737 family + "73G": "B737", "73R": "B737", + "73H": "B738", "73W": "B738", "738": "B738", + "73J": "B739", "739": "B739", "73Y": "B739", + "732": "B732", "733": "B733", "734": "B734", "735": "B735", "736": "B736", + "7M7": "B37M", "7M8": "B38M", "7M9": "B39M", "7MJ": "B3XM", + // Boeing 747/767/777/787 + "744": "B744", "748": "B748", + "762": "B762", "763": "B763", "764": "B764", + "772": "B772", "773": "B773", "77L": "B77L", "77W": "B77W", "77F": "B77F", + "778": "B778", "779": "B779", + "788": "B788", "789": "B789", "78X": "B78X", "78J": "B78X", + // 757 + "752": "B752", "753": "B753", + // Embraer regional + "E70": "E170", "E75": "E175", "E7W": "E175", + "E90": "E190", "E95": "E195", "295": "E295", + // Bombardier / CRJ + "CR2": "CRJ2", "CR7": "CRJ7", "CR9": "CRJ9", + // Dash 8 + "DH4": "DH8D", "DH3": "DH8C", + // ATR + "AT5": "AT45", "AT7": "AT72", "ATR": "AT72", + // MD-80 family + "M80": "MD80", "M81": "MD81", "M82": "MD82", "M83": "MD83", "M87": "MD87", "M88": "MD88", "M90": "MD90", + ] + /// Friendly names for the ~150 most common commercial type designators /// we'd see on the map. Anything else displays as the raw 3–4 letter /// code (still useful for filtering). This is by ICAO Doc 8643. diff --git a/Flights/Services/FlightAwareLookup.swift b/Flights/Services/FlightAwareLookup.swift new file mode 100644 index 0000000..0d0da81 --- /dev/null +++ b/Flights/Services/FlightAwareLookup.swift @@ -0,0 +1,160 @@ +import Foundation + +/// Best-effort aircraft type lookup by scraping FlightAware's +/// `/live/flight/` page. Their server embeds a +/// `trackpollBootstrap` JSON in the page source that contains an +/// `activityLog.flights[]` array — each entry has an `aircraftType` +/// in ICAO designator form (B738, B38M, A21N, etc.), the route as +/// IATA codes, and the scheduled gate departure timestamp. +/// +/// Pages are not Cloudflare-gated for direct GET requests with a +/// browser-shaped User-Agent. No auth required. +/// +/// Matching strategy: prefer an activity-log entry whose route +/// matches the user's flight; otherwise fall back to the most common +/// `aircraftType` across the log (good proxy because flight numbers +/// usually keep the same equipment class across many days). +actor FlightAwareLookup { + static let shared = FlightAwareLookup() + + private let session: URLSession + private var cache: [String: String?] = [:] // callsign -> "B738" or nil for miss + + init(session: URLSession = .shared) { + self.session = session + } + + /// Look up the ICAO aircraft type for one flight. + /// `callsign` is ICAO carrier + number, e.g. "SWA1942". + /// `departureIATA` + `arrivalIATA` are used to find the best + /// route match in the activity log. + func lookupType( + callsign: String, + departureIATA: String, + arrivalIATA: String + ) async -> String? { + let key = "\(callsign)-\(departureIATA)-\(arrivalIATA)" + if let cached = cache[key] { return cached } + + guard let url = URL(string: "https://flightaware.com/live/flight/\(callsign)") else { + cache[key] = nil + return nil + } + var req = URLRequest(url: url) + req.timeoutInterval = 10 + req.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + forHTTPHeaderField: "User-Agent" + ) + req.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode), + let html = String(data: data, encoding: .utf8) + else { + cache[key] = nil + return nil + } + let result = parse(html: html, dep: departureIATA, arr: arrivalIATA) + cache[key] = result + return result + } catch { + cache[key] = nil + return nil + } + } + + // MARK: - Parsing + + /// Find the `trackpollBootstrap` JSON and pull aircraft types from + /// its activity log. Brace-walking handles the trailing JS noise + /// after the object literal (no easy regex sentinel). + private func parse(html: String, dep: String, arr: String) -> String? { + guard let blob = extractTrackpollBootstrap(from: html), + let json = try? JSONSerialization.jsonObject(with: Data(blob.utf8)) as? [String: Any] + else { return nil } + + // The bootstrap is `{flights: {: {activityLog: {flights: [...]}}}}`. + // We don't know the key, so just take the first one. + guard let flights = json["flights"] as? [String: Any], + let first = flights.values.first as? [String: Any], + let activityLog = first["activityLog"] as? [String: Any], + let entries = activityLog["flights"] as? [[String: Any]], + !entries.isEmpty + else { return nil } + + // Pull (route, type) pairs from each entry. + var byRoute: [String: [String]] = [:] // "DAL-HOU" → ["B738", "B38M", ...] + var allTypes: [String] = [] + for entry in entries { + guard let origin = entry["origin"] as? [String: Any], + let destination = entry["destination"] as? [String: Any], + let oIata = (origin["iata"] as? String)?.uppercased(), + let dIata = (destination["iata"] as? String)?.uppercased(), + let type = (entry["aircraftType"] as? String)?.uppercased(), + !type.isEmpty + else { continue } + let routeKey = "\(oIata)-\(dIata)" + byRoute[routeKey, default: []].append(type) + allTypes.append(type) + } + + // 1) Exact route match → most common type for that route + let routeKey = "\(dep)-\(arr)" + if let types = byRoute[routeKey], let top = mostCommon(types) { + return top + } + // 2) Reverse-direction match (return leg of same flight) + let reverseKey = "\(arr)-\(dep)" + if let types = byRoute[reverseKey], let top = mostCommon(types) { + return top + } + // 3) Most common across the entire activity log + return mostCommon(allTypes) + } + + /// Locate `var trackpollBootstrap = {...};` in the page and + /// return just the `{...}` literal, brace-balanced. + private func extractTrackpollBootstrap(from html: String) -> String? { + guard let start = html.range(of: "var trackpollBootstrap"), + let openBrace = html.range(of: "{", range: start.upperBound.. String? { + guard !list.isEmpty else { return nil } + var counts: [String: Int] = [:] + for v in list { counts[v, default: 0] += 1 } + return counts.max(by: { $0.value < $1.value })?.key + } +} diff --git a/Flights/Views/EnrichAircraftTypesView.swift b/Flights/Views/EnrichAircraftTypesView.swift index 2db2e90..b76670d 100644 --- a/Flights/Views/EnrichAircraftTypesView.swift +++ b/Flights/Views/EnrichAircraftTypesView.swift @@ -138,11 +138,21 @@ struct EnrichAircraftTypesView: View { phase = .done } + /// Two-step lookup: + /// 1. route-explorer schedule — works for future or near-future + /// flights. Returns IATA aircraft codes ("73H"). + /// 2. FlightAware activity-log scrape — works for historical + /// flights still on a current flight number. Returns ICAO + /// codes ("B738"). + /// Either way we normalize to canonical ICAO via AircraftDatabase + /// before saving so the rest of the app recognizes the value. private func lookupAircraftType(for f: LoggedFlight) async -> String? { guard let carrier = f.carrierIATA, let numStr = f.flightNumber, let num = Int(numStr) else { return nil } + + // 1) route-explorer let day = Calendar.current.startOfDay(for: f.flightDate) let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day let results = await routeExplorer.searchSchedule( @@ -151,13 +161,28 @@ struct EnrichAircraftTypesView: View { startDate: day, endDate: next ) - // Prefer the result whose dep/arr matches our flight's route - // (some flight numbers fly different routes day to day). let exact = results.first { $0.departure.airportIata == f.departureIATA && $0.arrival.airportIata == f.arrivalIATA } ?? results.first - guard let eq = exact?.equipmentIata, !eq.isEmpty else { return nil } - return eq.uppercased() + if let eq = exact?.equipmentIata, !eq.isEmpty { + return AircraftDatabase.shared.normalizedICAO(forCode: eq) + } + + // 2) FlightAware fallback + // Build the ICAO callsign — FA addresses pages by ICAO carrier + // + flight number. AircraftRegistry already maps IATA→ICAO. + guard let carrierICAO = f.carrierICAO + ?? AircraftRegistry.shared.lookup(iata: carrier)?.icao + else { return nil } + let callsign = "\(carrierICAO)\(num)" + if let icaoType = await FlightAwareLookup.shared.lookupType( + callsign: callsign, + departureIATA: f.departureIATA, + arrivalIATA: f.arrivalIATA + ) { + return AircraftDatabase.shared.normalizedICAO(forCode: icaoType) + } + return nil } } diff --git a/Flights/Views/ImportCSVView.swift b/Flights/Views/ImportCSVView.swift index 9e6180b..ceafa4d 100644 --- a/Flights/Views/ImportCSVView.swift +++ b/Flights/Views/ImportCSVView.swift @@ -187,10 +187,10 @@ struct ImportCSVView: View { } } - /// Look up the scheduled aircraft type from route-explorer for one - /// parsed row. Returns nil for old flights (no schedule data), - /// unmappable carriers, or network failures — those cases are - /// expected and we just save the flight without a type. + /// Two-step lookup. Tries route-explorer first (works for future + /// schedules, returns IATA), then FlightAware (works for + /// historical flights, returns ICAO). Normalizes the result to + /// canonical ICAO before returning. private func lookupAircraftType(for p: CSVFlightImporter.ParsedFlight) async -> String? { guard let carrier = p.carrierIATA, let numStr = p.flightNumber, @@ -204,16 +204,27 @@ struct ImportCSVView: View { startDate: day, endDate: next ) - // Match the route too (some flight numbers fly different - // routes on different days; we want the one matching the - // user's dep/arr). let exact = results.first { $0.departure.airportIata == p.departureIATA && $0.arrival.airportIata == p.arrivalIATA } ?? results.first - let eq = exact?.equipmentIata - guard let eq, !eq.isEmpty else { return nil } - return eq.uppercased() + if let eq = exact?.equipmentIata, !eq.isEmpty { + return AircraftDatabase.shared.normalizedICAO(forCode: eq) + } + + // FlightAware fallback for historical flights + guard let carrierICAO = p.carrierICAO + ?? AircraftRegistry.shared.lookup(iata: carrier)?.icao + else { return nil } + let callsign = "\(carrierICAO)\(num)" + if let icaoType = await FlightAwareLookup.shared.lookupType( + callsign: callsign, + departureIATA: p.departureIATA, + arrivalIATA: p.arrivalIATA + ) { + return AircraftDatabase.shared.normalizedICAO(forCode: icaoType) + } + return nil } private func runParse(url: URL) async {