- Eviivo: no-dates page fetch returns all room types with data-item-name and
data-rate-plan-name; single request populates the full name catalogue
- QBook: item_name already in /api/pull response; extracted on a dummy date
call; no distinct rate plan names exposed so rate_labels left empty
- Mews: getCalendarData (bookingEngineId field) returns resourceCategories[].name
and rates[].name as {en-US: ...} dicts; both room and rate labels populated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
141 lines
4.9 KiB
Python
141 lines
4.9 KiB
Python
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_category_names(self, client, params: dict) -> tuple[dict, dict]:
|
|
slug = params["property_slug"]
|
|
try:
|
|
r = await client.get(f"https://via.eviivo.com/{slug}", headers=HEADERS, timeout=20)
|
|
except Exception:
|
|
return {}, {}
|
|
if r.status_code != 200:
|
|
return {}, {}
|
|
room_names: dict[str, str] = {}
|
|
rate_names: dict[str, str] = {}
|
|
for tag in _TAG_RE.finditer(r.text):
|
|
attrs = _extract_data_attrs(tag.group(0))
|
|
room_id = attrs.get("room-type-id", "").strip()
|
|
if not room_id:
|
|
continue
|
|
room_name = attrs.get("item-name", "").strip()
|
|
rate_plan = attrs.get("rate-plan-name", "").strip()
|
|
rate_id = f"{room_id}_{_slugify(rate_plan) if rate_plan else 'standard'}"
|
|
if room_name:
|
|
room_names[room_id] = room_name
|
|
if rate_plan:
|
|
rate_names[rate_id] = rate_plan
|
|
return room_names, rate_names
|
|
|
|
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
|