|
|
@@ -4,7 +4,6 @@ import json
|
|
|
import os
|
|
|
import random
|
|
|
import string
|
|
|
-import time
|
|
|
import uuid
|
|
|
from datetime import date, datetime, timedelta
|
|
|
from zoneinfo import ZoneInfo
|
|
|
@@ -16,7 +15,8 @@ import logging
|
|
|
|
|
|
from ._scripts import _assert_not_async, _npm_env, _ts_dir, _ensure_installed
|
|
|
from room_cal._utils import data_home, get_config, read_env
|
|
|
-from ._utils import CACHE_TTL, month_bounds, months_in_range
|
|
|
+from ._month_cache import MonthCache, TTLCache
|
|
|
+from ._utils import month_bounds, months_in_range
|
|
|
from .credentials import is_stale, mark_stale
|
|
|
from .types import CalendarEvent, DayInfo, ServiceAuthError, Reservation
|
|
|
|
|
|
@@ -131,17 +131,22 @@ class _AirbnbClient:
|
|
|
self._tz = tz
|
|
|
self._cache: dict[str, DayInfo] = {}
|
|
|
self._fetched_months: set[tuple[int, int]] = set()
|
|
|
- self._raw_months: dict[tuple[int, int], list[dict]] = {}
|
|
|
- self._month_fetched_at: dict[tuple[int, int], float] = {}
|
|
|
- self._months_inflight: dict[tuple[int, int], asyncio.Task] = {}
|
|
|
# Tracks months where we've seen at least one booked day; survives clear_cache() so we can
|
|
|
# detect when a freshly-fetched month drops from N booked days to 0 (stale replica signal).
|
|
|
self._had_bookings: set[tuple[int, int]] = set()
|
|
|
+ self._months: MonthCache[list[dict]] = MonthCache(
|
|
|
+ lambda m, y: self._fetch_month_days(m, y),
|
|
|
+ suspect=self._suspect_days,
|
|
|
+ revalidate=self._revalidate_month,
|
|
|
+ on_fetch=self._note_bookings,
|
|
|
+ on_evict=self._evict_derived,
|
|
|
+ label='Airbnb',
|
|
|
+ log=_log,
|
|
|
+ )
|
|
|
+ self._details: TTLCache[dict] = TTLCache()
|
|
|
self._refresh_lock = asyncio.Lock()
|
|
|
self._refresh_task: asyncio.Task | None = None
|
|
|
self._http = httpx.AsyncClient(timeout=10.0)
|
|
|
- self._detail_cache: dict[str, dict] = {}
|
|
|
- self._detail_fetched_at: dict[str, float] = {}
|
|
|
self._booked_nets: dict[str, int] = {}
|
|
|
|
|
|
@classmethod
|
|
|
@@ -159,10 +164,8 @@ class _AirbnbClient:
|
|
|
def clear_cache(self) -> None:
|
|
|
self._cache.clear()
|
|
|
self._fetched_months.clear()
|
|
|
- self._raw_months.clear()
|
|
|
- self._month_fetched_at.clear()
|
|
|
- self._detail_cache.clear()
|
|
|
- self._detail_fetched_at.clear()
|
|
|
+ self._months.clear()
|
|
|
+ self._details.clear()
|
|
|
self._booked_nets.clear()
|
|
|
|
|
|
def _reload_from_env(self) -> None:
|
|
|
@@ -304,79 +307,48 @@ class _AirbnbClient:
|
|
|
'readConsistency': 'EVENTUAL',
|
|
|
}
|
|
|
referer = f'https://www.airbnb.com/multicalendar/{self._listing_id}'
|
|
|
+ resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
|
|
|
+ section = (
|
|
|
+ resp.get('data', {}).get('presentation', {})
|
|
|
+ .get('hostCalendar', {}).get('sections', {})
|
|
|
+ .get('calendarGridViewSection')
|
|
|
+ ) or {}
|
|
|
+ return section.get('days', [])
|
|
|
+
|
|
|
+ def _suspect_days(self, key: tuple[int, int], days: list[dict]) -> bool:
|
|
|
+ return not days or not any(d.get('priceData', {}).get('price') is not None for d in days)
|
|
|
+
|
|
|
+ def _revalidate_month(self, key: tuple[int, int], cached: list[dict]) -> bool:
|
|
|
+ # If we've seen bookings for this month before but the cache now shows none,
|
|
|
+ # the data is likely from a stale eventual-consistency replica — force a refetch.
|
|
|
+ if key in self._had_bookings and not any(d.get('reservationData') for d in cached):
|
|
|
+ month, year = key
|
|
|
+ _log.warning(
|
|
|
+ 'Airbnb: %d-%02d cache shows 0 booked days but month had bookings; forcing refetch',
|
|
|
+ year, month,
|
|
|
+ )
|
|
|
+ return False
|
|
|
+ return True
|
|
|
|
|
|
- def _extract_days(resp: dict) -> list[dict]:
|
|
|
- section = (
|
|
|
- resp.get('data', {}).get('presentation', {})
|
|
|
- .get('hostCalendar', {}).get('sections', {})
|
|
|
- .get('calendarGridViewSection')
|
|
|
- ) or {}
|
|
|
- return section.get('days', [])
|
|
|
-
|
|
|
- def _has_prices(days: list[dict]) -> bool:
|
|
|
- return any(d.get('priceData', {}).get('price') is not None for d in days)
|
|
|
+ def _note_bookings(self, key: tuple[int, int], result: list[dict], revalidation_failed: bool) -> None:
|
|
|
+ if any(d.get('reservationData') for d in result):
|
|
|
+ self._had_bookings.add(key)
|
|
|
+ elif revalidation_failed:
|
|
|
+ # Forced refetch confirmed no bookings — stop guarding this month
|
|
|
+ self._had_bookings.discard(key)
|
|
|
|
|
|
- resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
|
|
|
- days = _extract_days(resp)
|
|
|
- if not days or not _has_prices(days):
|
|
|
- _log.warning('Airbnb: empty or priceless calendar for %d-%02d; retrying', year, month)
|
|
|
- await asyncio.sleep(1)
|
|
|
- resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
|
|
|
- days = _extract_days(resp)
|
|
|
- if not days or not _has_prices(days):
|
|
|
- _log.warning('Airbnb: still empty/priceless calendar for %d-%02d after retry', year, month)
|
|
|
- return days
|
|
|
-
|
|
|
- def _evict_month(self, key: tuple[int, int]) -> None:
|
|
|
+ def _evict_derived(self, key: tuple[int, int]) -> None:
|
|
|
month, year = key
|
|
|
- self._raw_months.pop(key, None)
|
|
|
- self._month_fetched_at.pop(key, None)
|
|
|
self._fetched_months.discard(key)
|
|
|
prefix = f'{year}-{month:02d}'
|
|
|
for d in [k for k in self._cache if k.startswith(prefix)]:
|
|
|
del self._cache[d]
|
|
|
|
|
|
- async def _get_month_days(self, month: int, year: int) -> list[dict]:
|
|
|
- key = (month, year)
|
|
|
- forced_refetch = False
|
|
|
- if key in self._raw_months:
|
|
|
- cached = self._raw_months[key]
|
|
|
- fetched_at = self._month_fetched_at.get(key)
|
|
|
- if fetched_at is None or time.monotonic() - fetched_at > CACHE_TTL:
|
|
|
- self._evict_month(key)
|
|
|
- # If we've seen bookings for this month before but the cache now shows none,
|
|
|
- # the data is likely from a stale eventual-consistency replica — force a refetch.
|
|
|
- elif key in self._had_bookings and not any(d.get('reservationData') for d in cached):
|
|
|
- _log.warning(
|
|
|
- 'Airbnb: %d-%02d cache shows 0 booked days but month had bookings; forcing refetch',
|
|
|
- year, month,
|
|
|
- )
|
|
|
- self._evict_month(key)
|
|
|
- forced_refetch = True
|
|
|
- else:
|
|
|
- return cached
|
|
|
- if key in self._months_inflight:
|
|
|
- return await self._months_inflight[key]
|
|
|
- task = asyncio.create_task(self._fetch_month_days(month, year))
|
|
|
- self._months_inflight[key] = task
|
|
|
- try:
|
|
|
- result = await task
|
|
|
- finally:
|
|
|
- self._months_inflight.pop(key, None)
|
|
|
- if any(d.get('reservationData') for d in result):
|
|
|
- self._had_bookings.add(key)
|
|
|
- elif forced_refetch:
|
|
|
- # Forced refetch confirmed no bookings — stop guarding this month
|
|
|
- self._had_bookings.discard(key)
|
|
|
- self._raw_months[key] = result
|
|
|
- self._month_fetched_at[key] = time.monotonic()
|
|
|
- return result
|
|
|
-
|
|
|
async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
|
|
|
if is_stale('airbnb'):
|
|
|
raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
|
|
|
month_list = months_in_range(date_from, date_to)
|
|
|
- all_days = await asyncio.gather(*[self._get_month_days(m, y) for m, y in month_list])
|
|
|
+ all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
|
|
|
for (month, year), days in zip(month_list, all_days):
|
|
|
if (month, year) not in self._fetched_months:
|
|
|
for day in days:
|
|
|
@@ -407,7 +379,7 @@ class _AirbnbClient:
|
|
|
if is_stale('airbnb'):
|
|
|
raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
|
|
|
month_list = months_in_range(date_from, date_to)
|
|
|
- all_days = await asyncio.gather(*[self._get_month_days(m, y) for m, y in month_list])
|
|
|
+ all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
|
|
|
booked: dict[str, str] = {}
|
|
|
for days in all_days:
|
|
|
for day in days:
|
|
|
@@ -496,7 +468,7 @@ class _AirbnbClient:
|
|
|
if is_stale('airbnb'):
|
|
|
raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
|
|
|
month_list = months_in_range(date_from, date_to)
|
|
|
- all_days = await asyncio.gather(*[self._get_month_days(m, y) for m, y in month_list])
|
|
|
+ all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
|
|
|
|
|
|
booked_codes: dict[str, str] = {} # date → confirmation code
|
|
|
for days in all_days:
|
|
|
@@ -574,10 +546,8 @@ class _AirbnbClient:
|
|
|
return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
|
|
|
|
|
|
async def _fetch_one_detail(self, code: str) -> dict:
|
|
|
- if code in self._detail_cache:
|
|
|
- fetched_at = self._detail_fetched_at.get(code)
|
|
|
- if fetched_at is not None and time.monotonic() - fetched_at <= CACHE_TTL:
|
|
|
- return self._detail_cache[code]
|
|
|
+ if (cached := self._details.get(code)) is not None:
|
|
|
+ return cached
|
|
|
try:
|
|
|
resp = await self._gql(
|
|
|
'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
|
|
|
@@ -591,8 +561,7 @@ class _AirbnbClient:
|
|
|
resp.get('data', {}).get('presentation', {})
|
|
|
.get('hostingDetails', {}).get('stayHostingDetails', {})
|
|
|
)
|
|
|
- self._detail_cache[code] = det
|
|
|
- self._detail_fetched_at[code] = time.monotonic()
|
|
|
+ self._details.set(code, det)
|
|
|
return det
|
|
|
|
|
|
def _global_thread_id(self, thread_id: str) -> str:
|