- Eviivo: no-dates page fetch returns all room types with data-item-name and
data-rate-plan-name; single request populates the full name catalogue
- QBook: item_name already in /api/pull response; extracted on a dummy date
call; no distinct rate plan names exposed so rate_labels left empty
- Mews: getCalendarData (bookingEngineId field) returns resourceCategories[].name
and rates[].name as {en-US: ...} dicts; both room and rate labels populated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
127 lines
4.9 KiB
Python
127 lines
4.9 KiB
Python
import base64
|
|
import re
|
|
from datetime import date, timedelta
|
|
from urllib.parse import unquote
|
|
from .base import BaseProfile
|
|
|
|
# Hardcoded in the QBook JS bundle — fixed for all Hotels UK properties
|
|
_AUTH_BASE = "5wv2AGgSxfRKvH92UzxHcbSRZNJBEMd8cAay2RY7qC8YBjSTdswMvvdw343X"
|
|
_BUNDLE_K = "ad7ec113c738d3f5fe9f89a7e58a4b74e8301128"
|
|
|
|
HEADERS = {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
"Accept": "application/json",
|
|
"Referer": "https://web-bookings.hotels.uk.com/",
|
|
"Origin": "https://web-bookings.hotels.uk.com",
|
|
}
|
|
|
|
|
|
def _build_auth(hotel_id: str, from_date: str, to_date: str) -> str:
|
|
code_auth = base64.b64encode(
|
|
f"{hotel_id}#{from_date}#{to_date}".encode()
|
|
).decode().rstrip("=")
|
|
return _AUTH_BASE[:30] + code_auth + _AUTH_BASE[30:]
|
|
|
|
|
|
class QBookProfile(BaseProfile):
|
|
name = "qbook"
|
|
label = "QBook (Hotels UK)"
|
|
required_params = [
|
|
{"key": "hotel_id", "label": "Hotel ID",
|
|
"help": "Numeric hotel ID from the booking URL, e.g. 51420"},
|
|
{"key": "dk", "label": "Distribution Key",
|
|
"help": "The k= value from the booking widget URL"},
|
|
]
|
|
|
|
@classmethod
|
|
def detect(cls, url: str) -> dict | None:
|
|
m = re.search(r'web-bookings\.hotels\.uk\.com/#/booking/(\d+)', url)
|
|
if not m:
|
|
return None
|
|
hotel_id = m.group(1)
|
|
dk_match = re.search(r'[?&]k=([^&\s#]+)', url)
|
|
dk = unquote(dk_match.group(1)) if dk_match else ""
|
|
return {"hotel_id": hotel_id, "dk": dk}
|
|
|
|
async def fetch_category_names(self, client, params: dict) -> tuple[dict, dict]:
|
|
hotel_id = params["hotel_id"]
|
|
dk = params["dk"]
|
|
from datetime import date, timedelta
|
|
tomorrow = (date.today() + timedelta(days=1)).isoformat()
|
|
day_after = (date.today() + timedelta(days=2)).isoformat()
|
|
try:
|
|
r = await client.get(
|
|
"https://web-bookings.hotels.uk.com/api/pull",
|
|
params={
|
|
"HotelAvailability": "", "k": _BUNDLE_K,
|
|
"HotelID": hotel_id, "from": tomorrow, "to": day_after,
|
|
"json": "", "dk": dk, "Qbook": "",
|
|
"GHABooking": "", "CookieBooking": "", "COOKIE_HotelID": "",
|
|
"guests": "", "adultsCheck": "", "childAges": "",
|
|
"roomId": "", "revised": "",
|
|
},
|
|
headers={**HEADERS, "Auth": _build_auth(hotel_id, tomorrow, day_after)},
|
|
timeout=20,
|
|
)
|
|
except Exception:
|
|
return {}, {}
|
|
if r.status_code != 200:
|
|
return {}, {}
|
|
room_names: dict[str, str] = {}
|
|
for item_id, item in r.json().get("availability", {}).items():
|
|
if isinstance(item, dict) and item.get("item_name"):
|
|
room_names[str(item_id)] = item["item_name"]
|
|
# QBook has no distinct rate plan names
|
|
return room_names, {}
|
|
|
|
async def fetch_arrival_dates(self, client, params: dict) -> list[str]:
|
|
today = date.today()
|
|
return [(today + timedelta(days=i)).isoformat() for i in range(1, 91)]
|
|
|
|
async def fetch_night_rates(self, client, params: dict, arrival: str, nights: int = 1) -> list[dict]:
|
|
hotel_id = params["hotel_id"]
|
|
dk = params["dk"]
|
|
departure = (date.fromisoformat(arrival) + timedelta(days=nights)).isoformat()
|
|
|
|
try:
|
|
r = await client.get(
|
|
"https://web-bookings.hotels.uk.com/api/pull",
|
|
params={
|
|
"HotelAvailability": "", "k": _BUNDLE_K,
|
|
"HotelID": hotel_id, "from": arrival, "to": departure,
|
|
"json": "", "dk": dk, "Qbook": "",
|
|
"GHABooking": "", "CookieBooking": "", "COOKIE_HotelID": "",
|
|
"guests": "", "adultsCheck": "", "childAges": "",
|
|
"roomId": "", "revised": "",
|
|
},
|
|
headers={**HEADERS, "Auth": _build_auth(hotel_id, arrival, departure)},
|
|
timeout=20,
|
|
)
|
|
except Exception:
|
|
return []
|
|
|
|
if r.status_code != 200:
|
|
return []
|
|
|
|
availability = r.json().get("availability", {})
|
|
if not isinstance(availability, dict):
|
|
return []
|
|
|
|
rooms = []
|
|
for item_id, item in availability.items():
|
|
if not isinstance(item, dict):
|
|
continue
|
|
avail_count = item.get("availableroom", 0)
|
|
price = item.get("roomprice", 0.0)
|
|
# roomprice=0 on an available room means no rate configured in QBook's backend
|
|
if not price:
|
|
continue
|
|
rooms.append({
|
|
"roomId": str(item_id),
|
|
"rateId": "default",
|
|
"availability": avail_count,
|
|
"prices": [{"amountBeforeTax": price, "amountAfterTax": price}] * nights,
|
|
"currencyCode": "GBP",
|
|
})
|
|
|
|
return rooms
|