"""MC2: Webseiten lokal lesen — Anbieter für Hermes' Werkzeug web_extract (Box-Wart, 09/2026). Warum: Die Box hat als Web-Anbieter nur DuckDuckGo (ddgs). Der kann suchen, aber keine Seiten lesen. Hermes bot web_extract trotzdem an (die Prüfung fragt nur „gibt es irgendeinen Web-Anbieter?“), jeder Aufruf scheiterte mit „ddgs is a search-only backend“, und der Nachrichten-Job warf deshalb jeden Morgen einen Werkzeugfehler. Dieser Anbieter liest Seiten selbst: httpx lädt, trafilatura zieht den Haupttext heraus — dasselbe wie MC2s MCP-Werkzeug fetch_url. Kein fremder Dienst, kein Schlüssel, die Box bleibt lokal. Hermes-Quellcode bleibt unberührt: das hier ist ein Plugin über die offizielle Anbieter-Schnittstelle. Einbau (deploy.sh kopiert den Ordner nach ~/.hermes/plugins/, der Rest einmalig): hermes plugins enable mc2-web-lesen hermes config set web.extract_backend mc2-lesen systemctl --user restart hermes-gateway Rückweg: web.extract_backend wieder auf ddgs setzen und `hermes plugins disable mc2-web-lesen`. """ from __future__ import annotations import re from typing import Any import httpx from agent.web_search_provider import WebSearchProvider KOPFZEILEN = { "User-Agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36"), "Accept-Language": "de,en;q=0.8", } ZEITLIMIT_S = 20 MAX_ZEICHEN = 3_000_000 # rohes HTML; Hermes kürzt das Ergebnis danach selbst (web.extract_char_limit) def _fehler(url: str, grund: str) -> dict: return {"url": url, "title": "", "content": "", "error": grund} def _titel(html: str) -> str: m = re.search(r"]*>(.*?)", html, re.IGNORECASE | re.DOTALL) return re.sub(r"\s+", " ", m.group(1)).strip()[:200] if m else "" def haupttext(html: str) -> str: """Haupttext einer Seite: trafilatura, sonst grob ohne Skripte, Stile und Tags.""" try: import trafilatura text = trafilatura.extract(html, include_comments=False, include_tables=True, no_fallback=False) or "" except Exception: # trafilatura fehlt oder stolpert über die Seite → grober Rückfall text = "" if not text: ohne = re.sub(r"<(script|style|noscript)[^>]*>.*?", " ", html, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", ohne)).strip() return text async def lies(client: httpx.AsyncClient, url: str) -> dict: try: antwort = await client.get(url) antwort.raise_for_status() except httpx.TimeoutException: return _fehler(url, f"Zeitüberschreitung nach {ZEITLIMIT_S} s") except httpx.HTTPStatusError as e: return _fehler(url, f"HTTP {e.response.status_code}") except httpx.HTTPError as e: return _fehler(url, f"Seite nicht erreichbar: {e}") art = antwort.headers.get("content-type", "").lower() if art and not any(t in art for t in ("html", "xml", "text/")): return _fehler(url, f"Kein Text-Inhalt ({art.split(';')[0]}), lokal nicht lesbar") html = antwort.text[:MAX_ZEICHEN] text = haupttext(html) if not text: return _fehler(url, "Kein Text auf der Seite gefunden") return {"url": url, "title": _titel(html), "content": text, "raw_content": text, "metadata": {"quelle": "mc2-lesen", "endadresse": str(antwort.url)}} class LokalLesen(WebSearchProvider): """Nur Lesen (extract), keine Suche — die Suche bleibt bei ddgs (web.search_backend).""" @property def name(self) -> str: return "mc2-lesen" @property def display_name(self) -> str: return "MC2 · Seiten lokal lesen" def is_available(self) -> bool: return True def supports_search(self) -> bool: return False def supports_extract(self) -> bool: return True async def extract(self, urls: list[str], **kwargs: Any) -> list[dict]: async with httpx.AsyncClient(headers=KOPFZEILEN, timeout=ZEITLIMIT_S, follow_redirects=True) as client: return [await lies(client, u) for u in urls] def register(ctx) -> None: ctx.register_web_search_provider(LokalLesen())