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 +21 -0
- package/dist/browser.js +220 -41
- package/dist/index.js +35 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
|
732
|
-
|
|
733
|
-
await
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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.
|
|
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.
|
|
547
|
+
console.error("HotelZero v1.2.0 running on stdio");
|
|
516
548
|
}
|
|
517
549
|
main().catch((error) => {
|
|
518
550
|
console.error("Fatal error:", error);
|