"""Web tools for the assistant (Notion-style "research" capabilities). Phase 1 of the documented web-toolset roadmap: * ``web_search`` — query the self-hosted SearXNG instance (no API key) and, when it returns nothing, fall back to keyless HTML providers (DuckDuckGo, then Bing) so a dead meta-search instance never leaves the assistant answering « je n'ai pas accès à internet ». * ``fetch_url`` — retrieve a public web page and return readable text. Phase 2 (#92) additions: * keyed providers — Tavily, Brave Search, SerpAPI and Exa are used first when their API key is configured (env, injected by Infisical in production); * SQLite cache — search/fetch results are cached with a TTL (:mod:`backend.tools.webcache`); * retry with backoff — transient network errors get one extra attempt; * dynamic rendering — ``fetch_url(render=True)`` uses an isolated Playwright worker (optional dependency, graceful degradation). All are READ-risk tools (no confirmation), rate-limited through the shared registry, SSRF-guarded (scheme + private-address rejection), and size-capped. Configuration (environment): * ``OBSIGATE_SEARXNG_URL`` — defaults to https://search.dracodev.net * ``OBSIGATE_WEB_TIMEOUT`` — seconds, default 10 * ``OBSIGATE_WEB_FALLBACK`` — ``0``/``false`` disables the keyless HTML fallbacks (SearXNG only), default enabled * ``OBSIGATE_TAVILY_API_KEY`` / ``OBSIGATE_BRAVE_API_KEY`` / ``OBSIGATE_SERPAPI_API_KEY`` / ``OBSIGATE_EXA_API_KEY`` — optional keyed providers, tried before SearXNG when set * ``OBSIGATE_WEB_PROVIDERS`` — optional comma-separated provider order (e.g. ``brave,searxng``); keyed providers without a key are skipped * ``OBSIGATE_WEB_RETRY`` — extra attempts for transient network errors (default 1) * ``OBSIGATE_WEB_CACHE_TTL`` — cache TTL seconds, ``0`` disables (default 900) """ from __future__ import annotations import base64 import binascii import html as html_lib import ipaddress import logging import os import re import socket import time from collections.abc import Callable from typing import Any from urllib.parse import parse_qs, urlparse import httpx from backend.tools import webcache from backend.tools.context import ToolError, ToolRisk, ToolScope from backend.tools.registry import tool from backend.tools.schemas import FetchUrlInput, WebSearchInput from backend.tools.secrets import get_tool_key logger = logging.getLogger("obsigate.tools.web") SEARXNG_URL = os.environ.get("OBSIGATE_SEARXNG_URL", "https://search.dracodev.net") WEB_TIMEOUT = float(os.environ.get("OBSIGATE_WEB_TIMEOUT", "10")) WEB_FALLBACK_ENABLED = os.environ.get("OBSIGATE_WEB_FALLBACK", "1").strip().lower() not in { "0", "false", "no", "off", } WEB_RETRY_ATTEMPTS = int(os.environ.get("OBSIGATE_WEB_RETRY", "1")) USER_AGENT = "ObsiGateAssistant/1.0 (+self-hosted vault AI)" # Search engines reject non-browser agents on their public HTML endpoints. BROWSER_UA = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) # A minimal UA is not enough: Bing serves decoy SERPs (unrelated results) to # requests missing the usual browser navigation headers. BROWSER_HEADERS = { "User-Agent": BROWSER_UA, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Upgrade-Insecure-Requests": "1", } MAX_FETCH_BYTES = 1_500_000 MAX_TEXT_CHARS = 20_000 _BLOCKED_TAGS_RE = re.compile( r"<(script|style|noscript|template|svg)\b.*?\1>", re.IGNORECASE | re.DOTALL ) _TAG_RE = re.compile(r"<[^>]+>") _BLOCK_SPLIT_RE = re.compile( r"?(?:p|div|br|li|h[1-6]|tr|table|ul|ol|section|article|header|footer)\b[^>]*>", re.IGNORECASE, ) _DDG_RESULT_RE = re.compile( r']*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL ) _DDG_SNIPPET_RE = re.compile( r']*class="result__snippet"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL ) _BING_RESULT_RE = re.compile( r'
(.*?)
', re.IGNORECASE | re.DOTALL ) class SSRFError(ToolError): """Raised for a URL whose host is private/loopback or scheme unsupported.""" def _assert_public_http_url(url: str) -> str: """Reject non-http(s) schemes and private/loopback/link-local targets.""" try: parsed = urlparse(url) except ValueError as e: raise SSRFError("URL invalide", code="invalid_url") from e if parsed.scheme not in ("http", "https"): raise SSRFError("Seuls les schémas http/https sont autorisés", code="invalid_scheme") host = parsed.hostname if not host: raise SSRFError("URL sans hôte", code="invalid_url") # Resolve the host so DNS-rebinding to internal IPs is also caught. try: infos = socket.getaddrinfo(host, None) except socket.gaierror as e: raise SSRFError(f"Hôte introuvable: {host}", code="dns_error") from e for info in infos: ip = ipaddress.ip_address(info[4][0]) if ( ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified ): raise SSRFError("Accès aux adresses internes interdit", code="ssrf_blocked") return url def _html_to_text(raw: str) -> str: """Cheap HTML → readable text: strip scripts/styles, tags, then compress. Comments (which may carry script-like payloads) are removed first. """ text = re.sub(r"", " ", raw, flags=re.DOTALL) text = _BLOCKED_TAGS_RE.sub(" ", text) # Keep block boundaries as newlines before dropping the remaining tags. text = _BLOCK_SPLIT_RE.sub("\n", text) text = _TAG_RE.sub("", text) text = html_lib.unescape(text) text = re.sub(r"[ \t]+", " ", text) text = re.sub(r" ?\n ?", "\n", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def _response_text(resp: httpx.Response) -> str: """Decode a response body without relying on ``resp.text`` (easier to mock).""" return resp.content.decode(resp.encoding or "utf-8", errors="replace") def _clean_fragment(fragment: str) -> str: return html_lib.unescape(_TAG_RE.sub("", fragment)).strip() def _result( title: str, url: str, snippet: str, published: Any = None, score: Any = None ) -> dict[str, Any]: return { "title": (title or "")[:300], "url": url or "", "snippet": (snippet or "")[:600], "published": published, "score": score, } def _with_retry(call: Callable[[], Any]) -> Any: """Run *call* with one extra attempt on transient network errors. House-made backoff (the roadmap's « tenacity ou boucle maison »): DNS blips and rate-limit hiccups are the common failure mode, and a single retry keeps the fallback chain from being consumed too early. """ for attempt in range(1 + max(0, WEB_RETRY_ATTEMPTS)): try: return call() except httpx.TransportError: if attempt >= max(0, WEB_RETRY_ATTEMPTS): raise time.sleep(0.2 * (attempt + 1)) raise RuntimeError("unreachable") # pragma: no cover def _env_key(name: str) -> str: """Read an API key: configuration-page store first, then environment.""" return get_tool_key(name) def _search_tavily(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]: """Tavily Search API (agent-oriented results, key required).""" resp = httpx.post( "https://api.tavily.com/search", json={ "api_key": _env_key("OBSIGATE_TAVILY_API_KEY"), "query": query, "max_results": params.max_results, "search_depth": "basic", "include_answer": False, }, headers={"User-Agent": USER_AGENT}, timeout=WEB_TIMEOUT, ) resp.raise_for_status() data = resp.json() return [ _result(item.get("title") or "", item.get("url") or "", item.get("content") or "") for item in (data.get("results") or []) ], [] def _search_brave(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]: """Brave Search API (key required).""" resp = httpx.get( "https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": params.max_results, "safesearch": "moderate"}, headers={ "X-Subscription-Id": _env_key("OBSIGATE_BRAVE_API_KEY"), "Accept": "application/json", "User-Agent": USER_AGENT, }, timeout=WEB_TIMEOUT, ) resp.raise_for_status() data = resp.json() return [ _result(item.get("title") or "", item.get("url") or "", item.get("description") or "") for item in ((data.get("web") or {}).get("results") or []) ], [] def _search_serpapi(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]: """SerpAPI (Google SERP, key required).""" resp = httpx.get( "https://serpapi.com/search", params={"q": query, "api_key": _env_key("OBSIGATE_SERPAPI_API_KEY"), "num": params.max_results}, headers={"User-Agent": USER_AGENT}, timeout=WEB_TIMEOUT, ) resp.raise_for_status() data = resp.json() return [ _result(item.get("title") or "", item.get("link") or "", item.get("snippet") or "") for item in (data.get("organic_results") or []) ], [] def _search_exa(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]: """Exa neural search (key required).""" resp = httpx.post( "https://api.exa.ai/search", json={"query": query, "numResults": params.max_results}, headers={ "x-api-key": _env_key("OBSIGATE_EXA_API_KEY"), "User-Agent": USER_AGENT, }, timeout=WEB_TIMEOUT, ) resp.raise_for_status() data = resp.json() return [ _result(item.get("title") or "", item.get("url") or "", (item.get("text") or "")[:600]) for item in (data.get("results") or []) ], [] # Keyed providers: name -> (implementation, API key env var) _KEYED_PROVIDERS: dict[str, tuple[_Provider, str]] = { "tavily": (_search_tavily, "OBSIGATE_TAVILY_API_KEY"), "brave": (_search_brave, "OBSIGATE_BRAVE_API_KEY"), "serpapi": (_search_serpapi, "OBSIGATE_SERPAPI_API_KEY"), "exa": (_search_exa, "OBSIGATE_EXA_API_KEY"), } def _search_searxng( query: str, params: WebSearchInput ) -> tuple[list[dict[str, Any]], list[str]]: """Query the self-hosted SearXNG instance (JSON API).""" url = SEARXNG_URL.rstrip("/") + "/search" resp = httpx.get( url, params={ "q": query, "format": "json", "categories": params.category or "general", "pageno": max(1, params.page), **({"language": params.language} if params.language else {}), "safesearch": "1", }, headers={"User-Agent": USER_AGENT}, timeout=WEB_TIMEOUT, follow_redirects=False, ) resp.raise_for_status() data = resp.json() results = [ _result( item.get("title") or "", item.get("url") or "", item.get("content") or "", item.get("publishedDate"), item.get("score"), ) for item in (data.get("results") or [])[: params.max_results] ] unresponsive = [ name for entry in (data.get("unresponsive_engines") or []) for name in ([entry[0]] if isinstance(entry, (list, tuple)) and entry else [entry]) if isinstance(name, str) ] return results, unresponsive def _unwrap_duckduckgo_url(href: str) -> str: """DuckDuckGo HTML wraps hits in ``/l/?uddg=