Add Rate Monitor app — Booking.com + direct booking engine competitor rates

Combines Booking.com Playwright scraper (from forecasting), direct booking
engine scraper (ported from laptop-archive/guestline-monitor), and Newbook
own-hotel rates into one focused tool. Four views: Bookability, Market View
(with price index badges + direct rate sub-rows), Direct Rates (per-competitor
room breakdown, min-stay flags, hotel config/discovery), Rate Analysis
(advance purchase curve, DOW chart, rate timeline, comparison table).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-05 12:06:30 +00:00
commit e05054172f
50 changed files with 11860 additions and 0 deletions

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
node_modules/
dist/
__pycache__/
*.pyc
*.pyo
.env
.env.local
.DS_Store
*.egg-info/
.eggs/
build/
.venv/
venv/

22
backend/Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
postgresql-client \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Playwright + Chromium for Booking.com scraper
RUN playwright install chromium --with-deps
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/api/__init__.py Normal file
View file

317
backend/api/analysis.py Normal file
View file

@ -0,0 +1,317 @@
"""
Rate Analysis API advance purchase curves, DOW analysis, rate timelines, strategy summary
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from typing import Optional
from datetime import date, timedelta
from database import AsyncSessionLocal
from auth import get_current_user, require_cap
router = APIRouter()
async def get_db():
async with AsyncSessionLocal() as db:
yield db
# ─── Hotels available for analysis ───────────────────────────────────────────
@router.get("/hotels")
async def list_analysis_hotels(
tier: Optional[str] = Query(None, description="Filter by tier: own|competitor|market"),
user=Depends(get_current_user)
):
require_cap(user, "rate_analysis")
async with AsyncSessionLocal() as db:
where = "is_active = true"
params = {}
if tier:
where += " AND tier = :tier"
params["tier"] = tier
result = await db.execute(
text(f"""
SELECT h.id, h.name, h.tier, h.star_rating, h.review_score,
h.booking_com_url,
COUNT(DISTINCT r.rate_date) AS scraped_dates,
MAX(r.scraped_at) AS last_scraped
FROM booking_com_hotels h
LEFT JOIN booking_com_rates r ON r.hotel_id = h.id
WHERE {where}
GROUP BY h.id, h.name, h.tier, h.star_rating, h.review_score, h.booking_com_url
ORDER BY h.tier, h.display_order, h.name
"""),
params
)
rows = result.mappings().all()
return [dict(r) for r in rows]
# ─── Full analysis for one hotel ─────────────────────────────────────────────
@router.get("/hotel/{hotel_id}")
async def analyse_hotel(
hotel_id: int,
from_date: date = Query(default_factory=lambda: date.today()),
to_date: date = Query(default_factory=lambda: date.today() + timedelta(days=89)),
user=Depends(get_current_user)
):
require_cap(user, "rate_analysis")
async with AsyncSessionLocal() as db:
# Hotel info
hotel_row = await db.execute(
text("SELECT id, name, tier, star_rating, booking_com_url FROM booking_com_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = hotel_row.mappings().fetchone()
if not hotel:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Hotel not found")
# Advance purchase curve
apc_result = await db.execute(
text("""
SELECT
CASE
WHEN (rate_date - scraped_at::date) <= 7 THEN '0-7d'
WHEN (rate_date - scraped_at::date) <= 29 THEN '8-29d'
WHEN (rate_date - scraped_at::date) <= 89 THEN '30-89d'
ELSE '90+d'
END AS lead_bucket,
ROUND(AVG(rate_gross)::numeric, 2) AS avg_rate,
COUNT(*) AS sample_count
FROM booking_com_rates
WHERE hotel_id = :hid
AND rate_date BETWEEN :from_date AND :to_date
AND availability_status = 'available'
AND rate_gross IS NOT NULL
GROUP BY lead_bucket
ORDER BY
CASE lead_bucket
WHEN '0-7d' THEN 1
WHEN '8-29d' THEN 2
WHEN '30-89d' THEN 3
ELSE 4
END
"""),
{"hid": hotel_id, "from_date": from_date, "to_date": to_date}
)
advance_purchase_curve = [dict(r) for r in apc_result.mappings().all()]
# Day-of-week averages (latest scrape per date)
dow_result = await db.execute(
text("""
SELECT
EXTRACT(DOW FROM rate_date)::int AS dow,
TO_CHAR(rate_date, 'Dy') AS dow_label,
ROUND(AVG(rate_gross)::numeric, 2) AS avg_rate,
COUNT(DISTINCT rate_date) AS date_count
FROM (
SELECT DISTINCT ON (rate_date)
rate_date, rate_gross
FROM booking_com_rates
WHERE hotel_id = :hid
AND rate_date BETWEEN :from_date AND :to_date
AND availability_status = 'available'
AND rate_gross IS NOT NULL
ORDER BY rate_date, scraped_at DESC
) latest
GROUP BY dow, dow_label
ORDER BY dow
"""),
{"hid": hotel_id, "from_date": from_date, "to_date": to_date}
)
dow_analysis = [dict(r) for r in dow_result.mappings().all()]
# Sold-out pattern by day-of-week
sold_out_result = await db.execute(
text("""
SELECT
EXTRACT(DOW FROM rate_date)::int AS dow,
TO_CHAR(rate_date, 'Dy') AS dow_label,
COUNT(DISTINCT rate_date) AS total_dates,
COUNT(DISTINCT rate_date) FILTER (
WHERE availability_status = 'sold_out'
) AS sold_out_dates
FROM (
SELECT DISTINCT ON (rate_date)
rate_date, availability_status
FROM booking_com_rates
WHERE hotel_id = :hid
AND rate_date BETWEEN :from_date AND :to_date
ORDER BY rate_date, scraped_at DESC
) latest
GROUP BY dow, dow_label
ORDER BY dow
"""),
{"hid": hotel_id, "from_date": from_date, "to_date": to_date}
)
sold_out_pattern = [dict(r) for r in sold_out_result.mappings().all()]
# Strategy summary
strategy = _compute_strategy(advance_purchase_curve, dow_analysis, sold_out_pattern)
return {
"hotel": dict(hotel),
"date_range": {"from": str(from_date), "to": str(to_date)},
"advance_purchase_curve": advance_purchase_curve,
"dow_analysis": dow_analysis,
"sold_out_pattern": sold_out_pattern,
"strategy_summary": strategy,
}
# ─── Rate timeline for a single date ─────────────────────────────────────────
@router.get("/hotel/{hotel_id}/timeline")
async def rate_timeline(
hotel_id: int,
rate_date: date = Query(...),
user=Depends(get_current_user)
):
require_cap(user, "rate_analysis")
async with AsyncSessionLocal() as db:
result = await db.execute(
text("""
SELECT
scraped_at,
rate_gross,
availability_status,
rooms_left,
room_type,
(rate_date - scraped_at::date) AS days_out
FROM booking_com_rates
WHERE hotel_id = :hid AND rate_date = :rd
ORDER BY scraped_at ASC
"""),
{"hid": hotel_id, "rd": rate_date}
)
rows = result.mappings().all()
return {
"hotel_id": hotel_id,
"rate_date": str(rate_date),
"timeline": [dict(r) for r in rows],
}
# ─── Own vs Competitor comparison ────────────────────────────────────────────
@router.get("/comparison")
async def rate_comparison(
competitor_ids: str = Query(..., description="Comma-separated hotel IDs to compare"),
from_date: date = Query(default_factory=lambda: date.today()),
to_date: date = Query(default_factory=lambda: date.today() + timedelta(days=29)),
user=Depends(get_current_user)
):
require_cap(user, "rate_analysis")
try:
comp_ids = [int(x.strip()) for x in competitor_ids.split(",") if x.strip()]
except ValueError:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="competitor_ids must be comma-separated integers")
async with AsyncSessionLocal() as db:
# Own hotel latest rates
own_result = await db.execute(
text("""
SELECT
ncr.rate_date,
ncr.gross_rate AS own_rate
FROM newbook_current_rates ncr
WHERE ncr.rate_date BETWEEN :from_date AND :to_date
ORDER BY ncr.rate_date
"""),
{"from_date": from_date, "to_date": to_date}
)
own_rates = {str(r.rate_date): float(r.own_rate) for r in own_result if r.own_rate}
# Competitor latest rates per date
comp_result = await db.execute(
text("""
SELECT
r.hotel_id,
h.name AS hotel_name,
r.rate_date,
r.rate_gross,
r.availability_status
FROM (
SELECT DISTINCT ON (hotel_id, rate_date)
hotel_id, rate_date, rate_gross, availability_status
FROM booking_com_rates
WHERE hotel_id = ANY(:comp_ids)
AND rate_date BETWEEN :from_date AND :to_date
ORDER BY hotel_id, rate_date, scraped_at DESC
) r
JOIN booking_com_hotels h ON h.id = r.hotel_id
ORDER BY r.rate_date, h.display_order
"""),
{"comp_ids": comp_ids, "from_date": from_date, "to_date": to_date}
)
comp_rows = comp_result.mappings().all()
# Build per-date rows
date_map: dict = {}
hotel_names: dict = {}
for row in comp_rows:
d = str(row["rate_date"])
if d not in date_map:
date_map[d] = {"date": d, "own_rate": own_rates.get(d)}
date_map[d][f"h{row['hotel_id']}"] = float(row["rate_gross"]) if row["rate_gross"] else None
date_map[d][f"h{row['hotel_id']}_status"] = row["availability_status"]
hotel_names[row["hotel_id"]] = row["hotel_name"]
return {
"hotel_names": hotel_names,
"rows": sorted(date_map.values(), key=lambda x: x["date"]),
}
# ─── Strategy computation helper ─────────────────────────────────────────────
def _compute_strategy(apc: list, dow: list, sold_out: list) -> dict:
# Advance discount — compare 0-7d vs 30-89d
rates_by_bucket = {r["lead_bucket"]: float(r["avg_rate"]) for r in apc if r.get("avg_rate")}
advance_discount_pct = None
if "0-7d" in rates_by_bucket and "30-89d" in rates_by_bucket:
close_in = rates_by_bucket["0-7d"]
far_out = rates_by_bucket["30-89d"]
if far_out > 0:
# Positive = closes in higher (scarcity premium); negative = discount for advance
advance_discount_pct = round((close_in - far_out) / far_out * 100, 1)
# Weekend premium — Fri(5)+Sat(6) vs Mon(1)Thu(4)
rates_by_dow = {r["dow"]: float(r["avg_rate"]) for r in dow if r.get("avg_rate")}
weekend_premium_pct = None
weekend_rates = [rates_by_dow[d] for d in [5, 6] if d in rates_by_dow]
weekday_rates = [rates_by_dow[d] for d in [1, 2, 3, 4] if d in rates_by_dow]
if weekend_rates and weekday_rates:
avg_wk = sum(weekend_rates) / len(weekend_rates)
avg_wd = sum(weekday_rates) / len(weekday_rates)
if avg_wd > 0:
weekend_premium_pct = round((avg_wk - avg_wd) / avg_wd * 100, 1)
# Sold-out rate
total_dates = sum(r["total_dates"] for r in sold_out)
sold_out_dates = sum(r["sold_out_dates"] for r in sold_out)
sold_out_rate_pct = round(sold_out_dates / total_dates * 100, 1) if total_dates > 0 else None
# Strategy label
label = "Mixed / insufficient data"
if advance_discount_pct is not None:
if advance_discount_pct <= -5:
label = "Advance-booking discounter"
elif advance_discount_pct >= 8 and (sold_out_rate_pct or 0) >= 10:
label = "Yield manager (scarcity-driven)"
elif advance_discount_pct >= 3:
label = "Flat-rate / hold-firm strategy"
else:
label = "Stable pricing"
return {
"advance_discount_pct": advance_discount_pct,
"weekend_premium_pct": weekend_premium_pct,
"avg_sold_out_rate_pct": sold_out_rate_pct,
"strategy_label": label,
}

624
backend/api/bookability.py Normal file
View file

@ -0,0 +1,624 @@
"""
Bookability API endpoints
Rate availability matrix and competitor rate comparison
"""
from typing import Optional, List, Dict, Any
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
import logging
import json
from database import get_db, SyncSessionLocal
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================
# RESPONSE MODELS
# ============================================
class CategoryInfo(BaseModel):
category_id: str
category_name: str
room_count: int
class TariffInfo(BaseModel):
name: str
description: Optional[str] = None
rate: Optional[float] = None
average_nightly: Optional[float] = None
available: bool
message: str
sort_order: int = 999
min_stay: Optional[int] = None
available_for_min_stay: Optional[bool] = None # True if available when queried with min_stay nights
class OccupancyInfo(BaseModel):
occupied: int = 0
available: int = 0
maintenance: int = 0
class DateRateInfo(BaseModel):
rate_gross: Optional[float] = None
rate_net: Optional[float] = None
tariffs: List[TariffInfo]
tariff_count: int
occupancy: Optional[OccupancyInfo] = None
valid_from: Optional[str] = None
class RateMatrixResponse(BaseModel):
categories: List[CategoryInfo]
dates: List[str]
matrix: Dict[str, Dict[str, DateRateInfo]]
date_last_updated: Dict[str, Optional[str]] = {}
# ============================================
# RATE MATRIX ENDPOINT
# ============================================
@router.get("/rate-matrix", response_model=RateMatrixResponse)
async def get_rate_matrix(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
category_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get rate availability matrix for all tariffs across dates and categories.
Returns a matrix showing all available tariff options for each room category
and date combination, including availability status and rates.
Args:
from_date: Start date (YYYY-MM-DD), defaults to today
to_date: End date (YYYY-MM-DD), defaults to today + 30 days
category_id: Optional filter to specific category
Returns:
RateMatrixResponse with categories, dates, and the matrix data
"""
# Default date range
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Validate date range
if end < start:
raise HTTPException(status_code=400, detail="to_date must be after from_date")
if (end - start).days > 366:
raise HTTPException(status_code=400, detail="Date range cannot exceed 366 days")
# Fetch categories
cat_query = """
SELECT site_id, site_name, room_count
FROM newbook_room_categories
WHERE is_included = true
"""
params: Dict[str, Any] = {}
if category_id:
cat_query += " AND site_id = :category_id"
params["category_id"] = category_id
cat_query += " ORDER BY display_order, site_name"
cat_result = await db.execute(text(cat_query), params)
categories = [
CategoryInfo(
category_id=row.site_id,
category_name=row.site_name,
room_count=row.room_count or 0
)
for row in cat_result.fetchall()
]
if not categories:
return RateMatrixResponse(categories=[], dates=[], matrix={})
# Build date list
dates = []
current = start
while current <= end:
dates.append(current.isoformat())
current += timedelta(days=1)
# Fetch rates with tariffs_data (get latest version per category/date)
rates_query = """
SELECT DISTINCT ON (category_id, rate_date)
category_id, rate_date, rate_gross, rate_net, tariffs_data, valid_from
FROM newbook_current_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
"""
rates_params: Dict[str, Any] = {"from_date": start, "to_date": end}
if category_id:
rates_query += " AND category_id = :category_id"
rates_params["category_id"] = category_id
rates_query += " ORDER BY category_id, rate_date, valid_from DESC"
rates_result = await db.execute(text(rates_query), rates_params)
rates_rows = rates_result.fetchall()
# Fetch occupancy data from newbook_occupancy_report_data
occupancy_query = """
SELECT category_id, date, occupied, available, maintenance
FROM newbook_occupancy_report_data
WHERE date >= :from_date AND date <= :to_date
"""
occupancy_params: Dict[str, Any] = {"from_date": start, "to_date": end}
if category_id:
occupancy_query += " AND category_id = :category_id"
occupancy_params["category_id"] = category_id
occupancy_result = await db.execute(text(occupancy_query), occupancy_params)
occupancy_rows = occupancy_result.fetchall()
# Build occupancy lookup: category_id -> date -> OccupancyInfo
occupancy_map: Dict[str, Dict[str, OccupancyInfo]] = {}
for row in occupancy_rows:
cat_id = row.category_id
occ_date = row.date.isoformat()
if cat_id not in occupancy_map:
occupancy_map[cat_id] = {}
occupancy_map[cat_id][occ_date] = OccupancyInfo(
occupied=row.occupied or 0,
available=row.available or 0,
maintenance=row.maintenance or 0
)
# Build matrix
matrix: Dict[str, Dict[str, DateRateInfo]] = {}
# Initialize matrix with empty data for all categories and dates
for cat in categories:
matrix[cat.category_id] = {}
for date_str in dates:
# Get occupancy for this category/date if available
occ = occupancy_map.get(cat.category_id, {}).get(date_str)
matrix[cat.category_id][date_str] = DateRateInfo(
rate_gross=None,
rate_net=None,
tariffs=[],
tariff_count=0,
occupancy=occ
)
# Populate matrix with actual data
for row in rates_rows:
cat_id = row.category_id
rate_date = row.rate_date.isoformat()
if cat_id not in matrix or rate_date not in matrix[cat_id]:
continue
# Parse tariffs_data
tariffs_data = row.tariffs_data or {}
if isinstance(tariffs_data, str):
try:
tariffs_data = json.loads(tariffs_data)
except json.JSONDecodeError:
tariffs_data = {}
# Build tariff list
tariffs_list = []
raw_tariffs = tariffs_data.get('tariffs', [])
for idx, tariff in enumerate(raw_tariffs):
tariffs_list.append(TariffInfo(
name=tariff.get('name', 'Unknown'),
description=tariff.get('description'),
rate=tariff.get('rate'),
average_nightly=tariff.get('average_nightly'),
available=tariff.get('success', False),
message=tariff.get('message', ''),
sort_order=tariff.get('sort_order', idx),
min_stay=tariff.get('min_stay'),
available_for_min_stay=tariff.get('available_for_min_stay')
))
# Preserve existing occupancy data
existing_occ = matrix[cat_id][rate_date].occupancy
vf = row.valid_from.isoformat() if row.valid_from else None
matrix[cat_id][rate_date] = DateRateInfo(
rate_gross=float(row.rate_gross) if row.rate_gross else None,
rate_net=float(row.rate_net) if row.rate_net else None,
tariffs=tariffs_list,
tariff_count=tariffs_data.get('tariff_count', len(tariffs_list)),
occupancy=existing_occ,
valid_from=vf
)
# Per-date latest update time (max valid_from across all categories for each date)
date_last_updated: Dict[str, Optional[str]] = {}
for date_str in dates:
latest = None
for cat in categories:
vf = matrix.get(cat.category_id, {}).get(date_str, DateRateInfo(tariffs=[], tariff_count=0)).valid_from
if vf and (latest is None or vf > latest):
latest = vf
date_last_updated[date_str] = latest
return RateMatrixResponse(
categories=categories,
dates=dates,
matrix=matrix,
date_last_updated=date_last_updated
)
# ============================================
# RATE MATRIX SUMMARY (lightweight endpoint)
# ============================================
@router.get("/rate-matrix/summary")
async def get_rate_matrix_summary(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get a summary of rate availability issues.
Returns counts of unavailable tariffs by category and date for quick
identification of potential bookability problems.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Query rates with issues (get latest version per category/date)
result = await db.execute(
text("""
SELECT DISTINCT ON (category_id, rate_date)
category_id,
rate_date,
tariffs_data
FROM newbook_current_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
AND tariffs_data IS NOT NULL
ORDER BY category_id, rate_date, valid_from DESC
"""),
{"from_date": start, "to_date": end}
)
issues = []
for row in result.fetchall():
tariffs_data = row.tariffs_data or {}
if isinstance(tariffs_data, str):
try:
tariffs_data = json.loads(tariffs_data)
except json.JSONDecodeError:
continue
tariffs = tariffs_data.get('tariffs', [])
unavailable = [t for t in tariffs if not t.get('success', False)]
if unavailable:
issues.append({
"category_id": row.category_id,
"date": row.rate_date.isoformat(),
"unavailable_count": len(unavailable),
"unavailable_tariffs": [t.get('name') for t in unavailable],
"messages": [t.get('message') for t in unavailable if t.get('message')]
})
return {
"from_date": start.isoformat(),
"to_date": end.isoformat(),
"total_issues": len(issues),
"issues": issues
}
# ============================================
# RATE HISTORY ENDPOINT
# ============================================
@router.get("/rate-history/{category_id}/{rate_date}")
async def get_rate_history(
category_id: str,
rate_date: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get rate change history for a specific category and date.
Returns all rate snapshots showing how rates evolved over time.
Useful for understanding when rates changed and by how much.
"""
try:
target_date = date.fromisoformat(rate_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
result = await db.execute(
text("""
SELECT
id,
rate_gross,
rate_net,
tariffs_data,
valid_from,
last_verified_at
FROM newbook_current_rates
WHERE category_id = :category_id AND rate_date = :rate_date
ORDER BY valid_from DESC
"""),
{"category_id": category_id, "rate_date": target_date}
)
history = []
for row in result.fetchall():
tariffs_data = row.tariffs_data or {}
if isinstance(tariffs_data, str):
try:
tariffs_data = json.loads(tariffs_data)
except json.JSONDecodeError:
tariffs_data = {}
tariffs = tariffs_data.get('tariffs', [])
history.append({
"id": row.id,
"rate_gross": float(row.rate_gross) if row.rate_gross else None,
"rate_net": float(row.rate_net) if row.rate_net else None,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"last_verified_at": row.last_verified_at.isoformat() if row.last_verified_at else None,
"tariff_count": len(tariffs),
"tariffs_available": sum(1 for t in tariffs if t.get('success', False)),
"tariffs_unavailable": sum(1 for t in tariffs if not t.get('success', False)),
"tariffs": [
{
"name": t.get('name'),
"rate": t.get('rate'),
"available": t.get('success', False),
"message": t.get('message', ''),
"min_stay": t.get('min_stay')
}
for t in tariffs
]
})
return {
"category_id": category_id,
"rate_date": rate_date,
"version_count": len(history),
"history": history
}
# ============================================
# RATE CHANGES SUMMARY
# ============================================
@router.get("/rate-changes")
async def get_rate_changes(
days: int = 7,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get summary of rate changes in the last N days.
Shows which rates changed and when, useful for tracking pricing strategy changes.
"""
cutoff = datetime.now() - timedelta(days=days)
# Find dates with multiple versions (indicating changes)
result = await db.execute(
text("""
SELECT
category_id,
rate_date,
COUNT(*) as version_count,
MIN(valid_from) as first_version,
MAX(valid_from) as latest_version
FROM newbook_current_rates
WHERE valid_from >= :cutoff
GROUP BY category_id, rate_date
HAVING COUNT(*) > 1
ORDER BY MAX(valid_from) DESC
LIMIT 100
"""),
{"cutoff": cutoff}
)
changes = []
for row in result.fetchall():
changes.append({
"category_id": row.category_id,
"rate_date": row.rate_date.isoformat(),
"version_count": row.version_count,
"first_version": row.first_version.isoformat() if row.first_version else None,
"latest_version": row.latest_version.isoformat() if row.latest_version else None
})
return {
"days": days,
"total_changes": len(changes),
"changes": changes
}
# ============================================
# FETCH RATES TRIGGER (manual refresh)
# ============================================
@router.post("/refresh-rates")
async def refresh_rates(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
category_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger a manual refresh of current rates from Newbook.
This runs the rate fetch job for the specified date range.
Note: This can be slow as it respects Newbook API rate limits.
"""
from jobs.fetch_current_rates import run_fetch_current_rates
# For now, just run the standard fetch
# TODO: Add support for custom date range and category filter
try:
await run_fetch_current_rates()
return {"status": "success", "message": "Rates refresh completed"}
except Exception as e:
logger.error(f"Rates refresh failed: {e}")
raise HTTPException(status_code=500, detail=f"Rates refresh failed: {str(e)}")
# ============================================
# SINGLE-DATE RATE REFRESH
# ============================================
def _refresh_date_sync(rate_date: date):
"""
Fetch rates for a single date from Newbook and save to DB.
Runs synchronously in a background task.
"""
import asyncio
from decimal import Decimal
from services.newbook_rates_client import NewbookRatesClient
from jobs.fetch_current_rates import save_rate_snapshot
db = SyncSessionLocal()
try:
# Get config
config_result = db.execute(
text("""
SELECT config_key, config_value FROM system_config
WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region', 'accommodation_vat_rate')
""")
)
config = {row.config_key: row.config_value for row in config_result.fetchall()}
# Central Settings service first, app-local config fallback
from services.central_settings import get_newbook_credentials_sync
creds = get_newbook_credentials_sync()
if not creds:
if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']):
logger.error("Newbook credentials not configured for single-date refresh")
return
creds = {
'api_key': config['newbook_api_key'],
'username': config['newbook_username'],
'password': config['newbook_password'],
'region': config['newbook_region'],
}
vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20'))
# Get included categories
cat_result = db.execute(
text("SELECT site_id FROM newbook_room_categories WHERE is_included = true")
)
included_categories = set(row.site_id for row in cat_result.fetchall())
client = NewbookRatesClient(
api_key=creds['api_key'],
username=creds['username'],
password=creds['password'],
region=creds['region'],
vat_rate=vat_rate
)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
async def _fetch():
async with client:
# Single-night query for this date (all categories)
category_rates = await client._fetch_all_categories_batch(
rate_date, guests_adults=2, guests_children=0
)
# Check for min_stay tariffs needing multi-night verification
dates_by_nights: Dict[int, list] = {}
for cat_id, rates in category_rates.items():
if cat_id not in included_categories:
continue
for rate in rates:
for tariff in rate.get('tariffs_data', {}).get('tariffs', []):
min_stay = tariff.get('min_stay')
if min_stay and min_stay > 1 and not tariff.get('success', False):
if min_stay not in dates_by_nights:
dates_by_nights[min_stay] = []
if rate_date not in dates_by_nights[min_stay]:
dates_by_nights[min_stay].append(rate_date)
# Run multi-night verification if needed
if dates_by_nights:
multi_results = await client.get_multi_night_availability(dates_by_nights)
for cat_id, rates in category_rates.items():
if cat_id not in included_categories:
continue
for rate in rates:
if rate_date in multi_results:
cat_avail = multi_results[rate_date].get(cat_id, {})
for tariff in rate.get('tariffs_data', {}).get('tariffs', []):
if tariff.get('min_stay') and tariff['min_stay'] > 1:
tariff['available_for_min_stay'] = cat_avail.get(tariff.get('name', ''), False)
# Save snapshots
inserted = 0
for cat_id, rates in category_rates.items():
if cat_id not in included_categories:
continue
for rate in rates:
result = save_rate_snapshot(db, cat_id, rate['date'], rate)
if result == 'inserted':
inserted += 1
return inserted
inserted = loop.run_until_complete(_fetch())
db.commit()
logger.info(f"Single-date refresh for {rate_date}: {inserted} new snapshots")
finally:
loop.close()
except Exception as e:
logger.error(f"Single-date refresh failed for {rate_date}: {e}", exc_info=True)
db.rollback()
finally:
db.close()
@router.post("/refresh-date/{rate_date}")
async def refresh_single_date(
rate_date: str,
current_user: dict = Depends(get_current_user)
):
"""
Trigger a refresh of rates for a single date from Newbook.
Runs synchronously so the client can invalidate its cache immediately on response.
"""
try:
target_date = date.fromisoformat(rate_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _refresh_date_sync, target_date)
return {"status": "success", "date": rate_date, "message": f"Rates refreshed for {rate_date}"}

940
backend/api/competitors.py Normal file
View file

@ -0,0 +1,940 @@
"""
Competitor Rates API endpoints
Booking.com rate scraping, hotel management, and competitor comparison
"""
from typing import Optional, List, Dict, Any
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
import logging
from database import get_db, SyncSessionLocal
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================
# REQUEST/RESPONSE MODELS
# ============================================
class ScrapeRequest(BaseModel):
from_date: str
to_date: Optional[str] = None
class LocationConfigRequest(BaseModel):
location_name: str
pages_to_scrape: int = 2
adults: int = 2
class HotelTierUpdate(BaseModel):
tier: str # 'own', 'competitor', 'market'
display_order: Optional[int] = None
class HotelResponse(BaseModel):
id: int
booking_com_id: str
name: str
booking_com_url: Optional[str]
star_rating: Optional[float]
review_score: Optional[float]
review_count: Optional[int]
tier: str
display_order: int
notes: Optional[str]
first_seen_at: Optional[datetime]
last_seen_at: Optional[datetime]
class RateResponse(BaseModel):
rate_date: str
hotel_id: int
hotel_name: str
tier: str
star_rating: Optional[float]
review_score: Optional[float]
availability_status: str
rate_gross: Optional[float]
room_type: Optional[str]
breakfast_included: Optional[bool]
free_cancellation: Optional[bool]
no_prepayment: Optional[bool]
rooms_left: Optional[int]
scraped_at: Optional[datetime]
class ScraperStatusResponse(BaseModel):
enabled: bool
paused: bool
pause_until: Optional[str]
backend: str
location_configured: bool
location_name: Optional[str]
last_scrape: Optional[dict]
# ============================================
# SCRAPER STATUS & CONFIGURATION
# ============================================
@router.get("/status", response_model=ScraperStatusResponse)
async def get_scraper_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get current scraper status and configuration."""
# Get config values
config_result = await db.execute(
text("""
SELECT config_key, config_value FROM system_config
WHERE config_key IN (
'booking_scraper_enabled',
'booking_scraper_paused',
'booking_scraper_pause_until',
'booking_scraper_backend'
)
""")
)
config = {row.config_key: row.config_value for row in config_result.fetchall()}
# Get location config
location_result = await db.execute(
text("SELECT location_name FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1")
)
location_row = location_result.fetchone()
# Get last scrape info
last_scrape_result = await db.execute(
text("""
SELECT batch_id, scrape_type, started_at, completed_at, status,
hotels_found, rates_scraped, error_message
FROM booking_scrape_log
ORDER BY started_at DESC
LIMIT 1
""")
)
last_scrape_row = last_scrape_result.fetchone()
last_scrape = None
if last_scrape_row:
last_scrape = {
'batch_id': str(last_scrape_row.batch_id),
'scrape_type': last_scrape_row.scrape_type,
'started_at': last_scrape_row.started_at.isoformat() if last_scrape_row.started_at else None,
'completed_at': last_scrape_row.completed_at.isoformat() if last_scrape_row.completed_at else None,
'status': last_scrape_row.status,
'hotels_found': last_scrape_row.hotels_found,
'rates_scraped': last_scrape_row.rates_scraped,
'error_message': last_scrape_row.error_message,
}
return ScraperStatusResponse(
enabled=config.get('booking_scraper_enabled', 'false') == 'true',
paused=config.get('booking_scraper_paused', 'false') == 'true',
pause_until=config.get('booking_scraper_pause_until'),
backend=config.get('booking_scraper_backend', 'playwright_local'),
location_configured=location_row is not None,
location_name=location_row.location_name if location_row else None,
last_scrape=last_scrape
)
@router.post("/config/location")
async def set_location_config(
config: LocationConfigRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Set the location to scrape for competitor rates."""
# Deactivate existing configs
await db.execute(
text("UPDATE booking_scrape_config SET is_active = FALSE")
)
# Insert new config
await db.execute(
text("""
INSERT INTO booking_scrape_config (location_name, pages_to_scrape, adults, is_active)
VALUES (:location, :pages, :adults, TRUE)
"""),
{'location': config.location_name, 'pages': config.pages_to_scrape, 'adults': config.adults}
)
await db.commit()
return {"status": "success", "location": config.location_name}
@router.post("/config/enable")
async def enable_scraper(
enabled: bool = True,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Enable or disable the booking.com scraper."""
await db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_enabled'"),
{'val': 'true' if enabled else 'false'}
)
await db.commit()
return {"status": "success", "enabled": enabled}
@router.post("/config/unpause")
async def unpause_scraper(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Manually unpause the scraper (clears blocking pause)."""
await db.execute(
text("UPDATE system_config SET config_value = 'false' WHERE config_key = 'booking_scraper_paused'")
)
await db.commit()
return {"status": "success", "message": "Scraper unpaused"}
# ============================================
# MANUAL SCRAPE TRIGGER
# ============================================
def run_scrape_sync(from_date: date, to_date: date):
"""Run scrape in sync context for background task."""
import asyncio
from services.booking_scraper import run_manual_scrape, cleanup_stale_batches
db = SyncSessionLocal()
try:
# Clean up any stale batches before starting
cleanup_stale_batches(db, max_age_minutes=60)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(run_manual_scrape(db, from_date, to_date))
logger.info(f"Background scrape completed: {result}")
finally:
loop.close()
except Exception as e:
logger.error(f"Background scrape failed: {e}", exc_info=True)
finally:
db.close()
@router.post("/scrape")
async def trigger_manual_scrape(
request: ScrapeRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger a manual scrape for the specified date range.
Runs in background - check /status for progress.
"""
try:
from_date = date.fromisoformat(request.from_date)
to_date = date.fromisoformat(request.to_date) if request.to_date else from_date
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
if to_date < from_date:
raise HTTPException(status_code=400, detail="to_date must be after from_date")
if (to_date - from_date).days > 30:
raise HTTPException(status_code=400, detail="Date range cannot exceed 30 days for manual scrape")
# Check if location is configured
location_result = await db.execute(
text("SELECT id FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1")
)
if not location_result.fetchone():
raise HTTPException(status_code=400, detail="No scrape location configured. Set location first.")
# Check if paused
paused_result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_paused'")
)
paused_row = paused_result.fetchone()
if paused_row and paused_row.config_value == 'true':
raise HTTPException(status_code=400, detail="Scraper is currently paused. Use /unpause first or wait for cooldown.")
# Start background task
background_tasks.add_task(run_scrape_sync, from_date, to_date)
return {
"status": "started",
"from_date": from_date.isoformat(),
"to_date": to_date.isoformat(),
"message": "Scrape started in background. Check /status for progress."
}
# ============================================
# HOTELS MANAGEMENT
# ============================================
@router.get("/hotels", response_model=List[HotelResponse])
async def list_hotels(
tier: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
List all discovered hotels.
Filter by tier: 'own', 'competitor', 'market', or None for all.
"""
query = """
SELECT id, booking_com_id, name, booking_com_url,
star_rating, review_score, review_count,
tier, display_order, notes, first_seen_at, last_seen_at
FROM booking_com_hotels
WHERE is_active = TRUE
"""
params = {}
if tier:
if tier not in ('own', 'competitor', 'market'):
raise HTTPException(status_code=400, detail="Invalid tier. Must be 'own', 'competitor', or 'market'")
query += " AND tier = :tier"
params['tier'] = tier
query += " ORDER BY display_order, name"
result = await db.execute(text(query), params)
return [
HotelResponse(
id=row.id,
booking_com_id=row.booking_com_id or '',
name=row.name,
booking_com_url=row.booking_com_url,
star_rating=float(row.star_rating) if row.star_rating else None,
review_score=float(row.review_score) if row.review_score else None,
review_count=row.review_count,
tier=row.tier,
display_order=row.display_order,
notes=row.notes,
first_seen_at=row.first_seen_at,
last_seen_at=row.last_seen_at
)
for row in result.fetchall()
]
@router.put("/hotels/{hotel_id}/tier")
async def update_hotel_tier(
hotel_id: int,
update: HotelTierUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update a hotel's tier and display order.
Tiers:
- 'own': Your hotel (for parity checking)
- 'competitor': Main competitors (full tracking)
- 'market': Other hotels (context only)
"""
if update.tier not in ('own', 'competitor', 'market'):
raise HTTPException(status_code=400, detail="Invalid tier")
# If setting as 'own', clear any existing 'own' hotel
if update.tier == 'own':
await db.execute(
text("UPDATE booking_com_hotels SET tier = 'market' WHERE tier = 'own'")
)
# Update the hotel
set_clause = "tier = :tier"
params = {'hotel_id': hotel_id, 'tier': update.tier}
if update.display_order is not None:
set_clause += ", display_order = :order"
params['order'] = update.display_order
result = await db.execute(
text(f"UPDATE booking_com_hotels SET {set_clause} WHERE id = :hotel_id RETURNING id"),
params
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Hotel not found")
await db.commit()
# If this is now the own hotel, update system config
if update.tier == 'own':
await db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_own_hotel_id'"),
{'val': str(hotel_id)}
)
await db.commit()
return {"status": "success", "hotel_id": hotel_id, "tier": update.tier}
@router.put("/hotels/{hotel_id}/notes")
async def update_hotel_notes(
hotel_id: int,
notes: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update notes for a hotel."""
result = await db.execute(
text("UPDATE booking_com_hotels SET notes = :notes WHERE id = :hotel_id RETURNING id"),
{'hotel_id': hotel_id, 'notes': notes}
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Hotel not found")
await db.commit()
return {"status": "success"}
# ============================================
# COMPETITOR RATES MATRIX
# ============================================
@router.get("/matrix")
async def get_competitor_matrix(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
include_market: bool = False,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get rate comparison matrix for competitors.
Returns rates for own hotel and competitors, organized by date.
Set include_market=true to also include market tier hotels.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
if end < start:
raise HTTPException(status_code=400, detail="to_date must be after from_date")
if (end - start).days > 90:
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
tier_filter = "h.tier IN ('own', 'competitor')"
if include_market:
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
# Get hotels
hotels_result = await db.execute(
text(f"""
SELECT id, name, tier, display_order, star_rating, review_score, booking_com_url
FROM booking_com_hotels
WHERE is_active = TRUE AND {tier_filter.replace('h.', '')}
ORDER BY display_order, name
""")
)
hotels = [dict(row._mapping) for row in hotels_result.fetchall()]
# Get latest rates using the view
rates_result = await db.execute(
text(f"""
SELECT DISTINCT ON (r.hotel_id, r.rate_date)
r.hotel_id,
r.rate_date,
r.availability_status,
r.rate_gross,
r.room_type,
r.breakfast_included,
r.free_cancellation,
r.no_prepayment,
r.rooms_left,
r.scraped_at
FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
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
"""),
{'from_date': start, 'to_date': end}
)
# Build matrix: hotel_id -> date -> rate data
rates_by_hotel: Dict[int, Dict[str, dict]] = {}
for row in rates_result.fetchall():
hotel_id = row.hotel_id
rate_date = row.rate_date.isoformat()
if hotel_id not in rates_by_hotel:
rates_by_hotel[hotel_id] = {}
rates_by_hotel[hotel_id][rate_date] = {
'availability_status': row.availability_status,
'rate_gross': float(row.rate_gross) if row.rate_gross else None,
'room_type': row.room_type,
'breakfast_included': row.breakfast_included,
'free_cancellation': row.free_cancellation,
'no_prepayment': row.no_prepayment,
'rooms_left': row.rooms_left,
'scraped_at': row.scraped_at.isoformat() if row.scraped_at else None,
}
# Build date list
dates = []
current = start
while current <= end:
dates.append(current.isoformat())
current += timedelta(days=1)
return {
'from_date': start.isoformat(),
'to_date': end.isoformat(),
'dates': dates,
'hotels': hotels,
'rates': rates_by_hotel
}
# ============================================
# RATE PARITY (OWN HOTEL VS NEWBOOK)
# ============================================
@router.get("/parity")
async def get_rate_parity(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get rate parity comparison between booking.com and Newbook rates.
Compares scraped booking.com rates for own hotel against Newbook current rates.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Get own hotel's booking.com rates
booking_rates_result = await db.execute(
text("""
SELECT DISTINCT ON (r.rate_date)
r.rate_date,
r.rate_gross as booking_rate,
r.availability_status,
r.room_type as booking_room_type,
r.scraped_at
FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE h.tier = 'own'
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
ORDER BY r.rate_date, r.scraped_at DESC
"""),
{'from_date': start, 'to_date': end}
)
booking_rates = {row.rate_date: dict(row._mapping) for row in booking_rates_result.fetchall()}
# Get Newbook rates (best rate per date across categories)
newbook_rates_result = await db.execute(
text("""
SELECT DISTINCT ON (rate_date)
rate_date,
rate_gross as newbook_rate,
category_id
FROM newbook_current_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
ORDER BY rate_date, valid_from DESC
"""),
{'from_date': start, 'to_date': end}
)
newbook_rates = {row.rate_date: dict(row._mapping) for row in newbook_rates_result.fetchall()}
# Compare rates
parity_issues = []
all_dates = set(booking_rates.keys()) | set(newbook_rates.keys())
for rate_date in sorted(all_dates):
booking = booking_rates.get(rate_date)
newbook = newbook_rates.get(rate_date)
if not booking or not newbook:
continue
booking_rate = booking.get('booking_rate')
newbook_rate = newbook.get('newbook_rate')
if not booking_rate or not newbook_rate:
continue
diff_pct = ((float(booking_rate) - float(newbook_rate)) / float(newbook_rate)) * 100
if abs(diff_pct) > 1: # More than 1% difference
parity_issues.append({
'rate_date': rate_date.isoformat(),
'booking_rate': float(booking_rate),
'newbook_rate': float(newbook_rate),
'difference_pct': round(diff_pct, 2),
'alert_type': 'higher' if diff_pct > 0 else 'lower',
'booking_room_type': booking.get('booking_room_type'),
'availability_status': booking.get('availability_status'),
})
return {
'from_date': start.isoformat(),
'to_date': end.isoformat(),
'issues_count': len(parity_issues),
'issues': parity_issues
}
# ============================================
# PARITY ALERTS
# ============================================
@router.get("/parity/alerts")
async def get_parity_alerts(
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get rate parity alerts."""
query = """
SELECT id, rate_date, room_category, newbook_rate, booking_com_rate,
difference_pct, alert_type, alert_status, created_at,
acknowledged_at, acknowledged_by, notes
FROM rate_parity_alerts
"""
params = {}
if status:
query += " WHERE alert_status = :status"
params['status'] = status
query += " ORDER BY rate_date DESC, created_at DESC LIMIT 100"
result = await db.execute(text(query), params)
return [
{
'id': row.id,
'rate_date': row.rate_date.isoformat(),
'room_category': row.room_category,
'newbook_rate': float(row.newbook_rate) if row.newbook_rate else None,
'booking_com_rate': float(row.booking_com_rate) if row.booking_com_rate else None,
'difference_pct': float(row.difference_pct) if row.difference_pct else None,
'alert_type': row.alert_type,
'alert_status': row.alert_status,
'created_at': row.created_at.isoformat() if row.created_at else None,
'acknowledged_at': row.acknowledged_at.isoformat() if row.acknowledged_at else None,
'acknowledged_by': row.acknowledged_by,
'notes': row.notes,
}
for row in result.fetchall()
]
@router.put("/parity/alerts/{alert_id}/acknowledge")
async def acknowledge_parity_alert(
alert_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Acknowledge a parity alert."""
result = await db.execute(
text("""
UPDATE rate_parity_alerts
SET alert_status = 'acknowledged',
acknowledged_at = NOW(),
acknowledged_by = :username
WHERE id = :alert_id
RETURNING id
"""),
{'alert_id': alert_id, 'username': current_user.get('username', 'unknown')}
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Alert not found")
await db.commit()
return {"status": "success"}
# ============================================
# QUEUE STATUS
# ============================================
@router.get("/queue-status")
async def get_queue_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get current scrape queue status."""
result = await db.execute(
text("""
SELECT
status,
COUNT(*) as count,
MIN(rate_date) as earliest_date,
MAX(rate_date) as latest_date
FROM booking_scrape_queue
GROUP BY status
""")
)
status_counts = {row.status: {
'count': row.count,
'earliest': row.earliest_date.isoformat() if row.earliest_date else None,
'latest': row.latest_date.isoformat() if row.latest_date else None,
} for row in result.fetchall()}
# Get retry items (failed but under max_attempts)
retry_result = await db.execute(
text("""
SELECT COUNT(*) as count
FROM booking_scrape_queue
WHERE status = 'pending' AND attempts > 0
""")
)
retry_count = retry_result.fetchone().count
return {
'statuses': status_counts,
'retries_pending': retry_count,
'total_pending': status_counts.get('pending', {}).get('count', 0),
'total_completed': status_counts.get('completed', {}).get('count', 0),
'total_failed': status_counts.get('failed', {}).get('count', 0),
}
# ============================================
# SCHEDULE INFO
# ============================================
@router.get("/schedule-info")
async def get_schedule_info(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get information about the scraping schedule."""
# Get configured time
time_result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_daily_time'")
)
time_row = time_result.fetchone()
daily_time = time_row.config_value if time_row and time_row.config_value else '05:30'
# Calculate what today's schedule would look like
from jobs.scrape_booking_rates import get_high_priority_dates, get_medium_priority_dates, get_low_priority_dates
high = get_high_priority_dates()
medium = get_medium_priority_dates()
low = get_low_priority_dates()
today = date.today()
weekday_name = today.strftime('%A')
return {
'daily_time': daily_time,
'today': today.isoformat(),
'weekday': weekday_name,
'tiers': {
'high': {
'description': 'Next 30 days (scraped first)',
'dates_today': len(high),
'range': f'{high[0].isoformat()} to {high[-1].isoformat()}' if high else None,
},
'medium': {
'description': 'Days 31-180 (scraped after high priority)',
'dates_today': len(medium),
'range': f'{medium[0].isoformat()} to {medium[-1].isoformat()}' if medium else None,
},
'low': {
'description': 'Days 181-365 (scraped last, or until rate limit)',
'dates_today': len(low),
'range': f'{low[0].isoformat()} to {low[-1].isoformat()}' if low else None,
},
},
'total_dates_today': len(set(high + medium + low)),
}
# ============================================
# SCRAPE COVERAGE (365-day view)
# ============================================
@router.get("/scrape-coverage")
async def get_scrape_coverage(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get 365-day scrape coverage showing last scraped time
and next expected scrape for every date.
"""
today = date.today()
end = today + timedelta(days=365)
# Get latest scraped_at per date (across all hotels)
result = await db.execute(
text("""
SELECT rate_date, MAX(scraped_at) as last_scraped
FROM booking_com_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
GROUP BY rate_date
"""),
{'from_date': today, 'to_date': end}
)
scraped_map = {row.rate_date: row.last_scraped for row in result.fetchall()}
# Compute tier and next scrape for each date
from jobs.scrape_booking_rates import compute_next_scrape_for_date
coverage = []
for offset in range(366):
d = today + timedelta(days=offset)
tier, next_scrape = compute_next_scrape_for_date(d)
last_scraped = scraped_map.get(d)
coverage.append({
'date': d.isoformat(),
'tier': tier,
'last_scraped': last_scraped.isoformat() if last_scraped else None,
'next_expected': next_scrape.isoformat() if next_scrape else None,
})
return {
'today': today.isoformat(),
'coverage': coverage,
}
# ============================================
# BOOKING.COM AVAILABILITY CHECK (for Bookability page)
# ============================================
@router.get("/booking-availability")
async def get_booking_availability(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Check own hotel's availability on booking.com.
Returns a simple summary: for each date in the range, whether the own hotel
appears available on booking.com based on the latest scrape data.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Get own hotel's latest scraped availability
result = await db.execute(
text("""
SELECT DISTINCT ON (r.rate_date)
r.rate_date,
r.availability_status,
r.rate_gross,
r.scraped_at
FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE h.tier = 'own'
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
ORDER BY r.rate_date, r.scraped_at DESC
"""),
{'from_date': start, 'to_date': end}
)
rows = result.fetchall()
if not rows:
return {
'has_own_hotel': False,
'dates_checked': 0,
'dates_available': 0,
'dates_sold_out': 0,
'dates_no_data': 0,
'latest_scrape': None,
'dates': {},
}
dates_map = {}
dates_available = 0
dates_sold_out = 0
dates_no_data = 0
latest_scrape = None
for row in rows:
status = row.availability_status
dates_map[row.rate_date.isoformat()] = {
'status': status,
'rate': float(row.rate_gross) if row.rate_gross else None,
}
if status == 'available':
dates_available += 1
elif status == 'sold_out':
dates_sold_out += 1
else:
dates_no_data += 1
if row.scraped_at and (not latest_scrape or row.scraped_at > latest_scrape):
latest_scrape = row.scraped_at
return {
'has_own_hotel': True,
'dates_checked': len(rows),
'dates_available': dates_available,
'dates_sold_out': dates_sold_out,
'dates_no_data': dates_no_data,
'latest_scrape': latest_scrape.isoformat() if latest_scrape else None,
'dates': dates_map,
}
# ============================================
# SCRAPE HISTORY
# ============================================
@router.get("/scrape-history")
async def get_scrape_history(
limit: int = 20,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get recent scrape batch history."""
result = await db.execute(
text("""
SELECT batch_id, scrape_type, started_at, completed_at, status,
dates_queued, dates_completed, dates_failed,
hotels_found, rates_scraped, error_message,
blocked_at, resume_after
FROM booking_scrape_log
ORDER BY started_at DESC
LIMIT :limit
"""),
{'limit': limit}
)
return [
{
'batch_id': str(row.batch_id),
'scrape_type': row.scrape_type,
'started_at': row.started_at.isoformat() if row.started_at else None,
'completed_at': row.completed_at.isoformat() if row.completed_at else None,
'status': row.status,
'dates_queued': row.dates_queued,
'dates_completed': row.dates_completed,
'dates_failed': row.dates_failed,
'hotels_found': row.hotels_found,
'rates_scraped': row.rates_scraped,
'error_message': row.error_message,
'blocked_at': row.blocked_at.isoformat() if row.blocked_at else None,
'resume_after': row.resume_after.isoformat() if row.resume_after else None,
}
for row in result.fetchall()
]

337
backend/api/direct.py Normal file
View file

@ -0,0 +1,337 @@
"""
Direct booking engine API hotel management, discovery, scrape control, rate data.
"""
import asyncio
import json
import logging
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy import text
from database import AsyncSessionLocal, SyncSessionLocal
from auth import get_current_user, require_cap
from services.direct_profiles import PROFILES, detect_profile, get_profile
from services.direct_scraper import run_discovery, get_discovery_status, run_scrape
router = APIRouter()
log = logging.getLogger(__name__)
# ─── Pydantic models ─────────────────────────────────────────────────────────
class HotelCreate(BaseModel):
name: str
profile_name: str
params: dict
class HotelUpdate(BaseModel):
name: Optional[str] = None
room_labels: Optional[dict] = None
rate_labels: Optional[dict] = None
room_order: Optional[list] = None
benchmark_room: Optional[str] = None
benchmark_rate: Optional[str] = None
tier_base_room: Optional[str] = None
tier_offsets: Optional[dict] = None
scrape_enabled: Optional[bool] = None
params: Optional[dict] = None
class DetectRequest(BaseModel):
url: str
# ─── Profiles ────────────────────────────────────────────────────────────────
@router.get("/profiles")
async def list_profiles(user=Depends(get_current_user)):
require_cap(user, "view_direct_rates")
return [
{
"name": name,
"label": cls.label,
"required_params": cls.required_params,
}
for name, cls in PROFILES.items()
]
@router.post("/profiles/detect")
async def detect_engine(req: DetectRequest, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
result = detect_profile(req.url)
if not result:
raise HTTPException(status_code=422, detail="Could not detect booking engine from URL")
return result
# ─── Hotel CRUD ───────────────────────────────────────────────────────────────
@router.get("/hotels")
async def list_hotels(user=Depends(get_current_user)):
require_cap(user, "view_direct_rates")
async with AsyncSessionLocal() as db:
result = await db.execute(
text("""SELECT h.id, h.name, h.profile_name, h.params,
h.room_labels, h.rate_labels, h.room_order,
h.benchmark_room, h.benchmark_rate, h.tier_base_room, h.tier_offsets,
h.scrape_enabled, h.last_scraped_at,
COUNT(DISTINCT r.stay_date) AS scraped_dates,
MAX(r.scraped_at) AS last_rate_at
FROM direct_competitor_hotels h
LEFT JOIN direct_rates r ON r.hotel_id = h.id
GROUP BY h.id
ORDER BY h.id""")
)
rows = result.mappings().all()
return [dict(r) for r in rows]
@router.post("/hotels", status_code=201)
async def create_hotel(body: HotelCreate, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
if body.profile_name not in PROFILES:
raise HTTPException(status_code=400, detail=f"Unknown profile: {body.profile_name}")
async with AsyncSessionLocal() as db:
result = await db.execute(
text("""INSERT INTO direct_competitor_hotels (name, profile_name, params)
VALUES (:name, :profile, :params) RETURNING id"""),
{"name": body.name, "profile": body.profile_name, "params": json.dumps(body.params)}
)
new_id = result.fetchone()[0]
await db.commit()
return {"id": new_id, "name": body.name, "profile_name": body.profile_name}
@router.put("/hotels/{hotel_id}")
async def update_hotel(hotel_id: int, body: HotelUpdate, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
updates = {}
if body.name is not None: updates["name"] = body.name
if body.scrape_enabled is not None: updates["scrape_enabled"] = body.scrape_enabled
if body.benchmark_room is not None: updates["benchmark_room"] = body.benchmark_room
if body.benchmark_rate is not None: updates["benchmark_rate"] = body.benchmark_rate
if body.tier_base_room is not None: updates["tier_base_room"] = body.tier_base_room
if body.params is not None: updates["params"] = json.dumps(body.params)
if body.room_labels is not None: updates["room_labels"] = json.dumps(body.room_labels)
if body.rate_labels is not None: updates["rate_labels"] = json.dumps(body.rate_labels)
if body.room_order is not None: updates["room_order"] = json.dumps(body.room_order)
if body.tier_offsets is not None: updates["tier_offsets"] = json.dumps(body.tier_offsets)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
set_clause = ", ".join(f"{k} = :{k}" for k in updates)
updates["hotel_id"] = hotel_id
async with AsyncSessionLocal() as db:
await db.execute(
text(f"UPDATE direct_competitor_hotels SET {set_clause} WHERE id = :hotel_id"),
updates
)
await db.commit()
return {"ok": True}
@router.delete("/hotels/{hotel_id}", status_code=204)
async def delete_hotel(hotel_id: int, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
async with AsyncSessionLocal() as db:
await db.execute(
text("DELETE FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
await db.commit()
# ─── Discovery ───────────────────────────────────────────────────────────────
@router.post("/hotels/{hotel_id}/discover")
async def trigger_discovery(hotel_id: int, background_tasks: BackgroundTasks, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
async with AsyncSessionLocal() as db:
row = await db.execute(
text("SELECT profile_name, params FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
background_tasks.add_task(
asyncio.get_event_loop().run_until_complete,
run_discovery(hotel_id, hotel["profile_name"], hotel["params"])
)
return {"status": "discovery started", "hotel_id": hotel_id}
@router.get("/hotels/{hotel_id}/discovery-status")
async def discovery_status(hotel_id: int, user=Depends(get_current_user)):
require_cap(user, "view_direct_rates")
return get_discovery_status(hotel_id)
# ─── Manual scrape trigger ────────────────────────────────────────────────────
@router.post("/hotels/{hotel_id}/scrape")
async def trigger_scrape(hotel_id: int, background_tasks: BackgroundTasks, user=Depends(get_current_user)):
require_cap(user, "manage_scraper")
async with AsyncSessionLocal() as db:
row = await db.execute(
text("SELECT profile_name, params FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
def _bg():
run_scrape(hotel_id, hotel["profile_name"], hotel["params"])
background_tasks.add_task(_bg)
return {"status": "scrape started", "hotel_id": hotel_id}
# ─── Rate data ────────────────────────────────────────────────────────────────
@router.get("/hotels/{hotel_id}/dates")
async def hotel_dates(
hotel_id: int,
from_date: date = None,
to_date: date = None,
user=Depends(get_current_user)
):
require_cap(user, "view_direct_rates")
if from_date is None:
from_date = date.today()
if to_date is None:
to_date = date.today() + timedelta(days=89)
async with AsyncSessionLocal() as db:
# Hotel config for labels/tier offsets
cfg_row = await db.execute(
text("SELECT name, room_labels, rate_labels, tier_offsets, benchmark_room, benchmark_rate, tier_base_room FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = cfg_row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
# Latest snapshot per date: cheapest available price_incl
rates_result = await db.execute(
text("""
SELECT
r.stay_date,
MIN(r.price_incl) FILTER (WHERE r.availability > 0) AS cheapest_rate,
BOOL_OR(r.availability > 0) AS has_availability,
BOOL_OR(r.min_stay_nights IS NOT NULL
AND r.min_stay_nights > 1) AS has_min_stay,
MAX(r.scraped_at) AS scraped_at
FROM (
SELECT DISTINCT ON (room_id, rate_id)
stay_date, room_id, rate_id, availability,
price_incl, min_stay_nights, scraped_at
FROM direct_rates
WHERE hotel_id = :hid
AND stay_date BETWEEN :fd AND :td
ORDER BY room_id, rate_id, scraped_at DESC
) r
GROUP BY r.stay_date
ORDER BY r.stay_date
"""),
{"hid": hotel_id, "fd": from_date, "td": to_date}
)
dates = [dict(r) for r in rates_result.mappings().all()]
return {
"hotel_id": hotel_id,
"hotel_name": hotel["name"],
"from_date": str(from_date),
"to_date": str(to_date),
"dates": dates,
}
@router.get("/hotels/{hotel_id}/date/{rate_date}/rooms")
async def hotel_date_rooms(
hotel_id: int,
rate_date: date,
user=Depends(get_current_user)
):
require_cap(user, "view_direct_rates")
async with AsyncSessionLocal() as db:
cfg_row = await db.execute(
text("SELECT room_labels, rate_labels, tier_offsets, tier_base_room, benchmark_room, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = cfg_row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
# Latest snapshot per room/rate for this date
rooms_result = await db.execute(
text("""
SELECT DISTINCT ON (room_id, rate_id)
room_id, rate_id, availability, price_excl, price_incl,
currency, min_stay_nights, scraped_at
FROM direct_rates
WHERE hotel_id = :hid AND stay_date = :sd
ORDER BY room_id, rate_id, scraped_at DESC
"""),
{"hid": hotel_id, "sd": rate_date}
)
rooms = [dict(r) for r in rooms_result.mappings().all()]
# Resolve tier-normalised benchmark rates
room_labels = hotel["room_labels"] or {}
rate_labels = hotel["rate_labels"] or {}
tier_offsets = hotel["tier_offsets"] or {}
tier_base_room = hotel["tier_base_room"]
benchmark_room = hotel["benchmark_room"]
benchmark_rate = hotel["benchmark_rate"]
# Find benchmark price
bench_price = None
if benchmark_room and benchmark_rate:
bench_match = next(
(r for r in rooms
if r["room_id"] == benchmark_room and r["rate_id"] == benchmark_rate
and r["availability"] > 0 and r["price_incl"]),
None
)
if bench_match:
bench_price = float(bench_match["price_incl"])
elif tier_base_room and tier_offsets:
base_match = next(
(r for r in rooms
if r["room_id"] == tier_base_room and r["availability"] > 0 and r["price_incl"]),
None
)
if base_match:
base_price = float(base_match["price_incl"])
bench_offset = tier_offsets.get(benchmark_room, 0)
base_offset = tier_offsets.get(tier_base_room, 0)
bench_price = base_price - base_offset + bench_offset
enriched = []
for r in rooms:
r["room_label"] = room_labels.get(r["room_id"], r["room_id"])
r["rate_label"] = rate_labels.get(r["rate_id"], r["rate_id"])
# Derive bench_rate for this room from tier offsets
r["bench_rate"] = None
if bench_price is not None and tier_offsets and tier_base_room:
room_offset = tier_offsets.get(r["room_id"])
bench_offset = tier_offsets.get(benchmark_room, 0)
if room_offset is not None:
r["bench_rate"] = round(bench_price + (room_offset - bench_offset), 2)
enriched.append(r)
return {
"hotel_id": hotel_id,
"date": str(rate_date),
"bench_price": bench_price,
"rooms": enriched,
}

69
backend/auth.py Normal file
View file

@ -0,0 +1,69 @@
"""
Auth middleware verifies the stack's hnf_session cookie using the shared
CENTRAL_AUTH_SECRET.
"""
import os
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from jose import JWTError, jwt
CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET", "")
JWT_ALGORITHM = "HS256"
APP_SLUG = os.getenv("APP_SLUG", "rates")
async def get_current_user(request: Request) -> dict:
token = request.cookies.get("hnf_session")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
try:
payload = jwt.decode(token, CENTRAL_AUTH_SECRET, algorithms=[JWT_ALGORITHM])
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session")
apps = payload.get("apps", [])
if APP_SLUG not in apps:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission for this app")
prefix = f"{APP_SLUG}:"
raw_caps = payload.get("caps", [])
if isinstance(raw_caps, list):
caps = [c[len(prefix):] for c in raw_caps if c.startswith(prefix)]
else:
caps = []
is_admin = payload.get("is_admin", False)
return {
"id": 0,
"username": payload.get("sub", ""),
"email": payload.get("sub", ""),
"display_name": payload.get("name", ""),
"name": payload.get("name", ""),
"is_admin": is_admin,
"caps": caps,
"role": "admin" if is_admin else "user",
}
def has_cap(user: dict, cap: str) -> bool:
return user.get("is_admin", False) or cap in user.get("caps", [])
def require_cap(cap: str):
async def checker(user: dict = Depends(get_current_user)):
if not has_cap(user, cap):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing capability: {cap}",
)
return user
return checker
async def get_admin_user(user: dict = Depends(get_current_user)) -> dict:
if not user.get("is_admin", False):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user

37
backend/database.py Normal file
View file

@ -0,0 +1,37 @@
"""
Database connection and session management
"""
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy import create_engine
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://rates:rates_secret@localhost:5432/rates_db")
ASYNC_DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://")
async_engine = create_async_engine(ASYNC_DATABASE_URL, echo=False)
AsyncSessionLocal = sessionmaker(
async_engine, class_=AsyncSession, expire_on_commit=False
)
sync_engine = create_engine(DATABASE_URL)
SyncSessionLocal = sessionmaker(bind=sync_engine)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
def get_sync_db():
db = SyncSessionLocal()
try:
yield db
finally:
db.close()

0
backend/jobs/__init__.py Normal file
View file

View file

@ -0,0 +1,370 @@
"""
Fetch Current Rates Job
Fetches current rack rates from Newbook API and populates newbook_current_rates table.
These rates are used by pickup-v2 model for upper bound calculations in confidence shading.
Uses a snapshot model - only inserts new rows when rates change, otherwise updates last_verified_at.
This allows tracking rate history over time.
Schedule: Daily at 5:20 AM (before pace snapshot runs)
Processing: Day-by-day with progressive DB commits. Each date is fully processed
(single-night fetch + inline multi-night verification) and saved before moving to the next.
If the job fails partway, all previously processed dates are preserved.
"""
import json
import logging
import re
from datetime import date, timedelta
from decimal import Decimal
from typing import Dict, Any, Optional, Set
import asyncio
from sqlalchemy import text
from database import SyncSessionLocal
logger = logging.getLogger(__name__)
COMMIT_BATCH_SIZE = 10 # Commit to DB every N days
def rates_changed(old_rate: Optional[Dict], new_rate: Dict) -> bool:
"""
Compare old and new rates to determine if they've changed.
Compares gross rate, net rate, and tariff availability status.
Returns True if rates have changed, False if they're the same.
"""
if old_rate is None:
return True # No existing rate, need to insert
# Compare gross and net rates
old_gross = float(old_rate.get('rate_gross') or 0)
new_gross = float(new_rate.get('gross_rate') or 0)
if abs(old_gross - new_gross) > 0.01:
return True
old_net = float(old_rate.get('rate_net') or 0)
new_net = float(new_rate.get('net_rate') or 0)
if abs(old_net - new_net) > 0.01:
return True
# Compare tariff availability
old_tariffs = old_rate.get('tariffs_data', {})
if isinstance(old_tariffs, str):
try:
old_tariffs = json.loads(old_tariffs)
except json.JSONDecodeError:
old_tariffs = {}
new_tariffs = new_rate.get('tariffs_data', {})
old_tariff_list = old_tariffs.get('tariffs', [])
new_tariff_list = new_tariffs.get('tariffs', [])
# Different number of tariffs
if len(old_tariff_list) != len(new_tariff_list):
return True
# Compare each tariff's key attributes
for old_t, new_t in zip(old_tariff_list, new_tariff_list):
# Name changed
if old_t.get('name') != new_t.get('name'):
return True
# Availability status changed
if old_t.get('success') != new_t.get('success'):
return True
# Rate changed significantly
old_rate_val = float(old_t.get('rate') or 0)
new_rate_val = float(new_t.get('rate') or 0)
if abs(old_rate_val - new_rate_val) > 0.01:
return True
# Min stay changed
if old_t.get('min_stay') != new_t.get('min_stay'):
return True
# Multi-night availability changed
if old_t.get('available_for_min_stay') != new_t.get('available_for_min_stay'):
return True
return False
def save_rate_snapshot(db, category_id: str, rate_date: date, rate: Dict) -> str:
"""
Save rate to database using snapshot logic.
If rate has changed from latest version, insert new row.
If rate is the same, just update last_verified_at.
Returns: 'inserted', 'verified', or 'error'
"""
gross_rate = rate.get('gross_rate')
net_rate = rate.get('net_rate')
tariffs_data = rate.get('tariffs_data', {})
# Get the latest rate for this category/date
existing = db.execute(
text("""
SELECT id, rate_gross, rate_net, tariffs_data
FROM newbook_current_rates
WHERE category_id = :category_id AND rate_date = :rate_date
ORDER BY valid_from DESC
LIMIT 1
"""),
{"category_id": category_id, "rate_date": rate_date}
).fetchone()
if existing:
existing_dict = {
'rate_gross': existing.rate_gross,
'rate_net': existing.rate_net,
'tariffs_data': existing.tariffs_data
}
else:
existing_dict = None
if rates_changed(existing_dict, rate):
# Rates changed - insert new snapshot
db.execute(
text("""
INSERT INTO newbook_current_rates
(category_id, rate_date, rate_gross, rate_net, tariffs_data, valid_from, last_verified_at)
VALUES (:category_id, :rate_date, :rate_gross, :rate_net,
CAST(:tariffs_data AS jsonb), NOW(), NOW())
"""),
{
"category_id": category_id,
"rate_date": rate_date,
"rate_gross": gross_rate,
"rate_net": net_rate,
"tariffs_data": json.dumps(tariffs_data)
}
)
return 'inserted'
else:
# Rates unchanged - just verify
db.execute(
text("""
UPDATE newbook_current_rates
SET last_verified_at = NOW()
WHERE id = :id
"""),
{"id": existing.id}
)
return 'verified'
def needs_multi_night_check(tariff: Dict, days_ahead: int) -> Optional[int]:
"""
Check if a tariff needs multi-night verification.
Returns the min_stay value if a multi-night check is needed, None otherwise.
Skips tariffs with advance booking restrictions that aren't met.
"""
min_stay = tariff.get('min_stay')
if not min_stay or min_stay <= 1:
return None
if tariff.get('success', False):
return None # Already available as single-night, no recheck needed
# Check for advance booking requirement
message = tariff.get('message', '') or ''
advance_match = re.search(r'(\d+)\s*days?\s*in\s*advance', message, re.IGNORECASE)
if advance_match:
min_advance_days = int(advance_match.group(1))
if days_ahead < min_advance_days:
return None # Within advance period - recheck won't help
return min_stay
async def run_fetch_current_rates(horizon_days: int = 720, start_date: date = None):
"""
Fetch current rates for all included categories and store in database.
Args:
horizon_days: Number of days ahead to fetch (default 720 for scheduled, configurable for manual)
start_date: Start date for fetch (default today)
Processing: Day-by-day with progressive commits.
For each date:
1. Fetch single-night rates (all categories in one API call)
2. Check if any tariffs need multi-night verification
3. If so, run multi-night check immediately for that date
4. Save all rates for that date to DB
5. Commit every COMMIT_BATCH_SIZE days
This means if the job fails at day 400, the first 390+ days are already saved.
"""
logger.info(f"Starting current rates fetch ({horizon_days} days)")
db = next(iter([SyncSessionLocal()]))
today = start_date or date.today()
try:
# Get VAT rate from config
vat_result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'")
).fetchone()
vat_rate_str = vat_result.config_value if vat_result and vat_result.config_value else '0.20'
# Get all included room categories
cat_result = db.execute(
text("SELECT site_id FROM newbook_room_categories WHERE is_included = true")
)
included_categories = set(row.site_id for row in cat_result.fetchall())
if not included_categories:
logger.warning("No included room categories found")
return
logger.info(f"Fetching rates for {len(included_categories)} categories")
# Import rates client
import base64
from services.newbook_rates_client import NewbookRatesClient
# Credentials: central Settings service first, app-local config fallback
from services.central_settings import get_newbook_credentials_sync
creds = get_newbook_credentials_sync()
if not creds:
config_result = db.execute(
text("""
SELECT config_key, config_value, COALESCE(is_encrypted, false) as is_encrypted
FROM system_config
WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region')
""")
)
config = {}
for row in config_result.fetchall():
value = row.config_value
if row.is_encrypted and value:
try:
value = base64.b64decode(value.encode()).decode()
except Exception:
pass # Use raw value if decryption fails
config[row.config_key] = value
if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']):
logger.error("Newbook credentials not configured")
return
creds = {
'api_key': config['newbook_api_key'],
'username': config['newbook_username'],
'password': config['newbook_password'],
'region': config['newbook_region'],
}
# Create client
client = NewbookRatesClient(
api_key=creds['api_key'],
username=creds['username'],
password=creds['password'],
region=creds['region'],
vat_rate=Decimal(vat_rate_str)
)
async with client:
inserted_total = 0
verified_total = 0
multi_night_checks = 0
skipped_advance = 0
current_date = today
day_count = 0
while current_date <= today + timedelta(days=horizon_days):
day_count += 1
days_ahead = (current_date - today).days
try:
# Step 1: Fetch single-night rates for all categories on this date
day_rates = await client.fetch_single_date_all_categories(
current_date, guests_adults=2, guests_children=0
)
# Step 2: Check for multi-night verification needs and run inline
# Collect unique min_stay values needed for this date
nights_needed: Set[int] = set()
for cat_id, rates in day_rates.items():
if cat_id not in included_categories:
continue
for rate in rates:
for tariff in rate.get('tariffs_data', {}).get('tariffs', []):
check = needs_multi_night_check(tariff, days_ahead)
if check:
nights_needed.add(check)
elif tariff.get('min_stay') and tariff['min_stay'] > 1 and not tariff.get('success', False):
skipped_advance += 1
# Step 3: Run multi-night checks for this date if needed
multi_night_results: Dict[int, Dict[str, Dict[str, bool]]] = {}
for nights in sorted(nights_needed):
try:
result = await client.fetch_multi_night_for_date(
current_date, nights
)
multi_night_results[nights] = result
multi_night_checks += 1
await asyncio.sleep(1.0) # Rate limiting
except Exception as e:
logger.warning(f"Multi-night check failed for {current_date} ({nights}n): {e}")
# Step 4: Update tariffs with multi-night results and save to DB
for cat_id, rates in day_rates.items():
if cat_id not in included_categories:
continue
for rate in rates:
tariffs_data = rate.get('tariffs_data', {})
# Apply multi-night results to tariffs
for tariff in tariffs_data.get('tariffs', []):
min_stay = tariff.get('min_stay')
if min_stay and min_stay > 1 and min_stay in multi_night_results:
cat_availability = multi_night_results[min_stay].get(cat_id, {})
tariff_name = tariff.get('name', '')
tariff['available_for_min_stay'] = cat_availability.get(tariff_name, False)
# Save to DB
result = save_rate_snapshot(db, cat_id, current_date, rate)
if result == 'inserted':
inserted_total += 1
elif result == 'verified':
verified_total += 1
if day_count % 50 == 0 or nights_needed:
logger.info(
f"Day {day_count}/{horizon_days}: {current_date}"
f" | {inserted_total} new, {verified_total} verified"
f"{f' | {len(nights_needed)} multi-night checks' if nights_needed else ''}"
)
except Exception as e:
logger.warning(f"Failed to fetch rates for {current_date}: {e}")
# Step 5: Commit periodically
if day_count % COMMIT_BATCH_SIZE == 0:
db.commit()
current_date += timedelta(days=1)
await asyncio.sleep(1.0) # Rate limiting between days
# Final commit for remaining days
db.commit()
if skipped_advance > 0:
logger.info(f"Skipped {skipped_advance} multi-night checks (advance booking restriction)")
logger.info(
f"Complete: {inserted_total} new snapshots, {verified_total} verified unchanged, "
f"{multi_night_checks} multi-night checks"
)
logger.info("Current rates fetch completed successfully")
except Exception as e:
logger.error(f"Current rates fetch failed: {e}")
db.rollback()
raise
finally:
db.close()

View file

@ -0,0 +1,165 @@
"""
Scheduled Booking.com Rate Scraping Job
Priority-based scheduling for 365-day coverage (all queued daily):
- High (priority 10): next 30 days
- Medium (priority 5): days 31-180
- Low (priority 2): days 181-365
Queue processes in priority order. If rate-limited/blocked, lower priority
dates remain queued for the next run.
Uses a queue-based approach:
1. Populate the queue with dates and priorities
2. Process the queue in priority order
3. Failed dates are retried (up to 3 attempts)
4. On blocking, the queue pauses and resumes after cooldown
Schedule: Daily at configurable time (default 05:30)
"""
import asyncio
import logging
from datetime import date, timedelta
from sqlalchemy import text
from database import SyncSessionLocal
from services.booking_scraper import (
populate_queue,
process_queue,
clear_old_queue_items,
cleanup_stale_batches,
get_scrape_config,
)
logger = logging.getLogger(__name__)
# Priority levels (higher = processed first)
PRIORITY_HIGH = 10 # 0-30 days
PRIORITY_MEDIUM = 5 # 31-180 days
PRIORITY_LOW = 2 # 181-365 days
def get_high_priority_dates() -> list[date]:
"""High priority: today + 30 days."""
today = date.today()
return [today + timedelta(days=i) for i in range(31)]
def get_medium_priority_dates() -> list[date]:
"""Medium priority: days 31-180."""
today = date.today()
return [today + timedelta(days=i) for i in range(31, 181)]
def get_low_priority_dates() -> list[date]:
"""Low priority: days 181-365."""
today = date.today()
return [today + timedelta(days=i) for i in range(181, 366)]
def compute_next_scrape_for_date(target_date: date) -> tuple[str, date | None]:
"""
For a target date, determine its priority tier and when it will next be scraped.
Returns (tier, next_scrape_date) where tier is 'high'/'medium'/'low'/'none'.
All dates are queued daily, so next scrape is always today (or tomorrow if
today's run has passed).
"""
today = date.today()
offset = (target_date - today).days
if offset < 0:
return ('none', None)
if offset > 365:
return ('none', None)
# All tiers run daily - next scrape is today
if offset <= 30:
return ('high', today)
elif offset <= 180:
return ('medium', today)
else:
return ('low', today)
def run_scheduled_booking_scrape():
"""
Main scheduled job: populate queue with today's dates, then process.
"""
db = SyncSessionLocal()
try:
# Check if scraper is enabled
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_enabled'")
).fetchone()
if not result or result.config_value != 'true':
logger.debug("Scheduled booking scrape skipped (disabled)")
return
if not get_scrape_config(db):
logger.warning("Scheduled booking scrape skipped (no location configured)")
return
# Clean up stale running batches and old queue items
cleanup_stale_batches(db, max_age_minutes=120)
clear_old_queue_items(db, days=3)
# Gather dates with priorities
high = get_high_priority_dates()
medium = get_medium_priority_dates()
low = get_low_priority_dates()
priorities = {}
for d in high:
priorities[d] = PRIORITY_HIGH
for d in medium:
priorities[d] = max(priorities.get(d, 0), PRIORITY_MEDIUM)
for d in low:
priorities[d] = max(priorities.get(d, 0), PRIORITY_LOW)
all_dates = sorted(priorities.keys())
if not all_dates:
logger.info("Scheduled booking scrape: no dates to scrape today")
return
logger.info(
f"Scheduled booking scrape: queuing {len(all_dates)} dates "
f"(high={len(high)}, medium={len(medium)}, low={len(low)})"
)
# Populate queue
populate_queue(db, all_dates, priorities)
# Process queue
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(process_queue(db))
if result.get('success'):
logger.info(
f"Scheduled booking scrape completed: "
f"{result.get('dates_completed', 0)} dates, "
f"{result.get('rates_scraped', 0)} rates"
)
elif result.get('blocked'):
logger.warning(
f"Scheduled booking scrape blocked: {result.get('block_reason')}. "
f"Completed {result.get('dates_completed', 0)} dates. "
f"Remaining dates stay queued for retry."
)
else:
logger.error(f"Scheduled booking scrape failed: {result.get('error')}")
finally:
loop.close()
except Exception as e:
logger.error(f"Scheduled booking scrape error: {e}", exc_info=True)
finally:
db.close()
async def run_scheduled_booking_scrape_async():
"""Async wrapper for APScheduler."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, run_scheduled_booking_scrape)

View file

@ -0,0 +1,43 @@
"""
Daily job: scrape all enabled direct competitor hotels.
Hotels are scraped sequentially the scraper enforces 10s delays per date
to avoid rate-limiting, so concurrent scraping is not beneficial.
"""
import logging
from sqlalchemy import text
from database import SyncSessionLocal
from services.direct_scraper import run_scrape
log = logging.getLogger(__name__)
def run_scrape_all_direct():
db = SyncSessionLocal()
try:
rows = db.execute(
text("""SELECT id, name, profile_name, params
FROM direct_competitor_hotels
WHERE scrape_enabled = true
ORDER BY id""")
).mappings().fetchall()
finally:
db.close()
if not rows:
log.info("No direct competitor hotels enabled for scraping")
return
log.info(f"Starting direct rate scrape for {len(rows)} hotels")
for hotel in rows:
try:
log.info(f"Scraping {hotel['name']} ({hotel['profile_name']})")
run_scrape(
hotel_id=hotel["id"],
profile_name=hotel["profile_name"],
params=hotel["params"],
)
except Exception as e:
log.error(f"Direct scrape failed for hotel {hotel['id']} ({hotel['name']}): {e}")
log.info("Direct rate scrape complete")

80
backend/main.py Normal file
View file

@ -0,0 +1,80 @@
"""
Rate Monitor FastAPI Backend
"""
import logging
import sys
from contextlib import asynccontextmanager
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import DATABASE_URL
from api import bookability, competitors, analysis, direct
from scheduler import start_scheduler, shutdown_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
import os, psycopg2
schema_path = os.path.join(os.path.dirname(__file__), 'schema.sql')
if os.path.exists(schema_path):
with open(schema_path) as f:
sql = f.read()
conn = psycopg2.connect(DATABASE_URL)
try:
conn.autocommit = True
with conn.cursor() as cur:
cur.execute(sql)
logging.getLogger(__name__).info("Schema applied successfully")
finally:
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"Schema init failed: {e}")
try:
from services.booking_scraper import cleanup_stale_batches
from database import SyncSessionLocal
db = SyncSessionLocal()
try:
cleanup_stale_batches(db, max_age_minutes=10)
finally:
db.close()
except Exception as e:
logging.getLogger(__name__).warning(f"Stale batch cleanup failed: {e}")
start_scheduler()
yield
shutdown_scheduler()
app = FastAPI(
title="Rate Monitor API",
description="Rate monitoring, competitor scraping, and bookability service",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(bookability.router, prefix="/bookability", tags=["Bookability"])
app.include_router(competitors.router, prefix="/competitors", tags=["Competitors"])
app.include_router(analysis.router, prefix="/analysis", tags=["Rate Analysis"])
app.include_router(direct.router, prefix="/direct", tags=["Direct Rates"])
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "rate-monitor-api"}

View file

@ -0,0 +1,167 @@
"""
One-off migration: copy guestline-monitor SQLite data into PostgreSQL.
Run manually after deploy:
docker compose exec backend python migrate_direct_data.py
"""
import json
import os
import sqlite3
import sys
from datetime import datetime, timezone
import psycopg2
ARCHIVE_DIR = os.environ.get(
"GUESTLINE_ARCHIVE",
"/home/jtr/laptop-archive/guestline-monitor/data"
)
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://rates:rates_secret@localhost:5432/rates_db")
CONFIG_DB = os.path.join(ARCHIVE_DIR, "config.db")
def dict_row(cursor, row):
return {col[0]: val for col, val in zip(cursor.description, row)}
def migrate():
if not os.path.exists(CONFIG_DB):
print(f"ERROR: config.db not found at {CONFIG_DB}", file=sys.stderr)
print("Set GUESTLINE_ARCHIVE env var to the data directory path.")
sys.exit(1)
pg = psycopg2.connect(DATABASE_URL)
pg.autocommit = False
# ── Read hotel configs from SQLite ──────────────────────────────────────
src = sqlite3.connect(CONFIG_DB)
src.row_factory = dict_row
hotels = src.execute(
"""SELECT id, name, profile, params, room_labels, rate_labels,
room_order, benchmark_room, benchmark_rate,
tier_base_room, tier_offsets, scrape_enabled, last_scraped_at
FROM hotels ORDER BY id"""
).fetchall()
src.close()
if not hotels:
print("No hotels found in config.db — nothing to migrate.")
return
print(f"Migrating {len(hotels)} hotels...")
# Map old SQLite hotel IDs to new PostgreSQL IDs
id_map: dict[int, int] = {}
with pg.cursor() as cur:
for h in hotels:
params = h["params"] if isinstance(h["params"], str) else json.dumps(h["params"] or {})
cur.execute(
"""INSERT INTO direct_competitor_hotels
(name, profile_name, params, room_labels, rate_labels, room_order,
benchmark_room, benchmark_rate, tier_base_room, tier_offsets,
scrape_enabled, last_scraped_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
h["name"],
h["profile"],
params,
h["room_labels"] or "{}",
h["rate_labels"] or "{}",
h["room_order"] or "[]",
h["benchmark_room"],
h["benchmark_rate"],
h["tier_base_room"],
h["tier_offsets"] or "{}",
bool(h["scrape_enabled"]),
h["last_scraped_at"],
)
)
new_id = cur.fetchone()[0]
id_map[h["id"]] = new_id
print(f" Hotel {h['id']}{new_id}: {h['name']}")
pg.commit()
# ── Migrate snapshot data from per-hotel SQLite DBs ─────────────────────
total_rows = 0
for old_id, new_id in id_map.items():
db_path = os.path.join(ARCHIVE_DIR, f"hotel_{old_id}.db")
if not os.path.exists(db_path):
print(f" hotel_{old_id}.db not found — skipping snapshot data")
continue
src = sqlite3.connect(db_path)
src.row_factory = dict_row
# Migrate scrape_runs first
runs = src.execute(
"SELECT id, scraped_at, dates_found, rows_saved FROM scrape_runs ORDER BY id"
).fetchall()
run_id_map: dict[int, int] = {}
with pg.cursor() as cur:
for run in runs:
cur.execute(
"""INSERT INTO direct_scrape_runs (hotel_id, scraped_at, dates_found, rows_saved)
VALUES (%s, %s, %s, %s) RETURNING id""",
(new_id, run["scraped_at"], run["dates_found"] or 0, run["rows_saved"] or 0)
)
run_id_map[run["id"]] = cur.fetchone()[0]
pg.commit()
# Migrate snapshots in batches
BATCH = 2000
offset = 0
hotel_rows = 0
while True:
rows = src.execute(
"""SELECT scrape_run_id, scraped_at, stay_date, room_id, rate_id,
availability, price_excl, price_incl, currency, min_stay_nights
FROM snapshots ORDER BY id LIMIT ? OFFSET ?""",
(BATCH, offset)
).fetchall()
if not rows:
break
with pg.cursor() as cur:
psycopg2.extras.execute_values(
cur,
"""INSERT INTO direct_rates
(hotel_id, scrape_run_id, scraped_at, stay_date, room_id, rate_id,
availability, price_excl, price_incl, currency, min_stay_nights)
VALUES %s""",
[
(
new_id,
run_id_map.get(r["scrape_run_id"]),
r["scraped_at"],
r["stay_date"],
r["room_id"],
r["rate_id"],
r["availability"] or 0,
r["price_excl"],
r["price_incl"],
r["currency"] or "GBP",
r["min_stay_nights"],
)
for r in rows
]
)
pg.commit()
hotel_rows += len(rows)
offset += BATCH
print(f" {hotel_rows} rows migrated for hotel {old_id}...", end="\r")
print(f" hotel_{old_id}: {hotel_rows} snapshot rows migrated")
total_rows += hotel_rows
src.close()
pg.close()
print(f"\nMigration complete: {len(hotels)} hotels, {total_rows} snapshot rows.")
if __name__ == "__main__":
import psycopg2.extras
migrate()

14
backend/requirements.txt Normal file
View file

@ -0,0 +1,14 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
asyncpg==0.29.0
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
python-multipart==0.0.6
httpx==0.26.0
apscheduler==3.10.4
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
python-dateutil==2.8.2
playwright>=1.40.0

120
backend/scheduler.py Normal file
View file

@ -0,0 +1,120 @@
"""
APScheduler configuration for rate monitor jobs
"""
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import text
from database import SyncSessionLocal
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler(
job_defaults={
'misfire_grace_time': 3600,
'coalesce': True,
}
)
def get_config_value(key: str, default: str = None) -> str:
db = SyncSessionLocal()
try:
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = :key"),
{"key": key}
)
row = result.fetchone()
if row and row.config_value:
return row.config_value
return default
except Exception as e:
logger.error(f"Error getting config {key}: {e}")
return default
finally:
db.close()
def is_sync_enabled(source: str) -> bool:
value = get_config_value(f"sync_{source}_enabled")
if value:
return value.lower() in ('true', '1', 'yes', 'enabled')
return False
def get_sync_time(source: str, default_hour: int = 5, default_minute: int = 0) -> tuple:
time_str = get_config_value(f"sync_{source}_time")
if time_str:
try:
parts = time_str.split(':')
return (int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
pass
return (default_hour, default_minute)
async def run_scheduled_direct_scrape():
from jobs.scrape_direct_rates import run_scrape_all_direct
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, run_scrape_all_direct)
async def run_scheduled_booking_scrape_async():
from jobs.scrape_booking_rates import run_scheduled_booking_scrape
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, run_scheduled_booking_scrape)
async def run_scheduled_fetch_current_rates():
if is_sync_enabled("newbook_current_rates"):
from jobs.fetch_current_rates import run_fetch_current_rates
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, run_fetch_current_rates)
else:
logger.debug("Newbook current rates sync skipped (disabled)")
def start_scheduler():
# Booking.com scrape — daily at configurable time (default 05:30)
scrape_time = get_config_value('booking_scraper_daily_time', '05:30')
try:
h, m = scrape_time.split(':')
scrape_hour, scrape_minute = int(h), int(m)
except Exception:
scrape_hour, scrape_minute = 5, 30
scheduler.add_job(
run_scheduled_booking_scrape_async,
CronTrigger(hour=scrape_hour, minute=scrape_minute),
id='booking_scrape',
replace_existing=True,
)
# Newbook current rates fetch — daily at 05:20
rates_hour, rates_minute = get_sync_time('newbook_current_rates', 5, 20)
scheduler.add_job(
run_scheduled_fetch_current_rates,
CronTrigger(hour=rates_hour, minute=rates_minute),
id='fetch_current_rates',
replace_existing=True,
)
# Direct booking engine scrape — daily at 06:00
scheduler.add_job(
run_scheduled_direct_scrape,
CronTrigger(hour=6, minute=0),
id='scrape_direct_rates',
replace_existing=True,
)
scheduler.start()
logger.info(f"Scheduler started: booking scrape at {scrape_hour:02d}:{scrape_minute:02d}, rates fetch at {rates_hour:02d}:{rates_minute:02d}, direct scrape at 06:00")
def shutdown_scheduler():
if scheduler.running:
scheduler.shutdown()

267
backend/schema.sql Normal file
View file

@ -0,0 +1,267 @@
-- Rate Monitor — Database Schema
-- All CREATE TABLE/INDEX are idempotent (IF NOT EXISTS)
-- ============================================
-- SYSTEM CONFIG
-- ============================================
CREATE TABLE IF NOT EXISTS system_config (
config_key VARCHAR(100) PRIMARY KEY,
config_value TEXT,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by VARCHAR(255)
);
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_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'),
('booking_scraper_proxy_password', NULL, 'Proxy password for playwright_proxy backend'),
('sync_newbook_current_rates_enabled', 'false', 'Enable automatic Newbook current rates sync (true/false)'),
('sync_newbook_current_rates_time', '05:20', 'Newbook current rates sync time (HH:MM)')
ON CONFLICT (config_key) DO NOTHING;
-- ============================================
-- BOOKING.COM SCRAPE LOG (batch tracking)
-- ============================================
CREATE TABLE IF NOT EXISTS booking_scrape_log (
batch_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scrape_type VARCHAR(50) NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'running',
hotels_found INTEGER DEFAULT 0,
rates_scraped INTEGER DEFAULT 0,
dates_queued INTEGER DEFAULT 0,
dates_completed INTEGER DEFAULT 0,
dates_failed INTEGER DEFAULT 0,
error_message TEXT,
blocked_at TIMESTAMPTZ,
resume_after TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_booking_scrape_log_started ON booking_scrape_log(started_at DESC);
-- ============================================
-- BOOKING.COM SCRAPE CONFIG
-- ============================================
CREATE TABLE IF NOT EXISTS booking_scrape_config (
id SERIAL PRIMARY KEY,
location_name VARCHAR(255) NOT NULL,
location_search_url TEXT,
pages_to_scrape INTEGER DEFAULT 2,
adults INTEGER DEFAULT 2,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================
-- BOOKING.COM SCRAPE QUEUE
-- ============================================
CREATE TABLE IF NOT EXISTS booking_scrape_queue (
id SERIAL PRIMARY KEY,
rate_date DATE NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
priority INTEGER DEFAULT 0,
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 3,
last_attempt_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
error_message TEXT,
UNIQUE (rate_date, status)
);
CREATE INDEX IF NOT EXISTS idx_booking_scrape_queue_pending
ON booking_scrape_queue(status, priority DESC, rate_date ASC)
WHERE status = 'pending';
-- ============================================
-- BOOKING.COM HOTELS
-- ============================================
CREATE TABLE IF NOT EXISTS booking_com_hotels (
id SERIAL PRIMARY KEY,
booking_com_id VARCHAR(50) UNIQUE,
name VARCHAR(255) NOT NULL,
booking_com_url TEXT,
star_rating DECIMAL(2,1),
review_score DECIMAL(3,1),
review_count INTEGER DEFAULT 0,
tier VARCHAR(20) DEFAULT 'market', -- own | competitor | market
display_order INTEGER DEFAULT 999,
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
first_seen_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================
-- BOOKING.COM RATES
-- ============================================
CREATE TABLE IF NOT EXISTS booking_com_rates (
id SERIAL PRIMARY KEY,
hotel_id INTEGER NOT NULL REFERENCES booking_com_hotels(id) ON DELETE CASCADE,
rate_date DATE NOT NULL,
availability_status VARCHAR(30), -- available | sold_out | no_data
rate_gross DECIMAL(10,2),
currency VARCHAR(10) DEFAULT 'GBP',
room_type VARCHAR(100),
breakfast_included BOOLEAN DEFAULT FALSE,
free_cancellation BOOLEAN DEFAULT FALSE,
no_prepayment BOOLEAN DEFAULT FALSE,
rooms_left INTEGER,
scrape_batch_id UUID REFERENCES booking_scrape_log(batch_id),
scraped_at TIMESTAMPTZ DEFAULT NOW()
);
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);
-- ============================================
-- RATE PARITY ALERTS
-- ============================================
CREATE TABLE IF NOT EXISTS rate_parity_alerts (
id SERIAL PRIMARY KEY,
rate_date DATE NOT NULL,
room_category VARCHAR(255),
newbook_rate DECIMAL(12,2),
booking_com_rate DECIMAL(12,2),
difference_pct DECIMAL(8,2),
alert_type VARCHAR(20), -- higher | lower
alert_status VARCHAR(20) DEFAULT 'active', -- active | acknowledged
created_at TIMESTAMPTZ DEFAULT NOW(),
acknowledged_at TIMESTAMPTZ,
acknowledged_by VARCHAR(255),
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_rate_parity_alerts_date ON rate_parity_alerts(rate_date DESC);
CREATE INDEX IF NOT EXISTS idx_rate_parity_alerts_status ON rate_parity_alerts(alert_status);
-- ============================================
-- NEWBOOK CURRENT RATES (own hotel, for bookability display)
-- ============================================
CREATE TABLE IF NOT EXISTS newbook_current_rates (
id SERIAL PRIMARY KEY,
category_id VARCHAR(50) NOT NULL,
rate_date DATE NOT NULL,
gross_rate DECIMAL(12,2),
net_rate DECIMAL(12,2),
tariffs_data JSONB DEFAULT '{}',
valid_from TIMESTAMP DEFAULT NOW(),
last_verified_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_current_rates_date ON newbook_current_rates(rate_date);
CREATE INDEX IF NOT EXISTS idx_current_rates_category ON newbook_current_rates(category_id);
CREATE INDEX IF NOT EXISTS idx_current_rates_tariffs ON newbook_current_rates USING gin(tariffs_data);
CREATE INDEX IF NOT EXISTS idx_current_rates_latest ON newbook_current_rates(category_id, rate_date, valid_from DESC);
-- ============================================
-- NEWBOOK ROOM CATEGORIES (for bookability display)
-- ============================================
CREATE TABLE IF NOT EXISTS newbook_room_categories (
id SERIAL PRIMARY KEY,
site_id VARCHAR(50) NOT NULL UNIQUE,
site_name VARCHAR(255) NOT NULL,
site_type VARCHAR(100),
room_count INTEGER DEFAULT 0,
is_included BOOLEAN DEFAULT TRUE,
display_order INTEGER DEFAULT 0,
fetched_at TIMESTAMP DEFAULT NOW()
);
-- ============================================
-- NEWBOOK OCCUPANCY REPORT DATA
-- ============================================
CREATE TABLE IF NOT EXISTS newbook_occupancy_report_data (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
category_id VARCHAR(50) NOT NULL,
category_name VARCHAR(255),
available INTEGER DEFAULT 0,
occupied INTEGER DEFAULT 0,
maintenance INTEGER DEFAULT 0,
allotted INTEGER DEFAULT 0,
revenue_gross DECIMAL(12,2) DEFAULT 0,
revenue_net DECIMAL(12,2) DEFAULT 0,
occupancy_pct DECIMAL(5,2),
fetched_at TIMESTAMP DEFAULT NOW(),
UNIQUE(date, category_id)
);
CREATE INDEX IF NOT EXISTS idx_occupancy_report_date ON newbook_occupancy_report_data(date);
-- ============================================
-- DIRECT COMPETITOR HOTEL CONFIGS
-- ============================================
CREATE TABLE IF NOT EXISTS direct_competitor_hotels (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
profile_name VARCHAR(50) NOT NULL,
params JSONB NOT NULL DEFAULT '{}',
room_labels JSONB DEFAULT '{}',
rate_labels JSONB DEFAULT '{}',
room_order JSONB DEFAULT '[]',
benchmark_room VARCHAR(100),
benchmark_rate VARCHAR(100),
tier_base_room VARCHAR(100),
tier_offsets JSONB DEFAULT '{}',
scrape_enabled BOOLEAN DEFAULT TRUE,
last_scraped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================
-- DIRECT SCRAPE RUNS
-- ============================================
CREATE TABLE IF NOT EXISTS direct_scrape_runs (
id SERIAL PRIMARY KEY,
hotel_id INTEGER NOT NULL REFERENCES direct_competitor_hotels(id) ON DELETE CASCADE,
scraped_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
dates_found INTEGER DEFAULT 0,
rows_saved INTEGER DEFAULT 0
);
-- ============================================
-- DIRECT RATES (per-room snapshots from booking engines)
-- ============================================
CREATE TABLE IF NOT EXISTS direct_rates (
id SERIAL PRIMARY KEY,
hotel_id INTEGER NOT NULL REFERENCES direct_competitor_hotels(id) ON DELETE CASCADE,
scrape_run_id INTEGER REFERENCES direct_scrape_runs(id),
scraped_at TIMESTAMPTZ NOT NULL,
stay_date DATE NOT NULL,
room_id VARCHAR(100) NOT NULL,
rate_id VARCHAR(100) NOT NULL,
availability INTEGER DEFAULT 0,
price_excl DECIMAL(10,2),
price_incl DECIMAL(10,2),
currency VARCHAR(10) DEFAULT 'GBP',
min_stay_nights INTEGER
);
CREATE INDEX IF NOT EXISTS idx_direct_rates_hotel_date ON direct_rates(hotel_id, stay_date);
CREATE INDEX IF NOT EXISTS idx_direct_rates_scraped ON direct_rates(scraped_at DESC);
-- Link booking_com_hotels to direct_competitor_hotels (optional, for Market View direct column)
ALTER TABLE booking_com_hotels ADD COLUMN IF NOT EXISTS direct_hotel_id INTEGER
REFERENCES direct_competitor_hotels(id) ON DELETE SET NULL;

View file

View file

@ -0,0 +1,829 @@
"""
Booking.com Rate Scraper Service
Main service for scraping competitor rates from booking.com.
Uses pluggable backends (Playwright local, proxy, Apify) via factory pattern.
Features:
- Location-based search (1 query = 40+ hotels)
- Hotel discovery and tier management
- Rate extraction with availability status
- Anti-scrape detection and pause/resume
"""
import logging
import uuid
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import List, Optional, Dict, Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from .scraper_backends import ScraperBackend, PlaywrightLocalBackend, HotelData, RateData, AvailabilityStatus
logger = logging.getLogger(__name__)
def get_scraper_backend(db: Session) -> ScraperBackend:
"""
Factory to get configured scraper backend.
Reads backend type from system_config and returns appropriate instance.
"""
# Get backend configuration
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_backend'")
).fetchone()
backend_type = result.config_value if result and result.config_value else 'playwright_local'
if backend_type == 'playwright_local':
return PlaywrightLocalBackend()
elif backend_type == 'playwright_proxy':
# Get proxy config
proxy_result = db.execute(
text("""
SELECT config_key, config_value FROM system_config
WHERE config_key IN ('booking_scraper_proxy_url', 'booking_scraper_proxy_username', 'booking_scraper_proxy_password')
""")
)
proxy_config = {row.config_key: row.config_value for row in proxy_result.fetchall()}
return PlaywrightLocalBackend(proxy_config=proxy_config)
elif backend_type == 'apify':
# Future: Apify backend
raise NotImplementedError("Apify backend not yet implemented")
else:
logger.warning(f"Unknown backend type '{backend_type}', falling back to playwright_local")
return PlaywrightLocalBackend()
def get_scrape_config(db: Session) -> Optional[Dict[str, Any]]:
"""Get the active scrape location configuration."""
result = db.execute(
text("""
SELECT id, location_name, location_search_url, pages_to_scrape, adults
FROM booking_scrape_config
WHERE is_active = TRUE
ORDER BY id
LIMIT 1
""")
).fetchone()
if not result:
return None
return {
'id': result.id,
'location_name': result.location_name,
'location_search_url': result.location_search_url,
'pages_to_scrape': result.pages_to_scrape or 2,
'adults': result.adults or 2,
}
async def is_scraper_paused(db: Session) -> bool:
"""Check if scraper is currently paused due to blocking."""
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_paused'")
).fetchone()
if not result or result.config_value != 'true':
return False
# Check if pause period has expired
pause_until_result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_pause_until'")
).fetchone()
if pause_until_result and pause_until_result.config_value:
try:
pause_until = datetime.fromisoformat(pause_until_result.config_value)
if datetime.now() >= pause_until:
# Pause expired, reset
db.execute(
text("UPDATE system_config SET config_value = 'false' WHERE config_key = 'booking_scraper_paused'")
)
db.commit()
return False
except ValueError:
pass
return True
async def set_scraper_paused(db: Session, paused: bool, hours: int = 2):
"""Set scraper pause status."""
db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_paused'"),
{'val': 'true' if paused else 'false'}
)
if paused:
pause_until = (datetime.now() + timedelta(hours=hours)).isoformat()
db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_pause_until'"),
{'val': pause_until}
)
db.commit()
def save_hotel(db: Session, hotel: HotelData) -> int:
"""
Save or update a hotel in the database.
Returns the hotel's database ID.
"""
# Check if hotel exists
existing = db.execute(
text("SELECT id FROM booking_com_hotels WHERE booking_com_id = :bid"),
{'bid': hotel.booking_com_id}
).fetchone()
if existing:
# Update last_seen_at and any changed fields
db.execute(
text("""
UPDATE booking_com_hotels SET
name = COALESCE(:name, name),
booking_com_url = COALESCE(:url, booking_com_url),
star_rating = COALESCE(:stars, star_rating),
review_score = COALESCE(:score, review_score),
review_count = COALESCE(:count, review_count),
last_seen_at = NOW()
WHERE booking_com_id = :bid
"""),
{
'bid': hotel.booking_com_id,
'name': hotel.name,
'url': hotel.booking_com_url,
'stars': float(hotel.star_rating) if hotel.star_rating else None,
'score': float(hotel.review_score) if hotel.review_score else None,
'count': hotel.review_count,
}
)
return existing.id
else:
# Insert new hotel (default tier is 'market')
result = db.execute(
text("""
INSERT INTO booking_com_hotels
(booking_com_id, name, booking_com_url, star_rating, review_score, review_count, tier)
VALUES (:bid, :name, :url, :stars, :score, :count, 'market')
RETURNING id
"""),
{
'bid': hotel.booking_com_id,
'name': hotel.name,
'url': hotel.booking_com_url,
'stars': float(hotel.star_rating) if hotel.star_rating else None,
'score': float(hotel.review_score) if hotel.review_score else None,
'count': hotel.review_count,
}
)
return result.fetchone().id
def save_rate(db: Session, rate: RateData, hotel_id: int, batch_id: uuid.UUID):
"""Save a rate to the database."""
db.execute(
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)
VALUES (:hotel_id, :rate_date, :status, :rate, :currency, :room_type,
:breakfast, :cancel, :prepay, :rooms_left, :batch_id)
"""),
{
'hotel_id': hotel_id,
'rate_date': rate.rate_date,
'status': rate.availability_status.value,
'rate': float(rate.rate_gross) if rate.rate_gross else None,
'currency': rate.currency,
'room_type': rate.room_type,
'breakfast': rate.breakfast_included,
'cancel': rate.free_cancellation,
'prepay': rate.no_prepayment,
'rooms_left': rate.rooms_left,
'batch_id': str(batch_id),
}
)
def create_scrape_batch(db: Session, scrape_type: str) -> uuid.UUID:
"""Create a new scrape batch log entry."""
batch_id = uuid.uuid4()
db.execute(
text("""
INSERT INTO booking_scrape_log
(batch_id, scrape_type, started_at, status)
VALUES (:batch_id, :scrape_type, NOW(), 'running')
"""),
{'batch_id': str(batch_id), 'scrape_type': scrape_type}
)
db.commit()
return batch_id
def update_scrape_batch(
db: Session,
batch_id: uuid.UUID,
status: str,
hotels_found: int = 0,
rates_scraped: int = 0,
error_message: str = None,
blocked: bool = False
):
"""Update scrape batch log with results."""
db.execute(
text("""
UPDATE booking_scrape_log SET
completed_at = CASE WHEN :status IN ('completed', 'failed', 'blocked') THEN NOW() ELSE NULL END,
status = :status,
hotels_found = :hotels,
rates_scraped = :rates,
error_message = :error,
blocked_at = CASE WHEN :blocked THEN NOW() ELSE NULL END,
resume_after = CASE WHEN :blocked THEN NOW() + INTERVAL '2 hours' ELSE NULL END
WHERE batch_id = :batch_id
"""),
{
'batch_id': str(batch_id),
'status': status,
'hotels': hotels_found,
'rates': rates_scraped,
'error': error_message,
'blocked': blocked,
}
)
db.commit()
def cleanup_stale_batches(db: Session, max_age_minutes: int = 60):
"""
Mark any 'running' scrape batches as 'failed' if they've been running
longer than max_age_minutes. This handles orphaned batches from
container restarts or crashes.
"""
result = db.execute(
text("""
UPDATE booking_scrape_log SET
status = 'failed',
completed_at = NOW(),
error_message = 'Interrupted (container restart or timeout)'
WHERE status = 'running'
AND started_at < NOW() - INTERVAL ':mins minutes'
RETURNING batch_id
""".replace(':mins', str(int(max_age_minutes))))
)
cleaned = result.fetchall()
db.commit()
if cleaned:
logger.info(f"Cleaned up {len(cleaned)} stale running scrape batch(es)")
return len(cleaned)
async def scrape_date(
db: Session,
rate_date: date,
backend: ScraperBackend,
config: Dict[str, Any],
batch_id: uuid.UUID
) -> Dict[str, Any]:
"""
Scrape rates for a single date.
Args:
db: Database session
rate_date: Date to scrape rates for
backend: Scraper backend instance
config: Scrape configuration
batch_id: Current batch ID
Returns:
Dict with 'success', 'blocked', 'hotels_count', 'rates_count'
"""
check_in = rate_date
check_out = rate_date + timedelta(days=1) # Single night
result = await backend.scrape_location_search(
location=config['location_name'],
check_in=check_in,
check_out=check_out,
adults=config['adults'],
pages=config['pages_to_scrape']
)
if result.blocked:
return {
'success': False,
'blocked': True,
'block_reason': result.block_reason,
'hotels_count': 0,
'rates_count': 0,
}
if not result.success:
return {
'success': False,
'blocked': False,
'error': result.error_message,
'hotels_count': 0,
'rates_count': 0,
}
# Save hotels and rates
hotels_saved = 0
rates_saved = 0
for hotel, rate in zip(result.hotels, result.rates):
if not hotel.booking_com_id:
continue
try:
hotel_id = save_hotel(db, hotel)
save_rate(db, rate, hotel_id, batch_id)
hotels_saved += 1
rates_saved += 1
except Exception as e:
logger.warning(f"Error saving hotel/rate: {e}")
continue
db.commit()
return {
'success': True,
'blocked': False,
'hotels_count': hotels_saved,
'rates_count': rates_saved,
}
async def run_manual_scrape(
db: Session,
from_date: date,
to_date: date = None
) -> Dict[str, Any]:
"""
Run a manual scrape for testing/on-demand use.
Args:
db: Database session
from_date: Start date
to_date: End date (defaults to from_date for single day)
Returns:
Dict with scrape results summary
"""
if to_date is None:
to_date = from_date
# Check if paused
if await is_scraper_paused(db):
return {
'success': False,
'error': 'Scraper is currently paused due to blocking. Try again later.',
}
# Get config
config = get_scrape_config(db)
if not config:
return {
'success': False,
'error': 'No scrape location configured. Add a location in settings.',
}
# Create batch
batch_id = create_scrape_batch(db, 'manual')
# Get backend
backend = get_scraper_backend(db)
total_hotels = 0
total_rates = 0
dates_completed = 0
dates_failed = 0
try:
current_date = from_date
while current_date <= to_date:
logger.info(f"Scraping date: {current_date}")
result = await scrape_date(db, current_date, backend, config, batch_id)
if result['blocked']:
# Blocking detected - pause and exit
await set_scraper_paused(db, True, hours=2)
update_scrape_batch(
db, batch_id,
status='blocked',
hotels_found=total_hotels,
rates_scraped=total_rates,
error_message=f"Blocked: {result.get('block_reason', 'unknown')}",
blocked=True
)
return {
'success': False,
'blocked': True,
'block_reason': result.get('block_reason'),
'dates_completed': dates_completed,
'dates_failed': dates_failed,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
if result['success']:
total_hotels += result['hotels_count']
total_rates += result['rates_count']
dates_completed += 1
else:
dates_failed += 1
logger.warning(f"Failed to scrape {current_date}: {result.get('error')}")
current_date += timedelta(days=1)
# Update batch as completed
update_scrape_batch(
db, batch_id,
status='completed',
hotels_found=total_hotels,
rates_scraped=total_rates
)
return {
'success': True,
'blocked': False,
'dates_completed': dates_completed,
'dates_failed': dates_failed,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
except Exception as e:
logger.error(f"Scrape error: {e}")
update_scrape_batch(
db, batch_id,
status='failed',
hotels_found=total_hotels,
rates_scraped=total_rates,
error_message=str(e)
)
return {
'success': False,
'error': str(e),
'dates_completed': dates_completed,
'dates_failed': dates_failed,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
finally:
await backend.close()
# ============================================
# QUEUE MANAGEMENT
# ============================================
def populate_queue(db: Session, dates: List[date], priorities: Dict[date, int] = None):
"""
Add dates to the scrape queue, skipping any already pending/processing.
Args:
db: Database session
dates: Dates to add to the queue
priorities: Optional priority map (higher = scraped first). Default: 0
"""
if not dates:
return 0
added = 0
for rate_date in dates:
priority = (priorities or {}).get(rate_date, 0)
try:
db.execute(
text("""
INSERT INTO booking_scrape_queue (rate_date, status, priority)
VALUES (:rate_date, 'pending', :priority)
ON CONFLICT (rate_date, status) DO UPDATE SET
priority = GREATEST(booking_scrape_queue.priority, :priority)
"""),
{'rate_date': rate_date, 'priority': priority}
)
added += 1
except Exception:
# Ignore duplicates or constraint issues
pass
db.commit()
logger.info(f"Queue: added/updated {added} dates")
return added
def get_pending_queue_items(db: Session, limit: int = 50) -> List[Dict[str, Any]]:
"""Get pending queue items ordered by priority (highest first), then date."""
result = db.execute(
text("""
SELECT id, rate_date, priority, attempts, max_attempts
FROM booking_scrape_queue
WHERE status = 'pending' AND attempts < max_attempts
ORDER BY priority DESC, rate_date ASC
LIMIT :limit
"""),
{'limit': limit}
)
return [dict(row._mapping) for row in result.fetchall()]
def mark_queue_item(db: Session, queue_id: int, status: str, error_message: str = None):
"""Update a queue item's status."""
if status == 'completed':
db.execute(
text("""
UPDATE booking_scrape_queue SET
status = 'completed',
completed_at = NOW(),
last_attempt_at = NOW(),
attempts = attempts + 1
WHERE id = :id
"""),
{'id': queue_id}
)
elif status == 'failed':
db.execute(
text("""
UPDATE booking_scrape_queue SET
status = CASE
WHEN attempts + 1 >= max_attempts THEN 'failed'
ELSE 'pending'
END,
last_attempt_at = NOW(),
attempts = attempts + 1,
error_message = :error
WHERE id = :id
"""),
{'id': queue_id, 'error': error_message}
)
db.commit()
def clear_old_queue_items(db: Session, days: int = 7):
"""Remove completed/failed queue items older than N days."""
db.execute(
text("""
DELETE FROM booking_scrape_queue
WHERE status IN ('completed', 'failed')
AND created_at < NOW() - INTERVAL ':days days'
""".replace(':days', str(int(days))))
)
db.commit()
async def process_queue(db: Session) -> Dict[str, Any]:
"""
Process pending items from the scrape queue.
Picks up pending items in priority order, scrapes each date,
and handles blocking/retries.
Returns:
Dict with processing results
"""
# Check if paused
if await is_scraper_paused(db):
return {
'success': False,
'error': 'Scraper is currently paused due to blocking.',
}
# Get config
config = get_scrape_config(db)
if not config:
return {
'success': False,
'error': 'No scrape location configured.',
}
# Get pending items
items = get_pending_queue_items(db, limit=200)
if not items:
return {'success': True, 'dates_completed': 0, 'message': 'Queue empty'}
# Create batch
batch_id = create_scrape_batch(db, 'scheduled')
# Update batch with queue count
db.execute(
text("UPDATE booking_scrape_log SET dates_queued = :count WHERE batch_id = :bid"),
{'count': len(items), 'bid': str(batch_id)}
)
db.commit()
# Get backend
backend = get_scraper_backend(db)
total_hotels = 0
total_rates = 0
dates_completed = 0
dates_failed = 0
try:
for item in items:
rate_date = item['rate_date']
queue_id = item['id']
logger.info(f"Queue processing: {rate_date} (priority={item['priority']}, attempt={item['attempts']+1})")
result = await scrape_date(db, rate_date, backend, config, batch_id)
if result['blocked']:
# Mark this item as failed, pause, and stop
mark_queue_item(db, queue_id, 'failed', f"Blocked: {result.get('block_reason')}")
await set_scraper_paused(db, True, hours=2)
update_scrape_batch(
db, batch_id,
status='blocked',
hotels_found=total_hotels,
rates_scraped=total_rates,
error_message=f"Blocked: {result.get('block_reason', 'unknown')}",
blocked=True
)
# Update dates counters
db.execute(
text("""
UPDATE booking_scrape_log SET
dates_completed = :completed,
dates_failed = :failed
WHERE batch_id = :bid
"""),
{'completed': dates_completed, 'failed': dates_failed + 1, 'bid': str(batch_id)}
)
db.commit()
return {
'success': False,
'blocked': True,
'block_reason': result.get('block_reason'),
'dates_completed': dates_completed,
'dates_failed': dates_failed + 1,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
if result['success']:
mark_queue_item(db, queue_id, 'completed')
total_hotels += result['hotels_count']
total_rates += result['rates_count']
dates_completed += 1
else:
mark_queue_item(db, queue_id, 'failed', result.get('error'))
dates_failed += 1
logger.warning(f"Queue: failed to scrape {rate_date}: {result.get('error')}")
# Update batch as completed
update_scrape_batch(
db, batch_id,
status='completed',
hotels_found=total_hotels,
rates_scraped=total_rates
)
db.execute(
text("""
UPDATE booking_scrape_log SET
dates_completed = :completed,
dates_failed = :failed
WHERE batch_id = :bid
"""),
{'completed': dates_completed, 'failed': dates_failed, 'bid': str(batch_id)}
)
db.commit()
return {
'success': True,
'blocked': False,
'dates_completed': dates_completed,
'dates_failed': dates_failed,
'hotels_found': total_hotels,
'rates_scraped': total_rates,
}
except Exception as e:
logger.error(f"Queue processing error: {e}")
update_scrape_batch(
db, batch_id,
status='failed',
hotels_found=total_hotels,
rates_scraped=total_rates,
error_message=str(e)
)
return {
'success': False,
'error': str(e),
'dates_completed': dates_completed,
'dates_failed': dates_failed,
}
finally:
await backend.close()
def get_competitor_matrix(
db: Session,
from_date: date,
to_date: date,
include_market: bool = False
) -> List[Dict[str, Any]]:
"""
Get rate comparison matrix for competitors.
Args:
db: Database session
from_date: Start date
to_date: End date
include_market: Include market tier hotels
Returns:
List of rate records for matrix display
"""
tier_filter = "h.tier IN ('own', 'competitor')"
if include_market:
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
result = db.execute(
text(f"""
SELECT
r.rate_date,
h.id AS hotel_id,
h.name AS hotel_name,
h.tier,
h.display_order,
h.star_rating,
h.review_score,
r.availability_status,
r.rate_gross,
r.room_type,
r.breakfast_included,
r.free_cancellation,
r.no_prepayment,
r.rooms_left,
r.scraped_at
FROM booking_latest_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE {tier_filter}
AND h.is_active = TRUE
AND r.rate_date BETWEEN :from_date AND :to_date
ORDER BY r.rate_date, h.display_order, h.name
"""),
{'from_date': from_date, 'to_date': to_date}
)
return [dict(row._mapping) for row in result.fetchall()]
def get_hotels_list(db: Session, tier: str = None) -> List[Dict[str, Any]]:
"""
Get list of discovered hotels.
Args:
db: Database session
tier: Filter by tier ('own', 'competitor', 'market') or None for all
Returns:
List of hotel records
"""
where_clause = "WHERE is_active = TRUE"
if tier:
where_clause += f" AND tier = '{tier}'"
result = db.execute(
text(f"""
SELECT
id, booking_com_id, name, booking_com_url,
star_rating, review_score, review_count,
tier, display_order, notes,
first_seen_at, last_seen_at
FROM booking_com_hotels
{where_clause}
ORDER BY display_order, name
""")
)
return [dict(row._mapping) for row in result.fetchall()]
def update_hotel_tier(db: Session, hotel_id: int, tier: str, display_order: int = None):
"""Update a hotel's tier and display order."""
if tier not in ('own', 'competitor', 'market'):
raise ValueError(f"Invalid tier: {tier}")
params = {'hotel_id': hotel_id, 'tier': tier}
set_clause = "tier = :tier"
if display_order is not None:
set_clause += ", display_order = :order"
params['order'] = display_order
db.execute(
text(f"UPDATE booking_com_hotels SET {set_clause} WHERE id = :hotel_id"),
params
)
db.commit()

View file

@ -0,0 +1,100 @@
"""
Client for the stack's central Settings service.
NewBook credentials are managed once in the Settings app (LXC 116) and
fetched live by every app the same pattern as cashup / room-planner /
maintenance (see their lib/newbook.js). Falls back to None if the service
is unreachable so callers can fall back to app-local config.
"""
import logging
import os
import time
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
SETTINGS_URL = os.getenv("SETTINGS_URL", "")
SETTINGS_SECRET = os.getenv("SETTINGS_SECRET", "")
_CACHE_TTL = 60 # seconds — credentials change rarely; avoid hammering the service
_cache: dict = {}
async def get_integration(name: str) -> Optional[dict]:
"""
Fetch integration config (e.g. 'newbook') from the central Settings
service. Returns the config dict, or None if unavailable/unconfigured.
"""
if not SETTINGS_URL or not SETTINGS_SECRET:
return None
cached = _cache.get(name)
if cached and (time.monotonic() - cached[0]) < _CACHE_TTL:
return cached[1]
url = f"{SETTINGS_URL}/settings/api/internal/integration/{name}"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(
url, headers={"Authorization": f"Bearer {SETTINGS_SECRET}"}
)
resp.raise_for_status()
data = resp.json()
_cache[name] = (time.monotonic(), data)
return data
except Exception as e:
logger.warning(f"Central settings fetch failed for '{name}': {e}")
return None
def _extract_newbook(s: Optional[dict]) -> Optional[dict]:
if not s:
return None
creds = {
"api_key": s.get("api_key") or "",
"username": s.get("username") or "",
"password": s.get("password") or "",
"region": s.get("region") or "eu",
}
# Only usable if the essential fields are present
if not (creds["api_key"] and creds["username"] and creds["password"]):
return None
return creds
async def get_newbook_credentials() -> Optional[dict]:
"""
Returns {'api_key', 'username', 'password', 'region'} from central
settings, or None if not available (caller should fall back).
"""
return _extract_newbook(await get_integration("newbook"))
def get_integration_sync(name: str) -> Optional[dict]:
"""Blocking variant of get_integration for sync job contexts."""
if not SETTINGS_URL or not SETTINGS_SECRET:
return None
cached = _cache.get(name)
if cached and (time.monotonic() - cached[0]) < _CACHE_TTL:
return cached[1]
url = f"{SETTINGS_URL}/settings/api/internal/integration/{name}"
try:
resp = httpx.get(
url, headers={"Authorization": f"Bearer {SETTINGS_SECRET}"}, timeout=5.0
)
resp.raise_for_status()
data = resp.json()
_cache[name] = (time.monotonic(), data)
return data
except Exception as e:
logger.warning(f"Central settings fetch failed for '{name}': {e}")
return None
def get_newbook_credentials_sync() -> Optional[dict]:
"""Blocking variant of get_newbook_credentials for sync job contexts."""
return _extract_newbook(get_integration_sync("newbook"))

View file

@ -0,0 +1,28 @@
from .guestline import GuestlineProfile
from .newbook_scrape import NewbookScrapeProfile
from .travelclick import TravelClickProfile
from .directbook import DirectBookProfile
from .base import BaseProfile
PROFILES = {
"guestline": GuestlineProfile,
"newbook_scrape": NewbookScrapeProfile,
"travelclick": TravelClickProfile,
"directbook": DirectBookProfile,
}
def get_profile(name: str) -> BaseProfile:
cls = PROFILES.get(name)
if not cls:
raise ValueError(f"Unknown engine profile: {name}")
return cls()
def detect_profile(url: str) -> dict | None:
"""Given a booking URL, return suggested profile name and extracted params."""
for name, cls in PROFILES.items():
result = cls.detect(url)
if result is not None:
return {"profile": name, **result}
return None

View file

@ -0,0 +1,25 @@
from abc import ABC, abstractmethod
class BaseProfile(ABC):
name: str = ""
label: str = ""
# Fields required to configure this engine, shown in the add-hotel form
# Each entry: {"key": "hotel_id", "label": "Hotel ID", "help": "e.g. THREEWAYS"}
required_params: list[dict] = []
@classmethod
@abstractmethod
def detect(cls, url: str) -> dict | None:
"""Return extracted params dict if URL matches this engine, else None."""
@abstractmethod
async def fetch_arrival_dates(self, client, params: dict) -> list[str]:
"""Return list of bookable arrival date strings YYYY-MM-DD."""
@abstractmethod
async def fetch_night_rates(self, client, params: dict, arrival: str, nights: int = 1) -> list[dict]:
"""Return list of room/rate dicts for the stay.
Each dict must have: roomId, rateId, availability, prices[{amountBeforeTax, amountAfterTax}], currencyCode
prices list has one entry per night when nights > 1.
"""

View file

@ -0,0 +1,175 @@
import json
import re
from datetime import date, timedelta
from urllib.parse import quote
from .base import BaseProfile
API_BASE = "https://direct-book.com"
SETTINGS_HASH = "52d786f5c45232c8c16022bc3af6dab2e1994f4919b953afafe188095125e9b6"
QUOTESETS_HASH = "1012a6203854357e44786380240eefad6b2ad863aee6ba79748c81a851f29217"
ROOMTYPES_HASH = "8021345a2e1f993717b1960097489b456a6b2dc136990b6f919adba1b1fe2c1f"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json",
# Required to bypass Apollo CSRF protection on the /api/graphql endpoint
"Content-Type": "application/json",
}
def _graphql_url(operation: str, variables: dict, sha256: str) -> str:
return (
f"{API_BASE}/api/graphql"
f"?operationName={operation}"
f"&variables={_json_compact(variables)}"
f"&extensions={_json_compact({'persistedQuery': {'version': 1, 'sha256Hash': sha256}})}"
)
def _json_compact(obj) -> str:
return quote(json.dumps(obj, separators=(',', ':')), safe='')
async def _get_property_id(client, channel_code: str) -> str:
"""Fetch numeric propertyId from settings query."""
url = _graphql_url("settings", {"channelCode": channel_code}, SETTINGS_HASH)
r = await client.get(url, headers=HEADERS, timeout=20)
r.raise_for_status()
return str(r.json()["data"]["settings"]["uuid"])
class DirectBookProfile(BaseProfile):
name = "directbook"
label = "SiteMinder Direct Book"
required_params = [
{"key": "channel_code", "label": "Channel Code",
"help": "The property slug in the booking URL, e.g. 'grapevinestowdirect'"},
]
@classmethod
def detect(cls, url: str) -> dict | None:
m = re.search(r'direct-book\.com/properties/([^/?#\s]+)', url)
if m:
return {"channel_code": m.group(1)}
return None
async def fetch_category_names(self, client, params: dict) -> tuple[dict[str, str], dict[str, str]]:
channel_code = params["channel_code"]
url = _graphql_url(
"roomTypes",
{"channelCode": channel_code, "checkInDate": date.today().isoformat(),
"checkOutDate": (date.today() + timedelta(days=1)).isoformat(), "locale": "en"},
ROOMTYPES_HASH,
)
r = await client.get(url, headers=HEADERS, timeout=30)
r.raise_for_status()
room_types = r.json()["data"]["roomTypes"]
room_labels = {rt["uuid"]: rt["name"] for rt in room_types}
rate_labels = {}
for rt in room_types:
for rate in rt.get("rates", []):
rate_labels[rate["uuid"]] = rate["name"]
return room_labels, rate_labels
async def fetch_arrival_dates(self, client, params: dict) -> list[str]:
channel_code = params["channel_code"]
today = date.today()
# Fetch 12 months of availability in monthly chunks (API seems to accept wide ranges too)
end = today.replace(day=1) + timedelta(days=365)
# Build monthly windows to stay within API limits
available_dates: list[str] = []
current = today.replace(day=1)
while current <= end:
# Last day of the month
if current.month == 12:
month_end = current.replace(year=current.year + 1, month=1, day=1) - timedelta(days=1)
else:
month_end = current.replace(month=current.month + 1, day=1) - timedelta(days=1)
check_from = max(today, current).isoformat()
check_to = month_end.isoformat()
url = (
f"{API_BASE}/api/properties/{channel_code}/availability"
f"?checkInsFrom={check_from}&checkInsTo={check_to}"
)
try:
r = await client.get(url, headers=HEADERS, timeout=20)
r.raise_for_status()
for entry in r.json().get("result", []):
if entry.get("canCheckIn"):
d = entry["date"][:10]
if d >= today.isoformat():
available_dates.append(d)
except Exception:
pass
# Advance to next month
if current.month == 12:
current = current.replace(year=current.year + 1, month=1)
else:
current = current.replace(month=current.month + 1)
return sorted(set(available_dates))
async def fetch_night_rates(self, client, params: dict, arrival: str, nights: int = 1) -> list[dict]:
channel_code = params["channel_code"]
# Get propertyId (numeric) — cache it on the params dict to avoid repeat fetches
if "property_id" not in params:
params["property_id"] = await _get_property_id(client, channel_code)
property_id = int(params["property_id"])
departure = (date.fromisoformat(arrival) + timedelta(days=nights)).isoformat()
url = _graphql_url(
"quoteSets",
{
"propertyId": property_id,
"promocode": "",
"checkInDate": arrival,
"checkOutDate": departure,
"adults": 2,
"children": 0,
"infants": 0,
"currencyFrom": "GBP",
"currencyTo": "GBP",
},
QUOTESETS_HASH,
)
r = await client.get(url, headers=HEADERS, timeout=20)
if r.status_code == 404:
return []
r.raise_for_status()
rooms = []
for qs in r.json()["data"].get("quoteSets", []):
room_id = str(qs["roomTypeId"])
for quote in qs.get("quotes", []):
rate_id = str(quote["roomRateId"])
price = quote["price"]["amount"]
available = quote.get("available", 0)
# Build per-night prices from breakdown if multi-night
breakdown = quote.get("breakdown", [])
if breakdown:
prices = [
{"amountBeforeTax": b["price"]["amount"], "amountAfterTax": b["price"]["amount"]}
for b in breakdown
]
else:
prices = [{"amountBeforeTax": price, "amountAfterTax": price}]
rooms.append({
"roomId": room_id,
"rateId": rate_id,
"availability": available,
"prices": prices,
"min_stay_nights": None,
"currencyCode": "GBP",
})
return rooms

View file

@ -0,0 +1,49 @@
import re
from datetime import date, timedelta
from .base import BaseProfile
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json",
}
class GuestlineProfile(BaseProfile):
name = "guestline"
label = "Guestline"
required_params = [
{"key": "hotel_id", "label": "Hotel ID", "help": "e.g. THREEWAYS — from the booking URL ?hotel= parameter"},
{"key": "collection_id", "label": "Collection ID", "help": "e.g. MT — the path segment before /availability in the booking URL"},
]
@classmethod
def detect(cls, url: str) -> dict | None:
# Matches: https://booking.eu.guestline.app/MT/availability?hotel=THREEWAYS
m = re.search(r'booking\.(?:eu\.)?guestline\.app/([^/?\s]+)/availability\?hotel=([^&\s]+)', url)
if m:
return {"collection_id": m.group(1), "hotel_id": m.group(2)}
return None
async def fetch_arrival_dates(self, client, params: dict) -> list[str]:
hotel_id = params["hotel_id"]
today = date.today()
url = f"https://booking.eu.guestline.app/api/availabilities/{hotel_id}/arrivals"
r = await client.get(url, params={
"month": today.month, "year": today.year,
"adults": 2, "children": 0, "count": 12,
}, headers=HEADERS, timeout=20)
r.raise_for_status()
return [a["date"] for a in r.json().get("arrivals", [])]
async def fetch_night_rates(self, client, params: dict, arrival: str, nights: int = 1) -> list[dict]:
hotel_id = params["hotel_id"]
collection_id = params.get("collection_id", "MT")
dep = (date.fromisoformat(arrival) + timedelta(days=nights)).isoformat()
url = f"https://booking.eu.guestline.app/api/availabilities/{collection_id}/{hotel_id}/enhanced"
r = await client.get(url, params={
"arrival": arrival, "departure": dep, "adults": 2, "children": 0,
}, headers=HEADERS, timeout=20)
if r.status_code == 404:
return []
r.raise_for_status()
return r.json().get("availabilities", {}).get("rooms", [])

View file

@ -0,0 +1,235 @@
import json
import re
from datetime import date, timedelta
from .base import BaseProfile
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
}
BASE_URL = "https://bookingseu.newbook.cloud"
def _base_params(slug: str, arrival: str, departure: str, nights: int) -> dict:
return {
"REMOTE_ADDR": "1.1.1.1",
"force_booking_channel_id": "",
"HTTP_REFERER": f"bookingseu.newbook.cloud/{slug}/index.php",
"discount_total_display": "0",
"force_category_id[]": "uK0ip@c7ty%8bQ#2i",
"force_category_type_id[]": "uK0ip@c7ty%8bQ#2i",
"no_billing_booking": "0",
"force_tariff_type_id[]": "uK0ip@c7ty%8bQ#2i",
"discount_id": "null",
"facebook_user_id": "",
"category_type_id": "",
"owner_occupied_booking_id": "",
"discount_code": "",
"booking_action": "",
"available_from": arrival,
"available_to": departure,
"nights": str(nights),
"adults": "2",
"children": "0",
"infants": "0",
"promo_code": "",
"language": "EN",
}
def _fmt_date(d: date) -> str:
"""Format date as NewBook expects: 'Mon 4 Jul 2026'"""
return d.strftime("%a %-d %b %Y")
def _parse_chart_html(html: str) -> tuple[dict[str, float], dict[str, str], dict[str, str], list[dict]]:
"""
Parse an availability_chart_responsive HTML response.
Returns:
counts: {cat_id: float} room counts from category_sites_available JS var
cat_names: {cat_id: str} friendly category names e.g. "Executive Double"
rate_names: {rate_id: str} friendly tariff names e.g. "DIRECT B&B FLEX"
rooms: list of room/rate dicts compatible with base scraper format
"""
# Room counts from embedded JS
counts: dict[str, float] = {}
m = re.search(r'category_sites_available\s*=\s*(\{[^;]+\})', html)
if m:
try:
counts = {k: float(v) for k, v in json.loads(m.group(1)).items()}
except Exception:
pass
cat_names: dict[str, str] = {}
rate_names: dict[str, str] = {}
rooms = []
# Split by category box: offset="{cat_id}"
cat_blocks = re.split(r'<div[^>]+class="[^"]*newbook_online_category_box[^"]*"[^>]+offset="(\d+)"', html)
# cat_blocks: [pre, cat_id, block, cat_id, block, ...]
i = 1
while i < len(cat_blocks) - 1:
cat_id = cat_blocks[i]
block = cat_blocks[i + 1]
i += 2
avail = counts.get(cat_id, 0.0)
# Category friendly name — from category_name attr on any book button in this block
# e.g. category_name='Standard' or category_name='Executive Double'
cn_m = re.search(r"category_name='([^']+)'", block)
if not cn_m:
# Fallback: <h3><a ...>Name</a></h3>
cn_m = re.search(r'<h3>[^<]*<a[^>]*>([^<]+)</a>', block)
if cn_m:
cat_names[cat_id] = cn_m.group(1).strip()
# Split on tariff row boundaries
tariff_rows = re.split(r'class="[^"]*newbook_online_categories_tariff_type_rows[^"]*"', block)
for row in tariff_rows[1:]:
# Rate label
name_m = re.search(r'newbook_online_categories_tariff_type_label[^>]*>(.*?)</div>', row, re.DOTALL)
rate_label = re.sub(r'<[^>]+>', '', name_m.group(1)).strip() if name_m else ""
# Price
price_m = re.search(r'newbook_online_from_price_text[^>]*>£([\d.]+)<', row)
price = float(price_m.group(1)) if price_m else None
# Internal tariff type ID (stable across dates)
tid_m = re.search(r'tariff_type_id="(\d+)"', row)
rate_id = tid_m.group(1) if tid_m else rate_label
# Store rate label for this rate_id
if rate_id and rate_label:
rate_names[rate_id] = rate_label
# Min-stay: requires_date_change class + optional extend_nights attr
# If extend_nights present: min_stay = 1 + N; if absent: default to 2
min_stay = None
if 'requires_date_change' in row:
en_m = re.search(r'extend_nights="(\d+)"', row)
min_stay = 1 + int(en_m.group(1)) if en_m else 2
if rate_label and price is not None:
rooms.append({
"roomId": cat_id,
"rateId": rate_id,
"rateLabel": rate_label,
"availability": int(avail),
"prices": [{"amountBeforeTax": price, "amountAfterTax": price}],
"min_stay_nights": min_stay,
"currencyCode": "GBP",
})
return counts, cat_names, rate_names, rooms
class NewbookScrapeProfile(BaseProfile):
name = "newbook_scrape"
label = "NewBook (HTML scrape)"
required_params = [
{"key": "slug", "label": "Property Slug",
"help": "The path segment in the booking URL, e.g. 'numberfour' from bookingseu.newbook.cloud/numberfour/"},
]
@classmethod
def detect(cls, url: str) -> dict | None:
m = re.search(r'bookingseu\.newbook\.cloud/([^/?#\s]+)', url)
if m and m.group(1) not in ('index.php',):
return {"slug": m.group(1)}
return None
async def fetch_category_names(self, client, params: dict) -> tuple[dict[str, str], dict[str, str]]:
"""
Return ({cat_id: friendly_name}, {rate_id: friendly_name}) from a single chart call.
Used by discovery to pre-populate room_labels and rate_labels.
"""
slug = params["slug"]
today = date.today()
base = _base_params(slug, _fmt_date(today), _fmt_date(today + timedelta(days=1)), 1)
r = await client.post(
f"{BASE_URL}/{slug}/api.php?newbook_api_action=availability_chart_responsive",
data=base, headers=HEADERS, timeout=30,
)
r.raise_for_status()
_, cat_names, rate_names, _ = _parse_chart_html(r.text)
return cat_names, rate_names
async def fetch_arrival_dates(self, client, params: dict) -> list[str]:
"""
Use the calendar endpoint to collect all available arrival dates
across all room types. One call per room type, union of available dates.
We first do a chart call to discover category IDs, then calendar per category.
"""
slug = params["slug"]
today = date.today()
arrival_str = _fmt_date(today)
departure_str = _fmt_date(today + timedelta(days=1))
# Step 1: chart call to discover category IDs and names
base = _base_params(slug, arrival_str, departure_str, 1)
r = await client.post(
f"{BASE_URL}/{slug}/api.php?newbook_api_action=availability_chart_responsive",
data=base, headers=HEADERS, timeout=30,
)
r.raise_for_status()
html = r.text
cat_ids = re.findall(r'class="[^"]*newbook_online_category_box[^"]*"[^>]+offset="(\d+)"', html)
if not cat_ids:
return []
# Step 2: calendar call per category, collect available dates
available_dates: set[str] = set()
more_tariffs = {f"more_tariffs_{cid}": "1" for cid in cat_ids}
for cat_id in cat_ids:
cal_params = {
**base,
**more_tariffs,
"query": "newbook_calendar_initialise",
"calendar_category_id": cat_id,
}
try:
cr = await client.post(
f"{BASE_URL}/{slug}/api.php?newbook_api_action=data",
data=cal_params, headers=HEADERS, timeout=30,
)
cr.raise_for_status()
cal_data = cr.json()
cal_html = cal_data.get("calendar_display", "")
for dm in re.finditer(r'class="day available[^"]*"\s+data-date="(\d{4}-\d{2}-\d{2})"', cal_html):
available_dates.add(dm.group(1))
except Exception:
pass
return sorted(d for d in available_dates if d >= today.isoformat())
async def fetch_night_rates(self, client, params: dict, arrival: str, nights: int = 1) -> list[dict]:
"""
POST to availability_chart_responsive for a specific date.
Returns list of room/rate dicts compatible with the base scraper format.
Also injects min_stay_nights onto each row.
"""
slug = params["slug"]
arr = date.fromisoformat(arrival)
dep = arr + timedelta(days=nights)
arr_str = _fmt_date(arr)
dep_str = _fmt_date(dep)
body = _base_params(slug, arr_str, dep_str, nights)
r = await client.post(
f"{BASE_URL}/{slug}/api.php?newbook_api_action=availability_chart_responsive",
data=body, headers=HEADERS, timeout=30,
)
if r.status_code == 404:
return []
r.raise_for_status()
_, _, _, rooms = _parse_chart_html(r.text)
return rooms

View file

@ -0,0 +1,166 @@
import re
import httpx
from datetime import date, timedelta
from .base import BaseProfile
API_BASE = "https://api.travelclick.com"
TOKEN_URL = f"{API_BASE}/oauth/token-referer?grant_type=client_credentials"
HEADERS_BASE = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json",
"Content-Type": "application/json",
}
async def _get_token(client, referer: str) -> str:
r = await client.post(TOKEN_URL, headers={**HEADERS_BASE, "Referer": referer}, timeout=20)
r.raise_for_status()
return r.json()["access_token"]
def _auth_headers(token: str, referer: str) -> dict:
return {**HEADERS_BASE, "Authorization": f"Bearer {token}", "Referer": referer}
class TravelClickProfile(BaseProfile):
name = "travelclick"
label = "TravelClick / Amadeus"
required_params = [
{"key": "hotel_code", "label": "Hotel Code",
"help": "Numeric hotel ID, e.g. 77346 — visible in the booking engine network requests"},
{"key": "booking_url", "label": "Booking URL Base",
"help": "e.g. https://reservations.bespokehotels.com/noelarmshotel/book/dates-of-stay"},
]
@classmethod
def detect(cls, url: str) -> dict | None:
m = re.search(r'(https://reservations\.bespokehotels\.com/[^/]+/book/[^?#\s]+)', url)
if not m:
return None
booking_url = m.group(1)
# hotel_code is in inline JS as bookingEngineHotelId: '77346' on the booking page
hotel_code = ""
try:
r = httpx.get(booking_url, timeout=10, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0"})
hm = re.search(r'bookingEngineHotelId\s*:\s*[\'"](\d+)[\'"]', r.text)
if hm:
hotel_code = hm.group(1)
except Exception:
pass
return {"booking_url": booking_url, "hotel_code": hotel_code}
async def fetch_category_names(self, client, params: dict) -> tuple[dict[str, str], dict[str, str]]:
hotel_code = params["hotel_code"]
referer = params["booking_url"]
token = await _get_token(client, referer)
r = await client.get(
f"{API_BASE}/be5-entity/v2/hotels/{hotel_code}/content",
params={"include": "roomtypes,rateplans", "lang": "EN_US"},
headers=_auth_headers(token, referer),
timeout=30,
)
r.raise_for_status()
data = r.json()
# roomtypes[].roomTypeId (numeric, used as roomId in avail) -> roomTypeName
room_labels = {
str(rt["roomTypeId"]): rt.get("roomTypeName", str(rt["roomTypeId"]))
for rt in data.get("roomtypes", [])
}
# ratePlans[].rateplanId (numeric, used as rateId in avail) -> rateplanName
rate_labels = {
str(rp["rateplanId"]): rp.get("rateplanName", str(rp["rateplanId"]))
for rp in data.get("ratePlans", [])
}
return room_labels, rate_labels
async def fetch_arrival_dates(self, client, params: dict) -> list[str]:
hotel_code = params["hotel_code"]
referer = params["booking_url"]
token = await _get_token(client, referer)
today = date.today()
end = today + timedelta(days=365)
body = {
"hotelCode": int(hotel_code),
"currency": "GBP",
"lang": "EN_US",
"dateIn": today.isoformat(),
"dateOut": end.isoformat(),
"multiRoomOccupancy": [{"adults": 2, "infant": 0, "children": 0}],
"bookerIdentifier": "",
"partnerIdentifier": "",
}
r = await client.post(
f"{API_BASE}/be5-shop/v1/hotel/{hotel_code}/basicavail/multi-room",
json=body,
headers=_auth_headers(token, referer),
timeout=30,
)
r.raise_for_status()
data = r.json()
return [
d["date"]
for d in data.get("dates", [])
if d.get("isAvailable") and d["date"] >= today.isoformat()
]
async def fetch_night_rates(self, client, params: dict, arrival: str, nights: int = 1) -> list[dict]:
hotel_code = params["hotel_code"]
referer = params["booking_url"]
token = await _get_token(client, referer)
departure = (date.fromisoformat(arrival) + timedelta(days=nights)).isoformat()
body = {
"roomStay": {
"startDate": arrival,
"endDate": departure,
"guestCount": {"adults": 2, "infants": 0, "children": {"ages": None, "count": 0}},
"roomQuantity": 1,
"productSearchCriteria": {
"sortingPreference": "SORT_BY_ORDER",
"includeUnavailable": False,
},
},
"languageCode": "EN_US",
"disableLocaitonSharing": False,
"currencyCode": "GBP",
"tpaExtension": [],
"includeMemberRate": True,
"includeNightlyRates": True,
}
r = await client.post(
f"{API_BASE}/be5-shop/v2/hotels/{hotel_code}/avail",
json=body,
headers=_auth_headers(token, referer),
timeout=30,
)
if r.status_code == 404:
return []
r.raise_for_status()
data = r.json()
# Response: roomStays[0].roomtypes[].products[] (both Regular rates and Packages)
room_stay = (data.get("roomStays") or [{}])[0]
rooms = []
for rt in room_stay.get("roomtypes", []):
room_id = str(rt["roomtypeId"])
for product in rt.get("products", []):
rate_id = str(product["productId"])
nightly = product.get("nightlyRates", [])
if not nightly:
continue
avail = nightly[0].get("inventoryCount", 0)
prices = [
{"amountBeforeTax": n["amountBeforeTax"], "amountAfterTax": n.get("amountTotal", n["amountBeforeTax"])}
for n in nightly
]
rooms.append({
"roomId": room_id,
"rateId": rate_id,
"availability": avail,
"prices": prices,
"min_stay_nights": None, # min-stay comes from basicavail per date
"currencyCode": "GBP",
})
return rooms

View file

@ -0,0 +1,295 @@
"""
Direct booking engine scraper adapted from guestline-monitor/app/scraper.py.
Replaces SQLite per-hotel DBs with PostgreSQL via SyncSessionLocal.
Logic (min-stay detection, discovery date sampling) is unchanged from the original.
"""
import asyncio
import logging
from datetime import datetime, timezone, date, timedelta
import httpx
from sqlalchemy import text
from database import SyncSessionLocal
from services.direct_profiles import get_profile
log = logging.getLogger(__name__)
REQUEST_DELAY = 10.0
DISCOVERY_DELAY = 2.0
# Track running discovery scrapes: hotel_id -> status dict
_discovery_status: dict[int, dict] = {}
def _discovery_dates() -> list[str]:
"""42 spread dates: one per DOW over 6 months, 6 weeks apart."""
dates = []
today = date.today()
for week_offset in range(6):
base = today + timedelta(weeks=week_offset * 4)
for dow in range(7):
days_ahead = (dow - base.weekday()) % 7
d = base + timedelta(days=days_ahead + 7)
iso = d.isoformat()
if iso not in dates:
dates.append(iso)
return sorted(dates)
async def run_discovery(hotel_id: int, profile_name: str, params: dict):
"""Sample ~42 spread dates to find all room/rate type IDs for a competitor hotel."""
_discovery_status[hotel_id] = {
"state": "running", "done": 0, "total": 0,
"found_rooms": [], "found_rates": [],
}
profile = get_profile(profile_name)
dates = _discovery_dates()
_discovery_status[hotel_id]["total"] = len(dates)
found_rooms: set[str] = set()
found_rates: set[str] = set()
async with httpx.AsyncClient() as client:
for i, arrival in enumerate(dates):
await asyncio.sleep(DISCOVERY_DELAY)
try:
rooms = await profile.fetch_night_rates(client, params, arrival)
for room in rooms:
found_rooms.add(room["roomId"])
found_rates.add(room["rateId"])
except Exception as e:
log.warning(f"Discovery hotel {hotel_id} {arrival}: {e}")
_discovery_status[hotel_id]["done"] = i + 1
_discovery_status[hotel_id]["found_rooms"] = sorted(found_rooms)
_discovery_status[hotel_id]["found_rates"] = sorted(found_rates)
# Fetch friendly names if the profile supports it
if hasattr(profile, "fetch_category_names"):
try:
cat_names, rate_names = await profile.fetch_category_names(client, params)
_discovery_status[hotel_id]["room_labels"] = cat_names
_discovery_status[hotel_id]["rate_labels"] = rate_names
# Merge into DB (existing user labels win)
db = SyncSessionLocal()
try:
row = db.execute(
text("SELECT room_labels, rate_labels FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
).mappings().fetchone()
if row:
import json
existing_rooms = row["room_labels"] or {}
existing_rates = row["rate_labels"] or {}
merged_rooms = {**cat_names, **existing_rooms}
merged_rates = {**rate_names, **existing_rates}
db.execute(
text("""UPDATE direct_competitor_hotels
SET room_labels = :rl, rate_labels = :ratel
WHERE id = :id"""),
{"rl": json.dumps(merged_rooms), "ratel": json.dumps(merged_rates), "id": hotel_id}
)
db.commit()
finally:
db.close()
except Exception as e:
log.warning(f"Discovery hotel {hotel_id}: could not fetch category names: {e}")
_discovery_status[hotel_id]["state"] = "complete"
log.info(f"Discovery complete hotel {hotel_id}: {len(found_rooms)} rooms, {len(found_rates)} rates")
def get_discovery_status(hotel_id: int) -> dict:
return _discovery_status.get(hotel_id, {"state": "idle"})
def run_scrape(hotel_id: int, profile_name: str, params: dict):
"""Full scrape run for one configured hotel. Writes to direct_rates + direct_scrape_runs."""
scraped_at = datetime.now(timezone.utc)
log.info(f"Direct scrape started for hotel {hotel_id}")
profile = get_profile(profile_name)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(_run_scrape_async(hotel_id, profile, params, scraped_at))
finally:
loop.close()
db = SyncSessionLocal()
try:
db.execute(
text("UPDATE direct_competitor_hotels SET last_scraped_at = :ts WHERE id = :id"),
{"ts": scraped_at, "id": hotel_id}
)
db.commit()
finally:
db.close()
async def _run_scrape_async(hotel_id: int, profile, params: dict, scraped_at: datetime):
async with httpx.AsyncClient() as client:
try:
arrival_dates = await profile.fetch_arrival_dates(client, params)
except Exception as e:
log.error(f"Hotel {hotel_id}: failed to fetch arrival dates: {e}")
return
log.info(f"Hotel {hotel_id}: {len(arrival_dates)} bookable dates")
db = SyncSessionLocal()
try:
result = db.execute(
text("INSERT INTO direct_scrape_runs (hotel_id, scraped_at, dates_found) VALUES (:hid, :ts, :df) RETURNING id"),
{"hid": hotel_id, "ts": scraped_at, "df": len(arrival_dates)}
)
run_id = result.fetchone()[0]
db.commit()
prev_dates = {r[0].isoformat() for r in db.execute(
text("SELECT DISTINCT stay_date FROM direct_rates WHERE hotel_id = :hid AND stay_date >= :today"),
{"hid": hotel_id, "today": date.today()}
).fetchall()}
finally:
db.close()
arrival_set = set(arrival_dates)
missing_dates = sorted(prev_dates - arrival_set)
if missing_dates:
log.info(f"Hotel {hotel_id}: {len(missing_dates)} dates absent, checking min-stay")
# Min-stay check for missing dates
for fd in missing_dates:
fd_date = date.fromisoformat(fd)
windows = [
(fd_date - timedelta(days=1), fd_date + timedelta(days=1)),
(fd_date, fd_date + timedelta(days=2)),
]
min_stay_rooms = None
min_stay_other_night = None
await asyncio.sleep(REQUEST_DELAY)
for win_start, win_end in windows:
try:
rooms_2n = await profile.fetch_night_rates(client, params, win_start.isoformat(), nights=2)
if rooms_2n:
min_stay_rooms = rooms_2n
companion = win_start if win_start.isoformat() != fd else (win_start + timedelta(days=1))
min_stay_other_night = companion.isoformat()
log.info(f" Hotel {hotel_id} {fd}: min-stay detected")
break
except Exception as e:
log.warning(f" Hotel {hotel_id} {fd}: min-stay check {win_start}: {e}")
db = SyncSessionLocal()
try:
last_rows = db.execute(
text("""SELECT DISTINCT ON (room_id, rate_id)
room_id, rate_id, availability, price_excl, price_incl, currency
FROM direct_rates
WHERE hotel_id = :hid AND stay_date = :fd
ORDER BY room_id, rate_id, scraped_at DESC"""),
{"hid": hotel_id, "fd": fd}
).mappings().fetchall()
insert_rows = []
for r in last_rows:
if min_stay_rooms is not None:
match = next(
(m for m in min_stay_rooms
if m["roomId"] == r["room_id"] and m["rateId"] == r["rate_id"]
and m.get("prices")),
None
)
if match:
prices = match["prices"]
win_start_used = windows[0][0] if min_stay_other_night == windows[0][0].isoformat() else windows[1][0]
idx = 1 if win_start_used.isoformat() != fd else 0
if len(prices) > idx:
p = prices[idx]
insert_rows.append({
"hid": hotel_id, "run_id": run_id, "ts": scraped_at, "sd": fd,
"room_id": r["room_id"], "rate_id": r["rate_id"],
"avail": match.get("availability", r["availability"]),
"pe": p["amountBeforeTax"], "pi": p["amountAfterTax"],
"cur": match.get("currencyCode", r["currency"]), "ms": 2
})
continue
insert_rows.append({
"hid": hotel_id, "run_id": run_id, "ts": scraped_at, "sd": fd,
"room_id": r["room_id"], "rate_id": r["rate_id"],
"avail": r["availability"], "pe": r["price_excl"], "pi": r["price_incl"],
"cur": r["currency"], "ms": 2
})
else:
insert_rows.append({
"hid": hotel_id, "run_id": run_id, "ts": scraped_at, "sd": fd,
"room_id": r["room_id"], "rate_id": r["rate_id"],
"avail": 0, "pe": r["price_excl"], "pi": r["price_incl"],
"cur": r["currency"], "ms": None
})
if insert_rows:
db.execute(
text("""INSERT INTO direct_rates
(hotel_id, scrape_run_id, scraped_at, stay_date, room_id, rate_id,
availability, price_excl, price_incl, currency, min_stay_nights)
VALUES (:hid, :run_id, :ts, :sd, :room_id, :rate_id,
:avail, :pe, :pi, :cur, :ms)"""),
insert_rows
)
db.commit()
status = "min-stay(2N)" if min_stay_rooms else "fully-booked"
log.info(f" Hotel {hotel_id} {fd}: recorded as {status}")
finally:
db.close()
# Scrape all bookable dates
rows_saved = 0
for i, arrival in enumerate(arrival_dates):
await asyncio.sleep(REQUEST_DELAY)
try:
rooms = await profile.fetch_night_rates(client, params, arrival)
if not rooms:
continue
insert_rows = [
{
"hid": hotel_id, "run_id": run_id, "ts": scraped_at, "sd": arrival,
"room_id": room["roomId"], "rate_id": room["rateId"],
"avail": room["availability"],
"pe": room["prices"][0]["amountBeforeTax"],
"pi": room["prices"][0]["amountAfterTax"],
"cur": room.get("currencyCode", "GBP"),
"ms": room.get("min_stay_nights")
}
for room in rooms if room.get("prices")
]
if insert_rows:
db = SyncSessionLocal()
try:
db.execute(
text("""INSERT INTO direct_rates
(hotel_id, scrape_run_id, scraped_at, stay_date, room_id, rate_id,
availability, price_excl, price_incl, currency, min_stay_nights)
VALUES (:hid, :run_id, :ts, :sd, :room_id, :rate_id,
:avail, :pe, :pi, :cur, :ms)"""),
insert_rows
)
db.commit()
finally:
db.close()
rows_saved += len(insert_rows)
log.info(f" Hotel {hotel_id} {arrival}: {len(rooms)} combos [{i+1}/{len(arrival_dates)}]")
except Exception as e:
log.error(f" Hotel {hotel_id} {arrival}: ERROR - {e}")
db = SyncSessionLocal()
try:
db.execute(
text("UPDATE direct_scrape_runs SET rows_saved = :rs WHERE id = :id"),
{"rs": rows_saved, "id": run_id}
)
db.commit()
finally:
db.close()
log.info(f"Hotel {hotel_id}: scrape complete, {rows_saved} rows saved")

View file

@ -0,0 +1,863 @@
"""
Newbook Rates Client
Fetches current rack rates from Newbook API for revenue forecasting.
Uses the bookings_availability_pricing endpoint to simulate booking requests.
This client is READ-ONLY - it only queries available rates, never creates bookings.
"""
import os
import httpx
import asyncio
import logging
from datetime import date, timedelta
from decimal import Decimal
from typing import Optional, List, Dict
logger = logging.getLogger(__name__)
class NewbookRatesError(Exception):
"""Custom exception for Newbook rates API errors"""
pass
class NewbookRatesClient:
"""
Async client for fetching current rates from Newbook API.
Uses bookings_availability_pricing endpoint which simulates a booking request.
Handles minimum stay restrictions by extending the stay period when needed.
Rate limiting: ~100 requests/min, using 0.75s delay between requests
"""
BASE_URL = "https://api.newbook.cloud/rest"
def __init__(self, api_key: str = None, username: str = None, password: str = None,
region: str = None, vat_rate: Decimal = Decimal('0.20')):
self.api_key = api_key or os.getenv("NEWBOOK_API_KEY")
self.username = username or os.getenv("NEWBOOK_USERNAME")
self.password = password or os.getenv("NEWBOOK_PASSWORD")
self.region = region or os.getenv("NEWBOOK_REGION")
self.vat_rate = vat_rate
if not all([self.api_key, self.username, self.password, self.region]):
logger.warning("Newbook credentials not fully configured")
def _get_url(self, endpoint: str) -> str:
"""Get full URL for an endpoint"""
return f"{self.BASE_URL}/{endpoint}"
@classmethod
async def from_db(cls, db):
"""
Create client with credentials from the central Settings service
(stack-wide NewBook config), falling back to the app-local
system_config table. VAT rate stays app-local either way.
"""
from sqlalchemy import text
from services.central_settings import get_newbook_credentials
result = await db.execute(
text("SELECT config_key, config_value FROM system_config WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region', 'accommodation_vat_rate')")
)
rows = result.fetchall()
config = {row.config_key: row.config_value for row in rows}
vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20'))
central = await get_newbook_credentials()
if central:
return cls(**central, vat_rate=vat_rate)
return cls(
api_key=config.get('newbook_api_key'),
username=config.get('newbook_username'),
password=config.get('newbook_password'),
region=config.get('newbook_region'),
vat_rate=vat_rate
)
async def __aenter__(self):
self.client = httpx.AsyncClient(timeout=60.0)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.client.aclose()
def _get_auth_payload(self) -> dict:
"""Get base authentication payload"""
return {
"api_key": self.api_key,
"region": self.region
}
async def get_category_rates(
self,
category_id: str,
from_date: date,
to_date: date,
guests_adults: int = 2,
guests_children: int = 0
) -> List[Dict]:
"""
Fetch current rates for a category over a date range.
Uses daily=true to get per-night rates. Handles minimum stay
restrictions by extending the period when needed.
Args:
category_id: Newbook category ID
from_date: Start date for rates
to_date: End date for rates (inclusive)
guests_adults: Number of adult guests (default 2)
guests_children: Number of child guests (default 0)
Returns:
List of dicts with {date, gross_rate, net_rate}
"""
rates = []
current_date = from_date
while current_date <= to_date:
try:
# Fetch rates for up to 7 days at a time to optimize API calls
batch_end = min(current_date + timedelta(days=6), to_date)
batch_rates = await self._fetch_rates_batch(
category_id, current_date, batch_end, guests_adults, guests_children
)
rates.extend(batch_rates)
# Move to next batch
current_date = batch_end + timedelta(days=1)
except Exception as e:
logger.error(f"Failed to fetch rates for category {category_id} starting {current_date}: {e}")
# Skip this batch and continue
current_date = current_date + timedelta(days=7)
# Rate limiting - ALWAYS wait 1.5s between requests, even after errors
await asyncio.sleep(1.5)
return rates
async def get_single_night_rates(
self,
category_id: str,
from_date: date,
to_date: date,
guests_adults: int = 2,
guests_children: int = 0
) -> List[Dict]:
"""
Fetch rates with single-night queries for accurate per-day tariff availability.
Unlike get_category_rates which batches, this queries each date individually
as a 1-night stay. This gives accurate tariff_success per night, catching
issues like Valentine's Day blocking only that night, not a whole week.
Much slower but necessary for accurate bookability data.
Args:
category_id: Newbook category ID
from_date: Start date for rates
to_date: End date for rates (inclusive)
guests_adults: Number of adult guests (default 2)
guests_children: Number of child guests (default 0)
Returns:
List of dicts with {date, gross_rate, net_rate, tariffs_data}
"""
rates = []
current_date = from_date
while current_date <= to_date:
try:
# Single-night query for accurate tariff availability
batch_rates = await self._fetch_rates_batch(
category_id, current_date, current_date, guests_adults, guests_children
)
rates.extend(batch_rates)
except Exception as e:
logger.warning(f"Failed to fetch single-night rate for {category_id} on {current_date}: {e}")
# Continue with next date
current_date += timedelta(days=1)
# Rate limiting - wait between each single-night query
await asyncio.sleep(1.0)
return rates
async def fetch_single_date_all_categories(
self,
for_date: date,
guests_adults: int = 2,
guests_children: int = 0
) -> Dict[str, List[Dict]]:
"""
Fetch single-night rates for ALL categories for one date.
Returns:
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]}
"""
return await self._fetch_all_categories_batch(
for_date, guests_adults, guests_children
)
async def fetch_multi_night_for_date(
self,
for_date: date,
nights: int,
guests_adults: int = 2,
guests_children: int = 0
) -> Dict[str, Dict[str, bool]]:
"""
Fetch multi-night availability for ALL categories for one date.
Returns:
Dict of {category_id: {tariff_name: available}}
"""
return await self._fetch_all_categories_multi_night(
for_date, nights, guests_adults, guests_children
)
async def get_all_categories_single_night_rates(
self,
from_date: date,
to_date: date,
guests_adults: int = 2,
guests_children: int = 0
) -> Dict[str, List[Dict]]:
"""
Fetch rates for ALL categories with single-night queries.
More efficient than get_single_night_rates - omits category_id to get
all categories in a single API call per date. This reduces API calls
from (categories × days) to just (days).
Args:
from_date: Start date for rates
to_date: End date for rates (inclusive)
guests_adults: Number of adult guests (default 2)
guests_children: Number of child guests (default 0)
Returns:
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}, ...]}
"""
all_rates: Dict[str, List[Dict]] = {}
current_date = from_date
total_days = (to_date - from_date).days + 1
day_count = 0
while current_date <= to_date:
day_count += 1
try:
# Single-night query WITHOUT category_id - returns ALL categories
category_rates = await self._fetch_all_categories_batch(
current_date, guests_adults, guests_children
)
# Merge into all_rates dict
for cat_id, rates in category_rates.items():
if cat_id not in all_rates:
all_rates[cat_id] = []
all_rates[cat_id].extend(rates)
logger.info(f"Fetched {current_date} ({day_count}/{total_days}) - {len(category_rates)} categories")
except Exception as e:
logger.warning(f"Failed to fetch rates for {current_date}: {e}")
# Continue with next date
current_date += timedelta(days=1)
# Rate limiting - wait between each query
await asyncio.sleep(1.0)
return all_rates
async def _fetch_all_categories_batch(
self,
for_date: date,
guests_adults: int,
guests_children: int,
retry_count: int = 0
) -> Dict[str, List[Dict]]:
"""
Fetch rates for ALL categories for a single date.
Omits category_id from request - Newbook returns all available categories.
Returns:
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]}
"""
# Single-night query
period_from = f"{for_date.isoformat()} 14:00:00"
period_to = f"{(for_date + timedelta(days=1)).isoformat()} 10:00:00"
payload = self._get_auth_payload()
payload.update({
"period_from": period_from,
"period_to": period_to,
"adults": guests_adults,
"children": guests_children,
"infants": 0,
"daily_mode": "true"
# NO category_id - returns all categories
})
response = await self.client.post(
self._get_url("bookings_availability_pricing"),
json=payload,
auth=(self.username, self.password)
)
# Handle rate limiting with exponential backoff
if response.status_code == 429:
if retry_count < 3:
wait_time = 60 * (retry_count + 1)
logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry {retry_count + 1}/3")
await asyncio.sleep(wait_time)
return await self._fetch_all_categories_batch(
for_date, guests_adults, guests_children, retry_count + 1
)
else:
raise NewbookRatesError(f"Rate limited after 3 retries")
if response.status_code != 200:
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
data = response.json()
if not data.get("success"):
raise NewbookRatesError(f"API returned failure: {data.get('message')}")
# Parse all categories from response
return self._parse_all_categories_tariffs(data, for_date)
async def _fetch_all_categories_multi_night(
self,
for_date: date,
nights: int,
guests_adults: int = 2,
guests_children: int = 0,
retry_count: int = 0
) -> Dict[str, Dict[str, bool]]:
"""
Fetch multi-night availability for ALL categories for a specific date.
Used to verify that rates with min_stay requirements are actually bookable.
Args:
for_date: Check-in date
nights: Number of nights to query (e.g., 2 for min_stay=2)
guests_adults: Number of adult guests
guests_children: Number of child guests
Returns:
Dict of {category_id: {tariff_name: available}}
"""
# Multi-night query
period_from = f"{for_date.isoformat()} 14:00:00"
period_to = f"{(for_date + timedelta(days=nights)).isoformat()} 10:00:00"
payload = self._get_auth_payload()
payload.update({
"period_from": period_from,
"period_to": period_to,
"adults": guests_adults,
"children": guests_children,
"infants": 0,
"daily_mode": "true"
})
response = await self.client.post(
self._get_url("bookings_availability_pricing"),
json=payload,
auth=(self.username, self.password)
)
# Handle rate limiting
if response.status_code == 429:
if retry_count < 3:
wait_time = 60 * (retry_count + 1)
logger.warning(f"Rate limited (multi-night), waiting {wait_time}s")
await asyncio.sleep(wait_time)
return await self._fetch_all_categories_multi_night(
for_date, nights, guests_adults, guests_children, retry_count + 1
)
else:
raise NewbookRatesError(f"Rate limited after 3 retries")
if response.status_code != 200:
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
data = response.json()
if not data.get("success"):
raise NewbookRatesError(f"API returned failure: {data.get('message')}")
# Parse availability by tariff name for each category
results: Dict[str, Dict[str, bool]] = {}
if not isinstance(data.get("data"), dict):
return results
for key, cat_data in data["data"].items():
if not (key.isdigit() or str(key).isnumeric()):
continue
if not isinstance(cat_data, dict):
continue
category_id = str(key)
tariffs_available = cat_data.get("tariffs_available", [])
results[category_id] = {}
for tariff in tariffs_available:
tariff_name = tariff.get("tariff_name", "")
tariff_label = tariff.get("tariff_label", "")
# Check tariff_success (API returns string "true"/"false")
tariff_success = str(tariff.get("tariff_success", False)).lower() in ("true", "1")
# Available if API says success, OR if rates are quoted and no restriction message
is_available = tariff_success or (
bool(tariff.get("tariffs_quoted")) and not tariff.get("tariff_message")
)
# Store under both tariff_name and tariff_label for flexible matching
results[category_id][tariff_name] = is_available
if tariff_label and tariff_label != tariff_name:
results[category_id][tariff_label] = is_available
return results
async def get_multi_night_availability(
self,
dates_by_nights: Dict[int, List[date]],
guests_adults: int = 2,
guests_children: int = 0
) -> Dict[date, Dict[str, Dict[str, bool]]]:
"""
Fetch multi-night availability for specific dates grouped by stay length.
Checks if a tariff is available when booking N nights starting from each date.
Args:
dates_by_nights: Dict of {nights: [dates]} e.g., {2: [date1, date2], 3: [date3]}
guests_adults: Number of adult guests
guests_children: Number of child guests
Returns:
Dict of {date: {category_id: {tariff_name: available}}}
"""
results: Dict[date, Dict[str, Dict[str, bool]]] = {}
total_queries = sum(len(dates) for dates in dates_by_nights.values())
query_count = 0
for nights, dates in dates_by_nights.items():
for query_date in dates:
query_count += 1
try:
result = await self._fetch_all_categories_multi_night(
query_date, nights, guests_adults, guests_children
)
results[query_date] = result
logger.info(f"Multi-night check {query_count}/{total_queries}: {query_date} ({nights} nights)")
except Exception as e:
logger.warning(f"Failed multi-night check for {query_date}: {e}")
# Rate limiting
await asyncio.sleep(1.0)
return results
async def _fetch_rates_batch(
self,
category_id: str,
from_date: date,
to_date: date,
guests_adults: int,
guests_children: int,
retry_count: int = 0
) -> List[Dict]:
"""
Fetch rates for a batch of dates (up to 7 days).
Handles minimum stay restrictions by extending the period and
extracting only the dates we need.
Returns:
List of dicts with {date, gross_rate, net_rate}
"""
# Format dates with times (check-in 14:00, check-out 10:00)
period_from = f"{from_date.isoformat()} 14:00:00"
period_to = f"{(to_date + timedelta(days=1)).isoformat()} 10:00:00"
payload = self._get_auth_payload()
payload.update({
"period_from": period_from,
"period_to": period_to,
"adults": guests_adults,
"children": guests_children,
"infants": 0,
"category_id": category_id,
"daily_mode": "true" # Get per-night breakdown
})
response = await self.client.post(
self._get_url("bookings_availability_pricing"),
json=payload,
auth=(self.username, self.password)
)
# Handle rate limiting with exponential backoff
if response.status_code == 429:
if retry_count < 3:
wait_time = 60 * (retry_count + 1) # 60s, 120s, 180s
logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry {retry_count + 1}/3")
await asyncio.sleep(wait_time)
return await self._fetch_rates_batch(
category_id, from_date, to_date, guests_adults, guests_children, retry_count + 1
)
else:
raise NewbookRatesError(f"Rate limited after 3 retries")
if response.status_code != 200:
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
data = response.json()
if not data.get("success"):
# Check if minimum stay restriction
categories = data.get("data", {}).get("categories", [])
if categories:
cat = categories[0] if isinstance(categories, list) else categories.get(category_id, {})
min_periods = cat.get("minimum_periods", 1)
if min_periods > 1:
# Extend the stay to meet minimum and retry
extended_to = from_date + timedelta(days=min_periods)
logger.info(f"Minimum stay {min_periods} nights for category {category_id}, extending to {extended_to}")
return await self._fetch_rates_with_min_stay(
category_id, from_date, to_date, extended_to,
guests_adults, guests_children
)
raise NewbookRatesError(f"API returned failure: {data.get('message')}")
# Parse tariffs_quoted from response
return self._parse_tariffs(data, from_date, to_date)
async def _fetch_rates_with_min_stay(
self,
category_id: str,
from_date: date,
to_date: date,
extended_to: date,
guests_adults: int,
guests_children: int,
retry_count: int = 0
) -> List[Dict]:
"""
Fetch rates with extended period for minimum stay requirement.
Args:
category_id: Newbook category ID
from_date: Original start date
to_date: Original end date (dates we want)
extended_to: Extended end date to meet minimum stay
guests_adults: Number of adults
guests_children: Number of children
Returns:
List of rates for the original date range only
"""
period_from = f"{from_date.isoformat()} 14:00:00"
period_to = f"{(extended_to + timedelta(days=1)).isoformat()} 10:00:00"
payload = self._get_auth_payload()
payload.update({
"period_from": period_from,
"period_to": period_to,
"adults": guests_adults,
"children": guests_children,
"infants": 0,
"category_id": category_id,
"daily_mode": "true"
})
response = await self.client.post(
self._get_url("bookings_availability_pricing"),
json=payload,
auth=(self.username, self.password)
)
# Handle rate limiting with exponential backoff
if response.status_code == 429:
if retry_count < 3:
wait_time = 60 * (retry_count + 1)
logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry")
await asyncio.sleep(wait_time)
return await self._fetch_rates_with_min_stay(
category_id, from_date, to_date, extended_to, guests_adults, guests_children, retry_count + 1
)
else:
raise NewbookRatesError(f"Rate limited after 3 retries")
if response.status_code != 200:
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
data = response.json()
if not data.get("success"):
raise NewbookRatesError(f"API returned failure even with extended stay: {data.get('message')}")
# Parse tariffs but only return dates in our original range
return self._parse_tariffs(data, from_date, to_date)
def _parse_tariffs(self, data: dict, from_date: date, to_date: date) -> List[Dict]:
"""
Parse tariffs from API response.
With daily_mode=true, the API returns tariffs_quoted as a dict keyed by date.
Falls back to tariffs_available average if tariffs_quoted not available.
Args:
data: Full API response
from_date: Start date to include
to_date: End date to include
Returns:
List of dicts with {date, gross_rate, net_rate, tariffs_data}
tariffs_data contains all available tariff options for rate report
"""
rates = []
tariffs_quoted = {}
fallback_rate = None
inventory_items = []
all_tariffs_available = [] # Store all tariff options for reporting
# Find tariffs data in the response
if isinstance(data.get("data"), dict):
for key in data["data"].keys():
# Category IDs are numeric strings
if key.isdigit() or key.isnumeric():
cat_data = data["data"][key]
if isinstance(cat_data, dict):
tariffs_available = cat_data.get("tariffs_available", [])
all_tariffs_available = tariffs_available # Capture all options
if tariffs_available:
first_tariff = tariffs_available[0]
# tariffs_quoted is a dict keyed by date string
tariffs_quoted = first_tariff.get("tariffs_quoted", {})
# inventory_items are at tariff level (total for whole stay)
inventory_items = first_tariff.get("inventory_items", [])
# Fallback average rate
fallback_rate = Decimal(str(first_tariff.get('average_nightly_tariff', 0) or 0))
break
# If we have per-night tariffs_quoted dict, parse it
if isinstance(tariffs_quoted, dict) and tariffs_quoted:
num_nights = len(tariffs_quoted)
# Calculate per-night inventory item amount for items already included in tariff
included_inventory_per_night = Decimal('0')
for item in inventory_items:
already_included = item.get('amount_already_included_in_tariff_total', '')
if str(already_included).lower() == 'true':
total_amount = Decimal(str(item.get('amount', 0) or 0))
included_inventory_per_night += total_amount / num_nights
for date_str, tariff in tariffs_quoted.items():
try:
stay_date = date.fromisoformat(date_str)
except ValueError:
continue
# Only include dates in our range
if stay_date < from_date or stay_date > to_date:
continue
gross_rate = Decimal(str(tariff.get('amount', 0) or 0))
# Net = (gross - included_inventory_per_night) / (1 + VAT)
gross_after_inventory = gross_rate - included_inventory_per_night
net_rate = (gross_after_inventory / (1 + self.vat_rate)).quantize(Decimal('0.01'))
# Build tariffs_data with day-specific rates
tariffs_data = self._build_tariffs_summary(all_tariffs_available, stay_date)
rates.append({
'date': stay_date,
'gross_rate': float(gross_rate),
'net_rate': float(net_rate),
'tariffs_data': tariffs_data
})
return rates
# Fallback: use average_nightly_tariff and apply to all dates
if fallback_rate and fallback_rate > 0:
net_rate = (fallback_rate / (1 + self.vat_rate)).quantize(Decimal('0.01'))
current_date = from_date
while current_date <= to_date:
# Build tariffs_data (no day-specific rates in fallback)
tariffs_data = self._build_tariffs_summary(all_tariffs_available, current_date)
rates.append({
'date': current_date,
'gross_rate': float(fallback_rate),
'net_rate': float(net_rate),
'tariffs_data': tariffs_data
})
current_date += timedelta(days=1)
return rates
logger.warning(f"No rate found in response for {from_date} to {to_date}")
return rates
def _build_tariffs_summary(self, tariffs_available: list, for_date: date = None) -> dict:
"""
Build a summary of all available tariff options for rate reporting.
Args:
tariffs_available: List of tariff dicts from API response
for_date: Optional specific date to extract day-specific rates
Returns:
Dict with tariff summaries - tariff_count and list of tariff details
"""
if not tariffs_available:
return {}
summary = {
'tariff_count': len(tariffs_available),
'tariffs': []
}
date_key = for_date.isoformat() if for_date else None
for idx, tariff in enumerate(tariffs_available):
# Get day-specific rate from tariffs_quoted if available
day_rate = None
if date_key:
tariffs_quoted = tariff.get('tariffs_quoted', {})
if isinstance(tariffs_quoted, dict) and date_key in tariffs_quoted:
day_quote = tariffs_quoted[date_key]
if isinstance(day_quote, dict):
day_rate = float(day_quote.get('amount', 0) or 0)
else:
day_rate = float(day_quote or 0)
# API uses tariff_label for the name
message = tariff.get('tariff_message', '')
# Extract minimum stay from message or dedicated field
min_stay = tariff.get('minimum_nights', None)
if min_stay is None and message:
# Try to parse from message like "Minimum 2 nights" or "2 Night Minimum"
import re
match = re.search(r'(\d+)\s*[Nn]ight\s*[Mm]inimum', message)
if not match:
match = re.search(r'[Mm]inimum\s+(\d+)\s*(?:night|period)', message)
if match:
min_stay = int(match.group(1))
# Extract advance booking requirement from message
min_advance_days = None
if message:
import re
advance_match = re.search(r'(\d+)\s*days?\s*in\s*advance', message, re.IGNORECASE)
if advance_match:
min_advance_days = int(advance_match.group(1))
tariff_info = {
'name': tariff.get('tariff_label', 'Unknown'),
'description': tariff.get('tariff_short_description', ''),
'rate': day_rate, # Day-specific rate (None if not available)
'average_nightly': float(tariff.get('average_nightly_tariff', 0) or 0),
'success': str(tariff.get('tariff_success', False)).lower() in ('true', '1'),
'message': message,
'sort_order': idx, # Preserve Newbook ordering
'min_stay': min_stay, # Minimum nights required (if any)
'min_advance_days': min_advance_days, # Advance booking requirement (if any)
}
summary['tariffs'].append(tariff_info)
return summary
def _parse_all_categories_tariffs(self, data: dict, for_date: date) -> Dict[str, List[Dict]]:
"""
Parse tariffs from API response for ALL categories.
When category_id is omitted, data.data contains category IDs as keys,
each with their own tariffs_available.
Args:
data: Full API response
for_date: The date we queried
Returns:
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]}
"""
results: Dict[str, List[Dict]] = {}
if not isinstance(data.get("data"), dict):
return results
for key, cat_data in data["data"].items():
# Category IDs are numeric strings like "1", "8", etc.
if not (key.isdigit() or str(key).isnumeric()):
continue
if not isinstance(cat_data, dict):
continue
category_id = str(key)
tariffs_available = cat_data.get("tariffs_available", [])
if not tariffs_available:
continue
# Get the first (best) tariff for gross/net calculation
first_tariff = tariffs_available[0]
tariffs_quoted = first_tariff.get("tariffs_quoted", {})
inventory_items = first_tariff.get("inventory_items", [])
# Get rate for this date
date_key = for_date.isoformat()
gross_rate = Decimal('0')
net_rate = Decimal('0')
if isinstance(tariffs_quoted, dict) and date_key in tariffs_quoted:
day_tariff = tariffs_quoted[date_key]
gross_rate = Decimal(str(day_tariff.get('amount', 0) or 0))
# Calculate included inventory per night
included_inventory = Decimal('0')
for item in inventory_items:
already_included = item.get('amount_already_included_in_tariff_total', '')
if str(already_included).lower() == 'true':
included_inventory += Decimal(str(item.get('amount', 0) or 0))
gross_after_inventory = gross_rate - included_inventory
net_rate = (gross_after_inventory / (1 + self.vat_rate)).quantize(Decimal('0.01'))
else:
# Fallback to average
gross_rate = Decimal(str(first_tariff.get('average_nightly_tariff', 0) or 0))
net_rate = (gross_rate / (1 + self.vat_rate)).quantize(Decimal('0.01'))
# Build tariffs summary for all options
tariffs_data = self._build_tariffs_summary(tariffs_available, for_date)
results[category_id] = [{
'date': for_date,
'gross_rate': float(gross_rate),
'net_rate': float(net_rate),
'tariffs_data': tariffs_data
}]
return results

View file

@ -0,0 +1,20 @@
"""
Scraper backends for booking.com rate scraping.
Provides pluggable backends to allow switching between:
- playwright_local: Direct Playwright (default)
- playwright_proxy: Playwright with rotating proxies (future)
- apify_backend: Apify scraping service (future)
"""
from .base import ScraperBackend, ScraperResult, HotelData, RateData, AvailabilityStatus
from .playwright_local import PlaywrightLocalBackend
__all__ = [
'ScraperBackend',
'ScraperResult',
'HotelData',
'RateData',
'AvailabilityStatus',
'PlaywrightLocalBackend',
]

View file

@ -0,0 +1,152 @@
"""
Abstract base class for booking.com scraper backends.
Defines the interface that all scraper backends must implement,
allowing easy switching between local Playwright, proxied Playwright,
or external services like Apify.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import List, Optional, Dict, Any
from enum import Enum
class AvailabilityStatus(str, Enum):
"""Availability status for a hotel rate."""
AVAILABLE = 'available' # Rate found, bookable
SOLD_OUT = 'sold_out' # Hotel shows no availability
NO_DATA = 'no_data' # Couldn't determine (scraper issue)
@dataclass
class RateData:
"""Rate data for a single hotel on a single date."""
hotel_id: Optional[str] = None # Our internal hotel_id (filled after DB lookup)
booking_com_id: str = '' # Hotel ID from booking.com
rate_date: date = None
availability_status: AvailabilityStatus = AvailabilityStatus.NO_DATA
rate_gross: Optional[Decimal] = None
currency: str = 'GBP'
room_type: Optional[str] = None
breakfast_included: Optional[bool] = None
free_cancellation: Optional[bool] = None
no_prepayment: Optional[bool] = None
rooms_left: Optional[int] = None # "Only X rooms left"
available_qty: Optional[int] = None # Future: from hotel page dropdown
@dataclass
class HotelData:
"""Hotel data discovered from search results."""
booking_com_id: str
name: str
booking_com_url: Optional[str] = None
star_rating: Optional[Decimal] = None
review_score: Optional[Decimal] = None
review_count: Optional[int] = None
@dataclass
class ScraperResult:
"""Result from a scraping operation."""
success: bool
blocked: bool = False # True if anti-scrape blocking detected
block_reason: Optional[str] = None # CAPTCHA, rate limit, etc.
hotels: List[HotelData] = field(default_factory=list)
rates: List[RateData] = field(default_factory=list)
error_message: Optional[str] = None
page_content_sample: Optional[str] = None # For debugging
class ScraperBackend(ABC):
"""
Abstract base class for scraper backends.
All backends must implement these methods to provide a consistent
interface for the main booking_scraper.py service.
"""
# Common block detection signals
BLOCK_SIGNALS = [
'captcha',
'unusual traffic',
'access denied',
'please verify',
'too many requests',
'are you a robot',
'verify you are human',
'security check',
]
@abstractmethod
async def scrape_location_search(
self,
location: str,
check_in: date,
check_out: date,
adults: int = 2,
pages: int = 2
) -> ScraperResult:
"""
Scrape booking.com location search results.
Args:
location: Location name (e.g., "Bowness-on-Windermere")
check_in: Check-in date
check_out: Check-out date (typically check_in + 1 for single night)
adults: Number of adults for search
pages: Number of search result pages to scrape
Returns:
ScraperResult with hotels and rates found
"""
pass
@abstractmethod
async def scrape_hotel_page(
self,
hotel_url: str,
check_in: date,
check_out: date,
adults: int = 2
) -> ScraperResult:
"""
Scrape an individual hotel page for detailed rates.
Future expansion - not used in initial implementation.
Will provide available_qty from room dropdowns.
Args:
hotel_url: Full booking.com URL for the hotel
check_in: Check-in date
check_out: Check-out date
adults: Number of adults
Returns:
ScraperResult with detailed rate information
"""
pass
@abstractmethod
async def close(self):
"""Clean up any resources (browser instances, etc.)."""
pass
def detect_blocking(self, page_content: str) -> tuple[bool, Optional[str]]:
"""
Check if page content shows anti-scrape response.
Args:
page_content: HTML content of the page
Returns:
Tuple of (is_blocked, reason)
"""
content_lower = page_content.lower()
for signal in self.BLOCK_SIGNALS:
if signal in content_lower:
return True, signal
return False, None

View file

@ -0,0 +1,401 @@
"""
Local Playwright backend for booking.com scraping.
Uses Playwright with Chromium to scrape search results.
No proxy - direct connection. Suitable for low-volume scraping.
"""
import asyncio
import logging
import random
import re
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import List, Optional
from urllib.parse import urlencode
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
from .base import (
ScraperBackend,
ScraperResult,
HotelData,
RateData,
AvailabilityStatus
)
logger = logging.getLogger(__name__)
class PlaywrightLocalBackend(ScraperBackend):
"""
Local Playwright backend using Chromium.
Features:
- Rotates user agents
- Random delays between requests
- Mimics human scroll behavior
- Uses data-testid selectors for stability
"""
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
]
def __init__(self, proxy_config: dict = None):
"""
Initialize the backend.
Args:
proxy_config: Optional proxy configuration (for future use)
"""
self.proxy_config = proxy_config
self._playwright = None
self._browser: Optional[Browser] = None
async def _ensure_browser(self) -> Browser:
"""Ensure browser is running, start if needed."""
if self._browser is None or not self._browser.is_connected():
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage',
]
)
return self._browser
async def _create_context(self) -> BrowserContext:
"""Create a new browser context with random user agent."""
browser = await self._ensure_browser()
context = await browser.new_context(
user_agent=random.choice(self.USER_AGENTS),
viewport={'width': 1920, 'height': 1080},
locale='en-GB',
timezone_id='Europe/London',
)
return context
def _build_search_url(
self,
location: str,
check_in: date,
check_out: date,
adults: int,
offset: int = 0
) -> str:
"""Build booking.com search URL with parameters."""
params = {
'ss': location,
'checkin': check_in.isoformat(),
'checkout': check_out.isoformat(),
'group_adults': adults,
'no_rooms': 1,
'group_children': 0,
}
if offset > 0:
params['offset'] = offset
return f"https://www.booking.com/searchresults.en-gb.html?{urlencode(params)}"
def _parse_price(self, price_text: str) -> Optional[Decimal]:
"""Parse price from text like '£150' or 'GBP 150'."""
if not price_text:
return None
# Remove currency symbols and extract number
cleaned = re.sub(r'[£$€,\s]', '', price_text)
# Find first number (including decimals)
match = re.search(r'[\d,]+(?:\.\d{2})?', cleaned)
if match:
try:
return Decimal(match.group().replace(',', ''))
except InvalidOperation:
return None
return None
def _extract_hotel_id(self, url: str) -> Optional[str]:
"""Extract hotel ID from booking.com URL."""
if not url:
return None
# URL format: /hotel/gb/hotel-name.en-gb.html or ?dest_id=123
# Try to extract from URL path
match = re.search(r'/hotel/[a-z]{2}/([^/]+)\.', url)
if match:
return match.group(1)
# Try dest_id parameter
match = re.search(r'dest_id=(-?\d+)', url)
if match:
return match.group(1)
return None
async def _human_like_scroll(self, page: Page):
"""Simulate human-like scrolling behavior."""
# Scroll down in increments
for _ in range(3):
await page.mouse.wheel(0, random.randint(300, 600))
await asyncio.sleep(random.uniform(0.3, 0.8))
async def _extract_search_results(self, page: Page, rate_date: date) -> tuple[List[HotelData], List[RateData]]:
"""Extract hotel and rate data from search results page."""
hotels = []
rates = []
# Wait for property cards - booking.com uses data-testid
try:
await page.wait_for_selector('[data-testid="property-card"]', timeout=15000)
except Exception as e:
logger.warning(f"No property cards found: {e}")
return hotels, rates
# Get all property cards
cards = await page.query_selector_all('[data-testid="property-card"]')
logger.info(f"Found {len(cards)} property cards")
for card in cards:
try:
hotel = HotelData(booking_com_id='', name='')
rate = RateData(rate_date=rate_date)
# Hotel name
name_el = await card.query_selector('[data-testid="title"]')
if name_el:
hotel.name = (await name_el.inner_text()).strip()
if not hotel.name:
continue # Skip if no name found
# Hotel URL and ID
link_el = await card.query_selector('[data-testid="title-link"]')
if link_el:
hotel.booking_com_url = await link_el.get_attribute('href')
hotel.booking_com_id = self._extract_hotel_id(hotel.booking_com_url) or ''
rate.booking_com_id = hotel.booking_com_id
# Star rating - look for star icons or rating text
stars_el = await card.query_selector('[data-testid="rating-stars"]')
if stars_el:
stars_text = await stars_el.get_attribute('aria-label') or ''
match = re.search(r'(\d+)', stars_text)
if match:
hotel.star_rating = Decimal(match.group(1))
# Review score
score_el = await card.query_selector('[data-testid="review-score"]')
if score_el:
score_text = await score_el.inner_text()
match = re.search(r'([\d.]+)', score_text)
if match:
try:
hotel.review_score = Decimal(match.group(1))
except InvalidOperation:
pass
# Check for no availability message FIRST
no_avail_el = await card.query_selector('[data-testid="availability-message"]')
if no_avail_el:
avail_text = (await no_avail_el.inner_text()).lower()
if 'no availability' in avail_text or 'sold out' in avail_text:
rate.availability_status = AvailabilityStatus.SOLD_OUT
hotels.append(hotel)
rates.append(rate)
continue
# Price
price_el = await card.query_selector('[data-testid="price-and-discounted-price"]')
if not price_el:
# Try alternative selector
price_el = await card.query_selector('[data-testid="price"]')
if price_el:
price_text = await price_el.inner_text()
rate.rate_gross = self._parse_price(price_text)
if rate.rate_gross:
rate.availability_status = AvailabilityStatus.AVAILABLE
# Room type
room_el = await card.query_selector('[data-testid="recommended-units"]')
if room_el:
rate.room_type = (await room_el.inner_text()).strip()
# Rate option badges - try multiple selectors
# Breakfast included
breakfast_el = await card.query_selector('[data-testid="breakfast-included"]')
if not breakfast_el:
# Check text content for breakfast mentions
card_text = (await card.inner_text()).lower()
rate.breakfast_included = 'breakfast included' in card_text
else:
rate.breakfast_included = True
# Free cancellation
cancel_el = await card.query_selector('[data-testid="cancellation-policy"]')
if cancel_el:
cancel_text = (await cancel_el.inner_text()).lower()
rate.free_cancellation = 'free cancellation' in cancel_text
else:
card_text = (await card.inner_text()).lower()
rate.free_cancellation = 'free cancellation' in card_text
# No prepayment
prepay_el = await card.query_selector('[data-testid="no-prepayment"]')
if prepay_el:
rate.no_prepayment = True
else:
card_text = (await card.inner_text()).lower()
rate.no_prepayment = 'no prepayment' in card_text
# Rooms left / scarcity indicator
scarcity_el = await card.query_selector('[data-testid="availability-rate"]')
if scarcity_el:
scarcity_text = await scarcity_el.inner_text()
match = re.search(r'(\d+)\s*room', scarcity_text.lower())
if match:
rate.rooms_left = int(match.group(1))
hotels.append(hotel)
rates.append(rate)
except Exception as e:
logger.warning(f"Error extracting hotel data: {e}")
continue
return hotels, rates
async def scrape_location_search(
self,
location: str,
check_in: date,
check_out: date,
adults: int = 2,
pages: int = 2
) -> ScraperResult:
"""
Scrape booking.com location search results.
Args:
location: Location name
check_in: Check-in date
check_out: Check-out date (check_in + 1 for single night rate)
adults: Number of adults
pages: Number of result pages to scrape
Returns:
ScraperResult with hotels and rates found
"""
all_hotels = []
all_rates = []
seen_hotel_ids = set()
context = None
page = None
try:
context = await self._create_context()
page = await context.new_page()
for page_num in range(pages):
# Random delay between pages (3-7 seconds)
if page_num > 0:
delay = random.uniform(3, 7)
logger.info(f"Waiting {delay:.1f}s before page {page_num + 1}")
await asyncio.sleep(delay)
# Build URL with offset for pagination (25 results per page)
url = self._build_search_url(
location, check_in, check_out, adults,
offset=page_num * 25
)
logger.info(f"Scraping page {page_num + 1}: {url}")
try:
await page.goto(url, wait_until='networkidle', timeout=30000)
except Exception as e:
logger.warning(f"Page load timeout, continuing: {e}")
# Check for blocking
content = await page.content()
is_blocked, reason = self.detect_blocking(content)
if is_blocked:
logger.warning(f"Blocking detected: {reason}")
return ScraperResult(
success=False,
blocked=True,
block_reason=reason,
hotels=all_hotels,
rates=all_rates,
page_content_sample=content[:1000]
)
# Human-like scrolling
await self._human_like_scroll(page)
# Extract data
hotels, rates = await self._extract_search_results(page, check_in)
# Deduplicate by booking_com_id
for hotel, rate in zip(hotels, rates):
if hotel.booking_com_id and hotel.booking_com_id not in seen_hotel_ids:
seen_hotel_ids.add(hotel.booking_com_id)
all_hotels.append(hotel)
all_rates.append(rate)
logger.info(f"Page {page_num + 1}: found {len(hotels)} hotels, {len(all_hotels)} total unique")
return ScraperResult(
success=True,
blocked=False,
hotels=all_hotels,
rates=all_rates
)
except Exception as e:
logger.error(f"Scrape error: {e}")
return ScraperResult(
success=False,
blocked=False,
error_message=str(e),
hotels=all_hotels,
rates=all_rates
)
finally:
if page:
await page.close()
if context:
await context.close()
async def scrape_hotel_page(
self,
hotel_url: str,
check_in: date,
check_out: date,
adults: int = 2
) -> ScraperResult:
"""
Scrape individual hotel page for detailed rates.
Future expansion - placeholder for now.
Will extract available_qty from room dropdowns.
"""
# Not implemented in Phase 2a
logger.warning("scrape_hotel_page not yet implemented")
return ScraperResult(
success=False,
error_message="Hotel page scraping not yet implemented"
)
async def close(self):
"""Clean up browser resources."""
if self._browser:
await self._browser.close()
self._browser = None
if self._playwright:
await self._playwright.stop()
self._playwright = None

36
docker-compose.yml Normal file
View file

@ -0,0 +1,36 @@
services:
backend:
build: ./backend
security_opt:
- apparmor=unconfined
environment:
- DATABASE_URL=${DATABASE_URL}
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- APP_SLUG=rates
- SETTINGS_URL=${SETTINGS_URL:-}
- SETTINGS_SECRET=${SETTINGS_SECRET:-}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s
timeout: 10s
retries: 10
start_period: 60s
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME:-Hotel}
security_opt:
- apparmor=unconfined
ports:
- "${FRONTEND_PORT:-3080}:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
networks:
default:
driver: bridge

15
frontend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
ARG VITE_HOTEL_NAME=Hotel
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html/rates
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rate Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

35
frontend/nginx.conf Normal file
View file

@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
# 1. Central auth proxy
location /rates/api/auth/ {
proxy_pass http://10.10.10.101:3001/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 2. App backend (Python FastAPI on port 8000)
location /rates/api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
proxy_read_timeout 300s;
proxy_connect_timeout 30s;
client_max_body_size 10M;
}
# 3. Health
location /rates/health {
proxy_pass http://backend:8000/health;
}
# 4. SPA fallback
location /rates/ {
root /usr/share/nginx/html;
try_files $uri $uri/ /rates/index.html;
}
}

30
frontend/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "rates-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.32.0",
"axios": "^1.6.8",
"lucide-react": "^0.395.0",
"plotly.js": "^2.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-plotly.js": "^2.6.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@types/plotly.js": "^2.12.29",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/react-plotly.js": "^2.6.4",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.4.5",
"vite": "^5.2.11"
}
}

28
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,28 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import Layout from './components/Layout'
import Bookability from './pages/Bookability'
import MarketView from './pages/MarketView'
import DirectRates from './pages/DirectRates'
import RateAnalysis from './pages/RateAnalysis'
import Settings from './pages/Settings'
export default function App() {
return (
<AuthGate>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/bookability" replace />} />
<Route path="/bookability" element={<Bookability />} />
<Route path="/market" element={<MarketView />} />
<Route path="/direct" element={<DirectRates />} />
<Route path="/direct/:hotelId" element={<DirectRates />} />
<Route path="/analysis" element={<RateAnalysis />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/:tab" element={<Settings />} />
<Route path="*" element={<Navigate to="/bookability" replace />} />
</Routes>
</Layout>
</AuthGate>
)
}

20
frontend/src/api.ts Normal file
View file

@ -0,0 +1,20 @@
import axios from 'axios'
const BASE = '/rates/api'
const api = axios.create({
baseURL: BASE,
withCredentials: true,
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
}
return Promise.reject(error)
}
)
export default api

View file

@ -0,0 +1,47 @@
import { createContext, useContext, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { User } from '../types'
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth outside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [checking, setChecking] = useState(true)
useEffect(() => {
fetch('/rates/api/auth/verify?app=rates', { credentials: 'include' })
.then(r => {
if (!r.ok) throw new Error('unauth')
return r.json()
})
.then(data => setUser({
email: data.email || data.sub || '',
name: data.name || data.display_name || '',
is_admin: data.is_admin ?? false,
caps: data.caps ?? [],
}))
.catch(() => {
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
})
.finally(() => setChecking(false))
}, [])
if (checking) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--navy-dark)' }}>
<div className="spinner" style={{ width: 32, height: 32 }} />
</div>
)
}
if (!user) return null
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,70 @@
import { NavLink } from 'react-router-dom'
import { TrendingUp, Calendar, Globe, BarChart2, Settings, Building2 } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from './AuthGate'
import { can } from '../types'
import api from '../api'
import type { ReactNode } from 'react'
const ICON = { size: 16, strokeWidth: 1.75 }
const NAV = [
{ to: '/bookability', label: 'Bookability', icon: Calendar, cap: 'view_own_rates' },
{ to: '/market', label: 'Market View', icon: Globe, cap: 'view_competitors' },
{ to: '/direct', label: 'Direct Rates', icon: Building2, cap: 'view_direct_rates' },
{ to: '/analysis', label: 'Rate Analysis', icon: TrendingUp, cap: 'rate_analysis' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'manage_scraper' },
]
export default function Layout({ children }: { children: ReactNode }) {
const { user } = useAuth()
const items = NAV.filter(n => can(user, n.cap))
const { data: alertCount } = useQuery<number>({
queryKey: ['parity-alert-count'],
queryFn: () => api.get('/competitors/parity/alerts?status=active').then(r => r.data.length),
refetchInterval: 60_000,
enabled: can(user, 'view_competitors'),
})
return (
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar-logo">
<BarChart2 size={18} strokeWidth={1.75} />
Rate Monitor
</div>
<nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON} />
{label}
{to === '/market' && alertCount ? (
<span style={{
marginLeft: 'auto', background: 'var(--danger)', color: '#fff',
borderRadius: 10, fontSize: 10, fontWeight: 700,
padding: '1px 6px', lineHeight: '16px',
}}>{alertCount}</span>
) : null}
</NavLink>
))}
</nav>
<div className="sidebar-user">{user.name || user.email}</div>
</aside>
<header className="top-bar">
<BarChart2 size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Rate Monitor</span>
<nav className="top-bar-nav">
{items.map(({ to, label }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
{label}
</NavLink>
))}
</nav>
</header>
<main className="page-content">{children}</main>
</div>
)
}

363
frontend/src/index.css Normal file
View file

@ -0,0 +1,363 @@
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--body-bg);
color: var(--text-dark);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
}
/* App shell layout */
.app-shell {
display: grid;
grid-template-columns: 220px 1fr;
grid-template-rows: auto 1fr;
min-height: 100vh;
}
.sidebar {
grid-column: 1;
grid-row: 1 / -1;
background: var(--navy);
display: flex;
flex-direction: column;
padding: 0;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 16px;
font-size: 15px;
font-weight: 600;
color: var(--text);
border-bottom: 1px solid var(--surface);
}
.sidebar-nav {
flex: 1;
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 8px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background 0.15s, color 0.15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,0.15); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
font-size: 12px;
color: var(--text-muted);
border-top: 1px solid var(--surface);
}
/* Top bar — mobile/collapsed fallback */
.top-bar {
display: none;
}
.page-content {
grid-column: 2;
grid-row: 1 / -1;
min-width: 0;
padding: 24px;
background: var(--body-bg);
}
/* Cards */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--card-border);
font-size: 14px;
font-weight: 600;
color: var(--text-dark);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-body { padding: 20px; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, opacity 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--gold);
color: var(--navy);
}
.btn-primary:hover:not(:disabled) { background: var(--gold-light); }
.btn-secondary {
background: var(--navy);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) { background: var(--navy-dark); }
.btn-outline {
background: transparent;
color: var(--text-dark);
border: 1px solid var(--card-border);
}
.btn-outline:hover:not(:disabled) { background: var(--body-bg); }
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover:not(:disabled) { opacity: 0.85; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* Form elements */
input, select, textarea {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 7px;
padding: 7px 10px;
font-size: 13px;
color: var(--text-dark);
width: 100%;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--gold); }
/* Badges */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
.badge-danger { background: #fee2e2; color: #dc2626; }
.badge-info { background: #dbeafe; color: #2563eb; }
.badge-neutral { background: #f1f5f9; color: #64748b; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
text-align: left;
padding: 8px 12px;
background: #f8fafc;
border-bottom: 1px solid var(--card-border);
font-weight: 600;
color: var(--text-mid);
white-space: nowrap;
}
td {
padding: 8px 12px;
border-bottom: 1px solid #f1f5f9;
color: var(--text-dark);
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8fafc; }
/* Page header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: var(--text-dark);
}
.page-subtitle { font-size: 13px; color: var(--text-mid); margin-top: 2px; }
/* Sub-nav tabs */
.sub-nav {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--card-border);
margin-bottom: 24px;
overflow-x: auto;
}
.sub-nav-item {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-mid);
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
background: none;
border-left: none;
border-right: none;
border-top: none;
transition: color 0.15s, border-color 0.15s;
}
.sub-nav-item:hover { color: var(--text-dark); }
.sub-nav-item.active { color: var(--gold); border-bottom-color: var(--gold); }
/* Status dot */
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.green { background: var(--success); }
.status-dot.yellow { background: var(--warning); }
.status-dot.red { background: var(--danger); }
.status-dot.grey { background: #94a3b8; }
/* Spinner */
.spinner {
width: 20px; height: 20px;
border: 2px solid var(--card-border);
border-top-color: var(--gold);
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Loading / empty states */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 60px 20px;
color: var(--text-mid);
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-mid);
font-size: 13px;
}
/* Grid helpers */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
/* Stat card */
.stat-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 16px 20px;
box-shadow: var(--shadow-sm);
}
.stat-label { font-size: 12px; color: var(--text-mid); margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; color: var(--text-dark); }
.stat-delta { font-size: 12px; margin-top: 4px; }
.stat-delta.positive { color: var(--success); }
.stat-delta.negative { color: var(--danger); }
.stat-delta.neutral { color: var(--text-mid); }
/* Responsive — at narrow widths hide sidebar, show top-bar */
@media (max-width: 900px) {
.app-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar { display: none; }
.top-bar {
display: flex;
align-items: center;
gap: 12px;
background: var(--navy);
padding: 0 16px;
height: 52px;
grid-column: 1;
grid-row: 1;
overflow-x: auto;
}
.top-bar-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
margin-right: 8px;
}
.top-bar-nav { display: flex; gap: 4px; }
.top-bar-nav a {
color: var(--text-muted);
text-decoration: none;
font-size: 12.5px;
padding: 6px 10px;
border-radius: 6px;
white-space: nowrap;
}
.top-bar-nav a:hover { color: var(--text); background: var(--surface); }
.top-bar-nav a.active { color: var(--gold); }
.page-content {
grid-column: 1;
grid-row: 2;
padding: 16px;
}
}

20
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const qc = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter basename="/rates">
<QueryClientProvider client={qc}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,520 @@
import React, { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Building2, RefreshCw, Plus, Settings, ChevronDown, ChevronRight,
AlertCircle, CheckCircle, Clock,
} from 'lucide-react'
import api from '../api'
import { useAuth } from '../components/AuthGate'
import { can } from '../types'
const fmtDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const TABS = ['overview', 'hotel', 'manage'] as const
type Tab = typeof TABS[number]
interface DirectHotel {
id: number
name: string
profile_name: string
scrape_enabled: boolean
last_scraped_at: string | null
scraped_dates: number
last_rate_at: string | null
}
interface DateRow {
stay_date: string
cheapest_rate: number | null
has_availability: boolean
has_min_stay: boolean
scraped_at: string | null
}
interface RoomRow {
room_id: string
rate_id: string
room_label: string
rate_label: string
availability: number
price_incl: number | null
min_stay_nights: number | null
bench_rate: number | null
}
const fmt = (v: number | null) => v != null ? `£${v.toFixed(2)}` : '—'
const age = (ts: string | null) => {
if (!ts) return 'Never'
const h = Math.round((Date.now() - new Date(ts).getTime()) / 3600000)
return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
}
export default function DirectRates() {
const { hotelId } = useParams<{ hotelId?: string }>()
const navigate = useNavigate()
const { user } = useAuth()
const canManage = can(user, 'manage_hotels')
const [tab, setTab] = useState<Tab>(hotelId ? 'hotel' : 'overview')
const [selectedHotel, setSelectedHotel] = useState<number | null>(hotelId ? parseInt(hotelId) : null)
const [expandedDate, setExpandedDate] = useState<string | null>(null)
const [fromDate, setFromDate] = useState(fmtDate(new Date()))
const [toDate, setToDate] = useState(fmtDate(new Date(Date.now() + 89 * 86400000)))
const { data: hotels, isLoading: hotelsLoading } = useQuery<DirectHotel[]>({
queryKey: ['direct-hotels'],
queryFn: () => api.get('/direct/hotels').then(r => r.data),
})
const { data: dates, isLoading: datesLoading } = useQuery<{ dates: DateRow[]; hotel_name: string }>({
queryKey: ['direct-dates', selectedHotel, fromDate, toDate],
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/dates`, {
params: { from_date: fromDate, to_date: toDate }
}).then(r => r.data),
enabled: !!selectedHotel && tab === 'hotel',
})
const { data: roomData } = useQuery<{ rooms: RoomRow[]; bench_price: number | null }>({
queryKey: ['direct-rooms', selectedHotel, expandedDate],
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/date/${expandedDate}/rooms`).then(r => r.data),
enabled: !!selectedHotel && !!expandedDate,
})
const qc = useQueryClient()
const scrapeMutation = useMutation({
mutationFn: (id: number) => api.post(`/direct/hotels/${id}/scrape`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['direct-hotels'] }),
})
const selectHotel = (id: number) => {
setSelectedHotel(id)
setTab('hotel')
setExpandedDate(null)
navigate(`/direct/${id}`)
}
const presets = [
{ label: '7d', days: 7 }, { label: '14d', days: 14 },
{ label: '30d', days: 30 }, { label: '90d', days: 90 },
]
return (
<div>
<div className="page-header">
<div>
<div className="page-title">Direct Rates</div>
<div className="page-subtitle">Competitor booking engine rates scraped directly</div>
</div>
</div>
<div className="sub-nav">
{TABS.map(t => (
<button key={t} className={`sub-nav-item${tab === t ? ' active' : ''}`}
onClick={() => setTab(t)}>
{t === 'overview' ? 'Overview' : t === 'hotel' ? 'Hotel Detail' : 'Manage'}
</button>
))}
</div>
{tab === 'overview' && (
<OverviewTab
hotels={hotels || []}
loading={hotelsLoading}
onSelectHotel={selectHotel}
onScrape={id => scrapeMutation.mutate(id)}
scraping={scrapeMutation.isPending}
/>
)}
{tab === 'hotel' && (
<HotelDetailTab
hotels={hotels || []}
selectedHotel={selectedHotel}
onSelectHotel={selectHotel}
dates={dates?.dates || []}
hotelName={dates?.hotel_name}
datesLoading={datesLoading}
fromDate={fromDate}
toDate={toDate}
setFromDate={setFromDate}
setToDate={setToDate}
presets={presets}
expandedDate={expandedDate}
setExpandedDate={setExpandedDate}
roomData={roomData}
/>
)}
{tab === 'manage' && canManage && (
<ManageTab hotels={hotels || []} onRefresh={() => qc.invalidateQueries({ queryKey: ['direct-hotels'] })} />
)}
{tab === 'manage' && !canManage && (
<div className="empty-state">You don't have permission to manage competitor hotels.</div>
)}
</div>
)
}
// ─── Overview Tab ─────────────────────────────────────────────────────────────
function OverviewTab({ hotels, loading, onSelectHotel, onScrape, scraping }: {
hotels: DirectHotel[]
loading: boolean
onSelectHotel: (id: number) => void
onScrape: (id: number) => void
scraping: boolean
}) {
if (loading) return <div className="loading-state"><div className="spinner" />Loading</div>
if (!hotels.length) return (
<div className="empty-state">
<Building2 size={32} strokeWidth={1.5} style={{ margin: '0 auto 12px', display: 'block', color: 'var(--text-mid)' }} />
No competitor hotels configured. Use the Manage tab to add hotels.
</div>
)
return (
<div className="card">
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Hotel</th>
<th>Engine</th>
<th>Dates Scraped</th>
<th>Last Scrape</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{hotels.map(h => (
<tr key={h.id}>
<td>
<button
onClick={() => onSelectHotel(h.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--gold)', fontWeight: 600, padding: 0, fontSize: 13 }}
>
{h.name}
</button>
</td>
<td><span className="badge badge-neutral">{h.profile_name}</span></td>
<td>{h.scraped_dates}</td>
<td style={{ color: 'var(--text-mid)', fontSize: 12 }}>{age(h.last_scraped_at)}</td>
<td>
<span className={`badge ${h.scrape_enabled ? 'badge-success' : 'badge-neutral'}`}>
{h.scrape_enabled ? 'Active' : 'Paused'}
</span>
</td>
<td>
<button className="btn btn-outline btn-sm" onClick={() => onScrape(h.id)} disabled={scraping}>
<RefreshCw size={12} strokeWidth={1.75} />
Scrape
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ─── Hotel Detail Tab ─────────────────────────────────────────────────────────
function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName, datesLoading,
fromDate, toDate, setFromDate, setToDate, presets, expandedDate, setExpandedDate, roomData }: any) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Controls */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>Hotel</label>
<select
style={{ width: 220 }}
value={selectedHotel || ''}
onChange={e => onSelectHotel(parseInt(e.target.value))}
>
<option value="">Select a hotel</option>
{hotels.map((h: DirectHotel) => (
<option key={h.id} value={h.id}>{h.name}</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>From</label>
<input type="date" style={{ width: 140 }} value={fromDate} onChange={e => setFromDate(e.target.value)} />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>To</label>
<input type="date" style={{ width: 140 }} value={toDate} onChange={e => setToDate(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
{presets.map((p: any) => (
<button key={p.label} className="btn btn-outline btn-sm"
onClick={() => {
const from = new Date(); const to = new Date(Date.now() + p.days * 86400000)
setFromDate(fmtDate(from)); setToDate(fmtDate(to))
}}>{p.label}</button>
))}
</div>
</div>
{!selectedHotel && <div className="empty-state">Select a hotel to view rates.</div>}
{selectedHotel && datesLoading && <div className="loading-state"><div className="spinner" />Loading</div>}
{selectedHotel && !datesLoading && dates.length === 0 && (
<div className="empty-state">No rate data for this period. Run a scrape first.</div>
)}
{selectedHotel && !datesLoading && dates.length > 0 && (
<div className="card">
<div className="card-header">{hotelName} {dates.length} dates</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th></th>
<th>Date</th>
<th>Cheapest Rate</th>
<th>Availability</th>
<th>Min-Stay</th>
<th>Scraped</th>
</tr>
</thead>
<tbody>
{dates.map((d: DateRow) => (
<React.Fragment key={d.stay_date}>
<tr
onClick={() => setExpandedDate(expandedDate === d.stay_date ? null : d.stay_date)}
style={{ cursor: 'pointer' }}
>
<td style={{ width: 24, color: 'var(--text-mid)' }}>
{expandedDate === d.stay_date
? <ChevronDown size={14} strokeWidth={1.75} />
: <ChevronRight size={14} strokeWidth={1.75} />}
</td>
<td>{d.stay_date}</td>
<td style={{ fontWeight: 600 }}>
{d.cheapest_rate ? `£${Number(d.cheapest_rate).toFixed(2)}` : '—'}
</td>
<td>
{d.has_availability
? <CheckCircle size={14} strokeWidth={1.75} color="var(--success)" />
: <AlertCircle size={14} strokeWidth={1.75} color="var(--danger)" />}
</td>
<td>
{d.has_min_stay
? <span className="badge badge-warning">Min-stay</span>
: null}
</td>
<td style={{ fontSize: 11, color: 'var(--text-mid)' }}>{age(d.scraped_at)}</td>
</tr>
{expandedDate === d.stay_date && roomData && (
<tr>
<td colSpan={6} style={{ background: '#f8fafc', padding: '8px 16px' }}>
<RoomBreakdown rooms={roomData.rooms} benchPrice={roomData.bench_price} />
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
function RoomBreakdown({ rooms, benchPrice }: { rooms: RoomRow[]; benchPrice: number | null }) {
return (
<table style={{ width: '100%', fontSize: 12 }}>
<thead>
<tr>
<th>Room</th>
<th>Rate Plan</th>
<th>Avail</th>
<th>Price</th>
<th>Bench Rate</th>
<th>Min-Stay</th>
</tr>
</thead>
<tbody>
{rooms.map((r, i) => (
<tr key={i}>
<td>{r.room_label}</td>
<td style={{ color: 'var(--text-mid)' }}>{r.rate_label}</td>
<td>{r.availability > 0 ? <CheckCircle size={12} strokeWidth={1.75} color="var(--success)" /> : <AlertCircle size={12} strokeWidth={1.75} color="var(--danger)" />}</td>
<td style={{ fontWeight: 600 }}>{fmt(r.price_incl)}</td>
<td style={{ color: 'var(--text-mid)' }}>{fmt(r.bench_rate)}</td>
<td>{r.min_stay_nights && r.min_stay_nights > 1 ? <span className="badge badge-warning">{r.min_stay_nights}N</span> : null}</td>
</tr>
))}
</tbody>
</table>
)
}
// ─── Manage Tab ───────────────────────────────────────────────────────────────
function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) {
const [showAdd, setShowAdd] = useState(false)
const [detectUrl, setDetectUrl] = useState('')
const [detected, setDetected] = useState<any>(null)
const [detecting, setDetecting] = useState(false)
const [newName, setNewName] = useState('')
const [extraParams, setExtraParams] = useState<Record<string, string>>({})
const [profiles, setProfiles] = useState<any[]>([])
const qc = useQueryClient()
const { data: profileList } = useQuery({
queryKey: ['direct-profiles'],
queryFn: () => api.get('/direct/profiles').then(r => r.data),
})
const detectMutation = useMutation({
mutationFn: (url: string) => api.post('/direct/profiles/detect', { url }),
onSuccess: (res) => setDetected(res.data),
})
const createMutation = useMutation({
mutationFn: (body: any) => api.post('/direct/hotels', body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['direct-hotels'] }); setShowAdd(false); setDetected(null); setDetectUrl('') },
})
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.put(`/direct/hotels/${id}`, { scrape_enabled: enabled }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['direct-hotels'] }),
})
const discoveryMutation = useMutation({
mutationFn: (id: number) => api.post(`/direct/hotels/${id}/discover`),
})
const requiredParams = detected
? (profileList || []).find((p: any) => p.name === detected.profile)?.required_params || []
: []
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary" onClick={() => setShowAdd(!showAdd)}>
<Plus size={14} strokeWidth={1.75} />
Add Competitor
</button>
</div>
{showAdd && (
<div className="card">
<div className="card-header">Add Competitor Hotel</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6 }}>
Booking URL (paste any booking page URL to auto-detect engine)
</label>
<div style={{ display: 'flex', gap: 8 }}>
<input value={detectUrl} onChange={e => setDetectUrl(e.target.value)}
placeholder="https://booking.eu.guestline.app/..." />
<button className="btn btn-outline"
onClick={() => detectMutation.mutate(detectUrl)}
disabled={!detectUrl || detectMutation.isPending}>
Detect
</button>
</div>
</div>
{detected && (
<>
<div style={{ padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, border: '1px solid #bbf7d0', fontSize: 13 }}>
<strong>Detected:</strong> {detected.profile} engine
{Object.entries(detected).filter(([k]) => k !== 'profile').map(([k, v]) => (
<span key={k} style={{ marginLeft: 12, color: 'var(--text-mid)' }}>{k}: <strong>{String(v)}</strong></span>
))}
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6 }}>Hotel Name</label>
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="e.g. Three Ways House Hotel" style={{ maxWidth: 300 }} />
</div>
{requiredParams.filter((p: any) => !(p.key in detected)).map((p: any) => (
<div key={p.key}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>{p.label}</label>
<input
placeholder={p.help}
style={{ maxWidth: 300 }}
value={extraParams[p.key] || ''}
onChange={e => setExtraParams(prev => ({ ...prev, [p.key]: e.target.value }))}
/>
</div>
))}
<button
className="btn btn-primary"
onClick={() => createMutation.mutate({
name: newName,
profile_name: detected.profile,
params: { ...detected, ...extraParams, profile: undefined },
})}
disabled={!newName || createMutation.isPending}
>
Add Hotel
</button>
</>
)}
</div>
</div>
)}
<div className="card">
<div className="card-header">Configured Competitors</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Engine</th>
<th>Scraping</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{hotels.length === 0 && (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-mid)', padding: 24 }}>No competitors configured yet.</td></tr>
)}
{hotels.map(h => (
<tr key={h.id}>
<td style={{ fontWeight: 500 }}>{h.name}</td>
<td><span className="badge badge-neutral">{h.profile_name}</span></td>
<td>
<button
className={`btn btn-sm ${h.scrape_enabled ? 'btn-outline' : 'btn-primary'}`}
onClick={() => toggleMutation.mutate({ id: h.id, enabled: !h.scrape_enabled })}
>
{h.scrape_enabled ? 'Enabled' : 'Disabled'}
</button>
</td>
<td style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-outline btn-sm"
onClick={() => discoveryMutation.mutate(h.id)}
disabled={discoveryMutation.isPending}>
<Clock size={12} strokeWidth={1.75} />
Re-discover
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,362 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Plot from 'react-plotly.js'
import { TrendingUp, TrendingDown, Minus, AlertTriangle, ChevronDown } from 'lucide-react'
import api from '../api'
const fmtDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
interface AnalysisHotel {
hotel_id: number
hotel_name: string
last_scraped: string | null
date_count: number
}
interface StrategyLabel {
label: string
advance_discount_pct: number
weekend_premium_pct: number
avg_sold_out_rate_pct: number
peak_months: string[]
}
interface HotelAnalysis {
strategy: StrategyLabel
advance_curve: { days_ahead: number; avg_price: number; sample_count: number }[]
dow_breakdown: { dow: number; dow_name: string; avg_price: number; count: number }[]
sold_out_pattern: { stay_date: string; sold_out_pct: number }[]
}
interface TimelineEntry {
scraped_at: string
room_id: string
rate_id: string
room_label: string
rate_label: string
price_incl: number | null
availability: number
}
interface ComparisonRow {
hotel_id: number
hotel_name: string
our_rate: number | null
their_rate: number | null
price_index: number | null
days_checked: number
}
const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const PLOT_LAYOUT_BASE = {
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { family: 'Inter, system-ui, sans-serif', size: 12, color: '#60748b' },
margin: { t: 20, r: 16, b: 48, l: 48 },
showlegend: false,
xaxis: { gridcolor: '#e5e9f0', zeroline: false },
yaxis: { gridcolor: '#e5e9f0', zeroline: false },
}
function strategyIcon(label: string) {
if (label.includes('Discount')) return <TrendingDown size={16} strokeWidth={1.75} color="var(--warning)" />
if (label.includes('Premium')) return <TrendingUp size={16} strokeWidth={1.75} color="var(--success)" />
if (label.includes('Yield')) return <TrendingUp size={16} strokeWidth={1.75} color="var(--gold)" />
return <Minus size={16} strokeWidth={1.75} color="var(--text-mid)" />
}
function priceIndexClass(idx: number | null) {
if (idx == null) return 'badge badge-neutral'
if (idx > 105) return 'badge badge-success'
if (idx < 85) return 'badge badge-danger'
if (idx < 95) return 'badge badge-warning'
return 'badge badge-neutral'
}
export default function RateAnalysis() {
const [selectedHotel, setSelectedHotel] = useState<number | null>(null)
const [timelineDate, setTimelineDate] = useState(fmtDate(new Date(Date.now() + 30 * 86400000)))
const [compFrom, setCompFrom] = useState(fmtDate(new Date()))
const [compTo, setCompTo] = useState(fmtDate(new Date(Date.now() + 29 * 86400000)))
const { data: hotels } = useQuery<AnalysisHotel[]>({
queryKey: ['analysis-hotels'],
queryFn: () => api.get('/analysis/hotels').then(r => r.data),
})
const { data: analysis, isLoading: analysisLoading } = useQuery<HotelAnalysis>({
queryKey: ['analysis-hotel', selectedHotel],
queryFn: () => api.get(`/analysis/hotel/${selectedHotel}`).then(r => r.data),
enabled: !!selectedHotel,
})
const { data: timeline } = useQuery<TimelineEntry[]>({
queryKey: ['analysis-timeline', selectedHotel, timelineDate],
queryFn: () => api.get(`/analysis/hotel/${selectedHotel}/timeline`, {
params: { date: timelineDate }
}).then(r => r.data),
enabled: !!selectedHotel,
})
const { data: comparison, isLoading: compLoading } = useQuery<ComparisonRow[]>({
queryKey: ['analysis-comparison', compFrom, compTo],
queryFn: () => api.get('/analysis/comparison', {
params: { from_date: compFrom, to_date: compTo }
}).then(r => r.data),
enabled: !!(compFrom && compTo),
})
const presets = [
{ label: '7d', days: 7 }, { label: '14d', days: 14 },
{ label: '30d', days: 30 },
]
return (
<div>
<div className="page-header">
<div>
<div className="page-title">Rate Analysis</div>
<div className="page-subtitle">Competitor pricing structure and advance purchase behaviour</div>
</div>
</div>
{/* Comparison table — full width, no hotel needed */}
<section style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Market Comparison</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{presets.map(p => (
<button key={p.label} className="btn btn-outline btn-sm"
onClick={() => { setCompFrom(fmtDate(new Date())); setCompTo(fmtDate(new Date(Date.now() + p.days * 86400000))) }}>
{p.label}
</button>
))}
<input type="date" style={{ width: 130 }} value={compFrom} onChange={e => setCompFrom(e.target.value)} />
<span style={{ color: 'var(--text-mid)', fontSize: 12 }}>to</span>
<input type="date" style={{ width: 130 }} value={compTo} onChange={e => setCompTo(e.target.value)} />
</div>
</div>
{compLoading ? (
<div className="loading-state"><div className="spinner" />Loading</div>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Competitor</th>
<th>Our Avg Rate</th>
<th>Their Avg Rate</th>
<th>Price Index</th>
<th>Dates Checked</th>
</tr>
</thead>
<tbody>
{(comparison || []).length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-mid)', padding: 24 }}>No comparison data available.</td></tr>
)}
{(comparison || []).map(row => (
<tr key={row.hotel_id}>
<td>
<button onClick={() => setSelectedHotel(row.hotel_id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--gold)', fontWeight: 600, padding: 0, fontSize: 13 }}>
{row.hotel_name}
</button>
</td>
<td>{row.our_rate ? `£${Number(row.our_rate).toFixed(2)}` : '—'}</td>
<td style={{ fontWeight: 600 }}>{row.their_rate ? `£${Number(row.their_rate).toFixed(2)}` : '—'}</td>
<td>
{row.price_index != null ? (
<span className={priceIndexClass(row.price_index)}>
{row.price_index.toFixed(0)}
</span>
) : '—'}
</td>
<td style={{ color: 'var(--text-mid)' }}>{row.days_checked}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
{/* Hotel selector for deep analysis */}
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
Competitor deep analysis
</label>
<select style={{ width: 260 }} value={selectedHotel || ''}
onChange={e => setSelectedHotel(e.target.value ? parseInt(e.target.value) : null)}>
<option value="">Select a competitor</option>
{(hotels || []).map(h => (
<option key={h.hotel_id} value={h.hotel_id}>{h.hotel_name}</option>
))}
</select>
</div>
</div>
</div>
{selectedHotel && analysisLoading && (
<div className="loading-state"><div className="spinner" />Loading analysis</div>
)}
{selectedHotel && !analysisLoading && analysis && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Strategy card */}
<div className="card">
<div className="card-header">Pricing Strategy</div>
<div className="card-body" style={{ display: 'flex', gap: 32, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{strategyIcon(analysis.strategy.label)}
<span style={{ fontWeight: 700, fontSize: 15 }}>{analysis.strategy.label}</span>
</div>
<StatChip label="Advance Discount" value={`${analysis.strategy.advance_discount_pct.toFixed(1)}%`}
hint="price delta from 90→7 days ahead" />
<StatChip label="Weekend Premium" value={`${analysis.strategy.weekend_premium_pct.toFixed(1)}%`}
hint="Fri-Sun vs Mon-Thu" />
<StatChip label="Sold-Out Rate" value={`${analysis.strategy.avg_sold_out_rate_pct.toFixed(1)}%`}
hint="% of scraped dates with no availability" />
{analysis.strategy.peak_months.length > 0 && (
<StatChip label="Peak Months" value={analysis.strategy.peak_months.join(', ')} hint="" />
)}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
{/* Advance purchase curve */}
<div className="card">
<div className="card-header">Advance Purchase Curve</div>
<div className="card-body" style={{ height: 260 }}>
{analysis.advance_curve.length > 0 ? (
<Plot
style={{ width: '100%', height: '100%' }}
data={[{
type: 'scatter',
mode: 'lines+markers',
x: analysis.advance_curve.map(p => p.days_ahead),
y: analysis.advance_curve.map(p => p.avg_price),
line: { color: '#c9a84c', width: 2 },
marker: { size: 4, color: '#c9a84c' },
hovertemplate: '%{x} days ahead: £%{y:.2f}<extra></extra>',
}]}
layout={{
...PLOT_LAYOUT_BASE,
xaxis: { ...PLOT_LAYOUT_BASE.xaxis, title: { text: 'Days ahead', font: { size: 11 } }, autorange: 'reversed' },
yaxis: { ...PLOT_LAYOUT_BASE.yaxis, title: { text: 'Avg price (£)', font: { size: 11 } }, tickprefix: '£' },
}}
config={{ displayModeBar: false, responsive: true }}
useResizeHandler
/>
) : (
<div className="empty-state">Not enough data yet.</div>
)}
</div>
</div>
{/* DOW breakdown */}
<div className="card">
<div className="card-header">Day-of-Week Breakdown</div>
<div className="card-body" style={{ height: 260 }}>
{analysis.dow_breakdown.length > 0 ? (
<Plot
style={{ width: '100%', height: '100%' }}
data={[{
type: 'bar',
x: analysis.dow_breakdown.map(d => d.dow_name),
y: analysis.dow_breakdown.map(d => d.avg_price),
marker: {
color: analysis.dow_breakdown.map(d =>
d.dow >= 5 ? '#c9a84c' : '#3b82f6'
),
},
hovertemplate: '%{x}: £%{y:.2f}<extra></extra>',
}]}
layout={{
...PLOT_LAYOUT_BASE,
yaxis: { ...PLOT_LAYOUT_BASE.yaxis, tickprefix: '£' },
}}
config={{ displayModeBar: false, responsive: true }}
useResizeHandler
/>
) : (
<div className="empty-state">Not enough data yet.</div>
)}
</div>
</div>
</div>
{/* Rate timeline */}
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Rate Timeline How Rates Changed for One Date</span>
<input type="date" style={{ width: 140 }} value={timelineDate}
onChange={e => setTimelineDate(e.target.value)} />
</div>
<div className="card-body" style={{ height: 280 }}>
{(timeline || []).length === 0 ? (
<div className="empty-state">No timeline data for this date.</div>
) : (
<Plot
style={{ width: '100%', height: '100%' }}
data={buildTimelineTraces(timeline || [])}
layout={{
...PLOT_LAYOUT_BASE,
showlegend: true,
legend: { font: { size: 11 }, bgcolor: 'transparent' },
margin: { t: 20, r: 120, b: 48, l: 56 },
xaxis: { ...PLOT_LAYOUT_BASE.xaxis },
yaxis: { ...PLOT_LAYOUT_BASE.yaxis, tickprefix: '£' },
}}
config={{ displayModeBar: false, responsive: true }}
useResizeHandler
/>
)}
</div>
</div>
</div>
)}
{!selectedHotel && (
<div className="empty-state" style={{ marginTop: 0 }}>
Select a competitor above to view their pricing strategy and advance purchase curve.
</div>
)}
</div>
)
}
function StatChip({ label, value, hint }: { label: string; value: string; hint: string }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontSize: 11, color: 'var(--text-mid)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</span>
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-dark)', lineHeight: 1 }}>{value}</span>
{hint && <span style={{ fontSize: 11, color: 'var(--text-mid)' }}>{hint}</span>}
</div>
)
}
function buildTimelineTraces(entries: TimelineEntry[]) {
const byRoom: Record<string, TimelineEntry[]> = {}
for (const e of entries) {
const key = e.room_label || e.room_id
if (!byRoom[key]) byRoom[key] = []
byRoom[key].push(e)
}
const colors = ['#c9a84c', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b']
return Object.entries(byRoom).map(([room, pts], i) => ({
type: 'scatter' as const,
mode: 'lines+markers' as const,
name: room,
x: pts.map(p => p.scraped_at),
y: pts.map(p => p.price_incl),
line: { color: colors[i % colors.length], width: 2 },
marker: { size: 5, color: colors[i % colors.length] },
hovertemplate: `${room}: £%{y:.2f}<extra></extra>`,
}))
}

View file

@ -0,0 +1,224 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Save, RefreshCw, Database, Clock } from 'lucide-react'
import api from '../api'
const TABS = [
{ id: 'newbook', label: 'Newbook Sync' },
{ id: 'system', label: 'System' },
]
interface SystemConfig {
[key: string]: string | null
}
export default function Settings() {
const { tab: tabParam } = useParams<{ tab?: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const activeTab = tabParam || 'newbook'
const { data: config, isLoading } = useQuery<SystemConfig>({
queryKey: ['system-config'],
queryFn: () => api.get('/competitors/config/system').then(r => r.data),
})
const saveMutation = useMutation({
mutationFn: (payload: { key: string; value: string }) =>
api.post('/competitors/config/system', payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ['system-config'] }),
})
const syncNow = useMutation({
mutationFn: () => api.post('/bookability/refresh-all'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['system-config'] }),
})
return (
<div>
<div className="page-header">
<div>
<div className="page-title">Settings</div>
<div className="page-subtitle">Newbook sync and system configuration</div>
</div>
</div>
<div className="sub-nav">
{TABS.map(t => (
<button
key={t.id}
className={`sub-nav-item${activeTab === t.id ? ' active' : ''}`}
onClick={() => navigate(`/settings/${t.id}`)}
>
{t.label}
</button>
))}
</div>
{activeTab === 'newbook' && (
<NewbookTab
config={config}
isLoading={isLoading}
onSave={(key, val) => saveMutation.mutate({ key, value: val })}
onSyncNow={() => syncNow.mutate()}
saving={saveMutation.isPending}
syncing={syncNow.isPending}
/>
)}
{activeTab === 'system' && (
<SystemTab config={config} isLoading={isLoading} />
)}
</div>
)
}
// ─── Newbook Sync Tab ─────────────────────────────────────────────────────────
interface NewbookTabProps {
config: SystemConfig | undefined
isLoading: boolean
onSave: (key: string, val: string) => void
onSyncNow: () => void
saving: boolean
syncing: boolean
}
function NewbookTab({ config, isLoading, onSave, onSyncNow, saving, syncing }: NewbookTabProps) {
const [syncTime, setSyncTime] = useState('')
const syncEnabled = config?.sync_newbook_current_rates_enabled === 'true'
const currentTime = config?.sync_newbook_current_rates_time || '05:20'
if (isLoading) {
return <div className="loading-state"><div className="spinner" /> Loading</div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 600 }}>
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Database size={16} strokeWidth={1.75} />
Newbook Rates Sync
</span>
<span className={`badge ${syncEnabled ? 'badge-success' : 'badge-neutral'}`}>
{syncEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
When enabled, the app fetches current tariff rates from the Newbook API daily and
stores them for the Bookability view and rate parity calculations.
</p>
<div style={{ display: 'flex', gap: 12 }}>
<button
className={`btn ${syncEnabled ? 'btn-outline' : 'btn-primary'}`}
onClick={() => onSave('sync_newbook_current_rates_enabled', syncEnabled ? 'false' : 'true')}
disabled={saving}
>
{syncEnabled ? 'Disable Sync' : 'Enable Sync'}
</button>
<button
className="btn btn-outline"
onClick={onSyncNow}
disabled={syncing}
>
<RefreshCw size={14} strokeWidth={1.75} />
{syncing ? 'Refreshing…' : 'Sync Now'}
</button>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Clock size={16} strokeWidth={1.75} />
Sync Schedule
</span>
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6 }}>
Daily sync time (HH:MM)
</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="time"
style={{ width: 130 }}
defaultValue={currentTime}
onChange={e => setSyncTime(e.target.value)}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => syncTime && onSave('sync_newbook_current_rates_time', syncTime)}
disabled={saving || !syncTime}
>
<Save size={13} strokeWidth={1.75} />
Save
</button>
</div>
<p style={{ fontSize: 12, color: 'var(--text-mid)', marginTop: 6 }}>
Current: {currentTime} Booking.com scraper runs at {config?.booking_scraper_daily_time || '05:30'}
</p>
</div>
</div>
</div>
</div>
)
}
// ─── System Tab ───────────────────────────────────────────────────────────────
function SystemTab({ config, isLoading }: { config: SystemConfig | undefined; isLoading: boolean }) {
if (isLoading) {
return <div className="loading-state"><div className="spinner" /> Loading</div>
}
const displayKeys = [
'booking_scraper_enabled',
'booking_scraper_paused',
'booking_scraper_backend',
'booking_scraper_daily_time',
'sync_newbook_current_rates_enabled',
'sync_newbook_current_rates_time',
]
return (
<div style={{ maxWidth: 700 }}>
<div className="card">
<div className="card-header">System Configuration</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{displayKeys.map(k => (
<tr key={k}>
<td><code style={{ fontSize: 12, color: 'var(--text-mid)' }}>{k}</code></td>
<td>
<span style={{ fontSize: 13 }}>
{config?.[k] ?? <em style={{ color: 'var(--text-mid)' }}>not set</em>}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-mid)' }}>
To configure the Booking.com scraper location and hotel tiers, use the Settings tab inside{' '}
<a href="/rates/market" style={{ color: 'var(--gold)' }}>Market View</a>.
</div>
</div>
)
}

114
frontend/src/types.ts Normal file
View file

@ -0,0 +1,114 @@
export interface User {
email: string
name: string
is_admin: boolean
caps: string[]
}
export function can(user: User | null, cap: string): boolean {
if (!user) return false
return user.is_admin || user.caps.includes(cap)
}
export interface Hotel {
id: number
name: string
tier: 'own' | 'competitor' | 'market'
star_rating: number | null
review_score: number | null
booking_com_url: string | null
booking_com_id: string | null
display_order: number
notes: string | null
is_active: boolean
scraped_dates?: number
last_scraped?: string | null
}
export interface RateMatrixEntry {
hotel_id: number
hotel_name: string
tier: string
rate_date: string
rate_gross: number | null
availability_status: string
rooms_left: number | null
scraped_at: string | null
}
export interface ScraperStatus {
enabled: boolean
paused: boolean
pause_until: string | null
backend: string
daily_time: string
last_batch: {
batch_id: string
started_at: string
status: string
rates_scraped: number
dates_completed: number
} | null
}
export interface BookabilityCategory {
category_id: string
category_name: string
room_count: number
display_order: number
dates: BookabilityDate[]
}
export interface BookabilityDate {
date: string
gross_rate: number | null
tariffs: TariffSummary[]
occupancy_pct: number | null
available_rooms: number | null
is_bookable: boolean
}
export interface TariffSummary {
tariff_name: string
rate: number | null
min_stay: number | null
advance_max: number | null
is_available: boolean
}
export interface AdvancePurchaseCurvePoint {
lead_bucket: string
avg_rate: number
sample_count: number
}
export interface DowAnalysisPoint {
dow: number
dow_label: string
avg_rate: number
date_count: number
}
export interface StrategySummary {
advance_discount_pct: number | null
weekend_premium_pct: number | null
avg_sold_out_rate_pct: number | null
strategy_label: string
}
export interface HotelAnalysis {
hotel: Hotel
date_range: { from: string; to: string }
advance_purchase_curve: AdvancePurchaseCurvePoint[]
dow_analysis: DowAnalysisPoint[]
sold_out_pattern: Array<{ dow: number; dow_label: string; total_dates: number; sold_out_dates: number }>
strategy_summary: StrategySummary
}
export interface RateTimelinePoint {
scraped_at: string
rate_gross: number | null
availability_status: string
rooms_left: number | null
days_out: number
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/rates/',
})