6005146e75
- 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>
220 lines
7.1 KiB
Swift
220 lines
7.1 KiB
Swift
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)
|
|
}
|
|
}
|