Files
Flights/scripts/jsx_cdp_probe.mjs
T
Trey t 6005146e75 Airline integration work: AirlineLoadService updates, docs, JSX scripts
- AirlineLoadService: pass airport DB for timezone-aware date strings,
  add browser-shaped headers for United, expand JetBlue/Alaska/Emirates
  signatures to take origin, log/parse fixes for Korean Air.
- FlightsApp: build AirlineLoadService with the airport DB and inject it.
- JSX: continued WebView-based fetcher work plus updated JSX_NOTES.
- Docs: add AIRLINE_INTEGRATION_GUIDE.md, drop the old AIRLINE_API_SPEC.md,
  add api_docs/ (StaffTraveler reverse-engineering captures + findings).
- Scripts: jsx_cdp_probe, jsx_live_monitor, jsx_swift_smoke for JSX
  protocol exploration.
- .gitignore: exclude airlines/ (local-only APK/IPA reverse-engineering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:21:30 -05:00

1085 lines
36 KiB
JavaScript

import { spawn } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const port = Number(process.env.CDP_PORT || 9229);
const origin = process.env.JSX_ORIGIN || "DAL";
const destination = process.env.JSX_DESTINATION || "HOU";
const beginDate = process.env.JSX_DATE || "2026-04-11";
const headless = process.env.HEADLESS === "1";
const userDataDir = process.env.CHROME_USER_DATA_DIR || `/tmp/jsx-chrome-${Date.now()}`;
const artifactsDir = process.env.JSX_ARTIFACTS_DIR || "/tmp/jsx-probe";
const navigationUrl = process.env.JSX_URL || "https://www.jsx.com/";
const settleMs = Number(process.env.SETTLE_MS || 8000);
const requestTimeoutMs = Number(process.env.REQUEST_TIMEOUT_MS || 30000);
await mkdir(artifactsDir, { recursive: true });
function log(message, details) {
if (details === undefined) {
console.log(`[probe] ${message}`);
return;
}
console.log(`[probe] ${message}`, details);
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
}
async function waitForDebugger() {
const deadline = Date.now() + requestTimeoutMs;
while (Date.now() < deadline) {
try {
return await fetchJson(`http://127.0.0.1:${port}/json/version`);
} catch {
await delay(250);
}
}
throw new Error("Timed out waiting for Chrome remote debugger");
}
function launchChrome() {
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-default-apps",
"--disable-popup-blocking",
"--disable-background-networking",
"--disable-renderer-backgrounding",
"--window-size=1440,1200",
navigationUrl,
];
if (headless) {
args.splice(args.length - 1, 0, "--headless=new");
}
log("Launching Chrome", { chromePath, port, userDataDir, headless, navigationUrl });
const child = spawn(chromePath, args, {
detached: false,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", chunk => {
const text = chunk.toString().trim();
if (text) {
console.log(`[chrome] ${text}`);
}
});
child.stderr.on("data", chunk => {
const text = chunk.toString().trim();
if (text) {
console.error(`[chrome] ${text}`);
}
});
return child;
}
class CDPClient {
constructor(socketUrl) {
this.socketUrl = socketUrl;
this.ws = new WebSocket(socketUrl);
this.nextId = 1;
this.pending = new Map();
this.eventHandlers = new Map();
this.openPromise = new Promise((resolve, reject) => {
this.ws.addEventListener("open", () => resolve());
this.ws.addEventListener("error", event => reject(event.error || new Error("WebSocket open failed")));
});
this.ws.addEventListener("message", event => {
const message = JSON.parse(event.data.toString());
if (message.id) {
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
} else {
pending.resolve(message.result);
}
return;
}
const handlers = this.eventHandlers.get(message.method) || [];
for (const handler of handlers) {
handler(message.params || {});
}
});
}
async ready() {
await this.openPromise;
}
async send(method, params = {}) {
await this.ready();
const id = this.nextId++;
const payload = JSON.stringify({ id, method, params });
const promise = new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id);
reject(new Error(`Timed out waiting for ${method}`));
}
}, requestTimeoutMs);
});
this.ws.send(payload);
return promise;
}
on(method, handler) {
const handlers = this.eventHandlers.get(method) || [];
handlers.push(handler);
this.eventHandlers.set(method, handlers);
}
async close() {
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close();
}
}
}
async function clickPoint(cdp, point) {
await cdp.send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
button: "none",
});
await cdp.send("Input.dispatchMouseEvent", {
type: "mousePressed",
x: point.x,
y: point.y,
button: "left",
clickCount: 1,
});
await cdp.send("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: point.x,
y: point.y,
button: "left",
clickCount: 1,
});
}
async function clickButtonByText(cdp, textPattern) {
const result = await cdp.send("Runtime.evaluate", {
expression: `
(() => {
const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
const button = Array.from(document.querySelectorAll("button, [role='button']"))
.filter(visible)
.find(element => ${textPattern}.test(textOf(element)));
if (!button) {
return { success: false, reason: "button-not-found", pattern: "${textPattern}" };
}
const rect = button.getBoundingClientRect();
return {
success: true,
text: textOf(button),
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
})()
`,
awaitPromise: true,
returnByValue: true,
});
const details = result.result?.value;
if (!details?.success) {
return details;
}
await clickPoint(cdp, details);
await delay(500);
return { success: true, text: details.text };
}
async function newTarget() {
const response = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, {
method: "PUT",
});
if (!response.ok) {
throw new Error(`Failed to create target: HTTP ${response.status}`);
}
return response.json();
}
function visibleFilter(element) {
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
const pageProbeScript = `
(() => {
const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
const describe = element => {
const label = element.labels && element.labels.length
? Array.from(element.labels).map(label => textOf(label)).join(" | ")
: "";
return {
tag: element.tagName.toLowerCase(),
type: element.getAttribute("type") || "",
id: element.id || "",
name: element.getAttribute("name") || "",
role: element.getAttribute("role") || "",
testId: element.getAttribute("data-testid") || "",
placeholder: element.getAttribute("placeholder") || "",
ariaLabel: element.getAttribute("aria-label") || "",
value: element.value || "",
text: textOf(element).slice(0, 120),
label: label.slice(0, 120),
};
};
const elements = Array.from(document.querySelectorAll("input, button, select, textarea, [role='button'], [role='combobox'], [data-testid]"))
.filter(visible)
.map(describe)
.slice(0, 250);
return {
url: location.href,
title: document.title,
readyState: document.readyState,
bodyTextPreview: textOf(document.body).slice(0, 1000),
localStorage: Object.fromEntries(Object.keys(localStorage).sort().map(key => [key, localStorage.getItem(key)])),
sessionStorage: Object.fromEntries(Object.keys(sessionStorage).sort().map(key => [key, sessionStorage.getItem(key)])),
elements,
};
})()
`;
const acceptConsentScript = `
(() => {
const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
const exactLabels = new Set([
"accept",
"accept all",
"allow all",
"i agree",
"agree"
]);
const candidates = Array.from(document.querySelectorAll("button, [role='button']"))
.filter(visible);
for (const candidate of candidates) {
const text = (candidate.innerText || candidate.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase();
if (exactLabels.has(text)) {
candidate.click();
return { clicked: true, text };
}
}
return { clicked: false };
})()
`;
const setOneWayTripScript = `
(async () => {
const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
const tripTrigger = Array.from(document.querySelectorAll("[role='combobox']"))
.find(element => /round trip|one way|multi city/i.test(textOf(element)));
if (!tripTrigger) {
return { success: false, reason: "trip-trigger-not-found" };
}
const anyOption = () => Array.from(document.querySelectorAll("mat-option, [role='option']")).find(visible);
const tried = [];
tripTrigger.scrollIntoView?.({ block: "center" });
tripTrigger.click();
tried.push("click");
await new Promise(resolve => setTimeout(resolve, 500));
if (!anyOption()) {
tripTrigger.focus?.();
await new Promise(resolve => setTimeout(resolve, 100));
for (const key of ["Enter", " ", "ArrowDown"]) {
tripTrigger.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 new Promise(resolve => setTimeout(resolve, 350));
if (anyOption()) break;
}
}
if (!anyOption()) {
const ctxKey = Object.keys(tripTrigger).find(key => key.startsWith("__ngContext__"));
if (ctxKey) {
for (const item of tripTrigger[ctxKey] || []) {
if (item && typeof item === "object" && typeof item.open === "function") {
try { item.open(); tried.push("ngContext.open"); } catch {}
await new Promise(resolve => setTimeout(resolve, 400));
if (anyOption()) break;
}
}
}
}
const options = Array.from(document.querySelectorAll("mat-option, [role='option']")).filter(visible);
const oneWay = options.find(element => /^one way$/i.test(textOf(element)));
if (!oneWay) {
return {
success: false,
reason: "one-way-option-not-found",
tried,
visibleOptions: options.map(textOf).filter(Boolean).slice(0, 40)
};
}
oneWay.scrollIntoView?.({ block: "center" });
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
oneWay.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
}
oneWay.click?.();
await new Promise(resolve => setTimeout(resolve, 500));
let usedAngular = false;
const returnVisible = Array.from(document.querySelectorAll("input")).some(input => {
const label = (input.getAttribute("aria-label") || "").toLowerCase();
return label.includes("return date") && (input.offsetWidth || input.offsetHeight);
});
if (returnVisible) {
const ctxKey = Object.keys(oneWay).find(key => key.startsWith("__ngContext__"));
if (ctxKey) {
for (const item of oneWay[ctxKey] || []) {
if (!item || typeof item !== "object") continue;
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 new Promise(resolve => setTimeout(resolve, 500));
}
const finalReturnVisible = Array.from(document.querySelectorAll("input")).some(input => {
const label = (input.getAttribute("aria-label") || "").toLowerCase();
return label.includes("return date") && (input.offsetWidth || input.offsetHeight);
});
return {
success: !finalReturnVisible,
selected: textOf(oneWay),
tried,
usedAngular,
finalReturnVisible
};
})()
`;
const dismissMarketingPopupScript = `
(() => {
const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
const closeButton = Array.from(document.querySelectorAll("button, [role='button']"))
.filter(visible)
.find(element => (element.getAttribute("aria-label") || "").toLowerCase() === "close dialog");
if (!closeButton) {
return { dismissed: false };
}
closeButton.click();
return { dismissed: true };
})()
`;
const selectStationScript = (index, stationCode) => `
(async () => {
const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
const metaOf = element => [
element.getAttribute("placeholder") || "",
element.getAttribute("aria-label") || "",
element.getAttribute("name") || "",
element.id || "",
textOf(element.parentElement || element),
].join(" ").toLowerCase();
const nativeValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
const setInputValue = (input, value) => {
input.focus();
if (nativeValueSetter) {
nativeValueSetter.call(input, value);
} else {
input.value = value;
}
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value }));
input.dispatchEvent(new Event("change", { bubbles: true }));
};
const stationButtons = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']"))
.filter(visible);
const trigger = stationButtons[${index}];
if (!trigger) {
return {
success: false,
reason: "station-trigger-not-found",
triggers: stationButtons.map(textOf)
};
}
trigger.click();
await new Promise(resolve => setTimeout(resolve, 700));
const searchInput = Array.from(document.querySelectorAll("input"))
.filter(visible)
.find(input => {
const placeholder = (input.getAttribute("placeholder") || "").toLowerCase();
return placeholder === "airport or city" || (/airport|station|city|search|origin|destination/.test(metaOf(input)) && !/date|email|phone/.test(metaOf(input)));
});
if (searchInput) {
setInputValue(searchInput, "${stationCode}");
await new Promise(resolve => setTimeout(resolve, 700));
}
const stationPattern = new RegExp("(^|\\\\b)${stationCode}(\\\\b|$)", "i");
const candidates = Array.from(document.querySelectorAll("li[role='option'].station-options__item, li[role='option'], .station-options__item"))
.filter(visible);
const target = candidates.find(element => stationPattern.test(textOf(element)));
if (!target) {
return {
success: false,
reason: "station-option-not-found",
searchInputMeta: searchInput ? metaOf(searchInput) : null,
visibleOptions: candidates.map(textOf).filter(Boolean).slice(0, 80)
};
}
target.scrollIntoView?.({ block: "center" });
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
}
target.click?.();
await new Promise(resolve => setTimeout(resolve, 500));
const after = () => Array.from(document.querySelectorAll("[aria-label='Station select']")).filter(visible)[${index}];
let finalText = (after()?.textContent || "").trim();
let usedAngular = false;
if (!finalText.includes("${stationCode}")) {
const ctxKey = Object.keys(target).find(key => key.startsWith("__ngContext__"));
if (ctxKey) {
for (const item of target[ctxKey] || []) {
if (!item || typeof item !== "object") continue;
for (const method of ["_selectViaInteraction", "select", "onSelect", "onClick", "handleClick"]) {
if (typeof item[method] === "function") {
try { item[method](); usedAngular = true; break; } catch {}
}
}
if (usedAngular) break;
}
}
await new Promise(resolve => setTimeout(resolve, 400));
finalText = (after()?.textContent || "").trim();
}
return {
success: finalText.includes("${stationCode}"),
selected: textOf(target),
finalText,
usedAngular
};
})()
`;
const setDepartDateScript = isoDate => `
(async () => {
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const [year, month, day] = "${isoDate}".split("-").map(Number);
const monthNames = [
"January","February","March","April","May","June",
"July","August","September","October","November","December"
];
const targetMonthName = monthNames[month - 1];
const input = Array.from(document.querySelectorAll("input")).find(element => {
const label = (element.getAttribute("aria-label") || "").toLowerCase();
return label.includes("depart date") || (label.includes("date") && !label.includes("return"));
});
if (!input) {
return { success: false, reason: "depart-input-not-found" };
}
const wrapper = input.closest("mat-form-field, jsx-form-field, .form-field, .jsx-form-field");
const toggle =
wrapper?.querySelector("mat-datepicker-toggle button") ||
wrapper?.querySelector("button[aria-label*='calendar' i]") ||
wrapper?.querySelector("button[aria-label*='date picker' i]") ||
input;
toggle.click();
await sleep(800);
const findCalendar = () => {
const direct = document.querySelector(
"mat-calendar, .mat-calendar, mat-datepicker-content, [class*='mat-datepicker']"
);
if (direct) return direct;
const overlay = document.querySelector(".cdk-overlay-container");
if (overlay) {
const panes = overlay.querySelectorAll(".cdk-overlay-pane, [class*='datepicker'], [class*='calendar'], [class*='date-picker']");
for (const pane of panes) {
if (!(pane.offsetWidth || pane.offsetHeight)) continue;
const text = pane.textContent || "";
if (/january|february|march|april|may|june|july|august|september|october|november|december/i.test(text)) {
return pane;
}
}
}
const candidates = Array.from(document.querySelectorAll("div, section, article"))
.filter(element => element.offsetWidth && element.offsetHeight && element.offsetWidth < 1000)
.filter(element => /\\b(Sun|Mon|Tue|Wed|Thu|Fri|Sat)\\b/.test(element.textContent || ""))
.filter(element => /january|february|march|april|may|june|july|august|september|october|november|december/i.test(element.textContent || ""));
return candidates[0] || null;
};
let calendar = findCalendar();
if (!calendar) {
input.click();
input.focus?.();
await sleep(500);
calendar = findCalendar();
}
if (!calendar) {
return { success: false, reason: "datepicker-did-not-open" };
}
const next = calendar.querySelector(".mat-calendar-next-button, [aria-label*='Next month' i]");
const prev = calendar.querySelector(".mat-calendar-previous-button, [aria-label*='Previous month' i]");
const monthIndex = name =>
["january","february","march","april","may","june","july","august","september","october","november","december"]
.indexOf(name.toLowerCase());
for (let i = 0; i < 24; i++) {
const header = calendar.querySelector(".mat-calendar-period-button, [class*='calendar-header']");
const text = header?.textContent?.trim() || "";
if (text.includes(targetMonthName) && text.includes(String(year))) break;
const match = text.match(/(\\w+)\\s+(\\d{4})/);
let forward = true;
if (match) {
const current = parseInt(match[2], 10) * 12 + monthIndex(match[1]);
const target = year * 12 + (month - 1);
forward = current < target;
}
const button = forward ? next : prev;
if (!button || button.disabled) break;
button.click();
await sleep(250);
}
const ariaExact = targetMonthName + " " + day + ", " + year;
const ariaLoose = targetMonthName + " " + day + " " + year;
const allCells = Array.from(calendar.querySelectorAll("[aria-label]"))
.filter(element => element.offsetWidth || element.offsetHeight);
let cell = allCells.find(element => {
const aria = (element.getAttribute("aria-label") || "").trim();
return aria === ariaExact || aria === ariaLoose || aria.startsWith(ariaExact);
});
if (!cell) {
const dayRe = new RegExp("\\\\b" + day + "\\\\b");
cell = allCells.find(element => {
const aria = element.getAttribute("aria-label") || "";
return aria.includes(targetMonthName) && aria.includes(String(year)) && dayRe.test(aria);
});
}
if (!cell) {
return { success: false, reason: "day-cell-not-found", ariaExact };
}
cell.scrollIntoView?.({ block: "center" });
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
cell.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
}
cell.click?.();
await sleep(700);
const doneButton = Array.from(document.querySelectorAll("button, [role='button']"))
.filter(element => element.offsetWidth || element.offsetHeight)
.find(element => /^\\s*done\\s*$/i.test((element.innerText || element.textContent || "").trim()));
let doneClicked = false;
if (doneButton) {
doneButton.scrollIntoView?.({ block: "center" });
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
doneButton.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
}
doneButton.click?.();
doneClicked = true;
await sleep(600);
}
document.body.click();
for (const element of document.querySelectorAll("input")) {
element.dispatchEvent(new Event("blur", { bubbles: true }));
}
await sleep(300);
return {
success: true,
value: input.value,
doneClicked
};
})()
`;
const forceAngularFormUpdateScript = `
(async () => {
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
document.body.click();
for (const input of document.querySelectorAll("input")) {
input.dispatchEvent(new Event("blur", { bubbles: true }));
}
await sleep(300);
let touched = 0;
for (const element of document.querySelectorAll("*")) {
const ctxKey = Object.keys(element).find(key => key.startsWith("__ngContext__"));
if (!ctxKey) continue;
for (const item of element[ctxKey] || []) {
if (!item || typeof item !== "object" || !(item.controls || item.control)) continue;
const form = item.controls ? item : item.control?.controls ? item.control : null;
if (!form) continue;
try { form.markAllAsTouched?.(); } catch {}
try { form.updateValueAndValidity?.(); } catch {}
touched++;
}
if (touched > 20) break;
}
const findButton = Array.from(document.querySelectorAll("button"))
.find(button => /find flights/i.test(button.textContent || ""));
return {
success: true,
touched,
findButtonDisabled: Boolean(findButton?.disabled || findButton?.getAttribute("aria-disabled") === "true")
};
})()
`;
const componentSnapshotScript = `
(() => {
const safe = (value, depth = 0) => {
if (depth > 3) return null;
if (value === null || value === undefined) return value;
if (typeof value === "function") return "[function]";
if (value instanceof Date) return value.toISOString();
if (typeof value !== "object") return value;
if (Array.isArray(value)) return value.slice(0, 10).map(item => safe(item, depth + 1));
const output = {};
for (const key of Object.keys(value).slice(0, 25)) {
try {
output[key] = safe(value[key], depth + 1);
} catch {
output[key] = "[unreadable]";
}
}
return output;
};
for (const element of document.querySelectorAll("*")) {
const ctxKey = Object.keys(element).find(key => key.startsWith("__ngContext__"));
if (!ctxKey) continue;
const ctx = element[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") {
continue;
}
const keys = Object.keys(item).sort();
const proto = Object.getPrototypeOf(item);
const protoMethods = proto
? Object.getOwnPropertyNames(proto)
.filter(name => name !== "constructor" && typeof item[name] === "function")
.sort()
: [];
return {
found: true,
idx,
hostTag: (element.tagName || "").toLowerCase(),
hostClass: String(element.className || ""),
keys,
interestingKeys: keys.filter(name => /(origin|destination|home|station|airport|date|trip|search|market|store)/i.test(name)),
protoMethods: protoMethods.filter(name => /(origin|destination|home|station|airport|date|trip|search|select|set|update|submit)/i.test(name)),
gatingValues: {
departureSelectionComplete: item.departureSelectionComplete,
arrivalSelectionComplete: item.arrivalSelectionComplete,
tripType: item.tripType,
searchStart: item.searchStart,
searchComplete: item.searchComplete,
searchButtonDisabled: Boolean(item.searchButton?.disabled),
searchButtonAriaDisabled: item.searchButton?.getAttribute?.("aria-disabled") || null
},
methodBodies: Object.fromEntries(
["fromUpdated", "toUpdated", "setSelectedStations", "validateTripSelection", "validateDates", "search"]
.filter(name => typeof item[name] === "function")
.map(name => [name, String(item[name]).slice(0, 1200)])
),
fromStationProtoMethods: item.fromStation ? Object.getOwnPropertyNames(Object.getPrototypeOf(item.fromStation) || {})
.filter(name => name !== "constructor" && typeof item.fromStation[name] === "function")
.filter(name => /(select|choose|station|input|search|update|focus|click|set)/i.test(name))
.sort() : [],
toStationProtoMethods: item.toStation ? Object.getOwnPropertyNames(Object.getPrototypeOf(item.toStation) || {})
.filter(name => name !== "constructor" && typeof item.toStation[name] === "function")
.filter(name => /(select|choose|station|input|search|update|focus|click|set)/i.test(name))
.sort() : [],
originPreview: safe(item.origin),
destinationPreview: safe(item.destination),
beginDatePreview: safe(item.beginDate),
interestingValues: Object.fromEntries(
keys
.filter(name => /(origin|destination|home|station|airport|date|trip|market|arrivalSelectionComplete|departureSelectionComplete)/i.test(name))
.slice(0, 20)
.map(name => {
try {
return [name, safe(item[name])];
} catch {
return [name, "[unreadable]"];
}
})
)
};
}
}
return { found: false };
})()
`;
const stationOverlayDebugScript = index => `
(async () => {
const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
const stationButtons = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']"))
.filter(visible);
const trigger = stationButtons[${index}];
if (!trigger) {
return { success: false, reason: "station-trigger-not-found" };
}
trigger.click();
await new Promise(resolve => setTimeout(resolve, 900));
const elements = Array.from(document.querySelectorAll("input, button, [role='button'], [role='option'], [role='dialog'], mat-option, li, .mat-mdc-option, .mat-option"))
.filter(visible)
.map(element => ({
tag: element.tagName.toLowerCase(),
role: element.getAttribute("role") || "",
ariaLabel: element.getAttribute("aria-label") || "",
placeholder: element.getAttribute("placeholder") || "",
id: element.id || "",
className: element.className || "",
text: textOf(element).slice(0, 160)
}))
.filter(item => item.text || item.ariaLabel || item.placeholder)
.slice(0, 150);
return {
success: true,
triggerText: textOf(trigger),
activeElement: document.activeElement ? {
tag: document.activeElement.tagName.toLowerCase(),
ariaLabel: document.activeElement.getAttribute("aria-label") || "",
placeholder: document.activeElement.getAttribute("placeholder") || "",
id: document.activeElement.id || "",
text: textOf(document.activeElement).slice(0, 160)
} : null,
elements
};
})()
`;
const availabilityProbeScript = `
(async ({ origin, destination, beginDate }) => {
const payload = {
beginDate,
destination,
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
};
const tokenData = JSON.parse(sessionStorage.getItem("navitaire.digital.token") || "null");
const token = tokenData?.token || "";
const response = await fetch("https://api.jsx.com/api/nsk/v4/availability/search/simple", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Authorization": token
},
body: JSON.stringify(payload)
});
const body = await response.text();
return {
tokenPresent: Boolean(token),
tokenPrefix: token.slice(0, 24),
tokenExpiration: tokenData?.expiration || null,
status: response.status,
bodyLength: body.length,
bodySnippet: body.slice(0, 4000)
};
})(${JSON.stringify({ origin, destination, beginDate })})
`;
async function main() {
const chrome = launchChrome();
let cdp;
try {
await waitForDebugger();
const target = await newTarget();
log("Connected target", { id: target.id, url: target.url });
cdp = new CDPClient(target.webSocketDebuggerUrl);
await cdp.ready();
const requests = [];
const responseMetaByRequestId = new Map();
cdp.on("Network.requestWillBeSent", params => {
const url = params.request?.url || "";
if (!url.includes("jsx.com")) {
return;
}
const record = {
stage: "request",
type: params.type || "",
method: params.request?.method || "",
url,
postData: params.request?.postData || "",
hasAuthHeader: Boolean(params.request?.headers?.Authorization),
};
requests.push(record);
console.log(JSON.stringify(record));
});
cdp.on("Network.responseReceived", params => {
const url = params.response?.url || "";
if (!url.includes("jsx.com")) {
return;
}
const record = {
stage: "response",
type: params.type || "",
status: params.response?.status || 0,
mimeType: params.response?.mimeType || "",
url,
};
responseMetaByRequestId.set(params.requestId, record);
requests.push(record);
console.log(JSON.stringify(record));
});
cdp.on("Network.loadingFinished", async params => {
const meta = responseMetaByRequestId.get(params.requestId);
if (!meta || !meta.url.includes("api.jsx.com")) {
return;
}
try {
const body = await cdp.send("Network.getResponseBody", { requestId: params.requestId });
const snippet = (body.body || "").slice(0, 3000);
const record = {
stage: "body",
status: meta.status,
url: meta.url,
bodySnippet: snippet,
base64Encoded: Boolean(body.base64Encoded),
};
requests.push(record);
console.log(JSON.stringify(record));
} catch (error) {
console.log(JSON.stringify({
stage: "body-error",
url: meta.url,
error: error.message,
}));
}
});
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
await cdp.send("Network.enable", {
maxTotalBufferSize: 100_000_000,
maxResourceBufferSize: 10_000_000,
maxPostDataSize: 1_000_000,
});
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
log("Navigating page target", navigationUrl);
await cdp.send("Page.navigate", { url: navigationUrl });
await delay(settleMs);
const consentResult = await cdp.send("Runtime.evaluate", {
expression: acceptConsentScript,
awaitPromise: true,
returnByValue: true,
});
log("Consent result", consentResult.result?.value);
await delay(1500);
const initialProbe = await cdp.send("Runtime.evaluate", {
expression: pageProbeScript,
awaitPromise: true,
returnByValue: true,
});
await writeFile(`${artifactsDir}/page-probe-before-search.json`, JSON.stringify(initialProbe.result?.value || {}, null, 2));
log("Page probe saved", `${artifactsDir}/page-probe-before-search.json`);
if (process.env.DEBUG_STATION_OVERLAY === "1") {
const overlayProbe = await cdp.send("Runtime.evaluate", {
expression: stationOverlayDebugScript(0),
awaitPromise: true,
returnByValue: true,
});
await writeFile(
`${artifactsDir}/station-overlay-debug.json`,
JSON.stringify(overlayProbe.result?.value || {}, null, 2)
);
log("Station overlay debug", overlayProbe.result?.value);
}
const popupDismissResult = await cdp.send("Runtime.evaluate", {
expression: dismissMarketingPopupScript,
awaitPromise: true,
returnByValue: true,
});
log("Marketing popup result", popupDismissResult.result?.value);
const tripTypeResult = await cdp.send("Runtime.evaluate", {
expression: setOneWayTripScript,
awaitPromise: true,
returnByValue: true,
});
log("Trip type result", tripTypeResult.result?.value);
const originResult = await cdp.send("Runtime.evaluate", {
expression: selectStationScript(0, origin),
awaitPromise: true,
returnByValue: true,
});
log("Origin selection result", originResult.result?.value);
const destinationResult = await cdp.send("Runtime.evaluate", {
expression: selectStationScript(1, destination),
awaitPromise: true,
returnByValue: true,
});
log("Destination selection result", destinationResult.result?.value);
const dateResult = await cdp.send("Runtime.evaluate", {
expression: setDepartDateScript(beginDate),
awaitPromise: true,
returnByValue: true,
});
log("Depart date result", dateResult);
const formUpdateResult = await cdp.send("Runtime.evaluate", {
expression: forceAngularFormUpdateScript,
awaitPromise: true,
returnByValue: true,
});
log("Form update result", formUpdateResult.result?.value);
const componentSnapshot = await cdp.send("Runtime.evaluate", {
expression: componentSnapshotScript,
awaitPromise: true,
returnByValue: true,
});
await writeFile(
`${artifactsDir}/component-snapshot-before-search.json`,
JSON.stringify(componentSnapshot.result?.value || {}, null, 2)
);
log("Component snapshot", componentSnapshot.result?.value);
const searchResult = await clickButtonByText(cdp, "/^find flights$/i");
log("Search click result", searchResult);
if (process.env.DIRECT_AVAILABILITY_PROBE === "1") {
const availabilityProbe = await cdp.send("Runtime.evaluate", {
expression: availabilityProbeScript,
awaitPromise: true,
returnByValue: true,
});
await writeFile(
`${artifactsDir}/availability-probe.json`,
JSON.stringify(availabilityProbe.result?.value || {}, null, 2)
);
log("Availability probe", availabilityProbe.result?.value);
}
await delay(settleMs);
const finalProbe = await cdp.send("Runtime.evaluate", {
expression: pageProbeScript,
awaitPromise: true,
returnByValue: true,
});
await writeFile(`${artifactsDir}/page-probe-after-search.json`, JSON.stringify(finalProbe.result?.value || {}, null, 2));
await writeFile(`${artifactsDir}/network-events.json`, JSON.stringify(requests, null, 2));
log("Artifacts saved", artifactsDir);
} finally {
if (cdp) {
await cdp.close();
}
chrome.kill("SIGTERM");
await delay(500);
await rm(userDataDir, { recursive: true, force: true });
}
}
await main();