@ggboy123/dsh-paper-reader 1.3.4 → 1.4.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/dist/library.js CHANGED
@@ -6,9 +6,12 @@
6
6
  // <dataDir>/<专题>/<文献>.pages.json 页码偏移表(转录时生成,检索映射页码用)
7
7
  // pdf2zh 的产物(-en / -zh / -dual)不算用户文献。
8
8
  // (问答不落盘存档——会话持久化交给 dsh 自己;pdfqa 时代的 -qa/ 目录不再读写。)
9
- import { readdirSync, existsSync, mkdirSync, statSync, renameSync, rmSync } from 'node:fs';
10
- import { join, resolve, extname, basename } from 'node:path';
9
+ import { readdirSync, existsSync, mkdirSync, statSync, realpathSync, lstatSync } from 'node:fs';
10
+ import { join, resolve, extname, basename, sep } from 'node:path';
11
11
  import { homedir } from 'node:os';
12
+ // 破坏性原语与回收站实现(我们的加固层):适配器只做「复用 #4 边界层 + 翻译签名/返回形状」,
13
+ // 真正的删除/改名永远只有 library-manage 一份实现。
14
+ import * as manage from "./library-manage.js";
12
15
  const PDF2ZH_SUFFIXES = ['-en', '-zh', '-dual'];
13
16
  /** 判定是否为用户文献:排除 pdf2zh 的中间/产出文件。 */
14
17
  export function isPaperPDF(filename) {
@@ -62,6 +65,9 @@ function refFor(topic, name, pdfPath) {
62
65
  pdfPath,
63
66
  txtPath: base + '.txt',
64
67
  pagesPath: base + '.pages.json',
68
+ transcriptPath: base + '.transcript.json',
69
+ mineruMdPath: base + '.mineru.md',
70
+ mineruJsonPath: base + '.mineru.json',
65
71
  };
66
72
  }
67
73
  /**
@@ -101,7 +107,23 @@ export function transcriptCached(ref) {
101
107
  return false;
102
108
  }
103
109
  }
104
- // ================= 库管理:改名/删除(纯文件系统操作,供 host 路由调用) =================
110
+ // ═══════════════════════════════════════════════════════════════════════════
111
+ // 边界校验层(词法 + realpath 子路径双层)—— **逐字复用上游 #4**
112
+ // (d8f24ec《fix(security):路径穿越修复》)的实现。
113
+ //
114
+ // 准确口径(评审 F-F,回复上游时按这个说,别写成「唯一实现」):
115
+ // · **共享的是 4 个原语**:`validateEntryName` / `validateEntryBoundary` /
116
+ // `realDataDir` / `assertInside`(其中后两个只多了 `export`,供 library-manage 消费);
117
+ // · host.ts 的路由层消费 `validateEntryName` / `validateEntryBoundary`(badEntry 词法门)
118
+ // 与 `resolveUploadTopic`(upload 的 realpath 层);
119
+ // · **组合层两套并存**:生产写路径走 library-manage 的 `resolveTopicDirChecked`
120
+ // (在其上更严:符号链接专题一律拒、`real === join(root, topic)`、`target === root` 拒),
121
+ // #4 的 `resolveSafeTopic` 组合层只被本文件下方的**适配器**调用,而那些适配器只服务
122
+ // 上游那 6 个 `library-*.test.mjs` 与老调用方。
123
+ // PR #5 整合时我们曾用自己那套「整段替换」掉这一层,代价是丢掉 #4 的两处覆盖
124
+ // (悬空符号链接的写路径、trim 语义)与上游 35 个用例中的 29 个;按上游评审 A.1
125
+ // 「复用 #4 的边界校验层,在其上加回收站与交互层」的要求,原语层照抄、加严叠在其上。
126
+ // ═══════════════════════════════════════════════════════════════════════════
105
127
  /** 名字校验(专题/文献共用)。合法返回 null,否则返回中文错误文案。 */
106
128
  export function validateEntryName(raw) {
107
129
  const name = raw.trim();
@@ -111,84 +133,180 @@ export function validateEntryName(raw) {
111
133
  return '名字过长(最多 120 个字符)';
112
134
  if (/[\\/]|\.\./.test(name))
113
135
  return '名字不能包含 / \\ 或 ..';
136
+ if (/[\u0000-\u001f\u007f]/.test(name))
137
+ return '名字不能包含控制字符';
114
138
  if (name.startsWith('.'))
115
139
  return '名字不能以 . 开头';
116
140
  return null;
117
141
  }
142
+ /**
143
+ * 边界类名字校验(删除/改名-from 用):只挡安全边界——空、分隔符/..、NUL。
144
+ * 与 validateEntryName 的分工:命名规范类(长度上限、前导点、非 NUL 控制字符)只在创建/改名时
145
+ * 强制(那是用户在起名字);删除是对已存在文件的操作——upload 能创建的名字(前导点、超长、
146
+ * \x01 等控制字符)必须能删,否则会成为无法清理的孤儿。NUL 必须挡:它会让 Node fs 抛
147
+ * ERR_INVALID_ARG_VALUE 并把绝对路径带进错误文案(错误回显卫生)。
148
+ */
149
+ export function validateEntryBoundary(raw) {
150
+ const name = raw.trim();
151
+ if (!name)
152
+ return '名字不能为空';
153
+ if (/[\\/]|\.\./.test(name))
154
+ return '名字不能包含 / \\ 或 ..';
155
+ if (name.includes('\u0000'))
156
+ return '名字不能包含控制字符';
157
+ return null;
158
+ }
159
+ // ================= 路径穿越防护(词法 + realpath 子路径双层) =================
160
+ // 这些 CRUD 原语以前只对 `to` 做过校验,`topic`/`name`/`from` 直接 `join(dataDir, x)`:
161
+ // `../sentinel` 这类输入会让 rmSync/renameSync 作用于 dataDir 之外的目录(可递归强制删除)。
162
+ // 这里补两层结构性防护:① 词法层(validateEntryName 拒绝 / \ .. 与隐藏名);② realpath
163
+ // 层(目标目录的 real 路径必须落在 dataDir 的 real 路径之内,挡住 `..` 之外的符号链接逃逸)。
164
+ /** dataDir 的 real 绝对路径(resolve + realpath,去符号链接与 `..`)。目录缺失时给无路径文案。 */
165
+ export function realDataDir(dataDir) {
166
+ const abs = resolve(dataDir);
167
+ if (!existsSync(abs))
168
+ throw new Error('文献库数据目录不存在');
169
+ return realpathSync(abs);
170
+ }
171
+ /** target 必须落在 root 之内(严格子路径,不含 root 本身),否则视为越界。 */
172
+ export function assertInside(root, target) {
173
+ const r = root.endsWith(sep) ? root : root + sep;
174
+ if (target !== root && !target.startsWith(r))
175
+ throw new Error('路径越界,已拒绝');
176
+ }
177
+ /**
178
+ * 安全解析专题目录:词法校验 + realpath 严格子路径包含。
179
+ * 返回可直接 rename/delete 的目录路径(join 后的路径,非 realpath——顶层符号链接对
180
+ * rmSync/renameSync 只操作链接本身,不跟随,语义保持不变)。任何调用方(不只路由)
181
+ * 传入越界 topic 都会被拒:`..`、绝对路径、`a/../b`、指向库外的符号链接等。
182
+ */
183
+ function resolveSafeTopic(dataDir, topic) {
184
+ const err = validateEntryName(topic);
185
+ if (err)
186
+ throw new Error(err);
187
+ const root = realDataDir(dataDir);
188
+ const dir = join(root, topic.trim());
189
+ if (!existsSync(dir))
190
+ throw new Error(`专题不存在: ${topic.trim()}`);
191
+ let real;
192
+ try {
193
+ real = realpathSync(dir);
194
+ }
195
+ catch {
196
+ throw new Error(`专题不存在: ${topic.trim()}`);
197
+ }
198
+ assertInside(root, real);
199
+ return dir;
200
+ }
201
+ /**
202
+ * 安全解析「待创建」的专题目录(upload 用):词法校验 + realpath 严格子路径包含。
203
+ * 与 resolveSafeTopic 不同:专题可以尚不存在(upload 会 mkdir 创建),但已存在的条目
204
+ * (目录/文件/符号链接)仍必须 realpath 落在 dataDir 之内——挡住「data/evil → 库外」这类
205
+ * 符号链接逃逸写。返回可直接 mkdir/writeFile 的目录路径(join 后的路径,非 realpath)。
206
+ */
207
+ export function resolveUploadTopic(dataDir, topic) {
208
+ const err = validateEntryName(topic);
209
+ if (err)
210
+ throw new Error(err);
211
+ const root = realDataDir(dataDir);
212
+ const dir = join(root, topic.trim());
213
+ let exists = false;
214
+ try {
215
+ lstatSync(dir); // lstat 不跟随符号链接:悬空链接也算「已存在」,交给 realpath 兜底
216
+ exists = true;
217
+ }
218
+ catch {
219
+ exists = false;
220
+ }
221
+ if (!exists)
222
+ return dir; // 尚不存在:mkdir(recursive) 会创建真实目录(词法层已保证无分隔符/../隐藏名)
223
+ let real;
224
+ try {
225
+ real = realpathSync(dir);
226
+ }
227
+ catch {
228
+ throw new Error(`专题不存在: ${topic.trim()}`);
229
+ }
230
+ assertInside(root, real);
231
+ return dir;
232
+ }
118
233
  /** 构造文献引用(不做存在性校验;给只按路径判定的调用方用,如翻译 busy 检查)。 */
119
234
  export function paperRefFor(dataDir, topic, name) {
120
235
  const pdfPath = join(dataDir, topic, name + '.pdf');
121
236
  const base = pdfPath.slice(0, -'.pdf'.length);
122
- return { topic, name, pdfPath, txtPath: base + '.txt', pagesPath: base + '.pages.json' };
123
- }
124
- /** 文献的全部关联文件后缀(含本体 .pdf):转录缓存 + 页码索引 + babeldoc 译文 + 学习档案。
125
- * 命名与 translate.ts(-zh/-dual)和 study.ts(.study.json)的产物规则一致。 */
126
- const PAPER_SIDECARS = ['.pdf', '.txt', '.pages.json', '-zh.pdf', '-dual.pdf', '.study.json'];
127
- /** 某文献的全部关联文件路径(存在与否由调用方自查)。 */
128
- export function derivedFilesFor(pdfPath) {
129
- const stem = pdfPath.slice(0, -'.pdf'.length);
130
- return PAPER_SIDECARS.map((s) => stem + s);
131
- }
132
- /** 改名专题(目录 rename)。from 不存在 / to 已存在 / 名字非法 → throw 中文 Error。
133
- * 已知局限:大小写不敏感文件系统(macOS/Windows)上纯大小写改名会被「目标已存在」
134
- * 拒绝——不做临时名两跳绕过,保持简单。 */
237
+ return {
238
+ topic,
239
+ name,
240
+ pdfPath,
241
+ txtPath: base + '.txt',
242
+ pagesPath: base + '.pages.json',
243
+ transcriptPath: base + '.transcript.json',
244
+ mineruMdPath: base + '.mineru.md',
245
+ mineruJsonPath: base + '.mineru.json',
246
+ };
247
+ }
248
+ // ═══════════════════════════════════════════════════════════════════════════
249
+ // 兼容适配器(薄壳):只为上游 6 个 `library-*.test.mjs` 与老调用方保留上游的
250
+ // 函数签名 / 返回形状;底层永远是 library-manage 的加固实现
251
+ // (回收站可恢复 + 精确 stem + 非递归 rmdir;会话/翻译/在途 409 在路由层)。
252
+ // 每个适配器都**先过上面那一层**(词法 validateEntryName/validateEntryBoundary →
253
+ // realpath resolveSafeTopic),再交给加固层——顺序固定,两层的语义不互相替代。
254
+ // ═══════════════════════════════════════════════════════════════════════════
255
+ /** 边界层错误 → 带机器码的普通 Error(保留上游用例断言的 `e.message`)。 */
256
+ function boundaryError(message) {
257
+ const err = new Error(message);
258
+ err.code = message.includes('路径越界') ? 'escape-rejected' : 'bad-name';
259
+ return err;
260
+ }
261
+ /** 删除文献(上游签名):词法-边界层 + realpath 层 → 加固层移入 `.trash/`;返回被移动的文件名清单。 */
262
+ export function deletePaper(dataDir, topic, name) {
263
+ const errName = validateEntryBoundary(name);
264
+ if (errName)
265
+ throw boundaryError(errName);
266
+ const topicName = topic.trim();
267
+ resolveSafeTopic(dataDir, topicName); // realpath 第二层:专题必须落在 dataDir 内
268
+ return manage.deletePaper(dataDir, topicName, name).moved;
269
+ }
270
+ /** 删除专题(上游签名):仅空专题;隐藏条目进回收站;非递归 rmdir(绝无 rm -rf)。 */
271
+ export function deleteTopic(dataDir, topic) {
272
+ const err = validateEntryName(topic);
273
+ if (err)
274
+ throw boundaryError(err);
275
+ const topicName = topic.trim();
276
+ resolveSafeTopic(dataDir, topicName);
277
+ manage.deleteTopic(dataDir, topicName); // 非空 → 抛 409 topic-not-empty(我们的产品决策)
278
+ }
279
+ /** 重命名专题(上游签名):`to` 走命名规范层,`from` 走 realpath 层。 */
135
280
  export function renameTopic(dataDir, from, to) {
136
281
  const err = validateEntryName(to);
137
282
  if (err)
138
- throw new Error(err);
139
- if (to.trim() === from)
283
+ throw boundaryError(err);
284
+ const fromName = from.trim();
285
+ const toName = to.trim();
286
+ if (fromName === toName)
140
287
  return;
141
- const src = join(dataDir, from);
142
- if (!existsSync(src))
143
- throw new Error(`专题不存在: ${from}`);
144
- const dst = join(dataDir, to.trim());
145
- if (existsSync(dst))
146
- throw new Error(`已存在同名专题: ${to.trim()}`);
147
- renameSync(src, dst);
148
- }
149
- /** 改名文献:连带派生文件(转录/页码索引/译文/学习档案)逐个跟随,不存在的跳过。
150
- * 返回实际改名的文件名清单(含 .pdf 本体)。 */
288
+ resolveSafeTopic(dataDir, fromName);
289
+ manage.renameTopic(dataDir, fromName, toName);
290
+ }
291
+ /**
292
+ * 重命名文献(上游签名):返回**改名前的**文件名清单(上游用例据此断言,如 `['CaseA.pdf']`)。
293
+ * `from` 走边界层(历史丑名也能改),`to` 走命名规范层;产物清单由加固层的精确 stem 匹配给出。
294
+ */
151
295
  export function renamePaper(dataDir, topic, from, to) {
152
- const err = validateEntryName(to);
153
- if (err)
154
- throw new Error(err);
155
- to = to.trim();
156
- if (to === from)
296
+ const errTo = validateEntryName(to);
297
+ if (errTo)
298
+ throw boundaryError(errTo);
299
+ const errFrom = validateEntryBoundary(from);
300
+ if (errFrom)
301
+ throw boundaryError(errFrom);
302
+ const topicName = topic.trim();
303
+ const fromName = from.trim();
304
+ const toName = to.trim();
305
+ if (fromName === toName)
157
306
  return [];
158
- const dir = join(dataDir, topic);
159
- if (!existsSync(join(dir, from + '.pdf')))
160
- throw new Error(`文献不存在: ${topic}/${from}`);
161
- if (existsSync(join(dir, to + '.pdf')))
162
- throw new Error(`已存在同名文献: ${to}`);
163
- const renamed = [];
164
- for (const suf of PAPER_SIDECARS) {
165
- const src = join(dir, from + suf);
166
- if (!existsSync(src))
167
- continue;
168
- renameSync(src, join(dir, to + suf));
169
- renamed.push(from + suf);
170
- }
171
- return renamed;
172
- }
173
- /** 删除文献及其全部派生文件。返回实际删除的文件名清单。 */
174
- export function deletePaper(dataDir, topic, name) {
175
- const dir = join(dataDir, topic);
176
- const pdfPath = join(dir, name + '.pdf');
177
- if (!existsSync(pdfPath))
178
- throw new Error(`文献不存在: ${topic}/${name}`);
179
- const deleted = [];
180
- for (const f of derivedFilesFor(pdfPath)) {
181
- if (!existsSync(f))
182
- continue;
183
- rmSync(f);
184
- deleted.push(basename(f));
185
- }
186
- return deleted;
187
- }
188
- /** 删除专题(递归,含其下全部文献与派生文件)。 */
189
- export function deleteTopic(dataDir, topic) {
190
- const dir = join(dataDir, topic);
191
- if (!existsSync(dir))
192
- throw new Error(`专题不存在: ${topic}`);
193
- rmSync(dir, { recursive: true, force: true });
307
+ resolveSafeTopic(dataDir, topicName);
308
+ const topicDir = manage.resolveExistingTopicDir(dataDir, topicName);
309
+ const before = manage.listArtifacts(topicDir, fromName).map((f) => f.name);
310
+ manage.renamePaper(dataDir, topicName, fromName, toName);
311
+ return before;
194
312
  }
@@ -0,0 +1,343 @@
1
+ // mineru-config.ts — MinerU 解析后端的配置落盘与解析(纯函数,不依赖 Cordis)。
2
+ //
3
+ // 存 $DSH_HOME/.dsh-paper-reader/mineru.json(0600)——与 translate/typesafe 同一套约定:
4
+ // dataDir 是文献库目录,可能被 git / 云盘同步,密钥不该跟着走。
5
+ // 优先级(逐字段):文件 > profile 的 cordis.patch.yml config.mineru > 环境变量 > 默认值。
6
+ // 环境变量只冻结两个:MINERU_API_KEY(cloud.apiKey)、DSH_MINERU_LOCAL_URL(local.baseUrl)。
7
+ // 密钥只落盘、只回显掩码:任何读接口都不返回明文(apiKeyHint 是掩码后的展示串)。
8
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
9
+ import { dirname, join } from 'node:path';
10
+ import { dshHome } from "./library.js";
11
+ import { maskApiKey } from "./translate-config.js";
12
+ // 预检会把响应体片段拼进错误文案,必须先脱敏(requirements §9.6 / 二轮评审 F-R2-1)。
13
+ // mineru.ts 对 mineru-config.ts 只有 type-only import(运行时无环),这里做值导入是安全的。
14
+ import { sanitizeDetail } from "./mineru.js";
15
+ export { maskApiKey };
16
+ /** 冻结的默认值(§9.2)。mode 默认 off,保证未配置时零行为变化。 */
17
+ export const MINERU_DEFAULTS = {
18
+ mode: 'off',
19
+ local: {
20
+ baseUrl: 'http://127.0.0.1:8000',
21
+ apiKey: '',
22
+ backend: 'pipeline',
23
+ effort: 'medium',
24
+ parseMethod: 'auto',
25
+ serverUrl: '',
26
+ langList: ['ch'],
27
+ imageAnalysis: false,
28
+ requestTimeoutMs: 60_000,
29
+ pollIntervalMs: 1500,
30
+ noResponseTimeoutMs: 600_000,
31
+ jobTimeoutMs: 1_800_000,
32
+ },
33
+ cloud: {
34
+ baseUrl: 'https://mineru.net/api/v4',
35
+ apiKey: '',
36
+ modelVersion: 'pipeline',
37
+ pollIntervalMs: 3000,
38
+ zipTimeoutMs: 300_000,
39
+ },
40
+ };
41
+ const MODES = ['off', 'local', 'cloud'];
42
+ const BACKENDS = ['pipeline', 'vlm-engine', 'hybrid-engine', 'vlm-http-client', 'hybrid-http-client'];
43
+ const CLOUD_MODELS = ['pipeline', 'vlm'];
44
+ const PARSE_METHODS = ['auto', 'txt', 'ocr'];
45
+ const LANGS = ['ch', 'ch_server', 'korean', 'ta', 'te', 'ka', 'th', 'el', 'arabic', 'east_slavic', 'cyrillic', 'devanagari'];
46
+ export function mineruConfigPath(home = dshHome()) {
47
+ return join(home, '.dsh-paper-reader', 'mineru.json');
48
+ }
49
+ /** baseUrl 规范化:http(s)://origin + path(去尾斜杠),非法/为空回落到默认值(照抄参考实现)。 */
50
+ export function normalizeLocalBaseUrl(value) {
51
+ const raw = typeof value === 'string' ? value.trim() : '';
52
+ if (!raw)
53
+ return MINERU_DEFAULTS.local.baseUrl;
54
+ try {
55
+ const url = new URL(raw);
56
+ if ((url.protocol === 'http:' || url.protocol === 'https:') && url.host) {
57
+ const path = url.pathname.replace(/\/+$/, '');
58
+ return `${url.origin}${path}`;
59
+ }
60
+ }
61
+ catch { /* 非法 URL 回落到默认值 */ }
62
+ return MINERU_DEFAULTS.local.baseUrl;
63
+ }
64
+ function str(v, fallback) {
65
+ return typeof v === 'string' ? v : fallback;
66
+ }
67
+ function num(v, fallback) {
68
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback;
69
+ }
70
+ function bool(v, fallback) {
71
+ return typeof v === 'boolean' ? v : fallback;
72
+ }
73
+ function strArr(v, fallback) {
74
+ if (!Array.isArray(v))
75
+ return fallback;
76
+ const out = v.filter((x) => typeof x === 'string');
77
+ return out.length ? out : fallback;
78
+ }
79
+ function modeOf(v) {
80
+ return MODES.includes(v) ? v : MINERU_DEFAULTS.mode;
81
+ }
82
+ function backendOf(v) {
83
+ return BACKENDS.includes(v) ? v : MINERU_DEFAULTS.local.backend;
84
+ }
85
+ function cloudModelOf(v) {
86
+ return CLOUD_MODELS.includes(v) ? v : MINERU_DEFAULTS.cloud.modelVersion;
87
+ }
88
+ function parseMethodOf(v) {
89
+ return PARSE_METHODS.includes(v) ? v : MINERU_DEFAULTS.local.parseMethod;
90
+ }
91
+ function langOf(v) {
92
+ const arr = strArr(v, []);
93
+ return arr.every((l) => LANGS.includes(l)) ? arr : MINERU_DEFAULTS.local.langList;
94
+ }
95
+ function isRecord(v) {
96
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
97
+ }
98
+ function applyLocal(rec) {
99
+ const d = MINERU_DEFAULTS.local;
100
+ if (!rec)
101
+ return { ...d };
102
+ return {
103
+ baseUrl: normalizeLocalBaseUrl(rec['baseUrl'] ?? d.baseUrl),
104
+ apiKey: str(rec['apiKey'], d.apiKey),
105
+ backend: backendOf(rec['backend']),
106
+ effort: rec['effort'] === 'high' ? 'high' : d.effort,
107
+ parseMethod: parseMethodOf(rec['parseMethod']),
108
+ serverUrl: str(rec['serverUrl'], d.serverUrl),
109
+ langList: langOf(rec['langList']),
110
+ imageAnalysis: bool(rec['imageAnalysis'], d.imageAnalysis),
111
+ requestTimeoutMs: num(rec['requestTimeoutMs'], d.requestTimeoutMs),
112
+ pollIntervalMs: num(rec['pollIntervalMs'], d.pollIntervalMs),
113
+ noResponseTimeoutMs: num(rec['noResponseTimeoutMs'], d.noResponseTimeoutMs),
114
+ jobTimeoutMs: num(rec['jobTimeoutMs'], d.jobTimeoutMs),
115
+ };
116
+ }
117
+ function applyCloud(rec) {
118
+ const d = MINERU_DEFAULTS.cloud;
119
+ if (!rec)
120
+ return { ...d };
121
+ return {
122
+ baseUrl: str(rec['baseUrl'], d.baseUrl),
123
+ apiKey: str(rec['apiKey'], d.apiKey),
124
+ modelVersion: cloudModelOf(rec['modelVersion']),
125
+ pollIntervalMs: num(rec['pollIntervalMs'], d.pollIntervalMs),
126
+ zipTimeoutMs: num(rec['zipTimeoutMs'], d.zipTimeoutMs),
127
+ };
128
+ }
129
+ /** 从原始 JSON 解析出 MineruConfig;没有任何已识别字段时返回 null。 */
130
+ function configFromRecord(data) {
131
+ if (!isRecord(data))
132
+ return null;
133
+ const rec = data;
134
+ const local = isRecord(rec['local']) ? rec['local'] : undefined;
135
+ const cloud = isRecord(rec['cloud']) ? rec['cloud'] : undefined;
136
+ const hasAny = 'mode' in rec || local !== undefined || cloud !== undefined;
137
+ if (!hasAny)
138
+ return null;
139
+ return {
140
+ mode: modeOf(rec['mode']),
141
+ local: applyLocal(local),
142
+ cloud: applyCloud(cloud),
143
+ };
144
+ }
145
+ /** 读配置文件原始 JSON(不做默认值填充);文件缺失/损坏/非对象一律返回 null。 */
146
+ export async function readMineruConfigRecord(home = dshHome()) {
147
+ try {
148
+ const data = JSON.parse(await readFile(mineruConfigPath(home), 'utf8'));
149
+ return isRecord(data) ? data : null;
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
155
+ /** 读配置;文件缺失/损坏/形状不对一律返回 null(与 translate-config 的读策略一致)。 */
156
+ export async function readMineruConfig(home = dshHome()) {
157
+ return configFromRecord(await readMineruConfigRecord(home));
158
+ }
159
+ /** 写配置。0600:文件含明文密钥,不靠 umask。 */
160
+ export async function writeMineruConfig(cfg, home = dshHome()) {
161
+ const file = mineruConfigPath(home);
162
+ await mkdir(dirname(file), { recursive: true });
163
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
164
+ await chmod(file, 0o600); // mode 只在新建时生效;已存在的文件要显式收紧
165
+ }
166
+ /** 删除配置(回落到 profile / 环境变量)。文件不存在也算成功。 */
167
+ export async function clearMineruConfig(home = dshHome()) {
168
+ await rm(mineruConfigPath(home), { force: true });
169
+ }
170
+ /** 逐字段合并多个 record:后面的覆盖前面的,只有「定义了」的字段才覆盖;嵌套对象浅合并。 */
171
+ function mergeRecords(...sources) {
172
+ const out = {};
173
+ for (const src of sources) {
174
+ if (!src)
175
+ continue;
176
+ for (const [k, v] of Object.entries(src)) {
177
+ if (v === undefined)
178
+ continue;
179
+ if (isRecord(v) && isRecord(out[k])) {
180
+ out[k] = mergeRecords(out[k], v);
181
+ }
182
+ else {
183
+ out[k] = v;
184
+ }
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+ /** profile 是否提供了任一 mineru 字段(判定 source 用,§10.3)。 */
190
+ function profileHasFields(profile) {
191
+ if (!isRecord(profile))
192
+ return false;
193
+ return 'mode' in profile || 'local' in profile || 'cloud' in profile;
194
+ }
195
+ /** 解析生效配置:逐字段 file > profile > env > 默认;source 按 §10.3 判定。 */
196
+ export async function resolveMineruConfig(profile, home = dshHome(), env = process.env) {
197
+ const file = await readMineruConfigRecord(home);
198
+ // 只有文件里含「已识别的 mineru 字段」才算 file 来源;一个空对象或无关字段的
199
+ // JSON 不应把来源标成 file(§10.3:文件存在**且含任一 mineru 字段** → file)。
200
+ const fileRecognized = configFromRecord(file) !== null;
201
+ const envKey = env['MINERU_API_KEY']?.trim();
202
+ const envLocalUrl = env['DSH_MINERU_LOCAL_URL']?.trim();
203
+ // 环境变量层:只冻结两个变量,命中才参与合并(§9.4)
204
+ const envLocal = {};
205
+ if (envLocalUrl)
206
+ envLocal['baseUrl'] = envLocalUrl;
207
+ const envCloud = {};
208
+ if (envKey)
209
+ envCloud['apiKey'] = envKey;
210
+ const envRec = {};
211
+ if (Object.keys(envLocal).length)
212
+ envRec['local'] = envLocal;
213
+ if (Object.keys(envCloud).length)
214
+ envRec['cloud'] = envCloud;
215
+ // 合并顺序(后面的覆盖前面的):默认 ← env ← profile ← file(§9.5 优先级)
216
+ const merged = mergeRecords(envRec, profile, file);
217
+ const config = configFromRecord(merged) ?? configFromRecord({ mode: MINERU_DEFAULTS.mode });
218
+ const source = fileRecognized
219
+ ? 'file'
220
+ : profileHasFields(profile)
221
+ ? 'profile'
222
+ : (envKey || envLocalUrl) ? 'env' : 'none';
223
+ return { config, source };
224
+ }
225
+ /** 连通性预检:GET {baseUrl}/health(超时 5s)。判据与 §2.1 一致。 */
226
+ export async function testMineruLocal(baseUrl, timeoutMs = 5000) {
227
+ const base = normalizeLocalBaseUrl(baseUrl);
228
+ try {
229
+ const r = await fetch(`${base}/health`, { signal: AbortSignal.timeout(timeoutMs) });
230
+ const text = (await r.text().catch(() => '')).slice(0, 2000);
231
+ let data = null;
232
+ try {
233
+ data = JSON.parse(text);
234
+ }
235
+ catch { /* 非 JSON 走失败 */ }
236
+ if (!r.ok) {
237
+ // 响应体片段先脱敏再拼入(同 F-R2-1 的处理;此处不发 apiKey,属统一的模式加固)
238
+ return { ok: false, detail: `HTTP ${r.status}${text ? ' ' + sanitizeDetail(text.slice(0, 120)) : ''}` };
239
+ }
240
+ if (!isRecord(data)) {
241
+ return { ok: false, detail: '响应不是 JSON' };
242
+ }
243
+ if (!['healthy', 'ok'].includes(String(data['status'])) || typeof data['version'] !== 'string') {
244
+ return { ok: false, detail: '不是已识别的 MinerU API 服务(status/version 缺失)' };
245
+ }
246
+ return { ok: true, version: data['version'], health: data };
247
+ }
248
+ catch (err) {
249
+ const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '';
250
+ return { ok: false, detail: (err instanceof Error ? err.message : String(err)) + cause };
251
+ }
252
+ }
253
+ /**
254
+ * 云端 token 失效类业务码。mineru.net 可能以 **HTTP 200 + body.code** 的形式返回 token 错误,
255
+ * 只判 401/403 会把坏 token 当「连通且有效」放行(评审 F1)。判据见 requirements §9.7:
256
+ * 「401/403 / code A0202/A0211 → 报 token 错」。
257
+ * 刻意**不**把 -60012/-60013 等「批次/任务不存在或无权」类码算进来——预检打的就是一个不存在的
258
+ * 批次(`_test`),那类码是预期内的成功信号。
259
+ */
260
+ const CLOUD_TOKEN_ERROR_CODES = new Set(['A0202', 'A0211']);
261
+ /** 云端预检:GET {baseUrl}/extract-results/batch/_test(故意不存在的批次 id)。 */
262
+ export async function testMineruCloud(baseUrl, apiKey, timeoutMs = 15_000) {
263
+ const base = baseUrl.replace(/\/+$/, '') || MINERU_DEFAULTS.cloud.baseUrl;
264
+ if (!apiKey)
265
+ return { ok: false, detail: '云端 API Key 不能为空' };
266
+ try {
267
+ const r = await fetch(`${base}/extract-results/batch/_test`, {
268
+ headers: { authorization: `Bearer ${apiKey}` },
269
+ signal: AbortSignal.timeout(timeoutMs),
270
+ });
271
+ if (r.status === 401 || r.status === 403) {
272
+ return { ok: false, detail: `HTTP ${r.status}:云端 token 无效或已过期(请检查 mineru.net 的 API Key)` };
273
+ }
274
+ const text = (await r.text().catch(() => '')).slice(0, 2000);
275
+ let body;
276
+ try {
277
+ body = JSON.parse(text);
278
+ }
279
+ catch {
280
+ // 非 JSON 响应体可能回显 Authorization/错误页:先按本次 key 脱敏再截断拼入(F-R2-1)
281
+ return { ok: false, detail: `响应不是 JSON(HTTP ${r.status}):${sanitizeDetail(text.slice(0, 120), apiKey)}` };
282
+ }
283
+ // HTTP 200 也可能是 token 失效(业务码判据,§9.7 / 评审 F1):不查 body.code 会「假绿灯」落盘坏 token
284
+ const code = isRecord(body) ? String(body['code'] ?? '').trim().toUpperCase() : '';
285
+ if (CLOUD_TOKEN_ERROR_CODES.has(code)) {
286
+ return { ok: false, detail: `云端 token 无效或已过期(业务码 ${code},请检查 mineru.net 的 API Key)` };
287
+ }
288
+ // 能拿到 JSON 响应(含业务级「批次不存在」)即视为「连通 + token 有效」(§9.7)。
289
+ return { ok: true };
290
+ }
291
+ catch (err) {
292
+ const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '';
293
+ return { ok: false, detail: (err instanceof Error ? err.message : String(err)) + cause };
294
+ }
295
+ }
296
+ /** 按生效 mode 分发预检。mode=off 无需预检直接通过。 */
297
+ export async function precheckMineruConfig(config) {
298
+ if (config.mode === 'off')
299
+ return { ok: true };
300
+ if (config.mode === 'local') {
301
+ const t = await testMineruLocal(config.local.baseUrl);
302
+ return t.ok ? { ok: true } : { ok: false, detail: `本地 MinerU 连接测试失败:${t.detail ?? '未知原因'}` };
303
+ }
304
+ const t = await testMineruCloud(config.cloud.baseUrl, config.cloud.apiKey);
305
+ return t.ok ? { ok: true } : { ok: false, detail: `云端 MinerU 连接测试失败:${t.detail ?? '未知原因'}` };
306
+ }
307
+ /** 把设置面板 POST 的 body 合并进已有生效配置:apiKey 留空沿用已存值;非法枚举回落已有值;超时等不暴露字段保持原样。 */
308
+ export function mergeMineruBody(prev, body) {
309
+ const rec = isRecord(body) ? body : {};
310
+ const localRec = isRecord(rec['local']) ? rec['local'] : {};
311
+ const cloudRec = isRecord(rec['cloud']) ? rec['cloud'] : {};
312
+ const localBaseUrl = typeof localRec['baseUrl'] === 'string' && localRec['baseUrl'].trim()
313
+ ? normalizeLocalBaseUrl(localRec['baseUrl'])
314
+ : prev.local.baseUrl;
315
+ const localApiKey = typeof localRec['apiKey'] === 'string' ? localRec['apiKey'].trim() : '';
316
+ const cloudBaseUrlRaw = typeof cloudRec['baseUrl'] === 'string' ? cloudRec['baseUrl'].trim() : '';
317
+ const cloudBaseUrl = cloudBaseUrlRaw || prev.cloud.baseUrl;
318
+ const cloudApiKey = typeof cloudRec['apiKey'] === 'string' ? cloudRec['apiKey'].trim() : '';
319
+ return {
320
+ mode: modeOf(rec['mode'] ?? prev.mode),
321
+ local: {
322
+ baseUrl: localBaseUrl,
323
+ apiKey: localApiKey || prev.local.apiKey,
324
+ backend: backendOf(localRec['backend'] ?? prev.local.backend),
325
+ effort: localRec['effort'] === 'high' || localRec['effort'] === 'medium' ? localRec['effort'] : prev.local.effort,
326
+ parseMethod: parseMethodOf(localRec['parseMethod'] ?? prev.local.parseMethod),
327
+ serverUrl: typeof localRec['serverUrl'] === 'string' ? localRec['serverUrl'].trim() : prev.local.serverUrl,
328
+ langList: localRec['langList'] !== undefined ? langOf(localRec['langList']) : prev.local.langList,
329
+ imageAnalysis: typeof localRec['imageAnalysis'] === 'boolean' ? localRec['imageAnalysis'] : prev.local.imageAnalysis,
330
+ requestTimeoutMs: prev.local.requestTimeoutMs,
331
+ pollIntervalMs: prev.local.pollIntervalMs,
332
+ noResponseTimeoutMs: prev.local.noResponseTimeoutMs,
333
+ jobTimeoutMs: prev.local.jobTimeoutMs,
334
+ },
335
+ cloud: {
336
+ baseUrl: cloudBaseUrl,
337
+ apiKey: cloudApiKey || prev.cloud.apiKey,
338
+ modelVersion: cloudModelOf(cloudRec['modelVersion'] ?? prev.cloud.modelVersion),
339
+ pollIntervalMs: prev.cloud.pollIntervalMs,
340
+ zipTimeoutMs: prev.cloud.zipTimeoutMs,
341
+ },
342
+ };
343
+ }