@agentrhq/webcmd 0.4.1 → 0.4.3

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 (74) hide show
  1. package/README.md +3 -0
  2. package/cli-manifest.json +955 -96
  3. package/clis/_shared/site-auth.js +0 -1
  4. package/clis/_shared/site-auth.test.js +0 -1
  5. package/clis/amazon-in/auth.js +44 -0
  6. package/clis/amazon-in/checkout-status.js +54 -0
  7. package/clis/amazon-in/checkout.js +478 -0
  8. package/clis/amazon-in/parsers.js +252 -0
  9. package/clis/amazon-in/parsers.test.js +230 -0
  10. package/clis/amazon-in/product.js +72 -0
  11. package/clis/amazon-in/search.js +76 -0
  12. package/clis/amazon-in/shared.js +40 -0
  13. package/clis/amazon-in/wishlist.js +98 -0
  14. package/clis/blinkit/checkout.js +0 -1
  15. package/clis/blinkit/place-order.js +0 -1
  16. package/clis/chatgpt/model.js +2 -2
  17. package/clis/chatgpt/model.test.js +7 -1
  18. package/clis/chatgpt/utils.js +8 -0
  19. package/clis/chatgpt/utils.test.js +92 -1
  20. package/clis/district/checkout.js +0 -1
  21. package/clis/district/seats.js +0 -1
  22. package/clis/district/set-location.js +0 -1
  23. package/clis/district/showtimes.js +0 -1
  24. package/clis/google/images.js +456 -0
  25. package/clis/google/images.test.js +375 -0
  26. package/clis/instagram/user.js +5 -13
  27. package/clis/instagram/user.test.js +66 -0
  28. package/clis/mercury/check-login.js +0 -1
  29. package/clis/mercury/reimbursement-draft.js +0 -1
  30. package/clis/practo/book-confirm.js +0 -1
  31. package/clis/practo/cancel.js +0 -1
  32. package/clis/trip/attraction.js +74 -0
  33. package/clis/trip/car.js +74 -0
  34. package/clis/trip/deals.js +61 -0
  35. package/clis/trip/flight-round.js +88 -0
  36. package/clis/trip/flight.js +83 -0
  37. package/clis/trip/hotel-search.js +80 -0
  38. package/clis/trip/hotel.js +54 -0
  39. package/clis/trip/package.js +93 -0
  40. package/clis/trip/search.js +43 -0
  41. package/clis/trip/tour.js +84 -0
  42. package/clis/trip/train.js +76 -0
  43. package/clis/trip/transfer.js +82 -0
  44. package/clis/trip/trip.test.js +1420 -0
  45. package/clis/trip/utils.js +911 -0
  46. package/dist/src/build-manifest.js +0 -1
  47. package/dist/src/build-manifest.test.js +4 -0
  48. package/dist/src/cli.js +7 -7
  49. package/dist/src/cli.test.js +26 -10
  50. package/dist/src/command-presentation.js +1 -1
  51. package/dist/src/command-presentation.test.js +3 -0
  52. package/dist/src/command-surface.js +1 -1
  53. package/dist/src/command-surface.test.js +4 -0
  54. package/dist/src/commands/auth.js +0 -2
  55. package/dist/src/commands/auth.test.js +0 -2
  56. package/dist/src/discovery.js +0 -1
  57. package/dist/src/execution.js +3 -3
  58. package/dist/src/execution.test.js +68 -15
  59. package/dist/src/hosted/browser-args.js +2 -2
  60. package/dist/src/hosted/browser-args.test.js +10 -0
  61. package/dist/src/hosted/client.d.ts +8 -10
  62. package/dist/src/hosted/client.js +26 -34
  63. package/dist/src/hosted/client.test.js +79 -24
  64. package/dist/src/hosted/runner.js +7 -35
  65. package/dist/src/hosted/runner.test.js +27 -51
  66. package/dist/src/hosted/types.d.ts +1 -5
  67. package/dist/src/manifest-types.d.ts +0 -2
  68. package/dist/src/registry.d.ts +0 -2
  69. package/dist/src/registry.js +0 -1
  70. package/dist/src/root-command-surface.js +10 -2
  71. package/hosted-contract.json +2627 -1535
  72. package/package.json +2 -2
  73. package/skills/webcmd-browser/SKILL.md +2 -1
  74. package/skills/webcmd-usage/SKILL.md +1 -1
@@ -0,0 +1,911 @@
1
+ /**
2
+ * Shared helpers for the Trip.com (international) adapter.
3
+ *
4
+ * Trip.com is the English-facing sibling of Ctrip; its search pages render
5
+ * results client-side, so the browser-mode commands read the rendered DOM.
6
+ * Flight rows are `.result-item` cards keyed by stable `data-testid` anchors
7
+ * (`flights-name`, `stopInfoText`, `flight_price_*`).
8
+ */
9
+ import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
10
+
11
+ const MIN_LIMIT = 1;
12
+ const MAX_LIMIT = 50;
13
+ const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
14
+ const POI_SEARCH_ENDPOINT = 'https://www.trip.com/restapi/soa2/14427/poiSearch';
15
+ const PACKAGE_SEARCH_ENDPOINT = 'https://www.trip.com/restapi/soa2/19866/FlightSelectSearch';
16
+
17
+ export function parseIataCode(name, raw) {
18
+ if (raw === undefined || raw === null || raw === '') {
19
+ throw new ArgumentError(`--${name} is required (3-letter IATA code, e.g. LON, NYC)`);
20
+ }
21
+ const value = String(raw).trim().toUpperCase();
22
+ if (!/^[A-Z]{3}$/.test(value)) {
23
+ throw new ArgumentError(`--${name} must be a 3-letter IATA code, got ${JSON.stringify(raw)}`);
24
+ }
25
+ return value;
26
+ }
27
+
28
+ export function parseIsoDate(name, raw) {
29
+ if (raw === undefined || raw === null || raw === '') {
30
+ throw new ArgumentError(`--${name} is required (YYYY-MM-DD)`);
31
+ }
32
+ const value = String(raw).trim();
33
+ const m = ISO_DATE_RE.exec(value);
34
+ if (!m) {
35
+ throw new ArgumentError(`--${name} must be YYYY-MM-DD, got ${JSON.stringify(raw)}`);
36
+ }
37
+ const year = Number(m[1]);
38
+ const month = Number(m[2]);
39
+ const day = Number(m[3]);
40
+ if (month < 1 || month > 12 || day < 1 || day > 31) {
41
+ throw new ArgumentError(`--${name} has invalid month/day: ${value}`);
42
+ }
43
+ // Cross-check via UTC date math so 2026-02-30 doesn't pass.
44
+ const parsed = new Date(Date.UTC(year, month - 1, day));
45
+ if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
46
+ throw new ArgumentError(`--${name} is not a real calendar date: ${value}`);
47
+ }
48
+ return value;
49
+ }
50
+
51
+ export function parseListLimit(raw, fallback = 20) {
52
+ if (raw === undefined || raw === null || raw === '') return fallback;
53
+ const parsed = Number(raw);
54
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
55
+ throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}, got ${JSON.stringify(raw)}`);
56
+ }
57
+ if (parsed < MIN_LIMIT || parsed > MAX_LIMIT) {
58
+ throw new ArgumentError(`--limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}, got ${parsed}`);
59
+ }
60
+ return parsed;
61
+ }
62
+
63
+ export function buildFlightSearchUrl(fromCode, toCode, date) {
64
+ const params = new URLSearchParams({
65
+ dcity: fromCode.toLowerCase(),
66
+ acity: toCode.toLowerCase(),
67
+ ddate: date,
68
+ triptype: 'ow',
69
+ class: 'y',
70
+ quantity: '1',
71
+ locale: 'en_US',
72
+ curr: 'USD',
73
+ });
74
+ return `https://www.trip.com/flights/showfarefirst?${params.toString()}`;
75
+ }
76
+
77
+ export function buildFlightRoundSearchUrl(fromCode, toCode, depart, ret) {
78
+ const params = new URLSearchParams({
79
+ dcity: fromCode.toLowerCase(),
80
+ acity: toCode.toLowerCase(),
81
+ ddate: depart,
82
+ rdate: ret,
83
+ triptype: 'rt',
84
+ class: 'y',
85
+ quantity: '1',
86
+ locale: 'en_US',
87
+ curr: 'USD',
88
+ });
89
+ return `https://www.trip.com/flights/showfarefirst?${params.toString()}`;
90
+ }
91
+
92
+ /**
93
+ * Browser-context IIFE that extracts flight rows from Trip.com's rendered
94
+ * `.result-item` cards. Fields are read from stable `data-testid` anchors plus
95
+ * the `HH:MM` / `AM-PM` / `IATA` leaf-node pattern. Cards missing the airline,
96
+ * both airports, or both times are dropped rather than surfaced with blanks.
97
+ */
98
+ export function buildFlightExtractJs() {
99
+ return `
100
+ (() => {
101
+ const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : '';
102
+ const rows = [];
103
+ document.querySelectorAll('.result-item').forEach((card) => {
104
+ const airline = clean(card.querySelector('[data-testid="flights-name"]'));
105
+ const codes = Array.from(card.querySelectorAll('[class*="font-black"]'))
106
+ .map((el) => clean(el)).filter((t) => /^[A-Z]{3}$/.test(t));
107
+ const leaves = Array.from(card.querySelectorAll('*'))
108
+ .filter((el) => !el.children.length).map((el) => clean(el));
109
+ const times = leaves.filter((t) => /^\\d{1,2}:\\d{2}$/.test(t));
110
+ const meridiems = leaves.filter((t) => /^(AM|PM)$/.test(t));
111
+ const duration = leaves.find((t) => /^\\d+h(\\s\\d+m)?$/.test(t)) || null;
112
+ if (!airline || codes.length < 2 || times.length < 2) return;
113
+ const withMeridiem = (i) => times[i] + (meridiems[i] ? ' ' + meridiems[i] : '');
114
+ const priceEl = card.querySelector('[data-testid^="flight_price"]');
115
+ const priceText = clean(priceEl);
116
+ const priceNum = priceText.replace(/[^0-9.]/g, '');
117
+ rows.push({
118
+ airline,
119
+ departureTime: withMeridiem(0),
120
+ departureAirport: codes[0],
121
+ arrivalTime: withMeridiem(1),
122
+ arrivalAirport: codes[1],
123
+ duration,
124
+ stops: clean(card.querySelector('[data-testid="stopInfoText"]')) || null,
125
+ price: priceNum ? Number(priceNum) : null,
126
+ currency: priceText.startsWith('$') ? 'USD' : (priceText.replace(/[0-9.,\\s]/g, '') || null),
127
+ });
128
+ });
129
+ return rows;
130
+ })()
131
+ `;
132
+ }
133
+
134
+ /** Wait for the flight list to render, or detect a captcha / verification wall. */
135
+ export const WAIT_FOR_FLIGHTS_JS = `
136
+ new Promise((resolve) => {
137
+ const detect = () => {
138
+ if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha';
139
+ if (document.querySelector('.result-item')) return 'content';
140
+ return null;
141
+ };
142
+ const found = detect();
143
+ if (found) return resolve(found);
144
+ const observer = new MutationObserver(() => {
145
+ const result = detect();
146
+ if (result) { observer.disconnect(); resolve(result); }
147
+ });
148
+ observer.observe(document.documentElement, { childList: true, subtree: true });
149
+ setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 12000);
150
+ })
151
+ `;
152
+
153
+ export function parseCityId(name, raw) {
154
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
155
+ throw new ArgumentError(`--${name} is required (numeric Trip.com city id, e.g. 338 for London)`);
156
+ }
157
+ const value = String(raw).trim();
158
+ if (!/^\d+$/.test(value)) {
159
+ throw new ArgumentError(`--${name} must be a numeric Trip.com city id, got ${JSON.stringify(raw)}`);
160
+ }
161
+ return value;
162
+ }
163
+
164
+ export function buildHotelSearchUrl(cityId, checkin, checkout) {
165
+ const params = new URLSearchParams({
166
+ city: cityId,
167
+ checkin,
168
+ checkout,
169
+ locale: 'en_US',
170
+ curr: 'USD',
171
+ });
172
+ return `https://www.trip.com/hotels/list?${params.toString()}`;
173
+ }
174
+
175
+ /**
176
+ * Browser-context IIFE that extracts hotel rows from Trip.com's rendered
177
+ * `.hotel-card` cards, read by stable class-keyed fields
178
+ * (`.hotelName/.score/.comment-num/.position-desc/.price-highlight`). Cards
179
+ * without a hotel name are dropped rather than surfaced with blanks.
180
+ */
181
+ export function buildHotelExtractJs() {
182
+ return `
183
+ (() => {
184
+ const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : '';
185
+ const toNum = (t) => { const m = String(t).replace(/[^0-9.]/g, ''); return m ? Number(m) : null; };
186
+ const rows = [];
187
+ document.querySelectorAll('.hotel-card').forEach((card) => {
188
+ const name = clean(card.querySelector('.hotelName'));
189
+ if (!name) return;
190
+ const locations = Array.from(card.querySelectorAll('.position-desc'))
191
+ .map((el) => clean(el)).filter(Boolean);
192
+ const priceText = clean(card.querySelector('.price-highlight'));
193
+ rows.push({
194
+ name,
195
+ score: toNum(clean(card.querySelector('.score'))),
196
+ reviewLabel: clean(card.querySelector('.comment-desc')) || null,
197
+ reviews: toNum(clean(card.querySelector('.comment-num'))),
198
+ location: locations.join(', ') || null,
199
+ room: clean(card.querySelector('.room-name')) || null,
200
+ price: toNum(priceText),
201
+ currency: priceText.startsWith('$') ? 'USD' : (priceText.replace(/[0-9.,\\s]/g, '') || null),
202
+ });
203
+ });
204
+ return rows;
205
+ })()
206
+ `;
207
+ }
208
+
209
+ /** Wait for the hotel list to render, or detect a verification wall. */
210
+ export const WAIT_FOR_HOTELS_JS = `
211
+ new Promise((resolve) => {
212
+ const detect = () => {
213
+ if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha';
214
+ if (document.querySelector('.hotel-card')) return 'content';
215
+ return null;
216
+ };
217
+ const found = detect();
218
+ if (found) return resolve(found);
219
+ const observer = new MutationObserver(() => {
220
+ const result = detect();
221
+ if (result) { observer.disconnect(); resolve(result); }
222
+ });
223
+ observer.observe(document.documentElement, { childList: true, subtree: true });
224
+ setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 12000);
225
+ })
226
+ `;
227
+
228
+ export function parseHotelId(name, raw) {
229
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
230
+ throw new ArgumentError(`--${name} is required (numeric Trip.com hotel id, discover via the hotels list)`);
231
+ }
232
+ const value = String(raw).trim();
233
+ if (!/^\d+$/.test(value)) {
234
+ throw new ArgumentError(`--${name} must be a numeric Trip.com hotel id, got ${JSON.stringify(raw)}`);
235
+ }
236
+ return value;
237
+ }
238
+
239
+ export function buildHotelDetailUrl(hotelId) {
240
+ const params = new URLSearchParams({ hotelId, locale: 'en_US', curr: 'USD' });
241
+ return `https://www.trip.com/hotels/detail/?${params.toString()}`;
242
+ }
243
+
244
+ /**
245
+ * Browser-context IIFE that projects the single-hotel profile from
246
+ * `__NEXT_DATA__.props.pageProps.hotelDetailResponse` (the same SSR shape the
247
+ * mainland `ctrip hotel` detail uses). Rating sub-scores, popular amenities, and
248
+ * the check-in/out policy are each joined into one string so the profile stays a
249
+ * single flat row. Returns `null` when the SSR block is absent, so the caller
250
+ * raises a typed error instead of surfacing blanks. Room-level nightly prices
251
+ * load via a post-SSR XHR and are out of scope here.
252
+ */
253
+ export function buildHotelDetailExtractJs() {
254
+ return `
255
+ (() => {
256
+ const pp = window.__NEXT_DATA__?.props?.pageProps;
257
+ const dr = pp && pp.hotelDetailResponse;
258
+ if (!dr || typeof dr !== 'object') return null;
259
+ const clean = (s) => (s == null ? null : String(s).replace(/\\s+/g, ' ').trim() || null);
260
+ const num = (s) => { const n = Number(s); return Number.isFinite(n) && n !== 0 ? n : null; };
261
+ const bi = dr.hotelBaseInfo || {};
262
+ const nameInfo = bi.nameInfo || {};
263
+ const starInfo = bi.starInfo || {};
264
+ const pos = dr.hotelPositionInfo || {};
265
+ const comment = (dr.hotelComment && dr.hotelComment.comment) || {};
266
+ const scoreDetail = Array.isArray(comment.scoreDetail) ? comment.scoreDetail : [];
267
+ const popList = (((dr.hotelFacilityPopV2 || {}).hotelPopularFacility || {}).list) || [];
268
+ const cio = (dr.hotelPolicyInfo && dr.hotelPolicyInfo.checkInAndOut) || {};
269
+ const cioContent = Array.isArray(cio.content) ? cio.content : [];
270
+ return {
271
+ hotelId: bi.masterHotelId != null ? String(bi.masterHotelId) : null,
272
+ name: clean(nameInfo.name),
273
+ enName: clean(nameInfo.nameEn),
274
+ star: (Number.isFinite(starInfo.level) && starInfo.level > 0) ? starInfo.level : null,
275
+ score: num(comment.score),
276
+ scoreLabel: clean(comment.scoreDescription),
277
+ reviewCount: (Number.isFinite(comment.totalComment) && comment.totalComment > 0) ? comment.totalComment : null,
278
+ ratingBreakdown: scoreDetail.map((s) => (s && s.showName && s.showScore) ? clean(s.showName) + ' ' + clean(s.showScore) : null).filter(Boolean).join(' / ') || null,
279
+ facilities: popList.map((f) => f && clean(f.facilityDesc)).filter(Boolean).join(' / ') || null,
280
+ checkInOut: cioContent.map((c) => c && clean((c.title || '') + (c.description || ''))).filter(Boolean).join(' / ') || null,
281
+ cityName: clean(bi.cityName),
282
+ address: clean(pos.address),
283
+ lat: num(pos.lat),
284
+ lon: num(pos.lng),
285
+ };
286
+ })()
287
+ `;
288
+ }
289
+
290
+ /** Wait for the hotel detail SSR block, or detect a verification wall. */
291
+ export const WAIT_FOR_HOTEL_DETAIL_JS = `
292
+ new Promise((resolve) => {
293
+ const detect = () => {
294
+ if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha';
295
+ const dr = window.__NEXT_DATA__?.props?.pageProps?.hotelDetailResponse;
296
+ if (dr && dr.hotelBaseInfo && dr.hotelBaseInfo.nameInfo) return 'content';
297
+ return null;
298
+ };
299
+ const found = detect();
300
+ if (found) return resolve(found);
301
+ const observer = new MutationObserver(() => {
302
+ const result = detect();
303
+ if (result) { observer.disconnect(); resolve(result); }
304
+ });
305
+ observer.observe(document.documentElement, { childList: true, subtree: true });
306
+ setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 12000);
307
+ })
308
+ `;
309
+
310
+ export function parseKeyword(name, raw) {
311
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
312
+ throw new ArgumentError(`--${name} is required (a destination or attraction keyword)`);
313
+ }
314
+ const value = String(raw).trim();
315
+ if (value.length > 60) {
316
+ throw new ArgumentError(`--${name} is too long (max 60 chars): ${JSON.stringify(raw)}`);
317
+ }
318
+ return value;
319
+ }
320
+
321
+ export function buildAttractionSearchUrl(keyword) {
322
+ const params = new URLSearchParams({ keyword, locale: 'en_US', curr: 'USD' });
323
+ return `https://www.trip.com/things-to-do/list?${params.toString()}`;
324
+ }
325
+
326
+ /**
327
+ * Browser-context IIFE that extracts attraction / experience rows from Trip.com's
328
+ * things-to-do results. The product cards use hashed CSS-module class names, so
329
+ * this anchors on the one stable handle each card exposes, the
330
+ * `things-to-do/detail/<id>` link (name is its text, `url` its href), and reads
331
+ * rating / reviews / booked / price from the card's text by data-format pattern
332
+ * rather than by hashed class. The price excludes the "$N off" promo tag and
333
+ * takes the current (lowest non-promo) fare. Cards without a name or id are
334
+ * dropped rather than surfaced with blanks.
335
+ */
336
+ export function buildAttractionExtractJs() {
337
+ return `
338
+ (() => {
339
+ const clean = (s) => (s || '').replace(/\\s+/g, ' ').trim();
340
+ const kNum = (s) => { if (!s) return null; const n = Number(String(s).replace(/k/i, '').replace(/,/g, '')); return /k/i.test(s) ? Math.round(n * 1000) : n; };
341
+ const rows = [];
342
+ const seen = new Set();
343
+ document.querySelectorAll('a[href*="/things-to-do/detail/"]').forEach((link) => {
344
+ const name = clean(link.textContent);
345
+ if (!name || name.length < 4) return;
346
+ const href = link.getAttribute('href') || '';
347
+ const idMatch = href.match(/\\/detail\\/(\\d+)/);
348
+ const id = idMatch ? idMatch[1] : null;
349
+ if (!id || seen.has(id)) return;
350
+ seen.add(id);
351
+ let card = link;
352
+ const txt = (el) => (el && (el.innerText || el.textContent)) || '';
353
+ for (let i = 0; i < 6; i++) { if (card.parentElement) { card = card.parentElement; if (/\\$\\s?\\d/.test(txt(card))) break; } }
354
+ const t = txt(card);
355
+ const ratingM = t.match(/(\\d(?:\\.\\d)?)\\s*\\/\\s*5/);
356
+ const reviewsM = t.match(/([\\d.]+k?)\\s+reviews/i);
357
+ const bookedM = t.match(/([\\d.]+k?)\\s+booked/i);
358
+ const prices = [];
359
+ const re = /\\$\\s?([\\d,]+(?:\\.\\d+)?)/g;
360
+ let m;
361
+ while ((m = re.exec(t)) !== null) {
362
+ if (!/off/i.test(t.slice(m.index, m.index + m[0].length + 5))) prices.push(Number(m[1].replace(/,/g, '')));
363
+ }
364
+ rows.push({
365
+ name,
366
+ rating: ratingM ? Number(ratingM[1]) : null,
367
+ reviews: kNum(reviewsM && reviewsM[1]),
368
+ booked: kNum(bookedM && bookedM[1]),
369
+ price: prices.length ? Math.min.apply(null, prices) : null,
370
+ url: href.startsWith('http') ? href : ('https://www.trip.com' + href),
371
+ });
372
+ });
373
+ return rows;
374
+ })()
375
+ `;
376
+ }
377
+
378
+ /** Wait for the attraction results to render (products lazy-load), or detect a verification wall. */
379
+ export const WAIT_FOR_ATTRACTIONS_JS = `
380
+ new Promise((resolve) => {
381
+ const detect = () => {
382
+ if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha';
383
+ if (document.querySelectorAll('a[href*="/things-to-do/detail/"]').length > 2 && /\\$\\s?\\d/.test(document.body?.innerText || '')) return 'content';
384
+ if (/no results|no matching|couldn.t find/i.test(document.body?.innerText || '')) return 'empty';
385
+ return null;
386
+ };
387
+ const found = detect();
388
+ if (found) return resolve(found);
389
+ const observer = new MutationObserver(() => {
390
+ const result = detect();
391
+ if (result) { observer.disconnect(); resolve(result); }
392
+ });
393
+ observer.observe(document.documentElement, { childList: true, subtree: true });
394
+ setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 15000);
395
+ })
396
+ `;
397
+
398
+ /**
399
+ * Build the timetable URL for a train route. Trip.com organises route pages under
400
+ * a country segment (`trains/<country>/route/<from>-to-<to>/`), so the country is
401
+ * required; the city names are slugified.
402
+ */
403
+ export function buildTrainRouteUrl(country, from, to) {
404
+ const slug = (s) => String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
405
+ return `https://www.trip.com/trains/${slug(country)}/route/${slug(from)}-to-${slug(to)}/`;
406
+ }
407
+
408
+ /**
409
+ * Browser-context IIFE that extracts train journeys from a Trip.com route
410
+ * timetable. Rows are `<tr>` entries with a `.item-departure` / `.item-arrival`
411
+ * cell (time in `.item-time-text`, station in `.item-name`); duration and change
412
+ * count are parsed from the departure cell text after the time. The SEO
413
+ * timetable carries no per-journey fare (it sits behind the "Find Tickets"
414
+ * booking step). Rows without both times and stations are dropped.
415
+ */
416
+ export function buildTrainExtractJs() {
417
+ return `
418
+ (() => {
419
+ const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : '';
420
+ const rows = [];
421
+ document.querySelectorAll('tr').forEach((tr) => {
422
+ const dep = tr.querySelector('.item-departure');
423
+ const arr = tr.querySelector('.item-arrival');
424
+ if (!dep || !arr) return;
425
+ const departureTime = (clean(dep.querySelector('.item-time-text')).match(/\\d{1,2}:\\d{2}/) || [])[0] || '';
426
+ const arrivalTime = (clean(arr.querySelector('.item-time-text')).match(/\\d{1,2}:\\d{2}/) || [])[0] || '';
427
+ const fromStation = clean(dep.querySelector('.item-name'));
428
+ const toStation = clean(arr.querySelector('.item-name'));
429
+ if (!departureTime || !arrivalTime || !fromStation || !toStation) return;
430
+ const rest = clean(dep).replace(departureTime, '');
431
+ const durMatch = rest.match(/(\\d+\\s*h(?:\\s*\\d+\\s*m)?|\\d+\\s*min)/i);
432
+ const changeMatch = rest.match(/(\\d+)\\s*changes?/i);
433
+ rows.push({
434
+ departureTime,
435
+ fromStation,
436
+ arrivalTime,
437
+ toStation,
438
+ duration: durMatch ? durMatch[1].replace(/\\s+/g, ' ').trim() : null,
439
+ changes: changeMatch ? Number(changeMatch[1]) : (/direct|non-?stop/i.test(rest) ? 0 : null),
440
+ });
441
+ });
442
+ return rows;
443
+ })()
444
+ `;
445
+ }
446
+
447
+ /** Wait for a train timetable to render, or detect a verification wall. */
448
+ export const WAIT_FOR_TRAINS_JS = `
449
+ new Promise((resolve) => {
450
+ const detect = () => {
451
+ if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha';
452
+ if (document.querySelector('.item-departure .item-time-text')) return 'content';
453
+ return null;
454
+ };
455
+ const found = detect();
456
+ if (found) return resolve(found);
457
+ const observer = new MutationObserver(() => {
458
+ const result = detect();
459
+ if (result) { observer.disconnect(); resolve(result); }
460
+ });
461
+ observer.observe(document.documentElement, { childList: true, subtree: true });
462
+ setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 12000);
463
+ })
464
+ `;
465
+
466
+ /**
467
+ * Build the car-rental listing URL for a Trip.com carhire city. Trip.com files
468
+ * these listings under an SEO path whose text slugs are cosmetic; only the
469
+ * numeric city id in the trailing segment routes, so a placeholder country/city
470
+ * slug is used and the id drives the page.
471
+ */
472
+ export function buildCarListUrl(cityId) {
473
+ return `https://www.trip.com/carhire/to-city-1/city-${cityId}/`;
474
+ }
475
+
476
+ /**
477
+ * Browser-context IIFE that extracts car-rental rows from a Trip.com carhire
478
+ * listing. Each `.card-item` carries the vehicle category and example model in
479
+ * `.card-item-title`, the passenger count as the first number in
480
+ * `.card-item-vehicle-info`, and the daily price in `.car-daily-price`. Cards
481
+ * without a price or any name are dropped rather than surfaced with blanks.
482
+ */
483
+ export function buildCarExtractJs() {
484
+ return `
485
+ (() => {
486
+ const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : '';
487
+ const txt = (el) => (el && (el.innerText || el.textContent)) || '';
488
+ const rows = [];
489
+ document.querySelectorAll('.card-item').forEach((card) => {
490
+ const priceText = clean(card.querySelector('.car-daily-price'));
491
+ const priceM = priceText.match(/\\$\\s?([\\d,]+(?:\\.\\d+)?)/);
492
+ if (!priceM) return;
493
+ const titleText = txt(card.querySelector('.card-item-title'));
494
+ const vehicle = clean(card.querySelector('.title-info')) || null;
495
+ const category = (vehicle ? titleText.replace(vehicle, '') : titleText).replace(/\\s+/g, ' ').trim() || null;
496
+ if (!category && !vehicle) return;
497
+ const seatsM = txt(card.querySelector('.card-item-vehicle-info')).match(/\\d+/);
498
+ rows.push({
499
+ category,
500
+ vehicle,
501
+ seats: seatsM ? Number(seatsM[0]) : null,
502
+ price: Number(priceM[1].replace(/,/g, '')),
503
+ currency: /\\$/.test(priceText) ? 'USD' : null,
504
+ });
505
+ });
506
+ return rows;
507
+ })()
508
+ `;
509
+ }
510
+
511
+ /** Wait for the car listing to render, or detect a verification wall. */
512
+ export const WAIT_FOR_CARS_JS = `
513
+ new Promise((resolve) => {
514
+ const detect = () => {
515
+ if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha';
516
+ if (document.querySelector('.card-item .car-daily-price')) return 'content';
517
+ if (/no results|no matching|couldn.t find|not available/i.test(document.body?.innerText || '')) return 'empty';
518
+ return null;
519
+ };
520
+ const found = detect();
521
+ if (found) return resolve(found);
522
+ const observer = new MutationObserver(() => {
523
+ const result = detect();
524
+ if (result) { observer.disconnect(); resolve(result); }
525
+ });
526
+ observer.observe(document.documentElement, { childList: true, subtree: true });
527
+ setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 15000);
528
+ })
529
+ `;
530
+
531
+ /**
532
+ * Build the airport-transfer listing URL. Trip.com files transfers under an SEO
533
+ * path keyed on the city slug plus the airport IATA code
534
+ * (`airport-transfers/<city>/airport-<iata>/`); a mismatched city bounces to the
535
+ * landing page, so both are required and slugified here.
536
+ */
537
+ export function buildTransferListUrl(city, airport) {
538
+ const slug = (s) => String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
539
+ return `https://www.trip.com/airport-transfers/${slug(city)}/airport-${slug(airport)}/`;
540
+ }
541
+
542
+ /**
543
+ * Browser-context IIFE that extracts airport-transfer rows from a Trip.com
544
+ * transfer listing. Each `.vehicle-card` carries the vehicle type in
545
+ * `.vehicle-card__title-text`, passenger and luggage capacity in
546
+ * `.vehicle-card__capacity-text` / `.vehicle-card__luggage-text`, and the
547
+ * from-price in `.vehicle-card__price-row` (the `$N off` promo sits in a
548
+ * separate discount tag and is excluded). Cards without a type or price are
549
+ * dropped rather than surfaced with blanks.
550
+ */
551
+ export function buildTransferExtractJs() {
552
+ return `
553
+ (() => {
554
+ const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : '';
555
+ const intOf = (el) => { const m = clean(el).match(/\\d+/); return m ? Number(m[0]) : null; };
556
+ const rows = [];
557
+ document.querySelectorAll('.vehicle-card').forEach((card) => {
558
+ const type = clean(card.querySelector('.vehicle-card__title-text'));
559
+ const priceText = clean(card.querySelector('.vehicle-card__price-row'));
560
+ const priceM = priceText.match(/\\$\\s?([\\d,]+(?:\\.\\d+)?)/);
561
+ if (!type || !priceM) return;
562
+ rows.push({
563
+ type,
564
+ passengers: intOf(card.querySelector('.vehicle-card__capacity-text')),
565
+ luggage: intOf(card.querySelector('.vehicle-card__luggage-text')),
566
+ price: Number(priceM[1].replace(/,/g, '')),
567
+ currency: /\\$/.test(priceText) ? 'USD' : null,
568
+ });
569
+ });
570
+ return rows;
571
+ })()
572
+ `;
573
+ }
574
+
575
+ /** Wait for the transfer listing to render, or detect a verification wall. */
576
+ export const WAIT_FOR_TRANSFERS_JS = `
577
+ new Promise((resolve) => {
578
+ const detect = () => {
579
+ if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha';
580
+ if (document.querySelector('.vehicle-card .vehicle-card__price-row')) return 'content';
581
+ if (/no results|no matching|couldn.t find|not available/i.test(document.body?.innerText || '')) return 'empty';
582
+ return null;
583
+ };
584
+ const found = detect();
585
+ if (found) return resolve(found);
586
+ const observer = new MutationObserver(() => {
587
+ const result = detect();
588
+ if (result) { observer.disconnect(); resolve(result); }
589
+ });
590
+ observer.observe(document.documentElement, { childList: true, subtree: true });
591
+ setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 15000);
592
+ })
593
+ `;
594
+
595
+ /**
596
+ * Build the tour search URL. Trip.com files tour packages under
597
+ * `package-tours/list?kwd=<keyword>` with a `tabType` selecting the product line
598
+ * (`privateTours` / `groupTours`).
599
+ */
600
+ export function buildTourSearchUrl(keyword, tourType) {
601
+ const params = new URLSearchParams({ kwd: keyword, tabType: tourType, locale: 'en-US', curr: 'USD' });
602
+ return `https://www.trip.com/package-tours/list?${params.toString()}`;
603
+ }
604
+
605
+ /**
606
+ * Browser-context Promise that reads a tour search off the results page. The
607
+ * product list is served by a signed POST that only fires on a search submit, so
608
+ * rather than replaying the signed request this installs a fetch hook that
609
+ * captures the response body carrying the `"products"` array, drives the page's
610
+ * own search box (re-submitting the keyword) so the page issues its own signed
611
+ * request, then resolves once the response lands. Returns `{ status, rows }`,
612
+ * where `status` is `content` / `captcha` / `empty` / `noinput` / `timeout`
613
+ * (`empty` when the results page reports `0 routes found` for a genuine no-match).
614
+ */
615
+ export function buildTourSearchJs(keyword) {
616
+ return `
617
+ new Promise((resolve) => {
618
+ const captured = [];
619
+ const origFetch = window.fetch;
620
+ window.fetch = function (url) {
621
+ const promise = origFetch.apply(this, arguments);
622
+ try {
623
+ promise.then((res) => { res.clone().text().then((text) => {
624
+ if (text.indexOf('"products":[') !== -1) {
625
+ try { const data = JSON.parse(text); if (Array.isArray(data.products) && data.products.length) captured.push(data.products); } catch (e) {}
626
+ }
627
+ }).catch(() => {}); }).catch(() => {});
628
+ } catch (e) {}
629
+ return promise;
630
+ };
631
+ const input = document.querySelector('input[placeholder]') || document.querySelector('input');
632
+ if (input) {
633
+ const setValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
634
+ input.focus();
635
+ setValue.call(input, ''); input.dispatchEvent(new Event('input', { bubbles: true }));
636
+ setValue.call(input, ${JSON.stringify(keyword)}); input.dispatchEvent(new Event('input', { bubbles: true }));
637
+ ['keydown', 'keyup'].forEach((type) => input.dispatchEvent(new KeyboardEvent(type, { bubbles: true, key: 'Enter', keyCode: 13 })));
638
+ }
639
+ const num = (v) => (typeof v === 'number' && isFinite(v)) ? v : null;
640
+ let elapsed = 0;
641
+ const timer = setInterval(() => {
642
+ elapsed += 300;
643
+ if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) { clearInterval(timer); window.fetch = origFetch; return resolve({ status: 'captcha' }); }
644
+ if (captured.length) {
645
+ clearInterval(timer); window.fetch = origFetch;
646
+ const products = captured[captured.length - 1];
647
+ const rows = products.map((p) => {
648
+ const basic = p.basicInfo || {};
649
+ const comment = (p.statistics && p.statistics.commentInfo) || {};
650
+ return {
651
+ name: basic.name || null,
652
+ type: basic.productTypeName || null,
653
+ rating: num(comment.score),
654
+ reviews: num(comment.count),
655
+ price: num(p.priceInfo && p.priceInfo.price),
656
+ url: (basic.detailUrl && basic.detailUrl.ONLINE) || null,
657
+ };
658
+ });
659
+ return resolve({ status: 'content', rows });
660
+ }
661
+ if (!input) { clearInterval(timer); window.fetch = origFetch; return resolve({ status: 'noinput' }); }
662
+ if (!captured.length && elapsed >= 6000 && /0\\s+routes?\\s+found/i.test((document.body && document.body.innerText) || '')) { clearInterval(timer); window.fetch = origFetch; return resolve({ status: 'empty' }); }
663
+ if (elapsed >= 16000) { clearInterval(timer); window.fetch = origFetch; return resolve({ status: 'timeout' }); }
664
+ }, 300);
665
+ })
666
+ `;
667
+ }
668
+
669
+ /** Build the "Today's Top Deals" hub URL (English / USD). */
670
+ export function buildDealsUrl() {
671
+ const params = new URLSearchParams({ locale: 'en-US', curr: 'USD' });
672
+ return `https://www.trip.com/sale/deals/?${params.toString()}`;
673
+ }
674
+
675
+ /**
676
+ * Browser-context IIFE that extracts running-promotion rows from Trip.com's
677
+ * "Today's Top Deals" hub. Each promotion is an `.top-deals_link-item` tile whose
678
+ * title sits in `.top-deals_item-tit`, offer line in `.top-deals_item-desc`, and
679
+ * campaign page in the tile `href`; the `discount` is the percentage or `$N off`
680
+ * parsed out of the title + offer text (`null` when the campaign states none).
681
+ * The campaign `url` is normalised to its canonical path, dropping the per-session
682
+ * promo tracking query. Tiles are deduped by that URL, and those without a title
683
+ * or offer are dropped rather than surfaced with blanks.
684
+ */
685
+ export function buildDealsExtractJs() {
686
+ return `
687
+ (() => {
688
+ const clean = (el) => el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : '';
689
+ const rows = [];
690
+ const seen = new Set();
691
+ document.querySelectorAll('a[class*="top-deals_link-item"]').forEach((a) => {
692
+ const raw = a.getAttribute('href') || '';
693
+ if (!raw) return;
694
+ const url = (raw.startsWith('http') ? raw : ('https://www.trip.com' + raw)).split('?')[0];
695
+ if (seen.has(url)) return;
696
+ const title = clean(a.querySelector('[class*="top-deals_item-tit"]'));
697
+ const offer = clean(a.querySelector('[class*="top-deals_item-desc"]'));
698
+ if (!title && !offer) return;
699
+ seen.add(url);
700
+ const discM = [title, offer].filter(Boolean).join(' ').match(/\\d+(?:\\.\\d+)?%|\\$\\s?\\d+(?:\\.\\d+)?\\s*off/i);
701
+ rows.push({
702
+ title: title || null,
703
+ offer: offer || null,
704
+ discount: discM ? discM[0].replace(/\\s+/g, ' ').trim() : null,
705
+ url,
706
+ });
707
+ });
708
+ return rows;
709
+ })()
710
+ `;
711
+ }
712
+
713
+ /** Wait for the deals hub to render, or detect a verification wall. */
714
+ export const WAIT_FOR_DEALS_JS = `
715
+ new Promise((resolve) => {
716
+ const detect = () => {
717
+ if (/captcha|verify you are human|security check/i.test(document.body?.innerText || '')) return 'captcha';
718
+ if (document.querySelector('a[class*="top-deals_link-item"]')) return 'content';
719
+ return null;
720
+ };
721
+ const found = detect();
722
+ if (found) return resolve(found);
723
+ const observer = new MutationObserver(() => {
724
+ const result = detect();
725
+ if (result) { observer.disconnect(); resolve(result); }
726
+ });
727
+ observer.observe(document.documentElement, { childList: true, subtree: true });
728
+ setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 15000);
729
+ })
730
+ `;
731
+
732
+ /**
733
+ * Query Trip.com's public destination-suggest endpoint (the same POI search the
734
+ * flight / hotel boxes call). It takes an unsigned JSON POST and returns city /
735
+ * airport / place matches, so this needs no browser session. Returns the raw
736
+ * `results` array. Missing/non-array `results` means schema drift; an explicit
737
+ * empty array is the only valid empty-result shape.
738
+ */
739
+ export async function fetchPoiSearch(keyword) {
740
+ let response;
741
+ try {
742
+ response = await fetch(POI_SEARCH_ENDPOINT, {
743
+ method: 'POST',
744
+ headers: { 'content-type': 'application/json', currency: 'USD' },
745
+ body: JSON.stringify({
746
+ key: keyword,
747
+ mode: '0',
748
+ tripType: 'RT',
749
+ Head: { Currency: 'USD', Locale: 'en-US', Source: 'ONLINE', Channel: 'EnglishSite', ClientID: 'webcmd-trip' },
750
+ }),
751
+ });
752
+ } catch (err) {
753
+ throw new CommandExecutionError(`Trip.com poiSearch fetch failed: ${err instanceof Error ? err.message : String(err)}`);
754
+ }
755
+ if (!response.ok) {
756
+ throw new CommandExecutionError(`Trip.com poiSearch failed with status ${response.status}`);
757
+ }
758
+ let payload;
759
+ try {
760
+ payload = await response.json();
761
+ } catch (err) {
762
+ throw new CommandExecutionError(`Trip.com poiSearch returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
763
+ }
764
+ if (!Array.isArray(payload?.results)) {
765
+ throw new CommandExecutionError('Trip.com poiSearch returned malformed payload: missing results array');
766
+ }
767
+ return payload.results;
768
+ }
769
+
770
+ /**
771
+ * Flatten POI results into a flat suggestion list: each top-level city keeps its
772
+ * own row, and its `childResults` (nearby airports) follow, so a single search
773
+ * surfaces both the city id and the airport codes.
774
+ */
775
+ export function flattenPoiResults(results) {
776
+ const rows = [];
777
+ for (const result of results) {
778
+ if (!result || typeof result !== 'object') continue;
779
+ rows.push(result);
780
+ if (Array.isArray(result.childResults)) {
781
+ for (const child of result.childResults) {
782
+ if (child && typeof child === 'object') rows.push(child);
783
+ }
784
+ }
785
+ }
786
+ return rows;
787
+ }
788
+
789
+ /**
790
+ * Project a POI suggestion into the stable adapter column shape. A row carrying
791
+ * an airport code is an `airport` (feeds `flight` / `transfer`); otherwise it is a
792
+ * `city` (feeds `hotel-search` / `car` / `tour`). Missing values stay `null`.
793
+ */
794
+ export function mapSearchRow(item, index) {
795
+ const airportCode = item?.airportCode ? String(item.airportCode).trim() : '';
796
+ return {
797
+ rank: index + 1,
798
+ name: item?.name ? String(item.name).replace(/\s+/g, ' ').trim() : null,
799
+ type: airportCode ? 'airport' : 'city',
800
+ cityId: Number.isFinite(item?.cityId) && item.cityId !== 0 ? item.cityId : null,
801
+ airportCode: airportCode || null,
802
+ province: item?.provinceName ? String(item.provinceName).trim() : null,
803
+ country: item?.countryName ? String(item.countryName).trim() : null,
804
+ };
805
+ }
806
+
807
+ /**
808
+ * Resolve a destination keyword to the Trip.com city its flight+hotel package
809
+ * search needs: the metro `cityCode` (e.g. `SEL`) drives the flight leg and the
810
+ * numeric `cityId` drives the hotel leg, both of which the public POI search
811
+ * returns for a city match. Picks the first result carrying both (an airport
812
+ * child only carries an `airportCode`), so one keyword resolves both endpoints.
813
+ * Returns `null` when no city matches.
814
+ */
815
+ export async function resolvePackageCity(keyword) {
816
+ const results = await fetchPoiSearch(keyword);
817
+ for (const item of results) {
818
+ if (!item || typeof item !== 'object' || item.airportCode) continue;
819
+ const cityCode = item.cityCode ? String(item.cityCode).trim().toUpperCase() : '';
820
+ const cityId = Number.isFinite(item.cityId) && item.cityId !== 0 ? item.cityId : null;
821
+ if (cityCode && cityId) {
822
+ return { name: item.name ? String(item.name).replace(/\s+/g, ' ').trim() : keyword, cityCode, cityId };
823
+ }
824
+ }
825
+ return null;
826
+ }
827
+
828
+ /**
829
+ * Query Trip.com's flight+hotel package search (the flight-selection step of the
830
+ * package booking flow). It takes an unsigned JSON POST keyed on the metro city
831
+ * codes plus the destination hotel city id and returns the package's flight
832
+ * options priced at the bundle rate, so this needs no browser session. `fmap` 19
833
+ * is the flight+hotel product map and `sgrade` 4 economy; the return date rides on
834
+ * the hotel checkout, so the flight criteria carries just the outbound segment,
835
+ * the same shape the results page submits. Returns the raw `grouplist` array;
836
+ * missing/non-array `grouplist` means schema drift, not "no packages".
837
+ */
838
+ export async function fetchPackageSearch({ dcode, acode, hcityid, depart, ret, adults }) {
839
+ const body = {
840
+ head: {
841
+ cid: '', ctok: '', cver: '1.0', lang: '01', sid: '8888', syscode: '09', auth: '', xsid: '',
842
+ extension: [
843
+ { name: 'locale', value: 'en-US' },
844
+ { name: 'currency', value: 'USD' },
845
+ { name: 'productLine', value: 'FlightHotel' },
846
+ { name: 'source', value: 'ONLINE' },
847
+ ],
848
+ Locale: 'en-US', Language: 'en', Currency: 'USD', ClientID: '',
849
+ },
850
+ platform: { src: 'PC', lang: 'en-US', currency: 'USD', sitesrc: 'trip' },
851
+ flightcriteria: {
852
+ osource: 1, triptype: 1, fmap: 19, sflag: 0, rtype: 2,
853
+ seglist: [{ segno: 1, ddate: depart, sgrade: 4, dcode, acode }],
854
+ pinfo: { adults, children: 0, babys: 0 },
855
+ },
856
+ hotelcriteria: { chin: depart, chout: ret, hcityid, rnum: 1 },
857
+ };
858
+ let response;
859
+ try {
860
+ response = await fetch(PACKAGE_SEARCH_ENDPOINT, {
861
+ method: 'POST',
862
+ headers: { 'content-type': 'application/json', currency: 'USD' },
863
+ body: JSON.stringify(body),
864
+ });
865
+ } catch (err) {
866
+ throw new CommandExecutionError(`Trip.com package search fetch failed: ${err instanceof Error ? err.message : String(err)}`);
867
+ }
868
+ if (!response.ok) {
869
+ throw new CommandExecutionError(`Trip.com package search failed with status ${response.status}`);
870
+ }
871
+ let payload;
872
+ try {
873
+ payload = await response.json();
874
+ } catch (err) {
875
+ throw new CommandExecutionError(`Trip.com package search returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
876
+ }
877
+ if (!Array.isArray(payload?.grouplist)) {
878
+ throw new CommandExecutionError('Trip.com package search returned malformed payload: missing grouplist array');
879
+ }
880
+ return payload.grouplist;
881
+ }
882
+
883
+ /**
884
+ * Project a package flight group into the stable adapter column shape. A group's
885
+ * `flightlist` is the itinerary legs (one for a nonstop), so the route summary
886
+ * reads the departure off the first leg and the arrival off the last, with the
887
+ * leg count minus one as the stop count. `price` is the per-person package
888
+ * starting fare (`policylist[0].price.price`); missing values stay `null`.
889
+ */
890
+ export function mapPackageRow(group, index) {
891
+ const legs = Array.isArray(group?.flightlist) ? group.flightlist : [];
892
+ const first = legs[0] || {};
893
+ const last = legs[legs.length - 1] || {};
894
+ const binfo = first.binfo || {};
895
+ const price = group?.policylist?.[0]?.price?.price;
896
+ const str = (v) => (v == null || v === '') ? null : String(v).replace(/\s+/g, ' ').trim();
897
+ return {
898
+ rank: index + 1,
899
+ airline: str(binfo.airlineName),
900
+ flightNo: str(binfo.flightno),
901
+ from: str(first.dportinfo && first.dportinfo.aport),
902
+ to: str(last.aportinfo && last.aportinfo.aport),
903
+ departure: str(first.dateinfo && first.dateinfo.dtime),
904
+ arrival: str(last.dateinfo && last.dateinfo.atime),
905
+ stops: legs.length ? legs.length - 1 : null,
906
+ price: (typeof price === 'number' && isFinite(price)) ? price : null,
907
+ currency: 'USD',
908
+ };
909
+ }
910
+
911
+ export const __test__ = { MIN_LIMIT, MAX_LIMIT, POI_SEARCH_ENDPOINT, PACKAGE_SEARCH_ENDPOINT };