rates/backend/services/direct_profiles/directbook.py
jtricerolph e05054172f 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>
2026-07-05 12:06:30 +00:00

175 lines
6.5 KiB
Python

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