Files
Flights/scripts/jsx_playwright_search.mjs
Trey t 77c59ce2c2 Rewrite JSX flow with per-step verification + direct API call
Full rewrite of Flights/Services/JSXWebViewFetcher.swift implementing a
19-step WKWebView flow that drives the jsx.com one-way search UI, then
calls POST /api/nsk/v4/availability/search/simple directly via fetch()
from within the page context using the anonymous auth token read from
sessionStorage["navitaire.digital.token"].

Why the direct call instead of clicking Find Flights: WKWebView's
synthetic MouseEvents have isTrusted=false, and JSX's custom datepicker
commits its day-cell selection into the Angular FormControl only on
trusted user gestures. The result is that the date input displays
"Sat, Apr 11" but the underlying FormControl stays null, so Angular's
search() sees form.invalid === true and silently returns without
firing a request. Playwright sidesteps this because CDP's
Input.dispatchMouseEvent produces trusted events; WKWebView has no
equivalent. The fix is to drive the UI steps (for page warm-up and
smoke testing) but then call the API directly — the same-origin fetch
inherits the browser's cookies and TLS fingerprint so Akamai sees it
as legitimate traffic, same as the lowfare/estimate GET that already
works through the page.

Every step has an action and one or more post-condition verifications.
On failure the runner dumps the action's returned data fields, page
state (URL, selector counts, form error markers), and both the last
initiated AND last completed api.jsx.com calls so network-level blocks
and form-validation bails can be distinguished.

New return type JSXSearchResult exposes every unique flight from the
search/simple response as [JSXFlight] with per-class load breakdowns
(classOfService, productClass, availableCount, fareTotal, revenueTotal)
so callers can see all flights, not just one.

Flights/Services/AirlineLoadService.swift: fetchJSXLoad now consumes
the [JSXFlight] array, logs every returned flight, and picks the
requested flight by digit-match. Deleted 495 lines of dead JSX helpers
(_fetchJSXLoad_oldMultiStep, parseJSXResponse, findJSXJourneys,
extractJSXFlightNumber, extractJSXAvailableSeats,
collectJSXAvailableCounts, parseJSXLowFareEstimate, normalizeFlightNumber).

scripts/jsx_playwright_search.mjs: standalone Playwright reference
implementation of the same flow. Launches real Chrome with --remote-
debugging-port and attaches via chromium.connectOverCDP() — this
bypasses Akamai's fingerprint check on Playwright's own launch and
produced the UI-flow steps and per-flight extractor logic that the
Swift rewrite mirrors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:11:29 -05:00

844 lines
35 KiB
JavaScript

#!/usr/bin/env node
// JSX one-way flight search + flight-load capture via Playwright.
//
// Usage:
// npm i -D playwright
// npx playwright install chromium
// node scripts/jsx_playwright_search.mjs --origin DAL --destination HOU --date 2026-04-15
//
// Env / flags:
// --origin IATA (default: DAL)
// --destination IATA (default: HOU)
// --date YYYY-MM-DD (default: 2026-04-15)
// --headful show the browser (default: headless)
// --out DIR where to write captured JSON (default: /tmp/jsx-playwright)
//
// What it does:
// 1. Opens https://www.jsx.com/ in a real Chromium (Akamai/JSX blocks plain
// fetches — the browser session is what makes the API call work).
// 2. Dismisses consent + marketing popups.
// 3. Selects "One way" from the mat-select trip type dropdown.
// 4. Picks origin station, destination station, depart date from the UI.
// 5. Clicks "Find Flights".
// 6. Listens for api.jsx.com responses and captures the body of
// POST /api/nsk/v4/availability/search/simple — that payload is what
// the JSX website uses to render seat counts. Flight loads live there
// as journey.segments[].legs[].legInfo and journey.fares[].paxFares[]
// plus the per-journey `seatsAvailable` field.
// 7. Also grabs the low-fare estimate response for sanity.
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
// ---------- CLI ----------
function parseArgs(argv) {
const out = {};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith("--")) continue;
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
out[key] = true;
} else {
out[key] = next;
i++;
}
}
return out;
}
const args = parseArgs(process.argv);
const origin = (args.origin || "DAL").toUpperCase();
const destination = (args.destination || "HOU").toUpperCase();
const date = args.date || "2026-04-15";
const headless = !args.headful;
const outDir = args.out || "/tmp/jsx-playwright";
const [yearStr, monthStr, dayStr] = date.split("-");
const targetYear = Number(yearStr);
const targetMonth = Number(monthStr); // 1-12
const targetDay = Number(dayStr);
const monthNames = [
"January","February","March","April","May","June",
"July","August","September","October","November","December",
];
const targetMonthName = monthNames[targetMonth - 1];
if (!targetYear || !targetMonth || !targetDay) {
console.error(`Invalid --date '${date}', expected YYYY-MM-DD`);
process.exit(2);
}
await mkdir(outDir, { recursive: true });
// ---------- Tiny logger ----------
let stepCounter = 0;
function step(label) {
stepCounter++;
const prefix = `[${String(stepCounter).padStart(2, "0")}]`;
const started = Date.now();
console.log(`${prefix}${label}`);
return (detail) => {
const ms = Date.now() - started;
const d = detail ? `${detail}` : "";
console.log(`${prefix}${label} (${ms}ms)${d}`);
};
}
// ---------- Main ----------
// IMPORTANT: Akamai Bot Manager (used by JSX via api.jsx.com) detects
// Playwright's `launch()`/`launchPersistentContext()` and returns
// ERR_HTTP2_PROTOCOL_ERROR on POST /availability/search/simple — even when
// using `channel: "chrome"`. The page, the token call, and the GET lowfare
// estimate all succeed, but the sensitive POST is blocked.
//
// The working approach: spawn REAL Chrome ourselves (identical to the
// existing jsx_cdp_probe.mjs pattern) and have Playwright attach over CDP.
// Plain-spawned Chrome has the exact TLS+HTTP/2 fingerprint Akamai expects
// from a real user, and Playwright is only used as the scripting interface.
const chromePath =
args["chrome-path"] || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const cdpPort = Number(args["cdp-port"] || 9231);
const userDataDir = args["user-data-dir"] || join(homedir(), ".cache", "jsx-playwright-profile");
await mkdir(userDataDir, { recursive: true });
function launchChromeProcess() {
const chromeArgs = [
`--remote-debugging-port=${cdpPort}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-default-apps",
"--disable-popup-blocking",
"--window-size=1440,1200",
"about:blank",
];
if (headless) chromeArgs.unshift("--headless=new");
console.log(`[chrome] launching: ${chromePath} on port ${cdpPort}${headless ? " (headless)" : ""}`);
const child = spawn(chromePath, chromeArgs, { detached: false, stdio: ["ignore", "pipe", "pipe"] });
// Silence Chrome's noisy startup/updater output — it writes tons of
// VERBOSE/ERROR lines to stderr during normal operation. Swallow them
// unless --debug-chrome is passed.
if (args["debug-chrome"]) {
child.stdout.on("data", (b) => { const t = b.toString().trim(); if (t) console.log(`[chrome.out] ${t.slice(0, 200)}`); });
child.stderr.on("data", (b) => { const t = b.toString().trim(); if (t) console.log(`[chrome.err] ${t.slice(0, 200)}`); });
} else {
child.stdout.on("data", () => {});
child.stderr.on("data", () => {});
}
return child;
}
async function waitForCdp() {
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
try {
const r = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
if (r.ok) return await r.json();
} catch {}
await delay(250);
}
throw new Error("Chrome remote debugger never came up");
}
const chromeProc = launchChromeProcess();
const cdpInfo = await waitForCdp();
console.log(`[chrome] CDP ready: ${cdpInfo.Browser}`);
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
const contexts = browser.contexts();
const context = contexts[0] || await browser.newContext();
const existingPages = context.pages();
const page = existingPages[0] || await context.newPage();
page.on("console", (msg) => {
const t = msg.type();
if (t === "error" || t === "warning") {
console.log(` [page.${t}] ${msg.text().slice(0, 200)}`);
}
});
page.on("pageerror", (err) => console.log(` [pageerror] ${err.message}`));
// Intercept JSX API responses. We want the body of search/simple (flight
// loads) and lowfare/estimate (per-day cheapest fare). Playwright exposes the
// response body via response.body() — no CDP plumbing needed.
const captured = {
searchSimple: null,
lowFare: null,
allCalls: [],
};
page.on("request", (request) => {
const url = request.url();
if (url.includes("/availability/search/simple")) {
console.log(` [outgoing request] ${request.method()} ${url}`);
const post = request.postData();
if (post) console.log(` [outgoing body] ${post.slice(0, 800)}`);
const headers = request.headers();
console.log(` [outgoing auth] ${headers.authorization ? headers.authorization.slice(0, 30) + "..." : "NONE"}`);
}
});
page.on("requestfailed", (request) => {
if (request.url().includes("api.jsx.com")) {
console.log(` [request failed] ${request.method()} ${request.url()}${request.failure()?.errorText}`);
}
});
page.on("response", async (response) => {
const url = response.url();
if (!url.includes("api.jsx.com")) return;
const req = response.request();
const entry = {
url,
method: req.method(),
status: response.status(),
};
captured.allCalls.push(entry);
try {
if (url.includes("/availability/search/simple")) {
const bodyText = await response.text();
captured.searchSimple = { ...entry, body: bodyText };
console.log(` ↳ captured search/simple (${bodyText.length} bytes, status ${response.status()})`);
} else if (url.includes("lowfare/estimate")) {
const bodyText = await response.text();
captured.lowFare = { ...entry, body: bodyText };
console.log(` ↳ captured lowfare/estimate (${bodyText.length} bytes)`);
}
} catch (err) {
console.log(` ↳ failed reading body for ${url}: ${err.message}`);
}
});
try {
// ---------- 1. Navigate ----------
let done = step("Navigate to https://www.jsx.com/");
await page.goto("https://www.jsx.com/", { waitUntil: "domcontentloaded" });
// Wait for network to settle so Angular bootstraps, token call completes,
// and the search form is fully initialized before we touch it.
try {
await page.waitForLoadState("networkidle", { timeout: 15000 });
} catch {}
await page.waitForTimeout(2000);
done(`url=${page.url()}`);
// ---------- 2. Consent + popup ----------
done = step("Dismiss consent / marketing popups");
const consentResult = await page.evaluate(() => {
const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
const txt = (el) => (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().toLowerCase();
const actions = [];
// Osano cookie banner — class-based match is more reliable than text.
const osanoAccept = document.querySelector(
".osano-cm-accept-all, .osano-cm-accept, button.osano-cm-button[class*='accept']"
);
if (osanoAccept && visible(osanoAccept)) {
osanoAccept.click();
actions.push("osano-accept");
}
// Generic consent buttons by text.
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; }
}
// Marketing modal close.
const close = Array.from(document.querySelectorAll("button, [role='button']"))
.filter(visible)
.find((el) => (el.getAttribute("aria-label") || "").toLowerCase() === "close dialog");
if (close) { close.click(); actions.push("close-dialog"); }
return { actions };
});
await page.waitForTimeout(800);
// Verify the Osano banner is actually gone — it tends to block pointer events.
const osanoStillVisible = await page.evaluate(() => {
const el = document.querySelector(".osano-cm-window, .osano-cm-dialog");
if (!el) return false;
return !!(el.offsetWidth || el.offsetHeight);
});
if (osanoStillVisible) {
// Hard-kill it so it doesn't intercept clicks or match selectors.
await page.evaluate(() => {
document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach((el) => el.remove());
});
consentResult.actions.push("osano-force-removed");
}
done(JSON.stringify(consentResult));
// ---------- 3. One way ----------
// JSX uses Angular Material mat-select. Open via keyboard/ArrowDown, then
// click the mat-option. This mirrors the battle-tested Swift flow.
done = step("Select trip type = One way");
const oneWayResult = await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const combo = Array.from(document.querySelectorAll("[role='combobox']"))
.find((el) => /round trip|one way|multi city/i.test(el.textContent || ""));
if (!combo) return { ok: false, reason: "trip-combobox-not-found" };
const anyOption = () => document.querySelector("mat-option, [role='option']");
// Strategy 1: plain click.
combo.scrollIntoView?.({ block: "center" });
combo.click();
await sleep(400);
// Strategy 2: focus + keyboard events.
if (!anyOption()) {
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,
}));
await sleep(350);
if (anyOption()) break;
}
}
// Strategy 3: walk __ngContext__ and call mat-select's .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(); } catch {}
await sleep(400);
if (anyOption()) break;
}
}
}
}
const options = Array.from(document.querySelectorAll("mat-option, [role='option']"))
.filter((el) => el.offsetWidth || el.offsetHeight);
const oneWay = options.find((el) => /one\s*way/i.test(el.textContent || ""));
if (!oneWay) return { ok: false, reason: "one-way-option-not-found", visible: options.map((o) => o.textContent.trim()) };
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
oneWay.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
}
oneWay.click?.();
await sleep(500);
// Verify: return-date input should no longer be visible.
const returnVisible = Array.from(document.querySelectorAll("input"))
.some((i) => (i.getAttribute("aria-label") || "").toLowerCase().includes("return date")
&& (i.offsetWidth || i.offsetHeight));
return { ok: !returnVisible, returnVisible };
});
done(JSON.stringify(oneWayResult));
if (!oneWayResult.ok) throw new Error(`Trip type selection failed: ${JSON.stringify(oneWayResult)}`);
// ---------- 4. Origin ----------
done = step(`Select origin = ${origin}`);
await selectStation(page, 0, origin);
done();
// ---------- 5. Destination ----------
done = step(`Select destination = ${destination}`);
await selectStation(page, 1, destination);
done();
// ---------- 6. Date ----------
done = step(`Set depart date = ${date}`);
const dateResult = await page.evaluate(async ({ targetYear, targetMonth, targetDay, targetMonthName }) => {
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 { ok: false, reason: "depart-input-not-found" };
// Open datepicker: prefer an adjacent mat-datepicker-toggle, else click input.
const wrapper = input.closest("mat-form-field, jsx-form-field, .form-field, .jsx-form-field");
let 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);
// Look specifically for the JSX/Material calendar — NOT any [role='dialog']
// because the Osano cookie banner also uses role="dialog".
// JSX uses a custom component; we also search cdk-overlay-container which
// is where Angular Material mounts datepicker overlays.
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) {
// Any visible overlay pane that contains month-name text or day-of-week header.
const panes = overlay.querySelectorAll(".cdk-overlay-pane, [class*='datepicker'], [class*='calendar'], [class*='date-picker']");
for (const p of panes) {
if (!(p.offsetWidth || p.offsetHeight)) continue;
const text = p.textContent || "";
if (/january|february|march|april|may|june|july|august|september|october|november|december/i.test(text)) {
return p;
}
}
}
// Last resort: any visible element with month + year text that also has a short-day header.
const candidates = Array.from(document.querySelectorAll("div, section, article"))
.filter((el) => el.offsetWidth && el.offsetHeight && el.offsetWidth < 1000)
.filter((el) => /\b(Sun|Mon|Tue|Wed|Thu|Fri|Sat)\b/.test(el.textContent || ""))
.filter((el) => /january|february|march|april|may|june|july|august|september|october|november|december/i.test(el.textContent || ""));
return candidates[0] || null;
};
let calendar = findCalendar();
if (!calendar) { input.click(); input.focus?.(); await sleep(500); calendar = findCalendar(); }
if (!calendar) {
// Dump what IS visible so we can adapt.
const overlayDump = (() => {
const overlay = document.querySelector(".cdk-overlay-container");
if (!overlay) return "no cdk-overlay-container";
return Array.from(overlay.children).map((c) => ({
tag: c.tagName.toLowerCase(),
cls: (c.className || "").toString().slice(0, 80),
visible: !!(c.offsetWidth || c.offsetHeight),
preview: (c.textContent || "").replace(/\s+/g, " ").trim().slice(0, 100),
}));
})();
return { ok: false, reason: "datepicker-did-not-open", overlayDump };
}
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(targetYear))) break;
const m = text.match(/(\w+)\s+(\d{4})/);
let forward = true;
if (m) {
const cur = parseInt(m[2], 10) * 12 + monthIndex(m[1]);
const tgt = targetYear * 12 + (targetMonth - 1);
forward = cur < tgt;
}
const btn = forward ? next : prev;
if (!btn || btn.disabled) break;
btn.click();
await sleep(250);
}
// Match the day cell by aria-label. JSX/Angular Material day cells have
// aria-labels like "April 15, 2026" — unambiguous across the two-month
// range picker and resilient to missed month navigation.
const ariaTarget = `${targetMonthName} ${targetDay}, ${targetYear}`;
const ariaTargetLoose = `${targetMonthName} ${targetDay} ${targetYear}`;
const allCells = Array.from(calendar.querySelectorAll("[aria-label]"))
.filter((el) => el.offsetWidth || el.offsetHeight);
let cell = allCells.find((el) => {
const a = (el.getAttribute("aria-label") || "").trim();
return a === ariaTarget || a === ariaTargetLoose || a.startsWith(ariaTarget);
});
// Fallback: find any element whose aria-label includes the month name,
// the year, and the exact day number (e.g. "Wednesday, April 15, 2026").
if (!cell) {
const dayRe = new RegExp(`\\b${targetDay}\\b`);
cell = allCells.find((el) => {
const a = (el.getAttribute("aria-label") || "");
return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a);
});
}
// Last resort: text-based match restricted to an element whose closest
// header says the target month.
if (!cell) {
const textCells = Array.from(calendar.querySelectorAll(
".mat-calendar-body-cell, [class*='calendar-body-cell'], [class*='day-cell'], " +
"td[role='gridcell'], [role='gridcell'], button[class*='day'], div[class*='day'], span[class*='day']"
)).filter((el) => el.offsetWidth || el.offsetHeight);
cell = textCells.find((c) => {
const text = ((c.innerText || c.textContent || "").trim());
if (text !== String(targetDay)) return false;
// Walk up looking for a header that contains the target month.
let parent = c.parentElement;
for (let i = 0; i < 8 && parent; i++, parent = parent.parentElement) {
const header = parent.querySelector?.(
"[class*='month-header'], [class*='calendar-header'], .mat-calendar-period-button, h2, h3, thead"
);
if (header && header.textContent?.includes(targetMonthName) && header.textContent?.includes(String(targetYear))) {
return true;
}
}
return false;
});
}
if (!cell) {
const ariaSample = allCells.slice(0, 30).map((el) => ({
tag: el.tagName.toLowerCase(),
aria: el.getAttribute("aria-label"),
}));
return { ok: false, reason: "day-cell-not-found", ariaTarget, ariaSample };
}
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);
// JSX custom datepicker has a "DONE" button that must be clicked to
// commit the selection — without it, the underlying Angular FormControl
// never gets the new value and the Find Flights submit stays no-op.
const doneBtn = Array.from(document.querySelectorAll("button, [role='button']"))
.filter((el) => el.offsetWidth || el.offsetHeight)
.find((el) => /^\s*done\s*$/i.test((el.innerText || el.textContent || "").trim()));
let doneClicked = false;
if (doneBtn) {
doneBtn.scrollIntoView?.({ block: "center" });
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
doneBtn.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
}
doneBtn.click?.();
doneClicked = true;
await sleep(600);
}
// Force Angular form update (blur everything, mark controls touched).
document.body.click();
for (const i of document.querySelectorAll("input")) {
i.dispatchEvent(new Event("blur", { bubbles: true }));
}
await sleep(400);
return { ok: true, value: input.value, doneClicked };
}, { targetYear, targetMonth, targetDay, targetMonthName });
done(JSON.stringify(dateResult));
if (!dateResult.ok) throw new Error(`Date selection failed: ${JSON.stringify(dateResult)}`);
// ---------- 6b. Force Angular form revalidation ----------
done = step("Force Angular form update");
await page.evaluate(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Blur everything — most Angular reactive forms recompute validity on blur.
document.body.click();
for (const input of document.querySelectorAll("input")) {
input.dispatchEvent(new Event("blur", { bubbles: true }));
}
await sleep(300);
// Walk __ngContext__ and call markAllAsTouched + updateValueAndValidity on
// every FormGroup/FormControl we can find. This is the exact trick that
// works in JSXWebViewFetcher.swift.
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?.controls ? item.control : null;
if (form) {
try { form.markAllAsTouched?.(); form.updateValueAndValidity?.(); touched++; } catch {}
}
}
}
if (touched > 20) break;
}
await sleep(400);
});
done();
// ---------- 7. Find Flights + wait for search/simple in parallel ----------
done = step("Click Find Flights (and wait for search/simple response)");
// Start waiting BEFORE we click so we don't miss the fast response.
const searchResponsePromise = page
.waitForResponse(
(r) => r.url().includes("/availability/search/simple") && r.request().method() === "POST",
{ timeout: 25000 }
)
.catch(() => null);
// Use Playwright's locator.click() — waits for the button to be enabled
// and retries internally. Retry up to 3 times in case Angular's form state
// isn't ready on the first attempt.
const findBtn = page.getByRole("button", { name: /find flights/i }).first();
let clicked = false;
let lastErr = null;
for (let attempt = 1; attempt <= 3 && !clicked; attempt++) {
try {
await findBtn.waitFor({ state: "visible", timeout: 5000 });
await page.waitForTimeout(300);
await findBtn.click({ timeout: 5000, force: attempt >= 2 });
clicked = true;
} catch (err) {
lastErr = err;
await page.waitForTimeout(600);
}
}
if (!clicked) throw new Error(`Find Flights click failed: ${lastErr?.message}`);
// Wait up to 25s for the search/simple response.
const searchResponse = await searchResponsePromise;
if (searchResponse && !captured.searchSimple) {
// In rare cases our page.on("response") handler hasn't captured it yet.
try {
const body = await searchResponse.text();
captured.searchSimple = {
url: searchResponse.url(),
method: "POST",
status: searchResponse.status(),
body,
};
} catch {}
}
done(captured.searchSimple ? `status=${captured.searchSimple.status}, url=${page.url()}` : `NOT CAPTURED (url=${page.url()})`);
// ---------- 9. Save artifacts + summarize flight loads ----------
done = step("Write artifacts");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const baseName = `${origin}-${destination}-${date}-${stamp}`;
const artifactPaths = {};
if (captured.searchSimple) {
const p = join(outDir, `${baseName}.search-simple.json`);
await writeFile(p, captured.searchSimple.body);
artifactPaths.searchSimple = p;
}
if (captured.lowFare) {
const p = join(outDir, `${baseName}.lowfare.json`);
await writeFile(p, captured.lowFare.body);
artifactPaths.lowFare = p;
}
const callsPath = join(outDir, `${baseName}.calls.json`);
await writeFile(callsPath, JSON.stringify(captured.allCalls, null, 2));
artifactPaths.calls = callsPath;
done(JSON.stringify(artifactPaths));
if (captured.searchSimple) {
console.log("\n=== Flight loads ===");
try {
const parsed = JSON.parse(captured.searchSimple.body);
const loads = extractFlightLoads(parsed);
if (loads.length === 0) {
console.log("(no journeys in response)");
} else {
for (const load of loads) {
const depTime = load.departTime.replace("T", " ").slice(0, 16);
const arrTime = load.arriveTime.replace("T", " ").slice(0, 16);
const lowest = load.lowestFare != null ? `$${load.lowestFare}` : "—";
console.log(
` ${load.flightNumber.padEnd(9)} ${load.departure}${load.arrival} ` +
`${depTime}${arrTime} stops=${load.stops} ` +
`seats=${load.totalAvailable} from=${lowest}`
);
for (const c of load.classes) {
console.log(
` class=${c.classOfService} bundle=${c.productClass} ` +
`count=${c.availableCount} fare=$${c.fareTotal ?? "?"} rev=$${c.revenueTotal ?? "?"}`
);
}
}
}
} catch (err) {
console.log(` (failed to parse search/simple body: ${err.message})`);
}
} else {
console.log("\n!! search/simple response was never captured — check for Akamai block or UI flow break.");
console.log(" All api.jsx.com calls seen:");
for (const c of captured.allCalls) {
console.log(` ${c.method} ${c.status} ${c.url}`);
}
process.exitCode = 1;
}
} catch (err) {
console.error("\nFATAL:", err.message);
try {
const shot = join(outDir, `crash-${Date.now()}.png`);
await page.screenshot({ path: shot, fullPage: true });
console.error("screenshot:", shot);
} catch {}
process.exitCode = 1;
} finally {
try { await browser.close(); } catch {}
if (chromeProc && !chromeProc.killed) {
chromeProc.kill("SIGTERM");
// Give it a moment to exit cleanly before the process exits.
await delay(300);
}
}
// ---------- helpers ----------
async function selectStation(page, index, code) {
// Open the station picker at position `index` (0 = origin, 1 = destination)
// then click the matching option by IATA code.
const result = await page.evaluate(async ({ index, code }) => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
const triggers = Array.from(document.querySelectorAll(
"[aria-label='Station select'], [aria-label*='Station select']"
)).filter(visible);
const trigger = triggers[index];
if (!trigger) return { ok: false, reason: "trigger-not-found", triggerCount: triggers.length };
trigger.click();
await sleep(800);
// The dropdown sometimes has a search box — try to type the code first.
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")?.set;
searchInput.focus();
setter ? setter.call(searchInput, code) : (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 { ok: false, reason: "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 {
ok: false,
reason: "code-not-in-list",
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 }));
}
match.click?.();
await sleep(500);
// Angular API fallback if click did not update the trigger text.
const after = Array.from(document.querySelectorAll("[aria-label='Station select']"))[index];
const afterText = after?.textContent.trim() || "";
if (!afterText.includes(code)) {
const ctxKey = Object.keys(match).find((k) => k.startsWith("__ngContext__"));
if (ctxKey) {
const ctx = match[ctxKey];
for (const item of ctx) {
if (!item || typeof item !== "object") continue;
for (const m of ["_selectViaInteraction", "select", "onSelect", "onClick", "handleClick"]) {
if (typeof item[m] === "function") {
try { item[m](); } catch {}
}
}
}
}
await sleep(400);
}
const finalText = Array.from(document.querySelectorAll("[aria-label='Station select']"))[index]
?.textContent.trim() || "";
return { ok: finalText.includes(code), finalText };
}, { index, code });
if (!result.ok) {
throw new Error(`Station ${code} (index ${index}) failed: ${JSON.stringify(result)}`);
}
}
// Pull a per-flight load summary out of the Navitaire availability payload.
//
// Real shape (confirmed via live DAL→HOU response):
// data.results[n].trips[n].journeysAvailableByMarket["DAL|HOU"][] → journey
// journey.segments[n].identifier.{carrierCode,identifier} → flight no
// journey.segments[n].designator.{origin,destination,departure,arrival}
// journey.fares[n].fareAvailabilityKey → links to
// data.faresAvailable[key].totals.fareTotal → price
// journey.fares[n].details[n].availableCount → load
// data.faresAvailable[key].fares[n].classOfService → fare class
function extractFlightLoads(payload) {
const loads = [];
const results = payload?.data?.results || [];
const faresAvailable = payload?.data?.faresAvailable || {};
for (const result of results) {
const trips = result?.trips || [];
for (const trip of trips) {
const byMarket = trip?.journeysAvailableByMarket || {};
for (const marketKey of Object.keys(byMarket)) {
const journeys = byMarket[marketKey] || [];
for (const j of journeys) {
// Build flight-number string from each segment so we cover direct + connection.
const segs = j?.segments || [];
const flightNumbers = segs
.map((s) => `${s?.identifier?.carrierCode || ""}${s?.identifier?.identifier || ""}`)
.filter(Boolean)
.join(" + ");
const firstSeg = segs[0];
const lastSeg = segs[segs.length - 1];
const departure = j?.designator?.origin || firstSeg?.designator?.origin || "?";
const arrival = j?.designator?.destination || lastSeg?.designator?.destination || "?";
const departTime = j?.designator?.departure || firstSeg?.designator?.departure || "?";
const arriveTime = j?.designator?.arrival || lastSeg?.designator?.arrival || "?";
// Each journey.fares entry links via fareAvailabilityKey to a
// faresAvailable record that carries price + class-of-service.
const classLoads = [];
for (const f of j?.fares || []) {
const key = f?.fareAvailabilityKey;
const fareRecord = key ? faresAvailable[key] : null;
const totals = fareRecord?.totals || {};
const classOfService = fareRecord?.fares?.[0]?.classOfService || "?";
const productClass = fareRecord?.fares?.[0]?.productClass || "?";
const availableCount = (f?.details || [])
.reduce((max, d) => Math.max(max, d?.availableCount ?? 0), 0);
classLoads.push({
fareAvailabilityKey: key,
classOfService,
productClass,
availableCount,
fareTotal: totals.fareTotal ?? null,
revenueTotal: totals.revenueTotal ?? null,
});
}
// Total "seats available for sale" at this price point = sum of details.
// Lowest fare = cheapest fareTotal across the fare buckets.
const totalAvailable = classLoads.reduce((sum, c) => sum + (c.availableCount || 0), 0);
const lowestFare = classLoads
.map((c) => c.fareTotal)
.filter((p) => p != null)
.reduce((min, p) => (min == null || p < min ? p : min), null);
loads.push({
market: marketKey,
flightNumber: flightNumbers || "?",
departure,
arrival,
departTime,
arriveTime,
stops: j?.stops ?? 0,
totalAvailable,
lowestFare,
classes: classLoads,
});
}
}
}
}
return loads;
}