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
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 },
|
|
};
|
|
}
|
|
},
|
|
};
|
|
|