@ggboy123/dsh-paper-reader 1.3.4 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +221 -21
- package/dist/embed-config.js +116 -0
- package/dist/embed.js +349 -0
- package/dist/formulas.js +272 -0
- package/dist/host.js +733 -110
- package/dist/library-manage.js +752 -0
- package/dist/library.js +187 -69
- package/dist/mineru-config.js +343 -0
- package/dist/mineru.js +1168 -0
- package/dist/search.js +52 -0
- package/dist/tools.js +150 -21
- package/dist/transcribe.js +316 -39
- package/dist/translate-config.js +8 -2
- package/lib/client.js +479 -60
- package/package.json +3 -1
- package/reader/index.html +417 -24
package/dist/mineru.js
ADDED
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
// mineru.ts — MinerU 解析后端(纯函数,不依赖 Cordis)。
|
|
2
|
+
//
|
|
3
|
+
// 两条路径统一返回 MineruParseResult:
|
|
4
|
+
// 1. 本地 legacy API:优先异步 POST /tasks → 轮询 GET /tasks/{id}/result(§2.5 冻结的生产路径),
|
|
5
|
+
// 另保留同步 /file_parse 作为集成测试用冒烟入口(不进工具调用路径)。
|
|
6
|
+
// 2. mineru.net v4 云端:POST /file-urls/batch → PUT 预签名 URL(不带 Content-Type)→
|
|
7
|
+
// 轮询 GET /extract-results/batch/{id} → 下载 zip → 解出 full.md + *_content_list.json。
|
|
8
|
+
// ✅ pipeline 通道已于 2026-10-11 用**真实 token** 端到端实测(19 页真实论文:提交 → 轮询 → 下载 →
|
|
9
|
+
// 投影 → 检索,25 条断言 0 失败、约 19s;18/18 个 equation 的 bbox/LaTeX 与本地产物逐字相同);
|
|
10
|
+
// ⚠️ `vlm` 系列模型与配额耗尽 / 429 / zip 重试等异常路径**仍未实测**(只有 mock)。
|
|
11
|
+
//
|
|
12
|
+
// content_list 的**形态按通道区分**(F-J,t4 实测):
|
|
13
|
+
// · 本地 legacy 通道:`content_list` 是 **JSON 字符串**,必须 `JSON.parse`(§2.4 实测);
|
|
14
|
+
// · 云端 v4 通道:zip 里是 **`*_content_list.json` 数组文件**(首个非空字符是 `[`),直接 parse 文件内容。
|
|
15
|
+
// 两条路径都归一成 MineruBlock[],再按 page_idx(0 起)聚页,
|
|
16
|
+
// 投影成与 pdfjs 路径同构的 .txt + .pages.json(page = page_idx + 1,1 起)。
|
|
17
|
+
import { inflateRawSync } from 'node:zlib';
|
|
18
|
+
/** MinerU 错误:附带 HTTP 状态码(可为 null)与经过脱敏的 message。 */
|
|
19
|
+
export class MineruError extends Error {
|
|
20
|
+
status;
|
|
21
|
+
constructor(message, status = null) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.name = 'MineruError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** 令牌类字符集:短密钥只在两侧都不是这些字符时才替换(见 redactSecret)。 */
|
|
28
|
+
const TOKEN_CHAR_CLASS = '[A-Za-z0-9._~+/=@-]';
|
|
29
|
+
/** 长度达到该值的密钥按字面量全量替换;更短的走「词边界」替换,避免毁掉整条消息。 */
|
|
30
|
+
const SHORT_SECRET_LITERAL_MIN = 4;
|
|
31
|
+
function escapeRegExp(s) {
|
|
32
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 抹掉本次请求实际使用的 key(服务端把 token 原样回显时的最后一道保险)。
|
|
36
|
+
* - 长度 ≥4:按字面量全量替换,保证任何拼装形式都不泄露;
|
|
37
|
+
* - 长度 1~3:只替换「独立出现」的那一段(前后都不是令牌字符),既不泄露又不会把
|
|
38
|
+
* 正文里的普通单词(例如包含该片段的 `abcdef`)一起抹掉(验证报告 O7)。
|
|
39
|
+
* - 同时处理**变形回显**:服务端可能把 key 以 URL 编码或 JSON 转义后的形式写进错误页,
|
|
40
|
+
* 这两种变体也一并抹掉(key 只在 URL 里出现过时也能堵住)。
|
|
41
|
+
*/
|
|
42
|
+
function redactSecret(msg, secret) {
|
|
43
|
+
if (!secret)
|
|
44
|
+
return msg;
|
|
45
|
+
const variants = new Set([secret]);
|
|
46
|
+
const urlEncoded = encodeURIComponent(secret);
|
|
47
|
+
if (urlEncoded !== secret)
|
|
48
|
+
variants.add(urlEncoded);
|
|
49
|
+
const jsonEscaped = secret.replace(/[\\"]/g, (c) => `\\${c}`);
|
|
50
|
+
if (jsonEscaped !== secret)
|
|
51
|
+
variants.add(jsonEscaped);
|
|
52
|
+
let out = msg;
|
|
53
|
+
for (const v of variants) {
|
|
54
|
+
out = v.length >= SHORT_SECRET_LITERAL_MIN
|
|
55
|
+
? out.split(v).join('[redacted]')
|
|
56
|
+
: out.replace(new RegExp(`(?<!${TOKEN_CHAR_CLASS})${escapeRegExp(v)}(?!${TOKEN_CHAR_CLASS})`, 'g'), '[redacted]');
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 把可能的令牌/URL 从错误信息里抹掉:日志与工具输出都不允许出现明文 key。
|
|
62
|
+
* `secret` 是本次请求实际使用的 key——服务端(或中间代理)把 token 原样回显在
|
|
63
|
+
* 错误体里时,只有按字面量替换才能保证不泄露,所以调用点一律把 key 传进来。
|
|
64
|
+
* 导出供 `mineru-config.ts` 的预检复用(预检也会把响应体片段拼进错误,见 F-R2-1)。
|
|
65
|
+
*/
|
|
66
|
+
export function sanitizeDetail(msg, secret = '') {
|
|
67
|
+
const out = redactSecret(msg, secret);
|
|
68
|
+
return out
|
|
69
|
+
// Bearer 之后只吃到「不可能属于令牌」的字符为止:不要把紧跟的 ) ] } , ; 引号 一起吃掉(验证报告 O4)
|
|
70
|
+
.replace(/Bearer\s+[^\s)\]}>"',;]+/gi, 'Bearer [redacted]')
|
|
71
|
+
.replace(/\bsk-[A-Za-z0-9_-]{4,}\b/g, '[redacted]')
|
|
72
|
+
.replace(/[?&](?:token|access_token|signature|Signature|Expires)=[^\s&"']+/gi, (m) => `${m.split('=')[0]}=[redacted]`)
|
|
73
|
+
.replace(/\s+/g, ' ')
|
|
74
|
+
.trim()
|
|
75
|
+
.slice(0, 240);
|
|
76
|
+
}
|
|
77
|
+
function isRecord(v) {
|
|
78
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
79
|
+
}
|
|
80
|
+
/** 从服务端错误体里取可读 message(脱敏;secret=本次请求的 key)。 */
|
|
81
|
+
function errorDetail(body, secret = '') {
|
|
82
|
+
if (!isRecord(body))
|
|
83
|
+
return '';
|
|
84
|
+
const msg = body['error'] ?? body['detail'] ?? body['message'] ?? body['msg'] ?? body['err_msg'];
|
|
85
|
+
if (typeof msg === 'string' && msg.trim())
|
|
86
|
+
return sanitizeDetail(msg, secret);
|
|
87
|
+
if (isRecord(msg)) {
|
|
88
|
+
const inner = msg['message'] ?? msg['msg'];
|
|
89
|
+
if (typeof inner === 'string')
|
|
90
|
+
return sanitizeDetail(inner, secret);
|
|
91
|
+
}
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
function throwIfAborted(signal) {
|
|
95
|
+
if (signal?.aborted)
|
|
96
|
+
throw new MineruError('解析已取消');
|
|
97
|
+
}
|
|
98
|
+
function sleep(ms, signal) {
|
|
99
|
+
throwIfAborted(signal);
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
signal?.removeEventListener('abort', onAbort);
|
|
103
|
+
resolve();
|
|
104
|
+
}, ms);
|
|
105
|
+
const onAbort = () => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
reject(new MineruError('解析已取消'));
|
|
108
|
+
};
|
|
109
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** 组合外部 signal 与单次请求超时(Node 22 有 AbortSignal.any)。 */
|
|
113
|
+
function requestSignal(signal, timeoutMs) {
|
|
114
|
+
const signals = [];
|
|
115
|
+
if (signal)
|
|
116
|
+
signals.push(signal);
|
|
117
|
+
signals.push(AbortSignal.timeout(timeoutMs));
|
|
118
|
+
return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
119
|
+
}
|
|
120
|
+
/** 读取 JSON 响应;非 JSON 返回 null。 */
|
|
121
|
+
async function readJson(res) {
|
|
122
|
+
const text = await res.text().catch(() => '');
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(text);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ── 块投影(§7.4 冻结规则)───────────────────────────────────────────────
|
|
131
|
+
/** 表格 HTML 剥标签:标签替换成空格、解基本实体、折成单行。 */
|
|
132
|
+
export function stripHtmlTags(html) {
|
|
133
|
+
return html
|
|
134
|
+
.replace(/<[^>]*>/g, ' ')
|
|
135
|
+
.replace(/ /g, ' ')
|
|
136
|
+
.replace(/&/g, '&')
|
|
137
|
+
.replace(/</g, '<')
|
|
138
|
+
.replace(/>/g, '>')
|
|
139
|
+
.replace(/"/g, '"')
|
|
140
|
+
.replace(/'|'/g, "'")
|
|
141
|
+
.replace(/\s+/g, ' ')
|
|
142
|
+
.trim();
|
|
143
|
+
}
|
|
144
|
+
// ── 数学区间空白压缩 + 单 token 花括号归一化(§7.4 增补)─────────────────
|
|
145
|
+
//
|
|
146
|
+
// MinerU 的 LaTeX 在**每个 token 之间**都插空格(`x _ { t - 1 }`),而 search_paper 的
|
|
147
|
+
// 关键词检索是纯子串匹配(查询按空格切词 → indexOf 计数),于是任何「正常写法」的公式
|
|
148
|
+
// 查询(`x_{t-1}`、`Q_t`)都是 0 命中。这里**只在投影阶段**(content_list → .txt)做两步
|
|
149
|
+
// 归一化;`.mineru.md` / `.mineru.json`(markdown / blocks)保持 MinerU 原样。
|
|
150
|
+
//
|
|
151
|
+
// 第一步 compressMathSpaces:压掉数学区间内部的空白。
|
|
152
|
+
// 第二步 normalizeMathBraces:`_`/`^` 的参数是**单 token**(一个字符或一个控制字)时去掉
|
|
153
|
+
// 花括号(`Q_{t}` → `Q_t`、`^{2}` → `^2`、`_{\alpha}` → `_\alpha`)。
|
|
154
|
+
// 语义边界(不可放宽):`Q_t` ≡ `Q_{t}` 是等价的,所以可以归一化;但
|
|
155
|
+
// `x_ij` ≠ `x_{ij}`——后者把 `ij` 整体当下标,前者只把 `i` 当下标、`j` 落回正文字号。
|
|
156
|
+
// 因此多 token 参数(`x_{t-1}`、`x_{ij}`、`^{K \times K}`)与空参数(`_{}`)必须保留花括号。
|
|
157
|
+
//
|
|
158
|
+
// 三条硬边界(宁可少压缩,绝不误伤):
|
|
159
|
+
// 1. 只在 `$...$` / `$$...$$` 区间内动手;区间外逐字节不变(无 `$` 直接快路径返回)。
|
|
160
|
+
// 2. `\text{...}` 一族(`\textrm` / `\textnormal` / …)内部空格是排版语义,成组原样保留,
|
|
161
|
+
// 组内的 `_`/`^` 也不归一化(那是正文,不是数学下标)。
|
|
162
|
+
// 3. 转义 `\$`、未闭合的 `$`、以及「行内定界符两侧有空白的 `$`」(价格 `$5`、
|
|
163
|
+
// 变量名 `$HOME`、正则、以及 MinerU 自己产出的 `\in$ $...$` 相邻区间)都不当定界符。
|
|
164
|
+
//
|
|
165
|
+
// 已知边界(与 pandoc / markdown-it 的 tex_math_dollars 规则一致):行内区间只要求
|
|
166
|
+
// 「开定界符后紧贴非空白、闭定界符前紧贴非空白」,所以同一段正文里同时出现 `$变量`
|
|
167
|
+
// 与「行尾 `$` 的正则」(如 `uses $HOME ... ^[a-z]$`)时仍可能被判成数学区间而压掉
|
|
168
|
+
// 其中的空格。要彻底消除歧义得上完整的 LaTeX 词法分析;真实 MinerU 语料 14 页实测
|
|
169
|
+
// 未出现该形状(详见 test/mineru-math-spaces.test.mjs 的定义域用例)。
|
|
170
|
+
//
|
|
171
|
+
// 归一化的已知代价:花括号形式被改写后,书写带括号的查询(`Q_{t}`)在投影产物里不再命中
|
|
172
|
+
// (改命中 `Q_t`)。这是本轮验收明确选择的形态(MinerU 一律产出 `Q_{t}`,而用户/模型
|
|
173
|
+
// 习惯写 `Q_t`)。
|
|
174
|
+
/**
|
|
175
|
+
* 「内容按正文排版、空格有语义」的命令白名单(**精确全名**匹配 —— F-R1:
|
|
176
|
+
* 前缀匹配会把 `\textwidth` / `\textstyle` 这类非正文命令也卷进来)。
|
|
177
|
+
* 注:`\textbf` 与白名单里的 `\textsf` / `\texttt` 同族(`\textbf{hello world}` 的空格同样是
|
|
178
|
+
* 排版语义),故一并纳入;多参数命令 `\textcolor` 见 TEXT_GROUP_COMMANDS_MULTI。
|
|
179
|
+
*/
|
|
180
|
+
const TEXT_GROUP_COMMANDS = new Set([
|
|
181
|
+
'text', 'textrm', 'textnormal', 'textup', 'textit', 'textbf', 'textsf', 'texttt', 'textsl', 'textsc', 'textmd',
|
|
182
|
+
'textsuperscript', 'textsubscript', 'mbox', 'hbox', 'fbox', 'operatorname', 'intertext', 'shortintertext',
|
|
183
|
+
]);
|
|
184
|
+
/**
|
|
185
|
+
* 多参数命令 → **参数组个数**(F-R1 后半 + F-V2)。声明几个就保护几个,前缀里的每个参数组都保护:
|
|
186
|
+
* `\textcolor{red}{hello world}`(2:颜色 + 正文)、`\colorbox{yellow}{hello world}`(2)、
|
|
187
|
+
* `\fcolorbox{red}{blue}{keep me}`(3:边框色 + 底色 + 正文)。
|
|
188
|
+
* 未列入此表的命令(含所有单组命令)按 1 组处理;**命令之后不属于它参数的独立组不受保护**:
|
|
189
|
+
* `\textcolor{red}{hello world} {normal text}` 里的 `{normal text}` 是独立组,照常压缩空格。
|
|
190
|
+
*/
|
|
191
|
+
const TEXT_GROUP_COMMANDS_MULTI = new Map([
|
|
192
|
+
['textcolor', 2],
|
|
193
|
+
['colorbox', 2],
|
|
194
|
+
['fcolorbox', 3],
|
|
195
|
+
]);
|
|
196
|
+
/** 该命令的参数组是否需要原样保留内部空白。 */
|
|
197
|
+
function keepsInnerSpaces(cmd) {
|
|
198
|
+
return TEXT_GROUP_COMMANDS.has(cmd) || TEXT_GROUP_COMMANDS_MULTI.has(cmd);
|
|
199
|
+
}
|
|
200
|
+
/** 该命令要保护的连续参数组个数(多参数命令按声明个数,其余 1 组)。 */
|
|
201
|
+
function argGroupCount(cmd) {
|
|
202
|
+
return TEXT_GROUP_COMMANDS_MULTI.get(cmd) ?? 1;
|
|
203
|
+
}
|
|
204
|
+
function isAsciiLetter(ch) {
|
|
205
|
+
return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'));
|
|
206
|
+
}
|
|
207
|
+
function isSpaceChar(ch) {
|
|
208
|
+
return ch !== undefined && /\s/.test(ch);
|
|
209
|
+
}
|
|
210
|
+
/** 第 i 个字符是否被反斜杠转义(前面连续的 `\` 个数为奇数)。 */
|
|
211
|
+
function isEscapedAt(text, i) {
|
|
212
|
+
let n = 0;
|
|
213
|
+
for (let k = i - 1; k >= 0 && text[k] === '\\'; k--)
|
|
214
|
+
n++;
|
|
215
|
+
return n % 2 === 1;
|
|
216
|
+
}
|
|
217
|
+
/** out 的末尾是否正好是一个控制字(`\` + 字母,如 `\times`)的结尾。 */
|
|
218
|
+
function endsWithControlWord(out) {
|
|
219
|
+
let k = out.length;
|
|
220
|
+
while (k > 0 && isAsciiLetter(out[k - 1]))
|
|
221
|
+
k--;
|
|
222
|
+
return k < out.length && k > 0 && out[k - 1] === '\\';
|
|
223
|
+
}
|
|
224
|
+
/** 从 `{` 起配对花括号(跳过 `\{` `\}` 这类转义),返回闭合后的下标;不配对返回 -1。 */
|
|
225
|
+
function matchBrace(src, open) {
|
|
226
|
+
let depth = 0;
|
|
227
|
+
for (let k = open; k < src.length; k++) {
|
|
228
|
+
const c = src[k];
|
|
229
|
+
if (c === '\\') {
|
|
230
|
+
k++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (c === '{')
|
|
234
|
+
depth++;
|
|
235
|
+
else if (c === '}') {
|
|
236
|
+
depth--;
|
|
237
|
+
if (depth === 0)
|
|
238
|
+
return k + 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return -1;
|
|
242
|
+
}
|
|
243
|
+
/** 区间内是否含空行(跨段落):跨段落的「数学区间」一定是误配对,直接放弃压缩。 */
|
|
244
|
+
function hasBlankLine(s) {
|
|
245
|
+
return /\n[^\S\n]*\n/.test(s);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 跳过 k 之后的空白后若紧跟 `{`,返回该花括号组 `{ start: '{' 的下标, end: 闭合后的下标 }`;
|
|
249
|
+
* `end === -1` 表示花括号不配对;形状不符(后面不是 `{`)返回 null。
|
|
250
|
+
* 空白压缩与花括号归一化共用(`\text` 族与 `_`/`^` 的参数都按这个形状识别)。
|
|
251
|
+
*/
|
|
252
|
+
function bracedGroupAfter(src, k) {
|
|
253
|
+
let g = k;
|
|
254
|
+
while (g < src.length && isSpaceChar(src[g]))
|
|
255
|
+
g++;
|
|
256
|
+
if (src[g] !== '{')
|
|
257
|
+
return null;
|
|
258
|
+
return { start: g, end: matchBrace(src, g) };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* 读取 `\cmd` 之后需要**原样保留**的花括号组:返回 `{ start: 第一组 '{' 的下标, end: 最后一组闭合后的下标 }`。
|
|
262
|
+
* 读取的组数 = `argGroupCount(cmd)`:单组命令 1 组,多参数命令按声明个数
|
|
263
|
+
* (`\textcolor` 2 组、`\colorbox` 2 组、`\fcolorbox` 3 组 —— 含第三组正文,F-V2)。
|
|
264
|
+
* 只吃「连续」的参数组:命令之后的独立组(`\textcolor{red}{hi} {normal text}` 的后一组)不在此列,
|
|
265
|
+
* 由调用方按普通内容处理。
|
|
266
|
+
* `end === -1` 表示某组不配对(调用方随即原样保留剩余内容);后面根本不是 `{` 则返回 null。
|
|
267
|
+
*/
|
|
268
|
+
function verbatimGroups(src, k, cmd) {
|
|
269
|
+
const want = argGroupCount(cmd);
|
|
270
|
+
let cursor = k;
|
|
271
|
+
let start = -1;
|
|
272
|
+
let end = -1;
|
|
273
|
+
for (let g = 0; g < want; g++) {
|
|
274
|
+
const grp = bracedGroupAfter(src, cursor);
|
|
275
|
+
if (!grp)
|
|
276
|
+
break;
|
|
277
|
+
if (grp.end === -1)
|
|
278
|
+
return { start: start === -1 ? grp.start : start, end: -1 };
|
|
279
|
+
if (start === -1)
|
|
280
|
+
start = grp.start;
|
|
281
|
+
end = grp.end;
|
|
282
|
+
cursor = grp.end;
|
|
283
|
+
}
|
|
284
|
+
return start === -1 ? null : { start, end };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* F-R2:定界符扫描时**跳过 `\text{...}` 一族组的内部**——组里的 `$` 是正文内容,不是定界符
|
|
288
|
+
* (否则 `$\text{costs $5}$` 会被内层 `$` 切断)。返回跳过后的下标;无需跳过时原样返回 i。
|
|
289
|
+
*/
|
|
290
|
+
function skipProtectedGroups(text, i) {
|
|
291
|
+
if (text[i] !== '\\' || isEscapedAt(text, i))
|
|
292
|
+
return i;
|
|
293
|
+
if (!isAsciiLetter(text[i + 1]))
|
|
294
|
+
return i;
|
|
295
|
+
const name = scanCommandName(text, i + 1);
|
|
296
|
+
if (!keepsInnerSpaces(name.cmd))
|
|
297
|
+
return i;
|
|
298
|
+
const grp = verbatimGroups(text, name.end, name.cmd);
|
|
299
|
+
return grp && grp.end !== -1 ? grp.end : i;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* 扫命令名(含 `\operatorname*` 这类「名字后紧跟 `*`」的变体)。
|
|
303
|
+
* 为什么必须吃 `*`:命令名扫描原先在 `*` 处停下,`\operatorname* { m a x }` 因此**没被识别成受保护组**
|
|
304
|
+
* ——带 `*` 的变体丢空格(`{ m a x }`→`{max}`、`{ arg max }`→`{argmax}`),不带 `*` 的变体保留逐字符空格,
|
|
305
|
+
* 两个变体行为不一致(t2 第 2 次修订点名的缺陷)。吃下 `*` 后两变体走同一条路径。
|
|
306
|
+
* 返回纯净命令名(白名单判定用)与「命令名+可选 *」之后的下标(组扫描起点)。
|
|
307
|
+
*/
|
|
308
|
+
function scanCommandName(text, from) {
|
|
309
|
+
let k = from;
|
|
310
|
+
while (k < text.length && isAsciiLetter(text[k]))
|
|
311
|
+
k++;
|
|
312
|
+
const nameEnd = k;
|
|
313
|
+
if (text[k] === '*')
|
|
314
|
+
k++;
|
|
315
|
+
return { cmd: text.slice(from, nameEnd), end: k, nameEnd };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* 压缩**已经是数学正文**的字符串里的空白(调用方负责界定区间)。
|
|
319
|
+
* 唯一保留的空白:控制字与紧跟其后的字母之间留一个空格——`\times K` 若压成 `\timesK`
|
|
320
|
+
* 就成了未定义命令(`\mathbb { R } ^ { K \times K }` → `\mathbb{R}^{K\times K}`,与验收一致)。
|
|
321
|
+
*/
|
|
322
|
+
function compressMathBody(src) {
|
|
323
|
+
let out = '';
|
|
324
|
+
let i = 0;
|
|
325
|
+
while (i < src.length) {
|
|
326
|
+
const ch = src[i];
|
|
327
|
+
if (isSpaceChar(ch)) {
|
|
328
|
+
let j = i;
|
|
329
|
+
while (j < src.length && isSpaceChar(src[j]))
|
|
330
|
+
j++;
|
|
331
|
+
const prev = out[out.length - 1];
|
|
332
|
+
// 控制字/单个 `\` 之后紧跟字母时保留**一个**空格;其余空白(含连续空格、换行)全部压掉
|
|
333
|
+
if (isAsciiLetter(src[j]) && (prev === '\\' || endsWithControlWord(out)))
|
|
334
|
+
out += ' ';
|
|
335
|
+
i = j;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (ch === '\\') {
|
|
339
|
+
const next = src[i + 1];
|
|
340
|
+
if (isAsciiLetter(next)) {
|
|
341
|
+
const nm = scanCommandName(src, i + 1);
|
|
342
|
+
const head = src.slice(i, nm.end); // `\cmd` 或 `\cmd*`:原样搬,绝不吞掉 `*`
|
|
343
|
+
if (keepsInnerSpaces(nm.cmd)) {
|
|
344
|
+
// `\text` 一族:整组(多参数命令是连续多组)逐字节搬走
|
|
345
|
+
const grp = verbatimGroups(src, nm.end, nm.cmd);
|
|
346
|
+
if (grp) {
|
|
347
|
+
if (grp.end !== -1) {
|
|
348
|
+
out += head + src.slice(grp.start, grp.end);
|
|
349
|
+
i = grp.end;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// 花括号不配对(MinerU 偶发截断):从这里往后原样保留,绝不冒险压掉 \text 的内容
|
|
353
|
+
return out + head + src.slice(grp.start);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
out += head;
|
|
357
|
+
i = nm.end;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// 控制符号(`\\` `\$` `\,` `\ ` 等):原样搬两个字符
|
|
361
|
+
if (next === undefined) {
|
|
362
|
+
out += '\\';
|
|
363
|
+
i++;
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
out += '\\' + next;
|
|
367
|
+
i += 2;
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
out += ch;
|
|
372
|
+
i++;
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* `_`/`^` 的花括号参数是否「单 token」(可以安全去掉花括号)。
|
|
378
|
+
* 是:恰好一个字符(`{t}` `{2}`)或恰好一个控制字 `\` + 字母(`{\alpha}`)。
|
|
379
|
+
* 否:多 token(`{ij}` `{t-1}` `{K \times K}`)、空(`{}`)、以及控制符号(`{\%}` `{\{}`——
|
|
380
|
+
* 控制符号去壳会与花括号语义冲突,例如 `_{\{}` 会变成不合法的 `_\{}`)。
|
|
381
|
+
* 另外单字符也排除 `_ ^ \ { } $`:`x_{_}` 去壳得到的 `x__` 是双下标,语义不同。
|
|
382
|
+
*/
|
|
383
|
+
function isSingleTokenArg(inner) {
|
|
384
|
+
if (inner.length === 1)
|
|
385
|
+
return !isSpaceChar(inner) && !'_^{}\\$'.includes(inner);
|
|
386
|
+
return /^\\[A-Za-z]+$/.test(inner);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* 参数是否可以安全去壳。除 isSingleTokenArg 之外还要看**组后紧跟的字符**:
|
|
390
|
+
* 控制字参数后面若紧跟字母,去壳会与它黏成另一个未定义命令(`x_{\alpha}y` → `x_\alphay`),
|
|
391
|
+
* 必须保留花括号(`x_{\alpha}\beta` → `x_\alpha\beta` 这种才是安全的)。
|
|
392
|
+
*/
|
|
393
|
+
function canDropArgBraces(inner, after) {
|
|
394
|
+
if (!isSingleTokenArg(inner))
|
|
395
|
+
return false;
|
|
396
|
+
return !(inner.startsWith('\\') && isAsciiLetter(after));
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* 归一化**已经是数学正文**的字符串里单 token 的下标/上标(调用方负责界定区间)。
|
|
400
|
+
* `Q_{t}` → `Q_t`、`^{2}` → `^2`、`_{\alpha}` → `_\alpha`(LaTeX 里两者语义等价)。
|
|
401
|
+
* 多 token 参数与空参数保留花括号(见 isSingleTokenArg 的语义边界说明)。
|
|
402
|
+
* 只动 `_`/`^` 的参数外壳,其余字符(含空白、控制符号、`\text{}` 组)原样保留。
|
|
403
|
+
*/
|
|
404
|
+
function normalizeMathBody(src) {
|
|
405
|
+
let out = '';
|
|
406
|
+
let i = 0;
|
|
407
|
+
while (i < src.length) {
|
|
408
|
+
const ch = src[i];
|
|
409
|
+
if (ch === '_' || ch === '^') {
|
|
410
|
+
const grp = bracedGroupAfter(src, i + 1);
|
|
411
|
+
if (grp && grp.end !== -1) {
|
|
412
|
+
// 组内递归归一化(`x_{y_{t}}` → `x_{y_t}`),再按语义边界决定是否去壳
|
|
413
|
+
const inner = normalizeMathBody(src.slice(grp.start + 1, grp.end - 1));
|
|
414
|
+
out += canDropArgBraces(inner, src[grp.end]) ? ch + inner : ch + src.slice(i + 1, grp.start) + '{' + inner + '}';
|
|
415
|
+
i = grp.end;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
out += ch;
|
|
419
|
+
i++;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (ch === '\\') {
|
|
423
|
+
const next = src[i + 1];
|
|
424
|
+
if (isAsciiLetter(next)) {
|
|
425
|
+
const nm = scanCommandName(src, i + 1);
|
|
426
|
+
const head = src.slice(i, nm.end); // `\cmd` 或 `\cmd*`
|
|
427
|
+
if (keepsInnerSpaces(nm.cmd)) {
|
|
428
|
+
// `\text` 一族整组(多参数命令为连续多组)原样搬走(组内的 `_`/`^` 是正文,不归一化)
|
|
429
|
+
const grp = verbatimGroups(src, nm.end, nm.cmd);
|
|
430
|
+
if (grp) {
|
|
431
|
+
if (grp.end !== -1) {
|
|
432
|
+
out += head + src.slice(grp.start, grp.end);
|
|
433
|
+
i = grp.end;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
return out + head + src.slice(grp.start);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
out += head;
|
|
440
|
+
i = nm.end;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
// 控制符号(`\\` `\$` `\,` `\ ` 等):原样搬两个字符
|
|
444
|
+
if (next === undefined) {
|
|
445
|
+
out += '\\';
|
|
446
|
+
i++;
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
out += '\\' + next;
|
|
450
|
+
i += 2;
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
out += ch;
|
|
455
|
+
i++;
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
/** 行内区间 `$...$`:返回闭定界符之后的下标;定界符判定失败返回 -1。 */
|
|
460
|
+
function findInlineMathEnd(text, open) {
|
|
461
|
+
// 开定界符之后必须紧贴非空白(`\in$ $\mathbb{...}$` 里第一个 `$` 因此被排除,价格 `$5` 同理)
|
|
462
|
+
if (isSpaceChar(text[open + 1]))
|
|
463
|
+
return -1;
|
|
464
|
+
for (let j = open + 1; j < text.length; j++) {
|
|
465
|
+
// F-R2:`\text{...}` 组内部的 `$` 不算定界符,整组跳过
|
|
466
|
+
const skip = skipProtectedGroups(text, j);
|
|
467
|
+
if (skip > j) {
|
|
468
|
+
j = skip - 1;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (text[j] !== '$' || isEscapedAt(text, j))
|
|
472
|
+
continue;
|
|
473
|
+
// 闭定界符之前必须紧贴非空白;否则这个 `$` 更像「钱/变量」的第二个符号,整段放弃
|
|
474
|
+
if (isSpaceChar(text[j - 1]))
|
|
475
|
+
return -1;
|
|
476
|
+
return hasBlankLine(text.slice(open + 1, j)) ? -1 : j + 1;
|
|
477
|
+
}
|
|
478
|
+
return -1;
|
|
479
|
+
}
|
|
480
|
+
/** 独立区间 `$$...$$`(MinerU 形如 `$$\n...\n$$`):返回闭定界符之后的下标;失败返回 -1。 */
|
|
481
|
+
function findDisplayMathEnd(text, open) {
|
|
482
|
+
for (let j = open + 2; j < text.length - 1; j++) {
|
|
483
|
+
// F-R2:同上,跳过 `\text{...}` 一族组的内部
|
|
484
|
+
const skip = skipProtectedGroups(text, j);
|
|
485
|
+
if (skip > j) {
|
|
486
|
+
j = skip - 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (text[j] !== '$' || text[j + 1] !== '$' || isEscapedAt(text, j))
|
|
490
|
+
continue;
|
|
491
|
+
return hasBlankLine(text.slice(open + 2, j)) ? -1 : j + 2;
|
|
492
|
+
}
|
|
493
|
+
return -1;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* 把 `fn` 应用到每个数学区间(`$...$` / `$$...$$`)的**内容**上;定界符与区间外逐字节保留。
|
|
497
|
+
* 无 `$` 走快路径(连一次字符串拼装都不做),保证不含公式的论文投影产物与改造前完全一致。
|
|
498
|
+
* 孤立的 `$` / 未闭合的区间 / 跨空行的误配对一律原样输出,绝不把后面的正文当数学处理。
|
|
499
|
+
*/
|
|
500
|
+
function mapMathRegions(text, fn) {
|
|
501
|
+
if (!text.includes('$'))
|
|
502
|
+
return text;
|
|
503
|
+
let out = '';
|
|
504
|
+
let i = 0;
|
|
505
|
+
while (i < text.length) {
|
|
506
|
+
const ch = text[i];
|
|
507
|
+
if (ch !== '$' || isEscapedAt(text, i)) {
|
|
508
|
+
out += ch;
|
|
509
|
+
i++;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const end = text[i + 1] === '$' ? findDisplayMathEnd(text, i) : findInlineMathEnd(text, i);
|
|
513
|
+
if (end === -1) {
|
|
514
|
+
out += ch;
|
|
515
|
+
i++;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const delim = text[i + 1] === '$' ? '$$' : '$';
|
|
519
|
+
out += delim + fn(text.slice(i + delim.length, end - delim.length)) + delim;
|
|
520
|
+
i = end;
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* 第一步:压掉数学区间内的空白,区间外**逐字节不变**。导出供测试直接驱动。
|
|
526
|
+
* 唯一保留的空白:控制字与紧跟其后的字母之间留一个空格(见 compressMathBody)。
|
|
527
|
+
*/
|
|
528
|
+
export function compressMathSpaces(text) {
|
|
529
|
+
return mapMathRegions(text, compressMathBody);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* 第二步:归一化数学区间内**单 token** 下标/上标的花括号(`Q_{t}` → `Q_t`),
|
|
533
|
+
* 区间外与 `\text{}` 组内逐字节不变。导出供测试直接驱动。
|
|
534
|
+
*/
|
|
535
|
+
export function normalizeMathBraces(text) {
|
|
536
|
+
return mapMathRegions(text, normalizeMathBody);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* 折叠项 1(查询侧归一化):把**同一套** token 级规则作用在裸文本上(不带 `$` 区间语义),
|
|
540
|
+
* 供 `search_paper` 归一化用户查询(`Q_{t}` → `Q_t`),保证与投影侧**逐字符同规则**
|
|
541
|
+
* (同一个 normalizeMathBody,不存在两套实现漂移的可能)。空白不动。
|
|
542
|
+
*/
|
|
543
|
+
export function normalizeMathTokens(text) {
|
|
544
|
+
return normalizeMathBody(text);
|
|
545
|
+
}
|
|
546
|
+
function blockStr(b, key) {
|
|
547
|
+
const v = b[key];
|
|
548
|
+
return typeof v === 'string' ? v : null;
|
|
549
|
+
}
|
|
550
|
+
function blockStrArr(b, key) {
|
|
551
|
+
const v = b[key];
|
|
552
|
+
if (!Array.isArray(v))
|
|
553
|
+
return [];
|
|
554
|
+
return v.filter((x) => typeof x === 'string');
|
|
555
|
+
}
|
|
556
|
+
function blockPageIdx(b) {
|
|
557
|
+
const v = b['page_idx'];
|
|
558
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* 单块投影为纯文本;无内容返回 null(未知 type 有 text 用 text,有 list_items 用条目,否则跳过)。
|
|
562
|
+
* 数学归一化只在**投影产物**上做:先压空白(`_ { t }` → `_{t}`)再归一化花括号(`_{t}` → `_t`);
|
|
563
|
+
* 原始 block 对象与 markdown 都不改写,所以 `.mineru.json` / `.mineru.md` 仍是 MinerU 原样产出。
|
|
564
|
+
*/
|
|
565
|
+
function blockToText(b) {
|
|
566
|
+
const raw = rawBlockToText(b);
|
|
567
|
+
return raw === null ? null : normalizeMathBraces(compressMathSpaces(raw));
|
|
568
|
+
}
|
|
569
|
+
/** 未经压缩的原始块文本(`$` 无定界符的裸公式与不含 `$` 的正文在这里一字不动,交给 compressMathSpaces 判定)。 */
|
|
570
|
+
function rawBlockToText(b) {
|
|
571
|
+
const text = blockStr(b, 'text');
|
|
572
|
+
if (text && text.trim())
|
|
573
|
+
return text;
|
|
574
|
+
const listItems = blockStrArr(b, 'list_items');
|
|
575
|
+
if (listItems.length)
|
|
576
|
+
return listItems.join('\n');
|
|
577
|
+
const type = typeof b['type'] === 'string' ? b['type'] : '';
|
|
578
|
+
if (type === 'table') {
|
|
579
|
+
const parts = [];
|
|
580
|
+
const caption = blockStrArr(b, 'table_caption');
|
|
581
|
+
if (caption.length)
|
|
582
|
+
parts.push(caption.join(' '));
|
|
583
|
+
const body = blockStr(b, 'table_body');
|
|
584
|
+
if (body) {
|
|
585
|
+
const stripped = stripHtmlTags(body);
|
|
586
|
+
if (stripped)
|
|
587
|
+
parts.push(stripped);
|
|
588
|
+
}
|
|
589
|
+
const footnote = blockStrArr(b, 'table_footnote');
|
|
590
|
+
if (footnote.length)
|
|
591
|
+
parts.push(footnote.join(' '));
|
|
592
|
+
return parts.length ? parts.join('\n') : null;
|
|
593
|
+
}
|
|
594
|
+
if (type === 'image' || type === 'chart') {
|
|
595
|
+
const parts = [];
|
|
596
|
+
const caption = blockStrArr(b, 'image_caption').concat(blockStrArr(b, 'chart_caption'));
|
|
597
|
+
if (caption.length)
|
|
598
|
+
parts.push(caption.join(' '));
|
|
599
|
+
const footnote = blockStrArr(b, 'image_footnote');
|
|
600
|
+
if (footnote.length)
|
|
601
|
+
parts.push(footnote.join(' '));
|
|
602
|
+
return parts.length ? parts.join('\n') : null;
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* 把 content_list 数组投影成 .txt + .pages.json 的形状。
|
|
608
|
+
* 页内块用 \n\n 连接,页间也用 \n\n;page = page_idx + 1(1 起)。
|
|
609
|
+
* 块缺 page_idx 归到上一个有 page_idx 的块页码,之前没有则归第 1 页(记 warnings)。
|
|
610
|
+
*/
|
|
611
|
+
export function projectContentList(blocks, warnings = []) {
|
|
612
|
+
const pageTexts = new Map();
|
|
613
|
+
let lastPage = 0;
|
|
614
|
+
let maxPage = 0;
|
|
615
|
+
for (const b of blocks) {
|
|
616
|
+
const idx = blockPageIdx(b);
|
|
617
|
+
let page;
|
|
618
|
+
if (idx !== null) {
|
|
619
|
+
page = idx + 1;
|
|
620
|
+
lastPage = page;
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
page = lastPage || 1;
|
|
624
|
+
if (lastPage === 0)
|
|
625
|
+
warnings.push('content_list 首个块缺少 page_idx,已归到第 1 页');
|
|
626
|
+
else
|
|
627
|
+
warnings.push(`content_list 存在缺少 page_idx 的块,已归到第 ${page} 页`);
|
|
628
|
+
}
|
|
629
|
+
maxPage = Math.max(maxPage, page);
|
|
630
|
+
const text = blockToText(b);
|
|
631
|
+
if (text === null)
|
|
632
|
+
continue;
|
|
633
|
+
const arr = pageTexts.get(page) ?? [];
|
|
634
|
+
arr.push(text);
|
|
635
|
+
pageTexts.set(page, arr);
|
|
636
|
+
}
|
|
637
|
+
const sortedPages = [...pageTexts.keys()].sort((a, b) => a - b);
|
|
638
|
+
let buf = '';
|
|
639
|
+
const pages = [];
|
|
640
|
+
for (const page of sortedPages) {
|
|
641
|
+
const pageText = pageTexts.get(page).join('\n\n');
|
|
642
|
+
if (!pageText)
|
|
643
|
+
continue;
|
|
644
|
+
const start = buf.length === 0 ? 0 : buf.length + 2;
|
|
645
|
+
buf = buf.length === 0 ? pageText : `${buf}\n\n${pageText}`;
|
|
646
|
+
pages.push({ page, start, end: buf.length });
|
|
647
|
+
}
|
|
648
|
+
return { text: buf, pages, pageCount: Math.max(1, maxPage), warnings };
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* 解析**本地 legacy 通道**的 `content_list`:它是 JSON 字符串(§2.4 实测),`JSON.parse` 后必须是数组。
|
|
652
|
+
* 云端 v4 不走这里——zip 里的 `*_content_list.json` 本身就是数组文件(见 parseCloudZip)。
|
|
653
|
+
*/
|
|
654
|
+
export function parseContentListString(s) {
|
|
655
|
+
if (s === null || s === undefined)
|
|
656
|
+
return null;
|
|
657
|
+
let v;
|
|
658
|
+
try {
|
|
659
|
+
v = JSON.parse(s);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
throw new MineruError('MinerU 返回的 content_list 不是合法 JSON 字符串');
|
|
663
|
+
}
|
|
664
|
+
if (!Array.isArray(v))
|
|
665
|
+
throw new MineruError('MinerU 返回的 content_list 不是数组');
|
|
666
|
+
return v;
|
|
667
|
+
}
|
|
668
|
+
function authHeaders(apiKey) {
|
|
669
|
+
return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
|
670
|
+
}
|
|
671
|
+
/** 组装 /file_parse 与 /tasks 共用的 multipart 参数(§2.2 参数表)。 */
|
|
672
|
+
function buildLocalForm(opts) {
|
|
673
|
+
const form = new FormData();
|
|
674
|
+
form.append('files', new Blob([opts.pdfBytes], { type: 'application/pdf' }), opts.fileName);
|
|
675
|
+
form.append('backend', opts.backend);
|
|
676
|
+
form.append('return_md', 'true');
|
|
677
|
+
form.append('return_content_list', 'true');
|
|
678
|
+
form.append('return_middle_json', 'false');
|
|
679
|
+
form.append('response_format_zip', 'false');
|
|
680
|
+
form.append('formula_enable', 'true');
|
|
681
|
+
form.append('table_enable', 'true');
|
|
682
|
+
form.append('image_analysis', opts.imageAnalysis ? 'true' : 'false');
|
|
683
|
+
form.append('parse_method', opts.parseMethod);
|
|
684
|
+
form.append('effort', opts.effort);
|
|
685
|
+
for (const lang of opts.langList)
|
|
686
|
+
form.append('lang_list', lang);
|
|
687
|
+
if (opts.serverUrl)
|
|
688
|
+
form.append('server_url', opts.serverUrl);
|
|
689
|
+
return form;
|
|
690
|
+
}
|
|
691
|
+
function baseNoSlash(baseUrl) {
|
|
692
|
+
return baseUrl.replace(/\/+$/, '');
|
|
693
|
+
}
|
|
694
|
+
/** 从 results + file_names 拼出统一结果。取结果键用响应里的 file_names[0](§2.2 实测)。 */
|
|
695
|
+
function buildResult(results, fileNames, fallback) {
|
|
696
|
+
const names = Array.isArray(fileNames) ? fileNames.filter((x) => typeof x === 'string') : [];
|
|
697
|
+
const resultsRec = isRecord(results) ? results : {};
|
|
698
|
+
let entry = null;
|
|
699
|
+
const key = names[0];
|
|
700
|
+
if (key && isRecord(resultsRec[key]))
|
|
701
|
+
entry = resultsRec[key];
|
|
702
|
+
if (!entry) {
|
|
703
|
+
const first = Object.values(resultsRec).find(isRecord);
|
|
704
|
+
entry = first ?? null;
|
|
705
|
+
}
|
|
706
|
+
if (!entry)
|
|
707
|
+
throw new MineruError('MinerU 响应缺少 results(或 results 为空对象)');
|
|
708
|
+
const md = entry['md_content'];
|
|
709
|
+
const contentListStr = entry['content_list'];
|
|
710
|
+
const markdown = typeof md === 'string' ? md : '';
|
|
711
|
+
const blocks = parseContentListString(typeof contentListStr === 'string' ? contentListStr : null) ?? [];
|
|
712
|
+
const warnings = [];
|
|
713
|
+
const projection = projectContentList(blocks, warnings);
|
|
714
|
+
const version = typeof fallback.version === 'string' ? fallback.version : undefined;
|
|
715
|
+
const meta = {
|
|
716
|
+
kind: 'mineru-local',
|
|
717
|
+
api: 'legacy',
|
|
718
|
+
baseUrl: fallback.baseUrl,
|
|
719
|
+
...(version ? { serverVersion: version } : {}),
|
|
720
|
+
backend: fallback.backend,
|
|
721
|
+
effort: fallback.opts.effort,
|
|
722
|
+
parseMethod: fallback.opts.parseMethod,
|
|
723
|
+
langList: fallback.opts.langList,
|
|
724
|
+
...(fallback.taskId ? { taskId: fallback.taskId } : {}),
|
|
725
|
+
elapsedMs: Date.now() - fallback.started,
|
|
726
|
+
observedStatuses: fallback.observedStatuses,
|
|
727
|
+
};
|
|
728
|
+
return { markdown, blocks, projection, meta };
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* 本地 MinerU 异步解析:POST /tasks → 轮询 GET /tasks/{id}/result。
|
|
732
|
+
* result 端点三种 HTTP 码必须区分(§2.3):202=未就绪、200=完成、409=解析失败(不是服务忙)。
|
|
733
|
+
*/
|
|
734
|
+
export async function parseLocalMineru(opts) {
|
|
735
|
+
throwIfAborted(opts.signal);
|
|
736
|
+
const base = baseNoSlash(opts.baseUrl);
|
|
737
|
+
const started = Date.now();
|
|
738
|
+
const observedStatuses = [];
|
|
739
|
+
const report = opts.report ?? (() => { });
|
|
740
|
+
// 提交(POST /tasks → 202 pending)。缺 files/扩展名不合法会在此 400/422。
|
|
741
|
+
let submitRes;
|
|
742
|
+
try {
|
|
743
|
+
submitRes = await fetch(`${base}/tasks`, {
|
|
744
|
+
method: 'POST',
|
|
745
|
+
headers: authHeaders(opts.apiKey),
|
|
746
|
+
body: buildLocalForm(opts),
|
|
747
|
+
signal: requestSignal(opts.signal, opts.requestTimeoutMs),
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
throwIfAborted(opts.signal);
|
|
752
|
+
throw new MineruError(`本地 MinerU 提交失败:${sanitizeDetail(networkMessage(err), opts.apiKey)}`);
|
|
753
|
+
}
|
|
754
|
+
const submitBody = await readJson(submitRes);
|
|
755
|
+
if (!submitRes.ok) {
|
|
756
|
+
throw new MineruError(`本地 MinerU 提交失败(HTTP ${submitRes.status})${errorDetail(submitBody, opts.apiKey) ? `:${errorDetail(submitBody, opts.apiKey)}` : ''}`, submitRes.status);
|
|
757
|
+
}
|
|
758
|
+
if (!isRecord(submitBody) || typeof submitBody['task_id'] !== 'string') {
|
|
759
|
+
throw new MineruError('本地 MinerU 提交响应缺少 task_id');
|
|
760
|
+
}
|
|
761
|
+
const taskId = submitBody['task_id'];
|
|
762
|
+
const fileNames = submitBody['file_names'];
|
|
763
|
+
const submitStatus = typeof submitBody['status'] === 'string' ? submitBody['status'] : '';
|
|
764
|
+
if (submitStatus)
|
|
765
|
+
observedStatuses.push(submitStatus);
|
|
766
|
+
report(`本地 MinerU 已提交任务(${taskId.slice(0, 8)}…,${submitStatus || 'pending'})`);
|
|
767
|
+
// 轮询 result 端点:202/200/409/404/网络错 分别处理(§2.3 冻结)。
|
|
768
|
+
let lastResponseAt = Date.now();
|
|
769
|
+
let poll = 0;
|
|
770
|
+
while (true) {
|
|
771
|
+
throwIfAborted(opts.signal);
|
|
772
|
+
if (Date.now() - started >= opts.jobTimeoutMs) {
|
|
773
|
+
throw new MineruError(`本地 MinerU 解析超时(超过 ${Math.round(opts.jobTimeoutMs / 1000)}s)。legacy API 没有取消端点,放弃等待后服务端任务可能仍在跑。`);
|
|
774
|
+
}
|
|
775
|
+
let res;
|
|
776
|
+
try {
|
|
777
|
+
res = await fetch(`${base}/tasks/${encodeURIComponent(taskId)}/result`, {
|
|
778
|
+
headers: authHeaders(opts.apiKey),
|
|
779
|
+
signal: requestSignal(opts.signal, opts.requestTimeoutMs),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
throwIfAborted(opts.signal);
|
|
784
|
+
// 网络错/超时视为可重试,但受 noResponseTimeout 约束(§2.5)。
|
|
785
|
+
if (Date.now() - lastResponseAt >= opts.noResponseTimeoutMs) {
|
|
786
|
+
throw new MineruError(`本地 MinerU 持续无响应(超过 ${Math.round(opts.noResponseTimeoutMs / 1000)}s),请检查或重启本地 MinerU 服务`);
|
|
787
|
+
}
|
|
788
|
+
report(`本地 MinerU 状态查询失败,重试中…(${sanitizeDetail(networkMessage(err), opts.apiKey)})`);
|
|
789
|
+
await sleep(backoffMs(poll++, opts.pollIntervalMs), opts.signal);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
const status = res.status;
|
|
793
|
+
const body = await readJson(res);
|
|
794
|
+
if (status === 200) {
|
|
795
|
+
lastResponseAt = Date.now();
|
|
796
|
+
const backend = isRecord(body) && typeof body['backend'] === 'string' ? body['backend'] : opts.backend;
|
|
797
|
+
const version = isRecord(body) && typeof body['version'] === 'string' ? body['version'] : undefined;
|
|
798
|
+
const results = isRecord(body) ? body['results'] : undefined;
|
|
799
|
+
report('本地 MinerU 解析完成');
|
|
800
|
+
return buildResult(results, fileNames, { baseUrl: base, backend, version, taskId, started, observedStatuses, opts });
|
|
801
|
+
}
|
|
802
|
+
if (status === 202) {
|
|
803
|
+
lastResponseAt = Date.now();
|
|
804
|
+
const st = isRecord(body) && typeof body['status'] === 'string' ? body['status'] : 'processing';
|
|
805
|
+
if (st && !observedStatuses.includes(st))
|
|
806
|
+
observedStatuses.push(st);
|
|
807
|
+
report(`本地 MinerU 解析中…(${st})`);
|
|
808
|
+
await sleep(backoffMs(poll++, opts.pollIntervalMs), opts.signal);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (status === 409) {
|
|
812
|
+
// 409 = 解析失败(不是「服务忙」),不重试(§2.3 / 易踩坑 2)。
|
|
813
|
+
throw new MineruError(`本地 MinerU 解析失败(HTTP 409)${errorDetail(body, opts.apiKey) ? `:${errorDetail(body, opts.apiKey)}` : ''}`, 409);
|
|
814
|
+
}
|
|
815
|
+
if (status === 404) {
|
|
816
|
+
throw new MineruError('本地 MinerU 已丢失该任务(HTTP 404),服务可能重启了,请重试', 404);
|
|
817
|
+
}
|
|
818
|
+
if (status === 401 || status === 403) {
|
|
819
|
+
throw new MineruError(`本地 MinerU 拒绝鉴权(HTTP ${status}),请检查本地 API Key`, status);
|
|
820
|
+
}
|
|
821
|
+
if (status >= 500 || status === 429 || status === 408) {
|
|
822
|
+
if (Date.now() - lastResponseAt >= opts.noResponseTimeoutMs) {
|
|
823
|
+
throw new MineruError(`本地 MinerU 持续返回错误(HTTP ${status},超过 ${Math.round(opts.noResponseTimeoutMs / 1000)}s),请检查或重启服务`, status);
|
|
824
|
+
}
|
|
825
|
+
report(`本地 MinerU 暂时不可用(HTTP ${status}),重试中…`);
|
|
826
|
+
await sleep(backoffMs(poll++, opts.pollIntervalMs), opts.signal);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
throw new MineruError(`本地 MinerU 返回意外状态(HTTP ${status})${errorDetail(body, opts.apiKey) ? `:${errorDetail(body, opts.apiKey)}` : ''}`, status);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/** 同步 /file_parse:仅用于集成测试冒烟(§2.5,不进工具调用路径)。 */
|
|
833
|
+
export async function parseLocalMineruSync(opts) {
|
|
834
|
+
throwIfAborted(opts.signal);
|
|
835
|
+
const base = baseNoSlash(opts.baseUrl);
|
|
836
|
+
const started = Date.now();
|
|
837
|
+
let res;
|
|
838
|
+
try {
|
|
839
|
+
res = await fetch(`${base}/file_parse`, {
|
|
840
|
+
method: 'POST',
|
|
841
|
+
headers: authHeaders(opts.apiKey),
|
|
842
|
+
body: buildLocalForm(opts),
|
|
843
|
+
// 同步长连接可能持续数分钟,给足超时(jobTimeoutMs 作为单次上限)
|
|
844
|
+
signal: requestSignal(opts.signal, opts.jobTimeoutMs),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
catch (err) {
|
|
848
|
+
throwIfAborted(opts.signal);
|
|
849
|
+
throw new MineruError(`本地 MinerU 同步解析失败:${sanitizeDetail(networkMessage(err), opts.apiKey)}`);
|
|
850
|
+
}
|
|
851
|
+
const body = await readJson(res);
|
|
852
|
+
if (!res.ok) {
|
|
853
|
+
throw new MineruError(`本地 MinerU 同步解析失败(HTTP ${res.status})${errorDetail(body, opts.apiKey) ? `:${errorDetail(body, opts.apiKey)}` : ''}`, res.status);
|
|
854
|
+
}
|
|
855
|
+
if (!isRecord(body))
|
|
856
|
+
throw new MineruError('本地 MinerU 同步响应不是 JSON');
|
|
857
|
+
const backend = typeof body['backend'] === 'string' ? body['backend'] : opts.backend;
|
|
858
|
+
const version = typeof body['version'] === 'string' ? body['version'] : undefined;
|
|
859
|
+
const taskId = typeof body['task_id'] === 'string' ? body['task_id'] : undefined;
|
|
860
|
+
return buildResult(body['results'], body['file_names'], { baseUrl: base, backend, version, taskId, started, observedStatuses: [], opts });
|
|
861
|
+
}
|
|
862
|
+
/** 退避:pollInterval × 1.5^poll,上限 10s(§2.5)。 */
|
|
863
|
+
function backoffMs(poll, baseMs) {
|
|
864
|
+
return Math.min(baseMs * 1.5 ** poll, 10_000);
|
|
865
|
+
}
|
|
866
|
+
function networkMessage(err) {
|
|
867
|
+
if (err instanceof Error) {
|
|
868
|
+
const cause = err.cause instanceof Error ? `:${err.cause.message}` : '';
|
|
869
|
+
return `${err.name === 'TimeoutError' ? '请求超时' : err.message}${cause}`;
|
|
870
|
+
}
|
|
871
|
+
return String(err);
|
|
872
|
+
}
|
|
873
|
+
const decoder = new TextDecoder('utf-8');
|
|
874
|
+
/**
|
|
875
|
+
* 从 ZIP 字节里读出全部条目(EOCD → 中央目录 → local header)。
|
|
876
|
+
* method 0 直取、method 8 inflateRaw;只用中央目录的 compSize 定位,兼容 data descriptor。
|
|
877
|
+
*/
|
|
878
|
+
export function extractZipEntries(bytes) {
|
|
879
|
+
if (bytes.length < 22 || bytes[0] !== 0x50 || bytes[1] !== 0x4b) {
|
|
880
|
+
throw new MineruError('下载的结果不是 ZIP 文件');
|
|
881
|
+
}
|
|
882
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
883
|
+
// 反向扫 EOCD(PK\x05\x06),容忍尾部注释
|
|
884
|
+
let eocd = -1;
|
|
885
|
+
for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 65536); i--) {
|
|
886
|
+
if (bytes[i] === 0x50 && bytes[i + 1] === 0x4b && bytes[i + 2] === 0x05 && bytes[i + 3] === 0x06) {
|
|
887
|
+
eocd = i;
|
|
888
|
+
break;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (eocd < 0)
|
|
892
|
+
throw new MineruError('下载的结果缺少 ZIP 结束标记(EOCD)');
|
|
893
|
+
const total = view.getUint16(eocd + 10, true);
|
|
894
|
+
const cdOffset = view.getUint32(eocd + 16, true);
|
|
895
|
+
const entries = [];
|
|
896
|
+
let p = cdOffset;
|
|
897
|
+
for (let n = 0; n < total; n++) {
|
|
898
|
+
if (view.getUint32(p, true) !== 0x02014b50)
|
|
899
|
+
throw new MineruError('ZIP 中央目录损坏');
|
|
900
|
+
const method = view.getUint16(p + 10, true);
|
|
901
|
+
const compSize = view.getUint32(p + 20, true);
|
|
902
|
+
const nameLen = view.getUint16(p + 28, true);
|
|
903
|
+
const extraLen = view.getUint16(p + 30, true);
|
|
904
|
+
const commentLen = view.getUint16(p + 32, true);
|
|
905
|
+
const localOffset = view.getUint32(p + 42, true);
|
|
906
|
+
const name = decoder.decode(bytes.subarray(p + 46, p + 46 + nameLen));
|
|
907
|
+
const lnLen = view.getUint16(localOffset + 26, true);
|
|
908
|
+
const leLen = view.getUint16(localOffset + 28, true);
|
|
909
|
+
const dataStart = localOffset + 30 + lnLen + leLen;
|
|
910
|
+
const raw = bytes.subarray(dataStart, dataStart + compSize);
|
|
911
|
+
let data;
|
|
912
|
+
if (method === 0)
|
|
913
|
+
data = raw;
|
|
914
|
+
else if (method === 8)
|
|
915
|
+
data = inflateRawSync(raw);
|
|
916
|
+
else
|
|
917
|
+
throw new MineruError(`ZIP 压缩方式不支持(method ${method})`);
|
|
918
|
+
entries.push({ name, data });
|
|
919
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
920
|
+
}
|
|
921
|
+
return entries;
|
|
922
|
+
}
|
|
923
|
+
/** 取 full.md(否则任意 .md)与 `*_content_list.json`(否则 content_list.json)——云端 v4 的后者是**数组文件**。 */
|
|
924
|
+
export function pickZipMarkdown(entries) {
|
|
925
|
+
return (entries.find((e) => /(^|[\\/])full\.md$/i.test(e.name)) ??
|
|
926
|
+
entries.find((e) => e.name.toLowerCase().endsWith('.md')));
|
|
927
|
+
}
|
|
928
|
+
export function pickZipContentList(entries) {
|
|
929
|
+
return (entries.find((e) => /content_list\.json$/i.test(e.name)) ??
|
|
930
|
+
entries.find((e) => e.name.toLowerCase().endsWith('_content_list.json')));
|
|
931
|
+
}
|
|
932
|
+
const CLOUD_TERMINAL_CODES = new Set(['A0202', 'A0211', '-60012', '-60013']);
|
|
933
|
+
const CLOUD_DAILY_LIMIT_CODE = '-60018';
|
|
934
|
+
const CLOUD_NO_STATUS_TIMEOUT_MS = 10 * 60 * 1000;
|
|
935
|
+
const CLOUD_PRE_PROCESSING_TIMEOUT_MS = 30 * 60 * 1000;
|
|
936
|
+
const CLOUD_RATE_LIMIT_MAX_POLL_INTERVAL_MS = 60 * 1000;
|
|
937
|
+
const CLOUD_ZIP_RETRY_DELAYS_MS = [5000, 15000];
|
|
938
|
+
// CDN 证书 2026-10-02 过期,同一产物在 OSS 桶里有副本;只做精确 host 映射。
|
|
939
|
+
const MINERU_ZIP_HOST_FALLBACKS = {
|
|
940
|
+
'cdn-mineru.openxlab.org.cn': 'mineru.oss-cn-shanghai.aliyuncs.com',
|
|
941
|
+
};
|
|
942
|
+
function cloudHeaders(apiKey) {
|
|
943
|
+
return { authorization: `Bearer ${apiKey}` };
|
|
944
|
+
}
|
|
945
|
+
/** 云端上传文件名 ASCII 化(非 ASCII → "_"),缓存仍按本地 stem 落盘。 */
|
|
946
|
+
export function asciiSanitize(fileName) {
|
|
947
|
+
return fileName.replace(/[^\x20-\x7E]/g, '_') || 'paper.pdf';
|
|
948
|
+
}
|
|
949
|
+
function rewriteZipHost(url) {
|
|
950
|
+
try {
|
|
951
|
+
const u = new URL(url);
|
|
952
|
+
const mapped = MINERU_ZIP_HOST_FALLBACKS[u.host];
|
|
953
|
+
if (mapped) {
|
|
954
|
+
u.host = mapped;
|
|
955
|
+
return u.href;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
catch { /* 非法 URL 原样返回 */ }
|
|
959
|
+
return url;
|
|
960
|
+
}
|
|
961
|
+
export async function parseCloudMineru(opts) {
|
|
962
|
+
throwIfAborted(opts.signal);
|
|
963
|
+
const base = baseNoSlash(opts.baseUrl);
|
|
964
|
+
const apiKey = opts.apiKey;
|
|
965
|
+
const started = Date.now();
|
|
966
|
+
const report = opts.report ?? (() => { });
|
|
967
|
+
if (!apiKey)
|
|
968
|
+
throw new MineruError('云端 MinerU 需要 API Key(mineru.net 的 token)');
|
|
969
|
+
const fileName = asciiSanitize(opts.fileName);
|
|
970
|
+
// 1) 申请批量上传地址
|
|
971
|
+
let batchRes;
|
|
972
|
+
try {
|
|
973
|
+
batchRes = await fetch(`${base}/file-urls/batch`, {
|
|
974
|
+
method: 'POST',
|
|
975
|
+
headers: { ...cloudHeaders(apiKey), 'content-type': 'application/json' },
|
|
976
|
+
body: JSON.stringify({
|
|
977
|
+
enable_formula: true,
|
|
978
|
+
enable_table: true,
|
|
979
|
+
language: 'ch',
|
|
980
|
+
model_version: opts.modelVersion,
|
|
981
|
+
files: [{ name: fileName, is_ocr: false }],
|
|
982
|
+
}),
|
|
983
|
+
signal: requestSignal(opts.signal, 60_000),
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
throwIfAborted(opts.signal);
|
|
988
|
+
throw new MineruError(`云端 MinerU 申请上传地址失败:${sanitizeDetail(networkMessage(err), apiKey)}`);
|
|
989
|
+
}
|
|
990
|
+
const batchBody = await readJson(batchRes);
|
|
991
|
+
if (batchRes.status === 429 || (isRecord(batchBody) && /rate.?limit|quota|exceeded|limit.*reached/i.test(String(batchBody['msg'] ?? '')))) {
|
|
992
|
+
throw new MineruError('云端 MinerU 当日额度用尽或限流(HTTP 429)', 429);
|
|
993
|
+
}
|
|
994
|
+
if (!batchRes.ok) {
|
|
995
|
+
throw new MineruError(`云端 MinerU 申请上传地址失败(HTTP ${batchRes.status})${errorDetail(batchBody, apiKey) ? `:${errorDetail(batchBody, apiKey)}` : ''}`, batchRes.status);
|
|
996
|
+
}
|
|
997
|
+
const batchData = isRecord(batchBody) ? batchBody['data'] : undefined;
|
|
998
|
+
const batchId = isRecord(batchData) && typeof batchData['batch_id'] === 'string' ? batchData['batch_id'] : '';
|
|
999
|
+
const fileUrls = isRecord(batchData) && Array.isArray(batchData['file_urls']) ? batchData['file_urls'].filter((x) => typeof x === 'string') : [];
|
|
1000
|
+
if (!batchId || fileUrls.length === 0) {
|
|
1001
|
+
throw new MineruError('云端 MinerU 响应缺少 batch_id 或 file_urls');
|
|
1002
|
+
}
|
|
1003
|
+
// 2) PUT 预签名 URL 上传字节(不带 Content-Type,OSS 签名不含它,加了会 403)
|
|
1004
|
+
report('云端 MinerU 上传中…');
|
|
1005
|
+
let putRes;
|
|
1006
|
+
try {
|
|
1007
|
+
putRes = await fetch(fileUrls[0], {
|
|
1008
|
+
method: 'PUT',
|
|
1009
|
+
body: opts.pdfBytes,
|
|
1010
|
+
signal: requestSignal(opts.signal, 60_000),
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
throwIfAborted(opts.signal);
|
|
1015
|
+
throw new MineruError(`云端 MinerU 上传失败:${sanitizeDetail(networkMessage(err), apiKey)}`);
|
|
1016
|
+
}
|
|
1017
|
+
if (putRes.status < 200 || putRes.status >= 300) {
|
|
1018
|
+
throw new MineruError(`云端 MinerU 上传失败(HTTP ${putRes.status},预签名 URL)`, putRes.status);
|
|
1019
|
+
}
|
|
1020
|
+
// 3) 轮询结果
|
|
1021
|
+
const pollStartMs = Date.now();
|
|
1022
|
+
let lastStatusAtMs = null;
|
|
1023
|
+
let activeStartedAtMs = null;
|
|
1024
|
+
let rateLimitDelayMs = 0;
|
|
1025
|
+
let polls = 0;
|
|
1026
|
+
while (true) {
|
|
1027
|
+
throwIfAborted(opts.signal);
|
|
1028
|
+
const nowMs = Date.now();
|
|
1029
|
+
// 无状态超时 / 开始处理前等待上限(§3)
|
|
1030
|
+
if (lastStatusAtMs === null ? nowMs - pollStartMs >= CLOUD_NO_STATUS_TIMEOUT_MS : nowMs - lastStatusAtMs >= CLOUD_NO_STATUS_TIMEOUT_MS) {
|
|
1031
|
+
throw new MineruError('云端 MinerU 状态超时:长时间没有可识别状态');
|
|
1032
|
+
}
|
|
1033
|
+
if (activeStartedAtMs === null && nowMs - pollStartMs >= CLOUD_PRE_PROCESSING_TIMEOUT_MS) {
|
|
1034
|
+
throw new MineruError('云端 MinerU 等待开始处理超时(30 分钟)');
|
|
1035
|
+
}
|
|
1036
|
+
const delay = Math.max(opts.pollIntervalMs, rateLimitDelayMs);
|
|
1037
|
+
await sleep(delay, opts.signal);
|
|
1038
|
+
let pollRes;
|
|
1039
|
+
try {
|
|
1040
|
+
pollRes = await fetch(`${base}/extract-results/batch/${encodeURIComponent(batchId)}`, {
|
|
1041
|
+
headers: cloudHeaders(apiKey),
|
|
1042
|
+
signal: requestSignal(opts.signal, 60_000),
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
catch (err) {
|
|
1046
|
+
throwIfAborted(opts.signal);
|
|
1047
|
+
report(`云端 MinerU 状态查询失败,重试中…(${sanitizeDetail(networkMessage(err), apiKey)})`);
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
const pollBody = await readJson(pollRes);
|
|
1051
|
+
const code = isRecord(pollBody) ? pollBody['code'] : undefined;
|
|
1052
|
+
const codeStr = code === undefined || code === null ? '' : String(code).trim();
|
|
1053
|
+
if (codeStr === CLOUD_DAILY_LIMIT_CODE) {
|
|
1054
|
+
throw new MineruError(`云端 MinerU 当日解析额度用尽(${CLOUD_DAILY_LIMIT_CODE})${errorDetail(pollBody, apiKey) ? `:${errorDetail(pollBody, apiKey)}` : ''}`);
|
|
1055
|
+
}
|
|
1056
|
+
if (pollRes.status === 429) {
|
|
1057
|
+
rateLimitDelayMs = Math.min(Math.max(rateLimitDelayMs, opts.pollIntervalMs) * 2, CLOUD_RATE_LIMIT_MAX_POLL_INTERVAL_MS);
|
|
1058
|
+
report(`云端 MinerU 状态请求被限流,退避重试…`);
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
rateLimitDelayMs = 0;
|
|
1062
|
+
if (pollRes.status === 401 || pollRes.status === 403) {
|
|
1063
|
+
throw new MineruError(`云端 MinerU 鉴权失败(HTTP ${pollRes.status}),token 无效或已过期`, pollRes.status);
|
|
1064
|
+
}
|
|
1065
|
+
if (codeStr && codeStr !== '0') {
|
|
1066
|
+
if (CLOUD_TERMINAL_CODES.has(codeStr)) {
|
|
1067
|
+
throw new MineruError(`云端 MinerU 返回不可重试错误(${codeStr})${errorDetail(pollBody, apiKey) ? `:${errorDetail(pollBody, apiKey)}` : ''}`);
|
|
1068
|
+
}
|
|
1069
|
+
report(`云端 MinerU 状态暂时不可用(${codeStr}),重试中…`);
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
if (pollRes.status < 200 || pollRes.status >= 300) {
|
|
1073
|
+
report(`云端 MinerU 状态暂时不可用(HTTP ${pollRes.status}),重试中…`);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const pollData = isRecord(pollBody) ? pollBody['data'] : undefined;
|
|
1077
|
+
const extractResult = isRecord(pollData) && Array.isArray(pollData['extract_result']) ? pollData['extract_result'][0] : undefined;
|
|
1078
|
+
if (!isRecord(extractResult)) {
|
|
1079
|
+
report('云端 MinerU 尚未返回结果,等待中…');
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
const state = typeof extractResult['state'] === 'string' ? extractResult['state'].trim().toLowerCase() : '';
|
|
1083
|
+
if (state && !['waiting-file', 'pending', 'running', 'converting', 'done', 'failed'].includes(state)) {
|
|
1084
|
+
report(`云端 MinerU 返回未识别状态(${state.slice(0, 60)}),继续等待…`);
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (!state) {
|
|
1088
|
+
report('云端 MinerU 状态为空,等待中…');
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
lastStatusAtMs = Date.now();
|
|
1092
|
+
if ((state === 'running' || state === 'converting') && activeStartedAtMs === null) {
|
|
1093
|
+
activeStartedAtMs = Date.now();
|
|
1094
|
+
}
|
|
1095
|
+
polls++;
|
|
1096
|
+
if (state === 'done') {
|
|
1097
|
+
const fullZipUrl = typeof extractResult['full_zip_url'] === 'string' ? extractResult['full_zip_url'] : '';
|
|
1098
|
+
if (!fullZipUrl)
|
|
1099
|
+
throw new MineruError('云端 MinerU 完成但缺少 full_zip_url');
|
|
1100
|
+
const result = await downloadCloudZip(fullZipUrl, opts);
|
|
1101
|
+
const meta = {
|
|
1102
|
+
kind: 'mineru-cloud',
|
|
1103
|
+
api: 'v4',
|
|
1104
|
+
baseUrl: base,
|
|
1105
|
+
batchId,
|
|
1106
|
+
modelVersion: opts.modelVersion,
|
|
1107
|
+
elapsedMs: Date.now() - started,
|
|
1108
|
+
};
|
|
1109
|
+
return { ...result, meta };
|
|
1110
|
+
}
|
|
1111
|
+
if (state === 'failed') {
|
|
1112
|
+
throw new MineruError(`云端 MinerU 解析失败(state=failed)${errorDetail(extractResult, apiKey) ? `:${errorDetail(extractResult, apiKey)}` : ''}`);
|
|
1113
|
+
}
|
|
1114
|
+
report(`云端 MinerU 解析中…(${state},${Math.round((Date.now() - pollStartMs) / 1000)}s)`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
/** 下载 zip(重试 [5s,15s])+ 解出 markdown 与 content_list。 */
|
|
1118
|
+
async function downloadCloudZip(url, opts) {
|
|
1119
|
+
const target = rewriteZipHost(url);
|
|
1120
|
+
let bytes = null;
|
|
1121
|
+
let lastErr = null;
|
|
1122
|
+
for (let attempt = 0;; attempt++) {
|
|
1123
|
+
throwIfAborted(opts.signal);
|
|
1124
|
+
try {
|
|
1125
|
+
const res = await fetch(target, { signal: requestSignal(opts.signal, opts.zipTimeoutMs) });
|
|
1126
|
+
if (res.ok) {
|
|
1127
|
+
bytes = new Uint8Array(await res.arrayBuffer());
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
lastErr = new MineruError(`下载结果失败(HTTP ${res.status})`, res.status);
|
|
1131
|
+
}
|
|
1132
|
+
catch (err) {
|
|
1133
|
+
throwIfAborted(opts.signal);
|
|
1134
|
+
lastErr = err;
|
|
1135
|
+
}
|
|
1136
|
+
const delay = CLOUD_ZIP_RETRY_DELAYS_MS[attempt];
|
|
1137
|
+
if (delay === undefined)
|
|
1138
|
+
break;
|
|
1139
|
+
await sleep(delay, opts.signal);
|
|
1140
|
+
}
|
|
1141
|
+
if (!bytes)
|
|
1142
|
+
throw new MineruError(`云端 MinerU 下载结果失败:${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
1143
|
+
const entries = extractZipEntries(bytes);
|
|
1144
|
+
const md = pickZipMarkdown(entries);
|
|
1145
|
+
if (!md)
|
|
1146
|
+
throw new MineruError('云端 MinerU 结果 zip 里没有 Markdown 文件');
|
|
1147
|
+
const markdown = decoder.decode(md.data);
|
|
1148
|
+
if (!markdown.trim())
|
|
1149
|
+
throw new MineruError('云端 MinerU 结果 Markdown 为空(可能为纯图片 PDF)');
|
|
1150
|
+
let blocks = [];
|
|
1151
|
+
const clEntry = pickZipContentList(entries);
|
|
1152
|
+
if (clEntry) {
|
|
1153
|
+
let parsed;
|
|
1154
|
+
try {
|
|
1155
|
+
parsed = JSON.parse(decoder.decode(clEntry.data));
|
|
1156
|
+
}
|
|
1157
|
+
catch {
|
|
1158
|
+
throw new MineruError('云端 MinerU content_list 文件不是合法 JSON');
|
|
1159
|
+
}
|
|
1160
|
+
// 云端 v4:zip 里的 `*_content_list.json` 是**数组文件**(首个非空字符 `[`,与本地的 JSON 字符串形态不同);
|
|
1161
|
+
// 兼容「直接是数组」与「{content_list:[...]}」两种布局
|
|
1162
|
+
const arr = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed['content_list']) ? parsed['content_list'] : [];
|
|
1163
|
+
blocks = arr;
|
|
1164
|
+
}
|
|
1165
|
+
const warnings = [];
|
|
1166
|
+
const projection = projectContentList(blocks, warnings);
|
|
1167
|
+
return { markdown, blocks, projection };
|
|
1168
|
+
}
|