tm-desensitize/tmkit/detector.py

154 lines
5.0 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.

"""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()