"""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. 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 """ from __future__ import annotations import base64 import binascii import html as html_lib import ipaddress import logging import os import re import socket from collections.abc import Callable from typing import Any from urllib.parse import parse_qs, urlparse import httpx from backend.tools.context import ToolError, ToolRisk, ToolScope from backend.tools.registry import tool from backend.tools.schemas import FetchUrlInput, WebSearchInput 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", } 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.*?", re.IGNORECASE | re.DOTALL ) _TAG_RE = re.compile(r"<[^>]+>") _BLOCK_SPLIT_RE = re.compile( r"]*>", 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']*>\s*]*href="([^"]+)"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL ) _BING_SNIPPET_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 _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=``.""" href = html_lib.unescape(href) if href.startswith("//"): href = "https:" + href if "uddg=" in href: values = parse_qs(urlparse(href).query).get("uddg") if values: return values[0] return href def _search_duckduckgo( query: str, params: WebSearchInput ) -> tuple[list[dict[str, Any]], list[str]]: """Keyless fallback: scrape the DuckDuckGo no-JS HTML endpoint.""" resp = httpx.get( "https://html.duckduckgo.com/html/", params={"q": query, **({"kl": params.language} if params.language else {})}, headers=BROWSER_HEADERS, timeout=WEB_TIMEOUT, follow_redirects=False, ) resp.raise_for_status() body = _response_text(resp) snippets = [_clean_fragment(m.group(1)) for m in _DDG_SNIPPET_RE.finditer(body)] results: list[dict[str, Any]] = [] for index, match in enumerate(_DDG_RESULT_RE.finditer(body)): results.append( _result( _clean_fragment(match.group(2)), _unwrap_duckduckgo_url(match.group(1)), snippets[index] if index < len(snippets) else "", ) ) if len(results) >= params.max_results: break return results, [] def _unwrap_bing_url(href: str) -> str: """Bing wraps hits in ``/ck/a?...&u=a1``.""" href = html_lib.unescape(href) match = re.search(r"[?&]u=a1([A-Za-z0-9_\-]+)", href) if not match: return href token = match.group(1).replace("-", "+").replace("_", "/") token += "=" * (-len(token) % 4) try: return base64.b64decode(token).decode("utf-8", errors="replace") except (ValueError, binascii.Error): return href def _search_bing( query: str, params: WebSearchInput ) -> tuple[list[dict[str, Any]], list[str]]: """Last-resort keyless fallback: scrape Bing's result page.""" resp = httpx.get( "https://www.bing.com/search", params={"q": query, **({"setlang": params.language} if params.language else {})}, headers=BROWSER_HEADERS, timeout=WEB_TIMEOUT, follow_redirects=False, ) resp.raise_for_status() body = _response_text(resp) snippets = [_clean_fragment(m.group(1)) for m in _BING_SNIPPET_RE.finditer(body)] results: list[dict[str, Any]] = [] for index, match in enumerate(_BING_RESULT_RE.finditer(body)): results.append( _result( _clean_fragment(match.group(2)), _unwrap_bing_url(match.group(1)), snippets[index] if index < len(snippets) else "", ) ) if len(results) >= params.max_results: break return results, [] _Provider = Callable[[str, WebSearchInput], "tuple[list[dict[str, Any]], list[str]]"] def _provider_chain() -> list[tuple[str, _Provider]]: """Ordered providers: self-hosted meta-search first, then keyless fallbacks.""" chain: list[tuple[str, _Provider]] = [("searxng", _search_searxng)] if WEB_FALLBACK_ENABLED: chain.append(("duckduckgo", _search_duckduckgo)) chain.append(("bing", _search_bing)) return chain @tool( name="web_search", description=( "Search the public web for current information and return ranked results " "(title, url, snippet). Use for facts outside the vault: weather, news, " "documentation, versions, prices, anything that needs live sources." ), input_model=WebSearchInput, risk=ToolRisk.READ, scopes=(ToolScope.IN_APP,), ) def web_search(ctx, params: WebSearchInput) -> dict[str, Any]: """Try each configured provider and return the first non-empty result set.""" query = params.query.strip() if not query: raise ToolError("Requête vide", code="invalid_arguments") attempts: list[str] = [] unresponsive: list[str] = [] reachable = False last_error: Exception | None = None for name, provider in _provider_chain(): attempts.append(name) try: results, engines = provider(query, params) except (httpx.HTTPError, ValueError, AttributeError) as e: logger.warning("web_search provider %s failed: %s", name, e) last_error = e continue reachable = True if engines: unresponsive = engines if results: payload: dict[str, Any] = { "query": query, "provider": name, "results": results, "count": len(results), } if unresponsive: payload["unresponsive_engines"] = unresponsive[:8] return payload if not reachable: raise ToolError( "Le moteur de recherche web est momentanément indisponible.", code="web_search_unavailable", ) from last_error # Every provider answered but returned nothing: tell the model explicitly # so it stops retrying the same query until its tool quota burns out. payload = { "query": query, "provider": attempts[-1], "results": [], "count": 0, "warning": ( "Aucun résultat : les fournisseurs de recherche web sont " f"indisponibles ({', '.join(attempts)}). " "Ne relance pas la même recherche — dis-le à l'utilisateur." ), } if unresponsive: payload["unresponsive_engines"] = unresponsive[:8] return payload @tool( name="fetch_url", description=( "Fetch a public web page (http/https) and return its readable text. " "Use after web_search to read a promising result in detail. HTML is " "converted to plain text; binary pages are rejected." ), input_model=FetchUrlInput, risk=ToolRisk.READ, scopes=(ToolScope.IN_APP,), ) def fetch_url(ctx, params: FetchUrlInput) -> dict[str, Any]: """Retrieve one page, guard against SSRF, and extract its text.""" url = _assert_public_http_url(params.url.strip()) try: # Follow redirects manually so every hop is re-checked against the # private-address SSRF guard (a public page can redirect to 127.0.0.1). resp = None for _hop in range(5): resp = httpx.get( url, headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml,*/*"}, timeout=WEB_TIMEOUT, follow_redirects=False, ) if resp.status_code in (301, 302, 303, 307, 308): location = resp.headers.get("location") or "" if not location: break url = str(httpx.URL(url).join(location)) url = _assert_public_http_url(url) continue break assert resp is not None resp.raise_for_status() except SSRFError: raise except httpx.HTTPError as e: logger.warning("fetch_url failed for %s: %s", url, e) raise ToolError("Impossible de récupérer la page.", code="fetch_unavailable") from e ctype = (resp.headers.get("content-type") or "").lower() if not any(t in ctype for t in ("html", "xml", "text", "json", "markdown")): raise ToolError( f"Type de contenu non pris en charge: {ctype.split(';')[0] or 'inconnu'}", code="unsupported_content_type", ) raw = (resp.content[:MAX_FETCH_BYTES]).decode(resp.encoding or "utf-8", errors="replace") title_match = re.search(r"]*>(.*?)", raw, re.IGNORECASE | re.DOTALL) title = html_lib.unescape(title_match.group(1)).strip()[:300] if title_match else "" text = _html_to_text(raw)[:MAX_TEXT_CHARS] return { "url": str(resp.url), "status": resp.status_code, "title": title, "text": text, "truncated": len(raw) > MAX_TEXT_CHARS, }