paperdepot 0.1.1 → 0.1.2

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.
@@ -1,462 +1,462 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- mineru_parse — 把 PDF 变成结构化产物(full.md + images/ + layout.json)
5
-
6
- 这是「Zotero 退场」后流水线的第一块积木。只负责"解析",不负责"入库"。
7
-
8
- 设计要点(全部经本机实测验证):
9
- 1. 上传必须用 http.client,只发 Content-Length
10
- —— urllib / requests 会自动加 Content-Type,破坏 OSS 签名 → 403
11
- 2. 轮询:3s 起步,5 分钟后 15s,30 分钟后 60s
12
- 3. ZIP 下载:cdn-mineru.openxlab.org.cn 的 TLS 证书已过期,
13
- 必须换成 mineru.oss-cn-shanghai.aliyuncs.com
14
- 4. ZIP 内文件名带 UUID 前缀,不能用固定名,必须 glob
15
- 5. 当前 API 版本(_version_name 3.4.4)**没有 manifest.json**,
16
- 版面结构在 layout.json 的 pdf_info[] 里
17
-
18
- 缓存目录:vault/.mineru-cache/<id>/ (点开头,Obsidian 不索引)
19
-
20
- 用法:
21
- python mineru_parse.py <pdf路径>
22
- python mineru_parse.py <pdf路径> --keep-zip
23
- python mineru_parse.py <pdf路径> --out <目录>
24
- """
25
- import argparse
26
- import glob
27
- import hashlib
28
- import http.client
29
- import io
30
- import json
31
- import os
32
- import re
33
- import shutil
34
- import sys
35
- import time
36
- import zipfile
37
- from urllib.parse import urlparse
38
- import urllib.request
39
-
40
- if hasattr(sys.stdout, 'reconfigure'):
41
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
42
-
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')
52
- try:
53
- with io.open(p, encoding='utf-8') as f:
54
- return (_json.load(f).get('mineru_api_key') or '').strip()
55
- except Exception:
56
- return ''
57
-
58
-
59
- API_KEY = _load_api_key()
60
- BASE = 'https://mineru.net/api/v4'
61
-
62
- # 已知 CDN 证书过期,只替换这一个 host
63
- ZIP_HOST_FALLBACK = {
64
- 'cdn-mineru.openxlab.org.cn': 'mineru.oss-cn-shanghai.aliyuncs.com',
65
- }
66
-
67
- # 轮询节奏(实测 16 页约 10 秒完成,这些阈值基本不会触发)
68
- POLL_FAST, POLL_FAST_UNTIL = 3, 300
69
- POLL_MED, POLL_MED_UNTIL = 15, 1800
70
- POLL_SLOW = 60
71
- TIMEOUT = 40 * 60
72
-
73
- TERMINAL_CODES = {'A0202', 'A0211', '-60012', '-60013'}
74
- DAILY_LIMIT_CODE = '-60018'
75
-
76
- # 缓存放在项目工作区 cache/ 下,不放进 vault。
77
- # (曾放在云盘 vault 的 .mineru-cache,占 1.25 GB 且全部参与云同步,已迁出)
78
- DEFAULT_CACHE = os.path.join(
79
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
80
- 'cache', 'mineru')
81
-
82
- # 缓存格式版本:旧缓存(没有这个字段)按 1 处理,保持向后兼容;
83
- # 未来格式变更时提升版本号,读到不认识的版本就重新解析。
84
- CACHE_VERSION = 1
85
-
86
-
87
- # ── 工具 ──────────────────────────────────────────────────────────────
88
- class MineruError(RuntimeError):
89
- pass
90
-
91
-
92
- class QuotaExhausted(MineruError):
93
- """每日配额用尽 —— 必须终止整个队列,不是单篇失败"""
94
- pass
95
-
96
-
97
- def safe_ascii_name(name: str) -> str:
98
- """MinerU 后端不处理 unicode 文件名,必须 ASCII 化"""
99
- n = re.sub(r'[^\x20-\x7E]', '_', name)
100
- if not n.lower().endswith('.pdf'):
101
- n += '.pdf'
102
- return n
103
-
104
-
105
- def pdf_fingerprint(path: str) -> str:
106
- """用内容 sha256 前 16 位做缓存 key,同一 PDF 不重复上传"""
107
- h = hashlib.sha256()
108
- with open(path, 'rb') as f:
109
- for chunk in iter(lambda: f.read(1 << 20), b''):
110
- h.update(chunk)
111
- return h.hexdigest()[:16]
112
-
113
-
114
- def api_call(path: str, method: str = 'GET', payload=None, timeout: int = 120) -> dict:
115
- if not API_KEY:
116
- raise MineruError('未配置 MinerU key:请设置环境变量 MINERU_API_KEY,或写 config/secrets.local.json(模板见 config/secrets.example.json)')
117
- url = f'{BASE}{path}'
118
- headers = {'Authorization': f'Bearer {API_KEY}', 'Accept': '*/*'}
119
- body = None
120
- if payload is not None:
121
- body = json.dumps(payload).encode('utf-8')
122
- headers['Content-Type'] = 'application/json'
123
- req = urllib.request.Request(url, data=body, headers=headers, method=method)
124
- with urllib.request.urlopen(req, timeout=timeout) as resp:
125
- return json.loads(resp.read().decode('utf-8'))
126
-
127
-
128
- def upload_pdf(upload_url: str, pdf_path: str) -> None:
129
- """
130
- 上传 PDF 到 OSS。
131
-
132
- 关键:绝不能带 Content-Type。
133
- · urllib.request 带 data 时会自动加 application/x-www-form-urlencoded
134
- · requests 同样会加
135
- → 所以只能用 http.client 手工控制请求头
136
- skip_accept_encoding=True 也必要,否则加 Accept-Encoding 同样破坏签名。
137
- """
138
- with open(pdf_path, 'rb') as f:
139
- data = f.read()
140
- u = urlparse(upload_url)
141
- path_q = u.path + (('?' + u.query) if u.query else '')
142
- conn = http.client.HTTPSConnection(u.hostname, timeout=300)
143
- try:
144
- conn.putrequest('PUT', path_q, skip_accept_encoding=True)
145
- conn.putheader('Content-Length', str(len(data)))
146
- conn.endheaders()
147
- conn.send(data)
148
- resp = conn.getresponse()
149
- body = resp.read()
150
- if resp.status not in (200, 201):
151
- raise MineruError(
152
- f'上传失败 HTTP {resp.status}: {body[:400].decode("utf-8", "replace")}')
153
- finally:
154
- conn.close()
155
-
156
-
157
- def download_zip(url: str, dst: str) -> None:
158
- """
159
- 下载 ZIP。
160
-
161
- 实测结论(2026-10-20,本机):
162
- · cdn-mineru.openxlab.org.cn 的 TLS 证书确实已过期
163
- · 但把它换到 OSS host 后,经常 read timeout(OSS 对大 zip 慢)
164
- · 原 host 虽然证书过期,用系统 CA 却常常能下成功
165
-
166
- → 策略反过来:**先试原 host,失败再换**。
167
- 文档说"必须替换",实测替换反而更慢。
168
- """
169
- host = urlparse(url).hostname
170
- attempts = [(url, '原 host')]
171
- if host in ZIP_HOST_FALLBACK:
172
- attempts.append((url.replace(host, ZIP_HOST_FALLBACK[host], 1), 'OSS 备用 host'))
173
-
174
- last = None
175
- for target, label in attempts:
176
- try:
177
- with urllib.request.urlopen(target, timeout=90) as r:
178
- raw = r.read()
179
- if raw[:2] != b'PK':
180
- raise MineruError(f'{label} 返回的不是 ZIP')
181
- with open(dst, 'wb') as f:
182
- f.write(raw)
183
- if label != '原 host':
184
- print(f' (原 host 失败,已用 {label})')
185
- return
186
- except Exception as exc:
187
- last = exc
188
- if len(attempts) > 1 and label == attempts[0][1]:
189
- print(f' 原 host 失败({type(exc).__name__}),换 OSS 重试…')
190
- raise MineruError(f'ZIP 下载失败: {last}')
191
-
192
-
193
- # ── UI 摘要生成 ───────────────────────────────────────────────────────
194
- def build_summary(out_dir: str) -> dict:
195
- """
196
- 从解析产物里提取元信息。
197
- 注意:产物文件名带 UUID 前缀,必须 glob。
198
- """
199
- info = {}
200
- md_path = os.path.join(out_dir, 'full.md')
201
- if os.path.isfile(md_path):
202
- text = open(md_path, encoding='utf-8', errors='replace').read()
203
- info['md_chars'] = len(text)
204
- m = re.search(r'^#\s+(.+)$', text, re.M)
205
- info['title'] = m.group(1).strip() if m else None
206
- info['image_refs'] = len(re.findall(r'!\[[^\]]*\]\(images/[^)]+\)', text))
207
- img_dir = os.path.join(out_dir, 'images')
208
- if os.path.isdir(img_dir):
209
- info['images'] = len([f for f in os.listdir(img_dir)
210
- if os.path.isfile(os.path.join(img_dir, f))])
211
- for pat, key in ((config_glob('content_list.json'), 'content_list'),
212
- (config_glob('model.json'), 'model'),
213
- (config_glob('origin.pdf'), 'origin_pdf')):
214
- hits = glob.glob(os.path.join(out_dir, pat))
215
- if hits:
216
- info[key] = os.path.basename(hits[0])
217
- lay = os.path.join(out_dir, 'layout.json')
218
- if os.path.isfile(lay):
219
- try:
220
- d = json.load(open(lay, encoding='utf-8'))
221
- info['layout_pages'] = len(d.get('pdf_info', []))
222
- info['layout_version'] = d.get('_version_name')
223
- except Exception:
224
- pass
225
- mf = os.path.join(out_dir, 'manifest.json')
226
- info['has_manifest'] = os.path.isfile(mf)
227
- return info
228
-
229
-
230
- def config_glob(suffix: str) -> str:
231
- return f'*_{suffix}'
232
-
233
-
234
- # ── 主流程 ────────────────────────────────────────────────────────────
235
- def cache_compatible(cached: dict) -> bool:
236
- """旧缓存没有 cache_version,按 1 处理(路径已在命中时改写,兼容搬家)。"""
237
- return cached.get('cache_version', 1) == CACHE_VERSION
238
-
239
-
240
- def _cache_selftest() -> int:
241
- """离线自检:陈旧 out_dir 的缓存应命中、路径应改写,版本判定应正确。不消耗 API。"""
242
- import tempfile
243
- tmp = tempfile.mkdtemp(prefix='paperdepot-cache-selftest-')
244
- try:
245
- cache = os.path.join(tmp, 'cache')
246
- pdf = os.path.join(tmp, 'x.pdf')
247
- with open(pdf, 'wb') as f:
248
- f.write(b'%PDF-1.4 cache-selftest')
249
- fp = pdf_fingerprint(pdf)
250
- d = os.path.join(cache, fp)
251
- os.makedirs(os.path.join(d, 'images'))
252
- io.open(os.path.join(d, 'full.md'), 'w', encoding='utf-8').write('# t\n')
253
- open(os.path.join(d, '.done'), 'w').close()
254
- io.open(os.path.join(d, '.meta.json'), 'w', encoding='utf-8').write(
255
- json.dumps({'out_dir': 'D:\\gone\\old-cache', 'title': 't'}))
256
- s = parse(pdf, cache)
257
- assert s.get('from_cache') is True, s
258
- assert os.path.normcase(s['out_dir']) == os.path.normcase(d), (s['out_dir'], d)
259
- assert cache_compatible({}) and cache_compatible({'cache_version': CACHE_VERSION})
260
- assert not cache_compatible({'cache_version': CACHE_VERSION + 1})
261
- print('✅ cache selftest ok')
262
- return 0
263
- finally:
264
- shutil.rmtree(tmp, ignore_errors=True)
265
-
266
-
267
- def parse(pdf_path: str, cache_dir: str, keep_zip: bool = False) -> dict:
268
- if not os.path.isfile(pdf_path):
269
- raise MineruError(f'PDF 不存在: {pdf_path}')
270
-
271
- fp = pdf_fingerprint(pdf_path)
272
- out_dir = os.path.join(cache_dir, fp)
273
- done_marker = os.path.join(out_dir, '.done')
274
-
275
- # 缓存命中直接返回
276
- if os.path.isfile(done_marker):
277
- print(f'✅ 缓存命中: {out_dir}')
278
- try:
279
- cached = json.load(open(os.path.join(out_dir, '.meta.json'), encoding='utf-8'))
280
- if not cache_compatible(cached):
281
- print(f" ⚠ 缓存版本不匹配({cached.get('cache_version')} != {CACHE_VERSION}),重新解析")
282
- else:
283
- # .meta.json 里的 out_dir 是生成缓存时写入的绝对路径;缓存被搬过目录
284
- # (例如从云盘 vault 的 .mineru-cache 迁到仓库 cache/)后必须改回当前路径
285
- cached['out_dir'] = out_dir
286
- cached['from_cache'] = True
287
- cached['parse_seconds'] = 0.0
288
- if os.path.isfile(os.path.join(out_dir, 'full.md')):
289
- return cached
290
- print(' ⚠ 缓存缺 full.md,重新解析')
291
- except Exception:
292
- pass
293
-
294
- os.makedirs(cache_dir, exist_ok=True)
295
- tmp_dir = os.path.join(cache_dir, f'_tmp_{fp}')
296
- if os.path.isdir(tmp_dir):
297
- shutil.rmtree(tmp_dir, ignore_errors=True)
298
- os.makedirs(tmp_dir, exist_ok=True)
299
-
300
- name = safe_ascii_name(os.path.basename(pdf_path))
301
- size_mb = os.path.getsize(pdf_path) / 1048576
302
- print(f'📄 {os.path.basename(pdf_path)} ({size_mb:.2f} MB)')
303
- print(f' 上传名: {name}')
304
-
305
- # 1. 建批次
306
- print(' [1/4] 建批次…')
307
- resp = api_call('/file-urls/batch', 'POST', {
308
- 'enable_formula': True,
309
- 'enable_table': True,
310
- 'language': 'ch',
311
- 'model_version': 'vlm',
312
- 'files': [{'name': name, 'is_ocr': False}],
313
- })
314
- if resp.get('code') != 0:
315
- raise MineruError(f'建批次失败: {resp}')
316
- batch_id = resp['data']['batch_id']
317
- upload_url = resp['data']['file_urls'][0]
318
-
319
- # 2. 上传
320
- print(' [2/4] 上传…')
321
- upload_pdf(upload_url, pdf_path)
322
-
323
- # 3. 轮询
324
- print(' [3/4] 解析中…')
325
- t0 = time.time()
326
- state = None
327
- result = None
328
- while True:
329
- elapsed = time.time() - t0
330
- if elapsed > TIMEOUT:
331
- raise MineruError(f'超时 {TIMEOUT}s')
332
-
333
- try:
334
- resp = api_call(f'/extract-results/batch/{batch_id}')
335
- except Exception as exc:
336
- print(f' 轮询异常: {exc}')
337
- time.sleep(POLL_FAST)
338
- continue
339
-
340
- code = resp.get('code')
341
- if code != 0:
342
- c = str(code)
343
- if c == DAILY_LIMIT_CODE:
344
- raise QuotaExhausted('每日配额用尽(-60018)—— 必须停止整个队列,明日再跑')
345
- if c in TERMINAL_CODES:
346
- raise MineruError(f'终态错误 {c}: {resp.get("msg")}')
347
- time.sleep(POLL_FAST)
348
- continue
349
-
350
- items = resp['data'].get('extract_result') or []
351
- if not items:
352
- time.sleep(POLL_FAST)
353
- continue
354
-
355
- r0 = items[0]
356
- st = r0.get('state')
357
- if st != state:
358
- print(f' [ {elapsed:5.1f}s ] {st}')
359
- state = st
360
-
361
- if st == 'done':
362
- result = r0
363
- break
364
- if st == 'failed':
365
- raise MineruError(f'解析失败: {r0.get("err_msg")}')
366
-
367
- prog = r0.get('extract_progress') or {}
368
- if prog.get('total_pages') and st == 'running':
369
- print(f' {prog.get("extracted_pages")}/{prog.get("total_pages")} 页')
370
-
371
- if elapsed < POLL_FAST_UNTIL:
372
- wait = POLL_FAST
373
- elif elapsed < POLL_MED_UNTIL:
374
- wait = POLL_MED
375
- else:
376
- wait = POLL_SLOW
377
- time.sleep(wait)
378
-
379
- parse_time = time.time() - t0
380
-
381
- # 4. 下载 + 解压
382
- print(' [4/4] 下载 ZIP…')
383
- zip_url = result['full_zip_url']
384
- zip_path = os.path.join(tmp_dir, 'result.zip')
385
- download_zip(zip_url, zip_path)
386
-
387
- with zipfile.ZipFile(zip_path) as z:
388
- z.extractall(tmp_dir)
389
- names = z.namelist()
390
- img_count = len([n for n in names if n.startswith('images/') and not n.endswith('/')])
391
- print(f' 解压完成:{len(names)} 条目,{img_count} 张图')
392
-
393
- # 落盘(去掉 tmp 里的 zip)
394
- if os.path.isdir(out_dir):
395
- shutil.rmtree(out_dir, ignore_errors=True)
396
- if not keep_zip:
397
- try:
398
- os.remove(zip_path)
399
- except OSError:
400
- pass
401
- shutil.move(tmp_dir, out_dir)
402
-
403
- summary = build_summary(out_dir)
404
- summary['cache_version'] = CACHE_VERSION
405
- summary.update({
406
- 'source_pdf': os.path.abspath(pdf_path),
407
- 'fingerprint': fp,
408
- 'batch_id': batch_id,
409
- 'parse_seconds': round(parse_time, 1),
410
- 'zip_url': zip_url,
411
- 'out_dir': out_dir,
412
- 'parsed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
413
- })
414
- with open(os.path.join(out_dir, '.meta.json'), 'w', encoding='utf-8') as f:
415
- json.dump(summary, f, ensure_ascii=False, indent=1)
416
- open(done_marker, 'w').close()
417
-
418
- return summary
419
-
420
-
421
- def main():
422
- ap = argparse.ArgumentParser(description='MinerU 解析 PDF(Zotero 退场后的解析积木)')
423
- ap.add_argument('pdf', nargs='?', help='PDF 文件路径')
424
- ap.add_argument('--out', default=DEFAULT_CACHE, help=f'缓存根目录(默认 {DEFAULT_CACHE})')
425
- ap.add_argument('--keep-zip', action='store_true', help='保留原始 ZIP')
426
- ap.add_argument('--json', action='store_true', help='只输出 JSON 摘要')
427
- ap.add_argument('--selftest-cache', action='store_true', help='离线自检缓存迁移逻辑(不耗 API)')
428
- args = ap.parse_args()
429
-
430
- if args.selftest_cache:
431
- return _cache_selftest()
432
- if not args.pdf:
433
- ap.error('缺少 PDF 路径')
434
-
435
- try:
436
- s = parse(args.pdf, args.out, args.keep_zip)
437
- except MineruError as exc:
438
- print(f'❌ {exc}', file=sys.stderr)
439
- return 1
440
-
441
- if args.json:
442
- print(json.dumps(s, ensure_ascii=False, indent=1))
443
- return 0
444
-
445
- print()
446
- print('─' * 62)
447
- print('✅ 解析完成')
448
- print(f" 输出目录 : {s['out_dir']}")
449
- print(f" 耗时 : {s['parse_seconds']} 秒")
450
- print(f" 标题 : {s.get('title')}")
451
- print(f" 正文字符 : {s.get('md_chars', 0):,}")
452
- print(f" 图片 : {s.get('images', 0)} 张"
453
- f"(引用 {s.get('image_refs', 0)} 处)")
454
- if s.get('layout_pages'):
455
- print(f" layout.json: {s['layout_pages']} 页 (v{s.get('layout_version')})")
456
- print(f" manifest : {'有' if s.get('has_manifest') else '无(此 API 版本不产出)'}")
457
- print('─' * 62)
458
- return 0
459
-
460
-
461
- if __name__ == '__main__':
462
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ mineru_parse — 把 PDF 变成结构化产物(full.md + images/ + layout.json)
5
+
6
+ 这是「Zotero 退场」后流水线的第一块积木。只负责"解析",不负责"入库"。
7
+
8
+ 设计要点(全部经本机实测验证):
9
+ 1. 上传必须用 http.client,只发 Content-Length
10
+ —— urllib / requests 会自动加 Content-Type,破坏 OSS 签名 → 403
11
+ 2. 轮询:3s 起步,5 分钟后 15s,30 分钟后 60s
12
+ 3. ZIP 下载:cdn-mineru.openxlab.org.cn 的 TLS 证书已过期,
13
+ 必须换成 mineru.oss-cn-shanghai.aliyuncs.com
14
+ 4. ZIP 内文件名带 UUID 前缀,不能用固定名,必须 glob
15
+ 5. 当前 API 版本(_version_name 3.4.4)**没有 manifest.json**,
16
+ 版面结构在 layout.json 的 pdf_info[] 里
17
+
18
+ 缓存目录:vault/.mineru-cache/<id>/ (点开头,Obsidian 不索引)
19
+
20
+ 用法:
21
+ python mineru_parse.py <pdf路径>
22
+ python mineru_parse.py <pdf路径> --keep-zip
23
+ python mineru_parse.py <pdf路径> --out <目录>
24
+ """
25
+ import argparse
26
+ import glob
27
+ import hashlib
28
+ import http.client
29
+ import io
30
+ import json
31
+ import os
32
+ import re
33
+ import shutil
34
+ import sys
35
+ import time
36
+ import zipfile
37
+ from urllib.parse import urlparse
38
+ import urllib.request
39
+
40
+ if hasattr(sys.stdout, 'reconfigure'):
41
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
42
+
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')
52
+ try:
53
+ with io.open(p, encoding='utf-8') as f:
54
+ return (_json.load(f).get('mineru_api_key') or '').strip()
55
+ except Exception:
56
+ return ''
57
+
58
+
59
+ API_KEY = _load_api_key()
60
+ BASE = 'https://mineru.net/api/v4'
61
+
62
+ # 已知 CDN 证书过期,只替换这一个 host
63
+ ZIP_HOST_FALLBACK = {
64
+ 'cdn-mineru.openxlab.org.cn': 'mineru.oss-cn-shanghai.aliyuncs.com',
65
+ }
66
+
67
+ # 轮询节奏(实测 16 页约 10 秒完成,这些阈值基本不会触发)
68
+ POLL_FAST, POLL_FAST_UNTIL = 3, 300
69
+ POLL_MED, POLL_MED_UNTIL = 15, 1800
70
+ POLL_SLOW = 60
71
+ TIMEOUT = 40 * 60
72
+
73
+ TERMINAL_CODES = {'A0202', 'A0211', '-60012', '-60013'}
74
+ DAILY_LIMIT_CODE = '-60018'
75
+
76
+ # 缓存放在项目工作区 cache/ 下,不放进 vault。
77
+ # (曾放在云盘 vault 的 .mineru-cache,占 1.25 GB 且全部参与云同步,已迁出)
78
+ DEFAULT_CACHE = os.path.join(
79
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
80
+ 'cache', 'mineru')
81
+
82
+ # 缓存格式版本:旧缓存(没有这个字段)按 1 处理,保持向后兼容;
83
+ # 未来格式变更时提升版本号,读到不认识的版本就重新解析。
84
+ CACHE_VERSION = 1
85
+
86
+
87
+ # ── 工具 ──────────────────────────────────────────────────────────────
88
+ class MineruError(RuntimeError):
89
+ pass
90
+
91
+
92
+ class QuotaExhausted(MineruError):
93
+ """每日配额用尽 —— 必须终止整个队列,不是单篇失败"""
94
+ pass
95
+
96
+
97
+ def safe_ascii_name(name: str) -> str:
98
+ """MinerU 后端不处理 unicode 文件名,必须 ASCII 化"""
99
+ n = re.sub(r'[^\x20-\x7E]', '_', name)
100
+ if not n.lower().endswith('.pdf'):
101
+ n += '.pdf'
102
+ return n
103
+
104
+
105
+ def pdf_fingerprint(path: str) -> str:
106
+ """用内容 sha256 前 16 位做缓存 key,同一 PDF 不重复上传"""
107
+ h = hashlib.sha256()
108
+ with open(path, 'rb') as f:
109
+ for chunk in iter(lambda: f.read(1 << 20), b''):
110
+ h.update(chunk)
111
+ return h.hexdigest()[:16]
112
+
113
+
114
+ def api_call(path: str, method: str = 'GET', payload=None, timeout: int = 120) -> dict:
115
+ if not API_KEY:
116
+ raise MineruError('未配置 MinerU key:请设置环境变量 MINERU_API_KEY,或写 config/secrets.local.json(模板见 config/secrets.example.json)')
117
+ url = f'{BASE}{path}'
118
+ headers = {'Authorization': f'Bearer {API_KEY}', 'Accept': '*/*'}
119
+ body = None
120
+ if payload is not None:
121
+ body = json.dumps(payload).encode('utf-8')
122
+ headers['Content-Type'] = 'application/json'
123
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
124
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
125
+ return json.loads(resp.read().decode('utf-8'))
126
+
127
+
128
+ def upload_pdf(upload_url: str, pdf_path: str) -> None:
129
+ """
130
+ 上传 PDF 到 OSS。
131
+
132
+ 关键:绝不能带 Content-Type。
133
+ · urllib.request 带 data 时会自动加 application/x-www-form-urlencoded
134
+ · requests 同样会加
135
+ → 所以只能用 http.client 手工控制请求头
136
+ skip_accept_encoding=True 也必要,否则加 Accept-Encoding 同样破坏签名。
137
+ """
138
+ with open(pdf_path, 'rb') as f:
139
+ data = f.read()
140
+ u = urlparse(upload_url)
141
+ path_q = u.path + (('?' + u.query) if u.query else '')
142
+ conn = http.client.HTTPSConnection(u.hostname, timeout=300)
143
+ try:
144
+ conn.putrequest('PUT', path_q, skip_accept_encoding=True)
145
+ conn.putheader('Content-Length', str(len(data)))
146
+ conn.endheaders()
147
+ conn.send(data)
148
+ resp = conn.getresponse()
149
+ body = resp.read()
150
+ if resp.status not in (200, 201):
151
+ raise MineruError(
152
+ f'上传失败 HTTP {resp.status}: {body[:400].decode("utf-8", "replace")}')
153
+ finally:
154
+ conn.close()
155
+
156
+
157
+ def download_zip(url: str, dst: str) -> None:
158
+ """
159
+ 下载 ZIP。
160
+
161
+ 实测结论(2026-10-20,本机):
162
+ · cdn-mineru.openxlab.org.cn 的 TLS 证书确实已过期
163
+ · 但把它换到 OSS host 后,经常 read timeout(OSS 对大 zip 慢)
164
+ · 原 host 虽然证书过期,用系统 CA 却常常能下成功
165
+
166
+ → 策略反过来:**先试原 host,失败再换**。
167
+ 文档说"必须替换",实测替换反而更慢。
168
+ """
169
+ host = urlparse(url).hostname
170
+ attempts = [(url, '原 host')]
171
+ if host in ZIP_HOST_FALLBACK:
172
+ attempts.append((url.replace(host, ZIP_HOST_FALLBACK[host], 1), 'OSS 备用 host'))
173
+
174
+ last = None
175
+ for target, label in attempts:
176
+ try:
177
+ with urllib.request.urlopen(target, timeout=90) as r:
178
+ raw = r.read()
179
+ if raw[:2] != b'PK':
180
+ raise MineruError(f'{label} 返回的不是 ZIP')
181
+ with open(dst, 'wb') as f:
182
+ f.write(raw)
183
+ if label != '原 host':
184
+ print(f' (原 host 失败,已用 {label})')
185
+ return
186
+ except Exception as exc:
187
+ last = exc
188
+ if len(attempts) > 1 and label == attempts[0][1]:
189
+ print(f' 原 host 失败({type(exc).__name__}),换 OSS 重试…')
190
+ raise MineruError(f'ZIP 下载失败: {last}')
191
+
192
+
193
+ # ── UI 摘要生成 ───────────────────────────────────────────────────────
194
+ def build_summary(out_dir: str) -> dict:
195
+ """
196
+ 从解析产物里提取元信息。
197
+ 注意:产物文件名带 UUID 前缀,必须 glob。
198
+ """
199
+ info = {}
200
+ md_path = os.path.join(out_dir, 'full.md')
201
+ if os.path.isfile(md_path):
202
+ text = open(md_path, encoding='utf-8', errors='replace').read()
203
+ info['md_chars'] = len(text)
204
+ m = re.search(r'^#\s+(.+)$', text, re.M)
205
+ info['title'] = m.group(1).strip() if m else None
206
+ info['image_refs'] = len(re.findall(r'!\[[^\]]*\]\(images/[^)]+\)', text))
207
+ img_dir = os.path.join(out_dir, 'images')
208
+ if os.path.isdir(img_dir):
209
+ info['images'] = len([f for f in os.listdir(img_dir)
210
+ if os.path.isfile(os.path.join(img_dir, f))])
211
+ for pat, key in ((config_glob('content_list.json'), 'content_list'),
212
+ (config_glob('model.json'), 'model'),
213
+ (config_glob('origin.pdf'), 'origin_pdf')):
214
+ hits = glob.glob(os.path.join(out_dir, pat))
215
+ if hits:
216
+ info[key] = os.path.basename(hits[0])
217
+ lay = os.path.join(out_dir, 'layout.json')
218
+ if os.path.isfile(lay):
219
+ try:
220
+ d = json.load(open(lay, encoding='utf-8'))
221
+ info['layout_pages'] = len(d.get('pdf_info', []))
222
+ info['layout_version'] = d.get('_version_name')
223
+ except Exception:
224
+ pass
225
+ mf = os.path.join(out_dir, 'manifest.json')
226
+ info['has_manifest'] = os.path.isfile(mf)
227
+ return info
228
+
229
+
230
+ def config_glob(suffix: str) -> str:
231
+ return f'*_{suffix}'
232
+
233
+
234
+ # ── 主流程 ────────────────────────────────────────────────────────────
235
+ def cache_compatible(cached: dict) -> bool:
236
+ """旧缓存没有 cache_version,按 1 处理(路径已在命中时改写,兼容搬家)。"""
237
+ return cached.get('cache_version', 1) == CACHE_VERSION
238
+
239
+
240
+ def _cache_selftest() -> int:
241
+ """离线自检:陈旧 out_dir 的缓存应命中、路径应改写,版本判定应正确。不消耗 API。"""
242
+ import tempfile
243
+ tmp = tempfile.mkdtemp(prefix='paperdepot-cache-selftest-')
244
+ try:
245
+ cache = os.path.join(tmp, 'cache')
246
+ pdf = os.path.join(tmp, 'x.pdf')
247
+ with open(pdf, 'wb') as f:
248
+ f.write(b'%PDF-1.4 cache-selftest')
249
+ fp = pdf_fingerprint(pdf)
250
+ d = os.path.join(cache, fp)
251
+ os.makedirs(os.path.join(d, 'images'))
252
+ io.open(os.path.join(d, 'full.md'), 'w', encoding='utf-8').write('# t\n')
253
+ open(os.path.join(d, '.done'), 'w').close()
254
+ io.open(os.path.join(d, '.meta.json'), 'w', encoding='utf-8').write(
255
+ json.dumps({'out_dir': 'D:\\gone\\old-cache', 'title': 't'}))
256
+ s = parse(pdf, cache)
257
+ assert s.get('from_cache') is True, s
258
+ assert os.path.normcase(s['out_dir']) == os.path.normcase(d), (s['out_dir'], d)
259
+ assert cache_compatible({}) and cache_compatible({'cache_version': CACHE_VERSION})
260
+ assert not cache_compatible({'cache_version': CACHE_VERSION + 1})
261
+ print('✅ cache selftest ok')
262
+ return 0
263
+ finally:
264
+ shutil.rmtree(tmp, ignore_errors=True)
265
+
266
+
267
+ def parse(pdf_path: str, cache_dir: str, keep_zip: bool = False) -> dict:
268
+ if not os.path.isfile(pdf_path):
269
+ raise MineruError(f'PDF 不存在: {pdf_path}')
270
+
271
+ fp = pdf_fingerprint(pdf_path)
272
+ out_dir = os.path.join(cache_dir, fp)
273
+ done_marker = os.path.join(out_dir, '.done')
274
+
275
+ # 缓存命中直接返回
276
+ if os.path.isfile(done_marker):
277
+ print(f'✅ 缓存命中: {out_dir}')
278
+ try:
279
+ cached = json.load(open(os.path.join(out_dir, '.meta.json'), encoding='utf-8'))
280
+ if not cache_compatible(cached):
281
+ print(f" ⚠ 缓存版本不匹配({cached.get('cache_version')} != {CACHE_VERSION}),重新解析")
282
+ else:
283
+ # .meta.json 里的 out_dir 是生成缓存时写入的绝对路径;缓存被搬过目录
284
+ # (例如从云盘 vault 的 .mineru-cache 迁到仓库 cache/)后必须改回当前路径
285
+ cached['out_dir'] = out_dir
286
+ cached['from_cache'] = True
287
+ cached['parse_seconds'] = 0.0
288
+ if os.path.isfile(os.path.join(out_dir, 'full.md')):
289
+ return cached
290
+ print(' ⚠ 缓存缺 full.md,重新解析')
291
+ except Exception:
292
+ pass
293
+
294
+ os.makedirs(cache_dir, exist_ok=True)
295
+ tmp_dir = os.path.join(cache_dir, f'_tmp_{fp}')
296
+ if os.path.isdir(tmp_dir):
297
+ shutil.rmtree(tmp_dir, ignore_errors=True)
298
+ os.makedirs(tmp_dir, exist_ok=True)
299
+
300
+ name = safe_ascii_name(os.path.basename(pdf_path))
301
+ size_mb = os.path.getsize(pdf_path) / 1048576
302
+ print(f'📄 {os.path.basename(pdf_path)} ({size_mb:.2f} MB)')
303
+ print(f' 上传名: {name}')
304
+
305
+ # 1. 建批次
306
+ print(' [1/4] 建批次…')
307
+ resp = api_call('/file-urls/batch', 'POST', {
308
+ 'enable_formula': True,
309
+ 'enable_table': True,
310
+ 'language': 'ch',
311
+ 'model_version': 'vlm',
312
+ 'files': [{'name': name, 'is_ocr': False}],
313
+ })
314
+ if resp.get('code') != 0:
315
+ raise MineruError(f'建批次失败: {resp}')
316
+ batch_id = resp['data']['batch_id']
317
+ upload_url = resp['data']['file_urls'][0]
318
+
319
+ # 2. 上传
320
+ print(' [2/4] 上传…')
321
+ upload_pdf(upload_url, pdf_path)
322
+
323
+ # 3. 轮询
324
+ print(' [3/4] 解析中…')
325
+ t0 = time.time()
326
+ state = None
327
+ result = None
328
+ while True:
329
+ elapsed = time.time() - t0
330
+ if elapsed > TIMEOUT:
331
+ raise MineruError(f'超时 {TIMEOUT}s')
332
+
333
+ try:
334
+ resp = api_call(f'/extract-results/batch/{batch_id}')
335
+ except Exception as exc:
336
+ print(f' 轮询异常: {exc}')
337
+ time.sleep(POLL_FAST)
338
+ continue
339
+
340
+ code = resp.get('code')
341
+ if code != 0:
342
+ c = str(code)
343
+ if c == DAILY_LIMIT_CODE:
344
+ raise QuotaExhausted('每日配额用尽(-60018)—— 必须停止整个队列,明日再跑')
345
+ if c in TERMINAL_CODES:
346
+ raise MineruError(f'终态错误 {c}: {resp.get("msg")}')
347
+ time.sleep(POLL_FAST)
348
+ continue
349
+
350
+ items = resp['data'].get('extract_result') or []
351
+ if not items:
352
+ time.sleep(POLL_FAST)
353
+ continue
354
+
355
+ r0 = items[0]
356
+ st = r0.get('state')
357
+ if st != state:
358
+ print(f' [ {elapsed:5.1f}s ] {st}')
359
+ state = st
360
+
361
+ if st == 'done':
362
+ result = r0
363
+ break
364
+ if st == 'failed':
365
+ raise MineruError(f'解析失败: {r0.get("err_msg")}')
366
+
367
+ prog = r0.get('extract_progress') or {}
368
+ if prog.get('total_pages') and st == 'running':
369
+ print(f' {prog.get("extracted_pages")}/{prog.get("total_pages")} 页')
370
+
371
+ if elapsed < POLL_FAST_UNTIL:
372
+ wait = POLL_FAST
373
+ elif elapsed < POLL_MED_UNTIL:
374
+ wait = POLL_MED
375
+ else:
376
+ wait = POLL_SLOW
377
+ time.sleep(wait)
378
+
379
+ parse_time = time.time() - t0
380
+
381
+ # 4. 下载 + 解压
382
+ print(' [4/4] 下载 ZIP…')
383
+ zip_url = result['full_zip_url']
384
+ zip_path = os.path.join(tmp_dir, 'result.zip')
385
+ download_zip(zip_url, zip_path)
386
+
387
+ with zipfile.ZipFile(zip_path) as z:
388
+ z.extractall(tmp_dir)
389
+ names = z.namelist()
390
+ img_count = len([n for n in names if n.startswith('images/') and not n.endswith('/')])
391
+ print(f' 解压完成:{len(names)} 条目,{img_count} 张图')
392
+
393
+ # 落盘(去掉 tmp 里的 zip)
394
+ if os.path.isdir(out_dir):
395
+ shutil.rmtree(out_dir, ignore_errors=True)
396
+ if not keep_zip:
397
+ try:
398
+ os.remove(zip_path)
399
+ except OSError:
400
+ pass
401
+ shutil.move(tmp_dir, out_dir)
402
+
403
+ summary = build_summary(out_dir)
404
+ summary['cache_version'] = CACHE_VERSION
405
+ summary.update({
406
+ 'source_pdf': os.path.abspath(pdf_path),
407
+ 'fingerprint': fp,
408
+ 'batch_id': batch_id,
409
+ 'parse_seconds': round(parse_time, 1),
410
+ 'zip_url': zip_url,
411
+ 'out_dir': out_dir,
412
+ 'parsed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
413
+ })
414
+ with open(os.path.join(out_dir, '.meta.json'), 'w', encoding='utf-8') as f:
415
+ json.dump(summary, f, ensure_ascii=False, indent=1)
416
+ open(done_marker, 'w').close()
417
+
418
+ return summary
419
+
420
+
421
+ def main():
422
+ ap = argparse.ArgumentParser(description='MinerU 解析 PDF(Zotero 退场后的解析积木)')
423
+ ap.add_argument('pdf', nargs='?', help='PDF 文件路径')
424
+ ap.add_argument('--out', default=DEFAULT_CACHE, help=f'缓存根目录(默认 {DEFAULT_CACHE})')
425
+ ap.add_argument('--keep-zip', action='store_true', help='保留原始 ZIP')
426
+ ap.add_argument('--json', action='store_true', help='只输出 JSON 摘要')
427
+ ap.add_argument('--selftest-cache', action='store_true', help='离线自检缓存迁移逻辑(不耗 API)')
428
+ args = ap.parse_args()
429
+
430
+ if args.selftest_cache:
431
+ return _cache_selftest()
432
+ if not args.pdf:
433
+ ap.error('缺少 PDF 路径')
434
+
435
+ try:
436
+ s = parse(args.pdf, args.out, args.keep_zip)
437
+ except MineruError as exc:
438
+ print(f'❌ {exc}', file=sys.stderr)
439
+ return 1
440
+
441
+ if args.json:
442
+ print(json.dumps(s, ensure_ascii=False, indent=1))
443
+ return 0
444
+
445
+ print()
446
+ print('─' * 62)
447
+ print('✅ 解析完成')
448
+ print(f" 输出目录 : {s['out_dir']}")
449
+ print(f" 耗时 : {s['parse_seconds']} 秒")
450
+ print(f" 标题 : {s.get('title')}")
451
+ print(f" 正文字符 : {s.get('md_chars', 0):,}")
452
+ print(f" 图片 : {s.get('images', 0)} 张"
453
+ f"(引用 {s.get('image_refs', 0)} 处)")
454
+ if s.get('layout_pages'):
455
+ print(f" layout.json: {s['layout_pages']} 页 (v{s.get('layout_version')})")
456
+ print(f" manifest : {'有' if s.get('has_manifest') else '无(此 API 版本不产出)'}")
457
+ print('─' * 62)
458
+ return 0
459
+
460
+
461
+ if __name__ == '__main__':
462
+ sys.exit(main())