From 77c59ce2c27ecc55e0db260c1fe27ff3f1c36c32 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 11 Apr 2026 10:11:29 -0500 Subject: [PATCH] Rewrite JSX flow with per-step verification + direct API call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Flights/Services/AirlineLoadService.swift | 700 +++++++++ Flights/Services/JSXWebViewFetcher.swift | 1744 +++++++++++++++++++++ scripts/jsx_playwright_search.mjs | 843 ++++++++++ 3 files changed, 3287 insertions(+) create mode 100644 Flights/Services/AirlineLoadService.swift create mode 100644 Flights/Services/JSXWebViewFetcher.swift create mode 100644 scripts/jsx_playwright_search.mjs diff --git a/Flights/Services/AirlineLoadService.swift b/Flights/Services/AirlineLoadService.swift new file mode 100644 index 0000000..876c728 --- /dev/null +++ b/Flights/Services/AirlineLoadService.swift @@ -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 + }() +} diff --git a/Flights/Services/JSXWebViewFetcher.swift b/Flights/Services/JSXWebViewFetcher.swift new file mode 100644 index 0000000..5ded5db --- /dev/null +++ b/Flights/Services/JSXWebViewFetcher.swift @@ -0,0 +1,1744 @@ +import Foundation +import WebKit + +// MARK: - Public types + +/// One fare class (e.g. Q/HO "Hop-On", Q/AI "All-In") inside a JSX flight. +struct JSXFareClass: Sendable { + let classOfService: String // "Q", "N", "S", "B" + let productClass: String // "HO" (Hop-On), "AI" (All-In) + let availableCount: Int // seats sellable at this class + let fareTotal: Double // price incl. taxes + let revenueTotal: Double // base fare + let fareBasisCode: String? // "Q00AHOXA" +} + +/// One unique flight surfaced by the JSX /availability/search/simple response. +/// The response typically contains several flights for a given route/date; each +/// entry here represents one of them. +struct JSXFlight: Sendable { + let flightNumber: String // "XE280" + let carrierCode: String // "XE" + let origin: String // "DAL" + let destination: String // "HOU" + let departureLocal: String // "2026-04-15T10:50:00" + let arrivalLocal: String // "2026-04-15T12:05:00" + let stops: Int + let equipmentType: String? // "ER4" + let totalAvailable: Int // sum of classes[].availableCount + let lowestFareTotal: Double? // min of classes[].fareTotal + let classes: [JSXFareClass] // per-class breakdown +} + +/// Result of one run of the JSX WKWebView flow. +struct JSXSearchResult: Sendable { + let flights: [JSXFlight] // one entry per unique flight, empty on failure + let rawSearchBody: String? // raw search/simple JSON, preserved for debug + let error: String? // non-nil iff a step failed +} + +// MARK: - Fetcher + +/// Drives the jsx.com SPA inside a WKWebView to capture the +/// /api/nsk/v4/availability/search/simple response (the one JSX's own website +/// uses to render fares and loads). The flow is broken into explicit steps, +/// each with an action AND a post-condition verification, so failures are +/// pinpointable from the console log. +@MainActor +final class JSXWebViewFetcher: NSObject { + func fetchAvailability( + origin: String, + destination: String, + date: String + ) async -> JSXSearchResult { + let flow = JSXFlow(origin: origin, destination: destination, date: date) + return await flow.run() + } +} + +// MARK: - Flow orchestrator + +@MainActor +private final class JSXFlow { + let origin: String + let destination: String + let date: String + + private var webView: WKWebView! + private var stepNumber = 0 + private var capturedBody: String? + + // Parsed date parts (used by step 14 aria-label match). + private let targetYear: Int + private let targetMonth: Int // 1-12 + private let targetDay: Int + private let targetMonthName: String + + init(origin: String, destination: String, date: String) { + self.origin = origin.uppercased() + self.destination = destination.uppercased() + self.date = date + + let parts = date.split(separator: "-") + let year = parts.count == 3 ? Int(parts[0]) ?? 0 : 0 + let month = parts.count == 3 ? Int(parts[1]) ?? 0 : 0 + let day = parts.count == 3 ? Int(parts[2]) ?? 0 : 0 + let months = [ + "January","February","March","April","May","June", + "July","August","September","October","November","December" + ] + self.targetYear = year + self.targetMonth = month + self.targetDay = day + self.targetMonthName = (month >= 1 && month <= 12) ? months[month - 1] : "" + } + + // MARK: - Run + + func run() async -> JSXSearchResult { + logHeader("JSX FLOW START: \(origin) → \(destination) on \(date)") + + guard targetYear > 0, targetMonth >= 1, targetMonth <= 12, targetDay >= 1 else { + return fail("Invalid date '\(date)' (expected YYYY-MM-DD)") + } + + // ---- STEP 01 ---- + let s01 = await nativeStep("Create WKWebView") { [weak self] in + guard let self else { return (false, "self gone", [:]) } + let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1280, height: 900)) + wv.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15" + self.webView = wv + return (true, "created 1280x900 with Safari UA", [:]) + } verify: { [weak self] in + guard let self else { + return [Verification(name: "webView non-nil", passed: false, detail: "flow deallocated")] + } + return [Verification(name: "webView non-nil", passed: self.webView != nil, detail: "")] + } + if !s01.ok { return fail("Step 01 failed: \(s01.message)") } + + // ---- STEP 02 ---- + let s02 = await nativeStep("Navigate to https://www.jsx.com/") { [weak self] in + guard let self else { return (false, "self gone", [:]) } + guard let url = URL(string: "https://www.jsx.com/") else { + return (false, "invalid URL", [:]) + } + let loaded = await self.navigateAndWait(url) + return (loaded, loaded ? "didFinishNavigation" : "navigation failed", [:]) + } verify: { [weak self] in + guard let self else { + return [Verification(name: "location.href contains jsx.com", passed: false, detail: "flow deallocated")] + } + let v = await self.asyncVerify(name: "location.href contains jsx.com", js: """ + return await (async () => { + const href = location.href || ""; + return JSON.stringify({ ok: href.includes("jsx.com"), detail: href }); + })(); + """) + return [v] + } + if !s02.ok { return fail("Step 02 failed: \(s02.message)") } + + // ---- STEP 03 ---- + let s03 = await jsStep( + "Wait for SPA bootstrap (poll for station buttons)", + action: """ + return await (async () => { + const deadline = Date.now() + 15000; + let count = 0; + while (Date.now() < deadline) { + count = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length; + if (count >= 2) break; + await new Promise(r => setTimeout(r, 250)); + } + return JSON.stringify({ + ok: count >= 2, + message: count >= 2 ? ("station buttons visible: " + count) : ("timeout; count=" + count), + stationButtonCount: count + }); + })(); + """, + verifiers: [ + Verifier(name: "≥2 station buttons visible", js: """ + return await (async () => { + const n = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length; + return JSON.stringify({ ok: n >= 2, detail: "count=" + n }); + })(); + """) + ] + ) + if !s03.ok { return fail("Step 03 failed: \(s03.message)") } + + // ---- STEP 04 ---- + // The interceptor tracks THREE things for every api.jsx.com request: + // 1. `initiatedCalls` — every URL passed into fetch()/XHR.send(), + // even ones that never receive a response (e.g. network failure, + // Akamai HTTP/2 block). + // 2. `allCalls` — every call that completed with any status, plus + // any error text if the request threw. + // 3. `searchSimple`/`lowFare`/`token`/`setCulture` — the specific + // response bodies we care about. + // This lets us distinguish "Angular's search() never fired a request" + // from "Angular fired a request but the network rejected it". + let s04 = await jsStep( + "Install network interceptor", + action: """ + return await (async () => { + if (window.__jsxProbe) { + return JSON.stringify({ ok: true, message: "already installed", already: true }); + } + window.__jsxProbe = { + searchSimple: null, + lowFare: null, + token: null, + setCulture: null, + allCalls: [], + initiatedCalls: [] + }; + + const noteInitiated = (url, method, transport) => { + try { + if (!url.includes("api.jsx.com")) return; + window.__jsxProbe.initiatedCalls.push({ + url, method, transport, t: Date.now() + }); + } catch (_) {} + }; + + const captureIfMatch = (url, method, status, body, transport, error) => { + try { + if (!url.includes("api.jsx.com")) return; + const entry = { url, method, status, body, transport, error: error || null }; + window.__jsxProbe.allCalls.push({ + url, method, status, transport, error: error || null + }); + if (url.includes("/availability/search/simple")) window.__jsxProbe.searchSimple = entry; + else if (url.includes("/lowfare/estimate")) window.__jsxProbe.lowFare = entry; + else if (url.includes("/nsk/v2/token")) window.__jsxProbe.token = entry; + else if (url.includes("/graph/setCulture")) window.__jsxProbe.setCulture = entry; + } catch (_) {} + }; + + const origFetch = window.fetch.bind(window); + window.fetch = async function(input, init) { + const url = typeof input === "string" ? input : (input && input.url ? input.url : ""); + const method = (init && init.method) || "GET"; + noteInitiated(url, method, "fetch"); + let resp; + try { + resp = await origFetch(input, init); + } catch (err) { + captureIfMatch(url, method, 0, "", "fetch", String(err && err.message ? err.message : err)); + throw err; + } + try { + if (url.includes("api.jsx.com")) { + const body = await resp.clone().text(); + captureIfMatch(url, method, resp.status, body, "fetch"); + } + } catch (_) {} + return resp; + }; + + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__jsxMethod = method; + this.__jsxUrl = typeof url === "string" ? url : String(url || ""); + return origOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + noteInitiated(this.__jsxUrl || "", this.__jsxMethod || "GET", "xhr"); + this.addEventListener("loadend", () => { + try { + captureIfMatch( + this.__jsxUrl || "", + this.__jsxMethod || "GET", + this.status, + this.responseText || "", + "xhr", + this.status === 0 ? "xhr status 0" : null + ); + } catch (_) {} + }); + this.addEventListener("error", () => { + try { + captureIfMatch( + this.__jsxUrl || "", + this.__jsxMethod || "GET", + 0, "", "xhr", "xhr error event" + ); + } catch (_) {} + }); + return origSend.apply(this, arguments); + }; + + return JSON.stringify({ ok: true, message: "installed fetch+xhr hooks with initiation tracking" }); + })(); + """, + verifiers: [ + Verifier(name: "window.__jsxProbe installed", js: """ + return await (async () => { + const p = window.__jsxProbe; + const ok = typeof p === "object" && p !== null && Array.isArray(p.initiatedCalls); + return JSON.stringify({ ok, detail: ok ? "present" : "missing" }); + })(); + """) + ] + ) + if !s04.ok { return fail("Step 04 failed: \(s04.message)") } + + // ---- STEP 05 ---- + let s05 = await jsStep( + "Dismiss Osano cookie banner", + action: """ + return await (async () => { + const visible = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + const txt = el => (el.innerText || el.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase(); + const actions = []; + + // Class-based accept (most reliable on Osano). + const classAccept = document.querySelector( + ".osano-cm-accept-all, .osano-cm-accept, button.osano-cm-button[class*='accept']" + ); + if (classAccept && visible(classAccept)) { + classAccept.click(); + actions.push("class-accept"); + } + + // Text fallback. + 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; } + } + + // Force-remove the banner element regardless of whether the click "stuck", + // because role='dialog' on the Osano window confuses calendar detection. + await new Promise(r => setTimeout(r, 300)); + document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach(el => { + el.remove(); + actions.push("removed-node"); + }); + + return JSON.stringify({ ok: true, message: actions.join(", ") || "nothing to do", actions }); + })(); + """, + verifiers: [ + Verifier(name: "no .osano-cm-window visible", js: """ + return await (async () => { + const el = document.querySelector(".osano-cm-window"); + const gone = !el || !(el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: gone, detail: gone ? "gone" : "still visible" }); + })(); + """), + Verifier(name: "no .osano-cm-dialog visible", js: """ + return await (async () => { + const el = document.querySelector(".osano-cm-dialog"); + const gone = !el || !(el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: gone, detail: gone ? "gone" : "still visible" }); + })(); + """) + ] + ) + if !s05.ok { return fail("Step 05 failed: \(s05.message)") } + + // ---- STEP 06 ---- + _ = await jsStep( + "Dismiss marketing modal (if present)", + action: """ + return await (async () => { + const visible = el => !!(el.offsetWidth || el.offsetHeight); + const close = Array.from(document.querySelectorAll("button, [role='button']")) + .filter(visible) + .find(el => (el.getAttribute("aria-label") || "").toLowerCase() === "close dialog"); + if (close) { close.click(); return JSON.stringify({ ok: true, message: "closed dialog" }); } + return JSON.stringify({ ok: true, message: "no dialog found" }); + })(); + """, + verifiers: [] + ) + + // ---- STEP 07 ---- + let s07 = await jsStep( + "Select trip type = One Way", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const visible = el => !!(el.offsetWidth || el.offsetHeight); + const combo = Array.from(document.querySelectorAll("[role='combobox']")) + .find(el => /round trip|one way|multi city/i.test(el.textContent || "")); + if (!combo) return JSON.stringify({ ok: false, message: "trip combobox not found" }); + + const anyOption = () => Array.from(document.querySelectorAll("mat-option, [role='option']")) + .find(visible); + + const tried = []; + + // Strategy 1: plain click. + combo.scrollIntoView({ block: "center" }); + combo.click(); + tried.push("click"); + await sleep(400); + + // Strategy 2: focus + keyboard events. + if (!anyOption()) { + combo.focus && 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 + })); + tried.push("key:" + key); + await sleep(350); + if (anyOption()) break; + } + } + + // Strategy 3: walk __ngContext__ for a mat-select with .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(); tried.push("ngContext.open"); } catch (_) {} + await sleep(400); + if (anyOption()) break; + } + } + } + } + + const options = Array.from(document.querySelectorAll("mat-option, [role='option']")) + .filter(visible); + if (options.length === 0) { + return JSON.stringify({ ok: false, message: "dropdown did not open", tried }); + } + + const oneWay = options.find(el => /one\\s*way/i.test(el.textContent || "")); + if (!oneWay) { + return JSON.stringify({ + ok: false, + message: "One Way option not found", + tried, + visibleOptions: options.map(o => (o.textContent || "").trim()).slice(0, 10) + }); + } + + oneWay.scrollIntoView({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + oneWay.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + if (typeof oneWay.click === "function") oneWay.click(); + await sleep(500); + + // Angular API fallback if the click didn't commit. + const stillHasReturn = Array.from(document.querySelectorAll("input")).some(i => { + const label = (i.getAttribute("aria-label") || "").toLowerCase(); + return label.includes("return date") && (i.offsetWidth || i.offsetHeight); + }); + let usedAngular = false; + if (stillHasReturn) { + const ctxKey = Object.keys(oneWay).find(k => k.startsWith("__ngContext__")); + if (ctxKey) { + for (const item of oneWay[ctxKey] || []) { + if (item && typeof item === "object") { + if (typeof item._selectViaInteraction === "function") { + try { item._selectViaInteraction(); usedAngular = true; break; } catch (_) {} + } + if (typeof item.select === "function" && item.value !== undefined) { + try { item.select(); usedAngular = true; break; } catch (_) {} + } + } + } + } + await sleep(500); + } + + return JSON.stringify({ + ok: true, + message: "one way dispatched" + (usedAngular ? " (angular fallback)" : ""), + tried, usedAngular + }); + })(); + """, + verifiers: [ + Verifier(name: "no return-date input visible", js: """ + return await (async () => { + const still = Array.from(document.querySelectorAll("input")).some(i => { + const label = (i.getAttribute("aria-label") || "").toLowerCase(); + return label.includes("return date") && (i.offsetWidth || i.offsetHeight); + }); + return JSON.stringify({ ok: !still, detail: still ? "return-date still visible" : "hidden" }); + })(); + """), + Verifier(name: "combobox text contains 'One Way'", js: """ + return await (async () => { + const combo = Array.from(document.querySelectorAll("[role='combobox']")) + .find(el => /round trip|one way|multi city/i.test(el.textContent || "")); + const text = combo ? (combo.textContent || "").replace(/\\s+/g, " ").trim() : ""; + const ok = /one\\s*way/i.test(text); + return JSON.stringify({ ok, detail: text.slice(0, 80) }); + })(); + """) + ] + ) + if !s07.ok { return fail("Step 07 failed: \(s07.message)") } + + // ---- STEP 08 ---- + let s08 = await jsStep( + "Open origin station picker", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const stations = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']")); + if (stations.length < 2) return JSON.stringify({ ok: false, message: "found " + stations.length + " station buttons, need 2" }); + stations[0].click(); + await sleep(800); + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(el => el.offsetWidth || el.offsetHeight); + return JSON.stringify({ + ok: items.length > 0, + message: "dropdown items: " + items.length, + count: items.length + }); + })(); + """, + verifiers: [ + Verifier(name: "dropdown items visible", js: """ + return await (async () => { + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(el => el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: items.length > 0, detail: "count=" + items.length }); + })(); + """) + ] + ) + if !s08.ok { return fail("Step 08 failed: \(s08.message)") } + + // ---- STEP 09 ---- + let s09 = await jsStep( + "Select origin = \(origin)", + action: selectStationActionJS(indexIsDestination: false), + verifiers: [ + Verifier( + name: "station button[0] contains \(origin)", + js: verifyStationButtonJS(index: 0, code: origin) + ) + ] + ) + if !s09.ok { return fail("Step 09 failed: \(s09.message)") } + + // ---- STEP 10 ---- + let s10 = await jsStep( + "Open destination station picker", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + await sleep(300); + const stations = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']")); + if (stations.length < 2) return JSON.stringify({ ok: false, message: "lost station buttons" }); + stations[1].click(); + await sleep(800); + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(el => el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: items.length > 0, message: "dropdown items: " + items.length }); + })(); + """, + verifiers: [ + Verifier(name: "dropdown items visible", js: """ + return await (async () => { + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(el => el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: items.length > 0, detail: "count=" + items.length }); + })(); + """) + ] + ) + if !s10.ok { return fail("Step 10 failed: \(s10.message)") } + + // ---- STEP 11 ---- + let s11 = await jsStep( + "Select destination = \(destination)", + action: selectStationActionJS(indexIsDestination: true), + verifiers: [ + Verifier( + name: "station button[1] contains \(destination)", + js: verifyStationButtonJS(index: 1, code: destination) + ) + ] + ) + if !s11.ok { return fail("Step 11 failed: \(s11.message)") } + + // ---- STEP 12 ---- + // JSX's picker renders in two phases: first the overlay shell (with + // month-name text in it), then the actual day cells a few hundred + // milliseconds later. We must wait for phase 2 — otherwise step 13 + // runs before any [aria-label="Month D, YYYY"] exists in the DOM and + // gives up. + let s12 = await jsStep( + "Open depart datepicker (wait for day cells to render)", + action: """ + return await (async () => { + 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 JSON.stringify({ ok: false, message: "depart-date input not found" }); + + const wrapper = input.closest("mat-form-field, jsx-form-field, .form-field, .jsx-form-field"); + let toggle = + (wrapper && wrapper.querySelector("mat-datepicker-toggle button")) || + (wrapper && wrapper.querySelector("button[aria-label*='calendar' i]")) || + (wrapper && wrapper.querySelector("button[aria-label*='date picker' i]")) || + input; + toggle.click(); + await sleep(400); + + // Count real day cells: any visible element whose aria-label + // matches the pattern " , ". + const DATE_ARIA = /\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4}\\b/; + const countDayCells = () => Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => (el.offsetWidth || el.offsetHeight) && DATE_ARIA.test(el.getAttribute("aria-label") || "")) + .length; + + // Poll up to 5 seconds for day cells to actually render. + const deadline = Date.now() + 5000; + let reopens = 0; + let lastCellCount = 0; + while (Date.now() < deadline) { + lastCellCount = countDayCells(); + if (lastCellCount > 0) break; + // Halfway through, try re-clicking the input in case the + // first open didn't take. + if (reopens === 0 && Date.now() - (deadline - 5000) > 2000) { + input.click(); + if (input.focus) input.focus(); + reopens++; + } + await sleep(150); + } + + return JSON.stringify({ + ok: lastCellCount > 0, + message: lastCellCount > 0 + ? ("picker open, " + lastCellCount + " day cells rendered" + (reopens ? " (reopened " + reopens + "x)" : "")) + : "picker opened but no day cells rendered within 5s", + dayCellCount: lastCellCount, + reopens + }); + })(); + """, + verifiers: [ + Verifier(name: "≥1 day-aria-label cell rendered", js: """ + return await (async () => { + const DATE_ARIA = /\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4}\\b/; + const n = Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => (el.offsetWidth || el.offsetHeight) && DATE_ARIA.test(el.getAttribute("aria-label") || "")) + .length; + return JSON.stringify({ ok: n > 0, detail: "day cells=" + n }); + })(); + """) + ] + ) + if !s12.ok { return fail("Step 12 failed: \(s12.message)") } + + // ---- STEP 13 ---- + let s13 = await jsStep( + "Ensure target day cell is visible (\(targetMonthName) \(targetDay), \(targetYear))", + action: navigateMonthActionJS(), + verifiers: [ + Verifier(name: "aria-label '\(targetMonthName) \(targetDay), \(targetYear)' present", js: """ + return await (async () => { + const targetMonthName = "\(targetMonthName)"; + const targetYear = \(targetYear); + const targetDay = \(targetDay); + const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear; + const cells = Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => el.offsetWidth || el.offsetHeight); + const exact = cells.some(el => { + const a = (el.getAttribute("aria-label") || "").trim(); + return a === ariaExact || a.startsWith(ariaExact); + }); + if (exact) return JSON.stringify({ ok: true, detail: "exact match" }); + const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b"); + const loose = cells.some(el => { + const a = el.getAttribute("aria-label") || ""; + return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a); + }); + return JSON.stringify({ ok: loose, detail: loose ? "loose match" : "cell not found" }); + })(); + """) + ] + ) + if !s13.ok { return fail("Step 13 failed: \(s13.message)") } + + // ---- STEP 14 ---- + let s14 = await jsStep( + "Click day cell for \(targetMonthName) \(targetDay), \(targetYear) (by aria-label)", + action: clickDayCellActionJS(), + verifiers: [ + Verifier(name: "day cell click was dispatched", js: """ + return await (async () => { + // Post-condition check: the depart-date input should now have + // a value. If not, the click missed. + const input = Array.from(document.querySelectorAll("input")).find(i => { + const label = (i.getAttribute("aria-label") || "").toLowerCase(); + return label.includes("depart date"); + }); + const val = (input && input.value) || ""; + return JSON.stringify({ ok: val.length > 0, detail: "input.value=" + (val || "(empty)") }); + })(); + """) + ] + ) + if !s14.ok { return fail("Step 14 failed: \(s14.message)") } + + // ---- STEP 15 ---- + let s15 = await jsStep( + "Click DONE button to commit date", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const visible = el => !!(el.offsetWidth || el.offsetHeight); + const doneBtn = Array.from(document.querySelectorAll("button, [role='button']")) + .filter(visible) + .find(el => /^\\s*done\\s*$/i.test((el.innerText || el.textContent || "").trim())); + if (!doneBtn) { + return JSON.stringify({ ok: false, message: "DONE button not found" }); + } + doneBtn.scrollIntoView({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + doneBtn.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + if (typeof doneBtn.click === "function") doneBtn.click(); + await sleep(600); + return JSON.stringify({ ok: true, message: "dispatched click on DONE" }); + })(); + """, + verifiers: [ + Verifier(name: "datepicker panel no longer visible", js: """ + return await (async () => { + const candidates = document.querySelectorAll( + "mat-calendar, .mat-calendar, mat-datepicker-content, [class*='mat-datepicker'], .cdk-overlay-container [class*='datepicker']" + ); + let anyVisible = false; + for (const c of candidates) { if (c.offsetWidth || c.offsetHeight) { anyVisible = true; break; } } + return JSON.stringify({ ok: !anyVisible, detail: anyVisible ? "still visible" : "closed" }); + })(); + """), + Verifier(name: "depart-date input has a value", js: """ + return await (async () => { + const inputs = Array.from(document.querySelectorAll("input")).filter(i => + (i.getAttribute("aria-label") || "").toLowerCase().includes("depart date") + ); + const withValue = inputs.filter(i => (i.value || "").length > 0); + const firstVal = (inputs[0] && inputs[0].value) || ""; + return JSON.stringify({ + ok: withValue.length > 0, + detail: "value=" + (firstVal || "(empty)") + }); + })(); + """), + Verifier(name: "only one visible depart-date input", js: """ + return await (async () => { + const visibles = Array.from(document.querySelectorAll("input")) + .filter(i => (i.getAttribute("aria-label") || "").toLowerCase().includes("depart date")) + .filter(i => i.offsetWidth || i.offsetHeight); + return JSON.stringify({ ok: visibles.length === 1, detail: "count=" + visibles.length }); + })(); + """) + ] + ) + if !s15.ok { return fail("Step 15 failed: \(s15.message)") } + + // ---- STEP 16 ---- + let s16 = await jsStep( + "Force Angular form revalidation", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + document.body.click(); + for (const i of document.querySelectorAll("input")) { + i.dispatchEvent(new Event("blur", { bubbles: true })); + } + await sleep(300); + 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 && item.control.controls ? item.control : null); + if (form) { + try { if (form.markAllAsTouched) form.markAllAsTouched(); } catch (_) {} + try { if (form.updateValueAndValidity) form.updateValueAndValidity(); } catch (_) {} + touched++; + } + } + } + if (touched > 30) break; + } + await sleep(400); + return JSON.stringify({ ok: true, message: "touched " + touched + " form controls", touched }); + })(); + """, + verifiers: [ + Verifier(name: "Find Flights button is enabled", js: """ + return await (async () => { + const btn = Array.from(document.querySelectorAll("button")) + .find(b => /find flights/i.test(b.textContent || "")); + if (!btn) return JSON.stringify({ ok: false, detail: "button not found" }); + const disabled = btn.disabled || btn.getAttribute("aria-disabled") === "true"; + return JSON.stringify({ + ok: !disabled, + detail: "disabled=" + btn.disabled + " aria-disabled=" + btn.getAttribute("aria-disabled") + }); + })(); + """) + ] + ) + if !s16.ok { return fail("Step 16 failed: \(s16.message)") } + + // ---- STEP 17 ---- + // IMPORTANT: we do NOT click the Find Flights button. In WKWebView, + // synthetic MouseEvents have `isTrusted=false`, and JSX's custom + // datepicker commits its selection into the Angular FormControl only + // on trusted user gestures. The result is: the date input shows + // "Sat, Apr 11" but the underlying FormControl is null, Angular's + // search() sees form.invalid === true, and silently bails without + // firing a request. + // + // Playwright doesn't hit this because CDP's Input.dispatchMouseEvent + // produces trusted events. WKWebView has no equivalent. + // + // Workaround: call the /availability/search/simple endpoint directly + // from within the loaded page context. We have: + // - The browser's TLS fingerprint and session cookies (so Akamai + // doesn't reject us the way it rejected Playwright's direct Chrome + // launch before we switched to connectOverCDP). + // - The anonymous auth token sitting in + // sessionStorage["navitaire.digital.token"], put there by the + // SPA's own bootstrap POST /api/nsk/v2/token call. + // - Steps 5–16 still ran (origin/dest/date UI flow), which warmed + // up the app state and triggered the lowfare/estimate preload, + // so the session is in the same state it would be after a real + // user pressed Find Flights. + let s17 = await jsStep( + "POST /availability/search/simple directly via fetch", + action: """ + return await (async () => { + // The SPA stores its anonymous auth token as JSON: + // { token: "eyJhbGci...", idleTimeoutInMinutes: 15 } + // under sessionStorage["navitaire.digital.token"]. Wait up + // to 3 seconds for it in case the SPA hasn't finished + // bootstrapping its token refresh. + const readToken = () => { + try { + const raw = sessionStorage.getItem("navitaire.digital.token"); + if (!raw) return ""; + const parsed = JSON.parse(raw); + return (parsed && parsed.token) || ""; + } catch (_) { return ""; } + }; + let token = readToken(); + if (!token) { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 200)); + token = readToken(); + if (token) break; + } + } + if (!token) { + return JSON.stringify({ + ok: false, + message: "no auth token in sessionStorage['navitaire.digital.token'] after 3s wait" + }); + } + + // Request body mirrors exactly what JSX's own search() method + // POSTs when the form is valid. Confirmed via the Playwright + // interceptor log in scripts/jsx_playwright_search.mjs. + const body = { + beginDate: "\(date)", + destination: "\(destination)", + origin: "\(origin)", + passengers: { types: [{ count: 1, type: "ADT" }] }, + taxesAndFees: 2, + filters: { + maxConnections: 4, + compressionType: 1, + sortOptions: [4], + fareTypes: ["R"], + exclusionType: 2 + }, + numberOfFaresPerJourney: 10, + codes: { currencyCode: "USD" }, + ssrCollectionsMode: 1 + }; + + try { + const resp = await fetch( + "https://api.jsx.com/api/nsk/v4/availability/search/simple", + { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "Authorization": token + }, + body: JSON.stringify(body) + } + ); + const bodyText = await resp.text(); + // Also populate window.__jsxProbe.searchSimple so the + // subsequent verification and extraction steps (18, 19) + // can read from the same place they would have if a UI + // click had triggered the call. + if (window.__jsxProbe) { + window.__jsxProbe.searchSimple = { + url: "https://api.jsx.com/api/nsk/v4/availability/search/simple", + method: "POST", + status: resp.status, + body: bodyText, + transport: "direct-fetch" + }; + } + return JSON.stringify({ + ok: resp.status >= 200 && resp.status < 300 && bodyText.length > 0, + message: "status=" + resp.status + ", body=" + bodyText.length + " bytes", + status: resp.status, + bodyLength: bodyText.length, + tokenPrefix: token.slice(0, 24) + }); + } catch (err) { + return JSON.stringify({ + ok: false, + message: "fetch error: " + String(err && err.message ? err.message : err), + error: String(err) + }); + } + })(); + """, + verifiers: [ + Verifier(name: "searchSimple status is 2xx", js: """ + return await (async () => { + const p = (window.__jsxProbe || {}).searchSimple; + const ok = p && typeof p.status === "number" && p.status >= 200 && p.status < 300; + return JSON.stringify({ ok, detail: "status=" + (p ? p.status : "nil") }); + })(); + """), + Verifier(name: "searchSimple body is non-empty", js: """ + return await (async () => { + const p = (window.__jsxProbe || {}).searchSimple; + const len = p && p.body ? p.body.length : 0; + return JSON.stringify({ ok: len > 100, detail: len + " bytes" }); + })(); + """), + Verifier(name: "body parses as JSON with data.results", js: """ + return await (async () => { + try { + const p = (window.__jsxProbe || {}).searchSimple; + if (!p || !p.body) return JSON.stringify({ ok: false, detail: "no body" }); + const json = JSON.parse(p.body); + const ok = !!(json && json.data && Array.isArray(json.data.results)); + return JSON.stringify({ ok, detail: ok ? "data.results present" : "missing data.results" }); + } catch (e) { + return JSON.stringify({ ok: false, detail: "parse error: " + String(e) }); + } + })(); + """) + ] + ) + if !s17.ok { return fail("Step 17 failed: \(s17.message)") } + + // ---- STEP 18 ---- + let s18 = await jsStep( + "Extract searchSimple body to Swift", + action: """ + return await (async () => { + const p = (window.__jsxProbe || {}).searchSimple; + if (!p || !p.body) return JSON.stringify({ ok: false, message: "no body" }); + return JSON.stringify({ ok: true, message: "body length " + p.body.length, body: p.body }); + })(); + """, + verifiers: [] + ) + if !s18.ok { return fail("Step 18 failed: \(s18.message)") } + guard let rawBody = s18.data["body"] as? String, !rawBody.isEmpty else { + return fail("Step 18 failed: body missing from JS result") + } + self.capturedBody = rawBody + + // ---- STEP 19 ---- + var parsedFlights: [JSXFlight] = [] + let s19 = await nativeStep("Parse searchSimple into [JSXFlight]") { [weak self] in + guard let self else { return (false, "self gone", [:]) } + parsedFlights = self.parseFlights(from: rawBody) + if parsedFlights.isEmpty { + return (false, "parser returned 0 flights", ["rawLength": rawBody.count]) + } + for flight in parsedFlights { + let classesText = flight.classes.map { c in + "\(c.classOfService)/\(c.productClass):\(c.availableCount)@$\(Int(c.fareTotal))" + }.joined(separator: ", ") + let low = flight.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a" + print("[JSX] │ \(flight.flightNumber) \(flight.origin)→\(flight.destination) " + + "\(flight.departureLocal) → \(flight.arrivalLocal) " + + "stops=\(flight.stops) seats=\(flight.totalAvailable) from=\(low) [\(classesText)]") + } + return (true, "decoded \(parsedFlights.count) flights", ["count": parsedFlights.count]) + } verify: { [weak self] in + guard let self else { return [] } + var results: [Verification] = [] + results.append(Verification( + name: "flights.count > 0", + passed: !parsedFlights.isEmpty, + detail: "count=\(parsedFlights.count)" + )) + let allHaveNumber = parsedFlights.allSatisfy { !$0.flightNumber.isEmpty } + results.append(Verification( + name: "every flight has a flight number", + passed: allHaveNumber, + detail: "" + )) + let expectedOrigin = self.origin + let expectedDestination = self.destination + let allMarketMatch = parsedFlights.allSatisfy { + $0.origin == expectedOrigin && $0.destination == expectedDestination + } + results.append(Verification( + name: "every flight origin/destination matches \(expectedOrigin)|\(expectedDestination)", + passed: allMarketMatch, + detail: "" + )) + return results + } + if !s19.ok { return fail("Step 19 failed: \(s19.message)") } + + logHeader("JSX FLOW COMPLETE: \(parsedFlights.count) unique flights") + self.webView = nil + return JSXSearchResult(flights: parsedFlights, rawSearchBody: rawBody, error: nil) + } + + // MARK: - Step infrastructure + + struct Verification { + let name: String + let passed: Bool + let detail: String + } + + struct Verifier { + let name: String + let js: String + } + + struct StepOutcome { + let ok: Bool + let message: String + let data: [String: Any] + let verifications: [Verification] + } + + /// JavaScript step — runs an action JS snippet, then each verifier JS snippet. + /// Every verifier is run so the log shows all assertions, even when later ones would fail. + private func jsStep( + _ name: String, + action: String, + verifiers: [Verifier] + ) async -> StepOutcome { + stepNumber += 1 + let label = "STEP \(String(format: "%02d", stepNumber))" + print("[JSX] ┌─ \(label) \(name)") + let start = Date() + + let actionResult = await runJS(action) + let actionOk = (actionResult?["ok"] as? Bool) ?? false + let actionMessage = (actionResult?["message"] as? String) ?? (actionOk ? "ok" : "failed") + var stepData: [String: Any] = [:] + if let dict = actionResult { + for (k, v) in dict where k != "ok" && k != "message" { stepData[k] = v } + } + print("[JSX] │ action: \(truncate(actionMessage))") + + var verifications: [Verification] = [] + var allOk = actionOk + for verifier in verifiers { + let res = await runJS(verifier.js) + let passed = (res?["ok"] as? Bool) ?? false + let detail = (res?["detail"] as? String) ?? "" + verifications.append(Verification(name: verifier.name, passed: passed, detail: detail)) + let glyph = passed ? "✓" : "✗" + let suffix = detail.isEmpty ? "" : " — \(truncate(detail))" + print("[JSX] │ verify \(glyph) \(verifier.name)\(suffix)") + if !passed { allOk = false } + } + + if !actionOk { + // Print any extra fields the action returned (sample arrays, + // counts, etc.) BEFORE the generic diagnostic dump. + for (k, v) in stepData.sorted(by: { $0.key < $1.key }) { + print("[JSX] │ action.\(k): \(truncate(describe(v), limit: 400))") + } + await dumpDiagnostics(reason: "action failed: \(actionMessage)") + } else if !allOk { + for (k, v) in stepData.sorted(by: { $0.key < $1.key }) { + print("[JSX] │ action.\(k): \(truncate(describe(v), limit: 400))") + } + await dumpDiagnostics(reason: "verification failed") + } + + let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start)) + let status = allOk ? "OK" : "FAIL" + print("[JSX] └─ \(label) \(status) (\(elapsed))") + return StepOutcome(ok: allOk, message: actionMessage, data: stepData, verifications: verifications) + } + + /// Render a JSON-deserialized value compactly for diagnostic logging. + private func describe(_ value: Any) -> String { + if let arr = value as? [Any] { + let items = arr.prefix(8).map { describe($0) }.joined(separator: ", ") + return "[" + items + (arr.count > 8 ? ", …\(arr.count - 8) more" : "") + "]" + } + if let dict = value as? [String: Any] { + let kv = dict.sorted { $0.key < $1.key } + .map { "\($0.key)=\(describe($0.value))" } + .joined(separator: " ") + return "{" + kv + "}" + } + return String(describing: value) + } + + /// Swift-side step — runs an async action closure, then an async verification closure. + /// Used for parsing the JSON body on the Swift side so the same reporting format applies. + private func nativeStep( + _ name: String, + action: () async -> (ok: Bool, message: String, data: [String: Any]), + verify: () async -> [Verification] + ) async -> StepOutcome { + stepNumber += 1 + let label = "STEP \(String(format: "%02d", stepNumber))" + print("[JSX] ┌─ \(label) \(name)") + let start = Date() + + let (actionOk, actionMessage, stepData) = await action() + print("[JSX] │ action: \(truncate(actionMessage))") + + let verifications = await verify() + var allOk = actionOk + for v in verifications { + let glyph = v.passed ? "✓" : "✗" + let suffix = v.detail.isEmpty ? "" : " — \(truncate(v.detail))" + print("[JSX] │ verify \(glyph) \(v.name)\(suffix)") + if !v.passed { allOk = false } + } + + let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start)) + let status = allOk ? "OK" : "FAIL" + print("[JSX] └─ \(label) \(status) (\(elapsed))") + return StepOutcome(ok: allOk, message: actionMessage, data: stepData, verifications: verifications) + } + + /// Convenience used by step 02's verify closure. + fileprivate func asyncVerify(name: String, js: String) async -> Verification { + let res = await runJS(js) + let passed = (res?["ok"] as? Bool) ?? false + let detail = (res?["detail"] as? String) ?? "" + return Verification(name: name, passed: passed, detail: detail) + } + + // MARK: - JS runner + + private func runJS(_ js: String) async -> [String: Any]? { + guard let webView = self.webView else { return nil } + do { + let raw = try await webView.callAsyncJavaScript(js, contentWorld: .page) + guard let text = raw as? String else { return nil } + guard let data = text.data(using: .utf8) else { return nil } + let parsed = try JSONSerialization.jsonObject(with: data) + return parsed as? [String: Any] + } catch { + print("[JSX] │ runJS exception: \(error.localizedDescription)") + return nil + } + } + + // MARK: - Diagnostics + + private func dumpDiagnostics(reason: String) async { + print("[JSX] │ ⚠ diagnostic dump (\(reason)):") + let diag = await runJS(""" + return await (async () => { + const stationBtns = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length; + const matOptions = Array.from(document.querySelectorAll("mat-option")).filter(e => e.offsetWidth || e.offsetHeight).length; + const calendar = !!document.querySelector("mat-calendar, .mat-calendar, [class*='mat-datepicker']"); + const findBtn = Array.from(document.querySelectorAll("button")).find(b => /find flights/i.test(b.textContent || "")); + const findBtnState = findBtn ? { disabled: findBtn.disabled, aria: findBtn.getAttribute("aria-disabled") } : null; + const probe = window.__jsxProbe || {}; + const shortenUrl = u => (u || "").split("?")[0].split("/").slice(-3).join("/"); + const lastCompleted = (probe.allCalls || []).slice(-6).map(c => + c.method + " " + c.status + (c.error ? "[" + c.error + "]" : "") + " " + shortenUrl(c.url) + ); + const lastInitiated = (probe.initiatedCalls || []).slice(-6).map(c => + c.method + " (init) " + shortenUrl(c.url) + ); + // Surface any error-flagged inputs so we can spot form validation failures. + const errorMarkers = Array.from(document.querySelectorAll( + "[class*='ng-invalid'], [class*='mat-form-field-invalid'], [class*='error']" + )).filter(e => e.offsetWidth || e.offsetHeight) + .slice(0, 5) + .map(e => ({ + tag: e.tagName.toLowerCase(), + cls: (e.className || "").toString().slice(0, 80), + aria: e.getAttribute("aria-label") || "" + })); + return JSON.stringify({ + ok: true, + href: location.href, + stationBtns, matOptions, calendar, + findBtnState, + lastCompleted, + lastInitiated, + errorMarkers + }); + })(); + """) + if let diag { + for (k, v) in diag where k != "ok" { + print("[JSX] │ \(k): \(v)") + } + } else { + print("[JSX] │ (diagnostic JS failed)") + } + } + + // MARK: - JS snippet builders + + private func selectStationActionJS(indexIsDestination: Bool) -> String { + let index = indexIsDestination ? 1 : 0 + let code = indexIsDestination ? destination : origin + return """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const visible = el => !!(el.offsetWidth || el.offsetHeight); + const idx = \(index); + const code = "\(code)"; + + // Type into the "Airport or city" search input if the dropdown has one. + 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") && Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set; + searchInput.focus(); + if (setter) setter.call(searchInput, code); else 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 JSON.stringify({ ok: false, message: "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 JSON.stringify({ + ok: false, + message: "no match for " + 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 })); + } + if (typeof match.click === "function") match.click(); + await sleep(500); + + // Angular API fallback. + const after = () => Array.from(document.querySelectorAll("[aria-label='Station select']"))[idx]; + let finalText = (after() && after().textContent || "").trim(); + let usedAngular = false; + if (!finalText.includes(code)) { + const ctxKey = Object.keys(match).find(k => k.startsWith("__ngContext__")); + if (ctxKey) { + for (const item of match[ctxKey] || []) { + if (!item || typeof item !== "object") continue; + for (const m of ["_selectViaInteraction", "select", "onSelect", "onClick", "handleClick"]) { + if (typeof item[m] === "function") { + try { item[m](); usedAngular = true; break; } catch (_) {} + } + } + if (usedAngular) break; + } + } + await sleep(400); + finalText = (after() && after().textContent || "").trim(); + } + + return JSON.stringify({ + ok: finalText.includes(code), + message: "clicked '" + (match.textContent || "").trim().slice(0, 40) + "'" + (usedAngular ? " (angular fallback)" : ""), + finalText: finalText.slice(0, 80), + usedAngular + }); + })(); + """ + } + + private func verifyStationButtonJS(index: Int, code: String) -> String { + return """ + return await (async () => { + const btn = Array.from(document.querySelectorAll("[aria-label='Station select']"))[\(index)]; + const text = btn ? (btn.textContent || "").replace(/\\s+/g, " ").trim() : ""; + return JSON.stringify({ ok: text.includes("\(code)"), detail: text.slice(0, 80) }); + })(); + """ + } + + private func navigateMonthActionJS() -> String { + return """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const targetYear = \(targetYear); + const targetMonth = \(targetMonth); + const targetDay = \(targetDay); + const targetMonthName = "\(targetMonthName)"; + const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear; + + // JSX uses a custom date picker (NOT Angular Material's mat-calendar), + // so rather than read month headers (which use unknown markup) we + // simply poll for the target day's aria-label existing. + const findTargetCell = () => { + const cells = Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => el.offsetWidth || el.offsetHeight); + // Exact aria-label first. + let cell = cells.find(el => { + const a = (el.getAttribute("aria-label") || "").trim(); + return a === ariaExact || a.startsWith(ariaExact); + }); + if (cell) return { cell, matchedBy: "exact" }; + // Loose: contains month + year + day word-boundary. + const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b"); + cell = cells.find(el => { + const a = el.getAttribute("aria-label") || ""; + return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a); + }); + return cell ? { cell, matchedBy: "loose" } : null; + }; + + const waitForTarget = async (ms) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const hit = findTargetCell(); + if (hit) return hit; + await sleep(150); + } + return findTargetCell(); + }; + + // Case A: target cell is already visible in the current month view. + // This is the common case for near-term dates because JSX shows + // two months at a time by default. + let hit = await waitForTarget(3000); + if (hit) { + return JSON.stringify({ + ok: true, + message: "target cell visible without navigation (" + hit.matchedBy + " match)", + attempts: 0 + }); + } + + // Case B: target is in a different month. Walk forward or back. + // Selectors are deliberately broad because JSX's picker is custom — + // neither .mat-calendar-next-button nor any Material class applies. + const findNext = () => document.querySelector( + ".mat-calendar-next-button, [aria-label*='Next month' i], [aria-label*='next' i], " + + "button[class*='next'], [class*='next-month']" + ); + const findPrev = () => document.querySelector( + ".mat-calendar-previous-button, [aria-label*='Previous month' i], [aria-label*='prev' i], " + + "button[class*='prev'], [class*='prev-month']" + ); + + const now = new Date(); + const currentAbs = now.getFullYear() * 12 + now.getMonth(); + const targetAbs = targetYear * 12 + (targetMonth - 1); + const forward = currentAbs <= targetAbs; + + let attempts = 0; + for (let i = 0; i < 24; i++) { + attempts++; + const btn = forward ? findNext() : findPrev(); + if (!btn || btn.disabled) { + return JSON.stringify({ + ok: false, + message: "target not visible and no " + (forward ? "next" : "prev") + " button", + attempts + }); + } + btn.click(); + hit = await waitForTarget(1500); + if (hit) { + return JSON.stringify({ + ok: true, + message: "navigated " + attempts + " step(s), target cell present (" + hit.matchedBy + " match)", + attempts + }); + } + } + + return JSON.stringify({ + ok: false, + message: "target cell not found after " + attempts + " navigation attempts", + attempts + }); + })(); + """ + } + + private func clickDayCellActionJS() -> String { + return """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + + // IMPORTANT: search the whole document, not a scoped calendar + // container. JSX's custom picker has no predictable wrapper class, + // and step 13 already confirmed the target aria-label is present + // in the global document. Scoping here causes a mismatch where + // step 13 finds the cell and step 14 doesn't. + const targetMonthName = "\(targetMonthName)"; + const targetYear = \(targetYear); + const targetDay = \(targetDay); + const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear; + const ariaLoose = targetMonthName + " " + targetDay + " " + targetYear; + + const ariaCells = Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => el.offsetWidth || el.offsetHeight); + + // Primary: exact aria-label match. + let cell = ariaCells.find(el => { + const a = (el.getAttribute("aria-label") || "").trim(); + return a === ariaExact || a === ariaLoose || a.startsWith(ariaExact); + }); + let matchedBy = cell ? "aria-exact" : null; + + // Secondary: aria-label contains month, year, and day as a word + // boundary. This is what step 13's loose match uses. + if (!cell) { + const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b"); + cell = ariaCells.find(el => { + const a = el.getAttribute("aria-label") || ""; + return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a); + }); + if (cell) matchedBy = "aria-loose"; + } + + if (!cell) { + // Collect diagnostics: all visible aria-labels that contain + // the month name (so we can see how JSX actually formats them). + const monthMatches = ariaCells + .filter(el => (el.getAttribute("aria-label") || "").includes(targetMonthName)) + .slice(0, 15) + .map(el => ({ + tag: el.tagName.toLowerCase(), + role: el.getAttribute("role") || "", + aria: el.getAttribute("aria-label"), + disabled: el.getAttribute("aria-disabled") || el.disabled || false + })); + return JSON.stringify({ + ok: false, + message: "day cell not found for '" + ariaExact + "' (ariaCells=" + ariaCells.length + ", month matches=" + monthMatches.length + ")", + ariaExact, + ariaCellCount: ariaCells.length, + monthMatches + }); + } + + // Sanity check: is the cell disabled? JSX marks past-date cells as + // aria-disabled="true" — clicking them won't commit the selection. + const disabled = cell.getAttribute("aria-disabled") === "true" + || cell.getAttribute("disabled") !== null + || cell.classList.contains("disabled") + || cell.classList.contains("mat-calendar-body-disabled"); + if (disabled) { + return JSON.stringify({ + ok: false, + message: "target day cell is disabled (" + matchedBy + "): " + (cell.getAttribute("aria-label") || ""), + matchedBy, + ariaLabel: cell.getAttribute("aria-label"), + className: (cell.className || "").toString().slice(0, 200) + }); + } + + cell.scrollIntoView({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + cell.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + if (typeof cell.click === "function") cell.click(); + await sleep(500); + + return JSON.stringify({ + ok: true, + message: "clicked day cell (" + matchedBy + ") aria='" + (cell.getAttribute("aria-label") || "") + "'", + matchedBy, + clickedAriaLabel: cell.getAttribute("aria-label") || "" + }); + })(); + """ + } + + // MARK: - Parsing + + /// Walk the Navitaire availability payload and extract one `JSXFlight` per + /// unique journey in `data.results[].trips[].journeysAvailableByMarket["ORG|DST"][]`. + /// Joins each `journey.fares[].fareAvailabilityKey` to the top-level + /// `data.faresAvailable[]` record for price + class-of-service. + fileprivate func parseFlights(from rawJSON: String) -> [JSXFlight] { + guard let data = rawJSON.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let payload = root["data"] as? [String: Any] else { + print("[JSX] │ parseFlights: root JSON not a dict with 'data'") + return [] + } + + let faresAvailable = payload["faresAvailable"] as? [String: Any] ?? [:] + let results = payload["results"] as? [[String: Any]] ?? [] + let marketKey = "\(origin)|\(destination)" + + var flights: [JSXFlight] = [] + var seenKeys = Set() + + for result in results { + let trips = result["trips"] as? [[String: Any]] ?? [] + for trip in trips { + let byMarket = trip["journeysAvailableByMarket"] as? [String: Any] ?? [:] + + // Prefer the market that exactly matches our route, but fall back to + // every market key in case JSX uses multi-origin/multi-dest notation. + var candidateKeys: [String] = [] + if byMarket[marketKey] != nil { candidateKeys.append(marketKey) } + for k in byMarket.keys where k != marketKey { candidateKeys.append(k) } + + for key in candidateKeys { + let journeys = byMarket[key] as? [[String: Any]] ?? [] + for journey in journeys { + if let flight = buildFlight( + journey: journey, + fallbackMarketKey: key, + faresAvailable: faresAvailable + ) { + let dedupeKey = "\(flight.flightNumber)|\(flight.departureLocal)" + if seenKeys.insert(dedupeKey).inserted { + flights.append(flight) + } + } + } + } + } + } + + // Sort by departure local time for stable logging/UI. + flights.sort { $0.departureLocal < $1.departureLocal } + return flights + } + + private func buildFlight( + journey: [String: Any], + fallbackMarketKey: String, + faresAvailable: [String: Any] + ) -> JSXFlight? { + let segments = journey["segments"] as? [[String: Any]] ?? [] + guard !segments.isEmpty else { return nil } + + // Flight number pieces come from segments[0].identifier. For multi-segment + // journeys we concatenate the segment flight numbers so the dedupe key is unique. + let firstSeg = segments[0] + let lastSeg = segments[segments.count - 1] + + let firstIdentifier = firstSeg["identifier"] as? [String: Any] ?? [:] + let carrierCode = (firstIdentifier["carrierCode"] as? String) ?? "" + let baseNumber = (firstIdentifier["identifier"] as? String) ?? "" + var fullFlightNumber = "\(carrierCode)\(baseNumber)" + if segments.count > 1 { + let extras = segments.dropFirst().compactMap { seg -> String? in + let ident = seg["identifier"] as? [String: Any] ?? [:] + let cc = (ident["carrierCode"] as? String) ?? "" + let num = (ident["identifier"] as? String) ?? "" + let combined = "\(cc)\(num)" + return combined.isEmpty ? nil : combined + } + if !extras.isEmpty { + fullFlightNumber += "+" + extras.joined(separator: "+") + } + } + + // Designators: prefer the journey-level designator if present; fall back to segments. + let journeyDesignator = journey["designator"] as? [String: Any] + let firstDesignator = firstSeg["designator"] as? [String: Any] ?? [:] + let lastDesignator = lastSeg["designator"] as? [String: Any] ?? [:] + + let originCode = (journeyDesignator?["origin"] as? String) + ?? (firstDesignator["origin"] as? String) + ?? marketOrigin(fallbackMarketKey) + ?? "" + let destinationCode = (journeyDesignator?["destination"] as? String) + ?? (lastDesignator["destination"] as? String) + ?? marketDestination(fallbackMarketKey) + ?? "" + let departureLocal = (journeyDesignator?["departure"] as? String) + ?? (firstDesignator["departure"] as? String) + ?? "" + let arrivalLocal = (journeyDesignator?["arrival"] as? String) + ?? (lastDesignator["arrival"] as? String) + ?? "" + + let stops = (journey["stops"] as? Int) ?? ((journey["stops"] as? NSNumber)?.intValue ?? 0) + + // Equipment type lives on segments[0].legs[0].legInfo.equipmentType. + var equipmentType: String? + if let legs = firstSeg["legs"] as? [[String: Any]], let firstLeg = legs.first, + let legInfo = firstLeg["legInfo"] as? [String: Any] { + equipmentType = legInfo["equipmentType"] as? String + } + + // Build per-class breakdown by joining journey.fares[] → data.faresAvailable[key]. + var classes: [JSXFareClass] = [] + let journeyFares = journey["fares"] as? [[String: Any]] ?? [] + for fareEntry in journeyFares { + guard let key = fareEntry["fareAvailabilityKey"] as? String else { continue } + guard let record = faresAvailable[key] as? [String: Any] else { continue } + + // availableCount is the sum of details[].availableCount for this fare bucket. + var availableCount = 0 + if let details = fareEntry["details"] as? [[String: Any]] { + for detail in details { + if let n = detail["availableCount"] as? Int { availableCount += n } + else if let n = (detail["availableCount"] as? NSNumber)?.intValue { availableCount += n } + } + } + + let totals = record["totals"] as? [String: Any] ?? [:] + let fareTotal = doubleFromJSON(totals["fareTotal"]) ?? 0 + let revenueTotal = doubleFromJSON(totals["revenueTotal"]) ?? 0 + + let recordFares = record["fares"] as? [[String: Any]] ?? [] + let primary = recordFares.first ?? [:] + let classOfService = (primary["classOfService"] as? String) ?? "" + let productClass = (primary["productClass"] as? String) ?? "" + let fareBasis = primary["fareBasisCode"] as? String + + classes.append(JSXFareClass( + classOfService: classOfService, + productClass: productClass, + availableCount: availableCount, + fareTotal: fareTotal, + revenueTotal: revenueTotal, + fareBasisCode: fareBasis + )) + } + + let totalAvailable = classes.reduce(0) { $0 + $1.availableCount } + let lowestFareTotal = classes.map { $0.fareTotal }.filter { $0 > 0 }.min() + + return JSXFlight( + flightNumber: fullFlightNumber, + carrierCode: carrierCode, + origin: originCode, + destination: destinationCode, + departureLocal: departureLocal, + arrivalLocal: arrivalLocal, + stops: stops, + equipmentType: equipmentType, + totalAvailable: totalAvailable, + lowestFareTotal: lowestFareTotal, + classes: classes + ) + } + + private func marketOrigin(_ key: String) -> String? { + key.split(separator: "|").first.map(String.init) + } + + private func marketDestination(_ key: String) -> String? { + let parts = key.split(separator: "|") + return parts.count >= 2 ? String(parts[1]) : nil + } + + private func doubleFromJSON(_ value: Any?) -> Double? { + if let d = value as? Double { return d } + if let n = value as? NSNumber { return n.doubleValue } + if let i = value as? Int { return Double(i) } + if let s = value as? String { return Double(s) } + return nil + } + + // MARK: - Navigation + + private func navigateAndWait(_ url: URL) async -> Bool { + guard let webView = self.webView else { return false } + return await withCheckedContinuation { (cont: CheckedContinuation) in + let delegate = NavDelegate(continuation: cont) + webView.navigationDelegate = delegate + webView.load(URLRequest(url: url)) + objc_setAssociatedObject(webView, "jsxNavDelegate", delegate, .OBJC_ASSOCIATION_RETAIN) + } + } + + // MARK: - Logging helpers + + private func logHeader(_ msg: String) { + print("[JSX] ══════════════════════════════════════════════════════════") + print("[JSX] \(msg)") + print("[JSX] ══════════════════════════════════════════════════════════") + } + + private func truncate(_ s: String, limit: Int = 180) -> String { + s.count <= limit ? s : "\(s.prefix(limit))… (\(s.count) chars)" + } + + private func fail(_ reason: String) -> JSXSearchResult { + logHeader("JSX FLOW FAILED: \(reason)") + self.webView = nil + return JSXSearchResult(flights: [], rawSearchBody: capturedBody, error: reason) + } +} + +// MARK: - Navigation delegate + +private final class NavDelegate: NSObject, WKNavigationDelegate { + private var continuation: CheckedContinuation? + init(continuation: CheckedContinuation) { self.continuation = continuation } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + continuation?.resume(returning: true); continuation = nil + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + print("[JSX] navigation failed: \(error.localizedDescription)") + continuation?.resume(returning: false); continuation = nil + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + print("[JSX] provisional navigation failed: \(error.localizedDescription)") + continuation?.resume(returning: false); continuation = nil + } +} diff --git a/scripts/jsx_playwright_search.mjs b/scripts/jsx_playwright_search.mjs new file mode 100644 index 0000000..ace36b6 --- /dev/null +++ b/scripts/jsx_playwright_search.mjs @@ -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; +}