@ggboy123/dsh-paper-reader 0.2.0 → 0.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/README.md +8 -3
- package/dist/babeldoc-install.js +167 -0
- package/dist/host.js +33 -5
- package/dist/tools.js +1 -1
- package/dist/transcribe.js +120 -102
- package/dist/translate-config.js +12 -2
- package/dist/translate.js +99 -14
- package/package.json +3 -2
- package/presets/paper-reader/agent.cordis.yml +10 -6
- package/presets/paper-reader/preset.yml +1 -1
- package/reader/index.html +121 -18
- package/reader/vendor/pdf.min.mjs +29 -0
- package/reader/vendor/pdf.worker.min.mjs +29 -0
package/README.md
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
- 📖 **PDF 阅读器居中**:中间是论文(PDF.js 缩放 / Retina 高清 / 文本层选择 / 适宽模式),最右是该论文的原生对话 —— 视觉换位实现,列宽拖拽/折叠仍是 dsh 原生行为,关掉 PDF 页签即还原官方布局
|
|
20
20
|
- 💬 **选中即问**:阅读器里选中文字 → 弹出提问框 → 注入当前会话,原生对话区实时回答
|
|
21
21
|
- 📍 **引用定位**:回答里的「第 N 页」可点击,平滑跳回 PDF 对应页并闪烁
|
|
22
|
-
- 🀄 **中英切换**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate`
|
|
23
|
-
- 🔍 **PDF 转录 + 检索**:本地提取(
|
|
22
|
+
- 🀄 **中英切换**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate` 端点;**无需预装 Python**——首次生成时自动下载 uv + 托管 Python + babeldoc(macOS/Linux,约几分钟),全程落在用户目录
|
|
23
|
+
- 🔍 **PDF 转录 + 检索**:本地提取(pdf.js,纯 Node 无需 Python),页眉页脚剔除 / 连字 / 断词愈合 / 段落重排,产出页码偏移表;兼容 pdfqa 的 `data/` 缓存布局(旧缓存读取时自动补建页码索引)
|
|
24
24
|
|
|
25
25
|
## 安装
|
|
26
26
|
|
|
@@ -55,6 +55,10 @@ dsh --profile web # 侧栏变为文献库树
|
|
|
55
55
|
> 中文版端点:**优先在阅读器 UI 里填**(点「中」→ 没配会自动弹表单,或点 ⚙)——填完会先测连接再落盘,不用重启。
|
|
56
56
|
> 上面的 `translate` 配置退为**部署方默认值**,仅当 UI 未配置时生效。babeldoc 只支持 OpenAI 协议端点,
|
|
57
57
|
> Anthropic 协议的端点(如 `api.kimi.com/coding/`)不能直连。
|
|
58
|
+
>
|
|
59
|
+
> 翻译引擎**零预装**:找不到 babeldoc 时自动走 uv 链路安装——uv 独立二进制(GitHub Releases API 下载 + sha256 校验)→
|
|
60
|
+
> uv 托管 Python 3.12 → `uv pip install babeldoc`,落在 `~/.dsh/.dsh-paper-reader/bin/` 与文献库同级 `.venv-pdf2zh/`,
|
|
61
|
+
> 不碰系统 Python。已装有 uv / babeldoc(含 pdfqa 的 `.venv-pdf2zh`)则直接复用。Windows 暂不支持自动安装,需手动装 uv 后重试。
|
|
58
62
|
|
|
59
63
|
> 伴读模式是**按需开启**的:默认不注册 `sidebar.workspaces`(官方工作区/会话列表原样),侧栏底部「📚 论文伴读」开关点击才接管,再点还原;模式选择记在 localStorage。退出模式时已打开的 PDF 页签和伴读会话不受影响。
|
|
60
64
|
|
|
@@ -67,10 +71,11 @@ src/
|
|
|
67
71
|
host.ts webServer 路由(/paper-reader/*),connection.requestRejection 鉴权;
|
|
68
72
|
sessionController.create/prompt(agentPreset=paper-reader)+ follow SSE 桥
|
|
69
73
|
library.ts 纯函数:文献库目录约定与解析
|
|
70
|
-
transcribe.ts 纯函数:
|
|
74
|
+
transcribe.ts 纯函数:pdf.js 提取 + 页码偏移表(视觉兜底后置)
|
|
71
75
|
search.ts 纯函数:分段 + 关键词打分 + 页码映射
|
|
72
76
|
study.ts 纯函数:学习档案(计划 + 检验成绩)读写
|
|
73
77
|
translate.ts 纯函数:babeldoc 调用(中文/中英对照 PDF 生成)
|
|
78
|
+
babeldoc-install.ts 纯函数:无 Python 环境时自动安装 babeldoc(uv → 托管 Python → venv)
|
|
74
79
|
translate-config.ts 纯函数:翻译端点配置读写($DSH_HOME 下 0600)+ 保存前连接预检
|
|
75
80
|
preset.ts 纯函数:自带 agent preset 安装到 $DSH_HOME/.agent-presets/
|
|
76
81
|
presets/paper-reader/ 「论文伴读」agent preset(persona 完整提示词 + compaction)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// babeldoc-install.ts — 无 Python 环境用户的 babeldoc 自动安装(纯函数,不依赖 Cordis)。
|
|
2
|
+
//
|
|
3
|
+
// 链路:uv 独立二进制(GitHub Releases,带 sha256 校验)
|
|
4
|
+
// → `uv venv`(uv 自动下载托管 Python,系统没有 Python 也能跑)
|
|
5
|
+
// → `uv pip install babeldoc`
|
|
6
|
+
// 全部落在用户目录:uv 二进制在 <dshHome>/.dsh-paper-reader/bin/uv,
|
|
7
|
+
// venv 在 文献库同级 .venv-pdf2zh(必须与 translate.ts findBabeldoc 的第一候选位一致),
|
|
8
|
+
// 不碰系统 Python、不需要 sudo。win32 不在支持范围(tar 解 zip、exe 后缀都要分叉),直接给手动指引。
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
/** uv 发布的 target triple;不支持的平台返回 null(给手动安装指引)。 */
|
|
16
|
+
function uvTriple() {
|
|
17
|
+
const { platform, arch } = process;
|
|
18
|
+
if (platform === 'darwin')
|
|
19
|
+
return arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin';
|
|
20
|
+
// musl 静态构建:不依赖系统 glibc 版本,最省心
|
|
21
|
+
if (platform === 'linux') {
|
|
22
|
+
if (arch === 'x64')
|
|
23
|
+
return 'x86_64-unknown-linux-musl';
|
|
24
|
+
if (arch === 'arm64')
|
|
25
|
+
return 'aarch64-unknown-linux-musl';
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function run(cmd, args, opts = {}) {
|
|
30
|
+
return new Promise((res, rej) => {
|
|
31
|
+
execFile(cmd, args, {
|
|
32
|
+
timeout: opts.timeout ?? 5 * 60_000,
|
|
33
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
34
|
+
env: { ...process.env, ...opts.env },
|
|
35
|
+
}, (err, stdout, stderr) => {
|
|
36
|
+
if (err) {
|
|
37
|
+
const tail = (stdout + '\n' + stderr).trim().slice(-300);
|
|
38
|
+
rej(new Error(`${cmd} ${args.join(' ')} 失败:${err.message}${tail ? `\n${tail}` : ''}`));
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
res(stdout);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function whichOk(cmd) {
|
|
46
|
+
return new Promise((res) => {
|
|
47
|
+
execFile('which', [cmd], (err, stdout) => res(!err && stdout.trim() !== ''));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** 插件托管的 uv 位置(不落系统目录,删插件目录即清干净)。 */
|
|
51
|
+
export function uvPath(home) {
|
|
52
|
+
return join(home, '.dsh-paper-reader', 'bin', 'uv');
|
|
53
|
+
}
|
|
54
|
+
/** 找 uv:插件托管 → PATH → 常见安装位。 */
|
|
55
|
+
async function findUv(home) {
|
|
56
|
+
const own = uvPath(home);
|
|
57
|
+
if (existsSync(own))
|
|
58
|
+
return own;
|
|
59
|
+
if (await whichOk('uv'))
|
|
60
|
+
return 'uv';
|
|
61
|
+
for (const c of [join(homedir(), '.local', 'bin', 'uv'), join(homedir(), '.cargo', 'bin', 'uv')]) {
|
|
62
|
+
if (existsSync(c))
|
|
63
|
+
return c;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 下载 uv 独立二进制。全程走 api.github.com(资产接口 + octet-stream 直连),
|
|
69
|
+
* 不依赖 github.com 页面域——后者在部分网络环境不可达,而 API 域通常可达。
|
|
70
|
+
* 完整性用 releases API 返回的 digest(sha256)校验,下载完直接执行的二进制不校验等于裸奔。
|
|
71
|
+
* fetch 下载不带 quarantine xattr,macOS 不会拦。
|
|
72
|
+
*/
|
|
73
|
+
async function downloadUv(home, onPhase) {
|
|
74
|
+
const triple = uvTriple();
|
|
75
|
+
if (!triple) {
|
|
76
|
+
throw new Error(`当前平台(${process.platform}/${process.arch})暂不支持自动安装:请手动安装 uv(https://docs.astral.sh/uv/)后重试`);
|
|
77
|
+
}
|
|
78
|
+
const want = `uv-${triple}.tar.gz`;
|
|
79
|
+
const tmp = join(home, '.dsh-paper-reader', 'bin', `.uv-install-${Date.now()}`);
|
|
80
|
+
await mkdir(tmp, { recursive: true });
|
|
81
|
+
try {
|
|
82
|
+
onPhase('正在安装翻译引擎(1/3 下载 uv)…');
|
|
83
|
+
const rel = await fetch('https://api.github.com/repos/astral-sh/uv/releases/latest', {
|
|
84
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-paper-reader' },
|
|
85
|
+
signal: AbortSignal.timeout(30_000),
|
|
86
|
+
});
|
|
87
|
+
if (!rel.ok)
|
|
88
|
+
throw new Error(`查询 uv 版本失败:HTTP ${rel.status}(可手动装 uv 后重试)`);
|
|
89
|
+
const assets = (await rel.json()).assets ?? [];
|
|
90
|
+
const asset = assets.find((a) => a.name === want);
|
|
91
|
+
if (!asset)
|
|
92
|
+
throw new Error(`uv 最新版没有 ${want} 资产`);
|
|
93
|
+
const sumAsset = assets.find((a) => a.name === want + '.sha256');
|
|
94
|
+
const download = async (a) => {
|
|
95
|
+
const r = await fetch(`https://api.github.com/repos/astral-sh/uv/releases/assets/${a.id}`, {
|
|
96
|
+
headers: { accept: 'application/octet-stream', 'user-agent': 'dsh-paper-reader' },
|
|
97
|
+
signal: AbortSignal.timeout(180_000),
|
|
98
|
+
});
|
|
99
|
+
if (!r.ok)
|
|
100
|
+
throw new Error(`下载 ${a.name} 失败:HTTP ${r.status}`);
|
|
101
|
+
return Buffer.from(await r.arrayBuffer());
|
|
102
|
+
};
|
|
103
|
+
const body = await download(asset);
|
|
104
|
+
// 校验和优先用 API 的 digest;老 GitHub 没有 digest 字段时退到 .sha256 资产(同通道)
|
|
105
|
+
let expect = asset.digest?.replace(/^sha256:/, '');
|
|
106
|
+
if (!expect && sumAsset)
|
|
107
|
+
expect = (await download(sumAsset)).toString('utf8').trim().split(/\s+/)[0];
|
|
108
|
+
if (!expect)
|
|
109
|
+
throw new Error('拿不到 uv 校验和,为安全起见放弃安装');
|
|
110
|
+
const actual = createHash('sha256').update(body).digest('hex');
|
|
111
|
+
if (actual !== expect)
|
|
112
|
+
throw new Error('uv 下载文件校验和不匹配,已丢弃(请重试)');
|
|
113
|
+
const tgzPath = join(tmp, 'uv.tar.gz');
|
|
114
|
+
await writeFile(tgzPath, body);
|
|
115
|
+
await run('tar', ['-xzf', tgzPath, '-C', tmp], { timeout: 60_000 });
|
|
116
|
+
const extracted = join(tmp, `uv-${triple}`, 'uv');
|
|
117
|
+
if (!existsSync(extracted))
|
|
118
|
+
throw new Error('uv 解包结果不符合预期');
|
|
119
|
+
const dst = uvPath(home);
|
|
120
|
+
await rename(extracted, dst);
|
|
121
|
+
await chmod(dst, 0o755);
|
|
122
|
+
return dst;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await rm(tmp, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** 建 venv + 装 babeldoc。uv 没有可用 Python 时会自己下载托管版,无需系统 Python。 */
|
|
129
|
+
async function installBabeldoc(uv, venvDir, onPhase) {
|
|
130
|
+
onPhase('正在安装翻译引擎(2/3 安装 Python)…');
|
|
131
|
+
// --python-preference only-managed:不碰用户系统里可能残缺/带 externally-managed 标记的 Python
|
|
132
|
+
await run(uv, ['venv', venvDir, '--python', '3.12', '--python-preference', 'only-managed'], { timeout: 10 * 60_000 });
|
|
133
|
+
const py = join(venvDir, 'bin', 'python');
|
|
134
|
+
onPhase('正在安装翻译引擎(3/3 安装 babeldoc,首次约几分钟)…');
|
|
135
|
+
await run(uv, ['pip', 'install', '--python', py, 'babeldoc'], { timeout: 20 * 60_000 });
|
|
136
|
+
const bin = join(venvDir, 'bin', 'babeldoc');
|
|
137
|
+
if (!existsSync(bin))
|
|
138
|
+
throw new Error('babeldoc 安装完成但未找到可执行文件');
|
|
139
|
+
return bin;
|
|
140
|
+
}
|
|
141
|
+
// 单例:多次点击「中」只允许一个安装流程在跑,其余复用同一个 promise。
|
|
142
|
+
let inflight = null;
|
|
143
|
+
/**
|
|
144
|
+
* 确保 babeldoc 可用,返回可执行文件路径。
|
|
145
|
+
* 已存在(venv/PATH/miniconda)直接返回;否则后台走完 uv → python → babeldoc 全链路。
|
|
146
|
+
* venv 落 dirname(dataDir)/.venv-pdf2zh —— 与 translate.ts findBabeldoc 的第一候选位严格一致。
|
|
147
|
+
*/
|
|
148
|
+
export function ensureBabeldoc(dataDir, home, findExisting, onPhase) {
|
|
149
|
+
if (!inflight) {
|
|
150
|
+
inflight = (async () => {
|
|
151
|
+
const existing = await findExisting();
|
|
152
|
+
if (existing)
|
|
153
|
+
return existing;
|
|
154
|
+
const venvDir = join(dirname(dataDir), '.venv-pdf2zh');
|
|
155
|
+
const uv = (await findUv(home)) ?? (await downloadUv(home, onPhase));
|
|
156
|
+
return installBabeldoc(uv, venvDir, onPhase);
|
|
157
|
+
})().finally(() => {
|
|
158
|
+
// 成功后清掉单例让下次走快速路径;失败也清掉让下次可以重试
|
|
159
|
+
inflight = null;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// 并发复用:把 phase 回调接到同一条进度线上(各自显示各自的状态栏)
|
|
164
|
+
inflight.then(() => onPhase('翻译引擎已就绪')).catch(() => { });
|
|
165
|
+
}
|
|
166
|
+
return inflight;
|
|
167
|
+
}
|
package/dist/host.js
CHANGED
|
@@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.js";
|
|
8
8
|
import { readTranscript, transcribePaper } from "./transcribe.js";
|
|
9
|
-
import { pdfVariantPath, startTranslation, zhStatus } from "./translate.js";
|
|
9
|
+
import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
|
|
10
10
|
import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
|
|
11
11
|
import { installPaperPreset, PAPER_PRESET_ID } from "./preset.js";
|
|
12
12
|
function json(res, status, body) {
|
|
@@ -41,6 +41,7 @@ export function registerRoutes(ctx, config) {
|
|
|
41
41
|
const dataDir = resolveDataDir(config.dataDir);
|
|
42
42
|
// dist/host.js → 包根/reader/index.html
|
|
43
43
|
const readerHtml = new URL('../reader/index.html', import.meta.url);
|
|
44
|
+
const readerVendorDir = new URL('../reader/vendor/', import.meta.url);
|
|
44
45
|
// 自带「论文伴读」agent preset → $DSH_HOME/.agent-presets/(实时扫描,免重启);
|
|
45
46
|
// 失败(旧版 dsh/无权限)则回退默认 preset,功能不受影响。
|
|
46
47
|
const presetDir = installPaperPreset(new URL('../', import.meta.url));
|
|
@@ -147,6 +148,24 @@ export function registerRoutes(ctx, config) {
|
|
|
147
148
|
res.end(html);
|
|
148
149
|
return;
|
|
149
150
|
}
|
|
151
|
+
// 阅读器静态资源(本地 vendor 的 pdf.js 等;拒绝路径穿越)
|
|
152
|
+
if (sub.startsWith('/vendor/') && req.method === 'GET') {
|
|
153
|
+
const name = sub.slice('/vendor/'.length);
|
|
154
|
+
if (!/^[\w.-]+$/.test(name)) {
|
|
155
|
+
json(res, 400, { error: 'bad asset name' });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const mime = { '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json' };
|
|
159
|
+
try {
|
|
160
|
+
const body = await readFile(new URL(name, readerVendorDir));
|
|
161
|
+
res.writeHead(200, { 'content-type': (mime[name.slice(name.lastIndexOf('.'))] ?? 'application/octet-stream') + '; charset=utf-8', 'content-length': body.length, 'cache-control': 'max-age=3600' });
|
|
162
|
+
res.end(body);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
json(res, 404, { error: 'not found' });
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
150
169
|
if (sub === '/api/library' && req.method === 'GET') {
|
|
151
170
|
json(res, 200, { dataDir, topics: listTopics(dataDir), papers: listPapers(dataDir) });
|
|
152
171
|
return;
|
|
@@ -261,17 +280,26 @@ export function registerRoutes(ctx, config) {
|
|
|
261
280
|
createReadStream(pdfPath).pipe(res);
|
|
262
281
|
return;
|
|
263
282
|
}
|
|
264
|
-
// 中文版状态:zh=纯中文 dual=中英对照 busy
|
|
283
|
+
// 中文版状态:zh=纯中文 dual=中英对照 busy=翻译中。
|
|
284
|
+
// 顺带返回生效端点的脱敏信息,让 UI 能回答「正在用哪个模型/key 翻」——key 只给掩码。
|
|
265
285
|
if (sub === '/api/zh' && req.method === 'GET') {
|
|
266
|
-
|
|
286
|
+
const { endpoint, source } = await resolveTranslateEndpoint(config.translate);
|
|
287
|
+
json(res, 200, {
|
|
288
|
+
...zhStatus(locate(url)),
|
|
289
|
+
model: endpoint.model ?? null,
|
|
290
|
+
apiKeyHint: endpoint.apiKey ? maskApiKey(endpoint.apiKey) : null,
|
|
291
|
+
endpointSource: source,
|
|
292
|
+
});
|
|
267
293
|
return;
|
|
268
294
|
}
|
|
269
|
-
//
|
|
295
|
+
// 启动后台翻译(幂等;force=true 为重翻:先删已有译文再启动)。端点:UI 里填的 > profile 的 translate
|
|
270
296
|
if (sub === '/api/zh/generate' && req.method === 'POST') {
|
|
271
297
|
const body = (await readBody(req));
|
|
272
298
|
const ref = resolvePaper(dataDir, body);
|
|
273
299
|
const { endpoint } = await resolveTranslateEndpoint(config.translate);
|
|
274
|
-
const r =
|
|
300
|
+
const r = body.force
|
|
301
|
+
? await restartTranslation(ref, dataDir, endpoint)
|
|
302
|
+
: await startTranslation(ref, dataDir, endpoint);
|
|
275
303
|
json(res, r.started ? 200 : 409, r);
|
|
276
304
|
return;
|
|
277
305
|
}
|
package/dist/tools.js
CHANGED
|
@@ -86,7 +86,7 @@ export function registerTools(ctx, config) {
|
|
|
86
86
|
}));
|
|
87
87
|
ctx.tools.register(defineTool({
|
|
88
88
|
name: 'transcribe_pdf',
|
|
89
|
-
description: '把一篇 PDF 论文转录为纯文本(本地
|
|
89
|
+
description: '把一篇 PDF 论文转录为纯文本(本地 pdf.js 提取,带页码索引),结果落盘缓存。' +
|
|
90
90
|
'读一篇新论文的第一步:先转录,再用 search_paper 检索。已有缓存时秒回。',
|
|
91
91
|
parameters: {
|
|
92
92
|
...locateParams,
|
package/dist/transcribe.js
CHANGED
|
@@ -2,111 +2,126 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 移植 pdfqa 的三级策略(视觉兜底后置):
|
|
4
4
|
// 1. 缓存:.txt 已存在直接复用
|
|
5
|
-
// 2. 快路径:
|
|
5
|
+
// 2. 快路径:pdfjs-dist(pdf.js)本地提取文本层(文本型 PDF 毫秒级,纯 Node,无需 Python)
|
|
6
6
|
// 3. 慢路径:视觉转录(扫描件兜底)— 暂不支持,返回明确错误
|
|
7
7
|
//
|
|
8
8
|
// 与 pdfqa 的差异:输出附带 pages.json 页码偏移表(每页文本在全文中的 [start,end) 区间),
|
|
9
|
-
// 让 search_paper
|
|
10
|
-
//
|
|
11
|
-
|
|
9
|
+
// 让 search_paper 能把片段映射回页码。清洗(连字/断词/段落重排)全部在偏移计算之前完成,
|
|
10
|
+
// 保证偏移与最终文本严格一致。
|
|
11
|
+
//
|
|
12
|
+
// 历史上快路径用 python3 + PyMuPDF,0.3.0 起换成 pdfjs-dist:阅读器本来就用 pdf.js 渲染,
|
|
13
|
+
// 提取能力等价,且消除了对用户机 Python 环境的依赖。清洗规则与 PyMuPDF 版逐条对齐,
|
|
14
|
+
// 缓存格式(.txt + .pages.json)不变,旧缓存继续兼容。
|
|
12
15
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
13
|
-
import { promisify } from 'node:util';
|
|
14
16
|
import { transcriptCached } from "./library.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
for
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'pages': [{'page': p, 'start': s, 'end': e} for p, s, e in spans],
|
|
86
|
-
}, ensure_ascii=False))
|
|
87
|
-
`;
|
|
88
|
-
/** 逐个尝试 python 候选(多个 python 里不一定都装了 fitz)。 */
|
|
89
|
-
async function runPyMuPDF(script, pdfPath) {
|
|
90
|
-
const candidates = ['python3', '/opt/homebrew/Caskroom/miniconda/base/bin/python3', '/usr/bin/python3'];
|
|
91
|
-
let sawScanned = false;
|
|
92
|
-
for (const py of candidates) {
|
|
93
|
-
try {
|
|
94
|
-
const { stdout } = await execFileP(py, ['-c', script, pdfPath], {
|
|
95
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
96
|
-
timeout: 120_000,
|
|
97
|
-
});
|
|
98
|
-
return { ok: true, stdout, scanned: false };
|
|
17
|
+
/** 扫描件信号:文本层过少,换提取器也没用,上层据此给专门提示。 */
|
|
18
|
+
class ScannedPdfError extends Error {
|
|
19
|
+
}
|
|
20
|
+
// ---- 以下为 PyMuPDF 版清洗规则的逐条 TS 移植 ----
|
|
21
|
+
const LIGATURES = [
|
|
22
|
+
['fi', 'fi'], ['fl', 'fl'], ['ff', 'ff'], ['ffi', 'ffi'], ['ffl', 'ffl'], ['ſt', 'st'],
|
|
23
|
+
];
|
|
24
|
+
function ligature(s) {
|
|
25
|
+
for (const [k, v] of LIGATURES)
|
|
26
|
+
s = s.replaceAll(k, v);
|
|
27
|
+
return s;
|
|
28
|
+
}
|
|
29
|
+
/** 页眉页脚归一化:去数字标点(对应 Python re.sub(r'[\d\W]+','',l).lower(),保留 unicode 字母)。 */
|
|
30
|
+
function norm(l) {
|
|
31
|
+
return l.replace(/[^\p{L}_]/gu, '').toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
const SENT_END = /[.!?:;]["'”’)\]]*$/;
|
|
34
|
+
/**
|
|
35
|
+
* pdfjs 提取文本层 → 剔页眉页脚 → 清洗 → 带偏移拼页。
|
|
36
|
+
* 扫描件(文本过少)抛 ScannedPdfError,交给上层决定兜底策略。
|
|
37
|
+
* pdfjs-dist 体积不小,动态 import,首次转录时才加载。
|
|
38
|
+
*/
|
|
39
|
+
async function extractWithPdfjs(pdfPath) {
|
|
40
|
+
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
|
41
|
+
const data = new Uint8Array(await readFile(pdfPath));
|
|
42
|
+
const loadingTask = getDocument({ data, disableFontFace: true, verbosity: 0 });
|
|
43
|
+
const doc = await loadingTask.promise;
|
|
44
|
+
try {
|
|
45
|
+
// 每页拼行:getTextContent 的 hasEOL 标记行尾,等价于 PyMuPDF get_text().splitlines()
|
|
46
|
+
const rawPages = [];
|
|
47
|
+
for (let i = 1; i <= doc.numPages; i++) {
|
|
48
|
+
const page = await doc.getPage(i);
|
|
49
|
+
const tc = await page.getTextContent();
|
|
50
|
+
const lines = [];
|
|
51
|
+
let cur = '';
|
|
52
|
+
for (const item of tc.items) {
|
|
53
|
+
if (typeof item.str !== 'string')
|
|
54
|
+
continue;
|
|
55
|
+
cur += item.str;
|
|
56
|
+
if (item.hasEOL) {
|
|
57
|
+
lines.push(cur.trim());
|
|
58
|
+
cur = '';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (cur.trim())
|
|
62
|
+
lines.push(cur.trim());
|
|
63
|
+
rawPages.push(lines.filter((l) => l));
|
|
64
|
+
page.cleanup();
|
|
65
|
+
}
|
|
66
|
+
// 页眉页脚识别:页首 2 行/页尾 3 行归一化(去数字标点),
|
|
67
|
+
// 在 >=1/5 页面边缘反复出现即剔除;只剔边缘行,正文相同行不受影响。
|
|
68
|
+
const thresh = Math.max(3, Math.floor(rawPages.length / 5));
|
|
69
|
+
const edge = new Map();
|
|
70
|
+
for (const p of rawPages) {
|
|
71
|
+
for (const l of [...p.slice(0, 2), ...p.slice(-3)]) {
|
|
72
|
+
const n = norm(l);
|
|
73
|
+
edge.set(n, (edge.get(n) ?? 0) + 1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const bad = new Set([...edge].filter(([, n]) => n >= thresh).map(([l]) => l));
|
|
77
|
+
const pageTexts = [];
|
|
78
|
+
for (const p of rawPages) {
|
|
79
|
+
while (p.length && bad.has(norm(p[0])))
|
|
80
|
+
p.shift();
|
|
81
|
+
while (p.length && bad.has(norm(p[p.length - 1])))
|
|
82
|
+
p.pop();
|
|
83
|
+
let t = p.join('\n');
|
|
84
|
+
t = ligature(t);
|
|
85
|
+
t = t.replace(/(\w)-\n(\w)/g, '$1$2'); // 页内断词愈合
|
|
86
|
+
pageTexts.push(t.trim());
|
|
99
87
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
88
|
+
const total = pageTexts.reduce((s, t) => s + t.length, 0);
|
|
89
|
+
if (total < 100 * Math.max(1, doc.numPages))
|
|
90
|
+
throw new ScannedPdfError();
|
|
91
|
+
// 段落重排拼页:PDF 分页会把一个句子拆到相邻两页(页眉页脚剔除后就挨在一起)。
|
|
92
|
+
// 上一页不以句末标点结束、且下一页以小写字母开头 → 同句延续,用空格衔接;
|
|
93
|
+
// 该页的偏移区间只覆盖它自己的文字(跨页句引用起始页)。
|
|
94
|
+
let buf = '';
|
|
95
|
+
const spans = [];
|
|
96
|
+
for (let i = 0; i < pageTexts.length; i++) {
|
|
97
|
+
const t = pageTexts[i];
|
|
98
|
+
if (!t)
|
|
99
|
+
continue;
|
|
100
|
+
if (!buf) {
|
|
101
|
+
spans.push({ page: i + 1, start: 0, end: t.length });
|
|
102
|
+
buf = t;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
let start;
|
|
106
|
+
if (!SENT_END.test(buf) && /^\p{Ll}/u.test(t)) {
|
|
107
|
+
buf = buf.replace(/[ \n]+$/, '');
|
|
108
|
+
start = buf.length + 1;
|
|
109
|
+
buf = buf + ' ' + t;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
start = buf.length + 2;
|
|
113
|
+
buf = buf + '\n\n' + t;
|
|
114
|
+
}
|
|
115
|
+
spans.push({ page: i + 1, start, end: buf.length });
|
|
104
116
|
}
|
|
117
|
+
return { pageCount: doc.numPages, chars: buf.length, text: buf, pages: spans };
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await loadingTask.destroy();
|
|
105
121
|
}
|
|
106
|
-
return { ok: false, stdout: '', scanned: sawScanned };
|
|
107
122
|
}
|
|
108
123
|
/**
|
|
109
|
-
* 转录一篇文献:缓存优先,否则本地
|
|
124
|
+
* 转录一篇文献:缓存优先,否则本地 pdfjs 提取并落盘(.txt + .pages.json)。
|
|
110
125
|
* 扫描件(文本层过少)抛错并说明视觉兜底尚未实现。
|
|
111
126
|
*/
|
|
112
127
|
export async function transcribePaper(ref, opts = {}) {
|
|
@@ -129,13 +144,16 @@ export async function transcribePaper(ref, opts = {}) {
|
|
|
129
144
|
}
|
|
130
145
|
/** 本地提取(不落盘)。 */
|
|
131
146
|
async function extractLocal(pdfPath) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
let data;
|
|
148
|
+
try {
|
|
149
|
+
data = await extractWithPdfjs(pdfPath);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (err instanceof ScannedPdfError) {
|
|
153
|
+
throw new Error('本地提取的文本过少,该 PDF 可能是扫描件。视觉转录兜底尚未实现(Phase 1 后置项),请换文本型 PDF 或手动放置同名 .txt 缓存。');
|
|
154
|
+
}
|
|
155
|
+
throw new Error(`PDF 文本提取失败:${err instanceof Error ? err.message : String(err)}`);
|
|
137
156
|
}
|
|
138
|
-
const data = JSON.parse(stdout);
|
|
139
157
|
if (data.text.length < 1000) {
|
|
140
158
|
throw new Error(`转录结果过短(${data.text.length} 字符),疑似失败,请重试`);
|
|
141
159
|
}
|
|
@@ -160,7 +178,7 @@ async function tryUpgradePageIndex(ref, cachedLen) {
|
|
|
160
178
|
return { text: t.text, pages: t.pages };
|
|
161
179
|
}
|
|
162
180
|
catch {
|
|
163
|
-
return null; //
|
|
181
|
+
return null; // 提取失败或扫描件:保持旧缓存,页码索引缺席
|
|
164
182
|
}
|
|
165
183
|
}
|
|
166
184
|
/** 读页码偏移表;旧缓存(pdfqa 时代)没有 pages.json 时返回 null。 */
|
package/dist/translate-config.js
CHANGED
|
@@ -90,8 +90,18 @@ export async function testTranslateEndpoint(ep) {
|
|
|
90
90
|
body: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1 }),
|
|
91
91
|
signal: AbortSignal.timeout(15_000),
|
|
92
92
|
});
|
|
93
|
-
if (r.ok)
|
|
94
|
-
|
|
93
|
+
if (r.ok) {
|
|
94
|
+
// 智谱系网关切错路径时(如 /api/v1)会回 HTTP 200 + {"code":1001,"success":false},
|
|
95
|
+
// 只看状态码会误判成功,babeldoc 跑起来才炸。真成功必须有 choices。
|
|
96
|
+
const text = (await r.text().catch(() => '')).slice(0, 300);
|
|
97
|
+
try {
|
|
98
|
+
const data = JSON.parse(text);
|
|
99
|
+
if (Array.isArray(data.choices))
|
|
100
|
+
return { ok: true };
|
|
101
|
+
}
|
|
102
|
+
catch { /* 非 JSON 也算失败,走下面报 body */ }
|
|
103
|
+
return { ok: false, detail: `HTTP 200 但响应不是补全结果:${text.slice(0, 200)}(多半是端点路径不对,如智谱应为 /api/paas/v4)` };
|
|
104
|
+
}
|
|
95
105
|
const text = (await r.text().catch(() => '')).slice(0, 200);
|
|
96
106
|
return { ok: false, detail: `HTTP ${r.status}${text ? ' ' + text : ''}` };
|
|
97
107
|
}
|