@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.
@@ -0,0 +1,329 @@
1
+ """检索/阅读层:在已缓存的 content_list.json 之上提供结构化访问。
2
+
3
+ content_list.json 是 MinerU 输出的"扁平、按阅读顺序"块列表,每块含:
4
+ type(text|image|table|equation|...)、page_idx(0 起)、bbox
5
+ text : text、可选 text_level(>=1 为标题层级)
6
+ image : img_path、image_caption[]、image_footnote[]
7
+ table : table_body(HTML)、table_caption[]、table_footnote[]、可选 img_path
8
+ equation: latex / text
9
+ """
10
+ import json
11
+ import os
12
+ import re
13
+
14
+ from . import cache, config
15
+
16
+
17
+ class NotParsed(Exception):
18
+ """论文尚未解析完成 / 缓存缺失。"""
19
+
20
+
21
+ def load_paper(paper: str) -> "Paper":
22
+ name = config.paper_key(paper)
23
+ e = cache.get_entry(name)
24
+ if not e or e.get("state") != "done" or not e.get("local_dir"):
25
+ raise NotParsed(
26
+ f"论文 {name!r} 尚未解析完成。请先调用 parse_papers 解析(阻塞,完成后再检索)。"
27
+ )
28
+ cl = cache.find_content_list(e["local_dir"])
29
+ if not cl:
30
+ raise NotParsed(f"缓存中未找到 content_list.json(目录:{e['local_dir']}),建议重新解析(force=True)。")
31
+ return Paper(name, e["local_dir"], cl)
32
+
33
+
34
+ def _as_list(v):
35
+ if v is None:
36
+ return []
37
+ if isinstance(v, list):
38
+ return [str(x) for x in v if str(x).strip()]
39
+ return [str(v)] if str(v).strip() else []
40
+
41
+
42
+ def _join(v, sep=" ") -> str:
43
+ return sep.join(_as_list(v)).strip()
44
+
45
+
46
+ def _html_table_to_md(html: str) -> str:
47
+ """把 MinerU 的表格 HTML 尽力转成 Markdown 表格;失败则原样返回 HTML。忽略 col/rowspan。"""
48
+ if not html or "<" not in html:
49
+ return html or ""
50
+ try:
51
+ from html.parser import HTMLParser
52
+
53
+ class _T(HTMLParser):
54
+ def __init__(self):
55
+ super().__init__()
56
+ self.rows, self.cur, self.cell = [], None, None
57
+
58
+ def handle_starttag(self, tag, attrs):
59
+ if tag == "tr":
60
+ self.cur = []
61
+ elif tag in ("td", "th"):
62
+ self.cell = []
63
+
64
+ def handle_endtag(self, tag):
65
+ if tag in ("td", "th") and self.cell is not None:
66
+ self.cur.append(" ".join("".join(self.cell).split()))
67
+ self.cell = None
68
+ elif tag == "tr" and self.cur is not None:
69
+ self.rows.append(self.cur)
70
+ self.cur = None
71
+
72
+ def handle_data(self, data):
73
+ if self.cell is not None:
74
+ self.cell.append(data)
75
+
76
+ p = _T()
77
+ p.feed(html)
78
+ rows = [r for r in p.rows if r]
79
+ if not rows:
80
+ return html
81
+ ncol = max(len(r) for r in rows)
82
+ rows = [r + [""] * (ncol - len(r)) for r in rows]
83
+ esc = lambda c: c.replace("|", "\\|")
84
+ out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |",
85
+ "| " + " | ".join(["---"] * ncol) + " |"]
86
+ out += ["| " + " | ".join(esc(c) for c in r) + " |" for r in rows[1:]]
87
+ return "\n".join(out)
88
+ except Exception: # noqa: BLE001
89
+ return html
90
+
91
+
92
+ class Paper:
93
+ def __init__(self, name, local_dir, content_list_path):
94
+ self.name = name
95
+ self.local_dir = local_dir
96
+ with open(content_list_path, "r", encoding="utf-8") as f:
97
+ blocks = json.load(f)
98
+ self.blocks = blocks if isinstance(blocks, list) else []
99
+ self._fig_idx = [i for i, b in enumerate(self.blocks) if b.get("type") == "image"]
100
+ self._tbl_idx = [i for i, b in enumerate(self.blocks) if b.get("type") == "table"]
101
+ self.fig_id = {idx: f"F{k + 1}" for k, idx in enumerate(self._fig_idx)}
102
+ self.tbl_id = {idx: f"T{k + 1}" for k, idx in enumerate(self._tbl_idx)}
103
+
104
+ # ---------------- 内部辅助 ----------------
105
+ @staticmethod
106
+ def _page(b) -> int:
107
+ return int(b.get("page_idx", 0) or 0) + 1 # 转 1-based
108
+
109
+ @staticmethod
110
+ def _level(b) -> int:
111
+ try:
112
+ return int(b.get("text_level") or 0)
113
+ except (TypeError, ValueError):
114
+ return 0
115
+
116
+ def _abs_img(self, img_path):
117
+ """把 content_list 里的(通常相对)img_path 解析成缓存内的绝对路径。"""
118
+ if not img_path:
119
+ return None
120
+ if os.path.isabs(img_path) and os.path.isfile(img_path):
121
+ return img_path
122
+ cand = os.path.join(self.local_dir, img_path)
123
+ if os.path.isfile(cand):
124
+ return os.path.abspath(cand)
125
+ base = os.path.basename(img_path)
126
+ for root, _d, files in os.walk(self.local_dir):
127
+ if base in files:
128
+ return os.path.join(root, base)
129
+ return os.path.abspath(cand) # 兜底
130
+
131
+ # ---------------- info / outline ----------------
132
+ def info(self) -> dict:
133
+ pages = max((self._page(b) for b in self.blocks), default=0)
134
+ n_eq = sum(1 for b in self.blocks if b.get("type") == "equation")
135
+ title = None
136
+ for b in self.blocks:
137
+ if b.get("type") == "text" and self._level(b) == 1:
138
+ title = (b.get("text") or "").strip()
139
+ break
140
+ return {
141
+ "name": self.name,
142
+ "title": title,
143
+ "pages": pages,
144
+ "n_figures": len(self._fig_idx),
145
+ "n_tables": len(self._tbl_idx),
146
+ "n_equations": n_eq,
147
+ "local_dir": self.local_dir,
148
+ }
149
+
150
+ def outline(self) -> list:
151
+ items = []
152
+ for b in self.blocks:
153
+ if b.get("type") != "text":
154
+ continue
155
+ lvl = self._level(b)
156
+ txt = (b.get("text") or "").strip()
157
+ if lvl >= 1 and txt:
158
+ items.append({"level": lvl, "title": txt, "page": self._page(b)})
159
+ return items
160
+
161
+ def outline_text(self) -> str:
162
+ items = self.outline()
163
+ if not items:
164
+ return "(未识别到标题层级;可用 read_paper 按页范围分段通读。)"
165
+ return "\n".join(f"{' ' * (it['level'] - 1)}- {it['title']} (p.{it['page']})" for it in items)
166
+
167
+ # ---------------- 正文阅读 ----------------
168
+ def _render(self, i) -> str:
169
+ b = self.blocks[i]
170
+ t = b.get("type")
171
+ if t == "text":
172
+ lvl = self._level(b)
173
+ txt = (b.get("text") or "").strip()
174
+ if not txt:
175
+ return ""
176
+ return f"{'#' * min(lvl, 6)} {txt}" if lvl >= 1 else txt
177
+ if t == "equation":
178
+ latex = (b.get("latex") or b.get("text") or "").strip()
179
+ return f"$$ {latex} $$" if latex else ""
180
+ if t == "image":
181
+ cap = _join(b.get("image_caption"))
182
+ return f"[图 {self.fig_id.get(i, 'F?')}(p.{self._page(b)}){': ' + cap if cap else ''} —— 用 view_image 看图]"
183
+ if t == "table":
184
+ cap = _join(b.get("table_caption"))
185
+ return f"[表 {self.tbl_id.get(i, 'T?')}(p.{self._page(b)}){': ' + cap if cap else ''} —— 用 get_table 取表格]"
186
+ return ""
187
+
188
+ def _section_indices(self, section: str) -> list:
189
+ s = section.strip().lower()
190
+ start, start_level = None, 1
191
+ for i, b in enumerate(self.blocks):
192
+ if b.get("type") == "text" and self._level(b) >= 1 and s in (b.get("text") or "").strip().lower():
193
+ start, start_level = i, self._level(b)
194
+ break
195
+ if start is None:
196
+ return []
197
+ out = [start]
198
+ for i in range(start + 1, len(self.blocks)):
199
+ b = self.blocks[i]
200
+ if b.get("type") == "text" and 1 <= self._level(b) <= start_level:
201
+ break # 遇到同级或更高级标题即止
202
+ out.append(i)
203
+ return out
204
+
205
+ def read(self, section=None, page_start=None, page_end=None, max_chars=8000) -> str:
206
+ if section:
207
+ idxs = self._section_indices(section)
208
+ if not idxs:
209
+ return f"(未找到章节 {section!r};可用 get_paper_outline 查看章节名。)"
210
+ elif page_start or page_end:
211
+ ps = page_start or 1
212
+ pe = page_end or 10 ** 9
213
+ idxs = [i for i, b in enumerate(self.blocks) if ps <= self._page(b) <= pe]
214
+ else:
215
+ idxs = list(range(len(self.blocks)))
216
+
217
+ parts, total, truncated = [], 0, False
218
+ for i in idxs:
219
+ s = self._render(i)
220
+ if not s:
221
+ continue
222
+ if parts and total + len(s) > max_chars:
223
+ truncated = True
224
+ break
225
+ parts.append(s)
226
+ total += len(s) + 2
227
+ body = "\n\n".join(parts) if parts else "(所选范围无正文内容)"
228
+ if truncated:
229
+ body += f"\n\n…(已达 {max_chars} 字上限而截断;请缩小 section 或用更小的页范围继续读)"
230
+ return body
231
+
232
+ # ---------------- 检索 ----------------
233
+ def search(self, query, regex=False, max_hits=20, ctx=90) -> list:
234
+ if not query:
235
+ return []
236
+ if regex:
237
+ try:
238
+ pat = re.compile(query, re.I)
239
+ except re.error as e:
240
+ return [{"error": f"正则无效:{e}"}]
241
+ hits, cur_section = [], None
242
+ for b in self.blocks:
243
+ if b.get("type") == "text" and self._level(b) >= 1:
244
+ cur_section = (b.get("text") or "").strip()
245
+ if b.get("type") != "text":
246
+ continue
247
+ txt = b.get("text") or ""
248
+ m = pat.search(txt) if regex else None
249
+ pos = m.start() if m else (txt.lower().find(query.lower()) if not regex else -1)
250
+ if pos < 0:
251
+ continue
252
+ a = max(0, pos - ctx)
253
+ snippet = txt[a: pos + len(query) + ctx].strip().replace("\n", " ")
254
+ hits.append({"page": self._page(b), "section": cur_section, "snippet": snippet})
255
+ if len(hits) >= max_hits:
256
+ break
257
+ return hits
258
+
259
+ # ---------------- 图 ----------------
260
+ def figures(self) -> list:
261
+ out = []
262
+ for i in self._fig_idx:
263
+ b = self.blocks[i]
264
+ p = self._abs_img(b.get("img_path"))
265
+ out.append({
266
+ "id": self.fig_id[i],
267
+ "page": self._page(b),
268
+ "caption": _join(b.get("image_caption")),
269
+ "footnote": _join(b.get("image_footnote")),
270
+ "path": p,
271
+ "kb": round(os.path.getsize(p) / 1024, 1) if p and os.path.isfile(p) else None,
272
+ })
273
+ return out
274
+
275
+ def figure(self, fid: str) -> dict:
276
+ fid = (fid or "").strip().upper()
277
+ for i in self._fig_idx:
278
+ if self.fig_id[i] == fid:
279
+ b = self.blocks[i]
280
+ return {
281
+ "id": fid,
282
+ "page": self._page(b),
283
+ "caption": _join(b.get("image_caption")),
284
+ "footnote": _join(b.get("image_footnote")),
285
+ "path": self._abs_img(b.get("img_path")),
286
+ "hint": "如需查看图片内容,请把上面的 path 传给 view_image 工具读取。",
287
+ }
288
+ return {"error": f"未找到图 {fid};可用 list_figures 查看全部。"}
289
+
290
+ # ---------------- 表 ----------------
291
+ def tables(self) -> list:
292
+ out = []
293
+ for i in self._tbl_idx:
294
+ b = self.blocks[i]
295
+ out.append({
296
+ "id": self.tbl_id[i],
297
+ "page": self._page(b),
298
+ "caption": _join(b.get("table_caption")),
299
+ "footnote": _join(b.get("table_footnote")),
300
+ "has_html": bool(b.get("table_body")),
301
+ "img_path": self._abs_img(b.get("img_path")) if b.get("img_path") else None,
302
+ })
303
+ return out
304
+
305
+ def table(self, tid: str, fmt="markdown") -> dict:
306
+ tid = (tid or "").strip().upper()
307
+ fmt = fmt if fmt in ("markdown", "html") else "markdown"
308
+ for i in self._tbl_idx:
309
+ if self.tbl_id[i] == tid:
310
+ b = self.blocks[i]
311
+ html = b.get("table_body") or ""
312
+ return {
313
+ "id": tid,
314
+ "page": self._page(b),
315
+ "caption": _join(b.get("table_caption")),
316
+ "footnote": _join(b.get("table_footnote")),
317
+ "format": fmt,
318
+ "table": html if fmt == "html" else _html_table_to_md(html),
319
+ "img_path": self._abs_img(b.get("img_path")) if b.get("img_path") else None,
320
+ }
321
+ return {"error": f"未找到表 {tid};可用 list_tables 查看全部。"}
322
+
323
+ # ---------------- 全文 markdown ----------------
324
+ def markdown(self) -> str:
325
+ md = cache.find_full_md(self.local_dir)
326
+ if md:
327
+ with open(md, "r", encoding="utf-8") as f:
328
+ return f.read()
329
+ return self.read(max_chars=10 ** 9)
@@ -0,0 +1,106 @@
1
+ """MinerU 云端 API(mineru.net /api/v4)客户端:只负责 HTTP 交互,不涉及缓存/业务。
2
+
3
+ 工作流(上传本地文件):
4
+ request_upload() → 拿到 batch_id 与预签名 URL
5
+ upload_file() → PUT 文件到预签名 URL(系统在上传完成后自动建任务)
6
+ batch_results() → 轮询任务状态,done 时得到 full_zip_url
7
+ download_zip() → 下载并解压结果
8
+ """
9
+ import io
10
+ import os
11
+ import zipfile
12
+
13
+ import httpx
14
+
15
+ from . import config
16
+
17
+
18
+ class MineruError(RuntimeError):
19
+ """MinerU API 返回错误(code != 0 或 HTTP 异常)。"""
20
+
21
+
22
+ def _headers() -> dict:
23
+ return {
24
+ "Authorization": f"Bearer {config.require_token()}",
25
+ "Content-Type": "application/json",
26
+ "Accept": "*/*",
27
+ }
28
+
29
+
30
+ def _unwrap(resp: httpx.Response) -> dict:
31
+ """校验 HTTP 与业务 code,返回 data 字段。"""
32
+ resp.raise_for_status()
33
+ try:
34
+ payload = resp.json()
35
+ except Exception as e: # noqa: BLE001
36
+ raise MineruError(f"MinerU 返回非 JSON:{resp.text[:200]!r}") from e
37
+ code = payload.get("code")
38
+ if code not in (0, "0", None):
39
+ raise MineruError(
40
+ f"MinerU API 错误 code={code} msg={payload.get('msg')!r} "
41
+ f"trace_id={payload.get('trace_id')!r}"
42
+ )
43
+ return payload.get("data") or {}
44
+
45
+
46
+ def request_upload(files, model_version, enable_formula=True, enable_table=True, language=None):
47
+ """POST /file-urls/batch:申请上传 URL(文件上传完成后系统自动建任务)。
48
+
49
+ files: [{"name": "x.pdf", "data_id": "...", "is_ocr": bool}, ...]
50
+ 返回 (batch_id, [upload_url ...]),upload_url 与 files 同序。
51
+ """
52
+ body = {
53
+ "enable_formula": enable_formula,
54
+ "enable_table": enable_table,
55
+ "files": files,
56
+ }
57
+ if language:
58
+ body["language"] = language
59
+ if model_version:
60
+ body["model_version"] = model_version
61
+ url = f"{config.MINERU_API_BASE}/file-urls/batch"
62
+ with httpx.Client(timeout=config.HTTP_TIMEOUT) as c:
63
+ data = _unwrap(c.post(url, headers=_headers(), json=body))
64
+ return data.get("batch_id"), (data.get("file_urls") or [])
65
+
66
+
67
+ def upload_file(upload_url: str, file_path: str) -> None:
68
+ """PUT 本地文件到预签名 URL。不带鉴权头,也不要设置 Content-Type(与签名保持一致)。"""
69
+ with open(file_path, "rb") as f:
70
+ content = f.read()
71
+ with httpx.Client(timeout=config.HTTP_TIMEOUT) as c:
72
+ r = c.put(upload_url, content=content)
73
+ r.raise_for_status()
74
+
75
+
76
+ def batch_results(batch_id: str) -> list:
77
+ """GET /extract-results/batch/{batch_id} → extract_result 列表。
78
+
79
+ 每项含 file_name / data_id / state(done|running|pending|converting|failed)/
80
+ err_msg / full_zip_url(done 时)/ extract_progress。
81
+ """
82
+ url = f"{config.MINERU_API_BASE}/extract-results/batch/{batch_id}"
83
+ with httpx.Client(timeout=config.HTTP_TIMEOUT) as c:
84
+ data = _unwrap(c.get(url, headers=_headers()))
85
+ return data.get("extract_result") or []
86
+
87
+
88
+ def download_zip_bytes(zip_url: str) -> bytes:
89
+ """下载 full_zip_url,返回 zip 字节(不解压)。"""
90
+ with httpx.Client(timeout=config.HTTP_TIMEOUT, follow_redirects=True) as c:
91
+ r = c.get(zip_url)
92
+ r.raise_for_status()
93
+ return r.content
94
+
95
+
96
+ def extract_zip(blob: bytes, dest_dir: str) -> str:
97
+ """把 zip 字节解压到 dest_dir,返回 dest_dir。"""
98
+ os.makedirs(dest_dir, exist_ok=True)
99
+ with zipfile.ZipFile(io.BytesIO(blob)) as z:
100
+ z.extractall(dest_dir)
101
+ return dest_dir
102
+
103
+
104
+ def download_zip(zip_url: str, dest_dir: str) -> str:
105
+ """下载并解压(= download_zip_bytes + extract_zip)。"""
106
+ return extract_zip(download_zip_bytes(zip_url), dest_dir)
@@ -0,0 +1,70 @@
1
+ """产出层:安全写文件 + 把选中的图收集到 pre 目录。
2
+
3
+ 所有写操作都夹在 BASE_DIR 内(safe_output_path),避免越界写。
4
+ """
5
+ import os
6
+ import re
7
+ import shutil
8
+ import urllib.parse
9
+
10
+ from . import config
11
+ from .content import load_paper
12
+
13
+
14
+ def _sanitize(name: str) -> str:
15
+ s = re.sub(r'[\\/:*?"<>|\s]+', "_", name).strip("_. ")
16
+ return s[:80] or "img"
17
+
18
+
19
+ def write_file(output_path: str, content: str) -> dict:
20
+ if output_path is None or content is None:
21
+ return {"error": f"参数有误:output_path={output_path!r}, content_为空={content is None}"}
22
+ try:
23
+ path = config.safe_output_path(output_path)
24
+ except ValueError as e:
25
+ return {"error": str(e)}
26
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
27
+ with open(path, "w", encoding="utf-8") as f:
28
+ f.write(content)
29
+ return {"ok": True, "path": path, "chars": len(content)}
30
+
31
+
32
+ def collect_figures(paper: str, figure_ids, dest_dir: str) -> dict:
33
+ """把指定图拷进 dest_dir(项目内),返回可用于 <img src> 的相对引用路径。
34
+
35
+ src_from_base:相对 BASE_DIR 的路径(已做 URL 转义,空格→%20)。
36
+ 若 HTML 与图不在同层,请据实际位置再自行调整相对路径。
37
+ """
38
+ try:
39
+ p = load_paper(paper)
40
+ except Exception as e: # noqa: BLE001
41
+ return {"error": str(e)}
42
+ try:
43
+ dest = config.safe_output_path(dest_dir)
44
+ except ValueError as e:
45
+ return {"error": str(e)}
46
+ os.makedirs(dest, exist_ok=True)
47
+
48
+ if isinstance(figure_ids, str):
49
+ figure_ids = [figure_ids]
50
+ fig_map = {f["id"]: f for f in p.figures()}
51
+
52
+ out = []
53
+ for fid in figure_ids:
54
+ f = fig_map.get(str(fid).strip().upper())
55
+ if not f or not f["path"] or not os.path.isfile(f["path"]):
56
+ out.append({"id": fid, "error": "未找到该图或源文件缺失"})
57
+ continue
58
+ ext = os.path.splitext(f["path"])[1] or ".png"
59
+ fname = _sanitize(f"{p.name}_{f['id']}") + ext
60
+ dst = os.path.join(dest, fname)
61
+ shutil.copyfile(f["path"], dst)
62
+ rel = os.path.relpath(dst, config.BASE_DIR).replace(os.sep, "/")
63
+ out.append({
64
+ "id": f["id"],
65
+ "file": fname,
66
+ "path": dst,
67
+ "caption": f["caption"],
68
+ "src_from_base": urllib.parse.quote(rel),
69
+ })
70
+ return {"dest_dir": dest, "count": sum(1 for o in out if "error" not in o), "figures": out}