@femio/paper-mcp 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TODO 填写版权人
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,239 @@
1
+ # Paper-Assistant MCP(论文阅读助手 · MinerU 版)
2
+
3
+ 一个基于 [FastMCP](https://github.com/modelcontextprotocol) 的 MCP Server:**PDF 解析全部交给 [MinerU](https://mineru.net) 云端端到端完成**(文 / 图 / 表 / 公式),Server 只负责三件事——
4
+
5
+ 1. **编排解析**:把 `./papers` 下的 PDF 提交给 MinerU,管好异步任务、状态播报与本地缓存;
6
+ 2. **结构化检索**:在解析结果之上,让模型省 token 地按章节/页读正文、看图、取表、检索;
7
+ 3. **产出汇报**:把理解结果写成带图的 pre HTML + 逐页讲稿。
8
+
9
+ 覆盖并扩展了旧版能力(批量读文/图 → 理解 → 出 pre),并**新增了表格、公式与「真实看图」**三项旧版没有的能力。
10
+
11
+ ---
12
+
13
+ ## 相比旧版(PyMuPDF 手工解析)的变化
14
+
15
+ | 维度 | 旧版(fitz 自研) | 新版(MinerU 端到端) |
16
+ | --- | --- | --- |
17
+ | 正文 | 竖排水印/页眉页脚启发式清洗 | MinerU 直接产出干净 markdown(噪声进 discarded) |
18
+ | 大纲 | 无书签时按字号猜(易乱序) | 用 MinerU 的标题层级,**准确且保持阅读顺序** |
19
+ | 图 | 自己聚类切片 + 渲染(可能漏矢量图) | MinerU 已裁好图 + 图注,还能把图**回传给模型看** |
20
+ | 表格 | ❌ 无 | ✅ HTML → markdown |
21
+ | 公式 | ❌ 无 | ✅ LaTeX |
22
+ | 解析等待 | —(本地同步解析) | 阻塞式**每 30s 轮询 + 状态播报**(解析中/下载中/解压中) |
23
+ | 依赖 | `pymupdf` | 无需 pymupdf;仅 `mcp` + `httpx` |
24
+
25
+ ---
26
+
27
+ ## 环境与依赖
28
+
29
+ - Python ≥ 3.10
30
+ - 依赖:`mcp`(FastMCP)、`httpx`(HTTP 客户端,通常随 `mcp` 一并安装)
31
+
32
+ ```bash
33
+ pip install -r requirements.txt # mcp + httpx
34
+ ```
35
+
36
+ ### 环境变量
37
+
38
+ | 变量 | 必填 | 说明 |
39
+ | --- | --- | --- |
40
+ | `MINERU_API_TOKEN` | ✅ | MinerU 云端 API Token(在 <https://mineru.net> 申请) |
41
+ | `PAPERS_PROJECT_ROOT` | | 项目根(`papers/`、`.cache/`、输出目录的根),默认当前工作目录 |
42
+ | `MINERU_API_BASE` | | 默认 `https://mineru.net/api/v4` |
43
+ | `MINERU_MODEL_VERSION` | | 解析模型,默认 `vlm`(亦可 `pipeline`) |
44
+ | `MINERU_POLL_INTERVAL` | | 阻塞等待的轮询周期,默认 `30`(秒) |
45
+ | `MINERU_MAX_WAIT` | | `parse_papers` 的总超时,默认 `1800`(秒,≈30 分钟) |
46
+ | `MINERU_HTTP_TIMEOUT` | | 单次 HTTP 超时,默认 `180`(秒) |
47
+
48
+ > ⚠️ 隐私提示:云端模式会把 PDF 上传到 mineru.net 解析。解析结果下载到本地 `.cache/mineru/` 后即可离线检索。
49
+
50
+ ---
51
+
52
+ ## 安装与运行
53
+
54
+ > 前提:装有 [uv](https://docs.astral.sh/uv/)(推荐,会自动准备 Python 与依赖);或 Python 3.10+ 且 `pip install mcp httpx`。
55
+
56
+ ### 方式一:npx(推荐,免安装)
57
+
58
+ ```bash
59
+ # 准备论文
60
+ mkdir -p papers && cp /path/to/*.pdf papers/
61
+
62
+ # 直接运行(stdio,通常由 MCP 客户端拉起)
63
+ MINERU_API_TOKEN=xxxx npx @femio/paper-mcp
64
+ ```
65
+
66
+ ### 方式二:从源码运行
67
+
68
+ ```bash
69
+ pip install -r requirements.txt
70
+ MINERU_API_TOKEN=xxxx python paper-mcp.py
71
+ ```
72
+
73
+ ### 在 MCP 客户端中注册
74
+
75
+ **用 npx(推荐):**
76
+
77
+ ```json
78
+ {
79
+ "mcpServers": {
80
+ "paper-reader": {
81
+ "command": "npx",
82
+ "args": ["-y", "@femio/paper-mcp"],
83
+ "env": {
84
+ "PAPERS_PROJECT_ROOT": "Your-Work-Space",
85
+ "MINERU_API_TOKEN": "your-mineru-token"
86
+ }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ **或直接用 Python:**
93
+
94
+ ```json
95
+ {
96
+ "mcpServers": {
97
+ "paper-reader": {
98
+ "command": "python",
99
+ "args": ["paper-mcp.py"],
100
+ "env": {
101
+ "PAPERS_PROJECT_ROOT": "Your-Work-Space",
102
+ "MINERU_API_TOKEN": "your-mineru-token"
103
+ }
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ > ℹ️ npx 版本本质是一个包装器:它把打包进 npm 的 Python 源码用 `uv run --with mcp --with httpx python paper-mcp.py` 拉起。**首次运行**若 uv 需下载 Python/依赖会稍慢,之后走缓存;若客户端有 MCP 启动超时,首次可先手动 `npx @femio/paper-mcp` 预热一次。可用 `PAPER_MCP_PYTHON` 指定 Python 版本、`PAPER_MCP_NO_UV=1` 强制走系统 python。
110
+
111
+ ---
112
+
113
+ ## 项目结构(分层)
114
+
115
+ ```
116
+ paper-mcp/
117
+ ├─ paper-mcp.py # 瘦入口:from paper_mcp.server import mcp; mcp.run()
118
+ ├─ requirements.txt
119
+ └─ paper_mcp/
120
+ ├─ config.py # 配置、路径解析(resolve_pdf)、输出沙箱(safe_output_path)
121
+ ├─ mineru.py # MinerU 云端 API 客户端(纯 HTTP)
122
+ ├─ cache.py # 本地缓存 + manifest(按文件哈希幂等)
123
+ ├─ content.py # 检索/阅读层:Paper 类(大纲/正文/图/表/检索)
124
+ ├─ parsing.py # 解析编排:parse(阻塞,轮询+进度播报+续等)
125
+ ├─ output.py # 产出层:write_file / collect_figures(均沙箱)
126
+ └─ server.py # FastMCP 实例与工具/资源/提示注册
127
+ ```
128
+
129
+ **数据流**:`parse_papers`(阻塞解析)→ MinerU 云端(上传→解析)→ 下载解压进 `.cache/mineru/<论文>/` → 检索工具读 `content_list.json`/`full.md`/`images/` → 产出 HTML/讲稿。
130
+
131
+ ---
132
+
133
+ ## 能力清单
134
+
135
+ ### 🔧 Tools
136
+
137
+ **A. 解析编排(单个阻塞工具)**
138
+
139
+ | 工具 | 说明 |
140
+ | --- | --- |
141
+ | `parse_papers(papers="all", model_version=None, enable_formula=True, enable_table=True, language=None, ocr=False, force=False, max_wait=None, poll_interval=None)` | **阻塞式解析**:提交后每 30s 轮询一次,直到全部完成或超时(默认 1800s);等待期间**播报进度**(见下)。已解析的按文件哈希命中缓存跳过;上次未完成的**续等原批次、不重复上传**(超时后再调一次即可继续等) |
142
+
143
+ > `parse_papers` 是异步工具:阻塞轮询在工作线程执行,不卡事件循环。等待期间按阶段用**预置多条文案随机播报**——
144
+ > 「解析中」`MinerU 正在加速解析中…(3/12 页)` → 「下载」`解析完成!正在下载解析后的文档…` → 「解压」`解析完成的文档解压中…`。
145
+ > 其中**周期性「解析中」走 MCP 进度条** `ctx.report_progress()`(`notifications/progress`,按已解析页数平滑推进 0→100%)。
146
+ > 下载/解压等瞬时子步骤只带文案,但会**复用上次进度值补发一次 `report_progress`(keep-alive)**——因为 MCP 里只有进度通知能重置客户端的空闲超时,日志通知不能,这样保证每次播报都刷新计时器。所有播报都同时写 `stderr`,**均不写 stdout**(那是 JSON-RPC 协议通道)。
147
+
148
+ **B. 检索 / 阅读**(均需先解析)
149
+
150
+ | 工具 | 说明 |
151
+ | --- | --- |
152
+ | `get_paper_info(paper)` | 标题、页数、图/表/公式数量、缓存目录 |
153
+ | `get_paper_outline(paper)` | 章节大纲(来自 MinerU 标题层级,含页码,保持阅读顺序) |
154
+ | `read_paper(paper, section=None, page_start=None, page_end=None, max_chars=8000)` | 分段读正文。图/表在正文里以 `[图 Fk]`/`[表 Tk]` 占位 |
155
+ | `search_paper(paper, query, regex=False, max_hits=20)` | 检索关键词/正则,返回片段 + 页/章节 |
156
+ | `list_figures(paper)` | 列全部图:`id`(F1…)、页、caption、footnote、path、大小 |
157
+ | `get_figure(paper, figure_id)` | 取某图的 path + 图注(**不直接返回图片**;看图请用 `view_image`) |
158
+ | `view_image(image_path)` | **读取并返回图片内容**供模型直接「看图」。仅允许读缓存/项目目录内文件 |
159
+ | `list_tables(paper)` / `get_table(paper, table_id, format="markdown")` | 列表格 / 取某表(markdown 或 html) |
160
+
161
+ **C. 产出**
162
+
163
+ | 工具 | 说明 |
164
+ | --- | --- |
165
+ | `write_file(output_path, content)` | 写文件(UTF-8,自动建父目录)。**限制在项目目录内** |
166
+ | `collect_figures(paper, figure_ids, dest_dir)` | 把选中图拷进 pre 目录,返回可用于 `<img src>` 的相对引用路径 |
167
+
168
+ ### 📚 Resources
169
+
170
+ | URI | 说明 |
171
+ | --- | --- |
172
+ | `papers://catalog` | 列出 `./papers` 及**解析状态**(已解析/解析中/未解析,含页数与图表数) |
173
+ | `papers://{name}/markdown` | 某篇已解析论文的完整 markdown(MinerU `full.md`) |
174
+
175
+ ### 💬 Prompts
176
+
177
+ | Prompt | 说明 |
178
+ | --- | --- |
179
+ | `papers_to_pre(papers="all", audience, language, output_path, script_path)` | 驱动 Claude:解析 → 逐篇理解(读正文/看关键图/取表)→ 每篇总结 → 汇总带图 HTML + 逐页讲稿(**HTML 与讲稿分别落盘**) |
180
+
181
+ ---
182
+
183
+ ## 典型工作流
184
+
185
+ ```mermaid
186
+ sequenceDiagram
187
+ participant U as 用户
188
+ participant A as Claude/Agent
189
+ participant M as Paper-Assistant MCP
190
+ participant Mineru as MinerU 云端
191
+
192
+ U->>A: "把 papers 里的论文整理成 pre"
193
+ A->>M: 读取 papers://catalog(看解析状态)
194
+ A->>M: parse_papers(papers=...) (阻塞)
195
+ M->>Mineru: 上传 PDF → 解析
196
+ loop 每 30s 轮询
197
+ M-->>A: 状态播报(解析中 3/12 页…)
198
+ end
199
+ Mineru-->>M: full_zip_url(done)
200
+ M-->>A: 状态播报(下载中→解压中)→ 解析完成(已缓存)
201
+ loop 每篇论文
202
+ A->>M: get_paper_info / get_paper_outline
203
+ A->>M: read_paper(分段) / search_paper(定位)
204
+ A->>M: list_figures / list_tables
205
+ A->>M: view_image(看关键图) / get_table(取关键表)
206
+ A->>M: write_file(写该篇 summary.md)
207
+ end
208
+ A->>M: collect_figures + write_file(HTML) + write_file(讲稿)
209
+ A-->>U: 汇报:用了哪些图表、HTML 与讲稿路径
210
+ ```
211
+
212
+ ---
213
+
214
+ ## 设计要点
215
+
216
+ - **阻塞解析 + 进度条播报 + keep-alive**:MinerU 解析异步且耗时(每篇常数分钟)。`parse_papers` 提交后每 30s 轮询一次,每轮未完成就**推进一次进度条**(`ctx.report_progress()`,按已解析页数折算 0→100%)并附一条随机「解析中」文案。因为 MCP 里**只有进度通知能重置客户端的空闲超时**(日志通知不能),下载/解压等瞬时子步骤也会**复用上次进度值补发一次 `report_progress`(keep-alive)**,确保整个等待期间没有任何空档会被误判为卡死。阻塞循环放在工作线程,`notify` 经 `run_coroutine_threadsafe` 把播报调度回事件循环——**绝不 print 到 stdout**(stdio MCP 的 stdout 是协议通道,写它会污染 JSON-RPC)。注意进度只能重置「空闲超时」;若客户端配了「墙钟超时」(如 `.mcp.json` 的 `timeout` / `MCP_TOOL_TIMEOUT`),那是硬上限、进度顶不住,需设得比最长解析久或对 stdio 不设。
217
+ - **缓存幂等 + 可续等**:结果按**文件 sha256** 缓存,PDF 不变则永不重复烧额度;上次未完成的论文(在途批次)再次调用 `parse_papers` 会**续等原批次而非重新上传**,所以超时后直接再调一次即可继续等,不会重复解析。
218
+ - **两段式图表理解**:先 `list_figures`/`get_figure` 拿清单和图注(便宜),模型判断哪些关键,再对关键图调 `view_image` 真正看图(按需付 token)。表格同理经 `get_table`。
219
+ - **token 友好**:`read_paper` 按章节/页范围分段读并有 `max_chars` 上限;图/表在正文中以占位符出现,不 inline 大块内容。
220
+ - **写操作沙箱**:`write_file` / `collect_figures` 一律夹在 `PAPERS_PROJECT_ROOT` 内,拒绝越界写;`view_image` 只读缓存/项目目录内的图片。
221
+
222
+ ---
223
+
224
+ ## 常见问题
225
+
226
+ **Q:提示未配置 `MINERU_API_TOKEN`?**
227
+ A:在 <https://mineru.net> 申请 Token,并在 MCP 客户端的 `env` 里设置。
228
+
229
+ **Q:`parse_papers` 一直在播报「解析中」?**
230
+ A:长论文解析需数分钟,播报会附 `已解析/总页数`;若超过 `max_wait`(默认 1800s)会带 `timeout` 返回——此时**再调一次 `parse_papers` 即可续等同一批次**(已完成的命中缓存、在途的不重复上传),或先调大 `max_wait`。
231
+
232
+ **Q:等待期间看不到状态消息?**
233
+ A:状态走 MCP 进度条 / 日志通知与 stderr——是否**内联显示**取决于客户端对 `notifications/progress`、`notifications/message` 的渲染;`stderr` 一定会进 MCP 服务器日志。
234
+
235
+ **Q:检索工具报「尚未解析完成」?**
236
+ A:先调用 `parse_papers` 解析(阻塞,返回即代表完成)后再检索(结果读本地缓存)。
237
+
238
+ **Q:想强制重新解析?**
239
+ A:`parse_papers(paper, force=True)`(例如换了更好的 `model_version`,或 PDF 有更新)。
package/bin/cli.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * paper-mcp 启动包装器。
4
+ *
5
+ * 本包是一个 Python MCP Server(FastMCP + httpx)。这里用 Node 包装,让它能通过
6
+ * `npx @femio/paper-mcp` 启动:优先用 uv 拉起(自动准备 Python 与 mcp/httpx 依赖),
7
+ * 无 uv 时回退到系统 python(需已装 mcp、httpx)。
8
+ *
9
+ * MCP 走 stdio(stdin/stdout 是 JSON-RPC 协议通道),因此这里用 stdio:'inherit'
10
+ * 把子进程的三个标准流透传给客户端,并转发退出码与终止信号。
11
+ *
12
+ * 可用环境变量:
13
+ * MINERU_API_TOKEN —— 必填,MinerU 云端 API Token
14
+ * PAPER_MCP_PYTHON —— 指定 Python 版本(uv --python,默认 3.12)或解释器路径(回退时)
15
+ * PAPER_MCP_NO_UV=1 —— 强制不使用 uv,直接用系统 python
16
+ */
17
+ "use strict";
18
+
19
+ const { spawn, spawnSync } = require("child_process");
20
+ const path = require("path");
21
+
22
+ const ROOT = path.resolve(__dirname, "..");
23
+ const ENTRY = path.join(ROOT, "paper-mcp.py");
24
+ const DEPS = ["mcp>=1.2.0", "httpx>=0.27.0"]; // Python 服务器所需依赖
25
+ const PYREQ = process.env.PAPER_MCP_PYTHON || "3.12";
26
+
27
+ function has(cmd) {
28
+ // uv/python/python3 均为原生可执行文件(Windows 上 CreateProcess 会自动补 .exe),
29
+ // 因此无需 shell 即可探测;命令不存在时 r.error 为 ENOENT。
30
+ const r = spawnSync(cmd, ["--version"], { stdio: "ignore" });
31
+ return !r.error && r.status === 0;
32
+ }
33
+
34
+ function launch(cmd, args) {
35
+ const child = spawn(cmd, args, { stdio: "inherit", env: process.env });
36
+
37
+ const forward = (sig) => {
38
+ try { child.kill(sig); } catch (_) { /* 子进程可能已退出 */ }
39
+ };
40
+ process.on("SIGINT", () => forward("SIGINT"));
41
+ process.on("SIGTERM", () => forward("SIGTERM"));
42
+
43
+ child.on("error", (err) => {
44
+ console.error(`[paper-mcp] 无法启动 ${cmd}:${err.message}`);
45
+ process.exit(127);
46
+ });
47
+ child.on("exit", (code, signal) => {
48
+ if (signal) process.kill(process.pid, signal);
49
+ else process.exit(code == null ? 1 : code);
50
+ });
51
+ }
52
+
53
+ const forwarded = process.argv.slice(2);
54
+ const useUv = process.env.PAPER_MCP_NO_UV !== "1" && has("uv");
55
+
56
+ if (useUv) {
57
+ // uv 会按需下载/复用 Python,并在临时环境中叠加 mcp、httpx(首次运行可能稍慢,之后走缓存)。
58
+ const args = ["run", "--python", PYREQ];
59
+ for (const d of DEPS) args.push("--with", d);
60
+ args.push("python", ENTRY, ...forwarded);
61
+ launch("uv", args);
62
+ } else {
63
+ const py = has("python3") ? "python3" : has("python") ? "python" : null;
64
+ if (!py) {
65
+ console.error(
66
+ "[paper-mcp] 未找到 uv,也未找到 Python 3.10+。\n" +
67
+ " 推荐安装 uv(会自动管理 Python 与依赖):https://docs.astral.sh/uv/\n" +
68
+ " 或安装 Python 3.10+ 并 `pip install mcp httpx`。"
69
+ );
70
+ process.exit(1);
71
+ }
72
+ if (process.env.PAPER_MCP_NO_UV !== "1") {
73
+ console.error(`[paper-mcp] 未检测到 uv,回退用 ${py} 运行(需已 pip install mcp httpx)。建议安装 uv 以自动管理依赖。`);
74
+ }
75
+ launch(py, [ENTRY, ...forwarded]);
76
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@femio/paper-mcp",
3
+ "version": "0.1.0",
4
+ "description": "基于 MinerU 的论文阅读 MCP Server(文/图/表 + 出 pre)。Python 实现,通过 uv 启动;npx 即用。",
5
+ "bin": {
6
+ "paper-mcp": "bin/cli.js"
7
+ },
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "files": [
12
+ "bin/",
13
+ "paper-mcp.py",
14
+ "paper_mcp/*.py",
15
+ "requirements.txt"
16
+ ],
17
+ "engines": {
18
+ "node": ">=16"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "mineru",
24
+ "pdf",
25
+ "paper",
26
+ "claude",
27
+ "llm",
28
+ "rag"
29
+ ],
30
+ "license": "MIT",
31
+ "author": "femio <719751595@qq.com>",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/RaiuSuinTawo/Paper-Reader-MCP-for-pre.git"
35
+ },
36
+ "homepage": "https://github.com/RaiuSuinTawo/Paper-Reader-MCP-for-pre#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/RaiuSuinTawo/Paper-Reader-MCP-for-pre/issues"
39
+ }
40
+ }
package/paper-mcp.py ADDED
@@ -0,0 +1,10 @@
1
+ """Paper-Assistant-MCP 入口(基于 MinerU 云端 API)。
2
+
3
+ 运行: python paper-mcp.py (stdio 传输,通常由 MCP 客户端拉起)
4
+ 实现分层在同目录的 paper_mcp/ 包内:
5
+ config / mineru / cache / content / parsing / output / server
6
+ """
7
+ from paper_mcp.server import mcp
8
+
9
+ if __name__ == "__main__":
10
+ mcp.run()
@@ -0,0 +1,13 @@
1
+ """Paper-Assistant-MCP —— 基于 MinerU 云端 API 重构的论文阅读 MCP Server。
2
+
3
+ 分层:
4
+ - config : 配置与路径解析
5
+ - mineru : MinerU 云端 API 客户端(纯 HTTP)
6
+ - cache : 本地缓存与 manifest
7
+ - content : 检索/阅读层(在 content_list.json 之上)
8
+ - parsing : 解析编排(提交 / 轮询 / 便捷等待)
9
+ - output : 产出层(写文件 / 收集图片)
10
+ - server : FastMCP 实例与工具/资源/提示注册
11
+ """
12
+
13
+ __all__ = ["server"]
@@ -0,0 +1,123 @@
1
+ """本地缓存与 manifest。
2
+
3
+ 所有检索工具都从这里读已解析结果(content_list.json / images / full.md),零额外 API 开销。
4
+ - 缓存有效性以文件内容 sha256 判定:PDF 变了会重新解析。
5
+ - manifest.json 记录:论文键 → {file_hash, batch_id, data_id, state, local_dir, params...}。
6
+ """
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import threading
12
+
13
+ from . import config
14
+
15
+ _LOCK = threading.RLock()
16
+ _MANIFEST = os.path.join(config.CACHE_DIR, "manifest.json")
17
+
18
+
19
+ def _safe_dirname(name: str) -> str:
20
+ s = re.sub(r'[\\/:*?"<>|\s]+', "_", name).strip("_. ")
21
+ return s[:100] or "paper"
22
+
23
+
24
+ def file_hash(path: str) -> str:
25
+ h = hashlib.sha256()
26
+ with open(path, "rb") as f:
27
+ for chunk in iter(lambda: f.read(1 << 20), b""):
28
+ h.update(chunk)
29
+ return h.hexdigest()
30
+
31
+
32
+ # ---------------- manifest 读写(带锁) ----------------
33
+ def _load() -> dict:
34
+ if not os.path.isfile(_MANIFEST):
35
+ return {"papers": {}}
36
+ try:
37
+ with open(_MANIFEST, "r", encoding="utf-8") as f:
38
+ m = json.load(f)
39
+ if not isinstance(m, dict):
40
+ return {"papers": {}}
41
+ m.setdefault("papers", {})
42
+ return m
43
+ except Exception: # noqa: BLE001 —— manifest 损坏时不阻塞,当作空
44
+ return {"papers": {}}
45
+
46
+
47
+ def _save(m: dict) -> None:
48
+ os.makedirs(config.CACHE_DIR, exist_ok=True)
49
+ tmp = _MANIFEST + ".tmp"
50
+ with open(tmp, "w", encoding="utf-8") as f:
51
+ json.dump(m, f, ensure_ascii=False, indent=2)
52
+ os.replace(tmp, _MANIFEST)
53
+
54
+
55
+ def get_entry(name: str):
56
+ with _LOCK:
57
+ return _load()["papers"].get(name)
58
+
59
+
60
+ def set_entry(name: str, **fields) -> dict:
61
+ """合并式写入(值为 None 的字段忽略,便于增量更新)。"""
62
+ with _LOCK:
63
+ m = _load()
64
+ entry = m["papers"].get(name, {})
65
+ entry.update({k: v for k, v in fields.items() if v is not None})
66
+ entry["name"] = name
67
+ m["papers"][name] = entry
68
+ _save(m)
69
+ return entry
70
+
71
+
72
+ def all_entries() -> dict:
73
+ with _LOCK:
74
+ return dict(_load()["papers"])
75
+
76
+
77
+ def name_by_data_id(data_id: str):
78
+ if not data_id:
79
+ return None
80
+ with _LOCK:
81
+ for name, e in _load()["papers"].items():
82
+ if e.get("data_id") == data_id:
83
+ return name
84
+ return None
85
+
86
+
87
+ # ---------------- 结果目录与文件定位 ----------------
88
+ def result_dir(name: str) -> str:
89
+ return os.path.join(config.CACHE_DIR, _safe_dirname(name))
90
+
91
+
92
+ def _find(local_dir: str, suffix: str):
93
+ if not local_dir or not os.path.isdir(local_dir):
94
+ return None
95
+ suffix = suffix.lower()
96
+ for root, _d, files in os.walk(local_dir):
97
+ for fn in files:
98
+ if fn.lower().endswith(suffix):
99
+ return os.path.join(root, fn)
100
+ return None
101
+
102
+
103
+ def find_content_list(local_dir: str):
104
+ # 优先 *content_list.json(排除 v2 变体的 endswith 差异)
105
+ return _find(local_dir, "content_list.json")
106
+
107
+
108
+ def find_full_md(local_dir: str):
109
+ hit = _find(local_dir, "full.md")
110
+ return hit or _find(local_dir, ".md")
111
+
112
+
113
+ def is_ready(name: str, expect_hash: str = None) -> bool:
114
+ """已解析且结果完整(state=done、目录存在、能找到 content_list.json,且哈希匹配)。"""
115
+ e = get_entry(name)
116
+ if not e or e.get("state") != "done":
117
+ return False
118
+ d = e.get("local_dir")
119
+ if not d or not os.path.isdir(d) or find_content_list(d) is None:
120
+ return False
121
+ if expect_hash and e.get("file_hash") and e["file_hash"] != expect_hash:
122
+ return False
123
+ return True
@@ -0,0 +1,91 @@
1
+ """配置与路径解析:集中管理环境变量、目录约定与安全路径处理。
2
+
3
+ 环境变量:
4
+ - PAPERS_PROJECT_ROOT : 项目根(papers/ 与输出目录的根),默认当前工作目录
5
+ - MINERU_API_TOKEN : MinerU 云端 API Token(必填,见 https://mineru.net)
6
+ - MINERU_API_BASE : API 基地址,默认 https://mineru.net/api/v4
7
+ - MINERU_MODEL_VERSION / MINERU_POLL_INTERVAL / MINERU_MAX_WAIT / MINERU_HTTP_TIMEOUT : 可选调优
8
+ """
9
+ import os
10
+
11
+
12
+ def _abs(p: str) -> str:
13
+ return os.path.abspath(os.path.expanduser(p))
14
+
15
+
16
+ BASE_DIR = _abs(os.environ.get("PAPERS_PROJECT_ROOT") or os.getcwd())
17
+ PAPERS_DIR = os.path.join(BASE_DIR, "papers")
18
+ CACHE_DIR = os.path.join(BASE_DIR, ".cache", "mineru")
19
+
20
+ # ---------------- MinerU 云端 API ----------------
21
+ MINERU_API_TOKEN = (os.environ.get("MINERU_API_TOKEN") or "").strip()
22
+ MINERU_API_BASE = (os.environ.get("MINERU_API_BASE") or "https://mineru.net/api/v4").rstrip("/")
23
+
24
+ DEFAULT_MODEL_VERSION = (os.environ.get("MINERU_MODEL_VERSION") or "vlm").strip()
25
+ POLL_INTERVAL = float(os.environ.get("MINERU_POLL_INTERVAL") or 30.0) # 阻塞等待时的轮询周期(秒)
26
+ DEFAULT_MAX_WAIT = float(os.environ.get("MINERU_MAX_WAIT") or 1800.0) # 便捷阻塞的总超时(秒) - 半小时
27
+ HTTP_TIMEOUT = float(os.environ.get("MINERU_HTTP_TIMEOUT") or 1800.0)
28
+
29
+
30
+ class MineruConfigError(RuntimeError):
31
+ """配置缺失(如未设置 Token)。"""
32
+
33
+
34
+ def require_token() -> str:
35
+ if not MINERU_API_TOKEN:
36
+ raise MineruConfigError(
37
+ "未配置 MINERU_API_TOKEN。请在 MCP 客户端的 env 中设置该环境变量"
38
+ "(在 https://mineru.net 申请 API Token)。"
39
+ )
40
+ return MINERU_API_TOKEN
41
+
42
+
43
+ # ---------------- 路径解析 ----------------
44
+ def resolve_pdf(pdf_path: str) -> str:
45
+ """把用户给的名字解析成真实存在的 PDF 绝对路径。
46
+
47
+ 依次尝试:原样(绝对/相对) → papers/ 下 → BASE_DIR 下;都允许省略 .pdf。
48
+ 找不到时抛出带"已尝试位置"的清晰错误。
49
+ """
50
+ if not pdf_path:
51
+ raise FileNotFoundError("pdf_path 为空。请传论文文件名(可省略 .pdf)。")
52
+ raw = pdf_path
53
+ names = [raw] if raw.lower().endswith(".pdf") else [raw, raw + ".pdf"]
54
+ cands = []
55
+ for name in names:
56
+ cands.append(name)
57
+ cands.append(os.path.join(PAPERS_DIR, name))
58
+ cands.append(os.path.join(BASE_DIR, name))
59
+ for c in cands:
60
+ if os.path.isfile(c):
61
+ return os.path.abspath(c)
62
+ tried = "\n - ".join(dict.fromkeys(os.path.abspath(c) for c in cands))
63
+ raise FileNotFoundError(
64
+ f"找不到 PDF:{raw!r}\n论文目录(papers):{PAPERS_DIR}\n已尝试以下位置:\n - {tried}\n"
65
+ f"提示:可用 papers://catalog 列出的文件名(支持省略 .pdf)。"
66
+ )
67
+
68
+
69
+ def paper_key(pdf_path: str) -> str:
70
+ """统一的论文标识:去掉目录与 .pdf 后缀的文件名(缓存 / 工具入参的规范键)。"""
71
+ base = os.path.basename(pdf_path or "")
72
+ if base.lower().endswith(".pdf"):
73
+ base = base[:-4]
74
+ return base
75
+
76
+
77
+ # ---------------- 输出沙箱 ----------------
78
+ def is_within(base: str, target: str) -> bool:
79
+ base, target = _abs(base), _abs(target)
80
+ return target == base or target.startswith(base + os.sep)
81
+
82
+
83
+ def safe_output_path(output_path: str) -> str:
84
+ """把输出路径夹到 BASE_DIR 内,禁止越界写。相对路径按 BASE_DIR 解析。"""
85
+ if not output_path:
86
+ raise ValueError("output_path 为空。")
87
+ p = output_path if os.path.isabs(output_path) else os.path.join(BASE_DIR, output_path)
88
+ p = _abs(p)
89
+ if not is_within(BASE_DIR, p):
90
+ raise ValueError(f"拒绝写入项目目录之外的位置:{p}(允许根:{BASE_DIR})")
91
+ return p