Airline integration work: AirlineLoadService updates, docs, JSX scripts
- AirlineLoadService: pass airport DB for timezone-aware date strings, add browser-shaped headers for United, expand JetBlue/Alaska/Emirates signatures to take origin, log/parse fixes for Korean Air. - FlightsApp: build AirlineLoadService with the airport DB and inject it. - JSX: continued WebView-based fetcher work plus updated JSX_NOTES. - Docs: add AIRLINE_INTEGRATION_GUIDE.md, drop the old AIRLINE_API_SPEC.md, add api_docs/ (StaffTraveler reverse-engineering captures + findings). - Scripts: jsx_cdp_probe, jsx_live_monitor, jsx_swift_smoke for JSX protocol exploration. - .gitignore: exclude airlines/ (local-only APK/IPA reverse-engineering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||
const port = Number(process.env.CDP_PORT || 9230);
|
||||
const navigationUrl = process.env.JSX_URL || "https://www.jsx.com/";
|
||||
const userDataDir = process.env.CHROME_USER_DATA_DIR || `/tmp/jsx-live-monitor-${Date.now()}`;
|
||||
const artifactsDir = process.env.JSX_ARTIFACTS_DIR || "/tmp/jsx-live-monitor";
|
||||
const logPath = `${artifactsDir}/network-events.ndjson`;
|
||||
const requestTimeoutMs = Number(process.env.REQUEST_TIMEOUT_MS || 30000);
|
||||
|
||||
await mkdir(artifactsDir, { recursive: true });
|
||||
|
||||
const logStream = createWriteStream(logPath, { flags: "a" });
|
||||
|
||||
function writeLine(line) {
|
||||
console.log(line);
|
||||
logStream.write(`${line}\n`);
|
||||
}
|
||||
|
||||
function log(message, details) {
|
||||
if (details === undefined) {
|
||||
writeLine(`[monitor] ${message}`);
|
||||
return;
|
||||
}
|
||||
writeLine(`[monitor] ${message} ${JSON.stringify(details)}`);
|
||||
}
|
||||
|
||||
async function fetchJson(url, init = undefined) {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function waitForDebugger() {
|
||||
const deadline = Date.now() + requestTimeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await fetchJson(`http://127.0.0.1:${port}/json/version`);
|
||||
} catch {
|
||||
await delay(250);
|
||||
}
|
||||
}
|
||||
throw new Error("Timed out waiting for Chrome remote debugger");
|
||||
}
|
||||
|
||||
async function waitForPageTarget() {
|
||||
const deadline = Date.now() + requestTimeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`);
|
||||
const pageTarget = targets.find(target =>
|
||||
target.type === "page" &&
|
||||
typeof target.url === "string" &&
|
||||
target.url.startsWith("http") &&
|
||||
target.webSocketDebuggerUrl
|
||||
);
|
||||
if (pageTarget) {
|
||||
return pageTarget;
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient startup failures while Chrome is coming up.
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
throw new Error("Timed out waiting for initial Chrome page target");
|
||||
}
|
||||
|
||||
function launchChrome() {
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-default-apps",
|
||||
"--disable-popup-blocking",
|
||||
"--window-size=1440,1200",
|
||||
navigationUrl,
|
||||
];
|
||||
|
||||
log("Launching Chrome", { chromePath, port, userDataDir, navigationUrl, logPath });
|
||||
|
||||
const child = spawn(chromePath, args, {
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", chunk => {
|
||||
const text = chunk.toString().trim();
|
||||
if (text) {
|
||||
writeLine(`[chrome] ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", chunk => {
|
||||
const text = chunk.toString().trim();
|
||||
if (text) {
|
||||
writeLine(`[chrome] ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
class CDPClient {
|
||||
constructor(socketUrl) {
|
||||
this.socketUrl = socketUrl;
|
||||
this.ws = new WebSocket(socketUrl);
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
this.eventHandlers = new Map();
|
||||
this.openPromise = new Promise((resolve, reject) => {
|
||||
this.ws.addEventListener("open", () => resolve());
|
||||
this.ws.addEventListener("error", event => reject(event.error || new Error("WebSocket open failed")));
|
||||
});
|
||||
|
||||
this.ws.addEventListener("message", event => {
|
||||
const message = JSON.parse(event.data.toString());
|
||||
if (message.id) {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const handlers = this.eventHandlers.get(message.method) || [];
|
||||
for (const handler of handlers) {
|
||||
handler(message.params || {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async ready() {
|
||||
await this.openPromise;
|
||||
}
|
||||
|
||||
async send(method, params = {}) {
|
||||
await this.ready();
|
||||
const id = this.nextId++;
|
||||
const payload = JSON.stringify({ id, method, params });
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
setTimeout(() => {
|
||||
if (this.pending.has(id)) {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Timed out waiting for ${method}`));
|
||||
}
|
||||
}, requestTimeoutMs);
|
||||
});
|
||||
|
||||
this.ws.send(payload);
|
||||
return promise;
|
||||
}
|
||||
|
||||
on(method, handler) {
|
||||
const handlers = this.eventHandlers.get(method) || [];
|
||||
handlers.push(handler);
|
||||
this.eventHandlers.set(method, handlers);
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
async function main() {
|
||||
const chrome = launchChrome();
|
||||
let cdp;
|
||||
|
||||
const shutdown = async signal => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
log("Shutting down", { signal });
|
||||
if (cdp) {
|
||||
await cdp.close();
|
||||
}
|
||||
chrome.kill("SIGTERM");
|
||||
await delay(500);
|
||||
await rm(userDataDir, { recursive: true, force: true });
|
||||
logStream.end();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
try {
|
||||
await waitForDebugger();
|
||||
const target = await waitForPageTarget();
|
||||
cdp = new CDPClient(target.webSocketDebuggerUrl);
|
||||
await cdp.ready();
|
||||
|
||||
log("Attached to page target", { id: target.id, url: target.url });
|
||||
log("Manual monitor ready", { instructions: "Drive the JSX page in Chrome. All api.jsx.com traffic will stream here." });
|
||||
|
||||
const responseMetaByRequestId = new Map();
|
||||
|
||||
cdp.on("Page.frameNavigated", params => {
|
||||
const url = params.frame?.url;
|
||||
if (url) {
|
||||
log("Page navigated", { url });
|
||||
}
|
||||
});
|
||||
|
||||
cdp.on("Network.requestWillBeSent", params => {
|
||||
const url = params.request?.url || "";
|
||||
if (!url.includes("api.jsx.com")) {
|
||||
return;
|
||||
}
|
||||
const record = {
|
||||
stage: "request",
|
||||
type: params.type || "",
|
||||
method: params.request?.method || "",
|
||||
url,
|
||||
postData: params.request?.postData || "",
|
||||
hasAuthHeader: Boolean(params.request?.headers?.Authorization),
|
||||
};
|
||||
writeLine(JSON.stringify(record));
|
||||
});
|
||||
|
||||
cdp.on("Network.responseReceived", params => {
|
||||
const url = params.response?.url || "";
|
||||
if (!url.includes("api.jsx.com")) {
|
||||
return;
|
||||
}
|
||||
const record = {
|
||||
stage: "response",
|
||||
type: params.type || "",
|
||||
status: params.response?.status || 0,
|
||||
mimeType: params.response?.mimeType || "",
|
||||
url,
|
||||
};
|
||||
responseMetaByRequestId.set(params.requestId, record);
|
||||
writeLine(JSON.stringify(record));
|
||||
});
|
||||
|
||||
cdp.on("Network.loadingFinished", async params => {
|
||||
const meta = responseMetaByRequestId.get(params.requestId);
|
||||
if (!meta || !meta.url.includes("api.jsx.com")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await cdp.send("Network.getResponseBody", { requestId: params.requestId });
|
||||
writeLine(JSON.stringify({
|
||||
stage: "body",
|
||||
status: meta.status,
|
||||
url: meta.url,
|
||||
bodySnippet: (body.body || "").slice(0, 4000),
|
||||
base64Encoded: Boolean(body.base64Encoded),
|
||||
}));
|
||||
} catch (error) {
|
||||
writeLine(JSON.stringify({
|
||||
stage: "body-error",
|
||||
url: meta.url,
|
||||
error: error.message,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
await cdp.send("Page.enable");
|
||||
await cdp.send("Runtime.enable");
|
||||
await cdp.send("Network.enable", {
|
||||
maxTotalBufferSize: 100_000_000,
|
||||
maxResourceBufferSize: 10_000_000,
|
||||
maxPostDataSize: 1_000_000,
|
||||
});
|
||||
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
|
||||
|
||||
await new Promise(() => {});
|
||||
} catch (error) {
|
||||
log("Monitor failed", { error: error.message });
|
||||
await shutdown("error");
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,219 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
private func logSmoke(_ message: String) {
|
||||
fputs("[JSX-SMOKE] \(message)\n", stderr)
|
||||
fflush(stderr)
|
||||
}
|
||||
|
||||
final class JSXSwiftSmokeApp: NSObject {
|
||||
private let origin: String
|
||||
private let destination: String
|
||||
private let date: String
|
||||
private let useService: Bool
|
||||
private let flightNumber: String
|
||||
private let departureTime: String?
|
||||
|
||||
var exitCode: Int32 = 1
|
||||
|
||||
init(
|
||||
origin: String,
|
||||
destination: String,
|
||||
date: String,
|
||||
useService: Bool,
|
||||
flightNumber: String,
|
||||
departureTime: String?
|
||||
) {
|
||||
self.origin = origin
|
||||
self.destination = destination
|
||||
self.date = date
|
||||
self.useService = useService
|
||||
self.flightNumber = flightNumber
|
||||
self.departureTime = departureTime
|
||||
}
|
||||
|
||||
private func parsedDate() -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: date)
|
||||
}
|
||||
|
||||
private func writeSummary(_ summary: [String: Any]) {
|
||||
if JSONSerialization.isValidJSONObject(summary),
|
||||
let data = try? JSONSerialization.data(withJSONObject: summary, options: [.prettyPrinted, .sortedKeys]),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
print(text)
|
||||
} else {
|
||||
print("Failed to encode summary")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runFetcherMode() async {
|
||||
logSmoke("starting fetcher \(origin)->\(destination) on \(date)")
|
||||
let fetcher = JSXWebViewFetcher()
|
||||
let result = await fetcher.fetchAvailability(
|
||||
origin: origin,
|
||||
destination: destination,
|
||||
date: date
|
||||
)
|
||||
logSmoke("fetcher returned")
|
||||
|
||||
let flights = result.flights.map { flight in
|
||||
[
|
||||
"flightNumber": flight.flightNumber,
|
||||
"origin": flight.origin,
|
||||
"destination": flight.destination,
|
||||
"departureLocal": flight.departureLocal,
|
||||
"arrivalLocal": flight.arrivalLocal,
|
||||
"stops": flight.stops,
|
||||
"equipmentType": flight.equipmentType ?? "",
|
||||
"totalAvailable": flight.totalAvailable,
|
||||
"lowestFareTotal": flight.lowestFareTotal ?? NSNull(),
|
||||
"classes": flight.classes.map { fareClass in
|
||||
[
|
||||
"classOfService": fareClass.classOfService,
|
||||
"productClass": fareClass.productClass,
|
||||
"availableCount": fareClass.availableCount,
|
||||
"fareTotal": fareClass.fareTotal,
|
||||
"revenueTotal": fareClass.revenueTotal,
|
||||
"fareBasisCode": fareClass.fareBasisCode ?? NSNull()
|
||||
]
|
||||
}
|
||||
] as [String: Any]
|
||||
}
|
||||
|
||||
var summary: [String: Any] = [
|
||||
"mode": "fetcher",
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"date": date,
|
||||
"flightCount": result.flights.count,
|
||||
"rawSearchBodyLength": result.rawSearchBody?.count ?? 0,
|
||||
"error": result.error ?? NSNull(),
|
||||
"flights": flights
|
||||
]
|
||||
|
||||
if let lowFare = result.lowFareFallback {
|
||||
summary["lowFareFallback"] = [
|
||||
"date": lowFare.date,
|
||||
"available": lowFare.available,
|
||||
"lowestPrice": lowFare.lowestPrice as Any? ?? NSNull()
|
||||
]
|
||||
}
|
||||
|
||||
writeSummary(summary)
|
||||
|
||||
// Verification requires true per-flight data, not only the route-level fallback.
|
||||
exitCode = (result.error == nil && !result.flights.isEmpty) ? 0 : 1
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runServiceMode() async {
|
||||
logSmoke("starting service \(flightNumber) \(origin)->\(destination) on \(date)"
|
||||
+ (departureTime.map { " @ \($0)" } ?? ""))
|
||||
guard let queryDate = parsedDate() else {
|
||||
writeSummary([
|
||||
"mode": "service",
|
||||
"error": "invalid date \(date)"
|
||||
])
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
let service = AirlineLoadService()
|
||||
let result = await service.fetchLoad(
|
||||
airlineCode: "XE",
|
||||
flightNumber: flightNumber,
|
||||
date: queryDate,
|
||||
origin: origin,
|
||||
destination: destination,
|
||||
departureTime: departureTime
|
||||
)
|
||||
logSmoke("service returned")
|
||||
|
||||
let cabins = result?.cabins.map { cabin in
|
||||
[
|
||||
"name": cabin.name,
|
||||
"capacity": cabin.capacity,
|
||||
"booked": cabin.booked,
|
||||
"available": cabin.available,
|
||||
"revenueStandby": cabin.revenueStandby,
|
||||
"nonRevStandby": cabin.nonRevStandby,
|
||||
"waitListCount": cabin.waitListCount,
|
||||
"jumpSeat": cabin.jumpSeat
|
||||
]
|
||||
} ?? []
|
||||
|
||||
let summary: [String: Any] = [
|
||||
"mode": "service",
|
||||
"queryFlightNumber": flightNumber,
|
||||
"queryDepartureTime": departureTime ?? NSNull(),
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"date": date,
|
||||
"result": result.map { load in
|
||||
[
|
||||
"airlineCode": load.airlineCode,
|
||||
"flightNumber": load.flightNumber,
|
||||
"totalAvailable": load.totalAvailable,
|
||||
"totalCapacity": load.totalCapacity,
|
||||
"cabins": cabins
|
||||
] as [String: Any]
|
||||
} ?? NSNull()
|
||||
]
|
||||
|
||||
writeSummary(summary)
|
||||
|
||||
let isPerFlightCabin = cabins.contains { ($0["name"] as? String) == "Cabin" }
|
||||
exitCode = (result != nil && isPerFlightCabin) ? 0 : 1
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func start() async {
|
||||
if useService {
|
||||
await runServiceMode()
|
||||
} else {
|
||||
await runFetcherMode()
|
||||
}
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct JSXSwiftSmokeMain {
|
||||
static func main() {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let origin = (env["JSX_ORIGIN"] ?? "DAL").uppercased()
|
||||
let destination = (env["JSX_DESTINATION"] ?? "HOU").uppercased()
|
||||
let date = env["JSX_DATE"] ?? "2026-04-15"
|
||||
let useService = env["JSX_USE_SERVICE"] == "1"
|
||||
let flightNumber = env["JSX_FLIGHT_NUMBER"] ?? "XE280"
|
||||
let departureTime = env["JSX_DEPARTURE_TIME"]
|
||||
|
||||
let app = NSApplication.shared
|
||||
let delegate = JSXSwiftSmokeApp(
|
||||
origin: origin,
|
||||
destination: destination,
|
||||
date: date,
|
||||
useService: useService,
|
||||
flightNumber: flightNumber,
|
||||
departureTime: departureTime
|
||||
)
|
||||
logSmoke("booting app")
|
||||
app.setActivationPolicy(.prohibited)
|
||||
Timer.scheduledTimer(withTimeInterval: 120, repeats: false) { _ in
|
||||
logSmoke("timeout after 120s")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
Task { @MainActor in
|
||||
await delegate.start()
|
||||
}
|
||||
app.run()
|
||||
exit(delegate.exitCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user