#!/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; }