""" Booking.com Hotel Page Scraper Backend Scrapes individual hotel property pages instead of search results. Returns all room types and rate plan variants per hotel per date. Advantages over search-results approach: - Individual hotel pages are not Cloudflare-protected (search results are) - Captures every room type, rate plan, meal plan, and availability count - Proxy reuse: one residential IP stays valid across many hotel pages (looks like a human browsing properties), rotates only when blocked Rate plan variants per room type (typical): 2-adult + room only + non-refundable 2-adult + room only + free cancellation 2-adult + breakfast + non-refundable 2-adult + breakfast + free cancellation 1-adult + room only / breakfast (filtered out by caller if not needed) """ import asyncio import logging import random import re import uuid from datetime import date from decimal import Decimal from typing import List, Optional, Tuple from urllib.parse import urlparse from playwright.async_api import async_playwright, Browser, BrowserContext, Page from .base import ScraperBackend, ScraperResult, HotelData, RateData, AvailabilityStatus from services import proxy as proxy_util logger = logging.getLogger(__name__) _USER_AGENTS = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0', ] _VIEWPORTS = [ {'width': 1920, 'height': 1080}, {'width': 1366, 'height': 768}, {'width': 1440, 'height': 900}, {'width': 1536, 'height': 864}, {'width': 1280, 'height': 800}, {'width': 1600, 'height': 900}, ] # Injected into every new context to mask headless/webdriver signals. _STEALTH_SCRIPT = """ Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]}); Object.defineProperty(navigator, 'languages', {get: () => ['en-GB', 'en']}); window.chrome = {runtime: {}}; """ # JS that extracts all rate plan rows from the room availability table. # Runs inside the page after the room table has loaded. # # Iterates ALL tbody rows (matching the original scrapy spider approach) rather than # walking siblings from room-type anchors. This handles hotels where the first rate # row of a room type lacks js-rt-block-row, or where the room header uses a
# instead of for the room_type_id_ element — both caused under-counting. _EXTRACT_RATES_JS = """ () => { const results = []; const roomTypeNames = {}; const rows = document.querySelectorAll( '#available_rooms tbody tr:not([data-is-room-upgrade])' ); for (const tr of rows) { const blockId = tr.getAttribute('data-block-id') || ''; if (!blockId) continue; const blockParts = blockId.split('_'); if (blockParts.length < 2) continue; const roomId = blockParts[0]; // Room name: the first row for each room type carries the room_type_id_ element. // Subsequent rows for the same room type don't — reuse stored name (same as scrapy). const roomNameEl = tr.querySelector('[id^="room_type_id_"]'); if (roomNameEl) { const name = ( roomNameEl.querySelector('.hprt-roomtype-icon-link')?.innerText || roomNameEl.querySelector('span')?.innerText || roomNameEl.innerText || '' ).trim(); if (name) roomTypeNames[roomId] = name; } const roomName = roomTypeNames[roomId] || ''; const priceRaw = tr.getAttribute('data-hotel-rounded-price') || ''; let fltrs = {}; try { fltrs = JSON.parse(tr.getAttribute('data-fltrs') || '{}'); } catch(e) {} // Conditions cell (3rd ) holds meal plan + cancel info const cells = tr.querySelectorAll('td'); const condCell = cells.length >= 3 ? cells[2].innerText || '' : ''; const breakfastIncluded = condCell.toLowerCase().includes('breakfast'); const nonRefundable = (fltrs.non_refundable === 1); const cancelMatch = condCell.match(/free cancellation before ([\\w\\s]+)/i); const freeCancelText = cancelMatch ? cancelMatch[1].trim() : null; // Max persons: read from visible span text (same as original scrapy spider). // block_id segment 2 is NOT reliable — some hotels use 0 there regardless of occupancy. let maxPersons = null; for (const span of tr.querySelectorAll('span')) { const t = span.innerText || ''; const m = t.match(/Max persons?:?\\s*(\\d+)/i); if (m) { maxPersons = parseInt(m[1]); break; } } // Also try occupancy icon count (aria-label or title with "X adults") if (maxPersons === null) { const occMatch = tr.innerHTML.match(/title="(\\d+) adults?"/i) || tr.innerHTML.match(/aria-label="(\\d+) adults?"/i); if (occMatch) maxPersons = parseInt(occMatch[1]); } // Scarcity text ("We have 2 left") as fallback for availability const availText = tr.querySelector('.only_x_left, .thisRoomAvailabilityNew span') ?.innerText?.trim() || ''; const availMatch = availText.match(/\\d+/); let qtyAvailable = availMatch ? parseInt(availMatch[0]) : null; // Quantity dropdown in last : options 0..N where N = available qty (capped at 10) const qtyCell = cells.length >= 2 ? cells[cells.length - 1] : null; if (qtyCell) { const sel = qtyCell.querySelector('select'); if (sel) { const vals = Array.from(sel.options) .map(o => parseInt(o.value)) .filter(v => !isNaN(v) && v > 0); if (vals.length > 0) qtyAvailable = Math.max(...vals); } else { const nums = (qtyCell.innerText || '').match(/^(\\d+)/gm); if (nums && nums.length > 0) { const parsed = nums.map(n => parseInt(n)).filter(v => v > 0); if (parsed.length > 0) qtyAvailable = Math.max(...parsed); } } } results.push({ room_id: roomId, room_name: roomName, avail_count: qtyAvailable, block_id: blockId, price: priceRaw ? parseInt(priceRaw) : null, breakfast_included: breakfastIncluded, non_refundable: nonRefundable, free_cancel_text: freeCancelText, max_persons: maxPersons, }); } return results; } """ def _slug_from_url(url: str) -> Optional[str]: """Extract hotel slug from Booking.com hotel page URL.""" if not url: return None m = re.search(r'/hotel/\w+/([^.]+)\.', url) return m.group(1) if m else None def _build_hotel_url(hotel_url: str, checkin: date, checkout: date, adults: int) -> str: base = hotel_url.split('?')[0] return ( f"{base}?checkin={checkin}&checkout={checkout}" f"&group_adults={adults}&no_rooms=1&selected_currency=GBP" ) class PlaywrightHotelPageBackend(ScraperBackend): """ Scrapes individual Booking.com hotel property pages. Keeps one browser context alive across multiple hotel+date requests. Rotates proxy only when a CF/WAF block is detected. """ def __init__(self, proxy_config: dict = None): self._proxy_config = proxy_config or {} self._pw = None self._browser: Optional[Browser] = None self._context: Optional[BrowserContext] = None self._requests_on_context = 0 # Rotate after this many requests even without a block (keeps session fresh) self._max_requests_per_context = 40 def _proxy_enabled(self) -> bool: return proxy_util.is_enabled(self._proxy_config) def _proxy_kwargs(self, session_id: Optional[str] = None) -> dict: # Credentials embedded in URL — separate username/password fields cause # a 407 round-trip that takes ~14s on DataImpulse (see proxy.py comment). proxy = proxy_util.playwright_proxy(self._proxy_config, session_id) return {'proxy': proxy} if proxy else {} async def _start(self): if not self._pw: self._pw = await async_playwright().start() if not self._browser: self._browser = await self._pw.chromium.launch( headless=True, args=[ '--no-sandbox', '--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled', '--disable-infobars', '--disable-extensions', '--blink-settings=imagesEnabled=false', # Some DataImpulse residential IPs drop Chromium's TLS 1.3 # GREASE/key_share extensions mid-handshake. Force TLS 1.2 # to use a simpler handshake those IPs can handle. '--ssl-version-max=tls1.2', ], ) async def _get_context(self) -> BrowserContext: await self._start() if self._context is None or self._requests_on_context >= self._max_requests_per_context: if self._context: try: await self._context.close() except Exception: pass session_id = uuid.uuid4().hex[:12] if self._proxy_enabled() else None ua = random.choice(_USER_AGENTS) viewport = random.choice(_VIEWPORTS) self._context = await self._browser.new_context( user_agent=ua, viewport=viewport, locale='en-GB', timezone_id='Europe/London', **self._proxy_kwargs(session_id), ) await self._context.add_init_script(_STEALTH_SCRIPT) self._requests_on_context = 0 logger.debug( f"Opened new browser context ua=…{ua[-30:]} viewport={viewport['width']}x{viewport['height']}" + (f" proxy_session={session_id}" if session_id else "") ) return self._context async def _rotate_context(self): """Force a new browser context (new proxy session).""" if self._context: try: await self._context.close() except Exception: pass self._context = None self._requests_on_context = 0 logger.info("Rotated browser context (new proxy session)") async def scrape_hotel_page( self, hotel_url: str, check_in: date, check_out: date, adults: int = 2, ) -> ScraperResult: """ Load a hotel page and extract all room types + rate plan variants. On block detection, rotates proxy and returns blocked=True so the caller can retry with the fresh context. """ page_url = _build_hotel_url(hotel_url, check_in, check_out, adults) context = await self._get_context() self._requests_on_context += 1 page: Optional[Page] = None try: page = await context.new_page() # Load the page page_loaded = False try: await page.goto(page_url, wait_until='domcontentloaded', timeout=30000) page_loaded = True except Exception as e: logger.warning(f"goto timeout for {hotel_url} {check_in}: {e}") if not page_loaded: # Treat as a retryable block — DataImpulse endpoints are intermittently # broken (EOF after CONNECT); rotating to a fresh context hits a different # endpoint and usually succeeds within 1-2 retries. await self._rotate_context() return ScraperResult(success=False, blocked=True, block_reason='page load timeout') # Wait for the room table (prices render via JS after DOM) room_table_appeared = False try: await page.wait_for_selector('[id^="room_type_id_"]', timeout=15000) room_table_appeared = True except Exception: pass if not room_table_appeared: # Could be sold-out, no-availability, or a block page content = await page.content() is_blocked, reason = self.detect_blocking(content) if is_blocked or len(content) < 20000: logger.warning(f"Block detected for {hotel_url} {check_in}: {reason or 'small page'}") await self._rotate_context() return ScraperResult(success=False, blocked=True, block_reason=reason or 'small page') # No availability for this date return ScraperResult( success=True, rates=[RateData( rate_date=check_in, availability_status=AvailabilityStatus.SOLD_OUT, currency='GBP', )], ) # Extra wait for JS prices to render await asyncio.sleep(10) # Check for explicit "no availability" message no_avail_el = await page.query_selector('#no_availability_msg') if no_avail_el and await no_avail_el.is_visible(): return ScraperResult( success=True, rates=[RateData( rate_date=check_in, availability_status=AvailabilityStatus.SOLD_OUT, currency='GBP', )], ) # Extract all rate plan rows raw_plans = await page.evaluate(_EXTRACT_RATES_JS) if not raw_plans: return ScraperResult(success=True, rates=[]) rates: List[RateData] = [] for plan in raw_plans: if plan['price'] is None: continue rates.append(RateData( rate_date=check_in, availability_status=AvailabilityStatus.AVAILABLE, rate_gross=Decimal(plan['price']), currency='GBP', room_type=plan['room_name'] or None, breakfast_included=plan['breakfast_included'], free_cancellation=not plan['non_refundable'], rooms_left=plan['avail_count'], # qty from dropdown (max 10) or scarcity text available_qty=plan['avail_count'], rate_plan_id=plan['block_id'] or None, max_persons=plan['max_persons'], )) logger.info( f"Hotel page {_slug_from_url(hotel_url)} {check_in}: " f"{len(raw_plans)} rate plans extracted" ) return ScraperResult(success=True, rates=rates) except Exception as e: logger.error(f"scrape_hotel_page error for {hotel_url} {check_in}: {e}") return ScraperResult(success=False, error_message=str(e)) finally: if page: try: await page.close() except Exception: pass async def scrape_location_search(self, *args, **kwargs) -> ScraperResult: """Not used by this backend — hotel pages replace location search.""" raise NotImplementedError("PlaywrightHotelPageBackend does not support location search") async def close(self): if self._context: try: await self._context.close() except Exception: pass self._context = None if self._browser: try: await self._browser.close() except Exception: pass self._browser = None if self._pw: try: await self._pw.stop() except Exception: pass self._pw = None