itamatrix 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/DESIGN.md +247 -0
  2. package/LICENSE +21 -0
  3. package/README.md +101 -0
  4. package/dist/browser/batch.d.ts +8 -0
  5. package/dist/browser/batch.js +87 -0
  6. package/dist/browser/batch.js.map +1 -0
  7. package/dist/browser/batch.test.d.ts +1 -0
  8. package/dist/browser/batch.test.js +38 -0
  9. package/dist/browser/batch.test.js.map +1 -0
  10. package/dist/browser/forms.d.ts +26 -0
  11. package/dist/browser/forms.js +233 -0
  12. package/dist/browser/forms.js.map +1 -0
  13. package/dist/browser/session.d.ts +20 -0
  14. package/dist/browser/session.js +126 -0
  15. package/dist/browser/session.js.map +1 -0
  16. package/dist/cache.d.ts +21 -0
  17. package/dist/cache.js +71 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/cache.test.d.ts +1 -0
  20. package/dist/cache.test.js +79 -0
  21. package/dist/cache.test.js.map +1 -0
  22. package/dist/cli.d.ts +3 -0
  23. package/dist/cli.js +154 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/commands/calendar.d.ts +19 -0
  26. package/dist/commands/calendar.js +47 -0
  27. package/dist/commands/calendar.js.map +1 -0
  28. package/dist/commands/calendar.test.d.ts +1 -0
  29. package/dist/commands/calendar.test.js +58 -0
  30. package/dist/commands/calendar.test.js.map +1 -0
  31. package/dist/commands/multicity.d.ts +17 -0
  32. package/dist/commands/multicity.js +50 -0
  33. package/dist/commands/multicity.js.map +1 -0
  34. package/dist/commands/multicity.test.d.ts +1 -0
  35. package/dist/commands/multicity.test.js +54 -0
  36. package/dist/commands/multicity.test.js.map +1 -0
  37. package/dist/commands/search.d.ts +20 -0
  38. package/dist/commands/search.js +43 -0
  39. package/dist/commands/search.js.map +1 -0
  40. package/dist/commands/search.test.d.ts +1 -0
  41. package/dist/commands/search.test.js +124 -0
  42. package/dist/commands/search.test.js.map +1 -0
  43. package/dist/commands/shared.d.ts +44 -0
  44. package/dist/commands/shared.js +77 -0
  45. package/dist/commands/shared.js.map +1 -0
  46. package/dist/model/spec.d.ts +63 -0
  47. package/dist/model/spec.js +29 -0
  48. package/dist/model/spec.js.map +1 -0
  49. package/dist/model/spec.test.d.ts +1 -0
  50. package/dist/model/spec.test.js +55 -0
  51. package/dist/model/spec.test.js.map +1 -0
  52. package/dist/model/types.d.ts +22080 -0
  53. package/dist/model/types.js +100 -0
  54. package/dist/model/types.js.map +1 -0
  55. package/dist/model/types.test.d.ts +1 -0
  56. package/dist/model/types.test.js +35 -0
  57. package/dist/model/types.test.js.map +1 -0
  58. package/dist/render/calendar.d.ts +21 -0
  59. package/dist/render/calendar.js +89 -0
  60. package/dist/render/calendar.js.map +1 -0
  61. package/dist/render/calendar.render.test.d.ts +1 -0
  62. package/dist/render/calendar.render.test.js +66 -0
  63. package/dist/render/calendar.render.test.js.map +1 -0
  64. package/dist/render/json.d.ts +2 -0
  65. package/dist/render/json.js +4 -0
  66. package/dist/render/json.js.map +1 -0
  67. package/dist/render/normalize.d.ts +32 -0
  68. package/dist/render/normalize.js +44 -0
  69. package/dist/render/normalize.js.map +1 -0
  70. package/dist/render/render.test.d.ts +1 -0
  71. package/dist/render/render.test.js +129 -0
  72. package/dist/render/render.test.js.map +1 -0
  73. package/dist/render/table.d.ts +2 -0
  74. package/dist/render/table.js +49 -0
  75. package/dist/render/table.js.map +1 -0
  76. package/docs/ROUTING_CODES.md +137 -0
  77. package/package.json +68 -0
  78. package/skills/itamatrix/SKILL.md +173 -0
package/DESIGN.md ADDED
@@ -0,0 +1,247 @@
1
+ # itamatrix — CLI for ITA Matrix flight search
2
+
3
+ A command-line interface to [ITA Matrix](https://matrix.itasoftware.com/search) (Google's
4
+ airfare search engine). Dual-purpose output: human-readable tables for interactive use,
5
+ JSON for scripting and AI agents.
6
+
7
+ ## Goals
8
+
9
+ - Query Matrix from the terminal: one-way, round-trip, multi-city, price calendar.
10
+ - Expose advanced controls (cabin, stops, carriers, ITA routing codes).
11
+ - Output auto-detects context: TTY → table, piped → JSON (`--json` / `--table` to force).
12
+ - Distribute via npm (`npx itamatrix`).
13
+
14
+ ## Non-goals (v1)
15
+
16
+ - Booking / purchase (Matrix doesn't sell tickets; it's search-only).
17
+ - Account/login features (Matrix is anonymous — see findings).
18
+ - Real-time price monitoring / alerts.
19
+
20
+ ---
21
+
22
+ ## Findings — live probe (cookieless, 2026-06)
23
+
24
+ Captured by driving the real site with Playwright and intercepting network traffic.
25
+
26
+ 1. **No auth.** `/search` loads anonymously; no login wall, no cookie required.
27
+
28
+ 2. **Search state is base64-encoded JSON in the URL.** Submitting a search redirects to
29
+ `/flights?search=<base64>`. The decoded payload is the canonical, reconstructable
30
+ search spec:
31
+
32
+ ```json
33
+ {
34
+ "type": "round-trip",
35
+ "slices": [{
36
+ "origin": ["BOS"], "dest": ["LAX"],
37
+ "dates": { "searchDateType": "specific",
38
+ "departureDate": "2026-08-10", "returnDate": "2026-08-17", "...": "..." }
39
+ }],
40
+ "options": { "cabin": "COACH", "stops": "-1", "extraStops": "1",
41
+ "allowAirportChanges": "true", "showOnlyAvailable": "true" },
42
+ "pax": { "adults": "1" }
43
+ }
44
+ ```
45
+
46
+ 3. **Real backend endpoint:**
47
+
48
+ ```text
49
+ POST https://content-alkalimatrix-pa.googleapis.com/v1/search?key=AIza…&alt=json
50
+ headers: x-alkali-application-key: applications/matrix
51
+ x-alkali-auth-apps-namespace: alkali_v2
52
+ x-alkali-auth-entities-namespace: alkali_v2
53
+ content-type: application/json
54
+ body: {
55
+ "summarizers": ["solutionList","carrierStopMatrix","itineraryPriceSlider",
56
+ "itinerary{Carrier,Origins,Destinations,StopCount}List",
57
+ "itinerary{Departure,Arrival}TimeRanges","durationSliderItinerary",
58
+ "currencyNotice","warningsItinerary"],
59
+ "inputs": { "slices":[…], "pax":{"adults":1}, "cabin":"COACH",
60
+ "page":{"current":1,"size":25}, "sorts":"default",
61
+ "maxLegsRelativeToMin":1, "changeOfAirport":true, "checkAvailability":true },
62
+ "summarizerSet": "wholeTrip",
63
+ "bgProgramResponse": "!LC-lL0vN…" // BotGuard token — see blocker
64
+ }
65
+ ```
66
+
67
+ 4. **Result schema** (`result_full.json` saved as fixture):
68
+
69
+ ```text
70
+ response.solutionList: { solutions[25], minPrice, solutionCount, pages }
71
+ solution: { displayTotal:"USD439.81", passengerCount, pricings,
72
+ itinerary:{ slices:[{ origin{code,name}, destination, departure, arrival,
73
+ flights:["UA2210"], cabins:["COACH"], duration,
74
+ ext.warnings:["OVERNIGHT"] }] },
75
+ ext:{ price, pricePerMile } }
76
+ response.{carrierStopMatrix, itineraryPriceSlider, *TimeRanges, itineraryStopCountList} // facets
77
+ ```
78
+
79
+ 5. **BotGuard — present but surmountable.** The search body carries `bgProgramResponse`, an
80
+ anti-bot token minted by Google's `Waa/Create` + `bg.js`. Verified by probe:
81
+ - **Pure-HTTP client is not viable** — cannot forge the token; must run Google's JS.
82
+ - **Bundled Playwright Chromium, headless, WORKS** (3/3 reliable, ~30–55 s) with two cheap
83
+ stealth tweaks: strip `"Headless"` from the UA string + hide `navigator.webdriver`.
84
+ No system Chrome, no display, no `xvfb` needed.
85
+ - **Earlier "headless blocked" was a false alarm** — it was a *timeout*, not a block.
86
+ Matrix searches genuinely take **40–60 s**; short cutoffs left the spinner spinning.
87
+ Use a ≥90 s timeout.
88
+ - **Direct nav to `/flights?search=…` does NOT trigger a search** — the search call
89
+ only fires from the in-app Search-button flow. The base64 URL encodes *state*, not a query.
90
+
91
+ 6. **Results now arrive via `/batch`, not `/v1/search`** (verified P1, 2026-06). The live
92
+ site issues `POST content-alkalimatrix-pa.googleapis.com/batch` returning
93
+ `multipart/mixed`; one part is an `application/http` wrapper whose JSON body is the
94
+ search result (top-level `solutionList`, `carrierStopMatrix`, … — same shape as
95
+ `result_full.json`). Many `/batch` calls fire (autocomplete, facets), so the driver
96
+ inspects bodies and picks the part containing `solutionList`. Form driving notes:
97
+ trip type is a `[role=tab]` ("Round Trip"/"One Way"); origin/dest are two
98
+ `[role=combobox]` "Add airport" autocompletes (pick first option); round-trip dates are
99
+ `input.mat-start-date`/`input.mat-end-date`, one-way is `input.mat-datepicker-input`
100
+ (M/D/YYYY, set via `fill()` + blur, then click the calendar backdrop to commit).
101
+
102
+ ---
103
+
104
+ ## Architecture
105
+
106
+ **Browser-driven, bundled Playwright Chromium, headless.** Google's JS must run to mint the
107
+ BotGuard token, but headless bundled Chromium suffices with light stealth — no system Chrome,
108
+ no display.
109
+
110
+ Per query:
111
+
112
+ ```text
113
+ launch chromium (headless) with UA stripped of "Headless" + navigator.webdriver hidden
114
+ → goto /search
115
+ → set origin / dest / dates / options (native-setter value injection, not clicks)
116
+ → click Search
117
+ → intercept POST content-alkalimatrix-pa…/v1/search response (timeout ≥90 s)
118
+ → parse JSON → render (table | json)
119
+ ```
120
+
121
+ One browser instance is reused across the process lifetime. A **persistent daemon** (warm
122
+ BotGuard session, IPC from CLI) is a later optimization for sub-second repeat queries.
123
+
124
+ ### Layout
125
+
126
+ ```text
127
+ itamatrix/
128
+ src/
129
+ cli.ts # commander; TTY-detect → table|json
130
+ browser/session.ts # launch chrome, drive form, click, intercept /v1/search
131
+ browser/forms.ts # set slices/dates/pax/cabin/stops via native setters
132
+ model/types.ts # zod schemas derived from result_full.json
133
+ render/table.ts
134
+ render/json.ts
135
+ commands/search.ts
136
+ commands/multicity.ts
137
+ commands/calendar.ts
138
+ fixtures/result_full.json
139
+ ```
140
+
141
+ ### Command surface
142
+
143
+ ```text
144
+ itamatrix search BOS LAX --depart 2026-08-10 --return 2026-08-17 \
145
+ --pax 1 --cabin economy --stops 1 --carriers UA,AA --sort price --limit 20
146
+
147
+ itamatrix multicity \
148
+ --leg JFK:NRT:2026-08-10 --leg NRT:SIN:2026-08-15 --leg SIN:JFK:2026-08-20
149
+
150
+ itamatrix calendar BOS LAX --depart-range 2026-08-01:2026-08-31 --trip-length 7
151
+
152
+ # global: --json | --table --currency USD --routing "<ITA routing codes>"
153
+ ```
154
+
155
+ ### Full option set
156
+
157
+ Extracted from the live form (incl. Advanced Controls). Every Matrix control is exposed.
158
+ "API field" = where it lands in the `/v1/search` `inputs` (or the base64 `options`/`pax`).
159
+
160
+ **Trip type** — `search` (round-trip via `--return`), `--one-way`, `multicity` command.
161
+ Maps to `slices` count + symmetry.
162
+
163
+ **Per-slice routing** (repeatable per leg):
164
+
165
+ | CLI flag | Control | Values | API field |
166
+ |----------|---------|--------|-----------|
167
+ | `<ORIGIN>` | Origin | airport/city codes, multiple OK | `slices[].origins[]` |
168
+ | `<DEST>` | Destination | airport/city codes, multiple OK | `slices[].destinations[]` |
169
+ | `--routing "<codes>"` | Routing Codes | ITA path language (carriers, vias, operators) — see [docs/ROUTING_CODES.md](docs/ROUTING_CODES.md) | passed through, per slice |
170
+ | `--ext "<codes>"` | Extension Codes | ITA faring & filter codes (`f bc=`, `MAXDUR`, `-OVERNIGHTS`…) — see [docs/ROUTING_CODES.md](docs/ROUTING_CODES.md) | passed through, per slice |
171
+
172
+ **Dates & times** (per slice):
173
+
174
+ | CLI flag | Control | Values | API field |
175
+ |----------|---------|--------|-----------|
176
+ | `--depart` / `--return` | Start/End date | `YYYY-MM-DD` | `slices[].date` |
177
+ | `--date-basis` | Departure / Arrival | `depart` \| `arrive` | `slices[].isArrivalDate` |
178
+ | `--date-window` | This day only / day before / day after / +/-1 / +/-2 | `0`,`-1`,`+1`,`1`,`2` | `slices[].dateModifier{minus,plus}` |
179
+ | `--times` | Preferred times | `early-am,am,midday,afternoon,evening,night` (6 windows) | `slices[].filter.times` |
180
+ | `--calendar` | "See calendar of lowest fares" | flag → calendar mode | `searchDateType: lowestFare` |
181
+
182
+ **Passengers** (`--pax` shorthand or individual):
183
+
184
+ | CLI flag | Control | Range | API field |
185
+ |----------|---------|-------|-----------|
186
+ | `--adults` | Adults (18-61) | int | `pax.adults` |
187
+ | `--seniors` | Seniors (62+) | int | `pax.seniors` |
188
+ | `--youths` | Youths (12-17) | int | `pax.youths` |
189
+ | `--children` | Children (2-11) | int | `pax.children` |
190
+ | `--infants-seat` | Infants in seat (<2) | int | `pax.infantsInSeat` |
191
+ | `--infants-lap` | Infants in lap (<2) | int | `pax.infantsInLap` |
192
+
193
+ **Trip options:**
194
+
195
+ | CLI flag | Control | Values | API field |
196
+ |----------|---------|--------|-----------|
197
+ | `--stops` | Stops | `none`(nonstop) \| `1` \| `2` \| `any`(no limit) | `options.stops` (`0/1/2/-1`) |
198
+ | `--extra-stops` | Extra stops | `none` \| `1` \| `2` \| `any` | `options.extraStops` / `maxLegsRelativeToMin` |
199
+ | `--cabin` | Cabin | `cheapest` \| `premium-economy` \| `business` \| `first` | `inputs.cabin` (`COACH`/`PREMIUM-COACH`/`BUSINESS-OR-HIGHER`/`FIRST`) |
200
+ | `--currency` | Currency | ISO code (e.g. `USD`) | sales currency |
201
+ | `--sales-city` | Sales City | airport/city code | sales city |
202
+ | `--allow-airport-changes` / `--no-airport-changes` | Allow airport changes | bool (default on) | `inputs.changeOfAirport` |
203
+ | `--available-only` / `--all` | Only show available seats | bool (default on) | `inputs.checkAvailability` |
204
+
205
+ **Output/paging:**
206
+
207
+ | CLI flag | Meaning |
208
+ |----------|---------|
209
+ | `--limit N` | page size (Matrix default 25) → `inputs.page.size` |
210
+ | `--sort` | `default` \| `price` \| `duration` \| `depart` \| `arrive` → `inputs.sorts` |
211
+ | `--json` / `--table` | force output format |
212
+
213
+ ---
214
+
215
+ ## Trade-offs
216
+
217
+ - **Bundles Chromium (~150 MB); ~30–60 s per query.** Search latency is server-side (Matrix
218
+ itself is slow), not browser overhead — a daemon won't fix it; only result caching would.
219
+ - **Coupled to the live DOM + network shape.** Form selectors and the `/v1/search` schema can
220
+ change without notice. Mitigation: thin selector layer (`forms.ts`), zod parse with clear
221
+ errors on schema drift, fixture-based tests.
222
+ - **Rejected: pure-HTTP client.** Cleanest and fastest, but impossible — BotGuard token.
223
+
224
+ ---
225
+
226
+ ## Build plan
227
+
228
+ | Phase | Scope | Ships |
229
+ |-------|-------|-------|
230
+ | P1 | session driver + `search` (one-way/round-trip) + table/json output | ✅ done |
231
+ | P2 | filters/options: cabin, stops, carriers, ITA routing codes | ✅ done |
232
+ | P3 | `multicity` (N slices), `calendar` (lowest-fare-per-date) | ✅ done |
233
+ | P4 | npm packaging (`npx itamatrix`), result caching | ✅ done |
234
+ | P5 | **agent skill** — NL → routing/extension codes via [docs/ROUTING_CODES.md](docs/ROUTING_CODES.md), wraps the CLI ([skills/itamatrix](skills/itamatrix/SKILL.md)) | ✅ done |
235
+
236
+ ## Open questions
237
+
238
+ - **`calendar` response schema is unconfirmed.** No live fixture was captured for the
239
+ lowest-fare calendar `/batch` body, so `extractCalendarPayload` matches any plausible
240
+ calendar summarizer key and `normalizeCalendar` deep-scans the payload for date→price
241
+ pairs rather than binding to a schema. Capture a real fixture and tighten to a zod
242
+ schema (like `result_full.json`) once available; calendar form selectors are likewise
243
+ provisional and live in `forms.ts` (the coupled layer).
244
+ - Daemon IPC mechanism (unix socket vs local HTTP) — superseded by P4 disk
245
+ result caching (`src/cache.ts`, spec-keyed, 60-min TTL, `--no-cache`/`--cache-ttl`).
246
+ Repeat queries are instant without a warm-process; a daemon stays a non-goal.
247
+ - Server/CI use — works as-is (headless, no display needed). Just `npx playwright install chromium`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eran Krakovsky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # itamatrix
2
+
3
+ A command-line interface to [ITA Matrix](https://matrix.itasoftware.com/search)
4
+ (Google's airfare search engine). Output auto-detects context: a TTY gets a
5
+ human-readable table, a pipe gets JSON for scripting and AI agents.
6
+
7
+ See [DESIGN.md](DESIGN.md) for architecture and [docs/ROUTING_CODES.md](docs/ROUTING_CODES.md)
8
+ for the ITA routing/extension code language.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npx itamatrix search BOS LAX --depart 2026-08-10
14
+ ```
15
+
16
+ Or install globally:
17
+
18
+ ```bash
19
+ npm install -g itamatrix
20
+ ```
21
+
22
+ itamatrix drives a headless Chromium (Google's anti-bot JS must run). The
23
+ bundled Playwright browser is required once:
24
+
25
+ ```bash
26
+ npx playwright install chromium
27
+ ```
28
+
29
+ If it is missing, itamatrix prints this exact command.
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Round-trip
35
+ itamatrix search BOS LAX --depart 2026-08-10 --return 2026-08-17 \
36
+ --adults 1 --cabin business --stops 1 --carriers UA,AA --limit 20
37
+
38
+ # Multi-city
39
+ itamatrix multicity \
40
+ --leg JFK:NRT:2026-08-10 --leg NRT:SIN:2026-08-15 --leg SIN:JFK:2026-08-20
41
+
42
+ # Price calendar (lowest fare per departure date)
43
+ itamatrix calendar BOS LAX --depart-range 2026-08-01:2026-08-31 --trip-length 7
44
+
45
+ # Global: --json | --table to force output format
46
+ ```
47
+
48
+ Each query takes 30–60 s — Matrix itself is slow server-side.
49
+
50
+ ## Caching
51
+
52
+ Results are cached on disk keyed by the full search spec, so a repeated query
53
+ returns instantly instead of re-driving the browser. Applies to every command.
54
+
55
+ | Flag | Meaning |
56
+ |------|---------|
57
+ | (default) | use cache; entries are fresh for 60 minutes |
58
+ | `--cache-ttl <minutes>` | change the freshness window (e.g. `--cache-ttl 5`) |
59
+ | `--no-cache` | bypass the cache and always query live |
60
+
61
+ Cache location: `$XDG_CACHE_HOME/itamatrix`, falling back to `~/.cache/itamatrix`.
62
+ Caching is best-effort — a read/write failure degrades silently to a live query.
63
+
64
+ ## Agent skill
65
+
66
+ [`skills/itamatrix/SKILL.md`](skills/itamatrix/SKILL.md) is a Claude Code agent
67
+ skill that turns a natural-language trip request ("cheapest business-class
68
+ nonstop to London on oneworld next August") into the right command plus ITA
69
+ [routing/extension codes](docs/ROUTING_CODES.md), then runs the CLI.
70
+
71
+ Install it with [`npx skills`](https://www.npmjs.com/package/skills) straight
72
+ from the repo:
73
+
74
+ ```bash
75
+ npx skills add ekrako/itamatrix # discovers skills/itamatrix/SKILL.md
76
+ npx skills add ekrako/itamatrix -g # install globally (user-level)
77
+ ```
78
+
79
+ Or drop the `skills/` directory into an agent's skill path manually.
80
+
81
+ The skill's **Command reference** section is generated from the CLI's own
82
+ commander definitions, so it can't drift from the real flags:
83
+
84
+ ```bash
85
+ npm run gen:skill # regenerate after changing the CLI
86
+ npm run gen:skill:check # CI/pre-commit: fail if the skill is stale
87
+ ```
88
+
89
+ A `core.hooksPath` pre-commit hook (`.githooks/pre-commit`, installed by `npm
90
+ install`'s `prepare` step) runs the skill-drift check, `typecheck`, and
91
+ `lint:md` on every commit.
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ npm run dev -- search BOS LAX --depart 2026-08-10 # run from source
97
+ npm test # vitest
98
+ npm run typecheck
99
+ npm run lint:md # markdownlint-cli2
100
+ npm run build
101
+ ```
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Matrix returns search results inside a `multipart/mixed` `/batch` response:
3
+ * each part is an `application/http` wrapper around a JSON body. Rather than
4
+ * parse MIME framing, scan the raw text for balanced top-level JSON objects and
5
+ * return the first one that looks like a search result (`solutionList`).
6
+ */
7
+ export declare function extractSearchPayload(batchBody: string): unknown | null;
8
+ export declare function extractCalendarPayload(batchBody: string): unknown | null;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Matrix returns search results inside a `multipart/mixed` `/batch` response:
3
+ * each part is an `application/http` wrapper around a JSON body. Rather than
4
+ * parse MIME framing, scan the raw text for balanced top-level JSON objects and
5
+ * return the first one that looks like a search result (`solutionList`).
6
+ */
7
+ export function extractSearchPayload(batchBody) {
8
+ for (const obj of jsonObjects(batchBody)) {
9
+ if (obj && typeof obj === "object" && "solutionList" in obj)
10
+ return obj;
11
+ }
12
+ return null;
13
+ }
14
+ /**
15
+ * Keys the price-calendar ("lowest fare") response is expected to carry. The
16
+ * exact shape is unconfirmed (no captured fixture yet — DESIGN P3); this matches
17
+ * any plausible calendar summarizer, and `normalizeCalendar` deep-scans for
18
+ * date→price pairs so it tolerates which key actually wins.
19
+ */
20
+ const CALENDAR_KEYS = [
21
+ "calendarSliceList",
22
+ "lowestFareCalendar",
23
+ "overlayPriceBuckets",
24
+ "dateGridList",
25
+ "monthOfYearList",
26
+ ];
27
+ export function extractCalendarPayload(batchBody) {
28
+ for (const obj of jsonObjects(batchBody)) {
29
+ if (!obj || typeof obj !== "object")
30
+ continue;
31
+ const root = "response" in obj ? obj.response : obj;
32
+ if (root && typeof root === "object" && hasCalendarKey(root)) {
33
+ return obj;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ function hasCalendarKey(obj) {
39
+ return CALENDAR_KEYS.some((k) => k in obj);
40
+ }
41
+ /** Yields every balanced, parseable top-level `{...}` object in `text`. */
42
+ function* jsonObjects(text) {
43
+ let i = 0;
44
+ while (i < text.length) {
45
+ if (text[i] !== "{") {
46
+ i++;
47
+ continue;
48
+ }
49
+ const end = matchBrace(text, i);
50
+ if (end === -1)
51
+ break;
52
+ const slice = text.slice(i, end + 1);
53
+ try {
54
+ yield JSON.parse(slice);
55
+ }
56
+ catch {
57
+ // Not a self-contained object at this position; skip past the brace.
58
+ }
59
+ i = end + 1;
60
+ }
61
+ }
62
+ /** Index of the `}` matching the `{` at `start`, string/escape aware; -1 if none. */
63
+ function matchBrace(text, start) {
64
+ let depth = 0;
65
+ let inStr = false;
66
+ let escaped = false;
67
+ for (let i = start; i < text.length; i++) {
68
+ const c = text[i];
69
+ if (inStr) {
70
+ if (escaped)
71
+ escaped = false;
72
+ else if (c === "\\")
73
+ escaped = true;
74
+ else if (c === '"')
75
+ inStr = false;
76
+ continue;
77
+ }
78
+ if (c === '"')
79
+ inStr = true;
80
+ else if (c === "{")
81
+ depth++;
82
+ else if (c === "}" && --depth === 0)
83
+ return i;
84
+ }
85
+ return -1;
86
+ }
87
+ //# sourceMappingURL=batch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch.js","sourceRoot":"","sources":["../../src/browser/batch.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { extractCalendarPayload, extractSearchPayload } from "./batch.js";
5
+ import { parseSearchResponse } from "../model/types.js";
6
+ const batchBody = readFileSync(fileURLToPath(new URL("../../fixtures/batch_multipart.txt", import.meta.url)), "utf8");
7
+ describe("extractSearchPayload", () => {
8
+ it("pulls the solutionList payload out of a multipart /batch body", () => {
9
+ const payload = extractSearchPayload(batchBody);
10
+ expect(payload).not.toBeNull();
11
+ const resp = parseSearchResponse(payload);
12
+ expect(resp.solutionList.solutions.length).toBeGreaterThan(0);
13
+ });
14
+ it("returns null when no part carries a solutionList", () => {
15
+ expect(extractSearchPayload('--b\r\n\r\n{"foo":1}\r\n--b--')).toBeNull();
16
+ });
17
+ it("skips earlier non-result parts and finds a later solutionList", () => {
18
+ const body = '--b\r\n\r\n{"airports":["a"]}\r\n' +
19
+ '--b\r\n\r\n{"solutionList":{"solutions":[{"id":"X","displayTotal":"USD1","itinerary":{"slices":[]}}]}}\r\n--b--';
20
+ const payload = extractSearchPayload(body);
21
+ expect(payload.solutionList.solutions).toHaveLength(1);
22
+ });
23
+ it("ignores a brace inside a JSON string without misparsing", () => {
24
+ const body = '--b\r\n\r\n{"note":"a } brace","solutionList":{"solutions":[]}}\r\n--b--';
25
+ expect(extractSearchPayload(body)).not.toBeNull();
26
+ });
27
+ });
28
+ describe("extractCalendarPayload", () => {
29
+ it("pulls a part carrying a calendar key out of a /batch body", () => {
30
+ const body = '--b\r\n\r\n{"solutionList":{"solutions":[]}}\r\n' +
31
+ '--b\r\n\r\n{"response":{"calendarSliceList":{"days":[]}}}\r\n--b--';
32
+ expect(extractCalendarPayload(body)).not.toBeNull();
33
+ });
34
+ it("returns null when no part has a calendar-shaped payload", () => {
35
+ expect(extractCalendarPayload('--b\r\n\r\n{"solutionList":{}}\r\n--b--')).toBeNull();
36
+ });
37
+ });
38
+ //# sourceMappingURL=batch.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch.test.js","sourceRoot":"","sources":["../../src/browser/batch.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAC1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAExD,MAAM,SAAS,GAAG,YAAY,CAC5B,aAAa,CAAC,IAAI,GAAG,CAAC,oCAAoC,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAC7E,MAAM,CACP,CAAC;AAEF,QAAQ,CAAC,sBAAsB,EAAE,GAAG,EAAE;IACpC,EAAE,CAAC,+DAA+D,EAAE,GAAG,EAAE;QACvE,MAAM,OAAO,GAAG,oBAAoB,CAAC,SAAS,CAAC,CAAC;QAChD,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;QAC/B,MAAM,IAAI,GAAG,mBAAmB,CAAC,OAAO,CAAC,CAAC;QAC1C,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,MAAM,CAAC,oBAAoB,CAAC,+BAA+B,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC3E,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+DAA+D,EAAE,GAAG,EAAE;QACvE,MAAM,IAAI,GACR,mCAAmC;YACnC,iHAAiH,CAAC;QACpH,MAAM,OAAO,GAAG,oBAAoB,CAAC,IAAI,CAA+C,CAAC;QACzF,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IACzD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,MAAM,IAAI,GAAG,0EAA0E,CAAC;QACxF,MAAM,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;IACpD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,EAAE,CAAC,2DAA2D,EAAE,GAAG,EAAE;QACnE,MAAM,IAAI,GACR,kDAAkD;YAClD,oEAAoE,CAAC;QACvE,MAAM,CAAC,sBAAsB,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;IACtD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,MAAM,CAAC,sBAAsB,CAAC,yCAAyC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IACvF,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,26 @@
1
+ import type { Page } from "playwright";
2
+ import type { CalendarSpec, MultiCitySpec, SearchSpec } from "../model/spec.js";
3
+ /**
4
+ * Drives the Matrix search form and clicks Search.
5
+ *
6
+ * NOTE: this layer is intentionally thin and is the part most coupled to the
7
+ * live DOM (DESIGN.md "Trade-offs"). Selectors target the Angular Material
8
+ * markup observed on matrix.itasoftware.com/search. If Matrix restructures the
9
+ * form, failures surface here with a clear message.
10
+ */
11
+ export declare function driveSearchForm(page: Page, spec: SearchSpec): Promise<void>;
12
+ /**
13
+ * Drives the multi-city form: selects the Multi-City tab, materialises one row
14
+ * per leg, fills each leg's origin/destination/date, then applies the shared
15
+ * cabin/stops controls and clicks Search. The response is the same
16
+ * `solutionList` shape as a normal search (N slices instead of 1–2).
17
+ */
18
+ export declare function driveMultiCityForm(page: Page, spec: MultiCitySpec): Promise<void>;
19
+ /**
20
+ * Drives the price-calendar form: "See calendar of lowest fares" over a
21
+ * departure-date range. With `tripLength` it's a round-trip calendar; without,
22
+ * one-way. Selectors here are provisional — the calendar UI/response could not
23
+ * be captured for P3 (DESIGN), so this is the thinnest, most likely-to-drift
24
+ * layer; failures surface with a clear message.
25
+ */
26
+ export declare function driveCalendarForm(page: Page, spec: CalendarSpec): Promise<void>;