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>
This commit is contained in:
Trey t
2026-04-11 10:11:29 -05:00
parent 3790792040
commit 77c59ce2c2
3 changed files with 3287 additions and 0 deletions

View File

@@ -0,0 +1,700 @@
import Foundation
/// Queries airline APIs for flight load and standby data.
/// Each airline has its own endpoint and response format.
actor AirlineLoadService {
// MARK: - Properties
private let session: URLSession
private var unitedToken: (hash: String, expiresAt: Date)?
// MARK: - Init
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
session = URLSession(configuration: config)
}
// MARK: - Public Router
/// Route to the correct airline based on IATA code.
func fetchLoad(
airlineCode: String,
flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
let code = airlineCode.uppercased()
print("[LoadService] Fetching load for \(code) flight \(flightNumber) \(origin)->\(destination)")
switch code {
case "UA": return await fetchUnitedLoad(flightNumber: flightNumber, date: date, origin: origin)
case "AA": return await fetchAmericanLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "NK": return await fetchSpiritStatus(origin: origin, destination: destination, date: date)
case "KE": return await fetchKoreanAirLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date)
case "AS": return await fetchAlaskaStatus(flightNumber: flightNumber, date: date)
case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date)
case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
default:
print("[LoadService] Unsupported airline: \(code)")
return nil
}
}
// MARK: - United Airlines
/// Fetch an anonymous auth token from United, caching until expiry.
private func getUnitedToken() async -> String? {
if let cached = unitedToken, cached.expiresAt > Date() {
return cached.hash
}
guard let url = URL(string: "https://www.united.com/api/auth/anonymous-token") else { return nil }
do {
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataObj = json["data"] as? [String: Any],
let tokenObj = dataObj["token"] as? [String: Any],
let hash = tokenObj["hash"] as? String else {
return nil
}
// Cache for 25 minutes (tokens typically last 30).
unitedToken = (hash: hash, expiresAt: Date().addingTimeInterval(25 * 60))
return hash
} catch {
return nil
}
}
private func fetchUnitedLoad(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
guard let token = await getUnitedToken() else { return nil }
let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.dashDateFormatter.string(from: date)
guard let url = URL(string: "https://www.united.com/api/flightstatus/upgradeListExtended?flightNumber=\(num)&flightDate=\(dateStr)&fromAirportCode=\(origin.uppercased())") else {
return nil
}
do {
var request = URLRequest(url: url)
request.setValue("bearer \(token)", forHTTPHeaderField: "x-authorization-api")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
print("[UA] HTTP status: \((response as? HTTPURLResponse)?.statusCode ?? -1)")
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[UA] Failed to parse JSON")
return nil
}
// === LOGGING: Dump top-level keys and structure ===
print("[UA] ===== RESPONSE FOR UA\(num) =====")
print("[UA] Top-level keys: \(json.keys.sorted())")
if let segment = json["segment"] as? [String: Any] {
print("[UA] segment: \(segment)")
}
if let numCabins = json["numberOfCabins"] {
print("[UA] numberOfCabins: \(numCabins)")
}
// Log pbts
if let pbts = json["pbts"] as? [[String: Any]] {
print("[UA] pbts count: \(pbts.count)")
for (i, entry) in pbts.enumerated() {
print("[UA] pbts[\(i)]: \(entry)")
}
} else {
print("[UA] pbts: MISSING or wrong type")
}
// Log ALL cabin-level keys (front, middle, rear, etc.)
for key in json.keys.sorted() {
if let cabinData = json[key] as? [String: Any],
cabinData.keys.contains("standby") || cabinData.keys.contains("cleared") {
let clearedList = cabinData["cleared"] as? [[String: Any]] ?? []
let standbyListRaw = cabinData["standby"] as? [[String: Any]] ?? []
print("[UA] '\(key)' cabin: cleared=\(clearedList.count), standby=\(standbyListRaw.count)")
for pax in clearedList.prefix(3) {
print("[UA] cleared: \(pax["passengerName"] ?? "?") seat=\(pax["seatNumber"] ?? "?")")
}
for pax in standbyListRaw.prefix(3) {
print("[UA] standby: \(pax["passengerName"] ?? "?") seat=\(pax["seatNumber"] ?? "?")")
}
if clearedList.count > 3 { print("[UA] ... +\(clearedList.count - 3) more cleared") }
if standbyListRaw.count > 3 { print("[UA] ... +\(standbyListRaw.count - 3) more standby") }
}
}
// Log checkInSummaries if present
if let checkIn = json["checkInSummaries"] as? [[String: Any]] {
print("[UA] checkInSummaries count: \(checkIn.count)")
for entry in checkIn {
print("[UA] checkIn: \(entry)")
}
}
print("[UA] ===== END RESPONSE =====")
// Parse cabin loads from pbts array
var cabins: [CabinLoad] = []
if let pbts = json["pbts"] as? [[String: Any]] {
for entry in pbts {
let cabin = entry["cabin"] as? String ?? "Unknown"
let capacity = entry["capacity"] as? Int ?? 0
let booked = entry["booked"] as? Int ?? 0
let revStandby = entry["revenueStandby"] as? Int ?? 0
let sa = entry["sa"] as? Int ?? 0
let waitList = entry["waitList"] as? Int ?? 0
let jump = entry["jump"] as? Int ?? 0
let ps = entry["ps"] as? Int ?? 0
print("[UA] Cabin '\(cabin)': cap=\(capacity) booked=\(booked) sa=\(sa) revSB=\(revStandby) waitList=\(waitList) jump=\(jump) ps=\(ps)")
cabins.append(CabinLoad(
name: cabin,
capacity: capacity,
booked: booked,
revenueStandby: revStandby,
nonRevStandby: sa,
waitListCount: waitList,
jumpSeat: jump
))
}
}
// Parse standby / upgrade lists from ALL cabin sections
var standbyList: [StandbyPassenger] = []
var upgradeList: [StandbyPassenger] = []
// Check all keys that have cleared/standby sub-arrays
for key in json.keys.sorted() {
guard let cabinData = json[key] as? [String: Any],
cabinData.keys.contains("standby") || cabinData.keys.contains("cleared") else {
continue
}
let cabinName = key.capitalized
if let cleared = cabinData["cleared"] as? [[String: Any]] {
for pax in cleared {
upgradeList.append(StandbyPassenger(
order: upgradeList.count + 1,
displayName: pax["passengerName"] as? String ?? "",
cleared: true,
seat: pax["seatNumber"] as? String,
listName: cabinName
))
}
}
if let waiting = cabinData["standby"] as? [[String: Any]] {
for pax in waiting {
let seat = pax["seatNumber"] as? String
let hasSeat = seat != nil && !seat!.isEmpty
if hasSeat {
// Has a seat = already cleared from upgrade waitlist
upgradeList.append(StandbyPassenger(
order: upgradeList.count + 1,
displayName: pax["passengerName"] as? String ?? "",
cleared: true,
seat: seat,
listName: cabinName
))
} else {
// No seat = actually waiting on standby
standbyList.append(StandbyPassenger(
order: standbyList.count + 1,
displayName: pax["passengerName"] as? String ?? "",
cleared: false,
seat: nil,
listName: cabinName
))
}
}
}
}
return FlightLoad(
airlineCode: "UA",
flightNumber: "UA\(num)",
cabins: cabins,
standbyList: standbyList,
upgradeList: upgradeList,
seatAvailability: []
)
} catch {
return nil
}
}
// MARK: - American Airlines
private func fetchAmericanLoad(
flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.dashDateFormatter.string(from: date)
var components = URLComponents(string: "https://cdn.flyaa.aa.com/api/mobile/loyalty/waitlist/v1.2")
components?.queryItems = [
URLQueryItem(name: "carrierCode", value: "AA"),
URLQueryItem(name: "flightNumber", value: num),
URLQueryItem(name: "departureDate", value: dateStr),
URLQueryItem(name: "originAirportCode", value: origin.uppercased()),
URLQueryItem(name: "destinationAirportCode", value: destination.uppercased())
]
guard let url = components?.url else { return nil }
do {
var request = URLRequest(url: url)
request.setValue("Android/2025.31 Pixel 7|14|1080|2400|1.0|AmericanAirlines", forHTTPHeaderField: "User-Agent")
request.setValue("MOBILE", forHTTPHeaderField: "x-clientid")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(UUID().uuidString, forHTTPHeaderField: "Device-ID")
request.setValue("fs", forHTTPHeaderField: "x-referrer")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let waitListArray = json["waitList"] as? [[String: Any]] else {
return nil
}
var seatAvailability: [SeatAvailability] = []
var standbyList: [StandbyPassenger] = []
var upgradeList: [StandbyPassenger] = []
for entry in waitListArray {
let listName = entry["listName"] as? String ?? "Unknown"
let seatsAvailable = entry["seatsAvailableValue"] as? Int ?? 0
let semanticColor = entry["seatsAvailableSemanticColor"] as? String ?? "success"
let label = listName == "First" ? "First Class Upgrades" : "\(listName) Seats"
seatAvailability.append(SeatAvailability(
label: label,
available: seatsAvailable,
color: SeatAvailabilityColor(rawValue: semanticColor) ?? .success
))
// Parse passengers
if let passengers = entry["passengers"] as? [[String: Any]] {
for pax in passengers {
let order = pax["order"] as? Int ?? 0
let displayName = pax["displayName"] as? String ?? ""
let cleared = pax["cleared"] as? Bool ?? false
let seat = pax["seat"] as? String
let passenger = StandbyPassenger(
order: order,
displayName: displayName,
cleared: cleared,
seat: seat,
listName: listName
)
if listName.lowercased() == "standby" {
standbyList.append(passenger)
} else {
upgradeList.append(passenger)
}
}
}
}
return FlightLoad(
airlineCode: "AA",
flightNumber: "AA\(num)",
cabins: [],
standbyList: standbyList,
upgradeList: upgradeList,
seatAvailability: seatAvailability
)
} catch {
return nil
}
}
// MARK: - Spirit Airlines
private func fetchSpiritStatus(origin: String, destination: String, date: Date) async -> FlightLoad? {
guard let url = URL(string: "https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI") else {
print("[NK] Invalid URL")
return nil
}
let dateStr = Self.dashDateFormatter.string(from: date)
let body: [String: String] = [
"departureStation": origin.uppercased(),
"arrivalStation": destination.uppercased(),
"departureDate": dateStr
]
print("[NK] POST \(url) body: \(body)")
do {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("c6567af50d544dfbb3bc5dd99c6bb177", forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
request.setValue("Android", forHTTPHeaderField: "Platform")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[NK] HTTP status: \(http?.statusCode ?? -1)")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[NK] Response body: \(bodyStr.prefix(500))")
}
guard http?.statusCode == 200 else {
print("[NK] Non-200 response")
return nil
}
// Spirit is a ULCC with no standby program.
return FlightLoad(
airlineCode: "NK",
flightNumber: "NK",
cabins: [],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[NK] Error: \(error)")
return nil
}
}
// MARK: - Korean Air
private func fetchKoreanAirLoad(
flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
guard let url = URL(string: "https://www.koreanair.com/api/et/ibeSupport/flightSeatCount") else {
return nil
}
let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.compactDateFormatter.string(from: date)
let body: [String: String] = [
"carrierCode": "KE",
"flightNumber": num,
"departureAirport": origin.uppercased(),
"arrivalAirport": destination.uppercased(),
"departureDate": dateStr
]
do {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("pc", forHTTPHeaderField: "channel")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let seatCount = json["seatCount"] as? Int ?? 0
let cabin = CabinLoad(
name: "Economy",
capacity: 0,
booked: 0,
revenueStandby: 0,
nonRevStandby: seatCount
)
return FlightLoad(
airlineCode: "KE",
flightNumber: "KE\(num)",
cabins: [cabin],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
return nil
}
}
// MARK: - JetBlue
private func fetchJetBlueStatus(flightNumber: String, date: Date) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.dashDateFormatter.string(from: date)
guard let url = URL(string: "https://az-api.jetblue.com/flight-status/get-by-number?number=\(num)&date=\(dateStr)") else {
print("[B6] Invalid URL")
return nil
}
print("[B6] GET \(url)")
do {
var request = URLRequest(url: url)
request.setValue("49fc015f1ba44abf892d2b8961612378", forHTTPHeaderField: "apikey")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[B6] HTTP status: \(http?.statusCode ?? -1)")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[B6] Response: \(bodyStr.prefix(800))")
}
guard http?.statusCode == 200 else { return nil }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let flights = json["flights"] as? [[String: Any]],
let flight = flights.first,
let legs = flight["legs"] as? [[String: Any]],
let leg = legs.first else {
print("[B6] Failed to parse flight data")
return nil
}
let flightNo = leg["flightNo"] as? String ?? num
let status = leg["flightStatus"] as? String ?? "Unknown"
print("[B6] Flight B6\(flightNo) status: \(status)")
// JetBlue flight status only no seat/standby data without check-in session
return FlightLoad(
airlineCode: "B6",
flightNumber: "B6\(flightNo)",
cabins: [],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[B6] Error: \(error)")
return nil
}
}
// MARK: - Alaska Airlines
private func fetchAlaskaStatus(flightNumber: String, date: Date) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.dashDateFormatter.string(from: date)
guard let url = URL(string: "https://www.alaskaair.com/1/guestservices/customermobile/flights/status/AS/\(num)/\(dateStr)") else {
print("[AS] Invalid URL")
return nil
}
print("[AS] GET \(url)")
do {
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[AS] HTTP status: \(http?.statusCode ?? -1)")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[AS] Response: \(bodyStr.prefix(800))")
}
guard http?.statusCode == 200 else { return nil }
// Alaska flight status only seat data requires confirmation code
return FlightLoad(
airlineCode: "AS",
flightNumber: "AS\(num)",
cabins: [],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[AS] Error: \(error)")
return nil
}
}
// MARK: - Emirates
private func fetchEmiratesStatus(flightNumber: String, date: Date) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.dashDateFormatter.string(from: date)
// Pad flight number to 4 digits (Emirates uses 0-padded numbers like "0221")
let paddedNum = String(repeating: "0", count: max(0, 4 - num.count)) + num
guard let url = URL(string: "https://www.emirates.com/service/flight-status?departureDate=\(dateStr)&flight=\(paddedNum)") else {
print("[EK] Invalid URL")
return nil
}
print("[EK] GET \(url)")
do {
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[EK] HTTP status: \(http?.statusCode ?? -1)")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[EK] Response: \(bodyStr.prefix(800))")
}
guard http?.statusCode == 200 else { return nil }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let results = json["results"] as? [[String: Any]],
let flight = results.first else {
print("[EK] Failed to parse flight data")
return nil
}
let flightNo = flight["flightNumber"] as? String ?? num
print("[EK] Flight EK\(flightNo) found")
// Emirates flight status only seat/load data requires PNR
return FlightLoad(
airlineCode: "EK",
flightNumber: "EK\(flightNo)",
cabins: [],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[EK] Error: \(error)")
return nil
}
}
// MARK: - JSX (JetSuiteX)
private func fetchJSXLoad(flightNumber: String, date: Date, origin: String, destination: String) async -> FlightLoad? {
let dateStr = Self.dashDateFormatter.string(from: date)
let num = stripAirlinePrefix(flightNumber)
let upperOrigin = origin.uppercased()
let upperDestination = destination.uppercased()
print("[XE] Fetching JSX for XE\(num) \(upperOrigin)->\(upperDestination) on \(dateStr)")
let fetcher = await JSXWebViewFetcher()
let result = await fetcher.fetchAvailability(
origin: upperOrigin,
destination: upperDestination,
date: dateStr
)
if let error = result.error {
print("[XE] JSX flow failed: \(error)")
return nil
}
print("[XE] JSX returned \(result.flights.count) unique flights for \(upperOrigin)|\(upperDestination):")
for f in result.flights {
let low = f.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a"
print("[XE] - \(f.flightNumber) \(f.departureLocal) seats=\(f.totalAvailable) low=\(low)")
}
// Find the specific flight the caller asked for. We compare the
// digits-only portion so "XE280" matches "280", "XE 280", etc.
let targetDigits = num.filter(\.isNumber)
guard let flight = result.flights.first(where: {
$0.flightNumber.filter(\.isNumber) == targetDigits
}) else {
print("[XE] Flight XE\(num) not present in \(result.flights.count) results")
return nil
}
let capacity = 30 // JSX ERJ-145 approximate capacity
let booked = max(0, capacity - flight.totalAvailable)
return FlightLoad(
airlineCode: "XE",
flightNumber: flight.flightNumber,
cabins: [
CabinLoad(
name: "Cabin",
capacity: capacity,
booked: booked,
revenueStandby: 0,
nonRevStandby: 0
)
],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
}
// MARK: - Helpers
/// Strip any airline prefix from a flight number string.
/// "AA 2238" -> "2238", "UA2238" -> "2238", "2238" -> "2238"
private func stripAirlinePrefix(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
// Drop leading letters and any space to get just the numeric portion
var start = trimmed.startIndex
while start < trimmed.endIndex && (trimmed[start].isLetter || trimmed[start] == " ") {
start = trimmed.index(after: start)
}
let result = String(trimmed[start...])
return result.isEmpty ? trimmed : result
}
/// "yyyy-MM-dd" formatter for United, American, Spirit
private static let dashDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
return f
}()
/// "yyyyMMdd" formatter for Korean Air
private static let compactDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyyMMdd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
return f
}()
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,843 @@
#!/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;
}