tm脱敏系统 v0.1:分流+正则识别+坐标回写管线(12测试全绿) + FastAPI平台 + 进展看板

This commit is contained in:
tiezhu 2026-11-03 11:39:51 +08:00
commit 8b6b886bd1
13 changed files with 938 additions and 0 deletions

47
PROGRESS-backend.md Normal file
View 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
View 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
View 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
View 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("&", "&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)

54
tmkit/README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

79
tmkit/tests/conftest.py Normal file
View 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([""])

View 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)