@ggboy123/dsh-paper-reader 0.2.1 → 0.6.1

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 CHANGED
@@ -19,8 +19,8 @@
19
19
  - 📖 **PDF 阅读器居中**:中间是论文(PDF.js 缩放 / Retina 高清 / 文本层选择 / 适宽模式),最右是该论文的原生对话 —— 视觉换位实现,列宽拖拽/折叠仍是 dsh 原生行为,关掉 PDF 页签即还原官方布局
20
20
  - 💬 **选中即问**:阅读器里选中文字 → 弹出提问框 → 注入当前会话,原生对话区实时回答
21
21
  - 📍 **引用定位**:回答里的「第 N 页」可点击,平滑跳回 PDF 对应页并闪烁
22
- - 🀄 **中英切换**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate` 端点
23
- - 🔍 **PDF 转录 + 检索**:本地提取(python3 + PyMuPDF),页眉页脚剔除 / 连字 / 断词愈合 / 段落重排,产出页码偏移表;兼容 pdfqa 的 `data/` 缓存布局(旧缓存读取时自动补建页码索引)
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 纯函数:PyMuPDF 提取 + 页码偏移表(视觉兜底后置)
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
@@ -9,6 +9,7 @@ import { readTranscript, transcribePaper } from "./transcribe.js";
9
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
+ import { noteOrigin } from "./origin.js";
12
13
  function json(res, status, body) {
13
14
  const buf = Buffer.from(JSON.stringify(body), 'utf8');
14
15
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': buf.length });
@@ -41,6 +42,7 @@ export function registerRoutes(ctx, config) {
41
42
  const dataDir = resolveDataDir(config.dataDir);
42
43
  // dist/host.js → 包根/reader/index.html
43
44
  const readerHtml = new URL('../reader/index.html', import.meta.url);
45
+ const readerVendorDir = new URL('../reader/vendor/', import.meta.url);
44
46
  // 自带「论文伴读」agent preset → $DSH_HOME/.agent-presets/(实时扫描,免重启);
45
47
  // 失败(旧版 dsh/无权限)则回退默认 preset,功能不受影响。
46
48
  const presetDir = installPaperPreset(new URL('../', import.meta.url));
@@ -147,6 +149,24 @@ export function registerRoutes(ctx, config) {
147
149
  res.end(html);
148
150
  return;
149
151
  }
152
+ // 阅读器静态资源(本地 vendor 的 pdf.js 等;拒绝路径穿越)
153
+ if (sub.startsWith('/vendor/') && req.method === 'GET') {
154
+ const name = sub.slice('/vendor/'.length);
155
+ if (!/^[\w.-]+$/.test(name)) {
156
+ json(res, 400, { error: 'bad asset name' });
157
+ return;
158
+ }
159
+ const mime = { '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json' };
160
+ try {
161
+ const body = await readFile(new URL(name, readerVendorDir));
162
+ 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' });
163
+ res.end(body);
164
+ }
165
+ catch {
166
+ json(res, 404, { error: 'not found' });
167
+ }
168
+ return;
169
+ }
150
170
  if (sub === '/api/library' && req.method === 'GET') {
151
171
  json(res, 200, { dataDir, topics: listTopics(dataDir), papers: listPapers(dataDir) });
152
172
  return;
@@ -445,6 +465,8 @@ export function registerRoutes(ctx, config) {
445
465
  json(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' });
446
466
  return;
447
467
  }
468
+ // 借浏览器请求的 Host 头记录服务 origin(工具结果里的 readerUrl 链接基址靠它拼)
469
+ noteOrigin(req.headers.host);
448
470
  const url = new URL(req.url ?? '/', 'http://x');
449
471
  dispatch(req, res, url).catch((err) => {
450
472
  if (!res.headersSent)
package/dist/origin.js ADDED
@@ -0,0 +1,13 @@
1
+ // origin.ts — 记录 dsh web 服务的外部地址(从插件路由收到的请求 Host 头捕获)。
2
+ // 工具进程不是 HTTP 请求,自己不知道服务端口;但浏览器客户端会持续打 /paper-reader/api
3
+ // (文献库 30s 轮询等),借它的 Host 头还原出可点击链接的基址。
4
+ let origin = null;
5
+ /** 每次插件路由被请求时调用;Host 已过宿主围栏校验,可信。 */
6
+ export function noteOrigin(host) {
7
+ if (typeof host === 'string' && host.length > 0)
8
+ origin = `http://${host}`;
9
+ }
10
+ /** 当前服务 origin;尚无任何请求时为 null(此时工具输出不带链接基址)。 */
11
+ export function readerOrigin() {
12
+ return origin;
13
+ }
package/dist/tools.js CHANGED
@@ -4,6 +4,7 @@ import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.
4
4
  import { transcribePaper, readTranscript } from "./transcribe.js";
5
5
  import { chunkText, searchChunks, formatHits } from "./search.js";
6
6
  import { completeStep, formatStudy, readStudy, recordQuiz, setPlan } from "./study.js";
7
+ import { readerOrigin } from "./origin.js";
7
8
  const locateParams = {
8
9
  path: { type: 'string', description: 'PDF 绝对路径。与 topic+name 二选一。' },
9
10
  topic: { type: 'string', description: '专题名(文献库一级目录)。' },
@@ -42,6 +43,21 @@ export function paperFromSessionId(sessionId) {
42
43
  return null;
43
44
  }
44
45
  }
46
+ /** 某篇论文的阅读器链接基址(拼 &page=N 即跳转链接);origin 未捕获到时为 null。 */
47
+ function readerUrl(ref) {
48
+ const o = readerOrigin();
49
+ if (!o)
50
+ return null;
51
+ return `${o}/paper-reader/?topic=${encodeURIComponent(ref.topic)}&name=${encodeURIComponent(ref.name)}`;
52
+ }
53
+ /** 输出 schema 里的 readerUrl 字段声明(nullable:没有任何浏览器请求过时尚未捕获 origin)。 */
54
+ const readerUrlSchema = {
55
+ oneOf: [{ type: 'string' }, { type: 'null' }],
56
+ required: true,
57
+ description: '阅读器链接基址。引用页码时在末尾拼 &page=N 生成跳转链接;要定位到具体段落再拼 &q=<该段落原文短引文,3~8个词,URL编码>,点击会高亮对应片段。为 null 时本次不带链接。',
58
+ };
59
+ /** render 文本尾部统一带出的链接基址行(preset 规定了用法,这里只暴露值)。 */
60
+ const readerUrlLine = (url) => (url ? `\n阅读器链接基址:${url}` : '');
45
61
  export function registerTools(ctx, config) {
46
62
  const dataDir = resolveDataDir(config.dataDir);
47
63
  ctx.tools.register(defineTool({
@@ -86,7 +102,7 @@ export function registerTools(ctx, config) {
86
102
  }));
87
103
  ctx.tools.register(defineTool({
88
104
  name: 'transcribe_pdf',
89
- description: '把一篇 PDF 论文转录为纯文本(本地 PyMuPDF 提取,带页码索引),结果落盘缓存。' +
105
+ description: '把一篇 PDF 论文转录为纯文本(本地 pdf.js 提取,带页码索引),结果落盘缓存。' +
90
106
  '读一篇新论文的第一步:先转录,再用 search_paper 检索。已有缓存时秒回。',
91
107
  parameters: {
92
108
  ...locateParams,
@@ -102,6 +118,7 @@ export function registerTools(ctx, config) {
102
118
  pageCount: { type: 'integer', required: true },
103
119
  hasPageIndex: { type: 'boolean', required: true, description: '是否有页码索引(检索结果能否带页码)。' },
104
120
  source: { type: 'string', required: true, enum: ['cache', 'local'] },
121
+ readerUrl: readerUrlSchema,
105
122
  },
106
123
  additionalProperties: false,
107
124
  },
@@ -110,7 +127,8 @@ export function registerTools(ctx, config) {
110
127
  type: 'text',
111
128
  text: `已转录《${value.paper}》:${value.chars} 字符,${value.pageCount} 页` +
112
129
  `(${value.source === 'cache' ? '缓存复用' : '本地提取'}${value.hasPageIndex ? ',含页码索引' : ',无页码索引'})。` +
113
- `接下来可用 search_paper 检索具体内容。`,
130
+ `接下来可用 search_paper 检索具体内容。` +
131
+ readerUrlLine(value.readerUrl),
114
132
  },
115
133
  ],
116
134
  },
@@ -124,6 +142,7 @@ export function registerTools(ctx, config) {
124
142
  pageCount: t.pageCount,
125
143
  hasPageIndex: t.pages.length > 0,
126
144
  source: t.source,
145
+ readerUrl: readerUrl(ref),
127
146
  };
128
147
  },
129
148
  }));
@@ -162,6 +181,7 @@ export function registerTools(ctx, config) {
162
181
  additionalProperties: false,
163
182
  },
164
183
  },
184
+ readerUrl: readerUrlSchema,
165
185
  },
166
186
  additionalProperties: false,
167
187
  },
@@ -172,7 +192,7 @@ export function registerTools(ctx, config) {
172
192
  chunk: { index: h.chunk, start: 0, end: 0, text: h.text },
173
193
  score: 0,
174
194
  page: h.page,
175
- })), value.totalChunks, value.query),
195
+ })), value.totalChunks, value.query) + readerUrlLine(value.readerUrl),
176
196
  },
177
197
  ],
178
198
  },
@@ -193,6 +213,7 @@ export function registerTools(ctx, config) {
193
213
  query: args.query,
194
214
  totalChunks: chunks.length,
195
215
  hits: hits.map((h) => ({ chunk: h.chunk.index, page: h.page, text: h.chunk.text })),
216
+ readerUrl: readerUrl(ref),
196
217
  };
197
218
  },
198
219
  }));
@@ -235,13 +256,14 @@ export function registerTools(ctx, config) {
235
256
  additionalProperties: false,
236
257
  },
237
258
  },
259
+ readerUrl: readerUrlSchema,
238
260
  },
239
261
  additionalProperties: false,
240
262
  },
241
263
  render: (_args, value) => [
242
264
  {
243
265
  type: 'text',
244
- text: `《${value.paper}》学习档案:\n` + formatStudy(value.hasRecord ? { paper: value.paper, plan: value.plan, quizzes: value.quizzes } : null),
266
+ text: `《${value.paper}》学习档案:\n` + formatStudy(value.hasRecord ? { paper: value.paper, plan: value.plan, quizzes: value.quizzes } : null) + readerUrlLine(value.readerUrl),
245
267
  },
246
268
  ],
247
269
  },
@@ -253,6 +275,7 @@ export function registerTools(ctx, config) {
253
275
  hasRecord: record !== null,
254
276
  plan: record?.plan ?? [],
255
277
  quizzes: record?.quizzes ?? [],
278
+ readerUrl: readerUrl(ref),
256
279
  };
257
280
  },
258
281
  }));
@@ -2,111 +2,126 @@
2
2
  //
3
3
  // 移植 pdfqa 的三级策略(视觉兜底后置):
4
4
  // 1. 缓存:.txt 已存在直接复用
5
- // 2. 快路径:python3 + PyMuPDF 本地提取文本层(文本型 PDF 毫秒级)
5
+ // 2. 快路径:pdfjs-dist(pdf.js)本地提取文本层(文本型 PDF 毫秒级,纯 Node,无需 Python)
6
6
  // 3. 慢路径:视觉转录(扫描件兜底)— 暂不支持,返回明确错误
7
7
  //
8
8
  // 与 pdfqa 的差异:输出附带 pages.json 页码偏移表(每页文本在全文中的 [start,end) 区间),
9
- // 让 search_paper 能把片段映射回页码。清洗(连字/断词/段落重排)全部在 Python 侧、
10
- // 偏移计算之前完成,保证偏移与最终文本严格一致。
11
- import { execFile } from 'node:child_process';
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
- const execFileP = promisify(execFile);
16
- // Python 脚本:提取文本层 → 剔页眉页脚 → 清洗 → 带偏移拼页。
17
- // 退出码 3 = 文本过少(扫描件),交给上层决定兜底策略。
18
- const EXTRACT_SCRIPT = String.raw `
19
- import fitz, sys, re, json
20
- from collections import Counter
21
-
22
- LIG = {'fi':'fi','fl':'fl','ff':'ff','ffi':'ffi','ffl':'ffl','ſt':'st'}
23
- def ligature(s):
24
- for k, v in LIG.items():
25
- s = s.replace(k, v)
26
- return s
27
-
28
- doc = fitz.open(sys.argv[1])
29
- raw_pages = []
30
- for page in doc:
31
- lines = [l.strip() for l in page.get_text().splitlines()]
32
- raw_pages.append([l for l in lines if l])
33
-
34
- # 页眉页脚识别:页首 2 行/页尾 3 行归一化(去数字标点,页码归一为空串),
35
- # 在 >=1/5 页面边缘反复出现即剔除;只剔边缘行,正文相同行不受影响。
36
- thresh = max(3, len(raw_pages) // 5)
37
- def norm(l): return re.sub(r'[\d\W]+', '', l).lower()
38
- edge = Counter()
39
- for p in raw_pages:
40
- for l in p[:2] + p[-3:]:
41
- edge[norm(l)] += 1
42
- bad = {l for l, n in edge.items() if n >= thresh}
43
-
44
- page_texts = []
45
- for p in raw_pages:
46
- while p and norm(p[0]) in bad:
47
- p.pop(0)
48
- while p and norm(p[-1]) in bad:
49
- p.pop()
50
- t = '\n'.join(p)
51
- t = ligature(t)
52
- t = re.sub(r'(\w)-\n(\w)', r'\1\2', t) # 页内断词愈合
53
- page_texts.append(t.strip())
54
-
55
- total = sum(len(t) for t in page_texts)
56
- if total < 100 * max(1, len(doc)):
57
- sys.exit(3) # 扫描件
58
-
59
- # 段落重排拼页:PDF 分页会把一个句子拆到相邻两页(页眉页脚剔除后就挨在一起)。
60
- # 上一页不以句末标点结束、且下一页以小写字母开头 → 同句延续,用空格衔接;
61
- # 该页的偏移区间只覆盖它自己的文字(跨页句引用起始页)。
62
- SENT_END = re.compile(r'[.!?:;]["\'”’)\]]*$')
63
- buf = ''
64
- spans = []
65
- for i, t in enumerate(page_texts):
66
- if not t:
67
- continue
68
- if not buf:
69
- spans.append((i + 1, 0, len(t)))
70
- buf = t
71
- continue
72
- if not SENT_END.search(buf) and t[0].islower():
73
- buf = buf.rstrip(' \n')
74
- start = len(buf) + 1
75
- buf = buf + ' ' + t
76
- else:
77
- start = len(buf) + 2
78
- buf = buf + '\n\n' + t
79
- spans.append((i + 1, start, len(buf)))
80
-
81
- print(json.dumps({
82
- 'pageCount': len(doc),
83
- 'chars': len(buf),
84
- 'text': buf,
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
- catch (err) {
101
- // 退出码 3 = 扫描件信号,换一个 python 也没用,但记住这个结论
102
- if (err && typeof err === 'object' && err.code === 3)
103
- sawScanned = true;
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
- * 转录一篇文献:缓存优先,否则本地 PyMuPDF 提取并落盘(.txt + .pages.json)。
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
- const { ok, stdout, scanned } = await runPyMuPDF(EXTRACT_SCRIPT, pdfPath);
133
- if (!ok) {
134
- throw new Error(scanned
135
- ? '本地提取的文本过少,该 PDF 可能是扫描件。视觉转录兜底尚未实现(Phase 1 后置项),请换文本型 PDF 或手动放置同名 .txt 缓存。'
136
- : '未找到可用的 python3 + PyMuPDF 环境。请安装:python3 -m pip install PyMuPDF');
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; // 无 python 环境或扫描件:保持旧缓存,页码索引缺席
181
+ return null; // 提取失败或扫描件:保持旧缓存,页码索引缺席
164
182
  }
165
183
  }
166
184
  /** 读页码偏移表;旧缓存(pdfqa 时代)没有 pages.json 时返回 null。 */