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/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.2.1";
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.2.1",
4
- "description": "Unified web search for the DeepSeek Harness seam (ctx.web): DeepSeek (official), Tavily, and Brave backends with an in-browser selector",
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"