97 lines
3.4 KiB
Python
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)
|