@dsh-plus/web-search-services 0.1.20 → 0.2.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/src/normalize.ts DELETED
@@ -1,173 +0,0 @@
1
- /**
2
- * search.py 输出 → dsh-web 标准 WebSearchResult 归一化(纯函数)。
3
- *
4
- * skill 各服务 request() 统一信封 {ok, service, status, data: <API 原始响应>}:
5
- * tavily/exa 业务字段在 data 下(results/answer),openai-chat 回答在
6
- * data.answer。按 selectedService 归一;字段缺失降级而非报错(对 skill
7
- * 脚本版本漂移保持容差)。
8
- * @module web-search-services/normalize
9
- */
10
- import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web'
11
-
12
- type JsonObject = Record<string, unknown>
13
-
14
- function asObject(value: unknown): JsonObject {
15
- return typeof value === 'object' && value !== null && !Array.isArray(value)
16
- ? (value as JsonObject)
17
- : {}
18
- }
19
-
20
- function asNonEmptyString(value: unknown): string | undefined {
21
- return typeof value === 'string' && value.trim() !== '' ? value : undefined
22
- }
23
-
24
- function asArray(value: unknown): unknown[] {
25
- return Array.isArray(value) ? value : []
26
- }
27
-
28
- /** snippet 截断上限:官方 deepseek provider 的摘录是短引文;Tavily content /
29
- * Exa highlights 原始值可达整页文本,直接透传会撑爆工具结果上下文。 */
30
- const SNIPPET_MAX_CHARS = 500
31
-
32
- export function truncateSnippet(text: string): string {
33
- return text.length <= SNIPPET_MAX_CHARS ? text : `${text.slice(0, SNIPPET_MAX_CHARS)}…`
34
- }
35
-
36
- function makeSource(
37
- url: string,
38
- title?: string,
39
- snippet?: string,
40
- publishedAt?: string,
41
- ): WebSearchSource {
42
- return {
43
- url,
44
- ...(title !== undefined ? { title } : {}),
45
- ...(snippet !== undefined ? { snippet } : {}),
46
- ...(publishedAt !== undefined ? { publishedAt } : {}),
47
- }
48
- }
49
-
50
- /** skill 各服务 request() 统一信封 {ok, service, status, data: <API 响应>};
51
- * 取 data 为业务 payload,data 缺失/空对象时回退顶层(旧形状容差)。 */
52
- function payloadOf(raw: JsonObject): JsonObject {
53
- const data = asObject(raw.data)
54
- return Object.keys(data).length > 0 ? data : raw
55
- }
56
-
57
- function finish(content: string | undefined, sources: WebSearchSource[]): WebSearchResult {
58
- const seen = new Set<string>()
59
- const deduped = sources.filter((source) => {
60
- if (seen.has(source.url)) return false
61
- seen.add(source.url)
62
- return true
63
- })
64
- return {
65
- ...(content !== undefined ? { content } : {}),
66
- sources: deduped,
67
- truncated: false,
68
- }
69
- }
70
-
71
- function sourcesFromResults(
72
- results: unknown,
73
- pick: (item: JsonObject) => WebSearchSource | undefined,
74
- ): WebSearchSource[] {
75
- const out: WebSearchSource[] = []
76
- for (const entry of asArray(results)) {
77
- const source = pick(asObject(entry))
78
- if (source !== undefined) out.push(source)
79
- }
80
- return out
81
- }
82
-
83
- function normalizeTavily(raw: JsonObject): WebSearchResult {
84
- const payload = payloadOf(raw)
85
- const sources = sourcesFromResults(payload.results, (item) => {
86
- const url = asNonEmptyString(item.url)
87
- const content = asNonEmptyString(item.content)
88
- if (url === undefined) return undefined
89
- return makeSource(
90
- url,
91
- asNonEmptyString(item.title),
92
- content !== undefined ? truncateSnippet(content) : undefined,
93
- asNonEmptyString(item.published_date),
94
- )
95
- })
96
- return finish(asNonEmptyString(payload.answer), sources)
97
- }
98
-
99
- function normalizeExa(raw: JsonObject): WebSearchResult {
100
- const payload = payloadOf(raw)
101
- const sources = sourcesFromResults(payload.results, (item) => {
102
- const url = asNonEmptyString(item.url) ?? asNonEmptyString(item.id)
103
- if (url === undefined) return undefined
104
- const highlights = asArray(item.highlights)
105
- .map((h) => asNonEmptyString(h))
106
- .filter((h): h is string => h !== undefined)
107
- return makeSource(
108
- url,
109
- asNonEmptyString(item.title),
110
- highlights.length > 0 ? truncateSnippet(highlights.join(' ')) : undefined,
111
- asNonEmptyString(item.publishedDate),
112
- )
113
- })
114
- return finish(undefined, sources)
115
- }
116
-
117
- const MARKDOWN_LINK = /\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g
118
-
119
- /** 从生成式回答的 markdown 链接提取可引用来源(DeepSeek 官方 provider 同样只给
120
- * URL/标题)。无链接时返回空表——content-only 结果在 WebSearchResult 合法。 */
121
- export function extractMarkdownLinks(markdown: string, limit: number): WebSearchSource[] {
122
- const seen = new Set<string>()
123
- const out: WebSearchSource[] = []
124
- for (const match of markdown.matchAll(MARKDOWN_LINK)) {
125
- const url = match[2]
126
- if (url === undefined || seen.has(url)) continue
127
- seen.add(url)
128
- const label = match[1] ?? ''
129
- out.push(makeSource(url, label.trim() !== '' ? label : undefined))
130
- if (out.length >= limit) break
131
- }
132
- return out
133
- }
134
-
135
- function normalizeOpenAiChat(raw: JsonObject): WebSearchResult {
136
- const answer = asNonEmptyString(asObject(raw.data).answer)
137
- if (answer === undefined) {
138
- throw new Error('openai-chat 结果缺少 data.answer')
139
- }
140
- return finish(answer, extractMarkdownLinks(answer, 16))
141
- }
142
-
143
- function formatAttempts(attempts: unknown): string {
144
- const summary = asArray(attempts)
145
- .map((entry) => {
146
- const item = asObject(entry)
147
- const service = asNonEmptyString(item.service) ?? '?'
148
- const message = asNonEmptyString(item.error) ?? 'unknown'
149
- return `${service}: ${message}`
150
- })
151
- .join('; ')
152
- return summary === '' ? '' : `(尝试过: ${summary})`
153
- }
154
-
155
- /** 归一化 search.py 的完整 stdout JSON;失败以 Error 抛出(provider 层包 WebError)。 */
156
- export function normalizeSearchOutput(raw: unknown): WebSearchResult {
157
- const obj = asObject(raw)
158
- if (obj.ok === false) {
159
- const detail = asNonEmptyString(obj.error) ?? 'search.py 返回 ok=false'
160
- throw new Error(`${detail}${formatAttempts(obj.attempts)}`)
161
- }
162
- const service = asNonEmptyString(obj.selectedService) ?? asNonEmptyString(obj.service)
163
- switch (service) {
164
- case 'tavily':
165
- return normalizeTavily(obj)
166
- case 'exa':
167
- return normalizeExa(obj)
168
- case 'openai-chat':
169
- return normalizeOpenAiChat(obj)
170
- default:
171
- throw new Error(`search.py 返回未知或不支持的 selectedService: ${JSON.stringify(service)}`)
172
- }
173
- }
package/src/provider.ts DELETED
@@ -1,188 +0,0 @@
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 { fileURLToPath } from 'node:url'
9
-
10
- import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
11
- import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
12
- import { WebError } from '@deepseek-ai/dsh-web'
13
-
14
- import { buildSearchArgv } from './args.ts'
15
- import type { SearchBackend, WebSearchServicesConfig } from './config.ts'
16
- import { expandHome, loadEnvFile } from './env-file.ts'
17
- import { normalizeSearchOutput } from './normalize.ts'
18
- import {
19
- type PythonCandidate,
20
- type PythonDiscoveryEnv,
21
- pythonCandidates,
22
- realPythonFs,
23
- } from './python.ts'
24
- import { isAbortError, runProcess, type SpawnFn } from './runner.ts'
25
-
26
- export const PROVIDER_ID = 'search-services'
27
-
28
- /**
29
- * scriptPath 解析:空串 = 随包内置脚本副本(tgz 内 scripts/search.py,与 lib/
30
- * 同级;src 直跑时为包根 scripts/)。非空按 ~ 展开。
31
- */
32
- export function resolveScriptPath(scriptPath: string): string {
33
- if (scriptPath.trim() !== '') return expandHome(scriptPath)
34
- return fileURLToPath(new URL('../scripts/search.py', import.meta.url))
35
- }
36
-
37
- /** 各后端在合并环境(进程 env ⊕ envFile ⊕ 行级 keys)中的可用性判定变量。 */
38
- const BACKEND_KEY_VARS: Readonly<Record<SearchBackend, readonly string[]>> = {
39
- tavily: ['TAVILY_API_KEY', 'TAVILY_API_KEYS'],
40
- exa: ['EXA_API_KEY'],
41
- 'openai-chat': ['SEARCH_OPENAI_API_KEY'],
42
- }
43
-
44
- /** 行级 keys 覆盖 → 子进程环境变量;空串不注入(保持 envFile 单事实源优先)。 */
45
- export function keyOverridesEnv(keys: WebSearchServicesConfig['keys']): Record<string, string> {
46
- const out: Record<string, string> = {}
47
- if (keys.tavily !== '') out.TAVILY_API_KEY = keys.tavily
48
- if (keys.exa !== '') out.EXA_API_KEY = keys.exa
49
- if (keys.openaiApiKey !== '') out.SEARCH_OPENAI_API_KEY = keys.openaiApiKey
50
- if (keys.openaiBaseUrl !== '') out.SEARCH_OPENAI_BASE_URL = keys.openaiBaseUrl
51
- if (keys.openaiModel !== '') out.SEARCH_OPENAI_MODEL = keys.openaiModel
52
- return out
53
- }
54
-
55
- function messageOf(err: unknown): string {
56
- return err instanceof Error ? err.message : String(err)
57
- }
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
-
69
- function toWebError(err: unknown): WebError {
70
- if (err instanceof WebError) return err
71
- if (isAbortError(err)) return new WebError('搜索已取消', 'WEB_ABORTED', { cause: err })
72
- return new WebError(`search-services 搜索失败: ${messageOf(err)}`, 'WEB_PROVIDER_ERROR', {
73
- cause: err,
74
- })
75
- }
76
-
77
- export class SearchServicesProvider implements WebSearchProvider {
78
- readonly id = PROVIDER_ID
79
-
80
- private readonly current: () => WebSearchServicesConfig
81
- private readonly spawnFn: SpawnFn | undefined
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
- ) {
91
- this.current = current
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
- }))
101
- }
102
-
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
- */
119
- available(): boolean {
120
- const cfg = this.current()
121
- if (!existsSync(resolveScriptPath(cfg.scriptPath))) return false
122
- if (!this.candidates().some((candidate) => candidate.source !== 'fallback')) return false
123
- const env = this.childEnv(cfg)
124
- return cfg.priority.some((backend) =>
125
- BACKEND_KEY_VARS[backend].some((name) => (env[name]?.trim() ?? '') !== ''),
126
- )
127
- }
128
-
129
- async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
130
- const cfg = this.current()
131
- const argv = buildSearchArgv(cfg, request)
132
- const stdout = await this.run(cfg, argv, signal)
133
- return this.normalize(stdout)
134
- }
135
-
136
- private childEnv(cfg: WebSearchServicesConfig): NodeJS.ProcessEnv {
137
- const fromFile = cfg.envFile.trim() === '' ? {} : loadEnvFile(cfg.envFile)
138
- return { ...process.env, ...fromFile, ...keyOverridesEnv(cfg.keys) }
139
- }
140
-
141
- private async run(
142
- cfg: WebSearchServicesConfig,
143
- argv: string[],
144
- signal: AbortSignal | undefined,
145
- ): Promise<string> {
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
- }
164
- }
165
- throw toWebError(
166
- lastMissing ?? new Error('未找到可用的 python 解释器(配置 python 或安装 python3)'),
167
- )
168
- }
169
-
170
- private normalize(stdout: string): WebSearchResult {
171
- let raw: unknown
172
- try {
173
- raw = JSON.parse(stdout)
174
- } catch (err) {
175
- const preview = stdout.trim().slice(-200)
176
- throw new WebError(`search.py 输出非 JSON: …${preview}`, 'WEB_PROVIDER_ERROR', {
177
- cause: err,
178
- })
179
- }
180
- try {
181
- return normalizeSearchOutput(raw)
182
- } catch (err) {
183
- throw new WebError(`search.py 结果归一化失败: ${messageOf(err)}`, 'WEB_PROVIDER_ERROR', {
184
- cause: err,
185
- })
186
- }
187
- }
188
- }
package/src/python.ts DELETED
@@ -1,193 +0,0 @@
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 DELETED
@@ -1,140 +0,0 @@
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
- /** 进程树终止器(Windows 用 taskkill 注入面;测试可替换)。 */
11
- export type KillTreeFn = (pid: number) => void
12
-
13
- export interface RunProcessOptions {
14
- command: string
15
- args: readonly string[]
16
- env: NodeJS.ProcessEnv
17
- cwd?: string | undefined
18
- timeoutMs: number
19
- signal?: AbortSignal | undefined
20
- maxBufferBytes?: number | undefined
21
- spawnFn?: SpawnFn | undefined
22
- /** 平台(缺省 process.platform):win32 的超时/中止走进程树终止而非信号。 */
23
- platform?: NodeJS.Platform | undefined
24
- /** win32 进程树终止实现(缺省 taskkill /T /F)。 */
25
- killTree?: KillTreeFn | undefined
26
- }
27
-
28
- const DEFAULT_MAX_BUFFER = 8 * 1024 * 1024
29
- const KILL_GRACE_MS = 2_000
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
-
45
- export function abortError(): Error {
46
- const err = new Error('search aborted')
47
- err.name = 'AbortError'
48
- return err
49
- }
50
-
51
- export function isAbortError(err: unknown): boolean {
52
- return err instanceof Error && err.name === 'AbortError'
53
- }
54
-
55
- function tail(text: string, max = 500): string {
56
- const trimmed = text.trim()
57
- return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`
58
- }
59
-
60
- /** 运行子进程并返回完整 stdout;任何失败以 Error 拒绝(消息含 stderr/退出码上下文)。 */
61
- export function runProcess(opts: RunProcessOptions): Promise<string> {
62
- const spawnFn = opts.spawnFn ?? spawn
63
- const platform = opts.platform ?? process.platform
64
- const killTree = opts.killTree ?? killWindowsTree
65
- return new Promise<string>((resolve, reject) => {
66
- if (opts.signal?.aborted === true) {
67
- reject(abortError())
68
- return
69
- }
70
- const child = spawnFn(opts.command, [...opts.args], {
71
- env: opts.env,
72
- stdio: ['ignore', 'pipe', 'pipe'],
73
- ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
74
- })
75
-
76
- const maxBuffer = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER
77
- let stdout = ''
78
- let stderr = ''
79
- let settled = false
80
- let graceTimer: NodeJS.Timeout | undefined
81
-
82
- const cleanup = (): void => {
83
- clearTimeout(timer)
84
- if (graceTimer !== undefined) clearTimeout(graceTimer)
85
- opts.signal?.removeEventListener('abort', onAbort)
86
- }
87
- const settle = (fn: () => void): void => {
88
- if (settled) return
89
- settled = true
90
- cleanup()
91
- fn()
92
- }
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
- }
100
- child.kill('SIGTERM')
101
- graceTimer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS)
102
- graceTimer.unref()
103
- }
104
- const onAbort = (): void => {
105
- kill()
106
- settle(() => reject(abortError()))
107
- }
108
- const timer = setTimeout(() => {
109
- kill()
110
- settle(() => reject(new Error(`process timed out after ${opts.timeoutMs}ms`)))
111
- }, opts.timeoutMs)
112
- timer.unref()
113
- opts.signal?.addEventListener('abort', onAbort, { once: true })
114
-
115
- child.stdout?.on('data', (chunk: Buffer | string) => {
116
- stdout += chunk.toString()
117
- if (stdout.length > maxBuffer) {
118
- kill()
119
- settle(() => reject(new Error(`stdout exceeded ${maxBuffer} bytes`)))
120
- }
121
- })
122
- child.stderr?.on('data', (chunk: Buffer | string) => {
123
- stderr += chunk.toString()
124
- })
125
- child.on('error', (err) => {
126
- settle(() =>
127
- reject(new Error(`failed to start ${opts.command}: ${err.message}`, { cause: err })),
128
- )
129
- })
130
- child.on('close', (code) => {
131
- if (settled) return
132
- if (code === 0) {
133
- settle(() => resolve(stdout))
134
- return
135
- }
136
- const detail = tail(stderr) !== '' ? tail(stderr) : tail(stdout)
137
- settle(() => reject(new Error(`exited with code ${code ?? 'null'}: ${detail}`)))
138
- })
139
- })
140
- }