"""
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: extract from block_id format {room_id}_{rate_plan_id}_{persons}_{meal}_0
// More reliable than cell text parsing which can pick up the price instead.
const blockParts = blockId.split('_');
const maxPersons = blockParts.length >= 3 ? parseInt(blockParts[2]) : null;
// Quantity dropdown: last