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 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, offset } = params;
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
- // 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++) {
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
- // Check if "Load more" button exists and click it
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(1500);
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
- // Ignore if button not found
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
- window.scrollTo(0, 0);
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.3.0",
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', 'offset'].includes(k));
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 + offset))
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 + paginationLine + hotelList,
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.3.0 running on stdio");
720
+ console.error("HotelZero v1.4.0 running on stdio");
561
721
  }
562
722
  main().catch((error) => {
563
723
  console.error("Fatal error:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotelzero",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",