hotelzero 1.5.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 +33 -0
- package/dist/browser.js +264 -0
- package/dist/index.js +106 -2
- package/package.json +1 -1
package/dist/browser.d.ts
CHANGED
|
@@ -170,6 +170,35 @@ export interface AvailabilityResult {
|
|
|
170
170
|
message: string;
|
|
171
171
|
url: string;
|
|
172
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
|
+
}
|
|
173
202
|
export declare class HotelBrowser {
|
|
174
203
|
private browser;
|
|
175
204
|
private page;
|
|
@@ -207,4 +236,8 @@ export declare class HotelBrowser {
|
|
|
207
236
|
guests?: number;
|
|
208
237
|
rooms?: number;
|
|
209
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>;
|
|
210
243
|
}
|
package/dist/browser.js
CHANGED
|
@@ -1483,4 +1483,268 @@ export class HotelBrowser {
|
|
|
1483
1483
|
console.error(`Check availability attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delayMs / 1000)}s...`);
|
|
1484
1484
|
});
|
|
1485
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
|
+
}
|
|
1486
1750
|
}
|
package/dist/index.js
CHANGED
|
@@ -133,6 +133,12 @@ const CheckAvailabilitySchema = z.object({
|
|
|
133
133
|
guests: z.number().min(1).max(30).optional().describe("Number of guests (default: 2)"),
|
|
134
134
|
rooms: z.number().min(1).max(10).optional().describe("Number of rooms (default: 1)"),
|
|
135
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
|
+
});
|
|
136
142
|
// Global browser instance (reuse for efficiency)
|
|
137
143
|
let browser = null;
|
|
138
144
|
async function getBrowser() {
|
|
@@ -361,10 +367,81 @@ function formatAvailabilityResult(result) {
|
|
|
361
367
|
lines.push(`Book at: ${result.url.split("?")[0]}`);
|
|
362
368
|
return lines.join("\n");
|
|
363
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
|
+
}
|
|
364
441
|
// Create MCP server
|
|
365
442
|
const server = new Server({
|
|
366
443
|
name: "hotelzero",
|
|
367
|
-
version: "1.
|
|
444
|
+
version: "1.6.0",
|
|
368
445
|
}, {
|
|
369
446
|
capabilities: {
|
|
370
447
|
tools: {},
|
|
@@ -567,6 +644,20 @@ Results are scored and ranked by how well they match the criteria.`,
|
|
|
567
644
|
required: ["hotelUrl", "checkIn", "checkOut"],
|
|
568
645
|
},
|
|
569
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
|
+
},
|
|
570
661
|
],
|
|
571
662
|
};
|
|
572
663
|
});
|
|
@@ -741,6 +832,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
741
832
|
],
|
|
742
833
|
};
|
|
743
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
|
+
}
|
|
744
848
|
default:
|
|
745
849
|
throw new Error(`Unknown tool: ${name}`);
|
|
746
850
|
}
|
|
@@ -809,7 +913,7 @@ process.on("SIGTERM", async () => {
|
|
|
809
913
|
async function main() {
|
|
810
914
|
const transport = new StdioServerTransport();
|
|
811
915
|
await server.connect(transport);
|
|
812
|
-
console.error("HotelZero v1.
|
|
916
|
+
console.error("HotelZero v1.6.0 running on stdio");
|
|
813
917
|
}
|
|
814
918
|
main().catch((error) => {
|
|
815
919
|
console.error("Fatal error:", error);
|