diff --git a/plugins/web/ddgs/__init__.py b/plugins/web/ddgs/__init__.py new file mode 100644 index 00000000000..26eb6407ef8 --- /dev/null +++ b/plugins/web/ddgs/__init__.py @@ -0,0 +1,15 @@ +"""DuckDuckGo search plugin — bundled, auto-loaded. + +Backed by the community ``ddgs`` Python package which scrapes DDG's HTML +results page. No API key required, but the package itself must be installed +(it's an optional dep — gated via :meth:`is_available`). +""" + +from __future__ import annotations + +from plugins.web.ddgs.provider import DDGSWebSearchProvider + + +def register(ctx) -> None: + """Register the DDGS provider with the plugin context.""" + ctx.register_web_search_provider(DDGSWebSearchProvider()) diff --git a/plugins/web/ddgs/plugin.yaml b/plugins/web/ddgs/plugin.yaml new file mode 100644 index 00000000000..e85236c14cf --- /dev/null +++ b/plugins/web/ddgs/plugin.yaml @@ -0,0 +1,7 @@ +name: web-ddgs +version: 1.0.0 +description: "DuckDuckGo web search via the ddgs Python package — no API key required. Install with `pip install ddgs`." +author: NousResearch +kind: backend +provides_web_providers: + - ddgs diff --git a/plugins/web/ddgs/provider.py b/plugins/web/ddgs/provider.py new file mode 100644 index 00000000000..eefd98d51f6 --- /dev/null +++ b/plugins/web/ddgs/provider.py @@ -0,0 +1,100 @@ +"""DuckDuckGo search — plugin form (via the ``ddgs`` package). + +Subclasses the plugin-facing :class:`agent.web_search_provider.WebSearchProvider`. +Same behavior as the legacy ``tools.web_providers.ddgs`` module — only the +ABC name and import path change. + +The ``ddgs`` package is an optional dependency. ``is_available()`` reflects +whether the package is importable; the plugin still registers either way so +``hermes tools`` can prompt the user to install it. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class DDGSWebSearchProvider(WebSearchProvider): + """DuckDuckGo HTML-scrape search provider. + + No API key needed. Rate limits are enforced server-side by DuckDuckGo; + the provider surfaces ``DuckDuckGoSearchException`` and other ddgs errors + as ``{"success": False, "error": ...}`` rather than raising. + """ + + @property + def name(self) -> str: + return "ddgs" + + @property + def display_name(self) -> str: + return "DuckDuckGo (ddgs)" + + def is_available(self) -> bool: + """Return True when the ``ddgs`` package is importable. + + Probes the import once; cheap because Python caches the import. Must + NOT perform network I/O — runs at tool-registration time and on every + ``hermes tools`` paint. + """ + try: + import ddgs # noqa: F401 + + return True + except ImportError: + return False + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a DuckDuckGo search and return normalized results.""" + try: + from ddgs import DDGS # type: ignore + except ImportError: + return { + "success": False, + "error": "ddgs package is not installed — run `pip install ddgs`", + } + + # DDGS().text yields at most `max_results` items; we cap defensively + # in case the package ignores the hint. + safe_limit = max(1, int(limit)) + + try: + web_results = [] + with DDGS() as client: + for i, hit in enumerate(client.text(query, max_results=safe_limit)): + if i >= safe_limit: + break + url = str(hit.get("href") or hit.get("url") or "") + web_results.append( + { + "title": str(hit.get("title", "")), + "url": url, + "description": str(hit.get("body", "")), + "position": i + 1, + } + ) + except Exception as exc: # noqa: BLE001 — ddgs raises its own exceptions + logger.warning("DDGS search error: %s", exc) + return {"success": False, "error": f"DuckDuckGo search failed: {exc}"} + + logger.info("DDGS search '%s': %d results (limit %d)", query, len(web_results), limit) + return {"success": True, "data": {"web": web_results}} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "DuckDuckGo (ddgs)", + "badge": "free", + "tag": "No API key — community ddgs package (pip install ddgs).", + "env_vars": [], + }