hotelzero 1.4.0 → 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 CHANGED
@@ -147,6 +147,29 @@ export interface HotelDetails {
147
147
  guestReviewHighlights: string[];
148
148
  locationInfo: string;
149
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
+ }
150
173
  export declare class HotelBrowser {
151
174
  private browser;
152
175
  private page;
@@ -174,4 +197,14 @@ export declare class HotelBrowser {
174
197
  * Compare multiple hotels side-by-side
175
198
  */
176
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>;
177
210
  }
package/dist/browser.js CHANGED
@@ -1243,4 +1243,244 @@ export class HotelBrowser {
1243
1243
  }
1244
1244
  return results;
1245
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
+ }
1246
1486
  }
package/dist/index.js CHANGED
@@ -126,6 +126,13 @@ const HotelDetailsSchema = z.object({
126
126
  const CompareHotelsSchema = z.object({
127
127
  urls: z.array(z.string()).min(2).max(3).describe("Array of 2-3 Booking.com hotel URLs to compare"),
128
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
+ });
129
136
  // Global browser instance (reuse for efficiency)
130
137
  let browser = null;
131
138
  async function getBrowser() {
@@ -303,10 +310,61 @@ function formatHotelComparison(hotels) {
303
310
  });
304
311
  return lines.join("\n");
305
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
+ }
306
364
  // Create MCP server
307
365
  const server = new Server({
308
366
  name: "hotelzero",
309
- version: "1.4.0",
367
+ version: "1.5.0",
310
368
  }, {
311
369
  capabilities: {
312
370
  tools: {},
@@ -494,6 +552,21 @@ Results are scored and ranked by how well they match the criteria.`,
494
552
  required: ["urls"],
495
553
  },
496
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
+ },
497
570
  ],
498
571
  };
499
572
  });
@@ -649,6 +722,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
649
722
  ],
650
723
  };
651
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
+ }
652
744
  default:
653
745
  throw new Error(`Unknown tool: ${name}`);
654
746
  }
@@ -717,7 +809,7 @@ process.on("SIGTERM", async () => {
717
809
  async function main() {
718
810
  const transport = new StdioServerTransport();
719
811
  await server.connect(transport);
720
- console.error("HotelZero v1.4.0 running on stdio");
812
+ console.error("HotelZero v1.5.0 running on stdio");
721
813
  }
722
814
  main().catch((error) => {
723
815
  console.error("Fatal error:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotelzero",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",