hotelzero 1.4.0 → 1.6.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 +66 -0
- package/dist/browser.js +504 -0
- package/dist/index.js +198 -2
- package/package.json +1 -1
package/dist/browser.d.ts
CHANGED
|
@@ -147,6 +147,58 @@ 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
|
+
}
|
|
173
|
+
export interface Review {
|
|
174
|
+
title: string;
|
|
175
|
+
rating: number | null;
|
|
176
|
+
date: string;
|
|
177
|
+
travelerType: string;
|
|
178
|
+
country: string;
|
|
179
|
+
stayDate: string;
|
|
180
|
+
roomType: string;
|
|
181
|
+
nightsStayed: string;
|
|
182
|
+
positive: string;
|
|
183
|
+
negative: string;
|
|
184
|
+
}
|
|
185
|
+
export interface RatingBreakdown {
|
|
186
|
+
staff: number | null;
|
|
187
|
+
facilities: number | null;
|
|
188
|
+
cleanliness: number | null;
|
|
189
|
+
comfort: number | null;
|
|
190
|
+
valueForMoney: number | null;
|
|
191
|
+
location: number | null;
|
|
192
|
+
freeWifi: number | null;
|
|
193
|
+
}
|
|
194
|
+
export interface ReviewsResult {
|
|
195
|
+
hotelName: string;
|
|
196
|
+
overallRating: number | null;
|
|
197
|
+
totalReviews: number;
|
|
198
|
+
ratingBreakdown: RatingBreakdown;
|
|
199
|
+
reviews: Review[];
|
|
200
|
+
url: string;
|
|
201
|
+
}
|
|
150
202
|
export declare class HotelBrowser {
|
|
151
203
|
private browser;
|
|
152
204
|
private page;
|
|
@@ -174,4 +226,18 @@ export declare class HotelBrowser {
|
|
|
174
226
|
* Compare multiple hotels side-by-side
|
|
175
227
|
*/
|
|
176
228
|
compareHotels(hotelUrls: string[]): Promise<HotelDetails[]>;
|
|
229
|
+
/**
|
|
230
|
+
* Check availability for a specific hotel on given dates
|
|
231
|
+
*/
|
|
232
|
+
checkAvailability(params: {
|
|
233
|
+
hotelUrl: string;
|
|
234
|
+
checkIn: string;
|
|
235
|
+
checkOut: string;
|
|
236
|
+
guests?: number;
|
|
237
|
+
rooms?: number;
|
|
238
|
+
}): Promise<AvailabilityResult>;
|
|
239
|
+
/**
|
|
240
|
+
* Get reviews for a specific hotel
|
|
241
|
+
*/
|
|
242
|
+
getReviews(hotelUrl: string, limit?: number, sortBy?: "recent" | "highest" | "lowest", filterBy?: "couples" | "families" | "solo" | "business" | "groups"): Promise<ReviewsResult>;
|
|
177
243
|
}
|
package/dist/browser.js
CHANGED
|
@@ -1243,4 +1243,508 @@ 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
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Get reviews for a specific hotel
|
|
1488
|
+
*/
|
|
1489
|
+
async getReviews(hotelUrl, limit = 10, sortBy = "recent", filterBy) {
|
|
1490
|
+
return retryWithBackoff(async () => {
|
|
1491
|
+
await this.enforceRateLimit();
|
|
1492
|
+
if (!this.page)
|
|
1493
|
+
throw new Error("Browser not initialized");
|
|
1494
|
+
// Navigate to hotel page
|
|
1495
|
+
const cleanUrl = hotelUrl.split("?")[0].split("#")[0];
|
|
1496
|
+
await this.page.goto(cleanUrl, {
|
|
1497
|
+
waitUntil: "domcontentloaded",
|
|
1498
|
+
timeout: 60000,
|
|
1499
|
+
});
|
|
1500
|
+
await this.page.waitForTimeout(3000);
|
|
1501
|
+
// Close any popups
|
|
1502
|
+
try {
|
|
1503
|
+
const closeButtons = await this.page.$$('[aria-label="Dismiss sign-in info."], [data-testid="dismissButton"], button[aria-label*="close"], button[aria-label*="Close"]');
|
|
1504
|
+
for (const btn of closeButtons) {
|
|
1505
|
+
try {
|
|
1506
|
+
await btn.click({ timeout: 1000 });
|
|
1507
|
+
}
|
|
1508
|
+
catch { }
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
catch { }
|
|
1512
|
+
await this.page.keyboard.press("Escape");
|
|
1513
|
+
await this.page.waitForTimeout(500);
|
|
1514
|
+
// Get overall rating info from main page before opening modal
|
|
1515
|
+
const mainPageData = await this.page.evaluate(`
|
|
1516
|
+
(function() {
|
|
1517
|
+
var results = { hotelName: '', overallRating: null, totalReviews: 0, breakdown: {} };
|
|
1518
|
+
|
|
1519
|
+
// Hotel name
|
|
1520
|
+
var nameEl = document.querySelector('h2[class*="pp-header__title"], [data-testid="PropertyHeaderDesktop-wrapper"] h2, h2.d2fee87262');
|
|
1521
|
+
results.hotelName = nameEl?.textContent?.trim() || '';
|
|
1522
|
+
|
|
1523
|
+
// Overall rating and total reviews from review-score-component
|
|
1524
|
+
var scoreComponent = document.querySelector('[data-testid="review-score-component"]');
|
|
1525
|
+
if (scoreComponent) {
|
|
1526
|
+
var text = scoreComponent.textContent || '';
|
|
1527
|
+
// Extract score (e.g., "Scored 9.1 9.1..." -> 9.1)
|
|
1528
|
+
var scoreMatch = text.match(/Scored\\s+([\\d.]+)/);
|
|
1529
|
+
if (scoreMatch) {
|
|
1530
|
+
results.overallRating = parseFloat(scoreMatch[1]);
|
|
1531
|
+
}
|
|
1532
|
+
// Extract total reviews (e.g., "1,043 reviews")
|
|
1533
|
+
var reviewCountMatch = text.match(/([\\d,]+)\\s+reviews?/);
|
|
1534
|
+
if (reviewCountMatch) {
|
|
1535
|
+
results.totalReviews = parseInt(reviewCountMatch[1].replace(/,/g, ''));
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Rating breakdown categories
|
|
1540
|
+
var breakdownEls = document.querySelectorAll('[data-testid="review-subscore"]');
|
|
1541
|
+
breakdownEls.forEach(function(el) {
|
|
1542
|
+
var text = el.textContent?.trim() || '';
|
|
1543
|
+
var parts = text.split(/\\s+/);
|
|
1544
|
+
if (parts.length >= 2) {
|
|
1545
|
+
var score = parseFloat(parts[parts.length - 1]);
|
|
1546
|
+
var category = parts.slice(0, -1).join(' ').toLowerCase();
|
|
1547
|
+
if (category.includes('staff')) results.breakdown.staff = score;
|
|
1548
|
+
else if (category.includes('facilities')) results.breakdown.facilities = score;
|
|
1549
|
+
else if (category.includes('cleanliness')) results.breakdown.cleanliness = score;
|
|
1550
|
+
else if (category.includes('comfort')) results.breakdown.comfort = score;
|
|
1551
|
+
else if (category.includes('value') || category.includes('money')) results.breakdown.valueForMoney = score;
|
|
1552
|
+
else if (category.includes('location')) results.breakdown.location = score;
|
|
1553
|
+
else if (category.includes('wifi') || category.includes('wi-fi')) results.breakdown.freeWifi = score;
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
return results;
|
|
1558
|
+
})()
|
|
1559
|
+
`);
|
|
1560
|
+
// Click "Read all reviews" button to open reviews modal
|
|
1561
|
+
const readAllBtn = await this.page.$('[data-testid="fr-read-all-reviews"], [data-testid="review-score-read-all"]');
|
|
1562
|
+
if (!readAllBtn) {
|
|
1563
|
+
throw new Error("Could not find 'Read all reviews' button. Hotel may not have reviews.");
|
|
1564
|
+
}
|
|
1565
|
+
await readAllBtn.click();
|
|
1566
|
+
await this.page.waitForTimeout(3000);
|
|
1567
|
+
// Apply sort option if not default
|
|
1568
|
+
if (sortBy !== "recent") {
|
|
1569
|
+
try {
|
|
1570
|
+
const sorter = await this.page.$('[data-testid="reviews-sorter-component"]');
|
|
1571
|
+
if (sorter) {
|
|
1572
|
+
await sorter.click();
|
|
1573
|
+
await this.page.waitForTimeout(500);
|
|
1574
|
+
// Map our sortBy values to Booking.com's options
|
|
1575
|
+
const sortMap = {
|
|
1576
|
+
recent: "Newest first",
|
|
1577
|
+
highest: "Highest scores",
|
|
1578
|
+
lowest: "Lowest scores",
|
|
1579
|
+
};
|
|
1580
|
+
const sortOption = await this.page.$(`[role="option"]:has-text("${sortMap[sortBy]}")`);
|
|
1581
|
+
if (sortOption) {
|
|
1582
|
+
await sortOption.click();
|
|
1583
|
+
await this.page.waitForTimeout(2000);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
catch { }
|
|
1588
|
+
}
|
|
1589
|
+
// Apply traveler type filter if specified
|
|
1590
|
+
if (filterBy) {
|
|
1591
|
+
try {
|
|
1592
|
+
const filterMap = {
|
|
1593
|
+
couples: "Couples",
|
|
1594
|
+
families: "Families",
|
|
1595
|
+
solo: "Solo travelers",
|
|
1596
|
+
business: "Business travelers",
|
|
1597
|
+
groups: "Groups of friends",
|
|
1598
|
+
};
|
|
1599
|
+
const filterLabel = await this.page.$(`[data-testid="customerType"] label:has-text("${filterMap[filterBy]}")`);
|
|
1600
|
+
if (filterLabel) {
|
|
1601
|
+
await filterLabel.click();
|
|
1602
|
+
await this.page.waitForTimeout(2000);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
catch { }
|
|
1606
|
+
}
|
|
1607
|
+
// Scroll down to ensure reviews are visible
|
|
1608
|
+
await this.page.evaluate(`
|
|
1609
|
+
(function() {
|
|
1610
|
+
var reviewCards = document.querySelector('[data-testid="review-cards"]');
|
|
1611
|
+
if (reviewCards) {
|
|
1612
|
+
reviewCards.scrollIntoView({ behavior: 'instant', block: 'start' });
|
|
1613
|
+
}
|
|
1614
|
+
})()
|
|
1615
|
+
`);
|
|
1616
|
+
await this.page.waitForTimeout(1000);
|
|
1617
|
+
// Scroll to load more reviews if needed (up to limit)
|
|
1618
|
+
const targetReviews = Math.min(limit, 50);
|
|
1619
|
+
let currentCount = 0;
|
|
1620
|
+
let scrollAttempts = 0;
|
|
1621
|
+
const maxScrollAttempts = Math.ceil(targetReviews / 10) + 3;
|
|
1622
|
+
while (scrollAttempts < maxScrollAttempts) {
|
|
1623
|
+
const count = await this.page.evaluate(`
|
|
1624
|
+
document.querySelectorAll('[data-testid="review-card"]').length
|
|
1625
|
+
`);
|
|
1626
|
+
if (count >= targetReviews || count === currentCount) {
|
|
1627
|
+
break;
|
|
1628
|
+
}
|
|
1629
|
+
currentCount = count;
|
|
1630
|
+
// Scroll within the modal/container
|
|
1631
|
+
await this.page.evaluate(`
|
|
1632
|
+
(function() {
|
|
1633
|
+
var container = document.querySelector('[data-testid="review-list-container"]')
|
|
1634
|
+
|| document.querySelector('[role="dialog"]');
|
|
1635
|
+
if (container) {
|
|
1636
|
+
container.scrollTop = container.scrollHeight;
|
|
1637
|
+
}
|
|
1638
|
+
// Also scroll the last review into view
|
|
1639
|
+
var reviews = document.querySelectorAll('[data-testid="review-card"]');
|
|
1640
|
+
if (reviews.length > 0) {
|
|
1641
|
+
reviews[reviews.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' });
|
|
1642
|
+
}
|
|
1643
|
+
})()
|
|
1644
|
+
`);
|
|
1645
|
+
await this.page.waitForTimeout(1500);
|
|
1646
|
+
scrollAttempts++;
|
|
1647
|
+
}
|
|
1648
|
+
// Extract reviews
|
|
1649
|
+
const reviews = await this.page.evaluate(`
|
|
1650
|
+
(function() {
|
|
1651
|
+
var reviewCards = document.querySelectorAll('[data-testid="review-card"]');
|
|
1652
|
+
var reviews = [];
|
|
1653
|
+
|
|
1654
|
+
for (var i = 0; i < reviewCards.length; i++) {
|
|
1655
|
+
var card = reviewCards[i];
|
|
1656
|
+
var review = {};
|
|
1657
|
+
|
|
1658
|
+
// Title
|
|
1659
|
+
var titleEl = card.querySelector('[data-testid="review-title"]');
|
|
1660
|
+
review.title = titleEl?.textContent?.trim() || '';
|
|
1661
|
+
|
|
1662
|
+
// Score - extract number from "Scored 10 10"
|
|
1663
|
+
var scoreEl = card.querySelector('[data-testid="review-score"]');
|
|
1664
|
+
var scoreText = scoreEl?.textContent?.trim() || '';
|
|
1665
|
+
var scoreMatch = scoreText.match(/Scored\\s+([\\d.]+)/);
|
|
1666
|
+
review.rating = scoreMatch ? parseFloat(scoreMatch[1]) : null;
|
|
1667
|
+
|
|
1668
|
+
// Date - remove "Reviewed: " prefix
|
|
1669
|
+
var dateEl = card.querySelector('[data-testid="review-date"]');
|
|
1670
|
+
var dateText = dateEl?.textContent?.trim() || '';
|
|
1671
|
+
review.date = dateText.replace(/^Reviewed:\\s*/i, '');
|
|
1672
|
+
|
|
1673
|
+
// Traveler type
|
|
1674
|
+
var typeEl = card.querySelector('[data-testid="review-traveler-type"]');
|
|
1675
|
+
review.travelerType = typeEl?.textContent?.trim() || '';
|
|
1676
|
+
|
|
1677
|
+
// Stay date
|
|
1678
|
+
var stayDateEl = card.querySelector('[data-testid="review-stay-date"]');
|
|
1679
|
+
review.stayDate = stayDateEl?.textContent?.trim() || '';
|
|
1680
|
+
|
|
1681
|
+
// Room name
|
|
1682
|
+
var roomEl = card.querySelector('[data-testid="review-room-name"]');
|
|
1683
|
+
review.roomType = roomEl?.textContent?.trim() || '';
|
|
1684
|
+
|
|
1685
|
+
// Num nights
|
|
1686
|
+
var nightsEl = card.querySelector('[data-testid="review-num-nights"]');
|
|
1687
|
+
review.nightsStayed = nightsEl?.textContent?.trim()?.replace(/·/g, '').trim() || '';
|
|
1688
|
+
|
|
1689
|
+
// Positive
|
|
1690
|
+
var positiveEl = card.querySelector('[data-testid="review-positive-text"]');
|
|
1691
|
+
review.positive = positiveEl?.textContent?.trim() || '';
|
|
1692
|
+
|
|
1693
|
+
// Negative
|
|
1694
|
+
var negativeEl = card.querySelector('[data-testid="review-negative-text"]');
|
|
1695
|
+
review.negative = negativeEl?.textContent?.trim() || '';
|
|
1696
|
+
|
|
1697
|
+
// Avatar/country - extract country from text like "JJohn United Kingdom"
|
|
1698
|
+
var avatarEl = card.querySelector('[data-testid="review-avatar"]');
|
|
1699
|
+
var avatarText = avatarEl?.textContent?.trim() || '';
|
|
1700
|
+
// Try to extract country (usually after the name, common patterns)
|
|
1701
|
+
var countryPatterns = [
|
|
1702
|
+
/(?:United Kingdom|United States|Ireland|France|Germany|Spain|Italy|Netherlands|Belgium|Switzerland|Australia|Canada|Sweden|Norway|Denmark|Japan|China|Brazil|Mexico|India|South Korea|Russia|Poland|Austria|Portugal|Greece|Turkey|Czech Republic|Hungary|Romania|Argentina|Chile|Colombia|Thailand|Singapore|Malaysia|Indonesia|Philippines|Vietnam|New Zealand|Finland|Israel|South Africa|Egypt|United Arab Emirates|Saudi Arabia)$/i
|
|
1703
|
+
];
|
|
1704
|
+
review.country = '';
|
|
1705
|
+
for (var p = 0; p < countryPatterns.length; p++) {
|
|
1706
|
+
var match = avatarText.match(countryPatterns[p]);
|
|
1707
|
+
if (match) {
|
|
1708
|
+
review.country = match[0];
|
|
1709
|
+
break;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
// Fallback: take last two words if no country matched
|
|
1713
|
+
if (!review.country && avatarText) {
|
|
1714
|
+
var words = avatarText.split(/\\s+/);
|
|
1715
|
+
if (words.length >= 2) {
|
|
1716
|
+
review.country = words.slice(-2).join(' ');
|
|
1717
|
+
} else if (words.length === 1) {
|
|
1718
|
+
review.country = words[0];
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
reviews.push(review);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
return reviews;
|
|
1726
|
+
})()
|
|
1727
|
+
`);
|
|
1728
|
+
// Build rating breakdown with proper null handling
|
|
1729
|
+
const ratingBreakdown = {
|
|
1730
|
+
staff: mainPageData.breakdown.staff ?? null,
|
|
1731
|
+
facilities: mainPageData.breakdown.facilities ?? null,
|
|
1732
|
+
cleanliness: mainPageData.breakdown.cleanliness ?? null,
|
|
1733
|
+
comfort: mainPageData.breakdown.comfort ?? null,
|
|
1734
|
+
valueForMoney: mainPageData.breakdown.valueForMoney ?? null,
|
|
1735
|
+
location: mainPageData.breakdown.location ?? null,
|
|
1736
|
+
freeWifi: mainPageData.breakdown.freeWifi ?? null,
|
|
1737
|
+
};
|
|
1738
|
+
return {
|
|
1739
|
+
hotelName: mainPageData.hotelName,
|
|
1740
|
+
overallRating: mainPageData.overallRating,
|
|
1741
|
+
totalReviews: mainPageData.totalReviews,
|
|
1742
|
+
ratingBreakdown,
|
|
1743
|
+
reviews: reviews.slice(0, limit),
|
|
1744
|
+
url: cleanUrl,
|
|
1745
|
+
};
|
|
1746
|
+
}, DEFAULT_RETRY_CONFIG, (attempt, error, delayMs) => {
|
|
1747
|
+
console.error(`Get reviews attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delayMs / 1000)}s...`);
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1246
1750
|
}
|
package/dist/index.js
CHANGED
|
@@ -126,6 +126,19 @@ 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
|
+
});
|
|
136
|
+
const GetReviewsSchema = z.object({
|
|
137
|
+
hotelUrl: z.string().describe("Booking.com hotel URL to get reviews for"),
|
|
138
|
+
limit: z.number().min(1).max(50).optional().describe("Number of reviews to fetch (default: 10, max: 50)"),
|
|
139
|
+
sortBy: z.enum(["recent", "highest", "lowest"]).optional().describe("Sort reviews by: recent (default), highest score, or lowest score"),
|
|
140
|
+
filterBy: z.enum(["couples", "families", "solo", "business", "groups"]).optional().describe("Filter reviews by traveler type"),
|
|
141
|
+
});
|
|
129
142
|
// Global browser instance (reuse for efficiency)
|
|
130
143
|
let browser = null;
|
|
131
144
|
async function getBrowser() {
|
|
@@ -303,10 +316,132 @@ function formatHotelComparison(hotels) {
|
|
|
303
316
|
});
|
|
304
317
|
return lines.join("\n");
|
|
305
318
|
}
|
|
319
|
+
function formatAvailabilityResult(result) {
|
|
320
|
+
const lines = [];
|
|
321
|
+
lines.push("=".repeat(60));
|
|
322
|
+
lines.push("AVAILABILITY CHECK");
|
|
323
|
+
lines.push("=".repeat(60));
|
|
324
|
+
lines.push("");
|
|
325
|
+
lines.push(`Hotel: ${result.hotelName}`);
|
|
326
|
+
lines.push(`Dates: ${result.checkIn} to ${result.checkOut}`);
|
|
327
|
+
lines.push(`Guests: ${result.guests} | Rooms: ${result.rooms}`);
|
|
328
|
+
lines.push("");
|
|
329
|
+
if (!result.available) {
|
|
330
|
+
lines.push("STATUS: NOT AVAILABLE");
|
|
331
|
+
lines.push(result.message);
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
lines.push(`STATUS: AVAILABLE - ${result.message}`);
|
|
335
|
+
lines.push("");
|
|
336
|
+
lines.push("--- ROOM OPTIONS ---");
|
|
337
|
+
lines.push("");
|
|
338
|
+
result.roomOptions.forEach((room, i) => {
|
|
339
|
+
lines.push(`${i + 1}. ${room.name}`);
|
|
340
|
+
if (room.priceDisplay) {
|
|
341
|
+
lines.push(` Price: ${room.priceDisplay}`);
|
|
342
|
+
}
|
|
343
|
+
if (room.sleeps) {
|
|
344
|
+
lines.push(` Sleeps: ${room.sleeps}`);
|
|
345
|
+
}
|
|
346
|
+
if (room.bedType) {
|
|
347
|
+
lines.push(` Bed: ${room.bedType}`);
|
|
348
|
+
}
|
|
349
|
+
if (room.cancellation) {
|
|
350
|
+
lines.push(` Cancellation: ${room.cancellation}`);
|
|
351
|
+
}
|
|
352
|
+
if (room.breakfast) {
|
|
353
|
+
lines.push(` Meals: ${room.breakfast}`);
|
|
354
|
+
}
|
|
355
|
+
if (room.features.length > 0) {
|
|
356
|
+
lines.push(` Features: ${room.features.join(", ")}`);
|
|
357
|
+
}
|
|
358
|
+
lines.push("");
|
|
359
|
+
});
|
|
360
|
+
if (result.lowestPrice) {
|
|
361
|
+
lines.push("--- SUMMARY ---");
|
|
362
|
+
lines.push(`Lowest price: ${result.lowestPriceDisplay}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
lines.push("");
|
|
366
|
+
lines.push("=".repeat(60));
|
|
367
|
+
lines.push(`Book at: ${result.url.split("?")[0]}`);
|
|
368
|
+
return lines.join("\n");
|
|
369
|
+
}
|
|
370
|
+
function formatReviewsResult(result) {
|
|
371
|
+
const lines = [];
|
|
372
|
+
lines.push("=".repeat(60));
|
|
373
|
+
lines.push(`REVIEWS: ${result.hotelName}`);
|
|
374
|
+
lines.push("=".repeat(60));
|
|
375
|
+
lines.push("");
|
|
376
|
+
// Overall rating
|
|
377
|
+
if (result.overallRating) {
|
|
378
|
+
lines.push(`Overall Rating: ${result.overallRating}/10 (${result.totalReviews} reviews)`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
lines.push(`Total Reviews: ${result.totalReviews}`);
|
|
382
|
+
}
|
|
383
|
+
lines.push("");
|
|
384
|
+
// Rating breakdown
|
|
385
|
+
const breakdown = result.ratingBreakdown;
|
|
386
|
+
const categories = [
|
|
387
|
+
{ label: "Staff", value: breakdown.staff },
|
|
388
|
+
{ label: "Facilities", value: breakdown.facilities },
|
|
389
|
+
{ label: "Cleanliness", value: breakdown.cleanliness },
|
|
390
|
+
{ label: "Comfort", value: breakdown.comfort },
|
|
391
|
+
{ label: "Value for Money", value: breakdown.valueForMoney },
|
|
392
|
+
{ label: "Location", value: breakdown.location },
|
|
393
|
+
{ label: "Free WiFi", value: breakdown.freeWifi },
|
|
394
|
+
];
|
|
395
|
+
const validCategories = categories.filter(c => c.value !== null);
|
|
396
|
+
if (validCategories.length > 0) {
|
|
397
|
+
lines.push("--- RATING BREAKDOWN ---");
|
|
398
|
+
validCategories.forEach(cat => {
|
|
399
|
+
lines.push(`${cat.label}: ${cat.value}`);
|
|
400
|
+
});
|
|
401
|
+
lines.push("");
|
|
402
|
+
}
|
|
403
|
+
// Individual reviews
|
|
404
|
+
if (result.reviews.length > 0) {
|
|
405
|
+
lines.push(`--- REVIEWS (${result.reviews.length}) ---`);
|
|
406
|
+
lines.push("");
|
|
407
|
+
result.reviews.forEach((review, index) => {
|
|
408
|
+
lines.push(`[${index + 1}] ${review.title || "Review"}`);
|
|
409
|
+
if (review.rating !== null) {
|
|
410
|
+
lines.push(` Score: ${review.rating}/10`);
|
|
411
|
+
}
|
|
412
|
+
if (review.country) {
|
|
413
|
+
lines.push(` Reviewer: ${review.country}`);
|
|
414
|
+
}
|
|
415
|
+
if (review.travelerType) {
|
|
416
|
+
lines.push(` Traveler Type: ${review.travelerType}`);
|
|
417
|
+
}
|
|
418
|
+
if (review.roomType) {
|
|
419
|
+
lines.push(` Room: ${review.roomType}`);
|
|
420
|
+
}
|
|
421
|
+
if (review.stayDate || review.nightsStayed) {
|
|
422
|
+
const stayInfo = [review.stayDate, review.nightsStayed].filter(Boolean).join(" - ");
|
|
423
|
+
lines.push(` Stayed: ${stayInfo}`);
|
|
424
|
+
}
|
|
425
|
+
if (review.date) {
|
|
426
|
+
lines.push(` Reviewed: ${review.date}`);
|
|
427
|
+
}
|
|
428
|
+
if (review.positive) {
|
|
429
|
+
lines.push(` + ${review.positive}`);
|
|
430
|
+
}
|
|
431
|
+
if (review.negative) {
|
|
432
|
+
lines.push(` - ${review.negative}`);
|
|
433
|
+
}
|
|
434
|
+
lines.push("");
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
lines.push("=".repeat(60));
|
|
438
|
+
lines.push(`Hotel URL: ${result.url}`);
|
|
439
|
+
return lines.join("\n");
|
|
440
|
+
}
|
|
306
441
|
// Create MCP server
|
|
307
442
|
const server = new Server({
|
|
308
443
|
name: "hotelzero",
|
|
309
|
-
version: "1.
|
|
444
|
+
version: "1.6.0",
|
|
310
445
|
}, {
|
|
311
446
|
capabilities: {
|
|
312
447
|
tools: {},
|
|
@@ -494,6 +629,35 @@ Results are scored and ranked by how well they match the criteria.`,
|
|
|
494
629
|
required: ["urls"],
|
|
495
630
|
},
|
|
496
631
|
},
|
|
632
|
+
{
|
|
633
|
+
name: "check_availability",
|
|
634
|
+
description: "Check room availability and prices for a specific hotel on given dates. Returns available room types, prices, and booking details.",
|
|
635
|
+
inputSchema: {
|
|
636
|
+
type: "object",
|
|
637
|
+
properties: {
|
|
638
|
+
hotelUrl: { type: "string", description: "Booking.com hotel URL to check" },
|
|
639
|
+
checkIn: { type: "string", description: "Check-in date (YYYY-MM-DD)" },
|
|
640
|
+
checkOut: { type: "string", description: "Check-out date (YYYY-MM-DD)" },
|
|
641
|
+
guests: { type: "number", description: "Number of guests (default: 2)", minimum: 1, maximum: 30 },
|
|
642
|
+
rooms: { type: "number", description: "Number of rooms (default: 1)", minimum: 1, maximum: 10 },
|
|
643
|
+
},
|
|
644
|
+
required: ["hotelUrl", "checkIn", "checkOut"],
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
name: "get_reviews",
|
|
649
|
+
description: "Get guest reviews for a specific hotel. Returns overall rating, rating breakdown by category (staff, facilities, cleanliness, comfort, value, location, WiFi), and individual reviews with positive/negative comments, reviewer info, and stay details.",
|
|
650
|
+
inputSchema: {
|
|
651
|
+
type: "object",
|
|
652
|
+
properties: {
|
|
653
|
+
hotelUrl: { type: "string", description: "Booking.com hotel URL to get reviews for" },
|
|
654
|
+
limit: { type: "number", description: "Number of reviews to fetch (default: 10, max: 50)", minimum: 1, maximum: 50 },
|
|
655
|
+
sortBy: { type: "string", description: "Sort reviews by", enum: ["recent", "highest", "lowest"] },
|
|
656
|
+
filterBy: { type: "string", description: "Filter by traveler type", enum: ["couples", "families", "solo", "business", "groups"] },
|
|
657
|
+
},
|
|
658
|
+
required: ["hotelUrl"],
|
|
659
|
+
},
|
|
660
|
+
},
|
|
497
661
|
],
|
|
498
662
|
};
|
|
499
663
|
});
|
|
@@ -649,6 +813,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
649
813
|
],
|
|
650
814
|
};
|
|
651
815
|
}
|
|
816
|
+
case "check_availability": {
|
|
817
|
+
const parsed = CheckAvailabilitySchema.parse(args);
|
|
818
|
+
const result = await b.checkAvailability({
|
|
819
|
+
hotelUrl: parsed.hotelUrl,
|
|
820
|
+
checkIn: parsed.checkIn,
|
|
821
|
+
checkOut: parsed.checkOut,
|
|
822
|
+
guests: parsed.guests,
|
|
823
|
+
rooms: parsed.rooms,
|
|
824
|
+
});
|
|
825
|
+
const formatted = formatAvailabilityResult(result);
|
|
826
|
+
return {
|
|
827
|
+
content: [
|
|
828
|
+
{
|
|
829
|
+
type: "text",
|
|
830
|
+
text: formatted,
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
case "get_reviews": {
|
|
836
|
+
const parsed = GetReviewsSchema.parse(args);
|
|
837
|
+
const result = await b.getReviews(parsed.hotelUrl, parsed.limit, parsed.sortBy, parsed.filterBy);
|
|
838
|
+
const formatted = formatReviewsResult(result);
|
|
839
|
+
return {
|
|
840
|
+
content: [
|
|
841
|
+
{
|
|
842
|
+
type: "text",
|
|
843
|
+
text: formatted,
|
|
844
|
+
},
|
|
845
|
+
],
|
|
846
|
+
};
|
|
847
|
+
}
|
|
652
848
|
default:
|
|
653
849
|
throw new Error(`Unknown tool: ${name}`);
|
|
654
850
|
}
|
|
@@ -717,7 +913,7 @@ process.on("SIGTERM", async () => {
|
|
|
717
913
|
async function main() {
|
|
718
914
|
const transport = new StdioServerTransport();
|
|
719
915
|
await server.connect(transport);
|
|
720
|
-
console.error("HotelZero v1.
|
|
916
|
+
console.error("HotelZero v1.6.0 running on stdio");
|
|
721
917
|
}
|
|
722
918
|
main().catch((error) => {
|
|
723
919
|
console.error("Fatal error:", error);
|