itamatrix 1.0.3 → 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.
@@ -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
  }
@@ -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 response = await withCache("multicity", spec, resolveCacheOptions(opts), () => runMultiCity(spec, { headful: opts.headful }));
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) {
@@ -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 response = await withCache("search", spec, resolveCacheOptions(opts), () => runSearch(spec, { headful: opts.headful }));
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");
@@ -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}"`);
@@ -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
@@ -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,
@@ -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
- return `${header}\n${table.toString()}`;
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "itamatrix",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "CLI for ITA Matrix flight search",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,25 @@ flag can't express it.
47
47
  | "only UA and AA" (simple) | `--carriers UA,AA` |
48
48
  | number of travelers | `--adults N` |
49
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.**
50
69
 
51
70
  ## Encode advanced intent → routing / extension codes
52
71
 
@@ -122,6 +141,7 @@ Search one-way or round-trip flights
122
141
  | `--carriers <list>` | comma-separated carriers, e.g. UA,AA (sugar for --routing) |
123
142
  | `--routing <codes>` | ITA routing codes (path) — see docs/ROUTING_CODES.md |
124
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) |
125
145
  | `--headful` | show the browser window (debug) |
126
146
 
127
147
  ### `multicity`
@@ -139,6 +159,7 @@ Search a multi-city itinerary (N legs)
139
159
  | `--carriers <list>` | comma-separated carriers, e.g. UA,AA (sugar for --routing) |
140
160
  | `--routing <codes>` | ITA routing codes applied to every leg |
141
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) |
142
163
  | `--headful` | show the browser window (debug) |
143
164
 
144
165
  ### `calendar <origin> <dest>`
@@ -191,3 +212,10 @@ itamatrix --json search BOS SIN --depart 2026-10-10 \
191
212
  itamatrix --json calendar BOS LAX \
192
213
  --depart-range 2026-08-01:2026-08-31 --trip-length 7 --carriers UA
193
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
+ ```