diff --git a/backend/api/competitors.py b/backend/api/competitors.py index 075c543..fd490ab 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -675,7 +675,8 @@ async def get_competitor_matrix( WHERE {tier_filter} AND h.is_active = TRUE AND r.rate_date >= :from_date AND r.rate_date <= :to_date - ORDER BY r.hotel_id, r.rate_date, r.scraped_at DESC + AND (r.max_persons IS NULL OR r.max_persons = 2) + ORDER BY r.hotel_id, r.rate_date, r.scraped_at DESC, r.rate_gross ASC NULLS LAST """), {'from_date': start, 'to_date': end} ) @@ -714,7 +715,8 @@ async def get_competitor_matrix( AND r.rate_date >= :from_date AND r.rate_date <= :to_date AND r.availability_status = 'available' AND r.rate_gross IS NOT NULL - ORDER BY r.hotel_id, r.rate_date, r.scraped_at DESC + AND (r.max_persons IS NULL OR r.max_persons = 2) + ORDER BY r.hotel_id, r.rate_date, r.scraped_at DESC, r.rate_gross ASC NULLS LAST """), {'from_date': start, 'to_date': end} ) diff --git a/backend/schema.sql b/backend/schema.sql index b85c902..f23570e 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -17,7 +17,7 @@ INSERT INTO system_config (config_key, config_value, description) VALUES ('booking_scraper_enabled', 'false', 'Enable automatic booking.com rate scraping (true/false)'), ('booking_scraper_paused', 'false', 'Scraper temporarily paused due to blocking (true/false)'), ('booking_scraper_pause_until', NULL, 'ISO datetime when pause expires'), -('booking_scraper_backend', 'playwright_local', 'Scraper backend: playwright_local | playwright_proxy | apify'), +('booking_scraper_backend', 'playwright_local', 'Scraper backend: playwright_local | playwright_hotel_page | apify'), ('booking_scraper_daily_time', '05:30', 'Daily scrape time (HH:MM)'), ('booking_scraper_proxy_url', NULL, 'Proxy URL for playwright_proxy backend'), ('booking_scraper_proxy_username', NULL, 'Proxy username for playwright_proxy backend'), @@ -128,6 +128,10 @@ CREATE TABLE IF NOT EXISTS booking_com_rates ( ALTER TABLE booking_com_rates ALTER COLUMN room_type TYPE TEXT; ALTER TABLE booking_com_hotels ALTER COLUMN booking_com_id TYPE VARCHAR(255); +-- Hotel page scraper: rate plan identifier and occupancy per row +ALTER TABLE booking_com_rates ADD COLUMN IF NOT EXISTS rate_plan_id TEXT; +ALTER TABLE booking_com_rates ADD COLUMN IF NOT EXISTS max_persons INTEGER; + CREATE INDEX IF NOT EXISTS idx_booking_com_rates_hotel_date ON booking_com_rates(hotel_id, rate_date); CREATE INDEX IF NOT EXISTS idx_booking_com_rates_date ON booking_com_rates(rate_date); CREATE INDEX IF NOT EXISTS idx_booking_com_rates_scraped ON booking_com_rates(scraped_at DESC); diff --git a/backend/services/booking_scraper.py b/backend/services/booking_scraper.py index 3a070a0..fa8b0c1 100644 --- a/backend/services/booking_scraper.py +++ b/backend/services/booking_scraper.py @@ -25,7 +25,10 @@ from sqlalchemy.orm import Session from database import SyncSessionLocal from services import proxy as proxy_util -from .scraper_backends import ScraperBackend, PlaywrightLocalBackend, HotelData, RateData, AvailabilityStatus +from .scraper_backends import ( + ScraperBackend, PlaywrightLocalBackend, PlaywrightHotelPageBackend, + HotelData, RateData, AvailabilityStatus, +) logger = logging.getLogger(__name__) @@ -94,12 +97,17 @@ def get_scraper_backend(db: Session) -> ScraperBackend: # Future: Apify backend raise NotImplementedError("Apify backend not yet implemented") + proxy_cfg = proxy_util.load_config(db) + + if backend_type == 'playwright_hotel_page': + return PlaywrightHotelPageBackend(proxy_config=proxy_cfg) + if backend_type not in ('playwright_local', 'playwright_proxy'): logger.warning(f"Unknown backend type '{backend_type}', falling back to playwright_local") # Proxy config resolved by the shared module: system_config is authoritative # when booking_proxy_enabled is set, else BOOKING_PROXY_* env ({} = direct). - return PlaywrightLocalBackend(proxy_config=proxy_util.load_config(db)) + return PlaywrightLocalBackend(proxy_config=proxy_cfg) def get_scrape_config(db: Session) -> Optional[Dict[str, Any]]: @@ -189,9 +197,11 @@ def save_rate(db: Session, rate: RateData, hotel_id: int, batch_id: uuid.UUID): text(""" INSERT INTO booking_com_rates (hotel_id, rate_date, availability_status, rate_gross, currency, room_type, - breakfast_included, free_cancellation, no_prepayment, rooms_left, scrape_batch_id) + breakfast_included, free_cancellation, no_prepayment, rooms_left, + rate_plan_id, max_persons, scrape_batch_id) VALUES (:hotel_id, :rate_date, :status, :rate, :currency, :room_type, - :breakfast, :cancel, :prepay, :rooms_left, :batch_id) + :breakfast, :cancel, :prepay, :rooms_left, + :rate_plan_id, :max_persons, :batch_id) """), { 'hotel_id': hotel_id, @@ -204,6 +214,8 @@ def save_rate(db: Session, rate: RateData, hotel_id: int, batch_id: uuid.UUID): 'cancel': rate.free_cancellation, 'prepay': rate.no_prepayment, 'rooms_left': rate.rooms_left, + 'rate_plan_id': rate.rate_plan_id, + 'max_persons': rate.max_persons, 'batch_id': str(batch_id), } ) @@ -282,6 +294,69 @@ def cleanup_stale_batches(db: Session, max_age_minutes: int = 60): return len(cleaned) +def get_active_hotels(db: Session) -> List[Dict[str, Any]]: + """Return all active hotels that have a booking_com_url (needed for hotel-page scraping).""" + rows = db.execute( + text(""" + SELECT id, booking_com_id, name, booking_com_url + FROM booking_com_hotels + WHERE is_active = TRUE AND booking_com_url IS NOT NULL + ORDER BY display_order, id + """) + ).fetchall() + return [dict(r._mapping) for r in rows] + + +async def scrape_hotel_date( + db: Session, + hotel: Dict[str, Any], + rate_date: date, + backend: 'PlaywrightHotelPageBackend', + batch_id: uuid.UUID, + adults: int = 2, + max_retries: int = 2, +) -> Dict[str, Any]: + """ + Scrape all rate plans for one hotel on one date via the hotel page backend. + + On block detection, the backend rotates its proxy context automatically. + We retry up to max_retries times to handle the rotation. + """ + check_out = rate_date + timedelta(days=1) + hotel_url = hotel['booking_com_url'] + + for attempt in range(max_retries + 1): + result = await backend.scrape_hotel_page(hotel_url, rate_date, check_out, adults) + + if result.success: + rates_saved = 0 + for rate in result.rates: + rate.rate_date = rate_date # ensure date is set + try: + save_rate(db, rate, hotel['id'], batch_id) + rates_saved += 1 + except Exception as e: + logger.warning(f"Error saving rate plan for {hotel['name']} {rate_date}: {e}") + db.rollback() + db.commit() + return {'success': True, 'blocked': False, 'rates_count': rates_saved} + + if result.blocked and attempt < max_retries: + logger.info( + f"Hotel {hotel['name']} {rate_date} blocked (attempt {attempt + 1}), retrying…" + ) + continue + + return { + 'success': False, + 'blocked': result.blocked, + 'error': result.error_message, + 'rates_count': 0, + } + + return {'success': False, 'blocked': True, 'rates_count': 0} + + async def scrape_date( db: Session, rate_date: date, @@ -438,6 +513,13 @@ def get_scraper_concurrency(db: Session) -> int: return 6 +def _is_hotel_page_mode(db: Session) -> bool: + row = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_backend'") + ).fetchone() + return (row and row.config_value) == 'playwright_hotel_page' + + def _effective_concurrency(db: Session, n_jobs: int) -> int: """Clamp configured concurrency to the workload, and force serial when the proxy is off — N workers would share one IP and hammer it, worse than 1.""" @@ -451,6 +533,82 @@ def _effective_concurrency(db: Session, n_jobs: int) -> int: return max(1, min(configured, n_jobs)) +async def _scrape_hotels_concurrent( + hotel_date_jobs: List[Tuple[Dict[str, Any], date]], + concurrency: int, + batch_id: uuid.UUID, + adults: int = 2, +) -> Dict[str, int]: + """ + Scrape (hotel, date) pairs using the hotel-page backend. + + Jobs are sharded by HOTEL (not date) so each worker keeps its proxy session + alive across all dates for one hotel — looks like a single user checking + availability for a trip. + """ + # Group by hotel to get stable shards + hotels_seen: List[Dict[str, Any]] = [] + hotel_dates: Dict[int, List[date]] = {} + for hotel, rate_date in hotel_date_jobs: + hid = hotel['id'] + if hid not in hotel_dates: + hotel_dates[hid] = [] + hotels_seen.append(hotel) + hotel_dates[hid].append(rate_date) + + # Shard hotels across workers + shards: List[List[Dict[str, Any]]] = [hotels_seen[i::concurrency] for i in range(concurrency)] + shards = [s for s in shards if s] + + async def worker(hotel_shard: List[Dict[str, Any]], widx: int) -> Dict[str, int]: + acc = {'rates': 0, 'completed': 0, 'failed': 0, 'blocked': 0} + wdb = SyncSessionLocal() + backend = PlaywrightHotelPageBackend(proxy_config=proxy_util.load_config(wdb)) + try: + for hotel in hotel_shard: + for rate_date in hotel_dates[hotel['id']]: + try: + result = await scrape_hotel_date(wdb, hotel, rate_date, backend, batch_id, adults) + except Exception as e: + logger.error(f"[worker {widx}] {hotel['name']} {rate_date} crashed: {e}") + try: + wdb.rollback() + except Exception: + pass + acc['failed'] += 1 + continue + + if result.get('blocked'): + acc['blocked'] += 1 + acc['failed'] += 1 + elif result['success']: + acc['rates'] += result['rates_count'] + acc['completed'] += 1 + else: + acc['failed'] += 1 + finally: + try: + await backend.close() + except Exception: + pass + wdb.close() + return acc + + results = await asyncio.gather( + *(worker(shard, i) for i, shard in enumerate(shards)), + return_exceptions=True, + ) + + agg = {'rates': 0, 'completed': 0, 'failed': 0, 'blocked': 0} + for r in results: + if isinstance(r, Exception): + logger.error(f"Hotel-page scrape worker crashed: {r}") + continue + for k in agg: + agg[k] += r[k] + return agg + + def _safe_mark_queue(db: Session, queue_id: Optional[int], status: str, error: str = None): """mark_queue_item that never raises — a marking failure shouldn't kill a worker.""" if queue_id is None: @@ -565,12 +723,105 @@ async def run_manual_scrape( _release_scrape_lock() +async def _run_hotel_page_scrape( + db: Session, + from_date: date, + to_date: date, + scrape_type: str, +) -> Dict[str, Any]: + """ + Hotel-page scrape mode: load each known hotel's property page per date. + + Unlike search-results mode, this scrapes known hotels (from booking_com_hotels + WHERE is_active AND booking_com_url IS NOT NULL) rather than discovering them + from search results. Workers are sharded by hotel so each worker's proxy session + covers all dates for one hotel before moving to the next. + """ + hotels = get_active_hotels(db) + if not hotels: + return { + 'success': False, + 'error': 'No active hotels with booking_com_url in database. ' + 'Run a search-results scrape first to populate hotels.', + } + + dates: List[date] = [] + current_date = from_date + while current_date <= to_date: + dates.append(current_date) + current_date += timedelta(days=1) + + # Build (hotel, date) job pairs + hotel_date_jobs: List[Tuple[Dict[str, Any], date]] = [ + (hotel, d) for hotel in hotels for d in dates + ] + + batch_id = create_scrape_batch(db, scrape_type) + db.execute( + text("UPDATE booking_scrape_log SET dates_queued = :n WHERE batch_id = :bid"), + {'n': len(hotel_date_jobs), 'bid': str(batch_id)} + ) + db.commit() + + concurrency = _effective_concurrency(db, len(hotels)) + logger.info( + f"Hotel-page scrape {from_date}..{to_date}: " + f"{len(hotels)} hotels × {len(dates)} dates = {len(hotel_date_jobs)} jobs, " + f"{concurrency} worker(s)" + ) + + try: + config = get_scrape_config(db) + adults = config['adults'] if config else 2 + agg = await _scrape_hotels_concurrent(hotel_date_jobs, concurrency, batch_id, adults) + + status = 'completed' if agg['completed'] else 'failed' + update_scrape_batch( + db, batch_id, + status=status, + hotels_found=len(hotels), + rates_scraped=agg['rates'], + ) + db.execute( + text(""" + UPDATE booking_scrape_log SET + dates_completed = :completed, dates_failed = :failed + WHERE batch_id = :bid + """), + {'completed': agg['completed'], 'failed': agg['failed'], 'bid': str(batch_id)} + ) + db.commit() + + return { + 'success': agg['completed'] > 0, + 'blocked': agg['blocked'] > 0, + 'dates_completed': agg['completed'], + 'dates_failed': agg['failed'], + 'hotels_found': len(hotels), + 'rates_scraped': agg['rates'], + } + + except Exception as e: + logger.error(f"Hotel-page scrape error: {e}") + update_scrape_batch(db, batch_id, status='failed', error_message=str(e)) + return { + 'success': False, + 'error': str(e), + 'dates_completed': 0, + 'dates_failed': len(hotel_date_jobs), + } + + async def _run_manual_scrape_locked( db: Session, from_date: date, to_date: date ) -> Dict[str, Any]: - # Get config + # Hotel-page mode: iterate known hotels × dates + if _is_hotel_page_mode(db): + return await _run_hotel_page_scrape(db, from_date, to_date, 'manual') + + # Search-results mode (original) config = get_scrape_config(db) if not config: return { @@ -751,6 +1002,17 @@ async def process_queue(db: Session) -> Dict[str, Any]: async def _process_queue_locked(db: Session) -> Dict[str, Any]: + # Hotel-page mode ignores the queue and just scrapes the queued date range directly + if _is_hotel_page_mode(db): + items = get_pending_queue_items(db, limit=200) + if not items: + return {'success': True, 'dates_completed': 0, 'message': 'Queue empty'} + dates = [it['rate_date'] for it in items] + # Mark all as completed (hotel-page scrape manages its own tracking) + for it in items: + _safe_mark_queue(db, it['id'], 'completed') + return await _run_hotel_page_scrape(db, min(dates), max(dates), 'scheduled') + # Get config config = get_scrape_config(db) if not config: diff --git a/backend/services/scraper_backends/__init__.py b/backend/services/scraper_backends/__init__.py index d54c86a..fbe0d43 100644 --- a/backend/services/scraper_backends/__init__.py +++ b/backend/services/scraper_backends/__init__.py @@ -9,6 +9,7 @@ Provides pluggable backends to allow switching between: from .base import ScraperBackend, ScraperResult, HotelData, RateData, AvailabilityStatus from .playwright_local import PlaywrightLocalBackend +from .playwright_hotel_page import PlaywrightHotelPageBackend __all__ = [ 'ScraperBackend', @@ -17,4 +18,5 @@ __all__ = [ 'RateData', 'AvailabilityStatus', 'PlaywrightLocalBackend', + 'PlaywrightHotelPageBackend', ] diff --git a/backend/services/scraper_backends/base.py b/backend/services/scraper_backends/base.py index 92d74f3..34f0b43 100644 --- a/backend/services/scraper_backends/base.py +++ b/backend/services/scraper_backends/base.py @@ -36,6 +36,8 @@ class RateData: no_prepayment: Optional[bool] = None rooms_left: Optional[int] = None # "Only X rooms left" available_qty: Optional[int] = None # Future: from hotel page dropdown + rate_plan_id: Optional[str] = None # block_id from hotel page (identifies rate variant) + max_persons: Optional[int] = None # Occupancy this rate applies to @dataclass diff --git a/backend/services/scraper_backends/playwright_hotel_page.py b/backend/services/scraper_backends/playwright_hotel_page.py new file mode 100644 index 0000000..0c9d8ea --- /dev/null +++ b/backend/services/scraper_backends/playwright_hotel_page.py @@ -0,0 +1,328 @@ +""" +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