"""Tiny JSON file cache with TTL, used by the pick panels so scraping/LLM validation runs at most once per TTL (default 24h) instead of on every page load. `panel()` is the shared serve-path: fresh cache → live rebuild → STALE cache. A panel whose live refresh fails (missing key, rate-limit, scrape drift) degrades to the last good result flagged `stale`, instead of a 502 — the shipped data/*_deep.json files are that fallback on a cold Space.""" import json import time from config import CACHE_DIR, PICKS_CACHE_TTL_HOURS def _read(name: str): path = CACHE_DIR / f"{name}.json" if not path.exists(): return None try: return json.loads(path.read_text()) except (json.JSONDecodeError, OSError): return None def load(name: str): payload = _read(name) if not payload or time.time() - payload.get("_ts", 0) > PICKS_CACHE_TTL_HOURS * 3600: return None return payload["data"] def load_stale(name: str): """The cached payload regardless of TTL, as (data, age_hours) — or None if there is none.""" payload = _read(name) if not payload or "data" not in payload: return None return payload["data"], (time.time() - payload.get("_ts", 0)) / 3600 def save(name: str, data) -> None: path = CACHE_DIR / f"{name}.json" path.write_text(json.dumps({"_ts": time.time(), "data": data}, indent=1)) def panel(name: str, build, refresh: bool, limit: int) -> dict: """Serve a picks panel: cached-fresh unless `refresh`, else rebuild via `build()` and cache the DEEP list (homepage top-5 and the top-20 page slice the same ranking); on rebuild failure serve the stale cache marked as such, and only raise if there is nothing at all.""" if not refresh: fresh = load(name) if fresh: return {**fresh, "picks": fresh["picks"][:limit]} try: result = build() except Exception as e: # noqa: BLE001 — degrade to stale rather than blank the panel stale = load_stale(name) if stale is None: raise data, age = stale return {**data, "picks": data["picks"][:limit], "stale": True, "as_of": f"{data.get('as_of', '')} · ⚠ cached result, {age:.0f}h old — " f"live refresh failed: {str(e)[:90]}"} try: save(name, result) except OSError: pass # read-only FS: still serve the fresh result return {**result, "picks": result["picks"][:limit]}