2026-03-30 08:48:36 +00:00
|
|
|
|
"""
|
|
|
|
|
|
tools/web_search.py
|
|
|
|
|
|
网络搜索工具 —— 支持 mock / SerpAPI / Brave Search
|
|
|
|
|
|
配置通过 settings.tools['web_search'] 读取
|
|
|
|
|
|
"""
|
2026-02-28 08:21:35 +00:00
|
|
|
|
|
2026-03-30 08:48:36 +00:00
|
|
|
|
import json
|
2026-02-28 08:21:35 +00:00
|
|
|
|
import time
|
2026-03-30 08:48:36 +00:00
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
|
2026-03-09 05:37:29 +00:00
|
|
|
|
from config.settings import settings
|
2026-04-15 08:20:22 +00:00
|
|
|
|
from tools.base_tool import BaseTool
|
2026-03-30 08:48:36 +00:00
|
|
|
|
from utils.logger import get_logger
|
|
|
|
|
|
|
|
|
|
|
|
logger = get_logger("TOOL.WebSearch")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cfg(key: str, fallback=None):
|
|
|
|
|
|
return settings.tools['web_search'].get(key, fallback)
|
2026-02-28 08:21:35 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 08:48:36 +00:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class SearchResult:
|
|
|
|
|
|
title: str
|
|
|
|
|
|
url: str
|
|
|
|
|
|
snippet: str
|
|
|
|
|
|
rank: int = 0
|
2026-02-28 08:21:35 +00:00
|
|
|
|
|
2026-03-30 08:48:36 +00:00
|
|
|
|
def __str__(self) -> str:
|
|
|
|
|
|
return f"[{self.rank}] {self.title}\n {self.url}\n {self.snippet}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 08:20:22 +00:00
|
|
|
|
class Tool(BaseTool):
|
2026-02-28 08:21:35 +00:00
|
|
|
|
name = "web_search"
|
2026-03-30 08:48:36 +00:00
|
|
|
|
description = (
|
|
|
|
|
|
"在互联网上搜索信息,返回相关网页的标题、链接和摘要。"
|
|
|
|
|
|
"适用于需要实时信息、最新资讯或不确定的知识查询。"
|
|
|
|
|
|
)
|
2026-02-28 08:21:35 +00:00
|
|
|
|
parameters = {
|
2026-03-30 08:48:36 +00:00
|
|
|
|
"type": "object",
|
|
|
|
|
|
"properties": {
|
|
|
|
|
|
"query": {
|
|
|
|
|
|
"type": "string",
|
|
|
|
|
|
"description": "搜索关键词或问题,例如: 'Python 3.12 新特性'",
|
|
|
|
|
|
},
|
|
|
|
|
|
"max_results": {
|
|
|
|
|
|
"type": "integer",
|
|
|
|
|
|
"description": "返回结果数量(默认来自 config.yaml web_search.max_results)",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"required": ["query"],
|
2026-02-28 08:21:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 08:48:36 +00:00
|
|
|
|
def execute(self, query: str = "", max_results: int | None = None, **_) -> str:
|
|
|
|
|
|
if not query or not query.strip():
|
|
|
|
|
|
return "❌ 参数错误: query 不能为空"
|
2026-03-09 05:37:29 +00:00
|
|
|
|
|
2026-03-30 08:48:36 +00:00
|
|
|
|
n = max_results or _cfg('max_results', 5)
|
|
|
|
|
|
engine = _cfg('engine', 'mock')
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"🔍 搜索: {query}\n"
|
|
|
|
|
|
f" 引擎={engine} max_results={n} "
|
|
|
|
|
|
f"[config engine={_cfg('engine')} max_results={_cfg('max_results')}]"
|
2026-02-28 08:21:35 +00:00
|
|
|
|
)
|
2026-03-30 08:48:36 +00:00
|
|
|
|
|
|
|
|
|
|
match engine:
|
|
|
|
|
|
case "serpapi":
|
|
|
|
|
|
results = self._search_serpapi(query, n)
|
|
|
|
|
|
case "brave":
|
|
|
|
|
|
results = self._search_brave(query, n)
|
|
|
|
|
|
case _:
|
|
|
|
|
|
results = self._search_mock(query, n)
|
|
|
|
|
|
|
|
|
|
|
|
if not results:
|
|
|
|
|
|
return f"🔍 搜索 '{query}' 未找到相关结果"
|
|
|
|
|
|
|
|
|
|
|
|
lines = [f"🔍 搜索结果: {query} (共 {len(results)} 条)", "─" * 50]
|
|
|
|
|
|
for r in results:
|
|
|
|
|
|
lines.append(str(r))
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
# ── 搜索引擎实现 ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _search_mock(query: str, n: int) -> list[SearchResult]:
|
|
|
|
|
|
"""Mock 搜索(无需 API Key,用于测试)"""
|
|
|
|
|
|
return [
|
|
|
|
|
|
SearchResult(
|
|
|
|
|
|
title=f"搜索结果 {i + 1}: {query}",
|
|
|
|
|
|
url=f"https://example.com/result/{i + 1}",
|
|
|
|
|
|
snippet=(
|
|
|
|
|
|
f"这是关于 '{query}' 的第 {i + 1} 条模拟搜索结果。"
|
|
|
|
|
|
f"实际使用请在 config.yaml 中配置 engine: serpapi 或 brave。"
|
|
|
|
|
|
),
|
|
|
|
|
|
rank=i + 1,
|
|
|
|
|
|
)
|
|
|
|
|
|
for i in range(n)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def _search_serpapi(self, query: str, n: int) -> list[SearchResult]:
|
|
|
|
|
|
"""SerpAPI 搜索"""
|
|
|
|
|
|
api_key = _cfg('api_key', '')
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
logger.warning("⚠️ SerpAPI api_key 未配置,回退到 mock 模式")
|
|
|
|
|
|
return self._search_mock(query, n)
|
|
|
|
|
|
try:
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
timeout = _cfg('timeout', 10)
|
|
|
|
|
|
resp = httpx.get(
|
|
|
|
|
|
"https://serpapi.com/search",
|
|
|
|
|
|
params={
|
|
|
|
|
|
"q": query,
|
|
|
|
|
|
"num": n,
|
|
|
|
|
|
"api_key": api_key,
|
|
|
|
|
|
"engine": "google",
|
|
|
|
|
|
},
|
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
data = resp.json()
|
|
|
|
|
|
organic = data.get("organic_results", [])
|
|
|
|
|
|
return [
|
|
|
|
|
|
SearchResult(
|
|
|
|
|
|
title=r.get("title", ""),
|
|
|
|
|
|
url=r.get("link", ""),
|
|
|
|
|
|
snippet=r.get("snippet", ""),
|
|
|
|
|
|
rank=i + 1,
|
|
|
|
|
|
)
|
|
|
|
|
|
for i, r in enumerate(organic[:n])
|
|
|
|
|
|
]
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ SerpAPI 搜索失败: {e},回退到 mock")
|
|
|
|
|
|
return self._search_mock(query, n)
|
|
|
|
|
|
|
|
|
|
|
|
def _search_brave(self, query: str, n: int) -> list[SearchResult]:
|
|
|
|
|
|
"""Brave Search API"""
|
|
|
|
|
|
api_key = _cfg('api_key', '')
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
logger.warning("⚠️ Brave Search api_key 未配置,回退到 mock 模式")
|
|
|
|
|
|
return self._search_mock(query, n)
|
|
|
|
|
|
try:
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
timeout = _cfg('timeout', 10)
|
|
|
|
|
|
resp = httpx.get(
|
|
|
|
|
|
"https://api.search.brave.com/res/v1/web/search",
|
|
|
|
|
|
params={"q": query, "count": n},
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
|
"Accept-Encoding": "gzip",
|
|
|
|
|
|
"X-Subscription-Token": api_key,
|
|
|
|
|
|
},
|
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
data = resp.json()
|
|
|
|
|
|
web = data.get("web", {}).get("results", [])
|
|
|
|
|
|
return [
|
|
|
|
|
|
SearchResult(
|
|
|
|
|
|
title=r.get("title", ""),
|
|
|
|
|
|
url=r.get("url", ""),
|
|
|
|
|
|
snippet=r.get("description", ""),
|
|
|
|
|
|
rank=i + 1,
|
|
|
|
|
|
)
|
|
|
|
|
|
for i, r in enumerate(web[:n])
|
|
|
|
|
|
]
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Brave Search 失败: {e},回退到 mock")
|
|
|
|
|
|
return self._search_mock(query, n)
|