""" 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 re 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 logger = logging.getLogger(__name__) # JS that extracts all rate plan rows from the room availability table. # Runs inside the page after the room table has loaded. _EXTRACT_RATES_JS = """ () => { const results = []; document.querySelectorAll('[id^="room_type_id_"]').forEach(roomEl => { const roomId = roomEl.getAttribute('data-room-id') || roomEl.id.replace('room_type_id_', ''); const roomName = ( roomEl.querySelector('.hprt-roomtype-icon-link')?.innerText || roomEl.querySelector('span')?.innerText || '' ).trim(); // Availability count from scarcity indicator ("We have 2 left") const availText = roomEl.closest('tr') ?.querySelector('.only_x_left, .thisRoomAvailabilityNew span') ?.innerText?.trim() || ''; const availMatch = availText.match(/\\d+/); const availCount = availMatch ? parseInt(availMatch[0]) : null; // Walk sibling rows that belong to this room type let tr = roomEl.closest('tr'); while (tr) { if (tr.classList.contains('js-rt-block-row')) { const blockId = tr.getAttribute('data-block-id') || ''; 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); // Free cancellation date: "Free cancellation before DD Month YYYY" const cancelMatch = condCell.match(/free cancellation before ([\\w\\s]+)/i); const freeCancelText = cancelMatch ? cancelMatch[1].trim() : null; // Persons: first ("Max persons: 2" or "Only for 1 guest") const personsCell = cells.length >= 1 ? cells[0].innerText || '' : ''; const personsMatch = personsCell.match(/\\d+/); const maxPersons = personsMatch ? parseInt(personsMatch[0]) : null; // Quantity dropdown: last has a