@dsh-plus/web-search-services 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agguy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/lib/index.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { WebSearchProvider, WebSearchRequest, WebSearchResult } from "@deepseek-ai/dsh-web";
3
+ import { spawn } from "node:child_process";
4
+ import { Context } from "@deepseek-ai/cordis";
5
+ //#region src/config.d.ts
6
+ declare const SETTINGS_NS = "dsh-plus-web-search-services";
7
+ declare const Config: z<Schemastery.ObjectS<{
8
+ scriptPath: z<string, string>;
9
+ envFile: z<string, string>;
10
+ python: z<string, string>;
11
+ priority: z<("exa" | "openai-chat" | "tavily")[], ("exa" | "openai-chat" | "tavily")[]>;
12
+ timeoutMs: z<number, number>;
13
+ keys: z<Schemastery.ObjectS<{
14
+ tavily: z<string, string>;
15
+ exa: z<string, string>;
16
+ openaiApiKey: z<string, string>;
17
+ openaiBaseUrl: z<string, string>;
18
+ openaiModel: z<string, string>;
19
+ }>, Schemastery.ObjectT<{
20
+ tavily: z<string, string>;
21
+ exa: z<string, string>;
22
+ openaiApiKey: z<string, string>;
23
+ openaiBaseUrl: z<string, string>;
24
+ openaiModel: z<string, string>;
25
+ }>>;
26
+ }>, Schemastery.ObjectT<{
27
+ scriptPath: z<string, string>;
28
+ envFile: z<string, string>;
29
+ python: z<string, string>;
30
+ priority: z<("exa" | "openai-chat" | "tavily")[], ("exa" | "openai-chat" | "tavily")[]>;
31
+ timeoutMs: z<number, number>;
32
+ keys: z<Schemastery.ObjectS<{
33
+ tavily: z<string, string>;
34
+ exa: z<string, string>;
35
+ openaiApiKey: z<string, string>;
36
+ openaiBaseUrl: z<string, string>;
37
+ openaiModel: z<string, string>;
38
+ }>, Schemastery.ObjectT<{
39
+ tavily: z<string, string>;
40
+ exa: z<string, string>;
41
+ openaiApiKey: z<string, string>;
42
+ openaiBaseUrl: z<string, string>;
43
+ openaiModel: z<string, string>;
44
+ }>>;
45
+ }>>;
46
+ type WebSearchServicesConfig = Schemastery.TypeT<typeof Config>;
47
+ //#endregion
48
+ //#region src/runner.d.ts
49
+ type SpawnFn = typeof spawn;
50
+ //#endregion
51
+ //#region src/provider.d.ts
52
+ declare const PROVIDER_ID = "search-services";
53
+ declare class SearchServicesProvider implements WebSearchProvider {
54
+ readonly id = "search-services";
55
+ private readonly current;
56
+ private readonly spawnFn;
57
+ constructor(current: () => WebSearchServicesConfig, spawnFn?: SpawnFn | undefined);
58
+ /** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
59
+ available(): boolean;
60
+ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
61
+ private childEnv;
62
+ private run;
63
+ private normalize;
64
+ }
65
+ //#endregion
66
+ //#region src/index.d.ts
67
+ declare const name = "dsh-plus-web-search-services";
68
+ /** 行级 inject:dsh-web 服务激活后才应用本行(provider 注册即需 ctx.web)。 */
69
+ declare const inject: readonly ['web'];
70
+ declare function apply(ctx: Context, config: WebSearchServicesConfig): void;
71
+ //#endregion
72
+ export { Config, PROVIDER_ID, SETTINGS_NS, SearchServicesProvider, apply, inject, name };
package/lib/index.js ADDED
@@ -0,0 +1,419 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { WebError } from "@deepseek-ai/dsh-web";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ //#endregion
8
+ //#region src/config.ts
9
+ /**
10
+ * 配置单一事实源:cordis 行级 Config(组合默认值,patch 层可覆盖)与 settings
11
+ * namespace(用户层 $DSH_HOME/settings.yaml,热生效)共用同一 schemastery schema。
12
+ *
13
+ * 密钥主来源是 search-services skill 的 env 文件(envFile,默认
14
+ * ~/.config/search-services/env),保持 skill 单事实源、零搬迁。行级 keys 仅为
15
+ * 覆盖逃生舱;按存储规范 role('secret') 字段只允许行级注入,settings 用户层勿填。
16
+ * @module web-search-services/config
17
+ */
18
+ const SETTINGS_NS = "dsh-plus-web-search-services";
19
+ const DEFAULT_SCRIPT_PATH = "~/.dsh/skills/search-services/scripts/search.py";
20
+ const DEFAULT_ENV_FILE = "~/.config/search-services/env";
21
+ const DEFAULT_TIMEOUT_MS = 55e3;
22
+ const DEFAULT_PRIORITY = [
23
+ "tavily",
24
+ "exa",
25
+ "openai-chat"
26
+ ];
27
+ const PRIORITY_ENTRY = z.union([
28
+ "tavily",
29
+ "exa",
30
+ "openai-chat"
31
+ ]);
32
+ const KeysSchema = z.object({
33
+ tavily: z.string().role("secret").description("Tavily API key(覆盖 envFile 的 TAVILY_API_KEY;行级注入专用)").default(""),
34
+ exa: z.string().role("secret").description("Exa API key(覆盖 envFile 的 EXA_API_KEY;行级注入专用)").default(""),
35
+ openaiApiKey: z.string().role("secret").description("OpenAI-compatible API key(覆盖 SEARCH_OPENAI_API_KEY;行级注入专用)").default(""),
36
+ openaiBaseUrl: z.string().description("OpenAI-compatible base URL(覆盖 SEARCH_OPENAI_BASE_URL)").default(""),
37
+ openaiModel: z.string().description("OpenAI-compatible 搜索模型名(覆盖 SEARCH_OPENAI_MODEL)").default("")
38
+ });
39
+ const Config = z.object({
40
+ scriptPath: z.string().description("search-services skill 的 search.py 路径(支持 ~ 展开)").default(DEFAULT_SCRIPT_PATH),
41
+ envFile: z.string().description("skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile").default(DEFAULT_ENV_FILE),
42
+ python: z.string().description("python 可执行文件").default("python3"),
43
+ priority: z.array(PRIORITY_ENTRY).description("后端优先级:靠前的先尝试,失败自动回退到下一个").default([...DEFAULT_PRIORITY]),
44
+ timeoutMs: z.natural().min(1e3).description("单次搜索子进程超时(毫秒),应低于 dsh-tool-web 的 searchTimeoutMs").default(DEFAULT_TIMEOUT_MS),
45
+ keys: KeysSchema.default({
46
+ tavily: "",
47
+ exa: "",
48
+ openaiApiKey: "",
49
+ openaiBaseUrl: "",
50
+ openaiModel: ""
51
+ })
52
+ });
53
+ /** 由 web 请求 + 插件配置生成 search.py 的 argv(不含解释器与脚本路径)。 */
54
+ function buildSearchArgv(cfg, request) {
55
+ const query = request.query.trim();
56
+ if (query === "") throw new Error("query must be a non-empty string");
57
+ const maxResults = request.maxResults ?? 8;
58
+ return [
59
+ "search",
60
+ "--query",
61
+ query,
62
+ "--service",
63
+ cfg.priority.join(","),
64
+ "--max-results",
65
+ String(maxResults)
66
+ ];
67
+ }
68
+ //#endregion
69
+ //#region src/env-file.ts
70
+ /**
71
+ * search-services skill env 文件解析(纯函数)。
72
+ * 只解析不执行:按行提取 KEY=VALUE,不展开 $ 变量、不运行 shell,畸形行跳过;
73
+ * 文件缺失/不可读返回空表(envFile 是优化来源,不是硬依赖)。
74
+ * @module web-search-services/env-file
75
+ */
76
+ /** 展开路径开头的 ~(仅 ~ 与 ~/ 两种形态,不借 shell)。 */
77
+ function expandHome(path) {
78
+ if (path === "~") return homedir();
79
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
80
+ return path;
81
+ }
82
+ function unquote(value) {
83
+ const first = value.charAt(0);
84
+ const last = value.charAt(value.length - 1);
85
+ if (value.length >= 2 && first === last && (first === "\"" || first === "'")) return value.slice(1, -1);
86
+ return value;
87
+ }
88
+ function parseEnvText(text) {
89
+ const out = {};
90
+ for (const rawLine of text.split(/\r?\n/)) {
91
+ const line = rawLine.trim();
92
+ if (line === "" || line.startsWith("#")) continue;
93
+ const body = line.startsWith("export ") ? line.slice(7).trim() : line;
94
+ const eq = body.indexOf("=");
95
+ if (eq <= 0) continue;
96
+ const key = body.slice(0, eq).trim();
97
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
98
+ out[key] = unquote(body.slice(eq + 1).trim());
99
+ }
100
+ return out;
101
+ }
102
+ function loadEnvFile(path) {
103
+ if (path.trim() === "") return {};
104
+ try {
105
+ return parseEnvText(readFileSync(expandHome(path), "utf8"));
106
+ } catch {
107
+ return {};
108
+ }
109
+ }
110
+ //#endregion
111
+ //#region src/normalize.ts
112
+ function asObject(value) {
113
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
114
+ }
115
+ function asNonEmptyString(value) {
116
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
117
+ }
118
+ function asArray(value) {
119
+ return Array.isArray(value) ? value : [];
120
+ }
121
+ function makeSource(url, title, snippet, publishedAt) {
122
+ return {
123
+ url,
124
+ ...title !== void 0 ? { title } : {},
125
+ ...snippet !== void 0 ? { snippet } : {},
126
+ ...publishedAt !== void 0 ? { publishedAt } : {}
127
+ };
128
+ }
129
+ function finish(content, sources) {
130
+ const seen = /* @__PURE__ */ new Set();
131
+ const deduped = sources.filter((source) => {
132
+ if (seen.has(source.url)) return false;
133
+ seen.add(source.url);
134
+ return true;
135
+ });
136
+ return {
137
+ ...content !== void 0 ? { content } : {},
138
+ sources: deduped,
139
+ truncated: false
140
+ };
141
+ }
142
+ function sourcesFromResults(results, pick) {
143
+ const out = [];
144
+ for (const entry of asArray(results)) {
145
+ const source = pick(asObject(entry));
146
+ if (source !== void 0) out.push(source);
147
+ }
148
+ return out;
149
+ }
150
+ function normalizeTavily(raw) {
151
+ const sources = sourcesFromResults(raw.results, (item) => {
152
+ const url = asNonEmptyString(item.url);
153
+ if (url === void 0) return void 0;
154
+ return makeSource(url, asNonEmptyString(item.title), asNonEmptyString(item.content), asNonEmptyString(item.published_date));
155
+ });
156
+ return finish(asNonEmptyString(raw.answer), sources);
157
+ }
158
+ function normalizeExa(raw) {
159
+ return finish(void 0, sourcesFromResults(raw.results, (item) => {
160
+ const url = asNonEmptyString(item.url) ?? asNonEmptyString(item.id);
161
+ if (url === void 0) return void 0;
162
+ const highlights = asArray(item.highlights).map((h) => asNonEmptyString(h)).filter((h) => h !== void 0);
163
+ return makeSource(url, asNonEmptyString(item.title), highlights.length > 0 ? highlights.join(" ") : void 0, asNonEmptyString(item.publishedDate));
164
+ }));
165
+ }
166
+ const MARKDOWN_LINK = /\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g;
167
+ /** 从生成式回答的 markdown 链接提取可引用来源(DeepSeek 官方 provider 同样只给
168
+ * URL/标题)。无链接时返回空表——content-only 结果在 WebSearchResult 合法。 */
169
+ function extractMarkdownLinks(markdown, limit) {
170
+ const seen = /* @__PURE__ */ new Set();
171
+ const out = [];
172
+ for (const match of markdown.matchAll(MARKDOWN_LINK)) {
173
+ const url = match[2];
174
+ if (url === void 0 || seen.has(url)) continue;
175
+ seen.add(url);
176
+ const label = match[1] ?? "";
177
+ out.push(makeSource(url, label.trim() !== "" ? label : void 0));
178
+ if (out.length >= limit) break;
179
+ }
180
+ return out;
181
+ }
182
+ function normalizeOpenAiChat(raw) {
183
+ const answer = asNonEmptyString(asObject(raw.data).answer);
184
+ if (answer === void 0) throw new Error("openai-chat 结果缺少 data.answer");
185
+ return finish(answer, extractMarkdownLinks(answer, 16));
186
+ }
187
+ function formatAttempts(attempts) {
188
+ const summary = asArray(attempts).map((entry) => {
189
+ const item = asObject(entry);
190
+ return `${asNonEmptyString(item.service) ?? "?"}: ${asNonEmptyString(item.error) ?? "unknown"}`;
191
+ }).join("; ");
192
+ return summary === "" ? "" : `(尝试过: ${summary})`;
193
+ }
194
+ /** 归一化 search.py 的完整 stdout JSON;失败以 Error 抛出(provider 层包 WebError)。 */
195
+ function normalizeSearchOutput(raw) {
196
+ const obj = asObject(raw);
197
+ if (obj.ok === false) {
198
+ const detail = asNonEmptyString(obj.error) ?? "search.py 返回 ok=false";
199
+ throw new Error(`${detail}${formatAttempts(obj.attempts)}`);
200
+ }
201
+ const service = asNonEmptyString(obj.selectedService) ?? asNonEmptyString(obj.service);
202
+ switch (service) {
203
+ case "tavily": return normalizeTavily(obj);
204
+ case "exa": return normalizeExa(obj);
205
+ case "openai-chat": return normalizeOpenAiChat(obj);
206
+ default: throw new Error(`search.py 返回未知或不支持的 selectedService: ${JSON.stringify(service)}`);
207
+ }
208
+ }
209
+ //#endregion
210
+ //#region src/runner.ts
211
+ /**
212
+ * 子进程执行器:spawn 脚本、收集 stdout、超时/abort 杀进程。
213
+ * spawn 依赖注入以便单测 mock(禁网络铁律下测试绝不真实启动搜索脚本)。
214
+ * @module web-search-services/runner
215
+ */
216
+ const DEFAULT_MAX_BUFFER = 8388608;
217
+ const KILL_GRACE_MS = 2e3;
218
+ function abortError() {
219
+ const err = /* @__PURE__ */ new Error("search aborted");
220
+ err.name = "AbortError";
221
+ return err;
222
+ }
223
+ function isAbortError(err) {
224
+ return err instanceof Error && err.name === "AbortError";
225
+ }
226
+ function tail(text, max = 500) {
227
+ const trimmed = text.trim();
228
+ return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`;
229
+ }
230
+ /** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
231
+ function runProcess(opts) {
232
+ const spawnFn = opts.spawnFn ?? spawn;
233
+ return new Promise((resolve, reject) => {
234
+ if (opts.signal?.aborted === true) {
235
+ reject(abortError());
236
+ return;
237
+ }
238
+ const child = spawnFn(opts.command, [...opts.args], {
239
+ env: opts.env,
240
+ stdio: [
241
+ "ignore",
242
+ "pipe",
243
+ "pipe"
244
+ ],
245
+ ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {}
246
+ });
247
+ const maxBuffer = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER;
248
+ let stdout = "";
249
+ let stderr = "";
250
+ let settled = false;
251
+ let graceTimer;
252
+ const cleanup = () => {
253
+ clearTimeout(timer);
254
+ if (graceTimer !== void 0) clearTimeout(graceTimer);
255
+ opts.signal?.removeEventListener("abort", onAbort);
256
+ };
257
+ const settle = (fn) => {
258
+ if (settled) return;
259
+ settled = true;
260
+ cleanup();
261
+ fn();
262
+ };
263
+ const kill = () => {
264
+ child.kill("SIGTERM");
265
+ graceTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
266
+ graceTimer.unref();
267
+ };
268
+ const onAbort = () => {
269
+ kill();
270
+ settle(() => reject(abortError()));
271
+ };
272
+ const timer = setTimeout(() => {
273
+ kill();
274
+ settle(() => reject(/* @__PURE__ */ new Error(`process timed out after ${opts.timeoutMs}ms`)));
275
+ }, opts.timeoutMs);
276
+ timer.unref();
277
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
278
+ child.stdout?.on("data", (chunk) => {
279
+ stdout += chunk.toString();
280
+ if (stdout.length > maxBuffer) {
281
+ kill();
282
+ settle(() => reject(/* @__PURE__ */ new Error(`stdout exceeded ${maxBuffer} bytes`)));
283
+ }
284
+ });
285
+ child.stderr?.on("data", (chunk) => {
286
+ stderr += chunk.toString();
287
+ });
288
+ child.on("error", (err) => {
289
+ settle(() => reject(new Error(`failed to start ${opts.command}: ${err.message}`, { cause: err })));
290
+ });
291
+ child.on("close", (code) => {
292
+ if (settled) return;
293
+ if (code === 0) {
294
+ settle(() => resolve(stdout));
295
+ return;
296
+ }
297
+ const detail = tail(stderr) !== "" ? tail(stderr) : tail(stdout);
298
+ settle(() => reject(/* @__PURE__ */ new Error(`exited with code ${code ?? "null"}: ${detail}`)));
299
+ });
300
+ });
301
+ }
302
+ //#endregion
303
+ //#region src/provider.ts
304
+ /**
305
+ * dsh-web 搜索 provider:把 ctx.web.search 桥接到 search-services skill 的
306
+ * search.py(Tavily / Exa / OpenAI Chat 免费额度后端)。官方 web_search 工具、
307
+ * 渲染与提示词零修改——本 provider 只产出标准 WebSearchResult,格式化天然一致。
308
+ * @module web-search-services/provider
309
+ */
310
+ const PROVIDER_ID = "search-services";
311
+ /** 各后端在合并环境(进程 env ⊕ envFile ⊕ 行级 keys)中的可用性判定变量。 */
312
+ const BACKEND_KEY_VARS = {
313
+ tavily: ["TAVILY_API_KEY", "TAVILY_API_KEYS"],
314
+ exa: ["EXA_API_KEY"],
315
+ "openai-chat": ["SEARCH_OPENAI_API_KEY"]
316
+ };
317
+ /** 行级 keys 覆盖 → 子进程环境变量;空串不注入(保持 envFile 单事实源优先)。 */
318
+ function keyOverridesEnv(keys) {
319
+ const out = {};
320
+ if (keys.tavily !== "") out.TAVILY_API_KEY = keys.tavily;
321
+ if (keys.exa !== "") out.EXA_API_KEY = keys.exa;
322
+ if (keys.openaiApiKey !== "") out.SEARCH_OPENAI_API_KEY = keys.openaiApiKey;
323
+ if (keys.openaiBaseUrl !== "") out.SEARCH_OPENAI_BASE_URL = keys.openaiBaseUrl;
324
+ if (keys.openaiModel !== "") out.SEARCH_OPENAI_MODEL = keys.openaiModel;
325
+ return out;
326
+ }
327
+ function messageOf(err) {
328
+ return err instanceof Error ? err.message : String(err);
329
+ }
330
+ function toWebError(err) {
331
+ if (err instanceof WebError) return err;
332
+ if (isAbortError(err)) return new WebError("搜索已取消", "WEB_ABORTED", { cause: err });
333
+ return new WebError(`search-services 搜索失败: ${messageOf(err)}`, "WEB_PROVIDER_ERROR", { cause: err });
334
+ }
335
+ var SearchServicesProvider = class {
336
+ id = PROVIDER_ID;
337
+ current;
338
+ spawnFn;
339
+ constructor(current, spawnFn) {
340
+ this.current = current;
341
+ this.spawnFn = spawnFn;
342
+ }
343
+ /** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
344
+ available() {
345
+ const cfg = this.current();
346
+ if (!existsSync(expandHome(cfg.scriptPath))) return false;
347
+ const env = this.childEnv(cfg);
348
+ return cfg.priority.some((backend) => BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? "") !== ""));
349
+ }
350
+ async search(request, signal) {
351
+ const cfg = this.current();
352
+ const argv = buildSearchArgv(cfg, request);
353
+ const stdout = await this.run(cfg, argv, signal);
354
+ return this.normalize(stdout);
355
+ }
356
+ childEnv(cfg) {
357
+ const fromFile = cfg.envFile.trim() === "" ? {} : loadEnvFile(cfg.envFile);
358
+ return {
359
+ ...process.env,
360
+ ...fromFile,
361
+ ...keyOverridesEnv(cfg.keys)
362
+ };
363
+ }
364
+ async run(cfg, argv, signal) {
365
+ try {
366
+ return await runProcess({
367
+ command: cfg.python,
368
+ args: [expandHome(cfg.scriptPath), ...argv],
369
+ env: this.childEnv(cfg),
370
+ timeoutMs: cfg.timeoutMs,
371
+ ...signal !== void 0 ? { signal } : {},
372
+ ...this.spawnFn !== void 0 ? { spawnFn: this.spawnFn } : {}
373
+ });
374
+ } catch (err) {
375
+ throw toWebError(err);
376
+ }
377
+ }
378
+ normalize(stdout) {
379
+ let raw;
380
+ try {
381
+ raw = JSON.parse(stdout);
382
+ } catch (err) {
383
+ const preview = stdout.trim().slice(-200);
384
+ throw new WebError(`search.py 输出非 JSON: …${preview}`, "WEB_PROVIDER_ERROR", { cause: err });
385
+ }
386
+ try {
387
+ return normalizeSearchOutput(raw);
388
+ } catch (err) {
389
+ throw new WebError(`search.py 结果归一化失败: ${messageOf(err)}`, "WEB_PROVIDER_ERROR", { cause: err });
390
+ }
391
+ }
392
+ };
393
+ //#endregion
394
+ //#region src/index.ts
395
+ const name = "dsh-plus-web-search-services";
396
+ /** 行级 inject:dsh-web 服务激活后才应用本行(provider 注册即需 ctx.web)。 */
397
+ const inject = ["web"];
398
+ function apply(ctx, config) {
399
+ const provider = new SearchServicesProvider(configSource(ctx, config));
400
+ ctx.effect(() => ctx.web.registerSearchProvider(provider), "dsh-plus-web-search-services: register search provider");
401
+ }
402
+ /**
403
+ * 配置源:settings 用户层($DSH_HOME/settings.yaml,热生效)在时优先生效,
404
+ * 缺席/detach 回落 cordis 行级 config(官方 installSection 可选消费范式)。
405
+ */
406
+ function configSource(ctx, rowConfig) {
407
+ let current = () => rowConfig;
408
+ ctx.inject(["settings"], (settingsCtx) => {
409
+ settingsCtx.settings.installSection(ctx, SETTINGS_NS, Config, rowConfig, {
410
+ setSource: (source) => {
411
+ current = source;
412
+ },
413
+ onChange: () => {}
414
+ });
415
+ });
416
+ return () => current();
417
+ }
418
+ //#endregion
419
+ export { Config, PROVIDER_ID, SETTINGS_NS, SearchServicesProvider, apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@dsh-plus/web-search-services",
3
+ "version": "0.1.0",
4
+ "description": "dsh-plus service plugin: web_search 免费后端聚合——经官方 ctx.web provider 扩展点桥接 search-services skill(Tavily/Exa/OpenAI Chat 免费额度,优先级+自动回退+key 配置),官方 web_search 工具与输出格式零修改",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./src/*": "./src/*",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "src"
19
+ ],
20
+ "peerDependencies": {
21
+ "@deepseek-ai/cordis": "^4.0.2",
22
+ "@deepseek-ai/dsh-settings": "^0.1.6-alpha.2",
23
+ "@deepseek-ai/dsh-web": "^0.1.6-alpha.2",
24
+ "@deepseek-ai/schemastery": "^3.18.2"
25
+ },
26
+ "devDependencies": {
27
+ "@deepseek-ai/cordis": "4.0.2",
28
+ "@deepseek-ai/dsh-settings": "0.1.6-alpha.2",
29
+ "@deepseek-ai/dsh-web": "0.1.6-alpha.2",
30
+ "@deepseek-ai/schemastery": "3.18.2"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "tsdown src/index.ts -d lib --format esm --dts --no-fixed-extension"
37
+ }
38
+ }
package/src/args.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * search.py 子命令参数组装(纯函数)。
3
+ * 优先级直接透传 --service:search.py 自带链内 fallback 与 Tavily 多 key 轮转,
4
+ * 插件不重复实现回退编排。
5
+ * @module web-search-services/args
6
+ */
7
+ import type { WebSearchRequest } from '@deepseek-ai/dsh-web'
8
+
9
+ import type { WebSearchServicesConfig } from './config.ts'
10
+
11
+ /** dsh-tool-web 之外的调用方未给 maxResults 时的请求层缺省。 */
12
+ export const FALLBACK_MAX_RESULTS = 8
13
+
14
+ /** 由 web 请求 + 插件配置生成 search.py 的 argv(不含解释器与脚本路径)。 */
15
+ export function buildSearchArgv(cfg: WebSearchServicesConfig, request: WebSearchRequest): string[] {
16
+ const query = request.query.trim()
17
+ if (query === '') {
18
+ throw new Error('query must be a non-empty string')
19
+ }
20
+ const maxResults = request.maxResults ?? FALLBACK_MAX_RESULTS
21
+ return [
22
+ 'search',
23
+ '--query',
24
+ query,
25
+ '--service',
26
+ cfg.priority.join(','),
27
+ '--max-results',
28
+ String(maxResults),
29
+ ]
30
+ }
package/src/config.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 配置单一事实源:cordis 行级 Config(组合默认值,patch 层可覆盖)与 settings
3
+ * namespace(用户层 $DSH_HOME/settings.yaml,热生效)共用同一 schemastery schema。
4
+ *
5
+ * 密钥主来源是 search-services skill 的 env 文件(envFile,默认
6
+ * ~/.config/search-services/env),保持 skill 单事实源、零搬迁。行级 keys 仅为
7
+ * 覆盖逃生舱;按存储规范 role('secret') 字段只允许行级注入,settings 用户层勿填。
8
+ * @module web-search-services/config
9
+ */
10
+ import z from '@deepseek-ai/schemastery'
11
+
12
+ import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
13
+
14
+ export const SETTINGS_NS = NS_LITERAL
15
+
16
+ export const DEFAULT_SCRIPT_PATH = '~/.dsh/skills/search-services/scripts/search.py'
17
+ export const DEFAULT_ENV_FILE = '~/.config/search-services/env'
18
+ export const DEFAULT_TIMEOUT_MS = 55_000
19
+ export const DEFAULT_PRIORITY: readonly SearchBackend[] = ['tavily', 'exa', 'openai-chat']
20
+
21
+ export type SearchBackend = 'tavily' | 'exa' | 'openai-chat'
22
+
23
+ const PRIORITY_ENTRY = z.union(['tavily', 'exa', 'openai-chat'])
24
+
25
+ const KeysSchema = z.object({
26
+ tavily: z
27
+ .string()
28
+ .role('secret')
29
+ .description('Tavily API key(覆盖 envFile 的 TAVILY_API_KEY;行级注入专用)')
30
+ .default(''),
31
+ exa: z
32
+ .string()
33
+ .role('secret')
34
+ .description('Exa API key(覆盖 envFile 的 EXA_API_KEY;行级注入专用)')
35
+ .default(''),
36
+ openaiApiKey: z
37
+ .string()
38
+ .role('secret')
39
+ .description('OpenAI-compatible API key(覆盖 SEARCH_OPENAI_API_KEY;行级注入专用)')
40
+ .default(''),
41
+ openaiBaseUrl: z
42
+ .string()
43
+ .description('OpenAI-compatible base URL(覆盖 SEARCH_OPENAI_BASE_URL)')
44
+ .default(''),
45
+ openaiModel: z
46
+ .string()
47
+ .description('OpenAI-compatible 搜索模型名(覆盖 SEARCH_OPENAI_MODEL)')
48
+ .default(''),
49
+ })
50
+
51
+ export const Config = z.object({
52
+ scriptPath: z
53
+ .string()
54
+ .description('search-services skill 的 search.py 路径(支持 ~ 展开)')
55
+ .default(DEFAULT_SCRIPT_PATH),
56
+ envFile: z
57
+ .string()
58
+ .description('skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile')
59
+ .default(DEFAULT_ENV_FILE),
60
+ python: z.string().description('python 可执行文件').default('python3'),
61
+ priority: z
62
+ .array(PRIORITY_ENTRY)
63
+ .description('后端优先级:靠前的先尝试,失败自动回退到下一个')
64
+ .default([...DEFAULT_PRIORITY]),
65
+ timeoutMs: z
66
+ .natural()
67
+ .min(1000)
68
+ .description('单次搜索子进程超时(毫秒),应低于 dsh-tool-web 的 searchTimeoutMs')
69
+ .default(DEFAULT_TIMEOUT_MS),
70
+ keys: KeysSchema.default({
71
+ tavily: '',
72
+ exa: '',
73
+ openaiApiKey: '',
74
+ openaiBaseUrl: '',
75
+ openaiModel: '',
76
+ }),
77
+ })
78
+
79
+ export type WebSearchServicesConfig = Schemastery.TypeT<typeof Config>
80
+ export type KeysConfig = WebSearchServicesConfig['keys']
@@ -0,0 +1,49 @@
1
+ /**
2
+ * search-services skill env 文件解析(纯函数)。
3
+ * 只解析不执行:按行提取 KEY=VALUE,不展开 $ 变量、不运行 shell,畸形行跳过;
4
+ * 文件缺失/不可读返回空表(envFile 是优化来源,不是硬依赖)。
5
+ * @module web-search-services/env-file
6
+ */
7
+ import { readFileSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ /** 展开路径开头的 ~(仅 ~ 与 ~/ 两种形态,不借 shell)。 */
12
+ export function expandHome(path: string): string {
13
+ if (path === '~') return homedir()
14
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2))
15
+ return path
16
+ }
17
+
18
+ function unquote(value: string): string {
19
+ const first = value.charAt(0)
20
+ const last = value.charAt(value.length - 1)
21
+ if (value.length >= 2 && first === last && (first === '"' || first === "'")) {
22
+ return value.slice(1, -1)
23
+ }
24
+ return value
25
+ }
26
+
27
+ export function parseEnvText(text: string): Record<string, string> {
28
+ const out: Record<string, string> = {}
29
+ for (const rawLine of text.split(/\r?\n/)) {
30
+ const line = rawLine.trim()
31
+ if (line === '' || line.startsWith('#')) continue
32
+ const body = line.startsWith('export ') ? line.slice('export '.length).trim() : line
33
+ const eq = body.indexOf('=')
34
+ if (eq <= 0) continue
35
+ const key = body.slice(0, eq).trim()
36
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
37
+ out[key] = unquote(body.slice(eq + 1).trim())
38
+ }
39
+ return out
40
+ }
41
+
42
+ export function loadEnvFile(path: string): Record<string, string> {
43
+ if (path.trim() === '') return {}
44
+ try {
45
+ return parseEnvText(readFileSync(expandHome(path), 'utf8'))
46
+ } catch {
47
+ return {}
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * dsh-plus 插件:web_search 免费后端聚合(search-services skill 桥接)。
3
+ *
4
+ * 经官方 ctx.web provider 扩展点注册 id=search-services 的搜索后端:web_search /
5
+ * web_fetch 工具、渲染层与提示词零修改,模型可见输出格式与官方 provider 逐字节
6
+ * 一致。优先级与自动回退由 skill 的 search.py 编排(含 Tavily 多 key 轮转);
7
+ * Context7 仅支持带库名的 docs 查询,web_search 契约无 library 概念,不进搜索链。
8
+ * @module @dsh-plus/web-search-services
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import type {} from '@deepseek-ai/dsh-settings'
12
+ import type {} from '@deepseek-ai/dsh-web'
13
+
14
+ import { Config, SETTINGS_NS, type WebSearchServicesConfig } from './config.ts'
15
+ import { SearchServicesProvider } from './provider.ts'
16
+
17
+ export const name = 'dsh-plus-web-search-services'
18
+
19
+ /** 行级 inject:dsh-web 服务激活后才应用本行(provider 注册即需 ctx.web)。 */
20
+ export const inject = ['web'] as const
21
+
22
+ export { PROVIDER_ID, SearchServicesProvider } from './provider.ts'
23
+ export { Config, SETTINGS_NS }
24
+
25
+ export function apply(ctx: Context, config: WebSearchServicesConfig): void {
26
+ const provider = new SearchServicesProvider(configSource(ctx, config))
27
+ ctx.effect(
28
+ () => ctx.web.registerSearchProvider(provider),
29
+ 'dsh-plus-web-search-services: register search provider',
30
+ )
31
+ }
32
+
33
+ /**
34
+ * 配置源:settings 用户层($DSH_HOME/settings.yaml,热生效)在时优先生效,
35
+ * 缺席/detach 回落 cordis 行级 config(官方 installSection 可选消费范式)。
36
+ */
37
+ function configSource(
38
+ ctx: Context,
39
+ rowConfig: WebSearchServicesConfig,
40
+ ): () => WebSearchServicesConfig {
41
+ let current: () => WebSearchServicesConfig = () => rowConfig
42
+ ctx.inject(['settings'], (settingsCtx) => {
43
+ settingsCtx.settings.installSection(ctx, SETTINGS_NS, Config, rowConfig, {
44
+ setSource: (source) => {
45
+ current = source
46
+ },
47
+ onChange: () => {},
48
+ })
49
+ })
50
+ return () => current()
51
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * search.py 输出 → dsh-web 标准 WebSearchResult 归一化(纯函数)。
3
+ *
4
+ * skill 各后端 payload 形状不同:tavily/exa 是原生 API 形状(results[]),
5
+ * openai-chat 是生成式回答(data.answer)。按 selectedService 归一;
6
+ * 字段缺失降级而非报错(对 skill 脚本版本漂移保持容差)。
7
+ * @module web-search-services/normalize
8
+ */
9
+ import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web'
10
+
11
+ type JsonObject = Record<string, unknown>
12
+
13
+ function asObject(value: unknown): JsonObject {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
15
+ ? (value as JsonObject)
16
+ : {}
17
+ }
18
+
19
+ function asNonEmptyString(value: unknown): string | undefined {
20
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined
21
+ }
22
+
23
+ function asArray(value: unknown): unknown[] {
24
+ return Array.isArray(value) ? value : []
25
+ }
26
+
27
+ function makeSource(
28
+ url: string,
29
+ title?: string,
30
+ snippet?: string,
31
+ publishedAt?: string,
32
+ ): WebSearchSource {
33
+ return {
34
+ url,
35
+ ...(title !== undefined ? { title } : {}),
36
+ ...(snippet !== undefined ? { snippet } : {}),
37
+ ...(publishedAt !== undefined ? { publishedAt } : {}),
38
+ }
39
+ }
40
+
41
+ function finish(content: string | undefined, sources: WebSearchSource[]): WebSearchResult {
42
+ const seen = new Set<string>()
43
+ const deduped = sources.filter((source) => {
44
+ if (seen.has(source.url)) return false
45
+ seen.add(source.url)
46
+ return true
47
+ })
48
+ return {
49
+ ...(content !== undefined ? { content } : {}),
50
+ sources: deduped,
51
+ truncated: false,
52
+ }
53
+ }
54
+
55
+ function sourcesFromResults(
56
+ results: unknown,
57
+ pick: (item: JsonObject) => WebSearchSource | undefined,
58
+ ): WebSearchSource[] {
59
+ const out: WebSearchSource[] = []
60
+ for (const entry of asArray(results)) {
61
+ const source = pick(asObject(entry))
62
+ if (source !== undefined) out.push(source)
63
+ }
64
+ return out
65
+ }
66
+
67
+ function normalizeTavily(raw: JsonObject): WebSearchResult {
68
+ const sources = sourcesFromResults(raw.results, (item) => {
69
+ const url = asNonEmptyString(item.url)
70
+ if (url === undefined) return undefined
71
+ return makeSource(
72
+ url,
73
+ asNonEmptyString(item.title),
74
+ asNonEmptyString(item.content),
75
+ asNonEmptyString(item.published_date),
76
+ )
77
+ })
78
+ return finish(asNonEmptyString(raw.answer), sources)
79
+ }
80
+
81
+ function normalizeExa(raw: JsonObject): WebSearchResult {
82
+ const sources = sourcesFromResults(raw.results, (item) => {
83
+ const url = asNonEmptyString(item.url) ?? asNonEmptyString(item.id)
84
+ if (url === undefined) return undefined
85
+ const highlights = asArray(item.highlights)
86
+ .map((h) => asNonEmptyString(h))
87
+ .filter((h): h is string => h !== undefined)
88
+ return makeSource(
89
+ url,
90
+ asNonEmptyString(item.title),
91
+ highlights.length > 0 ? highlights.join(' ') : undefined,
92
+ asNonEmptyString(item.publishedDate),
93
+ )
94
+ })
95
+ return finish(undefined, sources)
96
+ }
97
+
98
+ const MARKDOWN_LINK = /\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g
99
+
100
+ /** 从生成式回答的 markdown 链接提取可引用来源(DeepSeek 官方 provider 同样只给
101
+ * URL/标题)。无链接时返回空表——content-only 结果在 WebSearchResult 合法。 */
102
+ export function extractMarkdownLinks(markdown: string, limit: number): WebSearchSource[] {
103
+ const seen = new Set<string>()
104
+ const out: WebSearchSource[] = []
105
+ for (const match of markdown.matchAll(MARKDOWN_LINK)) {
106
+ const url = match[2]
107
+ if (url === undefined || seen.has(url)) continue
108
+ seen.add(url)
109
+ const label = match[1] ?? ''
110
+ out.push(makeSource(url, label.trim() !== '' ? label : undefined))
111
+ if (out.length >= limit) break
112
+ }
113
+ return out
114
+ }
115
+
116
+ function normalizeOpenAiChat(raw: JsonObject): WebSearchResult {
117
+ const answer = asNonEmptyString(asObject(raw.data).answer)
118
+ if (answer === undefined) {
119
+ throw new Error('openai-chat 结果缺少 data.answer')
120
+ }
121
+ return finish(answer, extractMarkdownLinks(answer, 16))
122
+ }
123
+
124
+ function formatAttempts(attempts: unknown): string {
125
+ const summary = asArray(attempts)
126
+ .map((entry) => {
127
+ const item = asObject(entry)
128
+ const service = asNonEmptyString(item.service) ?? '?'
129
+ const message = asNonEmptyString(item.error) ?? 'unknown'
130
+ return `${service}: ${message}`
131
+ })
132
+ .join('; ')
133
+ return summary === '' ? '' : `(尝试过: ${summary})`
134
+ }
135
+
136
+ /** 归一化 search.py 的完整 stdout JSON;失败以 Error 抛出(provider 层包 WebError)。 */
137
+ export function normalizeSearchOutput(raw: unknown): WebSearchResult {
138
+ const obj = asObject(raw)
139
+ if (obj.ok === false) {
140
+ const detail = asNonEmptyString(obj.error) ?? 'search.py 返回 ok=false'
141
+ throw new Error(`${detail}${formatAttempts(obj.attempts)}`)
142
+ }
143
+ const service = asNonEmptyString(obj.selectedService) ?? asNonEmptyString(obj.service)
144
+ switch (service) {
145
+ case 'tavily':
146
+ return normalizeTavily(obj)
147
+ case 'exa':
148
+ return normalizeExa(obj)
149
+ case 'openai-chat':
150
+ return normalizeOpenAiChat(obj)
151
+ default:
152
+ throw new Error(`search.py 返回未知或不支持的 selectedService: ${JSON.stringify(service)}`)
153
+ }
154
+ }
package/src/ns.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * settings 命名空间字面量(纯常量,零依赖)。
3
+ * 服务端以字面量注册 settings section(用户层配置落 $DSH_HOME/settings.yaml,
4
+ * 热生效);与行级 cordis Config 共用同一份 schemastery schema(config.ts)。
5
+ * @module web-search-services/ns
6
+ */
7
+ export const SETTINGS_NS = 'dsh-plus-web-search-services'
@@ -0,0 +1,119 @@
1
+ /**
2
+ * dsh-web 搜索 provider:把 ctx.web.search 桥接到 search-services skill 的
3
+ * search.py(Tavily / Exa / OpenAI Chat 免费额度后端)。官方 web_search 工具、
4
+ * 渲染与提示词零修改——本 provider 只产出标准 WebSearchResult,格式化天然一致。
5
+ * @module web-search-services/provider
6
+ */
7
+ import { existsSync } from 'node:fs'
8
+ import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
9
+ import { WebError } from '@deepseek-ai/dsh-web'
10
+
11
+ import { buildSearchArgv } from './args.ts'
12
+ import type { SearchBackend, WebSearchServicesConfig } from './config.ts'
13
+ import { expandHome, loadEnvFile } from './env-file.ts'
14
+ import { normalizeSearchOutput } from './normalize.ts'
15
+ import { isAbortError, runProcess, type SpawnFn } from './runner.ts'
16
+
17
+ export const PROVIDER_ID = 'search-services'
18
+
19
+ /** 各后端在合并环境(进程 env ⊕ envFile ⊕ 行级 keys)中的可用性判定变量。 */
20
+ const BACKEND_KEY_VARS: Readonly<Record<SearchBackend, readonly string[]>> = {
21
+ tavily: ['TAVILY_API_KEY', 'TAVILY_API_KEYS'],
22
+ exa: ['EXA_API_KEY'],
23
+ 'openai-chat': ['SEARCH_OPENAI_API_KEY'],
24
+ }
25
+
26
+ /** 行级 keys 覆盖 → 子进程环境变量;空串不注入(保持 envFile 单事实源优先)。 */
27
+ export function keyOverridesEnv(keys: WebSearchServicesConfig['keys']): Record<string, string> {
28
+ const out: Record<string, string> = {}
29
+ if (keys.tavily !== '') out.TAVILY_API_KEY = keys.tavily
30
+ if (keys.exa !== '') out.EXA_API_KEY = keys.exa
31
+ if (keys.openaiApiKey !== '') out.SEARCH_OPENAI_API_KEY = keys.openaiApiKey
32
+ if (keys.openaiBaseUrl !== '') out.SEARCH_OPENAI_BASE_URL = keys.openaiBaseUrl
33
+ if (keys.openaiModel !== '') out.SEARCH_OPENAI_MODEL = keys.openaiModel
34
+ return out
35
+ }
36
+
37
+ function messageOf(err: unknown): string {
38
+ return err instanceof Error ? err.message : String(err)
39
+ }
40
+
41
+ function toWebError(err: unknown): WebError {
42
+ if (err instanceof WebError) return err
43
+ if (isAbortError(err)) return new WebError('搜索已取消', 'WEB_ABORTED', { cause: err })
44
+ return new WebError(`search-services 搜索失败: ${messageOf(err)}`, 'WEB_PROVIDER_ERROR', {
45
+ cause: err,
46
+ })
47
+ }
48
+
49
+ export class SearchServicesProvider implements WebSearchProvider {
50
+ readonly id = PROVIDER_ID
51
+
52
+ private readonly current: () => WebSearchServicesConfig
53
+ private readonly spawnFn: SpawnFn | undefined
54
+
55
+ constructor(current: () => WebSearchServicesConfig, spawnFn?: SpawnFn | undefined) {
56
+ this.current = current
57
+ this.spawnFn = spawnFn
58
+ }
59
+
60
+ /** 廉价本地检查:脚本存在 + 优先级内至少一个后端有 key;不做网络调用。 */
61
+ available(): boolean {
62
+ const cfg = this.current()
63
+ if (!existsSync(expandHome(cfg.scriptPath))) return false
64
+ const env = this.childEnv(cfg)
65
+ return cfg.priority.some((backend) =>
66
+ BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? '') !== ''),
67
+ )
68
+ }
69
+
70
+ async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
71
+ const cfg = this.current()
72
+ const argv = buildSearchArgv(cfg, request)
73
+ const stdout = await this.run(cfg, argv, signal)
74
+ return this.normalize(stdout)
75
+ }
76
+
77
+ private childEnv(cfg: WebSearchServicesConfig): NodeJS.ProcessEnv {
78
+ const fromFile = cfg.envFile.trim() === '' ? {} : loadEnvFile(cfg.envFile)
79
+ return { ...process.env, ...fromFile, ...keyOverridesEnv(cfg.keys) }
80
+ }
81
+
82
+ private async run(
83
+ cfg: WebSearchServicesConfig,
84
+ argv: string[],
85
+ signal: AbortSignal | undefined,
86
+ ): Promise<string> {
87
+ try {
88
+ return await runProcess({
89
+ command: cfg.python,
90
+ args: [expandHome(cfg.scriptPath), ...argv],
91
+ env: this.childEnv(cfg),
92
+ timeoutMs: cfg.timeoutMs,
93
+ ...(signal !== undefined ? { signal } : {}),
94
+ ...(this.spawnFn !== undefined ? { spawnFn: this.spawnFn } : {}),
95
+ })
96
+ } catch (err) {
97
+ throw toWebError(err)
98
+ }
99
+ }
100
+
101
+ private normalize(stdout: string): WebSearchResult {
102
+ let raw: unknown
103
+ try {
104
+ raw = JSON.parse(stdout)
105
+ } catch (err) {
106
+ const preview = stdout.trim().slice(-200)
107
+ throw new WebError(`search.py 输出非 JSON: …${preview}`, 'WEB_PROVIDER_ERROR', {
108
+ cause: err,
109
+ })
110
+ }
111
+ try {
112
+ return normalizeSearchOutput(raw)
113
+ } catch (err) {
114
+ throw new WebError(`search.py 结果归一化失败: ${messageOf(err)}`, 'WEB_PROVIDER_ERROR', {
115
+ cause: err,
116
+ })
117
+ }
118
+ }
119
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 子进程执行器:spawn 脚本、收集 stdout、超时/abort 杀进程。
3
+ * spawn 依赖注入以便单测 mock(禁网络铁律下测试绝不真实启动搜索脚本)。
4
+ * @module web-search-services/runner
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+
8
+ export type SpawnFn = typeof spawn
9
+
10
+ export interface RunProcessOptions {
11
+ command: string
12
+ args: readonly string[]
13
+ env: NodeJS.ProcessEnv
14
+ cwd?: string | undefined
15
+ timeoutMs: number
16
+ signal?: AbortSignal | undefined
17
+ maxBufferBytes?: number | undefined
18
+ spawnFn?: SpawnFn | undefined
19
+ }
20
+
21
+ const DEFAULT_MAX_BUFFER = 8 * 1024 * 1024
22
+ const KILL_GRACE_MS = 2_000
23
+
24
+ export function abortError(): Error {
25
+ const err = new Error('search aborted')
26
+ err.name = 'AbortError'
27
+ return err
28
+ }
29
+
30
+ export function isAbortError(err: unknown): boolean {
31
+ return err instanceof Error && err.name === 'AbortError'
32
+ }
33
+
34
+ function tail(text: string, max = 500): string {
35
+ const trimmed = text.trim()
36
+ return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`
37
+ }
38
+
39
+ /** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
40
+ export function runProcess(opts: RunProcessOptions): Promise<string> {
41
+ const spawnFn = opts.spawnFn ?? spawn
42
+ return new Promise<string>((resolve, reject) => {
43
+ if (opts.signal?.aborted === true) {
44
+ reject(abortError())
45
+ return
46
+ }
47
+ const child = spawnFn(opts.command, [...opts.args], {
48
+ env: opts.env,
49
+ stdio: ['ignore', 'pipe', 'pipe'],
50
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
51
+ })
52
+
53
+ const maxBuffer = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER
54
+ let stdout = ''
55
+ let stderr = ''
56
+ let settled = false
57
+ let graceTimer: NodeJS.Timeout | undefined
58
+
59
+ const cleanup = (): void => {
60
+ clearTimeout(timer)
61
+ if (graceTimer !== undefined) clearTimeout(graceTimer)
62
+ opts.signal?.removeEventListener('abort', onAbort)
63
+ }
64
+ const settle = (fn: () => void): void => {
65
+ if (settled) return
66
+ settled = true
67
+ cleanup()
68
+ fn()
69
+ }
70
+ const kill = (): void => {
71
+ child.kill('SIGTERM')
72
+ graceTimer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS)
73
+ graceTimer.unref()
74
+ }
75
+ const onAbort = (): void => {
76
+ kill()
77
+ settle(() => reject(abortError()))
78
+ }
79
+ const timer = setTimeout(() => {
80
+ kill()
81
+ settle(() => reject(new Error(`process timed out after ${opts.timeoutMs}ms`)))
82
+ }, opts.timeoutMs)
83
+ timer.unref()
84
+ opts.signal?.addEventListener('abort', onAbort, { once: true })
85
+
86
+ child.stdout?.on('data', (chunk: Buffer | string) => {
87
+ stdout += chunk.toString()
88
+ if (stdout.length > maxBuffer) {
89
+ kill()
90
+ settle(() => reject(new Error(`stdout exceeded ${maxBuffer} bytes`)))
91
+ }
92
+ })
93
+ child.stderr?.on('data', (chunk: Buffer | string) => {
94
+ stderr += chunk.toString()
95
+ })
96
+ child.on('error', (err) => {
97
+ settle(() =>
98
+ reject(new Error(`failed to start ${opts.command}: ${err.message}`, { cause: err })),
99
+ )
100
+ })
101
+ child.on('close', (code) => {
102
+ if (settled) return
103
+ if (code === 0) {
104
+ settle(() => resolve(stdout))
105
+ return
106
+ }
107
+ const detail = tail(stderr) !== '' ? tail(stderr) : tail(stdout)
108
+ settle(() => reject(new Error(`exited with code ${code ?? 'null'}: ${detail}`)))
109
+ })
110
+ })
111
+ }