Source code for ralph.mcp.websearch.backends.searxng
"""SearXNG web-search backend.
Implements the ``SearxngBackend`` dataclass that queries a self-hosted SearXNG
instance over HTTP. Unlike the API-key backends (Exa, Tavily, Brave), this
backend requires no credentials — only the base URL of a running SearXNG
server.
The backend POSTs to ``{url}/search?format=json`` with a 10-second timeout and
normalises the JSON response into a list of ``SearchResult`` objects. Network
errors and non-200 responses raise ``WebSearchError``.
Typical usage (from ``ralph.config.mcp_models`` backend selection)::
backend = SearxngBackend(url="http://localhost:8080")
results = backend.search("Python type hints", limit=5)
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import cast
from urllib.parse import urljoin
import httpx
from .base import SearchResult, WebSearchError
_SEARCH_PATH = "/search"
_TIMEOUT_SECONDS = 10.0
[docs]
@dataclass(frozen=True)
class SearxngBackend:
"""Backend that queries a user-managed SearXNG instance."""
url: str
def search(self, query: str, *, limit: int = 10) -> list[SearchResult]:
request_data: dict[str, str] = {"q": query, "format": "json"}
try:
response = httpx.post(
self._search_url,
data=request_data,
timeout=_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = cast("object", response.json())
except Exception as exc:
raise WebSearchError("searxng search failed") from exc
return self._normalize_results(payload, limit=limit)
@property
def _search_url(self) -> str:
return urljoin(f"{self.url.rstrip('/')}/", _SEARCH_PATH.lstrip("/"))
@staticmethod
def _normalize_results(payload: object, *, limit: int) -> list[SearchResult]:
if not isinstance(payload, Mapping):
raise WebSearchError("searxng returned an invalid response")
raw_results = payload.get("results")
if not isinstance(raw_results, Iterable):
return []
results: list[SearchResult] = []
for item in raw_results:
if not isinstance(item, Mapping):
continue
title = _string_value(item, "title")
url = _string_value(item, "url")
snippet = _string_value(item, "content")
if not title or not url:
continue
results.append(SearchResult(title=title, url=url, snippet=snippet))
if len(results) >= limit:
break
return results
def _string_value(payload: Mapping[str, object], *keys: str) -> str:
for key in keys:
value = payload.get(key)
if isinstance(value, str) and value:
return value
return ""
__all__ = ["SearxngBackend"]