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