itamatrix 1.0.2 → 1.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/dist/browser/batch.js +12 -0
- package/dist/browser/forms.js +7 -4
- package/dist/browser/session.js +93 -2
- package/dist/cli.js +4 -0
- package/dist/commands/multicity.js +17 -4
- package/dist/commands/search.js +21 -4
- package/dist/commands/shared.js +8 -0
- package/dist/model/types.js +38 -0
- package/dist/render/normalize.js +1 -0
- package/dist/render/table.js +17 -1
- package/docs/ROUTING_CODES.md +26 -1
- package/package.json +1 -1
- package/skills/itamatrix/SKILL.md +49 -1
package/dist/browser/batch.js
CHANGED
|
@@ -11,6 +11,17 @@ export function extractSearchPayload(batchBody) {
|
|
|
11
11
|
}
|
|
12
12
|
return null;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* The itinerary-detail page issues its own `/batch` whose part carries a
|
|
16
|
+
* `bookingDetails` body (priced solution: fare construction, taxes, segments).
|
|
17
|
+
*/
|
|
18
|
+
export function extractBookingDetailsPayload(batchBody) {
|
|
19
|
+
for (const obj of jsonObjects(batchBody)) {
|
|
20
|
+
if (obj && typeof obj === "object" && "bookingDetails" in obj)
|
|
21
|
+
return obj;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
14
25
|
/**
|
|
15
26
|
* Keys the price-calendar ("lowest fare") response is expected to carry. The
|
|
16
27
|
* exact shape is unconfirmed (no captured fixture yet — DESIGN P3); this matches
|
|
@@ -35,6 +46,7 @@ export function extractCalendarPayload(batchBody) {
|
|
|
35
46
|
}
|
|
36
47
|
return null;
|
|
37
48
|
}
|
|
49
|
+
/** True if `obj` carries any of the known price-calendar summary keys. */
|
|
38
50
|
function hasCalendarKey(obj) {
|
|
39
51
|
return CALENDAR_KEYS.some((k) => k in obj);
|
|
40
52
|
}
|
package/dist/browser/forms.js
CHANGED
|
@@ -24,7 +24,7 @@ export async function driveSearchForm(page, spec) {
|
|
|
24
24
|
* `solutionList` shape as a normal search (N slices instead of 1–2).
|
|
25
25
|
*/
|
|
26
26
|
export async function driveMultiCityForm(page, spec) {
|
|
27
|
-
await page.getByRole("tab", { name:
|
|
27
|
+
await page.getByRole("tab", { name: /multi[\s-]?city/i }).click();
|
|
28
28
|
await ensureLegRows(page, spec.slices.length);
|
|
29
29
|
for (const [i, leg] of spec.slices.entries()) {
|
|
30
30
|
await fillAirport(page, 2 * i, leg.origin);
|
|
@@ -87,13 +87,16 @@ async function setAdvancedControls(page, spec, roundTrip) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
/**
|
|
90
|
-
* Adds "Add
|
|
91
|
-
*
|
|
90
|
+
* Adds "Add Flight" rows until the form has `count` legs. The default number of
|
|
91
|
+
* rows Matrix renders has drifted (currently one), so derive it from the live
|
|
92
|
+
* combobox count (two per leg) rather than assuming a fixed starting point.
|
|
92
93
|
*/
|
|
93
94
|
async function ensureLegRows(page, count) {
|
|
94
95
|
const addLeg = page.getByRole("button", { name: /add (another )?flight/i });
|
|
95
|
-
|
|
96
|
+
const comboboxes = page.getByRole("combobox", { name: "Add airport" });
|
|
97
|
+
for (let existing = (await comboboxes.count()) / 2; existing < count; existing++) {
|
|
96
98
|
await addLeg.click();
|
|
99
|
+
await comboboxes.nth(2 * existing + 1).waitFor({ state: "visible", timeout: 15_000 });
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
/** Multi-city: shared cabin/stops globally, routing/ext per leg by row index. */
|
package/dist/browser/session.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { chromium } from "playwright";
|
|
2
2
|
import { driveCalendarForm, driveMultiCityForm, driveSearchForm } from "./forms.js";
|
|
3
|
-
import { extractCalendarPayload, extractSearchPayload } from "./batch.js";
|
|
4
|
-
import { parseCalendarResponse, parseSearchResponse, } from "../model/types.js";
|
|
3
|
+
import { extractBookingDetailsPayload, extractCalendarPayload, extractSearchPayload, } from "./batch.js";
|
|
4
|
+
import { parseBookingDetails, parseCalendarResponse, parseSearchResponse, } from "../model/types.js";
|
|
5
5
|
const SEARCH_URL = "https://matrix.itasoftware.com/search";
|
|
6
6
|
// Results arrive in a multipart `/batch` response (not the documented
|
|
7
7
|
// `/v1/search`); the relevant part carries a `solutionList` JSON body.
|
|
@@ -28,6 +28,14 @@ export async function runMultiCity(spec, opts = {}) {
|
|
|
28
28
|
export async function runCalendar(spec, opts = {}) {
|
|
29
29
|
return runDriver((page) => driveCalendarForm(page, spec), extractCalendarPayload, parseCalendarResponse, opts);
|
|
30
30
|
}
|
|
31
|
+
/** Like {@link runSearch}, then opens the top result's detail page for its fare construction + Google Flights link. */
|
|
32
|
+
export function runSearchWithDetails(spec, opts = {}) {
|
|
33
|
+
return runDriverWithDetails((page) => driveSearchForm(page, spec), opts);
|
|
34
|
+
}
|
|
35
|
+
/** Multi-city counterpart of {@link runSearchWithDetails}. */
|
|
36
|
+
export function runMultiCityWithDetails(spec, opts = {}) {
|
|
37
|
+
return runDriverWithDetails((page) => driveMultiCityForm(page, spec), opts);
|
|
38
|
+
}
|
|
31
39
|
/**
|
|
32
40
|
* Shared driver: launch Chromium, drive the form, intercept the first `/batch`
|
|
33
41
|
* response that `match` accepts, and parse it. All P1–P3 commands share this;
|
|
@@ -61,6 +69,88 @@ async function runDriver(drive, match, parse, opts) {
|
|
|
61
69
|
await browser.close();
|
|
62
70
|
}
|
|
63
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Search, then drill into the top result's detail page. The detail capture is
|
|
74
|
+
* best-effort: if the row can't be opened or the `bookingDetails` part never
|
|
75
|
+
* arrives, `details` is null and the search results are still returned. Reuses
|
|
76
|
+
* the same browser/page so the detail page inherits the live solution session.
|
|
77
|
+
*/
|
|
78
|
+
async function runDriverWithDetails(drive, opts) {
|
|
79
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
80
|
+
const browser = await launch(opts.headful ?? false);
|
|
81
|
+
const page = await newStealthPage(browser);
|
|
82
|
+
const waiter = waitForResponse(page, extractSearchPayload, timeoutMs);
|
|
83
|
+
waiter.promise.catch(() => { });
|
|
84
|
+
try {
|
|
85
|
+
try {
|
|
86
|
+
await page.goto(SEARCH_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
87
|
+
await drive(page);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Error("Failed to drive the Matrix search form (the site may have changed). Run with --headful to debug.");
|
|
91
|
+
}
|
|
92
|
+
const search = parseSearchResponse(await waiter.promise);
|
|
93
|
+
waiter.cancel();
|
|
94
|
+
const details = await captureTopDetails(page, timeoutMs);
|
|
95
|
+
return { search, details };
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
waiter.cancel();
|
|
99
|
+
await browser.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Opens the first results row and waits for its `bookingDetails` part, then reads
|
|
104
|
+
* the "Open in Google Flights" link from the DOM (it's rendered client-side, not
|
|
105
|
+
* in the API). Returns null on any failure — detail is an enrichment, never fatal.
|
|
106
|
+
*/
|
|
107
|
+
async function captureTopDetails(page, timeoutMs) {
|
|
108
|
+
const waiter = waitForResponse(page, extractBookingDetailsPayload, timeoutMs);
|
|
109
|
+
waiter.promise.catch(() => { });
|
|
110
|
+
try {
|
|
111
|
+
if (!(await openFirstSolution(page)))
|
|
112
|
+
return null;
|
|
113
|
+
const bookingDetails = parseBookingDetails(await waiter.promise);
|
|
114
|
+
return { bookingDetails, googleFlightsUrl: await readGoogleFlightsUrl(page) };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
waiter.cancel();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Clicks into the first itinerary; true if a clickable row was found. */
|
|
124
|
+
async function openFirstSolution(page) {
|
|
125
|
+
const candidates = [
|
|
126
|
+
page.getByRole("link").filter({ hasText: /\$|USD|\d:\d/ }).first(),
|
|
127
|
+
page.locator("td a, .mat-row a, [role=row] a").first(),
|
|
128
|
+
page.locator("[role=row]").filter({ hasText: /\$|USD/ }).nth(1),
|
|
129
|
+
];
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
try {
|
|
132
|
+
// click() auto-waits for the element; no count() pre-check, which would
|
|
133
|
+
// skip a row that renders a moment later.
|
|
134
|
+
await candidate.click({ timeout: 8_000 });
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Try the next selector; the results DOM has drifted before.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/** Reads the detail page's "Open in Google Flights" href; undefined if absent. */
|
|
144
|
+
async function readGoogleFlightsUrl(page) {
|
|
145
|
+
const link = page.getByRole("link", { name: /google flights/i }).first();
|
|
146
|
+
try {
|
|
147
|
+
return (await link.getAttribute("href", { timeout: 8_000 })) ?? undefined;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Launches Chromium, mapping Playwright's path-leaking errors to safe messages. */
|
|
64
154
|
async function launch(headful) {
|
|
65
155
|
try {
|
|
66
156
|
return await chromium.launch({
|
|
@@ -78,6 +168,7 @@ async function launch(headful) {
|
|
|
78
168
|
throw new Error("Failed to launch the browser. Run with --headful to debug, or reinstall Chromium: npx playwright install chromium");
|
|
79
169
|
}
|
|
80
170
|
}
|
|
171
|
+
/** New page with a real Chrome UA and `navigator.webdriver` hidden (light stealth). */
|
|
81
172
|
async function newStealthPage(browser) {
|
|
82
173
|
const context = await browser.newContext({
|
|
83
174
|
userAgent: STEALTH_UA,
|
package/dist/cli.js
CHANGED
|
@@ -65,6 +65,7 @@ program
|
|
|
65
65
|
.option("--carriers <list>", "comma-separated carriers, e.g. UA,AA (sugar for --routing)")
|
|
66
66
|
.option("--routing <codes>", "ITA routing codes (path) — see docs/ROUTING_CODES.md")
|
|
67
67
|
.option("--ext <codes>", "ITA extension codes (faring/filters) — see docs/ROUTING_CODES.md")
|
|
68
|
+
.option("--details", "also fetch the top result's fare construction + Google Flights link (live, skips cache)")
|
|
68
69
|
.option("--headful", "show the browser window (debug)")
|
|
69
70
|
.action(async (origin, dest, cmdOpts, command) => {
|
|
70
71
|
const globals = command.parent.opts();
|
|
@@ -80,6 +81,7 @@ program
|
|
|
80
81
|
carriers: cmdOpts.carriers,
|
|
81
82
|
routing: cmdOpts.routing,
|
|
82
83
|
ext: cmdOpts.ext,
|
|
84
|
+
details: cmdOpts.details,
|
|
83
85
|
headful: cmdOpts.headful,
|
|
84
86
|
format: resolveFormat(globals.json, globals.table),
|
|
85
87
|
cache: globals.cache,
|
|
@@ -99,6 +101,7 @@ program
|
|
|
99
101
|
.option("--carriers <list>", "comma-separated carriers, e.g. UA,AA (sugar for --routing)")
|
|
100
102
|
.option("--routing <codes>", "ITA routing codes applied to every leg")
|
|
101
103
|
.option("--ext <codes>", "ITA extension codes applied to every leg")
|
|
104
|
+
.option("--details", "also fetch the top result's fare construction + Google Flights link (live, skips cache)")
|
|
102
105
|
.option("--headful", "show the browser window (debug)")
|
|
103
106
|
.action(async (cmdOpts, command) => {
|
|
104
107
|
const globals = command.parent.opts();
|
|
@@ -112,6 +115,7 @@ program
|
|
|
112
115
|
routing: cmdOpts.routing ?? carriersToRouting(cmdOpts.carriers),
|
|
113
116
|
ext: cmdOpts.ext,
|
|
114
117
|
format: resolveFormat(globals.json, globals.table),
|
|
118
|
+
details: cmdOpts.details,
|
|
115
119
|
headful: cmdOpts.headful,
|
|
116
120
|
cache: globals.cache,
|
|
117
121
|
cacheTtlMinutes: globals.cacheTtl,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { runMultiCity } from "../browser/session.js";
|
|
1
|
+
import { runMultiCity, runMultiCityWithDetails } from "../browser/session.js";
|
|
2
2
|
import { withCache } from "../cache.js";
|
|
3
3
|
import { normalize } from "../render/normalize.js";
|
|
4
4
|
import { renderJson } from "../render/json.js";
|
|
5
5
|
import { renderTable } from "../render/table.js";
|
|
6
|
-
import { requireIsoDate, requirePageLimit, resolveCacheOptions, validateTripControls, } from "./shared.js";
|
|
6
|
+
import { requireIsoDate, requirePageLimit, resolveCacheOptions, toItineraryDetails, validateTripControls, } from "./shared.js";
|
|
7
|
+
/** Parses/validates legs, runs the multi-city search, and renders table or JSON. */
|
|
7
8
|
export async function runMultiCityCommand(opts) {
|
|
8
9
|
const slices = parseLegs(opts);
|
|
9
10
|
validateTripControls(opts);
|
|
@@ -16,10 +17,21 @@ export async function runMultiCityCommand(opts) {
|
|
|
16
17
|
stops: opts.stops,
|
|
17
18
|
extraStops: opts.extraStops,
|
|
18
19
|
};
|
|
19
|
-
const
|
|
20
|
-
const result = normalize(response, opts.limit);
|
|
20
|
+
const result = await multicity(spec, opts);
|
|
21
21
|
return opts.format === "json" ? renderJson(result) : renderTable(result);
|
|
22
22
|
}
|
|
23
|
+
/** `--details` drills into the top result live, so it bypasses the cache (see search.ts). */
|
|
24
|
+
async function multicity(spec, opts) {
|
|
25
|
+
if (opts.details) {
|
|
26
|
+
const { search: response, details } = await runMultiCityWithDetails(spec, {
|
|
27
|
+
headful: opts.headful,
|
|
28
|
+
});
|
|
29
|
+
const result = normalize(response, opts.limit);
|
|
30
|
+
return details ? { ...result, details: toItineraryDetails(details) } : result;
|
|
31
|
+
}
|
|
32
|
+
const response = await withCache("multicity", spec, resolveCacheOptions(opts), () => runMultiCity(spec, { headful: opts.headful }));
|
|
33
|
+
return normalize(response, opts.limit);
|
|
34
|
+
}
|
|
23
35
|
/** Parses `--leg ORIGIN:DEST:DATE` flags into slices; `--routing`/`--ext` apply to all. */
|
|
24
36
|
export function parseLegs(opts) {
|
|
25
37
|
if (!opts.legs || opts.legs.length < 2) {
|
|
@@ -27,6 +39,7 @@ export function parseLegs(opts) {
|
|
|
27
39
|
}
|
|
28
40
|
return opts.legs.map((raw, i) => toSlice(raw, i, opts));
|
|
29
41
|
}
|
|
42
|
+
/** Parses one `ORIGIN:DEST:DATE` leg, applying shared routing/ext, into a Slice. */
|
|
30
43
|
function toSlice(raw, index, opts) {
|
|
31
44
|
const parts = raw.split(":");
|
|
32
45
|
if (parts.length !== 3) {
|
package/dist/commands/search.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { runSearch } from "../browser/session.js";
|
|
1
|
+
import { runSearch, runSearchWithDetails } from "../browser/session.js";
|
|
2
2
|
import { withCache } from "../cache.js";
|
|
3
3
|
import { normalize } from "../render/normalize.js";
|
|
4
4
|
import { renderJson } from "../render/json.js";
|
|
5
5
|
import { renderTable } from "../render/table.js";
|
|
6
|
-
import { carriersToRouting, requireIsoDate, requirePageLimit, resolveCacheOptions, validateTripControls, } from "./shared.js";
|
|
6
|
+
import { carriersToRouting, requireIsoDate, requirePageLimit, resolveCacheOptions, toItineraryDetails, validateTripControls, } from "./shared.js";
|
|
7
|
+
/** Validates input, runs a one-way/round-trip search, and renders table or JSON. */
|
|
7
8
|
export async function runSearchCommand(origin, dest, opts) {
|
|
8
9
|
validate(origin, dest, opts);
|
|
9
10
|
const spec = {
|
|
@@ -19,14 +20,30 @@ export async function runSearchCommand(origin, dest, opts) {
|
|
|
19
20
|
routing: resolveRouting(opts),
|
|
20
21
|
ext: opts.ext,
|
|
21
22
|
};
|
|
22
|
-
const
|
|
23
|
-
const result = normalize(response, opts.limit);
|
|
23
|
+
const result = await search(spec, opts);
|
|
24
24
|
return opts.format === "json" ? renderJson(result) : renderTable(result);
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* `--details` needs a live browser session (it drills into the detail page using
|
|
28
|
+
* the in-flight solution session), so it bypasses the cache; the plain path stays
|
|
29
|
+
* cached.
|
|
30
|
+
*/
|
|
31
|
+
async function search(spec, opts) {
|
|
32
|
+
if (opts.details) {
|
|
33
|
+
const { search: response, details } = await runSearchWithDetails(spec, {
|
|
34
|
+
headful: opts.headful,
|
|
35
|
+
});
|
|
36
|
+
const result = normalize(response, opts.limit);
|
|
37
|
+
return details ? { ...result, details: toItineraryDetails(details) } : result;
|
|
38
|
+
}
|
|
39
|
+
const response = await withCache("search", spec, resolveCacheOptions(opts), () => runSearch(spec, { headful: opts.headful }));
|
|
40
|
+
return normalize(response, opts.limit);
|
|
41
|
+
}
|
|
26
42
|
/** `--routing` wins; otherwise `--carriers UA,AA` becomes the routing `UA,AA+`. */
|
|
27
43
|
export function resolveRouting(opts) {
|
|
28
44
|
return opts.routing ?? carriersToRouting(opts.carriers);
|
|
29
45
|
}
|
|
46
|
+
/** Throws on bad origin/dest, dates, return-before-depart, or trip-control values. */
|
|
30
47
|
function validate(origin, dest, opts) {
|
|
31
48
|
if (!origin || !dest)
|
|
32
49
|
throw new Error("origin and destination are required");
|
package/dist/commands/shared.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CABIN_LABELS, STOPS_LABELS, } from "../model/spec.js";
|
|
2
|
+
import { collectFareConstruction } from "../model/types.js";
|
|
2
3
|
import { DEFAULT_CACHE_TTL_MINUTES } from "../cache.js";
|
|
3
4
|
export function resolveCacheOptions(opts) {
|
|
4
5
|
const minutes = opts.cacheTtlMinutes ?? DEFAULT_CACHE_TTL_MINUTES;
|
|
@@ -61,6 +62,13 @@ export function carriersToRouting(carriers) {
|
|
|
61
62
|
return undefined;
|
|
62
63
|
return codes.length === 1 ? `${codes[0]}+` : `(${codes.join(",")})+`;
|
|
63
64
|
}
|
|
65
|
+
/** Shapes a browser-captured top-result detail into the flat render view. */
|
|
66
|
+
export function toItineraryDetails(capture) {
|
|
67
|
+
return {
|
|
68
|
+
fareConstruction: collectFareConstruction(capture.bookingDetails),
|
|
69
|
+
googleFlightsUrl: capture.googleFlightsUrl,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
64
72
|
export function requireIsoDate(value, flag) {
|
|
65
73
|
if (!DATE_RE.test(value) || !isRealCalendarDate(value)) {
|
|
66
74
|
throw new Error(`${flag} must be a valid date (YYYY-MM-DD), got "${value}"`);
|
package/dist/model/types.js
CHANGED
|
@@ -92,6 +92,44 @@ export function parseCalendarResponse(raw) {
|
|
|
92
92
|
}
|
|
93
93
|
return root;
|
|
94
94
|
}
|
|
95
|
+
export function parseBookingDetails(raw) {
|
|
96
|
+
const root = unwrapResponse(raw);
|
|
97
|
+
if (!root || typeof root !== "object" || Array.isArray(root) || !("bookingDetails" in root)) {
|
|
98
|
+
throw new Error("Itinerary-detail response did not contain bookingDetails");
|
|
99
|
+
}
|
|
100
|
+
return root;
|
|
101
|
+
}
|
|
102
|
+
/** Deep-scan for every `fareCalculations[].lines` string, deduped, in order. */
|
|
103
|
+
export function collectFareConstruction(node) {
|
|
104
|
+
const lines = [];
|
|
105
|
+
const walk = (o) => {
|
|
106
|
+
if (Array.isArray(o)) {
|
|
107
|
+
o.forEach(walk);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!o || typeof o !== "object")
|
|
111
|
+
return;
|
|
112
|
+
for (const [key, value] of Object.entries(o)) {
|
|
113
|
+
if (key === "fareCalculations")
|
|
114
|
+
collectLines(value, lines);
|
|
115
|
+
walk(value);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
walk(node);
|
|
119
|
+
return [...new Set(lines)];
|
|
120
|
+
}
|
|
121
|
+
/** Appends every string in `calculations[].lines` to `out`. */
|
|
122
|
+
function collectLines(calculations, out) {
|
|
123
|
+
if (!Array.isArray(calculations))
|
|
124
|
+
return;
|
|
125
|
+
for (const calc of calculations) {
|
|
126
|
+
const calcLines = calc?.lines;
|
|
127
|
+
if (Array.isArray(calcLines)) {
|
|
128
|
+
out.push(...calcLines.filter((v) => typeof v === "string"));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Unwraps the `{ response: … }` envelope Matrix bodies are sometimes wrapped in. */
|
|
95
133
|
function unwrapResponse(raw) {
|
|
96
134
|
return raw && typeof raw === "object" && "response" in raw
|
|
97
135
|
? raw.response
|
package/dist/render/normalize.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
function parseCarrier(flight) {
|
|
6
6
|
return flight?.match(/^[A-Z0-9]{2}/)?.[0];
|
|
7
7
|
}
|
|
8
|
+
/** Flattens one Matrix solution into the agent-friendly {@link FlatSolution} view. */
|
|
8
9
|
function flattenSolution(sol) {
|
|
9
10
|
const slices = sol.itinerary.slices.map((s) => ({
|
|
10
11
|
origin: s.origin.code,
|
package/dist/render/table.js
CHANGED
|
@@ -7,6 +7,7 @@ function fmtTime(iso) {
|
|
|
7
7
|
return iso;
|
|
8
8
|
return `${m[2]}-${m[3]} ${m[4]}`;
|
|
9
9
|
}
|
|
10
|
+
/** Formats minutes as `6h30`; empty string when undefined. */
|
|
10
11
|
function fmtDuration(min) {
|
|
11
12
|
if (min == null)
|
|
12
13
|
return "";
|
|
@@ -14,6 +15,7 @@ function fmtDuration(min) {
|
|
|
14
15
|
const m = min % 60;
|
|
15
16
|
return `${h}h${String(m).padStart(2, "0")}`;
|
|
16
17
|
}
|
|
18
|
+
/** One line per slice: route, times, stops/duration, flight numbers, warnings. */
|
|
17
19
|
function summarizeSlice(sol) {
|
|
18
20
|
return sol.slices
|
|
19
21
|
.map((s) => {
|
|
@@ -26,6 +28,7 @@ function summarizeSlice(sol) {
|
|
|
26
28
|
})
|
|
27
29
|
.join("\n");
|
|
28
30
|
}
|
|
31
|
+
/** Renders results as a colored terminal table, with an optional details footer. */
|
|
29
32
|
export function renderTable(result) {
|
|
30
33
|
if (result.solutions.length === 0) {
|
|
31
34
|
return chalk.yellow("No flights found.");
|
|
@@ -44,5 +47,18 @@ export function renderTable(result) {
|
|
|
44
47
|
}
|
|
45
48
|
const header = chalk.dim(`${result.shown} of ${result.count} results` +
|
|
46
49
|
(result.minPrice ? ` · from ${result.minPrice}` : ""));
|
|
47
|
-
|
|
50
|
+
const footer = result.details ? `\n${renderDetails(result.details)}` : "";
|
|
51
|
+
return `${header}\n${table.toString()}${footer}`;
|
|
52
|
+
}
|
|
53
|
+
/** Top-result detail footer: fare construction (for travel agents) + Google Flights link. */
|
|
54
|
+
function renderDetails(details) {
|
|
55
|
+
const lines = [chalk.bold("\nTop result")];
|
|
56
|
+
if (details.fareConstruction.length) {
|
|
57
|
+
lines.push(chalk.dim("Fare Construction (can be useful to travel agents):"));
|
|
58
|
+
lines.push(...details.fareConstruction.map((l) => ` ${l}`));
|
|
59
|
+
}
|
|
60
|
+
if (details.googleFlightsUrl) {
|
|
61
|
+
lines.push(`${chalk.bold("Open in Google Flights:")} ${details.googleFlightsUrl}`);
|
|
62
|
+
}
|
|
63
|
+
return lines.join("\n");
|
|
48
64
|
}
|
package/docs/ROUTING_CODES.md
CHANGED
|
@@ -41,6 +41,8 @@ Define which carriers / airports / operators a slice may use, and in what order.
|
|
|
41
41
|
| `C:XX` | force **marketing** carrier | `C:BA` |
|
|
42
42
|
| `O:XX` | force **operating** carrier | `O:AA` |
|
|
43
43
|
| `X:AAA` | **require** connection at airport | `X:DFW` |
|
|
44
|
+
| `X` | any single connection point | `UA X UA` |
|
|
45
|
+
| `X?` | nonstop **or** one connection | `X?` |
|
|
44
46
|
| `N:` | nonstop only (no connections) | `N:` |
|
|
45
47
|
| `F:` | direct (single flight number, stops allowed) | `F:` |
|
|
46
48
|
|
|
@@ -96,6 +98,9 @@ pipe-(`|`)-separated.
|
|
|
96
98
|
| No red-eyes | `-REDEYES` | `-REDEYES` | exclude red-eye flights |
|
|
97
99
|
| No overnights | `-OVERNIGHTS` | `-OVERNIGHTS` | exclude overnight layovers |
|
|
98
100
|
| No props | `-PROPS` | `-PROPS` | exclude propeller aircraft |
|
|
101
|
+
| No airport change | `-CHANGE` | `-CHANGE` | exclude connections that change airports in a city |
|
|
102
|
+
| No trains | `-TRAIN` | `-TRAIN` | exclude rail segments |
|
|
103
|
+
| No helicopters | `-HELICOPTER` | `-HELICOPTER` | exclude helicopter segments |
|
|
99
104
|
|
|
100
105
|
### Faring codes (`f` / booking class / fare basis)
|
|
101
106
|
|
|
@@ -123,7 +128,27 @@ pipe-(`|`)-separated.
|
|
|
123
128
|
|
|
124
129
|
---
|
|
125
130
|
|
|
126
|
-
## 3.
|
|
131
|
+
## 3. Power-user strategies (FlyerTalk / community)
|
|
132
|
+
|
|
133
|
+
Higher-leverage patterns that combine the primitives above:
|
|
134
|
+
|
|
135
|
+
- **Force a stopover / overnight.** Matrix has no "stopover" command — fake it with a large
|
|
136
|
+
minimum connection. `MINCONNECT 12:00` forces an overnight layover; values above `24:00`
|
|
137
|
+
(e.g. `MINCONNECT 30:00`) force a 24 h+ stopover. Pin *where* with `X:CITY` in Routing.
|
|
138
|
+
Pair with `MAXCONNECT` to bound it: `X:NRT` + `MINCONNECT 20:00; MAXCONNECT 30:00`.
|
|
139
|
+
- **Split marketing vs operating for mileage credit.** Force the metal you want with `O:` in
|
|
140
|
+
Routing and the program you want to credit with marketing carrier / `ALLIANCE`. E.g. fly
|
|
141
|
+
Lufthansa metal but ticket via a partner: Routing `O:LH+`, Extension `ALLIANCE star-alliance`.
|
|
142
|
+
- **Target a booking class for upgrades / earning.** `f bc=...` pins the prime booking code
|
|
143
|
+
(fare buckets differ in price, upgrade eligibility, and miles earned). `f bc=w|bc=v` allows
|
|
144
|
+
either. Combine with `+CABIN` to keep the displayed cabin honest.
|
|
145
|
+
- **Mileage-run / distance tuning.** `MINMILES`/`MAXMILES` shape itinerary distance; useful for
|
|
146
|
+
hitting an elite-qualifying threshold or avoiding wasteful backtracking.
|
|
147
|
+
- **Consider nearby airports.** Matrix's UI accepts comma-separated origins/destinations
|
|
148
|
+
(e.g. `BOS,PVD,MHT`) and secondary airports are often materially cheaper; if a single-field
|
|
149
|
+
comma string is rejected, fall back to one search per airport and compare.
|
|
150
|
+
|
|
151
|
+
## 4. Notes for the skill
|
|
127
152
|
|
|
128
153
|
- **Validate field placement**: path-shaped intent → Routing; filter/fare intent → Extension.
|
|
129
154
|
- **Cabin two ways**: the top-level `--cabin` (search option) sets the *displayed* cabin;
|
package/package.json
CHANGED
|
@@ -18,7 +18,9 @@ request into the right command + flags, encoding advanced intent as ITA
|
|
|
18
18
|
|
|
19
19
|
The CLI ships as `itamatrix` (`npx itamatrix ...` if not installed globally).
|
|
20
20
|
On first use it may ask for the browser: `npx playwright install chromium`.
|
|
21
|
-
Queries take
|
|
21
|
+
Queries can take a while (Matrix is slow server-side); repeated queries hit the
|
|
22
|
+
cache. **Set the command timeout to at least 180 s** — a search may run that long
|
|
23
|
+
before returning. Don't kill it early and conclude it failed.
|
|
22
24
|
|
|
23
25
|
Always run with `--json` so you get structured output to parse and summarize.
|
|
24
26
|
|
|
@@ -45,6 +47,25 @@ flag can't express it.
|
|
|
45
47
|
| "only UA and AA" (simple) | `--carriers UA,AA` |
|
|
46
48
|
| number of travelers | `--adults N` |
|
|
47
49
|
| how many results | `--limit N` (1–25 for search/multicity) |
|
|
50
|
+
| fare construction + Google Flights link | `--details` (search/multicity) |
|
|
51
|
+
|
|
52
|
+
## Fare construction & Google Flights link (`--details`)
|
|
53
|
+
|
|
54
|
+
`--details` (on `search` and `multicity`) opens the **top result's** itinerary
|
|
55
|
+
detail page and adds two things to the output:
|
|
56
|
+
|
|
57
|
+
- **Fare Construction** — the NUC fare-basis breakdown ITA labels "can be useful
|
|
58
|
+
to travel agents" (e.g. `BOS B6 LON 70.00OL8LBVL1 NUC 70.00 END ROE 1.00 XT
|
|
59
|
+
…`). Surface it verbatim; agents/ticketing desks read it directly.
|
|
60
|
+
- **Open in Google Flights** — the detail page's deep link
|
|
61
|
+
(`google.com/travel/flights?tfs=…&source=ita_matrix`) for the same itinerary.
|
|
62
|
+
|
|
63
|
+
It costs an extra page navigation and **bypasses the cache** (the link needs the
|
|
64
|
+
live solution session), so only pass it when the user wants the fare detail or a
|
|
65
|
+
hand-off link. Detail is best-effort: if the page can't be opened the search
|
|
66
|
+
results still return, just without the `details` block. In JSON it's a top-level
|
|
67
|
+
`details: { fareConstruction, googleFlightsUrl }`; in table output it's a footer
|
|
68
|
+
under the results. **Show both to the user when present.**
|
|
48
69
|
|
|
49
70
|
## Encode advanced intent → routing / extension codes
|
|
50
71
|
|
|
@@ -62,6 +83,17 @@ grammar before constructing codes. Rules of thumb:
|
|
|
62
83
|
- The `f` faring grammar is community-documented, not official — let Matrix
|
|
63
84
|
validate. Don't over-restrict locally.
|
|
64
85
|
|
|
86
|
+
### High-leverage patterns (see the reference doc's "Power-user strategies")
|
|
87
|
+
|
|
88
|
+
- **Force a stopover/overnight** — Matrix has no stopover command; fake it with a
|
|
89
|
+
big minimum connection. "Overnight in Tokyo" → `--routing X:NRT --ext "MINCONNECT 12:00"`;
|
|
90
|
+
24 h+ stopover → `MINCONNECT 30:00`.
|
|
91
|
+
- **Mileage credit / specific metal** — force operating carrier in `--routing` (`O:LH+`)
|
|
92
|
+
and the crediting program via `--ext "ALLIANCE star-alliance"`.
|
|
93
|
+
- **Upgrade/earning fare buckets** — pin booking class with `--ext "f bc=w|bc=v"`.
|
|
94
|
+
- **Time format gotcha**: `--ext` uses `h:mm` (`MINCONNECT 12:00`); the inline
|
|
95
|
+
`--routing / minconnect 720` form uses raw minutes. Prefer `--ext`.
|
|
96
|
+
|
|
65
97
|
## Workflow
|
|
66
98
|
|
|
67
99
|
1. Parse the request: route(s), dates, travelers, and any constraints.
|
|
@@ -109,6 +141,7 @@ Search one-way or round-trip flights
|
|
|
109
141
|
| `--carriers <list>` | comma-separated carriers, e.g. UA,AA (sugar for --routing) |
|
|
110
142
|
| `--routing <codes>` | ITA routing codes (path) — see docs/ROUTING_CODES.md |
|
|
111
143
|
| `--ext <codes>` | ITA extension codes (faring/filters) — see docs/ROUTING_CODES.md |
|
|
144
|
+
| `--details` | also fetch the top result's fare construction + Google Flights link (live, skips cache) |
|
|
112
145
|
| `--headful` | show the browser window (debug) |
|
|
113
146
|
|
|
114
147
|
### `multicity`
|
|
@@ -126,6 +159,7 @@ Search a multi-city itinerary (N legs)
|
|
|
126
159
|
| `--carriers <list>` | comma-separated carriers, e.g. UA,AA (sugar for --routing) |
|
|
127
160
|
| `--routing <codes>` | ITA routing codes applied to every leg |
|
|
128
161
|
| `--ext <codes>` | ITA extension codes applied to every leg |
|
|
162
|
+
| `--details` | also fetch the top result's fare construction + Google Flights link (live, skips cache) |
|
|
129
163
|
| `--headful` | show the browser window (debug) |
|
|
130
164
|
|
|
131
165
|
### `calendar <origin> <dest>`
|
|
@@ -165,9 +199,23 @@ itamatrix --json multicity \
|
|
|
165
199
|
--ext "-OVERNIGHTS"
|
|
166
200
|
```
|
|
167
201
|
|
|
202
|
+
> "Boston to Singapore in October, but I want a long overnight stopover in Tokyo."
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
itamatrix --json search BOS SIN --depart 2026-10-10 \
|
|
206
|
+
--routing "X:NRT" --ext "MINCONNECT 12:00; MAXCONNECT 30:00"
|
|
207
|
+
```
|
|
208
|
+
|
|
168
209
|
> "What's the cheapest week in August to do a 7-night trip BOS→LAX on United?"
|
|
169
210
|
|
|
170
211
|
```bash
|
|
171
212
|
itamatrix --json calendar BOS LAX \
|
|
172
213
|
--depart-range 2026-08-01:2026-08-31 --trip-length 7 --carriers UA
|
|
173
214
|
```
|
|
215
|
+
|
|
216
|
+
> "Cheapest BOS→LON next August, and give me the fare construction and a Google Flights link for the top one."
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
itamatrix --json search BOS LON --depart 2026-08-15 --details
|
|
220
|
+
# → result.details.fareConstruction[] + result.details.googleFlightsUrl
|
|
221
|
+
```
|