dsh-free-search 0.4.10 → 0.4.12
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 +17 -7
- package/cordis.patch.yml +4 -0
- package/lib/client.js +73 -21
- package/lib/index.js +198 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,11 +36,11 @@ 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.12),"检查更新"按钮直连 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,零额外依赖)
|
|
43
|
-
- **平台搜索(platform_search)** —— 搜 GitHub / V2EX / B
|
|
43
|
+
- **平台搜索(platform_search)** —— 搜 GitHub / V2EX / B站 / Reddit / Hacker News / Stack Overflow / 维基百科 / npm(公开 API,零依赖)
|
|
44
44
|
- **干净集成** —— 实现官方 `WebSearchProvider` seam 接口,与官方插件共存
|
|
45
45
|
|
|
46
46
|
### 引擎列表
|
|
@@ -205,8 +205,13 @@ Search engine test:
|
|
|
205
205
|
| `github` | GitHub 仓库搜索(API,免费无 key) |
|
|
206
206
|
| `v2ex` | V2EX 热门/相关主题 |
|
|
207
207
|
| `bilibili` | B站视频/内容搜索(公开接口) |
|
|
208
|
+
| `reddit` | Reddit 帖子/讨论搜索(公开 JSON API;部分网络环境可能被 Reddit 反爬拦截) |
|
|
209
|
+
| `hn` | Hacker News 技术社区讨论(Algolia 官方 API) |
|
|
210
|
+
| `stackoverflow` | Stack Overflow 技术问答(Stack Exchange 官方公开 API) |
|
|
211
|
+
| `wikipedia` | 维基百科词条(中文环境用 zh.wikipedia.org,`lang: en` 时切换 en.wikipedia.org) |
|
|
212
|
+
| `npm` | npm 包搜索(registry 官方 API) |
|
|
208
213
|
|
|
209
|
-
全部走公开 API
|
|
214
|
+
全部走公开 API,零外部依赖、无需任何 key,开箱即用。
|
|
210
215
|
|
|
211
216
|
### 本地引擎切换工具(tools/)
|
|
212
217
|
|
|
@@ -271,11 +276,11 @@ This plugin provides multiple free search engines with automatic fallback, compl
|
|
|
271
276
|
- **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
277
|
- **Time Filtering** — The `advanced_search` tool supports `timeRange`: fixed tiers, custom relative values, or an absolute date (details below)
|
|
273
278
|
- **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.
|
|
279
|
+
- **Version + Update Check** — The settings card shows the current version (v0.4.12), 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
280
|
- **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
281
|
- **Visual Badges** — Free engines feature a green `FREE` badge, while paid engines show an orange `API KEY` badge in the settings UI
|
|
277
282
|
- **Webpage Fetching (`web_fetch`)** — Allows the agent to read full webpage contents (official `dsh-web-fetch-http` provider, pure JS, zero extra dependencies)
|
|
278
|
-
- **Platform Search (`platform_search`)** — Search GitHub / V2EX / Bilibili (public APIs, zero extra dependencies)
|
|
283
|
+
- **Platform Search (`platform_search`)** — Search GitHub / V2EX / Bilibili / Reddit / Hacker News / Stack Overflow / Wikipedia / npm (public APIs, zero extra dependencies)
|
|
279
284
|
- **Clean Integration** — Implements the official `WebSearchProvider` seam interface, coexisting seamlessly with official plugins
|
|
280
285
|
|
|
281
286
|
### Supported Engines
|
|
@@ -336,7 +341,7 @@ After installation, navigate to **Settings → Plugins → Configurable** tab
|
|
|
336
341
|
- **API keys**: Enter keys for Exa / Tavily / Keenable / Perplexity / DeepSeek (password fields; displayed as "configured" once saved).
|
|
337
342
|
- **Test engine**: Tests the selected engine directly (no fallback chain; paid engines without a key report an explicit error).
|
|
338
343
|
- **Use Bing default**: stage a switch back to the stable free Bing engine; `Discard` only cancels unsaved edits
|
|
339
|
-
- **Platform search**: check GitHub / V2EX / Bilibili to enable them for the `platform_search` tool (disabled platforms are skipped).
|
|
344
|
+
- **Platform search**: check platforms (GitHub / V2EX / Bilibili / Reddit / HN / Stack Overflow / Wikipedia / npm) to enable them for the `platform_search` tool (disabled platforms are skipped).
|
|
340
345
|
- **EN / 中文**: toggle the interface language (default Chinese).
|
|
341
346
|
|
|
342
347
|
<table align="center" style="border: none; border-collapse: collapse;">
|
|
@@ -440,8 +445,13 @@ Ask the agent to search specific platforms (e.g., *"Search GitHub for deepseek h
|
|
|
440
445
|
| `github` | GitHub repository search (public API, free, no key required) |
|
|
441
446
|
| `v2ex` | V2EX hot / relevant topics |
|
|
442
447
|
| `bilibili` | Bilibili video / content search (public API) |
|
|
448
|
+
| `reddit` | Reddit posts / discussions (public JSON API; may be blocked by Reddit anti-bot in some network environments) |
|
|
449
|
+
| `hn` | Hacker News tech community discussions (official Algolia API) |
|
|
450
|
+
| `stackoverflow` | Stack Overflow Q&A (official public Stack Exchange API) |
|
|
451
|
+
| `wikipedia` | Wikipedia articles (zh.wikipedia.org for Chinese; switches to en.wikipedia.org when `lang: en`) |
|
|
452
|
+
| `npm` | npm package search (registry official API) |
|
|
443
453
|
|
|
444
|
-
All platform searches rely on public endpoints with zero external dependencies and work out of the box.
|
|
454
|
+
All platform searches rely on public endpoints with zero external dependencies and no API keys — they work out of the box.
|
|
445
455
|
|
|
446
456
|
### Local Engine Switcher (`tools/`)
|
|
447
457
|
|
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.12";
|
|
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", "reddit"]);
|
|
217
|
+
const [platforms, setPlatforms] = react.useState(["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"]);
|
|
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", "reddit"]);
|
|
244
|
+
setPlatforms(Array.isArray(v.platforms) && v.platforms.length > 0 ? v.platforms : ["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"]);
|
|
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);
|
|
@@ -516,6 +553,7 @@ react_jsx_runtime.jsx("div", {
|
|
|
516
553
|
className: "dshfs-platforms",
|
|
517
554
|
children: [
|
|
518
555
|
["github", "GitHub"], ["v2ex", "V2EX"], ["bilibili", "Bilibili"], ["reddit", "Reddit"],
|
|
556
|
+
["hn", "Hacker News"], ["stackoverflow", "Stack Overflow"], ["wikipedia", "Wikipedia"], ["npm", "npm"],
|
|
519
557
|
].map(([id, label]) =>
|
|
520
558
|
react_jsx_runtime.jsx("label", {
|
|
521
559
|
className: "dshfs-platform",
|
|
@@ -605,29 +643,43 @@ react_jsx_runtime.jsx("div", {
|
|
|
605
643
|
children: checkingUpdate ? t.checkingUpdate : t.checkUpdate,
|
|
606
644
|
}),
|
|
607
645
|
updateInfo && updateInfo.ok
|
|
608
|
-
? updateInfo.
|
|
646
|
+
? updateInfo.upgraded
|
|
609
647
|
? 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
648
|
className: "dshfs-updateOk",
|
|
625
|
-
children: t.
|
|
649
|
+
children: t.upgradeDone(updateInfo.latest),
|
|
626
650
|
})
|
|
651
|
+
: updateInfo.hasUpdate
|
|
652
|
+
? react_jsx_runtime.jsx("span", {
|
|
653
|
+
className: "dshfs-update",
|
|
654
|
+
children: [
|
|
655
|
+
t.updateAvailable(updateInfo.current, updateInfo.latest),
|
|
656
|
+
" ",
|
|
657
|
+
updateInfo.installable
|
|
658
|
+
? react_jsx_runtime.jsx("button", {
|
|
659
|
+
className: "dshfs-btn dshfs-upgrade",
|
|
660
|
+
type: "button",
|
|
661
|
+
onClick: runUpdate,
|
|
662
|
+
disabled: upgrading || saving || !ready,
|
|
663
|
+
children: upgrading ? t.upgrading : t.upgrade,
|
|
664
|
+
})
|
|
665
|
+
: react_jsx_runtime.jsx("a", {
|
|
666
|
+
className: "dshfs-link",
|
|
667
|
+
href: updateInfo.updateUrl,
|
|
668
|
+
target: "_blank",
|
|
669
|
+
rel: "noopener noreferrer",
|
|
670
|
+
children: t.updateView,
|
|
671
|
+
}),
|
|
672
|
+
updateInfo.installable ? null : " " + t.upgradeLinkMode,
|
|
673
|
+
],
|
|
674
|
+
})
|
|
675
|
+
: react_jsx_runtime.jsx("span", {
|
|
676
|
+
className: "dshfs-updateOk",
|
|
677
|
+
children: t.updateLatest(updateInfo.current),
|
|
678
|
+
})
|
|
627
679
|
: updateInfo && !updateInfo.ok
|
|
628
680
|
? react_jsx_runtime.jsx("span", {
|
|
629
681
|
className: "dshfs-failed",
|
|
630
|
-
children: t.updateCheckFailed,
|
|
682
|
+
children: updateInfo.upgradeFailed ? t.upgradeFailed(updateInfo.upgradeFailed) : t.updateCheckFailed,
|
|
631
683
|
})
|
|
632
684
|
: null,
|
|
633
685
|
],
|
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.12";
|
|
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 };
|
|
@@ -465,12 +488,16 @@ async function searchExaMCP(query, maxResults, signal) {
|
|
|
465
488
|
}
|
|
466
489
|
//#endregion
|
|
467
490
|
|
|
468
|
-
//#region platform search (GitHub / V2EX / Bilibili / Reddit)
|
|
491
|
+
//#region platform search (GitHub / V2EX / Bilibili / Reddit / HN / StackOverflow / Wikipedia / npm)
|
|
469
492
|
const PLATFORMS = {
|
|
470
493
|
github: { name: "GitHub" },
|
|
471
494
|
v2ex: { name: "V2EX" },
|
|
472
495
|
bilibili: { name: "Bilibili" },
|
|
473
496
|
reddit: { name: "Reddit" },
|
|
497
|
+
hn: { name: "Hacker News" },
|
|
498
|
+
stackoverflow: { name: "Stack Overflow" },
|
|
499
|
+
wikipedia: { name: "Wikipedia" },
|
|
500
|
+
npm: { name: "npm" },
|
|
474
501
|
};
|
|
475
502
|
|
|
476
503
|
async function searchGithub(query, maxResults, signal) {
|
|
@@ -567,7 +594,102 @@ async function searchReddit(query, maxResults, signal) {
|
|
|
567
594
|
};
|
|
568
595
|
}
|
|
569
596
|
|
|
570
|
-
async function
|
|
597
|
+
async function searchHackerNews(query, maxResults, signal) {
|
|
598
|
+
const response = await fetch(
|
|
599
|
+
`https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}&hitsPerPage=${maxResults ?? 5}`,
|
|
600
|
+
{
|
|
601
|
+
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
602
|
+
...(signal !== undefined ? { signal } : {}),
|
|
603
|
+
}
|
|
604
|
+
);
|
|
605
|
+
if (!response.ok) throw new Error(`Hacker News API error (HTTP ${response.status})`);
|
|
606
|
+
const data = await response.json();
|
|
607
|
+
return {
|
|
608
|
+
sources: (data.hits ?? [])
|
|
609
|
+
.filter((h) => h.title || h.story_title)
|
|
610
|
+
.map((h) => ({
|
|
611
|
+
// 有外链用外链,纯讨论帖用 HN 讨论页
|
|
612
|
+
url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`,
|
|
613
|
+
title: h.title ?? h.story_title,
|
|
614
|
+
...((h.points !== undefined && h.points !== null) || (h.num_comments !== undefined && h.num_comments !== null)
|
|
615
|
+
? { snippet: `HN discussion · ${h.points ?? 0} points · ${h.num_comments ?? 0} comments` }
|
|
616
|
+
: {}),
|
|
617
|
+
})),
|
|
618
|
+
truncated: false,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function searchStackOverflow(query, maxResults, signal) {
|
|
623
|
+
const response = await fetch(
|
|
624
|
+
`https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${encodeURIComponent(query)}&site=stackoverflow&pagesize=${maxResults ?? 5}&filter=!nNPvSNVZJS`,
|
|
625
|
+
{
|
|
626
|
+
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
627
|
+
...(signal !== undefined ? { signal } : {}),
|
|
628
|
+
}
|
|
629
|
+
);
|
|
630
|
+
if (!response.ok) throw new Error(`Stack Exchange API error (HTTP ${response.status})`);
|
|
631
|
+
const data = await response.json();
|
|
632
|
+
if (data.error_message) throw new Error(`Stack Exchange API error: ${data.error_message}`);
|
|
633
|
+
return {
|
|
634
|
+
sources: (data.items ?? []).map((it) => ({
|
|
635
|
+
url: it.link,
|
|
636
|
+
title: it.title,
|
|
637
|
+
...(it.score !== undefined || it.answer_count !== undefined
|
|
638
|
+
? { snippet: `${it.is_answered ? "✓ answered" : "unanswered"} · score ${it.score ?? 0} · ${it.answer_count ?? 0} answers` }
|
|
639
|
+
: {}),
|
|
640
|
+
})),
|
|
641
|
+
truncated: false,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function searchWikipedia(query, maxResults, signal, lang) {
|
|
646
|
+
const host = lang === "en" ? "en.wikipedia.org" : "zh.wikipedia.org";
|
|
647
|
+
const response = await fetch(
|
|
648
|
+
`https://${host}/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&srlimit=${maxResults ?? 5}`,
|
|
649
|
+
{
|
|
650
|
+
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
651
|
+
...(signal !== undefined ? { signal } : {}),
|
|
652
|
+
}
|
|
653
|
+
);
|
|
654
|
+
if (!response.ok) throw new Error(`Wikipedia API error (HTTP ${response.status})`);
|
|
655
|
+
const data = await response.json();
|
|
656
|
+
return {
|
|
657
|
+
sources: (data.query?.search ?? []).map((s) => ({
|
|
658
|
+
url: `https://${host}/wiki/${encodeURIComponent(String(s.title).replace(/ /g, "_"))}`,
|
|
659
|
+
title: s.title,
|
|
660
|
+
// snippet 含 <span class="searchmatch"> 高亮标签,剥掉
|
|
661
|
+
...(s.snippet ? { snippet: stripTags(s.snippet).slice(0, 200) } : {}),
|
|
662
|
+
})),
|
|
663
|
+
truncated: false,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function searchNpm(query, maxResults, signal) {
|
|
668
|
+
const response = await fetch(
|
|
669
|
+
`https://registry.npmjs.com/-/v1/search?text=${encodeURIComponent(query)}&size=${maxResults ?? 5}`,
|
|
670
|
+
{
|
|
671
|
+
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
672
|
+
...(signal !== undefined ? { signal } : {}),
|
|
673
|
+
}
|
|
674
|
+
);
|
|
675
|
+
if (!response.ok) throw new Error(`npm registry API error (HTTP ${response.status})`);
|
|
676
|
+
const data = await response.json();
|
|
677
|
+
return {
|
|
678
|
+
sources: (data.objects ?? [])
|
|
679
|
+
.map((o) => o.package)
|
|
680
|
+
.filter((p) => p && p.name)
|
|
681
|
+
.map((p) => ({
|
|
682
|
+
url: p.links?.npm ?? `https://www.npmjs.com/package/${p.name}`,
|
|
683
|
+
title: p.name,
|
|
684
|
+
...((p.description || p.version)
|
|
685
|
+
? { snippet: `v${p.version ?? "?"}${p.description ? ` — ${String(p.description).slice(0, 160)}` : ""}` }
|
|
686
|
+
: {}),
|
|
687
|
+
})),
|
|
688
|
+
truncated: false,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function searchPlatform(platform, query, maxResults, signal, lang) {
|
|
571
693
|
switch (platform) {
|
|
572
694
|
case "github":
|
|
573
695
|
return searchGithub(query, maxResults, signal);
|
|
@@ -577,6 +699,14 @@ async function searchPlatform(platform, query, maxResults, signal) {
|
|
|
577
699
|
return searchBilibili(query, maxResults, signal);
|
|
578
700
|
case "reddit":
|
|
579
701
|
return searchReddit(query, maxResults, signal);
|
|
702
|
+
case "hn":
|
|
703
|
+
return searchHackerNews(query, maxResults, signal);
|
|
704
|
+
case "stackoverflow":
|
|
705
|
+
return searchStackOverflow(query, maxResults, signal);
|
|
706
|
+
case "wikipedia":
|
|
707
|
+
return searchWikipedia(query, maxResults, signal, lang);
|
|
708
|
+
case "npm":
|
|
709
|
+
return searchNpm(query, maxResults, signal);
|
|
580
710
|
default:
|
|
581
711
|
throw new Error(`unknown platform: ${platform}`);
|
|
582
712
|
}
|
|
@@ -983,6 +1113,7 @@ function makeBridgeRoutes(settings, search, testEngine) {
|
|
|
983
1113
|
};
|
|
984
1114
|
}
|
|
985
1115
|
const cmp = compareVersions(latest, PLUGIN_VERSION);
|
|
1116
|
+
const mode = detectInstallMode();
|
|
986
1117
|
return {
|
|
987
1118
|
ok: true,
|
|
988
1119
|
value: {
|
|
@@ -991,9 +1122,46 @@ function makeBridgeRoutes(settings, search, testEngine) {
|
|
|
991
1122
|
hasUpdate: cmp > 0,
|
|
992
1123
|
updateUrl: PLUGIN_NPM_URL,
|
|
993
1124
|
repoUrl: PLUGIN_REPO_URL,
|
|
1125
|
+
// 可一键升级:npm 真安装时 true;本地 link 开发模式 false(升级请 git pull 源码)
|
|
1126
|
+
installable: mode !== null && !mode.isLink,
|
|
1127
|
+
installMode: mode?.isLink ? "link" : mode !== null ? "registry" : "unknown",
|
|
994
1128
|
},
|
|
995
1129
|
};
|
|
996
1130
|
},
|
|
1131
|
+
// 一键升级:仅在 npm 真安装模式下执行 pnpm 升级;link 模式拒绝(避免破坏本地开发链路)
|
|
1132
|
+
async updatePlugin() {
|
|
1133
|
+
const mode = detectInstallMode();
|
|
1134
|
+
if (mode === null) {
|
|
1135
|
+
return { ok: false, code: "install-not-found", message: "could not locate dsh-free-search in any profile" };
|
|
1136
|
+
}
|
|
1137
|
+
if (mode.isLink) {
|
|
1138
|
+
return {
|
|
1139
|
+
ok: false,
|
|
1140
|
+
code: "local-link-mode",
|
|
1141
|
+
message: "local development install (symlink) - update the source repo instead (git pull), then restart dsh",
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
const result = await new Promise((resolve, reject) => {
|
|
1146
|
+
exec("pnpm add dsh-free-search@latest", { cwd: mode.profileDir, timeout: 120000 }, (error, stdout, stderr) => {
|
|
1147
|
+
if (error) reject(new Error(`upgrade failed: ${(stderr || stdout || error.message).trim().slice(0, 300)}`));
|
|
1148
|
+
else resolve(stdout);
|
|
1149
|
+
});
|
|
1150
|
+
});
|
|
1151
|
+
const latest = await fetchLatestVersion();
|
|
1152
|
+
return {
|
|
1153
|
+
ok: true,
|
|
1154
|
+
value: {
|
|
1155
|
+
updated: true,
|
|
1156
|
+
latest: latest ?? "unknown",
|
|
1157
|
+
message: `upgraded to latest - restart dsh to apply`,
|
|
1158
|
+
output: String(result).trim().slice(0, 200),
|
|
1159
|
+
},
|
|
1160
|
+
};
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
return { ok: false, code: "upgrade-failed", message: error instanceof Error ? error.message : String(error) };
|
|
1163
|
+
}
|
|
1164
|
+
},
|
|
997
1165
|
async rawSearch(request) {
|
|
998
1166
|
if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.length === 0) {
|
|
999
1167
|
return { ok: false, code: "search-rejected", message: "malformed bridge search request (query is required)" };
|
|
@@ -1124,6 +1292,14 @@ function makeBridgeRoutes(settings, search, testEngine) {
|
|
|
1124
1292
|
writeJson(res, 200, await handlers.checkUpdate());
|
|
1125
1293
|
},
|
|
1126
1294
|
},
|
|
1295
|
+
{
|
|
1296
|
+
kind: "exact",
|
|
1297
|
+
path: `${BRIDGE_PREFIX}/update`,
|
|
1298
|
+
handler: async (req, res) => {
|
|
1299
|
+
if (!guard(req, res)) return;
|
|
1300
|
+
writeJson(res, 200, await handlers.updatePlugin());
|
|
1301
|
+
},
|
|
1302
|
+
},
|
|
1127
1303
|
{
|
|
1128
1304
|
kind: "exact",
|
|
1129
1305
|
path: `${BRIDGE_PREFIX}/raw-search`,
|
|
@@ -1152,7 +1328,7 @@ const Config = z.object({
|
|
|
1152
1328
|
region: z.string(),
|
|
1153
1329
|
bingMarket: z.string().default("zh-CN"),
|
|
1154
1330
|
searxngInstances: z.array(z.string()),
|
|
1155
|
-
platforms: z.array(z.string()).default(["github", "v2ex", "bilibili", "reddit"]),
|
|
1331
|
+
platforms: z.array(z.string()).default(["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"]),
|
|
1156
1332
|
exaApiKey: z.string().role("secret"),
|
|
1157
1333
|
tavilyApiKey: z.string().role("secret"),
|
|
1158
1334
|
keenableApiKey: z.string().role("secret"),
|
|
@@ -1389,6 +1565,15 @@ function apply(ctx, config) {
|
|
|
1389
1565
|
|
|
1390
1566
|
ctx.web.registerSearchProvider(provider);
|
|
1391
1567
|
|
|
1568
|
+
// 运行时兜底:DSH 0.1.1+ 中 profile patch 的 config 会整体覆盖 bundle patch 的 config,
|
|
1569
|
+
// 用户的 `- id: web` patch(如只设 fetchProvider)会静默抹掉 searchProvider,导致回退 DeepSeek 官方搜索。
|
|
1570
|
+
// 这里在 provider 注册后检查:未指向任何 provider(undefined)时自动接管为本插件;
|
|
1571
|
+
// 用户显式配置了其他 provider 则不动。
|
|
1572
|
+
if (!ctx.web.searchProviderId) {
|
|
1573
|
+
ctx.web.searchProviderId = provider.id;
|
|
1574
|
+
logger.info(`free-search: web.searchProvider was unset (patch override or missing config), taking over as "${provider.id}"`);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1392
1577
|
// 测试工具:让 agent 逐个测试所有搜索引擎,报告可用性
|
|
1393
1578
|
const runEngineTest = async (engine, query, timeRange) => {
|
|
1394
1579
|
const cfg = current();
|
|
@@ -1547,11 +1732,11 @@ function apply(ctx, config) {
|
|
|
1547
1732
|
defineTool({
|
|
1548
1733
|
name: "platform_search",
|
|
1549
1734
|
description:
|
|
1550
|
-
"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
|
|
1735
|
+
"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.",
|
|
1551
1736
|
parameters: {
|
|
1552
1737
|
platform: {
|
|
1553
1738
|
type: "string",
|
|
1554
|
-
description: "Platform to search: github, v2ex, bilibili, reddit",
|
|
1739
|
+
description: "Platform to search: github, v2ex, bilibili, reddit, hn, stackoverflow, wikipedia, npm",
|
|
1555
1740
|
},
|
|
1556
1741
|
query: {
|
|
1557
1742
|
type: "string",
|
|
@@ -1593,14 +1778,14 @@ function apply(ctx, config) {
|
|
|
1593
1778
|
throw new Error(`unknown platform "${platform}" - use one of: ${Object.keys(PLATFORMS).join(", ")}`);
|
|
1594
1779
|
}
|
|
1595
1780
|
// 平台开关:settings 里禁用某平台时,工具明确告知
|
|
1596
|
-
const enabled = current().platforms ?? ["github", "v2ex", "bilibili", "reddit"];
|
|
1781
|
+
const enabled = current().platforms ?? ["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"];
|
|
1597
1782
|
if (!enabled.includes(platform)) {
|
|
1598
1783
|
throw new Error(
|
|
1599
1784
|
`platform "${platform}" is disabled in Free Search settings - enable it in Settings > Plugins > Free Search to use it`
|
|
1600
1785
|
);
|
|
1601
1786
|
}
|
|
1602
1787
|
const limit = Math.min(args.maxResults ?? 5, 10);
|
|
1603
|
-
const result = await searchPlatform(platform, args.query, limit);
|
|
1788
|
+
const result = await searchPlatform(platform, args.query, limit, undefined, current().lang);
|
|
1604
1789
|
// lossless JSON 不允许 undefined 字段:剔除缺失字段
|
|
1605
1790
|
const sources = (result.sources ?? []).map((s) => {
|
|
1606
1791
|
const source = {};
|
|
@@ -1752,7 +1937,7 @@ function apply(ctx, config) {
|
|
|
1752
1937
|
"",
|
|
1753
1938
|
"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.",
|
|
1754
1939
|
"",
|
|
1755
|
-
"For platform-specific searches (GitHub repos, V2EX threads, Bilibili videos), use the platform_search tool with platform: github|v2ex|bilibili.",
|
|
1940
|
+
"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.",
|
|
1756
1941
|
"",
|
|
1757
1942
|
"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.",
|
|
1758
1943
|
].join("\n"),
|
|
@@ -1799,13 +1984,17 @@ export {
|
|
|
1799
1984
|
searchExa,
|
|
1800
1985
|
searchExaMCP,
|
|
1801
1986
|
searchGithub,
|
|
1987
|
+
searchHackerNews,
|
|
1802
1988
|
searchKeenable,
|
|
1803
1989
|
searchKeenableMCP,
|
|
1804
1990
|
searchKeenableREST,
|
|
1991
|
+
searchNpm,
|
|
1805
1992
|
searchPerplexity,
|
|
1806
1993
|
searchPlatform,
|
|
1807
1994
|
searchReddit,
|
|
1808
1995
|
searchSearxng,
|
|
1996
|
+
searchStackOverflow,
|
|
1809
1997
|
searchTavily,
|
|
1810
1998
|
searchV2ex,
|
|
1999
|
+
searchWikipedia,
|
|
1811
2000
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-free-search",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
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",
|