import Foundation actor FlightService { static let shared = FlightService() // MARK: - Configuration private let session: URLSession private let baseURL = "https://www.flightconnections.com" private static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" private var cachedCountries: [Country]? private var cachedAirportsByCountry: [String: [BrowseAirport]] = [:] init(session: URLSession = .shared) { self.session = session } // MARK: - Public API /// Search airports by term (autocomplete). func searchAirports(term: String) async throws -> [Airport] { let results = try await searchAll(term: term) return results.airports } /// Search airports, countries, and continents by term. func searchAll(term: String) async throws -> AutocompleteResults { guard let encoded = term.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "\(baseURL)/autocomplete_location.php?lang=en&term=\(encoded)") else { throw FlightServiceError.invalidURL } let request = makeRequest(url: url) let data = try await perform(request) let json = try parseJSON(data) guard let dict = json as? [String: Any] else { return AutocompleteResults(airports: [], countries: []) } let airportEntries = dict["airports"] as? [[String: Any]] ?? [] let airports: [Airport] = airportEntries.compactMap { entry in guard let value = entry["value"] as? String, let id = entry["id"] as? String else { return nil } let parts = value.split(separator: " - ", maxSplits: 1) guard parts.count == 2 else { return nil } let iata = String(parts[0]).trimmingCharacters(in: .whitespaces) let name = String(parts[1]).trimmingCharacters(in: .whitespaces) return Airport(id: id, iata: iata, name: name) } let countryEntries = dict["countries"] as? [[String: Any]] ?? [] let countries: [Country] = countryEntries.compactMap { entry in guard let name = entry["name"] as? String, let code = entry["code"] as? String else { return nil } let slug = name.lowercased().replacingOccurrences(of: " ", with: "-") return Country(id: code, name: name, slug: slug) } return AutocompleteResults(airports: airports, countries: countries) } /// Fetch all nonstop destinations from a given airport. func destinations(for airportId: String) async throws -> [Route] { guard let url = URL(string: "\(baseURL)/rt\(airportId).json") else { throw FlightServiceError.invalidURL } let request = makeRequest(url: url) let data = try await perform(request) let json = try parseJSON(data) guard let dict = json as? [String: Any], let pts = dict["pts"] as? [Int], let crd = dict["crd"] as? [Double], let mths = dict["mths"] as? [Int], let clss = dict["clss"] as? [Int], let lst = dict["lst"] as? [[Any]] else { throw FlightServiceError.invalidResponse } // pts[0] is origin; lst[i] maps to pts[i+1] var routes: [Route] = [] for (i, item) in lst.enumerated() { guard item.count >= 7, let iata = item[0] as? String, let city = item[1] as? String, let countryCode = item[2] as? String, let country = item[3] as? String else { continue } let durationStr = item[4] as? String ?? "\(item[4])" let distanceStr = item[5] as? String ?? "\(item[5])" let stopIndicator = item[6] as? String ?? "\(item[6])" let duration = Int(durationStr) ?? 0 let distance = Int(distanceStr) ?? 0 let ptsIndex = i + 1 let destId = ptsIndex < pts.count ? String(pts[ptsIndex]) : "" let monthBitmask = ptsIndex < mths.count ? mths[ptsIndex] : 0 let cabinBitmask = i < clss.count ? clss[i] : 0 let crdIndex = 2 * (i + 1) let lat = crdIndex < crd.count ? crd[crdIndex] : 0 let lng = (crdIndex + 1) < crd.count ? crd[crdIndex + 1] : 0 let airport = Airport( id: destId, iata: iata, name: city, countryCode: countryCode, country: country ) routes.append(Route( destinationAirport: airport, durationMinutes: duration, distanceMiles: distance, monthsBitmask: monthBitmask, cabinClasses: .from(bitmask: cabinBitmask), latitude: lat, longitude: lng )) } return routes } /// Fetch airlines operating a specific route. func airlines(from depId: String, to desId: String) async throws -> [RouteAirline] { guard let url = URL(string: "\(baseURL)/rt\(depId)_\(desId).json") else { throw FlightServiceError.invalidURL } let request = makeRequest(url: url) let data = try await perform(request) let json = try parseJSON(data) guard let dict = json as? [String: Any], let dataArray = dict["data"] as? [[String: Any]] else { throw FlightServiceError.invalidResponse } return dataArray.compactMap { entry in guard let routeArray = entry["route"] as? [Any], routeArray.count >= 5, let dist = entry["dist"] as? Int, let dur = entry["dur"] as? Int, let mths = entry["mths"] as? Int else { return nil } let airlineIdRaw = routeArray[0] let airlineId: String if let intId = airlineIdRaw as? Int { airlineId = String(intId) } else if let strId = airlineIdRaw as? String { airlineId = strId } else { return nil } guard let name = routeArray[2] as? String, let logoFilename = routeArray[3] as? String, let iata = routeArray[4] as? String else { return nil } let airline = Airline( id: airlineId, name: name, iata: iata, logoFilename: logoFilename ) return RouteAirline( airline: airline, distanceMiles: dist, durationMinutes: dur, monthsBitmask: mths ) } } /// Fetch flight schedules for a specific airline on a route. func schedules( dep depId: String, des desId: String, airlineId: String, airline: Airline ) async throws -> [FlightSchedule] { guard let url = URL(string: "\(baseURL)/validity.php") else { throw FlightServiceError.invalidURL } let calendar = Calendar.current let currentYear = calendar.component(.year, from: Date()) let nextYear = currentYear + 1 var request = makeRequest(url: url) request.httpMethod = "POST" request.setValue( "application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type" ) let body = "dep=\(depId)&des=\(desId)&id=\(airlineId)&startDate=\(currentYear)&endDate=\(nextYear)&lang=en" request.httpBody = body.data(using: .utf8) let data = try await perform(request) let json = try parseJSON(data) guard let dict = json as? [String: Any], let flights = dict["flights"] as? [[String: Any]] else { return [] } let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.timeZone = TimeZone(identifier: "UTC") let dayKeys: [(String, Int)] = [ ("su", 1), ("mo", 2), ("tu", 3), ("we", 4), ("th", 5), ("fr", 6), ("sa", 7) ] return flights.compactMap { flight in guard let rawFlightNumber = flight["flightnumber"] as? String, let aircraft = flight["aircraft"] as? String, let deptime = flight["deptime"] as? String, let destime = flight["destime"] as? String, let dateFromStr = flight["datefrom"] as? String, let dateToStr = flight["dateto"] as? String, let dateFrom = dateFormatter.date(from: dateFromStr), let dateTo = dateFormatter.date(from: dateToStr) else { return nil } let flightNumber = rawFlightNumber .split(separator: " ") .joined(separator: " ") .trimmingCharacters(in: .whitespaces) let acId: String if let idVal = flight["ac_id"] { acId = "\(idVal)" } else { acId = "" } var days = Set() for (key, weekday) in dayKeys { if let val = flight[key] as? String, val == "1" { days.insert(weekday) } } let classesStr = flight["classes"] as? String ?? "0000" let cabinClasses = CabinClass.from(classesString: classesStr) // Strip seconds from time strings: "16:45:00" -> "16:45" let depTimeFormatted = formatTime(deptime) let arrTimeFormatted = formatTime(destime) return FlightSchedule( airline: airline, flightNumber: flightNumber, aircraft: aircraft, aircraftId: acId, departureTime: depTimeFormatted, arrivalTime: arrTimeFormatted, dateFrom: dateFrom, dateTo: dateTo, daysOfWeek: days, cabinClasses: cabinClasses ) } } /// Fetch all flight schedules for a route across all airlines. /// Reports progress via the `onProgress` closure with (completed, total) counts. func allSchedules( dep depId: String, des desId: String, onProgress: @Sendable @escaping (Int, Int) -> Void ) async throws -> [FlightSchedule] { let routeAirlines = try await airlines(from: depId, to: desId) let total = routeAirlines.count if total == 0 { return [] } let completed = Counter() let results: [FlightSchedule] = try await withThrowingTaskGroup(of: [FlightSchedule].self) { group in var allSchedules: [FlightSchedule] = [] var launched = 0 var index = 0 // Launch initial batch (up to 3) while index < routeAirlines.count, launched < 3 { let ra = routeAirlines[index] group.addTask { [self] in try await self.schedules( dep: depId, des: desId, airlineId: ra.airline.id, airline: ra.airline ) } launched += 1 index += 1 } // As each completes, launch next for try await flights in group { allSchedules.append(contentsOf: flights) let current = await completed.increment() onProgress(current, total) if index < routeAirlines.count { let ra = routeAirlines[index] group.addTask { [self] in try await self.schedules( dep: depId, des: desId, airlineId: ra.airline.id, airline: ra.airline ) } index += 1 } } return allSchedules } return results.sorted { $0.departureTime < $1.departureTime } } // MARK: - Browse API /// Fetch all countries that have airports. func fetchCountries() async throws -> [Country] { if let cached = cachedCountries { return cached } guard let url = URL(string: "\(baseURL)/airports-by-country") else { throw FlightServiceError.invalidURL } let request = makeRequest(url: url) let data = try await perform(request) guard let html = String(data: data, encoding: .utf8) else { throw FlightServiceError.invalidResponse } // Pattern: href="/airports-in-{slug}-{code}" // Extract country name from slug (convert hyphens to spaces, title case) let pattern = try NSRegularExpression( pattern: #"href="/airports-in-([a-z-]+)-([a-z]{2})""# ) let matches = pattern.matches(in: html, range: NSRange(html.startIndex..., in: html)) var seen = Set() var countries: [Country] = [] for match in matches { guard let slugRange = Range(match.range(at: 1), in: html), let codeRange = Range(match.range(at: 2), in: html) else { continue } let slug = String(html[slugRange]) let code = String(html[codeRange]).uppercased() guard !seen.contains(code) else { continue } seen.insert(code) let name = slug.split(separator: "-") .map { $0.prefix(1).uppercased() + $0.dropFirst() } .joined(separator: " ") countries.append(Country(id: code, name: name, slug: slug)) } countries.sort { $0.name.localizedCompare($1.name) == .orderedAscending } cachedCountries = countries return countries } /// Fetch all airports in a country. func fetchAirports(country: Country) async throws -> [BrowseAirport] { if let cached = cachedAirportsByCountry[country.id] { return cached } guard let url = URL(string: "\(baseURL)/airports-in-\(country.slug)-\(country.id.lowercased())") else { throw FlightServiceError.invalidURL } let request = makeRequest(url: url) let data = try await perform(request) guard let html = String(data: data, encoding: .utf8) else { throw FlightServiceError.invalidResponse } // Pattern: title="Direct flights from {City} ({IATA})" // or: title="Flights from {City} ({IATA})" let pattern = try NSRegularExpression( pattern: #"title="(?:Direct )?[Ff]lights from ([^(]+)\(([A-Z]{3})\)""# ) let matches = pattern.matches(in: html, range: NSRange(html.startIndex..., in: html)) var seen = Set() var airports: [BrowseAirport] = [] for match in matches { guard let cityRange = Range(match.range(at: 1), in: html), let iataRange = Range(match.range(at: 2), in: html) else { continue } let city = String(html[cityRange]).trimmingCharacters(in: .whitespaces) let iata = String(html[iataRange]) guard !seen.contains(iata) else { continue } seen.insert(iata) airports.append(BrowseAirport(id: iata, iata: iata, city: city)) } airports.sort { $0.city.localizedCompare($1.city) == .orderedAscending } cachedAirportsByCountry[country.id] = airports return airports } // MARK: - Private Helpers private func makeRequest(url: URL) -> URLRequest { var request = URLRequest(url: url) request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") request.setValue("https://www.flightconnections.com/", forHTTPHeaderField: "Referer") return request } private func perform(_ request: URLRequest) async throws -> Data { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw FlightServiceError.invalidResponse } guard (200...299).contains(http.statusCode) else { throw FlightServiceError.httpError(http.statusCode) } return data } private func parseJSON(_ data: Data) throws -> Any { do { return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) } catch { throw FlightServiceError.decodingFailed(error) } } private func formatTime(_ raw: String) -> String { let components = raw.split(separator: ":") if components.count >= 2 { return "\(components[0]):\(components[1])" } return raw } } // MARK: - Concurrency Helper private actor Counter { private var value = 0 func increment() -> Int { value += 1 return value } } // MARK: - Errors struct AutocompleteResults: Sendable { let airports: [Airport] let countries: [Country] } enum FlightServiceError: LocalizedError { case invalidURL case invalidResponse case httpError(Int) case decodingFailed(Error) var errorDescription: String? { switch self { case .invalidURL: return "Invalid URL" case .invalidResponse: return "Invalid response from server" case .httpError(let code): return "HTTP error \(code)" case .decodingFailed(let error): return "Failed to decode response: \(error.localizedDescription)" } } }