You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

173 lines
5.4 KiB

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<typeof searchInputSchema>;
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<string, string> {
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<SearchToolConfig> = {
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<ToolResult> {
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 },
};
}
},
};