tm-desensitize/app.py

307 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""文档数据脱敏系统 - Web 平台(FastAPI, 端口 8736)
平台组负责:app.py / static / templates。
管线逻辑来自 tmkit(splitter/extractor/detector/redactor);若 tmkit 尚未就绪,
自动降级为占位逻辑(TODO 标注),保证页面可用。
"""
import re
import shutil
import threading
import time
import uuid
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
WORKDIR = Path(__file__).resolve().parent
UPLOAD_ROOT = Path("/tmp/tm-uploads")
UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
# ---------------- tmkit 装载(可降级) ----------------
try:
import tmkit # noqa
TMKIT_READY = True
TMKIT_ERR = None
except Exception as e: # ImportError 或子模块缺失
tmkit = None
TMKIT_READY = False
TMKIT_ERR = str(e)
def process_pdf(input_path: Path, out_path: Path, task: dict):
"""核心处理:tmkit 就绪走真管线,否则占位逻辑。返回敏感项列表。"""
items = []
if TMKIT_READY:
import fitz # PyMuPDF,tmkit 的底层依赖
pdf_bytes = input_path.read_bytes()
# 阶段 1:分流(native / scanned)
task["stage"] = "页面分流"
task["progress"] = 10
page_types = tmkit.classify_pages(pdf_bytes) or []
# 阶段 2+3:逐页抽取 + PII 识别
task["stage"] = "文字抽取与敏感项识别"
task["progress"] = 40
detector = tmkit.default_detector()
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
hits, items = [], []
for i, page in enumerate(doc):
page_no = i + 1
words = tmkit.extract_page_words(page)
found = detector.scan_page_words(words, page_no)
for h in found or []:
items.append({"type": h.get("type", "未知"),
"text": h.get("text", ""),
"page": h.get("page", page_no)})
if h.get("bbox") is not None:
hits.append({"page": h.get("page", page_no), "bbox": h["bbox"]})
# 阶段 4:脱敏回写
task["stage"] = "脱敏回写"
task["progress"] = 85
out_bytes = tmkit.redact_pdf(pdf_bytes, hits)
out_path.write_bytes(out_bytes)
else:
# ---------- TODO 占位逻辑(tmkit 未就绪) ----------
task["stage"] = "占位处理(tmkit 未就绪)"
task["progress"] = 50
time.sleep(1.2) # 模拟耗时,方便前端看到进度
raw = input_path.read_bytes()
# 用正则从原始字节里粗扫手机号样式,仅作演示
text = raw.decode("latin-1", errors="ignore")
for m in re.finditer(r"1[3-9]\d{9}", text):
items.append({"type": "手机号(占位)", "text": m.group(), "page": 1})
task["progress"] = 85
shutil.copy(input_path, out_path)
# ---------- 占位结束 ----------
if not out_path.exists():
shutil.copy(input_path, out_path)
return items
# ---------------- 任务表 ----------------
TASKS: dict = {} # task_id -> task dict
LOCK = threading.Lock()
def _run_task(task_id: str, input_path: Path, out_path: Path):
task = TASKS[task_id]
try:
items = process_pdf(input_path, out_path, task)
with LOCK:
task.update(status="done", stage="完成", progress=100,
items=items, finished_at=time.time())
except Exception as e:
with LOCK:
task.update(status="error", stage="失败", error=str(e))
app = FastAPI(title="文档数据脱敏系统")
@app.get("/api/tasks")
def list_tasks(task_id: Optional[str] = None):
with LOCK:
if task_id:
if task_id not in TASKS:
raise HTTPException(404, "task not found")
return TASKS[task_id]
return {"tmkit_ready": TMKIT_READY, "tmkit_error": TMKIT_ERR,
"tasks": list(TASKS.values())}
@app.post("/api/process")
async def process(file: UploadFile = File(...)):
if not (file.filename or "").lower().endswith(".pdf"):
raise HTTPException(400, "仅支持 PDF 文件")
task_id = uuid.uuid4().hex[:12]
tdir = UPLOAD_ROOT / task_id
tdir.mkdir(parents=True, exist_ok=True)
input_path = tdir / "input.pdf"
out_path = tdir / "redacted.pdf"
with open(input_path, "wb") as f:
shutil.copyfileobj(file.file, f)
TASKS[task_id] = {
"task_id": task_id, "filename": file.filename, "status": "processing",
"stage": "排队中", "progress": 5, "items": [],
"created_at": time.time(), "dir": str(tdir),
}
threading.Thread(target=_run_task, args=(task_id, input_path, out_path), daemon=True).start()
return {"task_id": task_id}
@app.get("/download/{task_id}")
def download(task_id: str, type: str = "redacted"):
task = TASKS.get(task_id)
if not task:
raise HTTPException(404, "task not found")
if task.get("status") != "done":
raise HTTPException(409, "任务未完成")
tdir = Path(task["dir"])
src = tdir / ("redacted.pdf" if type == "redacted" else "input.pdf")
if not src.exists():
raise HTTPException(404, "文件不存在")
if type == "redacted":
name = f"{Path(task['filename']).stem}_脱敏.pdf"
else:
name = task["filename"]
return FileResponse(src, filename=name)
@app.get("/progress", response_class=HTMLResponse)
def progress_page():
blocks = []
for key, label in (("backend", "管线组"), ("web", "平台组(Web)"), ("deploy", "部署组")):
p = WORKDIR / f"PROGRESS-{key}.md"
content = p.read_text(encoding="utf-8") if p.exists() else "> 进行中(暂无进展文件)"
blocks.append('<details open class="prog"><summary>{}</summary>'
'<pre>{}</pre></details>'.format(label, content.replace("&", "&amp;").replace("<", "&lt;")))
return """<!doctype html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>开发进展看板 · 文档数据脱敏系统</title><style>
body{font-family:-apple-system,"PingFang SC",sans-serif;margin:0;background:#f5f7fa;color:#222}
header{background:#1f2d3d;color:#fff;padding:18px 28px;font-size:18px;font-weight:600}
main{max-width:900px;margin:24px auto;padding:0 16px}
details{background:#fff;border-radius:8px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
summary{padding:12px 16px;font-weight:600;cursor:pointer;color:#1f2d3d}
pre{white-space:pre-wrap;margin:0;padding:4px 16px 16px;font-size:13px;line-height:1.7;color:#444}
</style></head><body>
<header>文档数据脱敏系统 · 开发进展看板</header>
<main>""" + "".join(blocks) + """</main></body></html>"""
@app.get("/", response_class=HTMLResponse)
def index():
return INDEX_HTML
INDEX_HTML = """<!doctype html>
<html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>文档数据脱敏系统</title><style>
:root{--c:#2563eb;--bg:#f5f7fa;--card:#fff;--border:#e5e7eb;--danger:#dc2626}
*{box-sizing:border-box}
body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;margin:0;background:var(--bg);color:#1f2937}
header{background:linear-gradient(135deg,#1e3a8a,#2563eb);color:#fff;padding:28px 20px;text-align:center}
header h1{margin:0;font-size:22px}header p{margin:6px 0 0;opacity:.85;font-size:13px}
main{max-width:860px;margin:0 auto;padding:20px 16px 60px}
.card{background:var(--card);border-radius:10px;padding:20px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.06)}
#drop{border:2px dashed var(--border);border-radius:10px;padding:44px 16px;text-align:center;color:#6b7280;cursor:pointer;transition:.2s}
#drop.over{border-color:var(--c);background:#eff6ff;color:var(--c)}
#drop .icon{font-size:36px}
button{background:var(--c);color:#fff;border:none;border-radius:6px;padding:10px 22px;font-size:14px;cursor:pointer}
button:hover{opacity:.9}
button.ghost{background:#fff;color:var(--c);border:1px solid var(--c)}
.bar{height:8px;background:var(--border);border-radius:4px;overflow:hidden;margin:10px 0}
.bar>i{display:block;height:100%;background:var(--c);width:0;transition:width .3s}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{border-bottom:1px solid var(--border);padding:8px 10px;text-align:left}
th{background:#f9fafb;color:#6b7280;font-weight:600}
.err{color:var(--danger)}
.hidden{display:none}
#statusLine{font-size:13px;color:#6b7280}
a.dl{display:inline-block;text-decoration:none}
.actions a{margin-right:10px}
small.tip{color:#9ca3af}
</style></head><body>
<header><h1>📄 文档数据脱敏系统</h1><p>上传 PDF,自动识别并脱敏敏感信息(手机号 / 身份证 / 银行卡等)</p></header>
<main>
<div class="card">
<div id="drop">
<div class="icon">📁</div>
<div>将 PDF 拖拽到此处,或点击选择文件</div>
<div style="margin-top:12px"><button id="pickBtn" type="button">选择 PDF 文件</button></div>
<input type="file" id="fileInput" accept="application/pdf,.pdf" style="display:none">
<div id="fileName" style="margin-top:8px;font-size:13px"></div>
</div>
</div>
<div class="card hidden" id="progressCard">
<b>处理进度</b>
<div class="bar"><i id="barFill"></i></div>
<div id="statusLine">等待中…</div>
<button id="cancelBtn" class="ghost" type="button" style="margin-top:8px" onclick="location.reload()">重新开始</button>
</div>
<div class="card hidden" id="resultCard">
<b>识别到的敏感项(<span id="itemCount">0</span> 项)</b>
<div style="max-height:320px;overflow:auto;margin-top:8px">
<table><thead><tr><th>类型</th><th>原文</th><th>页码</th></tr></thead>
<tbody id="itemBody"></tbody></table>
</div>
<div class="actions">
<a class="dl" id="dlRedacted" href="#"><button type="button">⬇️ 下载脱敏后文件</button></a>
<a class="dl" id="dlOriginal" href="#"><button class="ghost" type="button">保留原件下载</button></a>
</div>
<small class="tip" id="modeTip"></small>
</div>
</main>
<script>
const $=id=>document.getElementById(id);
let file=null,taskId=null,timer=null;
const drop=$('drop'),input=$('fileInput');
drop.onclick=()=>input.click();
input.onchange=()=>{if(input.files[0])setFile(input.files[0])};
['dragover','dragenter'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add('over')}));
['dragleave','drop'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove('over')}));
drop.addEventListener('drop',ev=>{const f=ev.dataTransfer.files[0];if(f)setFile(f)});
function setFile(f){
if(!f.name.toLowerCase().endsWith('.pdf')){alert('请上传 PDF 文件');return}
file=f;$('fileName').textContent='已选择:'+f.name+'('+(f.size/1024).toFixed(1)+' KB)';
upload();
}
async function upload(){
$('progressCard').classList.remove('hidden');
$('statusLine').textContent='上传中…';$('barFill').style.width='5%';
const fd=new FormData();fd.append('file',file);
try{
const r=await fetch('/api/process',{method:'POST',body:fd});
if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.status)}
const d=await r.json();taskId=d.task_id;poll();
}catch(err){$('statusLine').innerHTML='<span class="err">上传失败:'+err.message+'</span>'}
}
function poll(){
timer=setInterval(async()=>{
try{
const r=await fetch('/api/tasks?task_id='+taskId);
if(!r.ok)return;
const t=await r.json();
$('barFill').style.width=t.progress+'%';
if(t.status==='processing'){$('statusLine').textContent='正在处理:'+t.stage+'('+t.progress+'%)';return}
clearInterval(timer);
if(t.status==='error'){$('statusLine').innerHTML='<span class="err">处理失败:'+(t.error||'未知错误')+'</span>';return}
$('statusLine').textContent='处理完成 ✓';showResult(t);
}catch(e){}
},800);
}
function showResult(t){
$('progressCard').classList.add('hidden');
$('resultCard').classList.remove('hidden');
$('itemCount').textContent=t.items.length;
const tb=$('itemBody');tb.innerHTML='';
if(!t.items.length){tb.innerHTML='<tr><td colspan="3" style="color:#9ca3af">未识别到敏感项</td></tr>'}
t.items.forEach(it=>{
const tr=document.createElement('tr');
tr.innerHTML='<td>'+esc(it.type)+'</td><td>'+esc(it.text)+'</td><td>'+esc(String(it.page))+'</td>';
tb.appendChild(tr);
});
$('dlRedacted').href='/download/'+t.task_id+'?type=redacted';
$('dlOriginal').href='/download/'+t.task_id+'?type=original';
fetch('/api/tasks').then(r=>r.json()).then(d=>{
if(!d.tmkit_ready)$('modeTip').textContent='⚠️ 当前为占位模式(tmkit 管线未就绪),结果仅供演示';
}).catch(()=>{});
}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;')}
</script>
</body></html>"""
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8736)