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 +2 -0
- package/README.md +10 -0
- package/lib/client.js +12 -0
- package/lib/impl.js +71 -11
- package/package.json +1 -1
- package/pipeline/tools/_paths.py +49 -8
- package/pipeline/tools/mineru_parse.py +87 -12
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
|
|
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
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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
package/pipeline/tools/_paths.py
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|