dsh-web-search-plugin 0.2.1 → 0.3.1
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/CHANGELOG.md +36 -0
- package/README.md +104 -67
- package/cordis.patch.yml +1 -1
- package/lib/client.js +123 -26
- package/lib/index.js +97 -8
- package/lib/providers.js +212 -0
- package/lib/rest.js +329 -0
- package/lib/shared.js +1 -1
- package/package.json +3 -3
package/lib/rest.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic REST web-search backend driven by the static metadata table in
|
|
3
|
+
* `lib/providers.js`. One instance handles any built-in REST provider and any
|
|
4
|
+
* user-defined `customProviders[]` entry, so adding a provider is data (a table
|
|
5
|
+
* row), not a new backend class.
|
|
6
|
+
*
|
|
7
|
+
* Request shape comes from the provider row: `method` (GET/POST), `path`
|
|
8
|
+
* (POST suffix), `queryIn` (query-string vs JSON body), `queryParam` /
|
|
9
|
+
* `countParam` (field names), `params[]` (fixed/conditional extra params), and
|
|
10
|
+
* `auth` (bearer/header/none/query). Response shape is normalized by the
|
|
11
|
+
* lenient parser in this module to the seam's `WebSearchResult`.
|
|
12
|
+
*
|
|
13
|
+
* Side effects (Tavily credits, Brave rate-limit headers, proxy dispatch) are
|
|
14
|
+
* optional hooks the caller injects; the generic body never hard-codes them.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-web-search-plugin/rest
|
|
17
|
+
*/
|
|
18
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
19
|
+
import { ProxyAgent } from "undici";
|
|
20
|
+
import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
|
|
21
|
+
|
|
22
|
+
/** Acceptable auth templates (superset includes `query` for SerpApi). */
|
|
23
|
+
const AUTH_TYPES = new Set(["bearer", "header", "none", "query"]);
|
|
24
|
+
|
|
25
|
+
/** Read a non-empty string field. */
|
|
26
|
+
function str(value) {
|
|
27
|
+
return typeof value === "string" ? value : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve a `params[]` entry to `{ key, value }` or `null` (skip). */
|
|
31
|
+
/** Keep the param's native type: booleans stay booleans (JSON true/false). */
|
|
32
|
+
function paramValue(value) {
|
|
33
|
+
if (typeof value === "boolean") return value;
|
|
34
|
+
return value === void 0 ? "" : String(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveParam(param, ctx) {
|
|
38
|
+
if (param === null || typeof param !== "object") return null;
|
|
39
|
+
const when = str(param.when) || "always";
|
|
40
|
+
const key = str(param.key);
|
|
41
|
+
if (key === "") return null;
|
|
42
|
+
if (when === "always") {
|
|
43
|
+
return { key, value: paramValue(param.value) };
|
|
44
|
+
}
|
|
45
|
+
if (when === "nonEmpty") {
|
|
46
|
+
const raw = ctx[param.setting];
|
|
47
|
+
// Booleans: only `true` is "non-empty"; `false`/missing means omit the field.
|
|
48
|
+
if (typeof raw === "boolean") return raw === true ? { key, value: true } : null;
|
|
49
|
+
const value = str(raw);
|
|
50
|
+
if (value === "") return null;
|
|
51
|
+
return { key, value };
|
|
52
|
+
}
|
|
53
|
+
if (when === "keyed") {
|
|
54
|
+
return ctx.keyed === true ? { key, value: paramValue(param.value) } : null;
|
|
55
|
+
}
|
|
56
|
+
if (when === "keyless") {
|
|
57
|
+
return ctx.keyed !== true ? { key, value: paramValue(param.value) } : null;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Push one source, deduped by url, only when it has a usable url. */
|
|
63
|
+
function pushSource(sources, seen, item, title, snippet, publishedAt) {
|
|
64
|
+
const url = str(item.url ?? item.link ?? item.id);
|
|
65
|
+
if (url === "" || seen.has(url)) return;
|
|
66
|
+
seen.add(url);
|
|
67
|
+
sources.push({
|
|
68
|
+
url,
|
|
69
|
+
...str(title).length > 0 ? { title } : {},
|
|
70
|
+
...str(snippet).length > 0 ? { snippet } : {},
|
|
71
|
+
...str(publishedAt).length > 0 ? { publishedAt } : {}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Normalize a provider response into a `WebSearchResult` using a lenient
|
|
77
|
+
* (宽容) parser: the results array is located in the first non-empty of
|
|
78
|
+
* `organic_results` / `organic` → `web.results` → `results`, and per-item
|
|
79
|
+
* fields accept the common aliases (`url`/`link`, `content`/`snippet`/
|
|
80
|
+
* `description`/`text`, `published_date`/`date`/`page_age`/`publishedDate`).
|
|
81
|
+
* The `response` hint only influences which answer-style block becomes
|
|
82
|
+
* `content`.
|
|
83
|
+
* @param json - the parsed response body.
|
|
84
|
+
* @param response - the response-shape hint (`tavily`/`brave`/`exa`/`serper`).
|
|
85
|
+
*/
|
|
86
|
+
function mapCustomResponse(json, response) {
|
|
87
|
+
if (json === null || typeof json !== "object") throw new WebError("The provider returned a non-object response body", "WEB_PROVIDER_ERROR");
|
|
88
|
+
const seen = /* @__PURE__ */ new Set();
|
|
89
|
+
const sources = [];
|
|
90
|
+
const hint = str(response) || "tavily";
|
|
91
|
+
let results = null;
|
|
92
|
+
// Position of the results array, ordered by the common providers:
|
|
93
|
+
// Serper/Scavio/SerpApi → `organic_results` / `organic`;
|
|
94
|
+
// Firecrawl → `data.web` (v2) or bare `data` array (v1);
|
|
95
|
+
// Brave → `web.results`; Tavily/Exa/SearXNG → `results`.
|
|
96
|
+
if (hint === "firecrawl") {
|
|
97
|
+
results = Array.isArray(json?.data?.web) ? json.data.web : Array.isArray(json.data) ? json.data : null;
|
|
98
|
+
} else if (hint === "serper" || hint === "serpapi" || hint === "scavio") {
|
|
99
|
+
results = Array.isArray(json.organic_results) ? json.organic_results : Array.isArray(json.organic) ? json.organic : null;
|
|
100
|
+
} else {
|
|
101
|
+
results = Array.isArray(json.organic) ? json.organic : null;
|
|
102
|
+
}
|
|
103
|
+
if (results === null || results.length === 0) {
|
|
104
|
+
results = Array.isArray(json?.web?.results) ? json.web.results : null;
|
|
105
|
+
}
|
|
106
|
+
if (results === null || results.length === 0) {
|
|
107
|
+
results = Array.isArray(json.results) ? json.results : [];
|
|
108
|
+
}
|
|
109
|
+
for (const item of results) {
|
|
110
|
+
if (item === null || typeof item !== "object") continue;
|
|
111
|
+
const snippet = str(item.content).length > 0
|
|
112
|
+
? item.content
|
|
113
|
+
: str(item.snippet).length > 0
|
|
114
|
+
? item.snippet
|
|
115
|
+
: str(item.description).length > 0
|
|
116
|
+
? item.description
|
|
117
|
+
: str(item.text).length > 0
|
|
118
|
+
? item.text
|
|
119
|
+
: Array.isArray(item.highlights) && str(item.highlights[0]).length > 0
|
|
120
|
+
? item.highlights[0]
|
|
121
|
+
: Array.isArray(item.extra_snippets) && str(item.extra_snippets[0]).length > 0
|
|
122
|
+
? item.extra_snippets[0]
|
|
123
|
+
: "";
|
|
124
|
+
pushSource(sources, seen, item, item.title, snippet, item.published_date ?? item.date ?? item.page_age ?? item.publishedDate);
|
|
125
|
+
}
|
|
126
|
+
const answer = str(json.answer).length > 0
|
|
127
|
+
? json.answer
|
|
128
|
+
: str(json.answerBox?.answer).length > 0
|
|
129
|
+
? json.answerBox.answer
|
|
130
|
+
: str(json.knowledgeGraph?.description).length > 0
|
|
131
|
+
? json.knowledgeGraph.description
|
|
132
|
+
: str(json.knowledge_graph?.description).length > 0
|
|
133
|
+
? json.knowledge_graph.description
|
|
134
|
+
: "";
|
|
135
|
+
return {
|
|
136
|
+
...answer.length > 0 ? { content: answer } : {},
|
|
137
|
+
sources,
|
|
138
|
+
truncated: false
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generic REST backend. `resolveOptions` returns a projection with `baseURL`,
|
|
144
|
+
* `method`, `path`, `queryIn`, `queryParam`, `countParam`, `auth`, `response`,
|
|
145
|
+
* `params`, `keyed`, and optional `apiKey` / `resolveApiKey`.
|
|
146
|
+
*/
|
|
147
|
+
export class RestSearchProvider {
|
|
148
|
+
constructor(id, resolveOptions, hooks) {
|
|
149
|
+
this.id = id;
|
|
150
|
+
this.resolveOptions = resolveOptions;
|
|
151
|
+
this.hooks = hooks ?? {};
|
|
152
|
+
this.cachedProxy = void 0;
|
|
153
|
+
this.cachedDispatcher = void 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
available() {
|
|
157
|
+
const options = this.resolveOptions();
|
|
158
|
+
if (options?.baseURL == null || !URL.canParse(options.baseURL)) return false;
|
|
159
|
+
const auth = options.auth ?? { type: "none" };
|
|
160
|
+
if (auth.type === "none") return true;
|
|
161
|
+
return (options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async search(request, signal) {
|
|
165
|
+
const options = this.resolveOptions();
|
|
166
|
+
throwIfSearchAborted(signal);
|
|
167
|
+
if (options?.baseURL == null || !URL.canParse(options.baseURL)) {
|
|
168
|
+
throw new WebError(`Provider "${this.id}" has no usable endpoint`, "WEB_PROVIDER_ERROR");
|
|
169
|
+
}
|
|
170
|
+
const auth = AUTH_TYPES.has(options.auth?.type) ? options.auth : { type: "none" };
|
|
171
|
+
const method = options.method === "GET" ? "GET" : "POST";
|
|
172
|
+
const queryIn = options.queryIn === "query" ? "query" : "body";
|
|
173
|
+
const queryParam = str(options.queryParam) || "query";
|
|
174
|
+
const countParam = str(options.countParam) || "max_results";
|
|
175
|
+
const responseHint = str(options.response) || "tavily";
|
|
176
|
+
const count = Math.min(request.maxResults ?? options.maxResults ?? 8, MAX_RESULTS_CAP);
|
|
177
|
+
|
|
178
|
+
let apiKey;
|
|
179
|
+
if (auth.type !== "none") {
|
|
180
|
+
apiKey = await resolveApiKey(options, signal, `Provider "${this.id}" has no API key for "${options.apiKeyEnv ?? options.apiKeyRef ?? "key"}"; store it through the credentials service, export it in the launching environment, or fill it in the settings card`);
|
|
181
|
+
throwIfSearchAborted(signal);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Build query-string params (GET) or JSON body (POST).
|
|
185
|
+
const baseURL = options.baseURL.replace(/\/+$/u, "");
|
|
186
|
+
const path = str(options.path) || "";
|
|
187
|
+
let endpoint = baseURL + (path === "" ? "" : "/" + path.replace(/^\/+/u, ""));
|
|
188
|
+
const query = {
|
|
189
|
+
[queryParam]: request.query
|
|
190
|
+
};
|
|
191
|
+
if (countParam !== "") query[countParam] = queryIn === "query" ? String(count) : count;
|
|
192
|
+
const keyed = options.keyed === true;
|
|
193
|
+
for (const param of options.params ?? []) {
|
|
194
|
+
const resolved = resolveParam(param, { keyed, ...options.settings ?? {} });
|
|
195
|
+
if (resolved !== null) query[resolved.key] = resolved.value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const headers = {
|
|
199
|
+
accept: "application/json",
|
|
200
|
+
"user-agent": USER_AGENT
|
|
201
|
+
};
|
|
202
|
+
let body = void 0;
|
|
203
|
+
if (method === "GET") {
|
|
204
|
+
const params = new URLSearchParams();
|
|
205
|
+
for (const [key, value] of Object.entries(query)) params.set(key, value);
|
|
206
|
+
// SerpApi: the key rides the query string too.
|
|
207
|
+
if (auth.type === "query") params.set(str(auth.param) || "api_key", apiKey);
|
|
208
|
+
endpoint = `${endpoint}${endpoint.includes("?") ? "&" : "?"}${params.toString()}`;
|
|
209
|
+
} else {
|
|
210
|
+
headers["content-type"] = "application/json";
|
|
211
|
+
body = JSON.stringify(query);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Auth headers.
|
|
215
|
+
if (auth.type === "bearer") headers["authorization"] = `Bearer ${apiKey}`;
|
|
216
|
+
else if (auth.type === "header") headers[str(auth.header) || "x-api-key"] = apiKey;
|
|
217
|
+
else if (auth.type === "query") {
|
|
218
|
+
/* handled above for GET; POST with query auth is unsupported by design */
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Keyless-mode headers (Tavily x-tavily-access-mode).
|
|
222
|
+
const keylessHeaders = options.keylessHeaders ?? {};
|
|
223
|
+
const keylessWhen = options.keylessHeaderWhen ?? "";
|
|
224
|
+
if (keylessWhen === "keyless" && keyed === false) Object.assign(headers, keylessHeaders);
|
|
225
|
+
else if (keylessWhen === "keyed" && keyed === true) Object.assign(headers, keylessHeaders);
|
|
226
|
+
else if (keylessWhen === "" || keylessWhen === "always") Object.assign(headers, keylessHeaders);
|
|
227
|
+
|
|
228
|
+
const dispatcher = this.dispatcher(options);
|
|
229
|
+
let httpResponse;
|
|
230
|
+
try {
|
|
231
|
+
httpResponse = await fetch(endpoint, {
|
|
232
|
+
method,
|
|
233
|
+
redirect: "error",
|
|
234
|
+
headers,
|
|
235
|
+
...(body !== void 0 ? { body } : {}),
|
|
236
|
+
...signal !== void 0 ? { signal } : {},
|
|
237
|
+
...dispatcher !== void 0 ? { dispatcher } : {}
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
241
|
+
throw new WebError(`Provider "${this.id}" request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.hooks.onRateLimit?.(httpResponse.headers);
|
|
245
|
+
if (!httpResponse.ok) {
|
|
246
|
+
let message = `Provider "${this.id}" API error (HTTP ${httpResponse.status})`;
|
|
247
|
+
try {
|
|
248
|
+
const parsed = await httpResponse.json();
|
|
249
|
+
const detail = parsed?.detail?.error ?? parsed?.error ?? parsed?.message ?? parsed?.search_metadata?.error;
|
|
250
|
+
if (typeof detail === "string" && detail.length > 0) message = detail;
|
|
251
|
+
else if (typeof detail === "object" && detail != null && typeof detail.message === "string") message = detail.message;
|
|
252
|
+
} catch {
|
|
253
|
+
/* non-JSON error body — keep the status-line message */
|
|
254
|
+
}
|
|
255
|
+
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const json = await httpResponse.json();
|
|
260
|
+
if (responseHint === "tavily" && typeof this.hooks.onUsage === "function") {
|
|
261
|
+
const credits = Number(json?.usage?.credits);
|
|
262
|
+
if (Number.isFinite(credits) && credits >= 0) this.hooks.onUsage(credits);
|
|
263
|
+
}
|
|
264
|
+
return mapCustomResponse(json, responseHint);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
267
|
+
if (error instanceof WebError) throw error;
|
|
268
|
+
throw new WebError(`Provider "${this.id}" returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Cached undici ProxyAgent keyed by the current proxy URL. */
|
|
273
|
+
dispatcher(options) {
|
|
274
|
+
const proxy = options?.proxy;
|
|
275
|
+
if (proxy === void 0 || proxy.length === 0) return void 0;
|
|
276
|
+
if (this.cachedProxy === proxy) return this.cachedDispatcher;
|
|
277
|
+
if (this.cachedDispatcher !== void 0) {
|
|
278
|
+
this.cachedDispatcher.close?.().catch(() => {});
|
|
279
|
+
}
|
|
280
|
+
this.cachedProxy = proxy;
|
|
281
|
+
this.cachedDispatcher = new ProxyAgent(proxy);
|
|
282
|
+
return this.cachedDispatcher;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Project a provider table row + plugin section into backend options consumed
|
|
288
|
+
* by `RestSearchProvider`. `resolveSecret(ctx, ...)` supplies `apiKey` /
|
|
289
|
+
* `resolveApiKey` when the provider needs a key.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} ctx - plugin context (for credential/env resolution).
|
|
292
|
+
* @param {object} row - the provider metadata row (`lib/providers.js`).
|
|
293
|
+
* @param {object} opts - `{ config, settings, keyed, keySource }`.
|
|
294
|
+
* @returns backend options for one search.
|
|
295
|
+
*/
|
|
296
|
+
export function resolveRestProvider(ctx, row, opts) {
|
|
297
|
+
const { config, settings } = opts;
|
|
298
|
+
let auth = row.auth ?? { type: "none" };
|
|
299
|
+
const keyed = opts.keyed === true;
|
|
300
|
+
// A provider can be keyless by default (e.g. Tavily keyless mode); when no
|
|
301
|
+
// key is expected, drop the auth template so the generic backend does not
|
|
302
|
+
// demand a credential before the request.
|
|
303
|
+
if (row.keyRequired === false && keyed !== true) auth = { type: "none" };
|
|
304
|
+
const base = {
|
|
305
|
+
baseURL: row.baseURL,
|
|
306
|
+
method: row.method ?? "POST",
|
|
307
|
+
path: row.path ?? "",
|
|
308
|
+
queryIn: row.queryIn ?? "body",
|
|
309
|
+
queryParam: row.queryParam ?? "query",
|
|
310
|
+
countParam: row.countParam ?? "max_results",
|
|
311
|
+
auth,
|
|
312
|
+
response: row.response ?? "tavily",
|
|
313
|
+
params: row.params ?? [],
|
|
314
|
+
keyed,
|
|
315
|
+
keylessHeaders: row.keylessHeaders ?? {},
|
|
316
|
+
keylessHeaderWhen: row.keylessHeaderWhen ?? "",
|
|
317
|
+
settings,
|
|
318
|
+
maxResults: config.maxResults ?? 8
|
|
319
|
+
};
|
|
320
|
+
if (auth.type === "none") return base;
|
|
321
|
+
const keySource = opts.keySource ?? {};
|
|
322
|
+
return {
|
|
323
|
+
...base,
|
|
324
|
+
...resolveSecret(ctx, {
|
|
325
|
+
literal: keySource.literal,
|
|
326
|
+
envName: keySource.envName ?? row.apiKeyRef ?? ""
|
|
327
|
+
})
|
|
328
|
+
};
|
|
329
|
+
}
|
package/lib/shared.js
CHANGED
|
@@ -7,7 +7,7 @@ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
|
7
7
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
8
8
|
|
|
9
9
|
/** Attribution header sent on every request. */
|
|
10
|
-
export const USER_AGENT = "dsh-web-search-plugin/0.
|
|
10
|
+
export const USER_AGENT = "dsh-web-search-plugin/0.3.1";
|
|
11
11
|
/** Shared cap: Tavily `max_results` and Brave `count` both accept 1..20. */
|
|
12
12
|
export const MAX_RESULTS_CAP = 20;
|
|
13
13
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-web-search-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Unified web search for the DeepSeek Harness seam (ctx.web): DeepSeek (official), Tavily,
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Unified web search for the DeepSeek Harness seam (ctx.web): DeepSeek (official), Tavily, Brave, Serper, SerpApi, Exa, SearXNG, Scavio, and Firecrawl backends",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
7
7
|
"deepseek-harness",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/shared.js && node --check lib/tavily.js && node --check lib/brave.js && node --check lib/deepseek.js && node --check lib/usage.js"
|
|
56
|
+
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/shared.js && node --check lib/providers.js && node --check lib/rest.js && node --check lib/tavily.js && node --check lib/brave.js && node --check lib/deepseek.js && node --check lib/usage.js"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"undici": "^6.21.0"
|