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>
175 lines
6.5 KiB
Python
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
|