@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/lib/client.js +1203 -178
- package/lib/index.d.ts +10 -83
- package/lib/index.js +16 -649
- package/package.json +15 -15
- package/src/client/card.tsx +130 -72
- package/src/client/client.ts +19 -7
- package/src/client/draft.ts +29 -89
- package/src/client/i18n.ts +109 -52
- package/src/client/styles.ts +10 -7
- package/src/config.ts +24 -74
- package/src/index.ts +29 -14
- package/scripts/README.md +0 -34
- package/scripts/context7.py +0 -259
- package/scripts/env_loader.py +0 -46
- package/scripts/exa.py +0 -251
- package/scripts/fetch.py +0 -938
- package/scripts/fetch_playwright.py +0 -525
- package/scripts/openai_chat.py +0 -570
- package/scripts/search.py +0 -962
- package/scripts/tavily.py +0 -609
- package/src/args.ts +0 -30
- package/src/env-file.ts +0 -49
- package/src/normalize.ts +0 -173
- package/src/provider.ts +0 -188
- package/src/python.ts +0 -193
- package/src/runner.ts +0 -140
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
|
-
}
|