hotelzero 1.5.0 → 1.7.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
@@ -170,6 +170,56 @@ 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
+ }
202
+ export interface DatePrice {
203
+ date: string;
204
+ price: number | null;
205
+ priceDisplay: string;
206
+ available: boolean;
207
+ currency: string;
208
+ }
209
+ export interface PriceCalendarResult {
210
+ hotelName: string;
211
+ startDate: string;
212
+ endDate: string;
213
+ nights: number;
214
+ currency: string;
215
+ prices: DatePrice[];
216
+ lowestPrice: number | null;
217
+ lowestPriceDate: string | null;
218
+ highestPrice: number | null;
219
+ highestPriceDate: string | null;
220
+ averagePrice: number | null;
221
+ url: string;
222
+ }
173
223
  export declare class HotelBrowser {
174
224
  private browser;
175
225
  private page;
@@ -207,4 +257,12 @@ export declare class HotelBrowser {
207
257
  guests?: number;
208
258
  rooms?: number;
209
259
  }): Promise<AvailabilityResult>;
260
+ /**
261
+ * Get reviews for a specific hotel
262
+ */
263
+ getReviews(hotelUrl: string, limit?: number, sortBy?: "recent" | "highest" | "lowest", filterBy?: "couples" | "families" | "solo" | "business" | "groups"): Promise<ReviewsResult>;
264
+ /**
265
+ * Get price calendar for a hotel - shows prices for multiple dates
266
+ */
267
+ getPriceCalendar(hotelUrl: string, startDate: string, nights?: number, guests?: number, rooms?: number, currency?: string): Promise<PriceCalendarResult>;
210
268
  }
package/dist/browser.js CHANGED
@@ -1483,4 +1483,448 @@ 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
+ }
1750
+ /**
1751
+ * Get price calendar for a hotel - shows prices for multiple dates
1752
+ */
1753
+ async getPriceCalendar(hotelUrl, startDate, nights = 14, guests = 2, rooms = 1, currency = "USD") {
1754
+ // Validate inputs
1755
+ const start = new Date(startDate);
1756
+ if (isNaN(start.getTime())) {
1757
+ throw new Error("Invalid start date format. Use YYYY-MM-DD.");
1758
+ }
1759
+ // Limit to reasonable range
1760
+ const actualNights = Math.min(Math.max(nights, 1), 30);
1761
+ // Generate date range
1762
+ const dates = [];
1763
+ for (let i = 0; i < actualNights; i++) {
1764
+ const checkIn = new Date(start);
1765
+ checkIn.setDate(checkIn.getDate() + i);
1766
+ const checkOut = new Date(checkIn);
1767
+ checkOut.setDate(checkOut.getDate() + 1);
1768
+ dates.push({
1769
+ checkIn: checkIn.toISOString().split("T")[0],
1770
+ checkOut: checkOut.toISOString().split("T")[0],
1771
+ });
1772
+ }
1773
+ // Clean the hotel URL
1774
+ const cleanUrl = hotelUrl.split("?")[0].split("#")[0];
1775
+ // Collect prices for each date
1776
+ const prices = [];
1777
+ let hotelName = "";
1778
+ for (const dateRange of dates) {
1779
+ await this.enforceRateLimit();
1780
+ if (!this.page)
1781
+ throw new Error("Browser not initialized");
1782
+ const urlWithDates = `${cleanUrl}?checkin=${dateRange.checkIn}&checkout=${dateRange.checkOut}&group_adults=${guests}&no_rooms=${rooms}&selected_currency=${currency}`;
1783
+ try {
1784
+ await this.page.goto(urlWithDates, {
1785
+ waitUntil: "domcontentloaded",
1786
+ timeout: 30000,
1787
+ });
1788
+ await this.page.waitForTimeout(2000);
1789
+ // Close any popups
1790
+ try {
1791
+ await this.page.keyboard.press("Escape");
1792
+ }
1793
+ catch { }
1794
+ // Extract price data
1795
+ const priceData = await this.page.evaluate(`
1796
+ (function() {
1797
+ var result = { hotelName: '', price: null, priceDisplay: '', available: true, currency: '' };
1798
+
1799
+ // Get hotel name (only need it once)
1800
+ var nameEl = document.querySelector('h2[class*="pp-header__title"], [data-testid="PropertyHeaderDesktop-wrapper"] h2, h2.d2fee87262');
1801
+ result.hotelName = nameEl?.textContent?.trim() || '';
1802
+
1803
+ // Check for no availability message
1804
+ var noAvail = document.querySelector('[class*="soldout"], [class*="no-availability"], [data-testid="no-rooms-available"]');
1805
+ if (noAvail) {
1806
+ result.available = false;
1807
+ return result;
1808
+ }
1809
+
1810
+ // Find the lowest price - look for price elements
1811
+ var priceElements = document.querySelectorAll('[data-testid="price-and-discounted-price"], .bui-price-display__value, .prco-valign-middle-helper');
1812
+ var prices = [];
1813
+
1814
+ priceElements.forEach(function(el) {
1815
+ var text = el.textContent?.trim() || '';
1816
+ // Extract price number and currency
1817
+ var match = text.match(/([\\$€£¥₹])\\s*([\\d,]+)/);
1818
+ if (match) {
1819
+ var currencySymbol = match[1];
1820
+ var priceNum = parseInt(match[2].replace(/,/g, ''));
1821
+ if (!isNaN(priceNum) && priceNum > 0) {
1822
+ prices.push({ price: priceNum, display: text.trim(), currency: currencySymbol });
1823
+ }
1824
+ }
1825
+ // Also try format like "241 $" or "241 USD"
1826
+ var match2 = text.match(/([\\d,]+)\\s*([\\$€£¥₹]|USD|EUR|GBP)/);
1827
+ if (match2) {
1828
+ var priceNum2 = parseInt(match2[1].replace(/,/g, ''));
1829
+ if (!isNaN(priceNum2) && priceNum2 > 0) {
1830
+ prices.push({ price: priceNum2, display: text.trim(), currency: match2[2] });
1831
+ }
1832
+ }
1833
+ });
1834
+
1835
+ // Get the lowest price
1836
+ if (prices.length > 0) {
1837
+ prices.sort(function(a, b) { return a.price - b.price; });
1838
+ result.price = prices[0].price;
1839
+ result.priceDisplay = prices[0].display;
1840
+ result.currency = prices[0].currency;
1841
+ } else {
1842
+ // Try alternative price selectors
1843
+ var altPrice = document.querySelector('[class*="bui-price-display"], [class*="price"]');
1844
+ if (altPrice) {
1845
+ var altText = altPrice.textContent?.trim() || '';
1846
+ var altMatch = altText.match(/([\\$€£¥₹])\\s*([\\d,]+)/);
1847
+ if (altMatch) {
1848
+ result.price = parseInt(altMatch[2].replace(/,/g, ''));
1849
+ result.priceDisplay = altMatch[0];
1850
+ result.currency = altMatch[1];
1851
+ }
1852
+ }
1853
+ }
1854
+
1855
+ // If still no price found, might be unavailable
1856
+ if (result.price === null) {
1857
+ result.available = false;
1858
+ }
1859
+
1860
+ return result;
1861
+ })()
1862
+ `);
1863
+ // Store hotel name from first result
1864
+ if (!hotelName && priceData.hotelName) {
1865
+ hotelName = priceData.hotelName;
1866
+ }
1867
+ // Map currency symbol to code
1868
+ const currencyMap = {
1869
+ "$": "USD",
1870
+ "€": "EUR",
1871
+ "£": "GBP",
1872
+ "¥": "JPY",
1873
+ "₹": "INR",
1874
+ };
1875
+ const currencyCode = currencyMap[priceData.currency] || priceData.currency || currency;
1876
+ prices.push({
1877
+ date: dateRange.checkIn,
1878
+ price: priceData.price,
1879
+ priceDisplay: priceData.priceDisplay || (priceData.price ? `${priceData.currency}${priceData.price}` : "N/A"),
1880
+ available: priceData.available,
1881
+ currency: currencyCode,
1882
+ });
1883
+ }
1884
+ catch (error) {
1885
+ // If page load fails, mark as unavailable
1886
+ prices.push({
1887
+ date: dateRange.checkIn,
1888
+ price: null,
1889
+ priceDisplay: "Error",
1890
+ available: false,
1891
+ currency,
1892
+ });
1893
+ }
1894
+ }
1895
+ // Calculate statistics
1896
+ const availablePrices = prices.filter(p => p.price !== null && p.available);
1897
+ const priceValues = availablePrices.map(p => p.price);
1898
+ let lowestPrice = null;
1899
+ let lowestPriceDate = null;
1900
+ let highestPrice = null;
1901
+ let highestPriceDate = null;
1902
+ let averagePrice = null;
1903
+ if (priceValues.length > 0) {
1904
+ lowestPrice = Math.min(...priceValues);
1905
+ highestPrice = Math.max(...priceValues);
1906
+ averagePrice = Math.round(priceValues.reduce((a, b) => a + b, 0) / priceValues.length);
1907
+ const lowestPriceEntry = availablePrices.find(p => p.price === lowestPrice);
1908
+ const highestPriceEntry = availablePrices.find(p => p.price === highestPrice);
1909
+ lowestPriceDate = lowestPriceEntry?.date || null;
1910
+ highestPriceDate = highestPriceEntry?.date || null;
1911
+ }
1912
+ // Calculate end date
1913
+ const endDate = new Date(start);
1914
+ endDate.setDate(endDate.getDate() + actualNights - 1);
1915
+ return {
1916
+ hotelName,
1917
+ startDate,
1918
+ endDate: endDate.toISOString().split("T")[0],
1919
+ nights: actualNights,
1920
+ currency,
1921
+ prices,
1922
+ lowestPrice,
1923
+ lowestPriceDate,
1924
+ highestPrice,
1925
+ highestPriceDate,
1926
+ averagePrice,
1927
+ url: cleanUrl,
1928
+ };
1929
+ }
1486
1930
  }
package/dist/index.js CHANGED
@@ -133,6 +133,20 @@ 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
+ });
142
+ const GetPriceCalendarSchema = z.object({
143
+ hotelUrl: z.string().describe("Booking.com hotel URL to get prices for"),
144
+ startDate: z.string().describe("Start date for price calendar (YYYY-MM-DD)"),
145
+ nights: z.number().min(1).max(30).optional().describe("Number of nights to check (default: 14, max: 30)"),
146
+ guests: z.number().min(1).max(30).optional().describe("Number of guests (default: 2)"),
147
+ rooms: z.number().min(1).max(10).optional().describe("Number of rooms (default: 1)"),
148
+ currency: z.string().optional().describe("Currency code (default: USD)"),
149
+ });
136
150
  // Global browser instance (reuse for efficiency)
137
151
  let browser = null;
138
152
  async function getBrowser() {
@@ -361,10 +375,143 @@ function formatAvailabilityResult(result) {
361
375
  lines.push(`Book at: ${result.url.split("?")[0]}`);
362
376
  return lines.join("\n");
363
377
  }
378
+ function formatReviewsResult(result) {
379
+ const lines = [];
380
+ lines.push("=".repeat(60));
381
+ lines.push(`REVIEWS: ${result.hotelName}`);
382
+ lines.push("=".repeat(60));
383
+ lines.push("");
384
+ // Overall rating
385
+ if (result.overallRating) {
386
+ lines.push(`Overall Rating: ${result.overallRating}/10 (${result.totalReviews} reviews)`);
387
+ }
388
+ else {
389
+ lines.push(`Total Reviews: ${result.totalReviews}`);
390
+ }
391
+ lines.push("");
392
+ // Rating breakdown
393
+ const breakdown = result.ratingBreakdown;
394
+ const categories = [
395
+ { label: "Staff", value: breakdown.staff },
396
+ { label: "Facilities", value: breakdown.facilities },
397
+ { label: "Cleanliness", value: breakdown.cleanliness },
398
+ { label: "Comfort", value: breakdown.comfort },
399
+ { label: "Value for Money", value: breakdown.valueForMoney },
400
+ { label: "Location", value: breakdown.location },
401
+ { label: "Free WiFi", value: breakdown.freeWifi },
402
+ ];
403
+ const validCategories = categories.filter(c => c.value !== null);
404
+ if (validCategories.length > 0) {
405
+ lines.push("--- RATING BREAKDOWN ---");
406
+ validCategories.forEach(cat => {
407
+ lines.push(`${cat.label}: ${cat.value}`);
408
+ });
409
+ lines.push("");
410
+ }
411
+ // Individual reviews
412
+ if (result.reviews.length > 0) {
413
+ lines.push(`--- REVIEWS (${result.reviews.length}) ---`);
414
+ lines.push("");
415
+ result.reviews.forEach((review, index) => {
416
+ lines.push(`[${index + 1}] ${review.title || "Review"}`);
417
+ if (review.rating !== null) {
418
+ lines.push(` Score: ${review.rating}/10`);
419
+ }
420
+ if (review.country) {
421
+ lines.push(` Reviewer: ${review.country}`);
422
+ }
423
+ if (review.travelerType) {
424
+ lines.push(` Traveler Type: ${review.travelerType}`);
425
+ }
426
+ if (review.roomType) {
427
+ lines.push(` Room: ${review.roomType}`);
428
+ }
429
+ if (review.stayDate || review.nightsStayed) {
430
+ const stayInfo = [review.stayDate, review.nightsStayed].filter(Boolean).join(" - ");
431
+ lines.push(` Stayed: ${stayInfo}`);
432
+ }
433
+ if (review.date) {
434
+ lines.push(` Reviewed: ${review.date}`);
435
+ }
436
+ if (review.positive) {
437
+ lines.push(` + ${review.positive}`);
438
+ }
439
+ if (review.negative) {
440
+ lines.push(` - ${review.negative}`);
441
+ }
442
+ lines.push("");
443
+ });
444
+ }
445
+ lines.push("=".repeat(60));
446
+ lines.push(`Hotel URL: ${result.url}`);
447
+ return lines.join("\n");
448
+ }
449
+ function formatPriceCalendarResult(result) {
450
+ const lines = [];
451
+ lines.push("=".repeat(60));
452
+ lines.push(`PRICE CALENDAR: ${result.hotelName}`);
453
+ lines.push("=".repeat(60));
454
+ lines.push("");
455
+ lines.push(`Date Range: ${result.startDate} to ${result.endDate} (${result.nights} nights)`);
456
+ lines.push(`Currency: ${result.currency}`);
457
+ lines.push("");
458
+ // Summary statistics
459
+ lines.push("--- SUMMARY ---");
460
+ if (result.lowestPrice !== null) {
461
+ lines.push(`Lowest Price: ${result.currency} ${result.lowestPrice} (${result.lowestPriceDate})`);
462
+ }
463
+ if (result.highestPrice !== null) {
464
+ lines.push(`Highest Price: ${result.currency} ${result.highestPrice} (${result.highestPriceDate})`);
465
+ }
466
+ if (result.averagePrice !== null) {
467
+ lines.push(`Average Price: ${result.currency} ${result.averagePrice}`);
468
+ }
469
+ const availableCount = result.prices.filter(p => p.available).length;
470
+ const unavailableCount = result.prices.length - availableCount;
471
+ lines.push(`Available: ${availableCount}/${result.prices.length} nights`);
472
+ if (unavailableCount > 0) {
473
+ lines.push(`Unavailable: ${unavailableCount} nights`);
474
+ }
475
+ lines.push("");
476
+ // Price calendar
477
+ lines.push("--- PRICES BY DATE ---");
478
+ // Group by week for better readability
479
+ let currentWeek = [];
480
+ let lastWeekNum = -1;
481
+ result.prices.forEach((datePrice) => {
482
+ const date = new Date(datePrice.date);
483
+ const dayName = date.toLocaleDateString('en-US', { weekday: 'short' });
484
+ const monthDay = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
485
+ let priceStr;
486
+ if (!datePrice.available) {
487
+ priceStr = "N/A";
488
+ }
489
+ else if (datePrice.price !== null) {
490
+ // Highlight lowest price
491
+ if (datePrice.price === result.lowestPrice) {
492
+ priceStr = `${datePrice.priceDisplay} ★ LOWEST`;
493
+ }
494
+ else if (datePrice.price === result.highestPrice) {
495
+ priceStr = `${datePrice.priceDisplay} (highest)`;
496
+ }
497
+ else {
498
+ priceStr = datePrice.priceDisplay;
499
+ }
500
+ }
501
+ else {
502
+ priceStr = "N/A";
503
+ }
504
+ lines.push(`${dayName} ${monthDay}: ${priceStr}`);
505
+ });
506
+ lines.push("");
507
+ lines.push("=".repeat(60));
508
+ lines.push(`Hotel URL: ${result.url}`);
509
+ return lines.join("\n");
510
+ }
364
511
  // Create MCP server
365
512
  const server = new Server({
366
513
  name: "hotelzero",
367
- version: "1.5.0",
514
+ version: "1.7.0",
368
515
  }, {
369
516
  capabilities: {
370
517
  tools: {},
@@ -567,6 +714,36 @@ Results are scored and ranked by how well they match the criteria.`,
567
714
  required: ["hotelUrl", "checkIn", "checkOut"],
568
715
  },
569
716
  },
717
+ {
718
+ name: "get_reviews",
719
+ 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.",
720
+ inputSchema: {
721
+ type: "object",
722
+ properties: {
723
+ hotelUrl: { type: "string", description: "Booking.com hotel URL to get reviews for" },
724
+ limit: { type: "number", description: "Number of reviews to fetch (default: 10, max: 50)", minimum: 1, maximum: 50 },
725
+ sortBy: { type: "string", description: "Sort reviews by", enum: ["recent", "highest", "lowest"] },
726
+ filterBy: { type: "string", description: "Filter by traveler type", enum: ["couples", "families", "solo", "business", "groups"] },
727
+ },
728
+ required: ["hotelUrl"],
729
+ },
730
+ },
731
+ {
732
+ name: "get_price_calendar",
733
+ description: "Get prices for a hotel across multiple dates to find the cheapest time to stay. Shows nightly prices, identifies the lowest/highest price dates, and calculates average pricing. Useful for flexible travelers looking for the best deal.",
734
+ inputSchema: {
735
+ type: "object",
736
+ properties: {
737
+ hotelUrl: { type: "string", description: "Booking.com hotel URL to check prices for" },
738
+ startDate: { type: "string", description: "Start date for price calendar (YYYY-MM-DD)" },
739
+ nights: { type: "number", description: "Number of nights to check (default: 14, max: 30)", minimum: 1, maximum: 30 },
740
+ guests: { type: "number", description: "Number of guests (default: 2)", minimum: 1, maximum: 30 },
741
+ rooms: { type: "number", description: "Number of rooms (default: 1)", minimum: 1, maximum: 10 },
742
+ currency: { type: "string", description: "Currency code (default: USD)" },
743
+ },
744
+ required: ["hotelUrl", "startDate"],
745
+ },
746
+ },
570
747
  ],
571
748
  };
572
749
  });
@@ -741,6 +918,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
741
918
  ],
742
919
  };
743
920
  }
921
+ case "get_reviews": {
922
+ const parsed = GetReviewsSchema.parse(args);
923
+ const result = await b.getReviews(parsed.hotelUrl, parsed.limit, parsed.sortBy, parsed.filterBy);
924
+ const formatted = formatReviewsResult(result);
925
+ return {
926
+ content: [
927
+ {
928
+ type: "text",
929
+ text: formatted,
930
+ },
931
+ ],
932
+ };
933
+ }
934
+ case "get_price_calendar": {
935
+ const parsed = GetPriceCalendarSchema.parse(args);
936
+ const result = await b.getPriceCalendar(parsed.hotelUrl, parsed.startDate, parsed.nights, parsed.guests, parsed.rooms, parsed.currency);
937
+ const formatted = formatPriceCalendarResult(result);
938
+ return {
939
+ content: [
940
+ {
941
+ type: "text",
942
+ text: formatted,
943
+ },
944
+ ],
945
+ };
946
+ }
744
947
  default:
745
948
  throw new Error(`Unknown tool: ${name}`);
746
949
  }
@@ -809,7 +1012,7 @@ process.on("SIGTERM", async () => {
809
1012
  async function main() {
810
1013
  const transport = new StdioServerTransport();
811
1014
  await server.connect(transport);
812
- console.error("HotelZero v1.5.0 running on stdio");
1015
+ console.error("HotelZero v1.7.0 running on stdio");
813
1016
  }
814
1017
  main().catch((error) => {
815
1018
  console.error("Fatal error:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotelzero",
3
- "version": "1.5.0",
3
+ "version": "1.7.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",