hotelzero 1.6.0 → 1.8.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/README.md CHANGED
@@ -25,6 +25,30 @@ Or run directly with npx:
25
25
  npx hotelzero
26
26
  ```
27
27
 
28
+ ## Proxy Support
29
+
30
+ For heavy usage or to avoid IP blocks, you can configure a proxy server via the `HOTELZERO_PROXY` environment variable:
31
+
32
+ ```bash
33
+ # HTTP proxy
34
+ HOTELZERO_PROXY=http://proxy.example.com:8080 npx hotelzero
35
+
36
+ # HTTP proxy with authentication
37
+ HOTELZERO_PROXY=http://user:pass@proxy.example.com:8080 npx hotelzero
38
+
39
+ # SOCKS5 proxy
40
+ HOTELZERO_PROXY=socks5://proxy.example.com:1080 npx hotelzero
41
+
42
+ # SOCKS5 proxy with authentication
43
+ HOTELZERO_PROXY=socks5://user:pass@proxy.example.com:1080 npx hotelzero
44
+ ```
45
+
46
+ When a proxy is configured, you'll see confirmation in the startup logs:
47
+ ```
48
+ Proxy enabled: http://proxy.example.com:8080
49
+ HotelZero v1.8.0 running on stdio
50
+ ```
51
+
28
52
  ## Quick Start
29
53
 
30
54
  ### Run as MCP Server
@@ -516,6 +540,14 @@ Run `npx playwright install chromium` to install the browser.
516
540
 
517
541
  - Wait a few minutes before retrying
518
542
  - The server uses anti-detection measures, but excessive requests may trigger blocks
543
+ - Consider using a proxy server (see [Proxy Support](#proxy-support))
544
+
545
+ ### Proxy not working
546
+
547
+ - Verify the proxy server is running and accessible
548
+ - Check credentials if using authentication
549
+ - Ensure the proxy supports HTTPS connections
550
+ - Try a different proxy or test without proxy first
519
551
 
520
552
  ---
521
553
 
package/dist/browser.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ export interface ProxyConfig {
2
+ server: string;
3
+ username?: string;
4
+ password?: string;
5
+ }
1
6
  export declare class HotelSearchError extends Error {
2
7
  code: string;
3
8
  retryable: boolean;
@@ -199,12 +204,42 @@ export interface ReviewsResult {
199
204
  reviews: Review[];
200
205
  url: string;
201
206
  }
207
+ export interface DatePrice {
208
+ date: string;
209
+ price: number | null;
210
+ priceDisplay: string;
211
+ available: boolean;
212
+ currency: string;
213
+ }
214
+ export interface PriceCalendarResult {
215
+ hotelName: string;
216
+ startDate: string;
217
+ endDate: string;
218
+ nights: number;
219
+ currency: string;
220
+ prices: DatePrice[];
221
+ lowestPrice: number | null;
222
+ lowestPriceDate: string | null;
223
+ highestPrice: number | null;
224
+ highestPriceDate: string | null;
225
+ averagePrice: number | null;
226
+ url: string;
227
+ }
202
228
  export declare class HotelBrowser {
203
229
  private browser;
204
230
  private page;
205
231
  private lastRequestTime;
206
232
  private minRequestIntervalMs;
207
- init(headless?: boolean): Promise<void>;
233
+ private proxyConfig;
234
+ init(headless?: boolean, proxy?: ProxyConfig): Promise<void>;
235
+ /**
236
+ * Check if a proxy is configured
237
+ */
238
+ hasProxy(): boolean;
239
+ /**
240
+ * Get the current proxy server (without credentials)
241
+ */
242
+ getProxyServer(): string | null;
208
243
  close(): Promise<void>;
209
244
  private buildBookingUrl;
210
245
  private enforceRateLimit;
@@ -240,4 +275,8 @@ export declare class HotelBrowser {
240
275
  * Get reviews for a specific hotel
241
276
  */
242
277
  getReviews(hotelUrl: string, limit?: number, sortBy?: "recent" | "highest" | "lowest", filterBy?: "couples" | "families" | "solo" | "business" | "groups"): Promise<ReviewsResult>;
278
+ /**
279
+ * Get price calendar for a hotel - shows prices for multiple dates
280
+ */
281
+ getPriceCalendar(hotelUrl: string, startDate: string, nights?: number, guests?: number, rooms?: number, currency?: string): Promise<PriceCalendarResult>;
243
282
  }
package/dist/browser.js CHANGED
@@ -232,17 +232,42 @@ export class HotelBrowser {
232
232
  page = null;
233
233
  lastRequestTime = 0;
234
234
  minRequestIntervalMs = 2000; // Minimum 2 seconds between requests
235
- async init(headless = true) {
236
- this.browser = await chromium.launch({
235
+ proxyConfig = null;
236
+ async init(headless = true, proxy) {
237
+ // Store proxy config for reference
238
+ this.proxyConfig = proxy || null;
239
+ // Build launch options
240
+ const launchOptions = {
237
241
  headless,
238
242
  args: ["--disable-blink-features=AutomationControlled"],
239
- });
243
+ };
244
+ // Add proxy to launch options if provided
245
+ if (proxy) {
246
+ launchOptions.proxy = {
247
+ server: proxy.server,
248
+ username: proxy.username,
249
+ password: proxy.password,
250
+ };
251
+ }
252
+ this.browser = await chromium.launch(launchOptions);
240
253
  const context = await this.browser.newContext({
241
254
  userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
242
255
  viewport: { width: 1280, height: 900 },
243
256
  });
244
257
  this.page = await context.newPage();
245
258
  }
259
+ /**
260
+ * Check if a proxy is configured
261
+ */
262
+ hasProxy() {
263
+ return this.proxyConfig !== null;
264
+ }
265
+ /**
266
+ * Get the current proxy server (without credentials)
267
+ */
268
+ getProxyServer() {
269
+ return this.proxyConfig?.server || null;
270
+ }
246
271
  async close() {
247
272
  if (this.browser) {
248
273
  await this.browser.close();
@@ -1747,4 +1772,184 @@ export class HotelBrowser {
1747
1772
  console.error(`Get reviews attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delayMs / 1000)}s...`);
1748
1773
  });
1749
1774
  }
1775
+ /**
1776
+ * Get price calendar for a hotel - shows prices for multiple dates
1777
+ */
1778
+ async getPriceCalendar(hotelUrl, startDate, nights = 14, guests = 2, rooms = 1, currency = "USD") {
1779
+ // Validate inputs
1780
+ const start = new Date(startDate);
1781
+ if (isNaN(start.getTime())) {
1782
+ throw new Error("Invalid start date format. Use YYYY-MM-DD.");
1783
+ }
1784
+ // Limit to reasonable range
1785
+ const actualNights = Math.min(Math.max(nights, 1), 30);
1786
+ // Generate date range
1787
+ const dates = [];
1788
+ for (let i = 0; i < actualNights; i++) {
1789
+ const checkIn = new Date(start);
1790
+ checkIn.setDate(checkIn.getDate() + i);
1791
+ const checkOut = new Date(checkIn);
1792
+ checkOut.setDate(checkOut.getDate() + 1);
1793
+ dates.push({
1794
+ checkIn: checkIn.toISOString().split("T")[0],
1795
+ checkOut: checkOut.toISOString().split("T")[0],
1796
+ });
1797
+ }
1798
+ // Clean the hotel URL
1799
+ const cleanUrl = hotelUrl.split("?")[0].split("#")[0];
1800
+ // Collect prices for each date
1801
+ const prices = [];
1802
+ let hotelName = "";
1803
+ for (const dateRange of dates) {
1804
+ await this.enforceRateLimit();
1805
+ if (!this.page)
1806
+ throw new Error("Browser not initialized");
1807
+ const urlWithDates = `${cleanUrl}?checkin=${dateRange.checkIn}&checkout=${dateRange.checkOut}&group_adults=${guests}&no_rooms=${rooms}&selected_currency=${currency}`;
1808
+ try {
1809
+ await this.page.goto(urlWithDates, {
1810
+ waitUntil: "domcontentloaded",
1811
+ timeout: 30000,
1812
+ });
1813
+ await this.page.waitForTimeout(2000);
1814
+ // Close any popups
1815
+ try {
1816
+ await this.page.keyboard.press("Escape");
1817
+ }
1818
+ catch { }
1819
+ // Extract price data
1820
+ const priceData = await this.page.evaluate(`
1821
+ (function() {
1822
+ var result = { hotelName: '', price: null, priceDisplay: '', available: true, currency: '' };
1823
+
1824
+ // Get hotel name (only need it once)
1825
+ var nameEl = document.querySelector('h2[class*="pp-header__title"], [data-testid="PropertyHeaderDesktop-wrapper"] h2, h2.d2fee87262');
1826
+ result.hotelName = nameEl?.textContent?.trim() || '';
1827
+
1828
+ // Check for no availability message
1829
+ var noAvail = document.querySelector('[class*="soldout"], [class*="no-availability"], [data-testid="no-rooms-available"]');
1830
+ if (noAvail) {
1831
+ result.available = false;
1832
+ return result;
1833
+ }
1834
+
1835
+ // Find the lowest price - look for price elements
1836
+ var priceElements = document.querySelectorAll('[data-testid="price-and-discounted-price"], .bui-price-display__value, .prco-valign-middle-helper');
1837
+ var prices = [];
1838
+
1839
+ priceElements.forEach(function(el) {
1840
+ var text = el.textContent?.trim() || '';
1841
+ // Extract price number and currency
1842
+ var match = text.match(/([\\$€£¥₹])\\s*([\\d,]+)/);
1843
+ if (match) {
1844
+ var currencySymbol = match[1];
1845
+ var priceNum = parseInt(match[2].replace(/,/g, ''));
1846
+ if (!isNaN(priceNum) && priceNum > 0) {
1847
+ prices.push({ price: priceNum, display: text.trim(), currency: currencySymbol });
1848
+ }
1849
+ }
1850
+ // Also try format like "241 $" or "241 USD"
1851
+ var match2 = text.match(/([\\d,]+)\\s*([\\$€£¥₹]|USD|EUR|GBP)/);
1852
+ if (match2) {
1853
+ var priceNum2 = parseInt(match2[1].replace(/,/g, ''));
1854
+ if (!isNaN(priceNum2) && priceNum2 > 0) {
1855
+ prices.push({ price: priceNum2, display: text.trim(), currency: match2[2] });
1856
+ }
1857
+ }
1858
+ });
1859
+
1860
+ // Get the lowest price
1861
+ if (prices.length > 0) {
1862
+ prices.sort(function(a, b) { return a.price - b.price; });
1863
+ result.price = prices[0].price;
1864
+ result.priceDisplay = prices[0].display;
1865
+ result.currency = prices[0].currency;
1866
+ } else {
1867
+ // Try alternative price selectors
1868
+ var altPrice = document.querySelector('[class*="bui-price-display"], [class*="price"]');
1869
+ if (altPrice) {
1870
+ var altText = altPrice.textContent?.trim() || '';
1871
+ var altMatch = altText.match(/([\\$€£¥₹])\\s*([\\d,]+)/);
1872
+ if (altMatch) {
1873
+ result.price = parseInt(altMatch[2].replace(/,/g, ''));
1874
+ result.priceDisplay = altMatch[0];
1875
+ result.currency = altMatch[1];
1876
+ }
1877
+ }
1878
+ }
1879
+
1880
+ // If still no price found, might be unavailable
1881
+ if (result.price === null) {
1882
+ result.available = false;
1883
+ }
1884
+
1885
+ return result;
1886
+ })()
1887
+ `);
1888
+ // Store hotel name from first result
1889
+ if (!hotelName && priceData.hotelName) {
1890
+ hotelName = priceData.hotelName;
1891
+ }
1892
+ // Map currency symbol to code
1893
+ const currencyMap = {
1894
+ "$": "USD",
1895
+ "€": "EUR",
1896
+ "£": "GBP",
1897
+ "¥": "JPY",
1898
+ "₹": "INR",
1899
+ };
1900
+ const currencyCode = currencyMap[priceData.currency] || priceData.currency || currency;
1901
+ prices.push({
1902
+ date: dateRange.checkIn,
1903
+ price: priceData.price,
1904
+ priceDisplay: priceData.priceDisplay || (priceData.price ? `${priceData.currency}${priceData.price}` : "N/A"),
1905
+ available: priceData.available,
1906
+ currency: currencyCode,
1907
+ });
1908
+ }
1909
+ catch (error) {
1910
+ // If page load fails, mark as unavailable
1911
+ prices.push({
1912
+ date: dateRange.checkIn,
1913
+ price: null,
1914
+ priceDisplay: "Error",
1915
+ available: false,
1916
+ currency,
1917
+ });
1918
+ }
1919
+ }
1920
+ // Calculate statistics
1921
+ const availablePrices = prices.filter(p => p.price !== null && p.available);
1922
+ const priceValues = availablePrices.map(p => p.price);
1923
+ let lowestPrice = null;
1924
+ let lowestPriceDate = null;
1925
+ let highestPrice = null;
1926
+ let highestPriceDate = null;
1927
+ let averagePrice = null;
1928
+ if (priceValues.length > 0) {
1929
+ lowestPrice = Math.min(...priceValues);
1930
+ highestPrice = Math.max(...priceValues);
1931
+ averagePrice = Math.round(priceValues.reduce((a, b) => a + b, 0) / priceValues.length);
1932
+ const lowestPriceEntry = availablePrices.find(p => p.price === lowestPrice);
1933
+ const highestPriceEntry = availablePrices.find(p => p.price === highestPrice);
1934
+ lowestPriceDate = lowestPriceEntry?.date || null;
1935
+ highestPriceDate = highestPriceEntry?.date || null;
1936
+ }
1937
+ // Calculate end date
1938
+ const endDate = new Date(start);
1939
+ endDate.setDate(endDate.getDate() + actualNights - 1);
1940
+ return {
1941
+ hotelName,
1942
+ startDate,
1943
+ endDate: endDate.toISOString().split("T")[0],
1944
+ nights: actualNights,
1945
+ currency,
1946
+ prices,
1947
+ lowestPrice,
1948
+ lowestPriceDate,
1949
+ highestPrice,
1950
+ highestPriceDate,
1951
+ averagePrice,
1952
+ url: cleanUrl,
1953
+ };
1954
+ }
1750
1955
  }
package/dist/index.js CHANGED
@@ -139,12 +139,55 @@ const GetReviewsSchema = z.object({
139
139
  sortBy: z.enum(["recent", "highest", "lowest"]).optional().describe("Sort reviews by: recent (default), highest score, or lowest score"),
140
140
  filterBy: z.enum(["couples", "families", "solo", "business", "groups"]).optional().describe("Filter reviews by traveler type"),
141
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
+ });
142
150
  // Global browser instance (reuse for efficiency)
143
151
  let browser = null;
152
+ /**
153
+ * Parse proxy configuration from environment variable
154
+ * Supports formats:
155
+ * - http://proxy.example.com:8080
156
+ * - http://user:pass@proxy.example.com:8080
157
+ * - socks5://proxy.example.com:1080
158
+ * - socks5://user:pass@proxy.example.com:1080
159
+ */
160
+ function parseProxyFromEnv() {
161
+ const proxyUrl = process.env.HOTELZERO_PROXY;
162
+ if (!proxyUrl)
163
+ return undefined;
164
+ try {
165
+ const url = new URL(proxyUrl);
166
+ const config = {
167
+ server: `${url.protocol}//${url.host}`,
168
+ };
169
+ if (url.username) {
170
+ config.username = decodeURIComponent(url.username);
171
+ }
172
+ if (url.password) {
173
+ config.password = decodeURIComponent(url.password);
174
+ }
175
+ return config;
176
+ }
177
+ catch (error) {
178
+ console.error(`Invalid HOTELZERO_PROXY format: ${proxyUrl}`);
179
+ return undefined;
180
+ }
181
+ }
144
182
  async function getBrowser() {
145
183
  if (!browser) {
146
184
  browser = new HotelBrowser();
147
- await browser.init(true);
185
+ const proxyConfig = parseProxyFromEnv();
186
+ await browser.init(true, proxyConfig);
187
+ // Log proxy status (without credentials)
188
+ if (browser.hasProxy()) {
189
+ console.error(`Proxy enabled: ${browser.getProxyServer()}`);
190
+ }
148
191
  }
149
192
  return browser;
150
193
  }
@@ -438,10 +481,72 @@ function formatReviewsResult(result) {
438
481
  lines.push(`Hotel URL: ${result.url}`);
439
482
  return lines.join("\n");
440
483
  }
484
+ function formatPriceCalendarResult(result) {
485
+ const lines = [];
486
+ lines.push("=".repeat(60));
487
+ lines.push(`PRICE CALENDAR: ${result.hotelName}`);
488
+ lines.push("=".repeat(60));
489
+ lines.push("");
490
+ lines.push(`Date Range: ${result.startDate} to ${result.endDate} (${result.nights} nights)`);
491
+ lines.push(`Currency: ${result.currency}`);
492
+ lines.push("");
493
+ // Summary statistics
494
+ lines.push("--- SUMMARY ---");
495
+ if (result.lowestPrice !== null) {
496
+ lines.push(`Lowest Price: ${result.currency} ${result.lowestPrice} (${result.lowestPriceDate})`);
497
+ }
498
+ if (result.highestPrice !== null) {
499
+ lines.push(`Highest Price: ${result.currency} ${result.highestPrice} (${result.highestPriceDate})`);
500
+ }
501
+ if (result.averagePrice !== null) {
502
+ lines.push(`Average Price: ${result.currency} ${result.averagePrice}`);
503
+ }
504
+ const availableCount = result.prices.filter(p => p.available).length;
505
+ const unavailableCount = result.prices.length - availableCount;
506
+ lines.push(`Available: ${availableCount}/${result.prices.length} nights`);
507
+ if (unavailableCount > 0) {
508
+ lines.push(`Unavailable: ${unavailableCount} nights`);
509
+ }
510
+ lines.push("");
511
+ // Price calendar
512
+ lines.push("--- PRICES BY DATE ---");
513
+ // Group by week for better readability
514
+ let currentWeek = [];
515
+ let lastWeekNum = -1;
516
+ result.prices.forEach((datePrice) => {
517
+ const date = new Date(datePrice.date);
518
+ const dayName = date.toLocaleDateString('en-US', { weekday: 'short' });
519
+ const monthDay = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
520
+ let priceStr;
521
+ if (!datePrice.available) {
522
+ priceStr = "N/A";
523
+ }
524
+ else if (datePrice.price !== null) {
525
+ // Highlight lowest price
526
+ if (datePrice.price === result.lowestPrice) {
527
+ priceStr = `${datePrice.priceDisplay} ★ LOWEST`;
528
+ }
529
+ else if (datePrice.price === result.highestPrice) {
530
+ priceStr = `${datePrice.priceDisplay} (highest)`;
531
+ }
532
+ else {
533
+ priceStr = datePrice.priceDisplay;
534
+ }
535
+ }
536
+ else {
537
+ priceStr = "N/A";
538
+ }
539
+ lines.push(`${dayName} ${monthDay}: ${priceStr}`);
540
+ });
541
+ lines.push("");
542
+ lines.push("=".repeat(60));
543
+ lines.push(`Hotel URL: ${result.url}`);
544
+ return lines.join("\n");
545
+ }
441
546
  // Create MCP server
442
547
  const server = new Server({
443
548
  name: "hotelzero",
444
- version: "1.6.0",
549
+ version: "1.8.0",
445
550
  }, {
446
551
  capabilities: {
447
552
  tools: {},
@@ -658,6 +763,22 @@ Results are scored and ranked by how well they match the criteria.`,
658
763
  required: ["hotelUrl"],
659
764
  },
660
765
  },
766
+ {
767
+ name: "get_price_calendar",
768
+ 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.",
769
+ inputSchema: {
770
+ type: "object",
771
+ properties: {
772
+ hotelUrl: { type: "string", description: "Booking.com hotel URL to check prices for" },
773
+ startDate: { type: "string", description: "Start date for price calendar (YYYY-MM-DD)" },
774
+ nights: { type: "number", description: "Number of nights to check (default: 14, max: 30)", minimum: 1, maximum: 30 },
775
+ guests: { type: "number", description: "Number of guests (default: 2)", minimum: 1, maximum: 30 },
776
+ rooms: { type: "number", description: "Number of rooms (default: 1)", minimum: 1, maximum: 10 },
777
+ currency: { type: "string", description: "Currency code (default: USD)" },
778
+ },
779
+ required: ["hotelUrl", "startDate"],
780
+ },
781
+ },
661
782
  ],
662
783
  };
663
784
  });
@@ -845,6 +966,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
845
966
  ],
846
967
  };
847
968
  }
969
+ case "get_price_calendar": {
970
+ const parsed = GetPriceCalendarSchema.parse(args);
971
+ const result = await b.getPriceCalendar(parsed.hotelUrl, parsed.startDate, parsed.nights, parsed.guests, parsed.rooms, parsed.currency);
972
+ const formatted = formatPriceCalendarResult(result);
973
+ return {
974
+ content: [
975
+ {
976
+ type: "text",
977
+ text: formatted,
978
+ },
979
+ ],
980
+ };
981
+ }
848
982
  default:
849
983
  throw new Error(`Unknown tool: ${name}`);
850
984
  }
@@ -913,7 +1047,7 @@ process.on("SIGTERM", async () => {
913
1047
  async function main() {
914
1048
  const transport = new StdioServerTransport();
915
1049
  await server.connect(transport);
916
- console.error("HotelZero v1.6.0 running on stdio");
1050
+ console.error("HotelZero v1.8.0 running on stdio");
917
1051
  }
918
1052
  main().catch((error) => {
919
1053
  console.error("Fatal error:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotelzero",
3
- "version": "1.6.0",
3
+ "version": "1.8.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",