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 +58 -0
- package/dist/browser.js +444 -0
- package/dist/index.js +205 -2
- package/package.json +1 -1
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.
|
|
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.
|
|
1015
|
+
console.error("HotelZero v1.7.0 running on stdio");
|
|
813
1016
|
}
|
|
814
1017
|
main().catch((error) => {
|
|
815
1018
|
console.error("Fatal error:", error);
|