Revert "JSX: clean up dead WKWebView fallback paths"
This reverts commit 5f19e48172.
This commit is contained in:
@@ -698,9 +698,8 @@ actor AirlineLoadService {
|
|||||||
// Graceful degradation: /search/simple was unreachable but we have
|
// Graceful degradation: /search/simple was unreachable but we have
|
||||||
// the /lowfare/estimate day-total, which gives us at least
|
// the /lowfare/estimate day-total, which gives us at least
|
||||||
// "how many seats are available somewhere on the route today".
|
// "how many seats are available somewhere on the route today".
|
||||||
// We can't attribute that to a specific flight, so we label the
|
// We can't attribute that to a specific flight, so we show it as
|
||||||
// cabin clearly as a day-level estimate so the UI doesn't imply
|
// a route-level cabin so the UI isn't empty.
|
||||||
// these numbers belong to the tapped flight specifically.
|
|
||||||
if let lowFare = result.lowFareFallback {
|
if let lowFare = result.lowFareFallback {
|
||||||
print("[XE] Falling back to low-fare estimate: \(lowFare.available) seats available on \(lowFare.date)")
|
print("[XE] Falling back to low-fare estimate: \(lowFare.available) seats available on \(lowFare.date)")
|
||||||
let booked = max(0, capacity - lowFare.available)
|
let booked = max(0, capacity - lowFare.available)
|
||||||
@@ -709,7 +708,7 @@ actor AirlineLoadService {
|
|||||||
flightNumber: "XE\(num)",
|
flightNumber: "XE\(num)",
|
||||||
cabins: [
|
cabins: [
|
||||||
CabinLoad(
|
CabinLoad(
|
||||||
name: "Route day-total (fallback)",
|
name: "Route (day total)",
|
||||||
capacity: capacity,
|
capacity: capacity,
|
||||||
booked: booked,
|
booked: booked,
|
||||||
revenueStandby: 0,
|
revenueStandby: 0,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ private final class JSXFlow {
|
|||||||
|
|
||||||
private var webView: WKWebView!
|
private var webView: WKWebView!
|
||||||
private var stepNumber = 0
|
private var stepNumber = 0
|
||||||
|
private var capturedBody: String?
|
||||||
|
|
||||||
// Parsed date parts (used by step 14 aria-label match).
|
// Parsed date parts (used by step 14 aria-label match).
|
||||||
private let targetYear: Int
|
private let targetYear: Int
|
||||||
@@ -767,30 +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 ----
|
||||||
// ---- STEP 17 ----
|
// Invoke JSX's flight-search Angular component's `search()` method
|
||||||
// Direct POST /api/nsk/v4/availability/search/simple from the
|
// directly via __ngContext__, bypassing the Find Flights button
|
||||||
// loaded jsx.com page context, using the anonymous auth token
|
// click handler entirely.
|
||||||
// from sessionStorage and the page's own Akamai session cookies.
|
|
||||||
// This is the only path that actually works end-to-end.
|
|
||||||
//
|
//
|
||||||
// IMPORTANT: this POST fails on iOS Simulator (`TypeError: Load
|
// Context: the introspection dump from an earlier iteration revealed
|
||||||
// failed` / NSURLErrorNetworkConnectionLost) because simulator
|
// the real component shape. It's NOT Angular Reactive Forms — it's
|
||||||
// WebKit runs on macOS's CFNetwork stack, whose TLS/H2 fingerprint
|
// plain instance properties on a custom component (minified class
|
||||||
// Akamai's per-endpoint protection tier on /search/simple treats
|
// `Me` in the current build, but class names change per build, so
|
||||||
// as untrusted. On a real iOS device the POST returns 200 OK.
|
// we match by shape). The component sits at DOM depth 4 (the
|
||||||
// Other api.jsx.com endpoints (token, graph/*, lowfare/estimate)
|
// `<div class="flight-search-inner">` element, context index 8),
|
||||||
// work from both simulator and device because they're in a looser
|
// and by the time we get here the UI driving steps have already
|
||||||
// Akamai group — that's why the low-fare fallback in step 18 still
|
// set `beginDate=Date(...)`, `origin=Object{...}`, and
|
||||||
// returns usable data from the simulator.
|
// `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.
|
||||||
//
|
//
|
||||||
// If this step fails for any reason, step 18 falls back to a
|
// Why this works when the click doesn't: the click handler
|
||||||
// direct GET of /lowfare/estimate which gives us a day-level
|
// presumably checks some derived `isValid` or other gate that our
|
||||||
// seat count and lowest price per route — not per flight, but
|
// synthetic click/form state can't satisfy. `search()` doesn't
|
||||||
// enough to avoid showing nothing.
|
// 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(
|
let s17 = await jsStep(
|
||||||
"Direct fetch POST /search/simple from page context",
|
"Direct fetch POST /search/simple from page context",
|
||||||
action: """
|
action: """
|
||||||
return await (async () => {
|
return await (async () => {
|
||||||
|
// 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 = () => {
|
const readToken = () => {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem("navitaire.digital.token");
|
const raw = sessionStorage.getItem("navitaire.digital.token");
|
||||||
@@ -821,6 +846,11 @@ private final class JSXFlow {
|
|||||||
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(url, {
|
const resp = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -833,47 +863,238 @@ private final class JSXFlow {
|
|||||||
body
|
body
|
||||||
});
|
});
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
const ok = resp.status >= 200 && resp.status < 300 && text.length > 100;
|
directFetchResult = {
|
||||||
return JSON.stringify({
|
ok: resp.status >= 200 && resp.status < 300 && text.length > 100,
|
||||||
ok,
|
|
||||||
message: ok
|
|
||||||
? ("status=" + resp.status + " len=" + text.length)
|
|
||||||
: ("status=" + resp.status + " len=" + text.length + " (not ok)"),
|
|
||||||
status: resp.status,
|
status: resp.status,
|
||||||
length: text.length,
|
length: text.length,
|
||||||
body: ok ? text : null
|
body: text
|
||||||
});
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return JSON.stringify({
|
directFetchResult = {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "fetch threw: " + String(err && err.message ? err.message : err),
|
|
||||||
status: 0,
|
status: 0,
|
||||||
length: 0,
|
length: 0,
|
||||||
error: String(err && err.message ? err.message : err)
|
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: []
|
verifiers: [
|
||||||
|
Verifier(name: "body present in action result", js: """
|
||||||
|
return await (async () => {
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
""")
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 ----
|
||||||
// Parse the /search/simple body from step 17, or fall back to a
|
// Parse whatever step 17 harvested. If step 17 produced a `body`
|
||||||
// direct GET of /lowfare/estimate. The low-fare GET works from
|
// field, that's the wrapped availability payload; feed it straight
|
||||||
// both simulator and real device (it's in a looser Akamai
|
// to parseFlights(). If not, fall back to a direct GET of
|
||||||
// protection group than /search/simple), so it's the graceful
|
// /lowfare/estimate from the page context (GETs are known to
|
||||||
// degradation when step 17's POST couldn't complete.
|
// succeed from WKWebView) so the UI still has something to show.
|
||||||
var parsedFlights: [JSXFlight] = []
|
var parsedFlights: [JSXFlight] = []
|
||||||
let rawBody: String? = s17.data["body"] as? String
|
var rawHarvested: String? = s17.data["body"] as? String
|
||||||
var lowFareFallback: JSXLowFareEstimate? = nil
|
var lowFareFallback: JSXLowFareEstimate? = nil
|
||||||
|
|
||||||
let s18 = await nativeStep("Parse /search/simple body (or low-fare fallback)") { [weak self] in
|
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", [:]) }
|
||||||
if let rawBody, !rawBody.isEmpty {
|
if let rawHarvested, !rawHarvested.isEmpty {
|
||||||
parsedFlights = self.parseFlights(from: rawBody)
|
parsedFlights = self.parseFlights(from: rawHarvested)
|
||||||
if parsedFlights.isEmpty {
|
if parsedFlights.isEmpty {
|
||||||
return (false, "parser returned 0 flights from \(rawBody.count)-byte body",
|
return (false, "parser returned 0 flights from \(rawHarvested.count)-byte harvested payload",
|
||||||
["rawLength": rawBody.count])
|
["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
|
||||||
@@ -884,10 +1105,10 @@ 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",
|
return (true, "decoded \(parsedFlights.count) flights from harvested SPA state",
|
||||||
["count": parsedFlights.count])
|
["count": parsedFlights.count])
|
||||||
} else {
|
} else {
|
||||||
print("[JSX] ▸ step 17 did not produce a response body; falling back to /lowfare/estimate")
|
print("[JSX] ▸ no harvested payload; fetching /lowfare/estimate directly from page context")
|
||||||
if let lf = await self.fetchLowFareEstimateDirectly() {
|
if let lf = await self.fetchLowFareEstimateDirectly() {
|
||||||
lowFareFallback = lf
|
lowFareFallback = lf
|
||||||
print("[JSX] │ lowfare fallback: \(lf.date) available=\(lf.available)"
|
print("[JSX] │ lowfare fallback: \(lf.date) available=\(lf.available)"
|
||||||
@@ -895,7 +1116,7 @@ private final class JSXFlow {
|
|||||||
return (true, "using low-fare fallback (\(lf.available) seats on \(lf.date))",
|
return (true, "using low-fare fallback (\(lf.available) seats on \(lf.date))",
|
||||||
["available": lf.available, "date": lf.date])
|
["available": lf.available, "date": lf.date])
|
||||||
}
|
}
|
||||||
return (false, "no response body and direct low-fare fetch failed", [:])
|
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 [] }
|
||||||
@@ -935,7 +1156,7 @@ private final class JSXFlow {
|
|||||||
self.webView = nil
|
self.webView = nil
|
||||||
return JSXSearchResult(
|
return JSXSearchResult(
|
||||||
flights: parsedFlights,
|
flights: parsedFlights,
|
||||||
rawSearchBody: rawBody,
|
rawSearchBody: rawHarvested,
|
||||||
lowFareFallback: lowFareFallback,
|
lowFareFallback: lowFareFallback,
|
||||||
error: nil
|
error: nil
|
||||||
)
|
)
|
||||||
@@ -1223,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 {
|
||||||
@@ -1719,7 +2119,7 @@ private final class JSXFlow {
|
|||||||
self.webView = nil
|
self.webView = nil
|
||||||
return JSXSearchResult(
|
return JSXSearchResult(
|
||||||
flights: [],
|
flights: [],
|
||||||
rawSearchBody: nil,
|
rawSearchBody: capturedBody,
|
||||||
lowFareFallback: nil,
|
lowFareFallback: nil,
|
||||||
error: reason
|
error: reason
|
||||||
)
|
)
|
||||||
|
|||||||
20
JSX_NOTES.md
20
JSX_NOTES.md
@@ -21,13 +21,6 @@ hardware against the live production site in April 2026.
|
|||||||
4. Driving the on-page UI to submit the search form is a dead end in
|
4. Driving the on-page UI to submit the search form is a dead end in
|
||||||
**WKWebView** (but works in Playwright) because of a subtle trusted-event
|
**WKWebView** (but works in Playwright) because of a subtle trusted-event
|
||||||
issue described below.
|
issue described below.
|
||||||
5. **Environment caveat**: the in-page `fetch()` POST (#3) works on a
|
|
||||||
**real iOS device** but **fails in the iOS Simulator**. Simulator
|
|
||||||
WebKit runs on macOS's CFNetwork stack, whose TLS/H2 fingerprint
|
|
||||||
Akamai's per-endpoint protection tier on `/search/simple` treats as
|
|
||||||
untrusted. Other `api.jsx.com` endpoints (tokens, bootstrap graph
|
|
||||||
calls, low-fare estimate) work from both. **Test on device; develop
|
|
||||||
with the low-fare fallback in the simulator.**
|
|
||||||
|
|
||||||
The working pattern is therefore:
|
The working pattern is therefore:
|
||||||
|
|
||||||
@@ -35,7 +28,7 @@ The working pattern is therefore:
|
|||||||
Wait for SPA bootstrap (station buttons exist, token call finishes)
|
Wait for SPA bootstrap (station buttons exist, token call finishes)
|
||||||
Read token from sessionStorage
|
Read token from sessionStorage
|
||||||
POST /api/nsk/v4/availability/search/simple ← from inside the page
|
POST /api/nsk/v4/availability/search/simple ← from inside the page
|
||||||
Parse the response (real device only)
|
Parse the response
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -353,12 +346,6 @@ via a direct API call whose request shape matches exactly what
|
|||||||
|
|
||||||
This is what `Flights/Services/JSXWebViewFetcher.swift` step 17 does.
|
This is what `Flights/Services/JSXWebViewFetcher.swift` step 17 does.
|
||||||
|
|
||||||
**Important environment caveat:** this workaround works on a **real iOS
|
|
||||||
device** but **fails in the iOS Simulator**. See the anti-bot surface
|
|
||||||
table below for the specific failure modes. Develop with a real device
|
|
||||||
plugged in, or accept the low-fare day-total fallback when running
|
|
||||||
against the simulator.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Anti-bot surface (Akamai)
|
## Anti-bot surface (Akamai)
|
||||||
@@ -370,9 +357,8 @@ JSX fronts `api.jsx.com` with Akamai Bot Manager. Observed behavior:
|
|||||||
| Plain `curl`, `fetch` from Node, any external HTTP client | Blocked. Almost all endpoints return HTML challenge page or Akamai error. |
|
| Plain `curl`, `fetch` from Node, any external HTTP client | Blocked. Almost all endpoints return HTML challenge page or Akamai error. |
|
||||||
| Playwright's built-in `chromium.launch()` (both bundled chromium and `channel: "chrome"`) | GET requests succeed, but `POST /availability/search/simple` specifically returns `ERR_HTTP2_PROTOCOL_ERROR`. Playwright injects enough automation bits for Akamai to flag the TLS/H2 fingerprint. |
|
| Playwright's built-in `chromium.launch()` (both bundled chromium and `channel: "chrome"`) | GET requests succeed, but `POST /availability/search/simple` specifically returns `ERR_HTTP2_PROTOCOL_ERROR`. Playwright injects enough automation bits for Akamai to flag the TLS/H2 fingerprint. |
|
||||||
| Real Chrome spawned as a plain process + Playwright attached via `chromium.connectOverCDP()` | **Works reliably.** Chrome has the expected fingerprint and Playwright is only driving it via CDP, not altering it. |
|
| Real Chrome spawned as a plain process + Playwright attached via `chromium.connectOverCDP()` | **Works reliably.** Chrome has the expected fingerprint and Playwright is only driving it via CDP, not altering it. |
|
||||||
| **WKWebView on iOS Simulator** | GETs to `api.jsx.com` succeed (`/lowfare/estimate`, `/nsk/v1/token`, `/graph/*`). **`POST /availability/search/simple` fails** with `TypeError: Load failed` (in-page `fetch()`/`XHR`) or `NSURLErrorNetworkConnectionLost` (-1005) (native `URLSession` with cookies forwarded from the WKWebView store). iOS Simulator WebKit runs against macOS's CFNetwork stack, whose TLS/H2 fingerprint Akamai's per-endpoint protection tier treats as untrusted for this specific endpoint. No configuration (custom UA, XHR vs fetch, credentials mode, content type, cookie replay into URLSession) makes it work. |
|
| WKWebView on macOS / iOS | GET requests succeed. Direct `POST /availability/search/simple` from inside a loaded `jsx.com` page via `fetch()` also succeeds. The browser session's cookies and TLS fingerprint are trusted. |
|
||||||
| **WKWebView on real iOS hardware** | **Works.** Direct `POST /availability/search/simple` from inside a loaded `jsx.com` page via `fetch()` returns `status=200` with the full per-flight availability payload. Real iOS WebKit has the iOS-specific network stack whose TLS/H2 fingerprint Akamai accepts. The fetcher in `Flights/Services/JSXWebViewFetcher.swift` step 17 does exactly this and produces correct per-class seat counts. |
|
| WKWebView with UI-driven Find Flights click | Fails — but for an unrelated reason (the trusted-events trap above). Angular never fires the POST in the first place, so Akamai never sees it. |
|
||||||
| WKWebView with UI-driven Find Flights click (any platform) | Fails for an unrelated reason: WKWebView's synthetic `MouseEvent` has `isTrusted === false`, and JSX's custom datepicker only commits its day-cell selection to the Angular form model on a trusted user gesture. Result: the input visually shows the selected date but the underlying model stays null, `form.invalid === true`, and Angular's `search()` silently returns without firing a request. The direct-fetch POST bypasses this entirely. |
|
|
||||||
|
|
||||||
Observations:
|
Observations:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user