imprint-mcp 0.2.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 (97) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/examples/discoverandgo/README.md +57 -0
  5. package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
  6. package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
  7. package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
  8. package/examples/echo/README.md +37 -0
  9. package/examples/echo/echo_test/index.ts +31 -0
  10. package/examples/google-flights/search_google_flights/index.ts +101 -0
  11. package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
  12. package/examples/google-flights/search_google_flights/parser.ts +189 -0
  13. package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
  14. package/examples/google-flights/search_google_flights/workflow.json +48 -0
  15. package/examples/google-hotels/search_google_hotels/index.ts +194 -0
  16. package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
  17. package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
  18. package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
  19. package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
  20. package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
  21. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
  22. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
  23. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
  24. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
  25. package/examples/southwest/README.md +81 -0
  26. package/examples/southwest/search_southwest_flights/backends.json +23 -0
  27. package/examples/southwest/search_southwest_flights/cron.json +19 -0
  28. package/examples/southwest/search_southwest_flights/index.ts +110 -0
  29. package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
  30. package/examples/southwest/search_southwest_flights/workflow.json +54 -0
  31. package/package.json +78 -0
  32. package/prompts/compile-agent.md +580 -0
  33. package/prompts/intent-detection.md +198 -0
  34. package/prompts/playbook-compilation.md +279 -0
  35. package/prompts/request-triage.md +74 -0
  36. package/prompts/tool-candidate-detection.md +104 -0
  37. package/src/cli.ts +1287 -0
  38. package/src/imprint/agent.ts +468 -0
  39. package/src/imprint/app-api-hosts.ts +53 -0
  40. package/src/imprint/backend-ladder.ts +568 -0
  41. package/src/imprint/check.ts +136 -0
  42. package/src/imprint/chromium.ts +211 -0
  43. package/src/imprint/claude-cli-compile.ts +640 -0
  44. package/src/imprint/cli-credential.ts +394 -0
  45. package/src/imprint/codex-cli-compile.ts +712 -0
  46. package/src/imprint/compile-agent-types.ts +40 -0
  47. package/src/imprint/compile-agent.ts +404 -0
  48. package/src/imprint/compile-tools.ts +1389 -0
  49. package/src/imprint/compile.ts +720 -0
  50. package/src/imprint/cookie-jar.ts +246 -0
  51. package/src/imprint/credential-bundle.ts +195 -0
  52. package/src/imprint/credential-extract.ts +290 -0
  53. package/src/imprint/credential-store.ts +707 -0
  54. package/src/imprint/cron.ts +312 -0
  55. package/src/imprint/doctor.ts +223 -0
  56. package/src/imprint/emit.ts +154 -0
  57. package/src/imprint/etld.ts +134 -0
  58. package/src/imprint/freeform-redact.ts +216 -0
  59. package/src/imprint/inject-listener.ts +137 -0
  60. package/src/imprint/install.ts +795 -0
  61. package/src/imprint/integrations.ts +385 -0
  62. package/src/imprint/is-compiled.ts +2 -0
  63. package/src/imprint/json-path.ts +100 -0
  64. package/src/imprint/llm.ts +998 -0
  65. package/src/imprint/load-json.ts +54 -0
  66. package/src/imprint/log.ts +33 -0
  67. package/src/imprint/login.ts +166 -0
  68. package/src/imprint/mcp-compile-server.ts +282 -0
  69. package/src/imprint/mcp-maintenance.ts +1790 -0
  70. package/src/imprint/mcp-server.ts +350 -0
  71. package/src/imprint/multi-progress.ts +69 -0
  72. package/src/imprint/notify.ts +155 -0
  73. package/src/imprint/paths.ts +64 -0
  74. package/src/imprint/playbook-parser.ts +21 -0
  75. package/src/imprint/playbook-runner.ts +465 -0
  76. package/src/imprint/probe-backends.ts +251 -0
  77. package/src/imprint/progress.ts +28 -0
  78. package/src/imprint/record.ts +470 -0
  79. package/src/imprint/redact.ts +550 -0
  80. package/src/imprint/replay-capture.ts +387 -0
  81. package/src/imprint/request-context.ts +66 -0
  82. package/src/imprint/runtime-link.ts +73 -0
  83. package/src/imprint/runtime.ts +942 -0
  84. package/src/imprint/sensitive-keys.ts +156 -0
  85. package/src/imprint/session-diff.ts +409 -0
  86. package/src/imprint/session-merge.ts +198 -0
  87. package/src/imprint/session-writer.ts +149 -0
  88. package/src/imprint/sites.ts +27 -0
  89. package/src/imprint/stealth-fetch.ts +434 -0
  90. package/src/imprint/teach-state.ts +235 -0
  91. package/src/imprint/teach.ts +2120 -0
  92. package/src/imprint/tool-candidates.ts +423 -0
  93. package/src/imprint/tool-loader.ts +186 -0
  94. package/src/imprint/tool-selection.ts +70 -0
  95. package/src/imprint/tracing.ts +508 -0
  96. package/src/imprint/types.ts +472 -0
  97. package/src/imprint/version.ts +21 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * GENERATED by `imprint emit` — DO NOT EDIT BY HAND.
3
+ *
4
+ * Tool: book_discoverandgo_museum_pass
5
+ * Site: discoverandgo
6
+ * Intent: Reserve a museum pass on Discover & Go for a specific offer and date using a San Diego Public Library card.
7
+ *
8
+ * To regenerate: imprint emit ~/.imprint/discoverandgo/book_discoverandgo_museum_pass/workflow.json --force
9
+ */
10
+
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, join } from 'node:path';
13
+ import {
14
+ executeWorkflow,
15
+ type CredentialStore,
16
+ } from 'imprint/runtime';
17
+ import type { ToolResult, Workflow } from 'imprint/types';
18
+
19
+ const WORKFLOW: Workflow = {
20
+ "toolName": "book_discoverandgo_museum_pass",
21
+ "intent": {
22
+ "description": "Reserve a museum pass on Discover & Go for a specific offer and date using a San Diego Public Library card.",
23
+ "userSaid": "clicked the cooley museum attraction and then clicked the option to view additional dates; selected may 26 and confirmaqtion dialog came up again; i confirmed it and it took me to my list of reservations"
24
+ },
25
+ "parameters": [
26
+ {
27
+ "name": "offer_id",
28
+ "type": "number",
29
+ "description": "The numeric ID of the specific museum offer to reserve (e.g. 1175 for J.A. Cooley Museum Family Pass)"
30
+ },
31
+ {
32
+ "name": "offer_date",
33
+ "type": "string",
34
+ "description": "The date for the reservation in YYYY-MM-DD format (e.g. 2026-05-26)"
35
+ },
36
+ {
37
+ "name": "notification_email",
38
+ "type": "string",
39
+ "description": "Email address to send the reservation confirmation to"
40
+ }
41
+ ],
42
+ "requests": [
43
+ {
44
+ "method": "GET",
45
+ "url": "https://sandiego.discoverandgo.net/epass_server.php?dataType=json&method=makeReservation&functionFile=Reservations%2CAttractions&language=en&patronID=${credential.patron_id}&offerID=${param.offer_id}&offerDate=${param.offer_date}&notificationMethod=Email&notificationEmail=${param.notification_email}&notificationTXTNumber=",
46
+ "headers": {
47
+ "x-epass-clientID": "1",
48
+ "x-epass-clientKey": "335e26134a53d4e23e4bed13517b7303",
49
+ "x-epass-libraryID": "63",
50
+ "x-epass-patronID": "${credential.patron_id}",
51
+ "X-Requested-With": "XMLHttpRequest",
52
+ "Accept": "application/json, text/javascript, */*; q=0.01"
53
+ }
54
+ }
55
+ ],
56
+ "site": "discoverandgo"
57
+ };
58
+
59
+ export interface BookDiscoverandgoMuseumPassInput {
60
+ /** The numeric ID of the specific museum offer to reserve (e.g. 1175 for J.A. Cooley Museum Family Pass) */
61
+ offer_id: number;
62
+ /** The date for the reservation in YYYY-MM-DD format (e.g. 2026-05-26) */
63
+ offer_date: string;
64
+ /** Email address to send the reservation confirmation to */
65
+ notification_email: string;
66
+ }
67
+
68
+ export async function bookDiscoverandgoMuseumPass(
69
+ input: BookDiscoverandgoMuseumPassInput,
70
+ opts: { credentials?: CredentialStore; fetchImpl?: typeof fetch; initialState?: Record<string, unknown> } = {},
71
+ ): Promise<ToolResult> {
72
+ const __dirname = dirname(fileURLToPath(import.meta.url));
73
+ const params: Record<string, string | number | boolean> = {
74
+ offer_id: input.offer_id,
75
+ offer_date: input.offer_date,
76
+ notification_email: input.notification_email,
77
+
78
+ };
79
+ return executeWorkflow({
80
+ workflow: WORKFLOW,
81
+ params,
82
+ credentials: opts.credentials,
83
+ fetchImpl: opts.fetchImpl,
84
+ initialState: opts.initialState,
85
+ workflowPath: join(__dirname, 'workflow.json'),
86
+ });
87
+ }
88
+
89
+ export { WORKFLOW };
@@ -0,0 +1,39 @@
1
+ {
2
+ "toolName": "book_discoverandgo_museum_pass",
3
+ "intent": {
4
+ "description": "Reserve a museum pass on Discover & Go for a specific offer and date using a San Diego Public Library card.",
5
+ "userSaid": "clicked the cooley museum attraction and then clicked the option to view additional dates; selected may 26 and confirmaqtion dialog came up again; i confirmed it and it took me to my list of reservations"
6
+ },
7
+ "parameters": [
8
+ {
9
+ "name": "offer_id",
10
+ "type": "number",
11
+ "description": "The numeric ID of the specific museum offer to reserve (e.g. 1175 for J.A. Cooley Museum Family Pass)"
12
+ },
13
+ {
14
+ "name": "offer_date",
15
+ "type": "string",
16
+ "description": "The date for the reservation in YYYY-MM-DD format (e.g. 2026-05-26)"
17
+ },
18
+ {
19
+ "name": "notification_email",
20
+ "type": "string",
21
+ "description": "Email address to send the reservation confirmation to"
22
+ }
23
+ ],
24
+ "requests": [
25
+ {
26
+ "method": "GET",
27
+ "url": "https://sandiego.discoverandgo.net/epass_server.php?dataType=json&method=makeReservation&functionFile=Reservations%2CAttractions&language=en&patronID=${credential.patron_id}&offerID=${param.offer_id}&offerDate=${param.offer_date}&notificationMethod=Email&notificationEmail=${param.notification_email}&notificationTXTNumber=",
28
+ "headers": {
29
+ "x-epass-clientID": "1",
30
+ "x-epass-clientKey": "335e26134a53d4e23e4bed13517b7303",
31
+ "x-epass-libraryID": "63",
32
+ "x-epass-patronID": "${credential.patron_id}",
33
+ "X-Requested-With": "XMLHttpRequest",
34
+ "Accept": "application/json, text/javascript, */*; q=0.01"
35
+ }
36
+ }
37
+ ],
38
+ "site": "discoverandgo"
39
+ }
@@ -0,0 +1,37 @@
1
+ # Echo — MCP smoke-test fixture
2
+
3
+ > A network-free MCP tool used by the smoke-test scripts. Not a real demo — exists so you can verify your MCP wiring works without needing outbound HTTPS or a recorded session.
4
+
5
+ ## What this shows off
6
+
7
+ - The minimum viable shape of a generated tool: `WORKFLOW` constant + a `camelCase(toolName)` async function returning `ToolResult`.
8
+ - Useful when debugging Claude Desktop / mcp-inspector wire-up — if `echo_test` doesn't show up in the tools panel, your MCP config is wrong (not your network).
9
+
10
+ ## Run it
11
+
12
+ ```bash
13
+ # Inspect via mcp-inspector (recommended for debugging)
14
+ npx @modelcontextprotocol/inspector imprint mcp-server --site echo
15
+
16
+ # Or run the included client smoke test
17
+ bun scripts/mcp-client-test.ts
18
+ ```
19
+
20
+ ## What you should see
21
+
22
+ ```
23
+ [imprint mcp] registered echo_test (echo) — 1 param(s)
24
+ [imprint mcp] stdio transport ready (1 tool)
25
+ ```
26
+
27
+ The mcp-inspector UI lists `echo_test`. Calling it with `{"message": "hi"}` returns `{"echoed":"hi","ts":"..."}`.
28
+
29
+ ## Files
30
+
31
+ | File | What |
32
+ |---|---|
33
+ | `echo_test/index.ts` | The complete tool — no `workflow.json`, no recording, no compile pipeline. Pure code. |
34
+
35
+ ## Why this exists
36
+
37
+ Recording a session, redacting it, calling the LLM, emitting code — that's the happy path. When the happy path is broken, you want a fixture that strips every variable except "is the MCP wiring correct." Echo is that fixture.
@@ -0,0 +1,31 @@
1
+ import type { ToolResult, Workflow } from '../../../src/imprint/types.ts';
2
+
3
+ /**
4
+ * Echo workflow — a network-free fixture for MCP smoke tests. Exercises the
5
+ * full handler path (async tool with awaits, JSON-encoded result) without
6
+ * depending on outbound HTTPS, which the stdio client transport strips env
7
+ * for and may fail in restrictive environments (corporate MITM, no
8
+ * NODE_EXTRA_CA_CERTS passthrough).
9
+ */
10
+ export const WORKFLOW: Workflow = {
11
+ toolName: 'echo_test',
12
+ intent: { description: 'Echo back a message after a tiny async tick.' },
13
+ parameters: [
14
+ {
15
+ name: 'message',
16
+ type: 'string',
17
+ description: 'The text to echo back.',
18
+ },
19
+ ],
20
+ requests: [],
21
+ site: 'echo',
22
+ };
23
+
24
+ export async function echoTest(input: { message: string }): Promise<ToolResult> {
25
+ process.stderr.write(`[echo] received: ${input.message}\n`);
26
+ // Force a real microtask + macrotask boundary so we'd notice if the
27
+ // transport closed mid-handler (the original fastmcp/bun failure mode).
28
+ await Promise.resolve();
29
+ await new Promise((r) => setTimeout(r, 5));
30
+ return { ok: true, data: { echoed: input.message, ts: new Date().toISOString() } };
31
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * GENERATED by `imprint emit` — DO NOT EDIT BY HAND.
3
+ *
4
+ * Tool: search_google_flights
5
+ * Site: google-flights
6
+ * Intent: Search Google Flights for round-trip flights between two airports on specific dates.
7
+ *
8
+ * To regenerate: imprint emit ~/.imprint/google-flights/search_google_flights/workflow.json --force
9
+ */
10
+
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, join } from 'node:path';
13
+ import {
14
+ executeWorkflow,
15
+ type CredentialStore,
16
+ } from 'imprint/runtime';
17
+ import type { ToolResult, Workflow } from 'imprint/types';
18
+
19
+ const WORKFLOW: Workflow = {
20
+ "toolName": "search_google_flights",
21
+ "intent": {
22
+ "description": "Search Google Flights for round-trip flights between two airports on specific dates.",
23
+ "userSaid": "i searched for a sample flight the default mode is economy (include basic) now i changed to economy (exclude basic) now i searched for premium economy now i searched for business now i searched for first class now im going to start messing with all the filters available i played with all the filters now i clicked one of the departure flights, and now it's showing me a list of the return flights i selected the return flight, and now it's showing me the booking options"
24
+ },
25
+ "parameters": [
26
+ {
27
+ "name": "origin_airport",
28
+ "type": "string",
29
+ "description": "Origin airport IATA code.",
30
+ "default": "SJC"
31
+ },
32
+ {
33
+ "name": "destination_airport",
34
+ "type": "string",
35
+ "description": "Destination airport IATA code.",
36
+ "default": "SAN"
37
+ },
38
+ {
39
+ "name": "depart_date",
40
+ "type": "string",
41
+ "description": "Outbound departure date in YYYY-MM-DD format.",
42
+ "default": "2026-07-10"
43
+ },
44
+ {
45
+ "name": "return_date",
46
+ "type": "string",
47
+ "description": "Return departure date in YYYY-MM-DD format.",
48
+ "default": "2026-07-16"
49
+ }
50
+ ],
51
+ "requests": [
52
+ {
53
+ "method": "POST",
54
+ "url": "https://www.google.com/_/FlightsFrontendUi/data/travel.frontend.flights.FlightsFrontendService/GetShoppingResults?f.sid=-1290970187393889182&bl=boq_travel-frontend-flights-ui_20260506.02_p0&hl=en-US&soc-app=162&soc-platform=1&soc-device=1&_reqid=4008662&rt=c",
55
+ "headers": {
56
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
57
+ "Referer": "https://www.google.com/travel/flights",
58
+ "X-Same-Domain": "1",
59
+ "x-goog-ext-259736195-jspb": "[\"en-US\",\"US\",\"USD\",2,null,[420],null,null,7,[]]"
60
+ },
61
+ "body": "f.req=%5Bnull%2C%22%5B%5B%5D%2C%5Bnull%2Cnull%2C1%2Cnull%2C%5B%5D%2C1%2C%5B1%2C0%2C0%2C0%5D%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2C%5B%5B%5B%5B%5B%5C%22${param.origin_airport}%5C%22%2C0%5D%5D%5D%2C%5B%5B%5B%5C%22${param.destination_airport}%5C%22%2C0%5D%5D%5D%2Cnull%2C0%2Cnull%2Cnull%2C%5C%22${param.depart_date}%5C%22%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2C690%2Cnull%2C3%5D%2C%5B%5B%5B%5B%5C%22${param.destination_airport}%5C%22%2C0%5D%5D%5D%2C%5B%5B%5B%5C%22${param.origin_airport}%5C%22%2C0%5D%5D%5D%2Cnull%2C0%2Cnull%2Cnull%2C%5C%22${param.return_date}%5C%22%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2C690%2Cnull%2C3%5D%5D%2Cnull%2Cnull%2Cnull%2C1%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2C0%5D%2C0%2C0%2C0%2C1%5D%22%5D&"
62
+ }
63
+ ],
64
+ "site": "google-flights",
65
+ "parserModule": "./parser.ts"
66
+ };
67
+
68
+ export interface SearchGoogleFlightsInput {
69
+ /** Origin airport IATA code. */
70
+ origin_airport?: string;
71
+ /** Destination airport IATA code. */
72
+ destination_airport?: string;
73
+ /** Outbound departure date in YYYY-MM-DD format. */
74
+ depart_date?: string;
75
+ /** Return departure date in YYYY-MM-DD format. */
76
+ return_date?: string;
77
+ }
78
+
79
+ export async function searchGoogleFlights(
80
+ input: SearchGoogleFlightsInput,
81
+ opts: { credentials?: CredentialStore; fetchImpl?: typeof fetch; initialState?: Record<string, unknown> } = {},
82
+ ): Promise<ToolResult> {
83
+ const __dirname = dirname(fileURLToPath(import.meta.url));
84
+ const params: Record<string, string | number | boolean> = {
85
+ origin_airport: input.origin_airport ?? "SJC",
86
+ destination_airport: input.destination_airport ?? "SAN",
87
+ depart_date: input.depart_date ?? "2026-07-10",
88
+ return_date: input.return_date ?? "2026-07-16",
89
+
90
+ };
91
+ return executeWorkflow({
92
+ workflow: WORKFLOW,
93
+ params,
94
+ credentials: opts.credentials,
95
+ fetchImpl: opts.fetchImpl,
96
+ initialState: opts.initialState,
97
+ workflowPath: join(__dirname, 'workflow.json'),
98
+ });
99
+ }
100
+
101
+ export { WORKFLOW };
@@ -0,0 +1,140 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { extract } from './parser';
5
+
6
+ const fixtureResponse = readFileSync(
7
+ join(
8
+ import.meta.dir,
9
+ '../../../test/fixtures/examples/google-flights/search_google_flights/fixture_response.txt',
10
+ ),
11
+ 'utf-8'
12
+ );
13
+
14
+ describe('Google Flights parser', () => {
15
+ test('extract returns an object with flights array', () => {
16
+ const result = extract(fixtureResponse) as any;
17
+ expect(result).toBeDefined();
18
+ expect(result.flights).toBeDefined();
19
+ expect(Array.isArray(result.flights)).toBe(true);
20
+ });
21
+
22
+ test('returns multiple flights (SFO to Tokyo search)', () => {
23
+ const result = extract(fixtureResponse) as any;
24
+ expect(result.flights.length).toBeGreaterThan(0);
25
+ expect(result.totalCount).toBeGreaterThan(0);
26
+ expect(result.flights.length).toBe(result.totalCount);
27
+ });
28
+
29
+ test('flights originate from SFO (San Francisco)', () => {
30
+ const result = extract(fixtureResponse) as any;
31
+ const sfFlights = result.flights.filter((f: any) => f.origin === 'SFO');
32
+ expect(sfFlights.length).toBeGreaterThan(0);
33
+ // All flights should be from SFO
34
+ expect(result.flights.every((f: any) => f.origin === 'SFO')).toBe(true);
35
+ });
36
+
37
+ test('flights go to Tokyo airports (NRT or HND)', () => {
38
+ const result = extract(fixtureResponse) as any;
39
+ const tokyoFlights = result.flights.filter(
40
+ (f: any) => f.destination === 'NRT' || f.destination === 'HND'
41
+ );
42
+ expect(tokyoFlights.length).toBeGreaterThan(0);
43
+ // All flights should go to Tokyo area airports
44
+ expect(result.flights.every((f: any) => f.destination === 'NRT' || f.destination === 'HND')).toBe(true);
45
+ });
46
+
47
+ test('flights have valid prices in USD', () => {
48
+ const result = extract(fixtureResponse) as any;
49
+ const flightsWithPrice = result.flights.filter((f: any) => f.priceUsd !== null);
50
+ expect(flightsWithPrice.length).toBeGreaterThan(0);
51
+ // Prices should be reasonable for SFO-TYO (between $500 and $10000)
52
+ for (const flight of flightsWithPrice) {
53
+ expect(flight.priceUsd).toBeGreaterThan(500);
54
+ expect(flight.priceUsd).toBeLessThan(10000);
55
+ }
56
+ });
57
+
58
+ test('cheapest flight is ZIPAIR Tokyo at $1245', () => {
59
+ const result = extract(fixtureResponse) as any;
60
+ // Flights are sorted by price ascending
61
+ const cheapest = result.flights[0];
62
+ expect(cheapest.priceUsd).toBe(1245);
63
+ expect(cheapest.airlineCode).toBe('ZG');
64
+ expect(cheapest.airlineName).toBe('ZIPAIR Tokyo');
65
+ });
66
+
67
+ test('flights have valid departure dates in 2026', () => {
68
+ const result = extract(fixtureResponse) as any;
69
+ for (const flight of result.flights) {
70
+ expect(flight.departureDate).toMatch(/^2026-/);
71
+ expect(flight.departureDate).toBeTruthy();
72
+ }
73
+ });
74
+
75
+ test('flights have valid departure and arrival times', () => {
76
+ const result = extract(fixtureResponse) as any;
77
+ for (const flight of result.flights) {
78
+ expect(flight.departureTime).toMatch(/^\d{2}:\d{2}$/);
79
+ expect(flight.arrivalTime).toMatch(/^\d{2}:\d{2}$/);
80
+ }
81
+ });
82
+
83
+ test('flights have positive duration in minutes', () => {
84
+ const result = extract(fixtureResponse) as any;
85
+ for (const flight of result.flights) {
86
+ // SFO to Tokyo is roughly 10-28 hours
87
+ expect(flight.durationMinutes).toBeGreaterThan(600);
88
+ expect(flight.durationMinutes).toBeLessThan(2000);
89
+ }
90
+ });
91
+
92
+ test('flights have segment details', () => {
93
+ const result = extract(fixtureResponse) as any;
94
+ for (const flight of result.flights) {
95
+ expect(Array.isArray(flight.segments)).toBe(true);
96
+ expect(flight.segments.length).toBeGreaterThan(0);
97
+ const seg = flight.segments[0];
98
+ expect(seg.origin).toBe('SFO');
99
+ expect(seg.flightNumber).toBeTruthy();
100
+ expect(seg.aircraft).toBeTruthy();
101
+ }
102
+ });
103
+
104
+ test('includes known airlines: United, ANA, JAL', () => {
105
+ const result = extract(fixtureResponse) as any;
106
+ const airlineCodes = result.flights.map((f: any) => f.airlineCode);
107
+ expect(airlineCodes).toContain('UA'); // United
108
+ expect(airlineCodes).toContain('NH'); // ANA
109
+ expect(airlineCodes).toContain('JL'); // JAL
110
+ });
111
+
112
+ test('United flight to NRT costs $1809', () => {
113
+ const result = extract(fixtureResponse) as any;
114
+ const unitedNRT = result.flights.find(
115
+ (f: any) => f.airlineCode === 'UA' && f.destination === 'NRT'
116
+ );
117
+ expect(unitedNRT).toBeDefined();
118
+ expect(unitedNRT.priceUsd).toBe(1809);
119
+ expect(unitedNRT.durationMinutes).toBe(635);
120
+ });
121
+
122
+ test('flights have booking tokens', () => {
123
+ const result = extract(fixtureResponse) as any;
124
+ const flightsWithToken = result.flights.filter((f: any) => f.bookingToken !== null);
125
+ expect(flightsWithToken.length).toBeGreaterThan(0);
126
+ });
127
+
128
+ test('stops field is correctly parsed (0 for nonstop, 1+ for connecting)', () => {
129
+ const result = extract(fixtureResponse) as any;
130
+ // All flights in this response have at least 1 segment
131
+ for (const flight of result.flights) {
132
+ expect(flight.stopCount).toBeGreaterThanOrEqual(0);
133
+ }
134
+ // Asiana Airlines goes via ICN (1 stop)
135
+ const asiana = result.flights.find((f: any) => f.airlineCode === 'OZ');
136
+ if (asiana) {
137
+ expect(asiana.stopCount).toBeGreaterThan(0);
138
+ }
139
+ });
140
+ });
@@ -0,0 +1,189 @@
1
+ interface Segment {
2
+ origin: string;
3
+ originName: string;
4
+ destination: string;
5
+ destinationName: string;
6
+ departureDate: string;
7
+ departureTime: string;
8
+ arrivalDate: string;
9
+ arrivalTime: string;
10
+ durationMinutes: number;
11
+ airlineCode: string;
12
+ airlineName: string;
13
+ flightNumber: string;
14
+ aircraft: string;
15
+ cabinClass: string;
16
+ }
17
+
18
+ interface Flight {
19
+ airlineCode: string;
20
+ airlineName: string;
21
+ origin: string;
22
+ destination: string;
23
+ departureDate: string;
24
+ departureTime: string;
25
+ arrivalDate: string;
26
+ arrivalTime: string;
27
+ durationMinutes: number;
28
+ stopCount: number;
29
+ priceUsd: number | null;
30
+ bookingToken: string | null;
31
+ flightNumbers: string[];
32
+ segments: Segment[];
33
+ }
34
+
35
+ function formatDate(parts: unknown): string {
36
+ if (!Array.isArray(parts) || parts.length < 3) return '';
37
+ const [year, month, day] = parts as number[];
38
+ return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
39
+ }
40
+
41
+ function formatTime(parts: unknown): string {
42
+ if (!Array.isArray(parts) || parts.length === 0) return '';
43
+ const [hour = 0, minute = 0] = parts as number[];
44
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
45
+ }
46
+
47
+ function cabinClassLabel(code: unknown): string {
48
+ switch (code) {
49
+ case 1:
50
+ return 'economy';
51
+ case 2:
52
+ return 'premium_economy';
53
+ case 3:
54
+ return 'business';
55
+ case 4:
56
+ return 'first';
57
+ default:
58
+ return 'unknown';
59
+ }
60
+ }
61
+
62
+ function parseEnvelope(rawResponse: string): unknown[] | null {
63
+ const jsonLine = rawResponse
64
+ .split('\n')
65
+ .find((line) => line.startsWith('[[') || line.startsWith('[["'));
66
+
67
+ if (!jsonLine) return null;
68
+
69
+ let outer: unknown[];
70
+ try {
71
+ outer = JSON.parse(jsonLine) as unknown[];
72
+ } catch {
73
+ return null;
74
+ }
75
+
76
+ const innerString = (outer as unknown[][])?.[0]?.[2];
77
+ if (typeof innerString !== 'string') return null;
78
+
79
+ try {
80
+ return JSON.parse(innerString) as unknown[];
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function parseSegment(rawSegment: unknown): Segment | null {
87
+ if (!Array.isArray(rawSegment)) return null;
88
+
89
+ const flightInfo = rawSegment[22];
90
+ return {
91
+ origin: typeof rawSegment[3] === 'string' ? rawSegment[3] : '',
92
+ originName: typeof rawSegment[4] === 'string' ? rawSegment[4] : '',
93
+ destination: typeof rawSegment[6] === 'string' ? rawSegment[6] : '',
94
+ destinationName: typeof rawSegment[5] === 'string' ? rawSegment[5] : '',
95
+ departureDate: formatDate(rawSegment[20]),
96
+ departureTime: formatTime(rawSegment[8]),
97
+ arrivalDate: formatDate(rawSegment[21]),
98
+ arrivalTime: formatTime(rawSegment[10]),
99
+ durationMinutes: typeof rawSegment[11] === 'number' ? rawSegment[11] : 0,
100
+ airlineCode:
101
+ Array.isArray(flightInfo) && typeof flightInfo[0] === 'string' ? flightInfo[0] : '',
102
+ airlineName:
103
+ Array.isArray(flightInfo) && typeof flightInfo[3] === 'string' ? flightInfo[3] : '',
104
+ flightNumber:
105
+ Array.isArray(flightInfo) &&
106
+ typeof flightInfo[0] === 'string' &&
107
+ typeof flightInfo[1] === 'string'
108
+ ? `${flightInfo[0]}${flightInfo[1]}`
109
+ : '',
110
+ aircraft: typeof rawSegment[17] === 'string' ? rawSegment[17] : '',
111
+ cabinClass: cabinClassLabel(rawSegment[13]),
112
+ };
113
+ }
114
+
115
+ function parseFlight(rawOption: unknown): Flight | null {
116
+ if (!Array.isArray(rawOption) || rawOption.length < 2) return null;
117
+
118
+ const details = rawOption[0];
119
+ const priceInfo = rawOption[1];
120
+ if (!Array.isArray(details) || !Array.isArray(details[2])) return null;
121
+
122
+ const segments = details[2]
123
+ .map((segment) => parseSegment(segment))
124
+ .filter((segment): segment is Segment => segment !== null);
125
+
126
+ const firstPrice = Array.isArray(priceInfo) && Array.isArray(priceInfo[0]) ? priceInfo[0] : null;
127
+ const priceUsd = firstPrice && typeof firstPrice[1] === 'number' ? firstPrice[1] : null;
128
+ const bookingToken = Array.isArray(priceInfo) && typeof priceInfo[1] === 'string' ? priceInfo[1] : null;
129
+
130
+ return {
131
+ airlineCode: typeof details[0] === 'string' ? details[0] : '',
132
+ airlineName:
133
+ Array.isArray(details[1]) && typeof details[1][0] === 'string' ? details[1][0] : '',
134
+ origin: typeof details[3] === 'string' ? details[3] : '',
135
+ destination: typeof details[6] === 'string' ? details[6] : '',
136
+ departureDate: formatDate(details[4]),
137
+ departureTime: formatTime(details[5]),
138
+ arrivalDate: formatDate(details[7]),
139
+ arrivalTime: formatTime(details[8]),
140
+ durationMinutes: typeof details[9] === 'number' ? details[9] : 0,
141
+ stopCount: Math.max(segments.length - 1, 0),
142
+ priceUsd,
143
+ bookingToken,
144
+ flightNumbers: segments.map((segment) => segment.flightNumber).filter(Boolean),
145
+ segments,
146
+ };
147
+ }
148
+
149
+ function isFlightSection(section: unknown): section is [unknown[], ...unknown[]] {
150
+ return (
151
+ Array.isArray(section) &&
152
+ Array.isArray(section[0]) &&
153
+ section[0].length > 0 &&
154
+ Array.isArray(section[0][0]) &&
155
+ Array.isArray(section[0][0][0])
156
+ );
157
+ }
158
+
159
+ export function extract(rawResponse: unknown): unknown {
160
+ if (typeof rawResponse !== 'string') {
161
+ return { flights: [], totalCount: 0, error: 'Expected raw response string.' };
162
+ }
163
+
164
+ const envelope = parseEnvelope(rawResponse);
165
+ if (!envelope) {
166
+ return { flights: [], totalCount: 0, error: 'Unable to parse Google Flights response.' };
167
+ }
168
+
169
+ const flights: Flight[] = [];
170
+ for (const section of envelope) {
171
+ if (!isFlightSection(section)) continue;
172
+ for (const rawOption of section[0]) {
173
+ const flight = parseFlight(rawOption);
174
+ if (flight) flights.push(flight);
175
+ }
176
+ }
177
+
178
+ flights.sort((a, b) => {
179
+ if (a.priceUsd == null && b.priceUsd == null) return 0;
180
+ if (a.priceUsd == null) return 1;
181
+ if (b.priceUsd == null) return -1;
182
+ return a.priceUsd - b.priceUsd;
183
+ });
184
+
185
+ return {
186
+ flights,
187
+ totalCount: flights.length,
188
+ };
189
+ }