@240xu/dsh-websearch 2.0.5 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,13 @@ pnpm install
73
73
  | `numResults` | 8 | 每次搜索返回结果数(1-50) |
74
74
  | `concurrency` | 6 | 并发后端数(1-10) |
75
75
  | `backendTimeoutMs` | 30000 | 单后端超时毫秒 |
76
+ | `recency` | any | 时间范围过滤:day / week / month / year(映射到各后端原生参数) |
77
+ | `language` | 空 | 搜索语言,如 en、zh-CN(SearXNG/Brave/Serper 支持) |
78
+ | `safeSearch` | true | 安全搜索开关(SearXNG/Brave 支持) |
79
+ | `dedupStrategy` | url | url 仅按链接去重;url+title 额外合并同标题的转载镜像 |
80
+ | `rerank` | false | 按查询词相关性重排序(确定性;并列时保持后端优先级) |
81
+
82
+ **v2.1 结果策略**:设置面板新增「结果策略」分区。时间/语言过滤按各后端能力自动映射——Brave `freshness/country/search_lang`、Tavily `time_range`、Serper `tbs/hl/gl`(Google 日期语法)、SearXNG `time_range/language`、DDG `df`;不支持的后端自动忽略对应维度。内部超时以真实原因失败(backend "<id>" timed out after Nms),不再被静默降级为取消;Mojeek 的 API Key 改走 Authorization 头,不再出现在 URL 中。
76
83
 
77
84
  **11 个后端开关**:`enableExa` / `enableParallel` / `enableDdg` / `enableSearxng`(默认开);`enableBrave` / `enableTavily` / `enableSerper` / `enableMojeek` / `enableDeepseek` / `enableAnthropic` / `enableOpenai`(默认关)
78
85
 
@@ -98,7 +105,9 @@ pnpm install
98
105
  - **One provider, no ambiguity**: a single `registeredSearchProvider({id:"unified"})` — the `dsh-web` seam's selection rule picks it unambiguously, and `search()` caps `maxResults` itself.
99
106
  - **`Promise.allSettled` fan-out**: every enabled + available backend fires concurrently; a single backend failure is demoted to a soft `null` so the rest still contribute — only when ALL fail does the provider throw `WEB_PROVIDER_ERROR`.
100
107
  - **Per-backend abort demotion**: a single backend aborting becomes soft-null; the provider only rethrows `WEB_ABORTED` when the caller's own `AbortSignal` fires.
101
- - **URL dedup + merge**: results across backends are merged and deduped by case-insensitive URL; missing title/snippet/publishedAt from one backend get filled in from another (e.g. Exa's title + Parallel's excerpt).
108
+ - **Dedup strategies**: `url` keeps the historical URL-key merge with cross-backend field fill-in; `url+title` additionally collapses same-story syndicated mirrors (title Jaccard >= 0.9, CJK-aware tokenizer) while still enriching the kept entry.
109
+ - **Deterministic rerank (optional)**: query-term overlap scoring (title x3 / snippet x2 / URL x1); stable ties preserve fan-out priority — same input always yields the same order.
110
+ - **Honest timeouts**: each backend runs under an internal abort controller merged into the caller signal; when only the internal timer fires, the failure is reclassified as WEB_PROVIDER_ERROR ("backend <id> timed out") instead of being masked as a user cancellation.
102
111
  - **Concurrency & Timeout Control**: `concurrency` (default 6) limits simultaneous calls; `backendTimeoutMs` (default 30s) caps each backend.
103
112
  - **Streamable-http MCP, custom handshake**: `lib/util/mcp-client.js` implements `initialize → notifications/initialized → tools/call` with `Mcp-Session-Id` header caching against Exa and Parallel — no dependency on the full MCP SDK.
104
113
  - **hooks.recordRequest/recordOutcome bridge**: each backend routes its request/outcome to `lib/util/log.js` which records `web/unified-search-backend-request` / `…backend-outcome` events onto the active DSH session via `ctx.get("agents")?.currentInitiator()?.session.append(...)` (mirrors `dsh-web-search-deepseek`).
@@ -19,7 +19,7 @@ import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort
19
19
  import { WebError } from "@deepseek-ai/dsh-web";
20
20
 
21
21
  /** Attribution header; bumped with the package version. */
22
- const UA = "dsh-websearch/2.0";
22
+ const UA = "dsh-websearch/2.1";
23
23
 
24
24
  /**
25
25
  * Factory: create an Anthropic-like Messages backend.
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { WebError } from "@deepseek-ai/dsh-web";
9
9
  import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
10
+ import { applyBraveFilters, normalizeFilters, safeSearchParams } from "../util/filters.js";
10
11
 
11
12
  const BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
12
13
 
@@ -33,6 +34,9 @@ export const braveBackend = {
33
34
  const url = new URL(baseURL);
34
35
  url.searchParams.set("q", request.query);
35
36
  url.searchParams.set("count", String(count));
37
+ applyBraveFilters(url, normalizeFilters(request.filters));
38
+ const safe = safeSearchParams("brave", backendOpts.safeSearch);
39
+ if (safe) url.searchParams.set("safesearch", safe);
36
40
 
37
41
  hooks?.recordRequest?.({
38
42
  endpoint: baseURL,
@@ -47,7 +51,7 @@ export const braveBackend = {
47
51
  headers: {
48
52
  "X-Subscription-Token": apiKey,
49
53
  "Accept": "application/json",
50
- "User-Agent": "dsh-websearch/2.0",
54
+ "User-Agent": "dsh-websearch/2.1",
51
55
  },
52
56
  signal,
53
57
  });
@@ -6,6 +6,7 @@
6
6
  // are richer, DDG is the zero-dependency safety-net.
7
7
 
8
8
  import { searchAborted, isAbortError } from "../util/abort.js";
9
+ import { applyDdgFilters, normalizeFilters } from "../util/filters.js";
9
10
  import { WebError } from "@deepseek-ai/dsh-web";
10
11
 
11
12
  export const DDG_ENDPOINT = "https://html.duckduckgo.com/html/";
@@ -26,7 +27,9 @@ export const ddgBackend = {
26
27
  */
27
28
  async search(request, signal, options, hooks = {}) {
28
29
  throwIfSearchAborted(signal);
29
- const body = "q=" + encodeURIComponent(request.query) + "&b=" + Math.random().toString().slice(2);
30
+ const params = new URLSearchParams({ q: request.query, b: Math.random().toString().slice(2) });
31
+ applyDdgFilters(params, normalizeFilters(request.filters));
32
+ const body = params.toString();
30
33
  hooks.recordRequest?.({ endpoint: DDG_ENDPOINT, toolName: "html", body: { q: request.query }, headers: {} });
31
34
 
32
35
  let text;
@@ -37,7 +40,7 @@ export const ddgBackend = {
37
40
  headers: {
38
41
  "Content-Type": "application/x-www-form-urlencoded",
39
42
  "Accept": "text/html",
40
- "User-Agent": "dsh-websearch/2.0",
43
+ "User-Agent": "dsh-websearch/2.1",
41
44
  },
42
45
  body,
43
46
  ...(signal !== undefined ? { signal } : {}),
@@ -45,16 +45,15 @@ export const exaBackend = {
45
45
  );
46
46
  }
47
47
 
48
- hooks.recordOutcome?.({ status: "ok" });
49
- const sources = parseExaResult(result, request.maxResults ?? 8);
50
-
51
48
  if (result?.isError === true) {
52
- throw new WebError(
53
- `exa returned an MCP error: ${String(result.content?.[0]?.text ?? "unknown")}`,
54
- "WEB_PROVIDER_ERROR",
55
- );
49
+ const message = String(result.content?.[0]?.text ?? "unknown");
50
+ hooks.recordOutcome?.({ status: "error", message });
51
+ throw new WebError(`exa returned an MCP error: ${message}`, "WEB_PROVIDER_ERROR");
56
52
  }
57
53
 
54
+ const sources = parseExaResult(result, request.maxResults ?? 8);
55
+ hooks.recordOutcome?.({ status: "ok", count: sources.length });
56
+
58
57
  return { sources, content: undefined, truncated: false };
59
58
  },
60
59
  };
@@ -35,11 +35,13 @@ export const mojeekBackend = {
35
35
  url.searchParams.set("q", request.query);
36
36
  url.searchParams.set("fmt", "json");
37
37
  url.searchParams.set("count", String(count));
38
- url.searchParams.set("api_key", apiKey);
39
38
 
39
+ // Auth via Authorization header — keeps the API key out of URLs (which
40
+ // leak into logs, history and referer headers).
40
41
  const headers = {
41
42
  "Accept": "application/json",
42
- "User-Agent": "dsh-websearch/2.0",
43
+ "Authorization": "Bearer " + apiKey,
44
+ "User-Agent": "dsh-websearch/2.1",
43
45
  };
44
46
 
45
47
  hooks?.recordRequest?.({
@@ -13,7 +13,7 @@
13
13
  import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
14
14
  import { WebError } from "@deepseek-ai/dsh-web";
15
15
 
16
- const UA = "dsh-websearch/2.0";
16
+ const UA = "dsh-websearch/2.1";
17
17
 
18
18
  export const openaiBackend = {
19
19
  id: "openai",
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { WebError } from "@deepseek-ai/dsh-web";
10
10
  import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
11
+ import { applySearxngFilters, normalizeFilters, safeSearchParams } from "../util/filters.js";
11
12
 
12
13
  const DEFAULT_SEARXNG_URL = "https://searx.be";
13
14
 
@@ -33,13 +34,17 @@ export const searxngBackend = {
33
34
  url.searchParams.set("q", request.query);
34
35
  url.searchParams.set("format", "json");
35
36
  url.searchParams.set("categories", "general");
36
- url.searchParams.set("language", "en");
37
- url.searchParams.set("safesearch", "1");
37
+ // Language: settings/request filter wins, else historical "en" default.
38
+ const filters = normalizeFilters(request.filters);
39
+ url.searchParams.set("language", filters.lang ?? "en");
40
+ applySearxngFilters(url, filters); // time_range (+ language override)
41
+ // Safe-search defaults ON (preserves the previous hardcoded behavior).
42
+ url.searchParams.set("safesearch", safeSearchParams("searxng", backendOpts.safeSearch ?? true) ?? "1");
38
43
  url.searchParams.set("pageno", "1");
39
44
 
40
45
  const headers = {
41
46
  "Accept": "application/json",
42
- "User-Agent": "dsh-websearch/2.0",
47
+ "User-Agent": "dsh-websearch/2.1",
43
48
  };
44
49
 
45
50
  if (apiKey) {
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { WebError } from "@deepseek-ai/dsh-web";
9
9
  import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
10
+ import { applySerperFilters, normalizeFilters } from "../util/filters.js";
10
11
 
11
12
  const SERPER_ENDPOINT = "https://google.serper.dev/search";
12
13
 
@@ -35,6 +36,7 @@ export const serperBackend = {
35
36
  q: request.query,
36
37
  num: count,
37
38
  };
39
+ applySerperFilters(body, normalizeFilters(request.filters));
38
40
 
39
41
  hooks?.recordRequest?.({
40
42
  endpoint: baseURL,
@@ -50,7 +52,7 @@ export const serperBackend = {
50
52
  "X-API-KEY": apiKey,
51
53
  "Content-Type": "application/json",
52
54
  "Accept": "application/json",
53
- "User-Agent": "dsh-websearch/2.0",
55
+ "User-Agent": "dsh-websearch/2.1",
54
56
  },
55
57
  body: JSON.stringify(body),
56
58
  signal,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { WebError } from "@deepseek-ai/dsh-web";
9
9
  import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
10
+ import { applyTavilyFilters, normalizeFilters } from "../util/filters.js";
10
11
 
11
12
  const TAVILY_ENDPOINT = "https://api.tavily.com/search";
12
13
 
@@ -39,6 +40,7 @@ export const tavilyBackend = {
39
40
  include_answer: true,
40
41
  include_raw_content: false,
41
42
  };
43
+ applyTavilyFilters(body, normalizeFilters(request.filters));
42
44
 
43
45
  hooks?.recordRequest?.({
44
46
  endpoint: baseURL,
@@ -54,7 +56,7 @@ export const tavilyBackend = {
54
56
  "Authorization": `Bearer ${apiKey}`,
55
57
  "Content-Type": "application/json",
56
58
  "Accept": "application/json",
57
- "User-Agent": "dsh-websearch/2.0",
59
+ "User-Agent": "dsh-websearch/2.1",
58
60
  },
59
61
  body: JSON.stringify(body),
60
62
  signal,
package/lib/client.js CHANGED
@@ -25,6 +25,15 @@ window.__ModuleLoader__.load({
25
25
  { id: "openai", keyEnv: "OPENAI_API_KEY", label: "OpenAI" },
26
26
  ];
27
27
  const GLOBAL_FIELDS = ["numResults", "concurrency", "backendTimeoutMs"];
28
+ /** Enum-valued settings (validated client-side, saved as strings). */
29
+ const SELECT_FIELDS = {
30
+ recency: ["any", "day", "week", "month", "year"],
31
+ dedupStrategy: ["url", "url+title"],
32
+ };
33
+ /** Boolean settings staged as "true"/"false" strings by the selects. */
34
+ const BOOLEAN_FIELDS = ["rerank", "safeSearch"];
35
+ const KEYLESS = BACKENDS.filter((b) => !b.keyEnv);
36
+ const KEYED = BACKENDS.filter((b) => b.keyEnv);
28
37
 
29
38
  /** Where the user goes to get/rotate each key. */
30
39
  const KEY_URLS = {
@@ -39,6 +48,8 @@ window.__ModuleLoader__.load({
39
48
 
40
49
  const zh = {
41
50
  nav: "搜索",
51
+ groupFree: "免密钥(开箱即用)",
52
+ groupKeyed: "需要 API Key",
42
53
  statusEnabled: "已启用后端",
43
54
  statusKeys: "已配置密钥",
44
55
  keyMissing: "缺密钥",
@@ -61,9 +72,27 @@ window.__ModuleLoader__.load({
61
72
  togglesTitle: "启用后端",
62
73
  globalsTitle: "全局参数",
63
74
  keysTitle: "API 密钥(保存后写入凭证库,立即生效)",
75
+ shapingTitle: "结果策略",
76
+ recencyLabel: "时间范围",
77
+ langLabel: "搜索语言",
78
+ safeSearchLabel: "安全搜索",
79
+ dedupLabel: "去重策略",
80
+ rerankLabel: "相关性重排",
81
+ on: "开",
82
+ off: "关",
83
+ recencyAny: "不限",
84
+ recencyDay: "一天内",
85
+ recencyWeek: "一周内",
86
+ recencyMonth: "一月内",
87
+ recencyYear: "一年内",
88
+ dedupUrl: "按链接",
89
+ dedupTitle: "链接 + 标题(合并转载)",
90
+ langPlaceholder: "如 en、zh-CN,留空为默认",
64
91
  };
65
92
  const en = {
66
93
  nav: "Web Search",
94
+ groupFree: "Keyless (ready to use)",
95
+ groupKeyed: "Requires API key",
67
96
  statusEnabled: "Backends enabled",
68
97
  statusKeys: "Keys configured",
69
98
  keyMissing: "key missing",
@@ -86,6 +115,22 @@ window.__ModuleLoader__.load({
86
115
  togglesTitle: "Enabled backends",
87
116
  globalsTitle: "Global parameters",
88
117
  keysTitle: "API keys (saved to the credential store, effective immediately)",
118
+ shapingTitle: "Result shaping",
119
+ recencyLabel: "Recency",
120
+ langLabel: "Language",
121
+ safeSearchLabel: "Safe search",
122
+ dedupLabel: "Dedup strategy",
123
+ rerankLabel: "Relevance rerank",
124
+ on: "On",
125
+ off: "Off",
126
+ recencyAny: "Any time",
127
+ recencyDay: "Past day",
128
+ recencyWeek: "Past week",
129
+ recencyMonth: "Past month",
130
+ recencyYear: "Past year",
131
+ dedupUrl: "By URL",
132
+ dedupTitle: "URL + title (merge syndication)",
133
+ langPlaceholder: "e.g. en, zh-CN; blank = backend default",
89
134
  };
90
135
 
91
136
  //#region styles
@@ -196,6 +241,18 @@ window.__ModuleLoader__.load({
196
241
  for (const [field, staged] of this.staged) {
197
242
  // Empty draft clears the override so the schema default applies again.
198
243
  if (staged.clear || staged.text === "") { await this.scope.unset(field); doneFields.push(field); continue; }
244
+ if (SELECT_FIELDS[field]) {
245
+ if (!SELECT_FIELDS[field].includes(staged.text)) { this.failed = true; continue; }
246
+ await this.scope.set(field, staged.text);
247
+ doneFields.push(field);
248
+ continue;
249
+ }
250
+ if (BOOLEAN_FIELDS.includes(field)) {
251
+ if (staged.text !== "true" && staged.text !== "false") { this.failed = true; continue; }
252
+ await this.scope.set(field, staged.text === "true");
253
+ doneFields.push(field);
254
+ continue;
255
+ }
199
256
  if (GLOBAL_FIELDS.includes(field)) {
200
257
  const num = Number(staged.text);
201
258
  // Mirror the server schema bounds client-side so bad input is
@@ -266,6 +323,21 @@ window.__ModuleLoader__.load({
266
323
  ] });
267
324
  }
268
325
 
326
+ /** Labeled <select> for enum / boolean settings. */
327
+ function SelectField(props) {
328
+ return react_jsx_runtime.jsxs("div", { children: [
329
+ react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.label }) }),
330
+ react_jsx_runtime.jsx("select", {
331
+ className: "wsc-input",
332
+ value: props.value,
333
+ onChange: (e) => props.onChange(e.target.value),
334
+ children: props.options.map((o) =>
335
+ react_jsx_runtime.jsx("option", { value: o.value, children: o.label }, o.value)
336
+ ),
337
+ }),
338
+ ] });
339
+ }
340
+
269
341
  /** The card root. */
270
342
  function UnifiedSearchSection(props) {
271
343
  const state = props.useUnifiedSearchCard((snapshot) => snapshot);
@@ -282,18 +354,44 @@ window.__ModuleLoader__.load({
282
354
  const keyedBackends = BACKENDS.filter((b) => b.keyEnv);
283
355
  const keysReady = keyedBackends.filter((b) => state.keyState[b.keyEnv]?.configured).length;
284
356
 
357
+ const recencyOptions = [
358
+ { value: "any", label: props.t("recencyAny") },
359
+ { value: "day", label: props.t("recencyDay") },
360
+ { value: "week", label: props.t("recencyWeek") },
361
+ { value: "month", label: props.t("recencyMonth") },
362
+ { value: "year", label: props.t("recencyYear") },
363
+ ];
364
+ const dedupOptions = [
365
+ { value: "url", label: props.t("dedupUrl") },
366
+ { value: "url+title", label: props.t("dedupTitle") },
367
+ ];
368
+ const boolOptions = [
369
+ { value: "true", label: props.t("on") },
370
+ { value: "false", label: props.t("off") },
371
+ ];
372
+ const selVal = (f, dflt) => state.stagedText(f) ?? String(state.value[f] ?? dflt);
373
+
285
374
  return react_jsx_runtime.jsxs("div", { className: "wsc-card", children: [
286
375
  react_jsx_runtime.jsx("div", { style: { display: "flex", gap: 14, alignItems: "center", padding: "10px 12px", marginBottom: 10, borderRadius: 8, background: "rgba(127,127,127,.08)", fontSize: 13 }, children: [
287
376
  react_jsx_runtime.jsxs("span", { children: [props.t("statusEnabled"), ": ", react_jsx_runtime.jsx("b", { children: enabledCount + "/" + BACKENDS.length })] }),
288
377
  react_jsx_runtime.jsxs("span", { children: [props.t("statusKeys"), ": ", react_jsx_runtime.jsx("b", { children: keysReady + "/" + keyedBackends.length })] }),
289
378
  ] }),
290
379
  react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, opacity: .65 }, children: props.t("description") }),
291
- react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("togglesTitle") }),
292
- react_jsx_runtime.jsx("div", { className: "wsc-toggles", children: BACKENDS.map((b) =>
380
+ react_jsx_runtime.jsx("h5", { className: "wsc-sectionTitle", children: props.t("groupFree") }),
381
+ react_jsx_runtime.jsx("div", { className: "wsc-toggles", children: KEYLESS.map((b) =>
293
382
  react_jsx_runtime.jsx(ToggleRow, {
294
383
  backend: b,
295
384
  value: state.value[toggleField(b.id)],
296
- keyConfigured: !b.keyEnv || !!state.keyState[b.keyEnv]?.configured,
385
+ keyConfigured: true,
386
+ onChange: (checked) => card.setToggle(b.id, checked),
387
+ }, b.id)
388
+ ) }),
389
+ react_jsx_runtime.jsx("h5", { className: "wsc-sectionTitle", children: props.t("groupKeyed") }),
390
+ react_jsx_runtime.jsx("div", { className: "wsc-toggles", children: KEYED.map((b) =>
391
+ react_jsx_runtime.jsx(ToggleRow, {
392
+ backend: b,
393
+ value: state.value[toggleField(b.id)],
394
+ keyConfigured: !!state.keyState[b.keyEnv]?.configured,
297
395
  onChange: (checked) => card.setToggle(b.id, checked),
298
396
  }, b.id)
299
397
  ) }),
@@ -312,6 +410,42 @@ window.__ModuleLoader__.load({
312
410
  react_jsx_runtime.jsx("input", { className: "wsc-input", value: state.stagedText("backendTimeoutMs") ?? String(state.value.backendTimeoutMs ?? ""), onChange: e => card.edit("backendTimeoutMs", e.target.value), inputMode: "numeric" }),
313
411
  ] }),
314
412
  ] }),
413
+ react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("shapingTitle") }),
414
+ react_jsx_runtime.jsxs("div", { className: "wsc-grid", children: [
415
+ react_jsx_runtime.jsx(SelectField, {
416
+ label: props.t("recencyLabel"),
417
+ value: selVal("recency", "any"),
418
+ options: recencyOptions,
419
+ onChange: (v) => card.edit("recency", v),
420
+ }),
421
+ react_jsx_runtime.jsxs("div", { children: [
422
+ react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.t("langLabel") }) }),
423
+ react_jsx_runtime.jsx("input", {
424
+ className: "wsc-input",
425
+ placeholder: props.t("langPlaceholder"),
426
+ value: state.stagedText("language") ?? String(state.value.language ?? ""),
427
+ onChange: e => card.edit("language", e.target.value),
428
+ }),
429
+ ] }),
430
+ react_jsx_runtime.jsx(SelectField, {
431
+ label: props.t("dedupLabel"),
432
+ value: selVal("dedupStrategy", "url"),
433
+ options: dedupOptions,
434
+ onChange: (v) => card.edit("dedupStrategy", v),
435
+ }),
436
+ react_jsx_runtime.jsx(SelectField, {
437
+ label: props.t("rerankLabel"),
438
+ value: selVal("rerank", "false"),
439
+ options: boolOptions,
440
+ onChange: (v) => card.edit("rerank", v),
441
+ }),
442
+ react_jsx_runtime.jsx(SelectField, {
443
+ label: props.t("safeSearchLabel"),
444
+ value: selVal("safeSearch", "true"),
445
+ options: boolOptions,
446
+ onChange: (v) => card.edit("safeSearch", v),
447
+ }),
448
+ ] }),
315
449
  react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("keysTitle") }),
316
450
  react_jsx_runtime.jsx("div", { className: "wsc-grid", children: BACKENDS.filter((b) => b.keyEnv).map((b) =>
317
451
  react_jsx_runtime.jsxs("div", { children: [
@@ -359,7 +493,7 @@ window.__ModuleLoader__.load({
359
493
  name: "settings.section",
360
494
  id: "websearch",
361
495
  order: 16,
362
- label: () => t("nav"),
496
+ label: () => ctx.locale.bind(NS)("nav"),
363
497
  locale: NS,
364
498
  inject: () => controller.inject(),
365
499
  }, UnifiedSearchSection));
package/lib/index.js CHANGED
@@ -130,6 +130,18 @@ export const Config = z.object({
130
130
  backendTimeoutMs: z.number().step(1000).min(1000).max(120000).default(30000)
131
131
  .description("单后端最大等待时间(毫秒)"),
132
132
 
133
+ // v2.1 result shaping
134
+ recency: z.union(["any", "day", "week", "month", "year"]).default("any")
135
+ .description("时间范围过滤(映射到各后端原生参数;any=不过滤)"),
136
+ language: z.string().default("")
137
+ .description("搜索语言(如 en、zh-CN;留空用各后端默认)"),
138
+ safeSearch: z.boolean().default(true)
139
+ .description("安全搜索(SearXNG / Brave 支持开关,其余后端忽略)"),
140
+ dedupStrategy: z.union(["url", "url+title"]).default("url")
141
+ .description("去重策略:url 仅按链接;url+title 额外合并同标题的转载镜像"),
142
+ rerank: z.boolean().default(false)
143
+ .description("按查询词相关性重排序(确定性、并列时保持后端优先级)"),
144
+
133
145
  // Enable toggles (one per backend)
134
146
  enableExa: z.boolean().default(true).description(DEFAULT_LABELS.exa),
135
147
  enableParallel: z.boolean().default(true).description(DEFAULT_LABELS.parallel),
@@ -230,7 +242,8 @@ export function resolveOptions(ctx, config) {
230
242
  apiKeyEnv: keyEnvName,
231
243
  baseURL: config?.[`${id}BaseURL`] ?? DEFAULT_BASE_URLS[id],
232
244
  model: config?.[`${id}Model`] ?? DEFAULT_MODELS[id],
233
- searchDepth: config?.tavilySearchDepth,
245
+ ...(id === "tavily" ? { searchDepth: config?.tavilySearchDepth ?? "basic" } : {}),
246
+ safeSearch: config?.safeSearch !== false,
234
247
  label: DEFAULT_LABELS[id] ?? id,
235
248
  requiresKey: DEFAULT_KEY_ENV[id] !== undefined,
236
249
  resolveApiKey: async () => {
@@ -242,11 +255,20 @@ export function resolveOptions(ctx, config) {
242
255
  };
243
256
  }
244
257
 
258
+ const language = typeof config?.language === "string" ? config.language.trim() : "";
259
+
245
260
  return {
246
261
  enabledBackends,
247
262
  numResults: config?.numResults ?? 8,
248
263
  concurrency: config?.concurrency ?? 6,
249
264
  backendTimeoutMs: config?.backendTimeoutMs ?? 30000,
265
+ // v2.1 shaping options consumed by the provider merge pipeline.
266
+ dedupStrategy: config?.dedupStrategy === "url+title" ? "url+title" : "url",
267
+ rerank: config?.rerank === true,
268
+ filters: {
269
+ recency: typeof config?.recency === "string" && config.recency !== "any" ? config.recency : undefined,
270
+ lang: language.length > 0 ? language : undefined,
271
+ },
250
272
  backends,
251
273
  ctx,
252
274
  };
package/lib/provider.js CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  recordBackendRequest,
21
21
  recordBackendOutcome,
22
22
  } from "./util/log.js";
23
+ import { dedupeSources, rerankSources } from "./util/rank.js";
24
+ import { normalizeFilters } from "./util/filters.js";
23
25
 
24
26
  const PROVIDER_ID = "unified";
25
27
 
@@ -107,50 +109,62 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
107
109
  );
108
110
  }
109
111
 
112
+ // Settings-level default filters, overridden by per-request filters
113
+ // (normalizeFilters drops unknown/invalid values so typos degrade to
114
+ // "no filter" instead of breaking the search).
115
+ const filters = normalizeFilters({
116
+ ...(opts.filters ?? {}),
117
+ ...(request?.filters ?? {}),
118
+ });
119
+ const effectiveRequest =
120
+ Object.keys(filters).length > 0 ? { ...request, filters } : request;
121
+
110
122
  // Concurrency limiter
111
123
  const limit = createLimiter(opts.concurrency ?? 6);
112
124
 
113
- // Wrap each backend call with timeout
114
- const runWithTimeout = (backend, req, sig, beOpts, hooks) => {
115
- const timeoutMs = backendTimeoutMs;
125
+ // Run one backend under the per-backend timeout. The internal timer's
126
+ // controller is merged into the caller's signal; when the INTERNAL timer
127
+ // fired (caller did not abort), a backend abort-shaped failure is
128
+ // reclassified as WEB_PROVIDER_ERROR so timeouts surface as real
129
+ // failures instead of being silently demoted like user cancellations.
130
+ const runBackend = ({ id, backend, beOpts, hooks }) => {
116
131
  const controller = new AbortController();
117
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
118
-
119
- // Merge signals
120
- const mergedSignal = sig
121
- ? AbortSignal.any([sig, controller.signal])
132
+ const mergedSignal = signal
133
+ ? AbortSignal.any([signal, controller.signal])
122
134
  : controller.signal;
123
-
124
- return Promise.race([
125
- backend.search(req, mergedSignal, beOpts, hooks),
126
- new Promise((_, reject) => {
127
- controller.signal.addEventListener('abort', () => {
128
- if (controller.signal.reason === 'timeout' || !sig?.aborted) {
129
- reject(new WebError(`Backend timed out after ${timeoutMs}ms`, "WEB_PROVIDER_ERROR"));
130
- }
131
- });
135
+ const timeoutId = setTimeout(() => controller.abort(), backendTimeoutMs);
136
+ return backend
137
+ .search(effectiveRequest, mergedSignal, beOpts, hooks)
138
+ .catch((err) => {
139
+ if (controller.signal.aborted && !(signal && signal.aborted)) {
140
+ throw new WebError(
141
+ `backend "${id}" timed out after ${backendTimeoutMs}ms`,
142
+ "WEB_PROVIDER_ERROR",
143
+ { cause: err instanceof Error ? err : undefined },
144
+ );
145
+ }
146
+ throw err;
132
147
  })
133
- ]).finally(() => clearTimeout(timeoutId));
148
+ .finally(() => clearTimeout(timeoutId));
134
149
  };
135
150
 
136
151
  const results = await Promise.allSettled(
137
152
  eligible.map(({ id, backend, beOpts }) => {
138
- const recordRequest = (req) => recordBackendRequest(ctx, id, req);
139
- const recordOutcome = (out) => recordBackendOutcome(ctx, id, out);
140
- const hooks = { recordRequest, recordOutcome };
141
-
142
- return limit(async () => {
143
- return runWithTimeout(backend, request, signal, beOpts, hooks);
144
- });
153
+ const hooks = {
154
+ recordRequest: (req) => recordBackendRequest(ctx, id, req),
155
+ recordOutcome: (out) => recordBackendOutcome(ctx, id, out),
156
+ };
157
+ return limit(() => runBackend({ id, backend, beOpts, hooks }));
145
158
  }),
146
159
  );
147
160
 
148
161
  throwIfSearchAborted(signal);
149
162
 
150
- // Merge: dedup by url (case-insensitive normalized), keep first-seen
151
- // non-empty title/snippet/publishedAt. Tolerate soft-null settled values.
152
- const seen = new Set();
153
- const merged = [];
163
+ // Collect every fulfilled backend's sources in fan-out priority order,
164
+ // then dedupe/rank ONCE over the full set. (The previous loop stopped at
165
+ // maxResults mid-merge, which silently dropped later backends' entries
166
+ // before cross-backend fill-in and ranking could see them.)
167
+ const collected = [];
154
168
  let mergedContent;
155
169
  let hardAborted = false;
156
170
  let hardError = null;
@@ -161,25 +175,7 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
161
175
  if (mergedContent === undefined && typeof settled.value?.content === "string" && settled.value.content.length > 0) {
162
176
  mergedContent = settled.value.content;
163
177
  }
164
- const srcs = settled.value?.sources ?? [];
165
- for (const s of srcs) {
166
- if (!s || typeof s.url !== "string" || s.url.length === 0) continue;
167
- const key = s.url.toLowerCase();
168
- if (seen.has(key)) {
169
- // Merge fields into the existing entry (fill-in missing ones).
170
- const existing = merged.find((m) => m.url.toLowerCase() === key);
171
- if (existing) {
172
- if (!existing.title && s.title) existing.title = s.title;
173
- if (!existing.snippet && s.snippet) existing.snippet = s.snippet;
174
- if (!existing.publishedAt && s.publishedAt) existing.publishedAt = s.publishedAt;
175
- }
176
- continue;
177
- }
178
- seen.add(key);
179
- merged.push({ ...s });
180
- if (merged.length >= maxResults) break;
181
- }
182
- if (merged.length >= maxResults) break;
178
+ for (const s of settled.value?.sources ?? []) collected.push(s);
183
179
  } else {
184
180
  // settled.status === "rejected"
185
181
  const err = settled.reason;
@@ -199,13 +195,24 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
199
195
  throw searchAborted(signal);
200
196
  }
201
197
 
198
+ // Dedup: "url" preserves the historical URL-key behavior; "url+title"
199
+ // additionally collapses same-story syndicated copies across backends.
200
+ const strategy = opts.dedupStrategy === "url+title" ? "url+title" : "url";
201
+ const deduped = dedupeSources(collected, strategy);
202
+ // Optional deterministic relevance rerank (stable ties keep fan-out order).
203
+ const ordered =
204
+ opts.rerank === true
205
+ ? rerankSources(deduped, typeof request?.query === "string" ? request.query : "")
206
+ : deduped;
207
+
202
208
  // If we got ANY sources, return them even if some backends failed.
203
- if (merged.length > 0) {
204
- const sources = merged.slice(0, maxResults);
205
- const wasCapped =
206
- merged.length > maxResults ||
207
- results.some((r) => r.status === "fulfilled" && (r.value?.sources?.length ?? 0) > maxResults);
208
- return { sources, ...(mergedContent !== undefined ? { content: mergedContent } : {}), truncated: sources.length >= maxResults && wasCapped };
209
+ if (ordered.length > 0) {
210
+ const sources = ordered.slice(0, maxResults);
211
+ return {
212
+ sources,
213
+ ...(mergedContent !== undefined ? { content: mergedContent } : {}),
214
+ truncated: ordered.length > maxResults,
215
+ };
209
216
  }
210
217
 
211
218
  // All enabled backends failed — synthesize a meaningful WEB_PROVIDER_ERROR.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @module dsh-websearch/util/filters
3
+ *
4
+ * Normalized cross-backend search filters.
5
+ *
6
+ * The provider resolves ONE canonical filter shape from settings + request:
7
+ * { recency?: "day"|"week"|"month"|"year", lang?: string, region?: string }
8
+ *
9
+ * Each REST backend maps only the dimensions it supports (via the apply*
10
+ * helpers below); unsupported dimensions are silently ignored so a single
11
+ * settings panel can drive every backend without lying about reach.
12
+ * All functions are pure and unit-tested in tests/filters.test.js.
13
+ */
14
+
15
+ /** Canonical recency values accepted from settings/request. */
16
+ export const RECENCY_VALUES = ["day", "week", "month", "year"];
17
+
18
+ /**
19
+ * Backend-specific recency vocabularies:
20
+ * brave: freshness=pd|pw|pm|py ; tavily: time_range=day|week|month|year;
21
+ * serper: tbs=qdr:d|w|m|y (Google date syntax); searxng: time_range=*;
22
+ * ddg: df=d|w|m|y (html endpoint date filter).
23
+ */
24
+ const RECENCY_MAP = {
25
+ brave: { day: "pd", week: "pw", month: "pm", year: "py" },
26
+ tavily: { day: "day", week: "week", month: "month", year: "year" },
27
+ serper: { day: "qdr:d", week: "qdr:w", month: "qdr:m", year: "qdr:y" },
28
+ searxng: { day: "day", week: "week", month: "month", year: "year" },
29
+ ddg: { day: "d", week: "w", month: "m", year: "y" },
30
+ };
31
+
32
+ /**
33
+ * Validate + normalize a raw filter bag. Unknown keys and out-of-vocabulary
34
+ * values are dropped: a typo must degrade to "no filter", never break search.
35
+ */
36
+ export function normalizeFilters(raw) {
37
+ if (!raw || typeof raw !== "object") return {};
38
+ const out = {};
39
+ if (typeof raw.recency === "string" && RECENCY_VALUES.includes(raw.recency)) {
40
+ out.recency = raw.recency;
41
+ }
42
+ if (typeof raw.lang === "string" && raw.lang.trim().length > 0) out.lang = raw.lang.trim();
43
+ if (typeof raw.region === "string" && raw.region.trim().length > 0) out.region = raw.region.trim();
44
+ return out;
45
+ }
46
+
47
+ /** Backend-specific recency token, or undefined when unsupported/unset. */
48
+ export function recencyToken(backendId, filters) {
49
+ const v = filters && filters.recency;
50
+ if (!v) return undefined;
51
+ const row = RECENCY_MAP[backendId];
52
+ return row ? row[v] : undefined;
53
+ }
54
+
55
+ /** Brave: freshness + optional country/search_lang (ISO-ish codes passed through). */
56
+ export function applyBraveFilters(url, filters) {
57
+ const fresh = recencyToken("brave", filters);
58
+ if (fresh) url.searchParams.set("freshness", fresh);
59
+ if (filters && filters.region) url.searchParams.set("country", filters.region);
60
+ if (filters && filters.lang) url.searchParams.set("search_lang", filters.lang);
61
+ return url;
62
+ }
63
+
64
+ /** Tavily: time_range body field. */
65
+ export function applyTavilyFilters(body, filters) {
66
+ const tr = recencyToken("tavily", filters);
67
+ if (tr) body.time_range = tr;
68
+ return body;
69
+ }
70
+
71
+ /** Serper: Google tbs date syntax + hl (interface lang) / gl (country). */
72
+ export function applySerperFilters(body, filters) {
73
+ const tbs = recencyToken("serper", filters);
74
+ if (tbs) body.tbs = tbs;
75
+ if (filters && filters.lang) body.hl = filters.lang;
76
+ if (filters && filters.region) body.gl = filters.region;
77
+ return body;
78
+ }
79
+
80
+ /** SearXNG: native time_range + language query params. */
81
+ export function applySearxngFilters(url, filters) {
82
+ const tr = recencyToken("searxng", filters);
83
+ if (tr) url.searchParams.set("time_range", tr);
84
+ if (filters && filters.lang) url.searchParams.set("language", filters.lang);
85
+ return url;
86
+ }
87
+
88
+ /** DuckDuckGo html endpoint: df (date filter) form field, best-effort. */
89
+ export function applyDdgFilters(formParams, filters) {
90
+ const df = recencyToken("ddg", filters);
91
+ if (df) formParams.set("df", df);
92
+ return formParams;
93
+ }
94
+
95
+ /**
96
+ * Safe-search parameter per backend (null = omit / unsupported):
97
+ * searxng -> "1"|"0" ; brave -> "strict"|"off".
98
+ */
99
+ export function safeSearchParams(backendId, enabled) {
100
+ if (enabled === undefined || enabled === null) return null;
101
+ if (backendId === "searxng") return enabled ? "1" : "0";
102
+ if (backendId === "brave") return enabled ? "strict" : "off";
103
+ return null;
104
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { WebError } from "@deepseek-ai/dsh-web";
9
9
 
10
- const UA = "dsh-websearch/2.0";
10
+ const UA = "dsh-websearch/2.1";
11
11
  const PROTOCOL_VERSION = "2025-06-18";
12
12
  const SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes
13
13
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @module dsh-websearch/util/rank
3
+ *
4
+ * Pure result-set post-processing for the unified provider:
5
+ * - dedupeSources(sources, strategy) : cross-backend dedup with field fill-in
6
+ * - rerankSources(sources, query) : deterministic query-relevance ordering
7
+ *
8
+ * Both are pure, dependency-free and unit-tested in tests/rank.test.js.
9
+ * Determinism matters: identical inputs must always produce identical output
10
+ * so cached/replayed searches stay stable.
11
+ */
12
+
13
+ /** Lowercase + tokenize into a Set. ASCII words (len>=2) plus CJK chars singly,
14
+ * so Chinese/Japanese titles still produce meaningful overlap signals. */
15
+ export function tokenize(text) {
16
+ const tokens = new Set();
17
+ if (typeof text !== "string" || text.length === 0) return tokens;
18
+ const lower = text.toLowerCase();
19
+ // ASCII word runs of length >= 2 ...
20
+ for (const w of lower.match(/[a-z0-9]{2,}/g) ?? []) tokens.add(w);
21
+ // ... plus individual CJK chars (U+3400-U+9FFF common range covers CJK).
22
+ for (const ch of lower.match(/[\u3400-\u9fff]/g) ?? []) tokens.add(ch);
23
+ return tokens;
24
+ }
25
+
26
+ /** Jaccard similarity of two token sets: |A∩B| / |A∪B| ∈ [0,1]. */
27
+ export function tokenSimilarity(a, b) {
28
+ if (a.size === 0 && b.size === 0) return 0;
29
+ let inter = 0;
30
+ for (const t of a) if (b.has(t)) inter++;
31
+ const union = a.size + b.size - inter;
32
+ return union === 0 ? 0 : inter / union;
33
+ }
34
+
35
+ /** Titles this similar (Jaccard >= threshold) count as the same story when
36
+ * strategy is "url+title" (aggregator mirrors / syndicated copies). */
37
+ const TITLE_DUP_THRESHOLD = 0.9;
38
+
39
+ /**
40
+ * Dedupe an ordered source list.
41
+ *
42
+ * strategy "url" — key on lowercased URL; later duplicates fill in any
43
+ * missing title/snippet/publishedAt fields (existing
44
+ * behavior, preserved verbatim).
45
+ * strategy "url+title" — additionally drop later entries whose TITLE overlaps
46
+ * an already-kept entry's title at >=0.9 Jaccard (both
47
+ * titles non-empty). Different URLs reporting the same
48
+ * story collapse to the higher-priority backend's hit.
49
+ *
50
+ * The input array is not mutated; returns a fresh array.
51
+ */
52
+ export function dedupeSources(sources, strategy) {
53
+ const out = [];
54
+ const byUrl = new Map(); // urlKey -> kept source
55
+ const keptTitles = []; // [{ tokens }] only when strategy is url+title
56
+
57
+ for (const s of sources ?? []) {
58
+ if (!s || typeof s.url !== "string" || s.url.length === 0) continue;
59
+ const key = s.url.toLowerCase();
60
+ const existing = byUrl.get(key);
61
+ if (existing) {
62
+ if (!existing.title && s.title) existing.title = s.title;
63
+ if (!existing.snippet && s.snippet) existing.snippet = s.snippet;
64
+ if (!existing.publishedAt && s.publishedAt) existing.publishedAt = s.publishedAt;
65
+ continue;
66
+ }
67
+
68
+ if (strategy === "url+title" && typeof s.title === "string" && s.title.length > 0) {
69
+ const tokens = tokenize(s.title);
70
+ const dupOf = keptTitles.find((k) => tokenSimilarity(k.tokens, tokens) >= TITLE_DUP_THRESHOLD);
71
+ if (dupOf) {
72
+ // Same story from another outlet: enrich the kept entry with any
73
+ // fields the mirror carries and the original lacks.
74
+ if (!dupOf.source.title && s.title) dupOf.source.title = s.title;
75
+ if (!dupOf.source.snippet && s.snippet) dupOf.source.snippet = s.snippet;
76
+ if (!dupOf.source.publishedAt && s.publishedAt) dupOf.source.publishedAt = s.publishedAt;
77
+ continue;
78
+ }
79
+ }
80
+
81
+ const copy = { ...s };
82
+ byUrl.set(key, copy);
83
+ out.push(copy);
84
+ if (strategy === "url+title" && typeof s.title === "string" && s.title.length > 0) {
85
+ // backfill this kept entry into its title bucket for later comparisons
86
+ keptTitles.push({ tokens: tokenize(s.title), source: copy });
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * Score one source against the query tokens:
94
+ * title match ×3, snippet match ×2, URL match ×1.
95
+ */
96
+ function scoreSource(source, queryTokens) {
97
+ let score = 0;
98
+ const titleTokens = tokenize(source.title ?? "");
99
+ const snippetTokens = tokenize(source.snippet ?? "");
100
+ const urlTokens = tokenize(String(source.url ?? "").replace(/[^a-z0-9]+/g, " "));
101
+ for (const q of queryTokens) {
102
+ if (titleTokens.has(q)) score += 3;
103
+ if (snippetTokens.has(q)) score += 2;
104
+ if (urlTokens.has(q)) score += 1;
105
+ }
106
+ return score;
107
+ }
108
+
109
+ /**
110
+ * Deterministic relevance rerank. Stable sort by descending score; equal
111
+ * scores keep their original relative order (fan-out priority wins ties).
112
+ * Returns a fresh array; input untouched.
113
+ */
114
+ export function rerankSources(sources, query) {
115
+ const queryTokens = tokenize(query);
116
+ if (queryTokens.size === 0 || !Array.isArray(sources)) return [...(sources ?? [])];
117
+ return sources
118
+ .map((s, index) => ({ s, index, score: scoreSource(s, queryTokens) }))
119
+ .sort((a, b) => (b.score - a.score) || (a.index - b.index))
120
+ .map((e) => e.s);
121
+ }
package/lib/util/rpc.js CHANGED
@@ -24,7 +24,7 @@ import { WebError } from "@deepseek-ai/dsh-web";
24
24
  import { throwIfSearchAborted, searchAborted, isAbortError } from "./abort.js";
25
25
 
26
26
  /** User-Agent header; bumped with the package version. */
27
- export const UA = "dsh-websearch/2.0";
27
+ export const UA = "dsh-websearch/2.1";
28
28
 
29
29
  /** MCP protocol version we negotiate. */
30
30
  const PROTOCOL_VERSION = "2025-06-18";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@240xu/dsh-websearch",
3
- "version": "2.0.5",
3
+ "version": "2.1.0",
4
4
  "description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends — Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search tool.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",