imprint-mcp 0.2.1 → 0.3.1

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 (130) hide show
  1. package/README.md +193 -189
  2. package/examples/discoverandgo/README.md +1 -1
  3. package/examples/echo/README.md +1 -1
  4. package/examples/google-flights/README.md +28 -0
  5. package/examples/google-flights/_shared/batchexecute.ts +63 -0
  6. package/examples/google-flights/_shared/flights_request.ts +95 -0
  7. package/examples/google-flights/_shared/package.json +9 -0
  8. package/examples/google-flights/get_flight_booking_details/index.ts +159 -0
  9. package/examples/google-flights/get_flight_booking_details/package.json +9 -0
  10. package/examples/google-flights/get_flight_booking_details/parser.ts +182 -0
  11. package/examples/google-flights/get_flight_booking_details/playbook.yaml +138 -0
  12. package/examples/google-flights/get_flight_booking_details/request-transform.ts +86 -0
  13. package/examples/google-flights/get_flight_booking_details/workflow.json +98 -0
  14. package/examples/google-flights/get_flight_calendar_prices/index.ts +131 -0
  15. package/examples/google-flights/get_flight_calendar_prices/package.json +9 -0
  16. package/examples/google-flights/get_flight_calendar_prices/parser.ts +86 -0
  17. package/examples/google-flights/get_flight_calendar_prices/playbook.yaml +97 -0
  18. package/examples/google-flights/get_flight_calendar_prices/request-transform.ts +31 -0
  19. package/examples/google-flights/get_flight_calendar_prices/workflow.json +78 -0
  20. package/examples/google-flights/lookup_airport/index.ts +101 -0
  21. package/examples/google-flights/lookup_airport/package.json +9 -0
  22. package/examples/google-flights/lookup_airport/parser.ts +66 -0
  23. package/examples/google-flights/lookup_airport/playbook.yaml +47 -0
  24. package/examples/google-flights/lookup_airport/request-transform.ts +20 -0
  25. package/examples/google-flights/lookup_airport/workflow.json +57 -0
  26. package/examples/google-flights/search_flights/index.ts +219 -0
  27. package/examples/google-flights/search_flights/package.json +9 -0
  28. package/examples/google-flights/search_flights/parser.ts +169 -0
  29. package/examples/google-flights/search_flights/playbook.yaml +184 -0
  30. package/examples/google-flights/search_flights/request-transform.ts +119 -0
  31. package/examples/google-flights/search_flights/workflow.json +143 -0
  32. package/examples/google-hotels/README.md +29 -0
  33. package/examples/google-hotels/_shared/batchexecute.ts +73 -0
  34. package/examples/google-hotels/_shared/freq.ts +158 -0
  35. package/examples/google-hotels/_shared/package.json +9 -0
  36. package/examples/google-hotels/autocomplete_hotel_location/index.ts +80 -0
  37. package/examples/google-hotels/autocomplete_hotel_location/package.json +9 -0
  38. package/examples/google-hotels/autocomplete_hotel_location/parser.ts +71 -0
  39. package/examples/google-hotels/autocomplete_hotel_location/playbook.yaml +36 -0
  40. package/examples/google-hotels/autocomplete_hotel_location/request-transform.ts +37 -0
  41. package/examples/google-hotels/autocomplete_hotel_location/workflow.json +36 -0
  42. package/examples/google-hotels/get_hotel_booking_options/index.ts +143 -0
  43. package/examples/google-hotels/get_hotel_booking_options/package.json +9 -0
  44. package/examples/google-hotels/get_hotel_booking_options/parser.ts +271 -0
  45. package/examples/google-hotels/get_hotel_booking_options/playbook.yaml +154 -0
  46. package/examples/google-hotels/get_hotel_booking_options/request-transform.ts +154 -0
  47. package/examples/google-hotels/get_hotel_booking_options/workflow.json +84 -0
  48. package/examples/google-hotels/get_hotel_reviews/index.ts +81 -0
  49. package/examples/google-hotels/get_hotel_reviews/package.json +9 -0
  50. package/examples/google-hotels/get_hotel_reviews/parser.ts +128 -0
  51. package/examples/google-hotels/get_hotel_reviews/playbook.yaml +64 -0
  52. package/examples/google-hotels/get_hotel_reviews/request-transform.ts +42 -0
  53. package/examples/google-hotels/get_hotel_reviews/workflow.json +37 -0
  54. package/examples/google-hotels/search_hotels/index.ts +207 -0
  55. package/examples/google-hotels/search_hotels/package.json +9 -0
  56. package/examples/google-hotels/search_hotels/parser.ts +260 -0
  57. package/examples/google-hotels/search_hotels/playbook.yaml +87 -0
  58. package/examples/google-hotels/search_hotels/request-transform.ts +197 -0
  59. package/examples/google-hotels/search_hotels/workflow.json +127 -0
  60. package/examples/southwest/README.md +3 -2
  61. package/examples/southwest/search_southwest_flights/index.ts +18 -1
  62. package/examples/southwest/search_southwest_flights/workflow.json +18 -1
  63. package/package.json +3 -2
  64. package/prompts/audit-agent.md +71 -0
  65. package/prompts/build-planning.md +74 -0
  66. package/prompts/compile-agent.md +131 -27
  67. package/prompts/prereq-builder.md +64 -0
  68. package/prompts/prereq-planner.md +34 -0
  69. package/prompts/tool-planning.md +39 -0
  70. package/src/cli.ts +116 -3
  71. package/src/imprint/agent.ts +5 -0
  72. package/src/imprint/audit.ts +996 -0
  73. package/src/imprint/backend-ladder.ts +1214 -184
  74. package/src/imprint/build-plan.ts +1051 -0
  75. package/src/imprint/cdp-browser-fetch.ts +592 -0
  76. package/src/imprint/cdp-jar-cache.ts +320 -0
  77. package/src/imprint/chromium.ts +414 -8
  78. package/src/imprint/claude-cli-compile.ts +125 -25
  79. package/src/imprint/codex-cli-compile.ts +26 -23
  80. package/src/imprint/compile-agent-types.ts +38 -0
  81. package/src/imprint/compile-agent.ts +63 -25
  82. package/src/imprint/compile-tools.ts +1666 -66
  83. package/src/imprint/compile.ts +13 -1
  84. package/src/imprint/concurrency.ts +87 -0
  85. package/src/imprint/cron.ts +4 -0
  86. package/src/imprint/doctor.ts +48 -3
  87. package/src/imprint/freeform-redact.ts +5 -4
  88. package/src/imprint/install.ts +79 -4
  89. package/src/imprint/integrations.ts +3 -3
  90. package/src/imprint/llm.ts +56 -8
  91. package/src/imprint/mcp-compile-server.ts +43 -10
  92. package/src/imprint/mcp-maintenance.ts +18 -102
  93. package/src/imprint/mcp-server.ts +73 -7
  94. package/src/imprint/multi-progress.ts +7 -2
  95. package/src/imprint/param-grounding.ts +367 -0
  96. package/src/imprint/paths.ts +29 -0
  97. package/src/imprint/playbook-runner.ts +101 -40
  98. package/src/imprint/prereq-builder.ts +651 -0
  99. package/src/imprint/probe-backends.ts +6 -3
  100. package/src/imprint/record.ts +10 -1
  101. package/src/imprint/redact.ts +30 -2
  102. package/src/imprint/replay-capture.ts +19 -18
  103. package/src/imprint/runtime.ts +19 -10
  104. package/src/imprint/session-diff.ts +79 -2
  105. package/src/imprint/session-merge.ts +9 -5
  106. package/src/imprint/stealth-chromium.ts +79 -0
  107. package/src/imprint/stealth-fetch.ts +309 -29
  108. package/src/imprint/stealth-token-cache.ts +88 -0
  109. package/src/imprint/teach-plan.ts +251 -0
  110. package/src/imprint/teach-state.ts +10 -0
  111. package/src/imprint/teach.ts +456 -142
  112. package/src/imprint/tool-candidates.ts +72 -14
  113. package/src/imprint/tool-plan.ts +313 -0
  114. package/src/imprint/tracing.ts +135 -6
  115. package/src/imprint/types.ts +61 -3
  116. package/examples/google-flights/search_google_flights/index.ts +0 -101
  117. package/examples/google-flights/search_google_flights/parser.test.ts +0 -140
  118. package/examples/google-flights/search_google_flights/parser.ts +0 -189
  119. package/examples/google-flights/search_google_flights/playbook.yaml +0 -130
  120. package/examples/google-flights/search_google_flights/workflow.json +0 -48
  121. package/examples/google-hotels/search_google_hotels/index.ts +0 -194
  122. package/examples/google-hotels/search_google_hotels/parser.test.ts +0 -168
  123. package/examples/google-hotels/search_google_hotels/parser.ts +0 -330
  124. package/examples/google-hotels/search_google_hotels/playbook.yaml +0 -125
  125. package/examples/google-hotels/search_google_hotels/workflow.json +0 -111
  126. package/examples/namecheap-domains/search_namecheap_domains/index.ts +0 -144
  127. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +0 -380
  128. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +0 -50
  129. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +0 -136
  130. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +0 -97
@@ -0,0 +1,260 @@
1
+ // Parser for google-hotels search_hotels (AtySUc batchexecute response).
2
+ // Decodes the anti-XSSI envelope via the shared helper, then walks the inner
3
+ // JSON collecting hotel entries (keyed "397419284"), the result-count/area row
4
+ // (keyed "416343588"), and recursively extracting per-hotel fields whose exact
5
+ // array positions drift between entries.
6
+ import { parseBatchExecute } from '../_shared/batchexecute.ts';
7
+
8
+ type Ctx = {
9
+ params?: Record<string, string | number | boolean>;
10
+ responses?: unknown[];
11
+ };
12
+
13
+ function isArr(v: unknown): v is unknown[] {
14
+ return Array.isArray(v);
15
+ }
16
+
17
+ // Depth-first: collect every object value stored under `key` anywhere in the tree.
18
+ function collectByKey(node: unknown, key: string, out: unknown[]): void {
19
+ if (isArr(node)) {
20
+ for (const child of node) collectByKey(child, key, out);
21
+ } else if (node && typeof node === 'object') {
22
+ const obj = node as Record<string, unknown>;
23
+ for (const k of Object.keys(obj)) {
24
+ if (k === key) out.push(obj[k]);
25
+ collectByKey(obj[k], key, out);
26
+ }
27
+ }
28
+ }
29
+
30
+ // Recursively find the first node satisfying `pred`.
31
+ function findFirst(node: unknown, pred: (n: unknown) => boolean): unknown {
32
+ if (pred(node)) return node;
33
+ if (isArr(node)) {
34
+ for (const child of node) {
35
+ const hit = findFirst(child, pred);
36
+ if (hit !== undefined) return hit;
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function findHotelId(entry: unknown): string | null {
43
+ const hit = findFirst(
44
+ entry,
45
+ (n) => typeof n === 'string' && /^Ch[a-zA-Z]I[A-Za-z0-9_-]{8,}$/.test(n),
46
+ );
47
+ return typeof hit === 'string' ? hit : null;
48
+ }
49
+
50
+ function findLatLng(entry: unknown): { lat: number; lng: number } | null {
51
+ const hit = findFirst(
52
+ entry,
53
+ (n) =>
54
+ isArr(n) &&
55
+ n.length === 2 &&
56
+ typeof n[0] === 'number' &&
57
+ typeof n[1] === 'number' &&
58
+ Math.abs(n[0]) <= 90 &&
59
+ Math.abs(n[1]) <= 180 &&
60
+ // exclude small integer pairs like [12,18]; coords have decimals
61
+ (!Number.isInteger(n[0]) || !Number.isInteger(n[1])),
62
+ ) as number[] | undefined;
63
+ return hit ? { lat: hit[0]!, lng: hit[1]! } : null;
64
+ }
65
+
66
+ // Overall rating block looks like [[4,"4.6"],[1,"4.8"],...] (4 = overall).
67
+ function findRating(entry: unknown): { overall: number | null; categories: Record<string, number> } {
68
+ const block = findFirst(entry, (n) => {
69
+ if (!isArr(n) || n.length === 0) return false;
70
+ return n.every(
71
+ (p) =>
72
+ isArr(p) &&
73
+ p.length === 2 &&
74
+ typeof p[0] === 'number' &&
75
+ typeof p[1] === 'string' &&
76
+ /^\d+(\.\d+)?$/.test(p[1]),
77
+ );
78
+ }) as Array<[number, string]> | undefined;
79
+ const categories: Record<string, number> = {};
80
+ let overall: number | null = null;
81
+ if (block) {
82
+ const labels: Record<number, string> = {
83
+ 4: 'overall',
84
+ 1: 'location',
85
+ 5: 'rooms',
86
+ 2: 'service',
87
+ 3: 'value',
88
+ };
89
+ for (const [code, val] of block) {
90
+ const f = Number(val);
91
+ if (code === 4) overall = f;
92
+ categories[labels[code] ?? String(code)] = f;
93
+ }
94
+ }
95
+ return { overall, categories };
96
+ }
97
+
98
+ function findCheckInOut(entry: unknown): { checkIn: string | null; checkOut: string | null } {
99
+ const hit = findFirst(
100
+ entry,
101
+ (n) =>
102
+ isArr(n) &&
103
+ n.length === 2 &&
104
+ typeof n[0] === 'string' &&
105
+ typeof n[1] === 'string' &&
106
+ /\d{1,2}:\d{2}\s?(AM|PM)/i.test(n[0]) &&
107
+ /\d{1,2}:\d{2}\s?(AM|PM)/i.test(n[1]),
108
+ ) as string[] | undefined;
109
+ return hit ? { checkIn: hit[0]!, checkOut: hit[1]! } : { checkIn: null, checkOut: null };
110
+ }
111
+
112
+ // Price block: ["$98","$115",98,null,98] -> display + numeric.
113
+ function findPrice(entry: unknown): { display: string | null; amount: number | null } {
114
+ const hit = findFirst(
115
+ entry,
116
+ (n) =>
117
+ isArr(n) &&
118
+ n.length >= 3 &&
119
+ typeof n[0] === 'string' &&
120
+ n[0].startsWith('$') &&
121
+ typeof n[2] === 'number',
122
+ ) as unknown[] | undefined;
123
+ return hit
124
+ ? { display: hit[0] as string, amount: hit[2] as number }
125
+ : { display: null, amount: null };
126
+ }
127
+
128
+ function findStarClass(entry: unknown): string | null {
129
+ const hit = findFirst(
130
+ entry,
131
+ (n) =>
132
+ isArr(n) &&
133
+ n.length === 2 &&
134
+ typeof n[0] === 'string' &&
135
+ /-star (hotel|property)|star hotel|hotel$/i.test(n[0]) &&
136
+ typeof n[1] === 'number',
137
+ ) as unknown[] | undefined;
138
+ return hit ? (hit[0] as string) : null;
139
+ }
140
+
141
+ function findAreaMid(entry: unknown): string | null {
142
+ const hit = findFirst(
143
+ entry,
144
+ (n) => typeof n === 'string' && /^\/m\/[a-z0-9_]+$/i.test(n),
145
+ );
146
+ return typeof hit === 'string' ? hit : null;
147
+ }
148
+
149
+ // Nearby POIs: [[1,[1,null,[["Grant Park",null,[[2,"6 min"]]]]]],...]
150
+ function findPois(entry: unknown): string[] {
151
+ const names: string[] = [];
152
+ function walk(n: unknown): void {
153
+ if (isArr(n)) {
154
+ // a POI tuple ["Name", null, [[code,"X min"]], ...]
155
+ if (
156
+ typeof n[0] === 'string' &&
157
+ n[0].length > 1 &&
158
+ isArr(n[2]) &&
159
+ n[2].some(
160
+ (d) => isArr(d) && typeof d[1] === 'string' && /\bmin\b|\bhr\b/.test(d[1]),
161
+ )
162
+ ) {
163
+ if (!names.includes(n[0])) names.push(n[0]);
164
+ }
165
+ for (const c of n) walk(c);
166
+ }
167
+ }
168
+ walk(entry);
169
+ return names.slice(0, 8);
170
+ }
171
+
172
+ function mapHotel(rawEntry: unknown, areaMid: string | null, areaName: string): Record<string, unknown> | null {
173
+ // The "397419284" value wraps the hotel entry: [ [null,"Name",[...]] ].
174
+ let entry: unknown = rawEntry;
175
+ if (
176
+ isArr(entry) &&
177
+ typeof entry[1] !== 'string' &&
178
+ isArr(entry[0]) &&
179
+ typeof (entry[0] as unknown[])[1] === 'string'
180
+ ) {
181
+ entry = entry[0];
182
+ }
183
+ const name = isArr(entry) && typeof entry[1] === 'string' ? entry[1] : null;
184
+ const hotelId = findHotelId(entry);
185
+ if (!name && !hotelId) return null; // content-less sentinel
186
+ const latlng = findLatLng(entry);
187
+ const rating = findRating(entry);
188
+ const times = findCheckInOut(entry);
189
+ const price = findPrice(entry);
190
+ const ownMid = findAreaMid(entry) ?? areaMid;
191
+ return {
192
+ name,
193
+ hotel_id: hotelId, // PRODUCER token (ChcI… ftid)
194
+ location_context: `${ownMid ?? ''}|${areaName}`, // PRODUCER token "<mid>|<displayName>"
195
+ latitude: latlng?.lat ?? null,
196
+ longitude: latlng?.lng ?? null,
197
+ rating: rating.overall,
198
+ rating_categories: rating.categories,
199
+ star_class: findStarClass(entry),
200
+ check_in_time: times.checkIn,
201
+ check_out_time: times.checkOut,
202
+ price: price.display,
203
+ price_amount: price.amount,
204
+ nearby: findPois(entry),
205
+ };
206
+ }
207
+
208
+ export function extract(rawResponse: unknown, context?: Ctx): unknown {
209
+ const raw =
210
+ typeof rawResponse === 'string' ? rawResponse : JSON.stringify(rawResponse);
211
+ let inner: unknown;
212
+ try {
213
+ inner = parseBatchExecute(raw, 'AtySUc');
214
+ } catch {
215
+ inner = null;
216
+ }
217
+ if (inner == null) {
218
+ return { area: null, result_count: 0, hotels: [] };
219
+ }
220
+
221
+ // result-count / area row: [922,false,"Chicago Loop",true,2]
222
+ const countRows: unknown[] = [];
223
+ collectByKey(inner, '416343588', countRows);
224
+ let resultCount = 0;
225
+ let areaName = '';
226
+ for (const row of countRows) {
227
+ if (isArr(row) && typeof row[0] === 'number') {
228
+ resultCount = row[0];
229
+ if (typeof row[2] === 'string') areaName = row[2];
230
+ break;
231
+ }
232
+ }
233
+ if (!areaName && context?.params?.location) {
234
+ areaName = String(context.params.location);
235
+ }
236
+
237
+ // area mid from anywhere in the tree
238
+ const areaMid = findAreaMid(inner);
239
+
240
+ // hotel entries
241
+ const hotelVals: unknown[] = [];
242
+ collectByKey(inner, '397419284', hotelVals);
243
+ const seen = new Set<string>();
244
+ const hotels: Record<string, unknown>[] = [];
245
+ for (const v of hotelVals) {
246
+ const mapped = mapHotel(v, areaMid, areaName);
247
+ if (!mapped) continue;
248
+ const dedup = `${mapped.name}|${mapped.hotel_id}`;
249
+ if (seen.has(dedup)) continue;
250
+ seen.add(dedup);
251
+ hotels.push(mapped);
252
+ }
253
+
254
+ return {
255
+ area: areaName || null,
256
+ area_mid: areaMid,
257
+ result_count: resultCount || hotels.length,
258
+ hotels,
259
+ };
260
+ }
@@ -0,0 +1,87 @@
1
+ toolName: search_hotels
2
+ summary: Search Google Hotels for a location over a check-in/check-out date range and return the hotel result list from the AtySUc search XHR.
3
+ parameters:
4
+ - name: location
5
+ type: string
6
+ description: "Destination query typed into the hotel search box, e.g. 'chicago loop', 'tahoe city', 'denver downtown'."
7
+ - name: check_in_date
8
+ type: string
9
+ description: "Check-in day as it appears in the date-picker cell aria-label, e.g. 'Friday, July 3, 2026'."
10
+ - name: check_out_date
11
+ type: string
12
+ description: "Check-out day as it appears in the date-picker cell aria-label, e.g. 'Monday, July 6, 2026'."
13
+ steps:
14
+ - action: navigate
15
+ url: https://www.google.com/travel/search
16
+ wait_for: networkidle
17
+ - action: click
18
+ locators:
19
+ - by: aria_label
20
+ value: Search for places, hotels and more
21
+ - by: css
22
+ value: input.II2One.j0Ppje
23
+ wait_for:
24
+ sleep_ms: 300
25
+ - action: type
26
+ locators:
27
+ - by: aria_label
28
+ value: Search for places, hotels and more
29
+ - by: css
30
+ value: input.II2One.j0Ppje
31
+ value: ${location}
32
+ wait_for:
33
+ sleep_ms: 600
34
+ - action: click
35
+ locators:
36
+ - by: text
37
+ value_pattern: ${location}
38
+ - by: css
39
+ value: ul.F3AVKd > li.Q1RWxd
40
+ wait_for:
41
+ xhr: rpcids=AtySUc
42
+ - action: click
43
+ locators:
44
+ - by: aria_label
45
+ value: Check-in
46
+ - by: css
47
+ value: input.TP4Lpb.eoY5cb
48
+ wait_for:
49
+ sleep_ms: 400
50
+ - action: click
51
+ locators:
52
+ - by: aria_label
53
+ value_pattern: ${check_in_date}
54
+ wait_for:
55
+ sleep_ms: 300
56
+ - action: click
57
+ locators:
58
+ - by: aria_label
59
+ value_pattern: ${check_out_date}
60
+ wait_for:
61
+ sleep_ms: 300
62
+ - action: click
63
+ locators:
64
+ - by: text
65
+ value: Done
66
+ - by: css
67
+ value: div.XfpsVe.J9fJmf button.VfPpkd-LgbsSe-OWXEXe-k8QpJ
68
+ wait_for:
69
+ xhr: rpcids=AtySUc
70
+ result:
71
+ source: xhr
72
+ url_pattern: rpcids=AtySUc
73
+ extract: wrb.fr.AtySUc
74
+ return_as: hotels
75
+ notes: >-
76
+ The AtySUc response is a batchexecute anti-XSSI envelope: strip the ")]}'" prefix and chunk-length
77
+ lines, then JSON.parse the inner escaped string inside [["wrb.fr","AtySUc","<inner-json>"]]. The inner
78
+ payload is positional nested arrays (no named keys), so the result-bearing hotel list and the
79
+ "$low/typical/high" price-index block must be walked by index after parsing — the same shared helper
80
+ used by the API workflow does this. Each typed location triggers an mejVKc autocomplete lookup plus an
81
+ AtySUc search; clicking the first suggestion that contains ${location} re-runs AtySUc with the resolved
82
+ Knowledge-Graph mid. Date cells are targeted by their full aria-label (e.g. "Friday, July 3, 2026"), so
83
+ check_in_date/check_out_date must be passed in that exact format; if the target month is not visible,
84
+ the picker may need next-month navigation first. Filter refinements seen in the recording (min_rating,
85
+ min_price/max_price, amenities, hotel_class, brands, sort_by, property_type) all re-issue the same
86
+ AtySUc endpoint with integer filter codes and are better applied via the API workflow than via static
87
+ DOM steps; this playbook covers the core location + date search that grounds every variation.
@@ -0,0 +1,197 @@
1
+ // Per-tool request transform for google-hotels search_hotels.
2
+ // Builds the AtySUc batchexecute inner payload from the tool params, then wraps
3
+ // it with the SHARED freq.ts helpers (buildFreqBody / buildBatchExecuteUrl).
4
+ // The structure was reverse-engineered from recorded requests:
5
+ // seq 229 (location only) -> proves a bare text query resolves
6
+ // seq 300 (location + dates) -> [1][2] location/dates block
7
+ // seq 691 (4+ rating) -> [1][4][3]/[1][4][4]
8
+ // seq 745 (price max 338) -> [1][4][3][1] = [min,max]
9
+ // seq 1098 (19 amenities + 4/5 star) -> [1][4][0][0] amenities, [1][4][0][1] stars
10
+ // seq 1745 (brands) -> [1][4][0][7] brands
11
+ // seq 1842 (sort lowest price) -> [1][4][0][4] sort code
12
+ // seq 2082 (property type / vacation) -> [1][0] = 1 hotels / 2 vacation rentals
13
+ import {
14
+ buildFreqBody,
15
+ buildBatchExecuteUrl,
16
+ encodeDate,
17
+ } from '../_shared/freq.ts';
18
+
19
+ type Params = Record<string, string | number | boolean | undefined>;
20
+
21
+ function num(v: unknown, dflt = 0): number {
22
+ if (v === undefined || v === null || v === '') return dflt;
23
+ const n = typeof v === 'number' ? v : Number(v);
24
+ return Number.isFinite(n) ? n : dflt;
25
+ }
26
+
27
+ function str(v: unknown): string {
28
+ return v === undefined || v === null ? '' : String(v).trim();
29
+ }
30
+
31
+ // Amenity name aliases -> Google integer codes (best-effort; numeric codes pass
32
+ // through unchanged). Codes observed in the recording's 19-amenity list.
33
+ const AMENITY_ALIASES: Record<string, number> = {
34
+ 'free breakfast': 4,
35
+ breakfast: 4,
36
+ 'free wi-fi': 6,
37
+ 'free wifi': 6,
38
+ wifi: 6,
39
+ 'wi-fi': 6,
40
+ bar: 2,
41
+ pool: 9,
42
+ spa: 15,
43
+ 'air conditioning': 8,
44
+ 'pet-friendly': 12,
45
+ 'pet friendly': 12,
46
+ 'fitness center': 19,
47
+ gym: 19,
48
+ restaurant: 1,
49
+ 'room service': 3,
50
+ 'airport shuttle': 22,
51
+ 'electric vehicle charging station': 61,
52
+ parking: 7,
53
+ 'free parking': 10,
54
+ 'hot tub': 40,
55
+ 'accessible': 5,
56
+ 'kid-friendly': 53,
57
+ 'all-inclusive available': 11,
58
+ 'beach access': 52,
59
+ };
60
+
61
+ function parseCodeList(raw: string, aliases?: Record<string, number>): number[] {
62
+ if (!raw) return [];
63
+ const out: number[] = [];
64
+ for (const part of raw.split(',')) {
65
+ const t = part.trim().toLowerCase();
66
+ if (!t) continue;
67
+ if (aliases && t in aliases) {
68
+ out.push(aliases[t]!);
69
+ continue;
70
+ }
71
+ const n = Number(t);
72
+ if (Number.isFinite(n)) out.push(n);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // Brand filter: each entry encodes as [parentCode,[childCode]]. Accept "parent:child"
78
+ // or a bare numeric code (wrapped as [code,[code]]).
79
+ function parseBrands(raw: string): Array<[number, number[]]> {
80
+ if (!raw) return [];
81
+ const out: Array<[number, number[]]> = [];
82
+ for (const part of raw.split(',')) {
83
+ const t = part.trim();
84
+ if (!t) continue;
85
+ if (t.includes(':')) {
86
+ const [a, b] = t.split(':');
87
+ const pa = Number(a);
88
+ const pb = Number(b);
89
+ if (Number.isFinite(pa) && Number.isFinite(pb)) out.push([pa, [pb]]);
90
+ } else {
91
+ const n = Number(t);
92
+ if (Number.isFinite(n)) out.push([n, [n]]);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ const SORT_CODES: Record<string, number | null> = {
99
+ relevance: null,
100
+ lowest_price: 3,
101
+ highest_rating: 8,
102
+ most_reviewed: 13,
103
+ };
104
+
105
+ function nightsBetween(ci: string, co: string): number {
106
+ const a = Date.parse(ci + 'T00:00:00Z');
107
+ const b = Date.parse(co + 'T00:00:00Z');
108
+ if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) return 1;
109
+ return Math.round((b - a) / 86_400_000);
110
+ }
111
+
112
+ export function buildInnerPayload(params: Params): unknown {
113
+ const location = str(params.location);
114
+ const checkIn = str(params.check_in_date);
115
+ const checkOut = str(params.check_out_date);
116
+ const adults = Math.max(1, num(params.adults, 2));
117
+ const children = Math.max(0, num(params.children, 0));
118
+ const minRating = num(params.min_rating, 0);
119
+ const minPrice = num(params.min_price, 0);
120
+ const maxPrice = num(params.max_price, 0);
121
+ const amenities = parseCodeList(str(params.amenities), AMENITY_ALIASES);
122
+ const stars = parseCodeList(str(params.hotel_class));
123
+ const brands = parseBrands(str(params.brands));
124
+ const sortKey = str(params.sort_by).toLowerCase() || 'relevance';
125
+ const sortCode = sortKey in SORT_CODES ? SORT_CODES[sortKey] : null;
126
+ const propertyType =
127
+ str(params.property_type).toLowerCase() === 'vacation_rentals' ? 2 : 1;
128
+
129
+ // [1][1] occupancy: array of adults (each [3]) + children scalar
130
+ const occupancy: unknown = [Array.from({ length: adults }, () => [3]), children];
131
+
132
+ // [1][2] location/dates: empty mid block + dates block
133
+ let locDates: unknown;
134
+ if (checkIn && checkOut) {
135
+ locDates = [
136
+ [],
137
+ [
138
+ null,
139
+ [encodeDate(checkIn), encodeDate(checkOut), nightsBetween(checkIn, checkOut)],
140
+ null,
141
+ null,
142
+ null,
143
+ [1],
144
+ ],
145
+ ];
146
+ } else {
147
+ locDates = null;
148
+ }
149
+
150
+ // [1][4] filters
151
+ const filterObj: unknown[] = [
152
+ amenities.length ? amenities : null, // [0] amenities
153
+ stars.length ? stars : null, // [1] hotel class (stars)
154
+ null, // [2]
155
+ null, // [3]
156
+ sortCode ?? null, // [4] sort
157
+ null, // [5]
158
+ 'USD', // [6] currency
159
+ ];
160
+ if (brands.length) filterObj[7] = brands; // [7] brands
161
+
162
+ const f14: unknown[] = [filterObj, null, []];
163
+
164
+ const ratingCode = minRating > 0 ? Math.round(minRating * 2) : 0;
165
+ const priceSet = minPrice > 0 || maxPrice > 0;
166
+ if (priceSet || ratingCode) {
167
+ const priceTuple = priceSet ? [minPrice > 0 ? minPrice : null, maxPrice > 0 ? maxPrice : null] : null;
168
+ f14[3] = [null, priceTuple, 1];
169
+ if (ratingCode) f14[4] = ratingCode;
170
+ }
171
+
172
+ const block1: unknown = [propertyType, occupancy, locDates, null, f14];
173
+
174
+ // [2] fresh-search block (matches seq 229 / a brand-new query)
175
+ const block2: unknown = [0, null, null, 0, 0, null, null, null, 0];
176
+
177
+ return [location, block1, block2];
178
+ }
179
+
180
+ export function transform(
181
+ method: string,
182
+ url: string,
183
+ responses: Record<string, any>,
184
+ params?: Params,
185
+ ): { url: string; body: string } {
186
+ void method;
187
+ void responses;
188
+ const u = new URL(url);
189
+ const rpcid = u.searchParams.get('rpcids') || 'AtySUc';
190
+ const fSid = u.searchParams.get('f.sid') ?? '';
191
+ const bl = u.searchParams.get('bl') ?? '';
192
+ const inner = buildInnerPayload(params ?? {});
193
+ return {
194
+ url: buildBatchExecuteUrl(rpcid, { f_sid: fSid, bl }),
195
+ body: buildFreqBody(rpcid, inner, '1'),
196
+ };
197
+ }
@@ -0,0 +1,127 @@
1
+ {
2
+ "toolName": "search_hotels",
3
+ "intent": {
4
+ "description": "Search Google Hotels for a location and date range with optional filters (price, rating, amenities, star class, brands, sort, property type).",
5
+ "userSaid": "searched for hotels at chicago loop from july 3-6; filtered for > 4.0 rating; filtered min and max price; added amenities; added 2/4/5 star; added brand filters; sorted by lowest price, highest rating, most reviewed; changed property type to vacation rentals; changed location to tahoe city and denver downtown"
6
+ },
7
+ "parameters": [
8
+ {
9
+ "name": "location",
10
+ "type": "string",
11
+ "description": "Destination query, e.g. 'chicago loop', 'tahoe city', 'denver downtown'",
12
+ "default": "chicago loop",
13
+ "verified": true
14
+ },
15
+ {
16
+ "name": "check_in_date",
17
+ "type": "string",
18
+ "description": "Check-in date (YYYY-MM-DD)",
19
+ "default": "2026-07-03",
20
+ "verified": false,
21
+ "verifyNote": "annotated"
22
+ },
23
+ {
24
+ "name": "check_out_date",
25
+ "type": "string",
26
+ "description": "Check-out date (YYYY-MM-DD)",
27
+ "default": "2026-07-06",
28
+ "verified": false,
29
+ "verifyNote": "annotated"
30
+ },
31
+ {
32
+ "name": "adults",
33
+ "type": "number",
34
+ "description": "Number of adult travelers",
35
+ "default": 2,
36
+ "verified": false,
37
+ "verifyNote": "annotated"
38
+ },
39
+ {
40
+ "name": "children",
41
+ "type": "number",
42
+ "description": "Number of child travelers",
43
+ "default": 0,
44
+ "verified": false,
45
+ "verifyNote": "annotated"
46
+ },
47
+ {
48
+ "name": "min_rating",
49
+ "type": "number",
50
+ "description": "Minimum guest rating filter, e.g. 4.0 (0 = no filter)",
51
+ "default": 0,
52
+ "verified": false,
53
+ "verifyNote": "annotated"
54
+ },
55
+ {
56
+ "name": "min_price",
57
+ "type": "number",
58
+ "description": "Minimum nightly price filter (0 = no filter)",
59
+ "default": 0,
60
+ "verified": false,
61
+ "verifyNote": "annotated"
62
+ },
63
+ {
64
+ "name": "max_price",
65
+ "type": "number",
66
+ "description": "Maximum nightly price filter, e.g. 338 (0 = no filter)",
67
+ "default": 0,
68
+ "verified": true
69
+ },
70
+ {
71
+ "name": "amenities",
72
+ "type": "string",
73
+ "description": "Comma-separated amenity names or codes (e.g. 'free wi-fi,pool' or '6,9'); empty = no filter",
74
+ "default": "",
75
+ "verified": false,
76
+ "verifyNote": "annotated"
77
+ },
78
+ {
79
+ "name": "hotel_class",
80
+ "type": "string",
81
+ "description": "Comma-separated star classes, e.g. '2,3' or '4,5'; empty = no filter",
82
+ "default": "",
83
+ "verified": true
84
+ },
85
+ {
86
+ "name": "brands",
87
+ "type": "string",
88
+ "description": "Comma-separated brand codes ('parent:child' or numeric); empty = no filter",
89
+ "default": "",
90
+ "verified": false,
91
+ "verifyNote": "annotated"
92
+ },
93
+ {
94
+ "name": "sort_by",
95
+ "type": "string",
96
+ "description": "Sort order: relevance | lowest_price | highest_rating | most_reviewed",
97
+ "default": "relevance",
98
+ "verified": true
99
+ },
100
+ {
101
+ "name": "property_type",
102
+ "type": "string",
103
+ "description": "hotels | vacation_rentals",
104
+ "default": "hotels",
105
+ "verified": true
106
+ }
107
+ ],
108
+ "requests": [
109
+ {
110
+ "method": "POST",
111
+ "url": "https://www.google.com/_/TravelFrontendUi/data/batchexecute?rpcids=AtySUc&source-path=%2Ftravel%2Fsearch&f.sid=7513562915459271421&bl=boq_travel-frontend-ui_20260527.01_p0&hl=en-US&soc-app=162&soc-platform=1&soc-device=1&_reqid=2252256&rt=c",
112
+ "headers": {
113
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
114
+ "X-Same-Domain": "1",
115
+ "x-goog-ext-259736195-jspb": "[\"en-US\",\"US\",\"USD\",2,null,[420],null,null,7,[]]",
116
+ "x-goog-ext-190139975-jspb": "[\"US\",\"ZZ\",\"x6c25Q==\"]",
117
+ "Referer": "https://www.google.com/travel/search"
118
+ },
119
+ "body": "f.req={\"location\":\"${param.location}\",\"check_in_date\":\"${param.check_in_date}\",\"check_out_date\":\"${param.check_out_date}\",\"adults\":\"${param.adults}\",\"children\":\"${param.children}\",\"min_rating\":\"${param.min_rating}\",\"min_price\":\"${param.min_price}\",\"max_price\":\"${param.max_price}\",\"amenities\":\"${param.amenities}\",\"hotel_class\":\"${param.hotel_class}\",\"brands\":\"${param.brands}\",\"sort_by\":\"${param.sort_by}\",\"property_type\":\"${param.property_type}\"}",
120
+ "effect": "safe"
121
+ }
122
+ ],
123
+ "site": "google-hotels",
124
+ "requestTransformModule": "./request-transform.ts",
125
+ "parserModule": "./parser.ts",
126
+ "liveVerified": true
127
+ }
@@ -8,13 +8,14 @@
8
8
  - **`probe-backends` skipping the futile rung.** The cached `backends.json` orders the ladder `stealth-fetch → playbook` so cron doesn't burn 200ms on a fetch attempt every tick.
9
9
  - **`notifyWhen: price_below`** pushing only on real drops, with the `pricePath` extracting from real Southwest response shape.
10
10
  - **The fresh-UUID header trick** — Southwest rejects stale `X-User-Experience-ID`; stealth-fetch regenerates per call.
11
+ - **Public bootstrap config capture.** The workflow fetches Southwest's public bootstrap `data.js` and captures the current `api-keys.prod` value, so installed examples do not require a `SOUTHWEST_API_KEY` environment variable.
11
12
  - **Multi-path `pricePath`** — `cron.json`'s notifyWhen lists both the raw API shape (when stealth-fetch wins) and the playbook's reshaped output, so the push fires regardless of which backend produced the result.
12
13
 
13
14
  ## Run it
14
15
 
15
16
  ```bash
16
- # One-time setup (if you haven't already)
17
- bunx playwright install chromium
17
+ # One-time setup: registers the example MCP and installs Playwright Chromium if missing
18
+ imprint install southwest --source examples --platform claude-desktop
18
19
 
19
20
  # Run a single tick (verifies everything still works)
20
21
  imprint cron southwest --once