|
|
@@ -0,0 +1,202 @@
|
|
|
+import asyncio
|
|
|
+import logging
|
|
|
+import os
|
|
|
+from datetime import date, timedelta
|
|
|
+
|
|
|
+import httpx
|
|
|
+
|
|
|
+from room_cal._utils import data_home, get_config, read_env
|
|
|
+from .credentials import is_stale, mark_stale
|
|
|
+from .types import DayInfo, Reservation, ServiceAuthError
|
|
|
+
|
|
|
+_log = logging.getLogger(__name__)
|
|
|
+_BASE = 'https://www.homestay.com'
|
|
|
+_AUTH_HINT = 'refresh-creds'
|
|
|
+
|
|
|
+
|
|
|
+class HomestayAuthError(ServiceAuthError):
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+def _fmt_date(d: str) -> str:
|
|
|
+ """Convert YYYY-MM-DD to 'D Mon YYYY' for Homestay form fields."""
|
|
|
+ dt = date.fromisoformat(d)
|
|
|
+ return f'{dt.day} {dt.strftime("%b")} {dt.year}'
|
|
|
+
|
|
|
+
|
|
|
+class _HomestayClient:
|
|
|
+ def __init__(self, cookies: str, csrf_token: str, room_id: str):
|
|
|
+ self._cookies = cookies
|
|
|
+ self._csrf_token = csrf_token
|
|
|
+ self._room_id = room_id
|
|
|
+ self._calendar_data: dict | None = None
|
|
|
+ self._calendar_inflight: asyncio.Task | None = None
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def from_env(cls) -> '_HomestayClient':
|
|
|
+ env_file = data_home() / 'homestay.env'
|
|
|
+ env = {**read_env(env_file), **os.environ}
|
|
|
+ cookies = env.get('HOMESTAY_COOKIES', '')
|
|
|
+ csrf_token = env.get('HOMESTAY_CSRF_TOKEN', '')
|
|
|
+ if not cookies:
|
|
|
+ raise ValueError(f'HOMESTAY_COOKIES must be set in {env_file}')
|
|
|
+ room_id = get_config().services['homestay'].room_id
|
|
|
+ if not room_id:
|
|
|
+ raise ValueError('homestay.room_id must be set in config.json')
|
|
|
+ return cls(cookies=cookies, csrf_token=csrf_token, room_id=room_id)
|
|
|
+
|
|
|
+ def _headers(self, extra: dict | None = None) -> dict[str, str]:
|
|
|
+ h = {
|
|
|
+ 'Cookie': self._cookies,
|
|
|
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0',
|
|
|
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
|
|
|
+ 'Accept-Language': 'en-US,en;q=0.9',
|
|
|
+ 'X-Requested-With': 'XMLHttpRequest',
|
|
|
+ 'X-CSRF-Token': self._csrf_token,
|
|
|
+ }
|
|
|
+ if extra:
|
|
|
+ h.update(extra)
|
|
|
+ return h
|
|
|
+
|
|
|
+ def _check_auth(self, r: httpx.Response) -> None:
|
|
|
+ if r.status_code in (401, 403) or r.is_redirect:
|
|
|
+ mark_stale('homestay')
|
|
|
+ raise HomestayAuthError(f'Homestay session expired — run: {_AUTH_HINT}')
|
|
|
+
|
|
|
+ async def _fetch_calendar(self) -> dict:
|
|
|
+ async with httpx.AsyncClient(follow_redirects=False) as client:
|
|
|
+ r = await client.get(f'{_BASE}/homestay_rooms/calendar', headers=self._headers())
|
|
|
+ self._check_auth(r)
|
|
|
+ if r.status_code >= 400:
|
|
|
+ mark_stale('homestay')
|
|
|
+ raise HomestayAuthError(f'Homestay calendar fetch failed (HTTP {r.status_code}) — run: {_AUTH_HINT}')
|
|
|
+ return r.json()
|
|
|
+
|
|
|
+ async def _get_calendar_data(self) -> dict:
|
|
|
+ if self._calendar_data is not None:
|
|
|
+ return self._calendar_data
|
|
|
+
|
|
|
+ if self._calendar_inflight is None or self._calendar_inflight.done():
|
|
|
+ self._calendar_inflight = asyncio.create_task(self._fetch_calendar())
|
|
|
+ data = await self._calendar_inflight
|
|
|
+
|
|
|
+ all_empty = (
|
|
|
+ not data.get('leisure_bookings')
|
|
|
+ and not data.get('unavailabilities')
|
|
|
+ and not data.get('room_price_overrides')
|
|
|
+ )
|
|
|
+ if all_empty:
|
|
|
+ _log.warning('room_cal.service_api._homestay: empty calendar; retrying')
|
|
|
+ await asyncio.sleep(1)
|
|
|
+ data = await self._fetch_calendar()
|
|
|
+ if not data.get('leisure_bookings') and not data.get('unavailabilities') and not data.get('room_price_overrides'):
|
|
|
+ _log.warning('room_cal.service_api._homestay: empty calendar on retry; using empty response')
|
|
|
+
|
|
|
+ self._calendar_data = data
|
|
|
+ return data
|
|
|
+
|
|
|
+ def _build_day_map(self, data: dict, date_from: str, date_to: str) -> dict[str, DayInfo]:
|
|
|
+ d_from = date.fromisoformat(date_from)
|
|
|
+ d_to = date.fromisoformat(date_to)
|
|
|
+
|
|
|
+ price_map: dict[str, int] = {}
|
|
|
+ for override in data.get('room_price_overrides', []):
|
|
|
+ d = override.get('date')
|
|
|
+ amount = (override.get('nightly_rate') or {}).get('amount')
|
|
|
+ if d and amount is not None:
|
|
|
+ price_map[d] = amount // 100
|
|
|
+
|
|
|
+ booked: set[str] = set()
|
|
|
+ for booking in data.get('leisure_bookings', []):
|
|
|
+ ci = booking.get('start_date') or booking.get('check_in', '')
|
|
|
+ co = booking.get('end_date') or booking.get('check_out', '')
|
|
|
+ if not (ci and co):
|
|
|
+ continue
|
|
|
+ cur = date.fromisoformat(ci)
|
|
|
+ end = date.fromisoformat(co)
|
|
|
+ while cur < end:
|
|
|
+ booked.add(cur.isoformat())
|
|
|
+ cur += timedelta(days=1)
|
|
|
+
|
|
|
+ closed: set[str] = set()
|
|
|
+ for unav in data.get('unavailabilities', []):
|
|
|
+ start_s = unav.get('start_date')
|
|
|
+ end_s = unav.get('end_date')
|
|
|
+ if not (start_s and end_s):
|
|
|
+ continue
|
|
|
+ cur = date.fromisoformat(start_s)
|
|
|
+ end = date.fromisoformat(end_s)
|
|
|
+ while cur <= end:
|
|
|
+ closed.add(cur.isoformat())
|
|
|
+ cur += timedelta(days=1)
|
|
|
+
|
|
|
+ result: dict[str, DayInfo] = {}
|
|
|
+ cur = d_from
|
|
|
+ while cur <= d_to:
|
|
|
+ ds = cur.isoformat()
|
|
|
+ is_booked = ds in booked
|
|
|
+ is_closed = ds in closed and not is_booked
|
|
|
+ result[ds] = DayInfo(
|
|
|
+ date=ds,
|
|
|
+ price=price_map.get(ds),
|
|
|
+ available=not is_booked and not is_closed,
|
|
|
+ booked=is_booked,
|
|
|
+ rooms_to_sell=None,
|
|
|
+ )
|
|
|
+ cur += timedelta(days=1)
|
|
|
+ return result
|
|
|
+
|
|
|
+ async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
|
|
|
+ if is_stale('homestay'):
|
|
|
+ raise HomestayAuthError(f'Homestay credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ data = await self._get_calendar_data()
|
|
|
+ return self._build_day_map(data, date_from, date_to)
|
|
|
+
|
|
|
+ async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
|
|
|
+ # leisure_bookings field structure not yet validated against real data
|
|
|
+ return []
|
|
|
+
|
|
|
+ async def _post_form(self, path: str, fields: dict) -> dict:
|
|
|
+ body = {
|
|
|
+ 'utf8': '✓',
|
|
|
+ '_method': 'put',
|
|
|
+ 'authenticity_token': self._csrf_token,
|
|
|
+ 'room_id': self._room_id,
|
|
|
+ **fields,
|
|
|
+ 'commit': 'Save Details',
|
|
|
+ }
|
|
|
+ async with httpx.AsyncClient(follow_redirects=False) as client:
|
|
|
+ r = await client.post(f'{_BASE}{path}', data=body, headers=self._headers())
|
|
|
+ self._check_auth(r)
|
|
|
+ if r.status_code >= 400:
|
|
|
+ mark_stale('homestay')
|
|
|
+ raise HomestayAuthError(f'Homestay write failed (HTTP {r.status_code}) — run: {_AUTH_HINT}')
|
|
|
+ if 'application/json' not in r.headers.get('content-type', ''):
|
|
|
+ # Session expired — Homestay returns 200 with a JS redirect body instead of a proper 302
|
|
|
+ mark_stale('homestay')
|
|
|
+ raise HomestayAuthError(f'Homestay write session expired (JS redirect) — run: {_AUTH_HINT}')
|
|
|
+ data = r.json()
|
|
|
+ self._calendar_data = data
|
|
|
+ self._calendar_inflight = None
|
|
|
+ return data
|
|
|
+
|
|
|
+ async def set_price(self, d: str, price: int) -> None:
|
|
|
+ if is_stale('homestay'):
|
|
|
+ raise HomestayAuthError(f'Homestay credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ fmt = _fmt_date(d)
|
|
|
+ await self._post_form('/homestay_rooms/update_price_overrides', {
|
|
|
+ 'start_date': fmt,
|
|
|
+ 'end_date': fmt,
|
|
|
+ 'nightly_rate': str(price),
|
|
|
+ 'single_nightly_rate': str(price),
|
|
|
+ })
|
|
|
+
|
|
|
+ async def set_availability(self, d: str, available: bool) -> None:
|
|
|
+ if is_stale('homestay'):
|
|
|
+ raise HomestayAuthError(f'Homestay credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ fmt = _fmt_date(d)
|
|
|
+ await self._post_form('/homestay_rooms/update_calendar', {
|
|
|
+ 'start_date': fmt,
|
|
|
+ 'end_date': fmt,
|
|
|
+ 'mark_as': 'available' if available else 'unavailable',
|
|
|
+ })
|