fix: QQ截图cap_left右移至25%避开消息列表时间戳; 修复未来时间戳误判为新鲜(14:40不能匹配16:00); 修复跨午夜反向边界+1day误用; 修复_is_markdown_document日志插入导致长文本检测死代码; 四层过滤器加日志

This commit is contained in:
youpaishidifu
2026-08-02 14:53:46 +08:00
parent 22e781edac
commit e9b9d6d92e
+56 -9
View File
@@ -1292,7 +1292,7 @@ class MessageMonitor:
# 计数 + 主动重启:EasyOCR GPU 内存泄漏累积 → OOM → 冻结
self._easyocr_call_count += 1
if self._easyocr_call_count >= self._easyocr_max_calls:
logger.debug(f"[ocr] EasyOCR 已达 {self._easyocr_max_calls} 次调用,重启子进程清 GPU 缓存")
logger.debug(f"[ocr] EasyOCR 正常资源释放:{self._easyocr_max_calls}次调用完成,重启子进程清GPU缓存(非错误,不计入失败计数器)")
try:
if self._easyocr_proc and self._easyocr_proc.poll() is None:
self._easyocr_proc.kill()
@@ -1581,8 +1581,8 @@ class MessageMonitor:
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
time.sleep(0.3)
# TIM/QQ 窗口布局: ~5-30% 左栏, ~30-50% 会话列表(含文档预览), ~50-100% 消息面板
# cap_left=15% 避开会话列表(用户反馈10%截到会话列表)
cap_left = int(left + win_w * 0.15)
# cap_left=25% 继续右移(用户反馈20%仍会捕捉到消息列表的时间戳,干扰消息判定)
cap_left = int(left + win_w * 0.25)
cap_top = top + max(80, int(win_h * 0.06)) # 跳过标题栏+群名头栏
cap_right = int(left + win_w * 0.78) # 跳过右栏群成员列表(~80%+) 和滚动条
cap_bottom = bottom - max(100, int(win_h * 0.10)) # 输入框上方
@@ -1942,6 +1942,7 @@ class MessageMonitor:
if self._is_reply_fragment(msg):
logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}")
else:
logger.debug(f"[ocr] stage[{i}] 消息通过所有过滤器,添加到expanded: {msg[:60]!r}")
expanded.append(msg)
buf = ''
last_ts = part
@@ -1977,6 +1978,7 @@ class MessageMonitor:
if self._is_reply_fragment(msg):
logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}")
else:
logger.debug(f"[ocr] stage[{i}] 尾部消息通过所有过滤器,添加到expanded: {msg[:60]!r}")
# EasyOCR 常把横向相邻的两条消息(用户消息+群昵称/UI文字)
# 检测为同一行,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一"
# 这种合并文本。尝试在中文句尾标点处拆分为多条。
@@ -2974,7 +2976,9 @@ class MessageMonitor:
def _is_reply_fragment(self, text: str) -> bool:
"""检查文本是否为 bot 回复内容的碎片(OCR 截取的 md 残片/跨行碎片)。"""
logger.debug(f"[ocr] 过滤器4: _is_reply_fragment 检查: {text[:80]!r}")
if not self._replied_texts or len(text) < 6:
logger.debug(f"[ocr] 过滤器4结果: 无已回复文本或文本太短({len(text)}<6),返回False")
return False
import difflib
# 原始文本匹配(含字母数字)
@@ -2983,6 +2987,7 @@ class MessageMonitor:
continue
sm = difflib.SequenceMatcher(None, text, r)
if sm.find_longest_match(0, len(text), 0, len(r)).size >= 6:
logger.debug(f"[ocr] 过滤器4结果: 原始文本匹配成功,返回True")
return True
# CJK-only 降阈值
cjk_t = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
@@ -2993,7 +2998,9 @@ class MessageMonitor:
continue
sm = difflib.SequenceMatcher(None, cjk_t, r_cjk)
if sm.find_longest_match(0, len(cjk_t), 0, len(r_cjk)).size >= 5:
logger.debug(f"[ocr] 过滤器4结果: CJK文本匹配成功,返回True")
return True
logger.debug(f"[ocr] 过滤器4结果: 未匹配到已回复文本,返回False")
return False
@staticmethod
@@ -3024,27 +3031,34 @@ class MessageMonitor:
有时间戳但全部过期 → 仅当最新时间戳不太老(≤600s)且文本含明确新鲜特征时放行,
否则视为历史消息丢弃
"""
logger.debug(f"[ocr] === 过滤器1: _ocr_text_has_fresh_timestamp 开始检查 ===")
logger.debug(f"[ocr] 输入文本: {text[:100]!r}")
if not text:
logger.debug(f"[ocr] 过滤器1结果: 空文本,返回False")
return False
# 标准化 EasyOCR 时间戳:16.47 → 16:47, 15;39 → 15:39, 16:03.41 → 16:03:41
normalized = re.sub(r'(\d{1,2})[.;](\d{2})(?:[.;](\d{2}))?', r'\1:\2:\3', text)
timestamps = re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', normalized)
logger.debug(f"[ocr] 提取到时间戳: {timestamps}")
if not timestamps:
# ⭐ 无可见时间戳:仅超短中文消息(如"好的""嗯")视为可能的新消息放行;
# 较长文本无时间戳 → 无法验证新鲜性,可能是不含时间戳的历史/UI噪声
# → 丢弃,防止旧消息因整批无时间戳而绕过 stage splitting 放行
# 但如果文本包含噪声(HTML/图片路径等),直接丢弃
logger.debug(f"[ocr] 无可见时间戳")
if self._is_noise_text(text):
logger.debug(f"[ocr] 无可见时间戳且为噪声文本,丢弃: {text[:80]!r}")
logger.debug(f"[ocr] 过滤器1结果: 无时间戳且为噪声文本,丢弃: {text[:80]!r}")
return False
if len(text.strip()) <= 4 and re.search(r'[\u4e00-\u9fff]', text):
logger.debug(f"[ocr] 过滤器1结果: 无时间戳但为超短中文消息,放行: {text[:80]!r}")
return True
logger.debug(f"[ocr] 无可见时间戳且非超短消息,丢弃: {text[:80]!r}")
logger.debug(f"[ocr] 过滤器1结果: 无时间戳且非超短消息,丢弃: {text[:80]!r}")
return False
# 有新鲜时间戳 → 确定有新消息
for ts in timestamps:
try:
if not self._is_timestamp_old(ts, 300):
logger.debug(f"[ocr] 过滤器1结果: 发现新鲜时间戳{ts}(≤300s),放行")
return True
except Exception:
pass
@@ -3058,18 +3072,25 @@ class MessageMonitor:
except Exception:
pass
if not newest_fresh_enough:
logger.debug(f"[ocr] 最新可见时间戳已过期≥600s,丢弃: {text[:80]!r}")
logger.debug(f"[ocr] 过滤器1结果: 最新可见时间戳已过期≥600s,丢弃: {text[:80]!r}")
return False
# 最新时间戳在 300-600s 之间,且文本含明确新鲜特征 → 放行给 stage splitting
if self._looks_fresh_content(text):
logger.debug(f"[ocr] 时间戳全过期但最新≤600s且含新鲜特征,放行: {text[:80]!r}")
logger.debug(f"[ocr] 过滤器1结果: 时间戳全过期但最新≤600s且含新鲜特征,放行: {text[:80]!r}")
return True
logger.debug(f"[ocr] 过滤器1结果: 时间戳全过期且无新鲜特征,丢弃: {text[:80]!r}")
return False
# 未来时间戳容忍偏差(秒):同日内时间戳比系统时间晚超过此值 = 未到来,直接丢弃。
# 容忍小量偏差避免误杀:OCR/聊天服务器时钟与系统时钟存在秒级差异,刚到的消息
# 时间戳可能比系统时间晚几秒(如 14:41:00 消息在 14:40:58 被捕获)。
_TS_FUTURE_TOLERANCE_S = 15
@staticmethod
def _is_timestamp_old(ts_str: str, max_age: int = 300) -> bool:
"""检查 HH:MM 或 HH:MM:SS 时间戳是否超过 max_age 秒。
处理跨午夜边界:如果 diff > 12h 说明是昨天的时间戳。
未来时间戳硬拦截:同日内 diff < -容差 = 时间未到来(系统14:40不可能有16:00),丢弃。
OCR 误读兜底:15:31→5:31(`1`笔画细被吞),若 h<12,尝试 h+10/h+12
取 diff 最小者,若 < max_age 则视为 OCR 误读,放行。
返回 True = 时间戳太老,应跳过。
@@ -3095,10 +3116,16 @@ class MessageMonitor:
if diff > 43200:
parsed = parsed - timedelta(days=1)
diff = (now - parsed).total_seconds()
# 未来时间戳: diff < -12h 说明是明天(当前 23:55,时间戳 00:05)
# 跨午夜反向: diff < -12h 说明是昨天晚些时候(当前 00:05,时间戳 23:55 → 昨天23:55)
# ⚠ 必须 -1day:之前误用 +1day 得到 -47h 的负 diff → 返回"不旧"=新鲜 → 凌晨误判
if diff < -43200:
parsed = parsed + timedelta(days=1)
parsed = parsed - timedelta(days=1)
diff = (now - parsed).total_seconds()
# 未来时间戳硬拦截(用户反馈:系统14:40 不能把 16:00 未到来的时间判为新鲜):
# 跨午夜调整后仍为负 = 同日内未来时间,必为 OCR 误读/异常,直接丢弃
if diff < -MessageMonitor._TS_FUTURE_TOLERANCE_S:
logger.debug(f"[ts] 未来时间戳拦截: {ts} 比系统时间 {now.strftime('%H:%M:%S')} 晚 {-diff:.0f}s,未到来,丢弃")
return True
# OCR 误读兜底:h<12 且判定为老 → 尝试 h+10 / h+12(OCR 丢失前导1:15→5)
if diff > max_age and h < 12:
best_h, best_diff = h, diff
@@ -3246,31 +3273,41 @@ class MessageMonitor:
Bot 回复用 md 格式(# 标题、[链接](url)、列表等),用户日常对话不用。
借此区分 bot 旧回复和历史堆积 vs 用户新消息。
"""
logger.debug(f"[ocr] 过滤器3: _is_markdown_document 检查: {text[:80]!r}")
if not text:
logger.debug(f"[ocr] 过滤器3结果: 空文本,返回False")
return False
# # 行首标题:短文本也检测(OCR 把 ##协议修 切成 5 字符片段)
if re.match(r'^#{1,6}(?:\s|\[|[\u4e00-\u9fff])', text):
logger.debug(f"[ocr] 过滤器3结果: 检测到Markdown标题语法,返回True")
return True
# 行首列表项:短文本也检测(- 玩家义务 / * 注意)
# 必须放 len<10 之前,否则 "- 玩家应尊重他"(8c) 漏网
if re.match(r'^[\-\*]\s', text):
logger.debug(f"[ocr] 过滤器3结果: 检测到Markdown列表语法,返回True")
return True
# 全角括号退化 md 链接:短文本也检测
if re.search(r'\](', text):
logger.debug(f"[ocr] 过滤器3结果: 检测到全角括号退化链接,返回True")
return True
if re.search(r'\[.+?\](', text):
logger.debug(f"[ocr] 过滤器3结果: 检测到全角括号退化链接模式2,返回True")
return True
# OCR 退化 md checkbox:短文本也检测([V] / [x])
if re.search(r'\[\s*[VXvx√✗]\s*\]', text):
logger.debug(f"[ocr] 过滤器3结果: 检测到OCR退化checkbox,返回True")
return True
# OCR 退化 md 链接缺括号:短文本也检测
if re.search(r'\[.+?\]\(', text):
logger.debug(f"[ocr] 过滤器3结果: 检测到OCR退化链接,返回True")
return True
# **加粗**语法:短文本也检测(bot md 回复标配,用户日常不用)
if re.search(r'\*\*.+?\*\*', text):
logger.debug(f"[ocr] 过滤器3结果: 检测到加粗语法,返回True")
return True
# <br> HTML 换行标记 — bot md 回复中用于换行,用户聊天不打
if re.search(r'<\s*br\s*/?\s*>', text, re.IGNORECASE):
logger.debug(f"[ocr] 过滤器3结果: 检测到HTML换行标记,返回True")
return True
# ###1. / ####数字标题 — 数字直接跟在 # 后(OCR 常见)
if re.match(r'^#{1,6}\d', text):
@@ -3292,6 +3329,7 @@ class MessageMonitor:
if '**' in text:
return True
if len(text) < 10:
logger.debug(f"[ocr] 过滤器3结果: 短文本(<10)无Markdown特征,返回False")
return False
# ##直接跟汉字(OCR 丢失 ##后空格时常见:##文档导航)
if re.match(r'^#{1,6}[\u4e00-\u9fff]', text):
@@ -3321,26 +3359,35 @@ class MessageMonitor:
这些文本通常来自QQ消息中的引用/图片附属内容,或OCR识别错误。
返回 True = 应当丢弃。
"""
logger.debug(f"[ocr] 过滤器2: _is_noise_text 检查: {text[:80]!r}")
if not text:
logger.debug(f"[ocr] 过滤器2结果: 空文本,返回False")
return False
# HTML 标签:<br>、<br/>、<p>、</p>、<div>等
if re.search(r'<\s*(?:br|p|div|span|a|img)\s*/?\s*>', text, re.IGNORECASE):
logger.debug(f"[ocr] 过滤器2结果: 检测到HTML标签,返回True")
return True
# 图片路径引用:![](./img/...)、!0(Jimg/...)、![](...)=等
if re.search(r'!\s*\[.*?\]\s*\(.*?\)', text):
logger.debug(f"[ocr] 过滤器2结果: 检测到图片路径引用模式1,返回True")
return True
# 简化图片路径:!0(Jimg/...) 或 ![](./img/...)
if re.search(r'!\s*\[.*?\]\s*\(', text):
logger.debug(f"[ocr] 过滤器2结果: 检测到图片路径引用模式2,返回True")
return True
# 纯图片路径:./img/... 或 img/...
if re.search(r'(?:^|\s)\.?(?:img|image|pic|photo)/\S+', text, re.IGNORECASE):
logger.debug(f"[ocr] 过滤器2结果: 检测到纯图片路径,返回True")
return True
# OCR 噪声字符模式:!0(J、![](...)= 等
if re.search(r'![\[\(]\S+[\]\)]', text):
logger.debug(f"[ocr] 过滤器2结果: 检测到OCR噪声字符模式,返回True")
return True
# 编辑权限等UI文本残留(用户截图中出现)
if re.search(r'编辑权限', text):
logger.debug(f"[ocr] 过滤器2结果: 检测到UI文本残留,返回True")
return True
logger.debug(f"[ocr] 过滤器2结果: 未检测到噪声特征,返回False")
return False
def _paste_and_send_safe(self, text: str, hwnd: int, msg_hash: int, platform: str) -> bool: