hotelzero 1.0.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/README.md +4 -1
- package/dist/browser.d.ts +26 -0
- package/dist/browser.js +259 -43
- package/dist/index.js +65 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -100,13 +100,16 @@ Get detailed information about a specific hotel including full amenity list, des
|
|
|
100
100
|
| `checkOut` | string | Yes | Check-out date (YYYY-MM-DD) |
|
|
101
101
|
| `guests` | number | No | Number of guests (default: 2) |
|
|
102
102
|
| `rooms` | number | No | Number of rooms (default: 1) |
|
|
103
|
+
| `currency` | string | No | Currency code (USD, EUR, GBP, JPY, etc.) Default: USD |
|
|
104
|
+
| `sortBy` | enum | No | Sort results: `popularity`, `price_lowest`, `price_highest`, `rating`, `distance` |
|
|
103
105
|
|
|
104
106
|
### Rating & Price
|
|
105
107
|
|
|
106
108
|
| Filter | Type | Description |
|
|
107
109
|
|--------|------|-------------|
|
|
108
110
|
| `minRating` | number | Minimum review score: 6=Pleasant, 7=Good, 8=Very Good, 9=Wonderful |
|
|
109
|
-
| `
|
|
111
|
+
| `minPrice` | number | Minimum price per night |
|
|
112
|
+
| `maxPrice` | number | Maximum price per night |
|
|
110
113
|
|
|
111
114
|
### Property Type
|
|
112
115
|
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
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;
|
|
4
20
|
checkOut: string;
|
|
5
21
|
guests: number;
|
|
6
22
|
rooms: number;
|
|
23
|
+
currency?: string;
|
|
24
|
+
sortBy?: "popularity" | "price_lowest" | "price_highest" | "rating" | "distance";
|
|
7
25
|
}
|
|
8
26
|
export interface HotelFilters {
|
|
9
27
|
minRating?: number;
|
|
28
|
+
minPrice?: number;
|
|
10
29
|
maxPrice?: number;
|
|
11
30
|
propertyType?: "hotel" | "apartment" | "resort" | "villa" | "vacation_home" | "hostel" | "bnb" | "guesthouse" | "homestay" | "motel" | "inn" | "lodge" | "chalet" | "campground" | "glamping" | "boat" | "capsule" | "ryokan" | "riad" | "country_house" | "farm_stay";
|
|
12
31
|
starRating?: 1 | 2 | 3 | 4 | 5;
|
|
@@ -99,15 +118,22 @@ export interface HotelResult {
|
|
|
99
118
|
amenities: string[];
|
|
100
119
|
highlights: string[];
|
|
101
120
|
link: string;
|
|
121
|
+
thumbnailUrl: string | null;
|
|
122
|
+
availability: string | null;
|
|
102
123
|
matchScore?: number;
|
|
103
124
|
matchReasons?: string[];
|
|
104
125
|
}
|
|
105
126
|
export declare class HotelBrowser {
|
|
106
127
|
private browser;
|
|
107
128
|
private page;
|
|
129
|
+
private lastRequestTime;
|
|
130
|
+
private minRequestIntervalMs;
|
|
108
131
|
init(headless?: boolean): Promise<void>;
|
|
109
132
|
close(): Promise<void>;
|
|
110
133
|
private buildBookingUrl;
|
|
134
|
+
private enforceRateLimit;
|
|
135
|
+
private checkForBlocking;
|
|
136
|
+
private checkForNoResults;
|
|
111
137
|
searchHotels(params: HotelSearchParams, filters?: HotelFilters): Promise<HotelResult[]>;
|
|
112
138
|
private dismissPopups;
|
|
113
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,
|
|
@@ -191,14 +251,28 @@ export class HotelBrowser {
|
|
|
191
251
|
}
|
|
192
252
|
}
|
|
193
253
|
buildBookingUrl(params, filters) {
|
|
194
|
-
const { destination, checkIn, checkOut, guests, rooms } = params;
|
|
254
|
+
const { destination, checkIn, checkOut, guests, rooms, currency, sortBy } = params;
|
|
195
255
|
const url = new URL("https://www.booking.com/searchresults.html");
|
|
196
256
|
url.searchParams.set("ss", destination);
|
|
197
257
|
url.searchParams.set("checkin", checkIn);
|
|
198
258
|
url.searchParams.set("checkout", checkOut);
|
|
199
259
|
url.searchParams.set("group_adults", guests.toString());
|
|
200
260
|
url.searchParams.set("no_rooms", rooms.toString());
|
|
201
|
-
url.searchParams.set("selected_currency", "USD");
|
|
261
|
+
url.searchParams.set("selected_currency", currency || "USD");
|
|
262
|
+
// Sort order
|
|
263
|
+
if (sortBy) {
|
|
264
|
+
const sortMap = {
|
|
265
|
+
popularity: "popularity",
|
|
266
|
+
price_lowest: "price",
|
|
267
|
+
price_highest: "price",
|
|
268
|
+
rating: "review_score_and_price",
|
|
269
|
+
distance: "distance_from_search",
|
|
270
|
+
};
|
|
271
|
+
url.searchParams.set("order", sortMap[sortBy] || "popularity");
|
|
272
|
+
if (sortBy === "price_highest") {
|
|
273
|
+
url.searchParams.set("sort_order", "desc");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
202
276
|
if (!filters)
|
|
203
277
|
return url.toString();
|
|
204
278
|
const nfltParts = [];
|
|
@@ -399,23 +473,121 @@ export class HotelBrowser {
|
|
|
399
473
|
}
|
|
400
474
|
return url.toString();
|
|
401
475
|
}
|
|
402
|
-
|
|
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() {
|
|
403
488
|
if (!this.page)
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
);
|
|
417
520
|
}
|
|
418
|
-
|
|
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);
|
|
550
|
+
}
|
|
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
|
+
});
|
|
419
591
|
}
|
|
420
592
|
async dismissPopups() {
|
|
421
593
|
if (!this.page)
|
|
@@ -557,6 +729,24 @@ export class HotelBrowser {
|
|
|
557
729
|
highlights.push("Free Cancellation");
|
|
558
730
|
if (cardText.includes("no prepayment"))
|
|
559
731
|
highlights.push("No Prepayment");
|
|
732
|
+
// Extract thumbnail image URL
|
|
733
|
+
const imgEl = card.querySelector('img[data-testid="image"]');
|
|
734
|
+
const thumbnailUrl = imgEl?.src || null;
|
|
735
|
+
// Extract availability status (e.g., "Only 2 rooms left", "Last booked 5 minutes ago")
|
|
736
|
+
let availability = null;
|
|
737
|
+
const availabilityPatterns = [
|
|
738
|
+
/only\s*\d+\s*(rooms?|left)/i,
|
|
739
|
+
/last\s*(booked|reserved)\s*\d+\s*(minutes?|hours?)\s*ago/i,
|
|
740
|
+
/in\s*high\s*demand/i,
|
|
741
|
+
/selling\s*fast/i,
|
|
742
|
+
];
|
|
743
|
+
for (const pattern of availabilityPatterns) {
|
|
744
|
+
const match = cardText.match(pattern);
|
|
745
|
+
if (match) {
|
|
746
|
+
availability = match[0];
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
560
750
|
results.push({
|
|
561
751
|
name,
|
|
562
752
|
price,
|
|
@@ -569,6 +759,8 @@ export class HotelBrowser {
|
|
|
569
759
|
amenities,
|
|
570
760
|
highlights,
|
|
571
761
|
link,
|
|
762
|
+
thumbnailUrl,
|
|
763
|
+
availability,
|
|
572
764
|
});
|
|
573
765
|
});
|
|
574
766
|
return results;
|
|
@@ -682,6 +874,9 @@ export class HotelBrowser {
|
|
|
682
874
|
if (filters.minRating && hotel.rating && hotel.rating < filters.minRating) {
|
|
683
875
|
return false;
|
|
684
876
|
}
|
|
877
|
+
if (filters.minPrice && hotel.price && hotel.price < filters.minPrice) {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
685
880
|
if (filters.maxPrice && hotel.price && hotel.price > filters.maxPrice) {
|
|
686
881
|
return false;
|
|
687
882
|
}
|
|
@@ -690,32 +885,53 @@ export class HotelBrowser {
|
|
|
690
885
|
.sort((a, b) => (b.matchScore || 0) - (a.matchScore || 0));
|
|
691
886
|
}
|
|
692
887
|
async getHotelDetails(hotelUrl) {
|
|
693
|
-
if (!this.page)
|
|
694
|
-
throw new
|
|
695
|
-
|
|
696
|
-
await
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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...`);
|
|
719
935
|
});
|
|
720
936
|
}
|
|
721
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",
|
|
@@ -37,9 +37,13 @@ const FindHotelsSchema = z.object({
|
|
|
37
37
|
checkOut: z.string().describe("Check-out date (YYYY-MM-DD)"),
|
|
38
38
|
guests: z.number().default(2).describe("Number of guests"),
|
|
39
39
|
rooms: z.number().default(1).describe("Number of rooms"),
|
|
40
|
+
// Currency & Sorting
|
|
41
|
+
currency: z.string().optional().describe("Currency code (USD, EUR, GBP, JPY, etc.)"),
|
|
42
|
+
sortBy: z.enum(["popularity", "price_lowest", "price_highest", "rating", "distance"]).optional().describe("Sort results by"),
|
|
40
43
|
// Rating & Price
|
|
41
44
|
minRating: z.number().optional().describe("Minimum rating (6=Pleasant, 7=Good, 8=Very Good, 9=Wonderful)"),
|
|
42
|
-
|
|
45
|
+
minPrice: z.number().optional().describe("Minimum price per night"),
|
|
46
|
+
maxPrice: z.number().optional().describe("Maximum price per night"),
|
|
43
47
|
// Property & Star Rating
|
|
44
48
|
propertyType: PropertyTypeEnum.optional().describe("Type of property (hotel, resort, apartment, villa, etc.)"),
|
|
45
49
|
starRating: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]).optional().describe("Star rating (1-5)"),
|
|
@@ -136,6 +140,9 @@ function formatHotelResult(hotel, index) {
|
|
|
136
140
|
if (hotel.distanceToCenter) {
|
|
137
141
|
lines.push(` Location: ${hotel.distanceToCenter}`);
|
|
138
142
|
}
|
|
143
|
+
if (hotel.availability) {
|
|
144
|
+
lines.push(` Availability: ${hotel.availability}`);
|
|
145
|
+
}
|
|
139
146
|
if (hotel.amenities.length > 0) {
|
|
140
147
|
lines.push(` Amenities: ${hotel.amenities.join(", ")}`);
|
|
141
148
|
}
|
|
@@ -145,6 +152,9 @@ function formatHotelResult(hotel, index) {
|
|
|
145
152
|
lines.push(` Why it matches: ${hotel.matchReasons.join(", ")}`);
|
|
146
153
|
}
|
|
147
154
|
}
|
|
155
|
+
if (hotel.thumbnailUrl) {
|
|
156
|
+
lines.push(` Image: ${hotel.thumbnailUrl}`);
|
|
157
|
+
}
|
|
148
158
|
if (hotel.link) {
|
|
149
159
|
// Shorten link for readability
|
|
150
160
|
const shortLink = hotel.link.split("?")[0];
|
|
@@ -154,8 +164,8 @@ function formatHotelResult(hotel, index) {
|
|
|
154
164
|
}
|
|
155
165
|
// Create MCP server
|
|
156
166
|
const server = new Server({
|
|
157
|
-
name: "
|
|
158
|
-
version: "2.0
|
|
167
|
+
name: "hotelzero",
|
|
168
|
+
version: "1.2.0",
|
|
159
169
|
}, {
|
|
160
170
|
capabilities: {
|
|
161
171
|
tools: {},
|
|
@@ -173,7 +183,15 @@ const findHotelsInputSchema = {
|
|
|
173
183
|
rooms: { type: "number", description: "Number of rooms", default: 1 },
|
|
174
184
|
// Rating & Price
|
|
175
185
|
minRating: { type: "number", description: "Minimum rating: 6=Pleasant, 7=Good, 8=Very Good, 9=Wonderful" },
|
|
176
|
-
|
|
186
|
+
minPrice: { type: "number", description: "Minimum price per night" },
|
|
187
|
+
maxPrice: { type: "number", description: "Maximum price per night" },
|
|
188
|
+
// Currency & Sorting
|
|
189
|
+
currency: { type: "string", description: "Currency code (USD, EUR, GBP, JPY, etc.)", default: "USD" },
|
|
190
|
+
sortBy: {
|
|
191
|
+
type: "string",
|
|
192
|
+
description: "Sort results by",
|
|
193
|
+
enum: ["popularity", "price_lowest", "price_highest", "rating", "distance"]
|
|
194
|
+
},
|
|
177
195
|
// Property Type
|
|
178
196
|
propertyType: {
|
|
179
197
|
type: "string",
|
|
@@ -333,11 +351,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
333
351
|
checkOut: parsed.checkOut,
|
|
334
352
|
guests: parsed.guests,
|
|
335
353
|
rooms: parsed.rooms,
|
|
354
|
+
currency: parsed.currency,
|
|
355
|
+
sortBy: parsed.sortBy,
|
|
336
356
|
};
|
|
337
357
|
// Build filters object from all parsed parameters
|
|
338
358
|
const filters = {};
|
|
339
359
|
// Copy all filter properties
|
|
340
|
-
const filterKeys = Object.keys(parsed).filter(k => !['destination', 'checkIn', 'checkOut', 'guests', 'rooms'].includes(k));
|
|
360
|
+
const filterKeys = Object.keys(parsed).filter(k => !['destination', 'checkIn', 'checkOut', 'guests', 'rooms', 'currency', 'sortBy'].includes(k));
|
|
341
361
|
for (const key of filterKeys) {
|
|
342
362
|
const value = parsed[key];
|
|
343
363
|
if (value !== undefined && value !== null) {
|
|
@@ -389,6 +409,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
389
409
|
activeFilters.push(parsed.hotelChain);
|
|
390
410
|
if (parsed.minRating)
|
|
391
411
|
activeFilters.push(`rating ≥${parsed.minRating}`);
|
|
412
|
+
if (parsed.minPrice)
|
|
413
|
+
activeFilters.push(`≥$${parsed.minPrice}/night`);
|
|
392
414
|
if (parsed.maxPrice)
|
|
393
415
|
activeFilters.push(`≤$${parsed.maxPrice}/night`);
|
|
394
416
|
if (parsed.allInclusive)
|
|
@@ -399,6 +421,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
399
421
|
activeFilters.push("diving");
|
|
400
422
|
if (parsed.skiing)
|
|
401
423
|
activeFilters.push("skiing");
|
|
424
|
+
if (parsed.currency && parsed.currency !== "USD")
|
|
425
|
+
activeFilters.push(`currency: ${parsed.currency}`);
|
|
426
|
+
if (parsed.sortBy)
|
|
427
|
+
activeFilters.push(`sorted by: ${parsed.sortBy.replace("_", " ")}`);
|
|
402
428
|
const filtersLine = activeFilters.length > 0
|
|
403
429
|
? `Filters: ${activeFilters.join(", ")}\n\n`
|
|
404
430
|
: "\n";
|
|
@@ -455,6 +481,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
455
481
|
}
|
|
456
482
|
}
|
|
457
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
|
|
458
516
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
459
517
|
return {
|
|
460
518
|
content: [
|
|
@@ -486,7 +544,7 @@ process.on("SIGTERM", async () => {
|
|
|
486
544
|
async function main() {
|
|
487
545
|
const transport = new StdioServerTransport();
|
|
488
546
|
await server.connect(transport);
|
|
489
|
-
console.error("HotelZero v1.
|
|
547
|
+
console.error("HotelZero v1.2.0 running on stdio");
|
|
490
548
|
}
|
|
491
549
|
main().catch((error) => {
|
|
492
550
|
console.error("Fatal error:", error);
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hotelzero",
|
|
3
|
-
"version": "1.
|
|
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",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
|
-
"hotelzero": "
|
|
9
|
+
"hotelzero": "dist/index.js"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist"
|