Flight search app built on FlightConnections.com API data. Features: airport search with autocomplete, browse by country/state/map, flight schedules by route and date, multi-airline support with per-airline schedule loading. Includes 4,561-airport GPS database for map browsing. Adaptive light/dark mode UI inspired by Flighty. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
508 lines
18 KiB
Swift
508 lines
18 KiB
Swift
import Foundation
|
|
|
|
actor 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<Int>()
|
|
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<String>()
|
|
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<String>()
|
|
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)"
|
|
}
|
|
}
|
|
}
|