dsh-free-search 0.4.7 → 0.4.8

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
@@ -33,9 +33,10 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
33
33
  - **网页设置页** —— 引擎切换 + API key 配置(UI 中 key 脱敏显示"已配置")+ 中英文切换
34
34
  - **弹出式切换命令** —— 聊天框输入 `/free-search-engine`,弹出引擎选择窗口,点选即切换(等效设置页 + 保存)
35
35
  - **引擎测试** —— `free_search_test` 工具让 agent 一键测试所有引擎;设置页也有"测试引擎"按钮(直测当前引擎,不走回退链,付费引擎无 key 会明确报错)
36
- - **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401/限流/网络)自动轮流尝试下一个引擎:先试其他已配 key 的付费引擎,再试免费引擎,搜索永不直接失败
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.8),"检查更新"按钮直连 npm registry 对比最新版,有新版本时提示并可一键跳转
39
40
  - **免费标注** —— 设置页中免费引擎带绿色 `FREE` 徽章,付费引擎带橙色 `API KEY` 徽章
40
41
  - **网页抓取(web_fetch)** —— 让 agent 抓取网页内容(官方 `dsh-web-fetch-http` provider,纯 JS,零额外依赖)
41
42
  - **平台搜索(platform_search)** —— 搜 GitHub / V2EX / B站(公开 API,零依赖)
@@ -266,9 +267,10 @@ This plugin provides multiple free search engines with automatic fallback, compl
266
267
  - **Web Settings UI** — Engine switching, API key configuration (keys masked as "configured" in the UI), and a Chinese/English toggle
267
268
  - **Popup Switch Command** — Type `/free-search-engine` in the chat: a picker opens with all engines; click one to switch (equivalent to the settings page + save)
268
269
  - **Engine Testing** — `free_search_test` for the agent to check all engines in one call; the settings UI also has a "Test engine" button that tests the selected engine directly (no fallback chain; paid engines without a key report an explicit error)
269
- - **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: other paid engines with configured keys first, then free engines. Search never fails outright.
270
+ - **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.
270
271
  - **Time Filtering** — The `advanced_search` tool supports `timeRange`: fixed tiers, custom relative values, or an absolute date (details below)
271
272
  - **System Prompt Injection** — The agent is aware of the currently active engine and which engines require API keys
273
+ - **Version + Update Check** — The settings card shows the current version (v0.4.8), 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
272
274
  - **Visual Badges** — Free engines feature a green `FREE` badge, while paid engines show an orange `API KEY` badge in the settings UI
273
275
  - **Webpage Fetching (`web_fetch`)** — Allows the agent to read full webpage contents (official `dsh-web-fetch-http` provider, pure JS, zero extra dependencies)
274
276
  - **Platform Search (`platform_search`)** — Search GitHub / V2EX / Bilibili (public APIs, zero extra dependencies)
@@ -290,7 +292,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
290
292
  | `deepseek-official` | DeepSeek Official | Paid | Requires `DEEPSEEK_API_KEY` |
291
293
 
292
294
  - **Default engine is `bing`** (free and most stable), ready to use out of the box after installation.
293
- - **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, network error) automatically tries the next engine — other paid engines with configured keys first, then free engines (Bing/AnySearch etc.) — with a note attached to the results. Search never fails outright because of engine issues.
295
+ - **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, 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 because of engine issues.
294
296
  - **Official Links in Settings**: Free engines display "Visit Website →", while paid engines display "Get API Key →" (opens in a new tab):
295
297
  - Exa: <https://dashboard.exa.ai/api-keys>
296
298
  - Tavily: <https://app.tavily.com/home>
package/lib/client.js CHANGED
@@ -20,9 +20,12 @@ window.__ModuleLoader__.load({
20
20
  ".dshfs-chevron{color:var(--dsw-alias-label-tertiary);flex:none;font-size:13px;transition:transform .12s}",
21
21
  ".dshfs-chevronOpen{transform:rotate(180deg)}",
22
22
  ".dshfs-body{flex-direction:column;gap:14px;padding:0 14px 14px;display:flex}",
23
- ".dshfs-footer{justify-content:flex-end;align-items:center;gap:8px;display:flex}",
24
- ".dshfs-failed{color:var(--dsw-alias-state-error-primary);margin:0 auto 0 0;font-size:12px}",
25
- ".dshfs-testOk{color:#7ddb9c;margin:0 auto 0 0;font-size:12px;line-height:1.5}",
23
+ ".dshfs-footer{justify-content:space-between;align-items:center;gap:8px;display:flex;flex-wrap:wrap}",
24
+ ".dshfs-footerLeft{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}",
25
+ ".dshfs-footerRight{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
26
+ ".dshfs-failed{color:var(--dsw-alias-state-error-primary);font-size:12px}",
27
+ ".dshfs-testOk{color:#7ddb9c;font-size:12px;line-height:1.5}",
28
+ ".dshfs-resultRow{display:flex;flex-direction:column;align-items:flex-start;gap:4px;min-width:0;margin-top:2px}",
26
29
  ".dshfs-field{flex-direction:column;gap:4px;min-width:0;display:flex}",
27
30
  ".dshfs-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}",
28
31
  ".dshfs-select{border:1px solid var(--dsw-alias-border-l2);font:inherit;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border-radius:6px;padding:6px 8px;font-size:13px;transition:border-color .13s,box-shadow .13s;width:100%}",
@@ -45,6 +48,10 @@ window.__ModuleLoader__.load({
45
48
  ".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}",
46
49
  ".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}",
47
50
  ".dshfs-langToggle{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:transparent;flex:none;padding:2px 8px;font-size:11px;border-radius:6px}",
51
+ ".dshfs-update{color:var(--dsw-alias-state-warn-primary);flex:none;font-size:11px;white-space:nowrap;border:1px solid rgba(240,170,80,.3);background:rgba(240,170,80,.12);border-radius:999px;padding:1px 6px}",
52
+ ".dshfs-updateOk{color:#7ddb9c;flex:none;font-size:11px;white-space:nowrap;border:1px solid rgba(80,200,120,.3);background:rgba(80,200,120,.12);border-radius:999px;padding:1px 6px}",
53
+ ".dshfs-updateLine{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
54
+ ".dshfs-version{color:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}",
48
55
  ].join("");
49
56
  const tagId = "dsh-free-search/card.css";
50
57
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
@@ -86,6 +93,12 @@ window.__ModuleLoader__.load({
86
93
  testOk: (r) => `✓ ${r.count} 条结果(引擎: ${r.engine})${r.content ? ` — ${r.content}` : ""}${r.sample ? ` · 例如 "${r.sample.slice(0, 40)}"` : ""}`,
87
94
  testFail: (e) => `✗ ${e}`,
88
95
  toggleLang: "EN",
96
+ checkUpdate: "检查更新",
97
+ checkingUpdate: "检查中…",
98
+ updateAvailable: (c, l) => `发现新版本 v${l}(当前 v${c})`,
99
+ updateLatest: (c) => `已是最新版本 v${c}`,
100
+ updateCheckFailed: "检查更新失败(无法访问 npm registry)",
101
+ updateView: "查看 →",
89
102
  },
90
103
  en: {
91
104
  description: "Free web search — no API key needed (Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable)",
@@ -114,9 +127,17 @@ window.__ModuleLoader__.load({
114
127
  testOk: (r) => `✓ ${r.count} results (engine: ${r.engine})${r.content ? ` — ${r.content}` : ""}${r.sample ? ` · e.g. "${r.sample.slice(0, 40)}"` : ""}`,
115
128
  testFail: (e) => `✗ ${e}`,
116
129
  toggleLang: "中文",
130
+ checkUpdate: "Check update",
131
+ checkingUpdate: "Checking…",
132
+ updateAvailable: (c, l) => `New version v${l} available (current v${c})`,
133
+ updateLatest: (c) => `You are up to date (v${c})`,
134
+ updateCheckFailed: "Update check failed (cannot reach npm registry)",
135
+ updateView: "View →",
117
136
  },
118
137
  };
119
138
  const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
139
+ // 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致)
140
+ const PLUGIN_VERSION = "0.4.8";
120
141
  const ENGINES = [
121
142
  { id: "ddg", label: "DuckDuckGo · HTML", badge: "FREE", link: "https://duckduckgo.com" },
122
143
  { id: "ddg-lite", label: "DuckDuckGo · Lite", badge: "FREE", link: "https://duckduckgo.com" },
@@ -157,6 +178,15 @@ window.__ModuleLoader__.load({
157
178
  return response.json();
158
179
  }
159
180
 
181
+ async function bridgeCheckUpdate() {
182
+ const response = await fetch(`${BRIDGE_PREFIX}/check-update`, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json" },
185
+ body: "{}",
186
+ });
187
+ return response.json();
188
+ }
189
+
160
190
  function FreeSearchCard(props) {
161
191
  const [open, setOpen] = react.useState(false);
162
192
  const [state, setState] = react.useState({ status: "loading" });
@@ -174,6 +204,8 @@ window.__ModuleLoader__.load({
174
204
  const [failed, setFailed] = react.useState(false);
175
205
  const [testing, setTesting] = react.useState(false);
176
206
  const [testResult, setTestResult] = react.useState(null);
207
+ const [checkingUpdate, setCheckingUpdate] = react.useState(false);
208
+ const [updateInfo, setUpdateInfo] = react.useState(null);
177
209
 
178
210
  const load = react.useCallback(async () => {
179
211
  try {
@@ -288,6 +320,24 @@ window.__ModuleLoader__.load({
288
320
  }
289
321
  };
290
322
 
323
+ const runCheckUpdate = async () => {
324
+ setCheckingUpdate(true);
325
+ setUpdateInfo(null);
326
+ setFailed(false);
327
+ try {
328
+ const result = await bridgeCheckUpdate();
329
+ if (result.ok) {
330
+ setUpdateInfo({ ok: true, ...result.value });
331
+ } else {
332
+ setUpdateInfo({ ok: false });
333
+ }
334
+ } catch {
335
+ setUpdateInfo({ ok: false });
336
+ } finally {
337
+ setCheckingUpdate(false);
338
+ }
339
+ };
340
+
291
341
  if (state.status === "loading") return null;
292
342
  const ready = state.status === "ready";
293
343
  const t = tt(lang);
@@ -312,14 +362,11 @@ window.__ModuleLoader__.load({
312
362
  "aria-expanded": open,
313
363
  onClick: () => setOpen(!open),
314
364
  children: [
315
- react_jsx_runtime.jsx("span", {
316
- className: "dshfs-headText",
317
- children: [
365
+ react_jsx_runtime.jsx("span", { className: "dshfs-headText", children: [
318
366
  react_jsx_runtime.jsx("span", { className: "dshfs-name", children: title }),
319
367
  react_jsx_runtime.jsx("span", { className: "dshfs-description", children: description }),
320
- ],
321
- }),
322
- react_jsx_runtime.jsx("span", { className: badgeClass, children: currentEngine.badge }),
368
+ ] }),
369
+ react_jsx_runtime.jsx("span", { className: badgeClass, children: currentEngine.badge }),
323
370
  dirty ? react_jsx_runtime.jsx("span", { className: "dshfs-pending", children: t.unsaved }) : null,
324
371
  react_jsx_runtime.jsx("button", {
325
372
  type: "button",
@@ -488,24 +535,71 @@ react_jsx_runtime.jsx("div", {
488
535
  }),
489
536
  ],
490
537
  }),
491
- !ready
492
- ? react_jsx_runtime.jsx("p", {
493
- className: "dshfs-hint",
494
- children: t.unavailable,
495
- })
496
- : null,
497
538
  react_jsx_runtime.jsx("div", {
498
- className: "dshfs-footer",
539
+ className: "dshfs-resultRow",
499
540
  children: [
500
- failed ? react_jsx_runtime.jsx("p", { className: "dshfs-failed", children: t.saveFailed }) : null,
541
+ failed ? react_jsx_runtime.jsx("span", { className: "dshfs-failed", children: t.saveFailed }) : null,
501
542
  testResult
502
- ? react_jsx_runtime.jsx("p", {
543
+ ? react_jsx_runtime.jsx("span", {
503
544
  className: testResult.ok ? "dshfs-testOk" : "dshfs-failed",
504
545
  children: testResult.ok
505
546
  ? t.testOk(testResult)
506
547
  : t.testFail(testResult.error),
507
548
  })
508
549
  : null,
550
+ ],
551
+ }),
552
+ !ready
553
+ ? react_jsx_runtime.jsx("p", {
554
+ className: "dshfs-hint",
555
+ children: t.unavailable,
556
+ })
557
+ : null,
558
+ react_jsx_runtime.jsx("div", {
559
+ className: "dshfs-footer",
560
+ children: [
561
+ react_jsx_runtime.jsx("div", {
562
+ className: "dshfs-footerLeft",
563
+ children: [
564
+ react_jsx_runtime.jsx("span", { className: "dshfs-version", children: "v" + PLUGIN_VERSION }),
565
+ react_jsx_runtime.jsx("button", {
566
+ className: "dshfs-btn",
567
+ type: "button",
568
+ onClick: runCheckUpdate,
569
+ disabled: checkingUpdate || saving || !ready,
570
+ children: checkingUpdate ? t.checkingUpdate : t.checkUpdate,
571
+ }),
572
+ updateInfo && updateInfo.ok
573
+ ? updateInfo.hasUpdate
574
+ ? react_jsx_runtime.jsx("span", {
575
+ className: "dshfs-update",
576
+ children: [
577
+ t.updateAvailable(updateInfo.current, updateInfo.latest),
578
+ " ",
579
+ react_jsx_runtime.jsx("a", {
580
+ className: "dshfs-link",
581
+ href: updateInfo.updateUrl,
582
+ target: "_blank",
583
+ rel: "noopener noreferrer",
584
+ children: t.updateView,
585
+ }),
586
+ ],
587
+ })
588
+ : react_jsx_runtime.jsx("span", {
589
+ className: "dshfs-updateOk",
590
+ children: t.updateLatest(updateInfo.current),
591
+ })
592
+ : updateInfo && !updateInfo.ok
593
+ ? react_jsx_runtime.jsx("span", {
594
+ className: "dshfs-failed",
595
+ children: t.updateCheckFailed,
596
+ })
597
+ : null,
598
+ ],
599
+ }),
600
+ react_jsx_runtime.jsx("div", {
601
+ className: "dshfs-footerRight",
602
+ children: [
509
603
  react_jsx_runtime.jsx("button", {
510
604
  className: "dshfs-btn",
511
605
  type: "button",
@@ -540,9 +634,11 @@ react_jsx_runtime.jsx("div", {
540
634
  }),
541
635
  ],
542
636
  }),
543
- ],
544
- })
545
- : null,
637
+ ],
638
+ })
639
+ ],
640
+ })
641
+ : null,
546
642
  ],
547
643
  });
548
644
  }
package/lib/index.js CHANGED
@@ -17,6 +17,46 @@ const BRIDGE_PREFIX = "/api/dsh-free-search-settings";
17
17
  const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch"];
18
18
  const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "perplexity", "deepseek-official"];
19
19
 
20
+ // 当前插件版本(发布时与 package.json 同步)
21
+ const PLUGIN_VERSION = "0.4.8";
22
+ // 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
23
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
24
+ const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
25
+ const PLUGIN_REPO_URL = "https://github.com/DDDMUC/dsh-free-search";
26
+
27
+ // 查询 npm registry 的最新版本;失败时返回 null(网络/代理问题不阻塞设置页)
28
+ async function fetchLatestVersion(signal) {
29
+ const controller = new AbortController();
30
+ const timer = setTimeout(() => controller.abort(), 10000);
31
+ const onAbort = () => controller.abort();
32
+ signal?.addEventListener("abort", onAbort);
33
+ try {
34
+ const response = await fetch(NPM_REGISTRY_URL, {
35
+ headers: { accept: "application/json", "user-agent": "deepseek-harness/free-search" },
36
+ signal: controller.signal,
37
+ });
38
+ if (!response.ok) return null;
39
+ const data = await response.json();
40
+ return typeof data.version === "string" ? data.version : null;
41
+ } catch {
42
+ return null;
43
+ } finally {
44
+ clearTimeout(timer);
45
+ signal?.removeEventListener("abort", onAbort);
46
+ }
47
+ }
48
+
49
+ // 简单的 semver 比较(仅处理 x.y.z 主/次/补丁,忽略预发布标签);a>b 返回 1, a<b 返回 -1, 相等返回 0
50
+ function compareVersions(a, b) {
51
+ const na = String(a).split(".").map((n) => parseInt(n, 10) || 0);
52
+ const nb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
53
+ for (let i = 0; i < 3; i++) {
54
+ if ((na[i] ?? 0) > (nb[i] ?? 0)) return 1;
55
+ if ((na[i] ?? 0) < (nb[i] ?? 0)) return -1;
56
+ }
57
+ return 0;
58
+ }
59
+
20
60
  // time_range 支持:固定档 day/week/month/year,或自定义(相对 12h/3d/2mo/1y、绝对 YYYY-MM-DD)
21
61
  const TIME_RANGES = ["day", "week", "month", "year"];
22
62
  const DAYS_BY_RANGE = { day: 1, week: 7, month: 30, year: 365 };
@@ -898,6 +938,27 @@ function makeBridgeRoutes(settings, search, testEngine) {
898
938
  .map((descriptor) => String(descriptor.ns));
899
939
 
900
940
  const handlers = {
941
+ async checkUpdate() {
942
+ const latest = await fetchLatestVersion();
943
+ if (latest === null) {
944
+ return {
945
+ ok: false,
946
+ code: "update-check-failed",
947
+ message: "could not reach the npm registry (network/proxy) - check your connection",
948
+ };
949
+ }
950
+ const cmp = compareVersions(latest, PLUGIN_VERSION);
951
+ return {
952
+ ok: true,
953
+ value: {
954
+ current: PLUGIN_VERSION,
955
+ latest,
956
+ hasUpdate: cmp > 0,
957
+ updateUrl: PLUGIN_NPM_URL,
958
+ repoUrl: PLUGIN_REPO_URL,
959
+ },
960
+ };
961
+ },
901
962
  async rawSearch(request) {
902
963
  if (request === null || typeof request !== "object" || typeof request.query !== "string" || request.query.length === 0) {
903
964
  return { ok: false, code: "search-rejected", message: "malformed bridge search request (query is required)" };
@@ -1018,6 +1079,14 @@ function makeBridgeRoutes(settings, search, testEngine) {
1018
1079
  writeJson(res, 200, await handlers.mutate(body));
1019
1080
  },
1020
1081
  },
1082
+ {
1083
+ kind: "exact",
1084
+ path: `${BRIDGE_PREFIX}/check-update`,
1085
+ handler: async (req, res) => {
1086
+ if (!guard(req, res)) return;
1087
+ writeJson(res, 200, await handlers.checkUpdate());
1088
+ },
1089
+ },
1021
1090
  {
1022
1091
  kind: "exact",
1023
1092
  path: `${BRIDGE_PREFIX}/raw-search`,
@@ -1085,7 +1154,9 @@ function apply(ctx, config) {
1085
1154
  const cfg = current();
1086
1155
  const preferred = cfg.provider ?? "bing";
1087
1156
  // time_range 过滤(仅 advanced_search 工具透传;标准 web_search 无此参数)
1157
+ // 保留原始字符串用于 Note 展示;raw-search 桥可能已把 timeRange 解析成对象
1088
1158
  const timeRange = parseTimeRange(request.timeRange);
1159
+ const timeRangeLabel = typeof request.timeRange === "string" ? request.timeRange : String(timeRange?.days ?? timeRange?.after ?? "");
1089
1160
 
1090
1161
  // 统一引擎链:首选优先,然后其他付费引擎(有 key 的优先尝试),最后免费引擎
1091
1162
  const paidEngines = ["exa", "tavily", "keenable", "perplexity", "deepseek-official"];
@@ -1093,12 +1164,21 @@ function apply(ctx, config) {
1093
1164
  // 支持 time_range 过滤的引擎:tavily / exa / keenable / searxng / ddg / ddg-lite
1094
1165
  const timeEngines = ["tavily", "exa", "keenable", "searxng", "ddg", "ddg-lite"];
1095
1166
  let chain;
1167
+ // 首选引擎被跳过的原因(用于生成准确的 Note,避免误导 agent/用户):
1168
+ // - "time-filter":带 timeRange 且首选引擎不支持时间过滤(根本没尝试)
1169
+ // - "failed":首选引擎确实被尝试但失败(缺 key / 401 / 限流 / 0 结果 / 网络)
1170
+ // - null:首选引擎成功或无回退
1171
+ let preferredSkippedReason = null;
1096
1172
  if (timeRange) {
1097
1173
  // 有时间过滤需求时,把支持过滤的引擎排前面(首选引擎若支持仍优先)
1098
1174
  const preferredFirst = [preferred].filter((e) => timeEngines.includes(e));
1099
1175
  const otherTime = timeEngines.filter((e) => e !== preferred);
1100
1176
  const noTime = [...paidEngines, ...freeEngines].filter((e) => !timeEngines.includes(e) && e !== preferred);
1101
1177
  chain = [...preferredFirst, ...otherTime, ...noTime];
1178
+ if (!timeEngines.includes(preferred)) {
1179
+ // 首选引擎不支持时间过滤 → 它不在链里,不会被尝试(这不等于失败)
1180
+ preferredSkippedReason = "time-filter";
1181
+ }
1102
1182
  } else {
1103
1183
  const othersPaid = paidEngines.filter((e) => e !== preferred);
1104
1184
  const othersFree = freeEngines.filter((e) => e !== preferred);
@@ -1107,6 +1187,8 @@ function apply(ctx, config) {
1107
1187
 
1108
1188
  let lastError = null;
1109
1189
  let usedEngine = null;
1190
+ // 首选引擎若被尝试后失败,记录失败详情(用于 Note)
1191
+ let preferredFailure = null;
1110
1192
  for (const engine of chain) {
1111
1193
  try {
1112
1194
  let result;
@@ -1140,6 +1222,7 @@ function apply(ctx, config) {
1140
1222
  const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
1141
1223
  if (!key) {
1142
1224
  lastError = new Error("Perplexity requires PERPLEXITY_API_KEY");
1225
+ if (engine === preferred) preferredFailure = "PERPLEXITY_API_KEY is not configured";
1143
1226
  logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
1144
1227
  continue; // 无 key 跳过
1145
1228
  }
@@ -1148,6 +1231,7 @@ function apply(ctx, config) {
1148
1231
  const key = await resolveApiKey("DEEPSEEK_API_KEY", "deepseekApiKey");
1149
1232
  if (!key) {
1150
1233
  lastError = new Error("DeepSeek requires DEEPSEEK_API_KEY");
1234
+ if (engine === preferred) preferredFailure = "DEEPSEEK_API_KEY is not configured";
1151
1235
  logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
1152
1236
  continue; // 无 key 跳过
1153
1237
  }
@@ -1158,17 +1242,25 @@ function apply(ctx, config) {
1158
1242
 
1159
1243
  if (result.sources.length > 0) {
1160
1244
  usedEngine = engine;
1161
- // 用了非首选引擎时,在结果里附上提示(agent 会看到)
1245
+ // 用了非首选引擎时,在结果里附上准确提示(区分"不支持时间过滤被跳过"与"真实失败")
1162
1246
  if (engine !== preferred) {
1163
- result.content = `Note: ${preferred} unavailable or failed, using ${engine}.`;
1247
+ if (preferredSkippedReason === "time-filter") {
1248
+ result.content = `Note: ${preferred} does not support time filtering (timeRange=${timeRangeLabel}), using ${engine}.`;
1249
+ } else if (preferredFailure) {
1250
+ result.content = `Note: ${preferred} unavailable or failed (${preferredFailure}), using ${engine}.`;
1251
+ } else {
1252
+ result.content = `Note: ${preferred} unavailable or failed, using ${engine}.`;
1253
+ }
1164
1254
  }
1165
1255
  return { ...result, provider: engine, engine: engine };
1166
1256
  }
1167
1257
  lastError = new Error(`engine "${engine}" returned 0 results`);
1258
+ if (engine === preferred) preferredFailure = "returned 0 results";
1168
1259
  logger.warn(`free-search: ${engine} returned 0 results, trying next engine`);
1169
1260
  } catch (error) {
1170
1261
  lastError = error;
1171
1262
  const message = error instanceof Error ? error.message : String(error);
1263
+ if (engine === preferred) preferredFailure = message;
1172
1264
  logger.warn(`free-search: engine "${engine}" failed (${message}), trying next engine`);
1173
1265
  }
1174
1266
  }
@@ -1407,7 +1499,15 @@ function apply(ctx, config) {
1407
1499
  }
1408
1500
  const limit = Math.min(args.maxResults ?? 5, 10);
1409
1501
  const result = await searchPlatform(platform, args.query, limit);
1410
- return { platform, sources: result.sources };
1502
+ // lossless JSON 不允许 undefined 字段:剔除缺失字段
1503
+ const sources = (result.sources ?? []).map((s) => {
1504
+ const source = {};
1505
+ if (s.url !== undefined && s.url !== null && s.url !== "") source.url = s.url;
1506
+ if (s.title !== undefined && s.title !== null && s.title !== "") source.title = String(s.title);
1507
+ if (s.snippet !== undefined && s.snippet !== null && s.snippet !== "") source.snippet = String(s.snippet);
1508
+ return source;
1509
+ });
1510
+ return { platform, sources };
1411
1511
  },
1412
1512
  finalizeContent(exec, result) {
1413
1513
  // Tool-result content must be an array of content blocks, not a raw string.
@@ -1485,15 +1585,20 @@ function apply(ctx, config) {
1485
1585
  // engine 指定时:仅当该引擎可用才优先(仍走回退链,失败自动换引擎)
1486
1586
  if (args.engine && ALL_ENGINES.includes(args.engine)) request.engine = args.engine;
1487
1587
  const result = await provider.search(request);
1588
+ // lossless JSON 不允许 undefined 字段:按存在的值构造对象,缺字段直接省略
1488
1589
  return {
1489
- provider: result.provider ?? result._provider,
1490
- content: result.content ?? "",
1491
- sources: (result.sources ?? []).map((s) => ({
1492
- url: s.url,
1493
- title: s.title,
1494
- snippet: s.snippet,
1495
- publishedAt: s.publishedAt,
1496
- })),
1590
+ provider: result.provider ?? result._provider ?? "bing",
1591
+ content: typeof result.content === "string" ? result.content : "",
1592
+ sources: (result.sources ?? []).map((s) => {
1593
+ const source = {};
1594
+ if (s.url !== undefined && s.url !== null && s.url !== "") source.url = s.url;
1595
+ if (s.title !== undefined && s.title !== null && s.title !== "") source.title = String(s.title);
1596
+ if (s.snippet !== undefined && s.snippet !== null && s.snippet !== "") source.snippet = String(s.snippet);
1597
+ if (s.publishedAt !== undefined && s.publishedAt !== null && s.publishedAt !== "") {
1598
+ source.publishedAt = String(s.publishedAt);
1599
+ }
1600
+ return source;
1601
+ }),
1497
1602
  };
1498
1603
  },
1499
1604
  finalizeContent(exec, result) {
@@ -1538,7 +1643,7 @@ function apply(ctx, config) {
1538
1643
  "- perplexity - requires PERPLEXITY_API_KEY",
1539
1644
  "- deepseek-official - requires DEEPSEEK_API_KEY",
1540
1645
  "",
1541
- "IMPORTANT: If the configured engine fails (missing key, invalid key, 401, rate limit, or network error), web_search automatically tries other engines in this order: (1) other PAID engines whose API key is configured, (2) then FREE engines (Bing, AnySearch, DuckDuckGo, SearXNG). This applies to ALL engines - paid or free. The results include a note showing which engine was actually used. Never tell the user search is unavailable - it always falls back.",
1646
+ "IMPORTANT: If the configured engine fails (missing key, invalid key, 401, rate limit, or network error), web_search automatically tries other engines in this order: (1) the configured engine first, (2) then other engines with API keys configured (exa/tavily/keenable work keyless too, so they are tried even without a key), (3) then the remaining free engines (Bing, AnySearch, DuckDuckGo, SearXNG). This applies to ALL engines - paid or free. The results include a note showing which engine was actually used and why the preferred one was skipped. Understand the two note forms: (a) 'Note: X does not support time filtering (timeRange=...), using Y.' means X cannot filter by time so it was skipped BEFORE any attempt (X did NOT fail); (b) 'Note: X unavailable or failed (reason), using Y.' means X was actually tried but failed (missing key / invalid key / 401 / rate limit / network / 0 results). Never tell the user search is unavailable - it always falls back.",
1542
1647
  "",
1543
1648
  "Use the free_search_test tool to test which engines actually work right now.",
1544
1649
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-free-search",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
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",