dsh-free-search 0.2.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/lib/index.js ADDED
@@ -0,0 +1,815 @@
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
+
5
+ const DDG_HTML_URL = "https://html.duckduckgo.com/html/";
6
+ const DDG_LITE_URL = "https://lite.duckduckgo.com/lite/";
7
+ const BING_URL = "https://www.bing.com/search";
8
+ const USER_AGENT =
9
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
10
+ const ACCEPT_LANG = "zh-CN,zh;q=0.9,en;q=0.8";
11
+
12
+ const FREE_SEARCH_NS = settingsNamespace("free-search");
13
+ const BRIDGE_PREFIX = "/api/dsh-free-search-settings";
14
+ const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng"];
15
+ const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "exa", "perplexity", "deepseek-official"];
16
+
17
+ function decodeEntities(text) {
18
+ return String(text)
19
+ .replace(/&/g, "&")
20
+ .replace(/&lt;/g, "<")
21
+ .replace(/&gt;/g, ">")
22
+ .replace(/&quot;/g, '"')
23
+ .replace(/&#39;/g, "'")
24
+ .replace(/&#x27;/g, "'")
25
+ .replace(/&nbsp;/g, " ")
26
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
27
+ }
28
+
29
+ function stripTags(html) {
30
+ return decodeEntities(String(html).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
31
+ }
32
+
33
+ function extractDdgUrl(rel) {
34
+ if (!rel) return null;
35
+ const m = rel.match(/uddg=([^&]+)/);
36
+ if (m) {
37
+ try {
38
+ return decodeURIComponent(m[1]);
39
+ } catch {
40
+ return m[1];
41
+ }
42
+ }
43
+ if (rel.startsWith("//")) return `https:${rel}`;
44
+ return rel;
45
+ }
46
+
47
+ function uniqueSources(sources, limit) {
48
+ const seen = new Set();
49
+ const out = [];
50
+ for (const s of sources) {
51
+ if (s.url && !seen.has(s.url)) {
52
+ seen.add(s.url);
53
+ out.push(s);
54
+ }
55
+ if (out.length >= limit) break;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ async function fetchHtml(url, signal) {
61
+ // 单次请求超时 12s,避免挂起被当成 Connection error
62
+ let response;
63
+ try {
64
+ const controller = new AbortController();
65
+ const timer = setTimeout(() => controller.abort(), 12000);
66
+ const onAbort = () => controller.abort();
67
+ signal?.addEventListener("abort", onAbort);
68
+ response = await fetch(url, {
69
+ headers: { "user-agent": USER_AGENT, "accept-language": ACCEPT_LANG },
70
+ signal: controller.signal,
71
+ redirect: "follow",
72
+ });
73
+ clearTimeout(timer);
74
+ signal?.removeEventListener("abort", onAbort);
75
+ } catch (error) {
76
+ if (signal?.aborted) throw error;
77
+ throw new Error(`connection error: ${error?.message ?? String(error)}`);
78
+ }
79
+ if (!response.ok) {
80
+ throw new Error(`HTTP ${response.status} from ${url.split("?")[0]}`);
81
+ }
82
+ const html = await response.text();
83
+ // DuckDuckGo 反爬验证页检测(HTTP 202 或验证关键字)
84
+ if (response.status === 202 || /anomaly|captcha|unusual traffic|robot check/i.test(html.slice(0, 4000))) {
85
+ throw new Error("DuckDuckGo is rate-limited right now (anti-bot challenge, usually temporary) - Bing works");
86
+ }
87
+ return html;
88
+ }
89
+
90
+ // 带重试的抓取:网络错误/空结果时重试,间隔 1.5s,最多 3 次
91
+ async function fetchHtmlWithRetry(url, signal) {
92
+ let lastError;
93
+ for (let attempt = 1; attempt <= 3; attempt++) {
94
+ try {
95
+ const html = await fetchHtml(url, signal);
96
+ if (html.length > 500) return html;
97
+ lastError = new Error(`empty response (${html.length} bytes)`);
98
+ } catch (error) {
99
+ lastError = error;
100
+ }
101
+ if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 1500));
102
+ }
103
+ throw lastError ?? new Error("fetch failed");
104
+ }
105
+
106
+ async function searchDdgHtml(query, maxResults, options, signal) {
107
+ const params = new URLSearchParams({ q: query });
108
+ if (options?.region) params.set("kl", options.region);
109
+ const html = await fetchHtmlWithRetry(`${DDG_HTML_URL}?${params}`, signal);
110
+ const blocks = html.match(/<div class="result results_links[\s\S]*?<\/div>\s*<\/div>\s*<\/div>/g) ?? [];
111
+ const sources = [];
112
+ for (const block of blocks) {
113
+ const urlMatch = block.match(/<a[^>]*class="result__a"[^>]*href="([^"]*)"/);
114
+ const titleMatch = block.match(/<a[^>]*class="result__a"[^>]*>(.*?)<\/a>/);
115
+ const snippetMatch = block.match(/<a[^>]*class="result__snippet"[^>]*>(.*?)<\/a>/);
116
+ const dateMatch = block.match(/<span[^>]*>\s*([\dT:.+-]+)\s*<\/span>/);
117
+ const url = extractDdgUrl(urlMatch?.[1]);
118
+ if (!url) continue;
119
+ sources.push({
120
+ url,
121
+ ...(titleMatch ? { title: stripTags(titleMatch[1]) } : {}),
122
+ ...(snippetMatch ? { snippet: stripTags(snippetMatch[1]) } : {}),
123
+ ...(dateMatch ? { publishedAt: dateMatch[1] } : {}),
124
+ });
125
+ }
126
+ return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
127
+ }
128
+
129
+ async function searchDdgLite(query, maxResults, signal) {
130
+ const params = new URLSearchParams({ q: query });
131
+ const html = await fetchHtmlWithRetry(`${DDG_LITE_URL}?${params}`, signal);
132
+ const linkMatches = html.match(/<a[^>]*class=['"]result-link['"][^>]*>[\s\S]*?<\/a>/g) ?? [];
133
+ const snippetMatches = html.match(/class=['"]result-snippet['"][^>]*>([\s\S]*?)<\/td>/g) ?? [];
134
+ const sources = [];
135
+ for (let i = 0; i < linkMatches.length; i++) {
136
+ const tag = linkMatches[i];
137
+ const hrefMatch = tag.match(/href="([^"]*)"/);
138
+ const titleMatch = tag.match(/class=['"]result-link['"][^>]*>(.*?)<\/a>/);
139
+ if (!hrefMatch) continue;
140
+ const url = extractDdgUrl(hrefMatch[1]);
141
+ if (!url) continue;
142
+ const snippet = snippetMatches[i]?.match(/class=['"]result-snippet['"][^>]*>([\s\S]*?)<\/td>/)?.[1];
143
+ sources.push({
144
+ url,
145
+ ...(titleMatch ? { title: stripTags(titleMatch[1]) } : {}),
146
+ ...(snippet ? { snippet: stripTags(snippet) } : {}),
147
+ });
148
+ }
149
+ return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
150
+ }
151
+
152
+ async function searchBing(query, maxResults, options, signal) {
153
+ const params = new URLSearchParams({ q: query, mkt: options?.bingMarket ?? "zh-CN" });
154
+ const html = await fetchHtmlWithRetry(`${BING_URL}?${params}`, signal);
155
+ const blocks = html.match(/<li class="b_algo"[\s\S]*?<\/li>/g) ?? [];
156
+ const sources = [];
157
+ for (const block of blocks) {
158
+ const hrefMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]+)"/);
159
+ const titleMatch = block.match(/<h2[^>]*>[\s\S]*?<a[^>]*>(.*?)<\/a>[\s\S]*?<\/h2>/);
160
+ const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/);
161
+ if (!hrefMatch) continue;
162
+ sources.push({
163
+ url: hrefMatch[1],
164
+ ...(titleMatch ? { title: stripTags(titleMatch[1]) } : {}),
165
+ ...(snippetMatch ? { snippet: stripTags(snippetMatch[1]) } : {}),
166
+ });
167
+ }
168
+ return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
169
+ }
170
+
171
+ //#region searxng (meta-search, free instances, auto-failover)
172
+ const SEARXNG_INSTANCES = [
173
+ "https://opnxng.com",
174
+ "https://priv.au",
175
+ "https://searx.be",
176
+ "https://searx.tiekoetter.com",
177
+ "https://search.inetol.net",
178
+ "https://paulgo.io",
179
+ ];
180
+
181
+ async function searchSearxng(query, maxResults, options, signal) {
182
+ const instances = options?.searxngInstances?.length
183
+ ? options.searxngInstances
184
+ : SEARXNG_INSTANCES;
185
+ let lastError = null;
186
+ for (const base of instances) {
187
+ try {
188
+ const params = new URLSearchParams({ q: query, format: "json" });
189
+ const ctrl = new AbortController();
190
+ const timer = setTimeout(() => ctrl.abort(), 8000);
191
+ const onAbort = () => ctrl.abort();
192
+ signal?.addEventListener("abort", onAbort);
193
+ const response = await fetch(`${base}/search?${params}`, {
194
+ headers: { "user-agent": USER_AGENT, accept: "application/json" },
195
+ signal: ctrl.signal,
196
+ });
197
+ clearTimeout(timer);
198
+ signal?.removeEventListener("abort", onAbort);
199
+ if (!response.ok) {
200
+ lastError = new Error(`${base} HTTP ${response.status}`);
201
+ continue;
202
+ }
203
+ const data = await response.json().catch(() => null);
204
+ if (!data || !Array.isArray(data.results)) {
205
+ lastError = new Error(`${base} invalid JSON`);
206
+ continue;
207
+ }
208
+ const sources = data.results
209
+ .filter((r) => r.url)
210
+ .map((r) => ({
211
+ url: r.url,
212
+ ...(r.title ? { title: String(r.title) } : {}),
213
+ ...(r.content ? { snippet: String(r.content) } : {}),
214
+ }));
215
+ if (sources.length > 0) {
216
+ return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
217
+ }
218
+ lastError = new Error(`${base} 0 results`);
219
+ } catch (error) {
220
+ lastError = error;
221
+ }
222
+ }
223
+ throw lastError ?? new Error("all SearXNG instances failed");
224
+ }
225
+ //#endregion
226
+
227
+ //#region paid engines (exa / perplexity / deepseek-official)
228
+ async function searchExa(query, maxResults, apiKey, signal) {
229
+ if (!apiKey) throw new Error("Exa search requires EXA_API_KEY");
230
+ const response = await fetch("https://api.exa.ai/search", {
231
+ method: "POST",
232
+ redirect: "error",
233
+ headers: {
234
+ authorization: `Bearer ${apiKey}`,
235
+ "content-type": "application/json",
236
+ accept: "application/json",
237
+ "user-agent": "deepseek-harness/free-search",
238
+ },
239
+ body: JSON.stringify({
240
+ query,
241
+ type: "auto",
242
+ contents: { highlights: { highlightsPerUrl: 1 } },
243
+ ...(maxResults !== undefined ? { numResults: maxResults } : {}),
244
+ }),
245
+ ...(signal !== undefined ? { signal } : {}),
246
+ });
247
+ if (!response.ok) {
248
+ const detail = await response.text().catch(() => "");
249
+ if (response.status === 401) {
250
+ throw new Error("Exa API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
251
+ }
252
+ throw new Error(`Exa API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
253
+ }
254
+ const data = await response.json();
255
+ const sources = (data.results ?? [])
256
+ .map((result) => {
257
+ const snippet = result.highlights?.find((h) => h.trim().length > 0);
258
+ if (!snippet) return null;
259
+ return {
260
+ url: result.url,
261
+ ...(result.title ? { title: result.title } : {}),
262
+ snippet,
263
+ ...(result.publishedDate ? { publishedAt: result.publishedDate } : {}),
264
+ };
265
+ })
266
+ .filter(Boolean);
267
+ return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
268
+ }
269
+
270
+ async function searchPerplexity(query, maxResults, apiKey, signal) {
271
+ if (!apiKey) throw new Error("Perplexity search requires PERPLEXITY_API_KEY");
272
+ const response = await fetch("https://api.perplexity.ai/chat/completions", {
273
+ method: "POST",
274
+ redirect: "error",
275
+ headers: {
276
+ authorization: `Bearer ${apiKey}`,
277
+ "content-type": "application/json",
278
+ accept: "application/json",
279
+ },
280
+ body: JSON.stringify({
281
+ model: "sonar",
282
+ max_tokens: 1024,
283
+ messages: [{ role: "user", content: query }],
284
+ }),
285
+ ...(signal !== undefined ? { signal } : {}),
286
+ });
287
+ if (!response.ok) {
288
+ const detail = await response.text().catch(() => "");
289
+ if (response.status === 401) {
290
+ throw new Error("Perplexity API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
291
+ }
292
+ throw new Error(`Perplexity API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
293
+ }
294
+ const data = await response.json();
295
+ const answer = data.choices?.[0]?.message?.content ?? "";
296
+ const citations = data.citations ?? [];
297
+ const sources = citations.map((url) => ({ url, ...(answer ? { snippet: answer.slice(0, 200) } : {}) }));
298
+ return {
299
+ content: answer,
300
+ sources: uniqueSources(sources, maxResults ?? 10),
301
+ truncated: false,
302
+ };
303
+ }
304
+
305
+ async function searchDeepSeekOfficial(query, maxResults, apiKey, signal) {
306
+ if (!apiKey) throw new Error("DeepSeek search requires DEEPSEEK_API_KEY");
307
+ const response = await fetch("https://api.deepseek.com/anthropic/v1/messages", {
308
+ method: "POST",
309
+ redirect: "error",
310
+ headers: {
311
+ "x-api-key": apiKey,
312
+ authorization: `Bearer ${apiKey}`,
313
+ "anthropic-version": "2023-06-01",
314
+ "content-type": "application/json",
315
+ accept: "application/json",
316
+ "user-agent": "deepseek-harness/free-search",
317
+ },
318
+ body: JSON.stringify({
319
+ model: "deepseek-v4-flash",
320
+ max_tokens: 4096,
321
+ messages: [
322
+ {
323
+ role: "user",
324
+ content: [{ type: "text", text: `Perform a web search for the query: ${query}` }],
325
+ },
326
+ ],
327
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 1 }],
328
+ }),
329
+ ...(signal !== undefined ? { signal } : {}),
330
+ });
331
+ if (!response.ok) {
332
+ const detail = await response.text().catch(() => "");
333
+ if (response.status === 401) {
334
+ throw new Error("DeepSeek API key is invalid (HTTP 401) - update it in Settings > Plugins > Free Search");
335
+ }
336
+ throw new Error(`DeepSeek API error (HTTP ${response.status}): ${detail.slice(0, 200)}`);
337
+ }
338
+ const data = await response.json();
339
+ const blocks = data.content ?? [];
340
+ const resultBlocks = blocks.filter((block) => block.type === "web_search_tool_result");
341
+ const snippets = new Map();
342
+ for (const block of blocks) {
343
+ if (block.type !== "text") continue;
344
+ for (const cite of block.citations ?? []) {
345
+ if (cite.url && cite.cited_text && !snippets.has(cite.url)) snippets.set(cite.url, cite.cited_text);
346
+ }
347
+ }
348
+ const sources = [];
349
+ for (const block of resultBlocks) {
350
+ for (const item of block.content ?? []) {
351
+ if (item.type !== "web_search_result" || !item.url) continue;
352
+ if (sources.some((s) => s.url === item.url)) continue;
353
+ sources.push({
354
+ url: item.url,
355
+ ...(item.title ? { title: item.title } : {}),
356
+ ...(snippets.get(item.url) ? { snippet: snippets.get(item.url) } : {}),
357
+ ...(item.page_age ? { publishedAt: item.page_age } : {}),
358
+ });
359
+ }
360
+ }
361
+ return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
362
+ }
363
+ //#endregion
364
+
365
+ //#region bridge
366
+ const MAX_JSON_BODY_BYTES = 64 * 1024;
367
+
368
+ function isLoopbackRequest(request) {
369
+ const address = request.socket.remoteAddress;
370
+ if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
371
+ const host = request.headers.host;
372
+ if (typeof host !== "string") return false;
373
+ let hostUrl;
374
+ try {
375
+ hostUrl = new URL("http://" + host);
376
+ } catch {
377
+ return false;
378
+ }
379
+ if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
380
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
381
+ const origin = request.headers.origin;
382
+ if (origin === undefined) return true;
383
+ try {
384
+ return new URL(origin).host === hostUrl.host;
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
389
+
390
+ function writeJson(res, status, body) {
391
+ const payload = JSON.stringify(body);
392
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "referrer-policy": "no-referrer" });
393
+ res.end(payload);
394
+ }
395
+
396
+ async function readJsonBody(req) {
397
+ const chunks = [];
398
+ let size = 0;
399
+ for await (const chunk of req) {
400
+ const buffer = chunk;
401
+ size += buffer.length;
402
+ if (size > MAX_JSON_BODY_BYTES) return undefined;
403
+ chunks.push(buffer);
404
+ }
405
+ try {
406
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
407
+ } catch {
408
+ return undefined;
409
+ }
410
+ }
411
+
412
+ function toView(descriptor) {
413
+ return {
414
+ ns: String(descriptor.ns),
415
+ schema: descriptor.schema,
416
+ value: descriptor.value,
417
+ ...(descriptor.base === undefined ? {} : { base: descriptor.base }),
418
+ ...(descriptor.user === undefined ? {} : { user: descriptor.user }),
419
+ ...(descriptor.secrets === undefined
420
+ ? {}
421
+ : { secrets: descriptor.secrets.map((secret) => ({ path: [...secret.path], set: secret.set })) }),
422
+ revision: descriptor.revision,
423
+ };
424
+ }
425
+
426
+ function makeBridgeRoutes(settings) {
427
+ const allowlisted = () =>
428
+ settings
429
+ .describe({ redactSecrets: true })
430
+ .filter((descriptor) => String(descriptor.ns) === FREE_SEARCH_NS)
431
+ .map((descriptor) => String(descriptor.ns));
432
+
433
+ const handlers = {
434
+ async describe() {
435
+ const descriptors = settings.describe({ redactSecrets: true });
436
+ return {
437
+ ok: true,
438
+ value: {
439
+ namespaces: allowlisted()
440
+ .map((ns) => descriptors.find((descriptor) => String(descriptor.ns) === ns))
441
+ .filter((descriptor) => descriptor !== undefined)
442
+ .map(toView),
443
+ writable: settings.writable !== false,
444
+ },
445
+ };
446
+ },
447
+ async mutate(request) {
448
+ const body = request;
449
+ if (body === null || typeof body !== "object" || typeof body.ns !== "string" || !Array.isArray(body.ops)) {
450
+ return { ok: false, code: "settings-rejected", message: "malformed bridge settings request" };
451
+ }
452
+ const { ns } = body;
453
+ if (!allowlisted().includes(ns)) {
454
+ return { ok: false, code: "settings-not-exposed", message: `settings namespace "${ns}" is not exposed` };
455
+ }
456
+ const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : undefined;
457
+ try {
458
+ await settings.mutate(settingsNamespace(ns), body.ops, expectedRevision);
459
+ } catch (error) {
460
+ if (error instanceof SettingsConflictError) {
461
+ return { ok: false, code: "settings-conflict", message: error.message };
462
+ }
463
+ const message = error instanceof Error ? error.message : String(error);
464
+ return { ok: false, code: "internal", message };
465
+ }
466
+ const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === ns);
467
+ if (descriptor === undefined) {
468
+ return { ok: false, code: "internal", message: `settings namespace "${ns}" was disposed after the mutate` };
469
+ }
470
+ return { ok: true, value: toView(descriptor) };
471
+ },
472
+ };
473
+
474
+ const guard = (req, res) => {
475
+ if (!isLoopbackRequest(req)) {
476
+ writeJson(res, 403, { error: "loopback requests only" });
477
+ return false;
478
+ }
479
+ if (req.method !== "POST") {
480
+ writeJson(res, 405, { error: "method not allowed: " + (req.method ?? "") });
481
+ return false;
482
+ }
483
+ return true;
484
+ };
485
+
486
+ return [
487
+ {
488
+ kind: "exact",
489
+ path: `${BRIDGE_PREFIX}/describe`,
490
+ handler: async (req, res) => {
491
+ if (!guard(req, res)) return;
492
+ writeJson(res, 200, await handlers.describe());
493
+ },
494
+ },
495
+ {
496
+ kind: "exact",
497
+ path: `${BRIDGE_PREFIX}/mutate`,
498
+ handler: async (req, res) => {
499
+ if (!guard(req, res)) return;
500
+ const body = await readJsonBody(req);
501
+ if (body === undefined) {
502
+ writeJson(res, 400, { ok: false, code: "settings-rejected", message: "malformed JSON body" });
503
+ return;
504
+ }
505
+ writeJson(res, 200, await handlers.mutate(body));
506
+ },
507
+ },
508
+ ];
509
+ }
510
+ //#endregion
511
+
512
+ const name = "web-search-free";
513
+ const inject = ["web"];
514
+
515
+ const Config = z.object({
516
+ provider: z.string().default("bing"),
517
+ region: z.string(),
518
+ bingMarket: z.string().default("zh-CN"),
519
+ searxngInstances: z.array(z.string()),
520
+ exaApiKey: z.string().role("secret"),
521
+ perplexityApiKey: z.string().role("secret"),
522
+ deepseekApiKey: z.string().role("secret"),
523
+ });
524
+
525
+ function apply(ctx, config) {
526
+ let current = () => config ?? {};
527
+ const logger = ctx.logger;
528
+ const credentials = ctx.get("credentials");
529
+
530
+ // key 优先级:settings 的 free-search.<x>ApiKey > 环境变量/credentials
531
+ const resolveApiKey = async (envName, settingsKey) => {
532
+ const cfg = current();
533
+ if (settingsKey && cfg[settingsKey]) return cfg[settingsKey];
534
+ if (credentials) {
535
+ try {
536
+ const resolved = await credentials.resolve(envName);
537
+ if (resolved?.value) return resolved.value;
538
+ } catch {}
539
+ }
540
+ return process.env[envName] ?? "";
541
+ };
542
+
543
+ // 总控 provider:按 settings 的 provider 字段路由到任意引擎。
544
+ // 免费引擎失败自动回退其他免费引擎;付费引擎缺 key 时报清晰错误(不静默切换)。
545
+ const provider = {
546
+ id: "ddg",
547
+ available() {
548
+ return true;
549
+ },
550
+ async search(request, signal) {
551
+ const cfg = current();
552
+ const preferred = cfg.provider ?? "bing";
553
+
554
+ // 付费引擎:直接调用,缺 key 报错
555
+ if (preferred === "exa") {
556
+ const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
557
+ return searchExa(request.query, request.maxResults, key, signal);
558
+ }
559
+ if (preferred === "perplexity") {
560
+ const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
561
+ return searchPerplexity(request.query, request.maxResults, key, signal);
562
+ }
563
+ if (preferred === "deepseek-official") {
564
+ const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
565
+ return searchDeepSeekOfficial(request.query, request.maxResults, key, signal);
566
+ }
567
+
568
+ // 免费引擎:首选 + 自动回退(searxng 元搜索多实例自动切换)
569
+ const chain =
570
+ preferred === "ddg-lite"
571
+ ? ["ddg-lite", "ddg", "searxng", "bing"]
572
+ : preferred === "bing"
573
+ ? ["bing", "searxng", "ddg", "ddg-lite"]
574
+ : preferred === "searxng"
575
+ ? ["searxng", "bing", "ddg", "ddg-lite"]
576
+ : ["ddg", "searxng", "ddg-lite", "bing"];
577
+ let lastError = null;
578
+ for (const engine of chain) {
579
+ try {
580
+ let result;
581
+ switch (engine) {
582
+ case "ddg":
583
+ result = await searchDdgHtml(request.query, request.maxResults, cfg, signal);
584
+ break;
585
+ case "ddg-lite":
586
+ result = await searchDdgLite(request.query, request.maxResults, signal);
587
+ break;
588
+ case "bing":
589
+ result = await searchBing(request.query, request.maxResults, cfg, signal);
590
+ break;
591
+ case "searxng":
592
+ result = await searchSearxng(request.query, request.maxResults, cfg, signal);
593
+ break;
594
+ default:
595
+ result = await searchDdgHtml(request.query, request.maxResults, cfg, signal);
596
+ }
597
+ if (result.sources.length > 0) return result;
598
+ lastError = new Error(`engine "${engine}" returned 0 results`);
599
+ logger.warn(`free-search: ${engine} returned 0 results, trying next engine`);
600
+ } catch (error) {
601
+ lastError = error;
602
+ const message = error instanceof Error ? error.message : String(error);
603
+ logger.warn(`free-search: engine "${engine}" failed (${message}), trying next engine`);
604
+ }
605
+ }
606
+ throw lastError ?? new Error("all search engines failed");
607
+ },
608
+ };
609
+
610
+ installSettingsSection(ctx, FREE_SEARCH_NS, Config, config ?? {}, {
611
+ setSource: (source) => {
612
+ current = source;
613
+ },
614
+ onChange: () => {},
615
+ });
616
+
617
+ ctx.inject(["webServer", "settings"], (sctx) => {
618
+ sctx.effect(() => {
619
+ const disposers = makeBridgeRoutes(sctx.settings).map((route) => sctx.webServer.register(route));
620
+ return () => {
621
+ for (const dispose of disposers) dispose();
622
+ };
623
+ }, "free-search: settings bridge");
624
+ });
625
+
626
+ ctx.web.registerSearchProvider(provider);
627
+
628
+ // 测试工具:让 agent 逐个测试所有搜索引擎,报告可用性
629
+ const runEngineTest = async (engine, query) => {
630
+ const cfg = current();
631
+ const q = query || "DeepSeek Harness";
632
+ const attempt = async () => {
633
+ switch (engine) {
634
+ case "ddg":
635
+ return await searchDdgHtml(q, 2, cfg);
636
+ case "ddg-lite":
637
+ return await searchDdgLite(q, 2);
638
+ case "bing":
639
+ return await searchBing(q, 2, cfg);
640
+ case "searxng":
641
+ return await searchSearxng(q, 2, cfg);
642
+ case "exa": {
643
+ const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
644
+ if (!key) return { ok: false, error: "EXA_API_KEY not configured" };
645
+ return await searchExa(q, 2, key);
646
+ }
647
+ case "perplexity": {
648
+ const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
649
+ if (!key) return { ok: false, error: "PERPLEXITY_API_KEY not configured" };
650
+ return await searchPerplexity(q, 2, key);
651
+ }
652
+ case "deepseek-official": {
653
+ const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
654
+ if (!key) return { ok: false, error: "DEEPSEEK_API_KEY not configured" };
655
+ return await searchDeepSeekOfficial(q, 2, key);
656
+ }
657
+ default:
658
+ return { ok: false, error: `unknown engine: ${engine}` };
659
+ }
660
+ };
661
+ try {
662
+ const result = await attempt();
663
+ // 付费引擎无 key:直接透传失败结果
664
+ if (result.ok === false) return result;
665
+ // 免费引擎偶发反爬/空结果时重试一次
666
+ if (result.sources && result.sources.length === 0) {
667
+ await new Promise((resolve) => setTimeout(resolve, 1500));
668
+ return await attempt();
669
+ }
670
+ return { ok: true, sources: result.sources ?? [], truncated: result.truncated ?? false };
671
+ } catch (error) {
672
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
673
+ }
674
+ };
675
+
676
+ ctx.inject(["tools"], (sctx) => {
677
+ sctx.effect(() => {
678
+ const dispose = sctx.tools.register(
679
+ defineTool({
680
+ name: "free_search_test",
681
+ description:
682
+ "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.",
683
+ parameters: {
684
+ engines: {
685
+ type: "array",
686
+ description: "Which engines to test (default: all). Options: ddg, ddg-lite, bing, exa, perplexity, deepseek-official.",
687
+ items: { type: "string" },
688
+ },
689
+ query: {
690
+ type: "string",
691
+ description: "Optional search query to use for the test (default: 'DeepSeek Harness').",
692
+ },
693
+ },
694
+ output: {
695
+ schema: {
696
+ type: "object",
697
+ additionalProperties: false,
698
+ properties: {
699
+ results: {
700
+ type: "array",
701
+ items: {
702
+ type: "object",
703
+ additionalProperties: false,
704
+ properties: {
705
+ engine: { type: "string" },
706
+ status: { type: "string" },
707
+ results: { type: "number" },
708
+ error: { type: "string" },
709
+ sampleTitle: { type: "string" },
710
+ sampleUrl: { type: "string" },
711
+ },
712
+ },
713
+ },
714
+ },
715
+ },
716
+ render(args, value) {
717
+ const lines = value.results.map((r) => {
718
+ if (r.status === "ok") {
719
+ return `- ${r.engine}: OK (${r.results} results${r.sampleTitle ? `, e.g. "${r.sampleTitle.slice(0, 40)}"` : ""})`;
720
+ }
721
+ return `- ${r.engine}: FAIL - ${r.error}`;
722
+ });
723
+ return `Search engine test:\n${lines.join("\n")}`;
724
+ },
725
+ },
726
+ async execute(args) {
727
+ const engines = args.engines && args.engines.length > 0 ? args.engines : ALL_ENGINES;
728
+ const results = [];
729
+ for (const engine of engines) {
730
+ const r = await runEngineTest(engine, args.query);
731
+ if (r.ok) {
732
+ const item = {
733
+ engine,
734
+ status: "ok",
735
+ results: r.sources.length,
736
+ };
737
+ if (r.sources[0]?.title) item.sampleTitle = String(r.sources[0].title);
738
+ if (r.sources[0]?.url) item.sampleUrl = String(r.sources[0].url);
739
+ results.push(item);
740
+ } else {
741
+ results.push({ engine, status: "fail", error: r.error ?? "unknown error" });
742
+ }
743
+ }
744
+ return { results };
745
+ },
746
+ finalizeContent(exec, result) {
747
+ // 把 render 输出包装成合法的 text block(content 必须是 block 数组)
748
+ const text = result.content;
749
+ if (typeof text === "string" && text.length > 0) {
750
+ return [{ type: "text", text }];
751
+ }
752
+ return undefined;
753
+ },
754
+ })
755
+ );
756
+ return () => {
757
+ dispose();
758
+ };
759
+ }, "free-search: test engines tool");
760
+ });
761
+
762
+ // 让 agent 知道可用搜索引擎(动态生成,随 key 配置变化)
763
+ ctx.inject(["systemPrompt"], (sctx) => {
764
+ sctx.effect(() => {
765
+ const section = {
766
+ name: "free-search:engines",
767
+ order: 500,
768
+ text: [
769
+ "## Available web search engines (free-search plugin)",
770
+ "",
771
+ "You have the web_search tool. Its backend engine is chosen in Settings > Plugins > Free Search.",
772
+ "Current engine: " + (current().provider ?? "bing"),
773
+ "",
774
+ "Available engines and their requirements:",
775
+ "- ddg (DuckDuckGo HTML) - FREE, no key (may be rate-limited)",
776
+ "- ddg-lite (DuckDuckGo Lite) - FREE, no key (may be rate-limited)",
777
+ "- bing (Bing) - FREE, no key (most stable)",
778
+ "- searxng (meta-search, multi-instance) - FREE, no key",
779
+ "- exa - requires EXA_API_KEY",
780
+ "- perplexity - requires PERPLEXITY_API_KEY",
781
+ "- deepseek-official - requires DEEPSEEK_API_KEY",
782
+ "",
783
+ "FREE engines auto-fallback to another FREE engine on failure. Paid engines fail with a clear error when their key is missing - tell the user which key to configure.",
784
+ "",
785
+ "Use the free_search_test tool to test which engines actually work right now.",
786
+ ].join("\n"),
787
+ };
788
+ const dispose = sctx.systemPrompt.section(section);
789
+ return () => {
790
+ dispose();
791
+ };
792
+ }, "free-search: engine list prompt section");
793
+ });
794
+ }
795
+
796
+ export {
797
+ ALL_ENGINES,
798
+ BING_URL,
799
+ Config,
800
+ DDG_HTML_URL,
801
+ DDG_LITE_URL,
802
+ FREE_ENGINES,
803
+ FREE_SEARCH_NS,
804
+ SEARXNG_INSTANCES,
805
+ apply,
806
+ inject,
807
+ name,
808
+ searchBing,
809
+ searchDeepSeekOfficial,
810
+ searchDdgHtml,
811
+ searchDdgLite,
812
+ searchExa,
813
+ searchPerplexity,
814
+ searchSearxng,
815
+ };