paperdepot 0.1.2 → 0.1.3

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/README.en.md CHANGED
@@ -40,6 +40,8 @@ It handles ingestion and organization; Obsidian only stores and reads.
40
40
  Hand the [one-prompt installer](https://github.com/HUAWEIYYLS/paperdepot/blob/main/docs/install-prompt.md) to DSH.
41
41
  3. Get a MinerU token at <https://mineru.net>.
42
42
  4. Open the **论文库** panel in DSH, paste the token, verify it, and choose your vault.
43
+ The token is stored in `~/.dsh/paperdepot/config/` and passed to the Python pipeline
44
+ (`MINERU_API_KEY` / `PAPERDEPOT_*`), so the panel and the pipeline read the same file.
43
45
  5. Drop a PDF into the panel and open the generated entry in Obsidian.
44
46
 
45
47
  Full instructions (in Chinese): [README.md](README.md).
package/README.md CHANGED
@@ -111,6 +111,8 @@ pnpm add "link:D:\path\to\paperdepot"
111
111
 
112
112
  1. **MinerU Token**:粘贴 → 「保存并验证」。插件会真的调一次 MinerU 接口验 key。
113
113
  Token 只写到本机配置文件,不会上传;也可以用环境变量 `MINERU_API_KEY`。
114
+ 安装态文件在 `~/.dsh/paperdepot/config/secrets.local.json`,插件启动 Python 流水线时
115
+ 会把同一份 key 与配置目录传进去(面板会显示「Python 流水线能读到同一个 key」)。
114
116
  2. **Obsidian 库路径**:选择一个已存在的文件夹;空库也可以,第一次导入会创建 `1-Papers/`。
115
117
  3. **Python 依赖**:需要 Python 3.10+ 和以下依赖:
116
118
 
@@ -171,6 +173,8 @@ pnpm add "link:D:\path\to\paperdepot"
171
173
 
172
174
  - `MINERU_API_KEY`:MinerU Token(优先于本地文件)。
173
175
  - `PAPERDEPOT_HOME`:安装态数据根。
176
+ - `PAPERDEPOT_CONFIG_DIR`:配置目录覆盖(默认 `<PAPERDEPOT_HOME>/config`)。安装态由插件在启动 Python 时自动注入;
177
+ 手动跑 `pipeline/tools/` 下的脚本时才需要自己设置。
174
178
  - `PAPERDEPOT_VAULT`:默认库路径。
175
179
  - `PAPERDEPOT_PYTHON`:Python 解释器。
176
180
  - `ZOTERO_STORAGE`:Zotero storage 目录(仅 `--from-storage` 用)。
@@ -187,6 +191,12 @@ pnpm add "link:D:\path\to\paperdepot"
187
191
  - 401/403:Token 无效或过期,重新申请并保存。
188
192
  - 429:免费额度或频率限制,等一会儿或换自己的额度。
189
193
 
194
+ **面板显示「MinerU 已配置」,导入却报「未配置 MinerU key」**
195
+ - 0.1.2 及更早的安装态已知问题:面板(Node)把 key 写到 `~/.dsh/paperdepot/config/`,
196
+ Python 流水线却去插件包的 `pipeline/config/` 找,两边路径不一致。
197
+ - 升级到含本修复的版本即可;临时绕过:给 DSH 进程设置环境变量 `MINERU_API_KEY` 后重启。
198
+ - 新版本配置卡会显示「Python 流水线能读到同一个 key」;如果显示读不到,按上面两条处理。
199
+
190
200
  **导入成功但没有摘要**
191
201
  - 没有配置可用模型,或模型调用失败。入库不受影响;之后在论文页补摘要即可。
192
202
  - 开源前的旧条目不会自动补:顶栏「批量补摘要(N)」可一次排队跑完,跑长任务时可随时停止,下次再点跳过已完成的。
package/lib/client.js CHANGED
@@ -701,6 +701,18 @@ window.__ModuleLoader__.load({
701
701
  `Python:${setup.python && setup.python.ok ? '✅ ' + (setup.python.version || '可用') : '❌ 未找到(' + ((setup.python && setup.python.exe) || 'python') + ')'}`,
702
702
  h('br'),
703
703
  `MinerU key:${setup.keySource === 'env' ? '来自环境变量 MINERU_API_KEY' : setup.hasKey ? '已保存(…' + setup.keyTail + ')' : '未配置'}`,
704
+ setup.hasKey && setup.pythonKey
705
+ ? h(
706
+ 'div',
707
+ { style: { marginTop: 6, color: setup.pythonKey.ok === false ? ERROR_TEXT : muted, fontWeight: setup.pythonKey.ok === false ? 600 : 400 } },
708
+ setup.pythonKey.ok === true
709
+ ? '✓ Python 流水线能读到同一个 key'
710
+ : setup.pythonKey.ok === false
711
+ ? '⚠ Python 流水线读不到这个 key,导入会失败:' + (setup.pythonKey.detail || '未知原因') +
712
+ '。请把插件升级到含修复的版本,或给 DSH 进程设置环境变量 MINERU_API_KEY 后重启。'
713
+ : '(Python key 自检已跳过:' + (setup.pythonKey.detail || '流水线/解释器不可用') + ')',
714
+ )
715
+ : null,
704
716
  h('br'),
705
717
  `密钥文件:${setup.secretsPath}`,
706
718
  ),
package/lib/impl.js CHANGED
@@ -308,11 +308,11 @@ function trimLog(job) {
308
308
  if (job.lines.length > 400) job.lines.splice(0, job.lines.length - 400)
309
309
  }
310
310
 
311
- function runCommand(job, cmd, args, cwd) {
311
+ function runCommand(job, cfg, cmd, args) {
312
312
  return new Promise((resolve) => {
313
313
  let child
314
314
  try {
315
- child = spawn(cmd, args, { cwd, env: { ...process.env, PYTHONIOENCODING: 'utf-8' } })
315
+ child = spawn(cmd, args, { cwd: cfg.workspaceRoot, env: buildChildEnv(cfg) })
316
316
  } catch (err) {
317
317
  job.lines.push(`❌ 无法启动:${err.message}`)
318
318
  return resolve(-1)
@@ -359,6 +359,28 @@ function currentKey(cfg) {
359
359
  return { key: k, source: k ? 'file' : 'none' }
360
360
  }
361
361
 
362
+ /**
363
+ * Python 子进程环境:把宿主解析到的 home / config / key 原样传下去。
364
+ *
365
+ * 面板写 key 走 Node(<homeRoot>/config/secrets.local.json),真正解析 PDF 的是
366
+ * Python 子进程。如果只靠两边各自找文件,安装态下会一个读用户目录、一个读
367
+ * 插件包里的 pipeline/config,出现「面板说已配置、导入说未配置」。
368
+ */
369
+ export function buildChildEnv(cfg) {
370
+ const homeRoot = cfg.homeRoot || defaultHomeRoot()
371
+ const configDir = cfg.configDir || path.join(homeRoot, 'config')
372
+ const env = {
373
+ ...process.env,
374
+ PYTHONIOENCODING: 'utf-8',
375
+ PAPERDEPOT_HOME: homeRoot,
376
+ PAPERDEPOT_CONFIG_DIR: configDir,
377
+ }
378
+ if (cfg.vault) env.PAPERDEPOT_VAULT = cfg.vault
379
+ const { key, source } = currentKey({ ...cfg, homeRoot, configDir })
380
+ if (key && source === 'file') env.MINERU_API_KEY = key
381
+ return env
382
+ }
383
+
362
384
  async function probeMineru(key) {
363
385
  try {
364
386
  const r = await fetch('https://mineru.net/api/v4/extract-results/batch/00000000-0000-0000-0000-000000000000', {
@@ -388,6 +410,36 @@ function pythonInfo(cfg) {
388
410
  return res
389
411
  }
390
412
 
413
+ // Python 侧能不能读到同一份 key(面板自检用,不联网、不打印 key)
414
+ let pyKeyCache = { at: 0, stamp: '', res: null }
415
+ function pythonKeyStatus(cfg) {
416
+ const { key } = currentKey(cfg)
417
+ if (!key) return { ok: false, detail: '未配置 key' }
418
+ if (!pythonInfo(cfg).ok) return { ok: null, detail: 'Python 不可用' }
419
+ const script = path.join(cfg.toolsDir, 'mineru_parse.py')
420
+ if (!fs.existsSync(script)) return { ok: null, detail: '流水线缺少 mineru_parse.py' }
421
+ const stamp = [key, cfg.configDir, cfg.toolsDir].join('|')
422
+ const now = Date.now()
423
+ if (pyKeyCache.res && pyKeyCache.stamp === stamp && now - pyKeyCache.at < 60_000) return pyKeyCache.res
424
+ let res
425
+ try {
426
+ const r = spawnSync(cfg.python, [script, '--selftest-key'], {
427
+ encoding: 'utf8',
428
+ timeout: 20_000,
429
+ cwd: cfg.toolsDir,
430
+ env: buildChildEnv(cfg),
431
+ })
432
+ const out = String(r.stdout || '') + String(r.stderr || '')
433
+ const ok = r.status === 0 && /key ok = True/.test(out)
434
+ const first = out.trim().split(/\r?\n/).filter(Boolean)[0] || ''
435
+ res = { ok, detail: ok ? '' : first.slice(0, 200) }
436
+ } catch (err) {
437
+ res = { ok: false, detail: String((err && err.message) || err).slice(0, 200) }
438
+ }
439
+ pyKeyCache = { at: now, stamp, res }
440
+ return res
441
+ }
442
+
391
443
  function setupStatus(cfg) {
392
444
  const { key, source } = currentKey(cfg)
393
445
  const vaultExists = fs.existsSync(cfg.vault)
@@ -404,6 +456,7 @@ function setupStatus(cfg) {
404
456
  keySource: source,
405
457
  keyTail: key ? key.slice(-4) : '',
406
458
  secretsPath: secretsPathOf(cfg),
459
+ pythonKey: pythonKeyStatus(cfg),
407
460
  vault: cfg.vault,
408
461
  vaultExists,
409
462
  vaultReady: fs.existsSync(path.join(cfg.vault, '1-Papers')),
@@ -547,9 +600,9 @@ async function summarizeOne(job, cfg, host, dir) {
547
600
  const startedAt = Date.now()
548
601
  const code = await runCommand(
549
602
  job,
603
+ cfg,
550
604
  cfg.python,
551
605
  [path.join(cfg.toolsDir, 'make_digest.py'), '--vault', cfg.vault, '--only', dir, '--out', cfg.digestDir],
552
- cfg.workspaceRoot,
553
606
  )
554
607
  if (code !== 0) throw new Error('make_digest 失败')
555
608
  const files = fs
@@ -617,26 +670,26 @@ async function summarizeOne(job, cfg, host, dir) {
617
670
  job.lines.push(' ⏳ 合并进 index.md…')
618
671
  const code2 = await runCommand(
619
672
  job,
673
+ cfg,
620
674
  cfg.python,
621
675
  [path.join(cfg.toolsDir, 'apply_summary.py'), '--vault', cfg.vault, '--entry', dir],
622
- cfg.workspaceRoot,
623
676
  )
624
677
  if (code2 !== 0) throw new Error('apply_summary 失败')
625
678
  job.lines.push(' ✅ 摘要 / 标签已写入 index.md')
626
679
  // 标签再走一次受控词表归一(幂等;把模型偶尔写出的别名映射回规范词)
627
680
  const code3 = await runCommand(
628
681
  job,
682
+ cfg,
629
683
  cfg.python,
630
684
  [path.join(cfg.toolsDir, 'apply_tagmap.py'), '--vault', cfg.vault],
631
- cfg.workspaceRoot,
632
685
  )
633
686
  if (code3 !== 0) job.lines.push(' ⚠ apply_tagmap 未成功(不影响条目可用)')
634
687
  // 按 SKILL 契约校验(缺小节/标签不合规会报错;数字追溯只告警)
635
688
  const code4 = await runCommand(
636
689
  job,
690
+ cfg,
637
691
  cfg.python,
638
692
  [path.join(cfg.toolsDir, 'validate_summary.py'), '--vault', cfg.vault, '--entry', dir],
639
- cfg.workspaceRoot,
640
693
  )
641
694
  if (code4 !== 0) job.lines.push(' ⚠ 摘要校验有不符合项(见上,条目仍可用;可手动重跑)')
642
695
  await runMakeNav(job, cfg)
@@ -691,7 +744,7 @@ async function runMakeNav(job, cfg) {
691
744
  const script = path.join(cfg.toolsDir, 'make_nav.py')
692
745
  if (!fs.existsSync(script)) return
693
746
  job.lines.push(' ⏳ 更新导航块 / 分类页…')
694
- const code = await runCommand(job, cfg.python, [script, '--vault', cfg.vault], cfg.workspaceRoot)
747
+ const code = await runCommand(job, cfg, cfg.python, [script, '--vault', cfg.vault])
695
748
  if (code !== 0) job.lines.push(' ⚠ make_nav 未成功(导航/分类页可能未更新)')
696
749
  }
697
750
 
@@ -709,7 +762,7 @@ async function runImport(pdfPath, cfg, host) {
709
762
  const papersDir = path.join(cfg.vault, '1-Papers')
710
763
  const beforeDirs = new Set(listDirs(papersDir))
711
764
  const ingest = path.join(cfg.toolsDir, 'ingest.py')
712
- const code = await runCommand(job, cfg.python, [ingest, pdfPath, '--vault', cfg.vault, '--cache', cfg.mineruCache], cfg.workspaceRoot)
765
+ const code = await runCommand(job, cfg, cfg.python, [ingest, pdfPath, '--vault', cfg.vault, '--cache', cfg.mineruCache])
713
766
  job.exitCode = code
714
767
  if (code === 0 || code === 2) {
715
768
  // 用目录差集找新条目,比从日志里解析 slug 稳(日志格式/编码都可能变)
@@ -728,7 +781,7 @@ async function runImport(pdfPath, cfg, host) {
728
781
  }
729
782
  job.lines.push('— 重建索引 —')
730
783
  const mk = path.join(cfg.toolsDir, 'make_index.py')
731
- const code2 = await runCommand(job, cfg.python, [mk, '--vault', cfg.vault], cfg.workspaceRoot)
784
+ const code2 = await runCommand(job, cfg, cfg.python, [mk, '--vault', cfg.vault])
732
785
  if (code2 !== 0) job.lines.push(`⚠ make_index 退出码 ${code2}`)
733
786
  // 新条目也要有导航块(概念/分类);make_nav 幂等,已处理过的条目只会被确认
734
787
  await runMakeNav(job, cfg)
@@ -748,7 +801,7 @@ async function runReindex(cfg) {
748
801
  const job = { id: Date.now().toString(36), kind: 'reindex', state: 'running', lines: ['▶ 重建索引'], startedAt: new Date().toISOString() }
749
802
  st.job = job
750
803
  const mk = path.join(cfg.toolsDir, 'make_index.py')
751
- const code = await runCommand(job, cfg.python, [mk, '--vault', cfg.vault], cfg.workspaceRoot)
804
+ const code = await runCommand(job, cfg, cfg.python, [mk, '--vault', cfg.vault])
752
805
  job.exitCode = code
753
806
  job.state = code === 0 ? 'done' : 'error'
754
807
  job.finishedAt = new Date().toISOString()
@@ -782,6 +835,13 @@ function safeName(name) {
782
835
 
783
836
  export function createHandler(cfg, host) {
784
837
  const homeRoot = cfg.homeRoot || defaultHomeRoot()
838
+ // 首次安装时 homeRoot 可能还没建(例如 key 来自环境变量,没人写过配置文件);
839
+ // Python 子进程以它为 cwd,缺目录会报成误导性的 "spawn python ENOENT"。
840
+ try {
841
+ fs.mkdirSync(homeRoot, { recursive: true })
842
+ } catch {
843
+ /* 无权限时保持原状,具体错误由子进程日志给出 */
844
+ }
785
845
  const statePath = cfg.statePath || path.join(homeRoot, 'config', 'paperdepot.local.json')
786
846
  // 首次配置时写下的偏好优先(库路径 / 数据目录 / 流水线目录,都可以在面板里改)
787
847
  const localState = readJson(statePath)
@@ -1026,7 +1086,7 @@ export function createHandler(cfg, host) {
1026
1086
  const gscript = path.join(config.toolsDir, 'make_obsidian_graph.py')
1027
1087
  if (fs.existsSync(gscript)) {
1028
1088
  job.lines.push(' ⏳ 刷新图颜色分组…')
1029
- const c = await runCommand(job, config.python, [gscript, '--vault', config.vault], config.workspaceRoot)
1089
+ const c = await runCommand(job, config, config.python, [gscript, '--vault', config.vault])
1030
1090
  if (c !== 0) job.lines.push(' ⚠ 图颜色分组未更新(不影响导航)')
1031
1091
  }
1032
1092
  job.state = 'done'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperdepot",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "论文库(PaperDepot)— 把 PDF 论文变成可检索、可读、可连接的 Obsidian 知识库。DeepSeek Harness 插件。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -3,7 +3,15 @@
3
3
  """跨平台路径与本地配置解析(论文库流水线共用)。
4
4
 
5
5
  lp(): Windows 长路径前缀只在 Windows 添加;POSIX 原样返回。
6
+ home_dir(): PAPERDEPOT_HOME → 开发态仓库根 / 安装态 ~/.dsh/paperdepot。
7
+ config_dir(): PAPERDEPOT_CONFIG_DIR → <home_dir>/config(与 DSH 宿主 lib/impl.js 同一处)。
6
8
  default_vault(): 环境变量 → config/paperdepot.local.json → ~/Documents/ObsidianVault。
9
+
10
+ 为什么要有安装态判定:
11
+ npm / 市场安装时,流水线在 <插件包>/pipeline/tools/,而用户配置在
12
+ ~/.dsh/paperdepot/config/(宿主 lib/impl.js 写在这里)。旧版把父目录的
13
+ config/ 当成用户配置,安装态会指向插件包内只读的 pipeline/config/,
14
+ 与宿主各读各的路径 —— MinerU key 就是这样「面板说已配置、Python 说没配」。
7
15
  """
8
16
  import json
9
17
  import os
@@ -16,15 +24,48 @@ def lp(p: str) -> str:
16
24
  return '\\\\?\\' + os.path.abspath(p)
17
25
 
18
26
 
19
- def config_dir() -> str:
20
- home = os.environ.get('PAPERDEPOT_HOME')
27
+ def _tools_dir() -> str:
28
+ return os.path.dirname(os.path.abspath(__file__))
29
+
30
+
31
+ # 只有源码仓库(含 link 安装)才有的标记;npm 包按 package.json files 发布,不带这些。
32
+ _REPO_MARKERS = (
33
+ os.path.join('tools', 'ingest.py'),
34
+ os.path.join('scripts', 'pack-pipeline.mjs'),
35
+ )
36
+
37
+
38
+ def is_installed_layout() -> bool:
39
+ """流水线是否在插件包的 pipeline/tools 下(npm / 市场 / tarball 安装)。"""
40
+ tools = _tools_dir()
41
+ pipeline_root = os.path.dirname(tools)
42
+ if os.path.basename(pipeline_root) != 'pipeline':
43
+ return False
44
+ pkg_root = os.path.dirname(pipeline_root)
45
+ return not any(os.path.exists(os.path.join(pkg_root, m)) for m in _REPO_MARKERS)
46
+
47
+
48
+ def home_dir() -> str:
49
+ """配置与缓存根:环境变量 → 安装态 ~/.dsh/paperdepot → 开发态仓库根。"""
50
+ home = (os.environ.get('PAPERDEPOT_HOME') or '').strip()
21
51
  if home:
22
- return os.path.join(home, 'config')
23
- here = os.path.dirname(os.path.abspath(__file__))
24
- repo = os.path.join(os.path.dirname(here), 'config')
25
- if os.path.isdir(repo):
26
- return repo
27
- return os.path.join(os.path.expanduser('~'), '.dsh', 'paperdepot', 'config')
52
+ return home
53
+ if is_installed_layout():
54
+ return os.path.join(os.path.expanduser('~'), '.dsh', 'paperdepot')
55
+ return os.path.dirname(_tools_dir())
56
+
57
+
58
+ def config_dir() -> str:
59
+ """用户配置目录:显式覆盖 → <home_dir>/config。"""
60
+ explicit = (os.environ.get('PAPERDEPOT_CONFIG_DIR') or '').strip()
61
+ if explicit:
62
+ return explicit
63
+ return os.path.join(home_dir(), 'config')
64
+
65
+
66
+ def secrets_path() -> str:
67
+ """MinerU key 等本机密钥文件(不进 git)。"""
68
+ return os.path.join(config_dir(), 'secrets.local.json')
28
69
 
29
70
 
30
71
  def default_vault() -> str:
@@ -41,22 +41,81 @@ if hasattr(sys.stdout, 'reconfigure'):
41
41
  sys.stdout.reconfigure(encoding='utf-8', errors='replace')
42
42
 
43
43
  # ── 配置 ──────────────────────────────────────────────────────────────
44
- def _load_api_key() -> str:
45
- """按优先级取 MinerU key:环境变量 → config/secrets.local.json(不进 git)。"""
46
- key = (os.environ.get('MINERU_API_KEY') or '').strip()
47
- if key:
48
- return key
49
- import json as _json
50
- p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
51
- 'config', 'secrets.local.json')
44
+ def _candidate_key_files() -> list:
45
+ """按优先级列出 secrets.local.json 候选路径(去重、保持顺序)。
46
+
47
+ 宿主 lib/impl.js 把 key 写在 <homeRoot>/config/(安装态是
48
+ ~/.dsh/paperdepot/config/),并在启动流水线时通过 PAPERDEPOT_HOME /
49
+ PAPERDEPOT_CONFIG_DIR / MINERU_API_KEY 传给子进程。这里再做一层兜底,
50
+ 让直接跑脚本(未经过宿主)时也能找到同一份 key;插件包内旧路径仅为兼容。
51
+ """
52
+ seen = set()
53
+ cands = []
54
+
55
+ def add(p):
56
+ if not p:
57
+ return
58
+ p = os.path.abspath(os.path.expanduser(p))
59
+ mark = os.path.normcase(p)
60
+ if mark not in seen:
61
+ seen.add(mark)
62
+ cands.append(p)
63
+
64
+ cfg = (os.environ.get('PAPERDEPOT_CONFIG_DIR') or '').strip()
65
+ if cfg:
66
+ add(os.path.join(cfg, 'secrets.local.json'))
67
+ home = (os.environ.get('PAPERDEPOT_HOME') or '').strip()
68
+ if home:
69
+ add(os.path.join(home, 'config', 'secrets.local.json'))
52
70
  try:
53
- with io.open(p, encoding='utf-8') as f:
54
- return (_json.load(f).get('mineru_api_key') or '').strip()
71
+ import _paths
72
+ add(_paths.secrets_path())
73
+ except Exception:
74
+ pass
75
+ # 旧版路径:<仓库或插件包的上一级>/config(安装态即 <插件包>/pipeline/config)
76
+ add(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
77
+ 'config', 'secrets.local.json'))
78
+ # 不依赖 _paths 导入成功的最终兜底(与宿主安装态默认值一致)
79
+ add(os.path.join('~', '.dsh', 'paperdepot', 'config', 'secrets.local.json'))
80
+ return cands
81
+
82
+
83
+ def _read_key_file(path: str) -> str:
84
+ try:
85
+ with io.open(path, encoding='utf-8') as f:
86
+ return (json.load(f).get('mineru_api_key') or '').strip()
55
87
  except Exception:
56
88
  return ''
57
89
 
58
90
 
59
- API_KEY = _load_api_key()
91
+ def _resolve_api_key():
92
+ """返回 (key, 来源):环境变量 → 用户配置 → 旧插件包路径。"""
93
+ key = (os.environ.get('MINERU_API_KEY') or '').strip()
94
+ if key:
95
+ return key, 'env:MINERU_API_KEY'
96
+ for p in _candidate_key_files():
97
+ k = _read_key_file(p)
98
+ if k:
99
+ return k, p
100
+ return '', ''
101
+
102
+
103
+ API_KEY, KEY_SOURCE = _resolve_api_key()
104
+
105
+
106
+ def _load_api_key() -> str:
107
+ """兼容旧调用:只返回当前解析到的 key。"""
108
+ return API_KEY
109
+
110
+
111
+ def _key_hint() -> str:
112
+ """没配 key 时把查过的路径列出来,方便用户/支持定位(不含 key 本身)。"""
113
+ return ('未配置 MinerU key。已检查环境变量 MINERU_API_KEY 和:\n '
114
+ + '\n '.join(_candidate_key_files())
115
+ + '\n请在「论文库」面板里填 Token(保存后宿主会把它传给流水线),'
116
+ '或设置环境变量 MINERU_API_KEY。')
117
+
118
+
60
119
  BASE = 'https://mineru.net/api/v4'
61
120
 
62
121
  # 已知 CDN 证书过期,只替换这一个 host
@@ -113,7 +172,7 @@ def pdf_fingerprint(path: str) -> str:
113
172
 
114
173
  def api_call(path: str, method: str = 'GET', payload=None, timeout: int = 120) -> dict:
115
174
  if not API_KEY:
116
- raise MineruError('未配置 MinerU key:请设置环境变量 MINERU_API_KEY,或写 config/secrets.local.json(模板见 config/secrets.example.json)')
175
+ raise MineruError(_key_hint())
117
176
  url = f'{BASE}{path}'
118
177
  headers = {'Authorization': f'Bearer {API_KEY}', 'Accept': '*/*'}
119
178
  body = None
@@ -237,6 +296,19 @@ def cache_compatible(cached: dict) -> bool:
237
296
  return cached.get('cache_version', 1) == CACHE_VERSION
238
297
 
239
298
 
299
+ def _selftest_key() -> int:
300
+ """离线自检:只确认 key 能否解析到、来源在哪,不联网、不打印 key。"""
301
+ if API_KEY:
302
+ print(f'MinerU key: 已找到(来源:{KEY_SOURCE})')
303
+ print('key ok = True')
304
+ return 0
305
+ print('MinerU key: 未找到,已检查:')
306
+ for p in _candidate_key_files():
307
+ print(' - ' + p)
308
+ print('key ok = False')
309
+ return 1
310
+
311
+
240
312
  def _cache_selftest() -> int:
241
313
  """离线自检:陈旧 out_dir 的缓存应命中、路径应改写,版本判定应正确。不消耗 API。"""
242
314
  import tempfile
@@ -425,10 +497,13 @@ def main():
425
497
  ap.add_argument('--keep-zip', action='store_true', help='保留原始 ZIP')
426
498
  ap.add_argument('--json', action='store_true', help='只输出 JSON 摘要')
427
499
  ap.add_argument('--selftest-cache', action='store_true', help='离线自检缓存迁移逻辑(不耗 API)')
500
+ ap.add_argument('--selftest-key', action='store_true', help='离线自检 MinerU key 解析位置(不耗 API)')
428
501
  args = ap.parse_args()
429
502
 
430
503
  if args.selftest_cache:
431
504
  return _cache_selftest()
505
+ if args.selftest_key:
506
+ return _selftest_key()
432
507
  if not args.pdf:
433
508
  ap.error('缺少 PDF 路径')
434
509