@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/src/browser.ts ADDED
@@ -0,0 +1,1281 @@
1
+ /**
2
+ * Strider Labs - Booking.com Browser Automation
3
+ *
4
+ * Playwright-based browser automation for Booking.com operations.
5
+ */
6
+
7
+ import { chromium, Browser, BrowserContext, Page } from "playwright";
8
+ import {
9
+ saveCookies,
10
+ loadCookies,
11
+ saveSessionInfo,
12
+ type SessionInfo,
13
+ } from "./auth.js";
14
+
15
+ const BOOKING_BASE_URL = "https://www.booking.com";
16
+ const DEFAULT_TIMEOUT = 30000;
17
+
18
+ // Singleton browser instance
19
+ let browser: Browser | null = null;
20
+ let context: BrowserContext | null = null;
21
+ let page: Page | null = null;
22
+
23
+ // In-memory search results cache for filter/sort operations
24
+ let lastSearchResults: PropertyResult[] = [];
25
+
26
+ /** Random delay between 500-2000ms for human-like behaviour */
27
+ async function randomDelay(min = 500, max = 2000): Promise<void> {
28
+ const ms = Math.floor(Math.random() * (max - min + 1)) + min;
29
+ await new Promise((resolve) => setTimeout(resolve, ms));
30
+ }
31
+
32
+ export interface PropertyResult {
33
+ propertyId: string;
34
+ name: string;
35
+ location: string;
36
+ rating?: number;
37
+ reviewScore?: number;
38
+ reviewCount?: number;
39
+ pricePerNight?: string;
40
+ totalPrice?: string;
41
+ currency?: string;
42
+ imageUrl?: string;
43
+ url?: string;
44
+ distanceFromCenter?: string;
45
+ stars?: number;
46
+ freeCancellation?: boolean;
47
+ breakfastIncluded?: boolean;
48
+ }
49
+
50
+ export interface RoomOption {
51
+ roomId?: string;
52
+ name: string;
53
+ maxGuests?: number;
54
+ bedType?: string;
55
+ price?: string;
56
+ totalPrice?: string;
57
+ freeCancellation?: boolean;
58
+ breakfastIncluded?: boolean;
59
+ available: boolean;
60
+ }
61
+
62
+ export interface Reservation {
63
+ reservationId: string;
64
+ propertyName: string;
65
+ checkIn: string;
66
+ checkOut: string;
67
+ guests?: number;
68
+ totalPrice?: string;
69
+ status: string;
70
+ confirmationNumber?: string;
71
+ }
72
+
73
+ export interface Review {
74
+ reviewer?: string;
75
+ date?: string;
76
+ score?: number;
77
+ title?: string;
78
+ positives?: string;
79
+ negatives?: string;
80
+ country?: string;
81
+ }
82
+
83
+ /**
84
+ * Initialize browser with stealth settings
85
+ */
86
+ async function initBrowser(): Promise<{
87
+ browser: Browser;
88
+ context: BrowserContext;
89
+ page: Page;
90
+ }> {
91
+ if (browser && context && page) {
92
+ return { browser, context, page };
93
+ }
94
+
95
+ browser = await chromium.launch({
96
+ headless: true,
97
+ args: [
98
+ "--disable-blink-features=AutomationControlled",
99
+ "--no-sandbox",
100
+ "--disable-setuid-sandbox",
101
+ "--disable-dev-shm-usage",
102
+ "--disable-web-security",
103
+ "--disable-features=VizDisplayCompositor",
104
+ ],
105
+ });
106
+
107
+ context = await browser.newContext({
108
+ userAgent:
109
+ "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",
110
+ viewport: { width: 1366, height: 768 },
111
+ locale: "en-US",
112
+ timezoneId: "America/New_York",
113
+ extraHTTPHeaders: {
114
+ "Accept-Language": "en-US,en;q=0.9",
115
+ },
116
+ });
117
+
118
+ // Load saved cookies if available
119
+ const cookiesLoaded = await loadCookies(context);
120
+ if (cookiesLoaded) {
121
+ console.error("Loaded saved Booking.com cookies");
122
+ }
123
+
124
+ page = await context.newPage();
125
+
126
+ // Stealth patches
127
+ await page.addInitScript(() => {
128
+ Object.defineProperty(navigator, "webdriver", { get: () => false });
129
+ Object.defineProperty(navigator, "plugins", {
130
+ get: () => [1, 2, 3, 4, 5],
131
+ });
132
+ Object.defineProperty(navigator, "languages", {
133
+ get: () => ["en-US", "en"],
134
+ });
135
+ // @ts-ignore
136
+ window.chrome = { runtime: {} };
137
+ });
138
+
139
+ return { browser, context, page };
140
+ }
141
+
142
+ /**
143
+ * Close browser and save state
144
+ */
145
+ export async function closeBrowser(): Promise<void> {
146
+ if (context) {
147
+ await saveCookies(context);
148
+ }
149
+ if (browser) {
150
+ await browser.close();
151
+ browser = null;
152
+ context = null;
153
+ page = null;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Dismiss cookie/GDPR banners if present
159
+ */
160
+ async function dismissBanners(p: Page): Promise<void> {
161
+ try {
162
+ const acceptBtn = await p.$(
163
+ '[id*="onetrust-accept"], button[data-gdpr-consent="accept"], ' +
164
+ 'button:has-text("Accept"), button:has-text("I accept"), ' +
165
+ '[data-testid="accept-all-button"]'
166
+ );
167
+ if (acceptBtn) {
168
+ await acceptBtn.click();
169
+ await randomDelay(300, 700);
170
+ }
171
+ } catch {
172
+ // Ignore
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Check Booking.com login status
178
+ */
179
+ export async function checkLoginStatus(): Promise<SessionInfo> {
180
+ const { page, context } = await initBrowser();
181
+
182
+ try {
183
+ await page.goto(BOOKING_BASE_URL, {
184
+ waitUntil: "domcontentloaded",
185
+ timeout: DEFAULT_TIMEOUT,
186
+ });
187
+ await randomDelay();
188
+ await dismissBanners(page);
189
+
190
+ // Check for user account indicators
191
+ const accountEl = await page.$(
192
+ '[data-testid="header-sign-in-button"], ' +
193
+ '[data-testid="account-menu"], ' +
194
+ 'button[data-testid="account-picker-trigger"], ' +
195
+ '[data-component="header-user-account"]'
196
+ );
197
+
198
+ const signInBtn = await page.$(
199
+ 'a[href*="/sign-in"], a:has-text("Sign in"), a:has-text("Sign In"), ' +
200
+ '[data-testid="header-sign-in-button"]'
201
+ );
202
+
203
+ // If there's a sign-in button it means we're logged out
204
+ const isLoggedIn = signInBtn === null && accountEl !== null;
205
+
206
+ let userEmail: string | undefined;
207
+ let userName: string | undefined;
208
+
209
+ if (isLoggedIn && accountEl) {
210
+ try {
211
+ await accountEl.click();
212
+ await randomDelay(500, 1000);
213
+ const emailEl = await page.$('[data-testid="user-email"], .user-email, [class*="email"]');
214
+ if (emailEl) {
215
+ userEmail = (await emailEl.textContent()) || undefined;
216
+ }
217
+ const nameEl = await page.$('[data-testid="user-name"], .user-name, [class*="name"]');
218
+ if (nameEl) {
219
+ userName = (await nameEl.textContent()) || undefined;
220
+ }
221
+ await page.keyboard.press("Escape");
222
+ await randomDelay(300, 600);
223
+ } catch {
224
+ // ignore menu interaction errors
225
+ }
226
+ }
227
+
228
+ const sessionInfo: SessionInfo = {
229
+ isLoggedIn,
230
+ userEmail: userEmail?.trim(),
231
+ userName: userName?.trim(),
232
+ lastUpdated: new Date().toISOString(),
233
+ };
234
+
235
+ saveSessionInfo(sessionInfo);
236
+ await saveCookies(context);
237
+
238
+ return sessionInfo;
239
+ } catch (error) {
240
+ throw new Error(
241
+ `Failed to check login status: ${
242
+ error instanceof Error ? error.message : String(error)
243
+ }`
244
+ );
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Initiate login flow
250
+ */
251
+ export async function initiateLogin(): Promise<{
252
+ loginUrl: string;
253
+ instructions: string;
254
+ }> {
255
+ const { page, context } = await initBrowser();
256
+
257
+ try {
258
+ await page.goto(`${BOOKING_BASE_URL}/sign-in`, {
259
+ waitUntil: "domcontentloaded",
260
+ timeout: DEFAULT_TIMEOUT,
261
+ });
262
+ await randomDelay();
263
+ await saveCookies(context);
264
+
265
+ return {
266
+ loginUrl: `${BOOKING_BASE_URL}/sign-in`,
267
+ instructions:
268
+ "Please log in to Booking.com manually:\n" +
269
+ "1. Open the URL in your browser\n" +
270
+ "2. Sign in with your Booking.com account\n" +
271
+ "3. Once logged in, run 'booking_status' to verify the session\n\n" +
272
+ "Note: Session cookies are persisted to ~/.strider/booking/ for reuse.",
273
+ };
274
+ } catch (error) {
275
+ throw new Error(
276
+ `Failed to initiate login: ${
277
+ error instanceof Error ? error.message : String(error)
278
+ }`
279
+ );
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Search hotels on Booking.com
285
+ */
286
+ export async function searchProperties(
287
+ destination: string,
288
+ checkIn: string,
289
+ checkOut: string,
290
+ adults: number = 2,
291
+ rooms: number = 1,
292
+ children: number = 0,
293
+ maxResults: number = 10
294
+ ): Promise<PropertyResult[]> {
295
+ const { page, context } = await initBrowser();
296
+
297
+ try {
298
+ // Build search URL
299
+ const params = new URLSearchParams({
300
+ ss: destination,
301
+ checkin: checkIn,
302
+ checkout: checkOut,
303
+ group_adults: String(adults),
304
+ no_rooms: String(rooms),
305
+ group_children: String(children),
306
+ lang: "en-us",
307
+ });
308
+
309
+ const searchUrl = `${BOOKING_BASE_URL}/searchresults.html?${params.toString()}`;
310
+ await page.goto(searchUrl, {
311
+ waitUntil: "domcontentloaded",
312
+ timeout: DEFAULT_TIMEOUT,
313
+ });
314
+ await randomDelay();
315
+ await dismissBanners(page);
316
+
317
+ // Wait for results
318
+ await page
319
+ .waitForSelector(
320
+ '[data-testid="property-card"], [data-testid="property-card-container"]',
321
+ { timeout: 15000 }
322
+ )
323
+ .catch(() => {});
324
+
325
+ await randomDelay(500, 1000);
326
+
327
+ const results = await page.evaluate((max: number) => {
328
+ const cards = document.querySelectorAll(
329
+ '[data-testid="property-card"], [data-testid="property-card-container"]'
330
+ );
331
+ const out: PropertyResult[] = [];
332
+
333
+ cards.forEach((card, i) => {
334
+ if (i >= max) return;
335
+
336
+ const nameEl = card.querySelector(
337
+ '[data-testid="title"], [data-testid="property-card-name"], .sr-hotel__name'
338
+ );
339
+ const locationEl = card.querySelector(
340
+ '[data-testid="address"], [data-testid="property-card-address"]'
341
+ );
342
+ const priceEl = card.querySelector(
343
+ '[data-testid="price-and-discounted-price"], [data-testid="price"], .bui-price-display__value'
344
+ );
345
+ const scoreEl = card.querySelector(
346
+ '[data-testid="review-score"], .bui-review-score__badge'
347
+ );
348
+ const reviewCountEl = card.querySelector(
349
+ '[data-testid="review-score-count"], .bui-review-score__text'
350
+ );
351
+ const distanceEl = card.querySelector(
352
+ '[data-testid="distance"], [data-testid="property-card-distance"]'
353
+ );
354
+ const imgEl = card.querySelector("img");
355
+ const linkEl = card.querySelector("a[href*='/hotel/']") as HTMLAnchorElement | null;
356
+
357
+ const starsEl = card.querySelectorAll(
358
+ '[class*="stars"] svg, [data-testid="rating-stars"] span'
359
+ );
360
+
361
+ const freeCancelEl = card.querySelector(
362
+ '[data-testid="free-cancellation-label"], :not([hidden]) [class*="free-cancel"]'
363
+ );
364
+ const breakfastEl = card.querySelector(
365
+ '[data-testid="breakfast-included-label"], :not([hidden]) [class*="breakfast"]'
366
+ );
367
+
368
+ const priceText = priceEl?.textContent?.trim() || "";
369
+ const priceMatch = priceText.match(/[\d,]+/);
370
+ const price = priceMatch ? priceMatch[0].replace(/,/g, "") : undefined;
371
+
372
+ const scoreText = scoreEl?.textContent?.trim() || "";
373
+ const scoreMatch = scoreText.match(/[\d.]+/);
374
+ const reviewScore = scoreMatch ? parseFloat(scoreMatch[0]) : undefined;
375
+
376
+ const reviewCountText = reviewCountEl?.textContent?.trim() || "";
377
+ const reviewCountMatch = reviewCountText.match(/[\d,]+/);
378
+ const reviewCount = reviewCountMatch
379
+ ? parseInt(reviewCountMatch[0].replace(/,/g, ""), 10)
380
+ : undefined;
381
+
382
+ const propertyId =
383
+ card.getAttribute("data-hotelid") ||
384
+ card.getAttribute("data-property-id") ||
385
+ (linkEl?.href.match(/\/hotel\/[a-z]{2}\/([^.]+)\./) || [])[1] ||
386
+ String(i);
387
+
388
+ out.push({
389
+ propertyId,
390
+ name: nameEl?.textContent?.trim() || "Unknown Property",
391
+ location: locationEl?.textContent?.trim() || "",
392
+ reviewScore,
393
+ reviewCount,
394
+ pricePerNight: price ? `${price}` : undefined,
395
+ imageUrl: imgEl?.src || undefined,
396
+ url: linkEl?.href || undefined,
397
+ distanceFromCenter: distanceEl?.textContent?.trim() || undefined,
398
+ stars: starsEl.length || undefined,
399
+ freeCancellation: !!freeCancelEl,
400
+ breakfastIncluded: !!breakfastEl,
401
+ } as PropertyResult);
402
+ });
403
+
404
+ return out;
405
+ }, maxResults) as PropertyResult[];
406
+
407
+ lastSearchResults = results;
408
+ await saveCookies(context);
409
+ return results;
410
+ } catch (error) {
411
+ throw new Error(
412
+ `Failed to search properties: ${
413
+ error instanceof Error ? error.message : String(error)
414
+ }`
415
+ );
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Get property details by URL or property ID
421
+ */
422
+ export async function getPropertyDetails(
423
+ propertyUrlOrId: string
424
+ ): Promise<Record<string, unknown>> {
425
+ const { page, context } = await initBrowser();
426
+
427
+ try {
428
+ let url = propertyUrlOrId;
429
+ if (!url.startsWith("http")) {
430
+ // Try to find in cached results
431
+ const cached = lastSearchResults.find(
432
+ (r) => r.propertyId === propertyUrlOrId
433
+ );
434
+ if (cached?.url) {
435
+ url = cached.url;
436
+ } else {
437
+ throw new Error(
438
+ "Property URL required (or run booking_search first to cache results)"
439
+ );
440
+ }
441
+ }
442
+
443
+ await page.goto(url, {
444
+ waitUntil: "domcontentloaded",
445
+ timeout: DEFAULT_TIMEOUT,
446
+ });
447
+ await randomDelay();
448
+ await dismissBanners(page);
449
+
450
+ await page
451
+ .waitForSelector('h2[data-testid="property-name"], #hp_hotel_name', {
452
+ timeout: 10000,
453
+ })
454
+ .catch(() => {});
455
+
456
+ const details = await page.evaluate(() => {
457
+ const nameEl = document.querySelector(
458
+ 'h2[data-testid="property-name"], #hp_hotel_name, h1.pp-header__title'
459
+ );
460
+ const addressEl = document.querySelector(
461
+ '[data-testid="address"], span.hp_address_subtitle'
462
+ );
463
+ const descEl = document.querySelector(
464
+ '[data-testid="property-description"], #property_description_content, #summary'
465
+ );
466
+ const scoreEl = document.querySelector(
467
+ '[data-testid="review-score-component"] [class*="score"], .bui-review-score__badge'
468
+ );
469
+ const amenityEls = document.querySelectorAll(
470
+ '[data-testid="facility-list-item"] span, [class*="hotel-facilities"] li, .important_facility'
471
+ );
472
+ const policyEls = document.querySelectorAll(
473
+ '[data-testid="property-policies"] li, .hotel-policies li'
474
+ );
475
+ const imgEls = document.querySelectorAll(
476
+ '[data-testid="property-gallery"] img, .photo-item img'
477
+ );
478
+
479
+ const amenities: string[] = [];
480
+ amenityEls.forEach((el) => {
481
+ const t = el.textContent?.trim();
482
+ if (t && amenities.length < 20) amenities.push(t);
483
+ });
484
+
485
+ const policies: string[] = [];
486
+ policyEls.forEach((el) => {
487
+ const t = el.textContent?.trim();
488
+ if (t && policies.length < 10) policies.push(t);
489
+ });
490
+
491
+ const images: string[] = [];
492
+ imgEls.forEach((el) => {
493
+ const src = (el as HTMLImageElement).src;
494
+ if (src && images.length < 5) images.push(src);
495
+ });
496
+
497
+ const checkInEl = document.querySelector(
498
+ '[data-testid="check-in-time"], .check-in-time, [class*="checkin"]'
499
+ );
500
+ const checkOutEl = document.querySelector(
501
+ '[data-testid="check-out-time"], .check-out-time, [class*="checkout"]'
502
+ );
503
+
504
+ return {
505
+ name: nameEl?.textContent?.trim() || "Unknown",
506
+ address: addressEl?.textContent?.trim() || "",
507
+ description: descEl?.textContent?.trim().slice(0, 500) || "",
508
+ reviewScore: scoreEl?.textContent?.trim() || undefined,
509
+ amenities,
510
+ policies,
511
+ images,
512
+ checkIn: checkInEl?.textContent?.trim() || undefined,
513
+ checkOut: checkOutEl?.textContent?.trim() || undefined,
514
+ url: window.location.href,
515
+ };
516
+ });
517
+
518
+ await saveCookies(context);
519
+ return details;
520
+ } catch (error) {
521
+ throw new Error(
522
+ `Failed to get property details: ${
523
+ error instanceof Error ? error.message : String(error)
524
+ }`
525
+ );
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Check room availability for a property
531
+ */
532
+ export async function checkAvailability(
533
+ propertyUrlOrId: string,
534
+ checkIn: string,
535
+ checkOut: string,
536
+ adults: number = 2,
537
+ rooms: number = 1
538
+ ): Promise<{ available: boolean; rooms: RoomOption[]; message: string }> {
539
+ const { page, context } = await initBrowser();
540
+
541
+ try {
542
+ let url = propertyUrlOrId;
543
+ if (!url.startsWith("http")) {
544
+ const cached = lastSearchResults.find(
545
+ (r) => r.propertyId === propertyUrlOrId
546
+ );
547
+ if (cached?.url) {
548
+ url = cached.url;
549
+ } else {
550
+ throw new Error("Property URL required or run booking_search first");
551
+ }
552
+ }
553
+
554
+ // Add date params to URL
555
+ const separator = url.includes("?") ? "&" : "?";
556
+ const dateParams = `checkin=${checkIn}&checkout=${checkOut}&group_adults=${adults}&no_rooms=${rooms}`;
557
+ await page.goto(`${url}${separator}${dateParams}`, {
558
+ waitUntil: "domcontentloaded",
559
+ timeout: DEFAULT_TIMEOUT,
560
+ });
561
+ await randomDelay();
562
+ await dismissBanners(page);
563
+
564
+ await page
565
+ .waitForSelector(
566
+ '[data-testid="availability-table"], #available_rooms, table.roomstable',
567
+ { timeout: 12000 }
568
+ )
569
+ .catch(() => {});
570
+
571
+ await randomDelay(500, 1000);
572
+
573
+ const roomsData = await page.evaluate(() => {
574
+ const roomRows = document.querySelectorAll(
575
+ '[data-testid="availability-row"], tr.js-rt-block, .room-type-row'
576
+ );
577
+ const out: RoomOption[] = [];
578
+
579
+ roomRows.forEach((row, i) => {
580
+ if (i >= 10) return;
581
+ const nameEl = row.querySelector(
582
+ '[data-testid="room-type-name"], .room_type, td.ftd'
583
+ );
584
+ const priceEl = row.querySelector(
585
+ '[data-testid="price-for-x-nights"], .price, .bui-price-display__value'
586
+ );
587
+ const guestsEl = row.querySelector(
588
+ '[data-testid="occupancy"], .occupancy, [class*="occupancy"]'
589
+ );
590
+ const cancelEl = row.querySelector(
591
+ '[data-testid="cancellation-policy"], [class*="free-cancel"], [class*="freeCancellation"]'
592
+ );
593
+ const breakfastEl = row.querySelector(
594
+ '[data-testid="meal-plan"], [class*="breakfast"], [class*="meal"]'
595
+ );
596
+ const selectBtn = row.querySelector(
597
+ 'button[data-testid="select-room"], button.book_now, input[type="submit"]'
598
+ );
599
+
600
+ const priceText = priceEl?.textContent?.trim() || "";
601
+ const priceMatch = priceText.match(/[\d,]+/);
602
+
603
+ out.push({
604
+ name: nameEl?.textContent?.trim() || `Room option ${i + 1}`,
605
+ price: priceMatch
606
+ ? priceMatch[0].replace(/,/g, "")
607
+ : undefined,
608
+ maxGuests: guestsEl
609
+ ? parseInt(guestsEl.textContent?.match(/\d+/)?.[0] || "0", 10) ||
610
+ undefined
611
+ : undefined,
612
+ freeCancellation:
613
+ cancelEl?.textContent?.toLowerCase().includes("free") || false,
614
+ breakfastIncluded:
615
+ breakfastEl?.textContent?.toLowerCase().includes("breakfast") ||
616
+ false,
617
+ available: !!selectBtn,
618
+ } as RoomOption);
619
+ });
620
+
621
+ return out;
622
+ });
623
+
624
+ await saveCookies(context);
625
+
626
+ const available = roomsData.some((r) => r.available);
627
+ return {
628
+ available,
629
+ rooms: roomsData,
630
+ message: available
631
+ ? `${roomsData.filter((r) => r.available).length} room type(s) available`
632
+ : "No rooms available for selected dates",
633
+ };
634
+ } catch (error) {
635
+ throw new Error(
636
+ `Failed to check availability: ${
637
+ error instanceof Error ? error.message : String(error)
638
+ }`
639
+ );
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Get prices for a property
645
+ */
646
+ export async function getPrices(
647
+ propertyUrlOrId: string,
648
+ checkIn: string,
649
+ checkOut: string,
650
+ adults: number = 2,
651
+ rooms: number = 1
652
+ ): Promise<{ prices: RoomOption[]; currency: string; lowestPrice?: string }> {
653
+ const avail = await checkAvailability(
654
+ propertyUrlOrId,
655
+ checkIn,
656
+ checkOut,
657
+ adults,
658
+ rooms
659
+ );
660
+
661
+ const { page, context } = await initBrowser();
662
+
663
+ // Try to detect currency from page
664
+ let currency = "USD";
665
+ try {
666
+ const currencyEl = await page.$(
667
+ '[data-testid="currency-selector"], [class*="currency"] span'
668
+ );
669
+ if (currencyEl) {
670
+ currency = (await currencyEl.textContent())?.trim() || "USD";
671
+ }
672
+ } catch {
673
+ // ignore
674
+ }
675
+
676
+ await saveCookies(context);
677
+
678
+ const prices = avail.rooms.filter((r) => r.price);
679
+ const lowest =
680
+ prices.length > 0
681
+ ? prices.reduce((min, r) =>
682
+ parseFloat(r.price || "9999") < parseFloat(min.price || "9999")
683
+ ? r
684
+ : min
685
+ )
686
+ : undefined;
687
+
688
+ return {
689
+ prices: avail.rooms,
690
+ currency,
691
+ lowestPrice: lowest?.price,
692
+ };
693
+ }
694
+
695
+ /**
696
+ * Filter cached search results
697
+ */
698
+ export function filterResults(filters: {
699
+ minPrice?: number;
700
+ maxPrice?: number;
701
+ minRating?: number;
702
+ freeCancellation?: boolean;
703
+ breakfastIncluded?: boolean;
704
+ stars?: number;
705
+ keyword?: string;
706
+ }): PropertyResult[] {
707
+ let results = [...lastSearchResults];
708
+
709
+ if (filters.minPrice !== undefined) {
710
+ results = results.filter(
711
+ (r) =>
712
+ r.pricePerNight !== undefined &&
713
+ parseFloat(r.pricePerNight) >= filters.minPrice!
714
+ );
715
+ }
716
+ if (filters.maxPrice !== undefined) {
717
+ results = results.filter(
718
+ (r) =>
719
+ r.pricePerNight !== undefined &&
720
+ parseFloat(r.pricePerNight) <= filters.maxPrice!
721
+ );
722
+ }
723
+ if (filters.minRating !== undefined) {
724
+ results = results.filter(
725
+ (r) => r.reviewScore !== undefined && r.reviewScore >= filters.minRating!
726
+ );
727
+ }
728
+ if (filters.freeCancellation) {
729
+ results = results.filter((r) => r.freeCancellation === true);
730
+ }
731
+ if (filters.breakfastIncluded) {
732
+ results = results.filter((r) => r.breakfastIncluded === true);
733
+ }
734
+ if (filters.stars !== undefined) {
735
+ results = results.filter((r) => r.stars === filters.stars);
736
+ }
737
+ if (filters.keyword) {
738
+ const kw = filters.keyword.toLowerCase();
739
+ results = results.filter(
740
+ (r) =>
741
+ r.name.toLowerCase().includes(kw) ||
742
+ r.location.toLowerCase().includes(kw)
743
+ );
744
+ }
745
+
746
+ return results;
747
+ }
748
+
749
+ /**
750
+ * Sort cached search results
751
+ */
752
+ export function sortResults(
753
+ sortBy: "price_asc" | "price_desc" | "rating" | "distance" | "reviews"
754
+ ): PropertyResult[] {
755
+ const results = [...lastSearchResults];
756
+
757
+ switch (sortBy) {
758
+ case "price_asc":
759
+ return results.sort(
760
+ (a, b) =>
761
+ parseFloat(a.pricePerNight || "9999") -
762
+ parseFloat(b.pricePerNight || "9999")
763
+ );
764
+ case "price_desc":
765
+ return results.sort(
766
+ (a, b) =>
767
+ parseFloat(b.pricePerNight || "0") -
768
+ parseFloat(a.pricePerNight || "0")
769
+ );
770
+ case "rating":
771
+ return results.sort(
772
+ (a, b) => (b.reviewScore || 0) - (a.reviewScore || 0)
773
+ );
774
+ case "reviews":
775
+ return results.sort(
776
+ (a, b) => (b.reviewCount || 0) - (a.reviewCount || 0)
777
+ );
778
+ case "distance":
779
+ return results.sort((a, b) => {
780
+ const da = parseFloat(
781
+ a.distanceFromCenter?.match(/[\d.]+/)?.[0] || "9999"
782
+ );
783
+ const db = parseFloat(
784
+ b.distanceFromCenter?.match(/[\d.]+/)?.[0] || "9999"
785
+ );
786
+ return da - db;
787
+ });
788
+ default:
789
+ return results;
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Save a property to favorites (wishlist)
795
+ */
796
+ export async function saveProperty(
797
+ propertyUrlOrId: string
798
+ ): Promise<{ success: boolean; message: string }> {
799
+ const { page, context } = await initBrowser();
800
+
801
+ try {
802
+ let url = propertyUrlOrId;
803
+ if (!url.startsWith("http")) {
804
+ const cached = lastSearchResults.find(
805
+ (r) => r.propertyId === propertyUrlOrId
806
+ );
807
+ if (cached?.url) {
808
+ url = cached.url;
809
+ } else {
810
+ throw new Error("Property URL required or run booking_search first");
811
+ }
812
+ }
813
+
814
+ await page.goto(url, {
815
+ waitUntil: "domcontentloaded",
816
+ timeout: DEFAULT_TIMEOUT,
817
+ });
818
+ await randomDelay();
819
+ await dismissBanners(page);
820
+
821
+ // Look for wishlist/save/heart button
822
+ const saveBtn = await page.$(
823
+ '[data-testid="wishlist-button"], button[aria-label*="Save"], button[aria-label*="wishlist"], ' +
824
+ '[class*="wishlist"] button, button.save-button'
825
+ );
826
+
827
+ if (!saveBtn) {
828
+ return {
829
+ success: false,
830
+ message:
831
+ "Could not find save/wishlist button. Make sure you are logged in.",
832
+ };
833
+ }
834
+
835
+ await saveBtn.click();
836
+ await randomDelay(500, 1000);
837
+ await saveCookies(context);
838
+
839
+ return {
840
+ success: true,
841
+ message: "Property saved to your wishlist",
842
+ };
843
+ } catch (error) {
844
+ throw new Error(
845
+ `Failed to save property: ${
846
+ error instanceof Error ? error.message : String(error)
847
+ }`
848
+ );
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Book a room (with explicit confirmation required)
854
+ */
855
+ export async function bookRoom(
856
+ propertyUrlOrId: string,
857
+ checkIn: string,
858
+ checkOut: string,
859
+ adults: number = 2,
860
+ rooms: number = 1,
861
+ confirmBooking: boolean = false
862
+ ): Promise<
863
+ | { requiresConfirmation: true; summary: Record<string, unknown> }
864
+ | { success: boolean; bookingId?: string; message: string }
865
+ > {
866
+ if (!confirmBooking) {
867
+ // Return a preview / dry-run
868
+ let previewUrl = propertyUrlOrId;
869
+ if (!previewUrl.startsWith("http")) {
870
+ const cached = lastSearchResults.find(
871
+ (r) => r.propertyId === propertyUrlOrId
872
+ );
873
+ if (cached?.url) previewUrl = cached.url;
874
+ }
875
+ return {
876
+ requiresConfirmation: true,
877
+ summary: {
878
+ propertyUrl: previewUrl,
879
+ checkIn,
880
+ checkOut,
881
+ adults,
882
+ rooms,
883
+ message:
884
+ "Booking not placed. To confirm, call booking_book with confirm=true. " +
885
+ "IMPORTANT: Only set confirm=true after explicit user confirmation.",
886
+ },
887
+ };
888
+ }
889
+
890
+ const { page, context } = await initBrowser();
891
+
892
+ try {
893
+ let url = propertyUrlOrId;
894
+ if (!url.startsWith("http")) {
895
+ const cached = lastSearchResults.find(
896
+ (r) => r.propertyId === propertyUrlOrId
897
+ );
898
+ if (cached?.url) {
899
+ url = cached.url;
900
+ } else {
901
+ throw new Error("Property URL required or run booking_search first");
902
+ }
903
+ }
904
+
905
+ const separator = url.includes("?") ? "&" : "?";
906
+ const dateParams = `checkin=${checkIn}&checkout=${checkOut}&group_adults=${adults}&no_rooms=${rooms}`;
907
+ await page.goto(`${url}${separator}${dateParams}`, {
908
+ waitUntil: "domcontentloaded",
909
+ timeout: DEFAULT_TIMEOUT,
910
+ });
911
+ await randomDelay();
912
+ await dismissBanners(page);
913
+
914
+ await page
915
+ .waitForSelector(
916
+ '[data-testid="availability-table"], #available_rooms',
917
+ { timeout: 12000 }
918
+ )
919
+ .catch(() => {});
920
+
921
+ // Select first available room
922
+ const selectBtn = await page.$(
923
+ 'button[data-testid="select-room"], button.book_now, input[type="submit"][value*="Reserve"], input[type="submit"][value*="Book"]'
924
+ );
925
+
926
+ if (!selectBtn) {
927
+ throw new Error(
928
+ "No available rooms to book. Try checking availability first."
929
+ );
930
+ }
931
+
932
+ await selectBtn.click();
933
+ await randomDelay(1000, 2000);
934
+
935
+ // Wait for booking page
936
+ await page
937
+ .waitForURL(/\/book\//, { timeout: 15000 })
938
+ .catch(() => {});
939
+
940
+ // Look for final confirm button
941
+ const confirmBtn = await page.$(
942
+ 'button[data-testid="confirm-booking"], button:has-text("Complete booking"), ' +
943
+ 'button:has-text("Reserve"), input[type="submit"][value*="Complete"]'
944
+ );
945
+
946
+ if (!confirmBtn) {
947
+ return {
948
+ success: false,
949
+ message:
950
+ "Reached booking page but could not find final confirm button. " +
951
+ "Manual completion may be required at: " +
952
+ (await page.url()),
953
+ };
954
+ }
955
+
956
+ await confirmBtn.click();
957
+ await randomDelay(2000, 3000);
958
+
959
+ // Extract confirmation number
960
+ const bookingId = await page
961
+ .$eval(
962
+ '[data-testid="confirmation-number"], [class*="confirmation"] span, .conf-number',
963
+ (el) => el.textContent?.trim()
964
+ )
965
+ .catch(() => undefined);
966
+
967
+ await saveCookies(context);
968
+
969
+ return {
970
+ success: true,
971
+ bookingId: bookingId || "Confirmation pending",
972
+ message: `Booking placed successfully! Confirmation: ${bookingId || "check your email"}`,
973
+ };
974
+ } catch (error) {
975
+ throw new Error(
976
+ `Failed to book room: ${
977
+ error instanceof Error ? error.message : String(error)
978
+ }`
979
+ );
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Get current reservations
985
+ */
986
+ export async function getReservations(): Promise<Reservation[]> {
987
+ const { page, context } = await initBrowser();
988
+
989
+ try {
990
+ await page.goto(`${BOOKING_BASE_URL}/account/trips`, {
991
+ waitUntil: "domcontentloaded",
992
+ timeout: DEFAULT_TIMEOUT,
993
+ });
994
+ await randomDelay();
995
+ await dismissBanners(page);
996
+
997
+ await page
998
+ .waitForSelector(
999
+ '[data-testid="booking-card"], .booking-item, [class*="trip-card"]',
1000
+ { timeout: 12000 }
1001
+ )
1002
+ .catch(() => {});
1003
+
1004
+ const reservations = await page.evaluate(() => {
1005
+ const cards = document.querySelectorAll(
1006
+ '[data-testid="booking-card"], .booking-item, [class*="trip-card"], [class*="booking-card"]'
1007
+ );
1008
+ const out: Reservation[] = [];
1009
+
1010
+ cards.forEach((card, i) => {
1011
+ if (i >= 20) return;
1012
+
1013
+ const nameEl = card.querySelector(
1014
+ '[data-testid="booking-name"], .hotel-name, [class*="property-name"], h3, h2'
1015
+ );
1016
+ const checkInEl = card.querySelector(
1017
+ '[data-testid="check-in-date"], [class*="checkin"], [class*="check-in"]'
1018
+ );
1019
+ const checkOutEl = card.querySelector(
1020
+ '[data-testid="check-out-date"], [class*="checkout"], [class*="check-out"]'
1021
+ );
1022
+ const priceEl = card.querySelector(
1023
+ '[data-testid="booking-price"], [class*="price"], [class*="total"]'
1024
+ );
1025
+ const statusEl = card.querySelector(
1026
+ '[data-testid="booking-status"], [class*="status"], [class*="state"]'
1027
+ );
1028
+ const confirmEl = card.querySelector(
1029
+ '[data-testid="confirmation-number"], [class*="confirmation"], [class*="pin"]'
1030
+ );
1031
+
1032
+ const reservationId =
1033
+ card.getAttribute("data-booking-id") ||
1034
+ card.getAttribute("data-reservation-id") ||
1035
+ confirmEl?.textContent?.trim() ||
1036
+ String(i);
1037
+
1038
+ out.push({
1039
+ reservationId,
1040
+ propertyName: nameEl?.textContent?.trim() || "Unknown Property",
1041
+ checkIn: checkInEl?.textContent?.trim() || "",
1042
+ checkOut: checkOutEl?.textContent?.trim() || "",
1043
+ totalPrice: priceEl?.textContent?.trim() || undefined,
1044
+ status: statusEl?.textContent?.trim() || "Unknown",
1045
+ confirmationNumber: confirmEl?.textContent?.trim() || undefined,
1046
+ } as Reservation);
1047
+ });
1048
+
1049
+ return out;
1050
+ });
1051
+
1052
+ await saveCookies(context);
1053
+ return reservations;
1054
+ } catch (error) {
1055
+ throw new Error(
1056
+ `Failed to get reservations: ${
1057
+ error instanceof Error ? error.message : String(error)
1058
+ }`
1059
+ );
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * Cancel a reservation
1065
+ */
1066
+ export async function cancelReservation(
1067
+ reservationId: string,
1068
+ confirmCancellation: boolean = false
1069
+ ): Promise<{ success: boolean; message: string }> {
1070
+ if (!confirmCancellation) {
1071
+ return {
1072
+ success: false,
1073
+ message:
1074
+ `Cancellation not performed. To cancel reservation ${reservationId}, ` +
1075
+ "call booking_cancel_reservation with confirm=true. " +
1076
+ "IMPORTANT: This action cannot be undone.",
1077
+ };
1078
+ }
1079
+
1080
+ const { page, context } = await initBrowser();
1081
+
1082
+ try {
1083
+ await page.goto(`${BOOKING_BASE_URL}/account/trips`, {
1084
+ waitUntil: "domcontentloaded",
1085
+ timeout: DEFAULT_TIMEOUT,
1086
+ });
1087
+ await randomDelay();
1088
+ await dismissBanners(page);
1089
+
1090
+ // Look for the specific booking cancel button
1091
+ const cancelBtn = await page.$(
1092
+ `[data-booking-id="${reservationId}"] button[class*="cancel"], ` +
1093
+ `[data-reservation-id="${reservationId}"] button[class*="cancel"], ` +
1094
+ 'button[aria-label*="Cancel booking"], button:has-text("Cancel booking")'
1095
+ );
1096
+
1097
+ if (!cancelBtn) {
1098
+ return {
1099
+ success: false,
1100
+ message: `Could not find cancel button for reservation ${reservationId}. ` +
1101
+ "Navigate to your bookings page manually to cancel.",
1102
+ };
1103
+ }
1104
+
1105
+ await cancelBtn.click();
1106
+ await randomDelay(1000, 1500);
1107
+
1108
+ // Confirm cancellation dialog
1109
+ const confirmBtn = await page.$(
1110
+ 'button[data-testid="confirm-cancel"], button:has-text("Confirm cancellation"), ' +
1111
+ 'button:has-text("Yes, cancel"), button:has-text("Confirm")'
1112
+ );
1113
+
1114
+ if (confirmBtn) {
1115
+ await confirmBtn.click();
1116
+ await randomDelay(1000, 2000);
1117
+ }
1118
+
1119
+ await saveCookies(context);
1120
+
1121
+ return {
1122
+ success: true,
1123
+ message: `Reservation ${reservationId} cancellation initiated. Check your email for confirmation.`,
1124
+ };
1125
+ } catch (error) {
1126
+ throw new Error(
1127
+ `Failed to cancel reservation: ${
1128
+ error instanceof Error ? error.message : String(error)
1129
+ }`
1130
+ );
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * Get reviews for a property
1136
+ */
1137
+ export async function getPropertyReviews(
1138
+ propertyUrlOrId: string,
1139
+ maxReviews: number = 10
1140
+ ): Promise<{ reviews: Review[]; averageScore?: number; totalReviews?: number }> {
1141
+ const { page, context } = await initBrowser();
1142
+
1143
+ try {
1144
+ let url = propertyUrlOrId;
1145
+ if (!url.startsWith("http")) {
1146
+ const cached = lastSearchResults.find(
1147
+ (r) => r.propertyId === propertyUrlOrId
1148
+ );
1149
+ if (cached?.url) {
1150
+ url = cached.url;
1151
+ } else {
1152
+ throw new Error("Property URL required or run booking_search first");
1153
+ }
1154
+ }
1155
+
1156
+ // Navigate to reviews tab
1157
+ const reviewsUrl = url.includes("#reviews")
1158
+ ? url
1159
+ : `${url}#reviews_list`;
1160
+ await page.goto(reviewsUrl, {
1161
+ waitUntil: "domcontentloaded",
1162
+ timeout: DEFAULT_TIMEOUT,
1163
+ });
1164
+ await randomDelay();
1165
+ await dismissBanners(page);
1166
+
1167
+ // Try to click reviews tab if available
1168
+ try {
1169
+ const reviewsTab = await page.$(
1170
+ 'a[href="#reviews_list"], [data-testid="reviews-tab"], button:has-text("Reviews")'
1171
+ );
1172
+ if (reviewsTab) {
1173
+ await reviewsTab.click();
1174
+ await randomDelay(500, 1000);
1175
+ }
1176
+ } catch {
1177
+ // ignore
1178
+ }
1179
+
1180
+ await page
1181
+ .waitForSelector(
1182
+ '[data-testid="review-card"], [class*="review-block"], .review_list_new_item_block',
1183
+ { timeout: 10000 }
1184
+ )
1185
+ .catch(() => {});
1186
+
1187
+ const data = await page.evaluate((max: number) => {
1188
+ const cards = document.querySelectorAll(
1189
+ '[data-testid="review-card"], [class*="review-block"], .review_list_new_item_block'
1190
+ );
1191
+ const reviews: Review[] = [];
1192
+
1193
+ cards.forEach((card, i) => {
1194
+ if (i >= max) return;
1195
+
1196
+ const nameEl = card.querySelector(
1197
+ '[data-testid="reviewer-name"], .reviewer_name, [class*="reviewer"]'
1198
+ );
1199
+ const dateEl = card.querySelector(
1200
+ '[data-testid="review-date"], .review_date, [class*="date"]'
1201
+ );
1202
+ const scoreEl = card.querySelector(
1203
+ '[data-testid="review-score"], .bui-review-score__badge, [class*="score"]'
1204
+ );
1205
+ const titleEl = card.querySelector(
1206
+ '[data-testid="review-title"], .review_item_header_content, [class*="title"]'
1207
+ );
1208
+ const posEl = card.querySelector(
1209
+ '[data-testid="review-positive"], .review_pos, [class*="positive"]'
1210
+ );
1211
+ const negEl = card.querySelector(
1212
+ '[data-testid="review-negative"], .review_neg, [class*="negative"]'
1213
+ );
1214
+ const countryEl = card.querySelector(
1215
+ '[data-testid="reviewer-country"], [class*="country"], .reviewer_flags'
1216
+ );
1217
+
1218
+ const scoreText = scoreEl?.textContent?.trim() || "";
1219
+ const scoreMatch = scoreText.match(/[\d.]+/);
1220
+
1221
+ reviews.push({
1222
+ reviewer: nameEl?.textContent?.trim() || undefined,
1223
+ date: dateEl?.textContent?.trim() || undefined,
1224
+ score: scoreMatch ? parseFloat(scoreMatch[0]) : undefined,
1225
+ title: titleEl?.textContent?.trim() || undefined,
1226
+ positives: posEl?.textContent?.trim().slice(0, 300) || undefined,
1227
+ negatives: negEl?.textContent?.trim().slice(0, 300) || undefined,
1228
+ country: countryEl?.textContent?.trim() || undefined,
1229
+ } as Review);
1230
+ });
1231
+
1232
+ // Overall score
1233
+ const overallScoreEl = document.querySelector(
1234
+ '[data-testid="review-score-component"] [class*="score-value"], .bui-review-score__badge'
1235
+ );
1236
+ const overallScoreText = overallScoreEl?.textContent?.trim() || "";
1237
+ const overallMatch = overallScoreText.match(/[\d.]+/);
1238
+
1239
+ // Total reviews count
1240
+ const totalEl = document.querySelector(
1241
+ '[data-testid="review-count"], [class*="review-count"], .reviews_header_score'
1242
+ );
1243
+ const totalText = totalEl?.textContent?.trim() || "";
1244
+ const totalMatch = totalText.match(/[\d,]+/);
1245
+
1246
+ return {
1247
+ reviews,
1248
+ averageScore: overallMatch ? parseFloat(overallMatch[0]) : undefined,
1249
+ totalReviews: totalMatch
1250
+ ? parseInt(totalMatch[0].replace(/,/g, ""), 10)
1251
+ : undefined,
1252
+ };
1253
+ }, maxReviews);
1254
+
1255
+ await saveCookies(context);
1256
+ return data;
1257
+ } catch (error) {
1258
+ throw new Error(
1259
+ `Failed to get reviews: ${
1260
+ error instanceof Error ? error.message : String(error)
1261
+ }`
1262
+ );
1263
+ }
1264
+ }
1265
+
1266
+ // Process cleanup
1267
+ process.on("exit", () => {
1268
+ if (browser) {
1269
+ browser.close().catch(() => {});
1270
+ }
1271
+ });
1272
+
1273
+ process.on("SIGINT", async () => {
1274
+ await closeBrowser();
1275
+ process.exit(0);
1276
+ });
1277
+
1278
+ process.on("SIGTERM", async () => {
1279
+ await closeBrowser();
1280
+ process.exit(0);
1281
+ });