@striderlabs/mcp-booking 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/auth.d.ts +41 -0
- package/dist/auth.js +96 -0
- package/dist/browser.d.ts +145 -0
- package/dist/browser.js +834 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +612 -0
- package/package.json +36 -0
- package/server.json +16 -0
- package/src/auth.ts +118 -0
- package/src/browser.ts +1281 -0
- package/src/index.ts +865 -0
- package/tsconfig.json +14 -0
package/dist/browser.js
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strider Labs - Booking.com Browser Automation
|
|
3
|
+
*
|
|
4
|
+
* Playwright-based browser automation for Booking.com operations.
|
|
5
|
+
*/
|
|
6
|
+
import { chromium } from "playwright";
|
|
7
|
+
import { saveCookies, loadCookies, saveSessionInfo, } from "./auth.js";
|
|
8
|
+
const BOOKING_BASE_URL = "https://www.booking.com";
|
|
9
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
10
|
+
// Singleton browser instance
|
|
11
|
+
let browser = null;
|
|
12
|
+
let context = null;
|
|
13
|
+
let page = null;
|
|
14
|
+
// In-memory search results cache for filter/sort operations
|
|
15
|
+
let lastSearchResults = [];
|
|
16
|
+
/** Random delay between 500-2000ms for human-like behaviour */
|
|
17
|
+
async function randomDelay(min = 500, max = 2000) {
|
|
18
|
+
const ms = Math.floor(Math.random() * (max - min + 1)) + min;
|
|
19
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Initialize browser with stealth settings
|
|
23
|
+
*/
|
|
24
|
+
async function initBrowser() {
|
|
25
|
+
if (browser && context && page) {
|
|
26
|
+
return { browser, context, page };
|
|
27
|
+
}
|
|
28
|
+
browser = await chromium.launch({
|
|
29
|
+
headless: true,
|
|
30
|
+
args: [
|
|
31
|
+
"--disable-blink-features=AutomationControlled",
|
|
32
|
+
"--no-sandbox",
|
|
33
|
+
"--disable-setuid-sandbox",
|
|
34
|
+
"--disable-dev-shm-usage",
|
|
35
|
+
"--disable-web-security",
|
|
36
|
+
"--disable-features=VizDisplayCompositor",
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
context = await browser.newContext({
|
|
40
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
41
|
+
viewport: { width: 1366, height: 768 },
|
|
42
|
+
locale: "en-US",
|
|
43
|
+
timezoneId: "America/New_York",
|
|
44
|
+
extraHTTPHeaders: {
|
|
45
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
// Load saved cookies if available
|
|
49
|
+
const cookiesLoaded = await loadCookies(context);
|
|
50
|
+
if (cookiesLoaded) {
|
|
51
|
+
console.error("Loaded saved Booking.com cookies");
|
|
52
|
+
}
|
|
53
|
+
page = await context.newPage();
|
|
54
|
+
// Stealth patches
|
|
55
|
+
await page.addInitScript(() => {
|
|
56
|
+
Object.defineProperty(navigator, "webdriver", { get: () => false });
|
|
57
|
+
Object.defineProperty(navigator, "plugins", {
|
|
58
|
+
get: () => [1, 2, 3, 4, 5],
|
|
59
|
+
});
|
|
60
|
+
Object.defineProperty(navigator, "languages", {
|
|
61
|
+
get: () => ["en-US", "en"],
|
|
62
|
+
});
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
window.chrome = { runtime: {} };
|
|
65
|
+
});
|
|
66
|
+
return { browser, context, page };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Close browser and save state
|
|
70
|
+
*/
|
|
71
|
+
export async function closeBrowser() {
|
|
72
|
+
if (context) {
|
|
73
|
+
await saveCookies(context);
|
|
74
|
+
}
|
|
75
|
+
if (browser) {
|
|
76
|
+
await browser.close();
|
|
77
|
+
browser = null;
|
|
78
|
+
context = null;
|
|
79
|
+
page = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Dismiss cookie/GDPR banners if present
|
|
84
|
+
*/
|
|
85
|
+
async function dismissBanners(p) {
|
|
86
|
+
try {
|
|
87
|
+
const acceptBtn = await p.$('[id*="onetrust-accept"], button[data-gdpr-consent="accept"], ' +
|
|
88
|
+
'button:has-text("Accept"), button:has-text("I accept"), ' +
|
|
89
|
+
'[data-testid="accept-all-button"]');
|
|
90
|
+
if (acceptBtn) {
|
|
91
|
+
await acceptBtn.click();
|
|
92
|
+
await randomDelay(300, 700);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Ignore
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Check Booking.com login status
|
|
101
|
+
*/
|
|
102
|
+
export async function checkLoginStatus() {
|
|
103
|
+
const { page, context } = await initBrowser();
|
|
104
|
+
try {
|
|
105
|
+
await page.goto(BOOKING_BASE_URL, {
|
|
106
|
+
waitUntil: "domcontentloaded",
|
|
107
|
+
timeout: DEFAULT_TIMEOUT,
|
|
108
|
+
});
|
|
109
|
+
await randomDelay();
|
|
110
|
+
await dismissBanners(page);
|
|
111
|
+
// Check for user account indicators
|
|
112
|
+
const accountEl = await page.$('[data-testid="header-sign-in-button"], ' +
|
|
113
|
+
'[data-testid="account-menu"], ' +
|
|
114
|
+
'button[data-testid="account-picker-trigger"], ' +
|
|
115
|
+
'[data-component="header-user-account"]');
|
|
116
|
+
const signInBtn = await page.$('a[href*="/sign-in"], a:has-text("Sign in"), a:has-text("Sign In"), ' +
|
|
117
|
+
'[data-testid="header-sign-in-button"]');
|
|
118
|
+
// If there's a sign-in button it means we're logged out
|
|
119
|
+
const isLoggedIn = signInBtn === null && accountEl !== null;
|
|
120
|
+
let userEmail;
|
|
121
|
+
let userName;
|
|
122
|
+
if (isLoggedIn && accountEl) {
|
|
123
|
+
try {
|
|
124
|
+
await accountEl.click();
|
|
125
|
+
await randomDelay(500, 1000);
|
|
126
|
+
const emailEl = await page.$('[data-testid="user-email"], .user-email, [class*="email"]');
|
|
127
|
+
if (emailEl) {
|
|
128
|
+
userEmail = (await emailEl.textContent()) || undefined;
|
|
129
|
+
}
|
|
130
|
+
const nameEl = await page.$('[data-testid="user-name"], .user-name, [class*="name"]');
|
|
131
|
+
if (nameEl) {
|
|
132
|
+
userName = (await nameEl.textContent()) || undefined;
|
|
133
|
+
}
|
|
134
|
+
await page.keyboard.press("Escape");
|
|
135
|
+
await randomDelay(300, 600);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// ignore menu interaction errors
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const sessionInfo = {
|
|
142
|
+
isLoggedIn,
|
|
143
|
+
userEmail: userEmail?.trim(),
|
|
144
|
+
userName: userName?.trim(),
|
|
145
|
+
lastUpdated: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
saveSessionInfo(sessionInfo);
|
|
148
|
+
await saveCookies(context);
|
|
149
|
+
return sessionInfo;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw new Error(`Failed to check login status: ${error instanceof Error ? error.message : String(error)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Initiate login flow
|
|
157
|
+
*/
|
|
158
|
+
export async function initiateLogin() {
|
|
159
|
+
const { page, context } = await initBrowser();
|
|
160
|
+
try {
|
|
161
|
+
await page.goto(`${BOOKING_BASE_URL}/sign-in`, {
|
|
162
|
+
waitUntil: "domcontentloaded",
|
|
163
|
+
timeout: DEFAULT_TIMEOUT,
|
|
164
|
+
});
|
|
165
|
+
await randomDelay();
|
|
166
|
+
await saveCookies(context);
|
|
167
|
+
return {
|
|
168
|
+
loginUrl: `${BOOKING_BASE_URL}/sign-in`,
|
|
169
|
+
instructions: "Please log in to Booking.com manually:\n" +
|
|
170
|
+
"1. Open the URL in your browser\n" +
|
|
171
|
+
"2. Sign in with your Booking.com account\n" +
|
|
172
|
+
"3. Once logged in, run 'booking_status' to verify the session\n\n" +
|
|
173
|
+
"Note: Session cookies are persisted to ~/.strider/booking/ for reuse.",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw new Error(`Failed to initiate login: ${error instanceof Error ? error.message : String(error)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Search hotels on Booking.com
|
|
182
|
+
*/
|
|
183
|
+
export async function searchProperties(destination, checkIn, checkOut, adults = 2, rooms = 1, children = 0, maxResults = 10) {
|
|
184
|
+
const { page, context } = await initBrowser();
|
|
185
|
+
try {
|
|
186
|
+
// Build search URL
|
|
187
|
+
const params = new URLSearchParams({
|
|
188
|
+
ss: destination,
|
|
189
|
+
checkin: checkIn,
|
|
190
|
+
checkout: checkOut,
|
|
191
|
+
group_adults: String(adults),
|
|
192
|
+
no_rooms: String(rooms),
|
|
193
|
+
group_children: String(children),
|
|
194
|
+
lang: "en-us",
|
|
195
|
+
});
|
|
196
|
+
const searchUrl = `${BOOKING_BASE_URL}/searchresults.html?${params.toString()}`;
|
|
197
|
+
await page.goto(searchUrl, {
|
|
198
|
+
waitUntil: "domcontentloaded",
|
|
199
|
+
timeout: DEFAULT_TIMEOUT,
|
|
200
|
+
});
|
|
201
|
+
await randomDelay();
|
|
202
|
+
await dismissBanners(page);
|
|
203
|
+
// Wait for results
|
|
204
|
+
await page
|
|
205
|
+
.waitForSelector('[data-testid="property-card"], [data-testid="property-card-container"]', { timeout: 15000 })
|
|
206
|
+
.catch(() => { });
|
|
207
|
+
await randomDelay(500, 1000);
|
|
208
|
+
const results = await page.evaluate((max) => {
|
|
209
|
+
const cards = document.querySelectorAll('[data-testid="property-card"], [data-testid="property-card-container"]');
|
|
210
|
+
const out = [];
|
|
211
|
+
cards.forEach((card, i) => {
|
|
212
|
+
if (i >= max)
|
|
213
|
+
return;
|
|
214
|
+
const nameEl = card.querySelector('[data-testid="title"], [data-testid="property-card-name"], .sr-hotel__name');
|
|
215
|
+
const locationEl = card.querySelector('[data-testid="address"], [data-testid="property-card-address"]');
|
|
216
|
+
const priceEl = card.querySelector('[data-testid="price-and-discounted-price"], [data-testid="price"], .bui-price-display__value');
|
|
217
|
+
const scoreEl = card.querySelector('[data-testid="review-score"], .bui-review-score__badge');
|
|
218
|
+
const reviewCountEl = card.querySelector('[data-testid="review-score-count"], .bui-review-score__text');
|
|
219
|
+
const distanceEl = card.querySelector('[data-testid="distance"], [data-testid="property-card-distance"]');
|
|
220
|
+
const imgEl = card.querySelector("img");
|
|
221
|
+
const linkEl = card.querySelector("a[href*='/hotel/']");
|
|
222
|
+
const starsEl = card.querySelectorAll('[class*="stars"] svg, [data-testid="rating-stars"] span');
|
|
223
|
+
const freeCancelEl = card.querySelector('[data-testid="free-cancellation-label"], :not([hidden]) [class*="free-cancel"]');
|
|
224
|
+
const breakfastEl = card.querySelector('[data-testid="breakfast-included-label"], :not([hidden]) [class*="breakfast"]');
|
|
225
|
+
const priceText = priceEl?.textContent?.trim() || "";
|
|
226
|
+
const priceMatch = priceText.match(/[\d,]+/);
|
|
227
|
+
const price = priceMatch ? priceMatch[0].replace(/,/g, "") : undefined;
|
|
228
|
+
const scoreText = scoreEl?.textContent?.trim() || "";
|
|
229
|
+
const scoreMatch = scoreText.match(/[\d.]+/);
|
|
230
|
+
const reviewScore = scoreMatch ? parseFloat(scoreMatch[0]) : undefined;
|
|
231
|
+
const reviewCountText = reviewCountEl?.textContent?.trim() || "";
|
|
232
|
+
const reviewCountMatch = reviewCountText.match(/[\d,]+/);
|
|
233
|
+
const reviewCount = reviewCountMatch
|
|
234
|
+
? parseInt(reviewCountMatch[0].replace(/,/g, ""), 10)
|
|
235
|
+
: undefined;
|
|
236
|
+
const propertyId = card.getAttribute("data-hotelid") ||
|
|
237
|
+
card.getAttribute("data-property-id") ||
|
|
238
|
+
(linkEl?.href.match(/\/hotel\/[a-z]{2}\/([^.]+)\./) || [])[1] ||
|
|
239
|
+
String(i);
|
|
240
|
+
out.push({
|
|
241
|
+
propertyId,
|
|
242
|
+
name: nameEl?.textContent?.trim() || "Unknown Property",
|
|
243
|
+
location: locationEl?.textContent?.trim() || "",
|
|
244
|
+
reviewScore,
|
|
245
|
+
reviewCount,
|
|
246
|
+
pricePerNight: price ? `${price}` : undefined,
|
|
247
|
+
imageUrl: imgEl?.src || undefined,
|
|
248
|
+
url: linkEl?.href || undefined,
|
|
249
|
+
distanceFromCenter: distanceEl?.textContent?.trim() || undefined,
|
|
250
|
+
stars: starsEl.length || undefined,
|
|
251
|
+
freeCancellation: !!freeCancelEl,
|
|
252
|
+
breakfastIncluded: !!breakfastEl,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
return out;
|
|
256
|
+
}, maxResults);
|
|
257
|
+
lastSearchResults = results;
|
|
258
|
+
await saveCookies(context);
|
|
259
|
+
return results;
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
throw new Error(`Failed to search properties: ${error instanceof Error ? error.message : String(error)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Get property details by URL or property ID
|
|
267
|
+
*/
|
|
268
|
+
export async function getPropertyDetails(propertyUrlOrId) {
|
|
269
|
+
const { page, context } = await initBrowser();
|
|
270
|
+
try {
|
|
271
|
+
let url = propertyUrlOrId;
|
|
272
|
+
if (!url.startsWith("http")) {
|
|
273
|
+
// Try to find in cached results
|
|
274
|
+
const cached = lastSearchResults.find((r) => r.propertyId === propertyUrlOrId);
|
|
275
|
+
if (cached?.url) {
|
|
276
|
+
url = cached.url;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
throw new Error("Property URL required (or run booking_search first to cache results)");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
await page.goto(url, {
|
|
283
|
+
waitUntil: "domcontentloaded",
|
|
284
|
+
timeout: DEFAULT_TIMEOUT,
|
|
285
|
+
});
|
|
286
|
+
await randomDelay();
|
|
287
|
+
await dismissBanners(page);
|
|
288
|
+
await page
|
|
289
|
+
.waitForSelector('h2[data-testid="property-name"], #hp_hotel_name', {
|
|
290
|
+
timeout: 10000,
|
|
291
|
+
})
|
|
292
|
+
.catch(() => { });
|
|
293
|
+
const details = await page.evaluate(() => {
|
|
294
|
+
const nameEl = document.querySelector('h2[data-testid="property-name"], #hp_hotel_name, h1.pp-header__title');
|
|
295
|
+
const addressEl = document.querySelector('[data-testid="address"], span.hp_address_subtitle');
|
|
296
|
+
const descEl = document.querySelector('[data-testid="property-description"], #property_description_content, #summary');
|
|
297
|
+
const scoreEl = document.querySelector('[data-testid="review-score-component"] [class*="score"], .bui-review-score__badge');
|
|
298
|
+
const amenityEls = document.querySelectorAll('[data-testid="facility-list-item"] span, [class*="hotel-facilities"] li, .important_facility');
|
|
299
|
+
const policyEls = document.querySelectorAll('[data-testid="property-policies"] li, .hotel-policies li');
|
|
300
|
+
const imgEls = document.querySelectorAll('[data-testid="property-gallery"] img, .photo-item img');
|
|
301
|
+
const amenities = [];
|
|
302
|
+
amenityEls.forEach((el) => {
|
|
303
|
+
const t = el.textContent?.trim();
|
|
304
|
+
if (t && amenities.length < 20)
|
|
305
|
+
amenities.push(t);
|
|
306
|
+
});
|
|
307
|
+
const policies = [];
|
|
308
|
+
policyEls.forEach((el) => {
|
|
309
|
+
const t = el.textContent?.trim();
|
|
310
|
+
if (t && policies.length < 10)
|
|
311
|
+
policies.push(t);
|
|
312
|
+
});
|
|
313
|
+
const images = [];
|
|
314
|
+
imgEls.forEach((el) => {
|
|
315
|
+
const src = el.src;
|
|
316
|
+
if (src && images.length < 5)
|
|
317
|
+
images.push(src);
|
|
318
|
+
});
|
|
319
|
+
const checkInEl = document.querySelector('[data-testid="check-in-time"], .check-in-time, [class*="checkin"]');
|
|
320
|
+
const checkOutEl = document.querySelector('[data-testid="check-out-time"], .check-out-time, [class*="checkout"]');
|
|
321
|
+
return {
|
|
322
|
+
name: nameEl?.textContent?.trim() || "Unknown",
|
|
323
|
+
address: addressEl?.textContent?.trim() || "",
|
|
324
|
+
description: descEl?.textContent?.trim().slice(0, 500) || "",
|
|
325
|
+
reviewScore: scoreEl?.textContent?.trim() || undefined,
|
|
326
|
+
amenities,
|
|
327
|
+
policies,
|
|
328
|
+
images,
|
|
329
|
+
checkIn: checkInEl?.textContent?.trim() || undefined,
|
|
330
|
+
checkOut: checkOutEl?.textContent?.trim() || undefined,
|
|
331
|
+
url: window.location.href,
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
await saveCookies(context);
|
|
335
|
+
return details;
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
throw new Error(`Failed to get property details: ${error instanceof Error ? error.message : String(error)}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Check room availability for a property
|
|
343
|
+
*/
|
|
344
|
+
export async function checkAvailability(propertyUrlOrId, checkIn, checkOut, adults = 2, rooms = 1) {
|
|
345
|
+
const { page, context } = await initBrowser();
|
|
346
|
+
try {
|
|
347
|
+
let url = propertyUrlOrId;
|
|
348
|
+
if (!url.startsWith("http")) {
|
|
349
|
+
const cached = lastSearchResults.find((r) => r.propertyId === propertyUrlOrId);
|
|
350
|
+
if (cached?.url) {
|
|
351
|
+
url = cached.url;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
throw new Error("Property URL required or run booking_search first");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Add date params to URL
|
|
358
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
359
|
+
const dateParams = `checkin=${checkIn}&checkout=${checkOut}&group_adults=${adults}&no_rooms=${rooms}`;
|
|
360
|
+
await page.goto(`${url}${separator}${dateParams}`, {
|
|
361
|
+
waitUntil: "domcontentloaded",
|
|
362
|
+
timeout: DEFAULT_TIMEOUT,
|
|
363
|
+
});
|
|
364
|
+
await randomDelay();
|
|
365
|
+
await dismissBanners(page);
|
|
366
|
+
await page
|
|
367
|
+
.waitForSelector('[data-testid="availability-table"], #available_rooms, table.roomstable', { timeout: 12000 })
|
|
368
|
+
.catch(() => { });
|
|
369
|
+
await randomDelay(500, 1000);
|
|
370
|
+
const roomsData = await page.evaluate(() => {
|
|
371
|
+
const roomRows = document.querySelectorAll('[data-testid="availability-row"], tr.js-rt-block, .room-type-row');
|
|
372
|
+
const out = [];
|
|
373
|
+
roomRows.forEach((row, i) => {
|
|
374
|
+
if (i >= 10)
|
|
375
|
+
return;
|
|
376
|
+
const nameEl = row.querySelector('[data-testid="room-type-name"], .room_type, td.ftd');
|
|
377
|
+
const priceEl = row.querySelector('[data-testid="price-for-x-nights"], .price, .bui-price-display__value');
|
|
378
|
+
const guestsEl = row.querySelector('[data-testid="occupancy"], .occupancy, [class*="occupancy"]');
|
|
379
|
+
const cancelEl = row.querySelector('[data-testid="cancellation-policy"], [class*="free-cancel"], [class*="freeCancellation"]');
|
|
380
|
+
const breakfastEl = row.querySelector('[data-testid="meal-plan"], [class*="breakfast"], [class*="meal"]');
|
|
381
|
+
const selectBtn = row.querySelector('button[data-testid="select-room"], button.book_now, input[type="submit"]');
|
|
382
|
+
const priceText = priceEl?.textContent?.trim() || "";
|
|
383
|
+
const priceMatch = priceText.match(/[\d,]+/);
|
|
384
|
+
out.push({
|
|
385
|
+
name: nameEl?.textContent?.trim() || `Room option ${i + 1}`,
|
|
386
|
+
price: priceMatch
|
|
387
|
+
? priceMatch[0].replace(/,/g, "")
|
|
388
|
+
: undefined,
|
|
389
|
+
maxGuests: guestsEl
|
|
390
|
+
? parseInt(guestsEl.textContent?.match(/\d+/)?.[0] || "0", 10) ||
|
|
391
|
+
undefined
|
|
392
|
+
: undefined,
|
|
393
|
+
freeCancellation: cancelEl?.textContent?.toLowerCase().includes("free") || false,
|
|
394
|
+
breakfastIncluded: breakfastEl?.textContent?.toLowerCase().includes("breakfast") ||
|
|
395
|
+
false,
|
|
396
|
+
available: !!selectBtn,
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
return out;
|
|
400
|
+
});
|
|
401
|
+
await saveCookies(context);
|
|
402
|
+
const available = roomsData.some((r) => r.available);
|
|
403
|
+
return {
|
|
404
|
+
available,
|
|
405
|
+
rooms: roomsData,
|
|
406
|
+
message: available
|
|
407
|
+
? `${roomsData.filter((r) => r.available).length} room type(s) available`
|
|
408
|
+
: "No rooms available for selected dates",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
throw new Error(`Failed to check availability: ${error instanceof Error ? error.message : String(error)}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Get prices for a property
|
|
417
|
+
*/
|
|
418
|
+
export async function getPrices(propertyUrlOrId, checkIn, checkOut, adults = 2, rooms = 1) {
|
|
419
|
+
const avail = await checkAvailability(propertyUrlOrId, checkIn, checkOut, adults, rooms);
|
|
420
|
+
const { page, context } = await initBrowser();
|
|
421
|
+
// Try to detect currency from page
|
|
422
|
+
let currency = "USD";
|
|
423
|
+
try {
|
|
424
|
+
const currencyEl = await page.$('[data-testid="currency-selector"], [class*="currency"] span');
|
|
425
|
+
if (currencyEl) {
|
|
426
|
+
currency = (await currencyEl.textContent())?.trim() || "USD";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// ignore
|
|
431
|
+
}
|
|
432
|
+
await saveCookies(context);
|
|
433
|
+
const prices = avail.rooms.filter((r) => r.price);
|
|
434
|
+
const lowest = prices.length > 0
|
|
435
|
+
? prices.reduce((min, r) => parseFloat(r.price || "9999") < parseFloat(min.price || "9999")
|
|
436
|
+
? r
|
|
437
|
+
: min)
|
|
438
|
+
: undefined;
|
|
439
|
+
return {
|
|
440
|
+
prices: avail.rooms,
|
|
441
|
+
currency,
|
|
442
|
+
lowestPrice: lowest?.price,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Filter cached search results
|
|
447
|
+
*/
|
|
448
|
+
export function filterResults(filters) {
|
|
449
|
+
let results = [...lastSearchResults];
|
|
450
|
+
if (filters.minPrice !== undefined) {
|
|
451
|
+
results = results.filter((r) => r.pricePerNight !== undefined &&
|
|
452
|
+
parseFloat(r.pricePerNight) >= filters.minPrice);
|
|
453
|
+
}
|
|
454
|
+
if (filters.maxPrice !== undefined) {
|
|
455
|
+
results = results.filter((r) => r.pricePerNight !== undefined &&
|
|
456
|
+
parseFloat(r.pricePerNight) <= filters.maxPrice);
|
|
457
|
+
}
|
|
458
|
+
if (filters.minRating !== undefined) {
|
|
459
|
+
results = results.filter((r) => r.reviewScore !== undefined && r.reviewScore >= filters.minRating);
|
|
460
|
+
}
|
|
461
|
+
if (filters.freeCancellation) {
|
|
462
|
+
results = results.filter((r) => r.freeCancellation === true);
|
|
463
|
+
}
|
|
464
|
+
if (filters.breakfastIncluded) {
|
|
465
|
+
results = results.filter((r) => r.breakfastIncluded === true);
|
|
466
|
+
}
|
|
467
|
+
if (filters.stars !== undefined) {
|
|
468
|
+
results = results.filter((r) => r.stars === filters.stars);
|
|
469
|
+
}
|
|
470
|
+
if (filters.keyword) {
|
|
471
|
+
const kw = filters.keyword.toLowerCase();
|
|
472
|
+
results = results.filter((r) => r.name.toLowerCase().includes(kw) ||
|
|
473
|
+
r.location.toLowerCase().includes(kw));
|
|
474
|
+
}
|
|
475
|
+
return results;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Sort cached search results
|
|
479
|
+
*/
|
|
480
|
+
export function sortResults(sortBy) {
|
|
481
|
+
const results = [...lastSearchResults];
|
|
482
|
+
switch (sortBy) {
|
|
483
|
+
case "price_asc":
|
|
484
|
+
return results.sort((a, b) => parseFloat(a.pricePerNight || "9999") -
|
|
485
|
+
parseFloat(b.pricePerNight || "9999"));
|
|
486
|
+
case "price_desc":
|
|
487
|
+
return results.sort((a, b) => parseFloat(b.pricePerNight || "0") -
|
|
488
|
+
parseFloat(a.pricePerNight || "0"));
|
|
489
|
+
case "rating":
|
|
490
|
+
return results.sort((a, b) => (b.reviewScore || 0) - (a.reviewScore || 0));
|
|
491
|
+
case "reviews":
|
|
492
|
+
return results.sort((a, b) => (b.reviewCount || 0) - (a.reviewCount || 0));
|
|
493
|
+
case "distance":
|
|
494
|
+
return results.sort((a, b) => {
|
|
495
|
+
const da = parseFloat(a.distanceFromCenter?.match(/[\d.]+/)?.[0] || "9999");
|
|
496
|
+
const db = parseFloat(b.distanceFromCenter?.match(/[\d.]+/)?.[0] || "9999");
|
|
497
|
+
return da - db;
|
|
498
|
+
});
|
|
499
|
+
default:
|
|
500
|
+
return results;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Save a property to favorites (wishlist)
|
|
505
|
+
*/
|
|
506
|
+
export async function saveProperty(propertyUrlOrId) {
|
|
507
|
+
const { page, context } = await initBrowser();
|
|
508
|
+
try {
|
|
509
|
+
let url = propertyUrlOrId;
|
|
510
|
+
if (!url.startsWith("http")) {
|
|
511
|
+
const cached = lastSearchResults.find((r) => r.propertyId === propertyUrlOrId);
|
|
512
|
+
if (cached?.url) {
|
|
513
|
+
url = cached.url;
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
throw new Error("Property URL required or run booking_search first");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
await page.goto(url, {
|
|
520
|
+
waitUntil: "domcontentloaded",
|
|
521
|
+
timeout: DEFAULT_TIMEOUT,
|
|
522
|
+
});
|
|
523
|
+
await randomDelay();
|
|
524
|
+
await dismissBanners(page);
|
|
525
|
+
// Look for wishlist/save/heart button
|
|
526
|
+
const saveBtn = await page.$('[data-testid="wishlist-button"], button[aria-label*="Save"], button[aria-label*="wishlist"], ' +
|
|
527
|
+
'[class*="wishlist"] button, button.save-button');
|
|
528
|
+
if (!saveBtn) {
|
|
529
|
+
return {
|
|
530
|
+
success: false,
|
|
531
|
+
message: "Could not find save/wishlist button. Make sure you are logged in.",
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
await saveBtn.click();
|
|
535
|
+
await randomDelay(500, 1000);
|
|
536
|
+
await saveCookies(context);
|
|
537
|
+
return {
|
|
538
|
+
success: true,
|
|
539
|
+
message: "Property saved to your wishlist",
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
throw new Error(`Failed to save property: ${error instanceof Error ? error.message : String(error)}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Book a room (with explicit confirmation required)
|
|
548
|
+
*/
|
|
549
|
+
export async function bookRoom(propertyUrlOrId, checkIn, checkOut, adults = 2, rooms = 1, confirmBooking = false) {
|
|
550
|
+
if (!confirmBooking) {
|
|
551
|
+
// Return a preview / dry-run
|
|
552
|
+
let previewUrl = propertyUrlOrId;
|
|
553
|
+
if (!previewUrl.startsWith("http")) {
|
|
554
|
+
const cached = lastSearchResults.find((r) => r.propertyId === propertyUrlOrId);
|
|
555
|
+
if (cached?.url)
|
|
556
|
+
previewUrl = cached.url;
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
requiresConfirmation: true,
|
|
560
|
+
summary: {
|
|
561
|
+
propertyUrl: previewUrl,
|
|
562
|
+
checkIn,
|
|
563
|
+
checkOut,
|
|
564
|
+
adults,
|
|
565
|
+
rooms,
|
|
566
|
+
message: "Booking not placed. To confirm, call booking_book with confirm=true. " +
|
|
567
|
+
"IMPORTANT: Only set confirm=true after explicit user confirmation.",
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const { page, context } = await initBrowser();
|
|
572
|
+
try {
|
|
573
|
+
let url = propertyUrlOrId;
|
|
574
|
+
if (!url.startsWith("http")) {
|
|
575
|
+
const cached = lastSearchResults.find((r) => r.propertyId === propertyUrlOrId);
|
|
576
|
+
if (cached?.url) {
|
|
577
|
+
url = cached.url;
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
throw new Error("Property URL required or run booking_search first");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
584
|
+
const dateParams = `checkin=${checkIn}&checkout=${checkOut}&group_adults=${adults}&no_rooms=${rooms}`;
|
|
585
|
+
await page.goto(`${url}${separator}${dateParams}`, {
|
|
586
|
+
waitUntil: "domcontentloaded",
|
|
587
|
+
timeout: DEFAULT_TIMEOUT,
|
|
588
|
+
});
|
|
589
|
+
await randomDelay();
|
|
590
|
+
await dismissBanners(page);
|
|
591
|
+
await page
|
|
592
|
+
.waitForSelector('[data-testid="availability-table"], #available_rooms', { timeout: 12000 })
|
|
593
|
+
.catch(() => { });
|
|
594
|
+
// Select first available room
|
|
595
|
+
const selectBtn = await page.$('button[data-testid="select-room"], button.book_now, input[type="submit"][value*="Reserve"], input[type="submit"][value*="Book"]');
|
|
596
|
+
if (!selectBtn) {
|
|
597
|
+
throw new Error("No available rooms to book. Try checking availability first.");
|
|
598
|
+
}
|
|
599
|
+
await selectBtn.click();
|
|
600
|
+
await randomDelay(1000, 2000);
|
|
601
|
+
// Wait for booking page
|
|
602
|
+
await page
|
|
603
|
+
.waitForURL(/\/book\//, { timeout: 15000 })
|
|
604
|
+
.catch(() => { });
|
|
605
|
+
// Look for final confirm button
|
|
606
|
+
const confirmBtn = await page.$('button[data-testid="confirm-booking"], button:has-text("Complete booking"), ' +
|
|
607
|
+
'button:has-text("Reserve"), input[type="submit"][value*="Complete"]');
|
|
608
|
+
if (!confirmBtn) {
|
|
609
|
+
return {
|
|
610
|
+
success: false,
|
|
611
|
+
message: "Reached booking page but could not find final confirm button. " +
|
|
612
|
+
"Manual completion may be required at: " +
|
|
613
|
+
(await page.url()),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
await confirmBtn.click();
|
|
617
|
+
await randomDelay(2000, 3000);
|
|
618
|
+
// Extract confirmation number
|
|
619
|
+
const bookingId = await page
|
|
620
|
+
.$eval('[data-testid="confirmation-number"], [class*="confirmation"] span, .conf-number', (el) => el.textContent?.trim())
|
|
621
|
+
.catch(() => undefined);
|
|
622
|
+
await saveCookies(context);
|
|
623
|
+
return {
|
|
624
|
+
success: true,
|
|
625
|
+
bookingId: bookingId || "Confirmation pending",
|
|
626
|
+
message: `Booking placed successfully! Confirmation: ${bookingId || "check your email"}`,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
throw new Error(`Failed to book room: ${error instanceof Error ? error.message : String(error)}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Get current reservations
|
|
635
|
+
*/
|
|
636
|
+
export async function getReservations() {
|
|
637
|
+
const { page, context } = await initBrowser();
|
|
638
|
+
try {
|
|
639
|
+
await page.goto(`${BOOKING_BASE_URL}/account/trips`, {
|
|
640
|
+
waitUntil: "domcontentloaded",
|
|
641
|
+
timeout: DEFAULT_TIMEOUT,
|
|
642
|
+
});
|
|
643
|
+
await randomDelay();
|
|
644
|
+
await dismissBanners(page);
|
|
645
|
+
await page
|
|
646
|
+
.waitForSelector('[data-testid="booking-card"], .booking-item, [class*="trip-card"]', { timeout: 12000 })
|
|
647
|
+
.catch(() => { });
|
|
648
|
+
const reservations = await page.evaluate(() => {
|
|
649
|
+
const cards = document.querySelectorAll('[data-testid="booking-card"], .booking-item, [class*="trip-card"], [class*="booking-card"]');
|
|
650
|
+
const out = [];
|
|
651
|
+
cards.forEach((card, i) => {
|
|
652
|
+
if (i >= 20)
|
|
653
|
+
return;
|
|
654
|
+
const nameEl = card.querySelector('[data-testid="booking-name"], .hotel-name, [class*="property-name"], h3, h2');
|
|
655
|
+
const checkInEl = card.querySelector('[data-testid="check-in-date"], [class*="checkin"], [class*="check-in"]');
|
|
656
|
+
const checkOutEl = card.querySelector('[data-testid="check-out-date"], [class*="checkout"], [class*="check-out"]');
|
|
657
|
+
const priceEl = card.querySelector('[data-testid="booking-price"], [class*="price"], [class*="total"]');
|
|
658
|
+
const statusEl = card.querySelector('[data-testid="booking-status"], [class*="status"], [class*="state"]');
|
|
659
|
+
const confirmEl = card.querySelector('[data-testid="confirmation-number"], [class*="confirmation"], [class*="pin"]');
|
|
660
|
+
const reservationId = card.getAttribute("data-booking-id") ||
|
|
661
|
+
card.getAttribute("data-reservation-id") ||
|
|
662
|
+
confirmEl?.textContent?.trim() ||
|
|
663
|
+
String(i);
|
|
664
|
+
out.push({
|
|
665
|
+
reservationId,
|
|
666
|
+
propertyName: nameEl?.textContent?.trim() || "Unknown Property",
|
|
667
|
+
checkIn: checkInEl?.textContent?.trim() || "",
|
|
668
|
+
checkOut: checkOutEl?.textContent?.trim() || "",
|
|
669
|
+
totalPrice: priceEl?.textContent?.trim() || undefined,
|
|
670
|
+
status: statusEl?.textContent?.trim() || "Unknown",
|
|
671
|
+
confirmationNumber: confirmEl?.textContent?.trim() || undefined,
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
return out;
|
|
675
|
+
});
|
|
676
|
+
await saveCookies(context);
|
|
677
|
+
return reservations;
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
throw new Error(`Failed to get reservations: ${error instanceof Error ? error.message : String(error)}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Cancel a reservation
|
|
685
|
+
*/
|
|
686
|
+
export async function cancelReservation(reservationId, confirmCancellation = false) {
|
|
687
|
+
if (!confirmCancellation) {
|
|
688
|
+
return {
|
|
689
|
+
success: false,
|
|
690
|
+
message: `Cancellation not performed. To cancel reservation ${reservationId}, ` +
|
|
691
|
+
"call booking_cancel_reservation with confirm=true. " +
|
|
692
|
+
"IMPORTANT: This action cannot be undone.",
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const { page, context } = await initBrowser();
|
|
696
|
+
try {
|
|
697
|
+
await page.goto(`${BOOKING_BASE_URL}/account/trips`, {
|
|
698
|
+
waitUntil: "domcontentloaded",
|
|
699
|
+
timeout: DEFAULT_TIMEOUT,
|
|
700
|
+
});
|
|
701
|
+
await randomDelay();
|
|
702
|
+
await dismissBanners(page);
|
|
703
|
+
// Look for the specific booking cancel button
|
|
704
|
+
const cancelBtn = await page.$(`[data-booking-id="${reservationId}"] button[class*="cancel"], ` +
|
|
705
|
+
`[data-reservation-id="${reservationId}"] button[class*="cancel"], ` +
|
|
706
|
+
'button[aria-label*="Cancel booking"], button:has-text("Cancel booking")');
|
|
707
|
+
if (!cancelBtn) {
|
|
708
|
+
return {
|
|
709
|
+
success: false,
|
|
710
|
+
message: `Could not find cancel button for reservation ${reservationId}. ` +
|
|
711
|
+
"Navigate to your bookings page manually to cancel.",
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
await cancelBtn.click();
|
|
715
|
+
await randomDelay(1000, 1500);
|
|
716
|
+
// Confirm cancellation dialog
|
|
717
|
+
const confirmBtn = await page.$('button[data-testid="confirm-cancel"], button:has-text("Confirm cancellation"), ' +
|
|
718
|
+
'button:has-text("Yes, cancel"), button:has-text("Confirm")');
|
|
719
|
+
if (confirmBtn) {
|
|
720
|
+
await confirmBtn.click();
|
|
721
|
+
await randomDelay(1000, 2000);
|
|
722
|
+
}
|
|
723
|
+
await saveCookies(context);
|
|
724
|
+
return {
|
|
725
|
+
success: true,
|
|
726
|
+
message: `Reservation ${reservationId} cancellation initiated. Check your email for confirmation.`,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
throw new Error(`Failed to cancel reservation: ${error instanceof Error ? error.message : String(error)}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Get reviews for a property
|
|
735
|
+
*/
|
|
736
|
+
export async function getPropertyReviews(propertyUrlOrId, maxReviews = 10) {
|
|
737
|
+
const { page, context } = await initBrowser();
|
|
738
|
+
try {
|
|
739
|
+
let url = propertyUrlOrId;
|
|
740
|
+
if (!url.startsWith("http")) {
|
|
741
|
+
const cached = lastSearchResults.find((r) => r.propertyId === propertyUrlOrId);
|
|
742
|
+
if (cached?.url) {
|
|
743
|
+
url = cached.url;
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
throw new Error("Property URL required or run booking_search first");
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Navigate to reviews tab
|
|
750
|
+
const reviewsUrl = url.includes("#reviews")
|
|
751
|
+
? url
|
|
752
|
+
: `${url}#reviews_list`;
|
|
753
|
+
await page.goto(reviewsUrl, {
|
|
754
|
+
waitUntil: "domcontentloaded",
|
|
755
|
+
timeout: DEFAULT_TIMEOUT,
|
|
756
|
+
});
|
|
757
|
+
await randomDelay();
|
|
758
|
+
await dismissBanners(page);
|
|
759
|
+
// Try to click reviews tab if available
|
|
760
|
+
try {
|
|
761
|
+
const reviewsTab = await page.$('a[href="#reviews_list"], [data-testid="reviews-tab"], button:has-text("Reviews")');
|
|
762
|
+
if (reviewsTab) {
|
|
763
|
+
await reviewsTab.click();
|
|
764
|
+
await randomDelay(500, 1000);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
// ignore
|
|
769
|
+
}
|
|
770
|
+
await page
|
|
771
|
+
.waitForSelector('[data-testid="review-card"], [class*="review-block"], .review_list_new_item_block', { timeout: 10000 })
|
|
772
|
+
.catch(() => { });
|
|
773
|
+
const data = await page.evaluate((max) => {
|
|
774
|
+
const cards = document.querySelectorAll('[data-testid="review-card"], [class*="review-block"], .review_list_new_item_block');
|
|
775
|
+
const reviews = [];
|
|
776
|
+
cards.forEach((card, i) => {
|
|
777
|
+
if (i >= max)
|
|
778
|
+
return;
|
|
779
|
+
const nameEl = card.querySelector('[data-testid="reviewer-name"], .reviewer_name, [class*="reviewer"]');
|
|
780
|
+
const dateEl = card.querySelector('[data-testid="review-date"], .review_date, [class*="date"]');
|
|
781
|
+
const scoreEl = card.querySelector('[data-testid="review-score"], .bui-review-score__badge, [class*="score"]');
|
|
782
|
+
const titleEl = card.querySelector('[data-testid="review-title"], .review_item_header_content, [class*="title"]');
|
|
783
|
+
const posEl = card.querySelector('[data-testid="review-positive"], .review_pos, [class*="positive"]');
|
|
784
|
+
const negEl = card.querySelector('[data-testid="review-negative"], .review_neg, [class*="negative"]');
|
|
785
|
+
const countryEl = card.querySelector('[data-testid="reviewer-country"], [class*="country"], .reviewer_flags');
|
|
786
|
+
const scoreText = scoreEl?.textContent?.trim() || "";
|
|
787
|
+
const scoreMatch = scoreText.match(/[\d.]+/);
|
|
788
|
+
reviews.push({
|
|
789
|
+
reviewer: nameEl?.textContent?.trim() || undefined,
|
|
790
|
+
date: dateEl?.textContent?.trim() || undefined,
|
|
791
|
+
score: scoreMatch ? parseFloat(scoreMatch[0]) : undefined,
|
|
792
|
+
title: titleEl?.textContent?.trim() || undefined,
|
|
793
|
+
positives: posEl?.textContent?.trim().slice(0, 300) || undefined,
|
|
794
|
+
negatives: negEl?.textContent?.trim().slice(0, 300) || undefined,
|
|
795
|
+
country: countryEl?.textContent?.trim() || undefined,
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
// Overall score
|
|
799
|
+
const overallScoreEl = document.querySelector('[data-testid="review-score-component"] [class*="score-value"], .bui-review-score__badge');
|
|
800
|
+
const overallScoreText = overallScoreEl?.textContent?.trim() || "";
|
|
801
|
+
const overallMatch = overallScoreText.match(/[\d.]+/);
|
|
802
|
+
// Total reviews count
|
|
803
|
+
const totalEl = document.querySelector('[data-testid="review-count"], [class*="review-count"], .reviews_header_score');
|
|
804
|
+
const totalText = totalEl?.textContent?.trim() || "";
|
|
805
|
+
const totalMatch = totalText.match(/[\d,]+/);
|
|
806
|
+
return {
|
|
807
|
+
reviews,
|
|
808
|
+
averageScore: overallMatch ? parseFloat(overallMatch[0]) : undefined,
|
|
809
|
+
totalReviews: totalMatch
|
|
810
|
+
? parseInt(totalMatch[0].replace(/,/g, ""), 10)
|
|
811
|
+
: undefined,
|
|
812
|
+
};
|
|
813
|
+
}, maxReviews);
|
|
814
|
+
await saveCookies(context);
|
|
815
|
+
return data;
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
throw new Error(`Failed to get reviews: ${error instanceof Error ? error.message : String(error)}`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// Process cleanup
|
|
822
|
+
process.on("exit", () => {
|
|
823
|
+
if (browser) {
|
|
824
|
+
browser.close().catch(() => { });
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
process.on("SIGINT", async () => {
|
|
828
|
+
await closeBrowser();
|
|
829
|
+
process.exit(0);
|
|
830
|
+
});
|
|
831
|
+
process.on("SIGTERM", async () => {
|
|
832
|
+
await closeBrowser();
|
|
833
|
+
process.exit(0);
|
|
834
|
+
});
|