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>
415 lines
19 KiB
Markdown
415 lines
19 KiB
Markdown
# JSX / JetSuiteX — Reverse Engineering Notes
|
||
|
||
How to extract per-flight loads from `jsx.com` from an iOS app (WKWebView) or
|
||
a command-line harness (Playwright). Findings from debugging this on real
|
||
hardware against the live production site in April 2026.
|
||
|
||
---
|
||
|
||
## TL;DR
|
||
|
||
1. JSX's website runs on Navitaire. The data you want (seats available per
|
||
fare class, prices per class, all flights for the day) lives in the
|
||
`POST /api/nsk/v4/availability/search/simple` response body.
|
||
2. You **cannot** call that endpoint from outside a loaded `jsx.com` page —
|
||
Akamai will reject the request with an HTTP/2 protocol error based on the
|
||
TLS fingerprint of whatever's making the call.
|
||
3. Real Chrome succeeds when you actually drive the full one-way flow:
|
||
select `One Way`, fill origin and destination, pick the depart date,
|
||
click `DONE`, then click `FIND FLIGHTS`. That fires
|
||
`POST /api/nsk/v4/availability/search/simple` and lands on
|
||
`/booking/select`.
|
||
4. In WKWebView, the safest runtime strategy is layered: drive the same UI
|
||
flow first, try the real `FIND FLIGHTS` button, then fall back to the
|
||
component's `search()` method, then finally to a direct in-page
|
||
`fetch()` if page state still does not materialize.
|
||
|
||
The working pattern is therefore:
|
||
|
||
Navigate browser → jsx.com (real page load)
|
||
Wait for SPA bootstrap (station buttons exist, token call finishes)
|
||
Select One Way / route / date
|
||
Click DONE
|
||
Click FIND FLIGHTS
|
||
Wait for booking/select or Angular availability state
|
||
Fallback: component.search() or direct in-page POST if needed
|
||
Parse the response
|
||
|
||
---
|
||
|
||
## Endpoints
|
||
|
||
Base: `https://api.jsx.com`
|
||
|
||
| Method | Path | Purpose |
|
||
|---|---|---|
|
||
| `POST` | `/api/nsk/v2/token` | Anonymous auth token. Fired by the SPA automatically on page load. Response body is `{ data: { token: "eyJhbGci...", idleTimeoutInMinutes: 15 } }`. |
|
||
| `POST` | `/api/v2/graph/primaryResources` | Route network / station list. SPA bootstrap. |
|
||
| `POST` | `/api/v2/graph/secondaryResources` | Extra static resources. SPA bootstrap. |
|
||
| `POST` | `/api/v2/graph/setCulture` | Locale setup. SPA bootstrap. |
|
||
| `GET` | `/api/nsk/v1/resources/contents?Type=GeneralReference` | Reference data. SPA bootstrap. |
|
||
| `GET` | `/api/nsk/v1/availability/lowfare/estimate?origin=X&destination=Y&...` | Per-day cheapest fare for a route across a date range. Fires when the user selects origin+destination. Useful as a warm-up / liveness check. |
|
||
| `POST` | `/api/nsk/v4/availability/search/simple` | **The data you want.** Fired when the user clicks Find Flights. Contains every flight for the route/date plus per-fare-class prices and counts. |
|
||
| `PUT` | `/api/nsk/v2/token` | Token refresh. Happens after a successful search. |
|
||
|
||
All endpoints require the browser's Akamai cookies from a real `jsx.com` page load. The `search/simple` endpoint additionally requires the `Authorization` header set to the token (raw, **no** `Bearer ` prefix — just the JWT string).
|
||
|
||
---
|
||
|
||
## Auth
|
||
|
||
The SPA stores its anonymous token in `sessionStorage["navitaire.digital.token"]` as JSON:
|
||
|
||
```json
|
||
{
|
||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||
"idleTimeoutInMinutes": 15
|
||
}
|
||
```
|
||
|
||
Read it from inside the page:
|
||
|
||
```js
|
||
const raw = sessionStorage.getItem("navitaire.digital.token");
|
||
const token = raw ? (JSON.parse(raw).token || "") : "";
|
||
```
|
||
|
||
Wait up to ~3 seconds after page load for it to appear — the `POST /api/nsk/v2/token` call that populates it is async.
|
||
|
||
Use it as the `Authorization` header value on the `search/simple` POST:
|
||
|
||
```js
|
||
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(requestBody)
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Request body (`/availability/search/simple`)
|
||
|
||
Byte-identical to what the JSX website's own `search()` method posts when
|
||
its form is valid. Confirmed by intercepting the real call in Playwright.
|
||
|
||
```json
|
||
{
|
||
"beginDate": "2026-04-15",
|
||
"destination": "HOU",
|
||
"origin": "DAL",
|
||
"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
|
||
}
|
||
```
|
||
|
||
`beginDate` is `YYYY-MM-DD`. No `endDate` needed for one-way. `origin` and `destination` are IATA codes.
|
||
|
||
---
|
||
|
||
## Response shape (`/availability/search/simple`)
|
||
|
||
The response nests per-flight data inside a by-market dict, with fare
|
||
pricing split into a separate top-level dict that you join via key. Here's
|
||
the bit that matters:
|
||
|
||
```
|
||
data
|
||
├── results[]
|
||
│ └── trips[]
|
||
│ ├── date: "2026-04-15T00:00:00"
|
||
│ └── journeysAvailableByMarket
|
||
│ └── "DAL|HOU": [ ← key is `${origin}|${destination}`
|
||
│ {
|
||
│ stops: 0,
|
||
│ designator: { origin, destination, departure, arrival },
|
||
│ segments: [
|
||
│ {
|
||
│ identifier: { carrierCode: "XE", identifier: "280" },
|
||
│ designator: { origin, destination, departure, arrival },
|
||
│ legs: [{
|
||
│ legInfo: { equipmentType: "ER4", ... }
|
||
│ }]
|
||
│ }
|
||
│ ],
|
||
│ fares: [
|
||
│ {
|
||
│ fareAvailabilityKey: "MH5Rfn5YRX5R...",
|
||
│ details: [{ availableCount: 1, status: 1, ... }]
|
||
│ }
|
||
│ ]
|
||
│ },
|
||
│ ... more journeys
|
||
│ ]
|
||
└── faresAvailable ← keyed by fareAvailabilityKey
|
||
└── "MH5Rfn5YRX5R...": {
|
||
totals: { fareTotal: 339, revenueTotal: 329.2, ... },
|
||
fares: [
|
||
{
|
||
classOfService: "Q",
|
||
productClass: "HO", ← HO = Hop-On, AI = All-In
|
||
fareBasisCode: "Q00AHOXA",
|
||
passengerFares: [{ passengerType: "ADT", fareAmount: 339, ... }]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**To build a per-flight load summary**, for each journey in `journeysAvailableByMarket[<market>]`:
|
||
|
||
1. Flight number = `segments[0].identifier.carrierCode + segments[0].identifier.identifier` (e.g. `"XE" + "280"` → `"XE280"`). For multi-segment journeys concatenate all segments' identifiers.
|
||
2. Origin / destination / times come from `journey.designator` (or fall back to `segments[0].designator` / `segments[last].designator`).
|
||
3. Equipment type is on `segments[0].legs[0].legInfo.equipmentType`.
|
||
4. For each entry in `journey.fares[]`, look up `fareAvailabilityKey` in `data.faresAvailable`. From that record:
|
||
- `totals.fareTotal` = price including taxes
|
||
- `totals.revenueTotal` = base fare
|
||
- `fares[0].classOfService` = single-letter fare code
|
||
- `fares[0].productClass` = "HO" (Hop-On) or "AI" (All-In)
|
||
5. Sum `details[].availableCount` for that fare bucket — that's the seats sellable at that price point.
|
||
6. Total sellable seats for the flight = sum across all fare buckets on that journey.
|
||
7. Lowest price = `min` of all `fareTotal` values.
|
||
|
||
JSX flies ERJ-145 and Embraer 135 variants with ~30 seats configured 1x2. `availableCount` numbers in the single digits are normal.
|
||
|
||
Example parsed output for `DAL → HOU` on a given day:
|
||
|
||
```
|
||
XE280 DAL→HOU 07:15→08:30 stops=0 seats=8 from=$317
|
||
class=N bundle=HO count=4 fare=$317 rev=$307.2
|
||
class=N bundle=AI count=4 fare=$479 rev=$469.2
|
||
XE292 DAL→HOU 10:35→11:50 stops=0 seats=2 from=$409
|
||
class=W bundle=HO count=1 fare=$409 rev=$399.2
|
||
class=W bundle=AI count=1 fare=$649 rev=$639.2
|
||
```
|
||
|
||
---
|
||
|
||
## The jsx.com SPA UI — what the user does
|
||
|
||
This is only relevant if you're **driving the UI** (not doing a direct API
|
||
call). Useful for smoke testing that the page is functional and the
|
||
anonymous token is fresh, but see the WKWebView caveats below.
|
||
|
||
Structure of the one-way search form at `https://www.jsx.com/home/search`:
|
||
|
||
1. **Trip type** — a Material `mat-select` with combobox role. Options: "Round Trip", "One Way", "Multi City". Default is Round Trip.
|
||
2. **Origin station** — `button[aria-label='Station select']` (index 0). Opens a dropdown with a search input (placeholder "Airport or city") and a list of `li[role='option'].station-options__item` entries.
|
||
3. **Destination station** — `button[aria-label='Station select']` (index 1). Same structure as origin.
|
||
4. **Depart date** — `input[aria-label='Depart Date']` opens a custom two-month range picker overlay on click.
|
||
5. **Return date** — only visible when trip type is Round Trip.
|
||
6. **Passengers** — defaults to 1 adult, fine to leave alone.
|
||
7. **Find Flights** button — submits; Angular's `search()` handler POSTs `/availability/search/simple` if the form validates.
|
||
|
||
### The custom datepicker
|
||
|
||
JSX does **not** use Angular Material's `mat-calendar`. It's a custom
|
||
component and none of the standard Material selectors match:
|
||
|
||
- `mat-calendar`, `.mat-calendar`, `mat-datepicker-content`, `[class*='mat-datepicker']` — all return null.
|
||
- `.mat-calendar-period-button` — doesn't exist. There's no period button to parse "April 2026" out of.
|
||
- `.mat-calendar-body-cell` — doesn't exist. Day cells are something else entirely.
|
||
|
||
What **does** work:
|
||
|
||
- The picker mounts under `.cdk-overlay-container` in a pane that visibly contains month-name text (the Angular CDK overlay system is Material-ish even if the picker isn't).
|
||
- Day cells carry `aria-label` attributes in the format `"Saturday, April 11, 2026"` — **weekday prefix included**. That's why an exact match against `"April 11, 2026"` fails but a loose contains-month-AND-year-AND-day-word-boundary match succeeds.
|
||
- Two months are shown side by side (e.g. April + May in April). Clicking a day cell doesn't close the picker.
|
||
- There's a **"DONE"** button in the bottom right of the picker. It **must** be clicked to commit the selection. Without it, the picker stays open and the depart-date input value isn't set (when the picker is open there are *two* depart-date inputs in the DOM; after DONE there's one).
|
||
|
||
### The picker renders in two phases
|
||
|
||
First phase ~150–400 ms after click: overlay shell with month-name text (so selectors matching on month-name text succeed). Second phase a few hundred ms later: the actual day cells with aria-labels become queryable.
|
||
|
||
If you have separate script evaluations for "open picker" and "click day cell" (i.e. cross-bridge calls in WKWebView), the second call can race the second phase and see no cells. Always poll for at least one `[aria-label]` matching `/\b(January|…|December)\s+\d{1,2},?\s+\d{4}/` before trying to click a specific cell. Polling timeout of ~5 seconds is sufficient.
|
||
|
||
### Mat-select `One Way` option
|
||
|
||
The trip-type combobox needs a multi-strategy opener. None of these alone is
|
||
reliable across browser engines:
|
||
|
||
1. Plain `.click()` on the combobox
|
||
2. `focus()` + `keydown` of `Enter`, `Space`, or `ArrowDown`
|
||
3. Walk `__ngContext__` and call the Angular mat-select instance's `.open()` method
|
||
|
||
Once a visible `mat-option` appears, click the "One Way" option with the full
|
||
event sequence (`pointerdown` → `mousedown` → `pointerup` → `mouseup` →
|
||
`click` → `element.click()`). If the return-date input is still visible after
|
||
that, fall back to walking `__ngContext__` on the mat-option for
|
||
`_selectViaInteraction()` or `select()` and calling it directly.
|
||
|
||
### Station picker
|
||
|
||
- Click the `button[aria-label='Station select']` at the desired index (0 = origin, 1 = destination).
|
||
- The dropdown renders `li[role='option'].station-options__item` entries plus a search `input[placeholder='Airport or city']`.
|
||
- Type the IATA code into the search input via the native value setter + `input`/`change` events, wait ~500 ms for filtering, then click the option whose text contains the code at a word boundary.
|
||
- Fall back to `__ngContext__._selectViaInteraction()` / `.select()` if the click doesn't update the station button's text.
|
||
|
||
### Osano cookie banner
|
||
|
||
JSX embeds Osano. The banner is a `div[role="dialog"]` with class
|
||
`osano-cm-window` / `osano-cm-dialog`. Two problems:
|
||
|
||
1. It intercepts clicks on the page behind it.
|
||
2. Its `role="dialog"` matches any selector that looks for open dialogs, so code trying to find the datepicker via `[role='dialog']` will match Osano instead.
|
||
|
||
Fix: after accepting it (class-based: `.osano-cm-accept-all`, `.osano-cm-accept`; or text-based: "Accept", "Accept All", "I Agree", "Got it"), **force-remove the banner node**:
|
||
|
||
```js
|
||
document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach(el => el.remove());
|
||
```
|
||
|
||
### Find Flights button
|
||
|
||
Visible on the main form. Angular click handler calls `search()`, which:
|
||
|
||
1. Checks `form.invalid`. If true, marks touched and returns without firing a request.
|
||
2. Otherwise builds the request body (same shape documented above) and POSTs `/availability/search/simple`.
|
||
|
||
The button being visually enabled does **not** mean Angular thinks the form
|
||
is valid — Angular rechecks on every click. If `search()` silently bails, it
|
||
won't surface any error to the DOM.
|
||
|
||
---
|
||
|
||
## Browser validation and WKWebView strategy
|
||
|
||
The earlier "WKWebView cannot submit the form" theory was too strong. The
|
||
browser probe that only failed `POST /search/simple` had not actually driven
|
||
the same successful user flow Chrome uses.
|
||
|
||
### What is confirmed
|
||
|
||
- Real Chrome does succeed when the automation performs the whole flow:
|
||
`One Way` -> origin -> destination -> depart date -> `DONE` ->
|
||
`FIND FLIGHTS`.
|
||
- That path produces a live `POST /api/nsk/v4/availability/search/simple`
|
||
with HTTP `200` and advances the page to `https://www.jsx.com/booking/select`.
|
||
- The response contains per-flight availability counts that the app can map
|
||
back to `XE` flights.
|
||
|
||
### What remains true in WKWebView
|
||
|
||
Synthetic events can still be brittle. A visually-filled form is not enough;
|
||
the page may still reject the search if the component's internal state is not
|
||
fully committed. Because of that, the iOS fetcher should not rely on exactly
|
||
one trigger path.
|
||
|
||
### Current iOS runtime strategy
|
||
|
||
`Flights/Services/JSXWebViewFetcher.swift` step 17 now does this:
|
||
|
||
1. Prime the Angular search component with the target date.
|
||
2. Click the real `FIND FLIGHTS` button and wait for availability state.
|
||
3. If that does not materialize data, call `component.search()`.
|
||
4. If that still fails, issue the direct in-page `fetch()` POST as the last fallback.
|
||
|
||
That keeps the app aligned with the proven Chrome behavior without betting the
|
||
entire integration on one fragile DOM event path.
|
||
|
||
---
|
||
|
||
## Anti-bot surface (Akamai)
|
||
|
||
JSX fronts `api.jsx.com` with Akamai Bot Manager. Observed behavior:
|
||
|
||
| Request source | Result |
|
||
|---|---|
|
||
| Plain `curl`, `fetch` from Node, any external HTTP client | Blocked. Almost all endpoints return HTML challenge page or Akamai error. |
|
||
| Playwright's built-in `chromium.launch()` (both bundled chromium and `channel: "chrome"`) | GET requests succeed, but `POST /availability/search/simple` specifically returns `ERR_HTTP2_PROTOCOL_ERROR`. Playwright injects enough automation bits for Akamai to flag the TLS/H2 fingerprint. |
|
||
| Real Chrome spawned as a plain process + Playwright attached via `chromium.connectOverCDP()` | **Works reliably.** Chrome has the expected fingerprint and Playwright is only driving it via CDP, not altering it. |
|
||
| WKWebView on macOS / iOS | Same-origin traffic from a real loaded `jsx.com` page can work, but the most robust app strategy is still layered because DOM-driven submission can be sensitive to page state and timing. |
|
||
| WKWebView with the current app flow | Try real `FIND FLIGHTS` first, then `component.search()`, then direct in-page `fetch()`. This is the runtime path the app now uses. |
|
||
|
||
Observations:
|
||
|
||
- The Akamai block is on the request fingerprint, not on specific URLs. The same binary can reach some endpoints and not others because different endpoints attract different scrutiny.
|
||
- Cookies set by a real page load are transferable to `fetch()` calls made from the same page context via `credentials: "include"`, and Akamai accepts them.
|
||
- The `lowfare/estimate` endpoint is a useful liveness probe — it fires automatically when the SPA has origin+destination, and if it succeeds you know the Akamai session is good.
|
||
|
||
---
|
||
|
||
## Network observation
|
||
|
||
For the app runtime, prefer passive observation over wrapping `fetch()` or
|
||
`XMLHttpRequest`. The current iOS flow uses a `PerformanceObserver` to record
|
||
`api.jsx.com` resource entries without touching the request pipeline.
|
||
|
||
That gives enough signal to answer:
|
||
|
||
1. Did the page attempt `/availability/search/simple` at all?
|
||
2. Did the page advance to `/booking/select`?
|
||
3. Did Angular availability state appear after the search trigger?
|
||
|
||
See the `window.__jsxProbe` setup in
|
||
`Flights/Services/JSXWebViewFetcher.swift` step 4 for the current
|
||
implementation.
|
||
|
||
---
|
||
|
||
## Per-step verification
|
||
|
||
Every step in the WKWebView flow has an **action** and one or more
|
||
**post-condition verifications**. The action runs and returns success
|
||
or failure; then every verification runs regardless and logs its result.
|
||
This matters because a step can have an action that returns "ok" but
|
||
actually left the page in a broken state — e.g. step 12 can return
|
||
"datepicker open" because it found the overlay shell, while step 13's
|
||
check reveals the day cells haven't rendered yet. Running all the
|
||
verifications and surfacing their results per-line in the log (rather
|
||
than stopping at the first failure) lets you see the full state the
|
||
failing step produced, not just the first mismatch.
|
||
|
||
On failure, the step runner dumps:
|
||
|
||
- The action's returned data fields (e.g. `action.sample: [...]`,
|
||
`action.monthMatches: [...]`) — any diagnostic payload the action JS
|
||
chose to include gets surfaced automatically.
|
||
- Page state: current URL, visible `[role='combobox']` count, visible
|
||
`mat-option` count, Find Flights button disabled/aria-disabled state,
|
||
recently initiated and completed api.jsx.com calls, and any elements
|
||
matching `ng-invalid` / `mat-form-field-invalid` / `error` classes so
|
||
form validation errors are visible.
|
||
|
||
---
|
||
|
||
## Code references
|
||
|
||
- **iOS runtime flow**: `Flights/Services/JSXWebViewFetcher.swift` — the
|
||
19-step WKWebView-based flow with per-step verification. Returns
|
||
`JSXSearchResult { flights: [JSXFlight], rawSearchBody, error }` with one
|
||
`JSXFlight` entry per unique flight in the search response.
|
||
|
||
- **iOS call site**: `Flights/Services/AirlineLoadService.fetchJSXLoad` —
|
||
invokes the fetcher, logs every returned flight, and picks the
|
||
caller-requested flight number via digit match.
|
||
|
||
- **Playwright reference implementation**: `scripts/jsx_playwright_search.mjs`
|
||
— standalone command-line harness that drives the real jsx.com SPA from a
|
||
real Chrome attached via `chromium.connectOverCDP()`. Used as the source
|
||
of truth for the UI flow and request/response shapes that the Swift
|
||
rewrite mirrors.
|
||
|
||
```bash
|
||
npx playwright install chromium # one-time
|
||
node scripts/jsx_playwright_search.mjs --origin DAL --destination HOU --date 2026-04-16 --headful
|
||
```
|
||
|
||
Artifacts land in `/tmp/jsx-playwright/` (raw `search-simple.json`, the
|
||
`lowfare.json` low-fare estimate, and a `calls.json` log of every
|
||
`api.jsx.com` URL/status the session saw).
|