Add Eviivo, QBook and Mews direct scrape profiles

Three new engine profiles for competitor direct-rate monitoring:
- eviivo: HTML scrape via httpx (no JSON API); presence = available, no room count
- qbook: QBook/Hotels UK JSON API; availableroom count, skip roomprice=0 (unconfigured rate)
- mews: Mews Booking Engine v1 API (unauthenticated); getAvailability + getPricing; proper net/gross split; timezone-aware per property

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-10 12:11:37 +00:00
parent d9f9a9a5f0
commit c2e1a9703a
4 changed files with 392 additions and 0 deletions

View file

@ -0,0 +1,96 @@
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_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