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 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.10),"检查更新"按钮直连 npm registry 对比最新版,有新版本时提示并可一键跳转
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站(公开 API,零依赖)
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.10), 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
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
@@ -15,3 +15,7 @@
15
15
  config:
16
16
  provider: bing
17
17
  bingMarket: zh-CN
18
+ # 自动接管默认搜索:让 harness 的 web_search 直接用本插件的 provider(内部含全部引擎)
19
+ - id: web
20
+ config:
21
+ searchProvider: ddg
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 are up to date (v${c})`,
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.10";
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.hasUpdate
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.updateLatest(updateInfo.current),
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.10";
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 searchPlatform(platform, query, maxResults, signal) {
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 discussions.",
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.10",
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",