@dsh-plus/web-search-services 0.1.6 → 0.1.8
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/index.d.ts +28 -2
- package/lib/index.js +253 -11
- package/package.json +4 -2
- package/scripts/fetch_playwright.py +95 -62
- package/src/config.ts +7 -1
- package/src/env-file.ts +2 -2
- package/src/provider.ts +72 -14
- package/src/python.ts +193 -0
- package/src/runner.ts +29 -0
package/lib/index.d.ts
CHANGED
|
@@ -49,6 +49,24 @@ type WebSearchServicesConfigFields = Schemastery.TypeT<typeof Config>;
|
|
|
49
49
|
/** 平面配置形态(消费面的读取形态,由活动引用解包得到)。 */
|
|
50
50
|
type WebSearchServicesConfig = UnwrapVolatile<WebSearchServicesConfigFields>;
|
|
51
51
|
//#endregion
|
|
52
|
+
//#region src/python.d.ts
|
|
53
|
+
/** 注入的文件系统视图(测试在 Linux 上覆盖 win32 形态)。 */
|
|
54
|
+
interface PythonFs {
|
|
55
|
+
exists: (path: string) => boolean;
|
|
56
|
+
/** 列目录一层;目录不存在/不可读返回空表。 */
|
|
57
|
+
list: (path: string) => readonly {
|
|
58
|
+
name: string;
|
|
59
|
+
dir: boolean;
|
|
60
|
+
}[];
|
|
61
|
+
}
|
|
62
|
+
interface PythonDiscoveryEnv {
|
|
63
|
+
platform: NodeJS.Platform;
|
|
64
|
+
pathValue: string | undefined;
|
|
65
|
+
/** 桌面端捆绑运行时根;undefined = 无(跳过 BFS)。 */
|
|
66
|
+
bundledRoot: string | undefined;
|
|
67
|
+
fs: PythonFs;
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
52
70
|
//#region src/runner.d.ts
|
|
53
71
|
type SpawnFn = typeof spawn;
|
|
54
72
|
//#endregion
|
|
@@ -58,8 +76,16 @@ declare class SearchServicesProvider implements WebSearchProvider {
|
|
|
58
76
|
readonly id = "search-services";
|
|
59
77
|
private readonly current;
|
|
60
78
|
private readonly spawnFn;
|
|
61
|
-
|
|
62
|
-
/**
|
|
79
|
+
private readonly discovery;
|
|
80
|
+
/** 候选链按 `${python}|${bundledRoot}` 记忆:PATH 与捆绑布局在进程内静态。 */
|
|
81
|
+
private readonly candidateCache;
|
|
82
|
+
constructor(current: () => WebSearchServicesConfig, spawnFn?: SpawnFn | undefined, discovery?: (() => PythonDiscoveryEnv) | undefined);
|
|
83
|
+
/** 解释器候选链(有序、记忆化)。 */
|
|
84
|
+
private candidates;
|
|
85
|
+
/**
|
|
86
|
+
* 廉价本地检查:脚本存在 + 至少一个【已验证】解释器候选(配置/捆绑/PATH,
|
|
87
|
+
* 未验证兜底名不算)+ 优先级内至少一个后端有 key;不做网络调用、不 spawn。
|
|
88
|
+
*/
|
|
63
89
|
available(): boolean;
|
|
64
90
|
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
|
|
65
91
|
private childEnv;
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { unwrapVolatile } from "@dsh-plus/shared";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
4
5
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
@@ -29,10 +30,10 @@ function buildSearchArgv(cfg, request) {
|
|
|
29
30
|
* 文件缺失/不可读返回空表(envFile 是优化来源,不是硬依赖)。
|
|
30
31
|
* @module web-search-services/env-file
|
|
31
32
|
*/
|
|
32
|
-
/** 展开路径开头的
|
|
33
|
+
/** 展开路径开头的 ~(支持 `~`、`~/`、`~\` 三种形态,与官方 dsh-home-paths 对齐,不借 shell)。 */
|
|
33
34
|
function expandHome(path) {
|
|
34
35
|
if (path === "~") return homedir();
|
|
35
|
-
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
36
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
|
|
36
37
|
return path;
|
|
37
38
|
}
|
|
38
39
|
function unquote(value) {
|
|
@@ -177,6 +178,182 @@ function normalizeSearchOutput(raw) {
|
|
|
177
178
|
}
|
|
178
179
|
}
|
|
179
180
|
//#endregion
|
|
181
|
+
//#region src/python.ts
|
|
182
|
+
/**
|
|
183
|
+
* Python 解释器发现:Windows 与桌面端常无 `python3` 命令(桌面端自带的捆绑
|
|
184
|
+
* 运行时落在 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`),按候选链解析——
|
|
185
|
+
* 显式配置(可含参数,如 `py -3`)→ 捆绑运行时(浅层 BFS 找解释器)→
|
|
186
|
+
* PATH 扫描 → 平台兜底名(未验证,交 spawn 时再判)。
|
|
187
|
+
*
|
|
188
|
+
* 全部为同步纯逻辑 + 注入文件系统视图,零 spawn:`available()` 是廉价本地检查,
|
|
189
|
+
* 具体候选 spawn ENOENT 时由调用方逐个换下一个(见 provider.run)。
|
|
190
|
+
* @module web-search-services/python
|
|
191
|
+
*/
|
|
192
|
+
const POSIX_SPEC = {
|
|
193
|
+
pathNames: [{
|
|
194
|
+
name: "python3",
|
|
195
|
+
argsPrefix: []
|
|
196
|
+
}, {
|
|
197
|
+
name: "python",
|
|
198
|
+
argsPrefix: []
|
|
199
|
+
}],
|
|
200
|
+
fallbacks: [{
|
|
201
|
+
name: "python3",
|
|
202
|
+
argsPrefix: []
|
|
203
|
+
}, {
|
|
204
|
+
name: "python",
|
|
205
|
+
argsPrefix: []
|
|
206
|
+
}],
|
|
207
|
+
bundledNames: /* @__PURE__ */ new Set(["python3", "python"])
|
|
208
|
+
};
|
|
209
|
+
const WIN32_SPEC = {
|
|
210
|
+
pathNames: [
|
|
211
|
+
{
|
|
212
|
+
name: "python.exe",
|
|
213
|
+
argsPrefix: []
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "python3.exe",
|
|
217
|
+
argsPrefix: []
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "py.exe",
|
|
221
|
+
argsPrefix: ["-3"]
|
|
222
|
+
}
|
|
223
|
+
],
|
|
224
|
+
fallbacks: [
|
|
225
|
+
{
|
|
226
|
+
name: "python",
|
|
227
|
+
argsPrefix: []
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: "py",
|
|
231
|
+
argsPrefix: ["-3"]
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "python3",
|
|
235
|
+
argsPrefix: []
|
|
236
|
+
}
|
|
237
|
+
],
|
|
238
|
+
bundledNames: /* @__PURE__ */ new Set([
|
|
239
|
+
"python.exe",
|
|
240
|
+
"python3.exe",
|
|
241
|
+
"python3",
|
|
242
|
+
"python"
|
|
243
|
+
])
|
|
244
|
+
};
|
|
245
|
+
/** 捆绑运行时 BFS 的边界:层数与访问目录数双上限,防畸形布局拖慢 available()。 */
|
|
246
|
+
const BUNDLED_MAX_DEPTH = 3;
|
|
247
|
+
const BUNDLED_MAX_VISITS = 512;
|
|
248
|
+
/** 与解释器无关的大目录(Windows CPython 的 Lib/site-packages 等),跳过。 */
|
|
249
|
+
const NOISE_DIRS = /* @__PURE__ */ new Set([
|
|
250
|
+
"Lib",
|
|
251
|
+
"lib",
|
|
252
|
+
"lib64",
|
|
253
|
+
"include",
|
|
254
|
+
"share",
|
|
255
|
+
"site-packages",
|
|
256
|
+
"test",
|
|
257
|
+
"tests",
|
|
258
|
+
"doc",
|
|
259
|
+
"docs"
|
|
260
|
+
]);
|
|
261
|
+
function specOf(platform) {
|
|
262
|
+
return platform === "win32" ? WIN32_SPEC : POSIX_SPEC;
|
|
263
|
+
}
|
|
264
|
+
/** 显式配置 → 单候选(按空白拆分,支持 `py -3` 形态)。 */
|
|
265
|
+
function configCandidate(configPython) {
|
|
266
|
+
const [command, ...argsPrefix] = configPython.trim().split(/\s+/).filter((part) => part.length > 0);
|
|
267
|
+
if (command === void 0) return void 0;
|
|
268
|
+
return {
|
|
269
|
+
command,
|
|
270
|
+
argsPrefix,
|
|
271
|
+
source: "config"
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/** 在捆绑运行时根下浅层 BFS 找解释器文件(先按层扫文件名,再入队子目录)。 */
|
|
275
|
+
function findBundled(root, env) {
|
|
276
|
+
if (!env.fs.exists(root)) return void 0;
|
|
277
|
+
const spec = specOf(env.platform);
|
|
278
|
+
const queue = [{
|
|
279
|
+
dir: root,
|
|
280
|
+
depth: 0
|
|
281
|
+
}];
|
|
282
|
+
let visits = 0;
|
|
283
|
+
while (queue.length > 0 && visits < BUNDLED_MAX_VISITS) {
|
|
284
|
+
const item = queue.shift();
|
|
285
|
+
if (item === void 0) break;
|
|
286
|
+
visits += 1;
|
|
287
|
+
for (const entry of env.fs.list(item.dir)) {
|
|
288
|
+
if (!entry.dir && spec.bundledNames.has(entry.name)) return join(item.dir, entry.name);
|
|
289
|
+
if (entry.dir && item.depth < BUNDLED_MAX_DEPTH && !entry.name.startsWith(".") && !NOISE_DIRS.has(entry.name) && !spec.bundledNames.has(entry.name)) queue.push({
|
|
290
|
+
dir: join(item.dir, entry.name),
|
|
291
|
+
depth: item.depth + 1
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** PATH 逐目录扫描(名字在外层:win32 任一处的 python.exe 优先于 python3.exe)。 */
|
|
297
|
+
function scanPath(env, spec) {
|
|
298
|
+
const listSep = env.platform === "win32" ? ";" : ":";
|
|
299
|
+
const dirs = (env.pathValue ?? "").split(listSep).filter((dir) => dir.length > 0);
|
|
300
|
+
const out = [];
|
|
301
|
+
const seen = /* @__PURE__ */ new Set();
|
|
302
|
+
for (const { name, argsPrefix } of spec.pathNames) for (const dir of dirs) {
|
|
303
|
+
const full = join(dir, name);
|
|
304
|
+
if (seen.has(full)) continue;
|
|
305
|
+
seen.add(full);
|
|
306
|
+
if (env.fs.exists(full)) out.push({
|
|
307
|
+
command: full,
|
|
308
|
+
argsPrefix,
|
|
309
|
+
source: "path"
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* 解析解释器候选链(有序):显式配置独占;否则 捆绑运行时 → PATH 命中 → 平台兜底名。
|
|
316
|
+
* 兜底名永远追加在末尾(供 spawn ENOENT 时逐个回退),但 `source: 'fallback'`
|
|
317
|
+
* 不算「已验证」——`available()` 只信任 config/bundled/path。
|
|
318
|
+
*/
|
|
319
|
+
function pythonCandidates(env, configPython) {
|
|
320
|
+
const explicit = configCandidate(configPython);
|
|
321
|
+
if (explicit !== void 0) return [explicit];
|
|
322
|
+
const spec = specOf(env.platform);
|
|
323
|
+
const out = [];
|
|
324
|
+
if (env.bundledRoot !== void 0) {
|
|
325
|
+
const bundled = findBundled(env.bundledRoot, env);
|
|
326
|
+
if (bundled !== void 0) out.push({
|
|
327
|
+
command: bundled,
|
|
328
|
+
argsPrefix: [],
|
|
329
|
+
source: "bundled"
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
out.push(...scanPath(env, spec));
|
|
333
|
+
for (const { name, argsPrefix } of spec.fallbacks) if (!out.some((candidate) => candidate.command === name)) out.push({
|
|
334
|
+
command: name,
|
|
335
|
+
argsPrefix,
|
|
336
|
+
source: "fallback"
|
|
337
|
+
});
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
/** 生产文件系统视图(真实 fs;list 对不可读目录返回空表)。 */
|
|
341
|
+
function realPythonFs() {
|
|
342
|
+
return {
|
|
343
|
+
exists: (path) => existsSync(path),
|
|
344
|
+
list: (path) => {
|
|
345
|
+
try {
|
|
346
|
+
return readdirSync(path, { withFileTypes: true }).map((entry) => ({
|
|
347
|
+
name: entry.name,
|
|
348
|
+
dir: entry.isDirectory()
|
|
349
|
+
}));
|
|
350
|
+
} catch {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
//#endregion
|
|
180
357
|
//#region src/runner.ts
|
|
181
358
|
/**
|
|
182
359
|
* 子进程执行器:spawn 脚本、收集 stdout、超时/abort 杀进程。
|
|
@@ -185,6 +362,23 @@ function normalizeSearchOutput(raw) {
|
|
|
185
362
|
*/
|
|
186
363
|
const DEFAULT_MAX_BUFFER = 8388608;
|
|
187
364
|
const KILL_GRACE_MS = 2e3;
|
|
365
|
+
/**
|
|
366
|
+
* Windows 进程树终止:`taskkill /PID <pid> /T /F`(Windows 无 POSIX 信号语义,
|
|
367
|
+
* 单纯 `child.kill()` 会遗留 npx/playwright 派生的子进程树;上游
|
|
368
|
+
* dsh-subprocess-local 同款做法)。error 监听防 taskkill 缺席成为未捕获异常。
|
|
369
|
+
*/
|
|
370
|
+
const killWindowsTree = (pid) => {
|
|
371
|
+
const killer = spawn("taskkill", [
|
|
372
|
+
"/PID",
|
|
373
|
+
String(pid),
|
|
374
|
+
"/T",
|
|
375
|
+
"/F"
|
|
376
|
+
], { stdio: "ignore" });
|
|
377
|
+
killer.on("error", (error) => {
|
|
378
|
+
console.warn(`[web-search-services] taskkill 失败(pid ${pid}): ${error.message}`);
|
|
379
|
+
});
|
|
380
|
+
killer.unref();
|
|
381
|
+
};
|
|
188
382
|
function abortError() {
|
|
189
383
|
const err = /* @__PURE__ */ new Error("search aborted");
|
|
190
384
|
err.name = "AbortError";
|
|
@@ -200,6 +394,8 @@ function tail(text, max = 500) {
|
|
|
200
394
|
/** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
|
|
201
395
|
function runProcess(opts) {
|
|
202
396
|
const spawnFn = opts.spawnFn ?? spawn;
|
|
397
|
+
const platform = opts.platform ?? process.platform;
|
|
398
|
+
const killTree = opts.killTree ?? killWindowsTree;
|
|
203
399
|
return new Promise((resolve, reject) => {
|
|
204
400
|
if (opts.signal?.aborted === true) {
|
|
205
401
|
reject(abortError());
|
|
@@ -231,6 +427,11 @@ function runProcess(opts) {
|
|
|
231
427
|
fn();
|
|
232
428
|
};
|
|
233
429
|
const kill = () => {
|
|
430
|
+
if (platform === "win32") {
|
|
431
|
+
if (child.pid !== void 0) killTree(child.pid);
|
|
432
|
+
else child.kill();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
234
435
|
child.kill("SIGTERM");
|
|
235
436
|
graceTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
|
|
236
437
|
graceTimer.unref();
|
|
@@ -305,6 +506,14 @@ function keyOverridesEnv(keys) {
|
|
|
305
506
|
function messageOf(err) {
|
|
306
507
|
return err instanceof Error ? err.message : String(err);
|
|
307
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* 判定「解释器缺失」类失败(spawn ENOENT,runProcess 包装时保留 cause):
|
|
511
|
+
* 候选链据此换下一个解释器;其余错误照常上抛。
|
|
512
|
+
*/
|
|
513
|
+
function isMissingInterpreter(err) {
|
|
514
|
+
if (!(err instanceof Error)) return false;
|
|
515
|
+
return err.cause?.code === "ENOENT";
|
|
516
|
+
}
|
|
308
517
|
function toWebError(err) {
|
|
309
518
|
if (err instanceof WebError) return err;
|
|
310
519
|
if (isAbortError(err)) return new WebError("搜索已取消", "WEB_ABORTED", { cause: err });
|
|
@@ -314,14 +523,38 @@ var SearchServicesProvider = class {
|
|
|
314
523
|
id = PROVIDER_ID;
|
|
315
524
|
current;
|
|
316
525
|
spawnFn;
|
|
317
|
-
|
|
526
|
+
discovery;
|
|
527
|
+
/** 候选链按 `${python}|${bundledRoot}` 记忆:PATH 与捆绑布局在进程内静态。 */
|
|
528
|
+
candidateCache = /* @__PURE__ */ new Map();
|
|
529
|
+
constructor(current, spawnFn, discovery) {
|
|
318
530
|
this.current = current;
|
|
319
531
|
this.spawnFn = spawnFn;
|
|
532
|
+
this.discovery = discovery ?? (() => ({
|
|
533
|
+
platform: process.platform,
|
|
534
|
+
pathValue: process.env.PATH ?? process.env.Path,
|
|
535
|
+
bundledRoot: dshHomePath("dsh-runtimes", "dsh-primary-runtime"),
|
|
536
|
+
fs: realPythonFs()
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
/** 解释器候选链(有序、记忆化)。 */
|
|
540
|
+
candidates() {
|
|
541
|
+
const cfg = this.current();
|
|
542
|
+
const env = this.discovery();
|
|
543
|
+
const key = `${cfg.python}|${env.bundledRoot ?? ""}`;
|
|
544
|
+
const cached = this.candidateCache.get(key);
|
|
545
|
+
if (cached !== void 0) return cached;
|
|
546
|
+
const resolved = pythonCandidates(env, cfg.python);
|
|
547
|
+
this.candidateCache.set(key, resolved);
|
|
548
|
+
return resolved;
|
|
320
549
|
}
|
|
321
|
-
/**
|
|
550
|
+
/**
|
|
551
|
+
* 廉价本地检查:脚本存在 + 至少一个【已验证】解释器候选(配置/捆绑/PATH,
|
|
552
|
+
* 未验证兜底名不算)+ 优先级内至少一个后端有 key;不做网络调用、不 spawn。
|
|
553
|
+
*/
|
|
322
554
|
available() {
|
|
323
555
|
const cfg = this.current();
|
|
324
556
|
if (!existsSync(resolveScriptPath(cfg.scriptPath))) return false;
|
|
557
|
+
if (!this.candidates().some((candidate) => candidate.source !== "fallback")) return false;
|
|
325
558
|
const env = this.childEnv(cfg);
|
|
326
559
|
return cfg.priority.some((backend) => BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? "") !== ""));
|
|
327
560
|
}
|
|
@@ -340,18 +573,27 @@ var SearchServicesProvider = class {
|
|
|
340
573
|
};
|
|
341
574
|
}
|
|
342
575
|
async run(cfg, argv, signal) {
|
|
343
|
-
|
|
576
|
+
const script = resolveScriptPath(cfg.scriptPath);
|
|
577
|
+
const env = this.childEnv(cfg);
|
|
578
|
+
let lastMissing;
|
|
579
|
+
for (const candidate of this.candidates()) try {
|
|
344
580
|
return await runProcess({
|
|
345
|
-
command:
|
|
346
|
-
args: [
|
|
347
|
-
|
|
581
|
+
command: candidate.command,
|
|
582
|
+
args: [
|
|
583
|
+
...candidate.argsPrefix,
|
|
584
|
+
script,
|
|
585
|
+
...argv
|
|
586
|
+
],
|
|
587
|
+
env,
|
|
348
588
|
timeoutMs: cfg.timeoutMs,
|
|
349
589
|
...signal !== void 0 ? { signal } : {},
|
|
350
590
|
...this.spawnFn !== void 0 ? { spawnFn: this.spawnFn } : {}
|
|
351
591
|
});
|
|
352
592
|
} catch (err) {
|
|
353
|
-
throw toWebError(err);
|
|
593
|
+
if (!isMissingInterpreter(err)) throw toWebError(err);
|
|
594
|
+
lastMissing = err;
|
|
354
595
|
}
|
|
596
|
+
throw toWebError(lastMissing ?? /* @__PURE__ */ new Error("未找到可用的 python 解释器(配置 python 或安装 python3)"));
|
|
355
597
|
}
|
|
356
598
|
normalize(stdout) {
|
|
357
599
|
let raw;
|
|
@@ -402,7 +644,7 @@ const KeysSchema = z.object({
|
|
|
402
644
|
const Config = z.object({
|
|
403
645
|
scriptPath: z.string().description("search.py 路径(支持 ~ 展开);空串 = 使用随包内置脚本副本").default("").volatile(),
|
|
404
646
|
envFile: z.string().description("skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile").default(DEFAULT_ENV_FILE).volatile(),
|
|
405
|
-
python: z.string().description("python
|
|
647
|
+
python: z.string().description("python 解释器(可含参数,如 `py -3`);空串 = 自动发现(配置外按 桌面捆绑运行时 → PATH → 平台兜底名 解析)").default("").volatile(),
|
|
406
648
|
priority: z.array(PRIORITY_ENTRY).description("后端优先级:靠前的先尝试,失败自动回退到下一个").default([...DEFAULT_PRIORITY]).volatile(),
|
|
407
649
|
timeoutMs: z.natural().min(1e3).description("单次搜索子进程超时(毫秒),应低于 dsh-tool-web 的 searchTimeoutMs").default(DEFAULT_TIMEOUT_MS).volatile(),
|
|
408
650
|
keys: KeysSchema.default({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/web-search-services",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "dsh-plus service plugin: web_search 免费后端聚合——经官方 ctx.web provider 扩展点桥接 search-services skill(Tavily/Exa/OpenAI Chat 免费额度,优先级+自动回退+key 配置),官方 web_search 工具与输出格式零修改",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -19,16 +19,18 @@
|
|
|
19
19
|
"scripts"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@dsh-plus/shared": "0.1.
|
|
22
|
+
"@dsh-plus/shared": "0.1.30"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
26
|
+
"@deepseek-ai/dsh-home-paths": "^0.2.0-rc.1",
|
|
26
27
|
"@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
|
|
27
28
|
"@deepseek-ai/dsh-web": "^0.2.0-rc.1",
|
|
28
29
|
"@deepseek-ai/schemastery": "^3.18.4"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"@deepseek-ai/cordis": "4.0.4",
|
|
33
|
+
"@deepseek-ai/dsh-home-paths": "0.2.0-rc.1",
|
|
32
34
|
"@deepseek-ai/dsh-settings": "0.2.0-rc.1",
|
|
33
35
|
"@deepseek-ai/dsh-web": "0.2.0-rc.1",
|
|
34
36
|
"@deepseek-ai/schemastery": "3.18.4"
|
|
@@ -31,6 +31,7 @@ import fetch as local_fetch # noqa: E402
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
DEFAULT_SELECTOR = "main, article, [role='main'], .content, #content, .post, .entry-content"
|
|
34
|
+
_IS_WINDOWS = os.name == "nt"
|
|
34
35
|
_ACTIVE_PGIDS: set[int] = set()
|
|
35
36
|
_ACTIVE_BROWSER_PIDS: set[int] = set()
|
|
36
37
|
_TEMP_DIRS: set[str] = set()
|
|
@@ -52,46 +53,91 @@ class PlaywrightError(RuntimeError):
|
|
|
52
53
|
self.details = details
|
|
53
54
|
|
|
54
55
|
|
|
56
|
+
def _popen(cmd: list[str], **kwargs: Any) -> subprocess.Popen:
|
|
57
|
+
"""启动子进程;Windows 上 .cmd/.bat 必须经 cmd.exe(CreateProcess 不直接执行批处理)。"""
|
|
58
|
+
if _IS_WINDOWS and cmd and cmd[0].lower().endswith((".cmd", ".bat")):
|
|
59
|
+
cmd = ["cmd", "/c", *cmd]
|
|
60
|
+
return subprocess.Popen(cmd, **kwargs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _taskkill_tree(pid: int) -> None:
|
|
64
|
+
"""Windows 进程树终止:taskkill /T /F(无 POSIX 信号语义,单杀父进程会留孤儿)。"""
|
|
65
|
+
try:
|
|
66
|
+
subprocess.run(
|
|
67
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
68
|
+
stdout=subprocess.DEVNULL,
|
|
69
|
+
stderr=subprocess.DEVNULL,
|
|
70
|
+
check=False,
|
|
71
|
+
timeout=15,
|
|
72
|
+
)
|
|
73
|
+
except (OSError, subprocess.SubprocessError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _signal_group(pgid: int, *, force: bool) -> None:
|
|
78
|
+
"""向进程组发信号(仅 POSIX;Windows 无 killpg/SIGKILL,直接返回)。"""
|
|
79
|
+
if _IS_WINDOWS:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
os.killpg(pgid, signal.SIGKILL if force else signal.SIGTERM)
|
|
83
|
+
except OSError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _signal_pid(pid: int, *, force: bool) -> None:
|
|
88
|
+
"""终止单个进程:POSIX 走 SIGTERM→SIGKILL,Windows 统一 taskkill /T /F。"""
|
|
89
|
+
if _IS_WINDOWS:
|
|
90
|
+
_taskkill_tree(pid)
|
|
91
|
+
return
|
|
92
|
+
try:
|
|
93
|
+
os.kill(pid, signal.SIGKILL if force else signal.SIGTERM)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _pid_alive(pid: int) -> bool:
|
|
99
|
+
"""进程存活探测。Windows 上 os.kill(pid, 0) 会直接杀进程,绝不能用。"""
|
|
100
|
+
if _IS_WINDOWS:
|
|
101
|
+
try:
|
|
102
|
+
out = subprocess.run(
|
|
103
|
+
["tasklist", "/FI", f"PID eq {pid}"],
|
|
104
|
+
stdout=subprocess.PIPE,
|
|
105
|
+
stderr=subprocess.DEVNULL,
|
|
106
|
+
check=False,
|
|
107
|
+
timeout=15,
|
|
108
|
+
text=True,
|
|
109
|
+
)
|
|
110
|
+
except (OSError, subprocess.SubprocessError):
|
|
111
|
+
return False
|
|
112
|
+
return str(pid) in out.stdout
|
|
113
|
+
try:
|
|
114
|
+
os.kill(pid, 0)
|
|
115
|
+
return True
|
|
116
|
+
except OSError:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
55
120
|
def _safe_rmtree(path: str) -> None:
|
|
56
|
-
"""
|
|
121
|
+
"""只清理本脚本创建的临时目录(tempfile.gettempdir() 下 search-services-pw-*),避免误删。"""
|
|
57
122
|
|
|
123
|
+
root = os.path.realpath(tempfile.gettempdir())
|
|
58
124
|
real = os.path.realpath(path)
|
|
59
|
-
if not real.startswith("
|
|
125
|
+
if not real.startswith(os.path.join(root, "search-services-pw-")):
|
|
60
126
|
return
|
|
61
127
|
shutil.rmtree(real, ignore_errors=True)
|
|
62
128
|
|
|
63
129
|
|
|
64
130
|
def _cleanup() -> None:
|
|
65
131
|
for pgid in list(_ACTIVE_PGIDS):
|
|
66
|
-
|
|
67
|
-
os.killpg(pgid, signal.SIGTERM)
|
|
68
|
-
except ProcessLookupError:
|
|
69
|
-
pass
|
|
70
|
-
except OSError:
|
|
71
|
-
pass
|
|
132
|
+
_signal_group(pgid, force=False)
|
|
72
133
|
time.sleep(0.1)
|
|
73
134
|
for pgid in list(_ACTIVE_PGIDS):
|
|
74
|
-
|
|
75
|
-
os.killpg(pgid, signal.SIGKILL)
|
|
76
|
-
except ProcessLookupError:
|
|
77
|
-
pass
|
|
78
|
-
except OSError:
|
|
79
|
-
pass
|
|
135
|
+
_signal_group(pgid, force=True)
|
|
80
136
|
for pid in list(_ACTIVE_BROWSER_PIDS):
|
|
81
|
-
|
|
82
|
-
os.kill(pid, signal.SIGTERM)
|
|
83
|
-
except ProcessLookupError:
|
|
84
|
-
pass
|
|
85
|
-
except OSError:
|
|
86
|
-
pass
|
|
137
|
+
_signal_pid(pid, force=False)
|
|
87
138
|
time.sleep(0.1)
|
|
88
139
|
for pid in list(_ACTIVE_BROWSER_PIDS):
|
|
89
|
-
|
|
90
|
-
os.kill(pid, signal.SIGKILL)
|
|
91
|
-
except ProcessLookupError:
|
|
92
|
-
pass
|
|
93
|
-
except OSError:
|
|
94
|
-
pass
|
|
140
|
+
_signal_pid(pid, force=True)
|
|
95
141
|
for temp_dir in list(_TEMP_DIRS):
|
|
96
142
|
_safe_rmtree(temp_dir)
|
|
97
143
|
_TEMP_DIRS.discard(temp_dir)
|
|
@@ -124,7 +170,8 @@ def _playwright_command_base() -> list[str]:
|
|
|
124
170
|
|
|
125
171
|
codex_home = os.getenv("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
|
|
126
172
|
wrapper = os.path.join(codex_home, "skills", "playwright-headless", "scripts", "playwright_cli.sh")
|
|
127
|
-
|
|
173
|
+
# .sh 包装依赖 POSIX shell,Windows 上直接跳过走 npx 兜底。
|
|
174
|
+
if not _IS_WINDOWS and os.path.exists(wrapper):
|
|
128
175
|
return [wrapper]
|
|
129
176
|
|
|
130
177
|
npx = shutil.which("npx")
|
|
@@ -180,37 +227,33 @@ def _run_checked_with_retries(cmd: list[str], *, timeout: float, cwd: str, retri
|
|
|
180
227
|
|
|
181
228
|
|
|
182
229
|
def _run_command(cmd: list[str], *, timeout: float, cwd: str) -> tuple[int, str, str]:
|
|
183
|
-
proc =
|
|
230
|
+
proc = _popen(
|
|
184
231
|
cmd,
|
|
185
232
|
stdout=subprocess.PIPE,
|
|
186
233
|
stderr=subprocess.PIPE,
|
|
187
234
|
text=True,
|
|
188
235
|
cwd=cwd,
|
|
189
|
-
|
|
236
|
+
# Windows 无 setsid;进程树终止由 taskkill /T 承担。
|
|
237
|
+
start_new_session=not _IS_WINDOWS,
|
|
190
238
|
)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
239
|
+
pgid: Optional[int] = None
|
|
240
|
+
if not _IS_WINDOWS:
|
|
241
|
+
try:
|
|
242
|
+
pgid = os.getpgid(proc.pid)
|
|
243
|
+
_ACTIVE_PGIDS.add(pgid)
|
|
244
|
+
except OSError:
|
|
245
|
+
pgid = None
|
|
196
246
|
try:
|
|
197
247
|
stdout, stderr = proc.communicate(timeout=timeout)
|
|
198
248
|
return proc.returncode, stdout, stderr
|
|
199
249
|
except subprocess.TimeoutExpired as exc:
|
|
200
250
|
if pgid is not None:
|
|
201
|
-
|
|
202
|
-
os.killpg(pgid, signal.SIGTERM)
|
|
203
|
-
except ProcessLookupError:
|
|
204
|
-
pass
|
|
205
|
-
except OSError:
|
|
206
|
-
pass
|
|
251
|
+
_signal_group(pgid, force=False)
|
|
207
252
|
time.sleep(0.2)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
except OSError:
|
|
213
|
-
pass
|
|
253
|
+
_signal_group(pgid, force=True)
|
|
254
|
+
else:
|
|
255
|
+
# Windows(或 POSIX 取组失败):端掉整棵子进程树而非单个进程。
|
|
256
|
+
_signal_pid(proc.pid, force=True)
|
|
214
257
|
try:
|
|
215
258
|
proc.wait(timeout=2)
|
|
216
259
|
except subprocess.TimeoutExpired:
|
|
@@ -366,22 +409,12 @@ def fetch_one(
|
|
|
366
409
|
except Exception:
|
|
367
410
|
pass
|
|
368
411
|
if browser_pid is not None:
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
else:
|
|
376
|
-
try:
|
|
377
|
-
os.kill(browser_pid, signal.SIGTERM)
|
|
378
|
-
time.sleep(0.2)
|
|
379
|
-
os.kill(browser_pid, signal.SIGKILL)
|
|
380
|
-
except ProcessLookupError:
|
|
381
|
-
pass
|
|
382
|
-
except OSError:
|
|
383
|
-
pass
|
|
384
|
-
_ACTIVE_BROWSER_PIDS.discard(browser_pid)
|
|
412
|
+
# 存活探测必须走 _pid_alive:Windows 上 os.kill(pid, 0) 会直接杀进程。
|
|
413
|
+
if _pid_alive(browser_pid):
|
|
414
|
+
_signal_pid(browser_pid, force=False)
|
|
415
|
+
time.sleep(0.2)
|
|
416
|
+
_signal_pid(browser_pid, force=True)
|
|
417
|
+
_ACTIVE_BROWSER_PIDS.discard(browser_pid)
|
|
385
418
|
_safe_rmtree(temp_dir)
|
|
386
419
|
_TEMP_DIRS.discard(temp_dir)
|
|
387
420
|
|
package/src/config.ts
CHANGED
|
@@ -64,7 +64,13 @@ export const Config = z.object({
|
|
|
64
64
|
.description('skill 密钥 env 文件路径(支持 ~ 展开);空串表示不读 envFile')
|
|
65
65
|
.default(DEFAULT_ENV_FILE)
|
|
66
66
|
.volatile(),
|
|
67
|
-
python: z
|
|
67
|
+
python: z
|
|
68
|
+
.string()
|
|
69
|
+
.description(
|
|
70
|
+
'python 解释器(可含参数,如 `py -3`);空串 = 自动发现(配置外按 桌面捆绑运行时 → PATH → 平台兜底名 解析)',
|
|
71
|
+
)
|
|
72
|
+
.default('')
|
|
73
|
+
.volatile(),
|
|
68
74
|
priority: z
|
|
69
75
|
.array(PRIORITY_ENTRY)
|
|
70
76
|
.description('后端优先级:靠前的先尝试,失败自动回退到下一个')
|
package/src/env-file.ts
CHANGED
|
@@ -8,10 +8,10 @@ import { readFileSync } from 'node:fs'
|
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
|
|
11
|
-
/** 展开路径开头的
|
|
11
|
+
/** 展开路径开头的 ~(支持 `~`、`~/`、`~\` 三种形态,与官方 dsh-home-paths 对齐,不借 shell)。 */
|
|
12
12
|
export function expandHome(path: string): string {
|
|
13
13
|
if (path === '~') return homedir()
|
|
14
|
-
if (path.startsWith('~/')) return join(homedir(), path.slice(2))
|
|
14
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
|
|
15
15
|
return path
|
|
16
16
|
}
|
|
17
17
|
|
package/src/provider.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
|
|
10
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
10
11
|
import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
|
|
11
12
|
import { WebError } from '@deepseek-ai/dsh-web'
|
|
12
13
|
|
|
@@ -14,6 +15,12 @@ import { buildSearchArgv } from './args.ts'
|
|
|
14
15
|
import type { SearchBackend, WebSearchServicesConfig } from './config.ts'
|
|
15
16
|
import { expandHome, loadEnvFile } from './env-file.ts'
|
|
16
17
|
import { normalizeSearchOutput } from './normalize.ts'
|
|
18
|
+
import {
|
|
19
|
+
type PythonCandidate,
|
|
20
|
+
type PythonDiscoveryEnv,
|
|
21
|
+
pythonCandidates,
|
|
22
|
+
realPythonFs,
|
|
23
|
+
} from './python.ts'
|
|
17
24
|
import { isAbortError, runProcess, type SpawnFn } from './runner.ts'
|
|
18
25
|
|
|
19
26
|
export const PROVIDER_ID = 'search-services'
|
|
@@ -49,6 +56,16 @@ function messageOf(err: unknown): string {
|
|
|
49
56
|
return err instanceof Error ? err.message : String(err)
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
/**
|
|
60
|
+
* 判定「解释器缺失」类失败(spawn ENOENT,runProcess 包装时保留 cause):
|
|
61
|
+
* 候选链据此换下一个解释器;其余错误照常上抛。
|
|
62
|
+
*/
|
|
63
|
+
function isMissingInterpreter(err: unknown): boolean {
|
|
64
|
+
if (!(err instanceof Error)) return false
|
|
65
|
+
const cause = err.cause as { code?: unknown } | undefined
|
|
66
|
+
return cause?.code === 'ENOENT'
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
function toWebError(err: unknown): WebError {
|
|
53
70
|
if (err instanceof WebError) return err
|
|
54
71
|
if (isAbortError(err)) return new WebError('搜索已取消', 'WEB_ABORTED', { cause: err })
|
|
@@ -62,16 +79,47 @@ export class SearchServicesProvider implements WebSearchProvider {
|
|
|
62
79
|
|
|
63
80
|
private readonly current: () => WebSearchServicesConfig
|
|
64
81
|
private readonly spawnFn: SpawnFn | undefined
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
private readonly discovery: () => PythonDiscoveryEnv
|
|
83
|
+
/** 候选链按 `${python}|${bundledRoot}` 记忆:PATH 与捆绑布局在进程内静态。 */
|
|
84
|
+
private readonly candidateCache = new Map<string, PythonCandidate[]>()
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
current: () => WebSearchServicesConfig,
|
|
88
|
+
spawnFn?: SpawnFn | undefined,
|
|
89
|
+
discovery?: (() => PythonDiscoveryEnv) | undefined,
|
|
90
|
+
) {
|
|
67
91
|
this.current = current
|
|
68
92
|
this.spawnFn = spawnFn
|
|
93
|
+
this.discovery =
|
|
94
|
+
discovery ??
|
|
95
|
+
(() => ({
|
|
96
|
+
platform: process.platform,
|
|
97
|
+
pathValue: process.env.PATH ?? process.env.Path,
|
|
98
|
+
bundledRoot: dshHomePath('dsh-runtimes', 'dsh-primary-runtime'),
|
|
99
|
+
fs: realPythonFs(),
|
|
100
|
+
}))
|
|
69
101
|
}
|
|
70
102
|
|
|
71
|
-
/**
|
|
103
|
+
/** 解释器候选链(有序、记忆化)。 */
|
|
104
|
+
private candidates(): PythonCandidate[] {
|
|
105
|
+
const cfg = this.current()
|
|
106
|
+
const env = this.discovery()
|
|
107
|
+
const key = `${cfg.python}|${env.bundledRoot ?? ''}`
|
|
108
|
+
const cached = this.candidateCache.get(key)
|
|
109
|
+
if (cached !== undefined) return cached
|
|
110
|
+
const resolved = pythonCandidates(env, cfg.python)
|
|
111
|
+
this.candidateCache.set(key, resolved)
|
|
112
|
+
return resolved
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 廉价本地检查:脚本存在 + 至少一个【已验证】解释器候选(配置/捆绑/PATH,
|
|
117
|
+
* 未验证兜底名不算)+ 优先级内至少一个后端有 key;不做网络调用、不 spawn。
|
|
118
|
+
*/
|
|
72
119
|
available(): boolean {
|
|
73
120
|
const cfg = this.current()
|
|
74
121
|
if (!existsSync(resolveScriptPath(cfg.scriptPath))) return false
|
|
122
|
+
if (!this.candidates().some((candidate) => candidate.source !== 'fallback')) return false
|
|
75
123
|
const env = this.childEnv(cfg)
|
|
76
124
|
return cfg.priority.some((backend) =>
|
|
77
125
|
BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? '') !== ''),
|
|
@@ -95,18 +143,28 @@ export class SearchServicesProvider implements WebSearchProvider {
|
|
|
95
143
|
argv: string[],
|
|
96
144
|
signal: AbortSignal | undefined,
|
|
97
145
|
): Promise<string> {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
146
|
+
const script = resolveScriptPath(cfg.scriptPath)
|
|
147
|
+
const env = this.childEnv(cfg)
|
|
148
|
+
let lastMissing: unknown
|
|
149
|
+
// 候选链逐个尝试:仅解释器缺失(ENOENT)换下一个,其余错误立即上抛。
|
|
150
|
+
for (const candidate of this.candidates()) {
|
|
151
|
+
try {
|
|
152
|
+
return await runProcess({
|
|
153
|
+
command: candidate.command,
|
|
154
|
+
args: [...candidate.argsPrefix, script, ...argv],
|
|
155
|
+
env,
|
|
156
|
+
timeoutMs: cfg.timeoutMs,
|
|
157
|
+
...(signal !== undefined ? { signal } : {}),
|
|
158
|
+
...(this.spawnFn !== undefined ? { spawnFn: this.spawnFn } : {}),
|
|
159
|
+
})
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (!isMissingInterpreter(err)) throw toWebError(err)
|
|
162
|
+
lastMissing = err
|
|
163
|
+
}
|
|
109
164
|
}
|
|
165
|
+
throw toWebError(
|
|
166
|
+
lastMissing ?? new Error('未找到可用的 python 解释器(配置 python 或安装 python3)'),
|
|
167
|
+
)
|
|
110
168
|
}
|
|
111
169
|
|
|
112
170
|
private normalize(stdout: string): WebSearchResult {
|
package/src/python.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python 解释器发现:Windows 与桌面端常无 `python3` 命令(桌面端自带的捆绑
|
|
3
|
+
* 运行时落在 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`),按候选链解析——
|
|
4
|
+
* 显式配置(可含参数,如 `py -3`)→ 捆绑运行时(浅层 BFS 找解释器)→
|
|
5
|
+
* PATH 扫描 → 平台兜底名(未验证,交 spawn 时再判)。
|
|
6
|
+
*
|
|
7
|
+
* 全部为同步纯逻辑 + 注入文件系统视图,零 spawn:`available()` 是廉价本地检查,
|
|
8
|
+
* 具体候选 spawn ENOENT 时由调用方逐个换下一个(见 provider.run)。
|
|
9
|
+
* @module web-search-services/python
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** 候选来源:config=用户显式配置;bundled=桌面端捆绑运行时;path=PATH 扫描命中;fallback=未验证兜底名。 */
|
|
15
|
+
export type PythonSource = 'config' | 'bundled' | 'path' | 'fallback'
|
|
16
|
+
|
|
17
|
+
export interface PythonCandidate {
|
|
18
|
+
command: string
|
|
19
|
+
argsPrefix: readonly string[]
|
|
20
|
+
source: PythonSource
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 注入的文件系统视图(测试在 Linux 上覆盖 win32 形态)。 */
|
|
24
|
+
export interface PythonFs {
|
|
25
|
+
exists: (path: string) => boolean
|
|
26
|
+
/** 列目录一层;目录不存在/不可读返回空表。 */
|
|
27
|
+
list: (path: string) => readonly { name: string; dir: boolean }[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PythonDiscoveryEnv {
|
|
31
|
+
platform: NodeJS.Platform
|
|
32
|
+
pathValue: string | undefined
|
|
33
|
+
/** 桌面端捆绑运行时根;undefined = 无(跳过 BFS)。 */
|
|
34
|
+
bundledRoot: string | undefined
|
|
35
|
+
fs: PythonFs
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface PlatformCommand {
|
|
39
|
+
name: string
|
|
40
|
+
argsPrefix: readonly string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PlatformSpec {
|
|
44
|
+
/** PATH 扫描尝试的文件名(win32 带 .exe 后缀)。 */
|
|
45
|
+
pathNames: readonly PlatformCommand[]
|
|
46
|
+
/** 未验证兜底(PATH 扫描落空时信任 spawn 的 PATH 解析)。 */
|
|
47
|
+
fallbacks: readonly PlatformCommand[]
|
|
48
|
+
/** 捆绑运行时 BFS 中接受的解释器文件名。 */
|
|
49
|
+
bundledNames: ReadonlySet<string>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const POSIX_SPEC: PlatformSpec = {
|
|
53
|
+
pathNames: [
|
|
54
|
+
{ name: 'python3', argsPrefix: [] },
|
|
55
|
+
{ name: 'python', argsPrefix: [] },
|
|
56
|
+
],
|
|
57
|
+
fallbacks: [
|
|
58
|
+
{ name: 'python3', argsPrefix: [] },
|
|
59
|
+
{ name: 'python', argsPrefix: [] },
|
|
60
|
+
],
|
|
61
|
+
bundledNames: new Set(['python3', 'python']),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const WIN32_SPEC: PlatformSpec = {
|
|
65
|
+
pathNames: [
|
|
66
|
+
{ name: 'python.exe', argsPrefix: [] },
|
|
67
|
+
{ name: 'python3.exe', argsPrefix: [] },
|
|
68
|
+
// Python 启动器:-3 显式锁 Python 3,避免用户配置默认 py2。
|
|
69
|
+
{ name: 'py.exe', argsPrefix: ['-3'] },
|
|
70
|
+
],
|
|
71
|
+
fallbacks: [
|
|
72
|
+
{ name: 'python', argsPrefix: [] },
|
|
73
|
+
{ name: 'py', argsPrefix: ['-3'] },
|
|
74
|
+
{ name: 'python3', argsPrefix: [] },
|
|
75
|
+
],
|
|
76
|
+
bundledNames: new Set(['python.exe', 'python3.exe', 'python3', 'python']),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 捆绑运行时 BFS 的边界:层数与访问目录数双上限,防畸形布局拖慢 available()。 */
|
|
80
|
+
const BUNDLED_MAX_DEPTH = 3
|
|
81
|
+
const BUNDLED_MAX_VISITS = 512
|
|
82
|
+
/** 与解释器无关的大目录(Windows CPython 的 Lib/site-packages 等),跳过。 */
|
|
83
|
+
const NOISE_DIRS: ReadonlySet<string> = new Set([
|
|
84
|
+
'Lib',
|
|
85
|
+
'lib',
|
|
86
|
+
'lib64',
|
|
87
|
+
'include',
|
|
88
|
+
'share',
|
|
89
|
+
'site-packages',
|
|
90
|
+
'test',
|
|
91
|
+
'tests',
|
|
92
|
+
'doc',
|
|
93
|
+
'docs',
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
function specOf(platform: NodeJS.Platform): PlatformSpec {
|
|
97
|
+
return platform === 'win32' ? WIN32_SPEC : POSIX_SPEC
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 显式配置 → 单候选(按空白拆分,支持 `py -3` 形态)。 */
|
|
101
|
+
function configCandidate(configPython: string): PythonCandidate | undefined {
|
|
102
|
+
const parts = configPython
|
|
103
|
+
.trim()
|
|
104
|
+
.split(/\s+/)
|
|
105
|
+
.filter((part) => part.length > 0)
|
|
106
|
+
const [command, ...argsPrefix] = parts
|
|
107
|
+
if (command === undefined) return undefined
|
|
108
|
+
return { command, argsPrefix, source: 'config' }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 在捆绑运行时根下浅层 BFS 找解释器文件(先按层扫文件名,再入队子目录)。 */
|
|
112
|
+
function findBundled(root: string, env: PythonDiscoveryEnv): string | undefined {
|
|
113
|
+
if (!env.fs.exists(root)) return undefined
|
|
114
|
+
const spec = specOf(env.platform)
|
|
115
|
+
const queue: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }]
|
|
116
|
+
let visits = 0
|
|
117
|
+
while (queue.length > 0 && visits < BUNDLED_MAX_VISITS) {
|
|
118
|
+
const item = queue.shift()
|
|
119
|
+
if (item === undefined) break
|
|
120
|
+
visits += 1
|
|
121
|
+
for (const entry of env.fs.list(item.dir)) {
|
|
122
|
+
if (!entry.dir && spec.bundledNames.has(entry.name)) return join(item.dir, entry.name)
|
|
123
|
+
if (
|
|
124
|
+
entry.dir &&
|
|
125
|
+
item.depth < BUNDLED_MAX_DEPTH &&
|
|
126
|
+
!entry.name.startsWith('.') &&
|
|
127
|
+
!NOISE_DIRS.has(entry.name) &&
|
|
128
|
+
!spec.bundledNames.has(entry.name)
|
|
129
|
+
) {
|
|
130
|
+
queue.push({ dir: join(item.dir, entry.name), depth: item.depth + 1 })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** PATH 逐目录扫描(名字在外层:win32 任一处的 python.exe 优先于 python3.exe)。 */
|
|
138
|
+
function scanPath(env: PythonDiscoveryEnv, spec: PlatformSpec): PythonCandidate[] {
|
|
139
|
+
// PATH 分隔符按【被发现的平台】而非宿主平台取:测试可在 Linux 上覆盖 win32 形态。
|
|
140
|
+
const listSep = env.platform === 'win32' ? ';' : ':'
|
|
141
|
+
const dirs = (env.pathValue ?? '').split(listSep).filter((dir) => dir.length > 0)
|
|
142
|
+
const out: PythonCandidate[] = []
|
|
143
|
+
const seen = new Set<string>()
|
|
144
|
+
for (const { name, argsPrefix } of spec.pathNames) {
|
|
145
|
+
for (const dir of dirs) {
|
|
146
|
+
const full = join(dir, name)
|
|
147
|
+
if (seen.has(full)) continue
|
|
148
|
+
seen.add(full)
|
|
149
|
+
if (env.fs.exists(full)) out.push({ command: full, argsPrefix, source: 'path' })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 解析解释器候选链(有序):显式配置独占;否则 捆绑运行时 → PATH 命中 → 平台兜底名。
|
|
157
|
+
* 兜底名永远追加在末尾(供 spawn ENOENT 时逐个回退),但 `source: 'fallback'`
|
|
158
|
+
* 不算「已验证」——`available()` 只信任 config/bundled/path。
|
|
159
|
+
*/
|
|
160
|
+
export function pythonCandidates(env: PythonDiscoveryEnv, configPython: string): PythonCandidate[] {
|
|
161
|
+
const explicit = configCandidate(configPython)
|
|
162
|
+
if (explicit !== undefined) return [explicit]
|
|
163
|
+
const spec = specOf(env.platform)
|
|
164
|
+
const out: PythonCandidate[] = []
|
|
165
|
+
if (env.bundledRoot !== undefined) {
|
|
166
|
+
const bundled = findBundled(env.bundledRoot, env)
|
|
167
|
+
if (bundled !== undefined) out.push({ command: bundled, argsPrefix: [], source: 'bundled' })
|
|
168
|
+
}
|
|
169
|
+
out.push(...scanPath(env, spec))
|
|
170
|
+
for (const { name, argsPrefix } of spec.fallbacks) {
|
|
171
|
+
if (!out.some((candidate) => candidate.command === name)) {
|
|
172
|
+
out.push({ command: name, argsPrefix, source: 'fallback' })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 生产文件系统视图(真实 fs;list 对不可读目录返回空表)。 */
|
|
179
|
+
export function realPythonFs(): PythonFs {
|
|
180
|
+
return {
|
|
181
|
+
exists: (path: string) => existsSync(path),
|
|
182
|
+
list: (path: string) => {
|
|
183
|
+
try {
|
|
184
|
+
return readdirSync(path, { withFileTypes: true }).map((entry) => ({
|
|
185
|
+
name: entry.name,
|
|
186
|
+
dir: entry.isDirectory(),
|
|
187
|
+
}))
|
|
188
|
+
} catch {
|
|
189
|
+
return []
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -7,6 +7,9 @@ import { spawn } from 'node:child_process'
|
|
|
7
7
|
|
|
8
8
|
export type SpawnFn = typeof spawn
|
|
9
9
|
|
|
10
|
+
/** 进程树终止器(Windows 用 taskkill 注入面;测试可替换)。 */
|
|
11
|
+
export type KillTreeFn = (pid: number) => void
|
|
12
|
+
|
|
10
13
|
export interface RunProcessOptions {
|
|
11
14
|
command: string
|
|
12
15
|
args: readonly string[]
|
|
@@ -16,11 +19,29 @@ export interface RunProcessOptions {
|
|
|
16
19
|
signal?: AbortSignal | undefined
|
|
17
20
|
maxBufferBytes?: number | undefined
|
|
18
21
|
spawnFn?: SpawnFn | undefined
|
|
22
|
+
/** 平台(缺省 process.platform):win32 的超时/中止走进程树终止而非信号。 */
|
|
23
|
+
platform?: NodeJS.Platform | undefined
|
|
24
|
+
/** win32 进程树终止实现(缺省 taskkill /T /F)。 */
|
|
25
|
+
killTree?: KillTreeFn | undefined
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
const DEFAULT_MAX_BUFFER = 8 * 1024 * 1024
|
|
22
29
|
const KILL_GRACE_MS = 2_000
|
|
23
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Windows 进程树终止:`taskkill /PID <pid> /T /F`(Windows 无 POSIX 信号语义,
|
|
33
|
+
* 单纯 `child.kill()` 会遗留 npx/playwright 派生的子进程树;上游
|
|
34
|
+
* dsh-subprocess-local 同款做法)。error 监听防 taskkill 缺席成为未捕获异常。
|
|
35
|
+
*/
|
|
36
|
+
export const killWindowsTree: KillTreeFn = (pid) => {
|
|
37
|
+
const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
|
38
|
+
killer.on('error', (error) => {
|
|
39
|
+
// 无监听者的 'error' 会成为未捕获异常;终止是尽力而为,留上下文日志即可。
|
|
40
|
+
console.warn(`[web-search-services] taskkill 失败(pid ${pid}): ${error.message}`)
|
|
41
|
+
})
|
|
42
|
+
killer.unref()
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
export function abortError(): Error {
|
|
25
46
|
const err = new Error('search aborted')
|
|
26
47
|
err.name = 'AbortError'
|
|
@@ -39,6 +60,8 @@ function tail(text: string, max = 500): string {
|
|
|
39
60
|
/** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
|
|
40
61
|
export function runProcess(opts: RunProcessOptions): Promise<string> {
|
|
41
62
|
const spawnFn = opts.spawnFn ?? spawn
|
|
63
|
+
const platform = opts.platform ?? process.platform
|
|
64
|
+
const killTree = opts.killTree ?? killWindowsTree
|
|
42
65
|
return new Promise<string>((resolve, reject) => {
|
|
43
66
|
if (opts.signal?.aborted === true) {
|
|
44
67
|
reject(abortError())
|
|
@@ -68,6 +91,12 @@ export function runProcess(opts: RunProcessOptions): Promise<string> {
|
|
|
68
91
|
fn()
|
|
69
92
|
}
|
|
70
93
|
const kill = (): void => {
|
|
94
|
+
if (platform === 'win32') {
|
|
95
|
+
// Windows 无 SIGTERM/SIGKILL 语义:taskkill /T /F 端掉整棵进程树。
|
|
96
|
+
if (child.pid !== undefined) killTree(child.pid)
|
|
97
|
+
else child.kill()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
71
100
|
child.kill('SIGTERM')
|
|
72
101
|
graceTimer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS)
|
|
73
102
|
graceTimer.unref()
|