hotelzero 1.3.0 → 1.4.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 +31 -1
- package/dist/browser.js +295 -21
- package/dist/index.js +174 -14
- package/package.json +1 -1
package/dist/browser.d.ts
CHANGED
|
@@ -23,7 +23,6 @@ export interface HotelSearchParams {
|
|
|
23
23
|
currency?: string;
|
|
24
24
|
sortBy?: "popularity" | "price_lowest" | "price_highest" | "rating" | "distance";
|
|
25
25
|
limit?: number;
|
|
26
|
-
offset?: number;
|
|
27
26
|
}
|
|
28
27
|
export interface HotelFilters {
|
|
29
28
|
minRating?: number;
|
|
@@ -125,6 +124,29 @@ export interface HotelResult {
|
|
|
125
124
|
matchScore?: number;
|
|
126
125
|
matchReasons?: string[];
|
|
127
126
|
}
|
|
127
|
+
export interface HotelDetails {
|
|
128
|
+
name: string;
|
|
129
|
+
url: string;
|
|
130
|
+
rating: number | null;
|
|
131
|
+
ratingText: string;
|
|
132
|
+
reviewCount: number | null;
|
|
133
|
+
starRating: number | null;
|
|
134
|
+
address: string;
|
|
135
|
+
description: string;
|
|
136
|
+
highlights: string;
|
|
137
|
+
pricePerNight: number | null;
|
|
138
|
+
priceDisplay: string;
|
|
139
|
+
totalPrice: string;
|
|
140
|
+
checkInTime: string;
|
|
141
|
+
checkOutTime: string;
|
|
142
|
+
popularFacilities: string[];
|
|
143
|
+
allFacilities: string[];
|
|
144
|
+
roomTypes: string[];
|
|
145
|
+
photos: string[];
|
|
146
|
+
nearbyAttractions: string[];
|
|
147
|
+
guestReviewHighlights: string[];
|
|
148
|
+
locationInfo: string;
|
|
149
|
+
}
|
|
128
150
|
export declare class HotelBrowser {
|
|
129
151
|
private browser;
|
|
130
152
|
private page;
|
|
@@ -144,4 +166,12 @@ export declare class HotelBrowser {
|
|
|
144
166
|
getHotelDetails(hotelUrl: string): Promise<Record<string, unknown>>;
|
|
145
167
|
takeScreenshot(path: string): Promise<void>;
|
|
146
168
|
getPageContent(): Promise<string>;
|
|
169
|
+
/**
|
|
170
|
+
* Get detailed hotel information for comparison
|
|
171
|
+
*/
|
|
172
|
+
getHotelDetailsForComparison(hotelUrl: string): Promise<HotelDetails>;
|
|
173
|
+
/**
|
|
174
|
+
* Compare multiple hotels side-by-side
|
|
175
|
+
*/
|
|
176
|
+
compareHotels(hotelUrls: string[]): Promise<HotelDetails[]>;
|
|
147
177
|
}
|
package/dist/browser.js
CHANGED
|
@@ -251,7 +251,7 @@ export class HotelBrowser {
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
buildBookingUrl(params, filters) {
|
|
254
|
-
const { destination, checkIn, checkOut, guests, rooms, currency, sortBy
|
|
254
|
+
const { destination, checkIn, checkOut, guests, rooms, currency, sortBy } = params;
|
|
255
255
|
const url = new URL("https://www.booking.com/searchresults.html");
|
|
256
256
|
url.searchParams.set("ss", destination);
|
|
257
257
|
url.searchParams.set("checkin", checkIn);
|
|
@@ -259,10 +259,6 @@ export class HotelBrowser {
|
|
|
259
259
|
url.searchParams.set("group_adults", guests.toString());
|
|
260
260
|
url.searchParams.set("no_rooms", rooms.toString());
|
|
261
261
|
url.searchParams.set("selected_currency", currency || "USD");
|
|
262
|
-
// Pagination offset
|
|
263
|
-
if (offset && offset > 0) {
|
|
264
|
-
url.searchParams.set("offset", offset.toString());
|
|
265
|
-
}
|
|
266
262
|
// Sort order
|
|
267
263
|
if (sortBy) {
|
|
268
264
|
const sortMap = {
|
|
@@ -625,33 +621,42 @@ export class HotelBrowser {
|
|
|
625
621
|
async scrollToLoadMore(targetResults = 25) {
|
|
626
622
|
if (!this.page)
|
|
627
623
|
return;
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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++) {
|
|
635
|
-
await this.page.evaluate(() => {
|
|
636
|
-
window.scrollBy(0, window.innerHeight);
|
|
637
|
-
});
|
|
624
|
+
// If we only need 25 or fewer, minimal scrolling
|
|
625
|
+
if (targetResults <= 25) {
|
|
626
|
+
// Just one scroll to ensure initial results are loaded
|
|
627
|
+
await this.page.evaluate(() => window.scrollBy(0, window.innerHeight));
|
|
638
628
|
await this.page.waitForTimeout(1000);
|
|
639
|
-
|
|
629
|
+
await this.page.evaluate(() => window.scrollTo(0, 0));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
// For larger limits, we need to scroll and click "Load more" multiple times
|
|
633
|
+
// Booking.com loads ~25 results initially, then ~25 more per "Load more" click
|
|
634
|
+
const clicksNeeded = Math.ceil((targetResults - 25) / 25);
|
|
635
|
+
const maxClicks = Math.min(clicksNeeded, 4); // Cap at 4 clicks (~125 results max)
|
|
636
|
+
for (let i = 0; i < maxClicks; i++) {
|
|
637
|
+
// Scroll to bottom to trigger lazy loading and find "Load more" button
|
|
638
|
+
await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
639
|
+
await this.page.waitForTimeout(1500);
|
|
640
|
+
// Try to click "Load more" button
|
|
640
641
|
try {
|
|
641
642
|
const loadMoreBtn = await this.page.$('button[data-testid="load-more-results"]');
|
|
642
643
|
if (loadMoreBtn) {
|
|
643
644
|
await loadMoreBtn.click();
|
|
644
|
-
await this.page.waitForTimeout(
|
|
645
|
+
await this.page.waitForTimeout(2000); // Wait for results to load
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
// No more "Load more" button, we've loaded all available results
|
|
649
|
+
break;
|
|
645
650
|
}
|
|
646
651
|
}
|
|
647
652
|
catch {
|
|
648
|
-
//
|
|
653
|
+
// Button not found or click failed
|
|
654
|
+
break;
|
|
649
655
|
}
|
|
650
656
|
}
|
|
651
657
|
// Scroll back to top
|
|
652
|
-
await this.page.evaluate(() =>
|
|
653
|
-
|
|
654
|
-
});
|
|
658
|
+
await this.page.evaluate(() => window.scrollTo(0, 0));
|
|
659
|
+
await this.page.waitForTimeout(500);
|
|
655
660
|
}
|
|
656
661
|
async extractHotelDetails() {
|
|
657
662
|
if (!this.page)
|
|
@@ -969,4 +974,273 @@ export class HotelBrowser {
|
|
|
969
974
|
throw new Error("Browser not initialized");
|
|
970
975
|
return await this.page.content();
|
|
971
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* Get detailed hotel information for comparison
|
|
979
|
+
*/
|
|
980
|
+
async getHotelDetailsForComparison(hotelUrl) {
|
|
981
|
+
if (!this.page) {
|
|
982
|
+
throw new HotelSearchError("Browser not initialized. Call init() first.", ErrorCodes.BROWSER_NOT_INITIALIZED, false);
|
|
983
|
+
}
|
|
984
|
+
return await retryWithBackoff(async () => {
|
|
985
|
+
await this.enforceRateLimit();
|
|
986
|
+
try {
|
|
987
|
+
await this.page.goto(hotelUrl, {
|
|
988
|
+
waitUntil: "networkidle",
|
|
989
|
+
timeout: 30000
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
const err = error;
|
|
994
|
+
if (err.message.includes("timeout") || err.message.includes("Timeout")) {
|
|
995
|
+
throw new HotelSearchError("Page load timed out.", ErrorCodes.TIMEOUT, true);
|
|
996
|
+
}
|
|
997
|
+
throw new HotelSearchError(`Navigation failed: ${err.message}`, ErrorCodes.NAVIGATION_FAILED, true);
|
|
998
|
+
}
|
|
999
|
+
await this.page.waitForTimeout(2000);
|
|
1000
|
+
await this.checkForBlocking();
|
|
1001
|
+
await this.dismissPopups();
|
|
1002
|
+
// Extract comprehensive hotel details using evaluate with string to avoid __name compilation issues
|
|
1003
|
+
const details = await this.page.evaluate(`
|
|
1004
|
+
(function() {
|
|
1005
|
+
function getText(selector) {
|
|
1006
|
+
var el = document.querySelector(selector);
|
|
1007
|
+
return el && el.textContent ? el.textContent.trim() : "";
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function getTexts(selector) {
|
|
1011
|
+
var elements = document.querySelectorAll(selector);
|
|
1012
|
+
var result = [];
|
|
1013
|
+
for (var i = 0; i < elements.length; i++) {
|
|
1014
|
+
var text = elements[i].textContent;
|
|
1015
|
+
if (text) {
|
|
1016
|
+
text = text.trim();
|
|
1017
|
+
if (text.length > 0) result.push(text);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return result;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function getUniqueTexts(selector) {
|
|
1024
|
+
var texts = getTexts(selector);
|
|
1025
|
+
var seen = {};
|
|
1026
|
+
var result = [];
|
|
1027
|
+
for (var i = 0; i < texts.length; i++) {
|
|
1028
|
+
if (!seen[texts[i]]) {
|
|
1029
|
+
seen[texts[i]] = true;
|
|
1030
|
+
result.push(texts[i]);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return result;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Name - h2 is cleaner than h1 on Booking.com property pages
|
|
1037
|
+
var name = getText('h2');
|
|
1038
|
+
if (!name) name = getText('h1').split('(')[0].trim(); // fallback, strip suffix
|
|
1039
|
+
|
|
1040
|
+
// Rating - parse from review-score-component which contains "Scored 7.4 7.4Rated good Good · 11 reviews"
|
|
1041
|
+
var ratingEl = document.querySelector('[data-testid="review-score-component"]');
|
|
1042
|
+
var ratingFullText = ratingEl ? ratingEl.textContent || "" : "";
|
|
1043
|
+
|
|
1044
|
+
// Extract numeric rating (first number after "Scored")
|
|
1045
|
+
var ratingMatch = ratingFullText.match(/Scored\\s+([\\d.]+)/i);
|
|
1046
|
+
var rating = ratingMatch ? parseFloat(ratingMatch[1]) : null;
|
|
1047
|
+
|
|
1048
|
+
// Extract rating description (Good, Excellent, etc.)
|
|
1049
|
+
var descMatch = ratingFullText.match(/Rated\\s*\\w+\\s*(\\w+)/i);
|
|
1050
|
+
var ratingDesc = descMatch ? descMatch[1] : "";
|
|
1051
|
+
if (!ratingDesc) {
|
|
1052
|
+
// Try alternate pattern
|
|
1053
|
+
var altMatch = ratingFullText.match(/(Exceptional|Superb|Excellent|Very Good|Good|Pleasant|Review score)/i);
|
|
1054
|
+
ratingDesc = altMatch ? altMatch[1] : "";
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Review count - look for number followed by "review"
|
|
1058
|
+
var reviewMatch = ratingFullText.match(/([\\d,]+)\\s*reviews?/i);
|
|
1059
|
+
var reviewCount = reviewMatch ? parseInt(reviewMatch[1].replace(/,/g, "")) : null;
|
|
1060
|
+
|
|
1061
|
+
// Star rating (for hotels)
|
|
1062
|
+
var starEl = document.querySelector('[data-testid="rating-stars"]');
|
|
1063
|
+
var starCount = starEl ? starEl.querySelectorAll('span[class*="star"], svg').length : null;
|
|
1064
|
+
// Sometimes stars are indicated by aria-label
|
|
1065
|
+
if (!starCount) {
|
|
1066
|
+
var starLabel = document.querySelector('[aria-label*="star"]');
|
|
1067
|
+
if (starLabel) {
|
|
1068
|
+
var labelMatch = starLabel.getAttribute('aria-label').match(/(\\d+)/);
|
|
1069
|
+
starCount = labelMatch ? parseInt(labelMatch[1]) : null;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Address - from header address wrapper, clean up extra content
|
|
1074
|
+
var address = "";
|
|
1075
|
+
var addressWrapper = document.querySelector('[data-testid="PropertyHeaderAddressDesktop-wrapper"]');
|
|
1076
|
+
if (addressWrapper) {
|
|
1077
|
+
// Get all spans and find the one with the actual address
|
|
1078
|
+
var spans = addressWrapper.querySelectorAll('span');
|
|
1079
|
+
for (var i = 0; i < spans.length; i++) {
|
|
1080
|
+
var text = spans[i].textContent ? spans[i].textContent.trim() : "";
|
|
1081
|
+
// Address typically contains comma-separated parts ending with country
|
|
1082
|
+
if (text.length > 10 && text.indexOf(",") > 0) {
|
|
1083
|
+
// Cut off at common suffixes that indicate end of address
|
|
1084
|
+
var cutoffs = ["Excellent location", "Great location", "Good location", "Very good location", "show map", "– rated", "After booking"];
|
|
1085
|
+
for (var j = 0; j < cutoffs.length; j++) {
|
|
1086
|
+
var idx = text.indexOf(cutoffs[j]);
|
|
1087
|
+
if (idx > 0) {
|
|
1088
|
+
text = text.substring(0, idx).trim();
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (text.length > 10) {
|
|
1093
|
+
address = text;
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (!address) address = getText('[data-testid="property-header-location"]');
|
|
1100
|
+
|
|
1101
|
+
// Description
|
|
1102
|
+
var description = getText('[data-testid="property-description"]');
|
|
1103
|
+
|
|
1104
|
+
// Property highlights (size, bathroom, etc.)
|
|
1105
|
+
var highlights = getText('[data-testid="property-highlights"]');
|
|
1106
|
+
|
|
1107
|
+
// Price - look in the reservation/booking section
|
|
1108
|
+
var priceDisplay = "";
|
|
1109
|
+
var pricePerNight = null;
|
|
1110
|
+
|
|
1111
|
+
// Try multiple price selectors
|
|
1112
|
+
var priceSelectors = [
|
|
1113
|
+
'[data-testid="price-and-discounted-price"]',
|
|
1114
|
+
'[class*="prco-valign-middle-helper"]',
|
|
1115
|
+
'[class*="bui-price-display__value"]',
|
|
1116
|
+
'span[class*="price"]'
|
|
1117
|
+
];
|
|
1118
|
+
|
|
1119
|
+
for (var i = 0; i < priceSelectors.length; i++) {
|
|
1120
|
+
var priceEl = document.querySelector(priceSelectors[i]);
|
|
1121
|
+
if (priceEl && priceEl.textContent) {
|
|
1122
|
+
var text = priceEl.textContent.trim();
|
|
1123
|
+
// Look for currency symbol followed by number
|
|
1124
|
+
var match = text.match(/[\\$€£¥]\\s*([\\d,]+)/);
|
|
1125
|
+
if (match) {
|
|
1126
|
+
priceDisplay = text;
|
|
1127
|
+
pricePerNight = parseInt(match[1].replace(/,/g, ""));
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Check-in/out times - try multiple approaches
|
|
1134
|
+
var checkInTime = getText('[data-testid="check-in-time"]');
|
|
1135
|
+
var checkOutTime = getText('[data-testid="check-out-time"]');
|
|
1136
|
+
|
|
1137
|
+
// If not found, look in policies section
|
|
1138
|
+
if (!checkInTime) {
|
|
1139
|
+
var policyText = getText('[data-testid="policy-summary"]') || "";
|
|
1140
|
+
var checkInMatch = policyText.match(/check-in[:\\s]*(\\d{1,2}:\\d{2})/i);
|
|
1141
|
+
checkInTime = checkInMatch ? checkInMatch[1] : "";
|
|
1142
|
+
}
|
|
1143
|
+
if (!checkOutTime) {
|
|
1144
|
+
var policyText = getText('[data-testid="policy-summary"]') || "";
|
|
1145
|
+
var checkOutMatch = policyText.match(/check-out[:\\s]*(\\d{1,2}:\\d{2})/i);
|
|
1146
|
+
checkOutTime = checkOutMatch ? checkOutMatch[1] : "";
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Popular facilities - from the wrapper, get unique spans
|
|
1150
|
+
var facilitiesWrapper = document.querySelector('[data-testid="property-most-popular-facilities-wrapper"]');
|
|
1151
|
+
var popularFacilities = [];
|
|
1152
|
+
if (facilitiesWrapper) {
|
|
1153
|
+
var spans = facilitiesWrapper.querySelectorAll('span');
|
|
1154
|
+
var seen = {};
|
|
1155
|
+
for (var i = 0; i < spans.length; i++) {
|
|
1156
|
+
var text = spans[i].textContent ? spans[i].textContent.trim() : "";
|
|
1157
|
+
// Skip labels like "Most popular amenities" and short items
|
|
1158
|
+
if (text && text.length > 2 && text.length < 50 && !seen[text] &&
|
|
1159
|
+
text.indexOf("Most popular") === -1 && text.indexOf("amenities") === -1) {
|
|
1160
|
+
seen[text] = true;
|
|
1161
|
+
popularFacilities.push(text);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// If still empty, try property-highlights
|
|
1167
|
+
if (popularFacilities.length === 0 && highlights) {
|
|
1168
|
+
// Parse highlights like "Private bathroomFree WifiShower..."
|
|
1169
|
+
var items = highlights.split(/(?=[A-Z][a-z])/);
|
|
1170
|
+
for (var i = 0; i < items.length; i++) {
|
|
1171
|
+
var item = items[i].trim();
|
|
1172
|
+
if (item && item.length > 2) popularFacilities.push(item);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// All facilities from facilities section
|
|
1177
|
+
var allFacilities = getUniqueTexts('[data-testid="property-section-facilities"] li');
|
|
1178
|
+
if (allFacilities.length === 0) {
|
|
1179
|
+
allFacilities = getUniqueTexts('[data-testid="Property-Facilities-Tab-Content"] li');
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Room types
|
|
1183
|
+
var roomTypes = getUniqueTexts('[data-testid="room-name"]');
|
|
1184
|
+
|
|
1185
|
+
// Photos from gallery
|
|
1186
|
+
var photoEls = document.querySelectorAll('[data-testid="GalleryUnifiedDesktop-wrapper"] img, [class*="gallery"] img');
|
|
1187
|
+
var photos = [];
|
|
1188
|
+
var seenPhotos = {};
|
|
1189
|
+
for (var i = 0; i < photoEls.length && photos.length < 5; i++) {
|
|
1190
|
+
var src = photoEls[i].src;
|
|
1191
|
+
if (src && src.indexOf("data:") === -1 && !seenPhotos[src]) {
|
|
1192
|
+
seenPhotos[src] = true;
|
|
1193
|
+
photos.push(src);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Location info from map
|
|
1198
|
+
var locationInfo = getText('[data-testid="map-entry-point-desktop"]');
|
|
1199
|
+
|
|
1200
|
+
return {
|
|
1201
|
+
name: name,
|
|
1202
|
+
rating: rating,
|
|
1203
|
+
ratingText: ratingDesc,
|
|
1204
|
+
reviewCount: reviewCount,
|
|
1205
|
+
starRating: starCount,
|
|
1206
|
+
address: address,
|
|
1207
|
+
description: description.slice(0, 500),
|
|
1208
|
+
highlights: highlights,
|
|
1209
|
+
pricePerNight: pricePerNight,
|
|
1210
|
+
priceDisplay: priceDisplay,
|
|
1211
|
+
totalPrice: "",
|
|
1212
|
+
checkInTime: checkInTime,
|
|
1213
|
+
checkOutTime: checkOutTime,
|
|
1214
|
+
popularFacilities: popularFacilities.slice(0, 15),
|
|
1215
|
+
allFacilities: allFacilities.slice(0, 30),
|
|
1216
|
+
roomTypes: roomTypes.slice(0, 5),
|
|
1217
|
+
photos: photos,
|
|
1218
|
+
nearbyAttractions: [],
|
|
1219
|
+
guestReviewHighlights: [],
|
|
1220
|
+
locationInfo: locationInfo
|
|
1221
|
+
};
|
|
1222
|
+
})()
|
|
1223
|
+
`);
|
|
1224
|
+
return {
|
|
1225
|
+
...details,
|
|
1226
|
+
url: hotelUrl,
|
|
1227
|
+
};
|
|
1228
|
+
}, DEFAULT_RETRY_CONFIG, (attempt, error, delayMs) => {
|
|
1229
|
+
console.error(`Get hotel details attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delayMs / 1000)}s...`);
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Compare multiple hotels side-by-side
|
|
1234
|
+
*/
|
|
1235
|
+
async compareHotels(hotelUrls) {
|
|
1236
|
+
if (hotelUrls.length < 2 || hotelUrls.length > 3) {
|
|
1237
|
+
throw new HotelSearchError("Please provide 2-3 hotel URLs to compare", "INVALID_INPUT", false);
|
|
1238
|
+
}
|
|
1239
|
+
const results = [];
|
|
1240
|
+
for (const url of hotelUrls) {
|
|
1241
|
+
const details = await this.getHotelDetailsForComparison(url);
|
|
1242
|
+
results.push(details);
|
|
1243
|
+
}
|
|
1244
|
+
return results;
|
|
1245
|
+
}
|
|
972
1246
|
}
|
package/dist/index.js
CHANGED
|
@@ -42,7 +42,6 @@ const FindHotelsSchema = z.object({
|
|
|
42
42
|
sortBy: z.enum(["popularity", "price_lowest", "price_highest", "rating", "distance"]).optional().describe("Sort results by"),
|
|
43
43
|
// Pagination
|
|
44
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)"),
|
|
46
45
|
// Rating & Price
|
|
47
46
|
minRating: z.number().optional().describe("Minimum rating (6=Pleasant, 7=Good, 8=Very Good, 9=Wonderful)"),
|
|
48
47
|
minPrice: z.number().optional().describe("Minimum price per night"),
|
|
@@ -124,6 +123,9 @@ const FindHotelsSchema = z.object({
|
|
|
124
123
|
const HotelDetailsSchema = z.object({
|
|
125
124
|
url: z.string().describe("Booking.com URL for the hotel"),
|
|
126
125
|
});
|
|
126
|
+
const CompareHotelsSchema = z.object({
|
|
127
|
+
urls: z.array(z.string()).min(2).max(3).describe("Array of 2-3 Booking.com hotel URLs to compare"),
|
|
128
|
+
});
|
|
127
129
|
// Global browser instance (reuse for efficiency)
|
|
128
130
|
let browser = null;
|
|
129
131
|
async function getBrowser() {
|
|
@@ -165,10 +167,146 @@ function formatHotelResult(hotel, index) {
|
|
|
165
167
|
}
|
|
166
168
|
return lines.join("\n");
|
|
167
169
|
}
|
|
170
|
+
function formatHotelComparison(hotels) {
|
|
171
|
+
const lines = [];
|
|
172
|
+
// Header
|
|
173
|
+
lines.push("=".repeat(60));
|
|
174
|
+
lines.push("HOTEL COMPARISON");
|
|
175
|
+
lines.push("=".repeat(60));
|
|
176
|
+
lines.push("");
|
|
177
|
+
// Create comparison sections
|
|
178
|
+
const sections = [
|
|
179
|
+
{
|
|
180
|
+
title: "OVERVIEW",
|
|
181
|
+
rows: [
|
|
182
|
+
{ label: "Name", getValue: (h) => h.name || "Unknown" },
|
|
183
|
+
{ label: "Rating", getValue: (h) => h.rating ? `${h.rating}/10 ${h.ratingText}` : "N/A" },
|
|
184
|
+
{ label: "Reviews", getValue: (h) => h.reviewCount ? `${h.reviewCount.toLocaleString()} reviews` : "N/A" },
|
|
185
|
+
{ label: "Stars", getValue: (h) => h.starRating ? "★".repeat(h.starRating) : "N/A" },
|
|
186
|
+
{ label: "Location", getValue: (h) => h.address || h.locationInfo || "N/A" },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
title: "PRICING",
|
|
191
|
+
rows: [
|
|
192
|
+
{ label: "Per Night", getValue: (h) => h.priceDisplay || "N/A" },
|
|
193
|
+
{ label: "Total", getValue: (h) => h.totalPrice || "N/A" },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
title: "CHECK-IN/OUT",
|
|
198
|
+
rows: [
|
|
199
|
+
{ label: "Check-in", getValue: (h) => h.checkInTime || "N/A" },
|
|
200
|
+
{ label: "Check-out", getValue: (h) => h.checkOutTime || "N/A" },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
title: "PROPERTY HIGHLIGHTS",
|
|
205
|
+
rows: [
|
|
206
|
+
{ label: "Highlights", getValue: (h) => h.highlights || "N/A" },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
// Render each section
|
|
211
|
+
for (const section of sections) {
|
|
212
|
+
lines.push(`--- ${section.title} ---`);
|
|
213
|
+
lines.push("");
|
|
214
|
+
for (const row of section.rows) {
|
|
215
|
+
const values = hotels.map(h => row.getValue(h));
|
|
216
|
+
lines.push(`${row.label}:`);
|
|
217
|
+
values.forEach((v, i) => {
|
|
218
|
+
lines.push(` ${i + 1}. ${v}`);
|
|
219
|
+
});
|
|
220
|
+
lines.push("");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Facilities comparison
|
|
224
|
+
lines.push("--- TOP FACILITIES ---");
|
|
225
|
+
lines.push("");
|
|
226
|
+
hotels.forEach((h, i) => {
|
|
227
|
+
const facilities = h.popularFacilities.length > 0 ? h.popularFacilities : h.allFacilities;
|
|
228
|
+
lines.push(`${i + 1}. ${h.name}:`);
|
|
229
|
+
facilities.slice(0, 8).forEach(f => {
|
|
230
|
+
lines.push(` • ${f}`);
|
|
231
|
+
});
|
|
232
|
+
lines.push("");
|
|
233
|
+
});
|
|
234
|
+
// Find common and unique facilities
|
|
235
|
+
if (hotels.length >= 2) {
|
|
236
|
+
const allFacilitySets = hotels.map(h => {
|
|
237
|
+
const facilities = [...h.popularFacilities, ...h.allFacilities];
|
|
238
|
+
return new Set(facilities.map(f => f.toLowerCase()));
|
|
239
|
+
});
|
|
240
|
+
// Find facilities in all hotels
|
|
241
|
+
const commonFacilities = [];
|
|
242
|
+
const firstHotelFacilities = [...hotels[0].popularFacilities, ...hotels[0].allFacilities];
|
|
243
|
+
for (const facility of firstHotelFacilities) {
|
|
244
|
+
const lowerFacility = facility.toLowerCase();
|
|
245
|
+
if (allFacilitySets.every(set => set.has(lowerFacility))) {
|
|
246
|
+
commonFacilities.push(facility);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (commonFacilities.length > 0) {
|
|
250
|
+
lines.push("--- COMMON FACILITIES ---");
|
|
251
|
+
commonFacilities.slice(0, 10).forEach(f => {
|
|
252
|
+
lines.push(`• ${f}`);
|
|
253
|
+
});
|
|
254
|
+
lines.push("");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Room types
|
|
258
|
+
const hasRoomTypes = hotels.some(h => h.roomTypes.length > 0);
|
|
259
|
+
if (hasRoomTypes) {
|
|
260
|
+
lines.push("--- ROOM TYPES ---");
|
|
261
|
+
lines.push("");
|
|
262
|
+
hotels.forEach((h, i) => {
|
|
263
|
+
lines.push(`${i + 1}. ${h.name}:`);
|
|
264
|
+
if (h.roomTypes.length > 0) {
|
|
265
|
+
h.roomTypes.slice(0, 3).forEach(r => {
|
|
266
|
+
lines.push(` • ${r}`);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
lines.push(` (Room types not available)`);
|
|
271
|
+
}
|
|
272
|
+
lines.push("");
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// Quick verdict based on data
|
|
276
|
+
lines.push("--- QUICK COMPARISON ---");
|
|
277
|
+
lines.push("");
|
|
278
|
+
// Best rating
|
|
279
|
+
const withRatings = hotels.filter(h => h.rating !== null);
|
|
280
|
+
if (withRatings.length > 0) {
|
|
281
|
+
const bestRated = withRatings.reduce((a, b) => (a.rating > b.rating ? a : b));
|
|
282
|
+
lines.push(`Highest Rated: ${bestRated.name} (${bestRated.rating}/10)`);
|
|
283
|
+
}
|
|
284
|
+
// Best price
|
|
285
|
+
const withPrices = hotels.filter(h => h.pricePerNight !== null);
|
|
286
|
+
if (withPrices.length > 0) {
|
|
287
|
+
const cheapest = withPrices.reduce((a, b) => (a.pricePerNight < b.pricePerNight ? a : b));
|
|
288
|
+
lines.push(`Lowest Price: ${cheapest.name} (${cheapest.priceDisplay})`);
|
|
289
|
+
}
|
|
290
|
+
// Most reviews
|
|
291
|
+
const withReviews = hotels.filter(h => h.reviewCount !== null);
|
|
292
|
+
if (withReviews.length > 0) {
|
|
293
|
+
const mostReviewed = withReviews.reduce((a, b) => (a.reviewCount > b.reviewCount ? a : b));
|
|
294
|
+
lines.push(`Most Reviews: ${mostReviewed.name} (${mostReviewed.reviewCount?.toLocaleString()})`);
|
|
295
|
+
}
|
|
296
|
+
lines.push("");
|
|
297
|
+
lines.push("=".repeat(60));
|
|
298
|
+
// Add URLs for reference
|
|
299
|
+
lines.push("BOOKING LINKS:");
|
|
300
|
+
hotels.forEach((h, i) => {
|
|
301
|
+
const shortUrl = h.url.split("?")[0];
|
|
302
|
+
lines.push(`${i + 1}. ${shortUrl}`);
|
|
303
|
+
});
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
168
306
|
// Create MCP server
|
|
169
307
|
const server = new Server({
|
|
170
308
|
name: "hotelzero",
|
|
171
|
-
version: "1.
|
|
309
|
+
version: "1.4.0",
|
|
172
310
|
}, {
|
|
173
311
|
capabilities: {
|
|
174
312
|
tools: {},
|
|
@@ -197,7 +335,6 @@ const findHotelsInputSchema = {
|
|
|
197
335
|
},
|
|
198
336
|
// Pagination
|
|
199
337
|
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 },
|
|
201
338
|
// Property Type
|
|
202
339
|
propertyType: {
|
|
203
340
|
type: "string",
|
|
@@ -340,6 +477,23 @@ Results are scored and ranked by how well they match the criteria.`,
|
|
|
340
477
|
required: ["url"],
|
|
341
478
|
},
|
|
342
479
|
},
|
|
480
|
+
{
|
|
481
|
+
name: "compare_hotels",
|
|
482
|
+
description: "Compare 2-3 hotels side-by-side. Provide Booking.com URLs from search results to see a detailed comparison of ratings, prices, amenities, and facilities.",
|
|
483
|
+
inputSchema: {
|
|
484
|
+
type: "object",
|
|
485
|
+
properties: {
|
|
486
|
+
urls: {
|
|
487
|
+
type: "array",
|
|
488
|
+
items: { type: "string" },
|
|
489
|
+
minItems: 2,
|
|
490
|
+
maxItems: 3,
|
|
491
|
+
description: "Array of 2-3 Booking.com hotel URLs to compare"
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
required: ["urls"],
|
|
495
|
+
},
|
|
496
|
+
},
|
|
343
497
|
],
|
|
344
498
|
};
|
|
345
499
|
});
|
|
@@ -360,12 +514,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
360
514
|
currency: parsed.currency,
|
|
361
515
|
sortBy: parsed.sortBy,
|
|
362
516
|
limit: parsed.limit,
|
|
363
|
-
offset: parsed.offset,
|
|
364
517
|
};
|
|
365
518
|
// Build filters object from all parsed parameters
|
|
366
519
|
const filters = {};
|
|
367
520
|
// Copy all filter properties (exclude search params)
|
|
368
|
-
const filterKeys = Object.keys(parsed).filter(k => !['destination', 'checkIn', 'checkOut', 'guests', 'rooms', 'currency', 'sortBy', 'limit'
|
|
521
|
+
const filterKeys = Object.keys(parsed).filter(k => !['destination', 'checkIn', 'checkOut', 'guests', 'rooms', 'currency', 'sortBy', 'limit'].includes(k));
|
|
369
522
|
for (const key of filterKeys) {
|
|
370
523
|
const value = parsed[key];
|
|
371
524
|
if (value !== undefined && value !== null) {
|
|
@@ -436,20 +589,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
436
589
|
const filtersLine = activeFilters.length > 0
|
|
437
590
|
? `Filters: ${activeFilters.join(", ")}\n\n`
|
|
438
591
|
: "\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
|
-
: "";
|
|
445
592
|
const hotelList = results
|
|
446
|
-
.map((h, i) => formatHotelResult(h, i
|
|
593
|
+
.map((h, i) => formatHotelResult(h, i))
|
|
447
594
|
.join("\n\n");
|
|
448
595
|
return {
|
|
449
596
|
content: [
|
|
450
597
|
{
|
|
451
598
|
type: "text",
|
|
452
|
-
text: header + filtersLine +
|
|
599
|
+
text: header + filtersLine + hotelList,
|
|
453
600
|
},
|
|
454
601
|
],
|
|
455
602
|
};
|
|
@@ -489,6 +636,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
489
636
|
],
|
|
490
637
|
};
|
|
491
638
|
}
|
|
639
|
+
case "compare_hotels": {
|
|
640
|
+
const parsed = CompareHotelsSchema.parse(args);
|
|
641
|
+
const hotels = await b.compareHotels(parsed.urls);
|
|
642
|
+
const comparison = formatHotelComparison(hotels);
|
|
643
|
+
return {
|
|
644
|
+
content: [
|
|
645
|
+
{
|
|
646
|
+
type: "text",
|
|
647
|
+
text: comparison,
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
};
|
|
651
|
+
}
|
|
492
652
|
default:
|
|
493
653
|
throw new Error(`Unknown tool: ${name}`);
|
|
494
654
|
}
|
|
@@ -557,7 +717,7 @@ process.on("SIGTERM", async () => {
|
|
|
557
717
|
async function main() {
|
|
558
718
|
const transport = new StdioServerTransport();
|
|
559
719
|
await server.connect(transport);
|
|
560
|
-
console.error("HotelZero v1.
|
|
720
|
+
console.error("HotelZero v1.4.0 running on stdio");
|
|
561
721
|
}
|
|
562
722
|
main().catch((error) => {
|
|
563
723
|
console.error("Fatal error:", error);
|