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.
- package/README.en.md +12 -5
- package/README.md +25 -22
- package/lib/client.js +663 -179
- package/lib/impl.js +65 -0
- package/package.json +1 -1
- package/pipeline/config/tagmap_/344/273/252/345/231/250.json +554 -554
- package/pipeline/config/tagmap_/345/272/224/347/224/250.json +626 -626
- package/pipeline/config/tagmap_/346/226/271/346/263/225.json +890 -890
- package/pipeline/config/tagmap_/351/242/206/345/237/237.json +221 -221
- package/pipeline/tools/mineru_parse.py +462 -462
|
@@ -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())
|