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.
- package/DESIGN.md +247 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/browser/batch.d.ts +8 -0
- package/dist/browser/batch.js +87 -0
- package/dist/browser/batch.js.map +1 -0
- package/dist/browser/batch.test.d.ts +1 -0
- package/dist/browser/batch.test.js +38 -0
- package/dist/browser/batch.test.js.map +1 -0
- package/dist/browser/forms.d.ts +26 -0
- package/dist/browser/forms.js +233 -0
- package/dist/browser/forms.js.map +1 -0
- package/dist/browser/session.d.ts +20 -0
- package/dist/browser/session.js +126 -0
- package/dist/browser/session.js.map +1 -0
- package/dist/cache.d.ts +21 -0
- package/dist/cache.js +71 -0
- package/dist/cache.js.map +1 -0
- package/dist/cache.test.d.ts +1 -0
- package/dist/cache.test.js +79 -0
- package/dist/cache.test.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +154 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/calendar.d.ts +19 -0
- package/dist/commands/calendar.js +47 -0
- package/dist/commands/calendar.js.map +1 -0
- package/dist/commands/calendar.test.d.ts +1 -0
- package/dist/commands/calendar.test.js +58 -0
- package/dist/commands/calendar.test.js.map +1 -0
- package/dist/commands/multicity.d.ts +17 -0
- package/dist/commands/multicity.js +50 -0
- package/dist/commands/multicity.js.map +1 -0
- package/dist/commands/multicity.test.d.ts +1 -0
- package/dist/commands/multicity.test.js +54 -0
- package/dist/commands/multicity.test.js.map +1 -0
- package/dist/commands/search.d.ts +20 -0
- package/dist/commands/search.js +43 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/commands/search.test.d.ts +1 -0
- package/dist/commands/search.test.js +124 -0
- package/dist/commands/search.test.js.map +1 -0
- package/dist/commands/shared.d.ts +44 -0
- package/dist/commands/shared.js +77 -0
- package/dist/commands/shared.js.map +1 -0
- package/dist/model/spec.d.ts +63 -0
- package/dist/model/spec.js +29 -0
- package/dist/model/spec.js.map +1 -0
- package/dist/model/spec.test.d.ts +1 -0
- package/dist/model/spec.test.js +55 -0
- package/dist/model/spec.test.js.map +1 -0
- package/dist/model/types.d.ts +22080 -0
- package/dist/model/types.js +100 -0
- package/dist/model/types.js.map +1 -0
- package/dist/model/types.test.d.ts +1 -0
- package/dist/model/types.test.js +35 -0
- package/dist/model/types.test.js.map +1 -0
- package/dist/render/calendar.d.ts +21 -0
- package/dist/render/calendar.js +89 -0
- package/dist/render/calendar.js.map +1 -0
- package/dist/render/calendar.render.test.d.ts +1 -0
- package/dist/render/calendar.render.test.js +66 -0
- package/dist/render/calendar.render.test.js.map +1 -0
- package/dist/render/json.d.ts +2 -0
- package/dist/render/json.js +4 -0
- package/dist/render/json.js.map +1 -0
- package/dist/render/normalize.d.ts +32 -0
- package/dist/render/normalize.js +44 -0
- package/dist/render/normalize.js.map +1 -0
- package/dist/render/render.test.d.ts +1 -0
- package/dist/render/render.test.js +129 -0
- package/dist/render/render.test.js.map +1 -0
- package/dist/render/table.d.ts +2 -0
- package/dist/render/table.js +49 -0
- package/dist/render/table.js.map +1 -0
- package/docs/ROUTING_CODES.md +137 -0
- package/package.json +68 -0
- 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>;
|