#!/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()