From 4d46b836a111f02e09362f3a8f1738002b919f58 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 11 Apr 2026 13:44:30 -0500 Subject: [PATCH] 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) --- Flights/Services/AirlineLoadService.swift | 131 ++- Flights/Services/JSXWebViewFetcher.swift | 928 ++++++++++++++++------ Flights/Views/FlightLoadDetailView.swift | 20 +- 3 files changed, 799 insertions(+), 280 deletions(-) diff --git a/Flights/Services/AirlineLoadService.swift b/Flights/Services/AirlineLoadService.swift index 876c728..e5fef5e 100644 --- a/Flights/Services/AirlineLoadService.swift +++ b/Flights/Services/AirlineLoadService.swift @@ -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.. { if (window.__jsxProbe) { return JSON.stringify({ ok: true, message: "already installed", already: true }); } window.__jsxProbe = { - searchSimple: null, - lowFare: null, - token: null, - setCulture: null, - allCalls: [], - initiatedCalls: [] + resources: [] }; - - const noteInitiated = (url, method, transport) => { - try { - if (!url.includes("api.jsx.com")) return; - window.__jsxProbe.initiatedCalls.push({ - url, method, transport, t: Date.now() - }); - } 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; - else if (url.includes("/lowfare/estimate")) window.__jsxProbe.lowFare = entry; - 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) { - captureIfMatch(url, method, 0, "", "fetch", String(err && err.message ? err.message : err)); - throw err; - } - try { - if (url.includes("api.jsx.com")) { - const body = await resp.clone().text(); - captureIfMatch(url, method, resp.status, body, "fetch"); + try { + const po = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + 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 (_) {} - 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 { - captureIfMatch( - this.__jsxUrl || "", - this.__jsxMethod || "GET", - 0, "", "xhr", "xhr error event" - ); - } catch (_) {} + po.observe({ type: "resource", buffered: true }); + window.__jsxProbe.observer = po; + } catch (err) { + return JSON.stringify({ + ok: false, + message: "PerformanceObserver install failed: " + String(err && err.message ? err.message : err) }); - return origSend.apply(this, arguments); - }; - - return JSON.stringify({ ok: true, message: "installed fetch+xhr hooks with initiation tracking" }); + } + return JSON.stringify({ ok: true, message: "passive PerformanceObserver installed (no fetch/XHR wrapping)" }); })(); """, verifiers: [ - Verifier(name: "window.__jsxProbe installed", js: """ + Verifier(name: "__jsxProbe.resources exists", js: """ return await (async () => { const p = window.__jsxProbe; - const ok = typeof p === "object" && p !== null && Array.isArray(p.initiatedCalls); - return JSON.stringify({ ok, detail: ok ? "present" : "missing" }); + const ok = p && Array.isArray(p.resources); + 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)") } // ---- STEP 17 ---- - // IMPORTANT: we do NOT click the Find Flights button. In WKWebView, - // synthetic MouseEvents have `isTrusted=false`, and JSX's custom - // datepicker commits its selection into the Angular FormControl only - // 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. + // Invoke JSX's flight-search Angular component's `search()` method + // directly via __ngContext__, bypassing the Find Flights button + // click handler entirely. // - // Playwright doesn't hit this because CDP's Input.dispatchMouseEvent - // produces trusted events. WKWebView has no equivalent. + // Context: the introspection dump from an earlier iteration revealed + // 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 + // `
` 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 - // from within the loaded page context. We have: - // - The browser's TLS fingerprint and session cookies (so Akamai - // doesn't reject us the way it rejected Playwright's direct Chrome - // launch before we switched to connectOverCDP). - // - The anonymous auth token sitting in - // sessionStorage["navitaire.digital.token"], put there by the - // SPA's own bootstrap POST /api/nsk/v2/token call. - // - Steps 5–16 still ran (origin/dest/date UI flow), which warmed - // up the app state and triggered the lowfare/estimate preload, - // so the session is in the same state it would be after a real - // user pressed Find Flights. + // Why this works when the click doesn't: the click handler + // presumably checks some derived `isValid` or other gate that our + // synthetic click/form state can't satisfy. `search()` doesn't + // check, so it just runs. + // + // Shape match criteria: any object that is BOTH: + // - a state-bearing component (has `beginDate` + `origin` + + // `destination` as own properties) + // - callable (has a `search` function) + // This is unique to JSX's flight search component in practice — + // no other Angular component on the page has all four. let s17 = await jsStep( - "POST /availability/search/simple directly via fetch", + "Direct fetch POST /search/simple from page context", action: """ return await (async () => { - // The SPA stores its anonymous auth token as JSON: - // { token: "eyJhbGci...", idleTimeoutInMinutes: 15 } - // under sessionStorage["navitaire.digital.token"]. Wait up - // to 3 seconds for it in case the SPA hasn't finished - // bootstrapping its token refresh. + // This run deliberately uses a completely unwrapped fetch() + // — the step 4 `install passive performance observer` does + // NOT touch window.fetch or XMLHttpRequest. We also use the + // EXACT same shape that the step-18 low-fare fallback uses, + // 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 = () => { try { const raw = sessionStorage.getItem("navitaire.digital.token"); @@ -850,26 +824,11 @@ private final class JSXFlow { 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; - } - } - if (!token) { - return JSON.stringify({ - ok: false, - message: "no auth token in sessionStorage['navitaire.digital.token'] after 3s wait" - }); - } + const token = readToken(); + if (!token) return JSON.stringify({ ok: false, message: "no token in sessionStorage" }); - // Request body mirrors exactly what JSX's own search() method - // POSTs when the form is valid. Confirmed via the Playwright - // interceptor log in scripts/jsx_playwright_search.mjs. - const body = { + const url = "https://api.jsx.com/api/nsk/v4/availability/search/simple"; + const body = JSON.stringify({ beginDate: "\(date)", destination: "\(destination)", origin: "\(origin)", @@ -885,151 +844,412 @@ private final class JSXFlow { numberOfFaresPerJourney: 10, codes: { currencyCode: "USD" }, 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 { - const resp = await fetch( - "https://api.jsx.com/api/nsk/v4/availability/search/simple", - { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - "Accept": "application/json, text/plain, */*", - "Authorization": token - }, - body: JSON.stringify(body) - } - ); - const bodyText = await resp.text(); - // Also populate window.__jsxProbe.searchSimple so the - // 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, - body: bodyText, - transport: "direct-fetch" - }; - } - return JSON.stringify({ - ok: resp.status >= 200 && resp.status < 300 && bodyText.length > 0, - message: "status=" + resp.status + ", body=" + bodyText.length + " bytes", - status: resp.status, - bodyLength: bodyText.length, - tokenPrefix: token.slice(0, 24) + const resp = await fetch(url, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "Authorization": token + }, + body }); + const text = await resp.text(); + directFetchResult = { + ok: resp.status >= 200 && resp.status < 300 && text.length > 100, + status: resp.status, + length: text.length, + body: text + }; } catch (err) { - return JSON.stringify({ + directFetchResult = { ok: false, - message: "fetch error: " + String(err && err.message ? err.message : err), - error: String(err) + status: 0, + length: 0, + error: String(err && err.message ? err.message : err) + }; + } + + if (directFetchResult.ok) { + return JSON.stringify({ + ok: true, + message: "direct fetch POST succeeded status=" + directFetchResult.status + + " len=" + directFetchResult.length, + via: "direct-fetch", + directStatus: directFetchResult.status, + directLength: directFetchResult.length, + body: directFetchResult.body }); } + + // 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: [ - Verifier(name: "searchSimple status is 2xx", js: """ + Verifier(name: "body present in action result", js: """ return await (async () => { - const p = (window.__jsxProbe || {}).searchSimple; - const ok = p && typeof p.status === "number" && p.status >= 200 && p.status < 300; - return JSON.stringify({ ok, detail: "status=" + (p ? p.status : "nil") }); - })(); - """), - Verifier(name: "searchSimple body is non-empty", js: """ - 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) }); - } + // The action sets body as an action data field; the + // runner can't see it from JS. Just confirm SPA is still + // on jsx.com (not a redirect or error page). + return JSON.stringify({ + ok: location.href.includes("jsx.com"), + detail: location.href + }); })(); """) ] ) - 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 ---- - let s18 = await jsStep( - "Extract searchSimple body to Swift", - action: """ - return await (async () => { - const p = (window.__jsxProbe || {}).searchSimple; - 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 ---- + // Parse whatever step 17 harvested. If step 17 produced a `body` + // field, that's the wrapped availability payload; feed it straight + // to parseFlights(). If not, fall back to a direct GET of + // /lowfare/estimate from the page context (GETs are known to + // succeed from WKWebView) so the UI still has something to show. 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", [:]) } - parsedFlights = self.parseFlights(from: rawBody) - if parsedFlights.isEmpty { - return (false, "parser returned 0 flights", ["rawLength": rawBody.count]) + if let rawHarvested, !rawHarvested.isEmpty { + parsedFlights = self.parseFlights(from: rawHarvested) + if parsedFlights.isEmpty { + return (false, "parser returned 0 flights from \(rawHarvested.count)-byte harvested payload", + ["rawLength": rawHarvested.count]) + } + for flight in parsedFlights { + let classesText = flight.classes.map { c in + "\(c.classOfService)/\(c.productClass):\(c.availableCount)@$\(Int(c.fareTotal))" + }.joined(separator: ", ") + let low = flight.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a" + print("[JSX] │ \(flight.flightNumber) \(flight.origin)→\(flight.destination) " + + "\(flight.departureLocal) → \(flight.arrivalLocal) " + + "stops=\(flight.stops) seats=\(flight.totalAvailable) from=\(low) [\(classesText)]") + } + 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", [:]) } - for flight in parsedFlights { - let classesText = flight.classes.map { c in - "\(c.classOfService)/\(c.productClass):\(c.availableCount)@$\(Int(c.fareTotal))" - }.joined(separator: ", ") - let low = flight.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a" - print("[JSX] │ \(flight.flightNumber) \(flight.origin)→\(flight.destination) " - + "\(flight.departureLocal) → \(flight.arrivalLocal) " - + "stops=\(flight.stops) seats=\(flight.totalAvailable) from=\(low) [\(classesText)]") - } - return (true, "decoded \(parsedFlights.count) flights", ["count": parsedFlights.count]) } verify: { [weak self] in guard let self else { return [] } var results: [Verification] = [] - results.append(Verification( - name: "flights.count > 0", - passed: !parsedFlights.isEmpty, - 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 { - $0.origin == expectedOrigin && $0.destination == expectedDestination + if !parsedFlights.isEmpty { + results.append(Verification( + name: "flights.count > 0", + passed: true, + detail: "count=\(parsedFlights.count)" + )) + let allMarketMatch = parsedFlights.allSatisfy { + $0.origin == self.origin && $0.destination == self.destination + } + results.append(Verification( + name: "every flight origin/destination matches \(self.origin)|\(self.destination)", + passed: allMarketMatch, + detail: "" + )) + } else if lowFareFallback != nil { + results.append(Verification( + name: "low-fare fallback present", + passed: true, + detail: "" + )) } - results.append(Verification( - name: "every flight origin/destination matches \(expectedOrigin)|\(expectedDestination)", - passed: allMarketMatch, - detail: "" - )) 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 - 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 @@ -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 private func selectStationActionJS(indexIsDestination: Bool) -> String { @@ -1718,7 +2117,12 @@ private final class JSXFlow { private func fail(_ reason: String) -> JSXSearchResult { logHeader("JSX FLOW FAILED: \(reason)") self.webView = nil - return JSXSearchResult(flights: [], rawSearchBody: capturedBody, error: reason) + return JSXSearchResult( + flights: [], + rawSearchBody: capturedBody, + lowFareFallback: nil, + error: reason + ) } } diff --git a/Flights/Views/FlightLoadDetailView.swift b/Flights/Views/FlightLoadDetailView.swift index 832a1be..b0dfea3 100644 --- a/Flights/Views/FlightLoadDetailView.swift +++ b/Flights/Views/FlightLoadDetailView.swift @@ -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 }