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,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