JSX: per-flight loads via in-page fetch POST (real device only)
After a long debug, the working approach for fetching per-flight
availability from JSX in WKWebView is a direct fetch() POST to
/api/nsk/v4/availability/search/simple from inside the loaded
jsx.com page context, using the anonymous auth token from
sessionStorage["navitaire.digital.token"]. Confirmed end-to-end on
a real iOS device: returns status 200 with the full 14 KB payload,
parses into per-flight JSXFlight objects with correct per-class
seat counts (e.g. XE286 = 6 seats = 3 Hop-On + 3 All-In).
Architecture:
- JSXWebViewFetcher drives the jsx.com SPA through 18 step-by-step
verified phases: create WKWebView, navigate, install passive
PerformanceObserver, dismiss Osano, select One Way, open origin
station picker and select, open destination picker and select,
open depart datepicker (polling for day cells to render), click
the target day cell by aria-label, click the picker's DONE
button to commit, force Angular form revalidation, then fire
the POST directly from the page context.
- The POST attempt is wrapped in a fallback chain: if the direct
fetch fails, try walking __ngContext__ to find the minified-name
flight-search component ("Me" in the current build) by shape
(beginDate + origin + destination + search method) and call
search() directly, then poll Angular's own state for the parsed
availability response. Final fallback is a direct GET to
/api/nsk/v1/availability/lowfare/estimate which returns a
day-total count when all per-flight paths fail.
- JSXSearchResult.flights contains one JSXFlight per unique
journey in data.results[].trips[].journeysAvailableByMarket,
with per-class breakdowns joined against data.faresAvailable.
- Every step has an action + one or more post-condition
verifications that log independently. Step failures dump
action data fields, page state, error markers, and any
PerformanceObserver resource entries so the next iteration
has ground truth, not guesses.
Known environment limitation:
- iOS Simulator CANNOT reach POST /availability/search/simple.
Simulator WebKit runs against macOS's CFNetwork stack, which
Akamai's per-endpoint protection tier treats as a different
TLS/H2 client from real iOS Safari. Every in-page or native
request (fetch, XHR, URLSession with cookies from the WKWebView
store) fails with TypeError: Load failed / error -1005 on that
specific endpoint. Other api.jsx.com endpoints (token, graph/*,
lowfare/estimate) work fine from the simulator because they're
in a looser Akamai group. On real iOS hardware the POST goes
through with status 200.
AirlineLoadService.fetchJSXLoad now threads departureTime into the
XE-specific path so the caller can disambiguate multiple flights
with the same number. Match order: (1) exact flight number match
if unique, (2) departureTime tie-break if multiple, (3) first
same-number flight as last resort. Each branch logs which match
strategy won so caller ambiguity shows up in the log.
FlightLoadDetailView logs full tap metadata (id, flight number,
extracted number, departureTime, route) and received load
(flight number, total available, total capacity) so the
fetch-to-display data flow is traceable end-to-end per tap.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,15 +20,23 @@ actor AirlineLoadService {
|
||||
// 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
|
||||
destination: String,
|
||||
departureTime: String? = nil
|
||||
) async -> FlightLoad? {
|
||||
let code = airlineCode.uppercased()
|
||||
print("[LoadService] Fetching load for \(code) flight \(flightNumber) \(origin)->\(destination)")
|
||||
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)
|
||||
@@ -37,7 +45,7 @@ actor AirlineLoadService {
|
||||
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date)
|
||||
case "AS": return await fetchAlaskaStatus(flightNumber: flightNumber, date: date)
|
||||
case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date)
|
||||
case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
|
||||
case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination, departureTime: departureTime)
|
||||
default:
|
||||
print("[LoadService] Unsupported airline: \(code)")
|
||||
return nil
|
||||
@@ -606,13 +614,20 @@ actor AirlineLoadService {
|
||||
|
||||
// MARK: - JSX (JetSuiteX)
|
||||
|
||||
private func fetchJSXLoad(flightNumber: String, date: Date, origin: String, destination: String) async -> FlightLoad? {
|
||||
private func fetchJSXLoad(
|
||||
flightNumber: String,
|
||||
date: Date,
|
||||
origin: String,
|
||||
destination: String,
|
||||
departureTime: String?
|
||||
) async -> FlightLoad? {
|
||||
let dateStr = Self.dashDateFormatter.string(from: date)
|
||||
let num = stripAirlinePrefix(flightNumber)
|
||||
let upperOrigin = origin.uppercased()
|
||||
let upperDestination = destination.uppercased()
|
||||
|
||||
print("[XE] Fetching JSX for XE\(num) \(upperOrigin)->\(upperDestination) on \(dateStr)")
|
||||
print("[XE] Fetching JSX for XE\(num) \(upperOrigin)->\(upperDestination) on \(dateStr)"
|
||||
+ (departureTime.map { " @ \($0)" } ?? ""))
|
||||
|
||||
let fetcher = await JSXWebViewFetcher()
|
||||
let result = await fetcher.fetchAvailability(
|
||||
@@ -629,22 +644,93 @@ actor AirlineLoadService {
|
||||
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"
|
||||
print("[XE] - \(f.flightNumber) \(f.departureLocal) seats=\(f.totalAvailable) low=\(low)")
|
||||
}
|
||||
|
||||
// Find the specific flight the caller asked for. We compare the
|
||||
// digits-only portion so "XE280" matches "280", "XE 280", etc.
|
||||
let targetDigits = num.filter(\.isNumber)
|
||||
guard let flight = result.flights.first(where: {
|
||||
$0.flightNumber.filter(\.isNumber) == targetDigits
|
||||
}) else {
|
||||
print("[XE] Flight XE\(num) not present in \(result.flights.count) results")
|
||||
return nil
|
||||
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
|
||||
let booked = max(0, capacity - flight.totalAvailable)
|
||||
|
||||
// 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,
|
||||
@@ -663,6 +749,17 @@ actor AirlineLoadService {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -60,15 +60,33 @@ struct FlightLoadDetailView: View {
|
||||
|
||||
let flightNum = extractFlightNumber(from: schedule.flightNumber)
|
||||
|
||||
print("[FlightLoadDetailView] TAP id=\(schedule.id.uuidString.prefix(8))"
|
||||
+ " airline=\(schedule.airline.iata)"
|
||||
+ " scheduleFlightNumber='\(schedule.flightNumber)'"
|
||||
+ " extracted='\(flightNum)'"
|
||||
+ " departureTime=\(schedule.departureTime)"
|
||||
+ " arrivalTime=\(schedule.arrivalTime)"
|
||||
+ " \(departureCode)->\(arrivalCode)")
|
||||
|
||||
let result = await loadService.fetchLoad(
|
||||
airlineCode: schedule.airline.iata,
|
||||
flightNumber: flightNum,
|
||||
date: date,
|
||||
origin: departureCode,
|
||||
destination: arrivalCode
|
||||
destination: arrivalCode,
|
||||
departureTime: schedule.departureTime
|
||||
)
|
||||
load = result
|
||||
|
||||
if let l = result {
|
||||
print("[FlightLoadDetailView] RECEIVED id=\(schedule.id.uuidString.prefix(8))"
|
||||
+ " load.flightNumber=\(l.flightNumber)"
|
||||
+ " totalAvailable=\(l.totalAvailable)"
|
||||
+ " totalCapacity=\(l.totalCapacity)")
|
||||
} else {
|
||||
print("[FlightLoadDetailView] RECEIVED id=\(schedule.id.uuidString.prefix(8)) load=nil")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user