|
@@ -1,220 +0,0 @@
|
|
|
-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',
|
|
|
|
|
- 'Referer': f'{_BASE}/listing',
|
|
|
|
|
- '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',
|
|
|
|
|
- params={'room_id': self._room_id},
|
|
|
|
|
- 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}')
|
|
|
|
|
- if 'application/json' not in r.headers.get('content-type', ''):
|
|
|
|
|
- mark_stale('homestay')
|
|
|
|
|
- raise HomestayAuthError(
|
|
|
|
|
- f'Homestay session expired (HTTP {r.status_code}, content-type: {r.headers.get("content-type", "none")!r}, body: {r.text[:200]!r}) — 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 probe(self) -> None:
|
|
|
|
|
- from .credentials import check_credentials
|
|
|
|
|
- if is_stale('homestay'):
|
|
|
|
|
- raise HomestayAuthError(f'Homestay credentials are stale — run: {_AUTH_HINT}')
|
|
|
|
|
- info = check_credentials('homestay')
|
|
|
|
|
- if not info['valid']:
|
|
|
|
|
- raise HomestayAuthError(f'Homestay credentials missing — run: {_AUTH_HINT}')
|
|
|
|
|
-
|
|
|
|
|
- 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',
|
|
|
|
|
- })
|
|
|