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 .newbook_scrape import NewbookScrapeProfile
from .travelclick import TravelClickProfile from .travelclick import TravelClickProfile
from .directbook import DirectBookProfile from .directbook import DirectBookProfile
from .eviivo import EviivoProfile
from .qbook import QBookProfile
from .mews import MewsProfile
from .base import BaseProfile from .base import BaseProfile
PROFILES = { PROFILES = {
@ -9,6 +12,9 @@ PROFILES = {
"newbook_scrape": NewbookScrapeProfile, "newbook_scrape": NewbookScrapeProfile,
"travelclick": TravelClickProfile, "travelclick": TravelClickProfile,
"directbook": DirectBookProfile, "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