|
|
@@ -1,7 +1,6 @@
|
|
|
import asyncio
|
|
|
import json
|
|
|
import logging
|
|
|
-import os
|
|
|
import random
|
|
|
import time
|
|
|
from dataclasses import dataclass
|
|
|
@@ -11,10 +10,11 @@ from urllib.parse import parse_qs, urlparse
|
|
|
import httpx
|
|
|
|
|
|
from ._scripts import _assert_not_async, _booking_lock, _npm_env, _ts_dir, _ensure_installed
|
|
|
-from room_cal._utils import data_home, get_config, read_env
|
|
|
+from room_cal._utils import get_config
|
|
|
+from ._base import ServiceClientBase, _AUTH_HINT
|
|
|
from ._month_cache import MonthCache
|
|
|
from ._utils import month_bounds, months_in_range
|
|
|
-from .credentials import clear_stale, is_stale, mark_stale, set_credentials
|
|
|
+from .credentials import set_credentials
|
|
|
from .types import DayInfo, ServiceAuthError, Reservation
|
|
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
@@ -25,7 +25,6 @@ def _is_past(d: str) -> bool:
|
|
|
|
|
|
|
|
|
_BASE = 'https://admin.booking.com'
|
|
|
-_AUTH_HINT = 'refresh-creds'
|
|
|
_SENDER_LABELS: dict[str, str] = {'GUEST': 'Guest', 'PARTNER': 'Host'}
|
|
|
|
|
|
_UPDATE_MUTATION = """mutation updateRoomDateMutation($input: PartnerAvailabilityUpdateInput) {
|
|
|
@@ -145,8 +144,10 @@ def _page_view_id() -> str:
|
|
|
return ''.join(random.choices('0123456789abcdef', k=16))
|
|
|
|
|
|
|
|
|
-class _BookingClient:
|
|
|
+class _BookingClient(ServiceClientBase):
|
|
|
+ service_key = 'booking'
|
|
|
service_name = 'Booking.com'
|
|
|
+ auth_error_cls = BookingAuthError
|
|
|
|
|
|
def __init__(
|
|
|
self,
|
|
|
@@ -159,6 +160,7 @@ class _BookingClient:
|
|
|
csrf_token: str | None = None,
|
|
|
lang: str = 'xu',
|
|
|
):
|
|
|
+ super().__init__()
|
|
|
self._cookies = cookies
|
|
|
self._session = session
|
|
|
self._hotel_id = hotel_id
|
|
|
@@ -175,18 +177,12 @@ class _BookingClient:
|
|
|
label='Booking.com',
|
|
|
log=_log,
|
|
|
)
|
|
|
- self._refresh_lock = asyncio.Lock()
|
|
|
- self._refresh_task: asyncio.Task | None = None
|
|
|
self._http = httpx.AsyncClient(timeout=10.0)
|
|
|
|
|
|
@classmethod
|
|
|
def from_env(cls) -> '_BookingClient':
|
|
|
- env_file = data_home() / 'booking.env'
|
|
|
- env = {**read_env(env_file), **os.environ}
|
|
|
- required_env = ['BOOKING_COOKIES', 'BOOKING_SESSION']
|
|
|
- missing_env = [k for k in required_env if not env.get(k)]
|
|
|
- if missing_env:
|
|
|
- raise ValueError(f'Missing in {env_file}: {", ".join(missing_env)}')
|
|
|
+ env = cls._read_env()
|
|
|
+ cls._require_env(env, 'BOOKING_COOKIES', 'BOOKING_SESSION')
|
|
|
svc = get_config().services['booking']
|
|
|
missing_ids = [k for k in ('hotel_id', 'account_id', 'room_id', 'rate_id') if not getattr(svc, k)]
|
|
|
if missing_ids:
|
|
|
@@ -205,13 +201,10 @@ class _BookingClient:
|
|
|
self._cache.clear()
|
|
|
self._months.clear()
|
|
|
|
|
|
- def _reload_from_env(self) -> None:
|
|
|
- env_file = data_home() / 'booking.env'
|
|
|
- env = {**read_env(env_file), **os.environ}
|
|
|
+ def _apply_env(self, env: dict[str, str]) -> None:
|
|
|
self._cookies = env.get('BOOKING_COOKIES', self._cookies)
|
|
|
self._session = env.get('BOOKING_SESSION', self._session)
|
|
|
self._csrf_token = env.get('BOOKING_CSRF_TOKEN', self._csrf_token)
|
|
|
- self.clear_cache()
|
|
|
|
|
|
def _update_csrf(self, headers: httpx.Headers) -> None:
|
|
|
"""Capture CSRF from a response header if present and not yet known."""
|
|
|
@@ -220,7 +213,7 @@ class _BookingClient:
|
|
|
self._csrf_token = csrf
|
|
|
set_credentials('booking', {'BOOKING_CSRF_TOKEN': csrf})
|
|
|
|
|
|
- async def _try_http_session_refresh(self) -> bool:
|
|
|
+ async def _try_cheap_refresh(self) -> bool:
|
|
|
"""GET the monthly calendar page; extract a fresh ses= from the final URL.
|
|
|
|
|
|
Also captures CSRF from response headers if present.
|
|
|
@@ -272,30 +265,6 @@ class _BookingClient:
|
|
|
raise ServiceAuthError(f'Booking.com credential refresh failed (exit {e.returncode})') from None
|
|
|
_log.info('booking: credential refresh complete')
|
|
|
|
|
|
- async def _auto_refresh(self, *, trigger: str = 'unknown') -> None:
|
|
|
- _log.warning('Booking.com session may be expired — trying HTTP session refresh')
|
|
|
- if await self._try_http_session_refresh():
|
|
|
- _log.info('Booking.com session refreshed via HTTP')
|
|
|
- clear_stale('booking')
|
|
|
- return
|
|
|
- _log.warning('HTTP refresh failed — launching headless Playwright')
|
|
|
- try:
|
|
|
- await asyncio.get_running_loop().run_in_executor(None, _BookingClient.refresh_credentials)
|
|
|
- self._reload_from_env()
|
|
|
- # the file-mode refresh script writes booking.env directly (no set_credentials),
|
|
|
- # so the stale flag must be cleared here or the service stays poisoned
|
|
|
- clear_stale('booking')
|
|
|
- except ServiceAuthError:
|
|
|
- mark_stale('booking')
|
|
|
- raise
|
|
|
-
|
|
|
- async def _ensure_refreshed(self, *, trigger: str) -> None:
|
|
|
- async with self._refresh_lock:
|
|
|
- if self._refresh_task is None or self._refresh_task.done():
|
|
|
- self._refresh_task = asyncio.create_task(self._auto_refresh(trigger=trigger))
|
|
|
- task = self._refresh_task
|
|
|
- await task
|
|
|
-
|
|
|
def _actor(self) -> dict:
|
|
|
return {'actorId': str(self._hotel_id), 'actorType': 'HOTEL'}
|
|
|
|
|
|
@@ -401,20 +370,7 @@ class _BookingClient:
|
|
|
json={'operationName': operation_name, 'variables': variables, 'query': query},
|
|
|
headers=self._msg_headers(),
|
|
|
)
|
|
|
- try:
|
|
|
- r = await _do_request()
|
|
|
- except (httpx.TransportError, OSError, ExceptionGroup) as e:
|
|
|
- _log.warning('booking _msg_gql %s attempt=%d transport error: %s', operation_name, attempt, e)
|
|
|
- raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
|
|
|
- _log.debug('booking _msg_gql %s attempt=%d http=%d', operation_name, attempt, r.status_code)
|
|
|
- if r.status_code in (400, 401, 403):
|
|
|
- _log.warning('booking _msg_gql %s attempt=%d http=%d body=%.500s', operation_name, attempt, r.status_code, r.text)
|
|
|
- await self._ensure_refreshed(trigger='messaging')
|
|
|
- r = await _do_request()
|
|
|
- level = logging.WARNING if r.status_code in (400, 401, 403) else logging.INFO
|
|
|
- _log.log(level, 'booking _msg_gql %s attempt=%d (after refresh) http=%d', operation_name, attempt, r.status_code)
|
|
|
- await self._check_auth(r)
|
|
|
- r.raise_for_status()
|
|
|
+ r = await self._request(_do_request, op=operation_name)
|
|
|
raw = r.json()
|
|
|
if raw.get('errors'):
|
|
|
_log.warning('booking _msg_gql %s attempt=%d gql errors: %s', operation_name, attempt, json.dumps(raw['errors']))
|
|
|
@@ -422,25 +378,12 @@ class _BookingClient:
|
|
|
raise RuntimeError(f'GraphQL error: {msgs}')
|
|
|
return raw
|
|
|
|
|
|
- async def _check_auth(self, r: httpx.Response) -> None:
|
|
|
- if r.status_code in (400, 401, 403):
|
|
|
- mark_stale('booking')
|
|
|
- raise BookingAuthError(f'Booking.com session expired (HTTP {r.status_code}) — run: {_AUTH_HINT}')
|
|
|
-
|
|
|
async def _multipart_post(self, url: str, referer: str, body: dict, extra_headers: dict | None = None) -> dict:
|
|
|
async def _attempt() -> httpx.Response:
|
|
|
headers = self._rest_headers(referer, extra_headers)
|
|
|
return await self._http.post(url, params=self._common_params(),
|
|
|
files={'request': (None, json.dumps(body))}, headers=headers)
|
|
|
- try:
|
|
|
- r = await _attempt()
|
|
|
- except (httpx.TransportError, OSError, ExceptionGroup) as e:
|
|
|
- raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
|
|
|
- if r.status_code in (400, 401, 403):
|
|
|
- await self._ensure_refreshed(trigger='inventory/fetch')
|
|
|
- r = await _attempt()
|
|
|
- await self._check_auth(r)
|
|
|
- r.raise_for_status()
|
|
|
+ r = await self._request(_attempt, op=url.removeprefix(_BASE))
|
|
|
self._update_csrf(r.headers)
|
|
|
return r.json()
|
|
|
|
|
|
@@ -456,15 +399,7 @@ class _BookingClient:
|
|
|
'extensions': {}, 'query': _UPDATE_MUTATION},
|
|
|
headers=self._gql_headers(referer),
|
|
|
)
|
|
|
- try:
|
|
|
- r = await _attempt()
|
|
|
- except (httpx.TransportError, OSError, ExceptionGroup) as e:
|
|
|
- raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
|
|
|
- if r.status_code in (400, 401, 403):
|
|
|
- await self._ensure_refreshed(trigger='graphql')
|
|
|
- r = await _attempt()
|
|
|
- await self._check_auth(r)
|
|
|
- r.raise_for_status()
|
|
|
+ r = await self._request(_attempt, op='updateRoomDateMutation')
|
|
|
raw = r.json()
|
|
|
if raw.get('errors'):
|
|
|
msgs = ', '.join(e.get('message', '') for e in raw['errors'])
|
|
|
@@ -544,22 +479,15 @@ class _BookingClient:
|
|
|
del self._cache[d]
|
|
|
|
|
|
async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
|
|
|
- if is_stale('booking'):
|
|
|
- raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ self._check_fresh()
|
|
|
month_list = months_in_range(date_from, date_to)
|
|
|
all_results = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
|
|
|
for month_result in all_results:
|
|
|
self._cache.update(month_result)
|
|
|
return {d: info for d, info in self._cache.items() if date_from <= d <= date_to}
|
|
|
|
|
|
- async def probe(self) -> None:
|
|
|
- from datetime import date
|
|
|
- today = date.today().isoformat()
|
|
|
- await self.get_calendar(today, today)
|
|
|
-
|
|
|
async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
|
|
|
- if is_stale('booking'):
|
|
|
- raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ self._check_fresh()
|
|
|
async def _attempt() -> httpx.Response:
|
|
|
referer = self._monthly_referer()
|
|
|
params = self._common_params({
|
|
|
@@ -573,15 +501,7 @@ class _BookingClient:
|
|
|
params=params,
|
|
|
headers=self._rest_headers(referer),
|
|
|
)
|
|
|
- try:
|
|
|
- r = await _attempt()
|
|
|
- except (httpx.TransportError, OSError, ExceptionGroup) as e:
|
|
|
- raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
|
|
|
- if r.status_code in (400, 401, 403):
|
|
|
- await self._ensure_refreshed(trigger='get_reservations')
|
|
|
- r = await _attempt()
|
|
|
- await self._check_auth(r)
|
|
|
- r.raise_for_status()
|
|
|
+ r = await self._request(_attempt, op='get_reservations')
|
|
|
raw = r.json()
|
|
|
|
|
|
reservations = raw.get('data', {}).get('reservations') or {}
|
|
|
@@ -602,8 +522,7 @@ class _BookingClient:
|
|
|
|
|
|
async def get_thread(self, reservation_id: str) -> BookingThread:
|
|
|
"""Fetch a conversation thread and its messages by reservation ID."""
|
|
|
- if is_stale('booking'):
|
|
|
- raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ self._check_fresh()
|
|
|
raw = await self._msg_gql(
|
|
|
'conversationMessages', _CONVERSATION_MESSAGES_QUERY,
|
|
|
{'input': {
|
|
|
@@ -641,8 +560,7 @@ class _BookingClient:
|
|
|
|
|
|
async def send_message(self, reservation_id: str, body: str) -> None:
|
|
|
"""Send a text message to the guest conversation for a reservation."""
|
|
|
- if is_stale('booking'):
|
|
|
- raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ self._check_fresh()
|
|
|
idempotency = f'{reservation_id}-{int(time.time() * 1000)}'
|
|
|
payload = {'input': {
|
|
|
'actor': self._actor(),
|
|
|
@@ -676,8 +594,7 @@ class _BookingClient:
|
|
|
|
|
|
async def set_price(self, d: str, price: int, occupancy: int = 2) -> None:
|
|
|
price = int(round(price))
|
|
|
- if is_stale('booking'):
|
|
|
- raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ self._check_fresh()
|
|
|
if _is_past(d):
|
|
|
return
|
|
|
if (cached := self._cache.get(d)) and cached.price == price:
|
|
|
@@ -712,8 +629,7 @@ class _BookingClient:
|
|
|
_log.info('booking set_price date=%s price=$%d', d, price)
|
|
|
|
|
|
async def set_availability(self, d: str, available: bool) -> None:
|
|
|
- if is_stale('booking'):
|
|
|
- raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ self._check_fresh()
|
|
|
if _is_past(d):
|
|
|
return
|
|
|
expected_rts = 1 if available else 0
|
|
|
@@ -743,8 +659,7 @@ class _BookingClient:
|
|
|
_log.info('booking set_availability date=%s available=%s', d, available)
|
|
|
|
|
|
async def set_rooms_to_sell(self, d: str, count: int) -> None:
|
|
|
- if is_stale('booking'):
|
|
|
- raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
|
|
|
+ self._check_fresh()
|
|
|
if _is_past(d):
|
|
|
return
|
|
|
entry: dict = {
|