@agentrhq/webcmd 0.4.2 → 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.
- package/cli-manifest.json +755 -174
- package/clis/_shared/site-auth.js +0 -1
- package/clis/_shared/site-auth.test.js +0 -1
- package/clis/amazon-in/checkout.js +0 -1
- package/clis/blinkit/checkout.js +0 -1
- package/clis/blinkit/place-order.js +0 -1
- package/clis/chatgpt/model.js +2 -2
- package/clis/chatgpt/model.test.js +7 -1
- package/clis/chatgpt/utils.js +8 -0
- package/clis/chatgpt/utils.test.js +92 -1
- package/clis/district/checkout.js +0 -1
- package/clis/district/seats.js +0 -1
- package/clis/district/set-location.js +0 -1
- package/clis/district/showtimes.js +0 -1
- package/clis/google/images.js +456 -0
- package/clis/google/images.test.js +375 -0
- package/clis/instagram/user.js +5 -13
- package/clis/instagram/user.test.js +66 -0
- package/clis/mercury/check-login.js +0 -1
- package/clis/mercury/reimbursement-draft.js +0 -1
- package/clis/practo/book-confirm.js +0 -1
- package/clis/practo/cancel.js +0 -1
- package/clis/trip/attraction.js +74 -0
- package/clis/trip/car.js +74 -0
- package/clis/trip/deals.js +61 -0
- package/clis/trip/flight-round.js +88 -0
- package/clis/trip/flight.js +83 -0
- package/clis/trip/hotel-search.js +80 -0
- package/clis/trip/hotel.js +54 -0
- package/clis/trip/package.js +93 -0
- package/clis/trip/search.js +43 -0
- package/clis/trip/tour.js +84 -0
- package/clis/trip/train.js +76 -0
- package/clis/trip/transfer.js +82 -0
- package/clis/trip/trip.test.js +1420 -0
- package/clis/trip/utils.js +911 -0
- package/dist/src/build-manifest.js +0 -1
- package/dist/src/build-manifest.test.js +4 -0
- package/dist/src/cli.js +7 -7
- package/dist/src/cli.test.js +26 -10
- package/dist/src/command-presentation.js +1 -1
- package/dist/src/command-presentation.test.js +3 -0
- package/dist/src/command-surface.js +1 -1
- package/dist/src/command-surface.test.js +4 -0
- package/dist/src/commands/auth.js +0 -2
- package/dist/src/commands/auth.test.js +0 -2
- package/dist/src/discovery.js +0 -1
- package/dist/src/execution.js +3 -3
- package/dist/src/execution.test.js +68 -15
- package/dist/src/hosted/browser-args.js +2 -2
- package/dist/src/hosted/browser-args.test.js +10 -0
- package/dist/src/hosted/client.js +2 -2
- package/dist/src/manifest-types.d.ts +0 -2
- package/dist/src/registry.d.ts +0 -2
- package/dist/src/registry.js +0 -1
- package/hosted-contract.json +767 -3
- package/package.json +2 -2
- package/skills/webcmd-browser/SKILL.md +2 -1
- 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 };
|