Files
kefu/wechat_rpa/session_name.py
T
2026-08-03 10:52:51 +08:00

240 lines
10 KiB
Python

"""
会话身份:读昵称
================
企业微信全自绘,无障碍树里拿不到任何文本(实测整个主窗口只有 2 个控件、零
Name),所以过去只能拿头像感知哈希 + 名称像素哈希当身份。那套东西同一个人换
个渲染就对不上,同一个联系人被拆成好几份档案,客户从此收不到回复。
这里改成直接把昵称读成文字(「一个小迷糊@微信」「高瑞@微信」),会话身份就是
这串昵称的 md5,一个人只有一个身份。
为什么不用 OCR 自带的文字检测:检测模型找框要 1.6 秒,放不进轮询。而"找出这块
面板里文字在哪"本来就是像素分析的事,自己按墨迹算完框、只让模型做识别,实测
降到 20 毫秒左右,随便调。
"""
import hashlib
import re
import threading
import unicodedata
import numpy as np
# 识别结果低于这个分就当没读到。实测正常昵称在 0.92 以上,
# 裁歪或糊掉时会掉到 0.7 附近
MIN_CONFIDENCE = 0.80
# 与已认识的昵称最多差几个字仍算同一个人。OCR 偶尔会把「打卡」读成「江卡」,
# 差一个字;差两个字以上就不敢认了,宁可当新客户
MAX_CORRECTION_DISTANCE = 1
# 太短的名字差一个字就是另一个人(「小王」vs「小李」),不做纠正
MIN_LENGTH_FOR_CORRECTION = 4
# 画面指纹 → 昵称的缓存上限。同一行的未读态、选中态、悬停态各占一个条目,
# 机器人一跑就是一整天,不封顶迟早把内存吃光
MAX_CACHE_ENTRIES = 512
_WHITESPACE_RE = re.compile(r"\s+")
def normalize_name(value: str) -> str:
"""同一个昵称在标题和列表里要归一成同一串。
实测标题读出来是「高瑞 @微信」、列表行是「高瑞@微信」,中间多个空格。
不归一的话同一个人会算出两个 md5,又回到一人多档的老问题上。
"""
text = unicodedata.normalize("NFKC", str(value or ""))
text = _WHITESPACE_RE.sub("", text)
return text.strip()
def session_id_for(name: str) -> str:
"""昵称 → 会话 ID。空名字不给 ID,宁可这一轮不认。"""
normalized = normalize_name(name)
if not normalized:
return ""
return hashlib.md5(normalized.encode("utf-8")).hexdigest()
def ink_bounds(region: np.ndarray, threshold: float = 45.0):
"""面板里文字实际占的矩形,(x1, y1, x2, y2);没有文字返回 None。"""
if region is None or getattr(region, "ndim", 0) != 3 or not region.size:
return None
gray = region[:, :, :3].mean(axis=2)
if not gray.size:
return None
background = float(np.median(gray))
ink = np.abs(gray - background) > threshold
rows = np.flatnonzero(ink.any(axis=1))
cols = np.flatnonzero(ink.any(axis=0))
if not rows.size or not cols.size:
return None
return int(cols[0]), int(rows[0]), int(cols[-1]) + 1, int(rows[-1]) + 1
def crop_to_ink(region: np.ndarray, pad: int = 5):
"""把面板裁到只剩文字,四周留一点白边。
留白不是可有可无:贴着字裁会把首字的一竖切掉,实测「一个小迷糊@微信」会
读成「个小迷糊@微信」、「高瑞@微信」会读成「瑞@微信」。
"""
bounds = ink_bounds(region)
if bounds is None:
return None
x1, y1, x2, y2 = bounds
height, width = region.shape[:2]
return np.ascontiguousarray(
region[
max(0, y1 - pad):min(height, y2 + pad),
max(0, x1 - pad):min(width, x2 + pad),
:3,
]
)
def _edit_distance(left: str, right: str, limit: int) -> int:
"""超过 limit 就提前退出,返回 limit + 1。"""
if abs(len(left) - len(right)) > limit:
return limit + 1
previous = list(range(len(right) + 1))
for i, lch in enumerate(left, 1):
current = [i]
for j, rch in enumerate(right, 1):
current.append(
min(
previous[j] + 1,
current[j - 1] + 1,
previous[j - 1] + (lch != rch),
)
)
if min(current) > limit:
return limit + 1
previous = current
return previous[-1]
class NameReader:
"""把一块画面读成昵称,并把偶发的一字误读纠回已认识的那个人。"""
def __init__(self):
self._engine = None
self._engine_failed = False
self._lock = threading.Lock()
self._known = {} # 归一昵称 -> 见过的次数
self._cache = {} # 画面指纹 -> 归一昵称
# ── OCR 引擎 ─────────────────────────────────────────────────────────────
def _ensure_engine(self):
if self._engine is not None or self._engine_failed:
return self._engine
with self._lock:
if self._engine is not None or self._engine_failed:
return self._engine
try:
from rapidocr_onnxruntime import RapidOCR
try:
# onnxruntime 默认按核数开满线程池且空转抢 CPU。识别的
# 都是几十像素高的小裁片,两个线程绰绰有余;不限的话
# 低配机每轮轮询都会被 OCR 抖一下。
self._engine = RapidOCR(
intra_op_num_threads=2, inter_op_num_threads=1
)
except Exception:
self._engine = RapidOCR()
except Exception as exc:
self._engine_failed = True
print(f" [身份] OCR 引擎不可用,无法读取会话昵称: {exc}")
return self._engine
@property
def available(self) -> bool:
return self._ensure_engine() is not None
# ── 读取 ─────────────────────────────────────────────────────────────────
def read_raw(self, region: np.ndarray) -> tuple[str, float]:
"""读一块面板,返回(昵称原文, 置信度)。读不到返回 ("", 0.0)。"""
crop = crop_to_ink(region)
if crop is None or crop.shape[0] < 6 or crop.shape[1] < 6:
return "", 0.0
engine = self._ensure_engine()
if engine is None:
return "", 0.0
try:
# use_det=False:框已经自己算好了,别再花 1.6 秒让模型找一遍
result, _elapse = engine(crop, use_det=False, use_cls=False)
except Exception as exc:
print(f" [身份] 昵称识别失败: {exc}")
return "", 0.0
if not result:
return "", 0.0
try:
return str(result[0][0] or ""), float(result[0][1] or 0.0)
except (IndexError, TypeError, ValueError):
return "", 0.0
def read(self, region: np.ndarray, signature: bytes = b"") -> str:
"""读出归一后的昵称;读不出或不够可信返回空串。
`signature` 是这块画面的像素指纹。同一块画面只识别一次,之后直接命中
缓存——这让同一个会话在整轮里始终得到同一个身份,不会因为某次识别抖动
而变成另一个人。
"""
key = bytes(signature or b"")
if key and key in self._cache:
return self._cache[key]
text, score = self.read_raw(region)
name = normalize_name(text)
if not name or score < MIN_CONFIDENCE:
return ""
name = self.canonical(name)
self._known[name] = self._known.get(name, 0) + 1
if key:
if len(self._cache) >= MAX_CACHE_ENTRIES:
# 先进先出即可:留下的都是最近见过的渲染,认错人的风险为零
# ——缓存没命中只是多花 20 毫秒重识别一次
for stale in list(self._cache)[: MAX_CACHE_ENTRIES // 4]:
self._cache.pop(stale, None)
self._cache[key] = name
return name
# ── 一字误读纠正 ─────────────────────────────────────────────────────────
def canonical(self, name: str) -> str:
"""把偶发误读纠回已经认识的那个昵称。
实测同一批识别里「打卡」被读成「江卡」、「行业资讯」被读成「亏业资讯」。
不纠的话,某一轮读错一个字就会被当成另一个客户、另开一份档案、上下文
断掉。只纠差一个字的,且名字本身要够长——「小王」和「小李」也只差一个
字,但那是两个人。
"""
normalized = normalize_name(name)
if not normalized or normalized in self._known:
return normalized
if len(normalized) < MIN_LENGTH_FOR_CORRECTION:
return normalized
best = ""
best_distance = MAX_CORRECTION_DISTANCE + 1
best_seen = -1
for known, seen in self._known.items():
if len(known) < MIN_LENGTH_FOR_CORRECTION:
continue
distance = _edit_distance(normalized, known, MAX_CORRECTION_DISTANCE)
if distance > MAX_CORRECTION_DISTANCE:
continue
# 同样接近时认见得多的那个:偶发误读只会出现一两次
if distance < best_distance or (
distance == best_distance and seen > best_seen
):
best, best_distance, best_seen = known, distance, seen
if not best:
return normalized
print(f" [身份] 昵称疑似误读,已按已认识的联系人纠正:{normalized!r}{best!r}")
return best
def remember(self, name: str) -> None:
"""把一个确定无误的昵称登记为已认识(例如从档案里恢复的)。"""
normalized = normalize_name(name)
if normalized:
self._known.setdefault(normalized, 1)
def known_names(self) -> list[str]:
return sorted(self._known)