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:
parent
d9f9a9a5f0
commit
c2e1a9703a
4 changed files with 392 additions and 0 deletions
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
117
backend/services/direct_profiles/eviivo.py
Normal file
117
backend/services/direct_profiles/eviivo.py
Normal 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
|
||||||
173
backend/services/direct_profiles/mews.py
Normal file
173
backend/services/direct_profiles/mews.py
Normal 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
|
||||||
96
backend/services/direct_profiles/qbook.py
Normal file
96
backend/services/direct_profiles/qbook.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue