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

@ -2,6 +2,9 @@ from .guestline import GuestlineProfile
from .newbook_scrape import NewbookScrapeProfile
from .travelclick import TravelClickProfile
from .directbook import DirectBookProfile
from .eviivo import EviivoProfile
from .qbook import QBookProfile
from .mews import MewsProfile
from .base import BaseProfile
PROFILES = {
@ -9,6 +12,9 @@ PROFILES = {
"newbook_scrape": NewbookScrapeProfile,
"travelclick": TravelClickProfile,
"directbook": DirectBookProfile,
"eviivo": EviivoProfile,
"qbook": QBookProfile,
"mews": MewsProfile,
}

View file

@ -0,0 +1,117 @@
import re
from datetime import date, timedelta
from .base import BaseProfile
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
# Matches any opening tag that contains data-room-type-id="..."
_TAG_RE = re.compile(r'<[^>]*data-room-type-id="[^"]*"[^>]*>', re.DOTALL)
# Individual data-* attribute extractor
_ATTR_RE = re.compile(r'data-([\w-]+)="([^"]*)"')
def _slugify(text: str) -> str:
"""Lowercase and replace whitespace runs with underscores."""
return re.sub(r'\s+', '_', text.strip().lower())
def _extract_data_attrs(tag: str) -> dict:
"""Return all data-* attributes from an HTML opening tag as a flat dict."""
return {m.group(1): m.group(2) for m in _ATTR_RE.finditer(tag)}
class EviivoProfile(BaseProfile):
name = "eviivo"
label = "Eviivo"
required_params = [
{
"key": "property_slug",
"label": "Property Slug",
"help": "Path segment after via.eviivo.com/, e.g. StagLdgeGL54",
},
]
@classmethod
def detect(cls, url: str) -> dict | None:
# Matches: https://via.eviivo.com/StagLdgeGL54 (with optional path/query)
m = re.search(r'via\.eviivo\.com/([A-Za-z0-9_-]+)', url)
if m:
return {"property_slug": m.group(1)}
return None
async def fetch_arrival_dates(self, client, params: dict) -> list[str]:
"""Return the next 90 days as ISO date strings starting from tomorrow.
Eviivo has no dedicated calendar endpoint; availability is determined
by whether rooms appear in the fetch_night_rates response.
"""
tomorrow = date.today() + timedelta(days=1)
return [
(tomorrow + timedelta(days=i)).isoformat()
for i in range(90)
]
async def fetch_night_rates(self, client, params: dict, arrival: str, nights: int = 1) -> list[dict]:
"""Scrape Eviivo room rates for a given arrival date and stay length."""
slug = params["property_slug"]
arrival_date = date.fromisoformat(arrival)
departure_date = arrival_date + timedelta(days=nights)
url = f"https://via.eviivo.com/{slug}"
query = {
"startdate": arrival,
"enddate": departure_date.isoformat(),
"adults1": 2,
"children1": 0,
}
try:
r = await client.get(url, params=query, headers=HEADERS, timeout=20)
except Exception:
return []
if r.status_code != 200:
return []
html = r.text
results = []
for tag in _TAG_RE.finditer(html):
attrs = _extract_data_attrs(tag.group(0))
room_type_id = attrs.get("room-type-id", "").strip()
if not room_type_id:
continue
rate_plan_name = attrs.get("rate-plan-name", "").strip()
rate_slug = _slugify(rate_plan_name) if rate_plan_name else "standard"
rate_id = f"{room_type_id}_{rate_slug}"
price_str = attrs.get("price", "").strip()
try:
total_price = float(price_str)
except (ValueError, TypeError):
continue
per_night = round(total_price / nights, 2) if nights > 0 else total_price
currency = attrs.get("currency-code", "GBP").strip() or "GBP"
prices = [
{"amountBeforeTax": per_night, "amountAfterTax": per_night}
for _ in range(nights)
]
results.append({
"roomId": room_type_id,
"rateId": rate_id,
"availability": 1,
"prices": prices,
"currencyCode": currency,
})
return results

View file

@ -0,0 +1,173 @@
import hashlib
import os
import re
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from .base import BaseProfile
CLIENT = "Mews Distributor 5745.0.0"
API_BASE = "https://api.mews.com/api/bookingEngine/v1"
DAYS_AHEAD = 90
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json",
"Content-Type": "application/json",
"X-Accept-Casing": "Camel",
}
def _session() -> str:
rand = os.urandom(4).hex()
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
raw = rand + now
ek = "".join(f"{ord(c):03d}" for c in raw)
return (ek + hashlib.md5((ek + CLIENT).encode()).hexdigest()).upper()
def _midnight_utc(d: date, tz: ZoneInfo) -> str:
return datetime.combine(d, time.min, tzinfo=tz).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _utc_to_local_date(utc_str: str, tz: ZoneInfo) -> str:
return datetime.fromisoformat(utc_str.replace("Z", "+00:00")).astimezone(tz).date().isoformat()
async def _ensure_config(client, params: dict) -> bool:
if "enterprise_id" in params:
return True
r = await client.post(
f"{API_BASE}/configurations/get",
json={"ids": [params["booking_engine_id"]], "client": CLIENT, "session": _session()},
headers=HEADERS,
timeout=20,
)
if r.status_code != 200:
return False
data = r.json()
be = (data.get("bookingEngines") or [{}])[0]
service_id = be.get("serviceId", "")
# enterpriseId lives on services[], not bookingEngines[]
enterprise_id = ""
for svc in data.get("services") or []:
if svc.get("id") == service_id:
enterprise_id = svc.get("enterpriseId", "")
break
if not enterprise_id:
enterprise_id = (data.get("enterprises") or [{}])[0].get("id", "")
tz_name = (data.get("enterprises") or [{}])[0].get("ianaTimeZoneIdentifier", "Europe/London")
# Adult age category: classification=="Adult" or no maximalAge, filtered to this service
age_cat_id = ""
for cat in data.get("ageCategories") or []:
if cat.get("serviceId") != service_id:
continue
if cat.get("classification") == "Adult" or cat.get("maximalAge") is None:
age_cat_id = cat["id"]
break
params["enterprise_id"] = enterprise_id
params["service_id"] = service_id
params["age_category_id"] = age_cat_id
params["_tz"] = ZoneInfo(tz_name)
return bool(enterprise_id and service_id)
class MewsProfile(BaseProfile):
name = "mews"
label = "Mews"
required_params = [
{"key": "booking_engine_id", "label": "Booking Engine ID",
"help": "UUID from the /distributor/ URL, e.g. d0b39796-92ee-4664-bb50-aa4d00eb0c0c"},
]
@classmethod
def detect(cls, url: str) -> dict | None:
m = re.search(r'app\.mews\.com/distributor/([0-9a-f-]{36})', url)
if m:
return {"booking_engine_id": m.group(1)}
return None
async def fetch_arrival_dates(self, client, params: dict) -> list[str]:
if not await _ensure_config(client, params):
return []
tz = params["_tz"]
today = date.today()
r = await client.post(
f"{API_BASE}/services/getAvailability",
json={
"bookingEngineId": params["booking_engine_id"],
"startUtc": _midnight_utc(today, tz),
"endUtc": _midnight_utc(today + timedelta(days=DAYS_AHEAD), tz),
"client": CLIENT,
"session": _session(),
},
headers=HEADERS,
timeout=30,
)
if r.status_code != 200:
return []
data = r.json()
time_units = data.get("timeUnitStartsUtc", [])
cat_avails = data.get("categoryAvailabilities", [])
avail_by_date: dict[str, dict[str, int]] = {}
for cat in cat_avails:
cat_id = cat["categoryId"]
for i, utc_str in enumerate(time_units):
local_date = _utc_to_local_date(utc_str, tz)
avail_by_date.setdefault(local_date, {})[cat_id] = cat["availabilities"][i]
params["_avail"] = avail_by_date
return sorted(d for d, cats in avail_by_date.items() if any(v > 0 for v in cats.values()))
async def fetch_night_rates(self, client, params: dict, arrival: str, nights: int = 1) -> list[dict]:
if not await _ensure_config(client, params):
return []
tz = params.get("_tz", ZoneInfo("Europe/London"))
arrival_date = date.fromisoformat(arrival)
age_cat_id = params.get("age_category_id") or None
r = await client.post(
f"{API_BASE}/services/getPricing",
json={
"configurationId": params["booking_engine_id"],
"startUtc": _midnight_utc(arrival_date, tz),
"endUtc": _midnight_utc(arrival_date + timedelta(days=nights), tz),
"occupancyData": [{"ageCategoryId": age_cat_id, "personCount": 2}],
"client": CLIENT,
"session": _session(),
},
headers=HEADERS,
timeout=20,
)
if r.status_code != 200:
return []
avail_cache = params.get("_avail", {}).get(arrival, {})
rooms = []
for cat_price in r.json().get("categoryPrices", []):
cat_id = cat_price["categoryId"]
avail = avail_cache.get(cat_id, 1)
for occ in cat_price.get("occupancyPrices", []):
for rg in occ.get("rateGroupPrices", []):
rate_id = rg.get("minRateId", "")
amount = rg.get("minPrice", {}).get("totalAmount", {})
gross = amount.get("grossValue")
net = amount.get("netValue")
if gross is None:
continue
rooms.append({
"roomId": cat_id,
"rateId": rate_id,
"availability": avail,
"prices": [
{"amountBeforeTax": net or gross, "amountAfterTax": gross}
for _ in range(nights)
],
"currencyCode": amount.get("currency", "GBP"),
})
return rooms

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