dsh-free-search 0.4.9 → 0.4.11
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/README.md +2 -2
- package/cordis.patch.yml +4 -0
- package/lib/client.js +73 -22
- package/lib/index.js +127 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
|
|
|
36
36
|
- **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401/限流/网络)自动轮流尝试下一个引擎:首选引擎 → 其他引擎(exa/tavily/keenable 无 key 也会尝试,因为它们自带 keyless 免费额度)→ 剩余免费引擎,搜索永不直接失败;结果顶部注明实际生效的引擎(如 `Note: perplexity unavailable or failed, using exa.`)
|
|
37
37
|
- **时间过滤** —— `advanced_search` 工具支持 `timeRange`:固定档、自定义相对值、绝对日期三种形式(详见下方逻辑说明)
|
|
38
38
|
- **系统提示词注入** —— agent 知道当前用哪个引擎、哪些需要 key
|
|
39
|
-
- **版本号 + 检查更新** —— 设置卡片显示当前版本(v0.4.
|
|
39
|
+
- **版本号 + 检查更新** —— 设置卡片显示当前版本(v0.4.11),"检查更新"按钮直连 npm registry 对比最新版,有新版本时提示并可一键跳转
|
|
40
40
|
- **结果缓存** —— 相同查询(含引擎/时间过滤参数)5 分钟内命中缓存(LRU 50 条),防免费引擎限流、省付费额度;时长可在设置页 0-5 分钟自由配置(0 关闭)
|
|
41
41
|
- **免费标注** —— 设置页中免费引擎带绿色 `FREE` 徽章,付费引擎带橙色 `API KEY` 徽章
|
|
42
42
|
- **网页抓取(web_fetch)** —— 让 agent 抓取网页内容(官方 `dsh-web-fetch-http` provider,纯 JS,零额外依赖)
|
|
@@ -271,7 +271,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
|
|
|
271
271
|
- **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: the configured engine first, then other engines (exa/tavily/keenable are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Search never fails outright.
|
|
272
272
|
- **Time Filtering** — The `advanced_search` tool supports `timeRange`: fixed tiers, custom relative values, or an absolute date (details below)
|
|
273
273
|
- **System Prompt Injection** — The agent is aware of the currently active engine and which engines require API keys
|
|
274
|
-
- **Version + Update Check** — The settings card shows the current version (v0.4.
|
|
274
|
+
- **Version + Update Check** — The settings card shows the current version (v0.4.11), and a "Check update" button queries the npm registry to compare against the latest release, prompting a one-click jump when a newer version exists
|
|
275
275
|
- **Result Caching** — Identical queries (same engine / time-filter args) hit an LRU cache (50 entries) for up to 5 minutes, protecting free engines from rate-limiting and saving paid quota; the TTL is configurable from 0-5 minutes in the settings UI (0 disables caching)
|
|
276
276
|
- **Visual Badges** — Free engines feature a green `FREE` badge, while paid engines show an orange `API KEY` badge in the settings UI
|
|
277
277
|
- **Webpage Fetching (`web_fetch`)** — Allows the agent to read full webpage contents (official `dsh-web-fetch-http` provider, pure JS, zero extra dependencies)
|
package/cordis.patch.yml
CHANGED
package/lib/client.js
CHANGED
|
@@ -45,6 +45,9 @@ window.__ModuleLoader__.load({
|
|
|
45
45
|
".dshfs-save{border:1px solid var(--dsw-alias-button-info-fill);background:var(--dsw-alias-button-info-fill);color:var(--dsw-alias-label-primary-foreground)}",
|
|
46
46
|
".dshfs-save:hover:not(:disabled){border-color:var(--dsw-alias-button-info-hover);background:var(--dsw-alias-button-info-hover)}",
|
|
47
47
|
".dshfs-save:disabled{opacity:.5;cursor:default}",
|
|
48
|
+
".dshfs-upgrade{border:1px solid rgba(80,200,120,.4);background:rgba(80,200,120,.15);color:#7ddb9c}",
|
|
49
|
+
".dshfs-upgrade:hover:not(:disabled){background:rgba(80,200,120,.28)}",
|
|
50
|
+
".dshfs-upgrade:disabled{opacity:.5;cursor:default}",
|
|
48
51
|
".dshfs-badge{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-state-business-primary);white-space:nowrap;border-radius:999px;flex:none;padding:1px 6px;font-size:11px}",
|
|
49
52
|
".dshfs-badgeFree{background:rgba(80,200,120,.15);color:#7ddb9c;border:1px solid rgba(80,200,120,.3);white-space:nowrap;border-radius:999px;flex:none;padding:1px 6px;font-size:11px}",
|
|
50
53
|
".dshfs-badgeKey{background:rgba(240,170,80,.15);color:#f0b060;border:1px solid rgba(240,170,80,.3);white-space:nowrap;border-radius:999px;flex:none;padding:1px 6px;font-size:11px}",
|
|
@@ -102,6 +105,11 @@ window.__ModuleLoader__.load({
|
|
|
102
105
|
updateLatest: (c) => `已是最新版本 v${c}`,
|
|
103
106
|
updateCheckFailed: "检查更新失败(无法访问 npm registry)",
|
|
104
107
|
updateView: "查看 →",
|
|
108
|
+
upgrade: "升级",
|
|
109
|
+
upgrading: "升级中…",
|
|
110
|
+
upgradeLinkMode: "(本地开发模式,升级请用 git pull)",
|
|
111
|
+
upgradeDone: (l) => `升级到 v${l} 完成,重启 dsh 后生效`,
|
|
112
|
+
upgradeFailed: (m) => `升级失败:${m}`,
|
|
105
113
|
},
|
|
106
114
|
en: {
|
|
107
115
|
description: "Free web search — no API key needed (Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable)",
|
|
@@ -135,14 +143,19 @@ window.__ModuleLoader__.load({
|
|
|
135
143
|
checkUpdate: "Check update",
|
|
136
144
|
checkingUpdate: "Checking…",
|
|
137
145
|
updateAvailable: (c, l) => `New version v${l} available (current v${c})`,
|
|
138
|
-
updateLatest: (c) => `You
|
|
146
|
+
updateLatest: (c) => `You're on the latest version v${c}`,
|
|
139
147
|
updateCheckFailed: "Update check failed (cannot reach npm registry)",
|
|
140
148
|
updateView: "View →",
|
|
149
|
+
upgrade: "Upgrade",
|
|
150
|
+
upgrading: "Upgrading…",
|
|
151
|
+
upgradeLinkMode: "(local dev install - use git pull to update)",
|
|
152
|
+
upgradeDone: (l) => `Upgraded to v${l} - restart dsh to apply`,
|
|
153
|
+
upgradeFailed: (m) => `Upgrade failed: ${m}`,
|
|
141
154
|
},
|
|
142
155
|
};
|
|
143
156
|
const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
|
|
144
157
|
// 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致)
|
|
145
|
-
const PLUGIN_VERSION = "0.4.
|
|
158
|
+
const PLUGIN_VERSION = "0.4.11";
|
|
146
159
|
const ENGINES = [
|
|
147
160
|
{ id: "ddg", label: "DuckDuckGo · HTML", badge: "FREE", link: "https://duckduckgo.com" },
|
|
148
161
|
{ id: "ddg-lite", label: "DuckDuckGo · Lite", badge: "FREE", link: "https://duckduckgo.com" },
|
|
@@ -201,7 +214,7 @@ window.__ModuleLoader__.load({
|
|
|
201
214
|
const [keenableKey, setKeenableKey] = react.useState("");
|
|
202
215
|
const [perplexityKey, setPerplexityKey] = react.useState("");
|
|
203
216
|
const [deepseekKey, setDeepseekKey] = react.useState("");
|
|
204
|
-
const [platforms, setPlatforms] = react.useState(["github", "v2ex", "bilibili"]);
|
|
217
|
+
const [platforms, setPlatforms] = react.useState(["github", "v2ex", "bilibili", "reddit"]);
|
|
205
218
|
const [cacheTtl, setCacheTtl] = react.useState(5);
|
|
206
219
|
const [keysConfigured, setKeysConfigured] = react.useState({});
|
|
207
220
|
const [lang, setLang] = react.useState("zh");
|
|
@@ -211,6 +224,7 @@ window.__ModuleLoader__.load({
|
|
|
211
224
|
const [testing, setTesting] = react.useState(false);
|
|
212
225
|
const [testResult, setTestResult] = react.useState(null);
|
|
213
226
|
const [checkingUpdate, setCheckingUpdate] = react.useState(false);
|
|
227
|
+
const [upgrading, setUpgrading] = react.useState(false);
|
|
214
228
|
const [updateInfo, setUpdateInfo] = react.useState(null);
|
|
215
229
|
|
|
216
230
|
const load = react.useCallback(async () => {
|
|
@@ -227,7 +241,7 @@ window.__ModuleLoader__.load({
|
|
|
227
241
|
setKeenableKey(v.keenableApiKey ?? "");
|
|
228
242
|
setPerplexityKey(v.perplexityApiKey ?? "");
|
|
229
243
|
setDeepseekKey(v.deepseekApiKey ?? "");
|
|
230
|
-
setPlatforms(Array.isArray(v.platforms) && v.platforms.length > 0 ? v.platforms : ["github", "v2ex", "bilibili"]);
|
|
244
|
+
setPlatforms(Array.isArray(v.platforms) && v.platforms.length > 0 ? v.platforms : ["github", "v2ex", "bilibili", "reddit"]);
|
|
231
245
|
setCacheTtl(v.cacheTtl === undefined ? 5 : Math.min(Math.max(Number(v.cacheTtl) ?? 5, 0), 5));
|
|
232
246
|
// secrets 字段标记哪些 key 已配置(值被脱敏,仅显示"已配置")
|
|
233
247
|
const configured = {};
|
|
@@ -346,6 +360,29 @@ window.__ModuleLoader__.load({
|
|
|
346
360
|
}
|
|
347
361
|
};
|
|
348
362
|
|
|
363
|
+
const runUpdate = async () => {
|
|
364
|
+
setUpgrading(true);
|
|
365
|
+
setUpdateInfo(null);
|
|
366
|
+
setFailed(false);
|
|
367
|
+
try {
|
|
368
|
+
const response = await fetch(`${BRIDGE_PREFIX}/update`, {
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: { "content-type": "application/json" },
|
|
371
|
+
body: "{}",
|
|
372
|
+
});
|
|
373
|
+
const result = await response.json();
|
|
374
|
+
if (result.ok) {
|
|
375
|
+
setUpdateInfo({ ok: true, hasUpdate: false, upgraded: true, message: result.value.message, latest: result.value.latest });
|
|
376
|
+
} else {
|
|
377
|
+
setUpdateInfo({ ok: false, upgradeFailed: result.message ?? "upgrade failed" });
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
setUpdateInfo({ ok: false, upgradeFailed: "request failed" });
|
|
381
|
+
} finally {
|
|
382
|
+
setUpgrading(false);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
349
386
|
if (state.status === "loading") return null;
|
|
350
387
|
const ready = state.status === "ready";
|
|
351
388
|
const t = tt(lang);
|
|
@@ -515,7 +552,7 @@ react_jsx_runtime.jsx("div", {
|
|
|
515
552
|
react_jsx_runtime.jsx("div", {
|
|
516
553
|
className: "dshfs-platforms",
|
|
517
554
|
children: [
|
|
518
|
-
["github", "GitHub"], ["v2ex", "V2EX"], ["bilibili", "Bilibili"],
|
|
555
|
+
["github", "GitHub"], ["v2ex", "V2EX"], ["bilibili", "Bilibili"], ["reddit", "Reddit"],
|
|
519
556
|
].map(([id, label]) =>
|
|
520
557
|
react_jsx_runtime.jsx("label", {
|
|
521
558
|
className: "dshfs-platform",
|
|
@@ -605,29 +642,43 @@ react_jsx_runtime.jsx("div", {
|
|
|
605
642
|
children: checkingUpdate ? t.checkingUpdate : t.checkUpdate,
|
|
606
643
|
}),
|
|
607
644
|
updateInfo && updateInfo.ok
|
|
608
|
-
? updateInfo.
|
|
645
|
+
? updateInfo.upgraded
|
|
609
646
|
? react_jsx_runtime.jsx("span", {
|
|
610
|
-
className: "dshfs-update",
|
|
611
|
-
children: [
|
|
612
|
-
t.updateAvailable(updateInfo.current, updateInfo.latest),
|
|
613
|
-
" ",
|
|
614
|
-
react_jsx_runtime.jsx("a", {
|
|
615
|
-
className: "dshfs-link",
|
|
616
|
-
href: updateInfo.updateUrl,
|
|
617
|
-
target: "_blank",
|
|
618
|
-
rel: "noopener noreferrer",
|
|
619
|
-
children: t.updateView,
|
|
620
|
-
}),
|
|
621
|
-
],
|
|
622
|
-
})
|
|
623
|
-
: react_jsx_runtime.jsx("span", {
|
|
624
647
|
className: "dshfs-updateOk",
|
|
625
|
-
children: t.
|
|
648
|
+
children: t.upgradeDone(updateInfo.latest),
|
|
626
649
|
})
|
|
650
|
+
: updateInfo.hasUpdate
|
|
651
|
+
? react_jsx_runtime.jsx("span", {
|
|
652
|
+
className: "dshfs-update",
|
|
653
|
+
children: [
|
|
654
|
+
t.updateAvailable(updateInfo.current, updateInfo.latest),
|
|
655
|
+
" ",
|
|
656
|
+
updateInfo.installable
|
|
657
|
+
? react_jsx_runtime.jsx("button", {
|
|
658
|
+
className: "dshfs-btn dshfs-upgrade",
|
|
659
|
+
type: "button",
|
|
660
|
+
onClick: runUpdate,
|
|
661
|
+
disabled: upgrading || saving || !ready,
|
|
662
|
+
children: upgrading ? t.upgrading : t.upgrade,
|
|
663
|
+
})
|
|
664
|
+
: react_jsx_runtime.jsx("a", {
|
|
665
|
+
className: "dshfs-link",
|
|
666
|
+
href: updateInfo.updateUrl,
|
|
667
|
+
target: "_blank",
|
|
668
|
+
rel: "noopener noreferrer",
|
|
669
|
+
children: t.updateView,
|
|
670
|
+
}),
|
|
671
|
+
updateInfo.installable ? null : " " + t.upgradeLinkMode,
|
|
672
|
+
],
|
|
673
|
+
})
|
|
674
|
+
: react_jsx_runtime.jsx("span", {
|
|
675
|
+
className: "dshfs-updateOk",
|
|
676
|
+
children: t.updateLatest(updateInfo.current),
|
|
677
|
+
})
|
|
627
678
|
: updateInfo && !updateInfo.ok
|
|
628
679
|
? react_jsx_runtime.jsx("span", {
|
|
629
680
|
className: "dshfs-failed",
|
|
630
|
-
children: t.updateCheckFailed,
|
|
681
|
+
children: updateInfo.upgradeFailed ? t.upgradeFailed(updateInfo.upgradeFailed) : t.updateCheckFailed,
|
|
631
682
|
})
|
|
632
683
|
: null,
|
|
633
684
|
],
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { SettingsConflictError, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
2
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
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";
|
|
4
7
|
|
|
5
8
|
const DDG_HTML_URL = "https://html.duckduckgo.com/html/";
|
|
6
9
|
const DDG_LITE_URL = "https://lite.duckduckgo.com/lite/";
|
|
@@ -18,7 +21,7 @@ const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch"];
|
|
|
18
21
|
const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "perplexity", "deepseek-official"];
|
|
19
22
|
|
|
20
23
|
// 当前插件版本(发布时与 package.json 同步)
|
|
21
|
-
const PLUGIN_VERSION = "0.4.
|
|
24
|
+
const PLUGIN_VERSION = "0.4.11";
|
|
22
25
|
// 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
|
|
23
26
|
const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
|
|
24
27
|
const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
|
|
@@ -57,6 +60,26 @@ function compareVersions(a, b) {
|
|
|
57
60
|
return 0;
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
// 检测插件的安装模式:遍历 profiles/*/node_modules/dsh-free-search,
|
|
64
|
+
// symlink(link: 本地开发)→ isLink=true;npm 真安装 → isLink=false;找不到 → null
|
|
65
|
+
function detectInstallMode() {
|
|
66
|
+
const profilesDir = path.join(process.cwd(), "profiles");
|
|
67
|
+
let found = null;
|
|
68
|
+
try {
|
|
69
|
+
for (const name of fs.readdirSync(profilesDir)) {
|
|
70
|
+
const pkgPath = path.join(profilesDir, name, "node_modules", "dsh-free-search");
|
|
71
|
+
if (!fs.existsSync(pkgPath)) continue;
|
|
72
|
+
let isLink = false;
|
|
73
|
+
try {
|
|
74
|
+
isLink = fs.lstatSync(pkgPath).isSymbolicLink();
|
|
75
|
+
} catch {}
|
|
76
|
+
found = { profileDir: path.join(profilesDir, name), isLink };
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
// time_range 支持:固定档 day/week/month/year,或自定义(相对 12h/3d/2mo/1y、绝对 YYYY-MM-DD)
|
|
61
84
|
const TIME_RANGES = ["day", "week", "month", "year"];
|
|
62
85
|
const DAYS_BY_RANGE = { day: 1, week: 7, month: 30, year: 365 };
|
|
@@ -115,6 +138,8 @@ function decodeEntities(text) {
|
|
|
115
138
|
|
|
116
139
|
//#region 结果缓存(防限流/省额度,LRU 50 条,TTL 可配置 0-5 分钟)
|
|
117
140
|
const CACHE_MAX_ENTRIES = 50;
|
|
141
|
+
// fallback 条目(实际引擎 ≠ 首选引擎时)TTL = 配置 TTL 的 1/5(默认 5 分钟 → 60s):
|
|
142
|
+
// 首选引擎恢复后最多 1 分钟即可拿到新结果,避免回退结果被完整 TTL 钉死;首选成功条目仍用完整 TTL。
|
|
118
143
|
|
|
119
144
|
function buildCacheKey(query, maxResults, timeRangeLabel, preferred) {
|
|
120
145
|
return [query ?? "", maxResults ?? 5, timeRangeLabel ?? "", preferred].join("\u0000");
|
|
@@ -302,7 +327,8 @@ async function searchSearxng(query, maxResults, options, signal) {
|
|
|
302
327
|
const instances = options?.searxngInstances?.length
|
|
303
328
|
? options.searxngInstances
|
|
304
329
|
: SEARXNG_INSTANCES;
|
|
305
|
-
|
|
330
|
+
// 聚合所有实例的失败原因,避免只显示最后一个实例的错误
|
|
331
|
+
const errors = [];
|
|
306
332
|
for (const base of instances) {
|
|
307
333
|
try {
|
|
308
334
|
const params = new URLSearchParams({ q: query, format: "json" });
|
|
@@ -322,12 +348,12 @@ async function searchSearxng(query, maxResults, options, signal) {
|
|
|
322
348
|
clearTimeout(timer);
|
|
323
349
|
signal?.removeEventListener("abort", onAbort);
|
|
324
350
|
if (!response.ok) {
|
|
325
|
-
|
|
351
|
+
errors.push(`${base}: HTTP ${response.status}`);
|
|
326
352
|
continue;
|
|
327
353
|
}
|
|
328
354
|
const data = await response.json().catch(() => null);
|
|
329
355
|
if (!data || !Array.isArray(data.results)) {
|
|
330
|
-
|
|
356
|
+
errors.push(`${base}: invalid JSON`);
|
|
331
357
|
continue;
|
|
332
358
|
}
|
|
333
359
|
const sources = data.results
|
|
@@ -340,12 +366,16 @@ async function searchSearxng(query, maxResults, options, signal) {
|
|
|
340
366
|
if (sources.length > 0) {
|
|
341
367
|
return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
|
|
342
368
|
}
|
|
343
|
-
|
|
369
|
+
errors.push(`${base}: 0 results`);
|
|
344
370
|
} catch (error) {
|
|
345
|
-
|
|
371
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
372
|
+
errors.push(`${base}: ${message}`);
|
|
346
373
|
}
|
|
347
374
|
}
|
|
348
|
-
|
|
375
|
+
// 空实例列表兜底:避免 "all SearXNG instances failed: " 尾巴悬空
|
|
376
|
+
const detail = errors.length > 0 ? errors.join(", ") : "no instances configured";
|
|
377
|
+
// Note 会引用这个错误消息,截断避免 6 实例全挂时刷屏
|
|
378
|
+
throw new Error(`all SearXNG instances failed: ${detail.slice(0, 300)}`);
|
|
349
379
|
}
|
|
350
380
|
//#endregion
|
|
351
381
|
|
|
@@ -450,7 +480,8 @@ async function searchExaMCP(query, maxResults, signal) {
|
|
|
450
480
|
url,
|
|
451
481
|
...(title ? { title } : {}),
|
|
452
482
|
...(highlights ? { snippet: highlights.slice(0, 300) } : {}),
|
|
453
|
-
|
|
483
|
+
// 只保留日期形态(ISO 或 YYYY-MM-DD),过滤 "N/A" 等占位符
|
|
484
|
+
...(published && /^\d{4}-\d{2}-\d{2}/.test(published) ? { publishedAt: published } : {}),
|
|
454
485
|
});
|
|
455
486
|
}
|
|
456
487
|
return { sources, truncated: false };
|
|
@@ -462,6 +493,7 @@ const PLATFORMS = {
|
|
|
462
493
|
github: { name: "GitHub" },
|
|
463
494
|
v2ex: { name: "V2EX" },
|
|
464
495
|
bilibili: { name: "Bilibili" },
|
|
496
|
+
reddit: { name: "Reddit" },
|
|
465
497
|
};
|
|
466
498
|
|
|
467
499
|
async function searchGithub(query, maxResults, signal) {
|
|
@@ -700,7 +732,8 @@ function extractKeenableSources(text, maxResults) {
|
|
|
700
732
|
url,
|
|
701
733
|
...(title ? { title } : {}),
|
|
702
734
|
...(snippets ? { snippet: snippets.slice(0, 300) } : {}),
|
|
703
|
-
|
|
735
|
+
// 与 Exa MCP 一致:只保留日期形态,过滤 "N/A" 等占位符
|
|
736
|
+
...(published && /^\d{4}-\d{2}-\d{2}/.test(published) ? { publishedAt: published } : {}),
|
|
704
737
|
});
|
|
705
738
|
}
|
|
706
739
|
return uniqueSources(sources, maxResults ?? 10);
|
|
@@ -799,6 +832,7 @@ async function searchKeenable(query, maxResults, apiKey, timeRange, signal) {
|
|
|
799
832
|
|
|
800
833
|
async function searchPerplexity(query, maxResults, apiKey, signal) {
|
|
801
834
|
if (!apiKey) throw new Error("Perplexity search requires PERPLEXITY_API_KEY");
|
|
835
|
+
// 内置 20s 超时(与外部 signal 组合):调用方不传 signal 时也不会永久卡住
|
|
802
836
|
const response = await fetch("https://api.perplexity.ai/chat/completions", {
|
|
803
837
|
method: "POST",
|
|
804
838
|
redirect: "error",
|
|
@@ -812,7 +846,7 @@ async function searchPerplexity(query, maxResults, apiKey, signal) {
|
|
|
812
846
|
max_tokens: 1024,
|
|
813
847
|
messages: [{ role: "user", content: query }],
|
|
814
848
|
}),
|
|
815
|
-
...(signal !== undefined ?
|
|
849
|
+
signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(20000)]),
|
|
816
850
|
});
|
|
817
851
|
if (!response.ok) {
|
|
818
852
|
const detail = await response.text().catch(() => "");
|
|
@@ -834,6 +868,7 @@ async function searchPerplexity(query, maxResults, apiKey, signal) {
|
|
|
834
868
|
|
|
835
869
|
async function searchDeepSeekOfficial(query, maxResults, apiKey, signal) {
|
|
836
870
|
if (!apiKey) throw new Error("DeepSeek search requires DEEPSEEK_API_KEY");
|
|
871
|
+
// 内置 20s 超时(与外部 signal 组合):调用方不传 signal 时也不会永久卡住
|
|
837
872
|
const response = await fetch("https://api.deepseek.com/anthropic/v1/messages", {
|
|
838
873
|
method: "POST",
|
|
839
874
|
redirect: "error",
|
|
@@ -856,7 +891,7 @@ async function searchDeepSeekOfficial(query, maxResults, apiKey, signal) {
|
|
|
856
891
|
],
|
|
857
892
|
tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 1 }],
|
|
858
893
|
}),
|
|
859
|
-
...(signal !== undefined ?
|
|
894
|
+
signal: AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(20000)]),
|
|
860
895
|
});
|
|
861
896
|
if (!response.ok) {
|
|
862
897
|
const detail = await response.text().catch(() => "");
|
|
@@ -971,6 +1006,7 @@ function makeBridgeRoutes(settings, search, testEngine) {
|
|
|
971
1006
|
};
|
|
972
1007
|
}
|
|
973
1008
|
const cmp = compareVersions(latest, PLUGIN_VERSION);
|
|
1009
|
+
const mode = detectInstallMode();
|
|
974
1010
|
return {
|
|
975
1011
|
ok: true,
|
|
976
1012
|
value: {
|
|
@@ -979,9 +1015,46 @@ function makeBridgeRoutes(settings, search, testEngine) {
|
|
|
979
1015
|
hasUpdate: cmp > 0,
|
|
980
1016
|
updateUrl: PLUGIN_NPM_URL,
|
|
981
1017
|
repoUrl: PLUGIN_REPO_URL,
|
|
1018
|
+
// 可一键升级:npm 真安装时 true;本地 link 开发模式 false(升级请 git pull 源码)
|
|
1019
|
+
installable: mode !== null && !mode.isLink,
|
|
1020
|
+
installMode: mode?.isLink ? "link" : mode !== null ? "registry" : "unknown",
|
|
982
1021
|
},
|
|
983
1022
|
};
|
|
984
1023
|
},
|
|
1024
|
+
// 一键升级:仅在 npm 真安装模式下执行 pnpm 升级;link 模式拒绝(避免破坏本地开发链路)
|
|
1025
|
+
async updatePlugin() {
|
|
1026
|
+
const mode = detectInstallMode();
|
|
1027
|
+
if (mode === null) {
|
|
1028
|
+
return { ok: false, code: "install-not-found", message: "could not locate dsh-free-search in any profile" };
|
|
1029
|
+
}
|
|
1030
|
+
if (mode.isLink) {
|
|
1031
|
+
return {
|
|
1032
|
+
ok: false,
|
|
1033
|
+
code: "local-link-mode",
|
|
1034
|
+
message: "local development install (symlink) - update the source repo instead (git pull), then restart dsh",
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
const result = await new Promise((resolve, reject) => {
|
|
1039
|
+
exec("pnpm add dsh-free-search@latest", { cwd: mode.profileDir, timeout: 120000 }, (error, stdout, stderr) => {
|
|
1040
|
+
if (error) reject(new Error(`upgrade failed: ${(stderr || stdout || error.message).trim().slice(0, 300)}`));
|
|
1041
|
+
else resolve(stdout);
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
const latest = await fetchLatestVersion();
|
|
1045
|
+
return {
|
|
1046
|
+
ok: true,
|
|
1047
|
+
value: {
|
|
1048
|
+
updated: true,
|
|
1049
|
+
latest: latest ?? "unknown",
|
|
1050
|
+
message: `upgraded to latest - restart dsh to apply`,
|
|
1051
|
+
output: String(result).trim().slice(0, 200),
|
|
1052
|
+
},
|
|
1053
|
+
};
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
return { ok: false, code: "upgrade-failed", message: error instanceof Error ? error.message : String(error) };
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
985
1058
|
async rawSearch(request) {
|
|
986
1059
|
if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.length === 0) {
|
|
987
1060
|
return { ok: false, code: "search-rejected", message: "malformed bridge search request (query is required)" };
|
|
@@ -1112,6 +1185,14 @@ function makeBridgeRoutes(settings, search, testEngine) {
|
|
|
1112
1185
|
writeJson(res, 200, await handlers.checkUpdate());
|
|
1113
1186
|
},
|
|
1114
1187
|
},
|
|
1188
|
+
{
|
|
1189
|
+
kind: "exact",
|
|
1190
|
+
path: `${BRIDGE_PREFIX}/update`,
|
|
1191
|
+
handler: async (req, res) => {
|
|
1192
|
+
if (!guard(req, res)) return;
|
|
1193
|
+
writeJson(res, 200, await handlers.updatePlugin());
|
|
1194
|
+
},
|
|
1195
|
+
},
|
|
1115
1196
|
{
|
|
1116
1197
|
kind: "exact",
|
|
1117
1198
|
path: `${BRIDGE_PREFIX}/raw-search`,
|
|
@@ -1140,7 +1221,7 @@ const Config = z.object({
|
|
|
1140
1221
|
region: z.string(),
|
|
1141
1222
|
bingMarket: z.string().default("zh-CN"),
|
|
1142
1223
|
searxngInstances: z.array(z.string()),
|
|
1143
|
-
platforms: z.array(z.string()).default(["github", "v2ex", "bilibili"]),
|
|
1224
|
+
platforms: z.array(z.string()).default(["github", "v2ex", "bilibili", "reddit"]),
|
|
1144
1225
|
exaApiKey: z.string().role("secret"),
|
|
1145
1226
|
tavilyApiKey: z.string().role("secret"),
|
|
1146
1227
|
keenableApiKey: z.string().role("secret"),
|
|
@@ -1182,6 +1263,10 @@ function apply(ctx, config) {
|
|
|
1182
1263
|
},
|
|
1183
1264
|
// 单 query 结果缓存:key=query+maxResults+timeRangeLabel+preferred,Map 天然 LRU
|
|
1184
1265
|
async search(request, signal) {
|
|
1266
|
+
// 公共咽喉校验:web_search / advanced_search / raw-search 三条路径都经过这里
|
|
1267
|
+
if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.trim().length === 0) {
|
|
1268
|
+
throw new Error("query is required");
|
|
1269
|
+
}
|
|
1185
1270
|
const cfg = current();
|
|
1186
1271
|
// 首选引擎:free_search 工具显式指定(request.engine)优先于设置(cfg.provider)
|
|
1187
1272
|
const preferred =
|
|
@@ -1205,9 +1290,8 @@ function apply(ctx, config) {
|
|
|
1205
1290
|
if (signal?.aborted) throw new Error("search aborted");
|
|
1206
1291
|
searchCache.delete(cacheKey);
|
|
1207
1292
|
searchCache.set(cacheKey, hit);
|
|
1208
|
-
// 浅拷贝 +
|
|
1209
|
-
|
|
1210
|
-
return { ...hit.value, _cache: "hit" };
|
|
1293
|
+
// 浅拷贝 + 私有标记:sources 数组也复制一层,彻底隔离缓存对象(调用方 push/改元素不影响缓存)
|
|
1294
|
+
return { ...hit.value, sources: hit.value.sources?.slice(), _cache: "hit" };
|
|
1211
1295
|
}
|
|
1212
1296
|
if (hit) searchCache.delete(cacheKey);
|
|
1213
1297
|
}
|
|
@@ -1243,35 +1327,44 @@ function apply(ctx, config) {
|
|
|
1243
1327
|
let usedEngine = null;
|
|
1244
1328
|
// 首选引擎若被尝试后失败,记录失败详情(用于 Note)
|
|
1245
1329
|
let preferredFailure = null;
|
|
1330
|
+
// 总超时预算:串行回退时限制整条引擎链的总时长,防止各引擎超时累加达分钟级
|
|
1331
|
+
const BUDGET_MS = 30000;
|
|
1332
|
+
const deadline = Date.now() + BUDGET_MS;
|
|
1246
1333
|
for (const engine of chain) {
|
|
1334
|
+
const remaining = deadline - Date.now();
|
|
1335
|
+
if (remaining <= 0) {
|
|
1336
|
+
throw new Error(`search timed out after ${BUDGET_MS / 1000}s`);
|
|
1337
|
+
}
|
|
1338
|
+
// 组合外部取消 signal + 剩余预算超时:官方 web_search 取消、引擎超时、总预算都能触发
|
|
1339
|
+
const effSignal = AbortSignal.any([...(signal !== undefined ? [signal] : []), AbortSignal.timeout(remaining)]);
|
|
1247
1340
|
try {
|
|
1248
1341
|
let result;
|
|
1249
1342
|
if (engine === "ddg") {
|
|
1250
|
-
result = await searchDdgHtml(request.query, request.maxResults, { ...cfg, timeRange },
|
|
1343
|
+
result = await searchDdgHtml(request.query, request.maxResults, { ...cfg, timeRange }, effSignal);
|
|
1251
1344
|
} else if (engine === "ddg-lite") {
|
|
1252
|
-
result = await searchDdgLite(request.query, request.maxResults, { ...cfg, timeRange },
|
|
1345
|
+
result = await searchDdgLite(request.query, request.maxResults, { ...cfg, timeRange }, effSignal);
|
|
1253
1346
|
} else if (engine === "bing") {
|
|
1254
|
-
result = await searchBing(request.query, request.maxResults, cfg,
|
|
1347
|
+
result = await searchBing(request.query, request.maxResults, cfg, effSignal);
|
|
1255
1348
|
} else if (engine === "searxng") {
|
|
1256
|
-
result = await searchSearxng(request.query, request.maxResults, { ...cfg, timeRange },
|
|
1349
|
+
result = await searchSearxng(request.query, request.maxResults, { ...cfg, timeRange }, effSignal);
|
|
1257
1350
|
} else if (engine === "anysearch") {
|
|
1258
|
-
result = await searchAnysearch(request.query, request.maxResults,
|
|
1351
|
+
result = await searchAnysearch(request.query, request.maxResults, effSignal);
|
|
1259
1352
|
} else if (engine === "exa") {
|
|
1260
1353
|
// exa:有 key 走 REST,无 key 走 keyless MCP(免费)
|
|
1261
1354
|
const key = await resolveApiKey("EXA_API_KEY", "exaApiKey");
|
|
1262
1355
|
if (key) {
|
|
1263
|
-
result = await searchExa(request.query, request.maxResults, key, timeRange,
|
|
1356
|
+
result = await searchExa(request.query, request.maxResults, key, timeRange, effSignal);
|
|
1264
1357
|
} else {
|
|
1265
|
-
result = await searchExaMCP(request.query, request.maxResults,
|
|
1358
|
+
result = await searchExaMCP(request.query, request.maxResults, effSignal);
|
|
1266
1359
|
}
|
|
1267
1360
|
} else if (engine === "tavily") {
|
|
1268
1361
|
// tavily:有 key 走账号档,无 key 走 keyless(免费匿名额度)
|
|
1269
1362
|
const key = await resolveApiKey("TAVILY_API_KEY", "tavilyApiKey");
|
|
1270
|
-
result = await searchTavily(request.query, request.maxResults, key, timeRange,
|
|
1363
|
+
result = await searchTavily(request.query, request.maxResults, key, timeRange, effSignal);
|
|
1271
1364
|
} else if (engine === "keenable") {
|
|
1272
1365
|
// keenable:有 key 走 REST,无 key 走 keyless MCP(免费)
|
|
1273
1366
|
const key = await resolveApiKey("KEENABLE_API_KEY", "keenableApiKey");
|
|
1274
|
-
result = await searchKeenable(request.query, request.maxResults, key, timeRange,
|
|
1367
|
+
result = await searchKeenable(request.query, request.maxResults, key, timeRange, effSignal);
|
|
1275
1368
|
} else if (engine === "perplexity") {
|
|
1276
1369
|
const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
|
|
1277
1370
|
if (!key) {
|
|
@@ -1280,7 +1373,7 @@ function apply(ctx, config) {
|
|
|
1280
1373
|
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
1281
1374
|
continue; // 无 key 跳过
|
|
1282
1375
|
}
|
|
1283
|
-
result = await searchPerplexity(request.query, request.maxResults, key,
|
|
1376
|
+
result = await searchPerplexity(request.query, request.maxResults, key, effSignal);
|
|
1284
1377
|
} else if (engine === "deepseek-official") {
|
|
1285
1378
|
const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
|
|
1286
1379
|
if (!key) {
|
|
@@ -1289,7 +1382,7 @@ function apply(ctx, config) {
|
|
|
1289
1382
|
logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
|
|
1290
1383
|
continue; // 无 key 跳过
|
|
1291
1384
|
}
|
|
1292
|
-
result = await searchDeepSeekOfficial(request.query, request.maxResults, key,
|
|
1385
|
+
result = await searchDeepSeekOfficial(request.query, request.maxResults, key, effSignal);
|
|
1293
1386
|
} else {
|
|
1294
1387
|
continue;
|
|
1295
1388
|
}
|
|
@@ -1313,9 +1406,11 @@ function apply(ctx, config) {
|
|
|
1313
1406
|
// 写入缓存(只缓存成功结果,失败走 throw 天然不缓存)
|
|
1314
1407
|
const cached = { ...result, provider: engine, engine: engine };
|
|
1315
1408
|
if (cacheKey !== null) {
|
|
1409
|
+
// fallback 条目(实际引擎≠首选)用配置 TTL 的 1/5,首选成功保持完整 TTL
|
|
1410
|
+
const entryTtlMs = engine !== preferred ? Math.max(cacheTtlMs / 5, 1000) : cacheTtlMs;
|
|
1316
1411
|
searchCache.set(cacheKey, {
|
|
1317
1412
|
value: cached,
|
|
1318
|
-
expiresAt: Date.now() +
|
|
1413
|
+
expiresAt: Date.now() + entryTtlMs,
|
|
1319
1414
|
});
|
|
1320
1415
|
if (searchCache.size > CACHE_MAX_ENTRIES) {
|
|
1321
1416
|
const oldest = searchCache.keys().next().value;
|
|
@@ -1514,18 +1609,18 @@ function apply(ctx, config) {
|
|
|
1514
1609
|
}, "free-search: test engines tool");
|
|
1515
1610
|
});
|
|
1516
1611
|
|
|
1517
|
-
// 平台搜索工具:GitHub / V2EX / Bilibili(公开 API,零依赖)
|
|
1612
|
+
// 平台搜索工具:GitHub / V2EX / Bilibili / Reddit(公开 API,零依赖)
|
|
1518
1613
|
ctx.inject(["tools"], (sctx) => {
|
|
1519
1614
|
sctx.effect(() => {
|
|
1520
1615
|
const dispose = sctx.tools.register(
|
|
1521
1616
|
defineTool({
|
|
1522
1617
|
name: "platform_search",
|
|
1523
1618
|
description:
|
|
1524
|
-
"Search a specific platform (GitHub / V2EX / Bilibili) for a query. Returns source URLs with titles and snippets. Use this when the user asks about repos, code, forum threads, or
|
|
1619
|
+
"Search a specific platform (GitHub / V2EX / Bilibili / Reddit) for a query. Returns source URLs with titles and snippets. Use this when the user asks about repos, code, forum threads, videos, or discussions.",
|
|
1525
1620
|
parameters: {
|
|
1526
1621
|
platform: {
|
|
1527
1622
|
type: "string",
|
|
1528
|
-
description: "Platform to search: github, v2ex, bilibili",
|
|
1623
|
+
description: "Platform to search: github, v2ex, bilibili, reddit",
|
|
1529
1624
|
},
|
|
1530
1625
|
query: {
|
|
1531
1626
|
type: "string",
|
|
@@ -1567,7 +1662,7 @@ function apply(ctx, config) {
|
|
|
1567
1662
|
throw new Error(`unknown platform "${platform}" - use one of: ${Object.keys(PLATFORMS).join(", ")}`);
|
|
1568
1663
|
}
|
|
1569
1664
|
// 平台开关:settings 里禁用某平台时,工具明确告知
|
|
1570
|
-
const enabled = current().platforms ?? ["github", "v2ex", "bilibili"];
|
|
1665
|
+
const enabled = current().platforms ?? ["github", "v2ex", "bilibili", "reddit"];
|
|
1571
1666
|
if (!enabled.includes(platform)) {
|
|
1572
1667
|
throw new Error(
|
|
1573
1668
|
`platform "${platform}" is disabled in Free Search settings - enable it in Settings > Plugins > Free Search to use it`
|
|
@@ -1653,6 +1748,7 @@ function apply(ctx, config) {
|
|
|
1653
1748
|
},
|
|
1654
1749
|
},
|
|
1655
1750
|
async execute(args) {
|
|
1751
|
+
if (!args.query || !String(args.query).trim()) throw new Error("query is required");
|
|
1656
1752
|
const request = {
|
|
1657
1753
|
query: args.query,
|
|
1658
1754
|
maxResults: Math.min(args.maxResults ?? 5, 10),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-free-search",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.11",
|
|
4
4
|
"description": "Free web search for DeepSeek Harness: 10 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable keyless) + time filtering + platform search + web_fetch, with web settings UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|