hotelzero 1.1.0 → 1.3.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 +23 -0
- package/dist/browser.js +249 -45
- package/dist/index.js +53 -8
- 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;
|
|
@@ -6,6 +22,8 @@ export interface HotelSearchParams {
|
|
|
6
22
|
rooms: number;
|
|
7
23
|
currency?: string;
|
|
8
24
|
sortBy?: "popularity" | "price_lowest" | "price_highest" | "rating" | "distance";
|
|
25
|
+
limit?: number;
|
|
26
|
+
offset?: number;
|
|
9
27
|
}
|
|
10
28
|
export interface HotelFilters {
|
|
11
29
|
minRating?: number;
|
|
@@ -110,9 +128,14 @@ export interface HotelResult {
|
|
|
110
128
|
export declare class HotelBrowser {
|
|
111
129
|
private browser;
|
|
112
130
|
private page;
|
|
131
|
+
private lastRequestTime;
|
|
132
|
+
private minRequestIntervalMs;
|
|
113
133
|
init(headless?: boolean): Promise<void>;
|
|
114
134
|
close(): Promise<void>;
|
|
115
135
|
private buildBookingUrl;
|
|
136
|
+
private enforceRateLimit;
|
|
137
|
+
private checkForBlocking;
|
|
138
|
+
private checkForNoResults;
|
|
116
139
|
searchHotels(params: HotelSearchParams, filters?: HotelFilters): Promise<HotelResult[]>;
|
|
117
140
|
private dismissPopups;
|
|
118
141
|
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,7 +251,7 @@ export class HotelBrowser {
|
|
|
191
251
|
}
|
|
192
252
|
}
|
|
193
253
|
buildBookingUrl(params, filters) {
|
|
194
|
-
const { destination, checkIn, checkOut, guests, rooms, currency, sortBy } = params;
|
|
254
|
+
const { destination, checkIn, checkOut, guests, rooms, currency, sortBy, offset } = 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);
|
|
@@ -199,6 +259,10 @@ export class HotelBrowser {
|
|
|
199
259
|
url.searchParams.set("group_adults", guests.toString());
|
|
200
260
|
url.searchParams.set("no_rooms", rooms.toString());
|
|
201
261
|
url.searchParams.set("selected_currency", currency || "USD");
|
|
262
|
+
// Pagination offset
|
|
263
|
+
if (offset && offset > 0) {
|
|
264
|
+
url.searchParams.set("offset", offset.toString());
|
|
265
|
+
}
|
|
202
266
|
// Sort order
|
|
203
267
|
if (sortBy) {
|
|
204
268
|
const sortMap = {
|
|
@@ -413,23 +477,126 @@ export class HotelBrowser {
|
|
|
413
477
|
}
|
|
414
478
|
return url.toString();
|
|
415
479
|
}
|
|
416
|
-
|
|
480
|
+
// Rate limiting: ensure minimum time between requests
|
|
481
|
+
async enforceRateLimit() {
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
484
|
+
if (timeSinceLastRequest < this.minRequestIntervalMs) {
|
|
485
|
+
const waitTime = this.minRequestIntervalMs - timeSinceLastRequest;
|
|
486
|
+
await sleep(waitTime);
|
|
487
|
+
}
|
|
488
|
+
this.lastRequestTime = Date.now();
|
|
489
|
+
}
|
|
490
|
+
// Check for CAPTCHA or blocking pages
|
|
491
|
+
async checkForBlocking() {
|
|
417
492
|
if (!this.page)
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
493
|
+
return;
|
|
494
|
+
const pageContent = await this.page.content();
|
|
495
|
+
const pageUrl = this.page.url();
|
|
496
|
+
// Check for CAPTCHA
|
|
497
|
+
const captchaIndicators = [
|
|
498
|
+
"captcha",
|
|
499
|
+
"recaptcha",
|
|
500
|
+
"hcaptcha",
|
|
501
|
+
"challenge-running",
|
|
502
|
+
"challenge-form",
|
|
503
|
+
"px-captcha",
|
|
504
|
+
];
|
|
505
|
+
const hasCaptcha = captchaIndicators.some(indicator => pageContent.toLowerCase().includes(indicator));
|
|
506
|
+
if (hasCaptcha) {
|
|
507
|
+
throw new HotelSearchError("CAPTCHA detected. Please wait a few minutes before retrying.", ErrorCodes.CAPTCHA_DETECTED, false // Not retryable automatically
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
// Check for rate limiting / blocking
|
|
511
|
+
const blockIndicators = [
|
|
512
|
+
"access denied",
|
|
513
|
+
"too many requests",
|
|
514
|
+
"rate limit",
|
|
515
|
+
"blocked",
|
|
516
|
+
"forbidden",
|
|
517
|
+
"error 403",
|
|
518
|
+
"error 429",
|
|
519
|
+
];
|
|
520
|
+
const isBlocked = blockIndicators.some(indicator => pageContent.toLowerCase().includes(indicator));
|
|
521
|
+
if (isBlocked || pageUrl.includes("blocked") || pageUrl.includes("error")) {
|
|
522
|
+
throw new HotelSearchError("Request blocked by Booking.com. Please wait a few minutes before retrying.", ErrorCodes.BLOCKED, true // Retryable with backoff
|
|
523
|
+
);
|
|
431
524
|
}
|
|
432
|
-
|
|
525
|
+
}
|
|
526
|
+
// Check if destination was found
|
|
527
|
+
async checkForNoResults() {
|
|
528
|
+
if (!this.page)
|
|
529
|
+
return;
|
|
530
|
+
const pageContent = await this.page.content();
|
|
531
|
+
// Check for "no results" or "destination not found" messages
|
|
532
|
+
const noResultsIndicators = [
|
|
533
|
+
"no properties found",
|
|
534
|
+
"no results",
|
|
535
|
+
"0 properties",
|
|
536
|
+
"we couldn't find",
|
|
537
|
+
"try different dates",
|
|
538
|
+
];
|
|
539
|
+
const hasNoResults = noResultsIndicators.some(indicator => pageContent.toLowerCase().includes(indicator));
|
|
540
|
+
if (hasNoResults) {
|
|
541
|
+
// Check if it's a destination issue or just no matching filters
|
|
542
|
+
const destinationIssue = pageContent.toLowerCase().includes("destination") &&
|
|
543
|
+
(pageContent.toLowerCase().includes("not found") ||
|
|
544
|
+
pageContent.toLowerCase().includes("couldn't find"));
|
|
545
|
+
if (destinationIssue) {
|
|
546
|
+
throw new HotelSearchError("Destination not found. Please check the spelling and try again.", ErrorCodes.DESTINATION_NOT_FOUND, false);
|
|
547
|
+
}
|
|
548
|
+
// No results but destination is valid - this is not an error, just empty results
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async searchHotels(params, filters) {
|
|
552
|
+
if (!this.page) {
|
|
553
|
+
throw new HotelSearchError("Browser not initialized. Call init() first.", ErrorCodes.BROWSER_NOT_INITIALIZED, false);
|
|
554
|
+
}
|
|
555
|
+
const url = this.buildBookingUrl(params, filters);
|
|
556
|
+
// Use retry with exponential backoff for the main search operation
|
|
557
|
+
return await retryWithBackoff(async () => {
|
|
558
|
+
// Enforce rate limiting
|
|
559
|
+
await this.enforceRateLimit();
|
|
560
|
+
try {
|
|
561
|
+
await this.page.goto(url, {
|
|
562
|
+
waitUntil: "networkidle",
|
|
563
|
+
timeout: 30000
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
const err = error;
|
|
568
|
+
if (err.message.includes("timeout") || err.message.includes("Timeout")) {
|
|
569
|
+
throw new HotelSearchError("Page load timed out. The server may be slow or unavailable.", ErrorCodes.TIMEOUT, true);
|
|
570
|
+
}
|
|
571
|
+
if (err.message.includes("net::") || err.message.includes("Network")) {
|
|
572
|
+
throw new HotelSearchError("Network error occurred. Please check your connection.", ErrorCodes.NETWORK_ERROR, true);
|
|
573
|
+
}
|
|
574
|
+
throw new HotelSearchError(`Navigation failed: ${err.message}`, ErrorCodes.NAVIGATION_FAILED, true);
|
|
575
|
+
}
|
|
576
|
+
await this.page.waitForTimeout(2000);
|
|
577
|
+
// Check for blocking/CAPTCHA before proceeding
|
|
578
|
+
await this.checkForBlocking();
|
|
579
|
+
// Check for no results / destination issues
|
|
580
|
+
await this.checkForNoResults();
|
|
581
|
+
// Close any popups/modals
|
|
582
|
+
await this.dismissPopups();
|
|
583
|
+
// Scroll to load more results (pass limit to control how many to load)
|
|
584
|
+
const targetResults = params.limit || 25;
|
|
585
|
+
await this.scrollToLoadMore(targetResults);
|
|
586
|
+
// Extract detailed hotel info
|
|
587
|
+
let hotels = await this.extractHotelDetails();
|
|
588
|
+
// Apply limit to cap results
|
|
589
|
+
if (params.limit && params.limit > 0) {
|
|
590
|
+
hotels = hotels.slice(0, params.limit);
|
|
591
|
+
}
|
|
592
|
+
// Apply client-side filtering and scoring if we have preferences
|
|
593
|
+
if (filters) {
|
|
594
|
+
return this.scoreAndFilterHotels(hotels, filters);
|
|
595
|
+
}
|
|
596
|
+
return hotels;
|
|
597
|
+
}, DEFAULT_RETRY_CONFIG, (attempt, error, delayMs) => {
|
|
598
|
+
console.error(`Search attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delayMs / 1000)}s...`);
|
|
599
|
+
});
|
|
433
600
|
}
|
|
434
601
|
async dismissPopups() {
|
|
435
602
|
if (!this.page)
|
|
@@ -455,15 +622,31 @@ export class HotelBrowser {
|
|
|
455
622
|
}
|
|
456
623
|
}
|
|
457
624
|
}
|
|
458
|
-
async scrollToLoadMore() {
|
|
625
|
+
async scrollToLoadMore(targetResults = 25) {
|
|
459
626
|
if (!this.page)
|
|
460
627
|
return;
|
|
461
|
-
//
|
|
462
|
-
|
|
628
|
+
// Calculate how many scroll iterations needed
|
|
629
|
+
// Each scroll typically loads ~15-25 more results
|
|
630
|
+
// We start with ~25, so to get to targetResults we need (targetResults - 25) / 20 more scrolls
|
|
631
|
+
const scrollsNeeded = Math.max(1, Math.ceil((targetResults - 25) / 20));
|
|
632
|
+
const maxScrolls = Math.min(scrollsNeeded, 5); // Cap at 5 scrolls to avoid excessive loading
|
|
633
|
+
// Scroll down to load more results
|
|
634
|
+
for (let i = 0; i < maxScrolls; i++) {
|
|
463
635
|
await this.page.evaluate(() => {
|
|
464
636
|
window.scrollBy(0, window.innerHeight);
|
|
465
637
|
});
|
|
466
638
|
await this.page.waitForTimeout(1000);
|
|
639
|
+
// Check if "Load more" button exists and click it
|
|
640
|
+
try {
|
|
641
|
+
const loadMoreBtn = await this.page.$('button[data-testid="load-more-results"]');
|
|
642
|
+
if (loadMoreBtn) {
|
|
643
|
+
await loadMoreBtn.click();
|
|
644
|
+
await this.page.waitForTimeout(1500);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
// Ignore if button not found
|
|
649
|
+
}
|
|
467
650
|
}
|
|
468
651
|
// Scroll back to top
|
|
469
652
|
await this.page.evaluate(() => {
|
|
@@ -727,32 +910,53 @@ export class HotelBrowser {
|
|
|
727
910
|
.sort((a, b) => (b.matchScore || 0) - (a.matchScore || 0));
|
|
728
911
|
}
|
|
729
912
|
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
|
-
|
|
913
|
+
if (!this.page) {
|
|
914
|
+
throw new HotelSearchError("Browser not initialized. Call init() first.", ErrorCodes.BROWSER_NOT_INITIALIZED, false);
|
|
915
|
+
}
|
|
916
|
+
return await retryWithBackoff(async () => {
|
|
917
|
+
// Enforce rate limiting
|
|
918
|
+
await this.enforceRateLimit();
|
|
919
|
+
try {
|
|
920
|
+
await this.page.goto(hotelUrl, {
|
|
921
|
+
waitUntil: "networkidle",
|
|
922
|
+
timeout: 30000
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
const err = error;
|
|
927
|
+
if (err.message.includes("timeout") || err.message.includes("Timeout")) {
|
|
928
|
+
throw new HotelSearchError("Page load timed out. The server may be slow or unavailable.", ErrorCodes.TIMEOUT, true);
|
|
929
|
+
}
|
|
930
|
+
throw new HotelSearchError(`Navigation failed: ${err.message}`, ErrorCodes.NAVIGATION_FAILED, true);
|
|
931
|
+
}
|
|
932
|
+
await this.page.waitForTimeout(2000);
|
|
933
|
+
// Check for blocking/CAPTCHA
|
|
934
|
+
await this.checkForBlocking();
|
|
935
|
+
await this.dismissPopups();
|
|
936
|
+
// Extract detailed information from hotel page
|
|
937
|
+
return await this.page.evaluate(() => {
|
|
938
|
+
const details = {};
|
|
939
|
+
// Hotel name
|
|
940
|
+
const nameEl = document.querySelector('h2[class*="pp-header"]');
|
|
941
|
+
details.name = nameEl?.textContent?.trim();
|
|
942
|
+
// Description
|
|
943
|
+
const descEl = document.querySelector('[data-testid="property-description"]');
|
|
944
|
+
details.description = descEl?.textContent?.trim();
|
|
945
|
+
// All facilities
|
|
946
|
+
const facilityEls = document.querySelectorAll('[data-testid="property-section-facilities"] li');
|
|
947
|
+
details.facilities = Array.from(facilityEls).map((el) => el.textContent?.trim());
|
|
948
|
+
// Photos
|
|
949
|
+
const photoEls = document.querySelectorAll('[data-testid="gallery-image"] img');
|
|
950
|
+
details.photos = Array.from(photoEls)
|
|
951
|
+
.slice(0, 5)
|
|
952
|
+
.map((el) => el.src);
|
|
953
|
+
// Popular facilities highlighted
|
|
954
|
+
const popularFacilities = document.querySelectorAll('[data-testid="property-most-popular-facilities"] span');
|
|
955
|
+
details.popularFacilities = Array.from(popularFacilities).map((el) => el.textContent?.trim());
|
|
956
|
+
return details;
|
|
957
|
+
});
|
|
958
|
+
}, DEFAULT_RETRY_CONFIG, (attempt, error, delayMs) => {
|
|
959
|
+
console.error(`Get details attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delayMs / 1000)}s...`);
|
|
756
960
|
});
|
|
757
961
|
}
|
|
758
962
|
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",
|
|
@@ -40,6 +40,9 @@ const FindHotelsSchema = z.object({
|
|
|
40
40
|
// Currency & Sorting
|
|
41
41
|
currency: z.string().optional().describe("Currency code (USD, EUR, GBP, JPY, etc.)"),
|
|
42
42
|
sortBy: z.enum(["popularity", "price_lowest", "price_highest", "rating", "distance"]).optional().describe("Sort results by"),
|
|
43
|
+
// Pagination
|
|
44
|
+
limit: z.number().min(1).max(100).optional().describe("Maximum number of results to return (default: 25, max: 100)"),
|
|
45
|
+
offset: z.number().min(0).optional().describe("Number of results to skip for pagination (default: 0)"),
|
|
43
46
|
// Rating & Price
|
|
44
47
|
minRating: z.number().optional().describe("Minimum rating (6=Pleasant, 7=Good, 8=Very Good, 9=Wonderful)"),
|
|
45
48
|
minPrice: z.number().optional().describe("Minimum price per night"),
|
|
@@ -165,7 +168,7 @@ function formatHotelResult(hotel, index) {
|
|
|
165
168
|
// Create MCP server
|
|
166
169
|
const server = new Server({
|
|
167
170
|
name: "hotelzero",
|
|
168
|
-
version: "1.
|
|
171
|
+
version: "1.3.0",
|
|
169
172
|
}, {
|
|
170
173
|
capabilities: {
|
|
171
174
|
tools: {},
|
|
@@ -192,6 +195,9 @@ const findHotelsInputSchema = {
|
|
|
192
195
|
description: "Sort results by",
|
|
193
196
|
enum: ["popularity", "price_lowest", "price_highest", "rating", "distance"]
|
|
194
197
|
},
|
|
198
|
+
// Pagination
|
|
199
|
+
limit: { type: "number", description: "Maximum results to return (default: 25, max: 100)", default: 25 },
|
|
200
|
+
offset: { type: "number", description: "Number of results to skip for pagination", default: 0 },
|
|
195
201
|
// Property Type
|
|
196
202
|
propertyType: {
|
|
197
203
|
type: "string",
|
|
@@ -353,11 +359,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
353
359
|
rooms: parsed.rooms,
|
|
354
360
|
currency: parsed.currency,
|
|
355
361
|
sortBy: parsed.sortBy,
|
|
362
|
+
limit: parsed.limit,
|
|
363
|
+
offset: parsed.offset,
|
|
356
364
|
};
|
|
357
365
|
// Build filters object from all parsed parameters
|
|
358
366
|
const filters = {};
|
|
359
|
-
// Copy all filter properties
|
|
360
|
-
const filterKeys = Object.keys(parsed).filter(k => !['destination', 'checkIn', 'checkOut', 'guests', 'rooms', 'currency', 'sortBy'].includes(k));
|
|
367
|
+
// Copy all filter properties (exclude search params)
|
|
368
|
+
const filterKeys = Object.keys(parsed).filter(k => !['destination', 'checkIn', 'checkOut', 'guests', 'rooms', 'currency', 'sortBy', 'limit', 'offset'].includes(k));
|
|
361
369
|
for (const key of filterKeys) {
|
|
362
370
|
const value = parsed[key];
|
|
363
371
|
if (value !== undefined && value !== null) {
|
|
@@ -428,15 +436,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
428
436
|
const filtersLine = activeFilters.length > 0
|
|
429
437
|
? `Filters: ${activeFilters.join(", ")}\n\n`
|
|
430
438
|
: "\n";
|
|
439
|
+
// Pagination info
|
|
440
|
+
const offset = parsed.offset || 0;
|
|
441
|
+
const displayLimit = parsed.limit || 25;
|
|
442
|
+
const paginationLine = offset > 0
|
|
443
|
+
? `Showing results ${offset + 1}-${offset + results.length} of available hotels\n\n`
|
|
444
|
+
: "";
|
|
431
445
|
const hotelList = results
|
|
432
|
-
.
|
|
433
|
-
.map((h, i) => formatHotelResult(h, i))
|
|
446
|
+
.map((h, i) => formatHotelResult(h, i + offset))
|
|
434
447
|
.join("\n\n");
|
|
435
448
|
return {
|
|
436
449
|
content: [
|
|
437
450
|
{
|
|
438
451
|
type: "text",
|
|
439
|
-
text: header + filtersLine + hotelList,
|
|
452
|
+
text: header + filtersLine + paginationLine + hotelList,
|
|
440
453
|
},
|
|
441
454
|
],
|
|
442
455
|
};
|
|
@@ -481,6 +494,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
481
494
|
}
|
|
482
495
|
}
|
|
483
496
|
catch (error) {
|
|
497
|
+
// Handle custom HotelSearchError with structured error info
|
|
498
|
+
if (error instanceof HotelSearchError) {
|
|
499
|
+
let helpText = "";
|
|
500
|
+
switch (error.code) {
|
|
501
|
+
case ErrorCodes.CAPTCHA_DETECTED:
|
|
502
|
+
helpText = "\n\nTip: Wait 5-10 minutes before trying again.";
|
|
503
|
+
break;
|
|
504
|
+
case ErrorCodes.RATE_LIMITED:
|
|
505
|
+
case ErrorCodes.BLOCKED:
|
|
506
|
+
helpText = "\n\nTip: The server is rate-limiting requests. Wait a few minutes before trying again.";
|
|
507
|
+
break;
|
|
508
|
+
case ErrorCodes.DESTINATION_NOT_FOUND:
|
|
509
|
+
helpText = "\n\nTip: Check the destination spelling. Try a more specific location like 'Paris, France' instead of just 'Paris'.";
|
|
510
|
+
break;
|
|
511
|
+
case ErrorCodes.TIMEOUT:
|
|
512
|
+
helpText = "\n\nTip: The request timed out. This may be due to slow network or server issues. Try again.";
|
|
513
|
+
break;
|
|
514
|
+
case ErrorCodes.NETWORK_ERROR:
|
|
515
|
+
helpText = "\n\nTip: Check your internet connection and try again.";
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
content: [
|
|
520
|
+
{
|
|
521
|
+
type: "text",
|
|
522
|
+
text: `Error [${error.code}]: ${error.message}${helpText}`,
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
isError: true,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// Handle generic errors
|
|
484
529
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
485
530
|
return {
|
|
486
531
|
content: [
|
|
@@ -512,7 +557,7 @@ process.on("SIGTERM", async () => {
|
|
|
512
557
|
async function main() {
|
|
513
558
|
const transport = new StdioServerTransport();
|
|
514
559
|
await server.connect(transport);
|
|
515
|
-
console.error("HotelZero v1.
|
|
560
|
+
console.error("HotelZero v1.3.0 running on stdio");
|
|
516
561
|
}
|
|
517
562
|
main().catch((error) => {
|
|
518
563
|
console.error("Fatal error:", error);
|