tm脱敏系统 v0.1:分流+正则识别+坐标回写管线(12测试全绿) + FastAPI平台 + 进展看板
This commit is contained in:
commit
8b6b886bd1
47
PROGRESS-backend.md
Normal file
47
PROGRESS-backend.md
Normal file
@ -0,0 +1,47 @@
|
||||
# tmkit 后端管线 · 进展报告(管线组)
|
||||
|
||||
**日期**:2026-11-03 **状态**:✅ 最小可用版交付,pytest 12/12 全绿
|
||||
|
||||
## 做了什么
|
||||
|
||||
在 `金唐智法/tm/tmkit/` 交付 Python 库 `tmkit`(纯库,不含 Web),四大核心模块 + 测试 + 文档:
|
||||
|
||||
| 模块 | 内容 |
|
||||
|---|---|
|
||||
| `splitter.py` | PDF 分流:`get_text` 检测文字层,逐页返回 `native` / `scanned`(阈值可调) |
|
||||
| `extractor.py` | 原生页按词抽取 bbox(`get_text('words')`);扫描页 `raise NotImplementedError('OCR 待接入 PaddleOCR')` |
|
||||
| `detector.py` | PII 识别引擎:身份证 18 位(GB 11643 校验位验证)、大陆手机号、银行卡(Luhn 校验)、统一社会信用代码(GB 32100 校验)、邮箱;输出 `[{type, text, page, bbox}]`,支持 `register()` 自定义规则 |
|
||||
| `redactor.py` | 坐标回写:两种方案都实现了——`add_redact_annot + apply_redactions`(默认,真删文字,测试验证 PII 从文本层消失、黑块像素可见)和 `draw_rect` 黑条(对比保留);输出脱敏后 PDF bytes |
|
||||
| `tests/` | reportlab 动态生成 3 个样例 PDF(纯中文文本页 / 身份证+手机号+银行卡+邮箱页 / 空白扫描页,中文字体用 STSong-Light CID),12 个用例全链路验证 |
|
||||
| `README.md` | 模块说明 + 代码示例 + 已知限制 |
|
||||
|
||||
## 测试结果(python3 -m pytest,12 passed)
|
||||
|
||||
- **全链路断言**:脱敏后 PDF 用 `get_text` 验证——身份证/手机号/银行卡/邮箱原文**不存在**、非敏感文本("张三""脱敏"等)**保留**、页数**不变**、bbox 区域像素为纯黑
|
||||
- **校验算法单测**:身份证校验位、Luhn 各有正反例
|
||||
- **零误报**:无 PII 的普通文本页检测结果为空
|
||||
- **自定义规则**:注册"案号"正则规则后命中正常
|
||||
- **扫描页**:正确抛 `NotImplementedError`(OCR 接口预留)
|
||||
|
||||
**覆盖 PII 类型(5 类)**:身份证、手机号、银行卡、统一社会信用代码、邮箱
|
||||
|
||||
## 开发中解决的坑(经验沉淀)
|
||||
|
||||
1. reportlab 默认字体 Helvetica 画中文全部变 "IIII",需注册 `UnicodeCIDFont("STSong-Light")`
|
||||
2. Python 正则 `\b` 在"中文紧贴数字"(如 `身份证号110101...`)处不成立(中文和数字都是 Unicode `\w`),已改用 ASCII-only lookaround 边界
|
||||
3. 回写方案选择:`apply_redactions` 删字最干净(文本层彻底无 PII),定为默认;`draw_rect` 黑条仅视觉遮挡、文字仍可复制提取,仅留作对比
|
||||
|
||||
## 已知限制 → 下一步
|
||||
|
||||
| 项 | 说明 | 计划 |
|
||||
|---|---|---|
|
||||
| OCR 未接入 | 扫描页直接抛 NotImplementedError | 接入 PaddleOCR,补齐扫描页链路(最高优先级) |
|
||||
| 跨行 PII | 身份证分两行排版时不命中(行内拼接策略) | 行间相邻性合并 |
|
||||
| 中文姓名/地址 | 属于 NER 范畴,正则不适用 | 待接入 NER 模型(第二优先级) |
|
||||
| 旋转页/加密 PDF | 未处理 | 视真实样本需求补 |
|
||||
| `draw` 模式 | 不删字,仅演示用 | 文档已标注风险 |
|
||||
|
||||
## 环境备忘
|
||||
|
||||
- `python3`(系统 3.9)+ pymupdf 1.26.5 / reportlab 5.0.0 / pytest 8.4.2(pip --user)
|
||||
- 复跑:`cd 金唐智法/tm && python3 -m pytest tmkit/tests/ -v`
|
||||
18
PROGRESS-deploy.md
Normal file
18
PROGRESS-deploy.md
Normal file
@ -0,0 +1,18 @@
|
||||
# 部署线进展(铁柱 · 2026-11-03 11:40)
|
||||
|
||||
## 已完成
|
||||
- ✅ tm.tongueclaw.com / git.tongueclaw.com 双域名 HTTPS 证书签发(Let's Encrypt,有效期至 2027-02-01)
|
||||
- ✅ git.tongueclaw.com:Gitea 代码仓库上线(管理员 tiezhu,仓库 tiezhu/tm-desensitize),HTTPS + systemd 常驻
|
||||
- ✅ tm.tongueclaw.com:nginx 反代就绪(127.0.0.1:8736,上传上限 30MB)
|
||||
- ✅ 服务器 Python 3.12 venv 环境(fastapi/uvicorn/pymupdf)
|
||||
- ✅ 代码推送仓库 + systemd 服务部署
|
||||
|
||||
## 访问入口
|
||||
- 系统使用:https://tm.tongueclaw.com/
|
||||
- 开发看板:https://tm.tongueclaw.com/progress
|
||||
- 代码仓库:https://git.tongueclaw.com/tiezhu/tm-desensitize
|
||||
|
||||
## 下一步
|
||||
- PaddleOCR 接入(扫描件路径)
|
||||
- 跨词/跨行 PII 识别优化(平台组已发现紧贴文字漏检问题)
|
||||
- 人审界面 + 审计日志
|
||||
41
PROGRESS-web.md
Normal file
41
PROGRESS-web.md
Normal file
@ -0,0 +1,41 @@
|
||||
# PROGRESS-web.md · 平台组(Web 前端)
|
||||
|
||||
更新:2026-11-03 11:50
|
||||
|
||||
## 已完成
|
||||
|
||||
- `app.py`:FastAPI 应用,端口 8736,绑定 0.0.0.0
|
||||
- `GET /`:中文单页应用(HTML/CSS/JS 全部内嵌,无外部 CDN,适配 2G 内存服务器)
|
||||
- 拖拽 + 按钮两种上传方式
|
||||
- 处理进度条 + 阶段文字(页面分流 → 抽取识别 → 脱敏回写)
|
||||
- 结果区:敏感项表格(类型/原文/页码)、下载脱敏文件按钮、保留原件下载按钮
|
||||
- `POST /api/process`(multipart):上传存 `/tmp/tm-uploads/<task_id>/input.pdf`,后台线程处理,立即返回 task_id
|
||||
- `GET /api/tasks` / `GET /api/tasks?task_id=`:任务状态轮询(含 tmkit 是否就绪标志)
|
||||
- `GET /download/{task_id}?type=redacted|original`:文件下载,未完成任务返回 409
|
||||
- `GET /progress`:开发进展看板,渲染 PROGRESS-backend/web/deploy.md,缺失显示「进行中」
|
||||
- tmkit 集成:按当前实际签名调用
|
||||
`classify_pages(pdf_bytes)` → 逐页 `extract_page_words(page)` + `detector.scan_page_words(words, page)` → `redact_pdf(pdf_bytes, hits)`
|
||||
tmkit 导入失败时自动降级为占位逻辑(正则粗扫手机号 + 原样拷贝输出),页面顶部/结果区会提示「占位模式」——TODO:tmkit 正式就绪后该分支可删
|
||||
- 非 PDF 上传返回 400;不存在任务/文件返回 404
|
||||
|
||||
## 自测结果(2026-11-03,本机)
|
||||
|
||||
- `uvicorn app:app --host 0.0.0.0 --port 8736` 启动正常
|
||||
- `GET /` → 200;`GET /progress` → 200
|
||||
- reportlab 生成测试 PDF(含手机号 13812345678 / 15987654321、身份证号)
|
||||
- `POST /api/process` → `{"task_id":"b603facc8ddc"}`
|
||||
- 轮询 `GET /api/tasks?task_id=` → `status:"done", items:[{"type":"phone","text":"15987654321","page":1}]`
|
||||
- `GET /download/...?type=redacted` → 200(966B,已脱敏)
|
||||
- `GET /download/...?type=original` → 200;错误 task_id → 404
|
||||
|
||||
## 已知问题
|
||||
|
||||
1. **检测漏项(疑似分词问题,归管线组)**:测试 PDF 里 `13812345678` 紧跟在 `Phone: ` 后,fitz 抽词时与前一截文字合并成一个 word,detector 逐词正则匹配不到;`15987654321` 前有空格可正常检出。建议 detector 对 word 做跨词滑窗或先拼接整行文本再定位 bbox。
|
||||
2. 任务表在内存中,重启后 /tmp 文件还在但任务状态丢失;上传目录无自动清理(长期运行会占盘)。
|
||||
3. 处理为单线程逐任务并发(threading),大文件并发多了内存可能吃紧(2G 服务器);未做限流。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 与管线组确认 detector 跨词匹配方案(已知问题 1)
|
||||
- 加 /tmp/tm-uploads 过期清理(如 24h 定时或启动时清理)
|
||||
- 看板加自动刷新/最后更新时间;部署组联调 systemd 常驻
|
||||
306
app.py
Normal file
306
app.py
Normal file
@ -0,0 +1,306 @@
|
||||
# -*- 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("&", "&").replace("<", "<")))
|
||||
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,'&').replace(/</g,'<')}
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8736)
|
||||
54
tmkit/README.md
Normal file
54
tmkit/README.md
Normal file
@ -0,0 +1,54 @@
|
||||
# tmkit — PDF 数据脱敏工具库(后端管线 · 最小可用版)
|
||||
|
||||
面向法律/金融文书 PDF 的 PII(个人敏感信息)自动脱敏。纯 Python 库,六大模块中的 4 个核心模块 + 测试 + 文档已交付。
|
||||
|
||||
## 模块
|
||||
|
||||
| 模块 | 功能 |
|
||||
|---|---|
|
||||
| `tmkit.splitter` | PDF 分流:`get_text` 检测文字层,逐页返回 `native` / `scanned` |
|
||||
| `tmkit.extractor` | 原生页按词抽取(`get_text('words')`,含 bbox 坐标);扫描页 `raise NotImplementedError('OCR 待接入 PaddleOCR')` |
|
||||
| `tmkit.detector` | PII 识别引擎:身份证 18 位(GB 11643 校验位)、大陆手机号、银行卡(Luhn)、统一社会信用代码(GB 32100)、邮箱;支持自定义规则注册 |
|
||||
| `tmkit.redactor` | 坐标回写:`add_redact_annot` + `apply_redactions`(默认,删字最干净)或 `draw_rect` 黑条;输出脱敏后 PDF bytes |
|
||||
|
||||
## 用法示例
|
||||
|
||||
```python
|
||||
import fitz
|
||||
from tmkit import default_detector, extract_page_words, redact_pdf
|
||||
|
||||
pdf_bytes = open("input.pdf", "rb").read()
|
||||
det = default_detector()
|
||||
hits = []
|
||||
with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
|
||||
for pno, page in enumerate(doc):
|
||||
hits += det.scan_page_words(extract_page_words(page), pno)
|
||||
# hits: [{"type": "id_card", "text": "1101...", "page": 0, "bbox": (x0,y0,x1,y1)}, ...]
|
||||
|
||||
clean = redact_pdf(pdf_bytes, hits) # method="redact"(默认,真删除)
|
||||
open("output.pdf", "wb").write(clean)
|
||||
```
|
||||
|
||||
自定义规则:
|
||||
|
||||
```python
|
||||
from tmkit import PIIDetector, Rule
|
||||
import re
|
||||
det = PIIDetector()
|
||||
det.register(Rule("order_no", re.compile(r"(\d{4})京\d+民初\d+号")))
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```
|
||||
cd tmkit && python3 -m pytest tests/ -v
|
||||
```
|
||||
|
||||
样例 PDF 由 reportlab 在 conftest.py 中动态生成:纯文本页、身份证+手机号+银行卡+邮箱页、空白扫描页。断言覆盖:脱敏后 `get_text` 中 PII 不存在、非敏感文本保留、页数不变、黑块像素可见、校验算法正确性、无 PII 页零误报。
|
||||
|
||||
## 已知限制(下一步)
|
||||
|
||||
- 扫描页 OCR(PaddleOCR)尚未接入,`extractor` 直接抛 `NotImplementedError`
|
||||
- PII 跨行换行(如身份证分两行)不会命中;词拼接按行内进行
|
||||
- `method="draw"` 只画黑条不删字,文本层仍可提取,仅作对比保留
|
||||
- 未处理旋转页 / 加密 PDF
|
||||
23
tmkit/__init__.py
Normal file
23
tmkit/__init__.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""tmkit: PDF 数据脱敏工具库(最小可用版)。
|
||||
|
||||
模块:
|
||||
- splitter: PDF 分流(native / scanned 页检测)
|
||||
- extractor: 原生页文字 + bbox 抽取
|
||||
- detector: PII 正则识别引擎(支持自定义规则)
|
||||
- redactor: bbox 坐标回写,输出脱敏后 PDF
|
||||
"""
|
||||
|
||||
from .splitter import classify_pages
|
||||
from .extractor import extract_page_words
|
||||
from .detector import PIIDetector, Rule, default_detector
|
||||
from .redactor import redact_pdf
|
||||
|
||||
__all__ = [
|
||||
"classify_pages",
|
||||
"extract_page_words",
|
||||
"PIIDetector",
|
||||
"Rule",
|
||||
"default_detector",
|
||||
"redact_pdf",
|
||||
]
|
||||
__version__ = "0.1.0"
|
||||
153
tmkit/detector.py
Normal file
153
tmkit/detector.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""PII 识别引擎:内置正则规则 + 校验算法,支持自定义规则注册。
|
||||
|
||||
输出统一为 {"type", "text", "page", "bbox"} 列表。
|
||||
坐标来源是"词"粒度(extractor 的 words),因此匹配策略:
|
||||
1) 先把整页 words 按行拼接(y 中心相近视为同线),
|
||||
2) 在行文本上跑规则,命中后把命中的字符区间映射回覆盖的 word bbox,
|
||||
3) 多个 word 命中时 bbox 取并集。
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
|
||||
# ---------- 校验算法 ----------
|
||||
|
||||
def _luhn_ok(digits: str) -> bool:
|
||||
if not digits.isdigit() or len(digits) < 12:
|
||||
return False
|
||||
total, alt = 0, False
|
||||
for ch in reversed(digits):
|
||||
d = ord(ch) - 48
|
||||
if alt:
|
||||
d *= 2
|
||||
if d > 9:
|
||||
d -= 9
|
||||
total += d
|
||||
alt = not alt
|
||||
return total % 10 == 0
|
||||
|
||||
|
||||
_ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
_ID_CODES = "10X98765432"
|
||||
|
||||
|
||||
def _idcard_ok(s: str) -> bool:
|
||||
if len(s) != 18:
|
||||
return False
|
||||
try:
|
||||
total = sum(int(s[i]) * _ID_WEIGHTS[i] for i in range(17))
|
||||
except ValueError:
|
||||
return False
|
||||
return _ID_CODES[total % 11] == s[17].upper()
|
||||
|
||||
|
||||
def _usc_ok(s: str) -> bool:
|
||||
"""统一社会信用代码校验(GB 32100-2015)。"""
|
||||
if len(s) != 18:
|
||||
return False
|
||||
chars = "0123456789ABCDEFGHJKLMNPQRTUWXY"
|
||||
weights = [1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28]
|
||||
try:
|
||||
total = sum(chars.index(s[i]) * weights[i] for i in range(17))
|
||||
except ValueError:
|
||||
return False
|
||||
return chars[(31 - total % 31) % 31] == s[17]
|
||||
|
||||
|
||||
# ---------- 规则定义 ----------
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
name: str
|
||||
pattern: "re.Pattern"
|
||||
validator: Optional[Callable[[str], bool]] = field(default=None)
|
||||
|
||||
|
||||
def r(pattern: str) -> "re.Pattern":
|
||||
return re.compile(pattern)
|
||||
|
||||
|
||||
# 左右边界:仅对 ASCII 字母/数字要求非邻接,允许紧贴中文
|
||||
_L, _R = r"(?<![0-9A-Za-z])", r"(?![0-9A-Za-z])"
|
||||
|
||||
|
||||
def _b(core: str) -> str:
|
||||
return _L + core + _R
|
||||
|
||||
|
||||
BUILTIN_RULES: List[Rule] = [
|
||||
Rule("id_card", r(_b(r"[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:[0-2]\d|3[01])\d{3}[\dXx]")), _idcard_ok),
|
||||
Rule("phone", r(_b(r"1[3-9]\d{9}"))),
|
||||
Rule("bank_card", r(_b(r"[3-6]\d{15,18}")), _luhn_ok),
|
||||
Rule("usc_code", r(_b(r"[159Y][1239]\d{6}[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]")), _usc_ok),
|
||||
Rule("email", r(_b(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"))),
|
||||
]
|
||||
|
||||
|
||||
# ---------- 检测器 ----------
|
||||
|
||||
class PIIDetector:
|
||||
def __init__(self, rules: Optional[List[Rule]] = None):
|
||||
self.rules = list(rules) if rules else list(BUILTIN_RULES)
|
||||
|
||||
def register(self, rule: Rule) -> None:
|
||||
"""注册自定义规则。"""
|
||||
self.rules.append(rule)
|
||||
|
||||
def _match(self, text: str):
|
||||
for rule in self.rules:
|
||||
for m in rule.pattern.finditer(text):
|
||||
if rule.validator is None or rule.validator(m.group()):
|
||||
yield rule.name, m
|
||||
|
||||
def scan_text(self, text: str) -> List[dict]:
|
||||
"""纯文本检测(无坐标),返回 [{type, text}]。"""
|
||||
return [{"type": t, "text": m.group()} for t, m in self._match(text)]
|
||||
|
||||
# -- 行重组:把 words 按基线聚成行 --
|
||||
|
||||
@staticmethod
|
||||
def _group_lines(words, y_tol=4.0):
|
||||
lines = []
|
||||
for w in sorted(words, key=lambda w: (round(w["bbox"][1], 1), w["bbox"][0])):
|
||||
placed = False
|
||||
for line in lines:
|
||||
ly = sum(w2["bbox"][1] + w2["bbox"][3] for w2 in line) / (2 * len(line))
|
||||
wy = (w["bbox"][1] + w["bbox"][3]) / 2
|
||||
if abs(ly - wy) <= y_tol:
|
||||
line.append(w)
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
lines.append([w])
|
||||
for line in lines:
|
||||
line.sort(key=lambda w: w["bbox"][0])
|
||||
return lines
|
||||
|
||||
def scan_page_words(self, words: List[dict], page: int) -> List[dict]:
|
||||
"""对单页 words(来自 extractor)做 PII 检测,返回带 bbox 命中。"""
|
||||
hits: List[dict] = []
|
||||
for line in self._group_lines(words):
|
||||
text = "".join(w["text"] for w in line)
|
||||
# 记录每个 word 在拼接文本中的起止偏移
|
||||
offsets, pos = [], 0
|
||||
for w in line:
|
||||
offsets.append((pos, pos + len(w["text"])))
|
||||
pos += len(w["text"])
|
||||
for ptype, m in self._match(text):
|
||||
s, e = m.span()
|
||||
covered = [w for w, (os_, oe) in zip(line, offsets) if oe > s and os_ < e]
|
||||
if not covered:
|
||||
continue
|
||||
x0 = min(w["bbox"][0] for w in covered)
|
||||
y0 = min(w["bbox"][1] for w in covered)
|
||||
x1 = max(w["bbox"][2] for w in covered)
|
||||
y1 = max(w["bbox"][3] for w in covered)
|
||||
hits.append({"type": ptype, "text": m.group(), "page": page, "bbox": (x0, y0, x1, y1)})
|
||||
return hits
|
||||
|
||||
|
||||
def default_detector() -> PIIDetector:
|
||||
return PIIDetector()
|
||||
29
tmkit/extractor.py
Normal file
29
tmkit/extractor.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""文字抽取:原生页按词取 bbox,扫描页留 OCR 接口。"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
Word = Dict # {"text": str, "bbox": (x0, y0, x1, y1)}
|
||||
|
||||
|
||||
def extract_page_words(page: "fitz.Page") -> List[Word]:
|
||||
"""抽取单页所有词及 bbox 坐标(PDF 坐标系,原点左上,单位 pt)。"""
|
||||
words = page.get_text("words") # [x0, y0, x1, y1, word, block, line, word_no]
|
||||
return [
|
||||
{"text": w[4], "bbox": (w[0], w[1], w[2], w[3])}
|
||||
for w in words
|
||||
]
|
||||
|
||||
|
||||
def extract_pdf_words(pdf_bytes: bytes) -> List[List[Word]]:
|
||||
"""抽取整个 PDF,外层按页。扫描页抛 NotImplementedError。"""
|
||||
from .splitter import classify_page
|
||||
|
||||
result: List[List[Word]] = []
|
||||
with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
|
||||
for page in doc:
|
||||
if classify_page(page) == "scanned":
|
||||
raise NotImplementedError("OCR 待接入 PaddleOCR")
|
||||
result.append(extract_page_words(page))
|
||||
return result
|
||||
48
tmkit/redactor.py
Normal file
48
tmkit/redactor.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""坐标回写:在 PII bbox 处黑条遮盖,输出脱敏后 PDF bytes。
|
||||
|
||||
两种方案:
|
||||
A) add_redact_annot + apply_redactions(官方推荐,真正删除文字内容)
|
||||
B) draw_rect fill=black(只画黑条,文字仍在文本层,可被复制提取)
|
||||
|
||||
默认用 A(删得最干净);fill 参数可同时画出黑底。测试验证 A 能让
|
||||
get_text 中 PII 消失。
|
||||
"""
|
||||
|
||||
import io
|
||||
from typing import Iterable, List
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
|
||||
def redact_pdf(pdf_bytes: bytes, hits: List[dict], method: str = "redact") -> bytes:
|
||||
"""按 hits([{page, bbox}, ...])脱敏,返回新 PDF bytes。
|
||||
|
||||
method:
|
||||
'redact' → add_redact_annot + apply_redactions(删除内容,推荐)
|
||||
'draw' → 仅画黑矩形(文字仍在)
|
||||
"""
|
||||
by_page: dict = {}
|
||||
for h in hits:
|
||||
by_page.setdefault(h["page"], []).append(h["bbox"])
|
||||
|
||||
src = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||
out = fitz.open()
|
||||
for pno, page in enumerate(src):
|
||||
boxes = by_page.get(pno, [])
|
||||
if method == "redact":
|
||||
for bbox in boxes:
|
||||
page.add_redact_annot(bbox, fill=(0, 0, 0))
|
||||
if boxes:
|
||||
page.apply_redactions()
|
||||
elif method == "draw":
|
||||
for bbox in boxes:
|
||||
page.draw_rect(bbox, color=None, fill=(0, 0, 0))
|
||||
else:
|
||||
raise ValueError(f"unknown method: {method}")
|
||||
out.insert_pdf(src, from_page=pno, to_page=pno)
|
||||
# 注意:redact 修改的是 src 中的页,insert_pdf 会拷贝修改后的页
|
||||
buf = io.BytesIO()
|
||||
out.save(buf)
|
||||
src.close()
|
||||
out.close()
|
||||
return buf.getvalue()
|
||||
24
tmkit/splitter.py
Normal file
24
tmkit/splitter.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""PDF 分流:检测每页有无文字层。"""
|
||||
|
||||
from typing import List
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
|
||||
def classify_page(page: "fitz.Page", min_chars: int = 20) -> str:
|
||||
"""判断单页类型。
|
||||
|
||||
有文字层(get_text 非空白字符数 >= min_chars)→ 'native',
|
||||
否则视为扫描页 → 'scanned'。min_chars 可调,避免只有页码的扫描页误判。
|
||||
"""
|
||||
text = page.get_text().strip()
|
||||
return "native" if len(text) >= min_chars else "scanned"
|
||||
|
||||
|
||||
def classify_pages(pdf_bytes: bytes, min_chars: int = 20) -> List[str]:
|
||||
"""返回 PDF 每页类型列表 ['native'|'scanned', ...]。"""
|
||||
out = []
|
||||
with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
|
||||
for page in doc:
|
||||
out.append(classify_page(page, min_chars))
|
||||
return out
|
||||
0
tmkit/tests/__init__.py
Normal file
0
tmkit/tests/__init__.py
Normal file
79
tmkit/tests/conftest.py
Normal file
79
tmkit/tests/conftest.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""pytest fixtures:用 reportlab 生成 3 个样例 PDF(中文字体 STSong-Light)。"""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))
|
||||
|
||||
|
||||
def _idcard_fix(prefix17: str) -> str:
|
||||
w = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
c = "10X98765432"
|
||||
total = sum(int(prefix17[i]) * w[i] for i in range(17))
|
||||
return prefix17 + c[total % 11]
|
||||
|
||||
|
||||
def _luhn_fix(prefix: str) -> str:
|
||||
for last in "0123456789":
|
||||
s = prefix + last
|
||||
total, alt = 0, False
|
||||
for ch in reversed(s):
|
||||
d = int(ch)
|
||||
if alt:
|
||||
d *= 2
|
||||
if d > 9:
|
||||
d -= 9
|
||||
total += d
|
||||
alt = not alt
|
||||
if total % 10 == 0:
|
||||
return s
|
||||
raise RuntimeError("no luhn digit")
|
||||
|
||||
|
||||
VALID_ID = _idcard_fix("11010119900307759") # 程序化补校验位,保证合法
|
||||
BANK_CARD = _luhn_fix("622202020011223344") # 19 位,Luhn 合法
|
||||
PHONE = "13812345678"
|
||||
EMAIL = "zhang.san@example.com"
|
||||
|
||||
|
||||
def _make_pdf(pages):
|
||||
"""pages: List[str],每页一段文本;空字符串 = 空白页(模拟扫描页)。"""
|
||||
buf = io.BytesIO()
|
||||
c = canvas.Canvas(buf, pagesize=A4)
|
||||
for text in pages:
|
||||
if text:
|
||||
y = 750
|
||||
for line in text.split("\n"):
|
||||
c.setFont("STSong-Light", 12)
|
||||
c.drawString(72, y, line)
|
||||
y -= 24
|
||||
c.showPage()
|
||||
c.save()
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pdf_plain():
|
||||
"""样例1:纯文本页(无 PII)。"""
|
||||
return _make_pdf(["本判决书仅供学习参考。\n案号:(2026)京01民初1号。\n审判员:李四。"])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pdf_pii():
|
||||
"""样例2:含身份证 + 手机号 + 银行卡 + 邮箱。"""
|
||||
return _make_pdf([
|
||||
f"原告:张三,身份证号 {VALID_ID},联系电话 {PHONE}。\n"
|
||||
f"收款账户:{BANK_CARD},邮箱 {EMAIL}。\n"
|
||||
"以上信息用于测试脱敏。"
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pdf_scanned():
|
||||
"""样例3:空白扫描页(无文字层)。"""
|
||||
return _make_pdf([""])
|
||||
116
tmkit/tests/test_pipeline.py
Normal file
116
tmkit/tests/test_pipeline.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""tmkit 全链路测试。"""
|
||||
|
||||
import fitz
|
||||
import pytest
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from tmkit.splitter import classify_pages
|
||||
from tmkit.extractor import extract_page_words, extract_pdf_words
|
||||
from tmkit.detector import PIIDetector, Rule, default_detector, _luhn_ok, _idcard_ok
|
||||
from tmkit.redactor import redact_pdf
|
||||
from .conftest import VALID_ID, BANK_CARD, PHONE, EMAIL
|
||||
|
||||
|
||||
def _scan(pdf_bytes):
|
||||
det = default_detector()
|
||||
hits = []
|
||||
with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
|
||||
for pno, page in enumerate(doc):
|
||||
words = extract_page_words(page)
|
||||
hits += det.scan_page_words(words, pno)
|
||||
return hits
|
||||
|
||||
|
||||
# ---------- splitter ----------
|
||||
|
||||
def test_splitter_native_vs_scanned(pdf_plain, pdf_scanned):
|
||||
assert classify_pages(pdf_plain) == ["native"]
|
||||
assert classify_pages(pdf_scanned) == ["scanned"]
|
||||
|
||||
|
||||
# ---------- extractor ----------
|
||||
|
||||
def test_extractor_words_with_bbox(pdf_plain):
|
||||
with fitz.open(stream=pdf_plain, filetype="pdf") as doc:
|
||||
words = extract_page_words(doc[0])
|
||||
assert words and all("text" in w and len(w["bbox"]) == 4 for w in words)
|
||||
joined = "".join(w["text"] for w in words)
|
||||
assert "判决书" in joined
|
||||
|
||||
|
||||
def test_extractor_scanned_raises(pdf_scanned):
|
||||
with pytest.raises(NotImplementedError):
|
||||
extract_pdf_words(pdf_scanned)
|
||||
|
||||
|
||||
# ---------- detector 校验算法 ----------
|
||||
|
||||
def test_idcard_validator():
|
||||
assert _idcard_ok(VALID_ID)
|
||||
assert not _idcard_ok(VALID_ID[:-1] + ("0" if VALID_ID[-1] != "0" else "1"))
|
||||
|
||||
|
||||
def test_luhn_validator():
|
||||
assert _luhn_ok(BANK_CARD)
|
||||
assert not _luhn_ok(BANK_CARD[:-1] + ("0" if BANK_CARD[-1] != "0" else "1"))
|
||||
|
||||
|
||||
# ---------- detector 全链路(文本 + 坐标) ----------
|
||||
|
||||
def test_detector_types_found(pdf_pii):
|
||||
hits = _scan(pdf_pii)
|
||||
types = {h["type"] for h in hits}
|
||||
assert types == {"id_card", "phone", "bank_card", "email"}
|
||||
texts = " ".join(h["text"] for h in hits)
|
||||
assert VALID_ID in texts and PHONE in texts and BANK_CARD in texts and EMAIL in texts
|
||||
for h in hits:
|
||||
assert set(h) == {"type", "text", "page", "bbox"}
|
||||
assert h["page"] == 0 and h["bbox"][2] > h["bbox"][0]
|
||||
|
||||
|
||||
def test_detector_no_false_positive_on_plain(pdf_plain):
|
||||
assert _scan(pdf_plain) == []
|
||||
|
||||
|
||||
def test_detector_custom_rule():
|
||||
det = PIIDetector(rules=[])
|
||||
det.register(Rule("order_no", __import__("re").compile(r"(2026)京01民初1号")))
|
||||
hits = det.scan_text("案号:(2026)京01民初1号。")
|
||||
assert hits == [{"type": "order_no", "text": "(2026)京01民初1号"}]
|
||||
|
||||
|
||||
# ---------- redactor 全链路 ----------
|
||||
|
||||
@pytest.mark.parametrize("method", ["redact", "draw"])
|
||||
def test_redact_pipeline(pdf_pii, method):
|
||||
hits = _scan(pdf_pii)
|
||||
out = redact_pdf(pdf_pii, hits, method=method)
|
||||
with fitz.open(stream=out, filetype="pdf") as doc:
|
||||
assert doc.page_count == 1 # 页数不变
|
||||
text = doc[0].get_text()
|
||||
if method == "redact":
|
||||
# PII 彻底删除
|
||||
assert VALID_ID not in text and PHONE not in text
|
||||
assert BANK_CARD not in text and EMAIL not in text
|
||||
# 非敏感文本保留
|
||||
assert "张三" in text and "脱敏" in text
|
||||
else:
|
||||
# draw 模式只画黑条,文本层仍在(已知限制,测试如实断言)
|
||||
assert VALID_ID in text
|
||||
|
||||
|
||||
def test_redact_idempotent_on_clean_pdf(pdf_plain):
|
||||
out = redact_pdf(pdf_plain, [])
|
||||
with fitz.open(stream=out, filetype="pdf") as doc:
|
||||
assert "判决书" in doc[0].get_text()
|
||||
|
||||
|
||||
def test_redact_black_box_visible(pdf_pii):
|
||||
hits = _scan(pdf_pii)
|
||||
out = redact_pdf(pdf_pii, hits, method="redact")
|
||||
# apply_redactions(fill) 后区域为黑:检查像素
|
||||
with fitz.open(stream=out, filetype="pdf") as doc:
|
||||
pix = doc[0].get_pixmap(clip=fitz.Rect(*hits[0]["bbox"]))
|
||||
assert pix.pixel(2, 2) == (0, 0, 0)
|
||||
Loading…
Reference in New Issue
Block a user