Files
metona-ai-desktop/electron/harness/tools/built-in/web-search.ts
T
thzxx 8718f5ac6f fix: SearXNG max_results 配置被 AI 参数覆盖
根因: ?? 空值回退链让 AI 传入的 max_results:10 直接生效, 配置面板的 30 被跳过

修复: 改用 Math.max() 让配置面板作为下限, AI 参数不得低于配置值

同时修复 fetch_top 的相同问题
2026-07-07 21:45:26 +08:00

601 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* web_search — 网络搜索工具
*
* 双模式搜索:SearXNG 元搜索 / 内置四引擎并行搜索
* 内置引擎:Bing + 百度 + 搜狗 + 360 搜索(Promise.allSettled 容错并发)
* 智能排序:引擎权重(50%) + 可达性(30%) + 摘要质量(20%)
* 自动抓取:对前 N 条结果调用 web_fetch 获取完整正文
*
* @see docs/Agent网络工具通用设计-v2.md — 第 2 章 web_search 搜索设计
*/
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import type { ConfigService } from '../../../services/config.service';
import {
searchCache,
normalizeUrl,
fetchWithTimeout,
buildSearXNGAuthHeaders,
logTool,
} from './network-utils';
import type { WebFetchTool } from './web-fetch';
// ===== 类型定义 =====
interface SearchResult {
title: string;
url: string;
snippet: string;
engine: string;
weight: number;
reachable?: boolean;
_score?: number;
_enhanced?: boolean;
}
interface SearXNGConfig {
enabled: boolean;
url: string;
engines: string;
language: string;
safesearch: number;
time_range: string;
max_results: number;
auth_key: string;
auth_type: string;
format: string;
fetch_count: number;
fetch_mode: string;
}
// ===== 引擎定义 =====
interface EngineDef {
name: string;
weight: number;
searchUrl: (query: string, timeRange?: string) => string;
parse: (html: string) => SearchResult[];
}
const ENGINES: EngineDef[] = [
{
name: 'bing',
weight: 90,
searchUrl: (q, tr) => {
const freshness = tr ? `&filters=ex1:"ez${tr === 'day' ? '1' : tr === 'week' ? '2' : tr === 'month' ? '3' : '4'}"` : '';
return `https://www.bing.com/search?q=${encodeURIComponent(q)}${freshness}&count=20`;
},
parse: parseBing,
},
{
name: '百度',
weight: 80,
searchUrl: (q) => `https://www.baidu.com/s?wd=${encodeURIComponent(q)}&rn=20`,
parse: parseBaidu,
},
{
name: '搜狗',
weight: 75,
searchUrl: (q) => `https://www.sogou.com/web?query=${encodeURIComponent(q)}&num=20`,
parse: parseSogou,
},
{
name: '360搜索',
weight: 75,
searchUrl: (q) => `https://www.so.com/s?q=${encodeURIComponent(q)}&pn=20`,
parse: parse360,
},
];
// ===== HTML 解析器(正则实现,后续可迁移至 cheerio) =====
function parseBing(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<li[^>]*class="b_algo"/i).slice(1);
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1];
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || block.match(/class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('bing.com')) {
results.push({ title, url, snippet, engine: 'bing', weight: 90 });
}
}
return results;
}
function parseBaidu(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<div[^>]*class="result[^"]*"/i).slice(1);
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*data-url="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i)
|| block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://${titleMatch[1]}`;
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/class="c-abstract[^"]*"[^>]*>([\s\S]*?)<\/span>/i)
|| block.match(/class="content-right[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('baidu.com/link')) {
results.push({ title, url, snippet, engine: '百度', weight: 80 });
}
}
return results;
}
function parseSogou(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<div[^>]*class="vrwrap"/i).slice(1)
.concat(html.split(/<div[^>]*class="rb"/i).slice(1));
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://www.sogou.com${titleMatch[1]}`;
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/class="star-wiki[^"]*"[^>]*>([\s\S]*?)<\/div>/i)
|| block.match(/class="space-txt[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|| block.match(/class="str_info[^"]*"[^>]*>([\s\S]*?)<\/p>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('sogou.com')) {
results.push({ title, url, snippet, engine: '搜狗', weight: 75 });
}
}
return results;
}
function parse360(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<li[^>]*class="res-list"/i).slice(1)
.concat(html.split(/<div[^>]*class="result"/i).slice(1));
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1];
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/class="res-desc[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|| block.match(/class="res-rich[^"]*"[^>]*>([\s\S]*?)<\/div>/i)
|| block.match(/class="res-summary[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|| block.match(/<dd[^>]*>([\s\S]*?)<\/dd>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('so.com')) {
results.push({ title, url, snippet, engine: '360搜索', weight: 75 });
}
}
return results;
}
// ===== 可达性预检 =====
async function checkReachability(urls: string[], concurrency = 5): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
for (let i = 0; i < urls.length; i += concurrency) {
const batch = urls.slice(i, i + concurrency);
const checks = batch.map(async (url) => {
try {
const resp = await fetchWithTimeout(url, { method: 'HEAD', redirect: 'follow' }, 3_000);
result.set(url, resp.ok);
} catch {
result.set(url, false);
}
});
await Promise.allSettled(checks);
}
return result;
}
// ===== 智能排序 =====
function smartSort(results: SearchResult[], reachabilityMap: Map<string, boolean>): SearchResult[] {
for (const r of results) {
const reachability = r.reachable ? 30 : -20;
const snippetQuality = Math.min(r.snippet.length, 100) / 100 * 20;
const weightScore = (r.weight / 100) * 50;
r._score = weightScore + reachability + snippetQuality;
}
return results.sort((a, b) => (b._score ?? 0) - (a._score ?? 0));
}
// ===== 相关性过滤(用于自动抓取) =====
function computeRelevance(query: string, result: SearchResult): number {
const terms: string[] = [];
// 中文双字/三字片段
const chinese = query.match(/[\u4e00-\u9fa5]{2,3}/g);
if (chinese) terms.push(...chinese);
// 英文单词
const english = query.match(/[a-zA-Z]{2,}/g);
if (english) terms.push(...english);
let score = 0;
const text = `${result.title} ${result.snippet}`.toLowerCase();
for (const term of terms) {
if (text.includes(term.toLowerCase())) score += 10;
}
return Math.min(score, 100);
}
// ===== WebSearchTool =====
export class WebSearchTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'web_search',
description: 'Search the web for information. Returns titles, snippets, and URLs. When SearXNG is enabled, uses the configured SearXNG instance; otherwise uses built-in engines (Bing, Baidu, Sogou, 360). Automatically fetches full content for top results.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query keywords' },
max_results: { type: 'number', description: 'Maximum results (default 30, max 30)' },
time_range: { type: 'string', description: 'Time filter: day, week, month, year (optional)' },
enhance_snippets: { type: 'boolean', description: 'Auto-enhance short snippets (default true)' },
fetch_top: { type: 'number', description: 'Auto-fetch full content for top N results (3-8, default 5)' },
},
required: ['query'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 300_000,
};
constructor(
private configService: ConfigService,
private webFetchTool: WebFetchTool,
) {}
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const query = args.query as string;
// 读取 SearXNG 配置(提前读取,使工具参数默认值与配置一致)
const searxngConfig = this.readSearXNGConfig();
// max_results: 配置面板为下限,AI 参数不得低于配置值,上限 30
const argMaxResults = (args.max_results as number) ?? undefined;
const cfgMaxResults = searxngConfig.max_results > 0 ? searxngConfig.max_results : undefined;
const maxResults = Math.min(30, Math.max(1, argMaxResults ?? 30, cfgMaxResults ?? 0));
const timeRange = (args.time_range as string) ?? '';
const enhanceSnippets = (args.enhance_snippets as boolean) ?? true;
// fetch_top: 配置面板为下限,AI 参数不得低于配置值,上限 8
const argFetchTop = (args.fetch_top as number) ?? undefined;
const cfgFetchCount = searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : undefined;
const fetchTop = Math.min(8, Math.max(3, argFetchTop ?? 5, cfgFetchCount ?? 0));
// 缓存检查(缓存 key 含 SearXNG 模式,避免切换后返回旧缓存)
const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${normalizeUrl(query).toLowerCase()}`;
const cached = searchCache.get(cacheKey);
if (cached) {
logTool('web_search', `Cache hit: "${query}"`);
return { ...(cached as Record<string, unknown>), from_cache: true };
}
let results: SearchResult[];
let mode: string;
let engineStats: Record<string, string>;
if (searxngConfig.enabled && searxngConfig.url) {
const searxResult = await this.searchSearXNG(query, searxngConfig, maxResults, timeRange);
results = searxResult.results;
mode = 'searxng';
engineStats = searxResult.engineStats;
} else {
const builtinResult = await this.searchBuiltinEngines(query, maxResults, timeRange);
results = builtinResult.results;
mode = 'builtin';
engineStats = builtinResult.engineStats;
}
// 合并去重
const deduped = this.deduplicate(results);
// 可达性预检
const topUrls = deduped.slice(0, Math.min(20, deduped.length)).map((r) => r.url);
const reachabilityMap = await checkReachability(topUrls);
for (const r of deduped) {
r.reachable = reachabilityMap.get(r.url) ?? false;
}
// 智能排序
const sorted = smartSort(deduped, reachabilityMap).slice(0, maxResults);
// 摘要增强
if (enhanceSnippets) {
await this.enhanceSnippets(sorted, 3);
}
// 自动抓取完整内容
const fetchedContent = await this.autoFetch(query, sorted, searxngConfig.fetch_mode, fetchTop);
// 格式化输出
const formatted = this.formatResults(query, sorted);
const output = {
success: true,
query,
results: sorted.map((r) => ({
title: r.title,
url: r.url,
snippet: r.snippet,
engine: r.engine,
reachable: r.reachable,
_score: r._score ? Math.round(r._score * 10) / 10 : undefined,
_enhanced: r._enhanced,
})),
total: sorted.length,
formatted,
from_cache: false,
engine_stats: engineStats,
_mode: mode,
_fetched: fetchedContent.map((f) => ({ url: f.url, title: f.title, content: f.content })),
_fetched_count: fetchedContent.length,
};
// 写入缓存
searchCache.set(cacheKey, output);
logTool('web_search', `Completed: ${sorted.length} results, ${fetchedContent.length} fetched, mode=${mode}`);
return output;
}
// ===== SearXNG 搜索 =====
private async searchSearXNG(
query: string,
config: SearXNGConfig,
maxResults: number,
timeRange: string,
): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> {
const params = new URLSearchParams();
params.set('q', query);
params.set('format', config.format || 'json');
if (config.engines) params.set('engines', config.engines);
if (config.language && config.language !== 'auto') params.set('language', config.language);
params.set('safesearch', String(config.safesearch));
const tr = timeRange || config.time_range;
if (tr) params.set('time_range', tr);
const limit = Math.max(maxResults, config.max_results);
if (limit > 0) params.set('results_count', String(limit));
const searchUrl = `${config.url.replace(/\/$/, '')}/search?${params.toString()}`;
const headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type);
logTool('web_search', `[SearXNG] Fetching: ${searchUrl}`);
const response = await fetchWithTimeout(searchUrl, { headers }, 15_000);
if (!response.ok) {
throw new Error(`SearXNG API error: ${response.status} ${response.statusText}`);
}
const results: SearchResult[] = [];
const engineStats: Record<string, string> = {};
const engineCounts = new Map<string, number>();
if (config.format === 'html') {
const html = await response.text();
// HTML 模式:简单提取链接和标题
const matches = html.matchAll(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi);
for (const m of matches) {
const url = m[1];
const title = m[2].replace(/<[^>]+>/g, '').trim();
if (title && url && !url.includes(config.url)) {
results.push({ title, url, snippet: '', engine: 'searxng', weight: 85 });
}
}
} else {
const data = await response.json() as { results?: Array<Record<string, unknown>> };
for (const item of data.results ?? []) {
const url = item.url as string;
const title = item.title as string;
const snippet = item.content as string;
const engine = (item.engine as string) ?? 'searxng';
results.push({ title, url, snippet: snippet ?? '', engine, weight: 85 });
engineCounts.set(engine, (engineCounts.get(engine) ?? 0) + 1);
}
}
for (const [engine, count] of engineCounts) {
engineStats[engine] = `${count} 条`;
}
return { results, engineStats };
}
// ===== 内置四引擎并行搜索 =====
private async searchBuiltinEngines(
query: string,
maxResults: number,
timeRange: string,
): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> {
const searchPromises = ENGINES.map(async (engine) => {
try {
const url = engine.searchUrl(query, timeRange);
const response = await fetchWithTimeout(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
},
}, 8_000);
if (!response.ok) {
logTool('web_search', `[内置] ${engine.name} HTTP ${response.status}`);
return [];
}
const html = await response.text();
const parsed = engine.parse(html);
logTool('web_search', `[内置] ${engine.name}: ${parsed.length} results`);
return parsed;
} catch (err) {
logTool('web_search', `[内置] ${engine.name} error: ${(err as Error).message}`);
return [];
}
});
const settled = await Promise.allSettled(searchPromises);
const allResults: SearchResult[] = [];
const engineStats: Record<string, string> = {};
for (let i = 0; i < ENGINES.length; i++) {
const result = settled[i];
const engineName = ENGINES[i].name;
if (result.status === 'fulfilled') {
allResults.push(...result.value);
engineStats[engineName] = `${result.value.length} 条`;
} else {
engineStats[engineName] = '失败';
}
}
return { results: allResults, engineStats };
}
// ===== 合并去重 =====
private deduplicate(results: SearchResult[]): SearchResult[] {
const seen = new Map<string, SearchResult>();
for (const r of results) {
const normalized = normalizeUrl(r.url);
if (!seen.has(normalized)) {
seen.set(normalized, r);
}
}
return Array.from(seen.values());
}
// ===== 摘要增强(委托给 WebFetchTool =====
private async enhanceSnippets(results: SearchResult[], maxEnhance: number): Promise<void> {
let enhanced = 0;
for (const r of results) {
if (enhanced >= maxEnhance) break;
if (r.snippet.length < 30 && r.reachable) {
try {
const fetchResult = await this.webFetchTool.execute(
{ url: r.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
) as { success: boolean; content?: string };
if (fetchResult.success && fetchResult.content) {
const text = fetchResult.content.slice(0, 200);
if (text.length > r.snippet.length) {
r.snippet = text;
r._enhanced = true;
enhanced++;
}
}
} catch {
// 忽略增强失败
}
}
}
}
// ===== 自动抓取完整内容(委托给 WebFetchTool =====
private async autoFetch(
query: string,
results: SearchResult[],
fetchMode: string,
fetchTop: number,
): Promise<Array<{ url: string; title: string; content: string }>> {
// 相关性评分(不过滤,relevance=0 的结果也参与抓取候选)
const withRelevance = results.map((r) => ({ result: r, relevance: computeRelevance(query, r) }));
const filtered = withRelevance.length > 0 ? withRelevance : [];
// 确定抓取数量:fetchTop 已在 execute() 中综合了配置面板和工具参数
const topN = Math.min(fetchTop, 8, filtered.length);
// 构建抓取列表
let toFetch: typeof filtered;
if (fetchMode === 'random') {
// Fisher-Yates 洗牌
const shuffled = [...filtered];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
toFetch = shuffled.slice(0, topN);
} else {
toFetch = filtered
.sort((a, b) => b.relevance - a.relevance)
.slice(0, topN);
}
const fetched: Array<{ url: string; title: string; content: string }> = [];
for (const item of toFetch) {
try {
// 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染)
const fetchResult = await this.webFetchTool.execute(
{ url: item.result.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
) as { success: boolean; content?: string; method?: string };
if (fetchResult.success && fetchResult.content) {
fetched.push({ url: item.result.url, title: item.result.title, content: fetchResult.content });
}
} catch (err) {
logTool('web_search', `Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`);
// 从剩余结果中随机补充
const remaining = withRelevance.filter((x) => !toFetch.includes(x) && !fetched.some((f) => f.url === x.result.url));
if (remaining.length > 0) {
const randomPick = remaining[Math.floor(Math.random() * remaining.length)];
try {
const fetchResult2 = await this.webFetchTool.execute(
{ url: randomPick.result.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
) as { success: boolean; content?: string };
if (fetchResult2.success && fetchResult2.content) {
fetched.push({ url: randomPick.result.url, title: randomPick.result.title, content: fetchResult2.content });
}
} catch {
// 忽略补充失败
}
}
}
}
return fetched;
}
// ===== 格式化人类可读输出 =====
private formatResults(query: string, results: SearchResult[]): string {
if (results.length === 0) return `搜索 "${query}" 无结果。`;
const lines = [`搜索 "${query}" — ${results.length} 条结果:\n`];
results.forEach((r, i) => {
lines.push(`${i + 1}. ${r.title}`);
lines.push(` URL: ${r.url}`);
if (r.snippet) lines.push(` 摘要: ${r.snippet.slice(0, 150)}`);
lines.push(` 来源: ${r.engine}${r.reachable === false ? ' (不可达)' : ''}${r._enhanced ? ' [已增强]' : ''}\n`);
});
return lines.join('\n');
}
// ===== 读取 SearXNG 配置 =====
private readSearXNGConfig(): SearXNGConfig {
const cs = this.configService;
return {
enabled: cs.get<boolean>('searxng.enabled') ?? false,
url: cs.get<string>('searxng.url') ?? '',
engines: cs.get<string>('searxng.engines') ?? '',
language: cs.get<string>('searxng.language') ?? 'zh-CN',
safesearch: cs.get<number>('searxng.safesearch') ?? 1,
time_range: cs.get<string>('searxng.time_range') ?? '',
max_results: cs.get<number>('searxng.max_results') ?? 0,
auth_key: cs.get<string>('searxng.auth_key') ?? '',
auth_type: cs.get<string>('searxng.auth_type') ?? 'bearer',
format: cs.get<string>('searxng.format') ?? 'json',
fetch_count: cs.get<number>('searxng.fetch_count') ?? 0,
fetch_mode: cs.get<string>('searxng.fetch_mode') ?? 'sequential',
};
}
}