hotelzero 1.1.0 → 1.2.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/dist/browser.d.ts CHANGED
@@ -1,3 +1,19 @@
1
+ export declare class HotelSearchError extends Error {
2
+ code: string;
3
+ retryable: boolean;
4
+ constructor(message: string, code: string, retryable?: boolean);
5
+ }
6
+ export declare const ErrorCodes: {
7
+ readonly BROWSER_NOT_INITIALIZED: "BROWSER_NOT_INITIALIZED";
8
+ readonly NAVIGATION_FAILED: "NAVIGATION_FAILED";
9
+ readonly RATE_LIMITED: "RATE_LIMITED";
10
+ readonly CAPTCHA_DETECTED: "CAPTCHA_DETECTED";
11
+ readonly NO_RESULTS: "NO_RESULTS";
12
+ readonly DESTINATION_NOT_FOUND: "DESTINATION_NOT_FOUND";
13
+ readonly NETWORK_ERROR: "NETWORK_ERROR";
14
+ readonly TIMEOUT: "TIMEOUT";
15
+ readonly BLOCKED: "BLOCKED";
16
+ };
1
17
  export interface HotelSearchParams {
2
18
  destination: string;
3
19
  checkIn: string;
@@ -110,9 +126,14 @@ export interface HotelResult {
110
126
  export declare class HotelBrowser {
111
127
  private browser;
112
128
  private page;
129
+ private lastRequestTime;
130
+ private minRequestIntervalMs;
113
131
  init(headless?: boolean): Promise<void>;
114
132
  close(): Promise<void>;
115
133
  private buildBookingUrl;
134
+ private enforceRateLimit;
135
+ private checkForBlocking;
136
+ private checkForNoResults;
116
137
  searchHotels(params: HotelSearchParams, filters?: HotelFilters): Promise<HotelResult[]>;
117
138
  private dismissPopups;
118
139
  private scrollToLoadMore;
package/dist/browser.js CHANGED
@@ -1,4 +1,62 @@
1
1
  import { chromium } from "playwright";
2
+ // Custom error types for better error handling
3
+ export class HotelSearchError extends Error {
4
+ code;
5
+ retryable;
6
+ constructor(message, code, retryable = false) {
7
+ super(message);
8
+ this.code = code;
9
+ this.retryable = retryable;
10
+ this.name = "HotelSearchError";
11
+ }
12
+ }
13
+ export const ErrorCodes = {
14
+ BROWSER_NOT_INITIALIZED: "BROWSER_NOT_INITIALIZED",
15
+ NAVIGATION_FAILED: "NAVIGATION_FAILED",
16
+ RATE_LIMITED: "RATE_LIMITED",
17
+ CAPTCHA_DETECTED: "CAPTCHA_DETECTED",
18
+ NO_RESULTS: "NO_RESULTS",
19
+ DESTINATION_NOT_FOUND: "DESTINATION_NOT_FOUND",
20
+ NETWORK_ERROR: "NETWORK_ERROR",
21
+ TIMEOUT: "TIMEOUT",
22
+ BLOCKED: "BLOCKED",
23
+ };
24
+ const DEFAULT_RETRY_CONFIG = {
25
+ maxRetries: 3,
26
+ baseDelayMs: 1000,
27
+ maxDelayMs: 10000,
28
+ };
29
+ // Sleep helper
30
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
31
+ // Retry with exponential backoff
32
+ async function retryWithBackoff(fn, config = DEFAULT_RETRY_CONFIG, onRetry) {
33
+ let lastError = null;
34
+ for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
35
+ try {
36
+ return await fn();
37
+ }
38
+ catch (error) {
39
+ lastError = error;
40
+ // Don't retry non-retryable errors
41
+ if (error instanceof HotelSearchError && !error.retryable) {
42
+ throw error;
43
+ }
44
+ // Don't retry on last attempt
45
+ if (attempt === config.maxRetries) {
46
+ break;
47
+ }
48
+ // Calculate delay with exponential backoff + jitter
49
+ const exponentialDelay = config.baseDelayMs * Math.pow(2, attempt - 1);
50
+ const jitter = Math.random() * 1000;
51
+ const delay = Math.min(exponentialDelay + jitter, config.maxDelayMs);
52
+ if (onRetry) {
53
+ onRetry(attempt, lastError, delay);
54
+ }
55
+ await sleep(delay);
56
+ }
57
+ }
58
+ throw lastError;
59
+ }
2
60
  // Booking.com filter code mappings
3
61
  const FILTER_CODES = {
4
62
  // Property Types
@@ -172,6 +230,8 @@ const FILTER_CODES = {
172
230
  export class HotelBrowser {
173
231
  browser = null;
174
232
  page = null;
233
+ lastRequestTime = 0;
234
+ minRequestIntervalMs = 2000; // Minimum 2 seconds between requests
175
235
  async init(headless = true) {
176
236
  this.browser = await chromium.launch({
177
237
  headless,
@@ -413,23 +473,121 @@ export class HotelBrowser {
413
473
  }
414
474
  return url.toString();
415
475
  }
416
- async searchHotels(params, filters) {
476
+ // Rate limiting: ensure minimum time between requests
477
+ async enforceRateLimit() {
478
+ const now = Date.now();
479
+ const timeSinceLastRequest = now - this.lastRequestTime;
480
+ if (timeSinceLastRequest < this.minRequestIntervalMs) {
481
+ const waitTime = this.minRequestIntervalMs - timeSinceLastRequest;
482
+ await sleep(waitTime);
483
+ }
484
+ this.lastRequestTime = Date.now();
485
+ }
486
+ // Check for CAPTCHA or blocking pages
487
+ async checkForBlocking() {
417
488
  if (!this.page)
418
- throw new Error("Browser not initialized");
419
- const url = this.buildBookingUrl(params, filters);
420
- await this.page.goto(url, { waitUntil: "networkidle" });
421
- await this.page.waitForTimeout(2000);
422
- // Close any popups/modals
423
- await this.dismissPopups();
424
- // Scroll to load more results
425
- await this.scrollToLoadMore();
426
- // Extract detailed hotel info
427
- const hotels = await this.extractHotelDetails();
428
- // Apply client-side filtering and scoring if we have preferences
429
- if (filters) {
430
- return this.scoreAndFilterHotels(hotels, filters);
489
+ return;
490
+ const pageContent = await this.page.content();
491
+ const pageUrl = this.page.url();
492
+ // Check for CAPTCHA
493
+ const captchaIndicators = [
494
+ "captcha",
495
+ "recaptcha",
496
+ "hcaptcha",
497
+ "challenge-running",
498
+ "challenge-form",
499
+ "px-captcha",
500
+ ];
501
+ const hasCaptcha = captchaIndicators.some(indicator => pageContent.toLowerCase().includes(indicator));
502
+ if (hasCaptcha) {
503
+ throw new HotelSearchError("CAPTCHA detected. Please wait a few minutes before retrying.", ErrorCodes.CAPTCHA_DETECTED, false // Not retryable automatically
504
+ );
505
+ }
506
+ // Check for rate limiting / blocking
507
+ const blockIndicators = [
508
+ "access denied",
509
+ "too many requests",
510
+ "rate limit",
511
+ "blocked",
512
+ "forbidden",
513
+ "error 403",
514
+ "error 429",
515
+ ];
516
+ const isBlocked = blockIndicators.some(indicator => pageContent.toLowerCase().includes(indicator));
517
+ if (isBlocked || pageUrl.includes("blocked") || pageUrl.includes("error")) {
518
+ throw new HotelSearchError("Request blocked by Booking.com. Please wait a few minutes before retrying.", ErrorCodes.BLOCKED, true // Retryable with backoff
519
+ );
520
+ }
521
+ }
522
+ // Check if destination was found
523
+ async checkForNoResults() {
524
+ if (!this.page)
525
+ return;
526
+ const pageContent = await this.page.content();
527
+ // Check for "no results" or "destination not found" messages
528
+ const noResultsIndicators = [
529
+ "no properties found",
530
+ "no results",
531
+ "0 properties",
532
+ "we couldn't find",
533
+ "try different dates",
534
+ ];
535
+ const hasNoResults = noResultsIndicators.some(indicator => pageContent.toLowerCase().includes(indicator));
536
+ if (hasNoResults) {
537
+ // Check if it's a destination issue or just no matching filters
538
+ const destinationIssue = pageContent.toLowerCase().includes("destination") &&
539
+ (pageContent.toLowerCase().includes("not found") ||
540
+ pageContent.toLowerCase().includes("couldn't find"));
541
+ if (destinationIssue) {
542
+ throw new HotelSearchError("Destination not found. Please check the spelling and try again.", ErrorCodes.DESTINATION_NOT_FOUND, false);
543
+ }
544
+ // No results but destination is valid - this is not an error, just empty results
545
+ }
546
+ }
547
+ async searchHotels(params, filters) {
548
+ if (!this.page) {
549
+ throw new HotelSearchError("Browser not initialized. Call init() first.", ErrorCodes.BROWSER_NOT_INITIALIZED, false);
431
550
  }
432
- return hotels;
551
+ const url = this.buildBookingUrl(params, filters);
552
+ // Use retry with exponential backoff for the main search operation
553
+ return await retryWithBackoff(async () => {
554
+ // Enforce rate limiting
555
+ await this.enforceRateLimit();
556
+ try {
557
+ await this.page.goto(url, {
558
+ waitUntil: "networkidle",
559
+ timeout: 30000
560
+ });
561
+ }
562
+ catch (error) {
563
+ const err = error;
564
+ if (err.message.includes("timeout") || err.message.includes("Timeout")) {
565
+ throw new HotelSearchError("Page load timed out. The server may be slow or unavailable.", ErrorCodes.TIMEOUT, true);
566
+ }
567
+ if (err.message.includes("net::") || err.message.includes("Network")) {
568
+ throw new HotelSearchError("Network error occurred. Please check your connection.", ErrorCodes.NETWORK_ERROR, true);
569
+ }
570
+ throw new HotelSearchError(`Navigation failed: ${err.message}`, ErrorCodes.NAVIGATION_FAILED, true);
571
+ }
572
+ await this.page.waitForTimeout(2000);
573
+ // Check for blocking/CAPTCHA before proceeding
574
+ await this.checkForBlocking();
575
+ // Check for no results / destination issues
576
+ await this.checkForNoResults();
577
+ // Close any popups/modals
578
+ await this.dismissPopups();
579
+ // Scroll to load more results
580
+ await this.scrollToLoadMore();
581
+ // Extract detailed hotel info
582
+ const hotels = await this.extractHotelDetails();
583
+ // Apply client-side filtering and scoring if we have preferences
584
+ if (filters) {
585
+ return this.scoreAndFilterHotels(hotels, filters);
586
+ }
587
+ return hotels;
588
+ }, DEFAULT_RETRY_CONFIG, (attempt, error, delayMs) => {
589
+ console.error(`Search attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delayMs / 1000)}s...`);
590
+ });
433
591
  }
434
592
  async dismissPopups() {
435
593
  if (!this.page)
@@ -727,32 +885,53 @@ export class HotelBrowser {
727
885
  .sort((a, b) => (b.matchScore || 0) - (a.matchScore || 0));
728
886
  }
729
887
  async getHotelDetails(hotelUrl) {
730
- if (!this.page)
731
- throw new Error("Browser not initialized");
732
- await this.page.goto(hotelUrl, { waitUntil: "networkidle" });
733
- await this.page.waitForTimeout(2000);
734
- await this.dismissPopups();
735
- // Extract detailed information from hotel page
736
- return await this.page.evaluate(() => {
737
- const details = {};
738
- // Hotel name
739
- const nameEl = document.querySelector('h2[class*="pp-header"]');
740
- details.name = nameEl?.textContent?.trim();
741
- // Description
742
- const descEl = document.querySelector('[data-testid="property-description"]');
743
- details.description = descEl?.textContent?.trim();
744
- // All facilities
745
- const facilityEls = document.querySelectorAll('[data-testid="property-section-facilities"] li');
746
- details.facilities = Array.from(facilityEls).map((el) => el.textContent?.trim());
747
- // Photos
748
- const photoEls = document.querySelectorAll('[data-testid="gallery-image"] img');
749
- details.photos = Array.from(photoEls)
750
- .slice(0, 5)
751
- .map((el) => el.src);
752
- // Popular facilities highlighted
753
- const popularFacilities = document.querySelectorAll('[data-testid="property-most-popular-facilities"] span');
754
- details.popularFacilities = Array.from(popularFacilities).map((el) => el.textContent?.trim());
755
- return details;
888
+ if (!this.page) {
889
+ throw new HotelSearchError("Browser not initialized. Call init() first.", ErrorCodes.BROWSER_NOT_INITIALIZED, false);
890
+ }
891
+ return await retryWithBackoff(async () => {
892
+ // Enforce rate limiting
893
+ await this.enforceRateLimit();
894
+ try {
895
+ await this.page.goto(hotelUrl, {
896
+ waitUntil: "networkidle",
897
+ timeout: 30000
898
+ });
899
+ }
900
+ catch (error) {
901
+ const err = error;
902
+ if (err.message.includes("timeout") || err.message.includes("Timeout")) {
903
+ throw new HotelSearchError("Page load timed out. The server may be slow or unavailable.", ErrorCodes.TIMEOUT, true);
904
+ }
905
+ throw new HotelSearchError(`Navigation failed: ${err.message}`, ErrorCodes.NAVIGATION_FAILED, true);
906
+ }
907
+ await this.page.waitForTimeout(2000);
908
+ // Check for blocking/CAPTCHA
909
+ await this.checkForBlocking();
910
+ await this.dismissPopups();
911
+ // Extract detailed information from hotel page
912
+ return await this.page.evaluate(() => {
913
+ const details = {};
914
+ // Hotel name
915
+ const nameEl = document.querySelector('h2[class*="pp-header"]');
916
+ details.name = nameEl?.textContent?.trim();
917
+ // Description
918
+ const descEl = document.querySelector('[data-testid="property-description"]');
919
+ details.description = descEl?.textContent?.trim();
920
+ // All facilities
921
+ const facilityEls = document.querySelectorAll('[data-testid="property-section-facilities"] li');
922
+ details.facilities = Array.from(facilityEls).map((el) => el.textContent?.trim());
923
+ // Photos
924
+ const photoEls = document.querySelectorAll('[data-testid="gallery-image"] img');
925
+ details.photos = Array.from(photoEls)
926
+ .slice(0, 5)
927
+ .map((el) => el.src);
928
+ // Popular facilities highlighted
929
+ const popularFacilities = document.querySelectorAll('[data-testid="property-most-popular-facilities"] span');
930
+ details.popularFacilities = Array.from(popularFacilities).map((el) => el.textContent?.trim());
931
+ return details;
932
+ });
933
+ }, DEFAULT_RETRY_CONFIG, (attempt, error, delayMs) => {
934
+ console.error(`Get details attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delayMs / 1000)}s...`);
756
935
  });
757
936
  }
758
937
  async takeScreenshot(path) {
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { z } from "zod";
6
- import { HotelBrowser } from "./browser.js";
6
+ import { HotelBrowser, HotelSearchError, ErrorCodes } from "./browser.js";
7
7
  // Property type enum
8
8
  const PropertyTypeEnum = z.enum([
9
9
  "hotel", "apartment", "resort", "villa", "vacation_home", "hostel", "bnb",
@@ -165,7 +165,7 @@ function formatHotelResult(hotel, index) {
165
165
  // Create MCP server
166
166
  const server = new Server({
167
167
  name: "hotelzero",
168
- version: "1.1.0",
168
+ version: "1.2.0",
169
169
  }, {
170
170
  capabilities: {
171
171
  tools: {},
@@ -481,6 +481,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
481
481
  }
482
482
  }
483
483
  catch (error) {
484
+ // Handle custom HotelSearchError with structured error info
485
+ if (error instanceof HotelSearchError) {
486
+ let helpText = "";
487
+ switch (error.code) {
488
+ case ErrorCodes.CAPTCHA_DETECTED:
489
+ helpText = "\n\nTip: Wait 5-10 minutes before trying again.";
490
+ break;
491
+ case ErrorCodes.RATE_LIMITED:
492
+ case ErrorCodes.BLOCKED:
493
+ helpText = "\n\nTip: The server is rate-limiting requests. Wait a few minutes before trying again.";
494
+ break;
495
+ case ErrorCodes.DESTINATION_NOT_FOUND:
496
+ helpText = "\n\nTip: Check the destination spelling. Try a more specific location like 'Paris, France' instead of just 'Paris'.";
497
+ break;
498
+ case ErrorCodes.TIMEOUT:
499
+ helpText = "\n\nTip: The request timed out. This may be due to slow network or server issues. Try again.";
500
+ break;
501
+ case ErrorCodes.NETWORK_ERROR:
502
+ helpText = "\n\nTip: Check your internet connection and try again.";
503
+ break;
504
+ }
505
+ return {
506
+ content: [
507
+ {
508
+ type: "text",
509
+ text: `Error [${error.code}]: ${error.message}${helpText}`,
510
+ },
511
+ ],
512
+ isError: true,
513
+ };
514
+ }
515
+ // Handle generic errors
484
516
  const errorMessage = error instanceof Error ? error.message : String(error);
485
517
  return {
486
518
  content: [
@@ -512,7 +544,7 @@ process.on("SIGTERM", async () => {
512
544
  async function main() {
513
545
  const transport = new StdioServerTransport();
514
546
  await server.connect(transport);
515
- console.error("HotelZero v1.1.0 running on stdio");
547
+ console.error("HotelZero v1.2.0 running on stdio");
516
548
  }
517
549
  main().catch((error) => {
518
550
  console.error("Fatal error:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotelzero",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for searching hotels on Booking.com with 80+ filters",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",