···11+"""Profile cache for enriching basic Bluesky profiles with full data."""
22+33+from __future__ import annotations
44+55+import time
66+from collections import OrderedDict
77+from typing import Any
88+99+_PROFILE_CACHE_MAX = 2000
1010+_PROFILE_CACHE_TTL = 600 # 10 minutes
1111+1212+1313+class _ProfileCache:
1414+ """LRU cache mapping DID → enrichment dict (description, counts, banner)."""
1515+1616+ def __init__(self, max_size: int = _PROFILE_CACHE_MAX, ttl: int = _PROFILE_CACHE_TTL):
1717+ self._cache: OrderedDict[str, tuple[dict[str, Any], float]] = OrderedDict()
1818+ self._max_size = max_size
1919+ self._ttl = ttl
2020+2121+ def store(self, did: str, profile: dict[str, Any]) -> None:
2222+ """Cache enrichment fields from a *full* profile response."""
2323+ enrichment = {
2424+ "description": profile.get("description") or "",
2525+ "followersCount": profile.get("followersCount", 0),
2626+ "followsCount": profile.get("followsCount", 0),
2727+ "postsCount": profile.get("postsCount", 0),
2828+ "banner": profile.get("banner") or "",
2929+ }
3030+ if did in self._cache:
3131+ del self._cache[did]
3232+ if len(self._cache) >= self._max_size:
3333+ self._cache.popitem(last=False)
3434+ self._cache[did] = (enrichment, time.time())
3535+3636+ def get(self, did: str) -> dict[str, Any] | None:
3737+ """Return cached enrichment dict, or *None* if missing / expired."""
3838+ if did not in self._cache:
3939+ return None
4040+ data, ts = self._cache[did]
4141+ if time.time() - ts > self._ttl:
4242+ del self._cache[did]
4343+ return None
4444+ self._cache.move_to_end(did)
4545+ return data
4646+4747+4848+_profile_cache = _ProfileCache()
4949+5050+5151+def cache_profile(profile: dict[str, Any]) -> None:
5252+ """Store a full Bluesky profile in the enrichment cache.
5353+5454+ Call this whenever a ``profileViewDetailed`` is fetched (e.g. getProfile).
5555+ """
5656+ did = profile.get("did", "")
5757+ if did:
5858+ _profile_cache.store(did, profile)
5959+6060+6161+def collect_uncached_dids(items: list[dict[str, Any]]) -> list[str]:
6262+ """Collect unique, uncached author DIDs from feed items or post views.
6363+6464+ Works with both ``feedViewPost`` items (containing a ``post`` key) and
6565+ bare ``postView`` dicts. Returns only DIDs not already in the cache so
6666+ callers can batch-fetch full profiles via ``getProfiles``.
6767+ """
6868+ seen: set[str] = set()
6969+ dids: list[str] = []
7070+ for item in items:
7171+ post = item.get("post", item)
7272+ did = post.get("author", {}).get("did", "")
7373+ if did and did not in seen and _profile_cache.get(did) is None:
7474+ seen.add(did)
7575+ dids.append(did)
7676+ # Also check repost reason author
7777+ reason = item.get("reason")
7878+ if reason:
7979+ rdid = reason.get("by", {}).get("did", "")
8080+ if rdid and rdid not in seen and _profile_cache.get(rdid) is None:
8181+ seen.add(rdid)
8282+ dids.append(rdid)
8383+ return dids
8484+8585+8686+def _enrich_profile(profile: dict[str, Any]) -> dict[str, Any]:
8787+ """Return *profile* with missing fields filled from the cache."""
8888+ did = profile.get("did", "")
8989+ if not did:
9090+ return profile
9191+ # If the profile already has counts, it's a full profile – cache it
9292+ if profile.get("followersCount") is not None:
9393+ _profile_cache.store(did, profile)
9494+ return profile
9595+ # Otherwise try to enrich from cache
9696+ cached = _profile_cache.get(did)
9797+ if cached is None:
9898+ return profile
9999+ enriched = dict(profile)
100100+ enriched.setdefault("description", cached["description"])
101101+ enriched.setdefault("followersCount", cached["followersCount"])
102102+ enriched.setdefault("followsCount", cached["followsCount"])
103103+ enriched.setdefault("postsCount", cached["postsCount"])
104104+ if not enriched.get("banner"):
105105+ enriched["banner"] = cached["banner"]
106106+ return enriched