Remove Spirit Airlines (defunct — merged into Frontier)

Spirit ceased operations, so the fetchSpiritStatus path and all NK
references are dead code. Pulled out:

- AirlineLoadService: drop `case "NK"` from the router, delete
  fetchSpiritStatus (the GetFlightInfoBI POST that was returning 403
  even after our APIM key was accepted).
- FlightLoadDetailView: drop the `schedule.airline.iata == "NK"` branch
  and the spiritUnavailableView placeholder.
- FlightLoad model: update the airlineCode comment.
- AirlineLoadIntegrationTests: remove test_NK_spirit and drop "NK" from
  statusOnlyAirlines / knownDailyFlights fallback table.
- AIRLINE_INTEGRATION_GUIDE.md: tombstone the Spirit section and remove
  it from the cheat-sheets and recommendations.

Test suite now: 6 airlines passing (AA, AS, B6, EK, KE, UA), 1 skipped
(XE — WKWebView host required), 0 failures, runs in ~10s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-26 14:23:33 -05:00
parent 62729213d7
commit 4a939340a2
5 changed files with 11 additions and 103 deletions
+4 -20
View File
@@ -12,7 +12,7 @@ Drop-in reference for integrating flight load / seat availability data from 11 a
| B6 | ✅ Status-only | Confirms flight exists; no load data without check-in session | | B6 | ✅ Status-only | Confirms flight exists; no load data without check-in session |
| EK | ✅ Status-only | Confirms flight exists; load data requires PNR | | EK | ✅ Status-only | Confirms flight exists; load data requires PNR |
| KE | ✅ Working | Returns seat count only (no capacity) | | KE | ✅ Working | Returns seat count only (no capacity) |
| NK | ❌ Broken (2026-05-26) | `GetFlightInfoBI` returns HTTP 403 with `{"getFlightInfoBIResult":null}`. APIM key is still accepted (401 without it), but the call itself is now rejected. Likely endpoint deprecation or new auth requirement. Spirit APK in `airlines/com.spirit.*` may have the new shape. | | ~~NK~~ | Removed | Spirit Airlines ceased operations (merged into Frontier). Removed from `AirlineLoadService` and tests. |
| XE | Manual only | WKWebView path; unit tests can't exercise it | | XE | Manual only | WKWebView path; unit tests can't exercise it |
--- ---
@@ -208,23 +208,9 @@ Script ready at `scripts/jsx_availability.js`.
--- ---
## 5. Spirit Airlines — PARTIAL (status only, no standby) ## 5. ~~Spirit Airlines~~DEFUNCT
**What you get:** Flight status, station/route data. **No standby — Spirit is a ULCC and doesn't run standby lists.** Spirit ceased operations and merged into Frontier. Removed from the codebase entirely. Section retained as a placeholder so the numbering below doesn't shift.
**Auth:** Static APIM key (decrypted). Plain curl for GETs; POSTs mostly blocked by Akamai CyberFend sensor.
**Key:** `c6567af50d544dfbb3bc5dd99c6bb177`
```bash
curl -X POST "https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI" \
-H "Ocp-Apim-Subscription-Key: c6567af50d544dfbb3bc5dd99c6bb177" \
-H "Content-Type: application/json" \
-H "Platform: Android" \
-d '{"departureStation":"FLL","arrivalStation":"ATL","departureDate":"2026-04-08"}'
```
Seat maps require JWT + CyberFend sensor data (real device + Frida hook only).
--- ---
@@ -356,7 +342,6 @@ Same backend powers Lufthansa, SWISS, Austrian, Brussels.
|------------|--------------------|---------| |------------|--------------------|---------|
| Alaska | APIM key header | Lowest (curl works) | | Alaska | APIM key header | Lowest (curl works) |
| Emirates | none | Lowest (curl works) | | Emirates | none | Lowest (curl works) |
| Spirit | APIM key (GET only)| Low (curl works) |
| JetBlue | apikey header | Low (curl works) | | JetBlue | apikey header | Low (curl works) |
| Korean Air | `channel` header | Low (Playwright or curl) | | Korean Air | `channel` header | Low (Playwright or curl) |
| JSX | Playwright → JWT | Medium | | JSX | Playwright → JWT | Medium |
@@ -382,7 +367,6 @@ These four are the core of any flight-load product. Alaska is the easiest to int
## Tier 2: Status only (useful, but no seat data) ## Tier 2: Status only (useful, but no seat data)
- **Spirit** — status/routes, no standby (ULCC)
- **Emirates** — status, zero auth - **Emirates** — status, zero auth
- **Korean Air** — status; `flightSeatCount` returns 0 far out - **Korean Air** — status; `flightSeatCount` returns 0 far out
- **JetBlue** — status + route DB; loads need PNR - **JetBlue** — status + route DB; loads need PNR
@@ -399,7 +383,7 @@ These four are the core of any flight-load product. Alaska is the easiest to int
3. Layer in American for the third major US carrier. 3. Layer in American for the third major US carrier.
4. JSX as a bonus — only route pairs that JSX serves (private terminals). 4. JSX as a bonus — only route pairs that JSX serves (private terminals).
5. For Delta/JetBlue: show flight status only, note "seat data unavailable" unless you have a PNR. 5. For Delta/JetBlue: show flight status only, note "seat data unavailable" unless you have a PNR.
6. Use Emirates/Korean Air/Spirit for status on international/ULCC routes. 6. Use Emirates/Korean Air for status on international routes.
## Shared integration notes ## Shared integration notes
+1 -1
View File
@@ -2,7 +2,7 @@ import Foundation
/// Flight load data from airline APIs /// Flight load data from airline APIs
struct FlightLoad: Sendable { struct FlightLoad: Sendable {
let airlineCode: String // "UA", "AA", "KE", "NK" let airlineCode: String // "UA", "AA", "KE", etc.
let flightNumber: String // "UA2238" let flightNumber: String // "UA2238"
let cabins: [CabinLoad] // Full cabin data (United) let cabins: [CabinLoad] // Full cabin data (United)
let standbyList: [StandbyPassenger] let standbyList: [StandbyPassenger]
+1 -55
View File
@@ -42,7 +42,6 @@ actor AirlineLoadService {
switch code { switch code {
case "UA": return await fetchUnitedLoad(flightNumber: flightNumber, date: date, origin: origin) 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 "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 "KE": return await fetchKoreanAirLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date, origin: origin) 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 "AS": return await fetchAlaskaLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
@@ -413,59 +412,6 @@ actor AirlineLoadService {
} }
} }
// 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 // MARK: - Korean Air
private func fetchKoreanAirLoad( private func fetchKoreanAirLoad(
@@ -1069,7 +1015,7 @@ actor AirlineLoadService {
return result.isEmpty ? trimmed : result return result.isEmpty ? trimmed : result
} }
/// "yyyy-MM-dd" formatter for United, American, Spirit. /// "yyyy-MM-dd" formatter for United and American.
/// NOTE: this is UTC-pinned and will cross the day boundary for users in /// NOTE: this is UTC-pinned and will cross the day boundary for users in
/// non-UTC timezones. Prefer `dayString(from:originIATA:)` which resolves /// non-UTC timezones. Prefer `dayString(from:originIATA:)` which resolves
/// the departure airport's approximate local timezone. /// the departure airport's approximate local timezone.
-12
View File
@@ -25,8 +25,6 @@ struct FlightLoadDetailView: View {
.tint(FlightTheme.accent) .tint(FlightTheme.accent)
} else if let error { } else if let error {
errorView(error) errorView(error)
} else if schedule.airline.iata.uppercased() == "NK" {
spiritUnavailableView
} else if let load { } else if let load {
loadContent(load) loadContent(load)
} else { } else {
@@ -114,16 +112,6 @@ struct FlightLoadDetailView: View {
} }
} }
// MARK: - Spirit Unavailable
private var spiritUnavailableView: some View {
ContentUnavailableView {
Label("Not Available", systemImage: "info.circle")
} description: {
Text("Spirit Airlines does not provide standby or load data.")
}
}
// MARK: - Unsupported Airline // MARK: - Unsupported Airline
/// Shown when fetchLoad returns nil. That can be either: /// Shown when fetchLoad returns nil. That can be either:
+5 -15
View File
@@ -19,8 +19,6 @@ import XCTest
/// Pre-existing limitations (NOT bugs in these tests): /// Pre-existing limitations (NOT bugs in these tests):
/// - JSX (XE) uses a WKWebView path and can't run from unit tests on the /// - JSX (XE) uses a WKWebView path and can't run from unit tests on the
/// simulator without a host scene. Skipped with a `XCTSkip`. /// simulator without a host scene. Skipped with a `XCTSkip`.
/// - Spirit (NK) intentionally returns an empty FlightLoad they have no
/// standby/load program. Test just asserts non-nil.
/// - Some carriers (notably AA, AS waitlist) only open the load endpoint /// - Some carriers (notably AA, AS waitlist) only open the load endpoint
/// close to departure. Tests prefer flights leaving < 24h out and skip /// close to departure. Tests prefer flights leaving < 24h out and skip
/// with a helpful message if nothing's findable. /// with a helpful message if nothing's findable.
@@ -39,16 +37,15 @@ final class AirlineLoadIntegrationTests: XCTestCase {
/// Airlines whose load endpoint deliberately returns only flight /// Airlines whose load endpoint deliberately returns only flight
/// status (no seat/standby data). We assert non-nil for these and /// status (no seat/standby data). We assert non-nil for these and
/// stop short of the "must have data" check. /// stop short of the "must have data" check.
private static let statusOnlyAirlines: Set<String> = ["NK", "B6", "EK"] private static let statusOnlyAirlines: Set<String> = ["B6", "EK"]
/// Hardcoded daily flights used as fallbacks when route-explorer's /// Hardcoded daily flights used as fallbacks when route-explorer's
/// `/departures` data doesn't include the carrier we're looking for /// `/departures` data doesn't include the carrier we're looking for
/// (notably ULCCs like NK and some international carriers like EK/KE /// (notably some international carriers like EK/KE that aren't in
/// that aren't in route-explorer's schedule feed). Each entry is a /// route-explorer's schedule feed). Each entry is a well-known daily
/// well-known daily operation that's been stable over time; if any /// operation that's been stable over time; if any of these stop
/// of these stop operating, update the entry. /// operating, update the entry.
private static let knownDailyFlights: [String: (flightNumber: String, origin: String, destination: String)] = [ private static let knownDailyFlights: [String: (flightNumber: String, origin: String, destination: String)] = [
"NK": ("401", "LAS", "FLL"), // Spirit Las Vegas Fort Lauderdale, daily
"EK": ("201", "JFK", "DXB"), // Emirates JFK Dubai, daily flagship "EK": ("201", "JFK", "DXB"), // Emirates JFK Dubai, daily flagship
"KE": ("82", "JFK", "ICN"), // Korean Air JFK Incheon, daily "KE": ("82", "JFK", "ICN"), // Korean Air JFK Incheon, daily
] ]
@@ -83,13 +80,6 @@ final class AirlineLoadIntegrationTests: XCTestCase {
) )
} }
func test_NK_spirit() async throws {
try await runAirlineLoadTest(
carrier: "NK",
hubs: ["FLL", "MCO", "LAS", "DTW", "ORD"]
)
}
func test_KE_koreanAir() async throws { func test_KE_koreanAir() async throws {
try await runAirlineLoadTest( try await runAirlineLoadTest(
carrier: "KE", carrier: "KE",