Files
metona-ai-desktop/electron/harness/tools/built-in/web-search.ts
T
thzxx 49c9b25538
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
2026-08-21 13:58:48 +08:00

819 lines
29 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 获取完整正文
*
* v0.4.1: HTML 解析迁移至 node-html-parser(结构化解析)
* 主层使用 DOM 结构解析(引擎改版时选择器更精确、可维护性远优于正则),
* 正则解析保留为降级路径(结构化解析无结果时兜底)。
* 此前纯正则方案违反项目开发规范第一铁律(HTML 解析应使用成熟库)。
*
* @see docs/Agent网络工具通用设计-v2.md — 第 2 章 web_search 搜索设计
*/
import { parse as parseHtmlDom, type HTMLElement } from 'node-html-parser';
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 解析器(v0.4.1: node-html-parser 结构化解析为主层,正则为降级层) =====
/**
* v0.4.1: 从结果块中提取标题链接 — 跳过指向搜索引擎自身域名的链接(favicon/子导航等)
*/
function extractTitleLink(
block: HTMLElement,
selfDomain: string,
): { url: string; title: string } | null {
for (const a of block.querySelectorAll('a[href]')) {
const url = a.getAttribute('href') ?? '';
const title = a.text.trim();
if (title && url && !url.includes(selfDomain) && url.startsWith('http')) {
return { url, title };
}
}
return null;
}
/** v0.4.1: 提取第一个非空文本的选择器(按优先级尝试多个候选选择器) */
function extractText(block: HTMLElement, selectors: string[]): string {
for (const sel of selectors) {
const el = block.querySelector(sel);
if (el) {
const text = el.text.trim();
if (text) return text;
}
}
return '';
}
/** v0.4.1: Bing 结构化解析 — li.b_algo 结果块 */
function parseBingStructured(html: string): SearchResult[] {
const results: SearchResult[] = [];
const root = parseHtmlDom(html);
for (const block of root.querySelectorAll('li.b_algo')) {
const link = extractTitleLink(block, 'bing.com');
if (!link) continue;
const snippet = extractText(block, ['p', '.b_caption']);
results.push({ title: link.title, url: link.url, snippet, engine: 'bing', weight: 90 });
}
return results;
}
/** v0.4.1: 百度结构化解析 — div.result / div.c-container 结果块,优先 a[data-url] 真实链接 */
function parseBaiduStructured(html: string): SearchResult[] {
const results: SearchResult[] = [];
const root = parseHtmlDom(html);
// 复合选择器去重:class="result c-container" 的元素同时命中两个类名,
// 分别查询再拼接会重复收录同一结果块
const blocks = root.querySelectorAll('div.result, div.c-container');
for (const block of blocks) {
// 百度标题链接: 优先 data-url 属性(真实目标 URL),href 通常是 baidu.com/link 跳转
const dataUrlLink = block.querySelector('a[data-url]');
let url = dataUrlLink?.getAttribute('data-url') ?? '';
let title = dataUrlLink?.text.trim() ?? '';
if (!url || !title) {
const fallback = block.querySelector('h3 a[href]') ?? block.querySelector('a[href]');
if (fallback) {
const href = fallback.getAttribute('href') ?? '';
url = href.startsWith('http') ? href : href ? `https://${href}` : '';
title = fallback.text.trim();
}
}
const snippet = extractText(block, ['.c-abstract', '[class^="content-right"]']);
if (title && url && !url.includes('baidu.com/link')) {
results.push({ title, url, snippet, engine: '百度', weight: 80 });
}
}
return results;
}
/**
* v0.4.1: 搜狗结构化解析 — div.vrwrap / div.rb 结果块(相对链接补全 sogou.com 前缀)
*
* v0.4.1 修复(原正则实现遗留缺陷): 搜狗结果链接是 sogou.com/link?url=... 跳转形式,
* 原 `!url.includes('sogou.com')` 过滤条件把所有跳转结果一并丢弃(相对链接补全后必含 sogou.com),
* 导致搜狗引擎基本无法返回结果。现仅过滤 sogou 自身页面链接,保留 /link 跳转结果
* (可达性预检会跟随重定向验证)。
*/
function parseSogouStructured(html: string): SearchResult[] {
const results: SearchResult[] = [];
const root = parseHtmlDom(html);
// 复合选择器避免同一元素命中两个类名时重复收录
const blocks = root.querySelectorAll('div.vrwrap, div.rb');
for (const block of blocks) {
const a = block.querySelector('h3 a[href]') ?? block.querySelector('a[href]');
if (!a) continue;
const href = a.getAttribute('href') ?? '';
const url = href.startsWith('http') ? href : `https://www.sogou.com${href}`;
const title = a.text.trim();
const snippet = extractText(block, ['.star-wiki', '.space-txt', '.str_info']);
// 过滤搜狗自身页面(保留 /link 跳转结果)
const isSelfPage = url.includes('sogou.com') && !url.includes('/link');
if (title && url && !isSelfPage) {
results.push({ title, url, snippet, engine: '搜狗', weight: 75 });
}
}
return results;
}
/** v0.4.1: 360 结构化解析 — li.res-list / div.result 结果块 */
function parse360Structured(html: string): SearchResult[] {
const results: SearchResult[] = [];
const root = parseHtmlDom(html);
// 复合选择器避免同一元素命中多个类名时重复收录
const blocks = root.querySelectorAll('li.res-list, div.result');
for (const block of blocks) {
const link = extractTitleLink(block, 'so.com');
if (!link) continue;
const snippet = extractText(block, ['.res-desc', '.res-rich', '.res-summary', 'dd']);
results.push({ title: link.title, url: link.url, snippet, engine: '360搜索', weight: 75 });
}
return results;
}
/** v0.4.1: 结构化解析 + 正则降级的组合入口(供 ENGINES 引用,测试导出) */
export function parseBing(html: string): SearchResult[] {
const structured = parseBingStructured(html);
if (structured.length > 0) return structured;
return parseBingRegex(html);
}
export function parseBaidu(html: string): SearchResult[] {
const structured = parseBaiduStructured(html);
if (structured.length > 0) return structured;
return parseBaiduRegex(html);
}
export function parseSogou(html: string): SearchResult[] {
const structured = parseSogouStructured(html);
if (structured.length > 0) return structured;
return parseSogouRegex(html);
}
export function parse360(html: string): SearchResult[] {
const structured = parse360Structured(html);
if (structured.length > 0) return structured;
return parse360Regex(html);
}
// ===== 正则降级解析器(v0.4.1 前的主实现,结构化解析无结果时兜底) =====
function parseBingRegex(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 parseBaiduRegex(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 parseSogouRegex(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 parse360Regex(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[]): 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' },
time_range: {
type: 'string',
description: 'Time filter: day, week, month, year (optional)',
},
enhance_snippets: {
type: 'boolean',
description: 'Auto-enhance short snippets (default true)',
},
},
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();
const useSearXNG = searxngConfig.enabled && !!searxngConfig.url;
// 两个模式参数完全独立:
// - 内置模式:固定默认值 maxResults=30, fetchTop=5
// - SearXNG 模式:由配置面板控制
const maxResults = useSearXNG
? Math.min(50, Math.max(1, searxngConfig.max_results > 0 ? searxngConfig.max_results : 30))
: 30;
const timeRange = (args.time_range as string) ?? '';
const enhanceSnippets = (args.enhance_snippets as boolean) ?? true;
const fetchTop = useSearXNG
? Math.min(8, Math.max(3, searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : 5))
: 5;
logTool(
'web_search',
`Mode=${useSearXNG ? 'searxng' : 'builtin'}, maxResults=${maxResults}, fetchTop=${fetchTop}`,
);
// 缓存检查(key 含模式 + maxResults + fetchTop,避免配置变更后返回旧缓存)
const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${maxResults}:${fetchTop}:${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 (useSearXNG) {
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).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 results: SearchResult[] = [];
const engineStats: Record<string, string> = {};
const engineCounts = new Map<string, number>();
const baseUrl = config.url.replace(/\/$/, '');
const headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type);
const tr = timeRange || config.time_range;
// SearXNG 标准分页:每页由实例配置决定(通常 10 条),用 pageno 翻页直到达到 maxResults
const maxPages = Math.ceil(maxResults / 5) + 1; // 保守估计,每页至少 5 条
let page = 1;
while (results.length < maxResults && page <= maxPages) {
const params = new URLSearchParams();
params.set('q', query);
params.set('format', config.format || 'json');
params.set('pageno', String(page));
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));
if (tr) params.set('time_range', tr);
const searchUrl = `${baseUrl}/search?${params.toString()}`;
logTool('web_search', `[SearXNG] Fetching page ${page}: ${searchUrl}`);
const pageResults: SearchResult[] = [];
try {
const response = await fetchWithTimeout(searchUrl, { headers }, 15_000);
if (!response.ok) {
throw new Error(`SearXNG API error: ${response.status} ${response.statusText}`);
}
if (config.format === 'html') {
const html = await response.text();
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)) {
pageResults.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';
pageResults.push({ title, url, snippet: snippet ?? '', engine, weight: 85 });
engineCounts.set(engine, (engineCounts.get(engine) ?? 0) + 1);
}
}
} catch (err) {
logTool('web_search', `[SearXNG] Page ${page} error: ${(err as Error).message}`);
// 第一页就失败则抛出,后续页失败则用已获取的结果
if (page === 1) throw err;
break;
}
// 本页无新结果 → 已到末尾
if (pageResults.length === 0) {
logTool('web_search', `[SearXNG] Page ${page} returned 0 results, stopping pagination`);
break;
}
results.push(...pageResults);
logTool(
'web_search',
`[SearXNG] Page ${page}: +${pageResults.length} (total ${results.length})`,
);
page++;
}
for (const [engine, count] of engineCounts) {
engineStats[engine] = `${count} 条`;
}
logTool('web_search', `[SearXNG] Total: ${results.length} raw results (target ${maxResults})`);
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 =====
/**
* P2-12: 自动抓取改为并行(批次并发 3)
*
* 原实现逐条串行抓取(单个 web_fetch 最长 120s 超时),top5 结果最坏耗时
* 逼近 web_search 的 300s 工具超时上限。并行批次化后总耗时约降至 1/3。
* 失败结果直接跳过(原"随机补充重试"逻辑收益边际,复杂度高,已移除)。
*/
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 }> = [];
const fetchOne = async (item: {
result: SearchResult;
}): Promise<{ url: string; title: string; content: string } | null> => {
try {
// 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染)
const fetchResult = (await this.webFetchTool.execute(
{ url: item.result.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
)) as { success: boolean; content?: string };
if (fetchResult.success && fetchResult.content) {
return { url: item.result.url, title: item.result.title, content: fetchResult.content };
}
return null;
} catch (err) {
logTool(
'web_search',
`Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`,
);
return null;
}
};
// 并行批次抓取(并发 3
const CONCURRENCY = 3;
for (let i = 0; i < toFetch.length; i += CONCURRENCY) {
const batch = toFetch.slice(i, i + CONCURRENCY);
const settled = await Promise.allSettled(batch.map((item) => fetchOne(item)));
for (const r of settled) {
if (r.status === 'fulfilled' && r.value) fetched.push(r.value);
}
}
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',
};
}
}