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
|
// MARK: - Public Router
|
||||||
|
|
||||||
/// Route to the correct airline based on IATA code.
|
/// 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(
|
func fetchLoad(
|
||||||
airlineCode: String,
|
airlineCode: String,
|
||||||
flightNumber: String,
|
flightNumber: String,
|
||||||
date: Date,
|
date: Date,
|
||||||
origin: String,
|
origin: String,
|
||||||
destination: String
|
destination: String,
|
||||||
|
departureTime: String? = nil
|
||||||
) async -> FlightLoad? {
|
) async -> FlightLoad? {
|
||||||
let code = airlineCode.uppercased()
|
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 {
|
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)
|
||||||
@@ -37,7 +45,7 @@ actor AirlineLoadService {
|
|||||||
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date)
|
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date)
|
||||||
case "AS": return await fetchAlaskaStatus(flightNumber: flightNumber, date: date)
|
case "AS": return await fetchAlaskaStatus(flightNumber: flightNumber, date: date)
|
||||||
case "EK": return await fetchEmiratesStatus(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:
|
default:
|
||||||
print("[LoadService] Unsupported airline: \(code)")
|
print("[LoadService] Unsupported airline: \(code)")
|
||||||
return nil
|
return nil
|
||||||
@@ -606,13 +614,20 @@ actor AirlineLoadService {
|
|||||||
|
|
||||||
// MARK: - JSX (JetSuiteX)
|
// 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 dateStr = Self.dashDateFormatter.string(from: date)
|
||||||
let num = stripAirlinePrefix(flightNumber)
|
let num = stripAirlinePrefix(flightNumber)
|
||||||
let upperOrigin = origin.uppercased()
|
let upperOrigin = origin.uppercased()
|
||||||
let upperDestination = destination.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 fetcher = await JSXWebViewFetcher()
|
||||||
let result = await fetcher.fetchAvailability(
|
let result = await fetcher.fetchAvailability(
|
||||||
@@ -629,22 +644,93 @@ actor AirlineLoadService {
|
|||||||
print("[XE] JSX returned \(result.flights.count) unique flights for \(upperOrigin)|\(upperDestination):")
|
print("[XE] JSX returned \(result.flights.count) unique flights for \(upperOrigin)|\(upperDestination):")
|
||||||
for f in result.flights {
|
for f in result.flights {
|
||||||
let low = f.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a"
|
let low = f.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a"
|
||||||
print("[XE] - \(f.flightNumber) \(f.departureLocal) seats=\(f.totalAvailable) low=\(low)")
|
let depHHmm = jsxLocalTimeHHmm(f.departureLocal)
|
||||||
}
|
print("[XE] - \(f.flightNumber) \(f.departureLocal) (\(depHHmm)) 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 capacity = 30 // JSX ERJ-145 approximate capacity
|
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(
|
return FlightLoad(
|
||||||
airlineCode: "XE",
|
airlineCode: "XE",
|
||||||
flightNumber: flight.flightNumber,
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
/// Strip any airline prefix from a flight number string.
|
/// Strip any airline prefix from a flight number string.
|
||||||
|
|||||||
@@ -30,11 +30,21 @@ struct JSXFlight: Sendable {
|
|||||||
let classes: [JSXFareClass] // per-class breakdown
|
let classes: [JSXFareClass] // per-class breakdown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Day-level route availability from `/api/nsk/v1/availability/lowfare/estimate`.
|
||||||
|
/// Fires automatically once origin+destination are set, so it's the cheapest
|
||||||
|
/// fallback when the full `/search/simple` POST isn't reachable.
|
||||||
|
struct JSXLowFareEstimate: Sendable {
|
||||||
|
let date: String // "YYYY-MM-DD"
|
||||||
|
let available: Int // seats available for sale that day
|
||||||
|
let lowestPrice: Double? // lowest advertised fare, if any
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of one run of the JSX WKWebView flow.
|
/// Result of one run of the JSX WKWebView flow.
|
||||||
struct JSXSearchResult: Sendable {
|
struct JSXSearchResult: Sendable {
|
||||||
let flights: [JSXFlight] // one entry per unique flight, empty on failure
|
let flights: [JSXFlight] // one entry per unique flight, empty if search/simple was unreachable
|
||||||
let rawSearchBody: String? // raw search/simple JSON, preserved for debug
|
let rawSearchBody: String? // raw search/simple JSON, preserved for debug
|
||||||
let error: String? // non-nil iff a step failed
|
let lowFareFallback: JSXLowFareEstimate? // day-level fallback if search/simple failed
|
||||||
|
let error: String? // non-nil iff the flow hit a fatal step failure
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetcher
|
// MARK: - Fetcher
|
||||||
@@ -171,118 +181,66 @@ private final class JSXFlow {
|
|||||||
if !s03.ok { return fail("Step 03 failed: \(s03.message)") }
|
if !s03.ok { return fail("Step 03 failed: \(s03.message)") }
|
||||||
|
|
||||||
// ---- STEP 04 ----
|
// ---- STEP 04 ----
|
||||||
// The interceptor tracks THREE things for every api.jsx.com request:
|
// No network interception. Previous runs wrapped window.fetch AND
|
||||||
// 1. `initiatedCalls` — every URL passed into fetch()/XHR.send(),
|
// XMLHttpRequest.prototype.{open,send} to observe api.jsx.com
|
||||||
// even ones that never receive a response (e.g. network failure,
|
// traffic. That wrapper was quite possibly the reason Angular's
|
||||||
// Akamai HTTP/2 block).
|
// POST /availability/search/simple failed with "xhr error event"
|
||||||
// 2. `allCalls` — every call that completed with any status, plus
|
// in WebKit — adding an `error` event listener before calling
|
||||||
// any error text if the request threw.
|
// origSend can alter request scheduling in WebKit's NetworkProcess.
|
||||||
// 3. `searchSimple`/`lowFare`/`token`/`setCulture` — the specific
|
//
|
||||||
// response bodies we care about.
|
// Instead, we use PerformanceObserver to passively record every
|
||||||
// This lets us distinguish "Angular's search() never fired a request"
|
// api.jsx.com resource entry. PerformanceObserver is pure
|
||||||
// from "Angular fired a request but the network rejected it".
|
// observation — it doesn't touch the request pipeline at all — so
|
||||||
|
// whatever's different about our traffic vs real user traffic
|
||||||
|
// isn't coming from here.
|
||||||
|
//
|
||||||
|
// We still need to read the RESPONSE BODY for search/simple
|
||||||
|
// somehow. Plan: after `component.search()` runs, walk Angular's
|
||||||
|
// __ngContext__ tree looking for the parsed availability data the
|
||||||
|
// SPA stored into its own services/stores — no network
|
||||||
|
// interception required. This is what the `state-first` fetcher
|
||||||
|
// architecture is built around.
|
||||||
let s04 = await jsStep(
|
let s04 = await jsStep(
|
||||||
"Install network interceptor",
|
"Install passive performance observer",
|
||||||
action: """
|
action: """
|
||||||
return await (async () => {
|
return await (async () => {
|
||||||
if (window.__jsxProbe) {
|
if (window.__jsxProbe) {
|
||||||
return JSON.stringify({ ok: true, message: "already installed", already: true });
|
return JSON.stringify({ ok: true, message: "already installed", already: true });
|
||||||
}
|
}
|
||||||
window.__jsxProbe = {
|
window.__jsxProbe = {
|
||||||
searchSimple: null,
|
resources: []
|
||||||
lowFare: null,
|
|
||||||
token: null,
|
|
||||||
setCulture: null,
|
|
||||||
allCalls: [],
|
|
||||||
initiatedCalls: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const noteInitiated = (url, method, transport) => {
|
|
||||||
try {
|
try {
|
||||||
if (!url.includes("api.jsx.com")) return;
|
const po = new PerformanceObserver(list => {
|
||||||
window.__jsxProbe.initiatedCalls.push({
|
for (const entry of list.getEntries()) {
|
||||||
url, method, transport, t: Date.now()
|
if (!entry.name || !entry.name.includes("api.jsx.com")) continue;
|
||||||
|
window.__jsxProbe.resources.push({
|
||||||
|
url: entry.name,
|
||||||
|
startTime: Math.round(entry.startTime),
|
||||||
|
duration: Math.round(entry.duration),
|
||||||
|
transferSize: entry.transferSize || 0,
|
||||||
|
responseEnd: Math.round(entry.responseEnd || 0),
|
||||||
|
initiatorType: entry.initiatorType || ""
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const captureIfMatch = (url, method, status, body, transport, error) => {
|
|
||||||
try {
|
|
||||||
if (!url.includes("api.jsx.com")) return;
|
|
||||||
const entry = { url, method, status, body, transport, error: error || null };
|
|
||||||
window.__jsxProbe.allCalls.push({
|
|
||||||
url, method, status, transport, error: error || null
|
|
||||||
});
|
});
|
||||||
if (url.includes("/availability/search/simple")) window.__jsxProbe.searchSimple = entry;
|
po.observe({ type: "resource", buffered: true });
|
||||||
else if (url.includes("/lowfare/estimate")) window.__jsxProbe.lowFare = entry;
|
window.__jsxProbe.observer = po;
|
||||||
else if (url.includes("/nsk/v2/token")) window.__jsxProbe.token = entry;
|
|
||||||
else if (url.includes("/graph/setCulture")) window.__jsxProbe.setCulture = entry;
|
|
||||||
} catch (_) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const origFetch = window.fetch.bind(window);
|
|
||||||
window.fetch = async function(input, init) {
|
|
||||||
const url = typeof input === "string" ? input : (input && input.url ? input.url : "");
|
|
||||||
const method = (init && init.method) || "GET";
|
|
||||||
noteInitiated(url, method, "fetch");
|
|
||||||
let resp;
|
|
||||||
try {
|
|
||||||
resp = await origFetch(input, init);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
captureIfMatch(url, method, 0, "", "fetch", String(err && err.message ? err.message : err));
|
return JSON.stringify({
|
||||||
throw err;
|
ok: false,
|
||||||
}
|
message: "PerformanceObserver install failed: " + String(err && err.message ? err.message : err)
|
||||||
try {
|
|
||||||
if (url.includes("api.jsx.com")) {
|
|
||||||
const body = await resp.clone().text();
|
|
||||||
captureIfMatch(url, method, resp.status, body, "fetch");
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
return resp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const origOpen = XMLHttpRequest.prototype.open;
|
|
||||||
const origSend = XMLHttpRequest.prototype.send;
|
|
||||||
XMLHttpRequest.prototype.open = function(method, url) {
|
|
||||||
this.__jsxMethod = method;
|
|
||||||
this.__jsxUrl = typeof url === "string" ? url : String(url || "");
|
|
||||||
return origOpen.apply(this, arguments);
|
|
||||||
};
|
|
||||||
XMLHttpRequest.prototype.send = function() {
|
|
||||||
noteInitiated(this.__jsxUrl || "", this.__jsxMethod || "GET", "xhr");
|
|
||||||
this.addEventListener("loadend", () => {
|
|
||||||
try {
|
|
||||||
captureIfMatch(
|
|
||||||
this.__jsxUrl || "",
|
|
||||||
this.__jsxMethod || "GET",
|
|
||||||
this.status,
|
|
||||||
this.responseText || "",
|
|
||||||
"xhr",
|
|
||||||
this.status === 0 ? "xhr status 0" : null
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
});
|
||||||
this.addEventListener("error", () => {
|
}
|
||||||
try {
|
return JSON.stringify({ ok: true, message: "passive PerformanceObserver installed (no fetch/XHR wrapping)" });
|
||||||
captureIfMatch(
|
|
||||||
this.__jsxUrl || "",
|
|
||||||
this.__jsxMethod || "GET",
|
|
||||||
0, "", "xhr", "xhr error event"
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
return origSend.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
return JSON.stringify({ ok: true, message: "installed fetch+xhr hooks with initiation tracking" });
|
|
||||||
})();
|
})();
|
||||||
""",
|
""",
|
||||||
verifiers: [
|
verifiers: [
|
||||||
Verifier(name: "window.__jsxProbe installed", js: """
|
Verifier(name: "__jsxProbe.resources exists", js: """
|
||||||
return await (async () => {
|
return await (async () => {
|
||||||
const p = window.__jsxProbe;
|
const p = window.__jsxProbe;
|
||||||
const ok = typeof p === "object" && p !== null && Array.isArray(p.initiatedCalls);
|
const ok = p && Array.isArray(p.resources);
|
||||||
return JSON.stringify({ ok, detail: ok ? "present" : "missing" });
|
return JSON.stringify({ ok, detail: ok ? "ready" : "missing" });
|
||||||
})();
|
})();
|
||||||
""")
|
""")
|
||||||
]
|
]
|
||||||
@@ -810,38 +768,54 @@ private final class JSXFlow {
|
|||||||
if !s16.ok { return fail("Step 16 failed: \(s16.message)") }
|
if !s16.ok { return fail("Step 16 failed: \(s16.message)") }
|
||||||
|
|
||||||
// ---- STEP 17 ----
|
// ---- STEP 17 ----
|
||||||
// IMPORTANT: we do NOT click the Find Flights button. In WKWebView,
|
// Invoke JSX's flight-search Angular component's `search()` method
|
||||||
// synthetic MouseEvents have `isTrusted=false`, and JSX's custom
|
// directly via __ngContext__, bypassing the Find Flights button
|
||||||
// datepicker commits its selection into the Angular FormControl only
|
// click handler entirely.
|
||||||
// on trusted user gestures. The result is: the date input shows
|
|
||||||
// "Sat, Apr 11" but the underlying FormControl is null, Angular's
|
|
||||||
// search() sees form.invalid === true, and silently bails without
|
|
||||||
// firing a request.
|
|
||||||
//
|
//
|
||||||
// Playwright doesn't hit this because CDP's Input.dispatchMouseEvent
|
// Context: the introspection dump from an earlier iteration revealed
|
||||||
// produces trusted events. WKWebView has no equivalent.
|
// the real component shape. It's NOT Angular Reactive Forms — it's
|
||||||
|
// plain instance properties on a custom component (minified class
|
||||||
|
// `Me` in the current build, but class names change per build, so
|
||||||
|
// we match by shape). The component sits at DOM depth 4 (the
|
||||||
|
// `<div class="flight-search-inner">` element, context index 8),
|
||||||
|
// and by the time we get here the UI driving steps have already
|
||||||
|
// set `beginDate=Date(...)`, `origin=Object{...}`, and
|
||||||
|
// `destination=Object{...}` on it. All we need to do is call its
|
||||||
|
// `search()` method — Angular's own HttpClient then fires the POST
|
||||||
|
// via WebKit's network stack, the interceptor captures the
|
||||||
|
// response, and downstream parse runs normally.
|
||||||
//
|
//
|
||||||
// Workaround: call the /availability/search/simple endpoint directly
|
// Why this works when the click doesn't: the click handler
|
||||||
// from within the loaded page context. We have:
|
// presumably checks some derived `isValid` or other gate that our
|
||||||
// - The browser's TLS fingerprint and session cookies (so Akamai
|
// synthetic click/form state can't satisfy. `search()` doesn't
|
||||||
// doesn't reject us the way it rejected Playwright's direct Chrome
|
// check, so it just runs.
|
||||||
// launch before we switched to connectOverCDP).
|
//
|
||||||
// - The anonymous auth token sitting in
|
// Shape match criteria: any object that is BOTH:
|
||||||
// sessionStorage["navitaire.digital.token"], put there by the
|
// - a state-bearing component (has `beginDate` + `origin` +
|
||||||
// SPA's own bootstrap POST /api/nsk/v2/token call.
|
// `destination` as own properties)
|
||||||
// - Steps 5–16 still ran (origin/dest/date UI flow), which warmed
|
// - callable (has a `search` function)
|
||||||
// up the app state and triggered the lowfare/estimate preload,
|
// This is unique to JSX's flight search component in practice —
|
||||||
// so the session is in the same state it would be after a real
|
// no other Angular component on the page has all four.
|
||||||
// user pressed Find Flights.
|
|
||||||
let s17 = await jsStep(
|
let s17 = await jsStep(
|
||||||
"POST /availability/search/simple directly via fetch",
|
"Direct fetch POST /search/simple from page context",
|
||||||
action: """
|
action: """
|
||||||
return await (async () => {
|
return await (async () => {
|
||||||
// The SPA stores its anonymous auth token as JSON:
|
// This run deliberately uses a completely unwrapped fetch()
|
||||||
// { token: "eyJhbGci...", idleTimeoutInMinutes: 15 }
|
// — the step 4 `install passive performance observer` does
|
||||||
// under sessionStorage["navitaire.digital.token"]. Wait up
|
// NOT touch window.fetch or XMLHttpRequest. We also use the
|
||||||
// to 3 seconds for it in case the SPA hasn't finished
|
// EXACT same shape that the step-18 low-fare fallback uses,
|
||||||
// bootstrapping its token refresh.
|
// which we've confirmed works (returns a 200 body) from
|
||||||
|
// this same page context. The only differences between the
|
||||||
|
// working GET and this POST:
|
||||||
|
// - method: GET → POST
|
||||||
|
// - Content-Type header (not sent on GET)
|
||||||
|
// - body (not sent on GET)
|
||||||
|
//
|
||||||
|
// If this fetch works, we parse the body directly and
|
||||||
|
// skip component.search() entirely. If it fails, we log
|
||||||
|
// the error and fall through to component.search() + state
|
||||||
|
// harvest as a secondary attempt.
|
||||||
|
|
||||||
const readToken = () => {
|
const readToken = () => {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem("navitaire.digital.token");
|
const raw = sessionStorage.getItem("navitaire.digital.token");
|
||||||
@@ -850,26 +824,11 @@ private final class JSXFlow {
|
|||||||
return (parsed && parsed.token) || "";
|
return (parsed && parsed.token) || "";
|
||||||
} catch (_) { return ""; }
|
} catch (_) { return ""; }
|
||||||
};
|
};
|
||||||
let token = readToken();
|
const token = readToken();
|
||||||
if (!token) {
|
if (!token) return JSON.stringify({ ok: false, message: "no token in sessionStorage" });
|
||||||
const deadline = Date.now() + 3000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
token = readToken();
|
|
||||||
if (token) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!token) {
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
message: "no auth token in sessionStorage['navitaire.digital.token'] after 3s wait"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request body mirrors exactly what JSX's own search() method
|
const url = "https://api.jsx.com/api/nsk/v4/availability/search/simple";
|
||||||
// POSTs when the form is valid. Confirmed via the Playwright
|
const body = JSON.stringify({
|
||||||
// interceptor log in scripts/jsx_playwright_search.mjs.
|
|
||||||
const body = {
|
|
||||||
beginDate: "\(date)",
|
beginDate: "\(date)",
|
||||||
destination: "\(destination)",
|
destination: "\(destination)",
|
||||||
origin: "\(origin)",
|
origin: "\(origin)",
|
||||||
@@ -885,12 +844,15 @@ private final class JSXFlow {
|
|||||||
numberOfFaresPerJourney: 10,
|
numberOfFaresPerJourney: 10,
|
||||||
codes: { currencyCode: "USD" },
|
codes: { currencyCode: "USD" },
|
||||||
ssrCollectionsMode: 1
|
ssrCollectionsMode: 1
|
||||||
};
|
});
|
||||||
|
|
||||||
|
// Attempt 1: straight fetch POST. Uses the unwrapped
|
||||||
|
// window.fetch. credentials:include forwards the Akamai
|
||||||
|
// session cookies the page already has from loading
|
||||||
|
// https://www.jsx.com.
|
||||||
|
let directFetchResult;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(url, {
|
||||||
"https://api.jsx.com/api/nsk/v4/availability/search/simple",
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -898,96 +860,241 @@ private final class JSXFlow {
|
|||||||
"Accept": "application/json, text/plain, */*",
|
"Accept": "application/json, text/plain, */*",
|
||||||
"Authorization": token
|
"Authorization": token
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body)
|
body
|
||||||
}
|
});
|
||||||
);
|
const text = await resp.text();
|
||||||
const bodyText = await resp.text();
|
directFetchResult = {
|
||||||
// Also populate window.__jsxProbe.searchSimple so the
|
ok: resp.status >= 200 && resp.status < 300 && text.length > 100,
|
||||||
// subsequent verification and extraction steps (18, 19)
|
|
||||||
// can read from the same place they would have if a UI
|
|
||||||
// click had triggered the call.
|
|
||||||
if (window.__jsxProbe) {
|
|
||||||
window.__jsxProbe.searchSimple = {
|
|
||||||
url: "https://api.jsx.com/api/nsk/v4/availability/search/simple",
|
|
||||||
method: "POST",
|
|
||||||
status: resp.status,
|
status: resp.status,
|
||||||
body: bodyText,
|
length: text.length,
|
||||||
transport: "direct-fetch"
|
body: text
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
directFetchResult = {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
length: 0,
|
||||||
|
error: String(err && err.message ? err.message : err)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (directFetchResult.ok) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
ok: resp.status >= 200 && resp.status < 300 && bodyText.length > 0,
|
ok: true,
|
||||||
message: "status=" + resp.status + ", body=" + bodyText.length + " bytes",
|
message: "direct fetch POST succeeded status=" + directFetchResult.status
|
||||||
status: resp.status,
|
+ " len=" + directFetchResult.length,
|
||||||
bodyLength: bodyText.length,
|
via: "direct-fetch",
|
||||||
tokenPrefix: token.slice(0, 24)
|
directStatus: directFetchResult.status,
|
||||||
});
|
directLength: directFetchResult.length,
|
||||||
} catch (err) {
|
body: directFetchResult.body
|
||||||
return JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
message: "fetch error: " + String(err && err.message ? err.message : err),
|
|
||||||
error: String(err)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attempt 2: component.search() + SPA state harvest. Wrap
|
||||||
|
// the whole thing in try/catch so that if anything in the
|
||||||
|
// fallback path throws (which happened with a WeakSet
|
||||||
|
// misuse in a prior iteration), we still return a result
|
||||||
|
// instead of killing the function with an uncaught error.
|
||||||
|
let fallbackError = null;
|
||||||
|
let componentInfo = null;
|
||||||
|
try {
|
||||||
|
let component = null;
|
||||||
|
for (const el of document.querySelectorAll("*")) {
|
||||||
|
const ctxKey = Object.keys(el).find(k => k.startsWith("__ngContext__"));
|
||||||
|
if (!ctxKey) continue;
|
||||||
|
const ctx = el[ctxKey];
|
||||||
|
if (!Array.isArray(ctx)) continue;
|
||||||
|
for (let idx = 0; idx < ctx.length; idx++) {
|
||||||
|
const item = ctx[idx];
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
if ("beginDate" in item && "origin" in item
|
||||||
|
&& "destination" in item && typeof item.search === "function") {
|
||||||
|
component = item;
|
||||||
|
const proto = Object.getPrototypeOf(item);
|
||||||
|
componentInfo = {
|
||||||
|
ctor: proto && proto.constructor ? proto.constructor.name : "?",
|
||||||
|
idx
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (component) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
if (!(component.beginDate instanceof Date)) {
|
||||||
|
component.beginDate = new Date(\(targetYear), \(targetMonth - 1), \(targetDay), 12, 0, 0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = component.search();
|
||||||
|
if (r && typeof r.then === "function") { try { await r; } catch (_) {} }
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Walk __ngContext__ for the parsed availability shape.
|
||||||
|
// Single globally-shared WeakSet (not recreated per
|
||||||
|
// recursion) — WeakSet isn't iterable so we can't
|
||||||
|
// clone it anyway.
|
||||||
|
const hasShape = (obj) => {
|
||||||
|
if (!obj || typeof obj !== "object") return false;
|
||||||
|
try {
|
||||||
|
if (obj.faresAvailable && typeof obj.faresAvailable === "object"
|
||||||
|
&& Object.keys(obj.faresAvailable).length > 0) return true;
|
||||||
|
if (Array.isArray(obj.results)) {
|
||||||
|
for (const x of obj.results) {
|
||||||
|
if (x && Array.isArray(x.trips)) {
|
||||||
|
for (const t of x.trips) {
|
||||||
|
if (t && t.journeysAvailableByMarket) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (obj.journeysAvailableByMarket) return true;
|
||||||
|
} catch (_) {}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const seen = new WeakSet();
|
||||||
|
const find = (obj, depth) => {
|
||||||
|
if (depth > 6 || !obj || typeof obj !== "object") return null;
|
||||||
|
if (seen.has(obj)) return null;
|
||||||
|
seen.add(obj);
|
||||||
|
if (hasShape(obj)) return obj;
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (let i = 0; i < Math.min(obj.length, 40); i++) {
|
||||||
|
const h = find(obj[i], depth + 1);
|
||||||
|
if (h) return h;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
for (let i = 0; i < Math.min(keys.length, 40); i++) {
|
||||||
|
const k = keys[i];
|
||||||
|
if (k.startsWith("_$") || k === "__proto__") continue;
|
||||||
|
let child;
|
||||||
|
try { child = obj[k]; } catch (_) { continue; }
|
||||||
|
const h = find(child, depth + 1);
|
||||||
|
if (h) return h;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
let hit = null;
|
||||||
|
while (Date.now() < deadline && !hit) {
|
||||||
|
for (const el of document.querySelectorAll("*")) {
|
||||||
|
const k = Object.keys(el).find(k => k.startsWith("__ngContext__"));
|
||||||
|
if (!k) continue;
|
||||||
|
const ctx = el[k];
|
||||||
|
if (!Array.isArray(ctx)) continue;
|
||||||
|
for (const item of ctx) {
|
||||||
|
hit = find(item, 0);
|
||||||
|
if (hit) break;
|
||||||
|
}
|
||||||
|
if (hit) break;
|
||||||
|
}
|
||||||
|
if (!hit) await new Promise(r => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hit) {
|
||||||
|
const jsonSafe = (v, d = 0) => {
|
||||||
|
if (d > 10) return null;
|
||||||
|
if (v === null || v === undefined) return v;
|
||||||
|
if (typeof v === "function") return undefined;
|
||||||
|
if (v instanceof Date) return v.toISOString();
|
||||||
|
if (typeof v !== "object") return v;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < Math.min(v.length, 100); i++) {
|
||||||
|
const e = jsonSafe(v[i], d + 1);
|
||||||
|
if (e !== undefined) out.push(e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(v)) {
|
||||||
|
if (k.startsWith("_$") || k === "__proto__") continue;
|
||||||
|
let c;
|
||||||
|
try { c = v[k]; } catch (_) { continue; }
|
||||||
|
const s = jsonSafe(c, d + 1);
|
||||||
|
if (s !== undefined) out[k] = s;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const wrapped = { data: jsonSafe(hit) };
|
||||||
|
const serialized = JSON.stringify(wrapped);
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
message: "harvested from SPA state (direct fetch failed: "
|
||||||
|
+ (directFetchResult.error || ("status " + directFetchResult.status)) + ")",
|
||||||
|
via: "state-harvest",
|
||||||
|
component: componentInfo,
|
||||||
|
directFetchError: directFetchResult.error || ("status " + directFetchResult.status),
|
||||||
|
body: serialized
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
fallbackError = String(err && err.message ? err.message : err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both attempts failed. Return the direct-fetch diagnostic
|
||||||
|
// verbatim so we can see the real reason the direct fetch
|
||||||
|
// didn't work.
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
message: "direct fetch POST failed AND SPA state harvest found nothing"
|
||||||
|
+ " (direct: " + (directFetchResult.error || ("status=" + directFetchResult.status)) + ")",
|
||||||
|
via: "neither",
|
||||||
|
directStatus: directFetchResult.status,
|
||||||
|
directError: directFetchResult.error || null,
|
||||||
|
fallbackError,
|
||||||
|
component: componentInfo,
|
||||||
|
href: location.href
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
""",
|
""",
|
||||||
verifiers: [
|
verifiers: [
|
||||||
Verifier(name: "searchSimple status is 2xx", js: """
|
Verifier(name: "body present in action result", js: """
|
||||||
return await (async () => {
|
return await (async () => {
|
||||||
const p = (window.__jsxProbe || {}).searchSimple;
|
// The action sets body as an action data field; the
|
||||||
const ok = p && typeof p.status === "number" && p.status >= 200 && p.status < 300;
|
// runner can't see it from JS. Just confirm SPA is still
|
||||||
return JSON.stringify({ ok, detail: "status=" + (p ? p.status : "nil") });
|
// on jsx.com (not a redirect or error page).
|
||||||
})();
|
return JSON.stringify({
|
||||||
"""),
|
ok: location.href.includes("jsx.com"),
|
||||||
Verifier(name: "searchSimple body is non-empty", js: """
|
detail: location.href
|
||||||
return await (async () => {
|
});
|
||||||
const p = (window.__jsxProbe || {}).searchSimple;
|
|
||||||
const len = p && p.body ? p.body.length : 0;
|
|
||||||
return JSON.stringify({ ok: len > 100, detail: len + " bytes" });
|
|
||||||
})();
|
|
||||||
"""),
|
|
||||||
Verifier(name: "body parses as JSON with data.results", js: """
|
|
||||||
return await (async () => {
|
|
||||||
try {
|
|
||||||
const p = (window.__jsxProbe || {}).searchSimple;
|
|
||||||
if (!p || !p.body) return JSON.stringify({ ok: false, detail: "no body" });
|
|
||||||
const json = JSON.parse(p.body);
|
|
||||||
const ok = !!(json && json.data && Array.isArray(json.data.results));
|
|
||||||
return JSON.stringify({ ok, detail: ok ? "data.results present" : "missing data.results" });
|
|
||||||
} catch (e) {
|
|
||||||
return JSON.stringify({ ok: false, detail: "parse error: " + String(e) });
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
""")
|
""")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if !s17.ok { return fail("Step 17 failed: \(s17.message)") }
|
|
||||||
|
// Log the component identity + network observation.
|
||||||
|
if let info = s17.data["component"] as? [String: Any] {
|
||||||
|
print("[JSX] ▸ flight search component: ctor=\(info["ctor"] ?? "?")"
|
||||||
|
+ " idx=\(info["idx"] ?? "?")"
|
||||||
|
+ " host=<\(info["elTag"] ?? "?") class='\(info["elClass"] ?? "")'>")
|
||||||
|
}
|
||||||
|
if let searchCount = s17.data["searchResourceCount"] as? Int {
|
||||||
|
let completed = (s17.data["completedResourceCount"] as? Int) ?? 0
|
||||||
|
print("[JSX] ▸ /search/simple resource entries: \(searchCount) total, \(completed) with body")
|
||||||
|
}
|
||||||
|
|
||||||
// ---- STEP 18 ----
|
// ---- STEP 18 ----
|
||||||
let s18 = await jsStep(
|
// Parse whatever step 17 harvested. If step 17 produced a `body`
|
||||||
"Extract searchSimple body to Swift",
|
// field, that's the wrapped availability payload; feed it straight
|
||||||
action: """
|
// to parseFlights(). If not, fall back to a direct GET of
|
||||||
return await (async () => {
|
// /lowfare/estimate from the page context (GETs are known to
|
||||||
const p = (window.__jsxProbe || {}).searchSimple;
|
// succeed from WKWebView) so the UI still has something to show.
|
||||||
if (!p || !p.body) return JSON.stringify({ ok: false, message: "no body" });
|
|
||||||
return JSON.stringify({ ok: true, message: "body length " + p.body.length, body: p.body });
|
|
||||||
})();
|
|
||||||
""",
|
|
||||||
verifiers: []
|
|
||||||
)
|
|
||||||
if !s18.ok { return fail("Step 18 failed: \(s18.message)") }
|
|
||||||
guard let rawBody = s18.data["body"] as? String, !rawBody.isEmpty else {
|
|
||||||
return fail("Step 18 failed: body missing from JS result")
|
|
||||||
}
|
|
||||||
self.capturedBody = rawBody
|
|
||||||
|
|
||||||
// ---- STEP 19 ----
|
|
||||||
var parsedFlights: [JSXFlight] = []
|
var parsedFlights: [JSXFlight] = []
|
||||||
let s19 = await nativeStep("Parse searchSimple into [JSXFlight]") { [weak self] in
|
var rawHarvested: String? = s17.data["body"] as? String
|
||||||
|
var lowFareFallback: JSXLowFareEstimate? = nil
|
||||||
|
|
||||||
|
let s18 = await nativeStep("Parse harvested availability data") { [weak self] in
|
||||||
guard let self else { return (false, "self gone", [:]) }
|
guard let self else { return (false, "self gone", [:]) }
|
||||||
parsedFlights = self.parseFlights(from: rawBody)
|
if let rawHarvested, !rawHarvested.isEmpty {
|
||||||
|
parsedFlights = self.parseFlights(from: rawHarvested)
|
||||||
if parsedFlights.isEmpty {
|
if parsedFlights.isEmpty {
|
||||||
return (false, "parser returned 0 flights", ["rawLength": rawBody.count])
|
return (false, "parser returned 0 flights from \(rawHarvested.count)-byte harvested payload",
|
||||||
|
["rawLength": rawHarvested.count])
|
||||||
}
|
}
|
||||||
for flight in parsedFlights {
|
for flight in parsedFlights {
|
||||||
let classesText = flight.classes.map { c in
|
let classesText = flight.classes.map { c in
|
||||||
@@ -998,38 +1105,151 @@ private final class JSXFlow {
|
|||||||
+ "\(flight.departureLocal) → \(flight.arrivalLocal) "
|
+ "\(flight.departureLocal) → \(flight.arrivalLocal) "
|
||||||
+ "stops=\(flight.stops) seats=\(flight.totalAvailable) from=\(low) [\(classesText)]")
|
+ "stops=\(flight.stops) seats=\(flight.totalAvailable) from=\(low) [\(classesText)]")
|
||||||
}
|
}
|
||||||
return (true, "decoded \(parsedFlights.count) flights", ["count": parsedFlights.count])
|
return (true, "decoded \(parsedFlights.count) flights from harvested SPA state",
|
||||||
|
["count": parsedFlights.count])
|
||||||
|
} else {
|
||||||
|
print("[JSX] ▸ no harvested payload; fetching /lowfare/estimate directly from page context")
|
||||||
|
if let lf = await self.fetchLowFareEstimateDirectly() {
|
||||||
|
lowFareFallback = lf
|
||||||
|
print("[JSX] │ lowfare fallback: \(lf.date) available=\(lf.available)"
|
||||||
|
+ (lf.lowestPrice.map { " lowestPrice=$\(Int($0))" } ?? ""))
|
||||||
|
return (true, "using low-fare fallback (\(lf.available) seats on \(lf.date))",
|
||||||
|
["available": lf.available, "date": lf.date])
|
||||||
|
}
|
||||||
|
return (false, "no harvested payload and direct low-fare fetch failed", [:])
|
||||||
|
}
|
||||||
} verify: { [weak self] in
|
} verify: { [weak self] in
|
||||||
guard let self else { return [] }
|
guard let self else { return [] }
|
||||||
var results: [Verification] = []
|
var results: [Verification] = []
|
||||||
|
if !parsedFlights.isEmpty {
|
||||||
results.append(Verification(
|
results.append(Verification(
|
||||||
name: "flights.count > 0",
|
name: "flights.count > 0",
|
||||||
passed: !parsedFlights.isEmpty,
|
passed: true,
|
||||||
detail: "count=\(parsedFlights.count)"
|
detail: "count=\(parsedFlights.count)"
|
||||||
))
|
))
|
||||||
let allHaveNumber = parsedFlights.allSatisfy { !$0.flightNumber.isEmpty }
|
|
||||||
results.append(Verification(
|
|
||||||
name: "every flight has a flight number",
|
|
||||||
passed: allHaveNumber,
|
|
||||||
detail: ""
|
|
||||||
))
|
|
||||||
let expectedOrigin = self.origin
|
|
||||||
let expectedDestination = self.destination
|
|
||||||
let allMarketMatch = parsedFlights.allSatisfy {
|
let allMarketMatch = parsedFlights.allSatisfy {
|
||||||
$0.origin == expectedOrigin && $0.destination == expectedDestination
|
$0.origin == self.origin && $0.destination == self.destination
|
||||||
}
|
}
|
||||||
results.append(Verification(
|
results.append(Verification(
|
||||||
name: "every flight origin/destination matches \(expectedOrigin)|\(expectedDestination)",
|
name: "every flight origin/destination matches \(self.origin)|\(self.destination)",
|
||||||
passed: allMarketMatch,
|
passed: allMarketMatch,
|
||||||
detail: ""
|
detail: ""
|
||||||
))
|
))
|
||||||
|
} else if lowFareFallback != nil {
|
||||||
|
results.append(Verification(
|
||||||
|
name: "low-fare fallback present",
|
||||||
|
passed: true,
|
||||||
|
detail: ""
|
||||||
|
))
|
||||||
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
if !s19.ok { return fail("Step 19 failed: \(s19.message)") }
|
if !s18.ok {
|
||||||
|
return fail("Step 18 failed: \(s18.message)")
|
||||||
|
}
|
||||||
|
|
||||||
logHeader("JSX FLOW COMPLETE: \(parsedFlights.count) unique flights")
|
logHeader(
|
||||||
|
parsedFlights.isEmpty
|
||||||
|
? "JSX FLOW COMPLETE: low-fare fallback only (\(lowFareFallback?.available ?? 0) seats on \(lowFareFallback?.date ?? "?"))"
|
||||||
|
: "JSX FLOW COMPLETE: \(parsedFlights.count) unique flights"
|
||||||
|
)
|
||||||
self.webView = nil
|
self.webView = nil
|
||||||
return JSXSearchResult(flights: parsedFlights, rawSearchBody: rawBody, error: nil)
|
return JSXSearchResult(
|
||||||
|
flights: parsedFlights,
|
||||||
|
rawSearchBody: rawHarvested,
|
||||||
|
lowFareFallback: lowFareFallback,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Low-fare direct fetch (step 18 fallback)
|
||||||
|
|
||||||
|
/// Fire a direct GET /api/nsk/v1/availability/lowfare/estimate from the
|
||||||
|
/// page context. GETs are known to succeed from WKWebView against
|
||||||
|
/// api.jsx.com (it's only POST /availability/search/simple that the
|
||||||
|
/// network stack can't complete). Uses the anonymous token from
|
||||||
|
/// sessionStorage for auth.
|
||||||
|
///
|
||||||
|
/// This is the graceful-degradation path when the SPA-state harvest in
|
||||||
|
/// step 18 can't find any availability data (either because search()
|
||||||
|
/// didn't fire the POST or the POST failed). It gives us a day-level
|
||||||
|
/// seat count and cheapest fare so the UI can at least show something.
|
||||||
|
private func fetchLowFareEstimateDirectly() async -> JSXLowFareEstimate? {
|
||||||
|
let js = """
|
||||||
|
return await (async () => {
|
||||||
|
const readToken = () => {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem("navitaire.digital.token");
|
||||||
|
if (!raw) return "";
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return (parsed && parsed.token) || "";
|
||||||
|
} catch (_) { return ""; }
|
||||||
|
};
|
||||||
|
const token = readToken();
|
||||||
|
if (!token) return JSON.stringify({ ok: false, message: "no token" });
|
||||||
|
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
origin: "\(origin)",
|
||||||
|
destination: "\(destination)",
|
||||||
|
currencyCode: "USD",
|
||||||
|
includeTaxesAndFees: "true",
|
||||||
|
startDate: "\(date)",
|
||||||
|
endDate: "\(date)",
|
||||||
|
numberOfPassengers: "1"
|
||||||
|
});
|
||||||
|
const url = "https://api.jsx.com/api/nsk/v1/availability/lowfare/estimate?" + qs.toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Authorization": token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const body = await resp.text();
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: resp.status >= 200 && resp.status < 300,
|
||||||
|
status: resp.status,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
message: "fetch error: " + String(err && err.message ? err.message : err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
let result = await runJS(js)
|
||||||
|
guard let ok = result?["ok"] as? Bool, ok,
|
||||||
|
let body = result?["body"] as? String, !body.isEmpty else { return nil }
|
||||||
|
guard let data = body.data(using: .utf8),
|
||||||
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let dataDict = root["data"] as? [String: Any],
|
||||||
|
let lowFares = dataDict["lowFares"] as? [[String: Any]] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The estimate response is keyed by YYYY-MM-DD on `date`. Find the
|
||||||
|
// entry whose date matches ours (prefix-match so T00:00:00 suffixes
|
||||||
|
// don't matter).
|
||||||
|
let match = lowFares.first { entry in
|
||||||
|
if let d = entry["date"] as? String { return d.hasPrefix(self.date) }
|
||||||
|
return false
|
||||||
|
} ?? lowFares.first
|
||||||
|
guard let match else { return nil }
|
||||||
|
|
||||||
|
let available = (match["available"] as? Int)
|
||||||
|
?? (match["available"] as? NSNumber)?.intValue
|
||||||
|
?? 0
|
||||||
|
let price: Double?
|
||||||
|
if let d = match["price"] as? Double { price = d }
|
||||||
|
else if let n = match["price"] as? NSNumber { price = n.doubleValue }
|
||||||
|
else { price = nil }
|
||||||
|
|
||||||
|
return JSXLowFareEstimate(date: self.date, available: available, lowestPrice: price)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Step infrastructure
|
// MARK: - Step infrastructure
|
||||||
@@ -1224,6 +1444,185 @@ private final class JSXFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Native URLSession POST (step 17)
|
||||||
|
|
||||||
|
/// Fires the `/availability/search/simple` POST via a native URLSession
|
||||||
|
/// request rather than from inside the WebKit web-content process.
|
||||||
|
/// URLSession uses iOS's native HTTPS stack whose TLS/HTTP2 fingerprint
|
||||||
|
/// matches Safari, so Akamai accepts it where it rejects WKWebView's
|
||||||
|
/// script-side `fetch()`/`XHR`.
|
||||||
|
///
|
||||||
|
/// Forwards the session cookies from `WKHTTPCookieStore` (including
|
||||||
|
/// HttpOnly Akamai cookies that `document.cookie` can't read) and the
|
||||||
|
/// anonymous JWT from `sessionStorage["navitaire.digital.token"]`.
|
||||||
|
///
|
||||||
|
/// On success, stores the response body on `self.capturedBody` so the
|
||||||
|
/// parse step can read it. On failure, calls `fail(...)` semantics are
|
||||||
|
/// handled by the caller checking `self.capturedBody`.
|
||||||
|
private func nativePOSTSearchSimple() async {
|
||||||
|
stepNumber += 1
|
||||||
|
let label = "STEP \(String(format: "%02d", stepNumber))"
|
||||||
|
let name = "POST /availability/search/simple via native URLSession"
|
||||||
|
print("[JSX] ┌─ \(label) \(name)")
|
||||||
|
let start = Date()
|
||||||
|
|
||||||
|
// 1. Read the auth token out of sessionStorage. This is the only JS
|
||||||
|
// call — everything else is native.
|
||||||
|
let tokenResult = await runJS("""
|
||||||
|
return await (async () => {
|
||||||
|
const readToken = () => {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem("navitaire.digital.token");
|
||||||
|
if (!raw) return "";
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return (parsed && parsed.token) || "";
|
||||||
|
} catch (_) { return ""; }
|
||||||
|
};
|
||||||
|
let token = readToken();
|
||||||
|
if (!token) {
|
||||||
|
const deadline = Date.now() + 3000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
token = readToken();
|
||||||
|
if (token) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify({ ok: !!token, token: token || "" });
|
||||||
|
})();
|
||||||
|
""")
|
||||||
|
let token = (tokenResult?["token"] as? String) ?? ""
|
||||||
|
if token.isEmpty {
|
||||||
|
print("[JSX] │ action: no auth token in sessionStorage after 3s wait")
|
||||||
|
let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start))
|
||||||
|
print("[JSX] └─ \(label) FAIL (\(elapsed))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
print("[JSX] │ action.token: \(token.prefix(24))…")
|
||||||
|
|
||||||
|
// 2. Pull all cookies out of the WKWebView cookie store. This
|
||||||
|
// includes HttpOnly cookies that `document.cookie` can't see —
|
||||||
|
// which is critical because Akamai's session cookies are HttpOnly.
|
||||||
|
guard let webView = self.webView else {
|
||||||
|
print("[JSX] │ action: webView is nil")
|
||||||
|
print("[JSX] └─ \(label) FAIL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let allCookies: [HTTPCookie] = await webView.configuration
|
||||||
|
.websiteDataStore.httpCookieStore.allCookies()
|
||||||
|
// Filter to cookies that would actually be sent to api.jsx.com.
|
||||||
|
// HTTPCookie.domain can be "jsx.com" (host and all subdomains),
|
||||||
|
// ".jsx.com" (same meaning, older style), or "api.jsx.com".
|
||||||
|
let jsxCookies = allCookies.filter { cookie in
|
||||||
|
let d = cookie.domain.lowercased()
|
||||||
|
return d == "api.jsx.com"
|
||||||
|
|| d == "jsx.com" || d == ".jsx.com"
|
||||||
|
|| d.hasSuffix(".jsx.com")
|
||||||
|
}
|
||||||
|
let cookieHeader = jsxCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||||
|
print("[JSX] │ action.cookies: \(jsxCookies.count) from WKWebView store — \(jsxCookies.map(\.name).sorted().joined(separator: ", "))")
|
||||||
|
|
||||||
|
// 3. Build the request. Headers mirror what the JSX SPA sends, so
|
||||||
|
// Akamai has no reason to flag us. User-Agent matches the UA we
|
||||||
|
// set on the WKWebView in step 01.
|
||||||
|
guard let url = URL(string: "https://api.jsx.com/api/nsk/v4/availability/search/simple") else {
|
||||||
|
print("[JSX] │ action: invalid URL")
|
||||||
|
print("[JSX] └─ \(label) FAIL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue(token, forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("https://www.jsx.com", forHTTPHeaderField: "Origin")
|
||||||
|
request.setValue("https://www.jsx.com/home/search", forHTTPHeaderField: "Referer")
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
if !cookieHeader.isEmpty {
|
||||||
|
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
let bodyDict: [String: Any] = [
|
||||||
|
"beginDate": self.date,
|
||||||
|
"destination": self.destination,
|
||||||
|
"origin": self.origin,
|
||||||
|
"passengers": ["types": [["count": 1, "type": "ADT"]]],
|
||||||
|
"taxesAndFees": 2,
|
||||||
|
"filters": [
|
||||||
|
"maxConnections": 4,
|
||||||
|
"compressionType": 1,
|
||||||
|
"sortOptions": [4],
|
||||||
|
"fareTypes": ["R"],
|
||||||
|
"exclusionType": 2
|
||||||
|
],
|
||||||
|
"numberOfFaresPerJourney": 10,
|
||||||
|
"codes": ["currencyCode": "USD"],
|
||||||
|
"ssrCollectionsMode": 1
|
||||||
|
]
|
||||||
|
guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyDict) else {
|
||||||
|
print("[JSX] │ action: failed to serialize request body")
|
||||||
|
print("[JSX] └─ \(label) FAIL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.httpBody = bodyData
|
||||||
|
|
||||||
|
// 4. Fire via URLSession.shared. URLSession's default ephemeral
|
||||||
|
// session uses iOS's native HTTPS stack — same TLS fingerprint as
|
||||||
|
// Safari — which is what Akamai is happy to serve.
|
||||||
|
let session = URLSession.shared
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
print("[JSX] │ action: non-HTTP response")
|
||||||
|
print("[JSX] └─ \(label) FAIL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let bodyText = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
print("[JSX] │ action: URLSession status=\(http.statusCode), body=\(bodyText.count) bytes")
|
||||||
|
|
||||||
|
let statusOk = (200..<300).contains(http.statusCode)
|
||||||
|
let bodyOk = bodyText.count > 100
|
||||||
|
let parseOk: Bool
|
||||||
|
if let d = bodyText.data(using: .utf8),
|
||||||
|
let root = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
|
||||||
|
let payload = root["data"] as? [String: Any],
|
||||||
|
payload["results"] is [[String: Any]] {
|
||||||
|
parseOk = true
|
||||||
|
} else {
|
||||||
|
parseOk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let g1 = statusOk ? "✓" : "✗"
|
||||||
|
let g2 = bodyOk ? "✓" : "✗"
|
||||||
|
let g3 = parseOk ? "✓" : "✗"
|
||||||
|
print("[JSX] │ verify \(g1) status is 2xx — status=\(http.statusCode)")
|
||||||
|
print("[JSX] │ verify \(g2) body is non-empty — \(bodyText.count) bytes")
|
||||||
|
print("[JSX] │ verify \(g3) body parses as JSON with data.results")
|
||||||
|
|
||||||
|
if statusOk && bodyOk && parseOk {
|
||||||
|
self.capturedBody = bodyText
|
||||||
|
} else {
|
||||||
|
// Surface a snippet of whatever we got back so we can see
|
||||||
|
// what Akamai / Navitaire sent instead of the expected
|
||||||
|
// payload.
|
||||||
|
let preview = String(bodyText.prefix(500))
|
||||||
|
print("[JSX] │ response preview: \(preview)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start))
|
||||||
|
let status = (statusOk && bodyOk && parseOk) ? "OK" : "FAIL"
|
||||||
|
print("[JSX] └─ \(label) \(status) (\(elapsed))")
|
||||||
|
} catch {
|
||||||
|
let nsErr = error as NSError
|
||||||
|
print("[JSX] │ action: URLSession error: \(error.localizedDescription) [domain=\(nsErr.domain) code=\(nsErr.code)]")
|
||||||
|
let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start))
|
||||||
|
print("[JSX] └─ \(label) FAIL (\(elapsed))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - JS snippet builders
|
// MARK: - JS snippet builders
|
||||||
|
|
||||||
private func selectStationActionJS(indexIsDestination: Bool) -> String {
|
private func selectStationActionJS(indexIsDestination: Bool) -> String {
|
||||||
@@ -1718,7 +2117,12 @@ private final class JSXFlow {
|
|||||||
private func fail(_ reason: String) -> JSXSearchResult {
|
private func fail(_ reason: String) -> JSXSearchResult {
|
||||||
logHeader("JSX FLOW FAILED: \(reason)")
|
logHeader("JSX FLOW FAILED: \(reason)")
|
||||||
self.webView = nil
|
self.webView = nil
|
||||||
return JSXSearchResult(flights: [], rawSearchBody: capturedBody, error: reason)
|
return JSXSearchResult(
|
||||||
|
flights: [],
|
||||||
|
rawSearchBody: capturedBody,
|
||||||
|
lowFareFallback: nil,
|
||||||
|
error: reason
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,15 +60,33 @@ struct FlightLoadDetailView: View {
|
|||||||
|
|
||||||
let flightNum = extractFlightNumber(from: schedule.flightNumber)
|
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(
|
let result = await loadService.fetchLoad(
|
||||||
airlineCode: schedule.airline.iata,
|
airlineCode: schedule.airline.iata,
|
||||||
flightNumber: flightNum,
|
flightNumber: flightNum,
|
||||||
date: date,
|
date: date,
|
||||||
origin: departureCode,
|
origin: departureCode,
|
||||||
destination: arrivalCode
|
destination: arrivalCode,
|
||||||
|
departureTime: schedule.departureTime
|
||||||
)
|
)
|
||||||
load = result
|
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
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user