Files
devplacepy/devplacepy/services/nodes/social.py
T
retoor 6f80a820da
DevPlace CI / test (push) Failing after 3m13s
Zo doen wijdat in oost
2026-10-16 07:20:44 +02:00

97 lines
3.4 KiB
Python

# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from itertools import zip_longest
from devplacepy.database import get_setting
from devplacepy.services.nodes.adapters.base import AdapterError, SearchAdapter, SocialHit
from devplacepy.services.nodes.adapters.direct import get as direct_get
from devplacepy.services.nodes.adapters.registry import ADAPTERS, DEFAULT_PLATFORMS
from devplacepy.services.nodes.fabric import FabricError, NodeFabric, shared_fabric
MAX_LIMIT = 20
DEFAULT_LIMIT = 8
MAX_QUERY_CHARS = 200
MAX_TOTAL_HITS = 60
PLATFORM_TIMEOUT_SECONDS = 20.0
@dataclass
class SocialOutcome:
hits: list[SocialHit] = field(default_factory=list)
unavailable: dict[str, str] = field(default_factory=dict)
def as_dict(self) -> dict:
return {
"hits": [item.as_dict() for item in self.hits],
"unavailable": dict(self.unavailable),
}
class SocialSearch:
def __init__(self, fabric: NodeFabric | None = None) -> None:
self.fabric = fabric or shared_fabric()
@staticmethod
def platforms_for(requested: list[str] | None) -> list[str]:
if not requested:
return list(DEFAULT_PLATFORMS)
return [name for name in dict.fromkeys(requested) if name in ADAPTERS]
async def search(
self,
query: str,
*,
caller_kind: str,
caller_id: str,
platforms: list[str] | None = None,
limit: int = DEFAULT_LIMIT,
) -> SocialOutcome:
query = " ".join(str(query or "").split())[:MAX_QUERY_CHARS]
outcome = SocialOutcome()
if not query:
return outcome
limit = max(1, min(int(limit or DEFAULT_LIMIT), MAX_LIMIT))
names = self.platforms_for(platforms)
results = await asyncio.gather(
*(self._one(ADAPTERS[name], query, limit, caller_kind, caller_id) for name in names)
)
buckets = []
for name, (hits, code) in zip(names, results):
if code:
outcome.unavailable[name] = code
buckets.append(hits)
for tier in zip_longest(*buckets):
outcome.hits.extend(item for item in tier if item)
outcome.hits = outcome.hits[:MAX_TOTAL_HITS]
return outcome
async def _one(
self, adapter: SearchAdapter, query: str, limit: int, caller_kind: str, caller_id: str
) -> tuple[list[SocialHit], str]:
try:
return await asyncio.wait_for(
self._fetch(adapter, query, limit, caller_kind, caller_id),
PLATFORM_TIMEOUT_SECONDS,
), ""
except asyncio.TimeoutError:
return [], "timeout"
except (AdapterError, FabricError) as exc:
return [], exc.code
async def _fetch(
self, adapter: SearchAdapter, query: str, limit: int, caller_kind: str, caller_id: str
) -> list[SocialHit]:
url = adapter.request(query, limit)
if adapter.direct:
return adapter.parse(await direct_get(url), limit)
if get_setting("nodes_social_via_nodes", "0") != "1":
raise AdapterError("nodes_not_used", "Routing through donor nodes is switched off")
result = await self.fabric.fetch(url, caller_kind=caller_kind, caller_id=caller_id)
if result.status >= 400:
raise AdapterError("upstream", f"HTTP {result.status}")
return adapter.parse(result.body, limit)