Files
Flights/Flights/Services/AirlineLoadService.swift
T
Trey T 62729213d7 Add FlightsTests target + fix AA load fetcher (Android UA version bump)
AA was silently returning nil because the server now rejects User-Agent
"Android/2025.31" with HTTP 403 ("Please update your version of the
American Airlines app"). Bumped to "2026.14" (matches the APK in
airlines/) and centralized to a constant so the next bump is one line.
Added comprehensive logging to fetchAmericanLoad (was zero) so the next
breakage won't be silent — including an explicit ⚠️ when the server
returns the "update your version" payload.

New FlightsTests target with AirlineLoadIntegrationTests — hits live
airline APIs to verify each fetcher still returns data. Per-airline
strategy:
- Try route-explorer /departures from carrier hubs for a flight in the
  next 24h (works for AA/UA/AS/B6).
- Fall back to a known-good daily flight when route-explorer doesn't
  have the carrier in its data (NK/EK/KE — ULCC + some intl carriers).
- B6/EK/NK are status-only by design (no standby data without a PNR);
  asserted as non-nil only.
- XE (JSX) skipped: needs WKWebView host.

Retries on route-explorer 429 by parsing the `retryAfter` field and
sleeping the indicated number of seconds. Static-shared client+services
across tests so the token cache survives.

Results 2026-05-26 (xcodebuild test -scheme Flights):
   AA, AS, B6, EK, KE, UA   NK  ⏭️ XE
NK (Spirit) is now broken: GetFlightInfoBI returns HTTP 403 with
{"getFlightInfoBIResult":null}. APIM key still accepted (401 without
it), but the call itself is rejected. Documented in
AIRLINE_INTEGRATION_GUIDE.md as a known regression to fix; likely
needs reverse-engineering against the current Spirit APK in airlines/.

Also: enable shared schemes in .gitignore so `xcodebuild test` works
out of the box for anyone cloning the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:14:09 -05:00

1137 lines
48 KiB
Swift

import Foundation
/// Queries airline APIs for flight load and standby data.
/// Each airline has its own endpoint and response format.
actor AirlineLoadService {
// MARK: - Properties
private let session: URLSession
private let airportDatabase: AirportDatabase?
private var unitedToken: (hash: String, expiresAt: Date)?
// MARK: - Init
init(airportDatabase: AirportDatabase? = nil) {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
session = URLSession(configuration: config)
self.airportDatabase = airportDatabase
}
// MARK: - Public Router
/// Route to the correct airline based on IATA code.
///
/// `departureTime` is an optional "HH:mm" disambiguator for airlines
/// (currently JSX / XE) where the flightconnections scraper can return
/// multiple rows sharing the same flight number but with different
/// departure times. When provided, the airline-specific fetcher can
/// match by departure time as a secondary signal.
func fetchLoad(
airlineCode: String,
flightNumber: String,
date: Date,
origin: String,
destination: String,
departureTime: String? = nil
) async -> FlightLoad? {
let code = airlineCode.uppercased()
print("[LoadService] Fetching load for \(code) flight \(flightNumber) \(origin)->\(destination)"
+ (departureTime.map { " @ \($0)" } ?? ""))
switch code {
case "UA": return await fetchUnitedLoad(flightNumber: flightNumber, date: date, origin: origin)
case "AA": return await fetchAmericanLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "NK": return await fetchSpiritStatus(origin: origin, destination: destination, date: date)
case "KE": return await fetchKoreanAirLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date, origin: origin)
case "AS": return await fetchAlaskaLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date, origin: origin)
case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination, departureTime: departureTime)
default:
print("[LoadService] Unsupported airline: \(code)")
return nil
}
}
// MARK: - United Airlines
/// Fetch an anonymous auth token from United, caching until expiry.
private func getUnitedToken() async -> String? {
if let cached = unitedToken, cached.expiresAt > Date() {
return cached.hash
}
guard let url = URL(string: "https://www.united.com/api/auth/anonymous-token") else { return nil }
do {
var request = URLRequest(url: url)
Self.applyUnitedBrowserHeaders(to: &request)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[UA] token HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
guard http?.statusCode == 200 else {
if let bodyStr = String(data: data, encoding: .utf8) {
print("[UA] token body (first 500): \(bodyStr.prefix(500))")
}
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataObj = json["data"] as? [String: Any],
let tokenObj = dataObj["token"] as? [String: Any],
let hash = tokenObj["hash"] as? String else {
print("[UA] token JSON shape unexpected")
return nil
}
// Cache for 25 minutes (tokens typically last 30).
unitedToken = (hash: hash, expiresAt: Date().addingTimeInterval(25 * 60))
return hash
} catch {
print("[UA] token error: \(error)")
return nil
}
}
/// Browser-shaped headers for United. The guide notes that plain curl is
/// blocked by TLS fingerprinting; iOS `URLSession` has a different
/// fingerprint but United's Akamai rules still sniff `User-Agent` /
/// `Accept-Language`, so we mirror a desktop browser. Not a guarantee
/// if this still fails we may need a WKWebView path like JSX.
private static func applyUnitedBrowserHeaders(to request: inout URLRequest) {
request.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
+ "(KHTML, like Gecko) Version/17.4 Safari/605.1.15",
forHTTPHeaderField: "User-Agent"
)
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
request.setValue("https://www.united.com/", forHTTPHeaderField: "Referer")
request.setValue("https://www.united.com", forHTTPHeaderField: "Origin")
}
private func fetchUnitedLoad(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
guard let token = await getUnitedToken() else { return nil }
let num = stripAirlinePrefix(flightNumber)
let dateStr = dayString(from: date, originIATA: origin)
guard let url = URL(string: "https://www.united.com/api/flightstatus/upgradeListExtended?flightNumber=\(num)&flightDate=\(dateStr)&fromAirportCode=\(origin.uppercased())") else {
return nil
}
do {
var request = URLRequest(url: url)
Self.applyUnitedBrowserHeaders(to: &request)
request.setValue("bearer \(token)", forHTTPHeaderField: "x-authorization-api")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
print("[UA] HTTP status: \((response as? HTTPURLResponse)?.statusCode ?? -1)")
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[UA] Failed to parse JSON")
return nil
}
// === LOGGING: Dump top-level keys and structure ===
print("[UA] ===== RESPONSE FOR UA\(num) =====")
print("[UA] Top-level keys: \(json.keys.sorted())")
if let segment = json["segment"] as? [String: Any] {
print("[UA] segment: \(segment)")
}
if let numCabins = json["numberOfCabins"] {
print("[UA] numberOfCabins: \(numCabins)")
}
// Log pbts
if let pbts = json["pbts"] as? [[String: Any]] {
print("[UA] pbts count: \(pbts.count)")
for (i, entry) in pbts.enumerated() {
print("[UA] pbts[\(i)]: \(entry)")
}
} else {
print("[UA] pbts: MISSING or wrong type")
}
// Log ALL cabin-level keys (front, middle, rear, etc.)
for key in json.keys.sorted() {
if let cabinData = json[key] as? [String: Any],
cabinData.keys.contains("standby") || cabinData.keys.contains("cleared") {
let clearedList = cabinData["cleared"] as? [[String: Any]] ?? []
let standbyListRaw = cabinData["standby"] as? [[String: Any]] ?? []
print("[UA] '\(key)' cabin: cleared=\(clearedList.count), standby=\(standbyListRaw.count)")
for pax in clearedList.prefix(3) {
print("[UA] cleared: \(pax["passengerName"] ?? "?") seat=\(pax["seatNumber"] ?? "?")")
}
for pax in standbyListRaw.prefix(3) {
print("[UA] standby: \(pax["passengerName"] ?? "?") seat=\(pax["seatNumber"] ?? "?")")
}
if clearedList.count > 3 { print("[UA] ... +\(clearedList.count - 3) more cleared") }
if standbyListRaw.count > 3 { print("[UA] ... +\(standbyListRaw.count - 3) more standby") }
}
}
// Log checkInSummaries if present
if let checkIn = json["checkInSummaries"] as? [[String: Any]] {
print("[UA] checkInSummaries count: \(checkIn.count)")
for entry in checkIn {
print("[UA] checkIn: \(entry)")
}
}
print("[UA] ===== END RESPONSE =====")
// Parse cabin loads from pbts array
var cabins: [CabinLoad] = []
if let pbts = json["pbts"] as? [[String: Any]] {
for entry in pbts {
let cabin = entry["cabin"] as? String ?? "Unknown"
let capacity = entry["capacity"] as? Int ?? 0
let booked = entry["booked"] as? Int ?? 0
let revStandby = entry["revenueStandby"] as? Int ?? 0
let sa = entry["sa"] as? Int ?? 0
let waitList = entry["waitList"] as? Int ?? 0
let jump = entry["jump"] as? Int ?? 0
let ps = entry["ps"] as? Int ?? 0
print("[UA] Cabin '\(cabin)': cap=\(capacity) booked=\(booked) sa=\(sa) revSB=\(revStandby) waitList=\(waitList) jump=\(jump) ps=\(ps)")
cabins.append(CabinLoad(
name: cabin,
capacity: capacity,
booked: booked,
revenueStandby: revStandby,
nonRevStandby: sa,
waitListCount: waitList,
jumpSeat: jump
))
}
}
// Parse standby / upgrade lists from ALL cabin sections
var standbyList: [StandbyPassenger] = []
var upgradeList: [StandbyPassenger] = []
// Check all keys that have cleared/standby sub-arrays
for key in json.keys.sorted() {
guard let cabinData = json[key] as? [String: Any],
cabinData.keys.contains("standby") || cabinData.keys.contains("cleared") else {
continue
}
let cabinName = key.capitalized
if let cleared = cabinData["cleared"] as? [[String: Any]] {
for pax in cleared {
upgradeList.append(StandbyPassenger(
order: upgradeList.count + 1,
displayName: pax["passengerName"] as? String ?? "",
cleared: true,
seat: pax["seatNumber"] as? String,
listName: cabinName
))
}
}
if let waiting = cabinData["standby"] as? [[String: Any]] {
for pax in waiting {
let seat = pax["seatNumber"] as? String
let hasSeat = seat != nil && !seat!.isEmpty
if hasSeat {
// Has a seat = already cleared from upgrade waitlist
upgradeList.append(StandbyPassenger(
order: upgradeList.count + 1,
displayName: pax["passengerName"] as? String ?? "",
cleared: true,
seat: seat,
listName: cabinName
))
} else {
// No seat = actually waiting on standby
standbyList.append(StandbyPassenger(
order: standbyList.count + 1,
displayName: pax["passengerName"] as? String ?? "",
cleared: false,
seat: nil,
listName: cabinName
))
}
}
}
}
return FlightLoad(
airlineCode: "UA",
flightNumber: "UA\(num)",
cabins: cabins,
standbyList: standbyList,
upgradeList: upgradeList,
seatAvailability: []
)
} catch {
return nil
}
}
// MARK: - American Airlines
/// AA gates the waitlist API on User-Agent version. Bump this when
/// `airlines/com.aa.android_*.apkm` is refreshed stale versions get
/// HTTP 403 with `{"alert":{"message":"Please update your version..."}}`.
private static let aaAppVersion = "2026.14"
private func fetchAmericanLoad(
flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = dayString(from: date, originIATA: origin)
var components = URLComponents(string: "https://cdn.flyaa.aa.com/api/mobile/loyalty/waitlist/v1.2")
components?.queryItems = [
URLQueryItem(name: "carrierCode", value: "AA"),
URLQueryItem(name: "flightNumber", value: num),
URLQueryItem(name: "departureDate", value: dateStr),
URLQueryItem(name: "originAirportCode", value: origin.uppercased()),
URLQueryItem(name: "destinationAirportCode", value: destination.uppercased())
]
guard let url = components?.url else {
print("[AA] Invalid URL components")
return nil
}
print("[AA] GET \(url.absoluteString)")
do {
var request = URLRequest(url: url)
request.setValue("Android/\(Self.aaAppVersion) Pixel 7|14|1080|2400|1.0|AmericanAirlines",
forHTTPHeaderField: "User-Agent")
request.setValue("MOBILE", forHTTPHeaderField: "x-clientid")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(UUID().uuidString, forHTTPHeaderField: "Device-ID")
request.setValue("fs", forHTTPHeaderField: "x-referrer")
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
let status = http?.statusCode ?? -1
print("[AA] HTTP status: \(status), \(data.count) bytes")
if status != 200 {
if let bodyStr = String(data: data, encoding: .utf8) {
print("[AA] body (first 500): \(bodyStr.prefix(500))")
// Server hints when the UA version has aged out surface it.
if status == 403, bodyStr.contains("update your version") {
print("[AA] ⚠️ User-Agent version (\(Self.aaAppVersion)) is rejected — bump aaAppVersion to match the latest APK in airlines/")
}
}
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[AA] JSON parse failed; body (first 500): \(String(data: data, encoding: .utf8)?.prefix(500) ?? "")")
return nil
}
print("[AA] top-level keys: \(json.keys.sorted())")
guard let waitListArray = json["waitList"] as? [[String: Any]] else {
// 200 OK but no `waitList` typical for AA Eagle 4-digit
// regional flights (marketed as AA but the mobile waitlist
// endpoint doesn't track them), or for flights whose waitlist
// hasn't opened yet (usually opens T-24h before departure).
print("[AA] No 'waitList' array in response — likely no waitlist open yet for this flight")
return nil
}
print("[AA] waitList entries: \(waitListArray.count)")
var seatAvailability: [SeatAvailability] = []
var standbyList: [StandbyPassenger] = []
var upgradeList: [StandbyPassenger] = []
for entry in waitListArray {
let listName = entry["listName"] as? String ?? "Unknown"
let seatsAvailable = entry["seatsAvailableValue"] as? Int ?? 0
let semanticColor = entry["seatsAvailableSemanticColor"] as? String ?? "success"
let label = listName == "First" ? "First Class Upgrades" : "\(listName) Seats"
seatAvailability.append(SeatAvailability(
label: label,
available: seatsAvailable,
color: SeatAvailabilityColor(rawValue: semanticColor) ?? .success
))
// Parse passengers
if let passengers = entry["passengers"] as? [[String: Any]] {
for pax in passengers {
let order = pax["order"] as? Int ?? 0
let displayName = pax["displayName"] as? String ?? ""
let cleared = pax["cleared"] as? Bool ?? false
let seat = pax["seat"] as? String
let passenger = StandbyPassenger(
order: order,
displayName: displayName,
cleared: cleared,
seat: seat,
listName: listName
)
if listName.lowercased() == "standby" {
standbyList.append(passenger)
} else {
upgradeList.append(passenger)
}
}
}
}
print("[AA] parsed seatAvailability=\(seatAvailability.count) standby=\(standbyList.count) upgrade=\(upgradeList.count)")
return FlightLoad(
airlineCode: "AA",
flightNumber: "AA\(num)",
cabins: [],
standbyList: standbyList,
upgradeList: upgradeList,
seatAvailability: seatAvailability
)
} catch {
print("[AA] error: \(error)")
return nil
}
}
// MARK: - Spirit Airlines
private func fetchSpiritStatus(origin: String, destination: String, date: Date) async -> FlightLoad? {
guard let url = URL(string: "https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI") else {
print("[NK] Invalid URL")
return nil
}
let dateStr = dayString(from: date, originIATA: origin)
let body: [String: String] = [
"departureStation": origin.uppercased(),
"arrivalStation": destination.uppercased(),
"departureDate": dateStr
]
print("[NK] POST \(url) body: \(body)")
do {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("c6567af50d544dfbb3bc5dd99c6bb177", forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
request.setValue("Android", forHTTPHeaderField: "Platform")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[NK] HTTP status: \(http?.statusCode ?? -1)")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[NK] Response body: \(bodyStr.prefix(500))")
}
guard http?.statusCode == 200 else {
print("[NK] Non-200 response")
return nil
}
// Spirit is a ULCC with no standby program.
return FlightLoad(
airlineCode: "NK",
flightNumber: "NK",
cabins: [],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[NK] Error: \(error)")
return nil
}
}
// MARK: - Korean Air
private func fetchKoreanAirLoad(
flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
guard let url = URL(string: "https://www.koreanair.com/api/et/ibeSupport/flightSeatCount") else {
return nil
}
let num = stripAirlinePrefix(flightNumber)
let dateStr = compactDayString(from: date, originIATA: origin)
let body: [String: String] = [
"carrierCode": "KE",
"flightNumber": num,
"departureAirport": origin.uppercased(),
"arrivalAirport": destination.uppercased(),
"departureDate": dateStr
]
print("[KE] POST \(url) body: \(body)")
do {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("pc", forHTTPHeaderField: "channel")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[KE] HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[KE] body (first 1200): \(bodyStr.prefix(1200))")
}
guard http?.statusCode == 200 else { return nil }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[KE] JSON parse failed")
return nil
}
// Guide doesn't document the response shape; try a few likely keys
// and fall back to scanning any top-level container for Int values
// named like "seatCount". Log what we see so the real shape is
// visible in the Xcode console on first run.
print("[KE] top-level keys: \(json.keys.sorted())")
let seatCount = (json["seatCount"] as? Int)
?? (json["availableSeatCount"] as? Int)
?? (json["totalSeatCount"] as? Int)
?? ((json["data"] as? [String: Any])?["seatCount"] as? Int)
?? 0
print("[KE] parsed seatCount=\(seatCount)")
// KE doesn't expose capacity/booked report what we know as
// non-revenue-standby availability in an Economy-labeled cabin
// (matches how we presented this previously).
let cabin = CabinLoad(
name: "Economy",
capacity: 0,
booked: 0,
revenueStandby: 0,
nonRevStandby: seatCount
)
return FlightLoad(
airlineCode: "KE",
flightNumber: "KE\(num)",
cabins: [cabin],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[KE] error: \(error)")
return nil
}
}
// MARK: - JetBlue
private func fetchJetBlueStatus(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = dayString(from: date, originIATA: origin)
guard let url = URL(string: "https://az-api.jetblue.com/flight-status/get-by-number?number=\(num)&date=\(dateStr)") else {
print("[B6] Invalid URL")
return nil
}
print("[B6] GET \(url)")
do {
var request = URLRequest(url: url)
request.setValue("49fc015f1ba44abf892d2b8961612378", forHTTPHeaderField: "apikey")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[B6] HTTP status: \(http?.statusCode ?? -1)")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[B6] Response: \(bodyStr.prefix(800))")
}
guard http?.statusCode == 200 else { return nil }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let flights = json["flights"] as? [[String: Any]],
let flight = flights.first,
let legs = flight["legs"] as? [[String: Any]],
let leg = legs.first else {
print("[B6] Failed to parse flight data")
return nil
}
let flightNo = leg["flightNo"] as? String ?? num
let status = leg["flightStatus"] as? String ?? "Unknown"
print("[B6] Flight B6\(flightNo) status: \(status)")
// JetBlue flight status only no seat/standby data without check-in session
return FlightLoad(
airlineCode: "B6",
flightNumber: "B6\(flightNo)",
cabins: [],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[B6] Error: \(error)")
return nil
}
}
// MARK: - Alaska Airlines
/// Static APIM key extracted from the Alaska mobile app, per
/// AIRLINE_INTEGRATION_GUIDE.md. Accepts `confirmationCode: null` on the
/// `/seats/waitlist` POST, so we get standby + upgrade lists without a PNR.
private static let alaskaAPIMKey = "de1d0ff837444468a5ea868945aab738"
private func fetchAlaskaLoad(
flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = dayString(from: date, originIATA: origin)
let upperOrigin = origin.uppercased()
let upperDestination = destination.uppercased()
print("[AS] Fetching Alaska load for AS\(num) \(upperOrigin)->\(upperDestination) on \(dateStr)")
// Run seatmap + waitlist in parallel; either may fail independently.
async let seatmap = fetchAlaskaSeatmap(
flightNumber: num,
origin: upperOrigin,
destination: upperDestination,
date: dateStr
)
async let waitlist = fetchAlaskaWaitlist(
flightNumber: num,
origin: upperOrigin,
date: dateStr
)
let cabinsFromMap = await seatmap
let waitlistResult = await waitlist
let cabinsFromWaitlist = waitlistResult.0
let standbyList = waitlistResult.1
let upgradeList = waitlistResult.2
// Prefer waitlist's `Authorized` cabin capacity (real FlightLoad numbers)
// over seatmap's `AvailableSeats` (count of open seats only).
let cabins = !cabinsFromWaitlist.isEmpty ? cabinsFromWaitlist : cabinsFromMap
if cabins.isEmpty && standbyList.isEmpty && upgradeList.isEmpty {
print("[AS] No data returned from seatmap or waitlist endpoints")
return nil
}
return FlightLoad(
airlineCode: "AS",
flightNumber: "AS\(num)",
cabins: cabins,
standbyList: standbyList,
upgradeList: upgradeList,
seatAvailability: []
)
}
/// GET /1/guestservices/customermobile/viewseatmap/seatmap
/// Returns per-seat status + `AvailableSeats` count per cabin section.
private func fetchAlaskaSeatmap(
flightNumber: String,
origin: String,
destination: String,
date: String
) async -> [CabinLoad] {
var components = URLComponents(string: "https://apis.alaskaair.com/1/guestservices/customermobile/viewseatmap/seatmap")
components?.queryItems = [
URLQueryItem(name: "flightnumber", value: flightNumber),
URLQueryItem(name: "departureairport", value: origin),
URLQueryItem(name: "arrivalairport", value: destination),
URLQueryItem(name: "departuredate", value: date)
]
guard let url = components?.url else {
print("[AS] seatmap: invalid URL")
return []
}
print("[AS] GET \(url)")
do {
var request = URLRequest(url: url)
request.setValue(Self.alaskaAPIMKey, forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[AS] seatmap HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[AS] seatmap body (first 1200): \(bodyStr.prefix(1200))")
}
guard http?.statusCode == 200 else { return [] }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
// Per the guide, the response carries `AvailableSeats` per cabin section.
// The exact wrapper differs by route; we walk the top level looking for
// anything that has {AvailableSeats, ...} or a list of cabin sections.
var cabins: [CabinLoad] = []
// Common shapes: { Cabins: [{ Name, AvailableSeats, ... }] } or
// { Sections: [...] } or the cabin fields inline at root.
if let cabinArr = (json["Cabins"] as? [[String: Any]])
?? (json["cabins"] as? [[String: Any]])
?? (json["Sections"] as? [[String: Any]]) {
for entry in cabinArr {
let name = (entry["Name"] as? String)
?? (entry["CabinName"] as? String)
?? (entry["Description"] as? String)
?? "Cabin"
let available = (entry["AvailableSeats"] as? Int)
?? (entry["availableSeats"] as? Int)
?? 0
// Capacity is best-effort; fall back to available so
// loadFactor stays sane when we only have open seats.
let capacity = (entry["TotalSeats"] as? Int)
?? (entry["Capacity"] as? Int)
?? available
let booked = max(0, capacity - available)
cabins.append(CabinLoad(
name: name,
capacity: capacity,
booked: booked,
revenueStandby: 0,
nonRevStandby: 0
))
}
}
print("[AS] seatmap parsed \(cabins.count) cabins")
return cabins
} catch {
print("[AS] seatmap error: \(error)")
return []
}
}
/// POST /1/guestservices/customermobile/seats/waitlist
/// Returns StandbyList + UpgradeList with FlightLoad.Authorized (capacity)
/// and passenger names. `confirmationCode: null` is accepted.
private func fetchAlaskaWaitlist(
flightNumber: String,
origin: String,
date: String
) async -> ([CabinLoad], [StandbyPassenger], [StandbyPassenger]) {
guard let url = URL(string: "https://apis.alaskaair.com/1/guestservices/customermobile/seats/waitlist") else {
print("[AS] waitlist: invalid URL")
return ([], [], [])
}
let body: [String: Any?] = [
"marketedByAirlineCode": "AS",
"departureAirportCode": origin,
"departureLocalDate": date,
"flightNumber": flightNumber,
"confirmationCode": NSNull()
]
print("[AS] POST \(url) body flight=\(flightNumber) origin=\(origin) date=\(date)")
do {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(Self.alaskaAPIMKey, forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONSerialization.data(withJSONObject: body.compactMapValues { $0 as Any? })
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[AS] waitlist HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[AS] waitlist body (first 1500): \(bodyStr.prefix(1500))")
}
guard http?.statusCode == 200 else { return ([], [], []) }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return ([], [], [])
}
let (mainCap, mainPax) = parseAlaskaWaitlistSection(json["StandbyList"], listLabel: "Standby")
let (upgradeCap, upgradePax) = parseAlaskaWaitlistSection(json["UpgradeList"], listLabel: "Upgrade")
var cabins: [CabinLoad] = []
if mainCap > 0 {
cabins.append(CabinLoad(
name: "Main",
capacity: mainCap,
booked: 0,
revenueStandby: 0,
nonRevStandby: 0
))
}
if upgradeCap > 0 {
cabins.append(CabinLoad(
name: "First",
capacity: upgradeCap,
booked: 0,
revenueStandby: 0,
nonRevStandby: 0
))
}
return (cabins, mainPax, upgradePax)
} catch {
print("[AS] waitlist error: \(error)")
return ([], [], [])
}
}
/// Parse one of the waitlist sections (StandbyList / UpgradeList).
/// Returns (cabinCapacity, passengers).
private func parseAlaskaWaitlistSection(_ raw: Any?, listLabel: String) -> (Int, [StandbyPassenger]) {
guard let section = raw as? [String: Any] else { return (0, []) }
let flightLoad = section["FlightLoad"] as? [String: Any] ?? [:]
let authorized = (flightLoad["Authorized"] as? Int) ?? 0
var passengers: [StandbyPassenger] = []
if let paxArr = section["Passengers"] as? [[String: Any]] {
for pax in paxArr {
let name = (pax["DisplayName"] as? String) ?? ""
let position = (pax["Position"] as? Int) ?? (passengers.count + 1)
let seat = pax["Seat"] as? String
let upgraded = (pax["UpgradedToPC"] as? Bool) ?? false
let cleared = upgraded || (seat != nil && !(seat?.isEmpty ?? true))
passengers.append(StandbyPassenger(
order: position,
displayName: name,
cleared: cleared,
seat: seat,
listName: listLabel
))
}
}
print("[AS] \(listLabel) section: authorized=\(authorized), passengers=\(passengers.count)")
return (authorized, passengers)
}
// MARK: - Emirates
private func fetchEmiratesStatus(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = dayString(from: date, originIATA: origin)
// Guide sample uses `flight=221` unpadded; padding was an unverified
// guess and is now removed to match the documented call exactly.
guard let url = URL(string: "https://www.emirates.com/service/flight-status?departureDate=\(dateStr)&flight=\(num)") else {
print("[EK] Invalid URL")
return nil
}
print("[EK] GET \(url)")
do {
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[EK] HTTP status: \(http?.statusCode ?? -1)")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[EK] Response: \(bodyStr.prefix(800))")
}
guard http?.statusCode == 200 else { return nil }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let results = json["results"] as? [[String: Any]],
let flight = results.first else {
print("[EK] Failed to parse flight data")
return nil
}
let flightNo = flight["flightNumber"] as? String ?? num
print("[EK] Flight EK\(flightNo) found")
// Emirates flight status only seat/load data requires PNR
return FlightLoad(
airlineCode: "EK",
flightNumber: "EK\(flightNo)",
cabins: [],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[EK] Error: \(error)")
return nil
}
}
// MARK: - JSX (JetSuiteX)
private func fetchJSXLoad(
flightNumber: String,
date: Date,
origin: String,
destination: String,
departureTime: String?
) async -> FlightLoad? {
let dateStr = dayString(from: date, originIATA: origin)
let num = stripAirlinePrefix(flightNumber)
let upperOrigin = origin.uppercased()
let upperDestination = destination.uppercased()
print("[XE] Fetching JSX for XE\(num) \(upperOrigin)->\(upperDestination) on \(dateStr)"
+ (departureTime.map { " @ \($0)" } ?? ""))
let fetcher = await JSXWebViewFetcher()
let result = await fetcher.fetchAvailability(
origin: upperOrigin,
destination: upperDestination,
date: dateStr
)
if let error = result.error {
print("[XE] JSX flow failed: \(error)")
return nil
}
print("[XE] JSX returned \(result.flights.count) unique flights for \(upperOrigin)|\(upperDestination):")
for f in result.flights {
let low = f.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a"
let depHHmm = jsxLocalTimeHHmm(f.departureLocal)
print("[XE] - \(f.flightNumber) \(f.departureLocal) (\(depHHmm)) seats=\(f.totalAvailable) low=\(low)")
}
let capacity = 30 // JSX ERJ-145 approximate capacity
// Preferred path: per-flight data from /search/simple.
if !result.flights.isEmpty {
let targetDigits = num.filter(\.isNumber)
// Primary: match by digits-only flight number. This works for
// genuinely-distinct flight numbers (XE280 vs XE292 vs XE290).
let byNumber = result.flights.filter {
$0.flightNumber.filter(\.isNumber) == targetDigits
}
// If the number alone identifies a unique flight, use it.
if byNumber.count == 1 {
let flight = byNumber[0]
print("[XE] MATCH by flight number: \(flight.flightNumber) seats=\(flight.totalAvailable)")
return makeJSXFlightLoad(flight: flight, capacity: capacity)
}
// Secondary: if multiple flights share the same number (which
// happens when the flightconnections scraper collapses distinct
// departures under one flightnumber row), or if the number
// matches nothing, use the caller's departureTime as the tie
// breaker. JSXFlight.departureLocal is "YYYY-MM-DDTHH:mm:ss";
// FlightSchedule.departureTime is "HH:mm".
if let wantHHmm = departureTime, !wantHHmm.isEmpty {
let pool = byNumber.isEmpty ? result.flights : byNumber
if let flight = pool.first(where: { jsxLocalTimeHHmm($0.departureLocal) == wantHHmm }) {
print("[XE] MATCH by departureTime \(wantHHmm): \(flight.flightNumber) seats=\(flight.totalAvailable)"
+ (byNumber.isEmpty ? " (ignored flight number)" : " (tie-break among \(byNumber.count) same-number flights)"))
return makeJSXFlightLoad(flight: flight, capacity: capacity)
}
print("[XE] departureTime \(wantHHmm) did not match any of \(result.flights.count) flights")
}
// Last resort in the primary path: if byNumber has anything,
// take the first. Report explicitly so we can tell this apart
// from a clean match in the logs.
if let flight = byNumber.first {
print("[XE] MATCH (ambiguous first-of-\(byNumber.count)): \(flight.flightNumber) seats=\(flight.totalAvailable)")
return makeJSXFlightLoad(flight: flight, capacity: capacity)
}
print("[XE] Flight XE\(num) not present in \(result.flights.count) results (no number or time match)")
return nil
}
// Graceful degradation: /search/simple was unreachable but we have
// the /lowfare/estimate day-total, which gives us at least
// "how many seats are available somewhere on the route today".
// We can't attribute that to a specific flight, so we show it as
// a route-level cabin so the UI isn't empty.
if let lowFare = result.lowFareFallback {
print("[XE] Falling back to low-fare estimate: \(lowFare.available) seats available on \(lowFare.date)")
let booked = max(0, capacity - lowFare.available)
return FlightLoad(
airlineCode: "XE",
flightNumber: "XE\(num)",
cabins: [
CabinLoad(
name: "Route (day total)",
capacity: capacity,
booked: booked,
revenueStandby: 0,
nonRevStandby: 0
)
],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
}
print("[XE] No per-flight data and no low-fare fallback; giving up")
return nil
}
// MARK: - JSX helpers
/// Build a single-cabin `FlightLoad` from a `JSXFlight`. Factored out so
/// all the match paths in `fetchJSXLoad` return the same shape.
private func makeJSXFlightLoad(flight: JSXFlight, capacity: Int) -> FlightLoad {
let booked = max(0, capacity - flight.totalAvailable)
return FlightLoad(
airlineCode: "XE",
flightNumber: flight.flightNumber,
cabins: [
CabinLoad(
name: "Cabin",
capacity: capacity,
booked: booked,
revenueStandby: 0,
nonRevStandby: 0
)
],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
}
/// Extract "HH:mm" from a JSX local time string like
/// "2026-04-15T17:35:00". Returns "" on malformed input.
private func jsxLocalTimeHHmm(_ local: String) -> String {
// Expect "YYYY-MM-DDTHH:mm:ss" the HH:mm starts at index 11.
guard let tIdx = local.firstIndex(of: "T") else { return "" }
let after = local.index(after: tIdx)
guard local.distance(from: after, to: local.endIndex) >= 5 else { return "" }
let end = local.index(after, offsetBy: 5)
return String(local[after..<end])
}
// MARK: - Helpers
/// Strip any airline prefix from a flight number string.
/// "AA 2238" -> "2238", "UA2238" -> "2238", "2238" -> "2238"
private func stripAirlinePrefix(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
// Drop leading letters and any space to get just the numeric portion
var start = trimmed.startIndex
while start < trimmed.endIndex && (trimmed[start].isLetter || trimmed[start] == " ") {
start = trimmed.index(after: start)
}
let result = String(trimmed[start...])
return result.isEmpty ? trimmed : result
}
/// "yyyy-MM-dd" formatter for United, American, Spirit.
/// NOTE: this is UTC-pinned and will cross the day boundary for users in
/// non-UTC timezones. Prefer `dayString(from:originIATA:)` which resolves
/// the departure airport's approximate local timezone.
private static let dashDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
return f
}()
/// Format a `Date` as "yyyy-MM-dd" in the *departure airport's* local
/// timezone. Airlines (JSX especially) interpret the day string as the
/// calendar day at the departure airport not UTC, not the user's locale.
///
/// Timezone resolution strategy:
/// 1. Look up the airport's longitude in the bundled airports.json.
/// 2. Approximate the offset as `round(lng / 15)` hours (15° 1hr).
/// 3. Fall back to `TimeZone.current` if the airport isn't in the DB.
///
/// The 15°/hour approximation ignores political timezone boundaries and
/// DST, but it's correct to within an hour of the real offset plenty
/// precise for *which calendar day* the `Date` falls on.
private func dayString(from date: Date, originIATA: String) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = resolveTimeZone(forIATA: originIATA)
return f.string(from: date)
}
/// Same as above but for Korean Air's compact "yyyyMMdd" format.
private func compactDayString(from date: Date, originIATA: String) -> String {
let f = DateFormatter()
f.dateFormat = "yyyyMMdd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = resolveTimeZone(forIATA: originIATA)
return f.string(from: date)
}
private func resolveTimeZone(forIATA iata: String) -> TimeZone {
guard let db = airportDatabase,
let airport = db.airport(byIATA: iata.uppercased()) else {
print("[TZ] \(iata): airport not in DB, falling back to TimeZone.current (\(TimeZone.current.identifier))")
return .current
}
// Round lng/15 to nearest hour. Clamp to [-12, 14] (real-world range).
let rawHours = (airport.lng / 15.0).rounded()
let hours = max(-12, min(14, Int(rawHours)))
let seconds = hours * 3600
let tz = TimeZone(secondsFromGMT: seconds) ?? .current
print("[TZ] \(iata) (lng=\(airport.lng)) → offset=\(hours)h (\(tz.identifier))")
return tz
}
/// "yyyyMMdd" formatter for Korean Air
private static let compactDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyyMMdd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
return f
}()
}