|
@@ -4,6 +4,7 @@ import json
|
|
|
import os
|
|
import os
|
|
|
import random
|
|
import random
|
|
|
import string
|
|
import string
|
|
|
|
|
+import time
|
|
|
import uuid
|
|
import uuid
|
|
|
from datetime import date, datetime, timedelta
|
|
from datetime import date, datetime, timedelta
|
|
|
from zoneinfo import ZoneInfo
|
|
from zoneinfo import ZoneInfo
|
|
@@ -15,7 +16,7 @@ import logging
|
|
|
|
|
|
|
|
from ._scripts import _assert_not_async, _npm_env, _ts_dir, _ensure_installed
|
|
from ._scripts import _assert_not_async, _npm_env, _ts_dir, _ensure_installed
|
|
|
from room_cal._utils import data_home, get_config, read_env
|
|
from room_cal._utils import data_home, get_config, read_env
|
|
|
-from ._utils import month_bounds, months_in_range
|
|
|
|
|
|
|
+from ._utils import CACHE_TTL, month_bounds, months_in_range
|
|
|
from .credentials import is_stale, mark_stale
|
|
from .credentials import is_stale, mark_stale
|
|
|
from .types import CalendarEvent, DayInfo, ServiceAuthError, Reservation
|
|
from .types import CalendarEvent, DayInfo, ServiceAuthError, Reservation
|
|
|
|
|
|
|
@@ -131,6 +132,7 @@ class _AirbnbClient:
|
|
|
self._cache: dict[str, DayInfo] = {}
|
|
self._cache: dict[str, DayInfo] = {}
|
|
|
self._fetched_months: set[tuple[int, int]] = set()
|
|
self._fetched_months: set[tuple[int, int]] = set()
|
|
|
self._raw_months: dict[tuple[int, int], list[dict]] = {}
|
|
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] = {}
|
|
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
|
|
# 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).
|
|
# detect when a freshly-fetched month drops from N booked days to 0 (stale replica signal).
|
|
@@ -139,6 +141,7 @@ class _AirbnbClient:
|
|
|
self._refresh_task: asyncio.Task | None = None
|
|
self._refresh_task: asyncio.Task | None = None
|
|
|
self._http = httpx.AsyncClient(timeout=10.0)
|
|
self._http = httpx.AsyncClient(timeout=10.0)
|
|
|
self._detail_cache: dict[str, dict] = {}
|
|
self._detail_cache: dict[str, dict] = {}
|
|
|
|
|
+ self._detail_fetched_at: dict[str, float] = {}
|
|
|
self._booked_nets: dict[str, int] = {}
|
|
self._booked_nets: dict[str, int] = {}
|
|
|
|
|
|
|
|
@classmethod
|
|
@classmethod
|
|
@@ -157,7 +160,9 @@ class _AirbnbClient:
|
|
|
self._cache.clear()
|
|
self._cache.clear()
|
|
|
self._fetched_months.clear()
|
|
self._fetched_months.clear()
|
|
|
self._raw_months.clear()
|
|
self._raw_months.clear()
|
|
|
|
|
+ self._month_fetched_at.clear()
|
|
|
self._detail_cache.clear()
|
|
self._detail_cache.clear()
|
|
|
|
|
+ self._detail_fetched_at.clear()
|
|
|
self._booked_nets.clear()
|
|
self._booked_nets.clear()
|
|
|
|
|
|
|
|
def _reload_from_env(self) -> None:
|
|
def _reload_from_env(self) -> None:
|
|
@@ -322,23 +327,31 @@ class _AirbnbClient:
|
|
|
_log.warning('Airbnb: still empty/priceless calendar for %d-%02d after retry', year, month)
|
|
_log.warning('Airbnb: still empty/priceless calendar for %d-%02d after retry', year, month)
|
|
|
return days
|
|
return days
|
|
|
|
|
|
|
|
|
|
+ def _evict_month(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]:
|
|
async def _get_month_days(self, month: int, year: int) -> list[dict]:
|
|
|
key = (month, year)
|
|
key = (month, year)
|
|
|
forced_refetch = False
|
|
forced_refetch = False
|
|
|
if key in self._raw_months:
|
|
if key in self._raw_months:
|
|
|
cached = self._raw_months[key]
|
|
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,
|
|
# 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.
|
|
# 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):
|
|
|
|
|
|
|
+ elif key in self._had_bookings and not any(d.get('reservationData') for d in cached):
|
|
|
_log.warning(
|
|
_log.warning(
|
|
|
'Airbnb: %d-%02d cache shows 0 booked days but month had bookings; forcing refetch',
|
|
'Airbnb: %d-%02d cache shows 0 booked days but month had bookings; forcing refetch',
|
|
|
year, month,
|
|
year, month,
|
|
|
)
|
|
)
|
|
|
- del self._raw_months[key]
|
|
|
|
|
- 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]
|
|
|
|
|
|
|
+ self._evict_month(key)
|
|
|
forced_refetch = True
|
|
forced_refetch = True
|
|
|
else:
|
|
else:
|
|
|
return cached
|
|
return cached
|
|
@@ -356,6 +369,7 @@ class _AirbnbClient:
|
|
|
# Forced refetch confirmed no bookings — stop guarding this month
|
|
# Forced refetch confirmed no bookings — stop guarding this month
|
|
|
self._had_bookings.discard(key)
|
|
self._had_bookings.discard(key)
|
|
|
self._raw_months[key] = result
|
|
self._raw_months[key] = result
|
|
|
|
|
+ self._month_fetched_at[key] = time.monotonic()
|
|
|
return result
|
|
return result
|
|
|
|
|
|
|
|
async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
|
|
async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
|
|
@@ -561,7 +575,9 @@ class _AirbnbClient:
|
|
|
|
|
|
|
|
async def _fetch_one_detail(self, code: str) -> dict:
|
|
async def _fetch_one_detail(self, code: str) -> dict:
|
|
|
if code in self._detail_cache:
|
|
if code in self._detail_cache:
|
|
|
- return self._detail_cache[code]
|
|
|
|
|
|
|
+ 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]
|
|
|
try:
|
|
try:
|
|
|
resp = await self._gql(
|
|
resp = await self._gql(
|
|
|
'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
|
|
'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
|
|
@@ -576,6 +592,7 @@ class _AirbnbClient:
|
|
|
.get('hostingDetails', {}).get('stayHostingDetails', {})
|
|
.get('hostingDetails', {}).get('stayHostingDetails', {})
|
|
|
)
|
|
)
|
|
|
self._detail_cache[code] = det
|
|
self._detail_cache[code] = det
|
|
|
|
|
+ self._detail_fetched_at[code] = time.monotonic()
|
|
|
return det
|
|
return det
|
|
|
|
|
|
|
|
def _global_thread_id(self, thread_id: str) -> str:
|
|
def _global_thread_id(self, thread_id: str) -> str:
|