2149 lines
101 KiB
Swift
2149 lines
101 KiB
Swift
import Foundation
|
|
import WebKit
|
|
|
|
// MARK: - Public types
|
|
|
|
/// One fare class (e.g. Q/HO "Hop-On", Q/AI "All-In") inside a JSX flight.
|
|
struct JSXFareClass: Sendable {
|
|
let classOfService: String // "Q", "N", "S", "B"
|
|
let productClass: String // "HO" (Hop-On), "AI" (All-In)
|
|
let availableCount: Int // seats sellable at this class
|
|
let fareTotal: Double // price incl. taxes
|
|
let revenueTotal: Double // base fare
|
|
let fareBasisCode: String? // "Q00AHOXA"
|
|
}
|
|
|
|
/// One unique flight surfaced by the JSX /availability/search/simple response.
|
|
/// The response typically contains several flights for a given route/date; each
|
|
/// entry here represents one of them.
|
|
struct JSXFlight: Sendable {
|
|
let flightNumber: String // "XE280"
|
|
let carrierCode: String // "XE"
|
|
let origin: String // "DAL"
|
|
let destination: String // "HOU"
|
|
let departureLocal: String // "2026-04-15T10:50:00"
|
|
let arrivalLocal: String // "2026-04-15T12:05:00"
|
|
let stops: Int
|
|
let equipmentType: String? // "ER4"
|
|
let totalAvailable: Int // sum of classes[].availableCount
|
|
let lowestFareTotal: Double? // min of classes[].fareTotal
|
|
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.
|
|
struct JSXSearchResult: Sendable {
|
|
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 lowFareFallback: JSXLowFareEstimate? // day-level fallback if search/simple failed
|
|
let error: String? // non-nil iff the flow hit a fatal step failure
|
|
}
|
|
|
|
// MARK: - Fetcher
|
|
|
|
/// Drives the jsx.com SPA inside a WKWebView to capture the
|
|
/// /api/nsk/v4/availability/search/simple response (the one JSX's own website
|
|
/// uses to render fares and loads). The flow is broken into explicit steps,
|
|
/// each with an action AND a post-condition verification, so failures are
|
|
/// pinpointable from the console log.
|
|
@MainActor
|
|
final class JSXWebViewFetcher: NSObject {
|
|
func fetchAvailability(
|
|
origin: String,
|
|
destination: String,
|
|
date: String
|
|
) async -> JSXSearchResult {
|
|
let flow = JSXFlow(origin: origin, destination: destination, date: date)
|
|
return await flow.run()
|
|
}
|
|
}
|
|
|
|
// MARK: - Flow orchestrator
|
|
|
|
@MainActor
|
|
private final class JSXFlow {
|
|
let origin: String
|
|
let destination: String
|
|
let date: String
|
|
|
|
private var webView: WKWebView!
|
|
private var stepNumber = 0
|
|
private var capturedBody: String?
|
|
|
|
// Parsed date parts (used by step 14 aria-label match).
|
|
private let targetYear: Int
|
|
private let targetMonth: Int // 1-12
|
|
private let targetDay: Int
|
|
private let targetMonthName: String
|
|
|
|
init(origin: String, destination: String, date: String) {
|
|
self.origin = origin.uppercased()
|
|
self.destination = destination.uppercased()
|
|
self.date = date
|
|
|
|
let parts = date.split(separator: "-")
|
|
let year = parts.count == 3 ? Int(parts[0]) ?? 0 : 0
|
|
let month = parts.count == 3 ? Int(parts[1]) ?? 0 : 0
|
|
let day = parts.count == 3 ? Int(parts[2]) ?? 0 : 0
|
|
let months = [
|
|
"January","February","March","April","May","June",
|
|
"July","August","September","October","November","December"
|
|
]
|
|
self.targetYear = year
|
|
self.targetMonth = month
|
|
self.targetDay = day
|
|
self.targetMonthName = (month >= 1 && month <= 12) ? months[month - 1] : ""
|
|
}
|
|
|
|
// MARK: - Run
|
|
|
|
func run() async -> JSXSearchResult {
|
|
logHeader("JSX FLOW START: \(origin) → \(destination) on \(date)")
|
|
|
|
guard targetYear > 0, targetMonth >= 1, targetMonth <= 12, targetDay >= 1 else {
|
|
return fail("Invalid date '\(date)' (expected YYYY-MM-DD)")
|
|
}
|
|
|
|
// ---- STEP 01 ----
|
|
let s01 = await nativeStep("Create WKWebView") { [weak self] in
|
|
guard let self else { return (false, "self gone", [:]) }
|
|
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1280, height: 900))
|
|
wv.customUserAgent = "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"
|
|
self.webView = wv
|
|
return (true, "created 1280x900 with Safari UA", [:])
|
|
} verify: { [weak self] in
|
|
guard let self else {
|
|
return [Verification(name: "webView non-nil", passed: false, detail: "flow deallocated")]
|
|
}
|
|
return [Verification(name: "webView non-nil", passed: self.webView != nil, detail: "")]
|
|
}
|
|
if !s01.ok { return fail("Step 01 failed: \(s01.message)") }
|
|
|
|
// ---- STEP 02 ----
|
|
let s02 = await nativeStep("Navigate to https://www.jsx.com/") { [weak self] in
|
|
guard let self else { return (false, "self gone", [:]) }
|
|
guard let url = URL(string: "https://www.jsx.com/") else {
|
|
return (false, "invalid URL", [:])
|
|
}
|
|
let loaded = await self.navigateAndWait(url)
|
|
return (loaded, loaded ? "didFinishNavigation" : "navigation failed", [:])
|
|
} verify: { [weak self] in
|
|
guard let self else {
|
|
return [Verification(name: "location.href contains jsx.com", passed: false, detail: "flow deallocated")]
|
|
}
|
|
let v = await self.asyncVerify(name: "location.href contains jsx.com", js: """
|
|
return await (async () => {
|
|
const href = location.href || "";
|
|
return JSON.stringify({ ok: href.includes("jsx.com"), detail: href });
|
|
})();
|
|
""")
|
|
return [v]
|
|
}
|
|
if !s02.ok { return fail("Step 02 failed: \(s02.message)") }
|
|
|
|
// ---- STEP 03 ----
|
|
let s03 = await jsStep(
|
|
"Wait for SPA bootstrap (poll for station buttons)",
|
|
action: """
|
|
return await (async () => {
|
|
const deadline = Date.now() + 15000;
|
|
let count = 0;
|
|
while (Date.now() < deadline) {
|
|
count = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length;
|
|
if (count >= 2) break;
|
|
await new Promise(r => setTimeout(r, 250));
|
|
}
|
|
return JSON.stringify({
|
|
ok: count >= 2,
|
|
message: count >= 2 ? ("station buttons visible: " + count) : ("timeout; count=" + count),
|
|
stationButtonCount: count
|
|
});
|
|
})();
|
|
""",
|
|
verifiers: [
|
|
Verifier(name: "≥2 station buttons visible", js: """
|
|
return await (async () => {
|
|
const n = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length;
|
|
return JSON.stringify({ ok: n >= 2, detail: "count=" + n });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s03.ok { return fail("Step 03 failed: \(s03.message)") }
|
|
|
|
// ---- STEP 04 ----
|
|
// No network interception. Previous runs wrapped window.fetch AND
|
|
// XMLHttpRequest.prototype.{open,send} to observe api.jsx.com
|
|
// traffic. That wrapper was quite possibly the reason Angular's
|
|
// POST /availability/search/simple failed with "xhr error event"
|
|
// in WebKit — adding an `error` event listener before calling
|
|
// origSend can alter request scheduling in WebKit's NetworkProcess.
|
|
//
|
|
// Instead, we use PerformanceObserver to passively record every
|
|
// api.jsx.com resource entry. PerformanceObserver is pure
|
|
// 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(
|
|
"Install passive performance observer",
|
|
action: """
|
|
return await (async () => {
|
|
if (window.__jsxProbe) {
|
|
return JSON.stringify({ ok: true, message: "already installed", already: true });
|
|
}
|
|
window.__jsxProbe = {
|
|
resources: []
|
|
};
|
|
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 || ""
|
|
});
|
|
}
|
|
});
|
|
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 JSON.stringify({ ok: true, message: "passive PerformanceObserver installed (no fetch/XHR wrapping)" });
|
|
})();
|
|
""",
|
|
verifiers: [
|
|
Verifier(name: "__jsxProbe.resources exists", js: """
|
|
return await (async () => {
|
|
const p = window.__jsxProbe;
|
|
const ok = p && Array.isArray(p.resources);
|
|
return JSON.stringify({ ok, detail: ok ? "ready" : "missing" });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s04.ok { return fail("Step 04 failed: \(s04.message)") }
|
|
|
|
// ---- STEP 05 ----
|
|
let s05 = await jsStep(
|
|
"Dismiss Osano cookie banner",
|
|
action: """
|
|
return await (async () => {
|
|
const visible = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
|
const txt = el => (el.innerText || el.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
const actions = [];
|
|
|
|
// Class-based accept (most reliable on Osano).
|
|
const classAccept = document.querySelector(
|
|
".osano-cm-accept-all, .osano-cm-accept, button.osano-cm-button[class*='accept']"
|
|
);
|
|
if (classAccept && visible(classAccept)) {
|
|
classAccept.click();
|
|
actions.push("class-accept");
|
|
}
|
|
|
|
// Text fallback.
|
|
const wanted = new Set(["accept", "accept all", "allow all", "i agree", "agree", "got it"]);
|
|
for (const btn of document.querySelectorAll("button, [role='button']")) {
|
|
if (!visible(btn)) continue;
|
|
if (wanted.has(txt(btn))) { btn.click(); actions.push("text:" + txt(btn)); break; }
|
|
}
|
|
|
|
// Force-remove the banner element regardless of whether the click "stuck",
|
|
// because role='dialog' on the Osano window confuses calendar detection.
|
|
await new Promise(r => setTimeout(r, 300));
|
|
document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach(el => {
|
|
el.remove();
|
|
actions.push("removed-node");
|
|
});
|
|
|
|
return JSON.stringify({ ok: true, message: actions.join(", ") || "nothing to do", actions });
|
|
})();
|
|
""",
|
|
verifiers: [
|
|
Verifier(name: "no .osano-cm-window visible", js: """
|
|
return await (async () => {
|
|
const el = document.querySelector(".osano-cm-window");
|
|
const gone = !el || !(el.offsetWidth || el.offsetHeight);
|
|
return JSON.stringify({ ok: gone, detail: gone ? "gone" : "still visible" });
|
|
})();
|
|
"""),
|
|
Verifier(name: "no .osano-cm-dialog visible", js: """
|
|
return await (async () => {
|
|
const el = document.querySelector(".osano-cm-dialog");
|
|
const gone = !el || !(el.offsetWidth || el.offsetHeight);
|
|
return JSON.stringify({ ok: gone, detail: gone ? "gone" : "still visible" });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s05.ok { return fail("Step 05 failed: \(s05.message)") }
|
|
|
|
// ---- STEP 06 ----
|
|
_ = await jsStep(
|
|
"Dismiss marketing modal (if present)",
|
|
action: """
|
|
return await (async () => {
|
|
const visible = el => !!(el.offsetWidth || el.offsetHeight);
|
|
const close = Array.from(document.querySelectorAll("button, [role='button']"))
|
|
.filter(visible)
|
|
.find(el => (el.getAttribute("aria-label") || "").toLowerCase() === "close dialog");
|
|
if (close) { close.click(); return JSON.stringify({ ok: true, message: "closed dialog" }); }
|
|
return JSON.stringify({ ok: true, message: "no dialog found" });
|
|
})();
|
|
""",
|
|
verifiers: []
|
|
)
|
|
|
|
// ---- STEP 07 ----
|
|
let s07 = await jsStep(
|
|
"Select trip type = One Way",
|
|
action: """
|
|
return await (async () => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
const visible = el => !!(el.offsetWidth || el.offsetHeight);
|
|
const combo = Array.from(document.querySelectorAll("[role='combobox']"))
|
|
.find(el => /round trip|one way|multi city/i.test(el.textContent || ""));
|
|
if (!combo) return JSON.stringify({ ok: false, message: "trip combobox not found" });
|
|
|
|
const anyOption = () => Array.from(document.querySelectorAll("mat-option, [role='option']"))
|
|
.find(visible);
|
|
|
|
const tried = [];
|
|
|
|
// Strategy 1: plain click.
|
|
combo.scrollIntoView({ block: "center" });
|
|
combo.click();
|
|
tried.push("click");
|
|
await sleep(400);
|
|
|
|
// Strategy 2: focus + keyboard events.
|
|
if (!anyOption()) {
|
|
combo.focus && combo.focus();
|
|
await sleep(100);
|
|
for (const key of ["Enter", " ", "ArrowDown"]) {
|
|
combo.dispatchEvent(new KeyboardEvent("keydown", {
|
|
key,
|
|
code: key === " " ? "Space" : key,
|
|
keyCode: key === "Enter" ? 13 : key === " " ? 32 : 40,
|
|
bubbles: true, cancelable: true
|
|
}));
|
|
tried.push("key:" + key);
|
|
await sleep(350);
|
|
if (anyOption()) break;
|
|
}
|
|
}
|
|
|
|
// Strategy 3: walk __ngContext__ for a mat-select with .open().
|
|
if (!anyOption()) {
|
|
const ctxKey = Object.keys(combo).find(k => k.startsWith("__ngContext__"));
|
|
if (ctxKey) {
|
|
for (const item of combo[ctxKey] || []) {
|
|
if (item && typeof item === "object" && typeof item.open === "function") {
|
|
try { item.open(); tried.push("ngContext.open"); } catch (_) {}
|
|
await sleep(400);
|
|
if (anyOption()) break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const options = Array.from(document.querySelectorAll("mat-option, [role='option']"))
|
|
.filter(visible);
|
|
if (options.length === 0) {
|
|
return JSON.stringify({ ok: false, message: "dropdown did not open", tried });
|
|
}
|
|
|
|
const oneWay = options.find(el => /one\\s*way/i.test(el.textContent || ""));
|
|
if (!oneWay) {
|
|
return JSON.stringify({
|
|
ok: false,
|
|
message: "One Way option not found",
|
|
tried,
|
|
visibleOptions: options.map(o => (o.textContent || "").trim()).slice(0, 10)
|
|
});
|
|
}
|
|
|
|
oneWay.scrollIntoView({ block: "center" });
|
|
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
|
|
oneWay.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
|
|
}
|
|
if (typeof oneWay.click === "function") oneWay.click();
|
|
await sleep(500);
|
|
|
|
// Angular API fallback if the click didn't commit.
|
|
const stillHasReturn = Array.from(document.querySelectorAll("input")).some(i => {
|
|
const label = (i.getAttribute("aria-label") || "").toLowerCase();
|
|
return label.includes("return date") && (i.offsetWidth || i.offsetHeight);
|
|
});
|
|
let usedAngular = false;
|
|
if (stillHasReturn) {
|
|
const ctxKey = Object.keys(oneWay).find(k => k.startsWith("__ngContext__"));
|
|
if (ctxKey) {
|
|
for (const item of oneWay[ctxKey] || []) {
|
|
if (item && typeof item === "object") {
|
|
if (typeof item._selectViaInteraction === "function") {
|
|
try { item._selectViaInteraction(); usedAngular = true; break; } catch (_) {}
|
|
}
|
|
if (typeof item.select === "function" && item.value !== undefined) {
|
|
try { item.select(); usedAngular = true; break; } catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await sleep(500);
|
|
}
|
|
|
|
return JSON.stringify({
|
|
ok: true,
|
|
message: "one way dispatched" + (usedAngular ? " (angular fallback)" : ""),
|
|
tried, usedAngular
|
|
});
|
|
})();
|
|
""",
|
|
verifiers: [
|
|
Verifier(name: "no return-date input visible", js: """
|
|
return await (async () => {
|
|
const still = Array.from(document.querySelectorAll("input")).some(i => {
|
|
const label = (i.getAttribute("aria-label") || "").toLowerCase();
|
|
return label.includes("return date") && (i.offsetWidth || i.offsetHeight);
|
|
});
|
|
return JSON.stringify({ ok: !still, detail: still ? "return-date still visible" : "hidden" });
|
|
})();
|
|
"""),
|
|
Verifier(name: "combobox text contains 'One Way'", js: """
|
|
return await (async () => {
|
|
const combo = Array.from(document.querySelectorAll("[role='combobox']"))
|
|
.find(el => /round trip|one way|multi city/i.test(el.textContent || ""));
|
|
const text = combo ? (combo.textContent || "").replace(/\\s+/g, " ").trim() : "";
|
|
const ok = /one\\s*way/i.test(text);
|
|
return JSON.stringify({ ok, detail: text.slice(0, 80) });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s07.ok { return fail("Step 07 failed: \(s07.message)") }
|
|
|
|
// ---- STEP 08 ----
|
|
let s08 = await jsStep(
|
|
"Open origin station picker",
|
|
action: """
|
|
return await (async () => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
const stations = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']"));
|
|
if (stations.length < 2) return JSON.stringify({ ok: false, message: "found " + stations.length + " station buttons, need 2" });
|
|
stations[0].click();
|
|
await sleep(800);
|
|
const items = Array.from(document.querySelectorAll(
|
|
"li[role='option'].station-options__item, li[role='option']"
|
|
)).filter(el => el.offsetWidth || el.offsetHeight);
|
|
return JSON.stringify({
|
|
ok: items.length > 0,
|
|
message: "dropdown items: " + items.length,
|
|
count: items.length
|
|
});
|
|
})();
|
|
""",
|
|
verifiers: [
|
|
Verifier(name: "dropdown items visible", js: """
|
|
return await (async () => {
|
|
const items = Array.from(document.querySelectorAll(
|
|
"li[role='option'].station-options__item, li[role='option']"
|
|
)).filter(el => el.offsetWidth || el.offsetHeight);
|
|
return JSON.stringify({ ok: items.length > 0, detail: "count=" + items.length });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s08.ok { return fail("Step 08 failed: \(s08.message)") }
|
|
|
|
// ---- STEP 09 ----
|
|
let s09 = await jsStep(
|
|
"Select origin = \(origin)",
|
|
action: selectStationActionJS(indexIsDestination: false),
|
|
verifiers: [
|
|
Verifier(
|
|
name: "station button[0] contains \(origin)",
|
|
js: verifyStationButtonJS(index: 0, code: origin)
|
|
)
|
|
]
|
|
)
|
|
if !s09.ok { return fail("Step 09 failed: \(s09.message)") }
|
|
|
|
// ---- STEP 10 ----
|
|
let s10 = await jsStep(
|
|
"Open destination station picker",
|
|
action: """
|
|
return await (async () => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
await sleep(300);
|
|
const stations = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']"));
|
|
if (stations.length < 2) return JSON.stringify({ ok: false, message: "lost station buttons" });
|
|
stations[1].click();
|
|
await sleep(800);
|
|
const items = Array.from(document.querySelectorAll(
|
|
"li[role='option'].station-options__item, li[role='option']"
|
|
)).filter(el => el.offsetWidth || el.offsetHeight);
|
|
return JSON.stringify({ ok: items.length > 0, message: "dropdown items: " + items.length });
|
|
})();
|
|
""",
|
|
verifiers: [
|
|
Verifier(name: "dropdown items visible", js: """
|
|
return await (async () => {
|
|
const items = Array.from(document.querySelectorAll(
|
|
"li[role='option'].station-options__item, li[role='option']"
|
|
)).filter(el => el.offsetWidth || el.offsetHeight);
|
|
return JSON.stringify({ ok: items.length > 0, detail: "count=" + items.length });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s10.ok { return fail("Step 10 failed: \(s10.message)") }
|
|
|
|
// ---- STEP 11 ----
|
|
let s11 = await jsStep(
|
|
"Select destination = \(destination)",
|
|
action: selectStationActionJS(indexIsDestination: true),
|
|
verifiers: [
|
|
Verifier(
|
|
name: "station button[1] contains \(destination)",
|
|
js: verifyStationButtonJS(index: 1, code: destination)
|
|
)
|
|
]
|
|
)
|
|
if !s11.ok { return fail("Step 11 failed: \(s11.message)") }
|
|
|
|
// ---- STEP 12 ----
|
|
// JSX's picker renders in two phases: first the overlay shell (with
|
|
// month-name text in it), then the actual day cells a few hundred
|
|
// milliseconds later. We must wait for phase 2 — otherwise step 13
|
|
// runs before any [aria-label="Month D, YYYY"] exists in the DOM and
|
|
// gives up.
|
|
let s12 = await jsStep(
|
|
"Open depart datepicker (wait for day cells to render)",
|
|
action: """
|
|
return await (async () => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
const input = Array.from(document.querySelectorAll("input")).find(i => {
|
|
const label = (i.getAttribute("aria-label") || "").toLowerCase();
|
|
return label.includes("depart date") || (label.includes("date") && !label.includes("return"));
|
|
});
|
|
if (!input) return JSON.stringify({ ok: false, message: "depart-date input not found" });
|
|
|
|
const wrapper = input.closest("mat-form-field, jsx-form-field, .form-field, .jsx-form-field");
|
|
let toggle =
|
|
(wrapper && wrapper.querySelector("mat-datepicker-toggle button")) ||
|
|
(wrapper && wrapper.querySelector("button[aria-label*='calendar' i]")) ||
|
|
(wrapper && wrapper.querySelector("button[aria-label*='date picker' i]")) ||
|
|
input;
|
|
toggle.click();
|
|
await sleep(400);
|
|
|
|
// Count real day cells: any visible element whose aria-label
|
|
// matches the pattern "<Month> <Day>, <Year>".
|
|
const DATE_ARIA = /\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4}\\b/;
|
|
const countDayCells = () => Array.from(document.querySelectorAll("[aria-label]"))
|
|
.filter(el => (el.offsetWidth || el.offsetHeight) && DATE_ARIA.test(el.getAttribute("aria-label") || ""))
|
|
.length;
|
|
|
|
// Poll up to 5 seconds for day cells to actually render.
|
|
const deadline = Date.now() + 5000;
|
|
let reopens = 0;
|
|
let lastCellCount = 0;
|
|
while (Date.now() < deadline) {
|
|
lastCellCount = countDayCells();
|
|
if (lastCellCount > 0) break;
|
|
// Halfway through, try re-clicking the input in case the
|
|
// first open didn't take.
|
|
if (reopens === 0 && Date.now() - (deadline - 5000) > 2000) {
|
|
input.click();
|
|
if (input.focus) input.focus();
|
|
reopens++;
|
|
}
|
|
await sleep(150);
|
|
}
|
|
|
|
return JSON.stringify({
|
|
ok: lastCellCount > 0,
|
|
message: lastCellCount > 0
|
|
? ("picker open, " + lastCellCount + " day cells rendered" + (reopens ? " (reopened " + reopens + "x)" : ""))
|
|
: "picker opened but no day cells rendered within 5s",
|
|
dayCellCount: lastCellCount,
|
|
reopens
|
|
});
|
|
})();
|
|
""",
|
|
verifiers: [
|
|
Verifier(name: "≥1 day-aria-label cell rendered", js: """
|
|
return await (async () => {
|
|
const DATE_ARIA = /\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4}\\b/;
|
|
const n = Array.from(document.querySelectorAll("[aria-label]"))
|
|
.filter(el => (el.offsetWidth || el.offsetHeight) && DATE_ARIA.test(el.getAttribute("aria-label") || ""))
|
|
.length;
|
|
return JSON.stringify({ ok: n > 0, detail: "day cells=" + n });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s12.ok { return fail("Step 12 failed: \(s12.message)") }
|
|
|
|
// ---- STEP 13 ----
|
|
let s13 = await jsStep(
|
|
"Ensure target day cell is visible (\(targetMonthName) \(targetDay), \(targetYear))",
|
|
action: navigateMonthActionJS(),
|
|
verifiers: [
|
|
Verifier(name: "aria-label '\(targetMonthName) \(targetDay), \(targetYear)' present", js: """
|
|
return await (async () => {
|
|
const targetMonthName = "\(targetMonthName)";
|
|
const targetYear = \(targetYear);
|
|
const targetDay = \(targetDay);
|
|
const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear;
|
|
const cells = Array.from(document.querySelectorAll("[aria-label]"))
|
|
.filter(el => el.offsetWidth || el.offsetHeight);
|
|
const exact = cells.some(el => {
|
|
const a = (el.getAttribute("aria-label") || "").trim();
|
|
return a === ariaExact || a.startsWith(ariaExact);
|
|
});
|
|
if (exact) return JSON.stringify({ ok: true, detail: "exact match" });
|
|
const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b");
|
|
const loose = cells.some(el => {
|
|
const a = el.getAttribute("aria-label") || "";
|
|
return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a);
|
|
});
|
|
return JSON.stringify({ ok: loose, detail: loose ? "loose match" : "cell not found" });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s13.ok { return fail("Step 13 failed: \(s13.message)") }
|
|
|
|
// ---- STEP 14 ----
|
|
let s14 = await jsStep(
|
|
"Click day cell for \(targetMonthName) \(targetDay), \(targetYear) (by aria-label)",
|
|
action: clickDayCellActionJS(),
|
|
verifiers: [
|
|
Verifier(name: "day cell click was dispatched", js: """
|
|
return await (async () => {
|
|
// Post-condition check: the depart-date input should now have
|
|
// a value. If not, the click missed.
|
|
const input = Array.from(document.querySelectorAll("input")).find(i => {
|
|
const label = (i.getAttribute("aria-label") || "").toLowerCase();
|
|
return label.includes("depart date");
|
|
});
|
|
const val = (input && input.value) || "";
|
|
return JSON.stringify({ ok: val.length > 0, detail: "input.value=" + (val || "(empty)") });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s14.ok { return fail("Step 14 failed: \(s14.message)") }
|
|
|
|
// ---- STEP 15 ----
|
|
let s15 = await jsStep(
|
|
"Click DONE button to commit date",
|
|
action: """
|
|
return await (async () => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
const visible = el => !!(el.offsetWidth || el.offsetHeight);
|
|
const doneBtn = Array.from(document.querySelectorAll("button, [role='button']"))
|
|
.filter(visible)
|
|
.find(el => /^\\s*done\\s*$/i.test((el.innerText || el.textContent || "").trim()));
|
|
if (!doneBtn) {
|
|
return JSON.stringify({ ok: false, message: "DONE button not found" });
|
|
}
|
|
doneBtn.scrollIntoView({ block: "center" });
|
|
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
|
|
doneBtn.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
|
|
}
|
|
if (typeof doneBtn.click === "function") doneBtn.click();
|
|
await sleep(600);
|
|
return JSON.stringify({ ok: true, message: "dispatched click on DONE" });
|
|
})();
|
|
""",
|
|
verifiers: [
|
|
Verifier(name: "datepicker panel no longer visible", js: """
|
|
return await (async () => {
|
|
const candidates = document.querySelectorAll(
|
|
"mat-calendar, .mat-calendar, mat-datepicker-content, [class*='mat-datepicker'], .cdk-overlay-container [class*='datepicker']"
|
|
);
|
|
let anyVisible = false;
|
|
for (const c of candidates) { if (c.offsetWidth || c.offsetHeight) { anyVisible = true; break; } }
|
|
return JSON.stringify({ ok: !anyVisible, detail: anyVisible ? "still visible" : "closed" });
|
|
})();
|
|
"""),
|
|
Verifier(name: "depart-date input has a value", js: """
|
|
return await (async () => {
|
|
const inputs = Array.from(document.querySelectorAll("input")).filter(i =>
|
|
(i.getAttribute("aria-label") || "").toLowerCase().includes("depart date")
|
|
);
|
|
const withValue = inputs.filter(i => (i.value || "").length > 0);
|
|
const firstVal = (inputs[0] && inputs[0].value) || "";
|
|
return JSON.stringify({
|
|
ok: withValue.length > 0,
|
|
detail: "value=" + (firstVal || "(empty)")
|
|
});
|
|
})();
|
|
"""),
|
|
Verifier(name: "only one visible depart-date input", js: """
|
|
return await (async () => {
|
|
const visibles = Array.from(document.querySelectorAll("input"))
|
|
.filter(i => (i.getAttribute("aria-label") || "").toLowerCase().includes("depart date"))
|
|
.filter(i => i.offsetWidth || i.offsetHeight);
|
|
return JSON.stringify({ ok: visibles.length === 1, detail: "count=" + visibles.length });
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s15.ok { return fail("Step 15 failed: \(s15.message)") }
|
|
|
|
// ---- STEP 16 ----
|
|
let s16 = await jsStep(
|
|
"Force Angular form revalidation",
|
|
action: """
|
|
return await (async () => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
document.body.click();
|
|
for (const i of document.querySelectorAll("input")) {
|
|
i.dispatchEvent(new Event("blur", { bubbles: true }));
|
|
}
|
|
await sleep(300);
|
|
let touched = 0;
|
|
for (const el of document.querySelectorAll("*")) {
|
|
const ctxKey = Object.keys(el).find(k => k.startsWith("__ngContext__"));
|
|
if (!ctxKey) continue;
|
|
for (const item of el[ctxKey] || []) {
|
|
if (item && typeof item === "object" && (item.controls || item.control)) {
|
|
const form = item.controls ? item : (item.control && item.control.controls ? item.control : null);
|
|
if (form) {
|
|
try { if (form.markAllAsTouched) form.markAllAsTouched(); } catch (_) {}
|
|
try { if (form.updateValueAndValidity) form.updateValueAndValidity(); } catch (_) {}
|
|
touched++;
|
|
}
|
|
}
|
|
}
|
|
if (touched > 30) break;
|
|
}
|
|
await sleep(400);
|
|
return JSON.stringify({ ok: true, message: "touched " + touched + " form controls", touched });
|
|
})();
|
|
""",
|
|
verifiers: [
|
|
Verifier(name: "Find Flights button is enabled", js: """
|
|
return await (async () => {
|
|
const btn = Array.from(document.querySelectorAll("button"))
|
|
.find(b => /find flights/i.test(b.textContent || ""));
|
|
if (!btn) return JSON.stringify({ ok: false, detail: "button not found" });
|
|
const disabled = btn.disabled || btn.getAttribute("aria-disabled") === "true";
|
|
return JSON.stringify({
|
|
ok: !disabled,
|
|
detail: "disabled=" + btn.disabled + " aria-disabled=" + btn.getAttribute("aria-disabled")
|
|
});
|
|
})();
|
|
""")
|
|
]
|
|
)
|
|
if !s16.ok { return fail("Step 16 failed: \(s16.message)") }
|
|
|
|
// ---- STEP 17 ----
|
|
// Invoke JSX's flight-search Angular component's `search()` method
|
|
// directly via __ngContext__, bypassing the Find Flights button
|
|
// click handler entirely.
|
|
//
|
|
// 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
|
|
// `<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.
|
|
//
|
|
// 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(
|
|
"Direct fetch POST /search/simple from page context",
|
|
action: """
|
|
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 = () => {
|
|
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 in sessionStorage" });
|
|
|
|
const url = "https://api.jsx.com/api/nsk/v4/availability/search/simple";
|
|
const body = JSON.stringify({
|
|
beginDate: "\(date)",
|
|
destination: "\(destination)",
|
|
origin: "\(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
|
|
});
|
|
|
|
// 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(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) {
|
|
directFetchResult = {
|
|
ok: false,
|
|
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: "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 ----
|
|
// 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] = []
|
|
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", [:]) }
|
|
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", [:])
|
|
}
|
|
} verify: { [weak self] in
|
|
guard let self else { return [] }
|
|
var results: [Verification] = []
|
|
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: ""
|
|
))
|
|
}
|
|
return results
|
|
}
|
|
if !s18.ok {
|
|
return fail("Step 18 failed: \(s18.message)")
|
|
}
|
|
|
|
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: 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
|
|
|
|
struct Verification {
|
|
let name: String
|
|
let passed: Bool
|
|
let detail: String
|
|
}
|
|
|
|
struct Verifier {
|
|
let name: String
|
|
let js: String
|
|
}
|
|
|
|
struct StepOutcome {
|
|
let ok: Bool
|
|
let message: String
|
|
let data: [String: Any]
|
|
let verifications: [Verification]
|
|
}
|
|
|
|
/// JavaScript step — runs an action JS snippet, then each verifier JS snippet.
|
|
/// Every verifier is run so the log shows all assertions, even when later ones would fail.
|
|
private func jsStep(
|
|
_ name: String,
|
|
action: String,
|
|
verifiers: [Verifier]
|
|
) async -> StepOutcome {
|
|
stepNumber += 1
|
|
let label = "STEP \(String(format: "%02d", stepNumber))"
|
|
print("[JSX] ┌─ \(label) \(name)")
|
|
let start = Date()
|
|
|
|
let actionResult = await runJS(action)
|
|
let actionOk = (actionResult?["ok"] as? Bool) ?? false
|
|
let actionMessage = (actionResult?["message"] as? String) ?? (actionOk ? "ok" : "failed")
|
|
var stepData: [String: Any] = [:]
|
|
if let dict = actionResult {
|
|
for (k, v) in dict where k != "ok" && k != "message" { stepData[k] = v }
|
|
}
|
|
print("[JSX] │ action: \(truncate(actionMessage))")
|
|
|
|
var verifications: [Verification] = []
|
|
var allOk = actionOk
|
|
for verifier in verifiers {
|
|
let res = await runJS(verifier.js)
|
|
let passed = (res?["ok"] as? Bool) ?? false
|
|
let detail = (res?["detail"] as? String) ?? ""
|
|
verifications.append(Verification(name: verifier.name, passed: passed, detail: detail))
|
|
let glyph = passed ? "✓" : "✗"
|
|
let suffix = detail.isEmpty ? "" : " — \(truncate(detail))"
|
|
print("[JSX] │ verify \(glyph) \(verifier.name)\(suffix)")
|
|
if !passed { allOk = false }
|
|
}
|
|
|
|
if !actionOk {
|
|
// Print any extra fields the action returned (sample arrays,
|
|
// counts, etc.) BEFORE the generic diagnostic dump.
|
|
for (k, v) in stepData.sorted(by: { $0.key < $1.key }) {
|
|
print("[JSX] │ action.\(k): \(truncate(describe(v), limit: 400))")
|
|
}
|
|
await dumpDiagnostics(reason: "action failed: \(actionMessage)")
|
|
} else if !allOk {
|
|
for (k, v) in stepData.sorted(by: { $0.key < $1.key }) {
|
|
print("[JSX] │ action.\(k): \(truncate(describe(v), limit: 400))")
|
|
}
|
|
await dumpDiagnostics(reason: "verification failed")
|
|
}
|
|
|
|
let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start))
|
|
let status = allOk ? "OK" : "FAIL"
|
|
print("[JSX] └─ \(label) \(status) (\(elapsed))")
|
|
return StepOutcome(ok: allOk, message: actionMessage, data: stepData, verifications: verifications)
|
|
}
|
|
|
|
/// Render a JSON-deserialized value compactly for diagnostic logging.
|
|
private func describe(_ value: Any) -> String {
|
|
if let arr = value as? [Any] {
|
|
let items = arr.prefix(8).map { describe($0) }.joined(separator: ", ")
|
|
return "[" + items + (arr.count > 8 ? ", …\(arr.count - 8) more" : "") + "]"
|
|
}
|
|
if let dict = value as? [String: Any] {
|
|
let kv = dict.sorted { $0.key < $1.key }
|
|
.map { "\($0.key)=\(describe($0.value))" }
|
|
.joined(separator: " ")
|
|
return "{" + kv + "}"
|
|
}
|
|
return String(describing: value)
|
|
}
|
|
|
|
/// Swift-side step — runs an async action closure, then an async verification closure.
|
|
/// Used for parsing the JSON body on the Swift side so the same reporting format applies.
|
|
private func nativeStep(
|
|
_ name: String,
|
|
action: () async -> (ok: Bool, message: String, data: [String: Any]),
|
|
verify: () async -> [Verification]
|
|
) async -> StepOutcome {
|
|
stepNumber += 1
|
|
let label = "STEP \(String(format: "%02d", stepNumber))"
|
|
print("[JSX] ┌─ \(label) \(name)")
|
|
let start = Date()
|
|
|
|
let (actionOk, actionMessage, stepData) = await action()
|
|
print("[JSX] │ action: \(truncate(actionMessage))")
|
|
|
|
let verifications = await verify()
|
|
var allOk = actionOk
|
|
for v in verifications {
|
|
let glyph = v.passed ? "✓" : "✗"
|
|
let suffix = v.detail.isEmpty ? "" : " — \(truncate(v.detail))"
|
|
print("[JSX] │ verify \(glyph) \(v.name)\(suffix)")
|
|
if !v.passed { allOk = false }
|
|
}
|
|
|
|
let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start))
|
|
let status = allOk ? "OK" : "FAIL"
|
|
print("[JSX] └─ \(label) \(status) (\(elapsed))")
|
|
return StepOutcome(ok: allOk, message: actionMessage, data: stepData, verifications: verifications)
|
|
}
|
|
|
|
/// Convenience used by step 02's verify closure.
|
|
fileprivate func asyncVerify(name: String, js: String) async -> Verification {
|
|
let res = await runJS(js)
|
|
let passed = (res?["ok"] as? Bool) ?? false
|
|
let detail = (res?["detail"] as? String) ?? ""
|
|
return Verification(name: name, passed: passed, detail: detail)
|
|
}
|
|
|
|
// MARK: - JS runner
|
|
|
|
private func runJS(_ js: String) async -> [String: Any]? {
|
|
guard let webView = self.webView else { return nil }
|
|
do {
|
|
let raw = try await webView.callAsyncJavaScript(js, contentWorld: .page)
|
|
guard let text = raw as? String else { return nil }
|
|
guard let data = text.data(using: .utf8) else { return nil }
|
|
let parsed = try JSONSerialization.jsonObject(with: data)
|
|
return parsed as? [String: Any]
|
|
} catch {
|
|
print("[JSX] │ runJS exception: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Diagnostics
|
|
|
|
private func dumpDiagnostics(reason: String) async {
|
|
print("[JSX] │ ⚠ diagnostic dump (\(reason)):")
|
|
let diag = await runJS("""
|
|
return await (async () => {
|
|
const stationBtns = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length;
|
|
const matOptions = Array.from(document.querySelectorAll("mat-option")).filter(e => e.offsetWidth || e.offsetHeight).length;
|
|
const calendar = !!document.querySelector("mat-calendar, .mat-calendar, [class*='mat-datepicker']");
|
|
const findBtn = Array.from(document.querySelectorAll("button")).find(b => /find flights/i.test(b.textContent || ""));
|
|
const findBtnState = findBtn ? { disabled: findBtn.disabled, aria: findBtn.getAttribute("aria-disabled") } : null;
|
|
const probe = window.__jsxProbe || {};
|
|
const shortenUrl = u => (u || "").split("?")[0].split("/").slice(-3).join("/");
|
|
const lastCompleted = (probe.allCalls || []).slice(-6).map(c =>
|
|
c.method + " " + c.status + (c.error ? "[" + c.error + "]" : "") + " " + shortenUrl(c.url)
|
|
);
|
|
const lastInitiated = (probe.initiatedCalls || []).slice(-6).map(c =>
|
|
c.method + " (init) " + shortenUrl(c.url)
|
|
);
|
|
// Surface any error-flagged inputs so we can spot form validation failures.
|
|
const errorMarkers = Array.from(document.querySelectorAll(
|
|
"[class*='ng-invalid'], [class*='mat-form-field-invalid'], [class*='error']"
|
|
)).filter(e => e.offsetWidth || e.offsetHeight)
|
|
.slice(0, 5)
|
|
.map(e => ({
|
|
tag: e.tagName.toLowerCase(),
|
|
cls: (e.className || "").toString().slice(0, 80),
|
|
aria: e.getAttribute("aria-label") || ""
|
|
}));
|
|
return JSON.stringify({
|
|
ok: true,
|
|
href: location.href,
|
|
stationBtns, matOptions, calendar,
|
|
findBtnState,
|
|
lastCompleted,
|
|
lastInitiated,
|
|
errorMarkers
|
|
});
|
|
})();
|
|
""")
|
|
if let diag {
|
|
for (k, v) in diag where k != "ok" {
|
|
print("[JSX] │ \(k): \(v)")
|
|
}
|
|
} else {
|
|
print("[JSX] │ (diagnostic JS failed)")
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
let index = indexIsDestination ? 1 : 0
|
|
let code = indexIsDestination ? destination : origin
|
|
return """
|
|
return await (async () => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
const visible = el => !!(el.offsetWidth || el.offsetHeight);
|
|
const idx = \(index);
|
|
const code = "\(code)";
|
|
|
|
// Type into the "Airport or city" search input if the dropdown has one.
|
|
const searchInput = Array.from(document.querySelectorAll("input"))
|
|
.filter(visible)
|
|
.find(i => (i.getAttribute("placeholder") || "").toLowerCase() === "airport or city");
|
|
if (searchInput) {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value") && Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
|
|
searchInput.focus();
|
|
if (setter) setter.call(searchInput, code); else searchInput.value = code;
|
|
searchInput.dispatchEvent(new InputEvent("input", { bubbles: true, data: code }));
|
|
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
await sleep(500);
|
|
}
|
|
|
|
const items = Array.from(document.querySelectorAll(
|
|
"li[role='option'].station-options__item, li[role='option']"
|
|
)).filter(visible);
|
|
if (items.length === 0) {
|
|
return JSON.stringify({ ok: false, message: "no dropdown items" });
|
|
}
|
|
|
|
const pattern = new RegExp("(^|\\\\b)" + code + "(\\\\b|$)", "i");
|
|
const match = items.find(el => pattern.test(((el.innerText || el.textContent) || "").trim()));
|
|
if (!match) {
|
|
return JSON.stringify({
|
|
ok: false,
|
|
message: "no match for " + code,
|
|
sample: items.slice(0, 10).map(i => (i.textContent || "").trim().slice(0, 60))
|
|
});
|
|
}
|
|
|
|
match.scrollIntoView({ block: "center" });
|
|
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
|
|
match.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
|
|
}
|
|
if (typeof match.click === "function") match.click();
|
|
await sleep(500);
|
|
|
|
// Angular API fallback.
|
|
const after = () => Array.from(document.querySelectorAll("[aria-label='Station select']"))[idx];
|
|
let finalText = (after() && after().textContent || "").trim();
|
|
let usedAngular = false;
|
|
if (!finalText.includes(code)) {
|
|
const ctxKey = Object.keys(match).find(k => k.startsWith("__ngContext__"));
|
|
if (ctxKey) {
|
|
for (const item of match[ctxKey] || []) {
|
|
if (!item || typeof item !== "object") continue;
|
|
for (const m of ["_selectViaInteraction", "select", "onSelect", "onClick", "handleClick"]) {
|
|
if (typeof item[m] === "function") {
|
|
try { item[m](); usedAngular = true; break; } catch (_) {}
|
|
}
|
|
}
|
|
if (usedAngular) break;
|
|
}
|
|
}
|
|
await sleep(400);
|
|
finalText = (after() && after().textContent || "").trim();
|
|
}
|
|
|
|
return JSON.stringify({
|
|
ok: finalText.includes(code),
|
|
message: "clicked '" + (match.textContent || "").trim().slice(0, 40) + "'" + (usedAngular ? " (angular fallback)" : ""),
|
|
finalText: finalText.slice(0, 80),
|
|
usedAngular
|
|
});
|
|
})();
|
|
"""
|
|
}
|
|
|
|
private func verifyStationButtonJS(index: Int, code: String) -> String {
|
|
return """
|
|
return await (async () => {
|
|
const btn = Array.from(document.querySelectorAll("[aria-label='Station select']"))[\(index)];
|
|
const text = btn ? (btn.textContent || "").replace(/\\s+/g, " ").trim() : "";
|
|
return JSON.stringify({ ok: text.includes("\(code)"), detail: text.slice(0, 80) });
|
|
})();
|
|
"""
|
|
}
|
|
|
|
private func navigateMonthActionJS() -> String {
|
|
return """
|
|
return await (async () => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
const targetYear = \(targetYear);
|
|
const targetMonth = \(targetMonth);
|
|
const targetDay = \(targetDay);
|
|
const targetMonthName = "\(targetMonthName)";
|
|
const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear;
|
|
|
|
// JSX uses a custom date picker (NOT Angular Material's mat-calendar),
|
|
// so rather than read month headers (which use unknown markup) we
|
|
// simply poll for the target day's aria-label existing.
|
|
const findTargetCell = () => {
|
|
const cells = Array.from(document.querySelectorAll("[aria-label]"))
|
|
.filter(el => el.offsetWidth || el.offsetHeight);
|
|
// Exact aria-label first.
|
|
let cell = cells.find(el => {
|
|
const a = (el.getAttribute("aria-label") || "").trim();
|
|
return a === ariaExact || a.startsWith(ariaExact);
|
|
});
|
|
if (cell) return { cell, matchedBy: "exact" };
|
|
// Loose: contains month + year + day word-boundary.
|
|
const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b");
|
|
cell = cells.find(el => {
|
|
const a = el.getAttribute("aria-label") || "";
|
|
return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a);
|
|
});
|
|
return cell ? { cell, matchedBy: "loose" } : null;
|
|
};
|
|
|
|
const waitForTarget = async (ms) => {
|
|
const deadline = Date.now() + ms;
|
|
while (Date.now() < deadline) {
|
|
const hit = findTargetCell();
|
|
if (hit) return hit;
|
|
await sleep(150);
|
|
}
|
|
return findTargetCell();
|
|
};
|
|
|
|
// Case A: target cell is already visible in the current month view.
|
|
// This is the common case for near-term dates because JSX shows
|
|
// two months at a time by default.
|
|
let hit = await waitForTarget(3000);
|
|
if (hit) {
|
|
return JSON.stringify({
|
|
ok: true,
|
|
message: "target cell visible without navigation (" + hit.matchedBy + " match)",
|
|
attempts: 0
|
|
});
|
|
}
|
|
|
|
// Case B: target is in a different month. Walk forward or back.
|
|
// Selectors are deliberately broad because JSX's picker is custom —
|
|
// neither .mat-calendar-next-button nor any Material class applies.
|
|
const findNext = () => document.querySelector(
|
|
".mat-calendar-next-button, [aria-label*='Next month' i], [aria-label*='next' i], " +
|
|
"button[class*='next'], [class*='next-month']"
|
|
);
|
|
const findPrev = () => document.querySelector(
|
|
".mat-calendar-previous-button, [aria-label*='Previous month' i], [aria-label*='prev' i], " +
|
|
"button[class*='prev'], [class*='prev-month']"
|
|
);
|
|
|
|
const now = new Date();
|
|
const currentAbs = now.getFullYear() * 12 + now.getMonth();
|
|
const targetAbs = targetYear * 12 + (targetMonth - 1);
|
|
const forward = currentAbs <= targetAbs;
|
|
|
|
let attempts = 0;
|
|
for (let i = 0; i < 24; i++) {
|
|
attempts++;
|
|
const btn = forward ? findNext() : findPrev();
|
|
if (!btn || btn.disabled) {
|
|
return JSON.stringify({
|
|
ok: false,
|
|
message: "target not visible and no " + (forward ? "next" : "prev") + " button",
|
|
attempts
|
|
});
|
|
}
|
|
btn.click();
|
|
hit = await waitForTarget(1500);
|
|
if (hit) {
|
|
return JSON.stringify({
|
|
ok: true,
|
|
message: "navigated " + attempts + " step(s), target cell present (" + hit.matchedBy + " match)",
|
|
attempts
|
|
});
|
|
}
|
|
}
|
|
|
|
return JSON.stringify({
|
|
ok: false,
|
|
message: "target cell not found after " + attempts + " navigation attempts",
|
|
attempts
|
|
});
|
|
})();
|
|
"""
|
|
}
|
|
|
|
private func clickDayCellActionJS() -> String {
|
|
return """
|
|
return await (async () => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
|
|
// IMPORTANT: search the whole document, not a scoped calendar
|
|
// container. JSX's custom picker has no predictable wrapper class,
|
|
// and step 13 already confirmed the target aria-label is present
|
|
// in the global document. Scoping here causes a mismatch where
|
|
// step 13 finds the cell and step 14 doesn't.
|
|
const targetMonthName = "\(targetMonthName)";
|
|
const targetYear = \(targetYear);
|
|
const targetDay = \(targetDay);
|
|
const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear;
|
|
const ariaLoose = targetMonthName + " " + targetDay + " " + targetYear;
|
|
|
|
const ariaCells = Array.from(document.querySelectorAll("[aria-label]"))
|
|
.filter(el => el.offsetWidth || el.offsetHeight);
|
|
|
|
// Primary: exact aria-label match.
|
|
let cell = ariaCells.find(el => {
|
|
const a = (el.getAttribute("aria-label") || "").trim();
|
|
return a === ariaExact || a === ariaLoose || a.startsWith(ariaExact);
|
|
});
|
|
let matchedBy = cell ? "aria-exact" : null;
|
|
|
|
// Secondary: aria-label contains month, year, and day as a word
|
|
// boundary. This is what step 13's loose match uses.
|
|
if (!cell) {
|
|
const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b");
|
|
cell = ariaCells.find(el => {
|
|
const a = el.getAttribute("aria-label") || "";
|
|
return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a);
|
|
});
|
|
if (cell) matchedBy = "aria-loose";
|
|
}
|
|
|
|
if (!cell) {
|
|
// Collect diagnostics: all visible aria-labels that contain
|
|
// the month name (so we can see how JSX actually formats them).
|
|
const monthMatches = ariaCells
|
|
.filter(el => (el.getAttribute("aria-label") || "").includes(targetMonthName))
|
|
.slice(0, 15)
|
|
.map(el => ({
|
|
tag: el.tagName.toLowerCase(),
|
|
role: el.getAttribute("role") || "",
|
|
aria: el.getAttribute("aria-label"),
|
|
disabled: el.getAttribute("aria-disabled") || el.disabled || false
|
|
}));
|
|
return JSON.stringify({
|
|
ok: false,
|
|
message: "day cell not found for '" + ariaExact + "' (ariaCells=" + ariaCells.length + ", month matches=" + monthMatches.length + ")",
|
|
ariaExact,
|
|
ariaCellCount: ariaCells.length,
|
|
monthMatches
|
|
});
|
|
}
|
|
|
|
// Sanity check: is the cell disabled? JSX marks past-date cells as
|
|
// aria-disabled="true" — clicking them won't commit the selection.
|
|
const disabled = cell.getAttribute("aria-disabled") === "true"
|
|
|| cell.getAttribute("disabled") !== null
|
|
|| cell.classList.contains("disabled")
|
|
|| cell.classList.contains("mat-calendar-body-disabled");
|
|
if (disabled) {
|
|
return JSON.stringify({
|
|
ok: false,
|
|
message: "target day cell is disabled (" + matchedBy + "): " + (cell.getAttribute("aria-label") || ""),
|
|
matchedBy,
|
|
ariaLabel: cell.getAttribute("aria-label"),
|
|
className: (cell.className || "").toString().slice(0, 200)
|
|
});
|
|
}
|
|
|
|
cell.scrollIntoView({ block: "center" });
|
|
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
|
|
cell.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
|
|
}
|
|
if (typeof cell.click === "function") cell.click();
|
|
await sleep(500);
|
|
|
|
return JSON.stringify({
|
|
ok: true,
|
|
message: "clicked day cell (" + matchedBy + ") aria='" + (cell.getAttribute("aria-label") || "") + "'",
|
|
matchedBy,
|
|
clickedAriaLabel: cell.getAttribute("aria-label") || ""
|
|
});
|
|
})();
|
|
"""
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
/// Walk the Navitaire availability payload and extract one `JSXFlight` per
|
|
/// unique journey in `data.results[].trips[].journeysAvailableByMarket["ORG|DST"][]`.
|
|
/// Joins each `journey.fares[].fareAvailabilityKey` to the top-level
|
|
/// `data.faresAvailable[<key>]` record for price + class-of-service.
|
|
fileprivate func parseFlights(from rawJSON: String) -> [JSXFlight] {
|
|
guard let data = rawJSON.data(using: .utf8),
|
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let payload = root["data"] as? [String: Any] else {
|
|
print("[JSX] │ parseFlights: root JSON not a dict with 'data'")
|
|
return []
|
|
}
|
|
|
|
let faresAvailable = payload["faresAvailable"] as? [String: Any] ?? [:]
|
|
let results = payload["results"] as? [[String: Any]] ?? []
|
|
let marketKey = "\(origin)|\(destination)"
|
|
|
|
var flights: [JSXFlight] = []
|
|
var seenKeys = Set<String>()
|
|
|
|
for result in results {
|
|
let trips = result["trips"] as? [[String: Any]] ?? []
|
|
for trip in trips {
|
|
let byMarket = trip["journeysAvailableByMarket"] as? [String: Any] ?? [:]
|
|
|
|
// Prefer the market that exactly matches our route, but fall back to
|
|
// every market key in case JSX uses multi-origin/multi-dest notation.
|
|
var candidateKeys: [String] = []
|
|
if byMarket[marketKey] != nil { candidateKeys.append(marketKey) }
|
|
for k in byMarket.keys where k != marketKey { candidateKeys.append(k) }
|
|
|
|
for key in candidateKeys {
|
|
let journeys = byMarket[key] as? [[String: Any]] ?? []
|
|
for journey in journeys {
|
|
if let flight = buildFlight(
|
|
journey: journey,
|
|
fallbackMarketKey: key,
|
|
faresAvailable: faresAvailable
|
|
) {
|
|
let dedupeKey = "\(flight.flightNumber)|\(flight.departureLocal)"
|
|
if seenKeys.insert(dedupeKey).inserted {
|
|
flights.append(flight)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by departure local time for stable logging/UI.
|
|
flights.sort { $0.departureLocal < $1.departureLocal }
|
|
return flights
|
|
}
|
|
|
|
private func buildFlight(
|
|
journey: [String: Any],
|
|
fallbackMarketKey: String,
|
|
faresAvailable: [String: Any]
|
|
) -> JSXFlight? {
|
|
let segments = journey["segments"] as? [[String: Any]] ?? []
|
|
guard !segments.isEmpty else { return nil }
|
|
|
|
// Flight number pieces come from segments[0].identifier. For multi-segment
|
|
// journeys we concatenate the segment flight numbers so the dedupe key is unique.
|
|
let firstSeg = segments[0]
|
|
let lastSeg = segments[segments.count - 1]
|
|
|
|
let firstIdentifier = firstSeg["identifier"] as? [String: Any] ?? [:]
|
|
let carrierCode = (firstIdentifier["carrierCode"] as? String) ?? ""
|
|
let baseNumber = (firstIdentifier["identifier"] as? String) ?? ""
|
|
var fullFlightNumber = "\(carrierCode)\(baseNumber)"
|
|
if segments.count > 1 {
|
|
let extras = segments.dropFirst().compactMap { seg -> String? in
|
|
let ident = seg["identifier"] as? [String: Any] ?? [:]
|
|
let cc = (ident["carrierCode"] as? String) ?? ""
|
|
let num = (ident["identifier"] as? String) ?? ""
|
|
let combined = "\(cc)\(num)"
|
|
return combined.isEmpty ? nil : combined
|
|
}
|
|
if !extras.isEmpty {
|
|
fullFlightNumber += "+" + extras.joined(separator: "+")
|
|
}
|
|
}
|
|
|
|
// Designators: prefer the journey-level designator if present; fall back to segments.
|
|
let journeyDesignator = journey["designator"] as? [String: Any]
|
|
let firstDesignator = firstSeg["designator"] as? [String: Any] ?? [:]
|
|
let lastDesignator = lastSeg["designator"] as? [String: Any] ?? [:]
|
|
|
|
let originCode = (journeyDesignator?["origin"] as? String)
|
|
?? (firstDesignator["origin"] as? String)
|
|
?? marketOrigin(fallbackMarketKey)
|
|
?? ""
|
|
let destinationCode = (journeyDesignator?["destination"] as? String)
|
|
?? (lastDesignator["destination"] as? String)
|
|
?? marketDestination(fallbackMarketKey)
|
|
?? ""
|
|
let departureLocal = (journeyDesignator?["departure"] as? String)
|
|
?? (firstDesignator["departure"] as? String)
|
|
?? ""
|
|
let arrivalLocal = (journeyDesignator?["arrival"] as? String)
|
|
?? (lastDesignator["arrival"] as? String)
|
|
?? ""
|
|
|
|
let stops = (journey["stops"] as? Int) ?? ((journey["stops"] as? NSNumber)?.intValue ?? 0)
|
|
|
|
// Equipment type lives on segments[0].legs[0].legInfo.equipmentType.
|
|
var equipmentType: String?
|
|
if let legs = firstSeg["legs"] as? [[String: Any]], let firstLeg = legs.first,
|
|
let legInfo = firstLeg["legInfo"] as? [String: Any] {
|
|
equipmentType = legInfo["equipmentType"] as? String
|
|
}
|
|
|
|
// Build per-class breakdown by joining journey.fares[] → data.faresAvailable[key].
|
|
var classes: [JSXFareClass] = []
|
|
let journeyFares = journey["fares"] as? [[String: Any]] ?? []
|
|
for fareEntry in journeyFares {
|
|
guard let key = fareEntry["fareAvailabilityKey"] as? String else { continue }
|
|
guard let record = faresAvailable[key] as? [String: Any] else { continue }
|
|
|
|
// availableCount is the sum of details[].availableCount for this fare bucket.
|
|
var availableCount = 0
|
|
if let details = fareEntry["details"] as? [[String: Any]] {
|
|
for detail in details {
|
|
if let n = detail["availableCount"] as? Int { availableCount += n }
|
|
else if let n = (detail["availableCount"] as? NSNumber)?.intValue { availableCount += n }
|
|
}
|
|
}
|
|
|
|
let totals = record["totals"] as? [String: Any] ?? [:]
|
|
let fareTotal = doubleFromJSON(totals["fareTotal"]) ?? 0
|
|
let revenueTotal = doubleFromJSON(totals["revenueTotal"]) ?? 0
|
|
|
|
let recordFares = record["fares"] as? [[String: Any]] ?? []
|
|
let primary = recordFares.first ?? [:]
|
|
let classOfService = (primary["classOfService"] as? String) ?? ""
|
|
let productClass = (primary["productClass"] as? String) ?? ""
|
|
let fareBasis = primary["fareBasisCode"] as? String
|
|
|
|
classes.append(JSXFareClass(
|
|
classOfService: classOfService,
|
|
productClass: productClass,
|
|
availableCount: availableCount,
|
|
fareTotal: fareTotal,
|
|
revenueTotal: revenueTotal,
|
|
fareBasisCode: fareBasis
|
|
))
|
|
}
|
|
|
|
let totalAvailable = classes.reduce(0) { $0 + $1.availableCount }
|
|
let lowestFareTotal = classes.map { $0.fareTotal }.filter { $0 > 0 }.min()
|
|
|
|
return JSXFlight(
|
|
flightNumber: fullFlightNumber,
|
|
carrierCode: carrierCode,
|
|
origin: originCode,
|
|
destination: destinationCode,
|
|
departureLocal: departureLocal,
|
|
arrivalLocal: arrivalLocal,
|
|
stops: stops,
|
|
equipmentType: equipmentType,
|
|
totalAvailable: totalAvailable,
|
|
lowestFareTotal: lowestFareTotal,
|
|
classes: classes
|
|
)
|
|
}
|
|
|
|
private func marketOrigin(_ key: String) -> String? {
|
|
key.split(separator: "|").first.map(String.init)
|
|
}
|
|
|
|
private func marketDestination(_ key: String) -> String? {
|
|
let parts = key.split(separator: "|")
|
|
return parts.count >= 2 ? String(parts[1]) : nil
|
|
}
|
|
|
|
private func doubleFromJSON(_ value: Any?) -> Double? {
|
|
if let d = value as? Double { return d }
|
|
if let n = value as? NSNumber { return n.doubleValue }
|
|
if let i = value as? Int { return Double(i) }
|
|
if let s = value as? String { return Double(s) }
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Navigation
|
|
|
|
private func navigateAndWait(_ url: URL) async -> Bool {
|
|
guard let webView = self.webView else { return false }
|
|
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
let delegate = NavDelegate(continuation: cont)
|
|
webView.navigationDelegate = delegate
|
|
webView.load(URLRequest(url: url))
|
|
objc_setAssociatedObject(webView, "jsxNavDelegate", delegate, .OBJC_ASSOCIATION_RETAIN)
|
|
}
|
|
}
|
|
|
|
// MARK: - Logging helpers
|
|
|
|
private func logHeader(_ msg: String) {
|
|
print("[JSX] ══════════════════════════════════════════════════════════")
|
|
print("[JSX] \(msg)")
|
|
print("[JSX] ══════════════════════════════════════════════════════════")
|
|
}
|
|
|
|
private func truncate(_ s: String, limit: Int = 180) -> String {
|
|
s.count <= limit ? s : "\(s.prefix(limit))… (\(s.count) chars)"
|
|
}
|
|
|
|
private func fail(_ reason: String) -> JSXSearchResult {
|
|
logHeader("JSX FLOW FAILED: \(reason)")
|
|
self.webView = nil
|
|
return JSXSearchResult(
|
|
flights: [],
|
|
rawSearchBody: capturedBody,
|
|
lowFareFallback: nil,
|
|
error: reason
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation delegate
|
|
|
|
private final class NavDelegate: NSObject, WKNavigationDelegate {
|
|
private var continuation: CheckedContinuation<Bool, Never>?
|
|
init(continuation: CheckedContinuation<Bool, Never>) { self.continuation = continuation }
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
continuation?.resume(returning: true); continuation = nil
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
print("[JSX] navigation failed: \(error.localizedDescription)")
|
|
continuation?.resume(returning: false); continuation = nil
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
print("[JSX] provisional navigation failed: \(error.localizedDescription)")
|
|
continuation?.resume(returning: false); continuation = nil
|
|
}
|
|
}
|