2026-08-20 12:32:35 +08:00
|
|
|
|
"""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
|
2026-08-20 13:05:35 +08:00
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "16003")), log_level="info")
|