@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.
@@ -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
+ });