hotelzero 1.3.1 → 1.5.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 +64 -0
- package/dist/browser.js +509 -0
- package/dist/index.js +263 -2
- package/package.json +1 -1
package/dist/browser.d.ts
CHANGED
|
@@ -124,6 +124,52 @@ export interface HotelResult {
|
|
|
124
124
|
matchScore?: number;
|
|
125
125
|
matchReasons?: string[];
|
|
126
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
|
+
}
|
|
150
|
+
export interface RoomOption {
|
|
151
|
+
name: string;
|
|
152
|
+
price: number | null;
|
|
153
|
+
priceDisplay: string;
|
|
154
|
+
sleeps: number | null;
|
|
155
|
+
features: string[];
|
|
156
|
+
bedType: string;
|
|
157
|
+
cancellation: string;
|
|
158
|
+
breakfast: string;
|
|
159
|
+
}
|
|
160
|
+
export interface AvailabilityResult {
|
|
161
|
+
available: boolean;
|
|
162
|
+
hotelName: string;
|
|
163
|
+
checkIn: string;
|
|
164
|
+
checkOut: string;
|
|
165
|
+
guests: number;
|
|
166
|
+
rooms: number;
|
|
167
|
+
roomOptions: RoomOption[];
|
|
168
|
+
lowestPrice: number | null;
|
|
169
|
+
lowestPriceDisplay: string;
|
|
170
|
+
message: string;
|
|
171
|
+
url: string;
|
|
172
|
+
}
|
|
127
173
|
export declare class HotelBrowser {
|
|
128
174
|
private browser;
|
|
129
175
|
private page;
|
|
@@ -143,4 +189,22 @@ export declare class HotelBrowser {
|
|
|
143
189
|
getHotelDetails(hotelUrl: string): Promise<Record<string, unknown>>;
|
|
144
190
|
takeScreenshot(path: string): Promise<void>;
|
|
145
191
|
getPageContent(): Promise<string>;
|
|
192
|
+
/**
|
|
193
|
+
* Get detailed hotel information for comparison
|
|
194
|
+
*/
|
|
195
|
+
getHotelDetailsForComparison(hotelUrl: string): Promise<HotelDetails>;
|
|
196
|
+
/**
|
|
197
|
+
* Compare multiple hotels side-by-side
|
|
198
|
+
*/
|
|
199
|
+
compareHotels(hotelUrls: string[]): Promise<HotelDetails[]>;
|
|
200
|
+
/**
|
|
201
|
+
* Check availability for a specific hotel on given dates
|
|
202
|
+
*/
|
|
203
|
+
checkAvailability(params: {
|
|
204
|
+
hotelUrl: string;
|
|
205
|
+
checkIn: string;
|
|
206
|
+
checkOut: string;
|
|
207
|
+
guests?: number;
|
|
208
|
+
rooms?: number;
|
|
209
|
+
}): Promise<AvailabilityResult>;
|
|
146
210
|
}
|
package/dist/browser.js
CHANGED
|
@@ -974,4 +974,513 @@ export class HotelBrowser {
|
|
|
974
974
|
throw new Error("Browser not initialized");
|
|
975
975
|
return await this.page.content();
|
|
976
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
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Check availability for a specific hotel on given dates
|
|
1248
|
+
*/
|
|
1249
|
+
async checkAvailability(params) {
|
|
1250
|
+
if (!this.page) {
|
|
1251
|
+
throw new HotelSearchError("Browser not initialized. Call init() first.", ErrorCodes.BROWSER_NOT_INITIALIZED, false);
|
|
1252
|
+
}
|
|
1253
|
+
const { hotelUrl, checkIn, checkOut, guests = 2, rooms = 1 } = params;
|
|
1254
|
+
// Validate dates
|
|
1255
|
+
const checkInDate = new Date(checkIn);
|
|
1256
|
+
const checkOutDate = new Date(checkOut);
|
|
1257
|
+
if (isNaN(checkInDate.getTime()) || isNaN(checkOutDate.getTime())) {
|
|
1258
|
+
throw new HotelSearchError("Invalid date format. Use YYYY-MM-DD.", "INVALID_INPUT", false);
|
|
1259
|
+
}
|
|
1260
|
+
if (checkOutDate <= checkInDate) {
|
|
1261
|
+
throw new HotelSearchError("Check-out date must be after check-in date.", "INVALID_INPUT", false);
|
|
1262
|
+
}
|
|
1263
|
+
return await retryWithBackoff(async () => {
|
|
1264
|
+
await this.enforceRateLimit();
|
|
1265
|
+
// Build URL with date parameters
|
|
1266
|
+
// Strip existing query params and add our own
|
|
1267
|
+
const baseUrl = hotelUrl.split("?")[0];
|
|
1268
|
+
const urlWithDates = `${baseUrl}?checkin=${checkIn}&checkout=${checkOut}&group_adults=${guests}&no_rooms=${rooms}&group_children=0`;
|
|
1269
|
+
try {
|
|
1270
|
+
await this.page.goto(urlWithDates, {
|
|
1271
|
+
waitUntil: "networkidle",
|
|
1272
|
+
timeout: 30000,
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
catch (error) {
|
|
1276
|
+
const err = error;
|
|
1277
|
+
if (err.message.includes("timeout") || err.message.includes("Timeout")) {
|
|
1278
|
+
throw new HotelSearchError("Page load timed out.", ErrorCodes.TIMEOUT, true);
|
|
1279
|
+
}
|
|
1280
|
+
throw new HotelSearchError(`Navigation failed: ${err.message}`, ErrorCodes.NAVIGATION_FAILED, true);
|
|
1281
|
+
}
|
|
1282
|
+
await this.page.waitForTimeout(2000);
|
|
1283
|
+
await this.checkForBlocking();
|
|
1284
|
+
await this.dismissPopups();
|
|
1285
|
+
// Extract room availability using string-based evaluate
|
|
1286
|
+
const result = await this.page.evaluate(`
|
|
1287
|
+
(function() {
|
|
1288
|
+
function getText(selector) {
|
|
1289
|
+
var el = document.querySelector(selector);
|
|
1290
|
+
return el && el.textContent ? el.textContent.trim() : "";
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Get hotel name
|
|
1294
|
+
var hotelName = getText('h2') || getText('h1').split('(')[0].trim() || "Unknown Hotel";
|
|
1295
|
+
|
|
1296
|
+
var roomOptions = [];
|
|
1297
|
+
var seenRooms = {};
|
|
1298
|
+
|
|
1299
|
+
// Strategy 1: Look for room type links (most reliable on Booking.com)
|
|
1300
|
+
var roomTypeLinks = document.querySelectorAll('.hprt-roomtype-link, a[class*="hprt-roomtype"]');
|
|
1301
|
+
|
|
1302
|
+
for (var i = 0; i < roomTypeLinks.length && roomOptions.length < 10; i++) {
|
|
1303
|
+
var roomLink = roomTypeLinks[i];
|
|
1304
|
+
var name = roomLink.textContent.trim();
|
|
1305
|
+
|
|
1306
|
+
if (!name || name.length < 3 || seenRooms[name]) continue;
|
|
1307
|
+
seenRooms[name] = true;
|
|
1308
|
+
|
|
1309
|
+
// Find the containing row to get price and details
|
|
1310
|
+
var row = roomLink.closest('tr') || roomLink.closest('[data-block-id]') || roomLink.parentElement;
|
|
1311
|
+
var rowText = row ? row.textContent || "" : "";
|
|
1312
|
+
|
|
1313
|
+
// Try to find price in the same row or nearby
|
|
1314
|
+
var price = null;
|
|
1315
|
+
var priceDisplay = "";
|
|
1316
|
+
|
|
1317
|
+
// Look for price cell in this row or next siblings
|
|
1318
|
+
var priceCell = row ? row.querySelector('.hprt-table-cell-price, [class*="price-block"], [class*="bui-price"]') : null;
|
|
1319
|
+
if (priceCell) {
|
|
1320
|
+
priceDisplay = priceCell.textContent.trim();
|
|
1321
|
+
var match = priceDisplay.match(/[\\$€£¥]\\s*([\\d,]+)/);
|
|
1322
|
+
if (match) {
|
|
1323
|
+
price = parseInt(match[1].replace(/,/g, ""));
|
|
1324
|
+
// Clean up price display
|
|
1325
|
+
var perNightMatch = priceDisplay.match(/[\\$€£¥]\\s*[\\d,]+/);
|
|
1326
|
+
priceDisplay = perNightMatch ? perNightMatch[0] : priceDisplay.split('\\n')[0];
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// If no price found in row, search in sibling rows with same room type
|
|
1331
|
+
if (!price) {
|
|
1332
|
+
var allPriceCells = document.querySelectorAll('.hprt-table-cell-price');
|
|
1333
|
+
for (var j = 0; j < allPriceCells.length && !price; j++) {
|
|
1334
|
+
var cellText = allPriceCells[j].textContent || "";
|
|
1335
|
+
var match = cellText.match(/[\\$€£¥]\\s*([\\d,]+)/);
|
|
1336
|
+
if (match) {
|
|
1337
|
+
price = parseInt(match[1].replace(/,/g, ""));
|
|
1338
|
+
priceDisplay = match[0];
|
|
1339
|
+
break;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Bed type - clean up multiline text
|
|
1345
|
+
var bedType = "";
|
|
1346
|
+
var bedEl = row ? row.querySelector('.hprt-roomtype-bed, [class*="bed-type"]') : null;
|
|
1347
|
+
if (bedEl) {
|
|
1348
|
+
// Get first meaningful line
|
|
1349
|
+
var bedText = bedEl.textContent || "";
|
|
1350
|
+
var bedLines = bedText.split('\\n').map(function(l) { return l.trim(); }).filter(function(l) { return l.length > 0; });
|
|
1351
|
+
// Find line with bed info
|
|
1352
|
+
for (var k = 0; k < bedLines.length; k++) {
|
|
1353
|
+
if (bedLines[k].match(/(bed|queen|king|twin|double|single|sofa)/i)) {
|
|
1354
|
+
bedType = bedLines[k];
|
|
1355
|
+
break;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
if (!bedType && bedLines.length > 0) {
|
|
1359
|
+
bedType = bedLines[0];
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Cancellation
|
|
1364
|
+
var cancellation = "";
|
|
1365
|
+
if (rowText.toLowerCase().indexOf("free cancellation") >= 0) {
|
|
1366
|
+
cancellation = "Free cancellation";
|
|
1367
|
+
} else if (rowText.toLowerCase().indexOf("non-refundable") >= 0) {
|
|
1368
|
+
cancellation = "Non-refundable";
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Breakfast
|
|
1372
|
+
var breakfast = "";
|
|
1373
|
+
if (rowText.toLowerCase().indexOf("breakfast included") >= 0) {
|
|
1374
|
+
breakfast = "Breakfast included";
|
|
1375
|
+
} else if (rowText.toLowerCase().indexOf("room only") >= 0) {
|
|
1376
|
+
breakfast = "Room only";
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Occupancy
|
|
1380
|
+
var sleeps = null;
|
|
1381
|
+
var occupancyEl = row ? row.querySelector('[class*="occupancy"], .hprt-occupancy-occupancy-info') : null;
|
|
1382
|
+
if (occupancyEl) {
|
|
1383
|
+
var occMatch = occupancyEl.textContent.match(/(\\d+)/);
|
|
1384
|
+
sleeps = occMatch ? parseInt(occMatch[1]) : null;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
roomOptions.push({
|
|
1388
|
+
name: name,
|
|
1389
|
+
price: price,
|
|
1390
|
+
priceDisplay: priceDisplay,
|
|
1391
|
+
sleeps: sleeps,
|
|
1392
|
+
features: [],
|
|
1393
|
+
bedType: bedType,
|
|
1394
|
+
cancellation: cancellation,
|
|
1395
|
+
breakfast: breakfast
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Strategy 2: If no rooms found, try data-block-id elements
|
|
1400
|
+
if (roomOptions.length === 0) {
|
|
1401
|
+
var blocks = document.querySelectorAll('[data-block-id]');
|
|
1402
|
+
for (var i = 0; i < blocks.length && roomOptions.length < 10; i++) {
|
|
1403
|
+
var block = blocks[i];
|
|
1404
|
+
var blockText = block.textContent || "";
|
|
1405
|
+
|
|
1406
|
+
// Look for any room name pattern
|
|
1407
|
+
var nameEl = block.querySelector('a[class*="room"], span[class*="room-name"]');
|
|
1408
|
+
var name = nameEl ? nameEl.textContent.trim() : "";
|
|
1409
|
+
|
|
1410
|
+
if (!name) {
|
|
1411
|
+
// Try to extract from block text
|
|
1412
|
+
var lines = blockText.split('\\n').filter(function(l) { return l.trim().length > 0; });
|
|
1413
|
+
name = lines[0] ? lines[0].trim().slice(0, 50) : "";
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (!name || name.length < 3 || seenRooms[name]) continue;
|
|
1417
|
+
seenRooms[name] = true;
|
|
1418
|
+
|
|
1419
|
+
var priceMatch = blockText.match(/[\\$€£¥]\\s*([\\d,]+)/);
|
|
1420
|
+
var price = priceMatch ? parseInt(priceMatch[1].replace(/,/g, "")) : null;
|
|
1421
|
+
|
|
1422
|
+
roomOptions.push({
|
|
1423
|
+
name: name,
|
|
1424
|
+
price: price,
|
|
1425
|
+
priceDisplay: priceMatch ? priceMatch[0] : "",
|
|
1426
|
+
sleeps: null,
|
|
1427
|
+
features: [],
|
|
1428
|
+
bedType: "",
|
|
1429
|
+
cancellation: "",
|
|
1430
|
+
breakfast: ""
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Check for "no availability" message
|
|
1436
|
+
var bodyText = document.body.textContent || "";
|
|
1437
|
+
var noAvailability =
|
|
1438
|
+
bodyText.indexOf("no availability") >= 0 ||
|
|
1439
|
+
bodyText.indexOf("sold out") >= 0 ||
|
|
1440
|
+
bodyText.indexOf("no rooms available") >= 0 ||
|
|
1441
|
+
bodyText.indexOf("fully booked") >= 0 ||
|
|
1442
|
+
bodyText.indexOf("We have no availability") >= 0;
|
|
1443
|
+
|
|
1444
|
+
return {
|
|
1445
|
+
hotelName: hotelName,
|
|
1446
|
+
roomOptions: roomOptions,
|
|
1447
|
+
noAvailabilityDetected: noAvailability && roomOptions.length === 0
|
|
1448
|
+
};
|
|
1449
|
+
})()
|
|
1450
|
+
`);
|
|
1451
|
+
// Determine availability and lowest price
|
|
1452
|
+
const available = result.roomOptions.length > 0 && !result.noAvailabilityDetected;
|
|
1453
|
+
const prices = result.roomOptions
|
|
1454
|
+
.map(r => r.price)
|
|
1455
|
+
.filter((p) => p !== null);
|
|
1456
|
+
const lowestPrice = prices.length > 0 ? Math.min(...prices) : null;
|
|
1457
|
+
const lowestPriceRoom = result.roomOptions.find(r => r.price === lowestPrice);
|
|
1458
|
+
// Build message
|
|
1459
|
+
let message;
|
|
1460
|
+
if (!available) {
|
|
1461
|
+
message = "No rooms available for the selected dates.";
|
|
1462
|
+
}
|
|
1463
|
+
else if (result.roomOptions.length === 1) {
|
|
1464
|
+
message = `1 room type available${lowestPrice ? ` from ${lowestPriceRoom?.priceDisplay || '$' + lowestPrice}` : ''}.`;
|
|
1465
|
+
}
|
|
1466
|
+
else {
|
|
1467
|
+
message = `${result.roomOptions.length} room types available${lowestPrice ? ` from ${lowestPriceRoom?.priceDisplay || '$' + lowestPrice}` : ''}.`;
|
|
1468
|
+
}
|
|
1469
|
+
return {
|
|
1470
|
+
available,
|
|
1471
|
+
hotelName: result.hotelName,
|
|
1472
|
+
checkIn,
|
|
1473
|
+
checkOut,
|
|
1474
|
+
guests,
|
|
1475
|
+
rooms,
|
|
1476
|
+
roomOptions: result.roomOptions,
|
|
1477
|
+
lowestPrice,
|
|
1478
|
+
lowestPriceDisplay: lowestPriceRoom?.priceDisplay || "",
|
|
1479
|
+
message,
|
|
1480
|
+
url: urlWithDates,
|
|
1481
|
+
};
|
|
1482
|
+
}, DEFAULT_RETRY_CONFIG, (attempt, error, delayMs) => {
|
|
1483
|
+
console.error(`Check availability attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delayMs / 1000)}s...`);
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
977
1486
|
}
|
package/dist/index.js
CHANGED
|
@@ -123,6 +123,16 @@ const FindHotelsSchema = z.object({
|
|
|
123
123
|
const HotelDetailsSchema = z.object({
|
|
124
124
|
url: z.string().describe("Booking.com URL for the hotel"),
|
|
125
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
|
+
});
|
|
129
|
+
const CheckAvailabilitySchema = z.object({
|
|
130
|
+
hotelUrl: z.string().describe("Booking.com hotel URL to check availability for"),
|
|
131
|
+
checkIn: z.string().describe("Check-in date (YYYY-MM-DD)"),
|
|
132
|
+
checkOut: z.string().describe("Check-out date (YYYY-MM-DD)"),
|
|
133
|
+
guests: z.number().min(1).max(30).optional().describe("Number of guests (default: 2)"),
|
|
134
|
+
rooms: z.number().min(1).max(10).optional().describe("Number of rooms (default: 1)"),
|
|
135
|
+
});
|
|
126
136
|
// Global browser instance (reuse for efficiency)
|
|
127
137
|
let browser = null;
|
|
128
138
|
async function getBrowser() {
|
|
@@ -164,10 +174,197 @@ function formatHotelResult(hotel, index) {
|
|
|
164
174
|
}
|
|
165
175
|
return lines.join("\n");
|
|
166
176
|
}
|
|
177
|
+
function formatHotelComparison(hotels) {
|
|
178
|
+
const lines = [];
|
|
179
|
+
// Header
|
|
180
|
+
lines.push("=".repeat(60));
|
|
181
|
+
lines.push("HOTEL COMPARISON");
|
|
182
|
+
lines.push("=".repeat(60));
|
|
183
|
+
lines.push("");
|
|
184
|
+
// Create comparison sections
|
|
185
|
+
const sections = [
|
|
186
|
+
{
|
|
187
|
+
title: "OVERVIEW",
|
|
188
|
+
rows: [
|
|
189
|
+
{ label: "Name", getValue: (h) => h.name || "Unknown" },
|
|
190
|
+
{ label: "Rating", getValue: (h) => h.rating ? `${h.rating}/10 ${h.ratingText}` : "N/A" },
|
|
191
|
+
{ label: "Reviews", getValue: (h) => h.reviewCount ? `${h.reviewCount.toLocaleString()} reviews` : "N/A" },
|
|
192
|
+
{ label: "Stars", getValue: (h) => h.starRating ? "★".repeat(h.starRating) : "N/A" },
|
|
193
|
+
{ label: "Location", getValue: (h) => h.address || h.locationInfo || "N/A" },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
title: "PRICING",
|
|
198
|
+
rows: [
|
|
199
|
+
{ label: "Per Night", getValue: (h) => h.priceDisplay || "N/A" },
|
|
200
|
+
{ label: "Total", getValue: (h) => h.totalPrice || "N/A" },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
title: "CHECK-IN/OUT",
|
|
205
|
+
rows: [
|
|
206
|
+
{ label: "Check-in", getValue: (h) => h.checkInTime || "N/A" },
|
|
207
|
+
{ label: "Check-out", getValue: (h) => h.checkOutTime || "N/A" },
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
title: "PROPERTY HIGHLIGHTS",
|
|
212
|
+
rows: [
|
|
213
|
+
{ label: "Highlights", getValue: (h) => h.highlights || "N/A" },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
// Render each section
|
|
218
|
+
for (const section of sections) {
|
|
219
|
+
lines.push(`--- ${section.title} ---`);
|
|
220
|
+
lines.push("");
|
|
221
|
+
for (const row of section.rows) {
|
|
222
|
+
const values = hotels.map(h => row.getValue(h));
|
|
223
|
+
lines.push(`${row.label}:`);
|
|
224
|
+
values.forEach((v, i) => {
|
|
225
|
+
lines.push(` ${i + 1}. ${v}`);
|
|
226
|
+
});
|
|
227
|
+
lines.push("");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Facilities comparison
|
|
231
|
+
lines.push("--- TOP FACILITIES ---");
|
|
232
|
+
lines.push("");
|
|
233
|
+
hotels.forEach((h, i) => {
|
|
234
|
+
const facilities = h.popularFacilities.length > 0 ? h.popularFacilities : h.allFacilities;
|
|
235
|
+
lines.push(`${i + 1}. ${h.name}:`);
|
|
236
|
+
facilities.slice(0, 8).forEach(f => {
|
|
237
|
+
lines.push(` • ${f}`);
|
|
238
|
+
});
|
|
239
|
+
lines.push("");
|
|
240
|
+
});
|
|
241
|
+
// Find common and unique facilities
|
|
242
|
+
if (hotels.length >= 2) {
|
|
243
|
+
const allFacilitySets = hotels.map(h => {
|
|
244
|
+
const facilities = [...h.popularFacilities, ...h.allFacilities];
|
|
245
|
+
return new Set(facilities.map(f => f.toLowerCase()));
|
|
246
|
+
});
|
|
247
|
+
// Find facilities in all hotels
|
|
248
|
+
const commonFacilities = [];
|
|
249
|
+
const firstHotelFacilities = [...hotels[0].popularFacilities, ...hotels[0].allFacilities];
|
|
250
|
+
for (const facility of firstHotelFacilities) {
|
|
251
|
+
const lowerFacility = facility.toLowerCase();
|
|
252
|
+
if (allFacilitySets.every(set => set.has(lowerFacility))) {
|
|
253
|
+
commonFacilities.push(facility);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (commonFacilities.length > 0) {
|
|
257
|
+
lines.push("--- COMMON FACILITIES ---");
|
|
258
|
+
commonFacilities.slice(0, 10).forEach(f => {
|
|
259
|
+
lines.push(`• ${f}`);
|
|
260
|
+
});
|
|
261
|
+
lines.push("");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Room types
|
|
265
|
+
const hasRoomTypes = hotels.some(h => h.roomTypes.length > 0);
|
|
266
|
+
if (hasRoomTypes) {
|
|
267
|
+
lines.push("--- ROOM TYPES ---");
|
|
268
|
+
lines.push("");
|
|
269
|
+
hotels.forEach((h, i) => {
|
|
270
|
+
lines.push(`${i + 1}. ${h.name}:`);
|
|
271
|
+
if (h.roomTypes.length > 0) {
|
|
272
|
+
h.roomTypes.slice(0, 3).forEach(r => {
|
|
273
|
+
lines.push(` • ${r}`);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
lines.push(` (Room types not available)`);
|
|
278
|
+
}
|
|
279
|
+
lines.push("");
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
// Quick verdict based on data
|
|
283
|
+
lines.push("--- QUICK COMPARISON ---");
|
|
284
|
+
lines.push("");
|
|
285
|
+
// Best rating
|
|
286
|
+
const withRatings = hotels.filter(h => h.rating !== null);
|
|
287
|
+
if (withRatings.length > 0) {
|
|
288
|
+
const bestRated = withRatings.reduce((a, b) => (a.rating > b.rating ? a : b));
|
|
289
|
+
lines.push(`Highest Rated: ${bestRated.name} (${bestRated.rating}/10)`);
|
|
290
|
+
}
|
|
291
|
+
// Best price
|
|
292
|
+
const withPrices = hotels.filter(h => h.pricePerNight !== null);
|
|
293
|
+
if (withPrices.length > 0) {
|
|
294
|
+
const cheapest = withPrices.reduce((a, b) => (a.pricePerNight < b.pricePerNight ? a : b));
|
|
295
|
+
lines.push(`Lowest Price: ${cheapest.name} (${cheapest.priceDisplay})`);
|
|
296
|
+
}
|
|
297
|
+
// Most reviews
|
|
298
|
+
const withReviews = hotels.filter(h => h.reviewCount !== null);
|
|
299
|
+
if (withReviews.length > 0) {
|
|
300
|
+
const mostReviewed = withReviews.reduce((a, b) => (a.reviewCount > b.reviewCount ? a : b));
|
|
301
|
+
lines.push(`Most Reviews: ${mostReviewed.name} (${mostReviewed.reviewCount?.toLocaleString()})`);
|
|
302
|
+
}
|
|
303
|
+
lines.push("");
|
|
304
|
+
lines.push("=".repeat(60));
|
|
305
|
+
// Add URLs for reference
|
|
306
|
+
lines.push("BOOKING LINKS:");
|
|
307
|
+
hotels.forEach((h, i) => {
|
|
308
|
+
const shortUrl = h.url.split("?")[0];
|
|
309
|
+
lines.push(`${i + 1}. ${shortUrl}`);
|
|
310
|
+
});
|
|
311
|
+
return lines.join("\n");
|
|
312
|
+
}
|
|
313
|
+
function formatAvailabilityResult(result) {
|
|
314
|
+
const lines = [];
|
|
315
|
+
lines.push("=".repeat(60));
|
|
316
|
+
lines.push("AVAILABILITY CHECK");
|
|
317
|
+
lines.push("=".repeat(60));
|
|
318
|
+
lines.push("");
|
|
319
|
+
lines.push(`Hotel: ${result.hotelName}`);
|
|
320
|
+
lines.push(`Dates: ${result.checkIn} to ${result.checkOut}`);
|
|
321
|
+
lines.push(`Guests: ${result.guests} | Rooms: ${result.rooms}`);
|
|
322
|
+
lines.push("");
|
|
323
|
+
if (!result.available) {
|
|
324
|
+
lines.push("STATUS: NOT AVAILABLE");
|
|
325
|
+
lines.push(result.message);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
lines.push(`STATUS: AVAILABLE - ${result.message}`);
|
|
329
|
+
lines.push("");
|
|
330
|
+
lines.push("--- ROOM OPTIONS ---");
|
|
331
|
+
lines.push("");
|
|
332
|
+
result.roomOptions.forEach((room, i) => {
|
|
333
|
+
lines.push(`${i + 1}. ${room.name}`);
|
|
334
|
+
if (room.priceDisplay) {
|
|
335
|
+
lines.push(` Price: ${room.priceDisplay}`);
|
|
336
|
+
}
|
|
337
|
+
if (room.sleeps) {
|
|
338
|
+
lines.push(` Sleeps: ${room.sleeps}`);
|
|
339
|
+
}
|
|
340
|
+
if (room.bedType) {
|
|
341
|
+
lines.push(` Bed: ${room.bedType}`);
|
|
342
|
+
}
|
|
343
|
+
if (room.cancellation) {
|
|
344
|
+
lines.push(` Cancellation: ${room.cancellation}`);
|
|
345
|
+
}
|
|
346
|
+
if (room.breakfast) {
|
|
347
|
+
lines.push(` Meals: ${room.breakfast}`);
|
|
348
|
+
}
|
|
349
|
+
if (room.features.length > 0) {
|
|
350
|
+
lines.push(` Features: ${room.features.join(", ")}`);
|
|
351
|
+
}
|
|
352
|
+
lines.push("");
|
|
353
|
+
});
|
|
354
|
+
if (result.lowestPrice) {
|
|
355
|
+
lines.push("--- SUMMARY ---");
|
|
356
|
+
lines.push(`Lowest price: ${result.lowestPriceDisplay}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
lines.push("");
|
|
360
|
+
lines.push("=".repeat(60));
|
|
361
|
+
lines.push(`Book at: ${result.url.split("?")[0]}`);
|
|
362
|
+
return lines.join("\n");
|
|
363
|
+
}
|
|
167
364
|
// Create MCP server
|
|
168
365
|
const server = new Server({
|
|
169
366
|
name: "hotelzero",
|
|
170
|
-
version: "1.
|
|
367
|
+
version: "1.5.0",
|
|
171
368
|
}, {
|
|
172
369
|
capabilities: {
|
|
173
370
|
tools: {},
|
|
@@ -338,6 +535,38 @@ Results are scored and ranked by how well they match the criteria.`,
|
|
|
338
535
|
required: ["url"],
|
|
339
536
|
},
|
|
340
537
|
},
|
|
538
|
+
{
|
|
539
|
+
name: "compare_hotels",
|
|
540
|
+
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.",
|
|
541
|
+
inputSchema: {
|
|
542
|
+
type: "object",
|
|
543
|
+
properties: {
|
|
544
|
+
urls: {
|
|
545
|
+
type: "array",
|
|
546
|
+
items: { type: "string" },
|
|
547
|
+
minItems: 2,
|
|
548
|
+
maxItems: 3,
|
|
549
|
+
description: "Array of 2-3 Booking.com hotel URLs to compare"
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
required: ["urls"],
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: "check_availability",
|
|
557
|
+
description: "Check room availability and prices for a specific hotel on given dates. Returns available room types, prices, and booking details.",
|
|
558
|
+
inputSchema: {
|
|
559
|
+
type: "object",
|
|
560
|
+
properties: {
|
|
561
|
+
hotelUrl: { type: "string", description: "Booking.com hotel URL to check" },
|
|
562
|
+
checkIn: { type: "string", description: "Check-in date (YYYY-MM-DD)" },
|
|
563
|
+
checkOut: { type: "string", description: "Check-out date (YYYY-MM-DD)" },
|
|
564
|
+
guests: { type: "number", description: "Number of guests (default: 2)", minimum: 1, maximum: 30 },
|
|
565
|
+
rooms: { type: "number", description: "Number of rooms (default: 1)", minimum: 1, maximum: 10 },
|
|
566
|
+
},
|
|
567
|
+
required: ["hotelUrl", "checkIn", "checkOut"],
|
|
568
|
+
},
|
|
569
|
+
},
|
|
341
570
|
],
|
|
342
571
|
};
|
|
343
572
|
});
|
|
@@ -480,6 +709,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
480
709
|
],
|
|
481
710
|
};
|
|
482
711
|
}
|
|
712
|
+
case "compare_hotels": {
|
|
713
|
+
const parsed = CompareHotelsSchema.parse(args);
|
|
714
|
+
const hotels = await b.compareHotels(parsed.urls);
|
|
715
|
+
const comparison = formatHotelComparison(hotels);
|
|
716
|
+
return {
|
|
717
|
+
content: [
|
|
718
|
+
{
|
|
719
|
+
type: "text",
|
|
720
|
+
text: comparison,
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
case "check_availability": {
|
|
726
|
+
const parsed = CheckAvailabilitySchema.parse(args);
|
|
727
|
+
const result = await b.checkAvailability({
|
|
728
|
+
hotelUrl: parsed.hotelUrl,
|
|
729
|
+
checkIn: parsed.checkIn,
|
|
730
|
+
checkOut: parsed.checkOut,
|
|
731
|
+
guests: parsed.guests,
|
|
732
|
+
rooms: parsed.rooms,
|
|
733
|
+
});
|
|
734
|
+
const formatted = formatAvailabilityResult(result);
|
|
735
|
+
return {
|
|
736
|
+
content: [
|
|
737
|
+
{
|
|
738
|
+
type: "text",
|
|
739
|
+
text: formatted,
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
};
|
|
743
|
+
}
|
|
483
744
|
default:
|
|
484
745
|
throw new Error(`Unknown tool: ${name}`);
|
|
485
746
|
}
|
|
@@ -548,7 +809,7 @@ process.on("SIGTERM", async () => {
|
|
|
548
809
|
async function main() {
|
|
549
810
|
const transport = new StdioServerTransport();
|
|
550
811
|
await server.connect(transport);
|
|
551
|
-
console.error("HotelZero v1.
|
|
812
|
+
console.error("HotelZero v1.5.0 running on stdio");
|
|
552
813
|
}
|
|
553
814
|
main().catch((error) => {
|
|
554
815
|
console.error("Fatal error:", error);
|