hermes-bot / scripts /patch_web_search_fallback.py
Z User
feat: add DuckDuckGo free fallback for web_search (no API key needed)
4036608
#!/usr/bin/env python3
"""
patch_web_search_fallback.py — DuckDuckGo 免费 fallback
当所有付费搜索后端(Firecrawl / Tavily / Exa / Parallel)都不可用时,
自动 fallback 到 DuckDuckGo 搜索,无需任何 API key。
Monkey-patch 目标(tools/web_tools.py):
- _get_backend() : 无可用后端时返回 "duckduckgo"
- web_search_tool() : 分发 duckduckgo 后端
- _is_backend_available(): duckduckgo 始终返回 True
"""
import json
import logging
logger = logging.getLogger(__name__)
def _ddg_search(query: str, limit: int = 5) -> dict:
"""通过 DuckDuckGo 搜索,返回标准格式结果。"""
try:
from duckduckgo_search import DDGS
results = []
with DDGS() as ddgs:
for i, r in enumerate(ddgs.text(query, max_results=limit)):
results.append({
"title": r.get("title", ""),
"url": r.get("href", ""),
"description": r.get("body", ""),
"position": i + 1,
})
return {"success": True, "data": {"web": results}}
except Exception as e:
logger.error("DuckDuckGo search failed: %s", e)
return {"success": False, "error": f"DuckDuckGo search failed: {e}"}
def patch():
"""Apply the DuckDuckGo fallback patch to web_tools."""
import tools.web_tools as wt
# ── 1. 让 _get_backend() 在无可用后端时返回 "duckduckgo" ──
_original_get_backend = wt._get_backend
def _get_backend_patched():
configured = (wt._load_web_config().get("backend") or "").lower().strip()
# 用户显式指定了某个后端 → 尊重配置(即使 key 可能无效)
if configured in ("parallel", "firecrawl", "tavily", "exa"):
return configured
# 按优先级检查哪个后端可用
for backend in ("firecrawl", "parallel", "tavily", "exa"):
if wt._is_backend_available(backend):
return backend
# 全都不可用 → DuckDuckGo 兜底
return "duckduckgo"
wt._get_backend = _get_backend_patched
# ── 2. 在 web_search_tool() 中添加 duckduckgo 分发 ──
_original_web_search = wt.web_search_tool
def web_search_patched(query: str, limit: int = 5) -> str:
backend = wt._get_backend()
if backend == "duckduckgo":
logger.info("DuckDuckGo search (free fallback): '%s' (limit: %d)", query, limit)
response_data = _ddg_search(query, limit)
return json.dumps(response_data, indent=2, ensure_ascii=False)
# 其他后端走原逻辑
return _original_web_search(query, limit)
wt.web_search_tool = web_search_patched
# ── 3. 让 _is_backend_available("duckduckgo") 始终返回 True ──
_original_is_backend_available = wt._is_backend_available
def _is_backend_available_patched(backend: str) -> bool:
if backend == "duckduckgo":
return True
return _original_is_backend_available(backend)
wt._is_backend_available = _is_backend_available_patched
# ── 4. 让 web_extract 也能在 duckduckgo 后端下工作 ──
# web_extract 不依赖搜索后端(它直接抓 URL),但如果 _get_backend
# 返回 "duckduckgo" 且 web_extract 内部也调用 _get_backend,需要兼容。
# 目前 web_extract 不调用 _get_backend,所以不需要 patch。
logger.info(
"Web search: DuckDuckGo free fallback enabled "
"(no API key required, auto-activates when paid backends unavailable)"
)
if __name__ == "__main__":
patch()