import { z } from "zod"; import type { JSONSchema7 } from "json-schema"; import type { ToolExecutor, ToolContext, ToolResult } from "../../registry"; import type { SearchToolConfig } from "./config"; import { parseBingResults, parseBaiduResults, parseGoogleResults } from "./parse"; export const searchInputSchema = z.object({ query: z.string().min(1).max(500).describe("搜索关键词"), maxResults: z.number().int().positive().max(20).optional().describe("返回结果数量上限,不传则使用默认值"), engine: z .enum(["bing", "baidu", "google"]) .optional() .describe("搜索引擎,不传则使用配置默认值"), }); type SearchInput = z.infer; function buildSearchUrl( query: string, engine: "bing" | "baidu" | "google", language: string, region: string, ): string { const encoded = encodeURIComponent(query); switch (engine) { case "bing": return `https://www.bing.com/search?q=${encoded}&setlang=${language}&cc=${region}&count=30`; case "baidu": return `https://www.baidu.com/s?wd=${encoded}&rn=30`; case "google": return `https://www.google.com/search?q=${encoded}&hl=${language}&gl=${region}&num=30`; } } function getBrowserHeaders(engine: "bing" | "baidu" | "google"): Record { return { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate, br", ...(engine === "google" ? { "Cookie": "CONSENT=YES+cb.20240101-00-0;" } : {}), }; } export const searchExecutor: ToolExecutor = { buildInputSchema(_config: SearchToolConfig): JSONSchema7 { return { type: "object", properties: { query: { type: "string", description: "搜索关键词" }, maxResults: { type: "number", description: "返回结果数量上限" }, engine: { type: "string", enum: ["bing", "baidu", "google"], description: "搜索引擎", }, }, required: ["query"], }; }, buildDescription(config: SearchToolConfig): string { return `网络搜索工具。使用 ${config.engine} 搜索引擎获取实时网页结果。默认返回最多 ${config.maxResults} 条结果(标题+URL+摘要)。语言: ${config.language},地区: ${config.region}。`; }, async execute( input: unknown, config: SearchToolConfig, _ctx: ToolContext, ): Promise { const start = Date.now(); const parsed = searchInputSchema.safeParse(input); if (!parsed.success) { return { success: false, data: null, error: `输入参数校验失败: ${parsed.error.message}`, metadata: { durationMs: Date.now() - start }, }; } const inp = parsed.data as SearchInput; const engine = inp.engine ?? config.engine; const maxResults = inp.maxResults ?? config.maxResults; const searchUrl = buildSearchUrl(inp.query, engine, config.language, config.region); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout); try { const response = await fetch(searchUrl, { method: "GET", headers: getBrowserHeaders(engine), signal: controller.signal, redirect: "follow", }); clearTimeout(timeoutId); if (!response.ok) { return { success: false, data: null, error: `搜索引擎返回 HTTP ${response.status} ${response.statusText}`, metadata: { durationMs: Date.now() - start }, }; } const html = await response.text(); let parsedResults; switch (engine) { case "bing": parsedResults = parseBingResults(html, maxResults, config.snippetLength); break; case "baidu": parsedResults = parseBaiduResults(html, maxResults, config.snippetLength); break; case "google": parsedResults = parseGoogleResults(html, maxResults, config.snippetLength); break; } if (parsedResults.items.length === 0) { return { success: true, data: { query: inp.query, engine, items: [], message: "未找到搜索结果,可能搜索引擎返回了反爬页面或验证码。建议更换关键词或引擎重试。", }, metadata: { responseSize: html.length, durationMs: Date.now() - start, }, }; } return { success: true, data: { query: inp.query, engine, totalResults: parsedResults.items.length, items: parsedResults.items, }, metadata: { responseSize: html.length, durationMs: Date.now() - start, }, }; } catch (e) { clearTimeout(timeoutId); if (e instanceof Error && e.name === "AbortError") { return { success: false, data: null, error: `搜索请求超时 (${config.timeout}ms)`, metadata: { durationMs: Date.now() - start }, }; } return { success: false, data: null, error: e instanceof Error ? e.message : String(e), metadata: { durationMs: Date.now() - start }, }; } }, };