"""PDF → Markdown 转换服务(FastAPI + LangGraph)。 接口: GET / 网页 GET /api/health 健康检查 POST /api/upload 上传 PDF 并启动解析(multipart file) GET /api/tasks 任务列表 GET /api/task/{id} 任务详情 GET /api/task/{id}/markdown Markdown 原文 GET /api/task/{id}/page/{n} 页面渲染图 GET /api/task/{id}/images/{f} 图片资源 GET /api/task/{id}/pdf 原始 PDF GET /api/task/{id}/download 打包下载(output.md + images) POST /api/task/{id}/rerun 重新解析 DELETE /api/task/{id} 删除任务 """ import io import json import os import shutil import threading import time import uuid import zipfile from datetime import datetime from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.responses import FileResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles import config from pipeline.graph import compiled_graph app = FastAPI(title="PDF → Markdown", docs_url="/docs") config.ensure_dirs() REGISTRY = os.path.join(config.DATA_DIR, "tasks.json") _lock = threading.Lock() # ---------------- 任务注册表 ---------------- def _load_registry(): if os.path.exists(REGISTRY): try: with open(REGISTRY, encoding="utf-8") as f: return json.load(f) except Exception: return {} return {} def _save_registry(reg): with open(REGISTRY, "w", encoding="utf-8") as f: json.dump(reg, f, ensure_ascii=False, indent=2) def _task_dir(task_id): return os.path.join(config.TASKS_DIR, task_id) def _safe_name(name: str) -> str: return "".join(c for c in (name or "upload.pdf") if c.isalnum() or c in "._- ").strip() or "upload.pdf" # ---------------- 解析执行 ---------------- def _run_parse(task_id, pdf_path, name): out_dir = _task_dir(task_id) images_dir = os.path.join(out_dir, "images") os.makedirs(images_dir, exist_ok=True) initial = { "task_id": task_id, "pdf_path": pdf_path, "out_dir": out_dir, "images_dir": images_dir, "status": "running", "logs": [f"开始解析:{name}"], } t0 = time.time() try: result = compiled_graph.invoke(initial) dt = time.time() - t0 if result.get("status") == "error": with _lock: reg = _load_registry() if task_id in reg: reg[task_id]["status"] = "failed" reg[task_id]["error"] = result.get("error") reg[task_id]["updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") _save_registry(reg) return # 更新清单耗时 mp = os.path.join(out_dir, "manifest.json") if os.path.exists(mp): try: with open(mp, encoding="utf-8") as f: man = json.load(f) man["elapsed_sec"] = round(dt, 1) man["status"] = "done" with open(mp, "w", encoding="utf-8") as f: json.dump(man, f, ensure_ascii=False, indent=2) except Exception: pass with _lock: reg = _load_registry() if task_id in reg: reg[task_id]["status"] = "done" reg[task_id]["elapsed_sec"] = round(dt, 1) reg[task_id]["updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") _save_registry(reg) except Exception as e: with _lock: reg = _load_registry() if task_id in reg: reg[task_id]["status"] = "failed" reg[task_id]["error"] = str(e) reg[task_id]["updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") _save_registry(reg) def _start_parse(task_id, pdf_path, name): t = threading.Thread(target=_run_parse, args=(task_id, pdf_path, name), daemon=True) t.start() # ---------------- 路由 ---------------- @app.get("/api/health") def health(): return {"ok": True, "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")} @app.post("/api/upload") async def upload(file: UploadFile = File(...)): name = _safe_name(file.filename) if not name.lower().endswith(".pdf"): raise HTTPException(400, "仅支持 PDF 文件") task_id = uuid.uuid4().hex[:12] out_dir = _task_dir(task_id) os.makedirs(out_dir, exist_ok=True) pdf_path = os.path.join(out_dir, "input.pdf") content = await file.read() if len(content) < 50 or not content.startswith(b"%PDF"): shutil.rmtree(out_dir, ignore_errors=True) raise HTTPException(400, "文件不是有效的 PDF") with open(pdf_path, "wb") as f: f.write(content) with _lock: reg = _load_registry() reg[task_id] = { "task_id": task_id, "name": name, "status": "running", "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "size_kb": round(len(content) / 1024, 1), "error": None, } _save_registry(reg) _start_parse(task_id, pdf_path, name) return {"task_id": task_id, "name": name, "status": "running"} @app.get("/api/tasks") def tasks(): reg = _load_registry() items = [] for tid, info in sorted(reg.items(), key=lambda x: x[1].get("created", ""), reverse=True): d = dict(info) out = _task_dir(tid) d["has_markdown"] = os.path.exists(os.path.join(out, "output.md")) n_imgs = 0 img_dir = os.path.join(out, "images") if os.path.isdir(img_dir): n_imgs = len([x for x in os.listdir(img_dir) if x.lower().endswith((".png", ".jpg", ".jpeg"))]) d["images"] = n_imgs if os.path.exists(os.path.join(out, "manifest.json")): try: with open(os.path.join(out, "manifest.json"), encoding="utf-8") as f: man = json.load(f) d["total_pages"] = man.get("total_pages") d["page_types"] = man.get("page_types") except Exception: pass items.append(d) return {"tasks": items} @app.get("/api/task/{task_id}") def task_detail(task_id: str): out = _task_dir(task_id) if not os.path.isdir(out): raise HTTPException(404, "任务不存在") reg = _load_registry() info = dict(reg.get(task_id, {})) info["task_id"] = task_id info["out_dir"] = out man_path = os.path.join(out, "manifest.json") if os.path.exists(man_path): try: with open(man_path, encoding="utf-8") as f: info["manifest"] = json.load(f) except Exception: info["manifest"] = None if not os.path.exists(man_path) and info.get("status") == "running": info["status"] = "running" info["logs"] = (info.get("manifest") or {}).get("logs") or [] return info @app.get("/api/task/{task_id}/markdown") def task_markdown(task_id: str): p = os.path.join(_task_dir(task_id), "output.md") if not os.path.exists(p): raise HTTPException(404, "还没有生成 Markdown") with open(p, encoding="utf-8") as f: return Response(f.read(), media_type="text/markdown; charset=utf-8") @app.get("/api/task/{task_id}/page/{n}") def task_page(task_id: str, n: int): p = os.path.join(_task_dir(task_id), "pages", f"page_{n:03d}.png") if not os.path.exists(p): raise HTTPException(404, "页面不存在") return FileResponse(p, media_type="image/png") @app.get("/api/task/{task_id}/images/{fname}") def task_image(task_id: str, fname: str): p = os.path.join(_task_dir(task_id), "images", os.path.basename(fname)) if not os.path.exists(p): raise HTTPException(404, "图片不存在") return FileResponse(p) @app.get("/api/task/{task_id}/pdf") def task_pdf(task_id: str): p = os.path.join(_task_dir(task_id), "input.pdf") if not os.path.exists(p): raise HTTPException(404, "PDF 不存在") reg = _load_registry() name = reg.get(task_id, {}).get("name", "input.pdf") return FileResponse(p, media_type="application/pdf", filename=name) @app.get("/api/task/{task_id}/download") def task_download(task_id: str): out = _task_dir(task_id) if not os.path.isdir(out): raise HTTPException(404, "任务不存在") buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: for root, _, files in os.walk(out): for fn in files: fp = os.path.join(root, fn) if fn == "input.pdf": continue arc = os.path.relpath(fp, out) z.write(fp, arc) buf.seek(0) reg = _load_registry() name = (reg.get(task_id, {}).get("name") or "task").replace(".pdf", "") return Response(buf.getvalue(), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{name}_markdown.zip"'}) @app.post("/api/task/{task_id}/rerun") def task_rerun(task_id: str): out = _task_dir(task_id) pdf_path = os.path.join(out, "input.pdf") if not os.path.exists(pdf_path): raise HTTPException(404, "PDF 不存在") # 清掉旧产出 for d in ("images", "pages"): shutil.rmtree(os.path.join(out, d), ignore_errors=True) for fn in os.listdir(out): if fn in ("input.pdf",): continue fp = os.path.join(out, fn) if os.path.isfile(fp): os.remove(fp) else: shutil.rmtree(fp, ignore_errors=True) reg = _load_registry() if task_id in reg: reg[task_id]["status"] = "running" reg[task_id]["error"] = None reg[task_id]["updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") _save_registry(reg) _start_parse(task_id, pdf_path, reg.get(task_id, {}).get("name", "input.pdf")) return {"task_id": task_id, "status": "running"} @app.delete("/api/task/{task_id}") def task_delete(task_id: str): out = _task_dir(task_id) if not os.path.isdir(out): raise HTTPException(404, "任务不存在") shutil.rmtree(out, ignore_errors=True) with _lock: reg = _load_registry() reg.pop(task_id, None) _save_registry(reg) return {"ok": True} # 静态前端(放在最后,避免覆盖 API) app.mount("/", StaticFiles(directory=os.path.join(config.BASE_DIR, "static"), html=True), name="static") if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "16101")), log_level="info")