@darlingc/dsh-freesearch 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +517 -0
- package/cordis.patch.yml +21 -0
- package/lib/client.js +805 -0
- package/lib/index.js +2103 -0
- package/package.json +72 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2103 @@
|
|
|
1
|
+
import { SettingsConflictError, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { exec } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const DDG_HTML_URL = "https://html.duckduckgo.com/html/";
|
|
9
|
+
const DDG_LITE_URL = "https://lite.duckduckgo.com/lite/";
|
|
10
|
+
const BING_URL = "https://www.bing.com/search";
|
|
11
|
+
const TAVILY_URL = "https://api.tavily.com/search";
|
|
12
|
+
const KEENABLE_URL = "https://api.keenable.ai/v1/search";
|
|
13
|
+
const KEENABLE_MCP_URL = "https://api.keenable.ai/mcp";
|
|
14
|
+
const USER_AGENT =
|
|
15
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
|
16
|
+
const ACCEPT_LANG = "zh-CN,zh;q=0.9,en;q=0.8";
|
|
17
|
+
|
|
18
|
+
const FREE_SEARCH_NS = settingsNamespace("free-search");
|
|
19
|
+
const BRIDGE_PREFIX = "/api/dsh-free-search-settings";
|
|
20
|
+
const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch"];
|
|
21
|
+
const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "perplexity", "deepseek-official", "gemini"];
|
|
22
|
+
|
|
23
|
+
// Gemini (Google search grounding) 引擎。
|
|
24
|
+
// 走 Grounding with Google Search:generateContent + tools:[{google_search:{}}]。
|
|
25
|
+
// groundingChunks[].web 只有 {uri,title}(无 snippet/publishedAt),snippet 用
|
|
26
|
+
// groundingSupports[].segment.text 按 groundingChunkIndices 拼接。
|
|
27
|
+
const GEMINI_MODEL = "gemini-2.5-flash";
|
|
28
|
+
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
|
29
|
+
|
|
30
|
+
// 当前插件版本(发布时与 package.json 同步)
|
|
31
|
+
const PLUGIN_VERSION = "0.5.0";
|
|
32
|
+
// 检查更新的 npm registry 元数据地址(@darlingc/dsh-freesearch 是 npmjs 上的公开包)
|
|
33
|
+
const NPM_REGISTRY_URL = "https://registry.npmjs.org/@darlingc%2Fdsh-freesearch/latest";
|
|
34
|
+
const PLUGIN_NPM_URL = "https://www.npmjs.com/package/@darlingc/dsh-freesearch";
|
|
35
|
+
const PLUGIN_REPO_URL = "https://github.com/DarlingC/dsh-freesearch";
|
|
36
|
+
|
|
37
|
+
// 查询 npm registry 的最新版本;失败时返回 null(网络/代理问题不阻塞设置页)
|
|
38
|
+
async function fetchLatestVersion(signal) {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timer = setTimeout(() => controller.abort(), 10000);
|
|
41
|
+
const onAbort = () => controller.abort();
|
|
42
|
+
signal?.addEventListener("abort", onAbort);
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(NPM_REGISTRY_URL, {
|
|
45
|
+
headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) return null;
|
|
49
|
+
const data = await response.json();
|
|
50
|
+
return typeof data.version === "string" ? data.version : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
signal?.removeEventListener("abort", onAbort);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 简单的 semver 比较(仅处理 x.y.z 主/次/补丁,忽略预发布标签);a>b 返回 1, a<b 返回 -1, 相等返回 0
|
|
60
|
+
function compareVersions(a, b) {
|
|
61
|
+
const na = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
62
|
+
const nb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
63
|
+
for (let i = 0; i < 3; i++) {
|
|
64
|
+
if ((na[i] ?? 0) > (nb[i] ?? 0)) return 1;
|
|
65
|
+
if ((na[i] ?? 0) < (nb[i] ?? 0)) return -1;
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 检测插件的安装模式:遍历 profiles/*/node_modules/dsh-free-search,
|
|
71
|
+
// symlink(link: 本地开发)→ isLink=true;npm 真安装 → isLink=false;找不到 → null
|
|
72
|
+
function detectInstallMode() {
|
|
73
|
+
const profilesDir = path.join(process.cwd(), "profiles");
|
|
74
|
+
let found = null;
|
|
75
|
+
try {
|
|
76
|
+
for (const name of fs.readdirSync(profilesDir)) {
|
|
77
|
+
const pkgPath = path.join(profilesDir, name, "node_modules", "dsh-free-search");
|
|
78
|
+
if (!fs.existsSync(pkgPath)) continue;
|
|
79
|
+
let isLink = false;
|
|
80
|
+
try {
|
|
81
|
+
isLink = fs.lstatSync(pkgPath).isSymbolicLink();
|
|
82
|
+
} catch {}
|
|
83
|
+
found = { profileDir: path.join(profilesDir, name), isLink };
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// time_range 支持:固定档 day/week/month/year,或自定义(相对 12h/3d/2mo/1y、绝对 YYYY-MM-DD)
|
|
91
|
+
const TIME_RANGES = ["day", "week", "month", "year"];
|
|
92
|
+
const DAYS_BY_RANGE = { day: 1, week: 7, month: 30, year: 365 };
|
|
93
|
+
const KEENABLE_REL = { day: "1d", week: "7d", month: "1mo", year: "1y" };
|
|
94
|
+
const SEARXNG_TIME = { day: "day", week: "week", month: "month", year: "year" };
|
|
95
|
+
|
|
96
|
+
function isoDaysAgo(days) {
|
|
97
|
+
return new Date(Date.now() - days * 86_400_000).toISOString().replace(/\.\d{3}Z$/, ".000Z");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 把用户/agent 给的 timeRange 解析成统一对象:{ days } 相对天数,或 { after } 绝对日期。
|
|
101
|
+
// 输入支持:day/week/month/year、12h/3d/2mo/1y、2026-07-01,或已解析的 {days}/{after} 对象。
|
|
102
|
+
// 无效返回 undefined。
|
|
103
|
+
function parseTimeRange(input) {
|
|
104
|
+
if (input === undefined || input === null) return undefined;
|
|
105
|
+
// 已解析对象:直接透传
|
|
106
|
+
if (typeof input === "object") {
|
|
107
|
+
if (typeof input.after === "string" && /^\d{4}-\d{2}-\d{2}$/.test(input.after)) return { after: input.after };
|
|
108
|
+
if (typeof input.days === "number" && Number.isFinite(input.days) && input.days > 0) return { days: input.days };
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const s = String(input).trim().toLowerCase();
|
|
112
|
+
if (s.length === 0) return undefined;
|
|
113
|
+
if (TIME_RANGES.includes(s)) return { days: DAYS_BY_RANGE[s] };
|
|
114
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return { after: s };
|
|
115
|
+
const m = s.match(/^(\d+(?:\.\d+)?)\s*(h|hour|hours|d|day|days|w|week|weeks|mo|month|months|y|year|years)$/);
|
|
116
|
+
if (m) {
|
|
117
|
+
const n = parseFloat(m[1]);
|
|
118
|
+
const unit = m[2][0];
|
|
119
|
+
const days =
|
|
120
|
+
unit === "h" ? n / 24 : unit === "d" ? n : unit === "w" ? n * 7 : unit === "m" ? n * 30 : n * 365;
|
|
121
|
+
return { days };
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 把自定义天数映射到只支持固定档的引擎(Tavily / SearXNG / DDG)的最近似档位
|
|
127
|
+
function approximateTimeRange(days) {
|
|
128
|
+
if (days <= 2) return "day";
|
|
129
|
+
if (days <= 14) return "week";
|
|
130
|
+
if (days <= 90) return "month";
|
|
131
|
+
return "year";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function decodeEntities(text) {
|
|
135
|
+
return String(text)
|
|
136
|
+
.replace(/&/g, "&")
|
|
137
|
+
.replace(/</g, "<")
|
|
138
|
+
.replace(/>/g, ">")
|
|
139
|
+
.replace(/"/g, '"')
|
|
140
|
+
.replace(/'/g, "'")
|
|
141
|
+
.replace(/'/g, "'")
|
|
142
|
+
.replace(/ /g, " ")
|
|
143
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
//#region 结果缓存(防限流/省额度,LRU 50 条,TTL 可配置 0-5 分钟)
|
|
147
|
+
const CACHE_MAX_ENTRIES = 50;
|
|
148
|
+
// fallback 条目(实际引擎 ≠ 首选引擎时)TTL = 配置 TTL 的 1/5(默认 5 分钟 → 60s):
|
|
149
|
+
// 首选引擎恢复后最多 1 分钟即可拿到新结果,避免回退结果被完整 TTL 钉死;首选成功条目仍用完整 TTL。
|
|
150
|
+
|
|
151
|
+
function buildCacheKey(query, maxResults, timeRangeLabel, preferred) {
|
|
152
|
+
return [query ?? "", maxResults ?? 5, timeRangeLabel ?? "", preferred].join("\u0000");
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
|
|
156
|
+
// 统一的 snippet 清洗:剔除登录/付费墙/订阅等噪音短语,折叠空白,限制长度。
|
|
157
|
+
// 只在回退链出口统一应用,各引擎内部不做,避免重复处理。
|
|
158
|
+
const SNIPPET_NOISE =
|
|
159
|
+
/\b(sign up|sign in|log in|login|subscribe( to| for)?|member[- ]?only|become a member|create (a )?free account|read more|continue reading|story continues|get started|install (the )?app|view on|medium membership|join \w+ for free|get updates from this writer|stories in your inbox|remember me for|unlock this|free to read|become a patron)\b/gi;
|
|
160
|
+
|
|
161
|
+
function cleanSnippet(text) {
|
|
162
|
+
if (!text) return text;
|
|
163
|
+
return String(text)
|
|
164
|
+
.replace(SNIPPET_NOISE, " ")
|
|
165
|
+
.replace(/^\s*(#{1,6}\s*|\[\s*x?\s*\]\s*|-\s*\[\s*x?\s*\]\s*|>\s*)/gm, " ")
|
|
166
|
+
.replace(/\s+/g, " ")
|
|
167
|
+
.trim()
|
|
168
|
+
.slice(0, 300);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function stripTags(html) {
|
|
172
|
+
return decodeEntities(String(html).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extractDdgUrl(rel) {
|
|
176
|
+
if (!rel) return null;
|
|
177
|
+
const m = rel.match(/uddg=([^&]+)/);
|
|
178
|
+
if (m) {
|
|
179
|
+
try {
|
|
180
|
+
return decodeURIComponent(m[1]);
|
|
181
|
+
} catch {
|
|
182
|
+
return m[1];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (rel.startsWith("//")) return `https:${rel}`;
|
|
186
|
+
return rel;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function uniqueSources(sources, limit) {
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const s of sources) {
|
|
193
|
+
if (s.url && !seen.has(s.url)) {
|
|
194
|
+
seen.add(s.url);
|
|
195
|
+
out.push(s);
|
|
196
|
+
}
|
|
197
|
+
if (out.length >= limit) break;
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function fetchHtml(url, signal) {
|
|
203
|
+
// 单次请求超时 12s,避免挂起被当成 Connection error
|
|
204
|
+
let response;
|
|
205
|
+
try {
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const timer = setTimeout(() => controller.abort(), 12000);
|
|
208
|
+
const onAbort = () => controller.abort();
|
|
209
|
+
signal?.addEventListener("abort", onAbort);
|
|
210
|
+
response = await fetch(url, {
|
|
211
|
+
headers: { "user-agent": USER_AGENT, "accept-language": ACCEPT_LANG },
|
|
212
|
+
signal: controller.signal,
|
|
213
|
+
redirect: "follow",
|
|
214
|
+
});
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
signal?.removeEventListener("abort", onAbort);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (signal?.aborted) throw error;
|
|
219
|
+
throw new Error(`connection error: ${error?.message ?? String(error)}`);
|
|
220
|
+
}
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(`HTTP ${response.status} from ${url.split("?")[0]}`);
|
|
223
|
+
}
|
|
224
|
+
const html = await response.text();
|
|
225
|
+
// DuckDuckGo 反爬验证页检测(HTTP 202 或验证关键字)
|
|
226
|
+
if (response.status === 202 || /anomaly|captcha|unusual traffic|robot check/i.test(html.slice(0, 4000))) {
|
|
227
|
+
throw new Error("DuckDuckGo is rate-limited right now (anti-bot challenge, usually temporary) - Bing works");
|
|
228
|
+
}
|
|
229
|
+
return html;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 带重试的抓取:网络错误/空结果时重试,间隔 1.5s,最多 3 次
|
|
233
|
+
async function fetchHtmlWithRetry(url, signal) {
|
|
234
|
+
let lastError;
|
|
235
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
236
|
+
try {
|
|
237
|
+
const html = await fetchHtml(url, signal);
|
|
238
|
+
if (html.length > 500) return html;
|
|
239
|
+
lastError = new Error(`empty response (${html.length} bytes)`);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
lastError = error;
|
|
242
|
+
}
|
|
243
|
+
if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
244
|
+
}
|
|
245
|
+
throw lastError ?? new Error("fetch failed");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function searchDdgHtml(query, maxResults, options, signal) {
|
|
249
|
+
const params = new URLSearchParams({ q: query });
|
|
250
|
+
if (options?.region) params.set("kl", options.region);
|
|
251
|
+
// DDG 时间过滤:df=d/w/m/y(只支持固定档,自定义取近似档)
|
|
252
|
+
if (options?.timeRange) {
|
|
253
|
+
const df = { day: "d", week: "w", month: "m", year: "y" }[approximateTimeRange(options.timeRange.days ?? 7)];
|
|
254
|
+
if (df) params.set("df", df);
|
|
255
|
+
}
|
|
256
|
+
const html = await fetchHtmlWithRetry(`${DDG_HTML_URL}?${params}`, signal);
|
|
257
|
+
const blocks = html.match(/<div class="result results_links[\s\S]*?<\/div>\s*<\/div>\s*<\/div>/g) ?? [];
|
|
258
|
+
const sources = [];
|
|
259
|
+
for (const block of blocks) {
|
|
260
|
+
const urlMatch = block.match(/<a[^>]*class="result__a"[^>]*href="([^"]*)"/);
|
|
261
|
+
const titleMatch = block.match(/<a[^>]*class="result__a"[^>]*>(.*?)<\/a>/);
|
|
262
|
+
const snippetMatch = block.match(/<a[^>]*class="result__snippet"[^>]*>(.*?)<\/a>/);
|
|
263
|
+
const dateMatch = block.match(/<span[^>]*>\s*([\dT:.+-]+)\s*<\/span>/);
|
|
264
|
+
const url = extractDdgUrl(urlMatch?.[1]);
|
|
265
|
+
if (!url) continue;
|
|
266
|
+
sources.push({
|
|
267
|
+
url,
|
|
268
|
+
...(titleMatch ? { title: stripTags(titleMatch[1]) } : {}),
|
|
269
|
+
...(snippetMatch ? { snippet: stripTags(snippetMatch[1]) } : {}),
|
|
270
|
+
...(dateMatch ? { publishedAt: dateMatch[1] } : {}),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function searchDdgLite(query, maxResults, options, signal) {
|
|
277
|
+
const params = new URLSearchParams({ q: query });
|
|
278
|
+
// DDG Lite 同样支持 df 时间过滤
|
|
279
|
+
if (options?.timeRange) {
|
|
280
|
+
const df = { day: "d", week: "w", month: "m", year: "y" }[approximateTimeRange(options.timeRange.days ?? 7)];
|
|
281
|
+
if (df) params.set("df", df);
|
|
282
|
+
}
|
|
283
|
+
const html = await fetchHtmlWithRetry(`${DDG_LITE_URL}?${params}`, signal);
|
|
284
|
+
const linkMatches = html.match(/<a[^>]*class=['"]result-link['"][^>]*>[\s\S]*?<\/a>/g) ?? [];
|
|
285
|
+
const snippetMatches = html.match(/class=['"]result-snippet['"][^>]*>([\s\S]*?)<\/td>/g) ?? [];
|
|
286
|
+
const sources = [];
|
|
287
|
+
for (let i = 0; i < linkMatches.length; i++) {
|
|
288
|
+
const tag = linkMatches[i];
|
|
289
|
+
const hrefMatch = tag.match(/href="([^"]*)"/);
|
|
290
|
+
const titleMatch = tag.match(/class=['"]result-link['"][^>]*>(.*?)<\/a>/);
|
|
291
|
+
if (!hrefMatch) continue;
|
|
292
|
+
const url = extractDdgUrl(hrefMatch[1]);
|
|
293
|
+
if (!url) continue;
|
|
294
|
+
const snippet = snippetMatches[i]?.match(/class=['"]result-snippet['"][^>]*>([\s\S]*?)<\/td>/)?.[1];
|
|
295
|
+
sources.push({
|
|
296
|
+
url,
|
|
297
|
+
...(titleMatch ? { title: stripTags(titleMatch[1]) } : {}),
|
|
298
|
+
...(snippet ? { snippet: stripTags(snippet) } : {}),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function searchBing(query, maxResults, options, signal) {
|
|
305
|
+
const params = new URLSearchParams({ q: query, mkt: options?.bingMarket ?? "zh-CN" });
|
|
306
|
+
const html = await fetchHtmlWithRetry(`${BING_URL}?${params}`, signal);
|
|
307
|
+
const blocks = html.match(/<li class="b_algo"[\s\S]*?<\/li>/g) ?? [];
|
|
308
|
+
const sources = [];
|
|
309
|
+
for (const block of blocks) {
|
|
310
|
+
const hrefMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]+)"/);
|
|
311
|
+
const titleMatch = block.match(/<h2[^>]*>[\s\S]*?<a[^>]*>(.*?)<\/a>[\s\S]*?<\/h2>/);
|
|
312
|
+
const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/);
|
|
313
|
+
if (!hrefMatch) continue;
|
|
314
|
+
sources.push({
|
|
315
|
+
url: hrefMatch[1],
|
|
316
|
+
...(titleMatch ? { title: stripTags(titleMatch[1]) } : {}),
|
|
317
|
+
...(snippetMatch ? { snippet: stripTags(snippetMatch[1]) } : {}),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
//#region searxng (meta-search, free instances, auto-failover)
|
|
324
|
+
const SEARXNG_INSTANCES = [
|
|
325
|
+
"https://opnxng.com",
|
|
326
|
+
"https://priv.au",
|
|
327
|
+
"https://searx.be",
|
|
328
|
+
"https://searx.tiekoetter.com",
|
|
329
|
+
"https://search.inetol.net",
|
|
330
|
+
"https://paulgo.io",
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
async function searchSearxng(query, maxResults, options, signal) {
|
|
334
|
+
const instances = options?.searxngInstances?.length
|
|
335
|
+
? options.searxngInstances
|
|
336
|
+
: SEARXNG_INSTANCES;
|
|
337
|
+
// 聚合所有实例的失败原因,避免只显示最后一个实例的错误
|
|
338
|
+
const errors = [];
|
|
339
|
+
for (const base of instances) {
|
|
340
|
+
try {
|
|
341
|
+
const params = new URLSearchParams({ q: query, format: "json" });
|
|
342
|
+
// SearXNG 原生支持 time_range 过滤(只支持固定档,自定义取近似档)
|
|
343
|
+
if (options?.timeRange) {
|
|
344
|
+
const tr = SEARXNG_TIME[approximateTimeRange(options.timeRange.days ?? 7)];
|
|
345
|
+
if (tr) params.set("time_range", tr);
|
|
346
|
+
}
|
|
347
|
+
const ctrl = new AbortController();
|
|
348
|
+
const timer = setTimeout(() => ctrl.abort(), 8000);
|
|
349
|
+
const onAbort = () => ctrl.abort();
|
|
350
|
+
signal?.addEventListener("abort", onAbort);
|
|
351
|
+
const response = await fetch(`${base}/search?${params}`, {
|
|
352
|
+
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
353
|
+
signal: ctrl.signal,
|
|
354
|
+
});
|
|
355
|
+
clearTimeout(timer);
|
|
356
|
+
signal?.removeEventListener("abort", onAbort);
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
errors.push(`${base}: HTTP ${response.status}`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const data = await response.json().catch(() => null);
|
|
362
|
+
if (!data || !Array.isArray(data.results)) {
|
|
363
|
+
errors.push(`${base}: invalid JSON`);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const sources = data.results
|
|
367
|
+
.filter((r) => r.url)
|
|
368
|
+
.map((r) => ({
|
|
369
|
+
url: r.url,
|
|
370
|
+
...(r.title ? { title: String(r.title) } : {}),
|
|
371
|
+
...(r.content ? { snippet: String(r.content) } : {}),
|
|
372
|
+
}));
|
|
373
|
+
if (sources.length > 0) {
|
|
374
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
375
|
+
}
|
|
376
|
+
errors.push(`${base}: 0 results`);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
379
|
+
errors.push(`${base}: ${message}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// 空实例列表兜底:避免 "all SearXNG instances failed: " 尾巴悬空
|
|
383
|
+
const detail = errors.length > 0 ? errors.join(", ") : "no instances configured";
|
|
384
|
+
// Note 会引用这个错误消息,截断避免 6 实例全挂时刷屏
|
|
385
|
+
throw new Error(`all SearXNG instances failed: ${detail.slice(0, 300)}`);
|
|
386
|
+
}
|
|
387
|
+
//#endregion
|
|
388
|
+
|
|
389
|
+
//#region keyless engines (AnySearch / Exa MCP - free, no API key)
|
|
390
|
+
const ANYSEARCH_URL = "https://api.anysearch.com/v1/search";
|
|
391
|
+
const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
|
|
392
|
+
|
|
393
|
+
// AnySearch: 免费匿名额度(无 key),结构化 JSON 结果
|
|
394
|
+
async function searchAnysearch(query, maxResults, signal) {
|
|
395
|
+
const controller = new AbortController();
|
|
396
|
+
const timer = setTimeout(() => controller.abort(), 12000);
|
|
397
|
+
const onAbort = () => controller.abort();
|
|
398
|
+
signal?.addEventListener("abort", onAbort);
|
|
399
|
+
let response;
|
|
400
|
+
try {
|
|
401
|
+
response = await fetch(ANYSEARCH_URL, {
|
|
402
|
+
method: "POST",
|
|
403
|
+
headers: { "content-type": "application/json" },
|
|
404
|
+
body: JSON.stringify({ query, max_results: maxResults ?? 5 }),
|
|
405
|
+
signal: controller.signal,
|
|
406
|
+
});
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (signal?.aborted) throw error;
|
|
409
|
+
throw new Error(`AnySearch request failed: ${error?.message ?? String(error)}`);
|
|
410
|
+
} finally {
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
signal?.removeEventListener("abort", onAbort);
|
|
413
|
+
}
|
|
414
|
+
if (!response.ok) throw new Error(`AnySearch API error (HTTP ${response.status})`);
|
|
415
|
+
const data = await response.json();
|
|
416
|
+
if (data.code !== 0) throw new Error(`AnySearch API error: ${data.message ?? data.code}`);
|
|
417
|
+
const results = data.data?.results ?? [];
|
|
418
|
+
return {
|
|
419
|
+
sources: results
|
|
420
|
+
.filter((r) => r.url)
|
|
421
|
+
.map((r) => ({
|
|
422
|
+
url: r.url,
|
|
423
|
+
...(r.title ? { title: String(r.title) } : {}),
|
|
424
|
+
...(r.snippet ? { snippet: String(r.snippet).slice(0, 300) } : {}),
|
|
425
|
+
})),
|
|
426
|
+
truncated: false,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Exa MCP: 匿名公开 MCP(无 key),web_search_exa 工具
|
|
431
|
+
async function searchExaMCP(query, maxResults, signal) {
|
|
432
|
+
const controller = new AbortController();
|
|
433
|
+
const timer = setTimeout(() => controller.abort(), 20000);
|
|
434
|
+
const onAbort = () => controller.abort();
|
|
435
|
+
signal?.addEventListener("abort", onAbort);
|
|
436
|
+
let response;
|
|
437
|
+
try {
|
|
438
|
+
response = await fetch(EXA_MCP_URL, {
|
|
439
|
+
method: "POST",
|
|
440
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
441
|
+
body: JSON.stringify({
|
|
442
|
+
jsonrpc: "2.0",
|
|
443
|
+
id: Date.now(),
|
|
444
|
+
method: "tools/call",
|
|
445
|
+
params: { name: "web_search_exa", arguments: { query, numResults: maxResults ?? 5 } },
|
|
446
|
+
}),
|
|
447
|
+
signal: controller.signal,
|
|
448
|
+
});
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (signal?.aborted) throw error;
|
|
451
|
+
throw new Error(`Exa MCP request failed: ${error?.message ?? String(error)}`);
|
|
452
|
+
} finally {
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
signal?.removeEventListener("abort", onAbort);
|
|
455
|
+
}
|
|
456
|
+
if (!response.ok) throw new Error(`Exa MCP error (HTTP ${response.status})`);
|
|
457
|
+
const text = await response.text();
|
|
458
|
+
// 解析 SSE 格式:event: message\ndata: {...}
|
|
459
|
+
const lines = text.split("\n");
|
|
460
|
+
let json = null;
|
|
461
|
+
for (const line of lines) {
|
|
462
|
+
if (line.startsWith("data: ")) {
|
|
463
|
+
try {
|
|
464
|
+
json = JSON.parse(line.slice(6));
|
|
465
|
+
break;
|
|
466
|
+
} catch {}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (!json || json.error) {
|
|
470
|
+
throw new Error(`Exa MCP error: ${json?.error?.message ?? "no data"}`);
|
|
471
|
+
}
|
|
472
|
+
const content = json.result?.content ?? [];
|
|
473
|
+
const sources = [];
|
|
474
|
+
const textBlocks = content
|
|
475
|
+
.filter((b) => b.type === "text")
|
|
476
|
+
.map((b) => b.text)
|
|
477
|
+
.join("\n");
|
|
478
|
+
// 解析 "Title: X\nURL: Y\nPublished: Z\nHighlights:\n..."
|
|
479
|
+
const blocks = textBlocks.split(/\n(?=Title:)/);
|
|
480
|
+
for (const block of blocks) {
|
|
481
|
+
const title = block.match(/^Title: (.+)$/m)?.[1];
|
|
482
|
+
const url = block.match(/^URL: (\S+)$/m)?.[1];
|
|
483
|
+
const published = block.match(/^Published: (.+)$/m)?.[1];
|
|
484
|
+
const highlights = block.split(/^Highlights:$/m)[1]?.split("\n").filter((l) => l.trim() && !l.trim().startsWith("...")).slice(0, 3).join(" ");
|
|
485
|
+
if (!url) continue;
|
|
486
|
+
sources.push({
|
|
487
|
+
url,
|
|
488
|
+
...(title ? { title } : {}),
|
|
489
|
+
...(highlights ? { snippet: highlights.slice(0, 300) } : {}),
|
|
490
|
+
// 只保留日期形态(ISO 或 YYYY-MM-DD),过滤 "N/A" 等占位符
|
|
491
|
+
...(published && /^\d{4}-\d{2}-\d{2}/.test(published) ? { publishedAt: published } : {}),
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return { sources, truncated: false };
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
497
|
+
|
|
498
|
+
//#region platform search (GitHub / V2EX / Bilibili / Reddit / HN / StackOverflow / Wikipedia / npm)
|
|
499
|
+
const PLATFORMS = {
|
|
500
|
+
github: { name: "GitHub" },
|
|
501
|
+
v2ex: { name: "V2EX" },
|
|
502
|
+
bilibili: { name: "Bilibili" },
|
|
503
|
+
reddit: { name: "Reddit" },
|
|
504
|
+
hn: { name: "Hacker News" },
|
|
505
|
+
stackoverflow: { name: "Stack Overflow" },
|
|
506
|
+
wikipedia: { name: "Wikipedia" },
|
|
507
|
+
npm: { name: "npm" },
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
async function searchGithub(query, maxResults, signal) {
|
|
511
|
+
const response = await fetch(
|
|
512
|
+
`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=${maxResults ?? 5}`,
|
|
513
|
+
{
|
|
514
|
+
headers: { "user-agent": USER_AGENT, accept: "application/vnd.github+json" },
|
|
515
|
+
...(signal !== undefined ? { signal } : {}),
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
if (!response.ok) throw new Error(`GitHub API error (HTTP ${response.status})`);
|
|
519
|
+
const data = await response.json();
|
|
520
|
+
return {
|
|
521
|
+
sources: (data.items ?? []).map((item) => ({
|
|
522
|
+
url: item.html_url,
|
|
523
|
+
title: item.full_name ?? item.name,
|
|
524
|
+
snippet: `${item.description ?? ""}${item.stargazers_count ? ` ⭐${item.stargazers_count}` : ""}`.trim(),
|
|
525
|
+
})),
|
|
526
|
+
truncated: false,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function searchV2ex(query, maxResults, signal) {
|
|
531
|
+
const response = await fetch("https://www.v2ex.com/api/topics/hot.json", {
|
|
532
|
+
headers: { "user-agent": USER_AGENT },
|
|
533
|
+
...(signal !== undefined ? { signal } : {}),
|
|
534
|
+
});
|
|
535
|
+
if (!response.ok) throw new Error(`V2EX API error (HTTP ${response.status})`);
|
|
536
|
+
const topics = await response.json();
|
|
537
|
+
const q = query.toLowerCase();
|
|
538
|
+
const matched = Array.isArray(topics)
|
|
539
|
+
? topics.filter((t) => (t.title ?? "").toLowerCase().includes(q) || (t.content ?? "").toLowerCase().includes(q))
|
|
540
|
+
: [];
|
|
541
|
+
return {
|
|
542
|
+
sources: matched.slice(0, maxResults ?? 5).map((t) => ({
|
|
543
|
+
url: `https://www.v2ex.com/t/${t.id}`,
|
|
544
|
+
title: t.title,
|
|
545
|
+
...(t.content ? { snippet: String(t.content).slice(0, 200) } : {}),
|
|
546
|
+
})),
|
|
547
|
+
truncated: false,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function searchBilibili(query, maxResults, signal) {
|
|
552
|
+
const response = await fetch(
|
|
553
|
+
`https://api.bilibili.com/x/web-interface/search/all/v2?keyword=${encodeURIComponent(query)}`,
|
|
554
|
+
{
|
|
555
|
+
headers: { "user-agent": USER_AGENT, referer: "https://www.bilibili.com" },
|
|
556
|
+
...(signal !== undefined ? { signal } : {}),
|
|
557
|
+
}
|
|
558
|
+
);
|
|
559
|
+
if (!response.ok) throw new Error(`Bilibili API error (HTTP ${response.status})`);
|
|
560
|
+
const data = await response.json();
|
|
561
|
+
if (data.code !== 0) throw new Error(`Bilibili API error: ${data.message ?? data.code}`);
|
|
562
|
+
const sources = [];
|
|
563
|
+
for (const section of data.data?.result ?? []) {
|
|
564
|
+
for (const item of section.data ?? []) {
|
|
565
|
+
if (!item.arcurl) continue;
|
|
566
|
+
sources.push({
|
|
567
|
+
url: item.arcurl,
|
|
568
|
+
title: item.title ? String(item.title).replace(/<[^>]+>/g, "") : item.bvid,
|
|
569
|
+
...(item.desc ? { snippet: String(item.desc).slice(0, 200) } : {}),
|
|
570
|
+
});
|
|
571
|
+
if (sources.length >= (maxResults ?? 5)) break;
|
|
572
|
+
}
|
|
573
|
+
if (sources.length >= (maxResults ?? 5)) break;
|
|
574
|
+
}
|
|
575
|
+
return { sources, truncated: false };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function searchReddit(query, maxResults, signal) {
|
|
579
|
+
const response = await fetch(
|
|
580
|
+
`https://old.reddit.com/search.json?q=${encodeURIComponent(query)}&limit=${maxResults ?? 5}&sort=relevance`,
|
|
581
|
+
{
|
|
582
|
+
headers: {
|
|
583
|
+
"user-agent": `${USER_AGENT} (dsh-free-search; contact: github.com/DDDMUC)`,
|
|
584
|
+
accept: "application/json",
|
|
585
|
+
},
|
|
586
|
+
...(signal !== undefined ? { signal } : {}),
|
|
587
|
+
}
|
|
588
|
+
);
|
|
589
|
+
if (!response.ok) throw new Error(`Reddit API error (HTTP ${response.status})`);
|
|
590
|
+
const data = await response.json();
|
|
591
|
+
return {
|
|
592
|
+
sources: (data.data?.children ?? [])
|
|
593
|
+
.map((c) => c.data)
|
|
594
|
+
.filter((p) => p && p.url)
|
|
595
|
+
.map((p) => ({
|
|
596
|
+
url: p.url,
|
|
597
|
+
title: p.title ?? "",
|
|
598
|
+
...(p.selftext ? { snippet: String(p.selftext).slice(0, 200) } : {}),
|
|
599
|
+
})),
|
|
600
|
+
truncated: false,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function searchHackerNews(query, maxResults, signal) {
|
|
605
|
+
const response = await fetch(
|
|
606
|
+
`https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}&hitsPerPage=${maxResults ?? 5}`,
|
|
607
|
+
{
|
|
608
|
+
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
609
|
+
...(signal !== undefined ? { signal } : {}),
|
|
610
|
+
}
|
|
611
|
+
);
|
|
612
|
+
if (!response.ok) throw new Error(`Hacker News API error (HTTP ${response.status})`);
|
|
613
|
+
const data = await response.json();
|
|
614
|
+
return {
|
|
615
|
+
sources: (data.hits ?? [])
|
|
616
|
+
.filter((h) => h.title || h.story_title)
|
|
617
|
+
.map((h) => ({
|
|
618
|
+
// 有外链用外链,纯讨论帖用 HN 讨论页
|
|
619
|
+
url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`,
|
|
620
|
+
title: h.title ?? h.story_title,
|
|
621
|
+
...((h.points !== undefined && h.points !== null) || (h.num_comments !== undefined && h.num_comments !== null)
|
|
622
|
+
? { snippet: `HN discussion · ${h.points ?? 0} points · ${h.num_comments ?? 0} comments` }
|
|
623
|
+
: {}),
|
|
624
|
+
})),
|
|
625
|
+
truncated: false,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function searchStackOverflow(query, maxResults, signal) {
|
|
630
|
+
const response = await fetch(
|
|
631
|
+
`https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${encodeURIComponent(query)}&site=stackoverflow&pagesize=${maxResults ?? 5}&filter=!nNPvSNVZJS`,
|
|
632
|
+
{
|
|
633
|
+
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
634
|
+
...(signal !== undefined ? { signal } : {}),
|
|
635
|
+
}
|
|
636
|
+
);
|
|
637
|
+
if (!response.ok) throw new Error(`Stack Exchange API error (HTTP ${response.status})`);
|
|
638
|
+
const data = await response.json();
|
|
639
|
+
if (data.error_message) throw new Error(`Stack Exchange API error: ${data.error_message}`);
|
|
640
|
+
return {
|
|
641
|
+
sources: (data.items ?? []).map((it) => ({
|
|
642
|
+
url: it.link,
|
|
643
|
+
title: it.title,
|
|
644
|
+
...(it.score !== undefined || it.answer_count !== undefined
|
|
645
|
+
? { snippet: `${it.is_answered ? "✓ answered" : "unanswered"} · score ${it.score ?? 0} · ${it.answer_count ?? 0} answers` }
|
|
646
|
+
: {}),
|
|
647
|
+
})),
|
|
648
|
+
truncated: false,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function searchWikipedia(query, maxResults, signal, lang) {
|
|
653
|
+
const host = lang === "en" ? "en.wikipedia.org" : "zh.wikipedia.org";
|
|
654
|
+
const response = await fetch(
|
|
655
|
+
`https://${host}/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&srlimit=${maxResults ?? 5}`,
|
|
656
|
+
{
|
|
657
|
+
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
658
|
+
...(signal !== undefined ? { signal } : {}),
|
|
659
|
+
}
|
|
660
|
+
);
|
|
661
|
+
if (!response.ok) throw new Error(`Wikipedia API error (HTTP ${response.status})`);
|
|
662
|
+
const data = await response.json();
|
|
663
|
+
return {
|
|
664
|
+
sources: (data.query?.search ?? []).map((s) => ({
|
|
665
|
+
url: `https://${host}/wiki/${encodeURIComponent(String(s.title).replace(/ /g, "_"))}`,
|
|
666
|
+
title: s.title,
|
|
667
|
+
// snippet 含 <span class="searchmatch"> 高亮标签,剥掉
|
|
668
|
+
...(s.snippet ? { snippet: stripTags(s.snippet).slice(0, 200) } : {}),
|
|
669
|
+
})),
|
|
670
|
+
truncated: false,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function searchNpm(query, maxResults, signal) {
|
|
675
|
+
const response = await fetch(
|
|
676
|
+
`https://registry.npmjs.com/-/v1/search?text=${encodeURIComponent(query)}&size=${maxResults ?? 5}`,
|
|
677
|
+
{
|
|
678
|
+
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
679
|
+
...(signal !== undefined ? { signal } : {}),
|
|
680
|
+
}
|
|
681
|
+
);
|
|
682
|
+
if (!response.ok) throw new Error(`npm registry API error (HTTP ${response.status})`);
|
|
683
|
+
const data = await response.json();
|
|
684
|
+
return {
|
|
685
|
+
sources: (data.objects ?? [])
|
|
686
|
+
.map((o) => o.package)
|
|
687
|
+
.filter((p) => p && p.name)
|
|
688
|
+
.map((p) => ({
|
|
689
|
+
url: p.links?.npm ?? `https://www.npmjs.com/package/${p.name}`,
|
|
690
|
+
title: p.name,
|
|
691
|
+
...((p.description || p.version)
|
|
692
|
+
? { snippet: `v${p.version ?? "?"}${p.description ? ` — ${String(p.description).slice(0, 160)}` : ""}` }
|
|
693
|
+
: {}),
|
|
694
|
+
})),
|
|
695
|
+
truncated: false,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function searchPlatform(platform, query, maxResults, signal, lang) {
|
|
700
|
+
switch (platform) {
|
|
701
|
+
case "github":
|
|
702
|
+
return searchGithub(query, maxResults, signal);
|
|
703
|
+
case "v2ex":
|
|
704
|
+
return searchV2ex(query, maxResults, signal);
|
|
705
|
+
case "bilibili":
|
|
706
|
+
return searchBilibili(query, maxResults, signal);
|
|
707
|
+
case "reddit":
|
|
708
|
+
return searchReddit(query, maxResults, signal);
|
|
709
|
+
case "hn":
|
|
710
|
+
return searchHackerNews(query, maxResults, signal);
|
|
711
|
+
case "stackoverflow":
|
|
712
|
+
return searchStackOverflow(query, maxResults, signal);
|
|
713
|
+
case "wikipedia":
|
|
714
|
+
return searchWikipedia(query, maxResults, signal, lang);
|
|
715
|
+
case "npm":
|
|
716
|
+
return searchNpm(query, maxResults, signal);
|
|
717
|
+
default:
|
|
718
|
+
throw new Error(`unknown platform: ${platform}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
//#endregion
|
|
722
|
+
|
|
723
|
+
//#region paid engines (exa / tavily / perplexity / deepseek-official)
|
|
724
|
+
async function searchExa(query, maxResults, apiKey, timeRange, signal) {
|
|
725
|
+
if (!apiKey) throw new Error("Exa search requires EXA_API_KEY");
|
|
726
|
+
const body = {
|
|
727
|
+
query,
|
|
728
|
+
type: "auto",
|
|
729
|
+
contents: { highlights: { highlightsPerUrl: 1 } },
|
|
730
|
+
...(maxResults !== undefined ? { numResults: maxResults } : {}),
|
|
731
|
+
};
|
|
732
|
+
// Exa 时间过滤:startPublishedDate(ISO 日期;支持任意天数和绝对日期)
|
|
733
|
+
if (timeRange) {
|
|
734
|
+
if (timeRange.after) body.startPublishedDate = timeRange.after;
|
|
735
|
+
else if (timeRange.days !== undefined) body.startPublishedDate = isoDaysAgo(timeRange.days);
|
|
736
|
+
}
|
|
737
|
+
const response = await fetch("https://api.exa.ai/search", {
|
|
738
|
+
method: "POST",
|
|
739
|
+
redirect: "error",
|
|
740
|
+
headers: {
|
|
741
|
+
authorization: `Bearer ${apiKey}`,
|
|
742
|
+
"content-type": "application/json",
|
|
743
|
+
accept: "application/json",
|
|
744
|
+
"user-agent": "deepseek-harness/free-search",
|
|
745
|
+
},
|
|
746
|
+
body: JSON.stringify(body),
|
|
747
|
+
...(signal !== undefined ? { signal } : {}),
|
|
748
|
+
});
|
|
749
|
+
if (!response.ok) {
|
|
750
|
+
const detail = await response.text().catch(() => "");
|
|
751
|
+
if (response.status === 401) {
|
|
752
|
+
throw new Error("Exa API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
|
|
753
|
+
}
|
|
754
|
+
throw new Error(`Exa API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
|
|
755
|
+
}
|
|
756
|
+
const data = await response.json();
|
|
757
|
+
const sources = (data.results ?? [])
|
|
758
|
+
.map((result) => {
|
|
759
|
+
const snippet = result.highlights?.find((h) => h.trim().length > 0);
|
|
760
|
+
if (!snippet) return null;
|
|
761
|
+
return {
|
|
762
|
+
url: result.url,
|
|
763
|
+
...(result.title ? { title: result.title } : {}),
|
|
764
|
+
snippet,
|
|
765
|
+
...(result.publishedDate ? { publishedAt: result.publishedDate } : {}),
|
|
766
|
+
};
|
|
767
|
+
})
|
|
768
|
+
.filter(Boolean);
|
|
769
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Tavily: 无 key 走 keyless(免费匿名额度),有 key 走账号档(Bearer)
|
|
773
|
+
async function searchTavily(query, maxResults, apiKey, timeRange, signal) {
|
|
774
|
+
const controller = new AbortController();
|
|
775
|
+
const timer = setTimeout(() => controller.abort(), 15000);
|
|
776
|
+
const onAbort = () => controller.abort();
|
|
777
|
+
signal?.addEventListener("abort", onAbort);
|
|
778
|
+
let response;
|
|
779
|
+
try {
|
|
780
|
+
const body = {
|
|
781
|
+
query,
|
|
782
|
+
max_results: Math.min(maxResults ?? 5, 20),
|
|
783
|
+
search_depth: "basic",
|
|
784
|
+
};
|
|
785
|
+
// Tavily 时间过滤:time_range 只支持固定档,自定义天数取最近似档位
|
|
786
|
+
if (timeRange) {
|
|
787
|
+
const tr = approximateTimeRange(timeRange.days ?? 7);
|
|
788
|
+
if (tr) body.time_range = tr;
|
|
789
|
+
}
|
|
790
|
+
response = await fetch(TAVILY_URL, {
|
|
791
|
+
method: "POST",
|
|
792
|
+
headers: {
|
|
793
|
+
"content-type": "application/json",
|
|
794
|
+
accept: "application/json",
|
|
795
|
+
...(apiKey ? { authorization: `Bearer ${apiKey}` } : { "x-tavily-access-mode": "keyless" }),
|
|
796
|
+
},
|
|
797
|
+
body: JSON.stringify(body),
|
|
798
|
+
signal: controller.signal,
|
|
799
|
+
redirect: "error",
|
|
800
|
+
});
|
|
801
|
+
} catch (error) {
|
|
802
|
+
if (signal?.aborted) throw error;
|
|
803
|
+
throw new Error(`Tavily request failed: ${error?.message ?? String(error)}`);
|
|
804
|
+
} finally {
|
|
805
|
+
clearTimeout(timer);
|
|
806
|
+
signal?.removeEventListener("abort", onAbort);
|
|
807
|
+
}
|
|
808
|
+
if (!response.ok) {
|
|
809
|
+
const detail = await response.text().catch(() => "");
|
|
810
|
+
if (response.status === 401) {
|
|
811
|
+
throw new Error("Tavily API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
|
|
812
|
+
}
|
|
813
|
+
throw new Error(`Tavily API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
|
|
814
|
+
}
|
|
815
|
+
const data = await response.json();
|
|
816
|
+
const sources = (data.results ?? [])
|
|
817
|
+
.filter((r) => r.url)
|
|
818
|
+
.map((r) => ({
|
|
819
|
+
url: r.url,
|
|
820
|
+
...(r.title ? { title: String(r.title) } : {}),
|
|
821
|
+
...(r.content ? { snippet: String(r.content).slice(0, 300) } : {}),
|
|
822
|
+
}));
|
|
823
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// 把任意天数转成 Keenable 的相对时间格式(12h / Nd / Nmo / Ny)
|
|
827
|
+
function formatKeenableRelative(days) {
|
|
828
|
+
if (days <= 0.5) return "12h";
|
|
829
|
+
if (days < 1) return `${Math.round(days * 24)}h`;
|
|
830
|
+
if (days < 30) return `${Math.round(days)}d`;
|
|
831
|
+
if (days < 365) return `${Math.round(days / 30)}mo`;
|
|
832
|
+
return `${Math.round(days / 365)}y`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Keenable: 有 key 走 REST API(X-API-Key),无 key 走 keyless MCP(免费匿名额度)
|
|
836
|
+
function extractKeenableSources(text, maxResults) {
|
|
837
|
+
const sources = [];
|
|
838
|
+
const blocks = String(text).split(/\n(?=Title:)/);
|
|
839
|
+
for (const block of blocks) {
|
|
840
|
+
const title = block.match(/^Title: (.+)$/m)?.[1];
|
|
841
|
+
const url = block.match(/^URL: (\S+)$/m)?.[1];
|
|
842
|
+
const published = block.match(/^Published: (.+)$/m)?.[1] ?? block.match(/^Acquired: (.+)$/m)?.[1];
|
|
843
|
+
const snippets = block.split(/^Snippets:$/m)[1]?.split("\n").filter((l) => l.trim()).slice(0, 3).join(" ");
|
|
844
|
+
if (!url) continue;
|
|
845
|
+
sources.push({
|
|
846
|
+
url,
|
|
847
|
+
...(title ? { title } : {}),
|
|
848
|
+
...(snippets ? { snippet: snippets.slice(0, 300) } : {}),
|
|
849
|
+
// 与 Exa MCP 一致:只保留日期形态,过滤 "N/A" 等占位符
|
|
850
|
+
...(published && /^\d{4}-\d{2}-\d{2}/.test(published) ? { publishedAt: published } : {}),
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
return uniqueSources(sources, maxResults ?? 10);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function searchKeenableREST(query, maxResults, apiKey, timeRange, signal) {
|
|
857
|
+
const controller = new AbortController();
|
|
858
|
+
const timer = setTimeout(() => controller.abort(), 20000);
|
|
859
|
+
const onAbort = () => controller.abort();
|
|
860
|
+
signal?.addEventListener("abort", onAbort);
|
|
861
|
+
let response;
|
|
862
|
+
try {
|
|
863
|
+
const body = { query, mode: "realtime" };
|
|
864
|
+
// Keenable 时间过滤:published_after(相对 12h/7d/1mo/1y 或绝对 YYYY-MM-DD)
|
|
865
|
+
if (timeRange) {
|
|
866
|
+
if (timeRange.after) body.published_after = timeRange.after;
|
|
867
|
+
else if (timeRange.days !== undefined) body.published_after = formatKeenableRelative(timeRange.days);
|
|
868
|
+
}
|
|
869
|
+
response = await fetch(KEENABLE_URL, {
|
|
870
|
+
method: "POST",
|
|
871
|
+
headers: { "x-api-key": apiKey, "content-type": "application/json", accept: "application/json" },
|
|
872
|
+
body: JSON.stringify(body),
|
|
873
|
+
signal: controller.signal,
|
|
874
|
+
});
|
|
875
|
+
} catch (error) {
|
|
876
|
+
if (signal?.aborted) throw error;
|
|
877
|
+
throw new Error(`Keenable request failed: ${error?.message ?? String(error)}`);
|
|
878
|
+
} finally {
|
|
879
|
+
clearTimeout(timer);
|
|
880
|
+
signal?.removeEventListener("abort", onAbort);
|
|
881
|
+
}
|
|
882
|
+
if (!response.ok) {
|
|
883
|
+
const detail = await response.text().catch(() => "");
|
|
884
|
+
if (response.status === 401) {
|
|
885
|
+
throw new Error("Keenable API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
|
|
886
|
+
}
|
|
887
|
+
throw new Error(`Keenable API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
|
|
888
|
+
}
|
|
889
|
+
const data = await response.json();
|
|
890
|
+
const sources = (data.results ?? [])
|
|
891
|
+
.filter((r) => r.url)
|
|
892
|
+
.map((r) => ({
|
|
893
|
+
url: r.url,
|
|
894
|
+
...(r.title ? { title: String(r.title) } : {}),
|
|
895
|
+
...(r.snippet ?? r.description ? { snippet: String(r.snippet ?? r.description).slice(0, 300) } : {}),
|
|
896
|
+
...(r.published_at ? { publishedAt: String(r.published_at) } : {}),
|
|
897
|
+
}));
|
|
898
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function searchKeenableMCP(query, maxResults, timeRange, signal) {
|
|
902
|
+
const controller = new AbortController();
|
|
903
|
+
const timer = setTimeout(() => controller.abort(), 25000);
|
|
904
|
+
const onAbort = () => controller.abort();
|
|
905
|
+
signal?.addEventListener("abort", onAbort);
|
|
906
|
+
let response;
|
|
907
|
+
try {
|
|
908
|
+
const arguments_ = { query };
|
|
909
|
+
// Keenable MCP 支持 published_after(相对或绝对日期)
|
|
910
|
+
if (timeRange) {
|
|
911
|
+
if (timeRange.after) arguments_.published_after = timeRange.after;
|
|
912
|
+
else if (timeRange.days !== undefined) arguments_.published_after = formatKeenableRelative(timeRange.days);
|
|
913
|
+
}
|
|
914
|
+
response = await fetch(KEENABLE_MCP_URL, {
|
|
915
|
+
method: "POST",
|
|
916
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
917
|
+
body: JSON.stringify({
|
|
918
|
+
jsonrpc: "2.0",
|
|
919
|
+
id: Date.now(),
|
|
920
|
+
method: "tools/call",
|
|
921
|
+
params: { name: "search_web_pages", arguments: arguments_ },
|
|
922
|
+
}),
|
|
923
|
+
signal: controller.signal,
|
|
924
|
+
});
|
|
925
|
+
} catch (error) {
|
|
926
|
+
if (signal?.aborted) throw error;
|
|
927
|
+
throw new Error(`Keenable MCP request failed: ${error?.message ?? String(error)}`);
|
|
928
|
+
} finally {
|
|
929
|
+
clearTimeout(timer);
|
|
930
|
+
signal?.removeEventListener("abort", onAbort);
|
|
931
|
+
}
|
|
932
|
+
if (!response.ok) throw new Error(`Keenable MCP error (HTTP ${response.status})`);
|
|
933
|
+
const data = await response.json();
|
|
934
|
+
if (data.error) throw new Error(`Keenable MCP error: ${data.error?.message ?? "unknown"}`);
|
|
935
|
+
const content = data.result?.content ?? [];
|
|
936
|
+
// isError=true 时 content 里是错误文本
|
|
937
|
+
const text = content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
938
|
+
if (data.result?.isError) throw new Error(`Keenable MCP error: ${text.slice(0, 200)}`);
|
|
939
|
+
return { sources: extractKeenableSources(text, maxResults ?? 10), truncated: false };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function searchKeenable(query, maxResults, apiKey, timeRange, signal) {
|
|
943
|
+
if (apiKey) return searchKeenableREST(query, maxResults, apiKey, timeRange, signal);
|
|
944
|
+
return searchKeenableMCP(query, maxResults, timeRange, signal);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function searchPerplexity(query, maxResults, apiKey, signal) {
|
|
948
|
+
if (!apiKey) throw new Error("Perplexity search requires PERPLEXITY_API_KEY");
|
|
949
|
+
// 内置 20s 超时(与外部 signal 组合):调用方不传 signal 时也不会永久卡住
|
|
950
|
+
const response = await fetch("https://api.perplexity.ai/chat/completions", {
|
|
951
|
+
method: "POST",
|
|
952
|
+
redirect: "error",
|
|
953
|
+
headers: {
|
|
954
|
+
authorization: `Bearer ${apiKey}`,
|
|
955
|
+
"content-type": "application/json",
|
|
956
|
+
accept: "application/json",
|
|
957
|
+
},
|
|
958
|
+
body: JSON.stringify({
|
|
959
|
+
model: "sonar",
|
|
960
|
+
max_tokens: 1024,
|
|
961
|
+
messages: [{ role: "user", content: query }],
|
|
962
|
+
}),
|
|
963
|
+
signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(20000)]),
|
|
964
|
+
});
|
|
965
|
+
if (!response.ok) {
|
|
966
|
+
const detail = await response.text().catch(() => "");
|
|
967
|
+
if (response.status === 401) {
|
|
968
|
+
throw new Error("Perplexity API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
|
|
969
|
+
}
|
|
970
|
+
throw new Error(`Perplexity API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
|
|
971
|
+
}
|
|
972
|
+
const data = await response.json();
|
|
973
|
+
const answer = data.choices?.[0]?.message?.content ?? "";
|
|
974
|
+
const citations = data.citations ?? [];
|
|
975
|
+
const sources = citations.map((url) => ({ url, ...(answer ? { snippet: answer.slice(0, 200) } : {}) }));
|
|
976
|
+
return {
|
|
977
|
+
content: answer,
|
|
978
|
+
sources: uniqueSources(sources, maxResults ?? 10),
|
|
979
|
+
truncated: false,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function searchDeepSeekOfficial(query, maxResults, apiKey, signal) {
|
|
984
|
+
if (!apiKey) throw new Error("DeepSeek search requires DEEPSEEK_API_KEY");
|
|
985
|
+
// 内置 20s 超时(与外部 signal 组合):调用方不传 signal 时也不会永久卡住
|
|
986
|
+
const response = await fetch("https://api.deepseek.com/anthropic/v1/messages", {
|
|
987
|
+
method: "POST",
|
|
988
|
+
redirect: "error",
|
|
989
|
+
headers: {
|
|
990
|
+
"x-api-key": apiKey,
|
|
991
|
+
authorization: `Bearer ${apiKey}`,
|
|
992
|
+
"anthropic-version": "2023-06-01",
|
|
993
|
+
"content-type": "application/json",
|
|
994
|
+
accept: "application/json",
|
|
995
|
+
"user-agent": "deepseek-harness/free-search",
|
|
996
|
+
},
|
|
997
|
+
body: JSON.stringify({
|
|
998
|
+
model: "deepseek-v4-flash",
|
|
999
|
+
max_tokens: 4096,
|
|
1000
|
+
messages: [
|
|
1001
|
+
{
|
|
1002
|
+
role: "user",
|
|
1003
|
+
content: [{ type: "text", text: `Perform a web search for the query: ${query}` }],
|
|
1004
|
+
},
|
|
1005
|
+
],
|
|
1006
|
+
tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 1 }],
|
|
1007
|
+
}),
|
|
1008
|
+
signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(20000)]),
|
|
1009
|
+
});
|
|
1010
|
+
if (!response.ok) {
|
|
1011
|
+
const detail = await response.text().catch(() => "");
|
|
1012
|
+
if (response.status === 401) {
|
|
1013
|
+
throw new Error("DeepSeek API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
|
|
1014
|
+
}
|
|
1015
|
+
throw new Error(`DeepSeek API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
|
|
1016
|
+
}
|
|
1017
|
+
const data = await response.json();
|
|
1018
|
+
const blocks = data.content ?? [];
|
|
1019
|
+
const resultBlocks = blocks.filter((block) => block.type === "web_search_tool_result");
|
|
1020
|
+
const snippets = new Map();
|
|
1021
|
+
for (const block of blocks) {
|
|
1022
|
+
if (block.type !== "text") continue;
|
|
1023
|
+
for (const cite of block.citations ?? []) {
|
|
1024
|
+
if (cite.url && cite.cited_text && !snippets.has(cite.url)) snippets.set(cite.url, cite.cited_text);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
const sources = [];
|
|
1028
|
+
for (const block of resultBlocks) {
|
|
1029
|
+
for (const item of block.content ?? []) {
|
|
1030
|
+
if (item.type !== "web_search_result" || !item.url) continue;
|
|
1031
|
+
if (sources.some((s) => s.url === item.url)) continue;
|
|
1032
|
+
sources.push({
|
|
1033
|
+
url: item.url,
|
|
1034
|
+
...(item.title ? { title: item.title } : {}),
|
|
1035
|
+
...(snippets.get(item.url) ? { snippet: snippets.get(item.url) } : {}),
|
|
1036
|
+
...(item.page_age ? { publishedAt: item.page_age } : {}),
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Gemini Grounding with Google Search:generateContent + tools:[{google_search:{}}]。
|
|
1044
|
+
// GroundingMetadata:
|
|
1045
|
+
// groundingChunks[].web = { uri, title }(无 snippet / publishedAt)
|
|
1046
|
+
// groundingSupports[].segment.text = 模型回答中引用该 chunk 的句子;
|
|
1047
|
+
// groundingSupports[].groundingChunkIndices = 对应 groundingChunks 下标。
|
|
1048
|
+
// snippet 用 segment.text 按下标拼接;publishedAt 无法从 Google web grounding 取得,留空。
|
|
1049
|
+
// 每个联网调用计 1 次搜索 RPD(gemini-2.5-flash:1.5K/天,RPM≈5),429 统一交给回退链。
|
|
1050
|
+
async function searchGemini(query, maxResults, apiKey, signal) {
|
|
1051
|
+
if (!apiKey) throw new Error("Gemini search requires GEMINI_API_KEY");
|
|
1052
|
+
const controller = new AbortController();
|
|
1053
|
+
const timer = setTimeout(() => controller.abort(), 20000);
|
|
1054
|
+
const onAbort = () => controller.abort();
|
|
1055
|
+
signal?.addEventListener("abort", onAbort);
|
|
1056
|
+
let response;
|
|
1057
|
+
try {
|
|
1058
|
+
response = await fetch(GEMINI_URL, {
|
|
1059
|
+
method: "POST",
|
|
1060
|
+
redirect: "error",
|
|
1061
|
+
headers: {
|
|
1062
|
+
"x-goog-api-key": apiKey,
|
|
1063
|
+
"content-type": "application/json",
|
|
1064
|
+
accept: "application/json",
|
|
1065
|
+
"user-agent": "deepseek-harness/free-search",
|
|
1066
|
+
},
|
|
1067
|
+
body: JSON.stringify({
|
|
1068
|
+
contents: [{ parts: [{ text: query }] }],
|
|
1069
|
+
tools: [{ google_search: {} }],
|
|
1070
|
+
}),
|
|
1071
|
+
signal: controller.signal,
|
|
1072
|
+
});
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
if (signal?.aborted) throw error;
|
|
1075
|
+
throw new Error(`Gemini request failed: ${error?.message ?? String(error)}`);
|
|
1076
|
+
} finally {
|
|
1077
|
+
clearTimeout(timer);
|
|
1078
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1079
|
+
}
|
|
1080
|
+
if (!response.ok) {
|
|
1081
|
+
const detail = await response.text().catch(() => "");
|
|
1082
|
+
if (response.status === 401) {
|
|
1083
|
+
throw new Error("Gemini API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
|
|
1084
|
+
}
|
|
1085
|
+
if (response.status === 429) {
|
|
1086
|
+
throw new Error(`Gemini rate-limited or daily quota exhausted (HTTP 429): ${detail.slice(0, 160)}`);
|
|
1087
|
+
}
|
|
1088
|
+
throw new Error(`Gemini API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
|
|
1089
|
+
}
|
|
1090
|
+
const data = await response.json();
|
|
1091
|
+
const candidate = data.candidates?.[0];
|
|
1092
|
+
const grounding = candidate?.groundingMetadata;
|
|
1093
|
+
const chunks = grounding?.groundingChunks ?? [];
|
|
1094
|
+
const supports = grounding?.groundingSupports ?? [];
|
|
1095
|
+
// chunk 下标 -> {url,title}
|
|
1096
|
+
const byIndex = new Map(); // Map<number, {url,title}>
|
|
1097
|
+
chunks.forEach((chunk, index) => {
|
|
1098
|
+
if (chunk?.web?.uri) byIndex.set(index, { url: chunk.web.uri, title: chunk.web.title ?? "" });
|
|
1099
|
+
});
|
|
1100
|
+
// chunk 下标 -> 引用它的回答片段(打散成句数组,最后 join)
|
|
1101
|
+
const supportTextByIndex = new Map(); // Map<number, string[]>
|
|
1102
|
+
for (const support of supports ?? []) {
|
|
1103
|
+
const segText = support?.segment?.text;
|
|
1104
|
+
if (!segText) continue;
|
|
1105
|
+
for (const idx of support.groundingChunkIndices ?? []) {
|
|
1106
|
+
if (!supportTextByIndex.has(idx)) supportTextByIndex.set(idx, []);
|
|
1107
|
+
supportTextByIndex.get(idx).push(segText);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
const sources = [];
|
|
1111
|
+
for (const [index, base] of byIndex) {
|
|
1112
|
+
const snippet = (supportTextByIndex.get(index) ?? []).join(" ").replace(/\s+/g, " ").slice(0, 300);
|
|
1113
|
+
const source = { url: base.url };
|
|
1114
|
+
if (base.title) source.title = base.title;
|
|
1115
|
+
if (snippet) source.snippet = snippet;
|
|
1116
|
+
sources.push(source);
|
|
1117
|
+
}
|
|
1118
|
+
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
1119
|
+
}
|
|
1120
|
+
//#endregion
|
|
1121
|
+
|
|
1122
|
+
//#region bridge
|
|
1123
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024;
|
|
1124
|
+
|
|
1125
|
+
function isLoopbackRequest(request) {
|
|
1126
|
+
const address = request.socket.remoteAddress;
|
|
1127
|
+
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
|
|
1128
|
+
const host = request.headers.host;
|
|
1129
|
+
if (typeof host !== "string") return false;
|
|
1130
|
+
let hostUrl;
|
|
1131
|
+
try {
|
|
1132
|
+
hostUrl = new URL("http://" + host);
|
|
1133
|
+
} catch {
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
|
|
1137
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
1138
|
+
const origin = request.headers.origin;
|
|
1139
|
+
if (origin === undefined) return true;
|
|
1140
|
+
try {
|
|
1141
|
+
return new URL(origin).host === hostUrl.host;
|
|
1142
|
+
} catch {
|
|
1143
|
+
return false;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function writeJson(res, status, body) {
|
|
1148
|
+
const payload = JSON.stringify(body);
|
|
1149
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "referrer-policy": "no-referrer" });
|
|
1150
|
+
res.end(payload);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function readJsonBody(req) {
|
|
1154
|
+
const chunks = [];
|
|
1155
|
+
let size = 0;
|
|
1156
|
+
for await (const chunk of req) {
|
|
1157
|
+
const buffer = chunk;
|
|
1158
|
+
size += buffer.length;
|
|
1159
|
+
if (size > MAX_JSON_BODY_BYTES) return undefined;
|
|
1160
|
+
chunks.push(buffer);
|
|
1161
|
+
}
|
|
1162
|
+
try {
|
|
1163
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1164
|
+
} catch {
|
|
1165
|
+
return undefined;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function toView(descriptor) {
|
|
1170
|
+
return {
|
|
1171
|
+
ns: String(descriptor.ns),
|
|
1172
|
+
schema: descriptor.schema,
|
|
1173
|
+
value: descriptor.value,
|
|
1174
|
+
...(descriptor.base === undefined ? {} : { base: descriptor.base }),
|
|
1175
|
+
...(descriptor.user === undefined ? {} : { user: descriptor.user }),
|
|
1176
|
+
...(descriptor.secrets === undefined
|
|
1177
|
+
? {}
|
|
1178
|
+
: { secrets: descriptor.secrets.map((secret) => ({ path: [...secret.path], set: secret.set })) }),
|
|
1179
|
+
revision: descriptor.revision,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function makeBridgeRoutes(settings, search, testEngine) {
|
|
1184
|
+
const allowlisted = () =>
|
|
1185
|
+
settings
|
|
1186
|
+
.describe({ redactSecrets: true })
|
|
1187
|
+
.filter((descriptor) => String(descriptor.ns) === FREE_SEARCH_NS)
|
|
1188
|
+
.map((descriptor) => String(descriptor.ns));
|
|
1189
|
+
|
|
1190
|
+
const handlers = {
|
|
1191
|
+
async checkUpdate() {
|
|
1192
|
+
const latest = await fetchLatestVersion();
|
|
1193
|
+
if (latest === null) {
|
|
1194
|
+
return {
|
|
1195
|
+
ok: false,
|
|
1196
|
+
code: "update-check-failed",
|
|
1197
|
+
message: "could not reach the npm registry (network/proxy) - check your connection",
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
const cmp = compareVersions(latest, PLUGIN_VERSION);
|
|
1201
|
+
const mode = detectInstallMode();
|
|
1202
|
+
return {
|
|
1203
|
+
ok: true,
|
|
1204
|
+
value: {
|
|
1205
|
+
current: PLUGIN_VERSION,
|
|
1206
|
+
latest,
|
|
1207
|
+
hasUpdate: cmp > 0,
|
|
1208
|
+
updateUrl: PLUGIN_NPM_URL,
|
|
1209
|
+
repoUrl: PLUGIN_REPO_URL,
|
|
1210
|
+
// 可一键升级:npm 真安装时 true;本地 link 开发模式 false(升级请 git pull 源码)
|
|
1211
|
+
installable: mode !== null && !mode.isLink,
|
|
1212
|
+
installMode: mode?.isLink ? "link" : mode !== null ? "registry" : "unknown",
|
|
1213
|
+
},
|
|
1214
|
+
};
|
|
1215
|
+
},
|
|
1216
|
+
// 一键升级:仅在 npm 真安装模式下执行 pnpm 升级;link 模式拒绝(避免破坏本地开发链路)
|
|
1217
|
+
async updatePlugin() {
|
|
1218
|
+
const mode = detectInstallMode();
|
|
1219
|
+
if (mode === null) {
|
|
1220
|
+
return { ok: false, code: "install-not-found", message: "could not locate @darlingc/dsh-freesearch in any profile" };
|
|
1221
|
+
}
|
|
1222
|
+
if (mode.isLink) {
|
|
1223
|
+
return {
|
|
1224
|
+
ok: false,
|
|
1225
|
+
code: "local-link-mode",
|
|
1226
|
+
message: "local development install (symlink) - update the source repo instead (git pull), then restart dsh",
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
const result = await new Promise((resolve, reject) => {
|
|
1231
|
+
exec("pnpm add @darlingc/dsh-freesearch@latest", { cwd: mode.profileDir, timeout: 120000 }, (error, stdout, stderr) => {
|
|
1232
|
+
if (error) reject(new Error(`upgrade failed: ${(stderr || stdout || error.message).trim().slice(0, 300)}`));
|
|
1233
|
+
else resolve(stdout);
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
const latest = await fetchLatestVersion();
|
|
1237
|
+
return {
|
|
1238
|
+
ok: true,
|
|
1239
|
+
value: {
|
|
1240
|
+
updated: true,
|
|
1241
|
+
latest: latest ?? "unknown",
|
|
1242
|
+
message: `upgraded to latest - restart dsh to apply`,
|
|
1243
|
+
output: String(result).trim().slice(0, 200),
|
|
1244
|
+
},
|
|
1245
|
+
};
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
return { ok: false, code: "upgrade-failed", message: error instanceof Error ? error.message : String(error) };
|
|
1248
|
+
}
|
|
1249
|
+
},
|
|
1250
|
+
async rawSearch(request) {
|
|
1251
|
+
if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.length === 0) {
|
|
1252
|
+
return { ok: false, code: "search-rejected", message: "malformed bridge search request (query is required)" };
|
|
1253
|
+
}
|
|
1254
|
+
const maxResults = Math.min(Math.max(Number(request.maxResults) || 5, 1), 10);
|
|
1255
|
+
const timeRange = parseTimeRange(request.timeRange);
|
|
1256
|
+
// 指定 engine:直测该引擎本身(不走回退链),报告它自己的可用性
|
|
1257
|
+
if (typeof request.engine === "string" && request.engine.length > 0) {
|
|
1258
|
+
if (typeof testEngine !== "function") {
|
|
1259
|
+
return { ok: false, code: "search-unavailable", message: "engine test is not wired" };
|
|
1260
|
+
}
|
|
1261
|
+
try {
|
|
1262
|
+
const result = await testEngine(request.engine, request.query, timeRange);
|
|
1263
|
+
if (result.ok === false) {
|
|
1264
|
+
return { ok: false, code: "engine-failed", message: result.error ?? `${request.engine} failed` };
|
|
1265
|
+
}
|
|
1266
|
+
return {
|
|
1267
|
+
ok: true,
|
|
1268
|
+
value: {
|
|
1269
|
+
provider: request.engine,
|
|
1270
|
+
sources: result.sources ?? [],
|
|
1271
|
+
content: result.content ?? "",
|
|
1272
|
+
},
|
|
1273
|
+
};
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
return { ok: false, code: "engine-failed", message: error instanceof Error ? error.message : String(error) };
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (typeof search !== "function") {
|
|
1279
|
+
return { ok: false, code: "search-unavailable", message: "search provider is not wired" };
|
|
1280
|
+
}
|
|
1281
|
+
try {
|
|
1282
|
+
const result = await search({ ...request, maxResults, timeRange });
|
|
1283
|
+
return {
|
|
1284
|
+
ok: true,
|
|
1285
|
+
value: {
|
|
1286
|
+
// 实际使用的引擎:provider.search 在成功时返回 provider 字段
|
|
1287
|
+
provider: result.provider ?? request.engine ?? request.provider ?? "bing",
|
|
1288
|
+
sources: result.sources ?? [],
|
|
1289
|
+
content: result.content ?? "",
|
|
1290
|
+
// 缓存命中标记:provider.search 成功路径标记 _cache(hit=命中缓存,miss=真实搜索)
|
|
1291
|
+
cache: result._cache === "hit" ? "hit" : "miss",
|
|
1292
|
+
},
|
|
1293
|
+
};
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
return { ok: false, code: "search-failed", message: error instanceof Error ? error.message : String(error) };
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
async describe() {
|
|
1299
|
+
const descriptors = settings.describe({ redactSecrets: true });
|
|
1300
|
+
return {
|
|
1301
|
+
ok: true,
|
|
1302
|
+
value: {
|
|
1303
|
+
namespaces: allowlisted()
|
|
1304
|
+
.map((ns) => descriptors.find((descriptor) => String(descriptor.ns) === ns))
|
|
1305
|
+
.filter((descriptor) => descriptor !== undefined)
|
|
1306
|
+
.map(toView),
|
|
1307
|
+
writable: settings.writable !== false,
|
|
1308
|
+
},
|
|
1309
|
+
};
|
|
1310
|
+
},
|
|
1311
|
+
async mutate(request) {
|
|
1312
|
+
const body = request;
|
|
1313
|
+
if (body === null || typeof body !== "object" || typeof body.ns !== "string" || !Array.isArray(body.ops)) {
|
|
1314
|
+
return { ok: false, code: "settings-rejected", message: "malformed bridge settings request" };
|
|
1315
|
+
}
|
|
1316
|
+
const { ns } = body;
|
|
1317
|
+
if (!allowlisted().includes(ns)) {
|
|
1318
|
+
return { ok: false, code: "settings-not-exposed", message: `settings namespace "${ns}" is not exposed` };
|
|
1319
|
+
}
|
|
1320
|
+
const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : undefined;
|
|
1321
|
+
try {
|
|
1322
|
+
await settings.mutate(settingsNamespace(ns), body.ops, expectedRevision);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
if (error instanceof SettingsConflictError) {
|
|
1325
|
+
return { ok: false, code: "settings-conflict", message: error.message };
|
|
1326
|
+
}
|
|
1327
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1328
|
+
return { ok: false, code: "internal", message };
|
|
1329
|
+
}
|
|
1330
|
+
const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === ns);
|
|
1331
|
+
if (descriptor === undefined) {
|
|
1332
|
+
return { ok: false, code: "internal", message: `settings namespace "${ns}" was disposed after the mutate` };
|
|
1333
|
+
}
|
|
1334
|
+
return { ok: true, value: toView(descriptor) };
|
|
1335
|
+
},
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
const guard = (req, res) => {
|
|
1339
|
+
if (!isLoopbackRequest(req)) {
|
|
1340
|
+
writeJson(res, 403, { error: "loopback requests only" });
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
if (req.method !== "POST") {
|
|
1344
|
+
writeJson(res, 405, { error: "method not allowed: " + (req.method ?? "") });
|
|
1345
|
+
return false;
|
|
1346
|
+
}
|
|
1347
|
+
return true;
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
return [
|
|
1351
|
+
{
|
|
1352
|
+
kind: "exact",
|
|
1353
|
+
path: `${BRIDGE_PREFIX}/describe`,
|
|
1354
|
+
handler: async (req, res) => {
|
|
1355
|
+
if (!guard(req, res)) return;
|
|
1356
|
+
writeJson(res, 200, await handlers.describe());
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
kind: "exact",
|
|
1361
|
+
path: `${BRIDGE_PREFIX}/mutate`,
|
|
1362
|
+
handler: async (req, res) => {
|
|
1363
|
+
if (!guard(req, res)) return;
|
|
1364
|
+
const body = await readJsonBody(req);
|
|
1365
|
+
if (body === undefined) {
|
|
1366
|
+
writeJson(res, 400, { ok: false, code: "settings-rejected", message: "malformed JSON body" });
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
writeJson(res, 200, await handlers.mutate(body));
|
|
1370
|
+
},
|
|
1371
|
+
},
|
|
1372
|
+
{
|
|
1373
|
+
kind: "exact",
|
|
1374
|
+
path: `${BRIDGE_PREFIX}/check-update`,
|
|
1375
|
+
handler: async (req, res) => {
|
|
1376
|
+
if (!guard(req, res)) return;
|
|
1377
|
+
writeJson(res, 200, await handlers.checkUpdate());
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
{
|
|
1381
|
+
kind: "exact",
|
|
1382
|
+
path: `${BRIDGE_PREFIX}/update`,
|
|
1383
|
+
handler: async (req, res) => {
|
|
1384
|
+
if (!guard(req, res)) return;
|
|
1385
|
+
writeJson(res, 200, await handlers.updatePlugin());
|
|
1386
|
+
},
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
kind: "exact",
|
|
1390
|
+
path: `${BRIDGE_PREFIX}/raw-search`,
|
|
1391
|
+
handler: async (req, res) => {
|
|
1392
|
+
if (!guard(req, res)) return;
|
|
1393
|
+
const body = await readJsonBody(req);
|
|
1394
|
+
if (body === undefined) {
|
|
1395
|
+
writeJson(res, 400, { ok: false, code: "search-rejected", message: "malformed JSON body" });
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
writeJson(res, 200, await handlers.rawSearch(body));
|
|
1399
|
+
},
|
|
1400
|
+
},
|
|
1401
|
+
];
|
|
1402
|
+
}
|
|
1403
|
+
//#endregion
|
|
1404
|
+
|
|
1405
|
+
const name = "web-search-free";
|
|
1406
|
+
const inject = ["web"];
|
|
1407
|
+
|
|
1408
|
+
const Config = z.object({
|
|
1409
|
+
provider: z.string().default("bing"),
|
|
1410
|
+
cache: z.boolean().default(true), // 单 query 结果缓存开关(防限流/省额度)
|
|
1411
|
+
cacheTtl: z.number().default(5), // 缓存时长(分钟),0-5 可配置(使用处再 clamp)
|
|
1412
|
+
lang: z.string().default("zh"),
|
|
1413
|
+
region: z.string(),
|
|
1414
|
+
bingMarket: z.string().default("zh-CN"),
|
|
1415
|
+
searxngInstances: z.array(z.string()),
|
|
1416
|
+
platforms: z.array(z.string()).default(["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"]),
|
|
1417
|
+
exaApiKey: z.string().role("secret"),
|
|
1418
|
+
tavilyApiKey: z.string().role("secret"),
|
|
1419
|
+
keenableApiKey: z.string().role("secret"),
|
|
1420
|
+
perplexityApiKey: z.string().role("secret"),
|
|
1421
|
+
deepseekApiKey: z.string().role("secret"),
|
|
1422
|
+
geminiApiKey: z.string().role("secret"),
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
function apply(ctx, config) {
|
|
1426
|
+
let current = () => config ?? {};
|
|
1427
|
+
const logger = ctx.logger;
|
|
1428
|
+
const credentials = ctx.get("credentials");
|
|
1429
|
+
|
|
1430
|
+
// 系统提示词动态刷新:设置变更时重新生成,避免显示旧引擎
|
|
1431
|
+
let refreshPrompt = null;
|
|
1432
|
+
|
|
1433
|
+
// 单 query 结果缓存(provider.search 内闭包持有):LRU 50 条 / TTL 可配置
|
|
1434
|
+
const searchCache = new Map(); // key -> { value, expiresAt }
|
|
1435
|
+
|
|
1436
|
+
// key 优先级:settings 的 free-search.<x>ApiKey > 环境变量/credentials
|
|
1437
|
+
const resolveApiKey = async (envName, settingsKey) => {
|
|
1438
|
+
const cfg = current();
|
|
1439
|
+
if (settingsKey && cfg[settingsKey]) return cfg[settingsKey];
|
|
1440
|
+
if (credentials) {
|
|
1441
|
+
try {
|
|
1442
|
+
const resolved = await credentials.resolve(envName);
|
|
1443
|
+
if (resolved?.value) return resolved.value;
|
|
1444
|
+
} catch {}
|
|
1445
|
+
}
|
|
1446
|
+
return process.env[envName] ?? "";
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
// 总控 provider:按 settings 的 provider 字段路由到任意引擎。
|
|
1450
|
+
// 任何引擎失败(缺 key / 401 / 限流 / 网络)都会自动轮流尝试下一个引擎,
|
|
1451
|
+
// 直到成功或全部失败。并在结果里附带回退提示,避免 agent 搜索直接失败。
|
|
1452
|
+
const provider = {
|
|
1453
|
+
id: "ddg",
|
|
1454
|
+
available() {
|
|
1455
|
+
return true;
|
|
1456
|
+
},
|
|
1457
|
+
// 单 query 结果缓存:key=query+maxResults+timeRangeLabel+preferred,Map 天然 LRU
|
|
1458
|
+
async search(request, signal) {
|
|
1459
|
+
// 公共咽喉校验:web_search / advanced_search / raw-search 三条路径都经过这里
|
|
1460
|
+
if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.trim().length === 0) {
|
|
1461
|
+
throw new Error("query is required");
|
|
1462
|
+
}
|
|
1463
|
+
const cfg = current();
|
|
1464
|
+
// 首选引擎:free_search 工具显式指定(request.engine)优先于设置(cfg.provider)
|
|
1465
|
+
const preferred =
|
|
1466
|
+
typeof request.engine === "string" && ALL_ENGINES.includes(request.engine)
|
|
1467
|
+
? request.engine
|
|
1468
|
+
: cfg.provider ?? "bing";
|
|
1469
|
+
// time_range 过滤(仅 advanced_search 工具透传;标准 web_search 无此参数)
|
|
1470
|
+
// 保留原始字符串用于 Note 展示;raw-search 桥可能已把 timeRange 解析成对象
|
|
1471
|
+
const timeRange = parseTimeRange(request.timeRange);
|
|
1472
|
+
const timeRangeLabel = typeof request.timeRange === "string" ? request.timeRange : String(timeRange?.days ?? timeRange?.after ?? "");
|
|
1473
|
+
|
|
1474
|
+
// 缓存 TTL(分钟,0-5 可配置);cache=false 或 ttl<=0 时完全禁用
|
|
1475
|
+
const cacheTtlMs = (Math.min(Math.max(Number(cfg.cacheTtl) ?? 5, 0), 5)) * 60 * 1000;
|
|
1476
|
+
const cacheEnabled = cfg.cache !== false && cacheTtlMs > 0;
|
|
1477
|
+
const cacheKey = cacheEnabled
|
|
1478
|
+
? buildCacheKey(request.query, request.maxResults, timeRangeLabel, preferred)
|
|
1479
|
+
: null;
|
|
1480
|
+
if (cacheKey !== null) {
|
|
1481
|
+
const hit = searchCache.get(cacheKey);
|
|
1482
|
+
if (hit && hit.expiresAt > Date.now()) {
|
|
1483
|
+
if (signal?.aborted) throw new Error("search aborted");
|
|
1484
|
+
searchCache.delete(cacheKey);
|
|
1485
|
+
searchCache.set(cacheKey, hit);
|
|
1486
|
+
// 浅拷贝 + 私有标记:sources 数组也复制一层,彻底隔离缓存对象(调用方 push/改元素不影响缓存)
|
|
1487
|
+
return { ...hit.value, sources: hit.value.sources?.slice(), _cache: "hit" };
|
|
1488
|
+
}
|
|
1489
|
+
if (hit) searchCache.delete(cacheKey);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// 统一引擎链:首选优先,然后其他付费引擎(有 key 的优先尝试),最后免费引擎
|
|
1493
|
+
const paidEngines = ["exa", "tavily", "keenable", "perplexity", "deepseek-official", "gemini"];
|
|
1494
|
+
const freeEngines = ["bing", "anysearch", "ddg", "ddg-lite", "searxng"];
|
|
1495
|
+
// 支持 time_range 过滤的引擎:tavily / exa / keenable / searxng / ddg / ddg-lite
|
|
1496
|
+
const timeEngines = ["tavily", "exa", "keenable", "searxng", "ddg", "ddg-lite"];
|
|
1497
|
+
let chain;
|
|
1498
|
+
// 首选引擎被跳过的原因(用于生成准确的 Note,避免误导 agent/用户):
|
|
1499
|
+
// - "time-filter":带 timeRange 且首选引擎不支持时间过滤(根本没尝试)
|
|
1500
|
+
// - "failed":首选引擎确实被尝试但失败(缺 key / 401 / 限流 / 0 结果 / 网络)
|
|
1501
|
+
// - null:首选引擎成功或无回退
|
|
1502
|
+
let preferredSkippedReason = null;
|
|
1503
|
+
if (timeRange) {
|
|
1504
|
+
// 有时间过滤需求时,把支持过滤的引擎排前面(首选引擎若支持仍优先)
|
|
1505
|
+
const preferredFirst = [preferred].filter((e) => timeEngines.includes(e));
|
|
1506
|
+
const otherTime = timeEngines.filter((e) => e !== preferred);
|
|
1507
|
+
const noTime = [...paidEngines, ...freeEngines].filter((e) => !timeEngines.includes(e) && e !== preferred);
|
|
1508
|
+
chain = [...preferredFirst, ...otherTime, ...noTime];
|
|
1509
|
+
if (!timeEngines.includes(preferred)) {
|
|
1510
|
+
// 首选引擎不支持时间过滤 → 它不在链里,不会被尝试(这不等于失败)
|
|
1511
|
+
preferredSkippedReason = "time-filter";
|
|
1512
|
+
}
|
|
1513
|
+
} else {
|
|
1514
|
+
const othersPaid = paidEngines.filter((e) => e !== preferred);
|
|
1515
|
+
const othersFree = freeEngines.filter((e) => e !== preferred);
|
|
1516
|
+
chain = [preferred, ...othersPaid, ...othersFree];
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
let lastError = null;
|
|
1520
|
+
let usedEngine = null;
|
|
1521
|
+
// 首选引擎若被尝试后失败,记录失败详情(用于 Note)
|
|
1522
|
+
let preferredFailure = null;
|
|
1523
|
+
// 总超时预算:串行回退时限制整条引擎链的总时长,防止各引擎超时累加达分钟级
|
|
1524
|
+
const BUDGET_MS = 30000;
|
|
1525
|
+
const deadline = Date.now() + BUDGET_MS;
|
|
1526
|
+
for (const engine of chain) {
|
|
1527
|
+
const remaining = deadline - Date.now();
|
|
1528
|
+
if (remaining <= 0) {
|
|
1529
|
+
throw new Error(`search timed out after ${BUDGET_MS / 1000}s`);
|
|
1530
|
+
}
|
|
1531
|
+
// 组合外部取消 signal + 剩余预算超时:官方 web_search 取消、引擎超时、总预算都能触发
|
|
1532
|
+
const effSignal = AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(remaining)]);
|
|
1533
|
+
try {
|
|
1534
|
+
let result;
|
|
1535
|
+
if (engine === "ddg") {
|
|
1536
|
+
result = await searchDdgHtml(request.query, request.maxResults, { ...cfg, timeRange }, effSignal);
|
|
1537
|
+
} else if (engine === "ddg-lite") {
|
|
1538
|
+
result = await searchDdgLite(request.query, request.maxResults, { ...cfg, timeRange }, effSignal);
|
|
1539
|
+
} else if (engine === "bing") {
|
|
1540
|
+
result = await searchBing(request.query, request.maxResults, cfg, effSignal);
|
|
1541
|
+
} else if (engine === "searxng") {
|
|
1542
|
+
result = await searchSearxng(request.query, request.maxResults, { ...cfg, timeRange }, effSignal);
|
|
1543
|
+
} else if (engine === "anysearch") {
|
|
1544
|
+
result = await searchAnysearch(request.query, request.maxResults, effSignal);
|
|
1545
|
+
} else if (engine === "exa") {
|
|
1546
|
+
// exa:有 key 走 REST,无 key 走 keyless MCP(免费)
|
|
1547
|
+
const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
|
|
1548
|
+
if (key) {
|
|
1549
|
+
result = await searchExa(request.query, request.maxResults, key, timeRange, effSignal);
|
|
1550
|
+
} else {
|
|
1551
|
+
result = await searchExaMCP(request.query, request.maxResults, effSignal);
|
|
1552
|
+
}
|
|
1553
|
+
} else if (engine === "tavily") {
|
|
1554
|
+
// tavily:有 key 走账号档,无 key 走 keyless(免费匿名额度)
|
|
1555
|
+
const key = await resolveApiKey("TAVILY_API_KEY", "tavilyApiKey");
|
|
1556
|
+
result = await searchTavily(request.query, request.maxResults, key, timeRange, effSignal);
|
|
1557
|
+
} else if (engine === "keenable") {
|
|
1558
|
+
// keenable:有 key 走 REST,无 key 走 keyless MCP(免费)
|
|
1559
|
+
const key = await resolveApiKey("KEENABLE_API_KEY", "keenableApiKey");
|
|
1560
|
+
result = await searchKeenable(request.query, request.maxResults, key, timeRange, effSignal);
|
|
1561
|
+
} else if (engine === "perplexity") {
|
|
1562
|
+
const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
|
|
1563
|
+
if (!key) {
|
|
1564
|
+
lastError = new Error("Perplexity requires PERPLEXITY_API_KEY");
|
|
1565
|
+
if (engine === preferred) preferredFailure = "PERPLEXITY_API_KEY is not configured";
|
|
1566
|
+
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
1567
|
+
continue; // 无 key 跳过
|
|
1568
|
+
}
|
|
1569
|
+
result = await searchPerplexity(request.query, request.maxResults, key, effSignal);
|
|
1570
|
+
} else if (engine === "deepseek-official") {
|
|
1571
|
+
const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
|
|
1572
|
+
if (!key) {
|
|
1573
|
+
lastError = new Error("DeepSeek requires DEEPSEEK_API_KEY");
|
|
1574
|
+
if (engine === preferred) preferredFailure = "DEEPSEEK_API_KEY is not configured";
|
|
1575
|
+
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
1576
|
+
continue; // 无 key 跳过
|
|
1577
|
+
}
|
|
1578
|
+
result = await searchDeepSeekOfficial(request.query, request.maxResults, key, effSignal);
|
|
1579
|
+
} else if (engine === "gemini") {
|
|
1580
|
+
const key = await resolveApiKey("GEMINI_API_KEY", "geminiApiKey");
|
|
1581
|
+
if (!key) {
|
|
1582
|
+
lastError = new Error("Gemini requires GEMINI_API_KEY");
|
|
1583
|
+
if (engine === preferred) preferredFailure = "GEMINI_API_KEY is not configured";
|
|
1584
|
+
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
1585
|
+
continue; // 无 key 跳过
|
|
1586
|
+
}
|
|
1587
|
+
result = await searchGemini(request.query, request.maxResults, key, effSignal);
|
|
1588
|
+
} else {
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
if (result.sources.length > 0) {
|
|
1593
|
+
usedEngine = engine;
|
|
1594
|
+
// 统一清洗 snippet:去登录/付费墙/订阅噪音,折叠空白(有值的才处理,保持 lossless JSON)
|
|
1595
|
+
result.sources = result.sources.map((s) =>
|
|
1596
|
+
s.snippet ? { ...s, snippet: cleanSnippet(s.snippet) } : s
|
|
1597
|
+
);
|
|
1598
|
+
// 用了非首选引擎时,在结果里附上准确提示(区分"不支持时间过滤被跳过"与"真实失败")
|
|
1599
|
+
if (engine !== preferred) {
|
|
1600
|
+
if (preferredSkippedReason === "time-filter") {
|
|
1601
|
+
result.content = `Note: ${preferred} does not support time filtering (timeRange=${timeRangeLabel}), using ${engine}.`;
|
|
1602
|
+
} else if (preferredFailure) {
|
|
1603
|
+
result.content = `Note: ${preferred} unavailable or failed (${preferredFailure}), using ${engine}.`;
|
|
1604
|
+
} else {
|
|
1605
|
+
result.content = `Note: ${preferred} unavailable or failed, using ${engine}.`;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
// 写入缓存(只缓存成功结果,失败走 throw 天然不缓存)
|
|
1609
|
+
const cached = { ...result, provider: engine, engine: engine };
|
|
1610
|
+
if (cacheKey !== null) {
|
|
1611
|
+
// fallback 条目(实际引擎≠首选)用配置 TTL 的 1/5,首选成功保持完整 TTL
|
|
1612
|
+
const entryTtlMs = engine !== preferred ? Math.max(cacheTtlMs / 5, 1000) : cacheTtlMs;
|
|
1613
|
+
searchCache.set(cacheKey, {
|
|
1614
|
+
value: cached,
|
|
1615
|
+
expiresAt: Date.now() + entryTtlMs,
|
|
1616
|
+
});
|
|
1617
|
+
if (searchCache.size > CACHE_MAX_ENTRIES) {
|
|
1618
|
+
const oldest = searchCache.keys().next().value;
|
|
1619
|
+
if (oldest !== undefined) searchCache.delete(oldest);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return { ...cached, _cache: "miss" };
|
|
1623
|
+
}
|
|
1624
|
+
lastError = new Error(`engine "${engine}" returned 0 results`);
|
|
1625
|
+
if (engine === preferred) preferredFailure = "returned 0 results";
|
|
1626
|
+
logger.warn(`free-search: ${engine} returned 0 results, trying next engine`);
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
lastError = error;
|
|
1629
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1630
|
+
if (engine === preferred) preferredFailure = message;
|
|
1631
|
+
logger.warn(`free-search: engine "${engine}" failed (${message}), trying next engine`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
throw lastError ?? new Error("all search engines failed");
|
|
1635
|
+
},
|
|
1636
|
+
};
|
|
1637
|
+
|
|
1638
|
+
installSettingsSection(ctx, FREE_SEARCH_NS, Config, config ?? {}, {
|
|
1639
|
+
setSource: (source) => {
|
|
1640
|
+
current = source;
|
|
1641
|
+
},
|
|
1642
|
+
onChange: () => {
|
|
1643
|
+
// settings 变更时刷新系统提示词(显示最新引擎)
|
|
1644
|
+
if (typeof refreshPrompt === "function") refreshPrompt();
|
|
1645
|
+
},
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
ctx.inject(["webServer", "settings"], (sctx) => {
|
|
1649
|
+
sctx.effect(() => {
|
|
1650
|
+
const disposers = makeBridgeRoutes(
|
|
1651
|
+
sctx.settings,
|
|
1652
|
+
(request) => provider.search(request, undefined),
|
|
1653
|
+
(engine, query, timeRange) => runEngineTest(engine, query, timeRange)
|
|
1654
|
+
).map((route) => sctx.webServer.register(route));
|
|
1655
|
+
return () => {
|
|
1656
|
+
for (const dispose of disposers) dispose();
|
|
1657
|
+
};
|
|
1658
|
+
}, "free-search: settings bridge");
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
ctx.web.registerSearchProvider(provider);
|
|
1662
|
+
|
|
1663
|
+
// 运行时兜底:DSH 0.1.1+ 中 profile patch 的 config 会整体覆盖 bundle patch 的 config,
|
|
1664
|
+
// 用户的 `- id: web` patch(如只设 fetchProvider)会静默抹掉 searchProvider,导致回退 DeepSeek 官方搜索。
|
|
1665
|
+
// 这里在 provider 注册后检查:未指向任何 provider(undefined)时自动接管为本插件;
|
|
1666
|
+
// 用户显式配置了其他 provider 则不动。
|
|
1667
|
+
if (!ctx.web.searchProviderId) {
|
|
1668
|
+
ctx.web.searchProviderId = provider.id;
|
|
1669
|
+
logger.info(`free-search: web.searchProvider was unset (patch override or missing config), taking over as "${provider.id}"`);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// 测试工具:让 agent 逐个测试所有搜索引擎,报告可用性
|
|
1673
|
+
const runEngineTest = async (engine, query, timeRange) => {
|
|
1674
|
+
const cfg = current();
|
|
1675
|
+
const q = query || "DeepSeek Harness";
|
|
1676
|
+
const tr = parseTimeRange(timeRange);
|
|
1677
|
+
const attempt = async () => {
|
|
1678
|
+
switch (engine) {
|
|
1679
|
+
case "ddg":
|
|
1680
|
+
return await searchDdgHtml(q, 2, { ...cfg, timeRange: tr });
|
|
1681
|
+
case "ddg-lite":
|
|
1682
|
+
return await searchDdgLite(q, 2, { ...cfg, timeRange: tr });
|
|
1683
|
+
case "bing":
|
|
1684
|
+
return await searchBing(q, 2, cfg);
|
|
1685
|
+
case "searxng":
|
|
1686
|
+
return await searchSearxng(q, 2, { ...cfg, timeRange: tr });
|
|
1687
|
+
case "anysearch":
|
|
1688
|
+
return await searchAnysearch(q, 2);
|
|
1689
|
+
case "exa": {
|
|
1690
|
+
const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
|
|
1691
|
+
if (key) return await searchExa(q, 2, key, tr);
|
|
1692
|
+
return await searchExaMCP(q, 2);
|
|
1693
|
+
}
|
|
1694
|
+
case "tavily": {
|
|
1695
|
+
const key = await resolveApiKey("TAVILY_API_KEY", "tavilyApiKey");
|
|
1696
|
+
return await searchTavily(q, 2, key, tr);
|
|
1697
|
+
}
|
|
1698
|
+
case "keenable": {
|
|
1699
|
+
const key = await resolveApiKey("KEENABLE_API_KEY", "keenableApiKey");
|
|
1700
|
+
return await searchKeenable(q, 2, key, tr);
|
|
1701
|
+
}
|
|
1702
|
+
case "perplexity": {
|
|
1703
|
+
const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
|
|
1704
|
+
if (!key) return { ok: false, error: "PERPLEXITY_API_KEY not configured" };
|
|
1705
|
+
return await searchPerplexity(q, 2, key);
|
|
1706
|
+
}
|
|
1707
|
+
case "deepseek-official": {
|
|
1708
|
+
const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
|
|
1709
|
+
if (!key) return { ok: false, error: "DEEPSEEK_API_KEY not configured" };
|
|
1710
|
+
return await searchDeepSeekOfficial(q, 2, key);
|
|
1711
|
+
}
|
|
1712
|
+
case "gemini": {
|
|
1713
|
+
const key = await resolveApiKey("GEMINI_API_KEY", "geminiApiKey");
|
|
1714
|
+
if (!key) return { ok: false, error: "GEMINI_API_KEY not configured" };
|
|
1715
|
+
return await searchGemini(q, 2, key);
|
|
1716
|
+
}
|
|
1717
|
+
default:
|
|
1718
|
+
return { ok: false, error: `unknown engine: ${engine}` };
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
try {
|
|
1722
|
+
const result = await attempt();
|
|
1723
|
+
// 付费引擎无 key:直接透传失败结果
|
|
1724
|
+
if (result.ok === false) return result;
|
|
1725
|
+
// 免费引擎偶发反爬/空结果时重试一次
|
|
1726
|
+
if (result.sources && result.sources.length === 0) {
|
|
1727
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1728
|
+
return await attempt();
|
|
1729
|
+
}
|
|
1730
|
+
return {
|
|
1731
|
+
ok: true,
|
|
1732
|
+
sources: (result.sources ?? []).map((s) =>
|
|
1733
|
+
s.snippet ? { ...s, snippet: cleanSnippet(s.snippet) } : s
|
|
1734
|
+
),
|
|
1735
|
+
truncated: result.truncated ?? false,
|
|
1736
|
+
};
|
|
1737
|
+
} catch (error) {
|
|
1738
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1739
|
+
}
|
|
1740
|
+
};
|
|
1741
|
+
|
|
1742
|
+
ctx.inject(["tools"], (sctx) => {
|
|
1743
|
+
sctx.effect(() => {
|
|
1744
|
+
const dispose = sctx.tools.register(
|
|
1745
|
+
defineTool({
|
|
1746
|
+
name: "free_search_test",
|
|
1747
|
+
description:
|
|
1748
|
+
"Test every configured web search engine and report which ones work. Use this to verify engine availability, diagnose search failures, or check whether an API key is configured.",
|
|
1749
|
+
parameters: {
|
|
1750
|
+
engines: {
|
|
1751
|
+
type: "array",
|
|
1752
|
+
description: "Which engines to test (default: all). Options: ddg, ddg-lite, bing, searxng, anysearch, exa, tavily, keenable, perplexity, deepseek-official, gemini.",
|
|
1753
|
+
items: { type: "string" },
|
|
1754
|
+
},
|
|
1755
|
+
query: {
|
|
1756
|
+
type: "string",
|
|
1757
|
+
description: "Optional search query to use for the test (default: 'DeepSeek Harness').",
|
|
1758
|
+
},
|
|
1759
|
+
},
|
|
1760
|
+
output: {
|
|
1761
|
+
schema: {
|
|
1762
|
+
type: "object",
|
|
1763
|
+
additionalProperties: false,
|
|
1764
|
+
properties: {
|
|
1765
|
+
results: {
|
|
1766
|
+
type: "array",
|
|
1767
|
+
items: {
|
|
1768
|
+
type: "object",
|
|
1769
|
+
additionalProperties: false,
|
|
1770
|
+
properties: {
|
|
1771
|
+
engine: { type: "string" },
|
|
1772
|
+
status: { type: "string" },
|
|
1773
|
+
results: { type: "number" },
|
|
1774
|
+
error: { type: "string" },
|
|
1775
|
+
sampleTitle: { type: "string" },
|
|
1776
|
+
sampleUrl: { type: "string" },
|
|
1777
|
+
},
|
|
1778
|
+
},
|
|
1779
|
+
},
|
|
1780
|
+
},
|
|
1781
|
+
},
|
|
1782
|
+
render(args, value) {
|
|
1783
|
+
const lines = value.results.map((r) => {
|
|
1784
|
+
if (r.status === "ok") {
|
|
1785
|
+
return `- ${r.engine}: OK (${r.results} results${r.sampleTitle ? `, e.g. "${r.sampleTitle.slice(0, 40)}"` : ""})`;
|
|
1786
|
+
}
|
|
1787
|
+
return `- ${r.engine}: FAIL - ${r.error}`;
|
|
1788
|
+
});
|
|
1789
|
+
return `Search engine test:\n${lines.join("\n")}`;
|
|
1790
|
+
},
|
|
1791
|
+
},
|
|
1792
|
+
async execute(args) {
|
|
1793
|
+
const engines = args.engines && args.engines.length > 0 ? args.engines : ALL_ENGINES;
|
|
1794
|
+
const results = [];
|
|
1795
|
+
for (const engine of engines) {
|
|
1796
|
+
const r = await runEngineTest(engine, args.query);
|
|
1797
|
+
if (r.ok) {
|
|
1798
|
+
const item = {
|
|
1799
|
+
engine,
|
|
1800
|
+
status: "ok",
|
|
1801
|
+
results: r.sources.length,
|
|
1802
|
+
};
|
|
1803
|
+
if (r.sources[0]?.title) item.sampleTitle = String(r.sources[0].title);
|
|
1804
|
+
if (r.sources[0]?.url) item.sampleUrl = String(r.sources[0].url);
|
|
1805
|
+
results.push(item);
|
|
1806
|
+
} else {
|
|
1807
|
+
results.push({ engine, status: "fail", error: r.error ?? "unknown error" });
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
return { results };
|
|
1811
|
+
},
|
|
1812
|
+
finalizeContent(exec, result) {
|
|
1813
|
+
// 把 render 输出包装成合法的 text block(content 必须是 block 数组)
|
|
1814
|
+
const text = result.content;
|
|
1815
|
+
if (typeof text === "string" && text.length > 0) {
|
|
1816
|
+
return [{ type: "text", text }];
|
|
1817
|
+
}
|
|
1818
|
+
return undefined;
|
|
1819
|
+
},
|
|
1820
|
+
})
|
|
1821
|
+
);
|
|
1822
|
+
return () => {
|
|
1823
|
+
dispose();
|
|
1824
|
+
};
|
|
1825
|
+
}, "free-search: test engines tool");
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
// 平台搜索工具:GitHub / V2EX / Bilibili / Reddit(公开 API,零依赖)
|
|
1829
|
+
ctx.inject(["tools"], (sctx) => {
|
|
1830
|
+
sctx.effect(() => {
|
|
1831
|
+
const dispose = sctx.tools.register(
|
|
1832
|
+
defineTool({
|
|
1833
|
+
name: "platform_search",
|
|
1834
|
+
description:
|
|
1835
|
+
"Search a specific platform (GitHub / V2EX / Bilibili / Reddit / Hacker News / Stack Overflow / Wikipedia / npm) for a query. Returns source URLs with titles and snippets. Use this when the user asks about repos, code, forum threads, videos, discussions, Q&A, encyclopedia entries, or packages.",
|
|
1836
|
+
parameters: {
|
|
1837
|
+
platform: {
|
|
1838
|
+
type: "string",
|
|
1839
|
+
description: "Platform to search: github, v2ex, bilibili, reddit, hn, stackoverflow, wikipedia, npm",
|
|
1840
|
+
},
|
|
1841
|
+
query: {
|
|
1842
|
+
type: "string",
|
|
1843
|
+
description: "The search query.",
|
|
1844
|
+
},
|
|
1845
|
+
maxResults: {
|
|
1846
|
+
type: "number",
|
|
1847
|
+
description: "Optional result count (default 5, max 10).",
|
|
1848
|
+
},
|
|
1849
|
+
},
|
|
1850
|
+
output: {
|
|
1851
|
+
schema: {
|
|
1852
|
+
type: "object",
|
|
1853
|
+
additionalProperties: false,
|
|
1854
|
+
properties: {
|
|
1855
|
+
platform: { type: "string" },
|
|
1856
|
+
sources: {
|
|
1857
|
+
type: "array",
|
|
1858
|
+
items: {
|
|
1859
|
+
type: "object",
|
|
1860
|
+
additionalProperties: false,
|
|
1861
|
+
properties: {
|
|
1862
|
+
url: { type: "string" },
|
|
1863
|
+
title: { type: "string" },
|
|
1864
|
+
snippet: { type: "string" },
|
|
1865
|
+
},
|
|
1866
|
+
},
|
|
1867
|
+
},
|
|
1868
|
+
},
|
|
1869
|
+
},
|
|
1870
|
+
render(args, value) {
|
|
1871
|
+
const lines = value.sources.map((s, i) => `- [${s.title ?? s.url}](${s.url})${s.snippet ? ` - ${s.snippet.slice(0, 120)}` : ""}`);
|
|
1872
|
+
return `Platform search (${value.platform}):\n${lines.join("\n") || "No results found."}`;
|
|
1873
|
+
},
|
|
1874
|
+
},
|
|
1875
|
+
async execute(args) {
|
|
1876
|
+
const platform = args.platform;
|
|
1877
|
+
if (!PLATFORMS[platform]) {
|
|
1878
|
+
throw new Error(`unknown platform "${platform}" - use one of: ${Object.keys(PLATFORMS).join(", ")}`);
|
|
1879
|
+
}
|
|
1880
|
+
// 平台开关:settings 里禁用某平台时,工具明确告知
|
|
1881
|
+
const enabled = current().platforms ?? ["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"];
|
|
1882
|
+
if (!enabled.includes(platform)) {
|
|
1883
|
+
throw new Error(
|
|
1884
|
+
`platform "${platform}" is disabled in Free Search settings - enable it in Settings > Plugins > Free Search to use it`
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
const limit = Math.min(args.maxResults ?? 5, 10);
|
|
1888
|
+
const result = await searchPlatform(platform, args.query, limit, undefined, current().lang);
|
|
1889
|
+
// lossless JSON 不允许 undefined 字段:剔除缺失字段
|
|
1890
|
+
const sources = (result.sources ?? []).map((s) => {
|
|
1891
|
+
const source = {};
|
|
1892
|
+
if (s.url !== undefined && s.url !== null && s.url !== "") source.url = s.url;
|
|
1893
|
+
if (s.title !== undefined && s.title !== null && s.title !== "") source.title = String(s.title);
|
|
1894
|
+
if (s.snippet !== undefined && s.snippet !== null && s.snippet !== "") source.snippet = String(s.snippet);
|
|
1895
|
+
return source;
|
|
1896
|
+
});
|
|
1897
|
+
return { platform, sources };
|
|
1898
|
+
},
|
|
1899
|
+
finalizeContent(exec, result) {
|
|
1900
|
+
// Tool-result content must be an array of content blocks, not a raw string.
|
|
1901
|
+
const text = result.content;
|
|
1902
|
+
return typeof text === "string" && text.length > 0 ? [{ type: "text", text }] : undefined;
|
|
1903
|
+
},
|
|
1904
|
+
})
|
|
1905
|
+
);
|
|
1906
|
+
return () => {
|
|
1907
|
+
dispose();
|
|
1908
|
+
};
|
|
1909
|
+
}, "free-search: platform search tool");
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
// 高级搜索工具:支持时间过滤(time_range)和指定引擎(engine)。
|
|
1913
|
+
// 走与 web_search 相同的统一回退链,但允许 agent 显式请求"最近 N 天"的结果。
|
|
1914
|
+
ctx.inject(["tools"], (sctx) => {
|
|
1915
|
+
sctx.effect(() => {
|
|
1916
|
+
const dispose = sctx.tools.register(
|
|
1917
|
+
defineTool({
|
|
1918
|
+
name: "advanced_search",
|
|
1919
|
+
description:
|
|
1920
|
+
"Search the web with optional time filtering. Use when the user wants results from a specific time window (e.g. 'last week', 'this month') or when you need to force a specific engine. Falls back across engines automatically just like web_search.",
|
|
1921
|
+
parameters: {
|
|
1922
|
+
query: {
|
|
1923
|
+
type: "string",
|
|
1924
|
+
description: "The search query.",
|
|
1925
|
+
},
|
|
1926
|
+
maxResults: {
|
|
1927
|
+
type: "number",
|
|
1928
|
+
description: "Optional result count (default 5, max 10).",
|
|
1929
|
+
},
|
|
1930
|
+
timeRange: {
|
|
1931
|
+
type: "string",
|
|
1932
|
+
description: "Optional time filter. Fixed tiers: day, week, month, year. Custom: relative like 12h, 3d, 2mo, 1y, or an absolute date like 2026-07-01 (published after that date). Exa/Keenable apply it precisely; Tavily/SearXNG/DDG map to the nearest tier.",
|
|
1933
|
+
},
|
|
1934
|
+
engine: {
|
|
1935
|
+
type: "string",
|
|
1936
|
+
description: "Optional specific engine to try first: ddg, ddg-lite, bing, searxng, anysearch, exa, tavily, keenable, perplexity, deepseek-official.",
|
|
1937
|
+
},
|
|
1938
|
+
},
|
|
1939
|
+
output: {
|
|
1940
|
+
schema: {
|
|
1941
|
+
type: "object",
|
|
1942
|
+
additionalProperties: false,
|
|
1943
|
+
properties: {
|
|
1944
|
+
provider: { type: "string" },
|
|
1945
|
+
content: { type: "string" },
|
|
1946
|
+
sources: {
|
|
1947
|
+
type: "array",
|
|
1948
|
+
items: {
|
|
1949
|
+
type: "object",
|
|
1950
|
+
additionalProperties: false,
|
|
1951
|
+
properties: {
|
|
1952
|
+
url: { type: "string" },
|
|
1953
|
+
title: { type: "string" },
|
|
1954
|
+
snippet: { type: "string" },
|
|
1955
|
+
publishedAt: { type: "string" },
|
|
1956
|
+
},
|
|
1957
|
+
},
|
|
1958
|
+
},
|
|
1959
|
+
},
|
|
1960
|
+
},
|
|
1961
|
+
render(args, value) {
|
|
1962
|
+
const lines = value.sources.map((s, i) => `- [${s.title ?? s.url}](${s.url})${s.snippet ? ` - ${s.snippet.slice(0, 120)}` : ""}${s.publishedAt ? ` (${s.publishedAt})` : ""}`);
|
|
1963
|
+
return `Search (${value.provider}${args.timeRange ? `, timeRange=${args.timeRange}` : ""}):\n${lines.join("\n") || "No results found."}${value.content ? `\n\n${value.content}` : ""}`;
|
|
1964
|
+
},
|
|
1965
|
+
},
|
|
1966
|
+
async execute(args) {
|
|
1967
|
+
if (!args.query || !String(args.query).trim()) throw new Error("query is required");
|
|
1968
|
+
const request = {
|
|
1969
|
+
query: args.query,
|
|
1970
|
+
maxResults: Math.min(args.maxResults ?? 5, 10),
|
|
1971
|
+
};
|
|
1972
|
+
if (parseTimeRange(args.timeRange) !== undefined) request.timeRange = args.timeRange;
|
|
1973
|
+
// engine 指定时:仅当该引擎可用才优先(仍走回退链,失败自动换引擎)
|
|
1974
|
+
if (args.engine && ALL_ENGINES.includes(args.engine)) request.engine = args.engine;
|
|
1975
|
+
const result = await provider.search(request);
|
|
1976
|
+
// lossless JSON 不允许 undefined 字段:按存在的值构造对象,缺字段直接省略
|
|
1977
|
+
return {
|
|
1978
|
+
provider: result.provider ?? result._provider ?? "bing",
|
|
1979
|
+
content: typeof result.content === "string" ? result.content : "",
|
|
1980
|
+
sources: (result.sources ?? []).map((s) => {
|
|
1981
|
+
const source = {};
|
|
1982
|
+
if (s.url !== undefined && s.url !== null && s.url !== "") source.url = s.url;
|
|
1983
|
+
if (s.title !== undefined && s.title !== null && s.title !== "") source.title = String(s.title);
|
|
1984
|
+
if (s.snippet !== undefined && s.snippet !== null && s.snippet !== "") source.snippet = String(s.snippet);
|
|
1985
|
+
if (s.publishedAt !== undefined && s.publishedAt !== null && s.publishedAt !== "") {
|
|
1986
|
+
source.publishedAt = String(s.publishedAt);
|
|
1987
|
+
}
|
|
1988
|
+
return source;
|
|
1989
|
+
}),
|
|
1990
|
+
};
|
|
1991
|
+
},
|
|
1992
|
+
finalizeContent(exec, result) {
|
|
1993
|
+
// Tool-result content must be an array of content blocks, not a raw string.
|
|
1994
|
+
const text = result.content;
|
|
1995
|
+
return typeof text === "string" && text.length > 0 ? [{ type: "text", text }] : undefined;
|
|
1996
|
+
},
|
|
1997
|
+
})
|
|
1998
|
+
);
|
|
1999
|
+
return () => {
|
|
2000
|
+
dispose();
|
|
2001
|
+
};
|
|
2002
|
+
}, "free-search: advanced search tool");
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
// 让 agent 知道可用搜索引擎(动态生成,随 key/设置变化)
|
|
2006
|
+
ctx.inject(["systemPrompt"], (sctx) => {
|
|
2007
|
+
let disposeSection = null;
|
|
2008
|
+
refreshPrompt = () => {
|
|
2009
|
+
if (disposeSection) {
|
|
2010
|
+
disposeSection();
|
|
2011
|
+
disposeSection = null;
|
|
2012
|
+
}
|
|
2013
|
+
disposeSection = sctx.systemPrompt.section({
|
|
2014
|
+
name: "free-search:engines",
|
|
2015
|
+
order: 500,
|
|
2016
|
+
text: [
|
|
2017
|
+
"## Available web search engines (free-search plugin)",
|
|
2018
|
+
"",
|
|
2019
|
+
"You have the web_search tool. Its backend engine is chosen in Settings > Plugins > Free Search.",
|
|
2020
|
+
"Current engine: " + (current().provider ?? "bing"),
|
|
2021
|
+
"",
|
|
2022
|
+
"Available engines and their requirements:",
|
|
2023
|
+
"- ddg (DuckDuckGo HTML) - FREE, no key (may be rate-limited)",
|
|
2024
|
+
"- ddg-lite (DuckDuckGo Lite) - FREE, no key (may be rate-limited)",
|
|
2025
|
+
"- bing (Bing) - FREE, no key (most stable)",
|
|
2026
|
+
"- searxng (meta-search, multi-instance) - FREE, no key",
|
|
2027
|
+
"- anysearch (AI search) - FREE, no key",
|
|
2028
|
+
"- exa - FREE keyless (MCP) or EXA_API_KEY for higher limits",
|
|
2029
|
+
"- tavily - FREE keyless or TAVILY_API_KEY for higher limits",
|
|
2030
|
+
"- keenable - FREE keyless (MCP) or KEENABLE_API_KEY for higher limits",
|
|
2031
|
+
"- perplexity - requires PERPLEXITY_API_KEY",
|
|
2032
|
+
"- deepseek-official - requires DEEPSEEK_API_KEY",
|
|
2033
|
+
"- gemini (Google search grounding) - requires GEMINI_API_KEY (free tier: ~1500 grounded searches/day, ~5 RPM; result count is model-controlled and may be small)",
|
|
2034
|
+
"",
|
|
2035
|
+
"IMPORTANT: If the configured engine fails (missing key, invalid key, 401, rate limit, or network error), web_search automatically tries other engines in this order: (1) the configured engine first, (2) then other engines with API keys configured (exa/tavily/keenable work keyless too, so they are tried even without a key), (3) then the remaining free engines (Bing, AnySearch, DuckDuckGo, SearXNG). This applies to ALL engines - paid or free. The results include a note showing which engine was actually used and why the preferred one was skipped. Understand the two note forms: (a) 'Note: X does not support time filtering (timeRange=...), using Y.' means X cannot filter by time so it was skipped BEFORE any attempt (X did NOT fail); (b) 'Note: X unavailable or failed (reason), using Y.' means X was actually tried but failed (missing key / invalid key / 401 / rate limit / network / 0 results). Never tell the user search is unavailable - it always falls back.",
|
|
2036
|
+
"",
|
|
2037
|
+
"Use the free_search_test tool to test which engines actually work right now.",
|
|
2038
|
+
"",
|
|
2039
|
+
"When the user wants results from a specific time window (e.g. 'last week', 'this month', 'last 3 days'), use the advanced_search tool with timeRange. Fixed tiers: day|week|month|year. Custom: 12h, 3d, 2mo, 1y, or an absolute date like 2026-07-01.",
|
|
2040
|
+
"",
|
|
2041
|
+
"For platform-specific searches (GitHub repos, V2EX threads, Bilibili videos, Reddit posts, Hacker News discussions, Stack Overflow questions, Wikipedia articles, npm packages), use the platform_search tool with platform: github|v2ex|bilibili|reddit|hn|stackoverflow|wikipedia|npm.",
|
|
2042
|
+
"",
|
|
2043
|
+
"The user can switch the search engine themselves by typing /free-search-engine in the chat — it opens a picker to choose an engine, just like the settings page. This changes the preferred engine; search still falls back to other engines automatically if it fails. You should not switch engines on your own; let the user decide.",
|
|
2044
|
+
].join("\n"),
|
|
2045
|
+
});
|
|
2046
|
+
};
|
|
2047
|
+
sctx.effect(() => {
|
|
2048
|
+
refreshPrompt();
|
|
2049
|
+
return () => {
|
|
2050
|
+
if (disposeSection) disposeSection();
|
|
2051
|
+
disposeSection = null;
|
|
2052
|
+
};
|
|
2053
|
+
}, "free-search: engine list prompt section");
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
export {
|
|
2058
|
+
ALL_ENGINES,
|
|
2059
|
+
ANYSEARCH_URL,
|
|
2060
|
+
BING_URL,
|
|
2061
|
+
Config,
|
|
2062
|
+
DDG_HTML_URL,
|
|
2063
|
+
DDG_LITE_URL,
|
|
2064
|
+
EXA_MCP_URL,
|
|
2065
|
+
FREE_ENGINES,
|
|
2066
|
+
FREE_SEARCH_NS,
|
|
2067
|
+
GEMINI_URL,
|
|
2068
|
+
KEENABLE_MCP_URL,
|
|
2069
|
+
KEENABLE_URL,
|
|
2070
|
+
PLATFORMS,
|
|
2071
|
+
SEARXNG_INSTANCES,
|
|
2072
|
+
TAVILY_URL,
|
|
2073
|
+
TIME_RANGES,
|
|
2074
|
+
apply,
|
|
2075
|
+
approximateTimeRange,
|
|
2076
|
+
formatKeenableRelative,
|
|
2077
|
+
inject,
|
|
2078
|
+
name,
|
|
2079
|
+
parseTimeRange,
|
|
2080
|
+
searchAnysearch,
|
|
2081
|
+
searchBing,
|
|
2082
|
+
searchBilibili,
|
|
2083
|
+
searchDeepSeekOfficial,
|
|
2084
|
+
searchDdgHtml,
|
|
2085
|
+
searchDdgLite,
|
|
2086
|
+
searchExa,
|
|
2087
|
+
searchExaMCP,
|
|
2088
|
+
searchGemini,
|
|
2089
|
+
searchGithub,
|
|
2090
|
+
searchHackerNews,
|
|
2091
|
+
searchKeenable,
|
|
2092
|
+
searchKeenableMCP,
|
|
2093
|
+
searchKeenableREST,
|
|
2094
|
+
searchNpm,
|
|
2095
|
+
searchPerplexity,
|
|
2096
|
+
searchPlatform,
|
|
2097
|
+
searchReddit,
|
|
2098
|
+
searchSearxng,
|
|
2099
|
+
searchStackOverflow,
|
|
2100
|
+
searchTavily,
|
|
2101
|
+
searchV2ex,
|
|
2102
|
+
searchWikipedia,
|
|
2103
|
+
};
|