6005146e75
- 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>
1085 lines
36 KiB
JavaScript
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();
|