diff --git a/backend/api/competitors.py b/backend/api/competitors.py index de5bdac..3379f33 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -1368,10 +1368,14 @@ async def get_hotel_rate_snapshot( r.room_type, r.breakfast_included, r.free_cancellation, + r.no_prepayment, r.rate_gross::float AS price, r.rooms_left, r.max_persons, - r.availability_status + r.availability_status, + r.breakfast_text, + r.cancel_text, + r.payment_text FROM booking_com_rates r WHERE r.hotel_id = :hotel_id AND r.rate_date = :stay_date @@ -1383,22 +1387,41 @@ async def get_hotel_rate_snapshot( ORDER BY scraped_at DESC LIMIT 1 ) - ORDER BY r.room_type, r.breakfast_included, r.free_cancellation DESC, r.rate_gross + ORDER BY r.room_type, r.breakfast_included NULLS LAST, r.free_cancellation DESC NULLS LAST, r.rate_gross """), {"hotel_id": hotel_id, "stay_date": stay_date}, ) rows = result.mappings().all() - # If no rate_plan data (old scraper rows), return a simple summary if not rows: return {"stay_date": str(stay_date), "rooms": [], "legacy": True} - # Check if this is old single-row data (no rate_plan_id breakdown) has_breakdown = any(r["room_type"] for r in rows) + def _plan(r) -> dict: + # Use stored text when available; fall back to deriving from booleans for old rows. + breakfast = r["breakfast_text"] or ( + "Breakfast included" if r["breakfast_included"] is True else + "Breakfast available as extra" if r["breakfast_included"] is False else None + ) + cancel = r["cancel_text"] or ( + "Free cancellation" if r["free_cancellation"] is True else + "Non-refundable" if r["free_cancellation"] is False else None + ) + payment = r["payment_text"] or ( + "No prepayment needed – pay at the property" if r["no_prepayment"] is True else + "Pay online" if r["no_prepayment"] is False else None + ) + return { + "breakfast": breakfast, + "cancel": cancel, + "payment": payment, + "price": r["price"], + "max_persons": r["max_persons"], + } + if not has_breakdown: - # Legacy: single row per hotel+date from search results scraper row = rows[0] return { "stay_date": str(stay_date), @@ -1407,16 +1430,10 @@ async def get_hotel_rate_snapshot( "room_type": "Best available", "rooms_left": row["rooms_left"], "availability_status": row["availability_status"], - "plans": [{ - "meal": "B&B" if row["breakfast_included"] else "Room only", - "cancel": "Free cancellation" if row["free_cancellation"] else "Non-refundable", - "price": row["price"], - "max_persons": row["max_persons"], - }] if row["price"] else [], + "plans": [_plan(row)] if row["price"] else [], }], } - # Group by room_type, keeping rooms_left from the first occurrence (same per room) rooms_map: dict = {} for r in rows: rt = (r["room_type"] or "Unknown").strip() @@ -1428,12 +1445,7 @@ async def get_hotel_rate_snapshot( "plans": [], } if r["price"] and (r["max_persons"] is None or r["max_persons"] == 2): - rooms_map[rt]["plans"].append({ - "meal": "B&B" if r["breakfast_included"] else "Room only", - "cancel": "Free cancellation" if r["free_cancellation"] else "Non-refundable", - "price": r["price"], - "max_persons": r["max_persons"], - }) + rooms_map[rt]["plans"].append(_plan(r)) return { "stay_date": str(stay_date), diff --git a/backend/schema.sql b/backend/schema.sql index 45eb8dc..3c28a53 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -133,6 +133,16 @@ ALTER TABLE booking_com_hotels ALTER COLUMN booking_com_id TYPE VARCHAR(255); 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; +-- Raw condition text from the page (text search approach — null = not mentioned, not "no") +ALTER TABLE booking_com_rates ADD COLUMN IF NOT EXISTS breakfast_text TEXT; +ALTER TABLE booking_com_rates ADD COLUMN IF NOT EXISTS cancel_text TEXT; +ALTER TABLE booking_com_rates ADD COLUMN IF NOT EXISTS payment_text TEXT; + +-- Make condition booleans nullable (null = unknown, not assumed false) +ALTER TABLE booking_com_rates ALTER COLUMN breakfast_included SET DEFAULT NULL; +ALTER TABLE booking_com_rates ALTER COLUMN free_cancellation SET DEFAULT NULL; +ALTER TABLE booking_com_rates ALTER COLUMN no_prepayment SET DEFAULT NULL; + 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 32e8cc8..812493a 100644 --- a/backend/services/booking_scraper.py +++ b/backend/services/booking_scraper.py @@ -198,10 +198,12 @@ def save_rate(db: Session, rate: RateData, hotel_id: int, batch_id: uuid.UUID): INSERT INTO booking_com_rates (hotel_id, rate_date, availability_status, rate_gross, currency, room_type, breakfast_included, free_cancellation, no_prepayment, rooms_left, - rate_plan_id, max_persons, scrape_batch_id) + rate_plan_id, max_persons, scrape_batch_id, + breakfast_text, cancel_text, payment_text) VALUES (:hotel_id, :rate_date, :status, :rate, :currency, :room_type, :breakfast, :cancel, :prepay, :rooms_left, - :rate_plan_id, :max_persons, :batch_id) + :rate_plan_id, :max_persons, :batch_id, + :breakfast_text, :cancel_text, :payment_text) """), { 'hotel_id': hotel_id, @@ -217,6 +219,9 @@ def save_rate(db: Session, rate: RateData, hotel_id: int, batch_id: uuid.UUID): 'rate_plan_id': rate.rate_plan_id, 'max_persons': rate.max_persons, 'batch_id': str(batch_id), + 'breakfast_text': rate.breakfast_text, + 'cancel_text': rate.cancel_text, + 'payment_text': rate.payment_text, } ) diff --git a/backend/services/scraper_backends/base.py b/backend/services/scraper_backends/base.py index 34f0b43..d286f73 100644 --- a/backend/services/scraper_backends/base.py +++ b/backend/services/scraper_backends/base.py @@ -38,6 +38,9 @@ class RateData: 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 + breakfast_text: Optional[str] = None # e.g. "Superb breakfast included" / "Superb breakfast £17.50" + cancel_text: Optional[str] = None # e.g. "Free cancellation before 15 July 2026" / "Non-refundable" + payment_text: Optional[str] = None # e.g. "No prepayment needed – pay at the property" / "Pay online" @dataclass diff --git a/backend/services/scraper_backends/playwright_hotel_page.py b/backend/services/scraper_backends/playwright_hotel_page.py index 64cd04b..43f3b3c 100644 --- a/backend/services/scraper_backends/playwright_hotel_page.py +++ b/backend/services/scraper_backends/playwright_hotel_page.py @@ -109,26 +109,61 @@ _EXTRACT_RATES_JS = """ const cells = tr.querySelectorAll('td'); - // Scan ALL cells for breakfast — the conditions column index varies - // across hotel page templates (some hotels swap occupancy + conditions). - // Also check data-fltrs which carries a mealplan flag on some properties. - const rowText = (tr.innerText || '').toLowerCase(); - const breakfastIncluded = rowText.includes('breakfast') - || fltrs.mealplan === 1 - || fltrs.breakfast_included === 1; + // Extract condition detail lines using the same text-search approach as the + // original scrapy spider: grab