@240xu/dsh-websearch 2.0.6 → 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 +10 -1
- package/lib/backends/anthropic-like.js +1 -1
- package/lib/backends/brave.js +5 -1
- package/lib/backends/ddg.js +5 -2
- package/lib/backends/exa.js +6 -7
- package/lib/backends/mojeek.js +4 -2
- package/lib/backends/openai.js +1 -1
- package/lib/backends/searxng.js +8 -3
- package/lib/backends/serper.js +3 -1
- package/lib/backends/tavily.js +3 -1
- package/lib/client.js +119 -0
- package/lib/index.js +23 -1
- package/lib/provider.js +61 -54
- package/lib/util/filters.js +104 -0
- package/lib/util/mcp-client.js +1 -1
- package/lib/util/rank.js +121 -0
- package/lib/util/rpc.js +1 -1
- package/package.json +1 -1
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
|
-
- **
|
|
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.
|
|
22
|
+
const UA = "dsh-websearch/2.1";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Factory: create an Anthropic-like Messages backend.
|
package/lib/backends/brave.js
CHANGED
|
@@ -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.
|
|
54
|
+
"User-Agent": "dsh-websearch/2.1",
|
|
51
55
|
},
|
|
52
56
|
signal,
|
|
53
57
|
});
|
package/lib/backends/ddg.js
CHANGED
|
@@ -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
|
|
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.
|
|
43
|
+
"User-Agent": "dsh-websearch/2.1",
|
|
41
44
|
},
|
|
42
45
|
body,
|
|
43
46
|
...(signal !== undefined ? { signal } : {}),
|
package/lib/backends/exa.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
};
|
package/lib/backends/mojeek.js
CHANGED
|
@@ -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
|
-
"
|
|
43
|
+
"Authorization": "Bearer " + apiKey,
|
|
44
|
+
"User-Agent": "dsh-websearch/2.1",
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
hooks?.recordRequest?.({
|
package/lib/backends/openai.js
CHANGED
|
@@ -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.
|
|
16
|
+
const UA = "dsh-websearch/2.1";
|
|
17
17
|
|
|
18
18
|
export const openaiBackend = {
|
|
19
19
|
id: "openai",
|
package/lib/backends/searxng.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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.
|
|
47
|
+
"User-Agent": "dsh-websearch/2.1",
|
|
43
48
|
};
|
|
44
49
|
|
|
45
50
|
if (apiKey) {
|
package/lib/backends/serper.js
CHANGED
|
@@ -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.
|
|
55
|
+
"User-Agent": "dsh-websearch/2.1",
|
|
54
56
|
},
|
|
55
57
|
body: JSON.stringify(body),
|
|
56
58
|
signal,
|
package/lib/backends/tavily.js
CHANGED
|
@@ -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.
|
|
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,13 @@ 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"];
|
|
28
35
|
const KEYLESS = BACKENDS.filter((b) => !b.keyEnv);
|
|
29
36
|
const KEYED = BACKENDS.filter((b) => b.keyEnv);
|
|
30
37
|
|
|
@@ -65,6 +72,22 @@ window.__ModuleLoader__.load({
|
|
|
65
72
|
togglesTitle: "启用后端",
|
|
66
73
|
globalsTitle: "全局参数",
|
|
67
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,留空为默认",
|
|
68
91
|
};
|
|
69
92
|
const en = {
|
|
70
93
|
nav: "Web Search",
|
|
@@ -92,6 +115,22 @@ window.__ModuleLoader__.load({
|
|
|
92
115
|
togglesTitle: "Enabled backends",
|
|
93
116
|
globalsTitle: "Global parameters",
|
|
94
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",
|
|
95
134
|
};
|
|
96
135
|
|
|
97
136
|
//#region styles
|
|
@@ -202,6 +241,18 @@ window.__ModuleLoader__.load({
|
|
|
202
241
|
for (const [field, staged] of this.staged) {
|
|
203
242
|
// Empty draft clears the override so the schema default applies again.
|
|
204
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
|
+
}
|
|
205
256
|
if (GLOBAL_FIELDS.includes(field)) {
|
|
206
257
|
const num = Number(staged.text);
|
|
207
258
|
// Mirror the server schema bounds client-side so bad input is
|
|
@@ -272,6 +323,21 @@ window.__ModuleLoader__.load({
|
|
|
272
323
|
] });
|
|
273
324
|
}
|
|
274
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
|
+
|
|
275
341
|
/** The card root. */
|
|
276
342
|
function UnifiedSearchSection(props) {
|
|
277
343
|
const state = props.useUnifiedSearchCard((snapshot) => snapshot);
|
|
@@ -288,6 +354,23 @@ window.__ModuleLoader__.load({
|
|
|
288
354
|
const keyedBackends = BACKENDS.filter((b) => b.keyEnv);
|
|
289
355
|
const keysReady = keyedBackends.filter((b) => state.keyState[b.keyEnv]?.configured).length;
|
|
290
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
|
+
|
|
291
374
|
return react_jsx_runtime.jsxs("div", { className: "wsc-card", children: [
|
|
292
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: [
|
|
293
376
|
react_jsx_runtime.jsxs("span", { children: [props.t("statusEnabled"), ": ", react_jsx_runtime.jsx("b", { children: enabledCount + "/" + BACKENDS.length })] }),
|
|
@@ -327,6 +410,42 @@ window.__ModuleLoader__.load({
|
|
|
327
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" }),
|
|
328
411
|
] }),
|
|
329
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
|
+
] }),
|
|
330
449
|
react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("keysTitle") }),
|
|
331
450
|
react_jsx_runtime.jsx("div", { className: "wsc-grid", children: BACKENDS.filter((b) => b.keyEnv).map((b) =>
|
|
332
451
|
react_jsx_runtime.jsxs("div", { children: [
|
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
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
controller.signal.
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
148
|
+
.finally(() => clearTimeout(timeoutId));
|
|
134
149
|
};
|
|
135
150
|
|
|
136
151
|
const results = await Promise.allSettled(
|
|
137
152
|
eligible.map(({ id, backend, beOpts }) => {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return limit(
|
|
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
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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 (
|
|
204
|
-
const sources =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
}
|
package/lib/util/mcp-client.js
CHANGED
package/lib/util/rank.js
ADDED
|
@@ -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.
|
|
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
|
|
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",
|