Files
msgbot/monitor.py
T

7256 lines
386 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""消息监控 — 全列表指纹对比 + 窗口冻结检测 + ControlFromHandle 强制刷新"""
import time
import logging
import threading
import traceback
import unicodedata
import faulthandler
import sqlite3
import hashlib
from difflib import SequenceMatcher
from typing import Optional, Callable, List
faulthandler.enable() # 捕获 C 扩展段错误,输出 traceback 到 stderr
logging.getLogger("comtypes").setLevel(logging.WARNING)
logger = logging.getLogger("msgbot.monitor")
# ⚠ 顺序重要: torch/easyocr 必须在 COM 模块 (uiautomation/winrt) 之前导入,
# 否则 c10.dll 会因 COM 初始化冲突抛出 WinError 1114。
_HAS_EASYOCR = False
_easyocr = None
_np = None
try:
import easyocr as _easyocr
import numpy as _np
_HAS_EASYOCR = True
except (ImportError, OSError) as _e:
import traceback as _tb
_easyocr = None
_np = None
logger.debug(f"[ocr] EasyOCR 导入失败: {type(_e).__name__}: {_e}")
logger.debug(f"[ocr] Traceback: {_tb.format_exc()}")
import uiautomation as auto
import win32gui
import win32con
import win32api
import win32process
import win32ui
import re
import sys
import ctypes
import difflib
import os
# ⭐ 偏旁部首 Unicode 库:用于 OCR 碎片过滤
# 单一数据源 = unicode_radicals.md(人和机器共用):
# - 人用肉眼看「偏旁」列维护
# - bot 启动时解析「Unicode」列(U+XXXX)构建码点集合,比对只看码点
# 「Unicode」列规则:U+XXXX = 真实码点(参与过滤);- = 不参与过滤
# (与常用字同形 女/子/日/月/木/水/火/土... 和繁体部首 見/貝/車/馬... 一律写 -,
# 防止误杀正常聊天;详见 md「使用说明」)
# 维护方式:编辑 md 添加/删除行即可,无需改代码。
# 仅当 md 缺失/解析失败时才回退到硬编码码位范围(保底,正常不触发)。
_RADICALS_MD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "unicode_radicals.md")
# 保底范围:CJK Radicals Supplement U+2E80-U+2EFF + Extended-A U+2F00-U+2FDF
_RADICAL_RANGES_FALLBACK = (
(0x2E80, 0x2EFF), # CJK Radicals Supplement
(0x2F00, 0x2FDF), # CJK Radicals Extended-A
)
def _load_radical_codepoints() -> frozenset:
"""从 unicode_radicals.md 解析「Unicode」列,返回码点集合。
行格式: | 亻 | U+4EBB | 单人旁 | 件→亻牛 | 件 |
Unicode 列必须是 U+XXXX / U+XXXXX 十六进制(偏旁的真实码点);
写 '-' 的行(常用字同形/繁体部首)解析不到 U+ 即跳过,不参与过滤。
"""
cps = set()
try:
with open(_RADICALS_MD, "r", encoding="utf-8") as f:
for line in f:
m = re.search(r"U\+([0-9A-Fa-f]{4,5})\b", line)
if m:
cps.add(int(m.group(1), 16))
except OSError as e:
logger.warning(f"[ocr] 偏旁库 {_RADICALS_MD} 读取失败({e}),回退码位范围")
return frozenset()
if not cps:
logger.warning("[ocr] 偏旁库为空,回退码位范围")
return frozenset()
logger.debug(f"[ocr] 偏旁库加载 {len(cps)} 个码点 <- {_RADICALS_MD}")
return frozenset(cps)
_RADICAL_CODEPOINTS = _load_radical_codepoints()
def _is_radical_char(ch: str) -> bool:
"""检查字符是否为 CJK 偏旁字符。
优先查 md 库的 Unicode 码点(维护只改 md);
库未加载成功时回退到硬编码码位范围(保底)。
"""
cp = ord(ch)
if _RADICAL_CODEPOINTS:
return cp in _RADICAL_CODEPOINTS
return any(lo <= cp <= hi for lo, hi in _RADICAL_RANGES_FALLBACK)
import subprocess
import json
import psutil
import os
import random
from datetime import datetime, timedelta
import signal
from PIL import Image, ImageGrab, ImageEnhance, ImageFilter
from winrt.windows.media.ocr import OcrEngine
from winrt.windows.globalization import Language
from winrt.windows.graphics.imaging import SoftwareBitmap, BitmapPixelFormat
if not _HAS_EASYOCR:
logger.debug("[ocr] EasyOCR 未就绪,仅使用 Windows OCR")
# ===== 主动防御: 全平台 UI 按钮/标签/面板标题黑名单 =====
# 微信/TIM/QQ 各界面按钮、标签、状态栏文本,OCR 读到后直接丢弃。
# 仅精确匹配(整行),零误杀风险。
_UI_EXACT_BLOCKLIST = frozenset({
# — 微信 顶栏/导航 —
'微信', '通讯录', '发现', '我',
# — 微信 发现页 —
'朋友圈', '视频号', '看一看', '搜一搜', '直播', '附近',
'扫一扫', '摇一摇', '小程序',
# — 微信 我页 —
'服务', '购物', '收藏', '卡包', '表情', '游戏中心', '设置',
'切换账号', '退出登录',
# — 微信 设置页 —
'开启群聊验证', '朋友权限', '停用', '更新',
'存储空间', '清空缓存', '帮助与反馈',
# — 微信 桌面版面板 —
'小程序面板', '朋友圈', # 紧凑版面板标题
# — 微信 聊天会话列表 —
'新的朋友', '群聊', '标签', '公众号', '我的设备', '我的收藏',
# — 微信 输入栏 —
'输入', '按住说话', '松开发送',
# — 微信 聊天附加功能 —
'视频通话', '语音通话', '个人名片', '位置共享',
'红包', '转账', '相机', '相册', '文件', '音乐',
# — 微信 群聊信息面板 —
'群聊名称', '群二维码', '群公告', '群聊信息', '群成员',
'全部', '未读', '置顶', '免打扰', '置顶聊天',
'我在本群的昵称', '显示群成员昵称',
'聊天记录', '查找聊天记录', '聊天文件', '清空聊天记录',
'投诉', '成员', '邀请', '添加成员', '移出群聊', '解散群聊',
# — 微信 公众号 —
'关注', '赞', '在看', '分享', '留言', '作者',
'阅读原文', '不让看', '取消关注',
# — TIM/QQ 顶栏/侧栏 —
'消息', '联系人', '勋章墙', '收藏', '文件', '设置',
# — TIM/QQ 状态 —
'在线', '离线', '忙碌', '隐身', 'Q我吧', '离开',
# — TIM/QQ 搜索 —
'搜索', '查找', '搜索聊天记录',
# — TIM/QQ 联系人/群管理 —
'我的设备', '我的收藏', '最近联系人', '创建群聊',
'添加好友', '添加群', '添加群聊', '创建讨论组',
# — TIM/QQ 聊天窗口 —
'截图', '文件传输', '远程桌面', '远程控制',
'发送文件', '发送图片',
'关闭', '最小化', '最大化',
'窗口抖动', '抖动',
# — TIM/QQ 面板 —
'QQ会员', 'QQ空间', '应用宝', '兴趣部落', '同城服务',
'我的文件', '网络硬盘', '收藏夹', '好友动态',
# — 自定义插件 —
'启用监控', '停止监控', '监控已启用', '监控已停止',
# — 通用系统提示 —
'加载中', '加载失败', '正在连接', '连接失败',
'网络连接成功', '网络连接断开', '连接中',
'已连接', '已断开', '重新连接',
'消息发送失败', '消息已发出', '对方正在输入',
'点击查看', '点击进入',
'已显示全部消息', '以上是历史消息',
'你已添加了', '现在可以开始聊天了',
'当前网络不可用', '请检查网络设置',
'操作失败', '操作成功', '请求超时',
# — QQ 加载状态提示 —
'正在加载页面,请稍后',
# — Smart-Dump 漏网 —
'手机',
})
# ===== 主动防御: UI 子串黑名单(含其中任何子串即跳过)=====
# 这些词在用户日常聊天中极不可能出现,只在 UI 面板/系统标签中出现。
# 防止 OCR 碎片精确匹配失败但子串命中。
_UI_SUBSTR_BLOCKLIST = frozenset({
'启用监控', '停止监控', '数据看板', '应用设置',
'债券总和', '注册用户总数', 'php管理员', '消息漫游',
# QQ 群"共同好友"推荐(非聊天内容,OCR误读触发查库)
'共同好友', '点击添加好友',
# 微信/QQ "拍了拍" 系统通知(UI按钮触发,非用户消息)
'拍了拍',
})
class MessageHistory:
"""管理消息历史数据库,用于判断无时间戳消息是否应放行"""
def __init__(self, db_path: str = "msg_history.db"):
self.db_path = db_path
self._last_bot_reply_content = {} # platform → content
self._init_db()
def _init_db(self):
"""初始化数据库表"""
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS message_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
sender_type TEXT NOT NULL,
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_content_hash ON message_history(content_hash)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_platform_time ON message_history(platform, created_at)
""")
conn.commit()
conn.close()
def _get_conn(self):
"""获取数据库连接"""
return sqlite3.connect(self.db_path, check_same_thread=False)
def record_bot_reply(self, platform: str, content: str):
"""记录bot回复"""
content_hash = hashlib.md5(content.strip().encode('utf-8')).hexdigest()
conn = self._get_conn()
try:
# ⭐ Task #1442: 使用本地时间而不是UTC时间
# SQLite的CURRENT_TIMESTAMP返回UTC时间,导致时间戳混乱
# 修复:使用Python本地时间
from datetime import datetime
local_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"INSERT INTO message_history (platform, sender_type, content, content_hash, created_at) VALUES (?, 'bot', ?, ?, ?)",
(platform, content.strip(), content_hash, local_time)
)
conn.commit()
finally:
conn.close()
# 更新最近bot回复内容(用于检测OCR噪声导致的重复)
self._last_bot_reply_content[platform] = content.strip()
def record_user_message(self, platform: str, content: str):
"""记录用户消息(用于后续去重)"""
content_hash = hashlib.md5(content.strip().encode('utf-8')).hexdigest()
conn = self._get_conn()
try:
# ⭐ Task #1442: 使用本地时间而不是UTC时间
# SQLite的CURRENT_TIMESTAMP返回UTC时间,导致时间戳混乱
from datetime import datetime
local_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"INSERT INTO message_history (platform, sender_type, content, content_hash, created_at) VALUES (?, 'user', ?, ?, ?)",
(platform, content.strip(), content_hash, local_time)
)
conn.commit()
finally:
conn.close()
def is_user_message_replied(self, platform: str, content: str, user_msg_time: float = 0) -> bool:
"""检查用户消息是否已被回复过(数据库中有相同内容的bot回复)
user_msg_time: 用户消息的时间戳(Unix时间),如果晚于最后bot回复时间,则视为新消息不跳过
"""
content_hash = hashlib.md5(content.strip().encode('utf-8')).hexdigest()
conn = self._get_conn()
try:
# 首先检查是否有相同内容的bot回复
cursor = conn.execute(
"SELECT COUNT(*) FROM message_history WHERE platform=? AND sender_type='bot' AND content_hash=?",
(platform, content_hash)
)
count = cursor.fetchone()[0]
if count == 0:
return False # 没有相同内容的bot回复,未被回复过
# 如果有相同内容的bot回复,但用户消息时间戳晚于最后bot回复时间,则视为新消息
if user_msg_time > 0:
cursor = conn.execute(
"SELECT created_at FROM message_history WHERE platform=? AND sender_type='bot' ORDER BY id DESC LIMIT 1",
(platform,)
)
row = cursor.fetchone()
if row and row[0]:
# ⭐ Task #1442: 正确解析本地时间
# 现在created_at存储的是本地时间(如"2026-08-06 14:30:00")
try:
from datetime import datetime
bot_time_str = row[0]
bot_time = datetime.strptime(bot_time_str, "%Y-%m-%d %H:%M:%S").timestamp()
# 如果用户消息时间戳晚于最后bot回复时间,视为新消息
if user_msg_time > bot_time:
return False
except Exception:
pass # 时间解析失败,按原逻辑处理
return True
finally:
conn.close()
def last_bot_reply_time(self, platform: str) -> float:
"""获取该平台最近一次bot回复的时间戳(Unix时间)"""
conn = self._get_conn()
try:
cursor = conn.execute(
"SELECT created_at FROM message_history WHERE platform=? AND sender_type='bot' ORDER BY id DESC LIMIT 1",
(platform,)
)
row = cursor.fetchone()
if row:
# ⭐ Task #1442: 正确解析本地时间
# 现在created_at存储的是本地时间(如"2026-08-06 14:30:00")
# 直接解析即可,无需时区转换
from datetime import datetime
dt = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S')
return dt.timestamp()
return 0.0
finally:
conn.close()
def get_recent_bot_replies(self, platform: str, limit: int = 20, within_minutes: int = None) -> list:
"""获取该平台最近的bot回复内容列表,用于查库比对
Args:
within_minutes: 只返回最近N分钟内的回复,None表示不过滤时间
"""
conn = self._get_conn()
try:
if within_minutes is not None and within_minutes > 0:
from datetime import datetime, timedelta
five_minutes_ago = (datetime.now() - timedelta(minutes=within_minutes)).strftime("%Y-%m-%d %H:%M:%S")
cursor = conn.execute(
"SELECT content FROM message_history WHERE platform=? AND sender_type='bot' AND created_at > ? ORDER BY id DESC LIMIT ?",
(platform, five_minutes_ago, limit)
)
else:
cursor = conn.execute(
"SELECT content FROM message_history WHERE platform=? AND sender_type='bot' ORDER BY id DESC LIMIT ?",
(platform, limit)
)
return [row[0] for row in cursor.fetchall() if row[0]]
finally:
conn.close()
class MessageMonitor:
"""多线程消息监控器"""
def __init__(self, on_message: Optional[Callable[[str, str], None]] = None):
self._running = False
self._thread: Optional[threading.Thread] = None
self._qq_thread: Optional[threading.Thread] = None
self._emergency_thread: Optional[threading.Thread] = None
self._poll_interval = 2.0
self._on_message = on_message
self._wechat_enabled = True
self._qq_enabled = False
# 全列表签名 & 完整文本列表 (用于新消息比对)
self._last_sig: dict = {}
self._last_texts: dict = {}
# 最新消息文本(供 GUI 展示)
self._last_msg: dict = {}
# 最近一次bot回复内容(用于检测OCR噪声导致的重复)
self._last_bot_reply_content: dict = {} # platform → content
# 轮询计数
self._poll_count: dict = {}
# QQ 轮询跳过计数(每 3 周期 poll 一次)
self._qq_skip_counter = 0
# 窗口句柄缓存(存 HWND,不存 Control 引用)
self._cached_hwnd: dict = {}
# 窗口对应进程 ID(验证窗口是否仍属同一进程)
self._cached_pid: dict = {}
# 窗口未找到冷却截止时间(time.time 值),期内跳过搜索
self._window_not_found_until: dict = {}
# 连续相同签名计数(用于冻结检测)
self._stale_count: dict = {}
# 连续空读
self._empty_reads: dict = {}
# 剪贴板原始文本(用于自动回复提取发送者)
self._raw_clipboard: dict = {}
# 发送中事件锁(防止自动回复时监控轮询干扰)
self._sending_event = threading.Event()
# 应用变体:qq→"qq_nt"或"tim"
self._app_variant: dict = {}
# 已处理消息的全局持久化集合(跨平台、跨轮次,防回环)
self._seen_messages: set = set()
# 已回复文本的永久集—永不裁切(剪贴板读到自己回复时必拦)
self._replied_texts: set = set()
# 上次回复时间戳(冷却期内跳过该平台 poll)
self._last_reply_time: dict = {}
# 同平台新消息冷却:同一 poll 或相邻 poll 内多条消息只处理第一条
self._last_new_msg_time: dict = {}
self._new_msg_cooldown: float = 3.0 # 同平台 3 秒内不重复处理
# 发送失败消息冷却:msg_hash → 失败时间,冷却期内不再尝试回复(防止卡死)
self._failed_send_cooldown: dict = {} # platform → timestamp
# 跨平台前台冷却:某平台刚切过前台(回复后),对端平台等待此时间再截图
self._foreground_cooldown_until: dict = {} # platform → float(time.time)
# 同平台回复冷却:回复后此秒数内同平台跳过 OCR(防读到自身回复回声)
self._self_reply_cooldown_until: dict = {} # platform → float(time.time)
# 本轮选中的 OCR 引擎(用于日志标记)
self._last_chosen_engine: dict = {} # platform → "windows"|"easyocr"
# OCR 引擎(Windows 原生,lazy init)
self._ocr_engine = None
# EasyOCR 备用引擎(lazy init)
self._easyocr_reader = None
# EasyOCR 子进程(GPU crash 隔离,崩溃只杀子进程不冻电脑)
self._easyocr_proc: Optional['subprocess.Popen'] = None
self._easyocr_broken: bool = False
# ⭐ Task #1446:worker 是否已完成 ready 握手(就绪感知,避免未就绪即派发请求)
self._easyocr_ready: bool = False
self._easyocr_ready_event = threading.Event() # 握手完成信号,并发等待线程用
self._easyocr_ready_timeout: float = 60.0 # 等待握手的最长预算(=初始化预算,不计入请求超时)
self._easyocr_starting: bool = False # 是否有线程正在拉起+Popen 子进程
# EasyOCR 子进程崩溃冷却:崩溃后 30s 内不重试(防快速崩溃循环刷屏)
self._easyocr_retry_until: float = 0.0
# EasyOCR 子进程调用计数:累积 GPU 内存泄漏,每 MAX 次主动重启清缓存
self._easyocr_call_count: int = 0
self._easyocr_max_calls: int = 3 # 最多 3 次后重启子进程
# EasyOCR 连续失败计数:前2次保留子进程(预留初始化时间),第3次杀进程重来
self._easyocr_consecutive_failures: int = 0
self._easyocr_max_failures: int = 3 # 连续3次失败后杀进程
# EasyOCR 连续启动失败计数:子进程无法启动时递增,达到阈值时清除冷却重试
self._easyocr_consecutive_startup_failures: int = 0
self._easyocr_max_startup_failures: int = 5 # 连续5次启动失败后长冷却(内存不足场景)
# EasyOCR CUDA 错误计数:≥2 次 OOM/kernel error → 下次启动强制 CPU(防冻结)
self._easyocr_cuda_oom_count: int = 0
# EasyOCR 正常调度重启标志:正常资源释放后重启子进程时跳过内存检测
self._easyocr_normal_restart: bool = False
# EasyOCR 动态超时:每次超时增加15秒,成功后重置为45秒,最大120秒
self._easyocr_current_timeout: float = 45.0
self._easyocr_min_timeout: float = 45.0
self._easyocr_max_timeout: float = 120.0
self._easyocr_timeout_step: float = 15.0
# 引擎选择计数器
self._ocr_engine_wins: dict = {"windows": 0, "easyocr": 0}
# 上次 OCR 原始文本(用于增量检测)
self._ocr_last_text: dict = {}
# OCR 垃圾连续计数(连续垃圾超过阈值清缓存重找)
self._ocr_garbage_count: dict = {}
# 截图 hash(像素级,截图不变→OCR 必然无新消息)
self._last_screenshot_hash: dict = {} # platform → str
self._ocr_unchanged: dict = {} # platform → bool(本轮因截图不变跳过)
self._last_ocr_time: dict = {} # platform → float(上次真正做OCR的时间戳)
# ⭐ hash 跳过鲁棒性:上一帧"已捕捉且已入队"才允许后续相同 hash 帧跳过。
# 若上一帧 OCR 捕捉到但未被入队(被时间戳/噪声过滤),则不跳过,
# 让后续相同 hash 帧重新做 OCR,避免因永久跳过导致消息丢失。
self._last_hash_queued: dict = {} # platform → bool(上一帧 OCR 是否产出可处理消息)
self._last_hash_replied: dict = {} # platform → bool(上一帧消息是否已被回复)
# OCR 截屏定时清理:保存后不再立即删(高频 create+delete 易触发 360 误报),
# 改由 _maybe_clean_ocr_dir 每 30~60 秒随机间隔清理一次过期截图。
self._next_ocr_clean_at: float = 0.0
self._ocr_clean_ttl: float = 60.0 # 保留最近 60 秒内的截图,更早的删除
# EasyOCR 子进程锁(QQ/微信独立线程共用同一子进程)
self._easyocr_lock = threading.Lock()
# ⭐ Task #1446:串行化子进程启动/Popen,防并发双 Popen 与「未就绪快路径」竞态
self._easyocr_startup_lock = threading.Lock()
# QQ 独立轮询线程(不阻塞微信主循环)
self._qq_thread: Optional[threading.Thread] = None
# 双击 ESC 急停线程
self._emergency_thread: Optional[threading.Thread] = None
# 消息历史数据库(用于无时间戳消息放行判断)
self._msg_history = MessageHistory()
# 启动时加载历史回复到 _replied_texts,防止重启后旧回复被重新处理
self._load_replied_texts_from_db()
# 剪贴板内容 hash(防止跨平台读到过时数据)
self._last_clipboard_hash: dict = {} # platform → str(hash)
# 紧急停止回调(通知 GUI 更新状态)
self._emergency_stop_callback: Optional[Callable[[], None]] = None
# 底层键盘钩子回调引用(防止 GC 回收导致系统崩溃)
self._esc_hook_proc = None
def _load_replied_texts_from_db(self):
"""从消息历史数据库加载最近的bot回复到 _replied_texts,
防止重启后旧回复被OCR重新识别并处理。
"""
if not self._msg_history:
return
try:
# 加载两个平台的最近回复
for platform in ["qq", "wechat"]:
recent_replies = self._msg_history.get_recent_bot_replies(platform, limit=50)
for reply in recent_replies:
if reply and len(reply) >= 6:
self._replied_texts.add(reply)
# 添加常见变体(与发送时一致)
if '\n' in reply:
self._replied_texts.add(reply.replace('\n', ' '))
self._replied_texts.add(reply.replace('\n', ' ').rstrip('.'))
for part in reply.split('\n'):
part = part.strip()
if part and len(part) > 3:
self._replied_texts.add(part)
flat = reply.replace('\n', ' ')
if len(flat) > 15:
self._replied_texts.add(flat[:30])
if len(flat) > 40:
self._replied_texts.add(flat[-30:].strip())
logger.info(f"[msg-history] 加载历史回复到去重集: {len(self._replied_texts)} 条")
except Exception as e:
logger.error(f"[msg-history] 加载历史回复失败: {e}")
def set_poll_interval(self, seconds: float):
self._poll_interval = max(0.5, seconds)
def set_enabled_platforms(self, wechat: bool = True, qq: bool = False):
self._wechat_enabled = wechat
self._qq_enabled = qq
def set_callback(self, cb: Callable[[str, str], None]):
self._on_message = cb
def set_emergency_callback(self, cb: Callable[[], None]):
self._emergency_stop_callback = cb
def start(self, full_restart: bool = False):
"""启动监控。
Args:
full_restart: True=彻底重启(进程级,重新检测内存恢复GPU)
False=急停/手动停止恢复(软重启,不重新检测内存)
"""
if self._running:
return
self._running = True
# ⭐ 重置 EasyOCR 状态:确保停止/急停后双引擎仍能搭档作业
# stop() 会杀死子进程,但如果此时有 _call_easyocr_safe 在运行,
# BrokenPipeError 会设置 _easyocr_broken=True,导致重启后 EasyOCR 永久不可用
self._easyocr_broken = False
self._easyocr_retry_until = 0.0 # 清除崩溃冷却
self._easyocr_call_count = 0
self._easyocr_consecutive_failures = 0 # ⭐ 清除连续超时计数(stop/急停后不残留)
self._easyocr_consecutive_startup_failures = 0 # ⭐ 清除连续启动失败计数
# ⭐ 彻底重启时重新检测内存,尝试恢复GPU模式
# 急停/手动停止恢复时保持上次状态,不重新检测
if full_restart:
self._easyocr_cuda_oom_count = 0 # 重新评估 GPU/CPU
logger.info("[ocr] 彻底重启:重新检测内存,尝试恢复GPU模式")
else:
# 急停/手动停止恢复:保持上次的降级状态(如果上次因OOM降级CPU,这次仍用CPU)
logger.info(f"[ocr] 软重启:保持上次EasyOCR状态(cuda_oom_count={self._easyocr_cuda_oom_count})")
self._easyocr_proc = None # 确保 clean state
self._easyocr_ready = False
self._easyocr_ready_event.clear()
self._easyocr_starting = False
# ⭐ OCR 状态清零:截图hash/垃圾计数/引擎选择/冷却等,确保重启不残留上一轮状态
# (上次 CUDA 报错降级 CPU 的计数、超时计数、截图指纹都从头开始)
self._last_screenshot_hash.clear()
self._ocr_unchanged.clear()
self._last_ocr_time.clear()
self._last_hash_queued.clear() # ⭐ 重置 hash 跳过鲁棒性标志
self._last_hash_replied.clear() # ⭐ 重置 hash 回复标志
self._ocr_garbage_count.clear()
self._ocr_last_text.clear()
self._last_chosen_engine.clear()
self._self_reply_cooldown_until.clear()
self._ocr_engine_wins = {"windows": 0, "easyocr": 0}
# OCR 截屏定时清理:启动后 30~60 秒随机触发第一次
self._next_ocr_clean_at = time.time() + random.uniform(30, 60)
# ⭐ 启动线程错峰:5个线程同时拉起导致 GIL 竞争,GUI 冻结 6-7 秒。
# 方案:按优先级分批启动,批间 sleep(0.5) 让 GUI 主线程有机会刷新。
# 第1批(立即):微信主轮询(核心功能) + 急停热键(安全)
# 第2批(0.5s):QQ轮询(独立平台,不依赖微信)
# 第3批(1.0s):EasyOCR子进程预热(重操作,延迟避免挤占GUI)
# 第1批:核心线程
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="monitor")
self._thread.start()
self._emergency_thread = threading.Thread(target=self._esc_check_loop, daemon=True, name="monitor-esc")
self._emergency_thread.start()
# 第2批:QQ轮询(延迟0.5s,让GUI有机会刷新 + 避免同时初始化Windows消息队列)
def _delayed_qq_start():
time.sleep(0.5)
if self._running:
self._qq_thread = threading.Thread(target=self._qq_poll_loop, daemon=True, name="monitor-qq")
self._qq_thread.start()
threading.Thread(target=_delayed_qq_start, daemon=True, name="qq-delay").start()
# 第3批:EasyOCR预热(延迟1s,最重的操作放最后)
if _HAS_EASYOCR and not self._easyocr_broken:
def _delayed_easyocr_start():
time.sleep(1.0)
if self._running:
self._ensure_easyocr_subprocess()
threading.Thread(target=_delayed_easyocr_start, daemon=True, name="easyocr-delay").start()
logger.info("消息监控已启动")
def stop(self):
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=3)
self._thread = None
if self._qq_thread and self._qq_thread.is_alive():
self._qq_thread.join(timeout=3)
self._qq_thread = None
if self._emergency_thread and self._emergency_thread.is_alive():
# Post WM_QUIT 唤醒 GetMessage 阻塞,让钩子线程退出
try:
ctypes.windll.user32.PostThreadMessageW(
self._emergency_thread.ident, 0x0012, 0, 0 # WM_QUIT = 0x0012
)
except Exception:
pass
self._emergency_thread.join(timeout=2)
self._emergency_thread = None
# 杀死 EasyOCR 子进程(防止 GPU 推理残留吃资源/冻电脑)
self._kill_easyocr_proc("stop")
# ⭐ 重置 broken 标记:stop 后 start 应能重新拉起 EasyOCR
self._easyocr_broken = False
self._easyocr_retry_until = 0.0 # 清除崩溃冷却
self._easyocr_call_count = 0
self._easyocr_consecutive_failures = 0 # ⭐ 清除连续超时计数(stop/急停后不残留)
self._easyocr_consecutive_startup_failures = 0 # ⭐ 清除连续启动失败计数
self._easyocr_cuda_oom_count = 0 # 重启后重新评估 GPU/CPU
# 释放钩子回调引用(防止 GC 延迟导致卸载后崩溃)
if hasattr(self, '_esc_hook_proc'):
del self._esc_hook_proc
self._cached_hwnd.clear()
self._cached_pid.clear()
self._last_sig.clear()
self._last_msg.clear()
self._stale_count.clear()
self._empty_reads.clear()
self._poll_count.clear()
logger.info("消息监控已停止")
# 主动释放内存,交还系统重新分配调度
self._release_memory()
@property
def is_running(self) -> bool:
return self._running
def _release_memory(self):
"""主动释放内存,交还系统重新分配调度。只保留GUI需要的基本内存。"""
import gc
import ctypes
import sys
# 强制垃圾回收
gc.collect()
# Windows 特定:使用 EmptyProcessWorkingSet 释放工作集内存
try:
# 获取当前进程句柄
kernel32 = ctypes.windll.kernel32
psapi = ctypes.windll.psapi
# EmptyProcessWorkingSet 函数:将进程工作集最小化
# 参数:进程句柄,最小值(-1表示当前进程),最大值(-1表示当前进程)
# 返回:非零表示成功
kernel32.SetProcessWorkingSetSize(
kernel32.GetCurrentProcess(),
-1, # 最小工作集:允许系统回收所有可回收页面
-1 # 最大工作集:允许系统回收所有可回收页面
)
logger.debug("[memory] 已调用 SetProcessWorkingSetSize 释放工作集内存")
except Exception as e:
logger.debug(f"[memory] SetProcessWorkingSetSize 失败: {e}")
# 清空 EasyOCR 相关缓存
if hasattr(self, '_easyocr_proc') and self._easyocr_proc:
# 子进程已杀,但引用可能残留
self._easyocr_proc = None
# 清空大对象引用
for attr in ['_last_texts', '_seen_messages', '_replied_texts']:
if hasattr(self, attr):
getattr(self, attr).clear()
# 再次垃圾回收
gc.collect()
logger.info("[memory] 内存释放完成,已交还系统")
def _kill_easyocr_proc(self, reason: str = ""):
"""安全杀死 EasyOCR 子进程:terminate→等待→kill→GC,防 CUDA 内存泄漏。
terminate() 发 SIGTERM 让 worker 有机会执行 atexit/signal 清理 CUDA 缓存;
3s 后仍存活则 kill() 强杀;最后 gc.collect() 释放主进程侧引用。
⭐ 杀前诊断:先收集 worker 状态文件 + 残留 stderr + 崩溃日志尾部,
汇总写入主日志(warning 级别),确保任何崩溃/超时杀进程都有迹可循,
杜绝"一声不吭就结束进程"导致后续排障无据。
"""
import gc
import threading
if not self._easyocr_proc or self._easyocr_proc.poll() is not None:
self._easyocr_proc = None
return
_tag = f"[ocr] 杀 EasyOCR 子进程({reason})" if reason else "[ocr] 杀 EasyOCR 子进程"
_proc = self._easyocr_proc
# ── 杀前诊断:收集 worker 死前状态,避免"一声不吭" ──
_diag = []
try:
_state = _read_easyocr_worker_state()
if _state:
_st = _state.get("status", "?")
_iso = _state.get("iso", "")
_mode = _state.get("mode", "?")
_img_len = _state.get("last_img_len", 0)
_err = _state.get("error", "")
_now = time.time()
_age = _now - float(_state.get("ts", _now)) if _state.get("ts") else None
_age_s = f",距现在{_age:.1f}s" if _age is not None else ""
_diag.append(
f"worker死前状态={_st}(模式{_mode}, 上次img长度{_img_len}, 更新于{_iso}{_age_s})"
+ (f", 错误={_err}" if _err else "")
)
except Exception:
pass
try:
# 非阻塞+限时读 worker 残留 stderr(崩溃前最后打印的内容),不阻塞杀进程
if _proc.stderr is not None:
from queue import Queue, Empty as _QE
_q = Queue()
def _drain_stderr():
try:
_b = _proc.stderr.read(65536)
_q.put(_b or "")
except Exception:
_q.put("")
_dt = threading.Thread(target=_drain_stderr, daemon=True)
_dt.start()
_dt.join(timeout=0.5)
_stderr_buf = ""
if not _dt.is_alive():
try:
_stderr_buf = _q.get_nowait()
except _QE:
pass
if isinstance(_stderr_buf, str) and _stderr_buf.strip():
_diag.append(f"worker残留stderr={_stderr_buf.strip()[-500:]}")
except Exception:
pass
try:
# 崩溃日志尾部(kill 诊断路径不过 60s 窗口过滤)
_crash = _read_easyocr_crash_tail(ignore_age=True)
if _crash:
_diag.append(f"崩溃日志={_crash}")
except Exception:
pass
try:
# 1) 先 terminate 让 worker 执行 GPU 清理(atexit/signal handler)
_proc.terminate()
try:
_proc.wait(timeout=3)
logger.debug(f"{_tag}: terminate 后正常退出")
except Exception:
# 2) 3s 内未退出 → 强杀
try:
_proc.kill()
_proc.wait(timeout=3)
logger.warning(f"{_tag}: terminate 无效,已 kill")
except Exception:
pass
except Exception as e:
logger.debug(f"{_tag} 异常: {e}")
# 杀后:把诊断信息汇总写入主日志(warning 确保可见)
if _diag:
logger.warning(f"{_tag} 诊断: " + " | ".join(_diag))
else:
logger.debug(f"{_tag}: 无可用诊断信息(worker 未留下状态文件/崩溃日志)")
self._easyocr_proc = None
# ⭐ Task #1446:进程已销毁 → 清除就绪标志与握手信号,强制后续调用重新等待/握手
self._easyocr_ready = False
self._easyocr_ready_event.clear()
self._easyocr_starting = False
# 3) 主进程 GC:释放 PIL Image / numpy array 等残留引用
try:
gc.collect()
except Exception:
pass
# ── 内存看门狗(诊断用,定位泄漏源)──────────────────────────
def _start_memory_watchdog(self):
"""内存+资源看门狗:周期性记录主进程 RSS/线程数/GDI 句柄/关键缓存,
用于定位内存只涨不跌的泄漏源(如永久存活的 OCR 线程持有大图)。"""
try:
import threading
import psutil
import win32api
import win32process
except Exception:
threading = psutil = win32api = win32process = None
def _watch():
while self._running:
try:
rss_mb = 0.0
n_threads = 0
gdi = 0
if psutil is not None:
try:
_proc = psutil.Process()
rss_mb = _proc.memory_info().rss / (1024 * 1024)
n_threads = _proc.num_threads()
except Exception:
pass
if win32api is not None and win32process is not None:
try:
_h = win32api.GetCurrentProcess()
gdi = win32process.GetGuiResources(_h, 0) # 0 = GDI 对象数
except Exception:
pass
_extra = (f"hash={len(self._last_screenshot_hash)} "
f"seen={len(getattr(self, '_seen_messages', set()))} "
f"garbage={dict(self._ocr_garbage_count)} "
f"easyocr_alive={self._easyocr_proc is not None and self._easyocr_proc.poll() is None}")
logger.warning(f"[mem-watch] RSS={rss_mb:.0f}MB threads={n_threads} gdi={gdi} {_extra}")
except Exception:
pass
for _ in range(30):
if not self._running:
break
time.sleep(1)
if threading is not None:
_t = threading.Thread(target=_watch, daemon=True, name="mem-watch")
_t.start()
logger.info("[mem-watch] 内存看门狗已启动(每30s记录 RSS/线程/GDI)")
# ── 轮询主循环 ──────────────────────────────────────────
def _run_loop(self):
# 内存看门狗:周期性记录主进程资源占用,便于排查内存只涨不跌的泄漏
self._start_memory_watchdog()
# 创建 Windows 消息队列(使 AttachThreadInput / SetForegroundWindow 在 daemon 线程生效)
try:
ctypes.windll.user32.PeekMessageW(None, 0, 0, 0, 1) # PM_NOREMOVE
except Exception:
pass
heartbeat = 0
logger.debug("_run_loop 进入 while 循环")
while self._running:
try:
# OCR 截屏定时清理(每 30~60 秒随机触发,删除过期截图)
self._maybe_clean_ocr_dir()
if self._wechat_enabled and not self._sending_event.is_set():
self._poll("wechat")
except Exception as e:
logger.error(f"轮询异常: {e}\n{traceback.format_exc()}")
heartbeat += 1
if heartbeat % 10 == 0:
logger.info(f"[♡] 心跳 #{heartbeat}")
time.sleep(self._poll_interval)
def _qq_poll_loop(self):
"""QQ 独立轮询线程:不阻塞微信 OCR,每轮间隔 ~2s。
QQ OCR 截图通常较小(裁剪后),EasyOCR 耗时 1-3s;
微信 OCR 每轮 ~15-20s。分开线程后 QQ 响应从 ~34s 降到 ~3-5s。
"""
try:
ctypes.windll.user32.PeekMessageW(None, 0, 0, 0, 1)
except Exception:
pass
# 随机 stagger 3-5s,避免 QQ/微信同时 OCR 抢镜
stagger = random.uniform(3, 5)
logger.debug(f"[qq] 轮询延迟 {stagger:.1f}s 与微信错开")
time.sleep(stagger)
while self._running:
try:
if self._qq_enabled and not self._sending_event.is_set():
self._poll("qq")
except Exception as e:
logger.error(f"QQ轮询异常: {e}\n{traceback.format_exc()}")
time.sleep(self._poll_interval)
def _maybe_clean_ocr_dir(self):
"""OCR 截屏定时清理:每 30~60 秒随机触发一次,删除超过保留期的过期截图。
保存截图后不再立即按数量清理(高频 create+delete 循环易触发 360 勒索启发式误报),
改为低频定时批量清理:保留最近 _ocr_clean_ttl 秒内的截图,删除更早的全部
(img/raw 原图 + img/processed 处理后图都清)。每次清理后随机设置下一次
触发时间(30~60 秒),避免固定周期被安全软件识别为规律行为。
"""
now = time.time()
if now < self._next_ocr_clean_at:
return
# 下一次清理时间:随机 30~60 秒后
self._next_ocr_clean_at = now + random.uniform(30, 60)
cutoff = now - self._ocr_clean_ttl
# 清理平台子目录(当前存放位置)+ 根目录(历史遗留文件)
dirs_to_clean = [
_get_ocr_raw_dir("qq"), _get_ocr_raw_dir("wechat"),
_get_ocr_processed_dir("qq"), _get_ocr_processed_dir("wechat"),
_get_ocr_raw_dir(), _get_ocr_processed_dir(),
]
for d in dirs_to_clean:
try:
for f in os.listdir(d):
p = os.path.join(d, f)
try:
if os.path.getmtime(p) < cutoff:
os.remove(p)
logger.debug(f"[ocr] 定时清理过期截图: {f}")
except OSError:
pass
except Exception:
pass
# ── 双击 ESC 急停 ──────────────────────────────────────
def _esc_check_loop(self):
"""独立 daemon 线程:RegisterHotKey 全局热键检测双击 ESC → 强制急停。
使用 RegisterHotKey 注册全局热键 + GetMessage 消息泵,
不依赖窗口焦点,任何进程按 ESC 都能捕获。
比 WH_KEYBOARD_LL 更可靠(避免 ctypes 64位调用约定问题)。
"""
from ctypes import wintypes
_VK_ESCAPE = 0x1B
_WM_HOTKEY = 0x0312
_HOTKEY_ID_ESC = 0x4553 # 'ES'
# ⭐ 步骤1: 创建消息队列
_msg = wintypes.MSG()
ctypes.windll.user32.PeekMessageW(ctypes.byref(_msg), None, 0, 0, 0)
# ⭐ 步骤2: 注册全局热键(hwnd=NULL → 投递到线程消息队列)
# MOD_NOREPEAT = 0x4000:长按不重复触发,只有按下时触发一次
ok = ctypes.windll.user32.RegisterHotKey(None, _HOTKEY_ID_ESC, 0x4000, _VK_ESCAPE)
if not ok:
err = ctypes.windll.kernel32.GetLastError()
# err=1409 = ERROR_HOTKEY_ALREADY_REGISTERED,其他热键已占 ESC
logger.error("[急停] RegisterHotKey 失败 (err=%d) — ESC 急停不可用", err)
return
logger.info("[急停] 全局热键注册成功,双击 ESC 急停已激活")
# ⭐ 步骤3: 消息泵 — 检测双击 ESC
last_esc_time = 0.0
while self._running:
ret = ctypes.windll.user32.GetMessageW(ctypes.byref(_msg), None, 0, 0)
if ret <= 0:
break
if _msg.message == _WM_HOTKEY and _msg.wParam == _HOTKEY_ID_ESC:
now = time.time()
if last_esc_time > 0 and (now - last_esc_time) < 0.5:
last_esc_time = 0.0
logger.warning("⚠️ 双击ESC急停触发!强制终止所有操作...")
self._emergency_stop()
else:
last_esc_time = now
# 线程退出 → 注销热键
ctypes.windll.user32.UnregisterHotKey(None, _HOTKEY_ID_ESC)
def _emergency_stop(self):
"""强制停止监控+杀死 EasyOCR+清空发送锁。"""
self._running = False
# 释放发送锁(防止被 hang 的发送操作卡住)
self._sending_event.clear()
# 杀死 EasyOCR 子进程(GPU 推理可能卡死不响应)
self._kill_easyocr_proc("急停")
# ⭐ 清零全部 OCR/EasyOCR 计数器:急停后不残留(下次启动从头评估 GPU/CPU)
# CUDA 报错降级 CPU 的计数、连续超时计数、截图指纹等全部归零
self._easyocr_broken = False
self._easyocr_retry_until = 0.0 # 清除崩溃冷却
self._easyocr_call_count = 0
self._easyocr_consecutive_failures = 0 # 清除连续超时计数
self._easyocr_consecutive_startup_failures = 0 # 清除连续启动失败计数
self._easyocr_cuda_oom_count = 0 # 清除 CUDA 错误计数(不继承本次降级)
self._last_screenshot_hash.clear()
self._ocr_unchanged.clear()
self._last_ocr_time.clear()
self._last_hash_queued.clear() # ⭐ 重置 hash 跳过鲁棒性标志
self._last_hash_replied.clear() # ⭐ 重置 hash 回复标志
self._ocr_garbage_count.clear()
self._ocr_last_text.clear()
self._last_chosen_engine.clear()
self._self_reply_cooldown_until.clear()
self._ocr_engine_wins = {"windows": 0, "easyocr": 0}
# 通知回调(GUI 更新状态)
try:
if self._emergency_stop_callback:
self._emergency_stop_callback()
except Exception as e:
logger.debug(f"[急停] 回调异常: {e}")
logger.warning("⚠️ 急停完成 — 消息监控已停止,点击开关可重新启动")
# ── 窗口定位 ─────────────────────────────────────────────
def _ensure_hwnd(self, platform: str) -> Optional[int]:
"""查找窗口并返回 HWND(不缓存 Control)。"""
hwnd = self._cached_hwnd.get(platform)
try:
if hwnd:
if win32gui.IsWindow(hwnd):
# 验证窗口进程是否仍属于目标平台
if platform == "wechat":
if self._validate_wechat_window(hwnd):
return hwnd
elif platform == "qq":
if self._validate_qq_window(hwnd):
return hwnd
else:
return hwnd
except Exception:
pass
# 仅清 HWND,保留 PID 供后续 EnumWindows 快速复用
old_pid = self._cached_pid.get(platform)
self._cached_hwnd.pop(platform, None)
# 非阻塞:窗口未找到冷却期内直接跳过搜索,不浪费 12s 超时
cooldown_until = self._window_not_found_until.get(platform, 0)
if time.time() < cooldown_until:
return None
if platform == "wechat":
# ── 快速路径:用缓存 PID 直接 EnumWindows(毫秒级,跳过 psutil 全进程扫描)──
if old_pid:
try:
pid_windows = []
def _fast_enum_cb(h, results, _pid=old_pid):
try:
_, found_pid = win32process.GetWindowThreadProcessId(h)
if found_pid == _pid:
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
if top not in results:
results.append(top)
except Exception:
pass
return True
win32gui.EnumWindows(_fast_enum_cb, pid_windows)
for top in pid_windows:
if not (win32gui.IsWindow(top) and win32gui.IsWindowVisible(top)):
continue
rect = win32gui.GetWindowRect(top)
if (rect[2] - rect[0]) > 300 and (rect[3] - rect[1]) > 200:
self._cached_hwnd[platform] = top
self._window_not_found_until.pop(platform, None)
title = win32gui.GetWindowText(top)
logger.info(f"[{platform}] 窗口已定位(PID 快速): pid={old_pid} title={title!r} top={top}")
return top
except Exception:
pass
# ── UIA 搜索 ──
for name in ["微信", "WeChat"]:
try:
w = auto.WindowControl(Name=name)
if w.Exists(maxSearchSeconds=0.5):
rect = w.BoundingRectangle
if rect.width() > 300 and rect.height() > 200:
h = w.NativeWindowHandle
# 确保存顶层窗口句柄(与 GetForegroundWindow 返回值一致)
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
# 窗口不可见(最小化/隐藏)→ 跳过
# 注意:IsWindowVisible 检查 WS_VISIBLE 标志位,只要窗口在 Z 顺序中
# 未被标记为隐藏即返回 True(即使被其他窗口覆盖),符合需求。
if not win32gui.IsWindowVisible(top):
continue
self._cached_hwnd[platform] = top
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
self._window_not_found_until.pop(platform, None)
logger.info(f"[{platform}] 窗口已定位: {name} handle={top}")
return top
except Exception:
pass
# 兜底:通过 Win32 API 按进程名查找微信窗口(UIAutomation 在窗口被覆盖时可能找不到)
# 只要窗口可见(未被最小化/隐藏)就返回,与 QQ 逻辑一致
for proc_name in ["WeChat.exe", "微信"]:
try:
for proc in psutil.process_iter(['name', 'pid']):
if proc.info['name'] and proc.info['name'].lower() == proc_name.lower():
pid = proc.info['pid']
pid_windows = []
def _pid_enum_cb(hwnd, results, _pid=pid):
try:
_, found_pid = win32process.GetWindowThreadProcessId(hwnd)
if found_pid == _pid:
top = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
if top not in results:
results.append(top)
except Exception:
pass
return True
win32gui.EnumWindows(_pid_enum_cb, pid_windows)
for top in pid_windows:
if not (win32gui.IsWindow(top) and win32gui.IsWindowVisible(top)):
continue
rect = win32gui.GetWindowRect(top)
if (rect[2] - rect[0]) > 300 and (rect[3] - rect[1]) > 200:
self._cached_hwnd[platform] = top
self._cached_pid[platform] = pid
self._window_not_found_until.pop(platform, None)
title = win32gui.GetWindowText(top)
logger.info(f"[{platform}] 窗口已定位(Win32 进程枚举): {proc_name} pid={pid} title={title!r} top={top}")
return top
except Exception:
continue
elif platform == "qq":
# 只按进程名找窗口,不管标题是什么。
# TIM/QQ 标题是动态的(跟随会话显示),任何标题都合法。
for proc_name in ["TIM.exe", "QQ.exe"]:
try:
for proc in psutil.process_iter(['name', 'pid']):
if proc.info['name'] and proc.info['name'].lower() == proc_name.lower():
pid = proc.info['pid']
pid_windows = []
def _pid_enum_cb(hwnd, results, _pid=pid):
try:
_, found_pid = win32process.GetWindowThreadProcessId(hwnd)
if found_pid == _pid:
top = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
if top not in results:
results.append(top)
except Exception:
pass
return True
win32gui.EnumWindows(_pid_enum_cb, pid_windows)
# 所有属于该进程的顶层窗口中,取第一个尺寸>=聊天区的、可见的
for top in pid_windows:
if not (win32gui.IsWindow(top) and win32gui.IsWindowVisible(top)):
continue
rect = win32gui.GetWindowRect(top)
if (rect[2] - rect[0]) > 300 and (rect[3] - rect[1]) > 200:
variant = "tim" if "TIM" in proc_name.upper() else "qq_nt"
self._app_variant[platform] = variant
self._cached_hwnd[platform] = top
self._cached_pid[platform] = pid
self._window_not_found_until.pop(platform, None)
title = win32gui.GetWindowText(top)
logger.info(f"[{platform}] 窗口已定位: {proc_name} pid={pid} title={title!r} top={top}")
return top
except Exception:
continue
# 兜底:UIA 搜索 TXGuiFoundation 类名窗口(进程已启动但窗口尚未就绪时)
try:
root = auto.GetRootControl()
for child in root.GetChildren():
try:
if child.ClassName == "TXGuiFoundation":
rect = child.BoundingRectangle
if rect.width() > 300 and rect.height() > 200:
h = child.NativeWindowHandle
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
pid = win32process.GetWindowThreadProcessId(top)[1]
# 验证进程是否属于 QQ/TIM
try:
hproc = win32api.OpenProcess(
0x0400 | 0x0010, # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ
False, pid
)
if hproc:
exe_path = win32process.GetModuleFileNameEx(hproc, 0)
win32api.CloseHandle(hproc)
exe_name = exe_path.split('\\')[-1].lower() if exe_path else ''
if exe_name not in ('qq.exe', 'tim.exe'):
continue # 不是QQ/TIM,跳过
else:
continue # 无法打开进程,跳过
except Exception:
continue # 验证失败,跳过
self._app_variant[platform] = "qq_nt"
self._cached_hwnd[platform] = top
self._cached_pid[platform] = pid
self._window_not_found_until.pop(platform, None)
logger.info(f"[{platform}] 窗口已定位(UIA TXGuiFoundation): top={top} pid={pid}")
return top
except Exception:
continue
except Exception:
pass
# 搜索完毕没找到 → 设冷却避免重复 12s 超时(10s 比 30s 更快恢复轮询)
self._window_not_found_until[platform] = time.time() + 10
return None
def _ensure_hwnd_with_timeout(self, platform: str, timeout: float = 12.0) -> Optional[int]:
"""_ensure_hwnd 包装 daemon 线程超时保护。
ThreadPoolExecutor 创建非 daemon 线程 → Ctrl+C 进程不退出。
改用 daemon thread + Event.wait(timeout),daemon 线程不阻塞进程退出。
"""
# 冷却期内免线程直接跳过
cooldown_until = self._window_not_found_until.get(platform, 0)
if time.time() < cooldown_until:
return None
result = [None]
done = threading.Event()
def worker():
# 如果外层已设冷却(超时后立即设),不再重复 UIA 搜索
cooldown_until = self._window_not_found_until.get(platform, 0)
if time.time() < cooldown_until:
done.set()
return
result[0] = self._ensure_hwnd(platform)
done.set()
t = threading.Thread(target=worker, daemon=True)
t.start()
if not done.wait(timeout=timeout):
logger.warning(f"[{platform}] _ensure_hwnd 超时({timeout:.0f}s),跳过本轮")
self._window_not_found_until[platform] = time.time() + 10
return None
return result[0]
# ── 强制刷新 UIA ──────────────────────────────────────────
def _fresh_window(self, platform: str):
"""从缓存 HWND 创建全新的 WindowControl(绕过 UIA 缓存)。"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
return None
try:
# 用 NativeWindowHandle 重建,保证 UIA 新缓存
win = auto.ControlFromHandle(hwnd)
if win is not None:
return win
except Exception:
pass
return None
def _find_qq_window_fresh(self):
"""通过类名重新查找 QQ/TIM 窗口,绕过 UIA 缓存(比 ControlFromHandle 更彻底)。"""
try:
# 先按窗口名称查找
for name in ["QQ", "TIM"]:
try:
w = auto.WindowControl(Name=name)
if w.Exists(maxSearchSeconds=0.2):
rect = w.BoundingRectangle
if rect.width() > 300 and rect.height() > 200:
return w
except Exception:
pass
# 按 TXGuiFoundation 类名深度查找
root = auto.GetRootControl()
for child in root.GetChildren():
try:
if child.ClassName == "TXGuiFoundation":
rect = child.BoundingRectangle
if rect.width() > 300 and rect.height() > 200:
# 验证进程是否属于 QQ/TIM
h = child.NativeWindowHandle
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
pid = win32process.GetWindowThreadProcessId(top)[1]
try:
hproc = win32api.OpenProcess(
0x0400 | 0x0010, False, pid
)
if hproc:
exe_path = win32process.GetModuleFileNameEx(hproc, 0)
win32api.CloseHandle(hproc)
exe_name = exe_path.split('\\')[-1].lower() if exe_path else ''
if exe_name not in ('qq.exe', 'tim.exe'):
continue
else:
continue
except Exception:
continue
return child
except Exception:
pass
except Exception:
pass
return None
def _force_uia_refresh(self, platform: str):
"""通过交互强制窗口更新 UIA 控件树。"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
return
try:
# 方式1: 窗口重绘
win32gui.InvalidateRect(hwnd, None, True)
win32gui.UpdateWindow(hwnd)
# QQ: 避免标题栏点击(不会干扰用户操作)
if platform == "qq":
win32gui.PostMessage(hwnd, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
else:
# 方式2: 向窗口发送非客户区点击(仅微信)
win32gui.PostMessage(hwnd, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN
win32gui.PostMessage(hwnd, 0x00A2, 2, 0) # WM_NCLBUTTONUP
logger.debug(f"[{platform}] UIA强制刷新")
except Exception as e:
logger.debug(f"[{platform}] 强制刷新失败: {e}")
def _force_scroll_bottom(self, platform: str):
"""滚动窗口到底部,触发 UI 更新。"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
return
try:
# 1. WM_VSCROLL SB_BOTTOM
win32gui.PostMessage(hwnd, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0)
# 2. Ctrl+End 快捷键
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_CONTROL, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_CONTROL, 0)
# 3. InvalidateRect 强制重绘
win32gui.InvalidateRect(hwnd, None, True)
except Exception as e:
logger.debug(f"[{platform}] 滚动到底失败: {e}")
def _ensure_visible(self, hwnd) -> bool:
"""确保窗口可见,尝试置前但不强制要求前台(用户操作不打断截屏)。
截屏用 ImageGrab.grab(bbox) 截的是整屏坐标,只要窗口在屏幕上可见
(未被其他窗口完全遮挡),截屏就能拿到正确内容。用户操作鼠标/键盘
会抢走前台焦点,但不等于窗口被遮挡——不应因此放弃截屏。
流程:恢复最小化/隐藏 → AllowSetForegroundWindow 解除限制 →
SetForegroundWindow + SwitchToThisWindow 尽力置前(重试3次)→
若置前成功直接返回;若置前失败但窗口仍然可见(IsWindowVisible + !IsIconic),
也返回 True 继续截图。仅当窗口真的不可见时返回 False。
"""
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
if not root_hwnd or not win32gui.IsWindow(root_hwnd):
return False
# 1) 恢复最小化 / 显示隐藏窗口
if win32gui.IsIconic(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
time.sleep(0.2)
if not win32gui.IsWindowVisible(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
time.sleep(0.2)
# 2) 解除系统前台限制(ASFW_ANY),让后台线程也能 SetForegroundWindow
try:
ctypes.windll.user32.AllowSetForegroundWindow(-1)
except Exception:
pass
# 3) 尽力置前:SetForegroundWindow + SwitchToThisWindow 兜底,重试 3 次
for _ in range(3):
try:
win32gui.SetForegroundWindow(root_hwnd)
except Exception:
pass
time.sleep(0.12)
try:
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
except Exception:
pass
time.sleep(0.1)
if self._check_foreground(root_hwnd):
return True
# 4) 前台校验失败 —— 但窗口可能仍然可见(用户操作抢走了前台焦点)
# ImageGrab.grab 只需要窗口在屏幕上可见即可,不要求前台。
# 仅当窗口真的不可见(被完全遮挡/最小化)时才放弃。
if win32gui.IsWindowVisible(root_hwnd) and not win32gui.IsIconic(root_hwnd):
logger.debug(f"[{self.__class__.__name__}] 窗口 {root_hwnd} 未获得前台但仍然可见,继续截图")
return True
logger.warning(f"[{self.__class__.__name__}] 窗口 {root_hwnd} 不可见,跳过本轮")
return False
def _ensure_foreground(self, hwnd, max_retries=5) -> bool:
"""确保窗口在前台,重试多次。失败返回 False,不强行发送。
Windows 限制:仅“最近收到用户输入的线程”可调用 SetForegroundWindow。
后台 daemon 线程常因此失败。采用多层兜底:
Method 0: keybd_event 模拟一次 Alt 按键,骗取前台权限(经典 workaround)
Method 1: AttachThreadInput + SetForegroundWindow(跨线程注入输入)
Method 2: SwitchToThisWindow(权限要求低,轻量)
Method 3: SetWindowPos(HWND_TOPMOST) 强置顶 + SetForegroundWindow 最后手段
"""
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
if not root_hwnd or not win32gui.IsWindow(root_hwnd):
logger.warning(f"[前台] 无效句柄 {hwnd}")
return False
# 前置条件:最小化则恢复,隐藏则显示
if win32gui.IsIconic(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
time.sleep(0.2)
if not win32gui.IsWindowVisible(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
time.sleep(0.2)
# 允许任意进程 SetForegroundWindow(移除系统限制,与发送路径一致)
try:
ctypes.windll.user32.AllowSetForegroundWindow(-1) # ASFW_ANY
except Exception:
pass
for i in range(max_retries):
# Method 0: 模拟一次 Alt 按键,让系统认为当前线程收到输入,
# 从而解除 SetForegroundWindow 的“最近用户输入”限制
try:
ctypes.windll.user32.keybd_event(0x12, 0, 0, 0) # Alt down
ctypes.windll.user32.keybd_event(0x12, 0, 2, 0) # Alt up
except Exception:
pass
# Method 1: AttachThreadInput + SetForegroundWindow
self._attach_set_foreground(root_hwnd)
time.sleep(0.15)
if self._check_foreground(root_hwnd):
return True
# Method 2: SwitchToThisWindow(权限要求低,轻量)
try:
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
time.sleep(0.15)
if self._check_foreground(root_hwnd):
return True
except Exception:
pass
# Method 3: 强置顶 + SetForegroundWindow(绕过前台限制的最后手段)
try:
ctypes.windll.user32.SetWindowPos(
root_hwnd, win32con.HWND_TOPMOST, 0, 0, 0, 0,
win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | win32con.SWP_SHOWWINDOW
)
ctypes.windll.user32.SetForegroundWindow(root_hwnd)
time.sleep(0.15)
if self._check_foreground(root_hwnd):
return True
# 取消 TOPMOST,避免窗口一直悬浮在最上层
ctypes.windll.user32.SetWindowPos(
root_hwnd, win32con.HWND_NOTOPMOST, 0, 0, 0, 0,
win32con.SWP_NOMOVE | win32con.SWP_NOSIZE
)
except Exception:
pass
time.sleep(0.1)
logger.warning(f"无法将窗口 {hwnd} 带到前台,放弃操作")
return False
@staticmethod
def _check_foreground(root_hwnd) -> bool:
"""检查窗口是否在前台(或其子窗口)。"""
try:
fg = win32gui.GetForegroundWindow()
if fg == root_hwnd:
return True
if fg and win32gui.IsChild(root_hwnd, fg):
return True
except Exception:
pass
return False
def _attach_set_foreground(self, hwnd):
"""AttachThreadInput + SetForegroundWindow,从 daemon 线程也能成功。"""
try:
current_tid = ctypes.windll.kernel32.GetCurrentThreadId()
target_tid = ctypes.windll.user32.GetWindowThreadProcessId(hwnd, None)
if ctypes.windll.user32.AttachThreadInput(current_tid, target_tid, True):
try:
win32gui.SetForegroundWindow(hwnd)
except Exception:
pass
finally:
ctypes.windll.user32.AttachThreadInput(current_tid, target_tid, False)
return
# AttachThreadInput 失败 → 裸 SetForegroundWindow
try:
win32gui.SetForegroundWindow(hwnd)
except Exception:
pass
except Exception:
try:
win32gui.SetForegroundWindow(hwnd)
except Exception:
pass
def _read_win32_texts(self, platform: str) -> list:
"""Win32 API 读窗口文本,递归枚举(depth 不限)。"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
return []
texts = []
def enum_callback(child_hwnd, _):
try:
text = win32gui.GetWindowText(child_hwnd)
if text and len(text.strip()) >= 2:
texts.append(text.strip())
except Exception:
pass
return True
try:
main_text = win32gui.GetWindowText(hwnd)
if main_text and len(main_text.strip()) >= 2:
texts.append(main_text.strip())
win32gui.EnumChildWindows(hwnd, enum_callback, None)
except Exception:
pass
return texts
# ── OCR 读屏(QQ 专用,Windows 原生 OCR) ──────────────────
def _ocr_ensure_engine(self):
"""惰性初始化 OCR 引擎(中文简体)。"""
if self._ocr_engine is None:
try:
self._ocr_engine = OcrEngine.try_create_from_language(
Language('zh-CN')
)
if self._ocr_engine:
logger.info(f"[ocr] OCR引擎就绪: {self._ocr_engine.recognizer_language.display_name}")
else:
logger.warning("[ocr] 创建OCR引擎失败")
except Exception as e:
logger.error(f"[ocr] OCR引擎初始化异常: {e}")
return self._ocr_engine
@staticmethod
def _normalize_ocr_text(text: str) -> str:
"""Windows OCR 给 CJK 字符间强加空格 → 去除这些空格使文本可去重。
"问 题 1 : 你 答 : 你 好" → "问题1:你答:你好"
"你 好 he llo" → "你好 he llo"(ASCII 词内空格保留)
此外修复常见 Windows OCR 字符混淆错误(只在非 md 文本上运行,
md 文档由分割后逐片段 md 检测过滤,不受影响)。
"""
# 所有紧邻 CJK/全角字符的空格全部去掉
# CJK 范围:汉字 (u4e00-) + 扩展 (u3400-) + 全角/兼容 (uff00-)
text = re.sub(
r'(?<=[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef\u3000-\u303f])'
r'\s+',
'', text
)
text = re.sub(
r'\s+'
r'(?=[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef\u3000-\u303f])',
'', text
)
# ⭐ Windows OCR 字符混淆修复
# 《》→ <>:Minecraft 聊天大量使用 <player> 等尖括号,OCR 误读为中文书名号
text = text.replace('《', '<').replace('》', '>')
# l→| 管道分隔符:|quiz| / |start| / |info| 误读为 lquizl / lstartl / linfol
text = re.sub(r'(?<=\s)l([A-Za-z]\w*?)l(?=\s)', r'|\1|', text)
# .→ . 全角点→半角数字小数点 (0.45 → 0.45)
text = re.sub(r'(\d)[.](\d)', r'\1.\2', text)
# 清理消息末尾的状态标记(如 "0~" "O~" "C~" "0~O~"),避免污染 KB 匹配
text = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', text)
# 清理 HTML 标签(OCR 截到网页/终端时常带 <br> <div> 等)
text = re.sub(r'<\s*br\s*/?\s*>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<\s*div\s*/?\s*>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<[^>]+>', '', text)
# ⭐ Windows OCR 常见汉字误识别纠错(词级,降低误伤)
# OCR 把相似笔画/结构的字搞混,只在确认是误识别的词组上替换
_ocr_fixes = {
'手髓': '手续', # 髓(骨髓) → 续(手续),笔画结构相似
'β余': '多余', # β(希腊字母) → 多,OCR把"多"拆成β形状
'早原': '草原', # 早 → 草,上半部分相似
'届件': '插件', # 届 → 插,结构相似
'文|牛': '文件', # 文|牛 → 文件,OCR把"件"拆成|牛
'日何': '如何', # 日 → 如,结构相似
'何使': '如何使', # 兜底:如果"如何"被拆成"日何"但前面有字
}
for wrong, right in _ocr_fixes.items():
text = text.replace(wrong, right)
# ⭐ OCR 尾巴清理:句子结束标点后紧跟的垃圾片段
# 典型: "PHP的用户端有哪些登录模式?,留【" → "PHP的用户端有哪些登录模式?"
# 判定: 最后一个句末标点后 ≤10字符且满足以下任一:
# a) 含未闭合括号(【(没有对应】))
# b) 紧跟逗号 + ≤5实字(OCR把消息边界逗号和下一行黏进来)
# c) ≤2实字截断碎片(如"留"、"O"等)
tail_m = re.search(r'([?。?!!?])([^?。?!!?]{1,10})$', text)
if tail_m:
tail_text = tail_m.group(2)
_tail_real = re.sub(r'[,,\s。!?!?]', '', tail_text)
# 特征a: 未闭合括号 → 垃圾
if re.search(r'[【「(((]\s*$', tail_text):
text = text[:tail_m.start(2)]
# 特征b: 紧跟逗号 + ≤5实字 → OCR截断拼接
elif re.match(r'[,,]\s*', tail_text) and len(_tail_real) <= 5:
text = text[:tail_m.start(2)]
# 特征c: ≤2实字截断碎片
elif len(_tail_real) <= 2:
text = text[:tail_m.start(2)]
return text.strip()
@staticmethod
def _is_ocr_console_garbage(text: str) -> bool:
"""OCR 输出的文本是否明显是控制台/Python 垃圾。
当 OCR 截错窗口(如读到终端窗口)时,文本包含:
- Python 路径/模块名:comtypes, uiautomation, sklearn, jieba, winrt
- 脚本文件名:monitor.py, main.py
- 虚拟环境路径:.venv
- 日志关键词:DEBUG, INFO, WARNING, msgbot
- 控制台序列:←[, [32m, [0m
返回 True = 应当丢弃。
"""
garbage_patterns = [
r'comtypes', r'uiautomation', r'sklearn', r'jieba',
r'winrt\.', r'msgbot\.', r'monitor\.py', r'main\.py',
r'\.venv', r'Loading model', r'Prefix dict',
r'WARNING', r'UserWarning', r'Building prefix',
r'←\[', r'\[32m', r'\[0m', r'\\n',
]
for pat in garbage_patterns:
if re.search(pat, text, re.IGNORECASE):
return True
cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
# 全字母数字+标点无中文 → 肯定是非聊天内容(阈值降到 15)
if cjk_count == 0 and len(text.strip()) > 15:
return True
# OCR 随机字符垃圾:无中文 + 特殊字符比例高(如 = ~ @ # $ % ^ & *)
# EasyOCR 在包含头像/表情/特殊 UI 的区域会输出 544UUJ1W H V=YJ~JL:e 这类乱码
if cjk_count == 0 and len(text.strip()) >= 8:
special_chars = sum(1 for c in text if c in '=~@#$%^&*_+{}[]|\\<>/`!')
alpha_count = sum(1 for c in text if c.isalpha())
digit_count = sum(1 for c in text if c.isdigit())
# 特殊字符占比过高(>15%)且字母数字混杂 → OCR 乱码
if special_chars / len(text) > 0.15 and alpha_count >= 2 and digit_count >= 2:
return True
return False
def _validate_qq_window(self, hwnd: int) -> bool:
"""验证窗口是否确实是 QQ/TIM 进程。
不尝试自动恢复隐藏/最小化窗口——交给剪贴板兜底的 _ensure_foreground 处理。
_ensure_foreground 有多重重试+SetForegroundWindow,比这里抢先 ShowWindow
更能正确弹出主窗口而非托盘预览。
"""
if not win32gui.IsWindow(hwnd):
return False
if not win32gui.IsWindowVisible(hwnd):
return False
if win32gui.IsIconic(hwnd):
return False
# 验证进程名
try:
_, pid = win32process.GetWindowThreadProcessId(hwnd)
hproc = win32api.OpenProcess(
0x0400 | 0x0010, # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ
False, pid
)
if hproc:
exe_path = win32process.GetModuleFileNameEx(hproc, 0)
win32api.CloseHandle(hproc)
exe_name = exe_path.split('\\')[-1].lower() if exe_path else ''
if exe_name in ('qq.exe', 'tim.exe'):
return True
except Exception:
pass
return False
def _validate_wechat_window(self, hwnd: int) -> bool:
"""验证窗口是否确实是微信进程。"""
logger.debug(f"[wechat] 验证窗口 hwnd={hwnd}")
if not win32gui.IsWindow(hwnd):
logger.debug(f"[wechat] 窗口验证失败: IsWindow=False")
return False
if not win32gui.IsWindowVisible(hwnd):
logger.debug(f"[wechat] 窗口验证失败: IsWindowVisible=False")
return False
if win32gui.IsIconic(hwnd):
logger.debug(f"[wechat] 窗口验证失败: IsIconic=True (最小化)")
return False
# 验证进程名
try:
_, pid = win32process.GetWindowThreadProcessId(hwnd)
logger.debug(f"[wechat] 窗口 hwnd={hwnd} 对应 PID={pid}")
hproc = win32api.OpenProcess(
0x0400 | 0x0010, # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ
False, pid
)
if hproc:
exe_path = win32process.GetModuleFileNameEx(hproc, 0)
win32api.CloseHandle(hproc)
exe_name = exe_path.split('\\')[-1].lower() if exe_path else ''
logger.debug(f"[wechat] 进程路径: {exe_path}, 进程名: {exe_name}")
if exe_name in ('wechat.exe', 'weixin.exe'): # wechat=海外版, weixin=国内版
return True
else:
logger.debug(f"[wechat] 进程名不匹配: 期望 wechat.exe/weixin.exe, 实际 {exe_name}")
else:
logger.debug(f"[wechat] 无法打开进程 PID={pid}")
except Exception as e:
logger.debug(f"[wechat] 验证进程异常: {e}")
return False
@staticmethod
def _fuzzy_duplicate(new_text: str, seen_set: set, threshold: float = 0.80) -> bool:
"""检查 new_text 是否与 seen_set 中已有文本相似度超阈值。
OCR 输出有噪声(每次勖彐碎片、行首数字、尾部截断不同),精确匹配无法去重。
用 difflib.SequenceMatcher 的 quick_ratio() 做快速上界剪枝。
"""
if not new_text or len(new_text) < 4:
return False
for existing in seen_set:
if abs(len(existing) - len(new_text)) > max(len(existing), len(new_text)) * 0.4:
# 长度差距超 40%,不可能相似(quick_ratio 上界 = 2*min/max < 阈值)
continue
ratio = difflib.SequenceMatcher(None, new_text, existing).quick_ratio()
if ratio >= threshold:
return True
return False
# ── 双 OCR 引擎 ──────────────────────────────────────────
def _init_easyocr(self):
"""惰性初始化 EasyOCR(首次约 3-10 秒)。"""
if not _HAS_EASYOCR or self._easyocr_reader is not None:
return
try:
logger.info("[ocr] EasyOCR 初始化中(首次约 3-10 秒)...")
# 限制 torch 线程数,避免 MKL/OpenMP 在多线程环境段错误
import torch
torch.set_num_threads(1)
# 检测可用内存,决定是否使用GPU
use_gpu = torch.cuda.is_available()
if use_gpu:
mem = psutil.virtual_memory()
available_gb = mem.available / (1024**3)
total_gb = mem.total / (1024**3)
# 获取页面文件信息
try:
swap = psutil.swap_memory()
swap_total_gb = swap.total / (1024**3)
swap_avail_gb = swap.free / (1024**3)
swap_pct = swap.percent
except:
swap_total_gb = swap_avail_gb = swap_pct = 0
# 可用内存 < 2GB 时强制降级CPU,防止OOM崩溃
if available_gb < 2.0:
logger.warning(f"[ocr] 可用内存不足({available_gb:.1f}GB < 2GB,总{total_gb:.1f}GB,使用{mem.percent}%),"
f"页面文件{swap_avail_gb:.1f}GB可用({swap_pct}%),EasyOCR 降级CPU模式")
use_gpu = False
else:
logger.info(f"[ocr] 可用内存充足({available_gb:.1f}GB/总{total_gb:.1f}GB,使用{mem.percent}%),"
f"页面文件{swap_avail_gb:.1f}GB可用({swap_pct}%),尝试GPU模式")
self._easyocr_reader = _easyocr.Reader(['ch_sim', 'en'], gpu=use_gpu)
if use_gpu:
logger.info("[ocr] EasyOCR 就绪(GPU 模式)")
else:
logger.info("[ocr] EasyOCR 就绪(CPU 模式)")
except Exception as e:
logger.error(f"[ocr] EasyOCR 初始化失败: {e}")
self._easyocr_reader = None
# ── EasyOCR 子进程隔离 ────────────────────────────────────
# 直接 GPU 推理 crash(access violation)会杀整个进程 + 冻电脑。
# 子进程方案:GPU crash 只杀子进程,主进程降级到 Windows OCR。
def _ensure_easyocr_subprocess(self, is_normal_restart: bool = False) -> bool:
"""确保 EasyOCR 子进程在运行。返回 True = 可用。
启动后等待 worker 的 {"ready": true} 握手(最长 60s 初始化预算),
初始化时间不计入请求超时。子进程崩溃/超时 → 杀进程 + 设置冷却,
冷却期后自动重试(不再永久 _easyocr_broken)。
is_normal_restart: 正常调度重启时跳过内存检测,避免正常资源释放后重启被误判为错误
"""
# ⭐ 检查监控是否仍在运行:stop()/急停后 warmup 线程不应重启子进程
if not self._running:
return False
if self._easyocr_broken:
return False
# ⭐ 检查是否为正常调度重启:正常资源释放后重启子进程清GPU缓存
if hasattr(self, '_easyocr_normal_restart') and self._easyocr_normal_restart:
is_normal_restart = True
self._easyocr_normal_restart = False # 重置标志
logger.debug("[ocr] 检测到正常调度重启标志,跳过内存检测")
# ── Task #1446 修复:就绪感知 + 启动串行化 ─────────────────────
# 旧快路径只看 poll() is None(进程存活),并发线程在初始化窗口抢到
# 「进程已存活但未完成 ready 握手」的快路径 return True,导致在 worker
# 未就绪时就派发 OCR 请求、触发 10s 请求超时("刚就绪即超时"竞态)。
# 新逻辑:
# 1) 快路径要求「进程存活【且】_easyocr_ready(已握手)」;
# 2) 进程存活但未就绪 → 说明另一线程正在握手,等待握手事件(≤60s
# 初始化预算),绝不提前派发请求;
# 3) 需要启动时用 _easyocr_startup_lock 串行化 Popen,仅一个线程真正
# 拉起,其余线程等待其握手结果。于是 _call_easyocr_safe 的 10s 请求
# 计时只会在 worker 真正就绪后才开始。
# 快路径(稳态):进程存活且已完成 ready 握手
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and self._easyocr_ready:
return True
# 进程存活但未就绪:另一线程正在握手,等待其完成(≤60s),不重 Popen、不提前派发
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and not self._easyocr_ready:
logger.debug("[ocr] EasyOCR 子进程存活但握手未完成,等待就绪(≤60s)...")
self._easyocr_ready_event.wait(timeout=self._easyocr_ready_timeout)
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and self._easyocr_ready:
return True
# 等待超时或进程已死:清理卡住的未就绪子进程,落到重启逻辑
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and not self._easyocr_ready:
logger.warning("[ocr] 等待握手超时,杀掉未就绪子进程并重试")
self._kill_easyocr_proc("握手等待超时")
# 继续往下走重启
# 进程已退出(poll 非 None)→ 清理后走重启逻辑
if self._easyocr_proc is not None and self._easyocr_proc.poll() is not None:
logger.warning("[ocr] EasyOCR 子进程已退出,重启...")
self._easyocr_proc = None
self._easyocr_ready = False
self._easyocr_ready_event.clear()
# 冷却期:子进程刚崩溃,30s 内不立即重试(防快速崩溃循环刷屏)
if time.time() < self._easyocr_retry_until:
return False
# 串行化启动:仅一个线程负责 Popen(防「proc 为 None」时并发双 Popen),
# 其余线程等待其握手结果。Popen 在锁内完成;握手在锁外执行,避免持锁 60s。
_i_am_starter = False
with self._easyocr_startup_lock:
if self._easyocr_starting or (self._easyocr_proc is not None and self._easyocr_proc.poll() is None):
_i_am_starter = False # 另一线程已拉起/正在握手 → 本线程不 Popen
else:
self._easyocr_starting = True # 持锁标记本线程负责启动
_i_am_starter = True
_started = self._start_easyocr_proc_locked(is_normal_restart)
if not _started:
self._easyocr_starting = False
# 锁已释放
if not _i_am_starter:
# 另一线程正在握手(或本线程未启动但进程存在)→ 等待其握手结果
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None:
self._easyocr_ready_event.wait(timeout=self._easyocr_ready_timeout)
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and self._easyocr_ready:
return True
return False
# 本线程成功拉起进程 → 执行握手(锁外,不持锁 60s)
return self._handshake_easyocr_subprocess(is_normal_restart)
def _start_easyocr_proc_locked(self, is_normal_restart: bool) -> bool:
"""(在 _easyocr_startup_lock 内调用)启动 EasyOCR 子进程:worker 路径检查 + 内存预检 + Popen。
返回 True = 子进程已拉起(self._easyocr_proc 存活);False = 启动失败
(内部已记录日志 / 设置 _easyocr_broken / 冷却)。
"""
worker_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'easyocr_worker.py'
)
if not os.path.exists(worker_path):
logger.error(f"[ocr] EasyOCR worker 不存在: {worker_path}")
self._easyocr_broken = True
return False
# ⭐ 内存预检:物理内存 + 页面文件(虚拟内存)双重检查
# 正常调度重启时跳过内存检测,避免正常资源释放后重启被误判为错误
if not is_normal_restart:
try:
import ctypes
class _MEMORYSTATUSEX(ctypes.Structure):
_fields_ = [
("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
("ullTotalPhys", ctypes.c_ulonglong), ("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong),
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
]
_mem = _MEMORYSTATUSEX()
_mem.dwLength = ctypes.sizeof(_MEMORYSTATUSEX)
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(_mem))
# 物理内存
_phys_total_gb = _mem.ullTotalPhys / (1024**3)
_phys_avail_gb = _mem.ullAvailPhys / (1024**3)
_phys_used_gb = _phys_total_gb - _phys_avail_gb
_phys_used_pct = round(_mem.dwMemoryLoad, 1)
# 页面文件(虚拟内存)
_page_total_gb = _mem.ullTotalPageFile / (1024**3)
_page_avail_gb = _mem.ullAvailPageFile / (1024**3)
_page_used_gb = _page_total_gb - _page_avail_gb
_page_used_pct = round((1 - _mem.ullAvailPageFile / max(_mem.ullTotalPageFile, 1)) * 100, 1)
# 虚拟内存(进程地址空间)
_virt_total_gb = _mem.ullTotalVirtual / (1024**3)
_virt_avail_gb = _mem.ullAvailVirtual / (1024**3)
logger.debug(f"[ocr] 内存状态:物理 {_phys_used_gb:.1f}/{_phys_total_gb:.1f}GB({_phys_used_pct}%),"
f"页面文件 {_page_used_gb:.1f}/{_page_total_gb:.1f}GB({_page_used_pct}%),"
f"虚拟地址空间 {_virt_avail_gb:.1f}GB可用")
# ⭐ 放宽页面文件百分比阈值:>95% 已用即视为不足(避免内存充足时误判)
# 用户报告提交量81.8%被误判为不足,提高阈值到95%
if _page_used_pct > 95.0:
logger.error(f"[ocr] ⚠️ 页面文件不足: 已用{_page_used_pct}%({_page_used_gb:.1f}/{_page_total_gb:.1f}GB),"
f"剩余{_page_avail_gb:.1f}GB。EasyOCR 冷却1小时。建议:增大 Windows 页面文件")
self._easyocr_retry_until = time.time() + 3600
return False
# ⭐ 放宽系统总提交量检查:提交量 > 95% 总commit容量(物理+页面文件) 时冻结风险极高
# 避免内存充足时误判(用户报告提交量81.8%被误判为不足)
_commit_gb = _phys_used_gb + _page_used_gb
_commit_total_gb = _phys_total_gb + _page_total_gb
_commit_pct = (_commit_gb / _commit_total_gb * 100) if _commit_total_gb > 0 else 0
if _commit_pct > 95.0:
logger.error(f"[ocr] ⚠️ 系统提交量过高: {_commit_gb:.1f}GB ({_commit_pct:.0f}%总commit容量{_commit_total_gb:.1f}GB),"
f"EasyOCR 冷却1小时。当前物理{_phys_used_pct}%+页面{_page_used_pct}%。"
f"建议:关闭占用内存的程序或增大物理内存")
self._easyocr_retry_until = time.time() + 3600
return False
# 物理内存严重不足时也跳过(防止系统冻结)
if _phys_avail_gb < 1.0 and _phys_used_pct > 90:
logger.error(f"[ocr] ⚠️ 物理内存严重不足: 剩余{_phys_avail_gb:.1f}GB({_phys_used_pct}%),"
f"EasyOCR 冷却30分钟。建议:关闭其他程序释放内存")
self._easyocr_retry_until = time.time() + 1800
return False
except Exception:
pass # 获取失败则不阻断
else:
logger.debug("[ocr] 正常调度重启:跳过内存检测,直接启动子进程")
try:
_worker_env = dict(os.environ)
# ⭐ CUDA_LAUNCH_BLOCKING=1: CUDA 操作同步化,access violation 时只杀子进程不冻整机
_worker_env["CUDA_LAUNCH_BLOCKING"] = "1"
# ⭐ EASYOCR_WORKER=1: 让 main.py 检测到 worker 模式,跳过 GUI 初始化
_worker_env["EASYOCR_WORKER"] = "1"
# 内存检测:可用内存 < 2GB 时强制CPU模式,防止CUDA加载导致冻结
# CUDA加载torch需要大量内存,物理内存不足时强制使用页面文件会导致系统冻结
# ⭐ 放宽阈值:物理内存<2GB才强制CPU,提交量>95%才强制CPU
try:
mem = psutil.virtual_memory()
available_gb = mem.available / (1024**3)
total_gb = mem.total / (1024**3)
used_gb = mem.used / (1024**3)
# 获取页面文件信息
try:
swap = psutil.swap_memory()
swap_total_gb = swap.total / (1024**3)
swap_used_gb = swap.used / (1024**3)
swap_avail_gb = swap.free / (1024**3)
swap_pct = swap.percent
except:
swap_total_gb = swap_used_gb = swap_avail_gb = swap_pct = 0
# 详细日志
logger.debug(f"[ocr] 内存详情:物理 {used_gb:.1f}/{total_gb:.1f}GB({mem.percent}%),"
f"页面文件 {swap_used_gb:.1f}/{swap_total_gb:.1f}GB({swap_pct}%)")
# ⭐ 页面文件百分比 + 总提交量双重检查:超阈值直接强制CPU(防冻结)
_commit_check_gb = used_gb + swap_used_gb
_commit_total_check_gb = total_gb + swap_total_gb
_commit_check_pct = (_commit_check_gb / _commit_total_check_gb * 100) if _commit_total_check_gb > 0 else 0
# ⭐ 放宽阈值:提交量>95%才强制CPU,避免内存充足时误判
if swap_pct > 95.0 or _commit_check_pct > 95.0:
_worker_env["EASYOCR_FORCE_CPU"] = "1"
logger.warning(f"[ocr] 内存压力高(物理{mem.percent}%+页面{swap_pct}%,"
f"总提交{_commit_check_gb:.1f}GB ({_commit_check_pct:.0f}%总commit容量{_commit_total_check_gb:.1f}GB),"
f"EasyOCR 子进程强制CPU模式(避免CUDA加载冻结)")
elif available_gb < 2.0:
_worker_env["EASYOCR_FORCE_CPU"] = "1"
logger.warning(f"[ocr] 可用内存不足({available_gb:.1f}GB/总{total_gb:.1f}GB,"
f"使用{mem.percent}%),页面文件{swap_avail_gb:.1f}GB可用({swap_pct}%),"
f"EasyOCR 子进程强制CPU模式(避免CUDA加载冻结)")
elif self._easyocr_cuda_oom_count >= 2:
# ⭐ 连续 CUDA OOM/kernel error → 强制 CPU 模式启动(显存不足时防冻结)
_worker_env["EASYOCR_FORCE_CPU"] = "1"
logger.warning("[ocr] EasyOCR 连续CUDA错误≥2次,本次强制 CPU 模式启动 worker")
except Exception as e:
logger.debug(f"[ocr] 获取内存信息失败({e}),不阻断启动")
self._easyocr_proc = subprocess.Popen(
[sys.executable, '-u', worker_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, # 捕获 worker 错误日志(防崩溃排查)
text=True,
bufsize=1, # 行缓冲
env=_worker_env,
)
logger.debug("[ocr] EasyOCR 子进程已启动,等待初始化握手...")
return True
except Exception as e:
logger.error(f"[ocr] 启动 EasyOCR 子进程失败: {e}")
self._easyocr_broken = True
self._easyocr_proc = None
return False
def _handshake_easyocr_subprocess(self, is_normal_restart: bool) -> bool:
"""(本线程成功拉起进程后调用)等待 worker 的 {"ready": true} 握手(最长 60s 初始化预算)。
握手成功:设置 _easyocr_ready + 触发 _easyocr_ready_event,返回 True。
握手失败/超时/崩溃:杀进程 + 冷却 + 清除就绪信号,返回 False。
"""
try:
from queue import Queue, Empty as _QueueEmpty
_q = Queue()
def _read_ready():
try:
_q.put(self._easyocr_proc.stdout.readline())
except Exception:
pass
_t = threading.Thread(target=_read_ready, daemon=True)
_t.start()
try:
line = _q.get(timeout=60)
except _QueueEmpty:
logger.error("[ocr] EasyOCR 子进程初始化超时(60s),杀死进程"
+ ("(正常重启,不施加冷却)" if is_normal_restart else ",30s 后重试"))
self._kill_easyocr_proc("初始化超时")
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 30
return False
if not line:
# stdout 关闭 = worker 崩溃,读 stderr + 崩溃日志看原因
err = ""
try:
err = self._easyocr_proc.stderr.read()[:500]
except Exception:
pass
crash = _read_easyocr_crash_tail()
detail = err.strip()
if crash:
detail = (detail + "\n[ocr] worker 崩溃日志(easyocr_crash.log):\n" + crash).strip()
logger.error(f"[ocr] EasyOCR worker 启动即崩溃: {detail}")
# ⭐ 检测 CUDA 相关崩溃(access violation / torch 错误 / 静默原生崩溃)→ 增加 OOM 计数,下次强制 CPU
_is_cuda_crash = (crash and ("access violation" in crash.lower() or "torch" in crash.lower()))
_is_silent_native_crash = (not err.strip() and not crash.strip()) # 无stderr无crash log = 原生层崩溃
if _is_cuda_crash or _is_silent_native_crash:
self._easyocr_cuda_oom_count += 1
logger.warning(f"[ocr] 检测到{'CUDA崩溃' if _is_cuda_crash else '静默原生崩溃(无日志)'}, "
f"累计{self._easyocr_cuda_oom_count}次,下次可能强制 CPU")
# ⭐ 检测页面文件不足(WinError 1455)→ 长期冷却,不反复重试
_is_pagefile_error = "页面文件" in str(detail) or "1455" in str(detail)
if _is_pagefile_error:
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 3600 # 1小时冷却(系统级问题)
logger.error("[ocr] ⚠️ 页面文件不足(WinError 1455),EasyOCR"
+ ("正常重启不施加冷却,交由系统调度" if is_normal_restart else "冷却1小时。")
+ "建议:增大 Windows 页面文件大小(系统属性→高级→性能→虚拟内存)")
else:
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 30
# ⭐ Task #1446:进程已死,清除就绪信号与握手事件
self._easyocr_proc = None
self._easyocr_ready = False
self._easyocr_ready_event.clear()
self._easyocr_starting = False
return False
try:
payload = json.loads(line)
if payload.get("ready"):
logger.info("[ocr] EasyOCR 子进程就绪")
self._easyocr_consecutive_startup_failures = 0 # 成功启动,重置启动失败计数
# ⭐ 截断崩溃日志:清除旧条目,防止旧记录混淆用户
_truncate_easyocr_crash_log()
# ⭐ Task #1446:握手完成 → 标记就绪并广播事件,唤醒等待中的并发线程,
# 保证 _call_easyocr_safe 的 10s 请求计时只在 worker 真正就绪后才开始
self._easyocr_ready = True
self._easyocr_ready_event.set()
self._easyocr_starting = False # 启动完成,清除「正在启动」标志
return True
except Exception:
pass
# 非 ready 响应 → 协议异常
logger.error(f"[ocr] EasyOCR worker 异常响应: {line[:100]!r}")
self._kill_easyocr_proc("协议异常")
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 30
return False
except Exception as e:
logger.error(f"[ocr] EasyOCR 握手失败: {e}")
self._kill_easyocr_proc("握手失败")
if not is_normal_restart:
self._easyocr_retry_until = time.time() + 30
return False
def _call_easyocr_safe(self, img_np, timeout: Optional[float] = None) -> Optional[str]:
"""通过子进程安全调用 EasyOCR。返回识别文本,失败/崩溃/超时时返回 None。
使用 _easyocr_lock 保护子进程 stdin/stdout,避免 QQ/微信独立线程并发写乱。
timeout — 子进程读取超时秒数,None=无限等待;超时后杀死子进程下次重启。
"""
import io
import base64
if not self._ensure_easyocr_subprocess():
# ⭐ 区分"冷却中等待"和"真正启动失败"
# 冷却中:_easyocr_retry_until > now → 只是还没到重试时间,不计入失败
# 真失败:子进程启动命令本身报错 → 计入失败
if self._easyocr_retry_until > time.time():
# 冷却期,跳过不计数(正常等待重试)
logger.debug(f"[ocr] EasyOCR 冷却中({int(self._easyocr_retry_until - time.time())}s),跳过")
return None
# 真正启动失败(_ensure_easyocr_subprocess内部报错)
self._easyocr_consecutive_startup_failures += 1
if self._easyocr_consecutive_startup_failures >= self._easyocr_max_startup_failures:
logger.warning(f"[ocr] EasyOCR 连续{self._easyocr_consecutive_startup_failures}次启动失败,"
f"降级 Windows OCR,5分钟后再试(疑似内存不足,请关闭其他程序释放内存)")
# 长冷却:内存不足场景下不反复重启刷屏,保留内存恢复后重试能力
# (彻底重启/用户释放内存后,冷却到期会自动重试)
self._easyocr_retry_until = time.time() + 300
# 重置计数器(冷却后重试重新计数)
self._easyocr_consecutive_startup_failures = 0
else:
# ⭐ 短冷却:未达阈值时也设30s冷却,避免反复启动失败刷屏
if self._easyocr_retry_until <= time.time():
self._easyocr_retry_until = time.time() + 30
logger.debug(f"[ocr] EasyOCR 第{self._easyocr_consecutive_startup_failures}次启动失败,"
f"冷却({int(self._easyocr_retry_until - time.time())}s)")
return None
try:
# numpy → PIL → PNG bytes → base64
img_pil = Image.fromarray(img_np)
buf = io.BytesIO()
img_pil.save(buf, format='PNG')
img_b64 = base64.b64encode(buf.getvalue()).decode()
# ⚠ 防永久阻塞:QQ/微信 共享单子进程+单锁,若一方慢速 OCR 持有锁,
# 另一方 acquire 会无限挂起 → daemon 线程永生 → 持有 2x 大图泄漏内存。
# 改为带超时 acquire,超时即放弃本次请求(下轮重试),线程正常结束释放大图。
if not self._easyocr_lock.acquire(timeout=15.0):
# ⭐ 锁竞争超时:另一个线程正在使用EasyOCR处理大图(正常行为)
# worker没崩溃,只是在忙 → 不计入consecutive_failures
# 仅重置call_count防止"正常释放"误触发
logger.debug("[ocr] EasyOCR 锁竞争超时(15s),另一线程正在处理中,本次跳过(正常行为)")
self._easyocr_call_count = 0 # 中断连续成功计数,防"正常释放"误触发
return None
try:
# ⚠ 竞态保护:另一个线程可能已杀死进程并设 None
if not self._easyocr_proc or self._easyocr_proc.poll() is not None:
return None
self._easyocr_proc.stdin.write(json.dumps({"img": img_b64}) + "\n")
self._easyocr_proc.stdin.flush()
from queue import Queue, Empty as _QueueEmpty
# readline 包装超时:用 daemon 线程 + Queue 实现跨平台
if timeout is not None:
_q = Queue()
def _read_worker():
try:
proc = self._easyocr_proc
if proc and proc.poll() is None:
_q.put(proc.stdout.readline())
except Exception:
pass
_t = threading.Thread(target=_read_worker, daemon=True)
_t.start()
# ⭐ 急停检查:轮询 _running,急停后立即放弃等待(丢弃未出结果的 OCR)
_deadline = time.time() + timeout
line = None
while self._running and time.time() < _deadline:
try:
line = _q.get(timeout=0.05)
break
except _QueueEmpty:
continue
if line is None:
if not self._running:
logger.debug("[ocr] EasyOCR 等待期间急停,丢弃未出结果的 OCR 请求")
else:
self._easyocr_consecutive_failures += 1
if self._easyocr_call_count > 0:
logger.debug(f"[ocr] EasyOCR 超时重置call_count: {self._easyocr_call_count}→0(防'正常释放'误触发)")
self._easyocr_call_count = 0 # ⭐ 超时重置call_count,防"正常释放"误触发
# 动态增加超时时间,避免EasyOCR初始化未完成就被判超时
old_timeout = self._easyocr_current_timeout
self._easyocr_current_timeout = min(self._easyocr_current_timeout + self._easyocr_timeout_step, self._easyocr_max_timeout)
if self._easyocr_current_timeout != old_timeout:
logger.debug(f"[ocr] EasyOCR 超时增加: {old_timeout}s → {self._easyocr_current_timeout}s")
if self._easyocr_consecutive_failures >= self._easyocr_max_failures:
logger.debug(f"[ocr] EasyOCR 连续{self._easyocr_consecutive_failures}次超时,杀子进程重来")
self._kill_easyocr_proc("连续超时")
self._easyocr_consecutive_failures = 0
self._easyocr_consecutive_startup_failures = 0 # 重置启动失败计数
else:
logger.debug(f"[ocr] EasyOCR 第{self._easyocr_consecutive_failures}次超时({timeout}s),保留子进程不杀")
return None
else:
line = self._easyocr_proc.stdout.readline()
if not line:
crash = _read_easyocr_crash_tail()
msg = "EasyOCR 子进程 stdout 关闭"
if crash:
msg += f"(崩溃日志: {crash})"
raise ConnectionError(msg)
finally:
self._easyocr_lock.release()
result = json.loads(line)
if result.get("ok"):
# 成功 → 重置连续失败计数
self._easyocr_consecutive_failures = 0
self._easyocr_consecutive_startup_failures = 0 # 成功启动,重置启动失败计数
# 成功识别后重置超时到最小值,避免累积
self._easyocr_current_timeout = self._easyocr_min_timeout
# 计数 + 主动重启: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缓存(非错误,不计入失败计数器)")
self._kill_easyocr_proc("正常资源释放")
self._easyocr_call_count = 0
# ⭐ 正常调度重启:标记为正常重启,跳过内存检测直接启动
self._easyocr_normal_restart = True
# ⭐ 正常调度是系统自主管控的启停,不录入任何错误计数器、不施加冷静期:
# 清除可能残留的 30s 冷却,重置连续失败计数,交由系统自行调度启停。
self._easyocr_retry_until = 0.0
self._easyocr_consecutive_failures = 0
self._easyocr_consecutive_startup_failures = 0
return result["text"]
else:
err = result.get('error', '?')[:100]
logger.debug(f"[ocr] EasyOCR worker 错误: {err}")
# ⭐ CUDA OOM / kernel error:worker 即将自杀退出(context 已损坏),
# 这里立即确认杀掉 + 冷却,避免后续轮询继续往坏进程发图
if 'cuda' in err.lower() or 'out of memory' in err.lower():
self._easyocr_cuda_oom_count += 1
logger.warning(
f"[ocr] EasyOCR CUDA 错误(累计{self._easyocr_cuda_oom_count}次),"
f"杀子进程+冷却,{('下次强制CPU' if self._easyocr_cuda_oom_count >= 2 else '保持GPU')}")
self._kill_easyocr_proc("CUDA错误")
self._easyocr_retry_until = time.time() + 30
return None
except (BrokenPipeError, ConnectionError, OSError) as e:
logger.debug(f"[ocr] EasyOCR 子进程通信断开: {e},30s 后自动重试")
self._easyocr_proc = None
self._easyocr_call_count = 0 # 进程死了,重置call_count
self._easyocr_consecutive_failures += 1
self._easyocr_retry_until = time.time() + 30 # 冷却后自动重试,不永久 broken
return None
except Exception as e:
logger.debug(f"[ocr] EasyOCR 子进程异常: {e}")
self._easyocr_call_count = 0
self._easyocr_consecutive_failures += 1
return None
@staticmethod
def _score_ocr_text(text: str) -> float:
"""为 OCR 结果质量打分(越高越好)。对比引擎用。"""
if not text or len(text.strip()) < 5:
return 0.0
score = 0.0
# 1) 有效 CJK 数量(越多 = 识别越好)
cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
score += cjk * 0.5
# 2) Unicode 垃圾字符扣分(OCR 常见误识碎片)
junk = set('勖彐丬亻俨冉勹刂冫孑亠丷')
junk_count = sum(1 for c in text if c in junk)
score -= junk_count * 3
# 3) CJK 间空格比例扣分(Windows OCR 特有毛病)
# "你 好" vs "你好" — 空格越多说明识别越差
total = len(text)
spaces = text.count(' ')
if total > 0 and cjk > 0:
space_penalty = (spaces / total) * 10
score -= space_penalty
# 4) 时间戳加分 = 消息结构更完整
timestamps = len(re.findall(r'\d{1,2}:\d{2}', text))
score += min(timestamps * 2, 6)
return max(score, 0.0)
def _pick_ocr_text(self, win_text: str, easy_text: str, image: Image.Image) -> tuple:
"""双引擎对比,选质量更高的结果。日志记录引擎胜负统计。返回 (text, engine_name)。
⭐ 长度保护:当一引擎识别行数显著多于另一方(>=1.5x)时,
即使分数略低也优先选更长文本——避免 Windows OCR 截断丢消息。
"""
if not easy_text:
return (win_text, "windows")
if not win_text:
return (easy_text, "easyocr")
win_score = self._score_ocr_text(win_text)
easy_score = self._score_ocr_text(easy_text)
win_lines = len([l for l in win_text.split('\n') if l.strip()])
easy_lines = len([l for l in easy_text.split('\n') if l.strip()])
win_len = len(win_text.strip())
easy_len = len(easy_text.strip())
# 长度保护:一方行数 >= 1.5x 另一方 → 倾向更长文本(分数差 < 10 时触发)
chosen = "windows" if win_score >= easy_score else "easyocr"
score_diff = abs(win_score - easy_score)
if score_diff < 10:
if easy_lines >= win_lines * 1.5 and easy_len > win_len * 1.3:
chosen = "easyocr"
logger.debug(
f"[ocr] 长度保护: easy行数{easy_lines}>=1.5x win行数{win_lines},"
f"分数差仅{score_diff:.1f},强制选easyocr"
)
elif win_lines >= easy_lines * 1.5 and win_len > easy_len * 1.3:
chosen = "windows"
logger.debug(
f"[ocr] 长度保护: win行数{win_lines}>=1.5x easy行数{easy_lines},"
f"分数差仅{score_diff:.1f},强制选windows"
)
self._ocr_engine_wins[chosen] = self._ocr_engine_wins.get(chosen, 0) + 1
total = sum(self._ocr_engine_wins.values())
ratio = self._ocr_engine_wins.get(chosen, 0) / max(total, 1)
logger.debug(
f"[ocr] 引擎对比: win={win_score:.1f}(行{win_lines},字符{win_len}) "
f"vs easy={easy_score:.1f}(行{easy_lines},字符{easy_len}) "
f"→ 选{chosen}(分数差{score_diff:.1f}, 胜率{ratio:.0%}, {total}轮)"
)
return (win_text, chosen) if chosen == "windows" else (easy_text, chosen)
def _safe_screenshot(self, bbox: tuple, timeout: float = 3.0):
"""带超时的屏幕截图。ImageGrab.grab() 的 BitBlt 可能因 DWM 卡死→整机冻结 2-3min。
用线程+timeout 隔离:BitBlt 卡死只杀线程,不波及 DWM/桌面。
timeout=3.0: 3 秒截不到就跳过本轮,bot 继续下次轮询。
⭐ 等待期间轮询 _running:急停/停止后立即折返,不等截屏结果。
"""
_im = [None]
def _grab():
try:
_im[0] = ImageGrab.grab(bbox=bbox)
except Exception:
pass
import threading
t = threading.Thread(target=_grab, daemon=True)
t.start()
# ⭐ 急停检查:join 期间轮询 _running,急停/停止后立即折返,不等截屏结果
deadline = time.time() + timeout
while self._running and t.is_alive() and time.time() < deadline:
t.join(0.05)
if t.is_alive():
if not self._running:
logger.debug("[ocr] 截图期间急停,丢弃未完成的截屏")
else:
logger.warning(f"[ocr] 截图超时 ({timeout}s),BitBlt 可能卡在 DWM,跳过本轮")
return None
return _im[0]
def _screenshot_window(self, hwnd: int, timeout: float = 3.0):
"""使用 PrintWindow API 截取指定窗口内容(即使被其他窗口遮挡)。
PrintWindow 可以截取不可见/被遮挡窗口的内容,但某些窗口(如 Electron)可能不支持。
返回 PIL Image 或 None。
"""
logger.debug(f"[screenshot] PrintWindow 开始: hwnd={hwnd}")
_im = [None]
def _grab():
try:
# 获取窗口尺寸
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
width = right - left
height = bottom - top
if width <= 0 or height <= 0:
return
# 创建设备上下文和位图(先置 None,finally 统一释放,防异常路径 GDI 句柄/位图泄漏累积)
hwnd_dc = mfc_dc = save_dc = save_bitmap = None
try:
hwnd_dc = win32gui.GetWindowDC(hwnd)
mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
save_dc = mfc_dc.CreateCompatibleDC()
save_bitmap = win32ui.CreateBitmap()
save_bitmap.CreateCompatibleBitmap(mfc_dc, width, height)
save_dc.SelectObject(save_bitmap)
# 使用 PrintWindow 截取窗口内容
# PW_RENDERFULLCONTENT = 0x00000002 (Windows 8.1+)
result = ctypes.windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), 0x00000002)
if result:
# 转换为 PIL Image
bmpinfo = save_bitmap.GetInfo()
bmpstr = save_bitmap.GetBitmapBits(True)
img = Image.frombuffer('RGB', (bmpinfo['bmWidth'], bmpinfo['bmHeight']), bmpstr, 'raw', 'BGRX', 0, 1)
_im[0] = img
finally:
# ⚠ 无论成功/异常都释放 GDI 资源,避免句柄/位图泄漏累积
try:
if save_bitmap is not None:
win32gui.DeleteObject(save_bitmap.GetHandle())
except Exception:
pass
try:
if save_dc is not None:
save_dc.DeleteDC()
except Exception:
pass
try:
if mfc_dc is not None:
mfc_dc.DeleteDC()
except Exception:
pass
try:
if hwnd_dc is not None:
win32gui.ReleaseDC(hwnd, hwnd_dc)
except Exception:
pass
except Exception as e:
logger.debug(f"[screenshot] PrintWindow 失败: {e}")
import threading
t = threading.Thread(target=_grab, daemon=True)
t.start()
deadline = time.time() + timeout
while self._running and t.is_alive() and time.time() < deadline:
t.join(0.05)
if t.is_alive():
if not self._running:
logger.debug("[screenshot] PrintWindow 期间急停")
else:
logger.warning(f"[screenshot] PrintWindow 超时 ({timeout}s)")
return None
logger.debug(f"[screenshot] PrintWindow 结果: {'成功' if _im[0] is not None else '失败'}")
return _im[0]
def _ocr_dual_engine(self, im_processed: Image.Image, im_original: Image.Image, platform: str) -> tuple:
"""双引擎并发 OCR:Windows OCR + EasyOCR 同时跑,最多等 10 秒。
哪個先出用哪個,兩個都出時 _pick_ocr_text 質量對比選優。
im_processed: 预处理后的图(2x resize + sharpen + contrast, RGBA)
im_original: 原始截图(bad allocation 降级用)
返回 (raw_text, engine_name) 含義同 _pick_ocr_text。
⚠ 不用 ThreadPoolExecutor with 块:__exit__ 调 shutdown(wait=True) 会在
Windows OCR 卡死时永久阻塞主线程 → 电脑冻结。改用 daemon 线程 + join(timeout)。
"""
engine = self._ocr_ensure_engine()
if not engine:
return ("", "")
win_text: list[str] = [""]
easy_text: list[str] = [""]
def _win_worker():
try:
im = im_processed
if im.mode != 'RGBA':
im = im.convert('RGBA')
sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height)
sb.copy_from_buffer(im.tobytes())
# recognize_async().get() 无超时保护——卡死会冻住整个线程
op = engine.recognize_async(sb)
result = op.get() # ⚠ 此处可能卡死:Windows OCR 引擎无响应
win_text[0] = result.text.strip()
except (RuntimeError, OSError) as e:
estr = str(e)
if "bad allocation" in estr or "memory" in estr.lower():
try:
im2 = im_original
if im2.mode != 'RGBA':
im2 = im2.convert('RGBA')
sb2 = SoftwareBitmap(BitmapPixelFormat.RGBA8, im2.width, im2.height)
sb2.copy_from_buffer(im2.tobytes())
op2 = engine.recognize_async(sb2)
result2 = op2.get()
win_text[0] = result2.text.strip()
except Exception:
pass
# 其他异常静默处理
_OCR_TIMEOUT = 10.0 # 总超时秒数(单次OCR请求)- 必须在 _easy_worker 之前定义
def _easy_worker():
if not _HAS_EASYOCR or self._easyocr_broken:
return
# EasyOCR 用原图(1x):2x 图送 GPU 显存压力大 + 加速泄漏
img_np = _np.array(im_original)
# 使用动态超时:初始45秒,每次超时增加15秒,成功后重置
_inner_timeout = self._easyocr_current_timeout
text = self._call_easyocr_safe(img_np, timeout=_inner_timeout)
if text:
easy_text[0] = text
# ── 用 daemon 线程替代 ThreadPoolExecutor ──
# ThreadPoolExecutor.__exit__ 调 shutdown(wait=True) 会永久阻塞主线程。
# daemon 线程:join 超时后直接丢弃,线程随进程退出自动回收。
_t_win = threading.Thread(target=_win_worker, daemon=True, name="ocr-win")
_t_easy = threading.Thread(target=_easy_worker, daemon=True, name="ocr-easy")
_t_win.start()
_t_easy.start()
# ⭐ 急停检查:join 期间轮询 _running,急停/停止后立即折返,不等 OCR 结果
_deadline = time.time() + _OCR_TIMEOUT
while self._running and time.time() < _deadline:
if not _t_win.is_alive() and not _t_easy.is_alive():
break
_t_win.join(0.05)
_t_easy.join(0.05)
if not self._running:
logger.debug(f"[{platform}] 双引擎 OCR 期间急停,丢弃未出结果的 OCR")
# 超时的线程还在跑?记录警告,不阻塞——daemon 线程随进程退出自动回收
if _t_win.is_alive():
logger.warning(f"[{platform}] Windows OCR 超时({_OCR_TIMEOUT}s)线程存活,跳过")
if _t_easy.is_alive():
logger.warning(f"[{platform}] EasyOCR 超时({_OCR_TIMEOUT}s)线程存活,跳过")
# ⭐ EasyOCR线程超时也计入连续失败,防止"超时跳过"绕过杀进程逻辑
self._easyocr_consecutive_failures += 1
self._easyocr_call_count = 0 # ⭐ 超时重置call_count,防"正常释放"误触发
if self._easyocr_consecutive_failures >= self._easyocr_max_failures:
logger.warning(f"[{platform}] EasyOCR 连续{self._easyocr_consecutive_failures}次超时,杀子进程重来")
self._kill_easyocr_proc("双引擎连续超时")
self._easyocr_consecutive_failures = 0
self._easyocr_consecutive_startup_failures = 0
wt = win_text[0]
et = easy_text[0]
# ⭐ EasyOCR超时+Windows OCR混合消息检测:当EasyOCR超时且Windows OCR结果包含
# 多个时间戳(表明是多条消息混合),返回空结果,避免消息搅和
if not et and wt:
timestamps = re.findall(r'\d{1,2}:\d{1,2}', wt)
if len(timestamps) >= 2:
logger.warning(f"[{platform}] EasyOCR超时+Windows OCR检测到{len(timestamps)}个时间戳"
f"({timestamps[:3]}...),疑似消息混合,跳过本轮避免搅和")
return ("", "")
# 日志(原始输出逐行记录,用於長期對比兩引擎能力)
if wt:
for i, l in enumerate(wt.split('\n')):
if l.strip():
logger.info(f"[ocr] [windows/原始] line[{i}]: {l.strip()[:120]!r}")
if et:
for i, l in enumerate(et.split('\n')):
if l.strip():
logger.info(f"[ocr] [easyocr/原始] line[{i}]: {l.strip()[:120]!r}")
if not wt and not et:
return ("", "")
return self._pick_ocr_text(wt, et, im_processed)
def _ocr_read_qq(self) -> list:
"""OCR 截屏 QQ 消息区域 -> 双引擎(Windows OCR + EasyOCR)提取文本。
包含窗口验证 + 垃圾过滤 + 连续垃圾清缓存。
"""
# 同平台冷却:回复后 15s 内跳过,防读到自身回复回声
# 冷却期间也设 _ocr_unchanged → _read_messages 跳过所有 fallback
# (_read_via_clipboard_once 调 _ensure_foreground → AttachThreadInput → 崩 TIM Electron)
if time.time() < self._self_reply_cooldown_until.get("qq", 0):
self._ocr_unchanged["qq"] = True
return []
hwnd = self._cached_hwnd.get("qq")
if not hwnd:
return []
if not self._validate_qq_window(hwnd):
gc = self._ocr_garbage_count.get("qq", 0) + 1
self._ocr_garbage_count["qq"] = gc
if gc == 1:
# 首次失败时详细日志
visible = win32gui.IsWindowVisible(hwnd) if win32gui.IsWindow(hwnd) else False
iconic = win32gui.IsIconic(hwnd) if win32gui.IsWindow(hwnd) else False
logger.debug(f"[qq] OCR: 窗口不可用(visible={visible}, iconic={iconic})")
if gc >= 3:
logger.info("[qq] OCR: 窗口不合法连续3次,清缓存")
self._cached_hwnd.pop("qq", None)
self._cached_pid.pop("qq", None)
self._app_variant.pop("qq", None)
self._ocr_garbage_count["qq"] = 0
return []
engine = self._ocr_ensure_engine()
if not engine:
return []
try:
# ⚠️ 急停检查:截屏+OCR是耗时操作,开始前必须检查
if not self._running:
return []
# ⭐ 先唤醒再取窗口 rect(最小化恢复后位置会变化),再截屏
# ImageGrab.grab 截整屏坐标,若 QQ 被遮挡(用户停在终端)截到的就是遮挡物
if not self._ensure_visible(hwnd):
logger.debug("[qq] 窗口不可见,退避5秒(用户可能在接管)")
# 退避期间也要检查急停,避免阻塞5秒
for _ in range(50):
if not self._running:
return []
time.sleep(0.1)
return []
time.sleep(0.4)
rect = win32gui.GetWindowRect(hwnd)
if not rect:
return []
left, top, right, bottom = rect
win_w = right - left
win_h = bottom - top
# QQ NT 窗口布局: ~0-5% 标签栏, ~5-30% 联系人/群列表, ~30-55% 会话列表, ~55-100% 消息面板
variant = self._app_variant.get("qq", "")
# TIM (Electron) 安全:不做 PostMessage 点击 + 不做 keybd_event 全局键
# 只做 PostMessage VK_END(异步,安全),避免干扰 Electron 渲染进程
if variant == "tim":
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
time.sleep(0.3)
else:
# 点一下消息面板区域设置焦点(QQ NT 默认焦点在会话列表,VK_END 只会滚会话列表)
try:
click_x = int(win_w * 0.72) # 消息面板中心偏右
click_y = int(win_h * 0.50)
lparam = win32api.MAKELONG(click_x, click_y)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
logger.debug(f"[qq] 点击消息面板({click_x},{click_y})设置焦点")
time.sleep(0.15)
except Exception as e:
logger.debug(f"[qq] 点击消息面板失败: {e}")
# Ctrl+End 滚动到底部,只截最新消息(避开历史堆积+bot 旧回复)
VK_CONTROL = 0x11
VK_END = 0x23
# 方式A: keybd_event(窗口在前台时生效)
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
ctypes.windll.user32.keybd_event(VK_END, 0, 0, 0)
time.sleep(0.05)
ctypes.windll.user32.keybd_event(VK_END, 0, 2, 0)
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
# 方式B: PostMessage 兜底(keybd_event 可能被 TIM 忽略)
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
time.sleep(0.3)
# TIM/QQ 窗口布局: ~5-30% 左栏, ~30-50% 会话列表(含文档预览), ~50-100% 消息面板
# 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)) # 输入框上方
if cap_right <= cap_left or cap_bottom <= cap_top:
return []
# 检查窗口是否在前台,如果不在则尝试使用 PrintWindow
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
is_foreground = self._check_foreground(root_hwnd)
if not is_foreground:
# 窗口可能被遮挡,使用 PrintWindow 截取
im = self._screenshot_window(hwnd, timeout=3.0)
if im is not None:
# 裁剪到消息区域
# 计算相对于窗口的裁剪坐标
crop_left = cap_left - left
crop_top = cap_top - top
crop_right = cap_right - left
crop_bottom = cap_bottom - top
# 确保坐标有效
crop_left = max(0, min(crop_left, im.width))
crop_top = max(0, min(crop_top, im.height))
crop_right = max(crop_left + 1, min(crop_right, im.width))
crop_bottom = max(crop_top + 1, min(crop_bottom, im.height))
im = im.crop((crop_left, crop_top, crop_right, crop_bottom))
logger.debug(f"[qq] PrintWindow 裁剪后: {im.width}x{im.height}")
if im.width < 50 or im.height < 50:
logger.warning(f"[qq] PrintWindow 结果太小({im.width}x{im.height}),回退屏幕截图")
im = None
if im is None:
# PrintWindow 失败或结果太小,回退到屏幕截图
im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom))
if im is None:
return []
else:
im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom))
if im is None:
return []
# ⭐ 立即保存调试截图(在所有检查之前,确保即使失败也有图可查)
_dbg_path = ""
try:
debug_dir = _get_ocr_raw_dir("qq")
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_dbg_path = os.path.join(debug_dir, f"ocr_debug_qq_{ts}.png")
im.save(_dbg_path, format="PNG")
if os.path.exists(_dbg_path):
logger.info(f"[qq] 调试截图已保存: {_dbg_path} ({im.width}x{im.height})")
else:
logger.warning(f"[qq] 调试截图保存后未找到文件: {_dbg_path}")
except Exception as e:
logger.warning(f"[qq] 调试截图保存失败: {e} (路径: {_dbg_path})")
if im.width < 50 or im.height < 50:
logger.warning(f"[qq] 截图太小: {im.width}x{im.height}")
return []
# 详细调试日志:记录截图信息和裁剪区域
logger.debug(f"[qq] OCR前截图信息: 尺寸={im.width}x{im.height}, "
f"窗口位置=({left},{top})-({right},{bottom}), "
f"裁剪区域=({cap_left},{cap_top})-({cap_right},{cap_bottom}), "
f"前台={is_foreground}")
# ⭐ 颜色像素检测:如果截图中只有 bot 蓝色气泡,无白色用户消息,跳过
if not self._has_user_colors(im, "qq"):
# ⚠️ 不设 _ocr_unchanged——像素检测失败≠截图没变化。
# 若设 _ocr_unchanged,_read_messages 会跳过所有 fallback(剪贴板/UIA),
# 导致 bot 回复后用户新消息再也无法入库。只 return 空列表即可。
logger.debug(f"[qq] 像素检测: 无对方消息, 跳过OCR (截图: {os.path.basename(_dbg_path)})")
return []
# ⭐ 截图 hash:像素级指纹——内容没变就直接跳过整轮(easyocr 同一截图每次输出不同文本)
# dHash + 5秒强制刷新:即使hash相同,每5秒也强制做一次OCR,防止永久卡死
h = self._image_hash(im)
now = time.time()
last_hash = self._last_screenshot_hash.get("qq", "")
last_time = self._last_ocr_time.get("qq", 0)
# ⭐ 仅当上一帧"已捕捉且已入队"才跳过:若上一帧 OCR 了但没入队
# (被时间戳/噪声过滤丢弃),则不跳过,给后续相同截图重新 OCR 的机会,
# 避免"捕捉到但没入队"的消息被永久跳过。
if h == last_hash and (now - last_time) < 5:
# ⭐ 修复:hash相同跳过仅当上一帧已入队且已回复
# 原逻辑:只要上一帧已入队就跳过,导致消息从未处理过但hash相同时被静默跳过
# 新逻辑:必须确认消息已被回复(_last_hash_replied),才能跳过
if self._last_hash_queued.get("qq", False) and self._last_hash_replied.get("qq", False):
self._ocr_unchanged["qq"] = True
logger.debug(f"[qq] 截图 hash 相同且上一帧已入队并已回复,屏幕无变化,跳过本轮")
return []
else:
logger.debug(f"[qq] 截图 hash 相同但上一帧未回复,强制重新 OCR")
self._last_screenshot_hash["qq"] = h
self._last_ocr_time["qq"] = now
self._ocr_unchanged.pop("qq", None)
# 图像预处理:提升 Windows OCR 识别准确率
# Windows OCR 对低 DPI/未处理截图识别差——CJK 空格、字符混淆
# 2x LANCZOS 放大 + 锐化 + 对比度增强
# ⚠️ 锐化/对比度过高会导致汉字边缘断裂成偏旁部首
im_original = im # 保留原图,bad allocation 时降级
try:
im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS)
im = ImageEnhance.Sharpness(im).enhance(1.2) # 降低锐化,防字符断裂
im = ImageEnhance.Contrast(im).enhance(1.2) # 降低对比度,保持笔画连贯
logger.debug(f"[ocr] 预处理: resize 2x + sharpen(1.2) + contrast(1.2)")
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
try:
debug_dir = _get_ocr_processed_dir("qq")
ts = time.strftime("%Y%m%d_%H%M%S")
_processed_path = os.path.join(debug_dir, f"ocr_processed_qq_{ts}.jpg")
# 转换为RGB模式保存JPG(不支持RGBA)
if im.mode == 'RGBA':
im_rgb = im.convert('RGB')
else:
im_rgb = im
im_rgb.save(_processed_path, format="JPEG", quality=95)
# 删除放缓:不在这里立即清理,改由 _maybe_clean_ocr_dir 每 30~60s 随机定时清理
if os.path.exists(_processed_path):
logger.debug(f"[qq] OCR处理后图片已保存: {_processed_path}")
except Exception as e:
logger.debug(f"[qq] OCR处理后图片保存失败: {e}")
except Exception as e:
logger.debug(f"[ocr] 预处理失败(跳过): {e}")
# ── 双引擎并发 OCR(Windows OCR + EasyOCR 同时跑,最多等 10 秒)──
raw_text, chosen_engine = self._ocr_dual_engine(im, im_original, "qq")
if not raw_text:
return []
self._last_chosen_engine["qq"] = chosen_engine
return self._process_ocr_output(raw_text, "qq")
except Exception as e:
logger.debug(f"[qq] OCR失败: {e}")
return []
def _ocr_read_wechat(self) -> list:
"""OCR 截屏微信消息区域 -> 双引擎提取文本。
微信 UIA 树不暴露聊天消息内容(新版微信 UIA 架构变化),必须用 OCR。
微信自绘控件,keybd_event 无法滚动,只截屏当前可见区域。
"""
# 同平台冷却:回复后 15s 内跳过,防读到自身回复回声
if time.time() < self._self_reply_cooldown_until.get("wechat", 0):
return []
hwnd = self._cached_hwnd.get("wechat")
if not hwnd:
logger.debug("[wechat] 无缓存HWND")
return []
if not win32gui.IsWindow(hwnd) or not self._validate_wechat_window(hwnd):
# 详细记录哪个验证失败
is_window = win32gui.IsWindow(hwnd)
is_visible = win32gui.IsWindowVisible(hwnd) if is_window else False
is_iconic = win32gui.IsIconic(hwnd) if is_window else False
logger.warning(f"[wechat] 窗口无效: hwnd={hwnd}, IsWindow={is_window}, IsVisible={is_visible}, IsIconic={is_iconic}")
self._cached_hwnd.pop("wechat", None)
self._cached_pid.pop("wechat", None)
return []
engine = self._ocr_ensure_engine()
if not engine:
return []
try:
# ⚠️ 急停检查:截屏+OCR是耗时操作,开始前必须检查
if not self._running:
return []
# ⭐ 先唤醒再截屏:ImageGrab.grab 截整屏坐标,若微信被遮挡(用户停在终端)
# 截到的就是遮挡物。唤醒后才取窗口 rect(最小化恢复后位置会变化)。
# 用 _ensure_foreground(AttachThreadInput 强置前):微信原生窗口安全。
# 若置前失败但窗口仍然可见(未被最小化/隐藏),仍应尝试截图,
# 与QQ逻辑一致:只要窗口在屏幕上就截,避免漏掉消息。
logger.debug(f"[wechat] 尝试唤醒窗口 hwnd={hwnd}")
fg_ok = self._ensure_foreground(hwnd)
logger.debug(f"[wechat] 唤醒结果: fg_ok={fg_ok}")
if not fg_ok:
# 检查窗口是否可见(未被最小化到托盘/隐藏)
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
is_visible = root_hwnd and win32gui.IsWindowVisible(root_hwnd) and not win32gui.IsIconic(root_hwnd)
logger.debug(f"[wechat] 可见性检查: root_hwnd={root_hwnd}, is_visible={is_visible}, iconic={win32gui.IsIconic(root_hwnd) if root_hwnd else 'N/A'}")
if is_visible:
logger.debug("[wechat] 窗口未获得前台但仍然可见,继续截图")
else:
logger.debug("[wechat] 窗口未前置且不可见,退避5秒(用户可能在接管)")
# ⭐ 保存"窗口不可见"现场截图:即使整轮跳过也有图可查
try:
_r = win32gui.GetWindowRect(hwnd)
if _r and (_r[2] - _r[0]) > 200 and (_r[3] - _r[1]) > 100:
_im = self._safe_screenshot((_r[0], _r[1], _r[2], _r[3]))
if _im is not None and _im.width > 50 and _im.height > 50:
debug_dir = _get_ocr_raw_dir("wechat")
_ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_p = os.path.join(debug_dir, f"ocr_debug_wx_{_ts}.png")
_im.save(_p, format="PNG")
logger.info(f"[wechat] 窗口不可见现场截图已保存: {_p}")
except Exception as _e:
logger.debug(f"[wechat] 现场截图保存失败: {_e}")
# 退避期间也要检查急停,避免阻塞5秒
for _ in range(50):
if not self._running:
return []
time.sleep(0.1)
return []
time.sleep(0.4) # 让 DWM 完成窗口激活的合成,防紧接 BitBlt 卡死
rect = win32gui.GetWindowRect(hwnd)
if not rect:
return []
left, top, right, bottom = rect
win_w = right - left
win_h = bottom - top
# WeChat 窗口布局: ~0-6% icon tab栏, ~6-32% 会话列表, ~32-98% 消息面板
# 左边界 30% 跳过会话列表(含通讯录/视频号/游戏中心等快捷入口)— 原34%太右,用户消息被裁切
# 顶部跳过导航栏(微信/通讯录/朋友圈/视频号 tab)
cap_left = int(left + win_w * 0.30)
cap_top = top + max(80, int(win_h * 0.08))
cap_right = right - max(20, int(win_w * 0.02)) # 跳过滚动条+右边框(2%宽度)
cap_bottom = bottom - max(65, int(win_h * 0.07)) # 跳过输入框
logger.debug(f"[wechat] 窗口区域: ({left},{top},{right},{bottom}) 尺寸: {win_w}x{win_h} 裁剪: ({cap_left},{cap_top},{cap_right},{cap_bottom})")
if cap_right <= cap_left or cap_bottom <= cap_top:
logger.warning(f"[wechat] 裁剪区域无效: ({cap_left},{cap_top},{cap_right},{cap_bottom})")
return []
# 检查窗口是否在前台,如果不在则尝试使用 PrintWindow
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
is_foreground = self._check_foreground(root_hwnd)
logger.debug(f"[wechat] 前台检查: is_foreground={is_foreground}, root_hwnd={root_hwnd}")
if not is_foreground:
# 窗口可能被遮挡,使用 PrintWindow 截取
logger.debug("[wechat] 窗口不在前台,尝试 PrintWindow")
im = self._screenshot_window(hwnd, timeout=3.0)
logger.debug(f"[wechat] PrintWindow 结果: {'成功' if im is not None else '失败'}")
if im is not None:
# 裁剪到消息区域
# 计算相对于窗口的裁剪坐标
crop_left = cap_left - left
crop_top = cap_top - top
crop_right = cap_right - left
crop_bottom = cap_bottom - top
# 确保坐标有效
crop_left = max(0, min(crop_left, im.width))
crop_top = max(0, min(crop_top, im.height))
crop_right = max(crop_left + 1, min(crop_right, im.width))
crop_bottom = max(crop_top + 1, min(crop_bottom, im.height))
im = im.crop((crop_left, crop_top, crop_right, crop_bottom))
logger.debug(f"[wechat] PrintWindow 裁剪后: {im.width}x{im.height}")
# PrintWindow 可能返回过小的图像(如空白窗口),回退到屏幕截图
if im.width < 50 or im.height < 50:
logger.warning(f"[wechat] PrintWindow 结果太小({im.width}x{im.height}),回退屏幕截图")
im = None
if im is None:
# PrintWindow 失败或结果太小,回退到屏幕截图
logger.debug("[wechat] 回退到屏幕截图")
im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom))
if im is None:
logger.warning("[wechat] PrintWindow和屏幕截图均失败")
return []
else:
im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom))
if im is None:
logger.warning("[wechat] 前台窗口截图失败")
return []
# ⭐ 立即保存调试截图(在所有检查之前,确保即使失败也有图可查)
_dbg_path = ""
try:
debug_dir = _get_ocr_raw_dir("wechat")
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_dbg_path = os.path.join(debug_dir, f"ocr_debug_wx_{ts}.png")
im.save(_dbg_path, format="PNG")
if os.path.exists(_dbg_path):
logger.info(f"[wechat] 调试截图已保存: {_dbg_path} ({im.width}x{im.height})")
else:
logger.warning(f"[wechat] 调试截图保存后未找到文件: {_dbg_path}")
except Exception as e:
logger.warning(f"[wechat] 调试截图保存失败: {e} (路径: {_dbg_path})")
logger.debug(f"[wechat] 最终截图尺寸: {im.width}x{im.height}")
if im.width < 50 or im.height < 50:
logger.warning(f"[wechat] 截图太小: {im.width}x{im.height}")
return []
# ⭐ 颜色像素检测:如果截图中只有自身绿色气泡,无对方白色消息,跳过
if not self._has_user_colors(im, "wechat"):
# ⚠️ 不设 _ocr_unchanged——像素检测失败≠截图没变化。
logger.debug(f"[wechat] 像素检测: 无对方消息(自身消息已过滤), 跳过OCR")
return []
# ⭐ 截图 hash:像素级指纹——内容没变就直接跳过整轮
# dHash + 5秒强制刷新:即使hash相同,每5秒也强制做一次OCR,防止永久卡死
h = self._image_hash(im)
now = time.time()
last_hash = self._last_screenshot_hash.get("wechat", "")
last_time = self._last_ocr_time.get("wechat", 0)
# ⭐ 仅当上一帧"已捕捉且已入队"才跳过(同 QQ 逻辑,防消息永久丢失)
if h == last_hash and (now - last_time) < 5:
# ⭐ 修复:hash相同跳过仅当上一帧已入队且已回复
# 原逻辑:只要上一帧已入队就跳过,导致消息从未处理过但hash相同时被静默跳过
# 新逻辑:必须确认消息已被回复(_last_hash_replied),才能跳过
if self._last_hash_queued.get("wechat", False) and self._last_hash_replied.get("wechat", False):
self._ocr_unchanged["wechat"] = True
logger.debug(f"[wechat] 截图 hash 相同且上一帧已入队并已回复,屏幕无变化,跳过本轮")
return []
else:
logger.debug(f"[wechat] 截图 hash 相同但上一帧未回复,强制重新 OCR")
self._last_screenshot_hash["wechat"] = h
self._last_ocr_time["wechat"] = now
self._ocr_unchanged.pop("wechat", None)
# 图像预处理(同 QQ)
# ⚠️ 锐化/对比度过高会导致汉字边缘断裂成偏旁部首
im_original = im # 保留原图,bad allocation 时降级
try:
im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS)
im = ImageEnhance.Sharpness(im).enhance(1.2) # 降低锐化,防字符断裂
im = ImageEnhance.Contrast(im).enhance(1.2) # 降低对比度,保持笔画连贯
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
try:
debug_dir = _get_ocr_processed_dir("wechat")
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_processed_path = os.path.join(debug_dir, f"ocr_processed_wx_{ts}.jpg")
# 转换为RGB模式保存JPG(不支持RGBA)
if im.mode == 'RGBA':
im_rgb = im.convert('RGB')
else:
im_rgb = im
im_rgb.save(_processed_path, format="JPEG", quality=95)
# 删除放缓:不在这里立即清理,改由 _maybe_clean_ocr_dir 每 30~60s 随机定时清理
if os.path.exists(_processed_path):
logger.debug(f"[wechat] OCR处理后图片已保存: {_processed_path}")
except Exception as e:
logger.debug(f"[wechat] OCR处理后图片保存失败: {e}")
except Exception as e:
logger.debug(f"[wechat] OCR 预处理失败(跳过): {e}")
# ── 双引擎并发 OCR(Windows OCR + EasyOCR 同时跑,最多等 10 秒)──
raw_text, chosen_engine = self._ocr_dual_engine(im, im_original, "wechat")
if not raw_text:
return []
self._last_chosen_engine["wechat"] = chosen_engine
return self._process_ocr_output(raw_text, "wechat")
except Exception as e:
logger.debug(f"[wechat] OCR失败: {e}")
return []
def _process_ocr_output(self, raw_text: str, platform: str) -> list:
"""OCR 原始文本的通用后处理:垃圾检测 → 时间戳分割 → 过滤清洗。
供 QQ / 微信 OCR 路径共用。
"""
# ⚠️ 急停检查:OCR 文本处理耗时,开始前检查
if not self._running:
return []
# —— WeChat 跨窗口污染检测:OCR 读到 QQ/TIM 调试输出时丢弃整批 ——
if platform == "wechat":
# 注意: WinOCR 输出带空格和全角符号(如"完 成 : 553 chars"),
# EasyOCR 用 ASCII 符号。regex 需兼容两种。
_cross_pollution_markers = [
r'sharpen', # 图片预处理参数
r'contrast\b', # 对比度参数
r'设置焦点', # UIA 调试输出
r'污染.*标记', # 自家 log: "污染(5/7标记)"
r'丢弃.*整批', # 自家 log: "丢弃整批13 items"
r'last=', # 自家 log: "last=\"关闭\""
r'OCR\s*完\s*[成]', # OCR完成 / OCR 完 成 — 自家 log
r'(?:完成|完成)\s*[:\uFF1A]\s*\d+\s+Char', # 完成:553 chars / 完成 : 553 Chars
r'[((]\s*\d+[\s,,\/]*\d+\s*[))]', # (1005,476) / ( 5 / 7 )坐标
r'原始\s*[\])]', # 原始] / 原始 ]
r'后处理\s*[\])]', # 后处理] / 后处理 ]
r'line\s*[\[(]\s*[\d@]\s*[\]))]', # line[0] / line [0] / line(0)
r'reader\s*就绪', # reader就绪状态 / reader 就绪
r'(?:扫描|跳过|丢弃)\s*本\s*轮', # 跳过本轮 / 跳 过 本 轮 — 自家 log
]
_hit = sum(1 for m in _cross_pollution_markers if re.search(m, raw_text, re.IGNORECASE))
# WinOCR 给 CJK 加空格("完 成 : 553 chars")→ raw regex 匹配不到
# 归一化后再查一次,双路覆盖两种引擎格式
if _hit < 2:
norm = self._normalize_ocr_text(raw_text)
_hit = sum(1 for m in _cross_pollution_markers if re.search(m, norm, re.IGNORECASE))
if _hit >= 2:
logger.warning(f"[{platform}] 跨窗口污染({_hit}/{len(_cross_pollution_markers)}标记),丢弃整批")
return []
# —— 垃圾检测 ——
if self._is_ocr_console_garbage(raw_text):
gc = self._ocr_garbage_count.get(platform, 0) + 1
self._ocr_garbage_count[platform] = gc
if gc >= 3:
logger.info(f"[{platform}] OCR: 垃圾连续3次,清缓存")
self._cached_hwnd.pop(platform, None)
self._cached_pid.pop(platform, None)
self._ocr_garbage_count[platform] = 0
return []
self._ocr_garbage_count[platform] = 0
self._ocr_last_text[platform] = raw_text
# —— JSON/YAML 配置块剥离 ——
# 微信 OCR 截屏范围 40%-98%,常把右侧文档/配置面板(JSON/YAML)一起截入
# JSON 块("key": value, {, })和 YAML 块(key: value)跟用户消息混在一起
# 在 split 前先剥离这些行,避免 JSON 碎片污染用户消息
raw_text = self._strip_json_yaml_blocks(raw_text, platform)
# —— ⭐ 混合文本预分割:分段噪声隔离 ——
# Windows OCR 返回整屏文字的大杂烩(bot回复含<br>/图片URL + 用户消息)。
# 若整体 _is_noise_text() 会因 HTML 标签把整段丢弃,用户消息跟着被杀。
# 修复:先按消息边界分割成候选段,对各段单独做噪声检测,
# 只丢弃噪声段(bot回复),保留干净段(用户消息)。
raw_text = self._split_and_denoise_segments(raw_text, platform)
# ⚠️ 急停检查:预分割后立即检查,丢弃半成品
if not self._running:
return []
# —— 可见时间戳新鲜性检查 ——
# 若文本中有可见时间戳(HH:MM),但全部过期(>300s),视为历史消息直接丢弃
# 防止 OCR 混入旧消息内容误触发回复
if not raw_text or not self._ocr_text_has_fresh_timestamp(raw_text, platform):
logger.debug(f"[{platform}] OCR 历史消息(无可识别新鲜时间戳),丢弃: {raw_text[:80]!r}")
return []
# —— 行切割 + 尾标清理 ——
ocr_lines = []
# ⭐ 2026-08-11: 被")昵称 "前缀剥离的行 → 短句检查豁免(真实用户消息)
_ui_prefix_stripped = set()
for l in raw_text.split('\n'):
l = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', l).strip()
# ⭐ Task #1476: 行内图片引用清理。
# QQ 发送图片后 OCR 识别出 !0(img/xxx.png) 格式,混在用户消息文本中。
# 先剥离这些引用,保留用户真实消息部分。
l = re.sub(r'!0?\s*\(?\s*img/[\w./\\-]+\)?', '', l, flags=re.IGNORECASE).strip()
if not l:
continue
# ⭐ Task #1475: 单行 Markdown 跳过。
# 正常用户从不手动打 markdown(# 标题、|表格|、**加粗** 等)。
# 撞上 md 段落标记 → 跳过该行;没有 md 标记 → 正常收集。
# 不做"块延续",下一行立即恢复,避免误杀紧随其后的用户消息。
if self._is_markdown_line(l):
logger.debug(f"[{platform}] OCR: 跳过Markdown行: {l[:60]!r}")
continue
# ⭐ Task #1476: 表情包/贴纸文字碎片过滤。
# QQ 表情包图片上常有文字(如"点击"、"按钮"),OCR 识别后产出
# 短文本+大量分隔符混排(如 "点击, ~按钮; ,"),不是真实用户消息。
# 判定:中文字符≤6 且 分隔符(逗号/分号/波浪号/斜杠等)≥2 → 碎片。
_delim_count = len(re.findall(r'[,;~!@#%^&,。;~、/\\|]', l))
_cjk_in_l = sum(1 for c in l if '\u4e00' <= c <= '\u9fff')
# ⭐ Task #1510: 纯时间戳行保护。EasyOCR 时间戳常用双分号分隔(15;35;38、
# 14;08;03),含两个分隔符会被误判为表情包碎片丢弃 → 新鲜时间戳丢失,
# 导致下方消息继承上一行陈旧时间戳被整体误杀(旧历史消息+新消息同屏时)。
if _cjk_in_l <= 6 and _delim_count >= 2 \
and not re.fullmatch(r'\d{1,2}[:;.]\d{1,2}(?:[:;.]\d{1,2})?', l):
logger.debug(f"[{platform}] OCR: 跳过表情包碎片(分隔符密度高): {l[:60]!r}")
continue
# ⭐ Task #1476: QQ 非消息文本跳过。
# QQ 群聊截图中,OCR 会识别出图片引用、用户头衔/徽章、用户标签等
# 非消息内容,混入消息流后触发残片判定。整行跳过这些 UI 元素。
if self._is_qq_ui_element(l):
logger.debug(f"[{platform}] OCR: 跳过QQ UI元素: {l[:60]!r}")
continue
# ⭐ 规则4放行的 ")昵称 内容" 格式(2026-08-11):_is_qq_ui_element 规则4
# 判定带空格的 ")youpaishidifu 关于我们" 为疑似用户消息放行,但 ) 前缀
# 是 OCR 把 @/冒号误读的结果,会挡在 _strip_qq_nickname 裸昵称剥离前面。
# 这里剥掉 ")昵称+空白" 前缀,恢复纯消息文本交给后续昵称剥离/查重链路。
# ⚠ 剥离后的短文本(如"关于我们"4字)会被末尾"短句碎片(≤8字无问词)"规则
# 误杀,必须记入 _ui_prefix_stripped 在短句检查处豁免——规则4放行贯穿到底。
_m4strip = re.match(r'^\)([\w\u4e00-\u9fff]{2,})\s+', l)
if _m4strip:
l = l[_m4strip.end():]
_ui_prefix_stripped.add(l)
logger.debug(f"[{platform}] OCR: 剥离)昵称前缀: {l[:60]!r} (短句豁免)")
ocr_lines.append(l)
# —— 连续行合并(修复 EasyOCR 逗号断行) ——
# EasyOCR 对长消息在中文逗号","处拆行,导致前半句无时间戳被丢弃。
# 合并规则:前一行不以句末标点结尾 且 后一行不以时间戳开头 → 合并
# ⚠ 修复漏消息:前一行若本身是"时间戳行"(如 16;28:40),绝不与其后的消息合并,
# 否则时间戳被黏进消息文本、re.split 错位,导致该消息被误判为尾部落入旧时间戳、
# 进而被"继承时间戳过老"丢弃(漏掉用户刚发的新消息)。
# ⭐ Task #1510: _SENT_END 补 ASCII `?`。OCR 对问句常输出半角 `?`(非全角 `?`),
# 导致「游戏专用PHP有哪两种登录模式?」这类问句行不与下一行合并,
# 防止多条用户消息被 `,` 黏成一行、共享同一陈旧继承时间戳被整体误杀。
_SENT_END = re.compile(r'[。!??…~))」』\]]$')
_TS_START = re.compile(r'^(?:\d{1,2}[.;:]\d{2}|(?:昨天|前天|大前天|今天|明天|星期[一二三四五六日天]|\d+天前)\s*\d)')
merged_lines = []
for rl in ocr_lines:
if merged_lines and not _SENT_END.search(merged_lines[-1]) \
and not _TS_START.match(rl) and not _TS_START.match(merged_lines[-1]):
merged_lines[-1] = merged_lines[-1] + ',' + rl
else:
merged_lines.append(rl)
if len(merged_lines) != len(ocr_lines):
logger.info(f"[ocr] 行合并: {len(ocr_lines)}行→{len(merged_lines)}行")
ocr_lines = merged_lines
engine_label = self._last_chosen_engine.get(platform, "")
for i, rl in enumerate(ocr_lines):
logger.info(f"[ocr] [{engine_label}/后处理] line[{i}]: {rl[:120]!r}")
# —— 时间戳分割(跨行继承) ——
expanded = []
_inherited_ts = None # 跨行继承:上一行的时间戳,供当前行无时间戳时使用
self._inherited_ts_from_past = False # 跨行继承:上一行时间戳是否来自过去日期前缀
# ⭐ QQ系统消息模式:跳过这些消息的时间戳继承,防止旧时间戳覆盖真实用户消息
_QQ_SYSTEM_MSG_RE = re.compile(
r'(?:邀请.*加入了|加入了本群|加入了群聊|退出了群聊|被管理员移出群|'
r'撤回了一条消息|修改了群名称|成为了管理员|取消了管理员|被禁言|被解除禁言|全体禁言|解除全体禁言)'
)
for i, rl in enumerate(ocr_lines):
# ⚠️ 急停检查:stage splitting 逐行处理,每行都检查,保证急停响应 ≤1行处理时间
if not self._running:
return []
before = rl
rl = self._normalize_ocr_text(rl)
logger.debug(f"[ocr] stage[{i}] normalize: len={len(before)} -> {rl[:120]!r}")
rl = rl.replace('&', '')
rl = rl.replace(':', ':')
logger.debug(f"[ocr] stage[{i}] clean: len={len(rl)} -> {rl[:120]!r}")
rl = re.sub(r'\s+', ' ', rl)
# ⭐ 检测是否为QQ系统消息:如果是,跳过时间戳继承,防止旧时间戳污染后续真实消息
_is_qq_system_msg = bool(_QQ_SYSTEM_MSG_RE.search(rl))
if _is_qq_system_msg:
logger.info(f"[ocr] stage[{i}] 检测到QQ系统消息,跳过时间戳继承: {rl[:60]!r}")
# 移除中文日期前缀(昨天、前天、星期X等),只保留数字时间
# ⭐ 检测是否含有"昨天"等过去日期前缀:若有,后续时间戳年龄检查跳过未来时间拦截
_had_past_date_prefix = bool(re.search(r'(昨天|前天|大前天|\d+天前)', rl))
rl = re.sub(r'(昨天|前天|大前天|星期[一二三四五六日天]|\d+天前|今天|明天)\s*(?=\d{1,2}[::.;]\d{1,2})', '', rl)
# 标准化 EasyOCR 时间戳:16.47 → 16:47, 15;39 → 15:39, 16:03.41 → 16:03:41
# ⭐ Task #1507: 防 URL 片段误转时间戳。QQ 转发消息的链接残片常被 EasyOCR
# 读成 0.50;57 / 12.12.40xx 这类片段——混合分隔符(0.50;57)或后跟字母数字
# /URL字符的都不是时间戳,不转换。否则会被当"凌晨 0:50:57 旧时间戳"误杀
# 相邻的真实新消息(过滤1与stage继承双重误判)。
# 后顾 ?<![;.0-9A-Za-z%]:匹配起点前不能在URL字符后,防止 0.50;57 中
# 的子串 50;57 被单独转换(50:57 会被当合法时间戳)。
rl = re.sub(
r'(?<![;.0-9A-Za-z%])(\d{1,2})([.;])(\d{2})(?:\2(\d{2}))?(?![;.0-9A-Za-z%])',
lambda m: f'{m.group(1)}:{m.group(3)}:{m.group(4)}' if m.group(4) else f'{m.group(1)}:{m.group(3)}',
rl)
# ⭐ 规整「逐字空格」时间戳:OCR 把 16:28:40 拆成 1 6 : 2 8,否则 re.split 找不到时间戳 → 漏消息
rl = self._normalize_timestamps(rl)
parts = re.split(r'(\d{1,2}:\d{1,2}(?::\d{1,2})?)', rl)
logger.debug(f"[ocr] stage[{i}] split -> {len(parts)} parts: "
f"{[p[:60] for p in parts if p.strip()]}")
buf = ''
# ⭐ QQ系统消息不继承时间戳,也不更新继承时间戳,防止旧时间戳污染
last_ts = None if _is_qq_system_msg else _inherited_ts
# ⭐ 继承的过去日期标记:若前一阶段有"昨天"等过去前缀,继承的时间戳也跳过未来检查
_inherited_past = _had_past_date_prefix or getattr(self, '_inherited_ts_from_past', False)
# ⭐ Task #1510: QQ 时间戳渲染在消息【下方】。消息行通常无自己的时间戳,
# 时间戳出现在其后续行。若本行或下一行含新鲜时间戳(≤300s),则本行消息
# 属于新鲜消息组 → 不应因继承上一行的陈旧时间戳而被误杀。
# 典型场景:旧历史消息(12:12:40)+新消息(15:35:38)同屏被OCR整屏捕捉,
# 新消息继承 12:12:40 被"尾部时间戳较老"整体丢弃。
_stage_fresh_hint = (
self._line_has_fresh_ts(rl, _inherited_past)
or (i + 1 < len(ocr_lines) and self._line_has_fresh_ts(ocr_lines[i + 1], _inherited_past))
)
for part in parts:
if re.match(r'^\d{1,2}:\d{1,2}', part):
if buf.strip():
if not last_ts:
# ⭐ 无前置时间戳:位于首个时间戳之前的消息。
# ⚠ 不再整段丢弃:可能是被裁切/漏识别时间戳的新消息,
# 交由下游 DB查重(避免连续聊天时时间戳被覆盖误杀用户消息)。
logger.info(f"[ocr] stage[{i}] [filter/放行] 无前置时间戳,交由DB查重: {buf.strip()[:60]!r}")
elif self._is_timestamp_old(last_ts, 300, skip_future_check=_inherited_past):
# ⭐ Task #1456: 继承时间戳>5min,直接丢弃,不再进行DB去重检查
# 时间戳已经表明这些是旧消息,即使它们从未被回复过,也不应该被处理
# 修复:移除DB去重检查,直接丢弃
if buf.strip() and _stage_fresh_hint:
# ⭐ Task #1510: 继承时间戳较老但本/下行含新鲜时间戳 → 新鲜组
logger.info(f"[ocr] stage[{i}] [age] 继承时间戳{last_ts}较老但本/下行含新鲜时间戳,按新鲜处理: {buf.strip()[:60]!r}")
elif buf.strip():
logger.info(f"[ocr] stage[{i}] [age] DROP 继承时间戳{last_ts}较老(>300s),直接丢弃: {buf.strip()[:60]!r}")
buf = ''
last_ts = part
continue
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
# ⭐ 2026-08-11 先剥 md 尾巴:md 在末尾→切尾巴留用户提问;
# md 大头/混中间→None 整段丢弃,防 md 尾巴入库撞关键词
md_stripped = self._strip_markdown_tail(buf.strip())
if md_stripped is None:
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃: {buf.strip()[:80]!r}")
elif md_stripped != buf.strip():
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴: {buf.strip()[:60]!r} -> {md_stripped!r}")
if md_stripped and self._is_markdown_document(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过: {md_stripped[:80]!r}")
elif md_stripped and self._is_noise_text(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过: {md_stripped[:80]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', md_stripped or '')
if msg:
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if msg:
# ⭐ 先剥离 bot 旧回复片段:OCR 把「用户新消息 + bot 旧回复」
# 拼成一行时,filter4 会误判整段为残片丢弃,导致用户新消息
# 永久丢失(bot 回复一次后再也不回复)。剥离后保留用户提问。
msg = self._strip_bot_reply_fragments(msg)
if msg:
# ⭐ 剥离QQ用户昵称,防止昵称拼凑进消息文本导致查库关键词被压住
msg = self._strip_qq_nickname(msg)
# ⭐ Task #1508 纵深防御:任何剥离后都不允许空串进查库/回复
if not msg:
logger.debug(f"[ocr] stage[{i}] 剥离后为空,丢弃防空消息: {part[:40]!r}")
elif self._is_reply_fragment(msg):
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}")
else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 通过所有过滤器,添加到expanded: {msg[:60]!r}")
expanded.append(msg)
buf = ''
last_ts = part
_inherited_past = False # 新时间戳重置过去日期标记
else:
buf += part
if buf.strip():
# 尾部检查:尾部消息位于最后一个可见时间戳之后,无独立时间戳。
# 关键修复:过滤器层(md/噪声/回复残片)必须【始终运行】,无论继承时间戳新旧,
# 之前错误地嵌在"时间戳较老"分支内,导致新鲜尾部消息(如用户刚发的提问)
# 被静默吞噬、永不进入 expanded。
# 仅当继承时间戳较老(>5min)且无新鲜特征且非提问豁免时才丢弃(Task #1440 的5分钟窗口),
# 其余一律保留交给过滤器层,绝不静默吞噬。
if not last_ts:
# 尾部无任何时间戳(整行无时间戳):交给下游DB查重,不整段丢弃
# 避免连续聊天时时间戳被覆盖误杀用户消息
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部无时间戳,交由DB查重: {buf.strip()[:60]!r}")
else:
# ⭐ Task #1456: 继承时间戳>5min,直接丢弃,不再进行DB去重检查
# 时间戳已经表明这些是旧消息,即使它们从未被回复过,也不应该被处理
# 修复:移除DB去重检查,直接丢弃
if self._is_timestamp_old(last_ts, 300, skip_future_check=_inherited_past):
if buf.strip() and _stage_fresh_hint:
# ⭐ Task #1510: 尾部继承时间戳较老但本/下行含新鲜时间戳 → 新鲜组,
# 不丢弃(QQ 时间戳在消息下方,见 stage 开头注释)
logger.info(f"[ocr] stage[{i}] [age] 尾部时间戳{last_ts}较老但本/下行含新鲜时间戳,按新鲜处理: {buf.strip()[:60]!r}")
elif buf.strip():
logger.info(f"[ocr] stage[{i}] [age] DROP 尾部时间戳{last_ts}较老(>300s),直接丢弃: {buf.strip()[:60]!r}")
buf = ''
# else: 时间戳新鲜(<5min),直接保留
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
if buf.strip():
md_stripped = self._strip_markdown_tail(buf.strip())
if md_stripped is None:
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃(尾部): {buf.strip()[:80]!r}")
elif md_stripped != buf.strip():
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴(尾部): {buf.strip()[:60]!r} -> {md_stripped!r}")
if md_stripped and self._is_markdown_document(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过(尾部): {md_stripped[:80]!r}")
elif md_stripped and self._is_noise_text(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过(尾部): {md_stripped[:80]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', md_stripped or '')
if msg:
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if msg:
# ⭐ 同上:尾部消息也要先剥离 bot 旧回复片段,避免整段误判为残片
msg = self._strip_bot_reply_fragments(msg)
if msg:
# ⭐ 剥离QQ用户昵称,防止昵称拼凑进消息文本导致查库关键词被压住
msg = self._strip_qq_nickname(msg)
# ⭐ Task #1508 纵深防御:任何剥离后都不允许空串进查库/回复
if not msg:
logger.debug(f"[ocr] stage[{i}] 尾部剥离后为空,丢弃防空消息: {part[:40]!r}")
elif self._is_reply_fragment(msg):
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过(尾部): {msg[:60]!r}")
else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部消息通过所有过滤器,添加到expanded: {msg[:60]!r}")
# EasyOCR 常把横向相邻的两条消息(用户消息+群昵称/UI文字)
# 检测为同一行,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一"
# 这种合并文本。尝试在中文句尾标点处拆分为多条。
split_msgs = self._split_merged_ocr_text(msg)
for sm in split_msgs:
expanded.append(sm)
# ⭐ QQ系统消息不更新继承时间戳,防止旧时间戳污染后续真实消息
if not _is_qq_system_msg:
# 更新跨行继承时间戳
if last_ts:
_inherited_ts = last_ts
# ⭐ 若本阶段有过去日期前缀,继承的时间戳也标记为来自过去日期
self._inherited_ts_from_past = _had_past_date_prefix
else:
self._inherited_ts_from_past = False
else:
logger.debug(f"[ocr] stage[{i}] QQ系统消息不更新继承时间戳: {rl[:60]!r}")
# —— 逐行过滤 ——
# ⚠️ 急停检查:stage splitting 完成后,逐行过滤前再查一次
if not self._running:
return []
ocr_lines = expanded
texts = []
for line in ocr_lines:
if not line:
continue
if line in ("查看更多", "收起", "选择会话开始聊天"):
continue
if re.match(r'^[✿♡♥❤★☆✦✧❀❁❂❃❄❅❆❇❈❉❊❋➠➡➢➣➤.·•●○◦◎◉◍◐◑◒◓◔◕◖◗◢◣◤◥☺☻☼♠♣♦♤♧♡♢♔♕♖♗♘♙🕐-🕧\s]{1,3}$', line):
continue
line = re.sub(r'^0+', '', line)
line = re.sub(r'[勖彐"\'\s\u2000-\u206f]+$', '', line)
# 末尾裸字母:OCR checkbox [V] 碎成 "? O"、" O"、" V"
line = re.sub(r'\s+[OV]\s*$', '', line)
if not line:
continue
if re.match(r'^问题\d+\s*[::]', line):
continue
if re.match(r'^[\d:\s]+$', line):
continue
line = re.sub(r'^\d+[\..]\d+s?\s*\d*[.\.]?\d*', '', line)
line = re.sub(r'^[\d.\.\s]{2,20}[一-鿿]{1,5}?[,。、]', '', line)
if not line:
continue
if self._is_system_message(line):
continue
if re.search(r'群成员|索群成员|搜索成员', line):
logger.debug(f"[{platform}] OCR 过滤: 群成员侧栏 line={line[:60]!r}")
continue
if re.match(r'^[^)】〕」]{2,40}[(【〔「][^)】〕」]{1,15}[)】〕」]', line):
logger.debug(f"[{platform}] OCR 过滤: 群名头栏 line={line[:60]!r}")
continue
if re.match(r'^[一-鿿]{1}$', line):
continue
# OCR 碎片:≤8 字符且无中文 → 时间戳/checkbox 残片
if len(line) <= 8 and not re.search(r'[\u4e00-\u9fff]', line):
continue
# OCR 随机字符垃圾:无中文 + 特殊字符比例高(如 `544UUJ1W H V=YJ~JL:e`)
# EasyOCR 对头像/表情/特殊 UI 区域会输出这类无意义字母数字符号组合
if not re.search(r'[\u4e00-\u9fff]', line) and len(line) >= 10:
special_chars = sum(1 for c in line if c in '=~@#$%^&*_+{}[]|\\<>/`')
alpha_count = sum(1 for c in line if c.isalpha())
digit_count = sum(1 for c in line if c.isdigit())
if special_chars / len(line) > 0.15 and alpha_count >= 2 and digit_count >= 2:
logger.debug(f"[{platform}] OCR 过滤: 随机字符垃圾 line={line!r}")
continue
# checkbox 残片:`[V]` `[X]` `[√]` 开头,明显不是用户提问
if re.match(r'^\[[VX√✗]\s*\](?:\s*[A-Za-z0-9_\-./]+)?\s*$', line):
logger.debug(f"[{platform}] OCR 过滤: checkbox残片 line={line!r}")
continue
# 多 checkbox 碎片:一行含 2+ 个 [V] 标记,md 列表 OCR 残留
if len(re.findall(r'\[\s*[VXvx√✗]\s*\]', line)) >= 2:
logger.debug(f"[{platform}] OCR 过滤: 多checkbox碎片 line={line!r}")
continue
# 全角括号 md 链接碎片:`[text](url` 或 `](` — md 链接 OCR 全角退化
if re.search(r'\](', line):
logger.debug(f"[{platform}] OCR 过滤: 全角括号碎片 line={line!r}")
continue
if re.match(r'^\d{1,2}[::\s]\d{1,2}', line):
continue
if re.match(r'^\d{4}[/-]\d{1,2}', line):
continue
# ===== 主动防御: UI 子串黑名单 (全平台) =====
# 含特定 UI 面板词的碎片直接丢弃,不依赖精确匹配
if any(sub in line for sub in _UI_SUBSTR_BLOCKLIST):
logger.debug(f"[{platform}] OCR 过滤: UI子串 line={line!r}")
continue
# ===== 主动防御: UI 按钮/标签黑名单 (精确匹配, 全平台) =====
if line in _UI_EXACT_BLOCKLIST:
logger.debug(f"[{platform}] OCR 过滤: UI黑名单 line={line!r}")
continue
# ===== 主动防御: 系统状态计数 (全平台) =====
if re.search(r'(?:浏览|阅读|查看)\d+[个条款篇张次]', line):
logger.debug(f"[{platform}] OCR 过滤: 浏览状态 line={line!r}")
continue
# ===== 主动防御: 多段数据面板 (全平台) =====
segs = [s.strip() for s in re.split(r'[。;]', line) if s.strip()]
if len(segs) >= 3:
digit_segs = sum(1 for s in segs if re.search(r'\d', s))
short_segs = sum(1 for s in segs if len(s) < 30)
if digit_segs >= 2 and short_segs >= 3:
logger.debug(f"[{platform}] OCR 过滤: 数据面板 line={line!r}")
continue
# ===== log/log排版残余: OCR 截到自身调试日志的尾巴 (全平台) =====
# "] line[0]" "line[o]:" — 引擎日志行号残留
if re.search(r'\]\s*line\s*\[\d', line, re.IGNORECASE):
logger.debug(f"[{platform}] OCR 过滤: log行号 line={line!r}")
continue
# "Pkdown" — "markdown" 的 OCR 乱码碎片
if re.search(r'[Pp]kdown', line):
logger.debug(f"[{platform}] OCR 过滤: Pkdown碎片 line={line!r}")
continue
# "fragment skipped" — log 文本直接裸露
if 'fragment skipped' in line.lower():
logger.debug(f"[{platform}] OCR 过滤: fragment-skipped line={line!r}")
continue
# 引号前缀 OCR 残余: "' '玩家..." "'注册用户总数' ,"
if re.match(r"^'+[\s,)]", line) or re.match(r"^'+[^']+'+\s*,", line):
logger.debug(f"[{platform}] OCR 过滤: 引号残余 line={line!r}")
continue
# 引号后缀 OCR 残余: "注册用户总数' ,"(中文后跟引号逗号)
if re.search(r"[\u4e00-\u9fff]'\s*,?\s*$", line):
logger.debug(f"[{platform}] OCR 过滤: 引号尾缀 line={line!r}")
continue
# URL-only: 无中文且含 http/https → OCR 面板图片URL碎片
# OCR 常把 https://img.ypshidifu.cn 识别为 httpsLlimgypshidifu(丢失 ://)
if not re.search(r'[\u4e00-\u9fff]', line) and re.search(r'https?\S', line, re.IGNORECASE):
logger.debug(f"[{platform}] OCR 过滤: URL-only碎片 line={line!r}")
continue
# —— 纯非中文乱码:QQ/微信中文群消息都至少有中文 ——
# URL 被 OCR 啃成 hpsllimgypshidifu cnli/...(丢失 https://)时 URL-only 漏网
# 排除游戏指令前缀 / 以免误杀 /register password 等合法消息
if len(line) > 10 and not re.search(r'[\u4e00-\u9fff]', line) and not line.startswith('/'):
logger.debug(f"[{platform}] OCR 过滤: 纯非中文乱码 line={line!r}")
continue
# —— CJK 比例过低乱码:EasyOCR 把 URL/代码认成罕见汉字混一堆字母 ——
# 如 "刈甘珂呋; .怂U止X旋XLeJ(ILUS.//OITIUU" — 含中文但中文极少
# 用户正常消息的 CJK 比例通常 > 30%
if len(line) > 15 and re.search(r'[A-Za-z]', line):
cjk_count = sum(1 for c in line if '\u4e00' <= c <= '\u9fff')
if cjk_count > 0 and cjk_count / max(len(line), 1) < 0.15:
logger.debug(f"[{platform}] OCR 过滤: CJK比例过低乱码 line={line!r}")
continue
# —— 游戏UI / 非聊天文本乱码(CJK+英文混合) ——
# QQ/微信 OCR 在非聊天区域(服务器列表/物品栏/群公告)产出
# CJK+英文混合乱码,特征:下划线英文("Ierite_spear")、CJK后跟大写英文("玩家Ierite")
has_cjk = bool(re.search(r'[\u4e00-\u9fff]', line))
has_alpha = bool(re.search(r'[A-Za-z]', line))
if has_cjk and has_alpha and len(line) > 10:
# 下划线英文 — 正常中文聊天极少包含("Ierite_spear" 游戏ID/代码残片)
if re.search(r'[a-zA-Z]+_[a-zA-Z]+', line):
logger.debug(f"[{platform}] OCR 过滤: 游戏UI乱码(下划线英文) line={line!r}")
continue
# CJK+大写英文组合("玩家Ierite" "比武Mtp")— 游戏UI公告/服务器列表特有
# 但正常句子也可能包含英文名词(如"Minecraft"),需区分:
# 若中文字符数 >= 英文字母数,视为正常句子不过滤
if re.search(r'[\u4e00-\u9fff][A-Z][a-z]{1,}', line):
cjk_count = len(re.findall(r'[\u4e00-\u9fff]', line))
alpha_count = len(re.findall(r'[A-Za-z]', line))
if alpha_count > 0 and cjk_count < alpha_count:
logger.debug(f"[{platform}] OCR 过滤: 游戏UI乱码(CJK+大写英文) line={line!r}")
continue
# —— 绿色气泡 OCR 乱码:| 管道符混中文 ——
# bot 的绿色气泡/蓝色气泡文字经过 OCR 后常把 :; 识别为 |,
# 正常中文聊天从不混用 | 与中文。如 "GU |进行配置" "SMTP | 设置"
if '|' in line and re.search(r'[\u4e00-\u9fff]', line) and len(line) > 5:
logger.debug(f"[{platform}] OCR 过滤: 管道符碎片(绿色气泡) line={line!r}")
continue
# —— 游戏指令列表碎片:重复 /cmd 模式 ——
# OCR 截到命令帮助文本如 "/sdfl主指令 /sdfl reload /sdfl update"
# 正常用户消息极少含 ≥2 个独立 /command
slash_cmds = re.findall(r'(?:^|\s)/(\w{2,})', line)
if len(slash_cmds) >= 2:
logger.debug(f"[{platform}] OCR 过滤: 指令列表碎片(多/cmd={slash_cmds}) line={line!r}")
continue
# —— 斜线英文命令碎片:"/sdfl undo撤销导入" "/register密码" ——
# 首字符是 / 且紧跟 ASCII 字母 → 命令帮助/参考碎片,非用户普通对话
# 不拦截 "/兑换码怎么用"(斜线后是中文)
if re.match(r'^/[A-Za-z]', line):
logger.debug(f"[{platform}] OCR 过滤: 斜线英文命令 line={line!r}")
continue
# 消息中段含 /英文命令(如"怎么办?/printer玩家名")→ 指令碎片拼接
if re.search(r'(?<=[\s??。!,,])/[A-Za-z]{2,}', line):
logger.debug(f"[{platform}] OCR 过滤: 斜线命令碎片(中段) line={line!r}")
continue
# —— OCR 误读斜线为 I/J:"/sdfl"→"Isdfl", "/import"→"Jimport" ——
# 特征1:含已知命令关键词(sdfl/sdf1),零误杀
if re.match(r'^[A-Za-z]', line) and ('sdfl' in line.lower() or 'sdf1' in line.lower()):
logger.debug(f"[{platform}] OCR 过滤: 变形命令(sdfl) line={line!r}")
continue
# 特征2:大写字母+4+小写 + CJK + >10 字符 → 非自然对话
if re.match(r'^[A-Z][a-z]{4,}', line) and re.search(r'[\u4e00-\u9fff]', line) and len(line) > 10:
logger.debug(f"[{platform}] OCR 过滤: 变形命令(大写+字母) line={line!r}")
continue
# —— Bot自身回复过滤:pipe 表格格式(|参数1|参数2|参数3|结果|) ——
# OCR 截屏可能截到旧的 bot 回复,pipe 表格不应进入 KB 匹配
pipe_count = line.count('|')
if pipe_count >= 3 and len(line) > 30:
logger.debug(f"[{platform}] OCR 过滤: bot表格(pipe={pipe_count}) line={line!r}")
continue
# —— Bot回复内容过滤:英文key=value配置格式 ——
# OCR截屏截到bot旧回复中的配置内容,如 "invite referral bonus=5afii"
# 特征:英文key + = + 值,正常中文用户对话不含这种格式
if re.search(r'[a-zA-Z_][\w]*\s*=\s*\S', line) and not re.search(r'[\u4e00-\u9fff]', line):
logger.debug(f"[{platform}] OCR 过滤: bot配置(key=value) line={line!r}")
continue
# —— Bot回复内容过滤:配置文件描述片段 ——
# OCR截屏截到bot旧回复中的配置说明,如 "配置此处的文件,负责展示..."
# 这些是KB中的配置说明文本,不是用户消息
# ⚠️ 要求同时包含配置短语 + 英文关键词 + 配置格式(=或:),避免误过滤用户讨论配置的消息
_config_phrases = ('配置此处的文件', '负责展示', '对应一个商品', '使用md文件格式',
'配置文件', '该配置', '此配置', '参数配置', '配置项')
_config_keywords = ('invite', 'referral', 'reward', 'bonus')
has_config_phrase = any(p in line for p in _config_phrases)
has_config_keyword = re.search(r'invite|referral|reward|bonus', line, re.IGNORECASE)
has_config_format = re.search(r'[=:]\s*\S', line) # 包含 = 或 : 后跟非空白字符
if has_config_phrase and has_config_keyword and has_config_format:
logger.debug(f"[{platform}] OCR 过滤: bot配置说明(三重匹配) line={line!r}")
continue
# —— KB内容回读过滤:bot旧回复中的KB段落标题/内容 ——
# OCR截屏截到bot之前回复的KB内容,如"奖励:邀请人可获得该新用户首充金额的30%"
# 特征:包含"奖励:"、"规则:"、"配置:"等KB段落标记 + 内容描述
_kb_markers = ('奖励:', '规则:', '配置:', '说明:', '要求:', '限制:', '条件:', '流程:',
'奖励:', '规则:', '配置:', '说明:', '要求:', '限制:', '条件:', '流程:')
if any(m in line for m in _kb_markers):
logger.debug(f"[{platform}] OCR 过滤: KB内容回读 line={line!r}")
continue
# —— Bot回复内容过滤:百分比+金额描述 ——
# OCR截屏截到bot旧回复中的奖励金额描述,如"首充金额的30 作"
# 正常用户对话不含这种精确百分比+金额的描述
if re.search(r'\d+\s*%', line) and re.search(r'(金额|奖励|积分|返利|分成)', line):
logger.debug(f"[{platform}] OCR 过滤: 奖励金额描述 line={line!r}")
continue
# —— JSON/YAML 碎片兜底:OCR 截屏混入配置面板 ——
# 行首是 "key": value 或 key: value(YAML)→ 配置面板碎片
if re.match(r'^["\']?[a-zA-Z_][\w]*["\']?\s*:\s*["\d\[\{tfn]', line):
logger.debug(f"[{platform}] OCR 过滤: JSON行 {line!r}")
continue
if re.match(r'^[a-zA-Z_][\w]*\s*:\s*["\w\d]', line) and \
not re.match(r'^(https?|ftp)://', line) and \
not re.match(r'^[a-zA-Z_][\w]*\s*:\s*/', line):
logger.debug(f"[{platform}] OCR 过滤: YAML行 {line!r}")
continue
# —— md 尾巴剥离:末尾 markdown 内容切掉,保留用户原始提问 ——
# ⭐ 2026-08-11:OCR 把 bot md 回复残片拼在用户消息尾部
# (如 "群管理员...(头衔UI)...# #来测试# # @C418minecraf")。
# md 在末尾→切小尾巴;md 大头/混中间→None 整段丢弃。
# 作用:入库的只有用户原始提问,md 尾巴不撞知识库关键词。
md_stripped = MessageMonitor._strip_markdown_tail(line)
if md_stripped is None:
logger.debug(f"[{platform}] OCR 过滤: md大头/混中间整段丢弃 line={line!r}")
continue
if md_stripped != line:
logger.debug(f"[{platform}] OCR 过滤: 剥离md尾巴 line={line!r} -> {md_stripped!r}")
line = md_stripped
# —— 末尾 #标题 碎片兜底 ——
# OCR 时间戳分割可能遗漏尾部 " #测试" " #草" 等 md 标题碎片
# 注:\s+ 要求空格在前,避免误杀 "C#语言" "中文#标签"
if re.search(r'#[\u4e00-\u9fff]', line):
cleaned = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', line).strip()
if cleaned != line:
if not cleaned or MessageMonitor._is_markdown_document(cleaned):
logger.debug(f"[{platform}] OCR 过滤: 末尾#标题碎片 line={line!r}")
continue
line = cleaned
# 如果 line 本身以 # 开头(纯 md),前面的 _is_markdown_document 应该已捕获
# 但此处再补一刀
if MessageMonitor._is_markdown_document(line):
logger.debug(f"[{platform}] OCR 过滤: md纯标题碎片 line={line!r}")
continue
# —— OCR 幻觉乱码检测:连续重复字符(LLL / 111 / ===) ——
# EasyOCR 对头像/特殊 UI 输出如 ":山LeLyeeuCL...ggsegU凶>债券管理"
# 普通消息不含连续 5+ 相同字符
if re.search(r'([A-Za-z0-9])\1{4,}', line):
logger.debug(f"[{platform}] OCR 过滤: 幻觉乱码(重复) line={line!r}")
continue
# —— OCR 乱码检测:CJK+拉丁混合乱码(如"默i"、"鼍") ——
# 正常中文消息不会出现单个拉丁字母夹在CJK之间
if re.search(r'[\u4e00-\u9fff][a-zA-Z][\u4e00-\u9fff]', line):
logger.debug(f"[{platform}] OCR 过滤: CJK+拉丁混合乱码 line={line!r}")
continue
# 生僻字/罕见字检测:CJK扩展区字符(U+3400-U+4DBF, U+20000-U+2A6DF)
if re.search(r'[\u3400-\u4dbf\U00020000-\U0002a6df]', line):
logger.debug(f"[{platform}] OCR 过滤: 生僻字/罕见字 line={line!r}")
continue
# —— 英文配置格式+乱码过滤:key=value 或 key: value 后跟乱码 ——
# 如 "invite referral bonus=5a激<5"、"config=file.txt乱码"
if re.search(r'[a-zA-Z_]{2,}\s*[=:]\s*\S+', line):
# 检查是否包含乱码特征:特殊字符、数字字母混合、非正常英文单词
乱码特征 = False
# 特征1:等号或冒号后包含特殊字符(<>!@#$%^&*等)
if re.search(r'[=:]\s*[a-zA-Z0-9]*[<>!@#$%^&*()\[\]{}|\\/:;\'",.?~`]+', line):
乱码特征 = True
# 特征2:等号或冒号后包含数字字母混合但无空格(如"5a激<5")
if re.search(r'[=:]\s*[a-zA-Z0-9]{3,}[^\s,.;:!?。,;:!?\u4e00-\u9fff]', line):
乱码特征 = True
if 乱码特征:
logger.debug(f"[{platform}] OCR 过滤: 英文配置+乱码 line={line!r}")
continue
# —— 系统配置内容过滤:Wiki/文档页面内容 ——
if re.search(r'概览|数据看板|在线玩家|注册用户|活跃玩家|玩家数据', line):
logger.debug(f"[{platform}] OCR 过滤: 系统配置内容(Wiki) line={line!r}")
continue
# —— 微信专属:气泡上下文验证 ——
# 微信截图可能混入 Windows 弹窗/通知/非聊天内容
# 真实用户消息通常含时间戳(14:30格式) 或 冒号分隔符(用户名:消息)
# 纯粹的碎片文本(无时间戳、无冒号、无标点结尾)→ 高概率是非消息内容
if platform == "wechat":
has_timestamp = bool(re.search(r'\d{1,2}:\d{2}', line))
has_colon = bool(re.search(r'[::]', line))
has_punct_end = bool(re.search(r'[。!?!?]$', line))
# 短句(≤8字)含中文 → 可能是合法短消息(如"好的"、"嗯"),不拦截
if len(line) <= 8 and re.search(r'[\u4e00-\u9fff]', line):
pass # 放行短中文消息
# 无时间戳 + 无冒号 + 无标点结尾 → 高概率是非消息碎片(弹窗/通知/UI)
elif not has_timestamp and not has_colon and not has_punct_end:
# 再放宽:含问词(怎么办/为什么/能不能)或感叹词 → 可能是真实消息
if not re.search(r'怎么办|为什么|能不能|怎么|如何|什么|可以|帮忙|谢谢|好的|嗯|收到|OK|ok', line):
logger.debug(f"[{platform}] OCR 过滤: 微信非消息碎片(无上下文) line={line!r}")
continue
# —— 回复内容片段检测:OCR 捕获 bot 自己刚发的回复 ——
# ⭐ 2026-08-11 修复:阈值对齐 filter4(_is_reply_fragment) 的"大部分才算"标准,
# 原来 size>=6 无条件命中即丢——历史回复含"youpaishidifu"(@提及旧回复)时,
# 用户消息"youpaishidifu关于我们..."与之共享13字符公共子串(占比仅45%),
# filter4 正确放行后这里又被宽松阈值抓回丢弃。
# 现在要求:公共子串≥15,或(≥8 且 占较短文本≥60%),与 filter4 一致。
if self._replied_texts and len(line) > 8:
import difflib
is_match = False
# 第一轮:原始文本匹配
for r in self._replied_texts:
if len(r) < 5:
continue
sm = difflib.SequenceMatcher(None, line, r)
lm = sm.find_longest_match(0, len(line), 0, len(r)).size
if lm >= 15 or (lm >= 8 and lm >= 0.6 * min(len(line), len(r))):
is_match = True
break
# 第二轮:CJK-only 降阈值匹配(OCR 碎片字母被啃掉时)
if not is_match:
cjk_line = ''.join(c for c in line if '\u4e00' <= c <= '\u9fff')
if len(cjk_line) >= 5:
for r in self._replied_texts:
r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff')
if len(r_cjk) < 5:
continue
sm = difflib.SequenceMatcher(None, cjk_line, r_cjk)
clm = sm.find_longest_match(0, len(cjk_line), 0, len(r_cjk)).size
if clm >= 15 or (clm >= 8 and clm >= 0.6 * min(len(cjk_line), len(r_cjk))):
is_match = True
break
if is_match:
# ⭐ 问句豁免(2026-08-11):短问句与 bot 旧回复共享长短语时,60%标准误杀。
# "登录插件下载地址是啥啊"(10字) vs bot回复"登录插件下载地址: https://..."
# 共享"登录插件下载地址"8字符占80%→被判回复片段。但 line 是问句且不是
# 任何 replied 的完全子串 → 用户提问,放行。真回声 line 整段≈r 仍拦。
_ask_words = ('怎么办', '为什么', '能不能', '怎么', '如何', '什么', '可以',
'帮忙', '是啥', '是啥啊', '在哪', '哪个', '哪里', '多少', '几', '吗', '呢')
_is_ask = any(w in line for w in _ask_words) or line.rstrip().endswith(('?', '?'))
_is_full_sub = any(line in r for r in self._replied_texts)
if _is_ask and not _is_full_sub:
logger.debug(f"[{platform}] OCR 过滤: 疑似回复片段但为问句且非完全子串,视为用户提问放行 line={line[:60]!r}")
else:
logger.debug(f"[{platform}] OCR 过滤: 回复内容片段 line={line[:60]!r}")
continue
# ⭐ Task #1441: bot自身消息查库过滤
# 检查OCR读到的内容是否与数据库中bot回复的消息重合率≥90%
# 如果是,说明这是bot自己回复的消息,不是用户消息,应丢弃
if len(line) > 10 and self._msg_history:
try:
import difflib
# 查询数据库中最近的bot回复(限制20条)
recent_bot_replies = self._msg_history.get_recent_bot_replies(platform, limit=20)
for bot_reply in recent_bot_replies:
if len(bot_reply) < 10:
continue
# 计算重合率
sm = difflib.SequenceMatcher(None, line, bot_reply)
ratio = sm.ratio()
if ratio >= 0.7:
logger.debug(f"[{platform}] OCR 过滤: bot自身消息(查库重合率{ratio:.2f}) line={line[:60]!r}")
continue
except Exception as e:
logger.debug(f"[{platform}] OCR 查库过滤异常: {e}")
# ⭐ Task #1515: bot回复菜单模式过滤
# OCR 把屏幕上的 bot 回复菜单(下载选项/checkbox 列表)重新识别为"新消息"。
# 典型特征:含版本标签 `(稳定版)` `(最新开发版)` `(官方版)` `(测试版)` 等,
# 或含 checkbox 标记 `[√]` `[⚠]` `[✗]`。正常用户聊天从不写这些。
_BOT_MENU_LABELS = re.compile(
r'\((?:稳定版|最新开发版|官方版|测试版|开发版|正式版|体验版|内测版|公测版|'
r'推荐|默认|可选|必选|已选|未选|免费|付费|专业版|企业版|个人版)\)'
)
if _BOT_MENU_LABELS.search(line):
logger.debug(f"[{platform}] OCR 过滤: bot回复菜单(版本标签) line={line!r}")
continue
# checkbox 标记:[ √ ] [ ⚠ ] [ ✗ ] [ X ] [ √] 等(OCR可能漏空格/括号)
if re.search(r'[\[【]\s*[√⚠✗×XvV✓✔]\s*[\]】]', line, re.IGNORECASE):
logger.debug(f"[{platform}] OCR 过滤: bot回复菜单(checkbox标记) line={line!r}")
continue
# 纯 checkbox 行:OCR 把 "[ √ ]" 读成 "√ " 或 "√"(无括号)
if re.match(r'^[√⚠✗×]{1,2}\s*$', line):
logger.debug(f"[{platform}] OCR 过滤: 纯checkbox符号 line={line!r}")
continue
# bot 回复中的下载链接格式:[官方下载](url) [bbsmc分流](url) 等
if re.search(r'\[.+\]\(https?://.+\)', line):
logger.debug(f"[{platform}] OCR 过滤: bot回复(md链接) line={line!r}")
continue
# === 全面 md 语法兜底:正常用户不写 md,只可能是 bot 回复 ===
# ** 加粗标记(用户聊天从不打 **)
if '**' in line:
logger.debug(f"[{platform}] OCR 过滤: 加粗语法 line={line!r}")
continue
# 行首 # 标题(未在前面的 md 检测中被捕获的)
if re.match(r'^#{1,6}(?:\s|\d|\[|[\u4e00-\u9fff])', line):
logger.debug(f"[{platform}] OCR 过滤: 行首标题 line={line!r}")
continue
# 编号列表:1. 2) 3、 开头
if re.match(r'^\d{1,3}[\.\)、]\s', line):
logger.debug(f"[{platform}] OCR 过滤: 编号列表 line={line!r}")
continue
# 全角 md 链接残片:]( 或 [text](url
if re.search(r'\](', line) or re.search(r'\[.+?\](', line):
logger.debug(f"[{platform}] OCR 过滤: 全角链接残片 line={line!r}")
continue
# —— * 开头 md 残片:OCR 吞了另一半,如 *如有错误。请您 ——
# 正常用户在 QQ/微信聊天从不以 * 开头
if line.startswith('*'):
logger.debug(f"[{platform}] OCR 过滤: *开头残片 line={line!r}")
continue
# —— *...* 配对残片:OCR 把 **使用** 啃成 *x但不衢...x* ——
# 任何 * 包裹内容(不论内容如何)都是 md 语法残余
if re.search(r'\*[^*]+\*', line):
logger.debug(f"[{platform}] OCR 过滤: *配对残片 line={line!r}")
continue
# —— 结构化数据面板:QQ/TIM 指令面板(指令:/rtp 权限类型:玩家 图标:标题:)——
# 3+ 个 中文短词:值 结构,用户聊天从不会连续3+个冒号键值对
colon_kv = re.findall(r'[\u4e00-\u9fff]{1,6}[::](?:[A-Za-z0-9/]|[\u4e00-\u9fff])', line)
if len(colon_kv) >= 3:
logger.debug(f"[{platform}] OCR 过滤: 结构化面板 line={line!r}")
continue
# —— OCR 偏旁碎片过滤:包含偏旁字符就丢弃 ——
# 偏旁字符(U+2E80-U+2FDF)在正常中文聊天中不会出现
# OCR 拆字碎片如"亻牛""彡"只要出现就是碎片
if any(_is_radical_char(ch) for ch in line):
logger.debug(f"[{platform}] OCR 过滤: 包含偏旁字符(丢弃) line={line!r}")
continue
# —— 系统配置内容过滤:SMTP/邮件地址/代码片段不是用户消息 ——
if re.search(r'@example|smtp|\.py\b|\.bat\b|\.sh\b|//.*\.\w{2,4}\b', line, re.IGNORECASE):
logger.debug(f"[{platform}] OCR 过滤: 系统配置内容 line={line!r}")
continue
if re.search(r'@\w+\.\w{2,4}[,.\s]', line) and re.search(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', line):
logger.debug(f"[{platform}] OCR 过滤: 邮件地址格式 line={line!r}")
continue
# —— OCR 短句/病句检测:OCR 把消息"嚼碎"生成碎片 ——
# 通用疑问词(有这些词说明有查询意向,放行)
_q_words = {"什么", "怎么", "如何", "为什么", "吗", "呢", "吧", "能", "可以", "有没有", "哪些", "哪", "哪几个",
"是不是", "会不会", "能不能", "怎么办", "好不好", "行不行"}
# "自行修?" → 3字无查询意向碎片;"将其修正后重三" → 病句
if len(line) <= 8:
# ≤8字短句:必须包含疑问词才放行,否则是OCR碎片
# 有问号但无疑问词 → 病句(如"自行修?"无查询意向)
has_q = any(w in line for w in _q_words)
# ⭐ 2026-08-11: 规则4剥离")昵称 "前缀的行(如"关于我们")是真实用户消息,
# 豁免短句检查——否则规则4放行+前缀剥离全白做,消息仍被此规则丢弃。
# ⭐ 2026-08-12: 包含括号的消息(如"端0(基岩版)")可能是有效短消息,放行
if not has_q and line not in _ui_prefix_stripped and not re.search(r'[()()]', line):
logger.debug(f"[{platform}] OCR 过滤: 短句碎片(≤8字无问词) line={line!r}")
continue
# ★ 以特殊符号(~、`等)开头 → OCR把"一键"识别为"~键"等碎片
if re.match(r'^[~`~]', line):
logger.debug(f"[{platform}] OCR 过滤: 特殊符号开头碎片 line={line!r}")
continue
if 5 < len(line) < 50:
# 病句1:句子以动词/助词结尾但缺少宾语且无标点
if re.search(r'[后以了过]+.{0,3}$', line) and not re.search(r'[。!?,,;;::?]$', line):
logger.debug(f"[{platform}] OCR 过滤: 病句(动词结尾) line={line!r}")
continue
# 病句2:句子以"三"结尾(OCR误读"试"为"三")
if re.search(r'重三$', line):
logger.debug(f"[{platform}] OCR 过滤: 病句(重三) line={line!r}")
continue
# 病句3:句子缺少主语且无标点(如"将其修正后重三" "认管理员密码多少")
# OCR常把"默认"识别丢掉→"认管理员密码多少"、把"帮"识别丢掉→"查xx"
if re.match(r'^[其将把被认查问帮试想]', line) and not re.search(r'[。!?,,;;::?]$', line):
logger.debug(f"[{platform}] OCR 过滤: 病句(缺主语) line={line!r}")
continue
# 病句4:OCR 把用户消息和系统提示拼接的复合碎片
# 如"自行修?# 本插件" "请自行下载源码修改...使用类json"
if '#' in line or '源码' in line and '修改' in line:
# 含文档标记或系统提示特征
if re.search(r'#\s*本|请自行|源码.*修改|修改.*源码', line):
logger.debug(f"[{platform}] OCR 过滤: 病句(系统提示拼接) line={line!r}")
continue
# 病句5:句子含"修"但不是完整查询(如"自行修?" "帮我修" "能不能修")
# 除非有明确疑问词,否则"修"碎片不应触发查库
if re.search(r'修[??。]?\s*$', line) and not any(w in line for w in _q_words):
logger.debug(f"[{platform}] OCR 过滤: 病句(修字碎片) line={line!r}")
continue
# 病句6:句子包含时间戳格式(如"16:29" "2026/7/29")
# OCR 常把时间戳和用户消息拼接,如"认管理员密码多少?在嘲多改?"
if re.search(r'\d{1,2}:\d{1,2}(?::\d{1,2})?|\d{4}[/-]\d{1,2}[/-]\d{1,2}', line):
logger.debug(f"[{platform}] OCR 过滤: 病句(含时间戳) line={line!r}")
continue
# 病句7:句子以数字开头且包含问号(如"1屏蔽?不想要的后缀。")
# 可能是 OCR 把消息编号和内容拼接
if re.match(r'^\d+', line) and re.search(r'[??]', line):
logger.debug(f"[{platform}] OCR 过滤: 病句(数字开头+问号) line={line!r}")
continue
# 病句8:句子包含引号包裹的时间戳(如'"2026/7/29"')
if re.search(r'["""][^"""]*\d{4}[/-]\d{1,2}[/-]\d{1,2}[^"""]*["""]', line):
logger.debug(f"[{platform}] OCR 过滤: 病句(引号包裹时间戳) line={line!r}")
continue
# 病句9:纯问句碎片(如"怎么办?" "好不好?")无实质内容
# 只有疑问词+标点,或疑问词+少量无意义字符
_pure_q = re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是不是|有没有|是什么|怎么样|在哪里)\s*[??。!]*\s*$', line)
if _pure_q:
logger.debug(f"[{platform}] OCR 过滤: 病句(纯问句碎片) line={line!r}")
continue
# 病句10:问句+乱码碎片拼接(如"怎么办?/printer玩家名")
# 问词开头 + 非自然字符(斜线/英文/数字乱拼)
if re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)', line):
# 检查问词后面是否有乱码特征
rest = re.sub(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)\s*[??。!]*\s*', '', line)
if rest and (re.search(r'/[A-Za-z]{2,}', rest) or re.search(r'[a-zA-Z]{3,}', rest) or len(rest) < 3):
logger.debug(f"[{platform}] OCR 过滤: 病句(问句+乱码拼接) line={line!r}")
continue
texts.append(line)
logger.debug(f"[{platform}] OCR -> {len(ocr_lines)} raw, {len(texts)} filtered, "
f"last=\"{texts[-1][:50] if texts else ''}\"")
return texts
def _read_via_clipboard_once(self, platform: str) -> list:
"""剪贴板法单次执行(安全键盘导航:Enter + Ctrl+End + Ctrl+A/C,无 Esc)。
去重由 _poll 的 _replied_texts + _seen_messages 负责,本函数只过滤并返回。
回复回环防御由 _paste_and_send 写入 _replied_texts 承担。
"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd or not win32gui.IsWindow(hwnd):
return []
import win32clipboard as wc
old = None
try:
wc.OpenClipboard()
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
old = wc.GetClipboardData(wc.CF_UNICODETEXT)
wc.CloseClipboard()
except Exception:
pass
texts = []
raw_lines = [] # 预初始化,防止 content 为空时 UnboundLocalError
try:
# 确保窗口在前台(失败则返回空,避免发送到错误窗口)
# QQ 剪贴板优先路径需要更可靠的前台保证(与发送路径 retries 一致)
if not self._ensure_foreground(hwnd, max_retries=5):
return []
# PostMessage 点击消息区域(设焦点,不全局,不比鼠标移动)
rect = win32gui.GetWindowRect(hwnd)
if rect:
left, top, right, bottom = rect
w = right - left
h = bottom - top
# QQ NT 消息面板在 ~55-95% 宽度区域
click_x = int(w * 0.72) # 消息面板中部
click_y = int(h * 0.60) # 消息面板中部偏下
lparam = win32api.MAKELONG(click_x, click_y)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.2)
# 滚动到底部(PostMessage 安全,不切焦点,确保新消息可见)
self._force_scroll_bottom(platform)
time.sleep(0.2)
# TIM/QQ NT: 安全键盘导航(无 Esc,不关窗口)
# 每步前校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台
VK_CONTROL = 0x11
variant = self._app_variant.get(platform, "")
if variant in ("tim", "qq_nt"):
VK_RETURN = 0x0D
VK_END = 0x23
# Enter 激活当前对话
if self._fg_is_target(hwnd):
ctypes.windll.user32.keybd_event(VK_RETURN, 0, 0, 0)
time.sleep(0.05)
ctypes.windll.user32.keybd_event(VK_RETURN, 0, 2, 0)
time.sleep(0.3)
# Ctrl+End 滚动到底部(双保险:PostMessage + keybd_event)
if self._fg_is_target(hwnd):
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
ctypes.windll.user32.keybd_event(VK_END, 0, 0, 0)
time.sleep(0.05)
ctypes.windll.user32.keybd_event(VK_END, 0, 2, 0)
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
time.sleep(0.3)
# Ctrl+A (全选) — 仅当目标窗口仍在焦点
# 先检查发送锁:主线程可能正在 _paste_and_send,避免 Ctrl+C 覆盖其剪贴板
if self._sending_event.is_set():
logger.debug(f"[{platform}] 发送中,剪贴板读取跳过防竞争")
return []
VK_A = 0x41
if self._fg_is_target(hwnd):
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
ctypes.windll.user32.keybd_event(VK_A, 0, 0, 0)
time.sleep(0.05)
ctypes.windll.user32.keybd_event(VK_A, 0, 2, 0)
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
time.sleep(0.15)
# Ctrl+C (复制)
VK_C = 0x43
if self._fg_is_target(hwnd):
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
ctypes.windll.user32.keybd_event(VK_C, 0, 0, 0)
time.sleep(0.05)
ctypes.windll.user32.keybd_event(VK_C, 0, 2, 0)
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
time.sleep(0.3)
wc.OpenClipboard()
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
content = wc.GetClipboardData(wc.CF_UNICODETEXT)
if content:
raw_lines = [l.strip() for l in content.strip().split('\n') if l.strip()]
self._raw_clipboard[platform] = content
wc.CloseClipboard()
logger.debug(f"[{platform}] 剪贴板读取 → {len(raw_lines)} lines")
# ⭐ 剪贴板 hash:内容没变化 → 跳过(防止跨平台读到过时数据)
import hashlib
cur_h = hashlib.md5(content.encode('utf-8') if content else b'').hexdigest()
last_h = self._last_clipboard_hash.get(platform, '')
if cur_h == last_h:
logger.debug(f"[{platform}] 剪贴板内容未变,跳过 stale 数据")
return []
self._last_clipboard_hash[platform] = cur_h
# ── 过滤:只返回"像用户消息"的行 ──
sender_pattern = re.compile(r'^[\u4e00-\u9fff·A-Za-z0-9_\-]{1,10}$')
for line in raw_lines:
# 清理 QQ 消息末尾的状态标记(如 "O~" "C~" "0~O~"),避免污染 KB 匹配
line = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', line).strip()
if not line:
continue
# 跳过:系统消息、纯时间、纯数字、无意义短行
if self._is_system_message(line):
continue
# 跳过:控制台/Python 垃圾(剪贴板截错窗口内容)
if self._is_ocr_console_garbage(line):
continue
# 跳过:单行发送者名(1-10汉字/字母/数字/下划线)
if sender_pattern.match(line):
continue
# 跳过:纯时间格式(连字符/斜杠分格日期)
if re.match(r'^\d{1,2}[::\s]\d{2}', line):
continue
if re.match(r'^\d{4}[/-]\d{1,2}', line):
continue
# 跳过:纯 emoji / 纯标点(短于 6 个字符且只有符号)
clean = re.sub('[~!@#$%^&*()_+\\-=\\[\\]{};:\\\'",.<>/?·!@#¥%……&*()—+。,、;:""\'\'【】{}「」『』《》【】 \\t\\n\\r\\u2000-\\u206f\\u2190-\\u2bff\\U0001F000-\\U0001FFFF]', '', line)
if len(clean) == 0 and len(line) <= 6:
continue
# 跳过:游戏UI乱码(CJK+下划线英文、CJK+大写英文组合)
if re.search(r'[\u4e00-\u9fff]', line) and re.search(r'[A-Za-z]', line) and len(line) > 10:
if re.search(r'[a-zA-Z]+_[a-zA-Z]+', line):
logger.debug(f"[{platform}] 剪贴板过滤: 游戏UI乱码(下划线英文) line={line!r}")
continue
if re.search(r'[\u4e00-\u9fff][A-Z][a-z]{1,}', line):
cjk_count = len(re.findall(r'[\u4e00-\u9fff]', line))
alpha_count = len(re.findall(r'[A-Za-z]', line))
if alpha_count > 0 and cjk_count < alpha_count:
logger.debug(f"[{platform}] 剪贴板过滤: 游戏UI乱码(CJK+大写英文) line={line!r}")
continue
# 通过全部检查 → 保留
texts.append(line)
logger.debug(f"[{platform}] 剪贴板过滤后 → {len(texts)} 条用户消息")
except Exception as e:
logger.debug(f"[{platform}] 剪贴板失败: {e}")
finally:
if old is not None:
try:
wc.OpenClipboard()
wc.EmptyClipboard()
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
wc.CloseClipboard()
except Exception:
pass
return texts
def get_raw_clipboard(self, platform: str) -> str:
"""获取最近一次剪贴板读取的原始文本。"""
return self._raw_clipboard.get(platform, "")
@staticmethod
def _parse_last_sender(raw_text: str) -> Optional[str]:
"""从剪贴板原始文本中提取最后一条消息的发送者。
TIM/QQ 剪贴板格式通常是:
[发送者]
[时间]
[消息]
(空行)
[发送者]
[时间]
[消息]
"""
if not raw_text:
return None
blocks = re.split(r'\n\s*\n', raw_text.strip())
if not blocks:
return None
last_block = blocks[-1].strip()
if not last_block:
return None
lines = [l.strip() for l in last_block.split('\n') if l.strip()]
if not lines:
return None
candidate = lines[0]
# 排除时间格式
if re.match(r'^\d{1,2}[::]\d{2}', candidate):
return None
if re.match(r'^\d{4}[/-]\d{1,2}', candidate):
return None
if len(candidate) > 30:
return None
return candidate
def _search_contact(self, platform: str, contact: str) -> bool:
"""在 TIM/QQ 搜索框中搜索联系人并切换到对应会话。"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
return False
try:
win = auto.ControlFromHandle(hwnd)
search = win.EditControl(Name="搜索", maxSearchSeconds=0.5)
if not search.Exists(maxSearchSeconds=0.3):
logger.warning(f"[{platform}] 找不到搜索框")
return False
# 恢复窗口并前置(失败则放弃搜索)
if win32gui.IsIconic(hwnd):
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
if not self._ensure_foreground(hwnd):
logger.warning(f"[{platform}] 无法前置窗口,放弃搜索联系人")
return False
time.sleep(0.2)
# SetFocus 到搜索框(不移动鼠标)
search.SetFocus()
time.sleep(0.2)
# 全选已有内容并输入联系人名
auto.SendKeys('{Ctrl}a')
time.sleep(0.1)
auto.SendKeys(contact)
time.sleep(0.5)
# Enter 打开第一个结果
auto.SendKeys('{Enter}')
time.sleep(0.5)
logger.info(f"[{platform}] 已搜索并切换到联系人: {contact}")
return True
except Exception as e:
logger.error(f"[{platform}] 搜索联系人失败: {e}")
return False
# ── SendInput 键盘模拟 ─────────────────────────────────
@staticmethod
def _key_down(vk: int):
"""SendInput KEYBDINPUT keydown."""
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
@staticmethod
def _key_up(vk: int):
"""SendInput KEYBDINPUT keyup (KEYEVENTF_KEYUP=2)."""
ctypes.windll.user32.keybd_event(vk, 0, 2, 0)
@staticmethod
def _send_key_press(vk: int, delay: float = 0.05):
"""SendInput single key press (down + up)."""
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
time.sleep(delay)
ctypes.windll.user32.keybd_event(vk, 0, 2, 0)
@staticmethod
def _send_ctrl_combo(vk: int):
"""Send Ctrl+<key>: Ctrl down → key down → key up → Ctrl up."""
VK_CONTROL = 0x11
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
ctypes.windll.user32.keybd_event(vk, 0, 2, 0)
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
@staticmethod
def _post_key(hwnd, vk, delay=0.05):
"""PostMessage 键盘到指定窗口(不全局,安全)。"""
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, vk, 0)
time.sleep(delay)
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, vk, 0)
@staticmethod
def _post_ctrl_combo(hwnd, vk, delay=0.05):
"""PostMessage Ctrl+组合键到指定窗口(不泄露到控制台)。"""
VK_CONTROL = 0x11
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, VK_CONTROL, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, vk, 0)
time.sleep(delay)
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, vk, 0)
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, VK_CONTROL, 0)
def _fg_is_target(self, hwnd) -> bool:
"""校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台。"""
try:
fg = win32gui.GetForegroundWindow()
root = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
return fg == root or (fg and win32gui.IsChild(root, fg))
except Exception:
return False
@staticmethod
def _image_hash(im) -> str:
"""感知哈希:缩至 9x8 灰阶 → dHash(差值哈希,对局部变化敏感)。
dHash 比较每行相邻像素的梯度,比 aHash(均值哈希)更能捕捉文字变化。
9 列 × 8 行 = 72 位 hash,比 8×8=64 位 aHash 信息量更大。
"""
small = im.convert('L').resize((9, 8), Image.LANCZOS)
pixels = list(small.getdata())
# 每行 9 个像素,比较相邻像素:左 > 右 → 1,否则 → 0
bits = []
for row in range(8):
for col in range(8):
left = pixels[row * 9 + col]
right = pixels[row * 9 + col + 1]
bits.append('1' if left > right else '0')
return ''.join(bits)
@staticmethod
def _is_white_bubble_pixel(px, x, y, w, h) -> bool:
"""判定 (x,y) 的纯白像素是否属于"白色气泡"(对方消息),而非:
- 聊天浅灰背景(本身不是 >=245 候选)
- 彩色(绿/蓝)自身气泡上的白色文字(邻域为彩色底色)
规则:四方向(±4px)邻域中 >=2 个也为 near-white(>=240) 才算气泡。
⭐ 放宽条件:>=3→>=2,>=245→>=240,适配新版微信UI颜色变化
白字笔画的邻域多为彩色气泡底色 → 不通过 → 不被误判为对方消息。
"""
d = 4
hits = 0
for dx, dy in ((d, 0), (-d, 0), (0, d), (0, -d)):
nx, ny = x + dx, y + dy
if 0 <= nx < w and 0 <= ny < h:
try:
rr, gg, bb = px[nx, ny][:3]
if rr >= 240 and gg >= 240 and bb >= 240:
hits += 1
except Exception:
pass
return hits >= 1
@staticmethod
def _has_user_colors(im, platform: str) -> bool:
"""区域颜色检测:判断截图中是否有**对方发来的消息**,决定是否需要OCR。
⭐ 核心原则:自身消息 = 最顶层门禁,不过则全部取消。
- QQ: 用户白色气泡左对齐,bot/对方蓝色气泡右对齐
- WeChat: 对方白色气泡左对齐,自己绿色气泡右对齐
返回 True=有对方消息需要OCR,False=仅自身消息/无消息 → 跳过
"""
w, h = im.size
if w < 10 or h < 10:
return True
# 跳过顶部 20%(群名/大标题栏)
check = im.crop((0, int(h * 0.20), w, h))
cw, ch = check.size
if cw < 20 or ch < 20:
return True
# 左右半区
left_half = check.crop((0, 0, cw // 2, ch))
right_half = check.crop((cw // 2, 0, cw, ch))
step_x = max(2, cw // 30) # 增加采样密度,提高白色气泡检测灵敏度
step_y = max(2, ch // 30)
# 详细调试日志:记录截图信息和采样参数
rlogger = logging.getLogger("msgbot.monitor")
rlogger.debug(f"[{platform}] 像素检测开始: 原始尺寸={w}x{h}, 检测区域={cw}x{ch}, 采样步长=({step_x},{step_y})")
if platform == "qq":
# QQ: 蓝色 = bot/对方气泡(右对齐)
def is_bot(r, g, b):
return 110 <= r <= 190 and 170 <= g <= 235 and 220 <= b <= 255
else:
# WeChat: 绿色 = 自己发的消息(右对齐)— 不是 bot 色
def is_self(r, g, b):
return 135 <= r <= 180 and 205 <= g <= 250 and 75 <= b <= 125
is_bot = is_self
def analyze(img):
p = img.load()
bot = other = white = total = 0
iw, ih = img.width, img.height
for y in range(0, ih, step_y):
for x in range(0, iw, step_x):
total += 1
try:
r, g, b = p[x, y][:3]
except:
continue
if r >= 230 and g >= 230 and b >= 230:
# ⚠️ 纯白像素可能是:①对方白色气泡(背景/白字) ②聊天浅灰背景
# ③绿色/蓝色自身气泡上的白色文字。
# ②③ 不能算"对方消息"——否则微信自身绿气泡白字被误判为对方
# → OCR → 自回复循环。必须确认邻域也为大面积白色(气泡)才计入。
if MessageMonitor._is_white_bubble_pixel(p, x, y, iw, ih):
white += 1
elif is_bot(r, g, b):
bot += 1
else:
other += 1
return bot, other, white, total
left_bot, left_other, left_white, left_total = analyze(left_half)
right_bot, right_other, right_white, right_total = analyze(right_half)
# 详细调试日志:记录像素统计结果
rlogger.debug(f"[{platform}] 像素统计: 左半区(其他={left_other}/{left_total}, "
f"{'自身' if platform=='wechat' else 'bot'}={left_bot}, 白色={left_white})")
rlogger.debug(f"[{platform}] 像素统计: 右半区(其他={right_other}/{right_total}, "
f"{'自身' if platform=='wechat' else 'bot'}={right_bot}, 白色={right_white})")
# ====== 微信专用逻辑 ======
# 颜色语义:
# 白色(>225,>225,>225) = 对方消息(左对齐)
# 绿色(135-180,205-250,75-125) = 自己消息(右对齐)
# 其它 = 背景/UI元素
if platform == "wechat":
# ⭐ 自身消息门禁:右大量绿色(自身) + 左无白色(无对方) → 仅自身,跳过
if right_bot >= 3 and left_white < 2:
rlogger.debug(f"[wechat] 像素检测: 仅自身消息(右绿={right_bot},左白={left_white}), 跳过")
return False
# 左半区有白色气泡(>=2) → 有对方消息,必须OCR
if left_white >= 2:
rlogger.debug(f"[wechat] 像素检测: 有对方消息(左白={left_white}), 继续")
return True
# ⭐ 放宽兜底:左半区有1个白色像素也尝试OCR,防新版UI检测遗漏
# (旧逻辑:0个才跳过,可能误杀新UI浅色气泡)
if left_white == 1:
rlogger.debug(f"[wechat] 像素检测: 左白=1(边缘), 尝试OCR")
return True
# 左半区无白色气泡 → 无对方消息,跳过
# (包括:仅自身消息、空聊天、非聊天界面)
rlogger.debug(f"[wechat] 像素检测: 无对方白色气泡(左白={left_white}), 跳过")
return False
# ====== QQ/TIM 专用逻辑 ======
# 颜色语义:
# 白色(>225,>225,>225) = 用户消息(左对齐)
# 蓝色(110-190,170-235,220-255) = bot/对方消息(右对齐)
# 其它 = 背景/UI元素
# ⭐ 核心原则:只要左半区无白色气泡 → 无用户消息 → 跳过
# ⭐ 跨窗口污染检测:QQ窗口无绿色气泡,若检测到大量绿色像素,说明截到了微信窗口
def is_wechat_green(r, g, b):
return 135 <= r <= 180 and 205 <= g <= 250 and 75 <= b <= 125
left_green = right_green = 0
for y in range(0, ch, step_y):
for x in range(0, cw // 2, step_x):
try:
r, g, b = check.load()[x, y][:3]
if is_wechat_green(r, g, b):
left_green += 1
except:
continue
for y in range(0, ch, step_y):
for x in range(cw // 2, cw, step_x):
try:
r, g, b = check.load()[x, y][:3]
if is_wechat_green(r, g, b):
right_green += 1
except:
continue
total_green = left_green + right_green
# 详细调试日志:记录绿色像素检测结果
rlogger.debug(f"[{platform}] 绿色像素检测: 左绿={left_green}, 右绿={right_green}, 总绿={total_green}")
if total_green >= 3:
rlogger.debug(f"[qq] 像素检测: 检测到微信绿色像素(绿={total_green}), 判定为微信窗口, 跳过")
return False
# 左半区有白色气泡 → 有用户消息,必须OCR
if left_white >= 1:
rlogger.debug(f"[qq] 像素检测: 有用户消息(左白={left_white}), 继续")
return True
# 左半区无白色气泡 → 无用户消息,跳过
# (包括:仅bot回复、空聊天、非聊天界面)
rlogger.debug(f"[qq] 像素检测: 无用户白色气泡(左白={left_white}), 跳过")
return False
@staticmethod
def _split_merged_ocr_text(text: str) -> list:
"""尝试将 OCR 单行合并文本在中文句尾标点处拆分。
EasyOCR 常把横向相邻的两条消息(用户问句 + 群昵称/UI文字/其他人消息)
检测为同一行文本,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一"式合并。
启发式: 在[。?!?]后紧跟CJK字符的位置拆分,前提是两侧都有足够中文。
不拆分续接句(第二段以"但/而/并/且/或/还/也/又/就/才/再/变"开头)。
"""
if len(text) < 10:
return [text]
_continuations = {'但', '而', '并', '且', '或', '还', '也', '又',
'就', '才', '再', '变', '便', '则', '却', '虽',
'因', '为', '与', '和', '同', '跟'}
result = []
remaining = text
max_splits = 2 # 最多拆 2 次(3 段),防过度拆分
while max_splits > 0:
# 找[。?!?]后紧跟CJK的位置
m = re.search(r'([。?!?])([\u4e00-\u9fff])', remaining)
if not m:
break
split_pos = m.start() + 1 # 标点后
first = remaining[:split_pos]
second = remaining[split_pos:]
# 两侧都需有足够 CJK
first_cjk = sum(1 for c in first if '\u4e00' <= c <= '\u9fff')
second_cjk = sum(1 for c in second if '\u4e00' <= c <= '\u9fff')
if first_cjk < 3 or second_cjk < 3:
break
# 第二段不以续接词开头
if second[0] in _continuations:
break
# 第二段太短(少于 3 CJK)→ 不拆
first_part = first
second_part = second
result.append(first_part)
remaining = second_part
max_splits -= 1
result.append(remaining)
if len(result) > 1:
logger.debug(f"[ocr] 句尾标点拆分: {[r[:60] for r in result]}")
return result
def _norm_frag(self, s: str) -> str:
"""bot 回复片段比对用的归一化:去空格、去中英文标点,抵抗 OCR 噪声。"""
s = re.sub(r'\s+', '', s)
for a in (',', '。', '、', ';', ':', '!', '?', ',', '.', ';', ':', '!', '?',
' ', ' ', '~', '~'):
s = s.replace(a, '')
return s
def _strip_bot_reply_fragments(self, text: str) -> str:
"""从 OCR 识别出的单条消息中剥离 bot 自己的旧回复片段,保留用户真实提问。
与 _poll 中的「反向包含剥离」逻辑保持一致,但此处用于在 _process_ocr_output
的 filter4(回复残片)判定【之前】清理消息,避免「用户新消息 + bot 旧回复」
被 OCR 拼成一行后,filter4 因检测到 bot 旧回复文本而把整段(含用户新消息)
误判为残片整体丢弃。
两种策略:
1. 精确子串:某条 bot 旧回复 r 完整出现在 text 中 → 直接删除 r
2. 反向包含:OCR 把「用户新消息 + bot 旧回复」按逗号拼成一行,且 r 不是
text 的子串,但 text 中逗号分隔的片段可能是 r 的子串 → 逐片段判断,
剥离属于 bot 回复的片段,保留用户片段。
⭐ OCR 噪声鲁棒性:bot 旧回复被 OCR 重新识别时,标点(,。)、全/半角、空格
常与原文不一致(如原文"Minecraft 生存服务器,拥有完善的领地系统"被识别成
"Minecraft生存服务器。拥有完善的领地系统"),导致精确子串匹配失效。
比较前对双方做归一化(去空格、去标点),让被 OCR 扭曲的 bot 回复片段也能被识别并剥离。
"""
if not text or not text.strip():
return text
replied = self._replied_texts
if not replied:
return text
ts = text
if len(ts) > 5:
stripped_bot = False
# 策略1:精确子串(归一化后比对,抵抗 OCR 标点/空格噪声)
for r in replied:
if len(r) >= 6 and self._norm_frag(r) and self._norm_frag(r) in self._norm_frag(ts):
ts = re.sub(re.escape(r), ' ', ts)
stripped_bot = True
# 策略2:反向包含剥离——逗号分段后逐段判断是否属于 bot 回复
_has_comma = ',' in ts or (',' in ts and re.search(r'[\u4e00-\u9fff],[\u4e00-\u9fff]', ts))
if not stripped_bot and _has_comma:
parts = re.split(r'[,,]', ts)
parts = [p.strip() for p in parts if p.strip()]
if len(parts) >= 2:
user_parts = []
for part in parts:
if len(part) < 2:
continue
is_bot_frag = False
npart = self._norm_frag(part)
if npart:
for r in replied:
nr = self._norm_frag(r)
if len(nr) >= 2 and npart in nr:
is_bot_frag = True
break
if not is_bot_frag:
user_parts.append(part)
if len(user_parts) < len(parts):
ts = ','.join(user_parts).strip(',').strip()
stripped_bot = True
if stripped_bot:
ts = re.sub(r'\s+', ' ', ts).strip().strip(',').strip()
return ts
def _strip_qq_nickname(self, text: str) -> str:
"""从消息文本开头剥离QQ用户昵称,防止昵称拼凑进消息文本导致查库关键词被压住。
QQ群聊截图中,OCR会把用户昵称和消息文本拼在一起,如:
"草原探险服务器备用号游戏专用在哪下载登陆插件"
其中"草原探险服务器备用号"和"游戏专用"是用户昵称,"在哪下载登陆插件"是实际消息。
策略(循环剥离,直到无可剥离):
1. 箭头前缀("//"或"=>")
2. QQ群头衔格式(【头衔】用户名 或 [头衔] 用户名)
3. @提及格式(@username 或 @昵称)
4. 裸昵称列表
"""
if not text or not text.strip():
return text
ts = text.strip()
logger.debug(f"[qq] _strip_qq_nickname输入: {ts[:80]!r}")
# 加载配置中的昵称列表
try:
from config import load_config
cfg = load_config()
nicknames = cfg.get('qq_nicknames', [])
except Exception:
nicknames = []
if not nicknames:
nicknames = [
"草原探险服务器备用号",
"游戏专用",
]
qq_title_pattern = re.compile(r'^[\[【][^】\]]+[】\]]\s*(?:[A-Za-z0-9_\u4e00-\u9fff]{2,30})[,,]?\s*')
_rank_pattern = re.compile(r'^[((][^))]+[))]\s*')
# ⭐ Task #1508: 前导垃圾不再吞噬句末标点 !!?? —— OCR 剥离@昵称后残留的
# "?" 是正文的一部分,吞掉会整条消息变空(见"游戏专用PHP有哪两种登录模式?"变'')。
# 只清空格/逗号/句号/冒号/分号等纯分隔符。
_leading_junk = re.compile(r'^[\s,,.。::;;]+')
_at_user_pattern = re.compile(r'^@([A-Za-z0-9_\u4e00-\u9fff]{2,30})')
# ⭐ 主循环:每轮按优先级尝试剥离一层,直到无可剥离
changed = True
while changed:
changed = False
# 先清理前导标点/空格(昵称后可能残留逗号、分隔符等)
ts = _leading_junk.sub('', ts)
if not ts:
break
# 1) 箭头前缀
if ts.startswith('//') or ts.startswith('=>'):
ts = ts[2:].lstrip()
changed = True
continue # 重新从头检查
# 2) QQ群头衔格式: 【头衔】用户名 或 [头衔] 用户名 或 (头衔)用户名
m = qq_title_pattern.match(ts)
if m:
ts = ts[m.end():].lstrip()
changed = True
continue
# 2b) QQ排名/头衔标记: (旅馆老板) —— 紧跟在用户名后的括号内容
m_rank = _rank_pattern.match(ts)
if m_rank:
ts = ts[m_rank.end():].lstrip()
changed = True
continue
# 3) @提及格式: @username 或 @昵称 —— QQ的@是UI元素,不是消息内容
if ts.startswith('@'):
matched = False
# 先尝试已知昵称
for nick in nicknames:
if ts[1:].startswith(nick):
ts = ts[1 + len(nick):].lstrip()
matched = True
changed = True
break
if not matched:
# 用正则匹配任意用户名(中英文混合,2-30字符)
m_at = _at_user_pattern.match(ts)
if m_at:
username = m_at.group(1)
rest = ts[m_at.end():]
ts = rest.lstrip()
# ⭐ Task #1508: 自动入库前校验,防「昵称+正文」整段被当昵称入库。
# 典型污染例子:"@游戏专用PHP有哪两种登录模式?" → 正则贪婪吞掉
# "游戏专用PHP有哪两种登录模式" 整段入库,导致后续匹配时正确正文
# 被脏昵称先命中剥掉、残留"?"再被前导垃圾清理吞掉 → 消息变空。
# 仅当 ① 匹配后不紧贴正文(rest 以分隔符/标点/结尾开头)
# ② 长度≤12(QQ昵称常见上限,超出多半是昵称+正文拼凑)才入库。
rest_head = rest[:1] if rest else ''
is_clean_at = (len(username) <= 12) and (not rest_head or rest_head in ' \t,,。.!!??;;::\n')
if is_clean_at and username not in nicknames:
nicknames.append(username)
try:
from config import load_config, save_config
cfg = load_config()
cfg.setdefault('qq_nicknames', []).append(username)
save_config(cfg)
logger.info(f"[qq] 自动入库@提及昵称: {username}")
except Exception as e:
logger.warning(f"[qq] 自动入库昵称失败: {e}")
elif username not in nicknames:
logger.debug(f"[qq] 跳过自动入库(疑似昵称+正文拼凑): {username!r}, rest={rest_head!r}")
else:
# 无法识别用户名,只剥离@符号
ts = ts[1:].lstrip()
changed = True
if changed:
continue
# 4) 裸昵称列表
for nick in nicknames:
if ts.startswith(nick):
ts = ts[len(nick):].lstrip()
changed = True
break
# ⭐ 群聊多消息拆分:OCR多条消息拼接时,[头衔]用户名 或 裸昵称 作为分隔符出现在文本中间
# 如 "C4炸弹起来准备测试了,[专业皮肤定制] C4炸弹,[专业皮肤定制] C4炸弹,youpaishidifu关于我们"
# 策略:找到所有QQ昵称分隔符位置,取最后一个分隔符之后的文本(最新用户消息)
# 模式1: [头衔]用户名 后跟逗号/冒号(QQ群头衔格式)
_mid_qq_title = re.compile(r'[\[【][^】\]]+[】\]]\s*(?:[A-Za-z0-9_\u4e00-\u9fff]{2,30})\s*[,,::]')
# 模式2: 已知裸昵称 后跟逗号/冒号(无头衔格式)
_mid_bare_nick = None
if nicknames:
# 构造已知昵称的转义正则
_escaped = [re.escape(n) for n in nicknames]
_mid_bare_nick = re.compile(r'(?:' + '|'.join(_escaped) + r')\s*[,,::]')
# 找到最后一个分隔符位置,截取其后的文本(最新消息)
last_sep_end = -1
# 查找模式1(QQ头衔格式)的最后一个匹配
for m in _mid_qq_title.finditer(ts):
last_sep_end = m.end()
# 查找模式2(裸昵称)的最后一个匹配(需排除开头的昵称)
if _mid_bare_nick:
for m in _mid_bare_nick.finditer(ts):
# 跳过开头的昵称(已由上面的主循环处理)
if m.start() > 0:
last_sep_end = m.end()
if last_sep_end > 0:
ts = ts[last_sep_end:].lstrip()
# 清理残留的前导标点/空格
ts = _leading_junk.sub('', ts)
logger.info(f"[qq] 群聊拆分: 取最后分隔符后的文本,长度={len(ts)}")
logger.debug(f"[qq] _strip_qq_nickname输出: {ts.strip()[:80]!r}")
# ⭐ Task #1508 防御兜底:剥离后为空但输入非空 → 回退原文
# (宁可保留昵称在消息里,也不能把整条消息吞成空串)。
if not ts.strip() and text.strip():
logger.warning(f"[qq] 剥离后为空,回退原文防消息丢失: {text.strip()[:80]!r}")
return text.strip()
return ts.strip()
def _is_reply_fragment(self, text: str) -> bool:
"""检查文本是否为 bot 回复内容的碎片(OCR 截取的 md 残片/跨行碎片)。
⭐ 判定稳健性(防误杀用户新消息):仅当文本「大部分」是 bot 旧回复时才判为残片,
要求公共子串:长度≥15,或(长度≥8 且 占较短文本≥60%)。这样既能拦住
「bot 回复回声」(整段≈bot 回复,公共子串占文本大头),又不会因用户消息中恰好
含有 bot 回复里的词(如服务器名「草原探险服务器」7字、专有名词「Minecraft」)
而被整体误杀——用户问句只与 bot 回复共享一个短语,远不到 60%。
"""
logger.debug(f"[ocr] 过滤器4: _is_reply_fragment 检查: {text[:80]!r}")
if not self._replied_texts:
logger.debug(f"[ocr] 过滤器4结果: 无已回复文本,返回False")
return False
if len(text) < 4:
logger.debug(f"[ocr] 过滤器4结果: 文本太短({len(text)}<4),返回False")
return False
import difflib
t_norm = self._norm_frag(text)
t_cjk = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
_matched = False
for r in self._replied_texts:
if len(r) < 5:
continue
# Latin/归一化匹配(要求占比较短文本≥60%,或足够长)
r_norm = self._norm_frag(r)
if t_norm and r_norm:
sm = difflib.SequenceMatcher(None, t_norm, r_norm)
lm = sm.find_longest_match(0, len(t_norm), 0, len(r_norm)).size
if lm >= 15 or (lm >= 8 and lm >= 0.6 * min(len(t_norm), len(r_norm))):
_matched = True
break
# CJK-only 匹配(同样要求占比较短文本≥60%,避免用户问句含服务器名被误杀)
r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff')
if len(t_cjk) >= 5 and len(r_cjk) >= 5:
sm = difflib.SequenceMatcher(None, t_cjk, r_cjk)
clm = sm.find_longest_match(0, len(t_cjk), 0, len(r_cjk)).size
if clm >= 15 or (clm >= 8 and clm >= 0.6 * min(len(t_cjk), len(r_cjk))):
_matched = True
break
if _matched:
# ⭐ 问句豁免(2026-08-11):与 3648 行逐行过滤逻辑一致——短问句与 bot 旧回复
# 共享长短语(如"登录插件下载地址是啥啊" vs 下载地址回复,共享8字占80%)
# 会命中 60% 标准,但这是用户提问不是 OCR 回声。line 是问句且非任何 replied
# 的完全子串 → 放行。真回声 text 整段≈回复仍拦。
_ask_words = ('怎么办', '为什么', '能不能', '怎么', '如何', '什么', '可以',
'帮忙', '是啥', '是啥啊', '在哪', '哪个', '哪里', '多少', '几', '吗', '呢')
_is_ask = any(w in text for w in _ask_words) or text.rstrip().endswith(('?', '?'))
_is_full_sub = any(text in r for r in self._replied_texts)
if _is_ask and not _is_full_sub:
logger.debug(f"[ocr] 过滤器4结果: 匹配成功但为问句且非完全子串,视为用户提问返回False")
return False
logger.debug(f"[ocr] 过滤器4结果: 文本匹配成功,返回True")
return True
logger.debug(f"[ocr] 过滤器4结果: 未匹配到已回复文本,返回False")
return False
@staticmethod
def _looks_fresh_content(text: str) -> bool:
"""判断一段 OCR 文本是否含"新鲜消息"特征。
只放行明显是用户新发的查询/聊天消息,避免旧历史记录因为含问句被误放行。
规则严格:短消息 或 明确疑问/请求句(含疑问词+问号/怎么/如何等)。
"""
if not text or not text.strip():
return False
t = text.strip()
# 超短中文消息(如"好的"、"嗯")可能无时间戳
if len(t) <= 4 and re.search(r'[\u4e00-\u9fff]', t):
return True
# 明确疑问/请求:含疑问词 + 问号,允许中间隔主语/谓语(同一句内,≤30字)
# ⚠ 修复漏消息:旧正则用 \s* 只匹配空白不匹配汉字,导致"如何配置的?"、
# "在哪可以下载?"等正常疑问句(疑问词与问号间隔文字)匹配失败→整块被丢弃。
# 改为允许疑问词与问号间有 ≤30 个非句末标点字符(同句内)。
if re.search(r'(?:怎么办|为什么|能不能|怎么|如何|什么|哪些|哪个|吗|呢)[^。!?\n]{0,30}[??]', t):
return True
if re.search(r'^(?:怎么|如何|请问|请教|求助)', t):
return True
return False
def _is_question_message(self, text: str) -> bool:
"""判断一段文本是否为"明确提问",用于 stage 远古历史过滤的提问豁免。
仅当文本以问号(?/?)结尾、含中文、且不是 md 文档/噪声文本时才判定为提问。
作用:截图内某条消息继承了来自其他内容(历史记录、文档)的极老时间戳,
但该消息本身是用户刚发的提问(如"A还是B?"型选择问句,不含怎么/如何等疑问词),
不应因继承时间戳极老被误判为远古历史永久丢弃,应放行交由下游去重处理。
"""
if not text or not text.strip():
return False
t = text.strip()
if not t.endswith(('?', '?')):
return False
if not re.search(r'[\u4e00-\u9fff]', t):
return False
if self._is_markdown_document(t):
return False
if self._is_noise_text(t):
return False
return True
def _block_has_cjk_message(self, normalized_text: str) -> bool:
"""判断整块 OCR 文本去掉时间戳/噪声后是否仍含中文消息内容。
用于修复漏消息:当整屏最新可见时间戳已过期(≥600s)时,不再整块丢弃,
只要块内残留中文消息内容(可能是无独立时间戳的新消息),就放行给
stage splitting 逐条切分,由下游 seen/_replied_texts + "取最后一条"去重决定。
"""
if not normalized_text:
return False
stripped = re.sub(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', ' ', normalized_text)
stripped = re.sub(r'\s+', ' ', stripped).strip()
if self._is_noise_text(stripped):
return False
return bool(re.search(r'[\u4e00-\u9fff]', stripped))
@staticmethod
def _normalize_timestamps(text: str) -> str:
"""规整 OCR 逐字输出的时间戳,修复「孤儿空格」导致的漏消息。
Windows OCR 常把 `16:28:40` 拆成每个字符间带空格、且冒号多为全角:
`1 6 : 2 8 : 4 0` → `16:28:40`
旧逻辑用 `re.sub(r'([\\d:])\\s+([\\d:])', r'\\1\\2')` 单次替换,是非重叠匹配,
会遗留「成对字符间隙」的孤儿空格(如 `1 6 : 2`→`16 :2`,`:` 前后空格残留),
导致 `findall(r'\\d{1,2}:\\d{1,2}')` 提取为空 → 整条消息被「无时间戳」丢弃(漏消息根因)。
本函数用 token 正则一次性匹配整段时间戳片段,内部去空格 + 全角→半角,天然规避该问题。
仅对【含冒号类分隔符的时间戳片段】做规整,绝不碰无冒号的纯数字串
(如兑换码 `1234 5678`、小数 `12.5`、版本号 `1.2.3`),避免误拼。
分隔符集合 `[::。.]` 不含 ASCII `.`:小数 `12.5` 不误判为时间;
ASCII 点分隔的时间(如 `16.28`)由调用处的 `[.;]→:` 兜底步骤处理。
"""
if not text:
return text
_tok = re.compile(r'\d{1,2}(?:[\s.]*\d{1,2})?(?:\s*[::。.]\s*\d{1,2}(?:[\s.]*\d{1,2})?)+')
def _fix(m):
s = m.group(0).replace('\u3000', '').replace(' ', '')
s = re.sub(r'[:。.]', ':', s)
return s
return _tok.sub(_fix, text)
def _ocr_text_has_fresh_timestamp(self, text: str, platform: str = "unknown") -> bool:
"""检查 OCR 文本是否可能包含新消息。
有时间戳且至少一个 ≤300s → True(确定新消息)
无时间戳 → 保守放行(可能是短句无时间戳的新消息)
有时间戳但全部过期 → 仅当最新时间戳不太老(≤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
# 标准化时间戳:规整 OCR 逐字输出的时间戳(清除字符间空格 + 全角冒号→ASCII)
# 16:28:40 → 16:28:40, 1 6 : 2 8 : 4 0 → 16:28:40(见 _normalize_timestamps,修复孤儿空格导致漏消息)
normalized = self._normalize_timestamps(text)
# 点号/分号分隔→冒号(兜底:ASCII 点分隔时间 16.28 等)
# ⭐ Task #1507: 同 stage splitting 防 URL 残片(0.50;57)误转时间戳——
# 混合分隔符或后跟字母数字/URL字符的不是时间戳,不转换。
# 否则假时间戳"0:50:57"过老导致过滤器1整批丢弃真实新消息。
# 后顾 ?<![;.0-9A-Za-z%]:匹配起点前不能在URL字符后,防止 0.50;57 中
# 的子串 50;57 被单独转换(50:57 会被当合法时间戳)。
# ⭐ 2026-08-11: EasyOCR 偶发把冒号误读成混合分隔符(10;20.48 = 10:20:48)。
# 原正则第二段要求同一分隔符重复(?:\2(\d{2})),混合分隔符无法匹配→
# 最新时间戳漏提(只提出10:14:21),距截图454s>300s→整批含用户消息
# ')youpaishidifu 服务器的基本信息有哪些?'被过滤器1丢弃。
# 第二段放宽为任意[.;:],与stage splitting的_line_has_fresh_ts
# (\d{1,2}[:;.]\d{1,2}(?:[:;.]\d{1,2})?)对齐;URL残片防护靠前后顾不变。
normalized = re.sub(
r'(?<![;.0-9A-Za-z%])(\d{1,2})([.;])(\d{2})(?:[.;:](\d{2}))?(?![;.0-9A-Za-z%])',
lambda m: f'{m.group(1)}:{m.group(3)}:{m.group(4)}' if m.group(4) else f'{m.group(1)}:{m.group(3)}',
normalized)
timestamps = re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', normalized)
# ⭐ 2026-08-11 放宽:独立6位裸数字→时间戳(HHMMSS)
# OCR 无法保证时间戳格式(10:20:48 可能被读成 102048 无分隔连体数字),
# 但能保证读出数字。前后顾挡数字/字母/中文/下划线/常见分隔与%,
# 防止版本号、日期、URL、小数串中取段。只保留合法HH/MM/SS范围——
# 非法组合(520131=52:xx、128000=xx:80)不当时间戳,否则 _is_timestamp_old
# 对非法返回False会被误判"新鲜"整批放行。
for _m6 in re.finditer(r'(?<![0-9A-Za-z_\u4e00-\u9fff./;:\-%])(\d{6})(?![0-9A-Za-z_\u4e00-\u9fff./;:\-%])', normalized):
_d6 = _m6.group(1)
_hh, _mm, _ss = int(_d6[0:2]), int(_d6[2:4]), int(_d6[4:6])
if _hh <= 23 and _mm <= 59 and _ss <= 59:
timestamps.append(f'{_d6[0:2]}:{_d6[2:4]}:{_d6[4:6]}')
logger.debug(f"[ocr] 提取到时间戳: {timestamps}")
# 提取日期信息(如2026/8/2、2026-08-02等),用于判断时间戳是否真的是"未来"
# OCR常把日期和时间连在一起:2026/8/319:45:20 → 匹配到2026/8/31(未来/非法)
# 需要截断到最后一个合法且≤今天的日期
raw_dates = re.findall(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', text)
raw_dates = [d.replace('-', '/') for d in raw_dates]
today = datetime.now().date()
# 改进日期解析:处理OCR把日期和时间连在一起的情况
dates = []
for date_str in raw_dates:
parts = date_str.split('/')
if len(parts) != 3:
continue
try:
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
except ValueError:
continue
# 尝试从d向下截断,找到第一个合法且≤今天的日期
# 处理:2026/8/31(未来)→ 2026/8/3(过去),2026/8/32(非法)→ 2026/8/3
found = False
for try_d in range(d, 0, -1):
try:
date_obj = datetime(y, m, try_d).date()
if date_obj <= today:
fixed_str = f"{y}/{m}/{try_d}"
if fixed_str != date_str:
logger.debug(f"[ocr] 日期截断修正: {date_str} → {fixed_str}")
dates.append(fixed_str)
found = True
break
except ValueError:
continue
if not found:
logger.debug(f"[ocr] 日期解析失败: {date_str}")
logger.debug(f"[ocr] 提取到日期: {dates}")
# 计算日期差:如果是过去的日期(昨天或更早),时间戳不应该是"未来时间戳"
has_past_date = False
for date_str in dates:
try:
parts = date_str.split('/')
if len(parts) == 3:
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
date_obj = datetime(y, m, d).date()
date_diff = (today - date_obj).days
if date_diff > 0: # 昨天或更早
has_past_date = True
logger.debug(f"[ocr] 检测到过去日期: {date_str} (距今{date_diff}天)")
break
except (ValueError, IndexError):
pass
if not timestamps:
# ⭐ 无可见时间戳:检查噪声文本
logger.debug(f"[ocr] 无可见时间戳")
if self._is_noise_text(text):
logger.debug(f"[ocr] 过滤器1结果: 无时间戳且为噪声文本,丢弃: {text[:80]!r}")
return False
# 检查是否为纯中文或中英文混合消息(排除噪声)
stripped = text.strip()
if not stripped:
return False
# 检查是否包含中文字符(用户消息特征)
has_chinese = re.search(r'[\u4e00-\u9fff]', stripped)
if not has_chinese:
# 纯英文/数字/符号,可能是UI噪声,丢弃
logger.debug(f"[ocr] 过滤器1结果: 无时间戳且无中文字符,丢弃: {stripped[:80]!r}")
return False
# 用户消息挨着bot消息判断:最近一次bot回复在60秒内(放宽窗口)
last_bot_time = self._msg_history.last_bot_reply_time(platform)
if last_bot_time > 0 and time.time() - last_bot_time <= 60:
# 用户消息挨着bot消息
if not self._msg_history.is_user_message_replied(platform, stripped, time.time()):
# 数据库无记录,无条件放行
logger.debug(f"[ocr] 过滤器1结果: 无时间戳但用户消息挨着bot消息且无记录,放行: {stripped[:80]!r}")
self._msg_history.record_user_message(platform, stripped)
return True
else:
# 数据库有记录,整段丢弃
logger.debug(f"[ocr] 过滤器1结果: 无时间戳且用户消息挨着bot消息但有记录,丢弃: {stripped[:80]!r}")
return False
# ⭐ 无时间戳消息:放行交给DB查重处理(避免连续聊天时时间戳被覆盖误杀用户消息)
# 无论消息长短,只要包含中文字符且非噪声,都放行
# DB查重会自动过滤掉已经回复过的消息
logger.info(f"[ocr] 过滤器1结果: 无时间戳但为中文消息,交由DB查重: {stripped[:80]!r}")
self._msg_history.record_user_message(platform, stripped)
return True
# 如果检测到过去日期(昨天或更早),时间戳不应该是"未来时间戳"
# 直接放行,让后续逻辑判断是否过期
if has_past_date:
logger.debug(f"[ocr] 检测到过去日期,跳过未来时间戳拦截,直接判断是否过期")
# 检查是否有新鲜时间戳(≤300s)
for ts in timestamps:
try:
if not self._is_timestamp_old(ts, 300, skip_future_check=True):
logger.debug(f"[ocr] 过滤器1结果: 过去日期+新鲜时间戳{ts}(≤300s),放行")
return True
except Exception:
pass
# 检查最新时间戳是否在300-600s之间
newest_fresh_enough = False
for ts in timestamps:
try:
if not self._is_timestamp_old(ts, 600, skip_future_check=True):
newest_fresh_enough = True
break
except Exception:
pass
if not newest_fresh_enough:
# ⭐ 硬截止:最新时间戳 >30min → 无论是否含 CJK 均丢弃(同主路径)
_hard_cutoff_s = 300 # 严格5分钟报废
_newest_age_s = None
for ts in timestamps:
try:
_age = self._timestamp_age_seconds(ts)
if _age is not None and (_newest_age_s is None or _age < _newest_age_s):
_newest_age_s = _age
except Exception:
pass
if _newest_age_s is not None and _newest_age_s > _hard_cutoff_s:
logger.debug(f"[ocr] 过滤器1结果: 过去日期+最新时间戳已过期>{_hard_cutoff_s}s({_newest_age_s:.0f}s),严格报废丢弃: {text[:80]!r}")
return False
# ⭐ 严格300s:有时间戳且全过期→直接丢弃
logger.debug(f"[ocr] 过滤器1结果: 过去日期+时间戳全过期(>300s),丢弃: {text[:80]!r}")
return False
# ⭐ 严格300s:最新时间戳在300-600s之间也视为过期,直接丢弃
logger.debug(f"[ocr] 过滤器1结果: 过去日期+最新时间戳已过期(>300s),丢弃: {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
# ⭐ 全部过期:所有时间戳均超过5分钟(300s) → 严格丢弃
# 修复:10:59的消息在12:22仍被处理。提取全部时间戳,只有5分钟内的才放行,
# 其余一律丢弃。不再"放行给DB去重"——DB去重无法判断消息是否真的是用户刚发的。
_newest_age_s = None
for ts in timestamps:
try:
_age = self._timestamp_age_seconds(ts)
if _age is not None and (_newest_age_s is None or _age < _newest_age_s):
_newest_age_s = _age
except Exception:
pass
_age_info = f"({_newest_age_s:.0f}s)" if _newest_age_s is not None else ""
logger.debug(f"[ocr] 过滤器1结果: 无新鲜时间戳(全部>300s){_age_info},丢弃: {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, skip_future_check: bool = False) -> 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 = 时间戳太老,应跳过。
skip_future_check: 如果为True,则跳过未来时间戳拦截(用于已检测到过去日期的情况)。
"""
ts = ts_str.strip()
if not ts:
return False
m = re.match(r'^(\d{1,2}):(\d{2})(?::(\d{2}))?$', ts)
if not m:
return False
h, mi, s = int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)
if h > 23 or mi > 59 or s > 59:
return False
now = datetime.now()
try:
parsed = now.replace(hour=h, minute=mi, second=s, microsecond=0)
except ValueError:
return False
diff = (now - parsed).total_seconds()
# 跨午夜: diff > 12h 说明时间戳来自昨天
# ⚠ 用 timedelta 回退而非 replace(day-1):每月1号 day=1 时 day-1=0 抛
# ValueError("day is out of range for month") → 微信 OCR 整轮失败
if diff > 43200:
parsed = parsed - timedelta(days=1)
diff = (now - parsed).total_seconds()
# 跨午夜反向: diff < -12h 说明是昨天晚些时候(当前 00:05,时间戳 23:55 → 昨天23:55)
# ⚠ 必须 -1day:之前误用 +1day 得到 -47h 的负 diff → 返回"不旧"=新鲜 → 凌晨误判
if diff < -43200:
parsed = parsed - timedelta(days=1)
diff = (now - parsed).total_seconds()
# 未来时间戳硬拦截(用户反馈:系统14:40 不能把 16:00 未到来的时间判为新鲜):
# 跨午夜调整后仍为负 = 同日内未来时间,必为 OCR 误读/异常,直接丢弃
# skip_future_check=True 时跳过此检查(用于已检测到过去日期的情况)
if diff < -MessageMonitor._TS_FUTURE_TOLERANCE_S and not skip_future_check:
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
for offset in (10, 12):
try:
h2 = h + offset
if h2 > 23:
continue
parsed2 = now.replace(hour=h2, minute=mi, second=s, microsecond=0)
diff2 = (now - parsed2).total_seconds()
if diff2 < 0:
diff2 += 86400
if diff2 < best_diff:
best_diff = diff2
best_h = h2
except ValueError:
pass
if best_h != h and best_diff < max_age:
logger.debug(f"[ts] OCR误读兜底: {ts} 可能是 {best_h}:{mi:02d}:{s:02d}, diff={best_diff:.0f}s < {max_age}s, 放行")
return False
return diff > max_age
def _line_has_fresh_ts(self, rl: str, skip_future_check: bool = False) -> bool:
"""检查一行是否含新鲜时间戳(≤300s)。
⭐ Task #1510: QQ 时间戳渲染在消息【下方】,stage splitting 的"继承上一行时间戳"
模型会把陈旧历史时间戳继承给下方的新鲜消息,导致新鲜消息被"尾部时间戳较老"误杀。
此函数用于前向 peek:检查本行或下一行是否含新鲜时间戳,若是则消息属于新鲜组。
复用 stage splitting 的规整链:;.分隔→冒号、逐字空格规整,再提取 HH:MM(:SS)。
"""
if not rl:
return False
n = self._normalize_timestamps(rl)
# ⭐ 2026-08-11: 第二段分隔符放宽为任意[.;:](原\2同分隔符漏提 10;20.48 之类
# EasyOCR混合分隔读法),与 _ocr_text_has_fresh_timestamp 保持一致
n = re.sub(
r'(?<![;.0-9A-Za-z%])(\d{1,2})([.;])(\d{2})(?:[.;:](\d{2}))?(?![;.0-9A-Za-z%])',
lambda m: f'{m.group(1)}:{m.group(3)}:{m.group(4)}' if m.group(4) else f'{m.group(1)}:{m.group(3)}',
n)
ts_list = list(re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', n))
# ⭐ 2026-08-11: 独立6位裸数字→时间戳(与 _ocr_text_has_fresh_timestamp 一致的放宽逻辑)
for _m6 in re.finditer(r'(?<![0-9A-Za-z_\u4e00-\u9fff./;:\-%])(\d{6})(?![0-9A-Za-z_\u4e00-\u9fff./;:\-%])', n):
_d6 = _m6.group(1)
_hh, _mm, _ss = int(_d6[0:2]), int(_d6[2:4]), int(_d6[4:6])
if _hh <= 23 and _mm <= 59 and _ss <= 59:
ts_list.append(f'{_d6[0:2]}:{_d6[2:4]}:{_d6[4:6]}')
for ts in ts_list:
if not self._is_timestamp_old(ts, 300, skip_future_check=skip_future_check):
return True
return False
@staticmethod
def _timestamp_age_seconds(ts_str: str):
"""计算 HH:MM 或 HH:MM:SS 时间戳距现在的秒数。跨午夜处理。
返回 None 表示无法解析。"""
ts = ts_str.strip()
if not ts:
return None
m = re.match(r'^(\d{1,2}):(\d{2})(?::(\d{2}))?$', ts)
if not m:
return None
h, mi, s = int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)
if h > 23 or mi > 59 or s > 59:
return None
now = datetime.now()
try:
parsed = now.replace(hour=h, minute=mi, second=s, microsecond=0)
except ValueError:
return None
diff = (now - parsed).total_seconds()
if diff > 43200:
parsed = parsed - timedelta(days=1)
diff = (now - parsed).total_seconds()
if diff < -43200:
parsed = parsed - timedelta(days=1)
diff = (now - parsed).total_seconds()
return max(diff, 0)
@staticmethod
def _is_system_message(text: str) -> bool:
"""过滤机器人/系统消息,不作为用户消息处理。"""
if not text:
return True
# NFKC 标准化:去除 UIA/OCR 夹杂的零宽字符、不可见 Unicode
text = unicodedata.normalize('NFKC', text)
# 系统前缀/标记
system_markers = [
"✅", "❌", "📝", "🔍", "📄", "➡", "♡", "✿",
"[WECHAT]", "[QQ]", "[wechat]", "[qq]",
]
for m in system_markers:
if text.startswith(m):
return True
# ⭐ QQ/微信 界面噪声文字(按钮/提示/碎片/导航,非用户消息)
qq_ui_noise = [
"查看更多", "可查看在其他设备产生的聊天记录",
"收藏", "转发", "多选", "引用", "回复",
"选择会话开始聊天",
# 微信底部导航
"通讯录", "发现", "我", "消息", "朋友圈",
# 微信/QQ 顶部/侧边
"搜索", "添加到表情", "更多",
# 微信底部栏 + 附加按钮
"导航", "视频号",
# QQ 空间/动态
"空间", "动态", "说说", "日志", "相册",
# 通用 UI
"发送", "接收", "取消", "确认", "确定", "关闭",
]
for n in qq_ui_noise:
if text == n:
return True
# 系统事件模板 — 注意:用 startswith 而非 in,避免混合文本(OCR 多行合一)误杀
system_patterns = [
"已自动回复", "知识库中未找到", "找到相关结果",
"监控已启动", "监控已暂停", "设置已保存",
"🤖", "找不到联系人", "当前会话回复",
"自动回复触发", "匹配度",
# KB 文档内容格式(机器人回复格式),OCR 可能把用户消息+KB 回复混成一行
# 只有全行以"答:"开头才过滤,内含"答:"的混合文本不过滤
"答:",
# 纯系统命令反馈
"PS ", "❯ ", "$ ",
]
for p in system_patterns:
if text.startswith(p):
return True
# QQ系统消息残片:撤回了一条消息、你撤回了, 条消息等
if re.match(r'^(?:你|对方|他|她|管理员)\s*撤回了\s*,?\s*\d*\s*条?\s*消息', text):
return True
if re.match(r'^撤回了一条消息', text):
return True
# 纯时间格式(不含消息内容)
if re.match(r'^\d{1,2}:\d{2}', text):
return True
return False
@staticmethod
def _strip_json_yaml_blocks(raw_text: str, platform: str = "") -> str:
"""剥离 OCR 截屏中混入的 JSON/YAML 配置块。
微信 OCR 截屏范围 40%-98%,常把右侧文档/配置面板一起截入。
JSON 块("key": value, {, })和 YAML 块(key: value, - item)
跟用户消息混在一起,导致 split 后 JSON 碎片污染用户消息行。
策略:逐行检测,丢弃明显是 JSON/YAML 语法的行。
用户聊天不会写 "key": value 或 { } 块。
"""
lines = raw_text.split('\n')
cleaned = []
in_json_block = False
brace_depth = 0
for line in lines:
stripped = line.strip()
if not stripped:
cleaned.append(line)
continue
# 检测 JSON 对象块开始/结束
if stripped == '{' or stripped.startswith('{') and '"' in stripped:
in_json_block = True
brace_depth = stripped.count('{') - stripped.count('}')
logger.debug(f"[{platform}] JSON块剥离: 开始 {stripped[:60]!r}")
continue
if in_json_block:
brace_depth += stripped.count('{') - stripped.count('}')
if brace_depth <= 0:
in_json_block = False
logger.debug(f"[{platform}] JSON块剥离: 结束 {stripped[:60]!r}")
continue
# JSON 行特征: "key": value 或 "key": "value"
if re.match(r'^["\']?[a-zA-Z_][\w]*["\']?\s*:\s*["\d\[\{tfn]', stripped):
logger.debug(f"[{platform}] JSON块剥离: key-value行 {stripped[:60]!r}")
continue
# YAML 行特征: key: value(无引号,非 URL)
if re.match(r'^[a-zA-Z_][\w]*\s*:\s*["\w\d]', stripped) and \
not re.match(r'^(https?|ftp)://', stripped) and \
not re.match(r'^[a-zA-Z_][\w]*\s*:\s*/', stripped):
logger.debug(f"[{platform}] JSON块剥离: YAML行 {stripped[:60]!r}")
continue
# YAML 列表项: - item
if re.match(r'^-\s+\S', stripped) and \
not re.match(r'^-\s*[\u4e00-\u9fff]', stripped):
logger.debug(f"[{platform}] JSON块剥离: YAML列表 {stripped[:60]!r}")
continue
# 纯 JSON 数组: [ "item", ... ]
if stripped.startswith('[') and stripped.endswith(']') and \
'"' in stripped:
logger.debug(f"[{platform}] JSON块剥离: 数组 {stripped[:60]!r}")
continue
cleaned.append(line)
return '\n'.join(cleaned)
# —— md 尾巴剥离模式 ——
# ⭐ 2026-08-11:QQ 群截图 OCR 会把 bot 的 md 回复残片拼在用户消息尾部,
# 如 "群管理员其实都是傲娇小南...(头衔UI)...# #来测试# # @C418minecraf"。
# 历史做法整条判 md 丢弃 → 用户原始提问陪葬;若放行 → md 尾巴入库,
# 可能撞知识库关键词把 bot 拽去回复残片内容,脱离用户原始提问。
# 规则:md 在末尾 → 切掉小尾巴保留前段;md 大头/混中间 → 整段切掉。
_MD_TAIL_PATTERNS = [
# # 标题(## 文档 / ##直接跟汉字 / # # 分割 / ###1.)
# 负向前瞻挡 C#语言 / 中文#标签 前的字母数字下划线#
re.compile(r'(?<![A-Za-z0-9_#])#{1,6}(?:\s|\[|[\u4e00-\u9fff]|\d)'),
# **加粗** / 裸 **(用户日常不打)
re.compile(r'\*\*'),
# [text](url) 及 OCR 退化 [text](、](
re.compile(r'\[.+?\]\([^)]*\)?'),
re.compile(r'\]('),
# OCR 退化 checkbox [V]/[x]/[√]
re.compile(r'\[\s*[VXvx√✗]\s*\]'),
# - 列表 / 编号列表 / > 引用(行内出现,前有空白或行首)
re.compile(r'(?:^|\s)[-*+]\s+(?=\S)'),
re.compile(r'(?:^|\s)\d{1,3}[\.\)、]\s+(?=\S)'),
re.compile(r'(?:^|\s)>\s(?=\S)'),
# 反引号代码 / .md 文件引用
re.compile(r'`{1,3}'),
re.compile(r'\S+\.md\)'),
]
@staticmethod
def _strip_markdown_tail(text: str):
"""剥离消息中混入的 markdown 内容。返回剥离后文本;None = 整段丢弃。
规则:
- md 特征在末尾(前面有用户文本)→ 切掉 md 特征及之后 → 返回前段
- md 特征在开头 / 前段仍含 md 特征 → 返回 None(md 大头/混中间 → 整段切掉)
- 无 md 特征 → 原样返回
"""
if not text or not text.strip():
return text
hit = None
for pat in MessageMonitor._MD_TAIL_PATTERNS:
m = pat.search(text)
if m and (hit is None or m.start() < hit):
hit = m.start()
if text.count('|') >= 2:
m = re.search(r'\|', text)
if m and (hit is None or m.start() < hit):
hit = m.start()
if hit is None:
return text
head = text[:hit].strip()
if not head:
# md 特征在开头 → md 大头,整段切掉
return None
# 前段仍含 md 特征 → md 混在中间/多段拼行 → 整段切掉防污染
for pat in MessageMonitor._MD_TAIL_PATTERNS:
if pat.search(head):
return None
if head.count('|') >= 2:
return None
return head
@staticmethod
def _is_markdown_document(text: str) -> bool:
"""检测是否为 Markdown 格式的文档内容。
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):
return True
# 编号列表项:1. 2. 12. / 1) 2) / 1、2、
if re.match(r'^\d{1,3}[\.\)、]\s', text):
return True
# 引用块:> 开头
if re.match(r'^>\s', text):
return True
# 水平分隔线:--- 或 === 连续 3+ 个
if re.match(r'^[-=]{3,}\s*$', text):
return True
# |表格行:| 开头和结尾
if re.match(r'^\|.+\|$', text):
return True
# *斜体* / ***粗斜体* 或任何 ** 裸标记
# 用户日常对话从不打 **,只可能是 bot md 回复
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):
return True
# 包含 markdown 链接 [text](url)
if re.search(r'\[.+?\]\(.+?\)', text):
return True
# 包含 .md 文件引用(如 "gin.md)" 分割后断开的链接)
if re.search(r'\S+\.md\)', text):
return True
# 包含代码块标记 ``` 或 `code`
if re.search(r'`{1,3}', text):
return True
# 连续 3+ 行包含 markdown 特征:| 表格、--- 分隔线、> 引用
pipes = text.count('|')
if pipes >= 3:
return True
# 内联 # # 标题标记(如 "# # 必装依赖" → 分割后 `# #` 或 `# #`)
# ⭐ 2026-08-11 修复:必须用 re.match 而非 re.search。
# QQ 标签 "##来测试" "## @C418minecraft" 经 OCR normalize 后变成
# " # #来测试# # @C418minecraf" 拼在用户消息尾部。
# re.search 在文本中间匹配到 # # → 整条真实用户消息被误判为 md 文档丢弃。
# 改用 re.match 只检行首,避免 QQ 标签误触发。
if re.match(r'#\s*#', text):
return True
return False
@staticmethod
def _is_markdown_line(line: str) -> bool:
"""⭐ Task #1475: 检测单行是否为 Markdown 强特征行。
正常用户从不手动打 markdown。一旦某一行 OCR 文本命中以下强特征,
即判定为 markdown 行,整行跳过,避免污染普通消息:
- # 标题(## 文档 / ##直接跟汉字 / ###1. 数字标题)
- | 表格行(首尾 | 或含 3+ 个 |)
- **加粗** / 裸 ** / 反引号 ` 代码
- <br> HTML 换行 / [text](url) 链接 / > 引用块
- --- 或 === 水平分隔线
- 编号列表 1. 2) 3、 (bot md 回复常见,用户日常不打)
注意:群名片 [专业皮肤定制] 不含 ] 后的 ( ,不会误判为链接;
纯 [xxx] 也不命中任何强特征,安全保留。
"""
if not line:
return False
s = line.strip()
# # 标题(含空格/直接跟汉字/数字/内联#)
if re.match(r'^#{1,6}(\s|\[|#|\u4e00-\u9fff|\d)', s):
return True
# | 表格行
if re.match(r'^\|.+\|$', s) or s.count('|') >= 3:
return True
# **加粗** 或裸 ** 或反引号代码
if '**' in s or '`' in s:
return True
# <br> HTML 换行标记
if re.search(r'<\s*br\s*/?\s*>', s, re.IGNORECASE):
return True
# [text](url) 链接(群名片 [xxx] 后无 ( 不命中)
if re.search(r'\[.+?\]\(', s):
return True
# > 引用块
if re.match(r'^>\s', s):
return True
# --- 或 === 水平分隔线
if re.match(r'^[-=]{3,}\s*$', s):
return True
# 编号列表项(1. 2) 3、)
if re.match(r'^\d{1,3}[\.\)、]\s', s):
return True
return False
@staticmethod
def _is_qq_ui_element(line: str) -> bool:
"""⭐ Task #1476: 检测单行是否为 QQ 界面 UI 元素(非用户消息)。
QQ 群聊截图中,OCR 会把以下非消息文本识别出来混入消息流:
- 图片引用: !0(img/skin_3.png) 等
- 用户头衔/徽章: 【专业皮肤定制】C418minecraft
- 用户标签: ## @C418minecraft, )youpaishidifu关于我们
这些整行跳过,避免混入消息流触发残片判定。
"""
if not line:
return False
s = line.strip()
# 1) 图片文件引用: !0(img/xxx.png) 或 !0(img/xxx.jpg) 等
# QQ 发送图片后,OCR 识别出的文件引用格式
if re.match(r'^!0?\s*\(?\s*img/', s, re.IGNORECASE):
logger.debug(f"[qq-ui] 规则1匹配(图片引用): {s}")
return True
# 2) 用户头衔/徽章: 【...】Username
# QQ 群成员的头衔标签,如【专业皮肤定制】C418minecraft
if re.match(r'^【[^】]{1,30}】\s*[\w\u4e00-\u9fff]+', s):
logger.debug(f"[qq-ui] 规则2匹配(头衔徽章): {s}")
return True
# 3) 用户标签: ## @Username — QQ 的 @ 提及格式
if re.match(r'^##\s*@[\w\u4e00-\u9fff]+', s):
logger.debug(f"[qq-ui] 规则3匹配(##标签): {s}")
return True
# 3b) 独立@提及: @Username — 仅当整行只有@用户名时才识别为UI元素
# 如果@后面还有其他内容(如消息文本),则不识别为UI元素
if re.match(r'^@[\w\u4e00-\u9fff]+$', s):
logger.debug(f"[qq-ui] 规则3b匹配(独立@): {s}")
return True
# 4) 闭合括号开头的用户标签: )username 或 )username关于我们 — OCR 识别出的 UI 残留
# ⭐ 修复(2026-08-11):原正则 ^\)[\w\u4e00-\u9fff]{2,}\S 有回溯bug——
# \S 会回溯吃掉 username 末尾字符,导致 ")youpaishidifu 关于我们"(带空格,
# 实为用户真实消息"关于我们")整行被误判为 UI 丢弃。
# 正确判断:)username 后 剩余为空(行尾)或 首字符非空白(粘连UI)
# → UI元素;剩余首字符是空白 → 真实消息(OCR把@/冒号误读为)),不跳过。
_m4 = re.match(r'^\)([\w\u4e00-\u9fff]{2,})', s)
if _m4:
_rest4 = s[_m4.end():]
if not _rest4 or not _rest4[0].isspace():
logger.debug(f"[qq-ui] 规则4匹配(括号用户标签): {s} (剩余={_rest4!r})")
return True
# 剩余以空白开头 → 可能是用户消息(如")youpaishidifu 关于我们"),放行
logger.debug(f"[qq-ui] 规则4不匹配(带空格→疑似消息): {s} (剩余={_rest4!r})")
return False
# 5) 纯图片引用后缀: .png .jpg .gif 等,无中文 → 图片文件名碎片
if re.match(r'^[\w/\\._-]+\.(?:png|jpg|jpeg|gif|bmp|webp|svg|ico)$', s, re.IGNORECASE):
logger.debug(f"[qq-ui] 规则5匹配(图片后缀): {s}")
return True
# 6) 纯 @username 消息:中文/乱码 + @ + 用户名 + 空格 + 用户名
# 如 "有人@拜 youpaishidifu",OCR 识别的 QQ @ 提及消息,无实质内容
# 特征:行包含 @ 符号,@ 前后有内容,整行无疑问词且长度 ≤ 50
if '@' in s and len(s) <= 50:
# 排除包含疑问词的消息(可能是正常提问)
_question_words = ('?', '?', '请问', '是不是', '有没有', '怎么', '如何', '什么',
'哪', '谁', '多少', '几', '为什么', '是否', '能否', '可以吗',
'好吗', '对吗', '是吗', '对不对', '好不好', '行不行', '可不可以')
if any(q in s for q in _question_words):
logger.debug(f"[qq-ui] 规则6跳过(含疑问词): {s}")
return False
# 匹配模式:可选中文前缀(群名片) + @ + 任意字符(可能乱码) + 空格 + 用户名(可多词)
# 示例:"有人@拜 youpaishidifu"、"用户@张三 测试"、"@李四 123"
# 要求 @ 后有空格和用户名,@ 前可有中文(QQ群名片)或无前缀(@开头)
if re.match(r'^(?:[\u4e00-\u9fff]+)?@[\w\u4e00-\u9fff]+[\s]+[\w\u4e00-\u9fff]+(?:[\s]+[\w\u4e00-\u9fff]+)*$', s):
logger.debug(f"[qq-ui] 规则6匹配(纯@消息): {s}")
return True
return False
@staticmethod
def _is_md_continuation(line: str) -> bool:
"""⭐ Task #1475: 判断某行是否为 markdown 块被 OCR 拆出来的"内容延续行"。
当上一行是 markdown 特征行时,OCR 常把同一 md 块的内容拆成多行,
带 ## 的头部被识别为 md 并跳过,但尾部纯文本行(如"你就换/个问题。没事/这个不影响")
无 md 特征会逃逸。此函数识别这些延续行并跳过。
判定为"延续行"(返回 True)的条件:
- 无问号(?/?)、无 @、不以吗/呢/么结尾(非明显提问)
- 非群名片 [xxx] 开头(群名片代表新消息起点)
- 非时间戳开头
- 长度 < 20(短句,符合 md 内容碎片特征)
否则视为明显用户消息,返回 False 退出 md 块。
"""
if not line:
return True
s = line.strip()
# 明显用户消息特征 → 不是延续
if '?' in s or '?' in s:
return False
if '@' in s:
return False
if s.endswith(('吗', '呢', '?', '?', '么')):
return False
# 群名片 [xxx] 开头 → 新消息起点,退出 md 块
if re.match(r'^[\[【][^\]】]*[\]】]', s):
return False
# 时间戳开头 → 新消息起点
if re.match(r'^\d{1,2}[::]', s):
return False
# 较长陈述句(>=20字)可能为真实消息,退出 md 块
if len(s) >= 20:
return False
# 默认:短、无问号、无@、非群名片 → 视为 md 内容延续
return True
def _split_and_denoise_segments(self, raw_text: str, platform: str) -> str:
"""混合文本预分割 + 分段噪声隔离。
Windows OCR 返回整屏文字的大杂烩(bot 回复含 <br>/图片URL + 用户消息)。
若整体调用 _is_noise_text() 会因 HTML 标签把整段丢弃,用户消息跟着被杀。
策略:
1. 按消息边界将文本分割成候选段
2. 对每个段单独做 _is_noise_text() 检查
3. 只保留干净的用户消息段,丢弃含噪声的 bot 回复段
4. 返回清理后的文本(空字符串 = 全部是噪声)
分割规则(按优先级):
- <br> 标签:bot markdown 回复的典型特征
- 时间戳 HH:MM / HH:MM:SS:消息间天然边界
- 图片 URL (https://img.ypshidifu.cn/...):bot 回复中的图片引用
- 长空白/间隔:消息间的自然分隔
"""
if not raw_text:
return ""
# 快速路径:文本本身不含噪声特征 → 直接原样返回,不做分割开销
if not self._is_noise_text(raw_text) and '<br' not in raw_text and 'https://img' not in raw_text:
return raw_text
# ---- 分割成候选段 ----
import copy
segments = []
# 按多级分隔符分割
# 1) <br> 或 <br/> 是 bot 回复中最强的噪声标记,优先在此处切分
parts = re.split(r'(<\s*br\s*/?\s*>|\s*<br>\s*)', raw_text)
for part in parts:
if not part or not part.strip():
continue
part = part.strip()
# 如果段落仍然很长且包含多种内容,尝试进一步切分
if len(part) > 100 and ('https://' in part or re.search(r'\d{1,2}:\d{1,2}', part)):
# 按 URL 前后的问号句或时间戳进一步切分
sub_parts = re.split(
r'(?<=。|?|!|\.)\s+(?=[^<])'
r'|(?<=[^:])\s+(?=\d{1,2}:\d{1,2})'
r'|(?<=\S)\s{2,}(?=\S)',
part
)
for sp in sub_parts:
sp = sp.strip()
if sp:
segments.append(sp)
else:
segments.append(part)
if not segments:
return raw_text # 分割失败,回退到原始文本
# ---- 对各段单独做噪声检测 ----
clean_segments = []
noise_count = 0
for i, seg in enumerate(segments):
is_noise = self._is_noise_text(seg)
if is_noise:
noise_count += 1
logger.debug(f"[{platform}] 预分割: 段[{i}] 含噪声特征,丢弃: {seg[:60]!r}")
else:
clean_segments.append(seg)
logger.debug(f"[{platform}] 预分割: 段[{i}] 干净,保留: {seg[:60]!r}")
# ---- 结果汇总 ----
if not clean_segments:
# 全部都是噪声 → 返回原始文本让后续流程正常丢弃
logger.debug(f"[{platform}] 预分割: 全部{len(segments)}段均为噪声,回退原始文本")
return raw_text
if noise_count == 0:
# 没有任何噪声段 → 原始文本本身可能就是干净的
return raw_text
# 有部分噪声被剥离,有部分干净段保留
result = '\n'.join(clean_segments)
logger.info(f"[{platform}] 预分割: {len(segments)}段中剥离{noise_count}段噪声,保留{len(clean_segments)}段干净文本")
return result
@staticmethod
def _is_noise_text(text: str) -> bool:
"""检测是否为OCR噪声文本(HTML标签、图片路径、OCR乱码等)。
这些文本通常来自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
# OCR乱码字符:§(Windows OCR常误读"互"为"§")、¶、©、®、™等非常规符号
if re.search(r'[§¶©®™]', text):
logger.debug(f"[ocr] 过滤器2结果: 检测到OCR乱码字符,返回True")
return True
# OCR短噪声残片:如"留, C"、"留, Q"等逗号+单字母组合(OCR截断残留)
if re.match(r'^[\u4e00-\u9fff]{1,2}\s*[,,]\s*[A-Za-z]{1,2}$', text):
logger.debug(f"[ocr] 过滤器2结果: 检测到OCR短噪声残片,返回True")
return True
# 纯逗号+字母噪声(如", C"、", Q")
if re.match(r'^[,,]\s*[A-Za-z]{1,2}$', text):
logger.debug(f"[ocr] 过滤器2结果: 检测到纯逗号字母噪声,返回True")
return True
# Windows系统进程名/窗口标题:任务管理器、控制面板、文件资源管理器等
# ⭐ 修复(2026-08-10):匹配词后必须不是中文字符——词表含"服务/设置/声音/键盘"等
# 短词,re.search 会把"服务器"("服务"+"器")、"设置公告"误判为进程名。
# 负向前瞻 (?![\u4e00-\u9fff]):命中词后紧跟中文字符 → 判定为普通文本,不丢弃。
# 窗口标题如"文件资源管理器 - 此电脑""记事本.txt"(后跟ASCII)仍正常命中。
_proc_match = re.match(r'^(?:任务管理器|控制面板|文件资源管理器|回收站|计算器|画图|记事本|命令提示符|'
r'Windows安全中心|设置|网络和共享中心|设备管理器|磁盘管理|注册表编辑器|'
r'系统信息|系统配置|服务|性能监视器|事件查看器|远程桌面连接|'
r'程序和功能|Windows更新|防火墙|用户账户|电源选项|显示设置|'
r'声音|蓝牙|打印机|键盘|鼠标|日期和时间|区域|语言|辅助功能|'
r'备份和还原|系统保护|激活|防火墙|WindowsDefender|'
r'资源监视器|性能|任务计划程序|组件服务|计算机管理|'
r'Windows PowerShell|Windows终端|Visual Studio Code|'
r'Google Chrome|Microsoft Edge|Mozilla Firefox|'
r'微信|QQ|TIM|钉钉|飞书|企业微信|'
r'网易云音乐|酷狗音乐|QQ音乐|酷我音乐|'
r'腾讯视频|爱奇艺|优酷|哔哩哔哩|'
r'百度网盘|阿里云盘|腾讯微云|'
r'WPS Office|Microsoft Word|Microsoft Excel|Microsoft PowerPoint|'
r'Adobe Photoshop|Adobe Illustrator|Adobe Premiere|'
r'剪映|必剪|达芬奇|Final Cut Pro|'
r'Steam|Epic Games|WeGame|暴雪战网|'
r'微信读书|QQ阅读|Kindle|'
r'百度|搜狗|有道|金山|'
r'腾讯会议|Zoom|Teams|飞书会议|'
r'TeamViewer|向日葵|ToDesk|'
r'7-Zip|WinRAR|Bandizip|'
r'火绒|360安全卫士|腾讯电脑管家|卡巴斯基|迈克菲|'
r'VMware|VirtualBox|Hyper-V|'
r'Navicat|DBeaver|DataGrip|'
r'Xshell|SecureCRT|MobaXterm|'
r'Everything|Listary|Wox|uTools|'
r'QQ浏览器|360浏览器|搜狗浏览器|UC浏览器|'
r'迅雷|IDM|Motrix|'
r'有道翻译|百度翻译|DeepL|'
r'印象笔记|Notion|Obsidian|语雀|'
r'GitHub Desktop|SourceTree|TortoiseGit|'
r'Postman|Apifox|Swagger|'
r'Figma|Sketch|蓝湖|墨刀)(?![\u4e00-\u9fff])',
text)
if _proc_match:
# ⭐ 命中日志必须爆出具体词——谁撞上、内容是什么,不装哑巴
logger.warning(f"[ocr] 过滤器2结果: 检测到Windows系统进程名/窗口标题,返回True | "
f"命中词={_proc_match.group(0)!r} | 全文={text[:80]!r}")
return True
# ⭐ 病句过滤:语法不完整片段(缺主语/动词/括号不匹配)
# 如"不得进行任何形式,[" 缺少主语,不应入库
if re.match(r'^(?:不得|禁止|允许|应该|必须|可以|需要|支持|提供|进行|进行任何形式|不得进行)', text):
# 检查是否有主语(我/你/他/我们/你们/他们/玩家/用户/管理员等)
subjects = ('我', '你', '他', '她', '它', '我们', '你们', '他们', '她们', '玩家', '用户', '管理员', '服务端', '客户端')
if not any(s in text for s in subjects):
logger.debug(f"[ocr] 过滤器2结果: 检测到病句(缺主语),返回True")
return True
# 以逗号/顿号结尾的短片段(可能截断)
if len(text) < 10 and text.endswith((',', '、', ',', '.')):
logger.debug(f"[ocr] 过滤器2结果: 检测到短截断片段,返回True")
return True
# 括号不匹配(可能OCR截断)
# 清理末尾的不完整括号(OCR噪声),如"消息内容['"
text_clean = re.sub(r'[\[((]+$', '', text)
if text_clean.count('[') != text_clean.count(']') or text_clean.count('(') != text_clean.count(')') or text_clean.count('(') != text_clean.count(')'):
# 仅当文本较短时才判定为病句(避免误杀正常聊天)
if len(text_clean) < 30:
logger.debug(f"[ocr] 过滤器2结果: 检测到括号不匹配,返回True")
return True
# ⭐ 用户协议/服务条款文档内容检测(牛头不对马嘴修复)
# OCR截屏同时捕获聊天窗口+用户协议文档窗口,协议中的正式法律用语
# 被误当成用户消息触发自动回复。以下模式匹配典型的协议/条款语言:
# 1. "玩家/用户 应/必须/须/不得 + 尊重/遵守/履行/维护/泄露/损害"
# 2. "运营方/服务提供方 + 有权/有义务/保留"
# 3. "本协议/条款/规定/政策/规则" 开头
# 4. 同时含"权利"+"义务"的陈述句
# ⭐ 改用 re.search 匹配(不强制行首),防止前缀绕过
_AGREEMENT_PLAYER = re.compile(r'(?:玩家|用户)(?:应|必须|须|不得)(?:尊重|遵守|履行|维护|泄露|损坏|损害|侵害)')
_AGREEMENT_OPERATOR = re.compile(r'(?:运营方|服务提供方|本公司|我方)(?:有权|有义务|保留|不承担)')
_AGREEMENT_TITLE = re.compile(r'^\s*本(?:协议|条款|规定|政策|规则|指引)')
# 拓展:协议中常见的"骚扰、辱骂或攻击行为"、"合法权益"等措辞
_AGREEMENT_KEYWORDS = re.compile(
r'(?:骚扰|辱骂|攻击行为|合法权益|侵权行为|违规内容|'
r'不得进行任何形式|恶意行为|滥用|违反.*规定|'
r'服务条款|隐私政策|用户协议|免责条款|'
r'运营方.*权利|服务.*提供.*义务|'
r'我们.*保留.*权利|承担.*责任)'
)
if _AGREEMENT_PLAYER.search(text):
logger.debug(f"[ocr] 过滤器2结果: 检测到用户协议条款内容(正式法律用语),返回True: {text[:60]!r}")
return True
if _AGREEMENT_OPERATOR.search(text):
logger.debug(f"[ocr] 过滤器2结果: 检测到运营方条款内容,返回True: {text[:60]!r}")
return True
if _AGREEMENT_TITLE.match(text):
logger.debug(f"[ocr] 过滤器2结果: 检测到协议条款文档标题,返回True: {text[:60]!r}")
return True
# 同时包含"权利"和"义务"的正式陈述句(法律文档特征),排除疑问句
if '权利' in text and '义务' in text and not text.endswith(('?', '?', '吗', '呢')):
logger.debug(f"[ocr] 过滤器2结果: 检测到含'权利'+'义务'的法律文档陈述句,返回True: {text[:60]!r}")
return True
# ⭐ 拓展协议关键词检测:短文本中出现多个协议特征词 → 高概率协议内容
if len(text) < 80 and _AGREEMENT_KEYWORDS.search(text):
# 至少匹配2个协议特征关键词才拦截,避免误杀正常聊天
_match_count = len(_AGREEMENT_KEYWORDS.findall(text))
if _match_count >= 2:
logger.debug(f"[ocr] 过滤器2结果: 检测到多个协议特征词({_match_count}个),返回True: {text[:60]!r}")
return True
# ⭐ 混合文字乱码检测:OCR将不同行/区域的文字混在一起
# 如"HHWH尸端自哪内柙登录槟J!" — Latin大写字母散落在CJK字符中间
# 正常聊天中Latin缩写是连续块(如"登录PHP服务器"、"GTA5玩家")
# 乱码特征:Latin大写被CJK分割成多个不连续块(≥2组)
#
# ⚠️ Task #1474 群聊例外(2026-08-09):
# 群聊OCR文本天然包含多个用户名(C418minecraft、BXZYAE、youpaishidifu等)
# 这些用户名分散在不同位置,会被误判为"混合文字乱码"。
# 以下模式是群聊正常特征,不应判为乱码:
# 1. @提及:@username
# 2. QQ群名片/头衔:【...】Username 或 [...] Username
# 3. 完整中文句子(含句末标点):说明是正常聊天而非乱码
if re.search(r'[A-Z]', text) and re.search(r'[\u4e00-\u9fff]', text):
# ── 群聊正常模式排除 ──
# 排除@提及(QQ群聊@用户是正常行为)
if re.search(r'@[A-Za-z0-9_\u4e00-\u9fff]+', text):
logger.debug(f"[ocr] 过滤器2: 检测到@提及模式,跳过混合乱码检测")
pass # 不return,继续后续检查
# 排除QQ群名片/头衔格式:【...】Username 或 [...]
elif re.search(r'[【\[][^】\]]*[】\]]\s*[A-Za-z]', text):
logger.debug(f"[ocr] 过滤器2: 检测到QQ群名片/头衔格式,跳过混合乱码检测")
pass # 不return,继续后续检查
# 排除含完整中文句子(有句末标点=正常聊天)
elif re.search(r'[\u4e00-\u9fff]{4,}[。!?!?…]+$|\?$', text):
logger.debug(f"[ocr] 过滤器2: 检测到完整中文句子,跳过混合乱码检测")
pass # 不return,继续后续检查
else:
_upper_groups = len(re.findall(r'[A-Z]+', text))
if _upper_groups >= 2:
logger.debug(f"[ocr] 过滤器2结果: 检测到混合文字乱码({_upper_groups}组大写字母分散),"
f"返回True: {text[:60]!r}")
return True
# ⭐ QQ系统消息过滤:群聊系统通知(邀请加入、退出等)
# 如"你邀请xx加入了本群"、"xx邀请了xx加入了群聊"、"xx加入了群聊"
_QQ_SYSTEM_MSG_PATTERNS = (
r'邀请.*加入了',
r'加入了本群',
r'加入了群聊',
r'退出了群聊',
r'被管理员移出群',
r'撤回了一条消息',
r'修改了群名称',
r'成为了管理员',
r'取消了管理员',
r'被禁言',
r'被解除禁言',
r'全体禁言',
r'解除全体禁言',
)
for pattern in _QQ_SYSTEM_MSG_PATTERNS:
if re.search(pattern, text):
logger.debug(f"[ocr] 过滤器2结果: 检测到QQ系统消息,返回True: {text[:60]!r}")
return True
# ⭐ Task #1457: 360安全弹窗内容过滤(防误触发查库+回复)
# 360安全卫士/杀毒弹窗常被OCR识别为用户消息,如"这是敲诈病毒,会勒索您的钱财"
# 以下关键词覆盖360常见报毒/安全警告弹窗
_SECURITY_POPUP_KEYWORDS = (
'敲诈病毒', '勒索您的钱财', '勒索病毒', '安全大脑', '极智守护',
'安全卫士', '安全防护', '安全中心', '病毒查杀', '木马查杀',
'恶意软件', '可疑程序', '高危风险', '立即处理', '立即清除',
'隔离区', '安全体检', '系统修复', '漏洞修复', '驱动大师',
'360安全', '360杀毒', '360卫士', '360弹窗', '360提示',
'这是敲诈', '勒索您的', '勒索钱财', '加密文件', '索要赎金',
'您的文件已被加密', '请支付赎金', '文件恢复',
)
if any(kw in text for kw in _SECURITY_POPUP_KEYWORDS):
logger.debug(f"[ocr] 过滤器2结果: 检测到安全软件弹窗内容,返回True: {text[:60]!r}")
return True
# ⭐ Task #1514: OCR病句乱码检测:数字混杂在中文字符间(如"游0由")
# OCR把不同区域文字拼接时,数字被嵌入中文字符中间形成乱码。
# 正常中文聊天中,数字通常出现在完整数字串(时间戳/数量/版本号)中,
# 不会单个数字夹在两个不相关的中文字符之间(如"游0由")。
# 特征:CJK + 单个数字 + CJK(数字前后都是中文字符)
# ⚠️ 排除正常场景:版本号(v1.0)、数量(3个)、时间(16:28)等
if re.search(r'[\u4e00-\u9fff]\d[\u4e00-\u9fff]', text):
# 检查是否为正常场景:数字是完整数字串的一部分(前后有数字或标点)
# 如"v1.0版本"(v+数字+数字+中文)、"3个"(数字+中文)、"16:28"(数字+冒号+数字)
# 异常场景:单个数字被两个不相关中文字符包围(如"游0由")
_cjk_digit_cjk = re.findall(r'([\u4e00-\u9fff])(\d)([\u4e00-\u9fff])', text)
for prefix, digit, suffix in _cjk_digit_cjk:
# 排除正常场景:数字前后是连续数字的一部分
# 检查该数字在原文中的位置,前后是否有其他数字或标点
_pos = text.find(prefix + digit + suffix)
if _pos >= 0:
_before = text[:_pos] if _pos > 0 else ''
_after = text[_pos + 3:] if _pos + 3 < len(text) else ''
# 前面有数字或标点 → 可能是正常数字串(如"v1.0版本"中的1)
# 后面有数字或标点 → 可能是正常数字串(如"16:28"中的6)
_is_normal = (
(_before and (_before[-1].isdigit() or _before[-1] in '.,:;:;,、')) or
(_after and (_after[0].isdigit() or _after[0] in '.,:;:;,、'))
)
if not _is_normal:
# 单个数字被两个不相关中文字符包围 → OCR病句乱码
# 但需排除常见合法组合:如"第1步"(序号)、"A1纸"(型号)等
_legal_combos = ('第', '步', '页', '行', '条', '项', '个', '只', '张', '台',
'台', '把', '件', '套', '组', '份', '篇', '章', '节',
'级', '等', '类', '种', '型', '号', '版', '次', '回')
if suffix in _legal_combos or prefix in ('第', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'):
continue # 合法组合,跳过
logger.debug(f"[ocr] 过滤器2结果: 检测到OCR病句乱码(数字混杂中文间'{prefix}{digit}{suffix}'),返回True: {text[:60]!r}")
return True
logger.debug(f"[ocr] 过滤器2结果: 未检测到噪声特征,返回False")
return False
# ⭐ OCR噪声前缀清理:去除UI按钮标签等OCR误识别的前缀
# 如"必选,运营方有哪些权利和义务?" → "运营方有哪些权利和义务?"
# 注意:前缀后必须跟标点/空格或字符串结束,不能紧跟中文字符
# 否则会误删"登录插件"中的"登录"
_OCR_NOISE_PREFIXES = re.compile(
r'^(?:必选|必填|可选|请输入|搜索|查找|查询|发送|取消|确认|确定|提交|保存|删除|编辑| '
r'|添加|新建|创建|设置|修改|返回|关闭|展开|收起|更多|选项|操作|'
r'选择|查看|下载|上传|导入|导出|刷新|重置|清空|复制|粘贴|剪切|'
r'同意|拒绝|同意并|下一步|上一步|完成|跳过|继续|登录|注册|退出|'
r'开启|关闭|启用|禁用|暂停|恢复|开始|停止|运行|'
r'是|否|有|无|对|错|真|假|好|坏|新|旧|已|未)'
r'[,,::\s]+|$'
)
@staticmethod
def _clean_ocr_prefix(text: str) -> str:
"""清理OCR噪声前缀(UI按钮标签等)。
如"必选,运营方有哪些权利和义务?" → "运营方有哪些权利和义务?"
只清理开头的短前缀(≤6字),避免误删正常消息内容。
"""
if not text or len(text) < 4:
return text
m = MessageMonitor._OCR_NOISE_PREFIXES.match(text)
if m and len(m.group(0)) <= 6:
cleaned = text[len(m.group(0)):].strip()
if cleaned and len(cleaned) >= 2:
logger.debug(f"[ocr] 清理噪声前缀: {text[:30]!r} → {cleaned[:30]!r}")
return cleaned
return text
def _paste_and_send_safe(self, text: str, hwnd: int, msg_hash: int, platform: str) -> bool:
"""Electron 应用安全发送(TIM/微信)。无 AttachThreadInput、无 SendMessage 同步点击。
Electron 多线程架构下 AttachThreadInput 会导致死锁,
SendMessage 同步阻塞在渲染进程 busy 时直接炸进程。
只用 SwitchToThisWindow + 前台确认后 keybd_event。
"""
import win32clipboard as wc
# 保存原始剪贴板
old = None
try:
wc.OpenClipboard()
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
old = wc.GetClipboardData(wc.CF_UNICODETEXT)
wc.CloseClipboard()
except Exception:
pass
success = False
try:
# 设剪贴板
try:
wc.OpenClipboard()
wc.EmptyClipboard()
wc.SetClipboardText(text, wc.CF_UNICODETEXT)
wc.CloseClipboard()
except Exception as e:
logger.error(f"[{platform}] 剪贴板设文本失败: {e}")
return False
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
# 恢复/显示窗口(仅 SwitchToThisWindow + SetForegroundWindow,无 AttachThreadInput)
try:
if win32gui.IsIconic(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
time.sleep(0.3)
if not win32gui.IsWindowVisible(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
time.sleep(0.3)
ctypes.windll.user32.AllowSetForegroundWindow(-1)
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
time.sleep(0.2)
win32gui.SetForegroundWindow(root_hwnd)
time.sleep(0.3)
except Exception as e:
logger.debug(f"[{platform}] 窗口唤醒异常: {e}")
# 验证前台 — 失败则放弃,不做 SendMessage 防崩
fg_now = win32gui.GetForegroundWindow()
fg_ok = (fg_now == root_hwnd) or (fg_now and win32gui.IsChild(root_hwnd, fg_now))
if not fg_ok:
logger.warning(f"[{platform}] 前台失败,放弃发送(不做 SendMessage 防崩)")
return False
# keybd_event 发送(只有在前台确认后才做)
VK_A = 0x41
VK_V = 0x56
VK_BACK = 0x08
VK_RETURN = 0x0D
try:
self._send_ctrl_combo(VK_A)
time.sleep(0.05)
self._key_down(VK_BACK)
self._key_up(VK_BACK)
time.sleep(0.05)
self._send_ctrl_combo(VK_V)
time.sleep(0.3)
self._key_down(VK_RETURN)
self._key_up(VK_RETURN)
time.sleep(0.5)
except Exception as e:
logger.error(f"[{platform}] keybd_event 发送失败: {e}")
return False
logger.info(f"[{platform}] 已回复(安全路径): {text[:60]}")
# 记录到消息历史数据库
self._msg_history.record_bot_reply(platform, text.strip())
# 记录回复到去重集(与 _paste_and_send 一致)
reply_text = text.strip()
self._replied_texts.add(reply_text)
self._seen_messages.add(reply_text)
if '\n' in reply_text:
self._replied_texts.add(reply_text.replace('\n', ' '))
self._replied_texts.add(reply_text.replace('\n', ' ').rstrip('.'))
self._seen_messages.add(reply_text.replace('\n', ' '))
self._seen_messages.add(reply_text.replace('\n', ' ').rstrip('.'))
for part in reply_text.split('\n'):
part = part.strip()
if part and len(part) > 3:
self._replied_texts.add(part)
self._seen_messages.add(part)
if part.startswith('#'):
no_hash = re.sub(r'^#+\s*', '', part).strip()
if no_hash and len(no_hash) > 2:
self._replied_texts.add(no_hash)
self._seen_messages.add(no_hash)
self._last_reply_time[platform] = time.time()
self._self_reply_cooldown_until[platform] = time.time() + 15.0
flat = reply_text.replace('\n', ' ')
if len(flat) > 15:
self._replied_texts.add(flat[:30])
if len(flat) > 40:
self._replied_texts.add(flat[-30:].strip())
self._foreground_cooldown_until[platform] = time.time() + 30.0
success = True
return True
finally:
# 恢复原始剪贴板
if old is not None:
try:
wc.OpenClipboard()
wc.EmptyClipboard()
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
wc.CloseClipboard()
except Exception:
pass
# 失败时记录冷却
if not success:
self._failed_send_cooldown[msg_hash] = time.time()
self._seen_messages.add(text.strip())
return True
def _paste_and_send(self, platform: str, text: str, force_retry: bool = False) -> bool:
"""粘贴文本到输入框并发送(剪贴板 + SendInput)。"""
hwnd = self._cached_hwnd.get(platform)
if not hwnd:
return False
# 失败冷却检查:60 秒内同一消息不重试(防止卡死)
# force_retry=True 时跳过冷却(用于 _execute_reply 主动重试场景)
msg_hash = hash(text) % 100000
if not force_retry:
last_fail = self._failed_send_cooldown.get(msg_hash, 0.0)
if time.time() - last_fail < 60.0:
logger.debug(f"[{platform}] 发送冷却中({int(60-(time.time()-last_fail))}s),跳过")
return False
import win32clipboard as wc
# 保存剪贴板
old = None
try:
wc.OpenClipboard()
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
old = wc.GetClipboardData(wc.CF_UNICODETEXT)
wc.CloseClipboard()
except Exception:
pass
try:
# 设置答案到剪贴板
wc.OpenClipboard()
wc.EmptyClipboard()
wc.SetClipboardText(text, wc.CF_UNICODETEXT)
wc.CloseClipboard()
# ⛔ 剪贴板回读校验:设完立即读回,确认内容一致才继续
# 防止 monitor 线程的 _read_via_clipboard_once 在设与用之间 Ctrl+C 覆盖
try:
wc.OpenClipboard()
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
verify = wc.GetClipboardData(wc.CF_UNICODETEXT)
else:
verify = None
wc.CloseClipboard()
if verify != text:
logger.error(f"[{platform}] 剪贴板校验失败: 设了{len(text)}c但读回{len(verify or '')}c,可能被其他线程覆盖")
raise RuntimeError("clipboard verification failed")
except RuntimeError:
raise
except Exception:
logger.error(f"[{platform}] 剪贴板校验异常,放弃发送")
raise
# ── Electron 安全发送路径:TIM/微信都无 AttachThreadInput、无 SendMessage ──
if (platform == "qq" and self._app_variant.get("qq") == "tim") or platform == "wechat":
return self._paste_and_send_safe(text, hwnd, msg_hash, platform)
# ── 更激进的窗口唤醒 ──
# 1) 允许任意进程 SetForegroundWindow(移除系统限制)
try:
ctypes.windll.user32.AllowSetForegroundWindow(-1) # ASFW_ANY
except Exception:
pass
# 2) 恢复/显示窗口
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
if win32gui.IsIconic(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
time.sleep(0.3)
if not win32gui.IsWindowVisible(root_hwnd):
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
time.sleep(0.3)
# 3) AttachThreadInput + SetForegroundWindow(不发送全局 Alt 键干扰 QQ 焦点)
try:
self._attach_set_foreground(root_hwnd)
except Exception as e:
logger.debug(f"[{platform}] _attach_set_foreground 异常: {e}")
time.sleep(0.2)
# 4) 再 SetForegroundWindow + SwitchToThisWindow
try:
win32gui.SetForegroundWindow(root_hwnd)
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
except Exception as e:
logger.debug(f"[{platform}] SetForegroundWindow/SwitchToThisWindow 异常: {e}")
time.sleep(0.3)
# 5) 验证 foreground — 不成功则用 SendMessage 点击 + keybd_event
VK_A = 0x41
VK_V = 0x56
VK_BACK = 0x08
VK_RETURN = 0x0D
fg_now = win32gui.GetForegroundWindow()
fg_ok = (fg_now == root_hwnd) or (fg_now and win32gui.IsChild(root_hwnd, fg_now))
if not fg_ok:
logger.warning(f"[{platform}] 前台唤醒失败(fg={fg_now}, target={root_hwnd}),SendMessage 点击+keybd_event 兜底")
# SendMessage 同步点击设置焦点(Electron 接受 SendMessage 点击)
rect = win32gui.GetWindowRect(hwnd)
if rect:
left, top, right, bottom = rect
w = right - left
h = bottom - top
click_x = int(w * 0.72)
click_y = int(h * 0.92)
lparam = win32api.MAKELONG(click_x, click_y)
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.3)
# 再试一次 SetForegroundWindow(SendMessage 点击后可能已生效)
self._attach_set_foreground(root_hwnd)
try:
win32gui.SetForegroundWindow(root_hwnd)
except Exception:
pass
time.sleep(0.2)
# keybd_event 发送(不依赖 PostMessage——Electron 不吃 PostMessage 键盘)
self._send_ctrl_combo(VK_A)
time.sleep(0.05)
self._key_down(VK_BACK)
self._key_up(VK_BACK)
time.sleep(0.05)
self._send_ctrl_combo(VK_V)
time.sleep(0.3)
self._key_down(VK_RETURN)
self._key_up(VK_RETURN)
time.sleep(0.5)
logger.info(f"[{platform}] 已回复(SendMessage+keybd): {text[:60]}")
# 记录到消息历史数据库
self._msg_history.record_bot_reply(platform, text.strip())
else:
# ── 前台成功:SendMessage 点击输入框(同步,确保焦点) + keybd_event ──
rect = win32gui.GetWindowRect(hwnd)
if rect:
left, top, right, bottom = rect
w = right - left
h = bottom - top
click_x = int(w * 0.72)
click_y = int(h * 0.92)
lparam = win32api.MAKELONG(click_x, click_y)
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
time.sleep(0.2)
# keybd_event 发送(窗口已在前台)
self._send_ctrl_combo(VK_A)
time.sleep(0.05)
self._key_down(VK_BACK)
self._key_up(VK_BACK)
time.sleep(0.05)
self._send_ctrl_combo(VK_V)
time.sleep(0.3)
self._key_down(VK_RETURN)
self._key_up(VK_RETURN)
time.sleep(0.5)
logger.info(f"[{platform}] 已回复: {text[:60]}")
# 记录到消息历史数据库
self._msg_history.record_bot_reply(platform, text.strip())
# 记录回复文本到去重集
# 将回复文本加入 _replied_texts(永久集,永不裁切,必拦)和 seen
reply_text = text.strip()
self._replied_texts.add(reply_text)
self._seen_messages.add(reply_text)
# 添加换行符归一化变体(跨频道转发时会替换 \n → 空格)
if '\n' in reply_text:
self._replied_texts.add(reply_text.replace('\n', ' '))
self._replied_texts.add(reply_text.replace('\n', ' ').rstrip('.'))
self._seen_messages.add(reply_text.replace('\n', ' '))
self._seen_messages.add(reply_text.replace('\n', ' ').rstrip('.'))
for part in reply_text.split('\n'):
part = part.strip()
if part and len(part) > 3:
self._replied_texts.add(part)
self._seen_messages.add(part)
# 同时追踪去掉 ## 前缀的标题文本(OCR 常丢失 ## 后的空格)
if part.startswith('#'):
no_hash = re.sub(r'^#+\s*', '', part).strip()
if no_hash and len(no_hash) > 2:
self._replied_texts.add(no_hash)
self._seen_messages.add(no_hash)
self._last_reply_time[platform] = time.time()
# 同平台 15s 冷却:回复回声截到也不触发
self._self_reply_cooldown_until[platform] = time.time() + 15.0
# 追加前30字符/后30字符变体(OCR 碎片常截取回复的开头或结尾)
flat = reply_text.replace('\n', ' ')
if len(flat) > 15:
self._replied_texts.add(flat[:30])
if len(flat) > 40:
self._replied_texts.add(flat[-30:].strip())
# 回复涉及前景切换(QQ Alt+Tab / 微信 SendMessage 点击)
# → 对端平台冷却 30s,避免另一平台截到相同回复内容又触发自动回复
other = "qq" if platform == "wechat" else "wechat"
self._foreground_cooldown_until[other] = time.time() + 30.0
# ⭐ 设置hash回复标志:消息已回复,允许后续相同hash帧跳过
self._last_hash_replied[platform] = True
return True
except Exception as e:
logger.error(f"[{platform}] 回复失败: {e}\n{traceback.format_exc()}")
# 进入冷却期,60 秒内不重试同一条消息
self._failed_send_cooldown[msg_hash] = time.time()
# 加入 seen_messages 防止下一轮 OCR 读到相同文本又触发
self._seen_messages.add(text.strip())
return False
finally:
# 恢复原始剪贴板
if old is not None:
try:
wc.OpenClipboard()
wc.EmptyClipboard()
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
wc.CloseClipboard()
except Exception:
pass
# ── 核心:读消息 ──────────────────────────────────────────
def _read_messages(self, window, platform: str = "") -> list:
"""每轮重新定位 ListControl。"""
if platform == "qq":
# QQ NT: OCR 读屏优先(零依赖,不切焦点,不碰剪贴板)
texts = self._ocr_read_qq()
if texts:
logger.debug(f"[qq] 消息来源: OCR → {len(texts)} 条")
return texts
# 截图未变→跳过所有 fallback(clipboard/UIA 只会读旧数据)
if self._ocr_unchanged.get("qq"):
return []
# TIM (Electron) 安全:OC R 空时跳过所有 fallback。
# 剪贴板路径调 _ensure_foreground → AttachThreadInput → 崩 TIM;
# UIA / Win32 读 Electron 窗口也无意义(结构不稳定)。
if self._app_variant.get("qq") == "tim":
return []
# OCR 空 → 剪贴板兜底(窗口置前→滚底→全选→复制,内部自带重试)
texts = self._read_via_clipboard_once(platform)
if texts and len(texts) > 5:
logger.debug(f"[qq] 消息来源: 剪贴板 → {len(texts)} 条")
return texts
# 剪贴板也空 → UIA 最后兜底(可能返回陈旧数据)
fresh_win = self._find_qq_window_fresh()
if fresh_win:
texts = self._read_message_list(fresh_win)
if texts:
logger.debug(f"[qq] 消息来源: UIA消息列表 → {len(texts)} 条")
return texts
texts = self._read_conversation_list(fresh_win)
if texts:
logger.debug(f"[qq] 消息来源: UIA会话列表 → {len(texts)} 条")
return texts
try:
rect = fresh_win.BoundingRectangle
boundary = rect.left + rect.width() * 0.40
for lst in self._list_controls(fresh_win):
try:
cr = lst.BoundingRectangle
if cr.left >= boundary:
texts = self._extract_last_line(lst)
if texts:
logger.debug(f"[S-C] ListControl(x={cr.left}) → {len(texts)} items")
return texts
except Exception:
pass
except Exception:
pass
try:
texts = self._collect_filtered_texts(fresh_win, "qq", max_depth=5)
if texts:
_before = len(texts)
texts = [t for t in texts if t not in _UI_EXACT_BLOCKLIST]
if texts:
logger.debug(f"[S-D] 全窗文本 → {_before} items ({_before - len(texts)} UI过滤)")
return texts
return []
except Exception:
pass
# Win32 兜底
texts = self._read_win32_texts(platform)
if texts:
logger.debug(f"[qq] 消息来源: Win32 → {len(texts)} 条")
return texts
return []
# 微信: OCR 读屏优先(同 QQ 策略,UIA 树不暴露消息内容)
texts = self._ocr_read_wechat()
if texts:
return texts
# 截图未变→跳过 fallback
if self._ocr_unchanged.get("wechat"):
return []
# 未提供 UIA 控件(_poll 跳过了 _fresh_window 避免 Electron 冻结)
# 此时惰性创建,仅在 OCR 失败后 fallback 时才需要
if window is None:
window = self._fresh_window(platform)
if window is None:
return []
# 微信: UIA 兜底(可能读到陈旧数据或 UI 文字)
texts = self._read_message_list(window)
if texts:
return texts
# 兜底:会话列表
texts = self._read_conversation_list(window)
if texts:
return texts
try:
rect = window.BoundingRectangle
boundary = rect.left + rect.width() * 0.40
for lst in self._list_controls(window):
try:
cr = lst.BoundingRectangle
if cr.left >= boundary:
texts = self._extract_last_line(lst)
if texts:
logger.debug(f"[S-C] ListControl(x={cr.left}) → {len(texts)} items")
return texts
except Exception:
pass
except Exception:
pass
try:
texts = self._collect_filtered_texts(window, "wechat", max_depth=5)
if texts:
# Smart-Dump 产出 UI 文本不经过 _process_ocr_output 过滤链
# → 手动过 UI 块列表
_before = len(texts)
texts = [t for t in texts if t not in _UI_EXACT_BLOCKLIST]
if texts:
logger.debug(f"[S-D] 全窗文本 → {_before} items ({_before - len(texts)} UI过滤)")
return texts
return []
except Exception:
pass
return []
def _read_message_list(self, window) -> list:
# 只接受窗口右半侧(>45%)的 ListControl,避开侧栏/文件列表用同名控件
try:
wr = window.BoundingRectangle
boundary = wr.left + wr.width() * 0.45
except Exception:
boundary = 0
for name in ["消息", "Message"]:
try:
lst = window.ListControl(Name=name)
if lst.Exists(maxSearchSeconds=0.2):
try:
cr = lst.BoundingRectangle
if cr.left < boundary:
continue # 左侧的"消息"列表(文件/笔记预览)跳过
except Exception:
pass
self._scroll_to_bottom(lst)
texts = self._extract_last_line(lst)
if texts:
logger.debug(f"[S-A] ListControl({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
return texts
except Exception:
pass
return []
def _read_conversation_list(self, window) -> list:
try:
wr = window.BoundingRectangle
boundary = wr.left + wr.width() * 0.45
except Exception:
boundary = 0
for name in ["会话", "聊天", "Chat"]:
try:
lst = window.ListControl(Name=name)
if lst.Exists(maxSearchSeconds=0.2):
try:
cr = lst.BoundingRectangle
if cr.left < boundary:
continue
except Exception:
pass
self._scroll_to_bottom(lst)
texts = self._extract_second_line(lst)
if texts:
logger.debug(f"[S-B] 会话({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
return texts
except Exception:
pass
return []
def _scroll_to_bottom(self, lst):
"""滚动 ListControl 到底部,触发应用加载新消息。"""
try:
scroll = lst.GetScrollPattern()
if scroll:
scroll.SetScrollPercent(100, 0)
except Exception:
pass
@staticmethod
def _extract_last_line(lst) -> list:
texts = []
try:
for item in lst.GetChildren():
try:
name = item.Name
if name and name.strip():
lines = [l.strip() for l in name.split("\n") if l.strip()]
if not lines:
continue
# NFKC 标准化(去除零宽字符后比较)
norm = unicodedata.normalize('NFKC', lines[-1])
# 跳过末尾按钮文字(微信消息项的"关闭"等)和时间戳
ui_known = {"关闭", "发送", "接收", "取消", "确定", "更多"}
if len(lines) >= 2 and (norm in ui_known or re.match(r'^\d{1,2}:\d{2}', norm)):
texts.append(lines[-2])
else:
texts.append(lines[-1])
except Exception:
pass
except Exception:
pass
return texts
@staticmethod
def _extract_deep_text(lst) -> list:
"""深度递归提取 ListControl 内所有控件的 Name 文本(最多3层)。"""
texts = []
def walk(ctrl, depth):
if depth > 3:
return
try:
name = ctrl.Name
if name and name.strip():
texts.append(name.strip())
except Exception:
pass
try:
for child in ctrl.GetChildren():
walk(child, depth + 1)
except Exception:
pass
try:
for item in lst.GetChildren():
walk(item, 0)
except Exception:
pass
return texts
@staticmethod
def _extract_second_line(lst) -> list:
texts = []
try:
for item in lst.GetChildren():
try:
name = item.Name
if name and name.strip():
lines = [l.strip() for l in name.split("\n") if l.strip()]
if len(lines) >= 2:
texts.append(lines[1])
elif lines:
texts.append(lines[-1])
except Exception:
pass
except Exception:
pass
return texts
@staticmethod
def _list_controls(window):
results = []
try:
for ctrl in window.GetChildren():
results.extend(_walk_list_controls(ctrl, 0))
except Exception:
pass
return results
@staticmethod
def _collect_filtered_texts(window, platform: str = "", max_depth=14) -> list:
"""深度遍历 + 位置过滤(排除搜索框/标题栏区域)。"""
items = [] # [(text, rect_top, rect_left, depth)]
def walk(ctrl, depth):
if depth > max_depth:
return
try:
cr = ctrl.BoundingRectangle
if cr.width() < 20 or cr.height() < 20:
return
except Exception:
return
try:
name = ctrl.Name
if name and name.strip():
t = name.strip()
if len(t) >= 2 and not t.isdigit():
items.append((t, cr.top, cr.left, depth))
except Exception:
pass
try:
for child in ctrl.GetChildren():
walk(child, depth + 1)
except Exception:
pass
try:
for child in window.GetChildren():
walk(child, 1)
except Exception:
pass
if not items:
return []
try:
w_rect = window.BoundingRectangle
w_width = w_rect.width()
w_height = w_rect.height()
except Exception:
w_width = 1390
w_height = 953
if platform == "qq":
filtered = [
t for t, top, left, d in items
if top > w_height * 0.08
]
seen = set()
deduped = []
for t in filtered:
if t not in seen:
seen.add(t)
deduped.append(t)
return deduped
return [t for t, _, _, _ in items]
# ── 单次轮询 ─────────────────────────────────────────────
def _poll(self, platform: str):
# ⚠️ 急停检查:OCR 期间用户可能已按 ESC 急停,必须立即折返
if not self._running:
return
# 自动回复发送中跳过轮询(防止窗口焦点被抢)
if self._sending_event.is_set():
return
# 对端平台冷却:刚切过前台(回复发送),给调试覆盖层消失时间
cooldown = self._foreground_cooldown_until.get(platform, 0.0)
if time.time() < cooldown:
return
# 冷却期:回复后 5 秒内跳过 poll,防止反馈回环
last_reply = self._last_reply_time.get(platform, 0.0)
if time.time() - last_reply < 5.0:
return
counter = self._poll_count.get(platform, 0) + 1
self._poll_count[platform] = counter
# 1. 确保窗口句柄
# qq 路径纯 Win32+psutil,直接调用不超时;wechat 用 UIA(可能挂死)才套子线程
if platform == "qq":
hwnd = self._ensure_hwnd(platform)
else:
hwnd = self._ensure_hwnd_with_timeout(platform, timeout=12.0)
if not hwnd:
if counter % 10 == 0 or (counter == 1 and platform == "qq"):
logger.debug(f"[{platform}] 窗口未找到")
return
# QQ 在托盘隐藏(IsWindowVisible=False)时跳过本轮。
# 任何尝试恢复隐藏窗口的操作(SW_SHOWNORMAL 等)在 QQ NT 上都会触发托盘预览弹窗,
# 不会恢复主聊天窗口。用户保持 QQ 窗口最小化到任务栏即可。
if platform == "qq" and hwnd and win32gui.IsWindow(hwnd) and not win32gui.IsWindowVisible(hwnd):
return
# 2. 从 HWND 创建全新 Control(绕过缓存)
# QQ: 需要 UIA 控件做多种 fallback 读取(ListControl、全窗爬文本等)
# 微信( Electron ): ControlFromHandle 构建 UIA 控件树冻结 3-5s,
# 而微信 OCR 读到消息就直接返回,不需要 UIA。只在 OCR 空/fallback 时创建。
if platform == "qq":
win = self._fresh_window(platform)
if not win:
logger.debug(f"[{platform}] 创建窗口控件失败")
return
else:
win = None # 惰性创建:OCR 失败后需要 fallback 时再建
# 3. 读消息(Windows OCR 同步阻塞,可能耗时 2-5s)
texts = self._read_messages(win, platform)
# ⚠️ 急停检查:OCR阻塞可能持续数秒,返回后立即检查,丢弃半成品
if not self._running:
return
# ⭐ hash 跳过鲁棒性:记录本轮 OCR 是否产出可处理消息。
# - 被 hash 跳过(_ocr_unchanged)→ 不动标志(沿用上一帧结果)
# - OCR 产出消息 → 标记已入队,允许后续相同 hash 帧跳过
# - OCR 运行但无消息(被过滤)→ 标记未入队,后续相同 hash 帧重新 OCR,
# 避免"捕捉到但没入队"的消息被永久跳过。
if self._ocr_unchanged.get(platform):
pass
elif texts:
self._last_hash_queued[platform] = True
# ⭐ OCR返回文本但未被回复,重置hash回复标志(等待消息被回复)
# 注意:_last_hash_replied 在消息被回复时才设置为True
if not self._last_hash_replied.get(platform, False):
self._last_hash_replied[platform] = False
else:
self._last_hash_queued[platform] = False
self._last_hash_replied[platform] = False
# ⚠️ OCR 返回后立即检查急停标志:急停可能在 OCR 阻塞期间触发,
# Windows OCR 无法被中断,但后续文本处理可以立即跳过。
if not self._running:
return
if not texts:
# 截图未变→不计入空读,不触发清缓存
if self._ocr_unchanged.get(platform):
return
empty = self._empty_reads.get(platform, 0) + 1
self._empty_reads[platform] = empty
if counter % 5 == 0:
logger.debug(f"[{platform}] 空读 #{empty}")
if empty >= 5:
self._cached_hwnd.pop(platform, None)
self._cached_pid.pop(platform, None)
self._empty_reads[platform] = 0
logger.info(f"[{platform}] 连续空读,清窗口缓存重试")
return
self._empty_reads[platform] = 0
# ⚠️ 急停检查:OCR返回文本后,处理前再查一次
if not self._running:
return
# 冻结计数器(不在 early-return 里定义,保证所有路径都能访问)
stale = 0
# 4a. 全文本签名快速跳过 — OCR 输出与上轮完全相同 → 无变化,跳过
sig = "||".join([t.strip() for t in texts if t.strip()])
if sig == self._last_sig.get(platform, ""):
# 检查是否有未回复的消息(防止未回复消息被永久跳过)
unreplied_texts = [t for t in texts if t.strip() and t.strip() not in self._replied_texts]
if not unreplied_texts:
# 所有消息均已回复,正常跳过
stale = self._stale_count.get(platform, 0) + 1
self._stale_count[platform] = stale
logger.debug(f"[{platform}] Poll: 签名相同(#{stale})静默跳过: {sig[:60]!r}")
return
else:
# 有未回复消息,继续处理(不跳过)
logger.info(f"[{platform}] Poll: 签名相同但有{len(unreplied_texts)}条未回复消息,继续处理: {[t[:30] for t in unreplied_texts]}")
# 仍然更新 stale 计数器,但不 return
stale = self._stale_count.get(platform, 0) + 1
self._stale_count[platform] = stale
# 4. 用全局 _seen_messages 去重(跨平台、跨轮次防回环)
# 精确匹配 + 模糊匹配(OCR 噪声导致精确匹配失效)
seen = self._seen_messages
replied = self._replied_texts
# ⭐ 动态获取近5分钟的bot回复,用于时间窗口查重
recent_replies = []
if self._msg_history:
try:
recent_replies = self._msg_history.get_recent_bot_replies(platform, limit=50, within_minutes=5)
except Exception as e:
logger.error(f"[{platform}] 获取近5分钟回复失败: {e}")
new_items = []
for t in texts:
ts = t.strip()
if not ts:
continue
# === URL-only 兜底:无中文且含 URL → 碎片 ===
if not re.search(r'[\u4e00-\u9fff]', ts) and re.search(r'https?\S', ts, re.IGNORECASE):
logger.debug(f"[{platform}] Poll过滤: URL-only碎片 ts={ts!r}")
# === Bot自身回复过滤:pipe 表格格式(|参数1|参数2|参数3|结果|) ===
pipe_count = ts.count('|')
if pipe_count >= 3 and len(ts) > 30:
logger.debug(f"[{platform}] Poll过滤: bot表格(pipe={pipe_count}) ts={ts!r}")
continue
# === Bot回复内容过滤:英文key=value配置格式 ===
if re.search(r'[a-zA-Z_][\w]*\s*=\s*\S', ts) and not re.search(r'[\u4e00-\u9fff]', ts):
logger.debug(f"[{platform}] Poll过滤: bot配置(key=value) ts={ts!r}")
continue
# === Bot回复内容过滤:配置文件描述片段 ===
# ⚠️ 要求同时包含配置短语 + 英文关键词 + 配置格式(=或:),避免误过滤用户讨论配置的消息
_config_phrases = ('配置此处的文件', '负责展示', '对应一个商品', '使用md文件格式',
'配置文件', '该配置', '此配置', '参数配置', '配置项')
_config_keywords = ('invite', 'referral', 'reward', 'bonus')
has_config_phrase = any(p in ts for p in _config_phrases)
has_config_keyword = re.search(r'invite|referral|reward|bonus', ts, re.IGNORECASE)
has_config_format = re.search(r'[=:]\s*\S', ts) # 包含 = 或 : 后跟非空白字符
if has_config_phrase and has_config_keyword and has_config_format:
logger.debug(f"[{platform}] Poll过滤: bot配置说明(三重匹配) ts={ts!r}")
continue
# === KB内容回读过滤:bot旧回复中的KB段落标题/内容 ===
_kb_markers = ('奖励:', '规则:', '配置:', '说明:', '要求:', '限制:', '条件:', '流程:',
'奖励:', '规则:', '配置:', '说明:', '要求:', '限制:', '条件:', '流程:')
if any(m in ts for m in _kb_markers):
logger.debug(f"[{platform}] Poll过滤: KB内容回读 ts={ts!r}")
continue
# === Bot回复内容过滤:百分比+金额描述 ===
if re.search(r'\d+\s*%', ts) and re.search(r'(金额|奖励|积分|返利|分成)', ts):
logger.debug(f"[{platform}] Poll过滤: 奖励金额描述 ts={ts!r}")
continue
# === 纯非中文乱码:OCR 阅读到终端 log 或 URL 啃碎,中文群必有中文 ===
if not re.search(r'[\u4e00-\u9fff]', ts) and len(ts) > 15 and not ts.startswith('/'):
logger.debug(f"[{platform}] Poll过滤: 无中文乱码 ts={ts!r}")
continue
# === CJK 比例过低乱码:EasyOCR 把 URL/代码认成罕见汉字混一堆字母 ===
if len(ts) > 15 and re.search(r'[A-Za-z]', ts):
cjk = sum(1 for c in ts if '\u4e00' <= c <= '\u9fff')
if cjk > 0 and cjk / max(len(ts), 1) < 0.15:
logger.debug(f"[{platform}] Poll过滤: CJK比例过低乱码 ts={ts!r}")
continue
# === 英文配置格式+乱码过滤:key=value 或 key: value 后跟乱码 ===
# 如 "invite referral bonus=5a激<5"、"config=file.txt乱码"
if re.search(r'[a-zA-Z_]{2,}\s*[=:]\s*\S+', ts):
# 检查是否包含乱码特征:特殊字符、数字字母混合、非正常英文单词
乱码特征 = False
# 特征1:等号或冒号后包含特殊字符(<>!@#$%^&*等)
if re.search(r'[=:]\s*[a-zA-Z0-9]*[<>!@#$%^&*()\[\]{}|\\/:;\'",.?~`]+', ts):
乱码特征 = True
# 特征2:等号或冒号后包含数字字母混合但无空格(如"5a激<5")
if re.search(r'[=:]\s*[a-zA-Z0-9]{3,}[^\s,.;:!?。,;:!?\u4e00-\u9fff]', ts):
乱码特征 = True
if 乱码特征:
logger.debug(f"[{platform}] Poll过滤: 英文配置+乱码 ts={ts!r}")
continue
# === 微信专属:气泡上下文验证 ===
# 微信截图可能混入 Windows 弹窗/通知/非聊天内容
# 真实用户消息通常含时间戳(14:30格式) 或 冒号分隔符(用户名:消息)
# 纯粹的碎片文本(无时间戳、无冒号、无标点结尾)→ 高概率是非消息内容
if platform == "wechat":
has_timestamp = bool(re.search(r'\d{1,2}:\d{2}', ts))
has_colon = bool(re.search(r'[::]', ts))
has_punct_end = bool(re.search(r'[。!?!?]$', ts))
# 短句(≤8字)含中文 → 可能是合法短消息(如"好的"、"嗯"),不拦截
if len(ts) <= 8 and re.search(r'[\u4e00-\u9fff]', ts):
pass # 放行短中文消息
# 无时间戳 + 无冒号 + 无标点结尾 → 高概率是非消息碎片(弹窗/通知/UI)
elif not has_timestamp and not has_colon and not has_punct_end:
# 再放宽:含问词(怎么办/为什么/能不能)或感叹词 → 可能是真实消息
if not re.search(r'怎么办|为什么|能不能|怎么|如何|什么|可以|帮忙|谢谢|好的|嗯|收到|OK|ok', ts):
logger.debug(f"[{platform}] Poll过滤: 微信非消息碎片(无上下文) ts={ts!r}")
continue
# === 斜线英文命令碎片:"/sdfl undo撤销导入" 命令帮助碎片 ===
if re.match(r'^/[A-Za-z]', ts):
logger.debug(f"[{platform}] Poll过滤: 斜线英文命令 ts={ts!r}")
continue
# === 消息中段含 /英文命令(如"怎么办?/printer玩家名")→ 指令碎片拼接 ===
if re.search(r'(?<=[\s??。!,,])/[A-Za-z]{2,}', ts):
logger.debug(f"[{platform}] Poll过滤: 斜线命令碎片(中段) ts={ts!r}")
continue
# === 下划线+斜线:OCR 左对齐气泡残影 "_ /sdfl undo" ===
if re.match(r'^_[ /]', ts):
logger.debug(f"[{platform}] Poll过滤: 下划线斜线残影 ts={ts!r}")
continue
# === OCR 误读斜线为 I/J:"/sdfl"→"Isdfl", "_ /sdfl"→下划线前缀 ===
# 常见变体:/sdfl, _ /sdfl, Isdfl, sdfl undo...
if 'sdfl' in ts.lower() or 'sdf1' in ts.lower():
logger.debug(f"[{platform}] Poll过滤: 变形命令(sdfl) ts={ts!r}")
continue
# === OCR 斜线→I 变形:/skin→Iskin, /reg→Ireg 等 ===
# 特征:CJK 上下文中出现 I+小写字母(正常中文聊天不会这样写)
# 用户不会发"使用指令Iskin"这种文本,安全砍掉不进知识库
if re.search(r'[\u4e00-\u9fff]I[a-z]{2,}', ts):
logger.debug(f"[{platform}] Poll过滤: 斜线I变形 ts={ts!r}")
continue
if re.match(r'^[A-Z][a-z]{4,}', ts) and re.search(r'[\u4e00-\u9fff]', ts) and len(ts) > 10:
logger.debug(f"[{platform}] Poll过滤: 变形命令(大写+字母) ts={ts!r}")
continue
# === 结构化数据面板:QQ/TIM 指令面板(指令:/rtp 权限类型:玩家 图标:标题:)===
colon_kv = re.findall(r'[\u4e00-\u9fff]{1,6}[::](?:[A-Za-z0-9/]|[\u4e00-\u9fff])', ts)
if len(colon_kv) >= 3:
logger.debug(f"[{platform}] Poll过滤: 结构化面板 ts={ts!r}")
continue
# === 偏旁碎片过滤:包含偏旁字符就丢弃 ===
# 偏旁字符(U+2E80-U+2FDF)在正常中文聊天中不会出现
# OCR 拆字碎片如"亻牛""彡"只要出现就是碎片
if any(_is_radical_char(ch) for ch in ts):
logger.debug(f"[{platform}] Poll过滤: 包含偏旁字符(丢弃) ts={ts!r}")
continue
# === 系统配置内容过滤:SMTP/邮件地址/代码片段不是用户消息 ===
if re.search(r'@example|smtp|\.py\b|\.bat\b|\.sh\b|//.*\.\w{2,4}\b', ts, re.IGNORECASE):
logger.debug(f"[{platform}] Poll过滤: 系统配置内容 ts={ts!r}")
continue
if re.search(r'@\w+\.\w{2,4}[,.\s]', ts) and re.search(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', ts):
logger.debug(f"[{platform}] Poll过滤: 邮件地址格式 ts={ts!r}")
continue
# === 游戏UI乱码:CJK+下划线英文、CJK+大写英文组合 → 非聊天文本 ===
if re.search(r'[\u4e00-\u9fff]', ts) and re.search(r'[A-Za-z]', ts) and len(ts) > 10:
if re.search(r'[a-zA-Z]+_[a-zA-Z]+', ts):
logger.debug(f"[{platform}] Poll过滤: 游戏UI乱码(下划线英文) ts={ts!r}")
continue
if re.search(r'[\u4e00-\u9fff][A-Z][a-z]{1,}', ts):
cjk_count = len(re.findall(r'[\u4e00-\u9fff]', ts))
alpha_count = len(re.findall(r'[A-Za-z]', ts))
if alpha_count > 0 and cjk_count < alpha_count:
logger.debug(f"[{platform}] Poll过滤: 游戏UI乱码(CJK+大写英文) ts={ts!r}")
continue
# === 绿色气泡 OCR 乱码:| 管道符混中文(正常聊天从不混用)===
if '|' in ts and re.search(r'[\u4e00-\u9fff]', ts) and len(ts) > 5:
logger.debug(f"[{platform}] Poll过滤: 管道符碎片(绿色气泡) ts={ts!r}")
continue
# === JSON/YAML 碎片兜底:OCR 截屏混入配置面板 ===
if re.match(r'^["\']?[a-zA-Z_][\w]*["\']?\s*:\s*["\d\[\{tfn]', ts):
logger.debug(f"[{platform}] Poll过滤: JSON行 ts={ts!r}")
continue
if re.match(r'^[a-zA-Z_][\w]*\s*:\s*["\w\d]', ts) and \
not re.match(r'^(https?|ftp)://', ts) and \
not re.match(r'^[a-zA-Z_][\w]*\s*:\s*/', ts):
logger.debug(f"[{platform}] Poll过滤: YAML行 ts={ts!r}")
continue
# === 纯问句碎片(如"怎么办?" "好不好?")无实质内容 ===
_pure_q = re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是不是|有没有|是什么|怎么样|在哪里)\s*[??。!]*\s*$', ts)
if _pure_q:
logger.debug(f"[{platform}] Poll过滤: 纯问句碎片 ts={ts!r}")
continue
# === 以特殊符号(~、`等)开头 → OCR把"一键"识别为"~键"等碎片 ===
if re.match(r'^[~`~]', ts):
logger.debug(f"[{platform}] Poll过滤: 特殊符号开头碎片 ts={ts!r}")
continue
# === 问句+乱码碎片拼接(如"怎么办?/printer玩家名")===
if re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)', ts):
rest = re.sub(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)\s*[??。!]*\s*', '', ts)
if rest and (re.search(r'/[A-Za-z]{2,}', rest) or re.search(r'[a-zA-Z]{3,}', rest) or len(rest) < 3):
logger.debug(f"[{platform}] Poll过滤: 问句+乱码拼接 ts={ts!r}")
continue
# === 安全阀:UI 子串过滤(兜底,防任何路径漏过)===
if any(sub in ts for sub in _UI_SUBSTR_BLOCKLIST):
logger.debug(f"[{platform}] Poll过滤: UI子串 ts={ts!r}")
continue
# === 安全阀:UI 块列表过滤(兜底,防任何路径漏过)===
if ts in _UI_EXACT_BLOCKLIST:
logger.debug(f"[{platform}] Poll过滤: UI黑名单 line={ts!r}")
seen.add(ts)
continue
# ⭐ 候选行包含 bot 自己的旧回复(OCR 把用户新消息 + bot 旧回复拼成一行)
# 例:用户问"PHP管理员端的默认密码"时屏上仍显示 bot 先前关于"全局配置"的回复,
# 两者被 OCR 拼成一条 → 直接拿去搜 KB 会命中 bot 旧回复(答非所问)。
# 剥离 bot 旧回复文本,保留用户真正的提问再去走后续过滤与 KB 搜索。
if len(ts) > 5:
stripped_bot = False
for r in replied:
if len(r) >= 6 and r in ts:
ts = re.sub(re.escape(r), ' ', ts)
stripped_bot = True
# ⭐ 反向包含剥离:OCR 截断导致 r 不是 ts 的子串,
# 但 ts 中逗号分隔的片段可能是 r 的子串
# 例: OCR="登录插件在哪下载?,从未注册过草原,使用全新的邮箱"
# r="从未注册过草原探险服务器的玩家、账号。\n使用全新的邮箱进行验证。"
# → "从未注册过草原" in r, "使用全新的邮箱" in r → 剥离这两段
_has_comma = ',' in ts or (',' in ts and re.search(r'[\u4e00-\u9fff],[\u4e00-\u9fff]', ts))
if not stripped_bot and _has_comma:
parts = re.split(r'[,,]', ts)
parts = [p.strip() for p in parts if p.strip()]
if len(parts) >= 2:
user_parts = []
for part in parts:
if len(part) < 2:
continue
is_bot_frag = False
for r in replied:
if part in r:
is_bot_frag = True
break
if not is_bot_frag:
user_parts.append(part)
if len(user_parts) < len(parts):
ts = ','.join(user_parts).strip(',').strip()
if not ts:
continue
stripped_bot = True
logger.debug(f"[{platform}] 反向包含剥离bot回复片段: {ts[:60]}")
if stripped_bot:
ts = re.sub(r'\s+', ' ', ts).strip()
if not ts:
continue
# 剥离后继续走后续过滤(不 continue),让干净的用户提问正常进入 new_items
# ⭐ 近5分钟时间窗口查重:如果消息在近5分钟的bot回复中,跳过
if recent_replies and ts in recent_replies:
logger.debug(f"[{platform}] 时间窗口查重跳过(近5分钟): {ts[:50]}")
continue
# _replied_texts 优先检查(永不裁切,100% 拦住自己的回复)
if ts in replied:
continue
# _replied_texts 模糊匹配:OCR 噪声导致精确匹配失效(长文本尤甚)
if self._fuzzy_duplicate(ts, replied, threshold=0.75):
logger.debug(f"[{platform}] 回复模糊跳过: {ts[:50]}")
continue
# _replied_texts 子串包含:OCR 提取的 md 表格细胞值(如"依赖插件可选/必选")
# 是 bot 回复表的一部分,不是用户提问。长度>5才检查避免误杀短消息。
# OCR 常把 ,转 , 等标点差异,做双向子串检查(原始+归一化标点)
if len(ts) > 5:
if any(ts in r for r in replied):
logger.debug(f"[{platform}] 回复子串包含跳过({len(ts)}c): {ts[:50]}")
continue
# 归一化标点后重试(OCR 噪声:,→, 。→. !→! ?→?)
norm_ts = ts.replace(',', ',').replace('.', '。').replace('!', '!').replace('?', '?')
if norm_ts != ts and any(norm_ts in r for r in replied):
logger.debug(f"[{platform}] 回复子串包含跳过(归一化,{len(ts)}c): {ts[:50]}")
continue
# _replied_texts 长公共子串检测:OCR 截取的碎片开头/结尾有噪声,精确/子串匹配失效
# 先试原始文本(含字母数字),再试 CJK-only(进一步降阈值)
if len(ts) > 8:
import difflib
matched_r = False
# 第一轮:原始文本匹配(阈值 6,OCR 噪声导致连续匹配可能中断)
for r in replied:
if len(r) < 5:
continue
sm = difflib.SequenceMatcher(None, ts, r)
if sm.find_longest_match(0, len(ts), 0, len(r)).size >= 6:
matched_r = True
break
# 第二轮:CJK-only 降阈值到 5(去除标点空格干扰后更稳定)
if not matched_r:
cjk_ts = ''.join(c for c in ts if '\u4e00' <= c <= '\u9fff')
if len(cjk_ts) >= 5:
for r in replied:
r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff')
if len(r_cjk) < 5:
continue
sm = difflib.SequenceMatcher(None, cjk_ts, r_cjk)
if sm.find_longest_match(0, len(cjk_ts), 0, len(r_cjk)).size >= 5:
matched_r = True
break
if matched_r:
logger.debug(f"[{platform}] 回复公共子串跳过: {ts[:50]}")
continue
if ts in seen:
continue
# 模糊匹配:OCR 输出每次微小差异(勖彐碎片、行首数字、尾部截断)
# 精确匹配命中不了 → 用 difflib 相似度去重
# ⭐ 2026-08-12: 包含问号的问句(如"PHP用户端有哪两种登录模式?")不视为模糊重复
# 用户可能重复问相同问题,允许再次处理
if not re.search(r'[??]', ts) and self._fuzzy_duplicate(ts, seen, threshold=0.80):
logger.debug(f"[{platform}] 模糊重复跳过: {ts[:50]}")
continue
# 超长文本(>200字符)跳过——大概率是堆积历史或 bot 自己的回复,不是单条新消息
if len(ts) > 200:
logger.debug(f"[{platform}] 超长文本跳过({len(ts)}c): {ts[:50]}...")
continue
if self._is_system_message(ts):
continue
# Markdown 文档过滤:bot 回复以 md 格式为主(#标题、[链接](url)、-列表项),
# 用户日常对话不用 md 语法。拦截 md 格式文本避免 bot 旧回复被重新处理。
# ⭐ 2026-08-11 先剥 md 尾巴再判:md 在末尾→切小尾巴留用户提问;
# md 大头/混中间→None 整段丢弃,确保入库的只有用户原始提问。
md_stripped = self._strip_markdown_tail(ts)
if md_stripped is None:
logger.debug(f"[{platform}] md大头整段丢弃: {ts[:50]}")
continue
if md_stripped != ts:
logger.debug(f"[{platform}] 剥离md尾巴: {ts[:50]} -> {md_stripped[:50]}")
ts = md_stripped
if self._is_markdown_document(ts):
logger.debug(f"[{platform}] md文档跳过: {ts[:50]}")
continue
# 纯装饰符号(单字符非汉字非字母数字)跳过
if len(ts) == 1 and not re.match(r'[\u4e00-\u9fff\w]', ts):
continue
# 只有符号/空格/emoji 的非消息文本(<3 字符且长度等于标点去掉后长度)
clean = re.sub(r'[\s\u2000-\u206f\u2190-\u2bff\U0001F000-\U0001FFFF✿♡♥❤★☆]', '', ts)
if len(ts) <= 3 and len(clean) == 0:
continue
seen.add(ts)
new_items.append(ts)
# _seen_messages 上限 500 条,超了裁到 300
# 防止集合无限膨胀 → 新消息模糊匹配时被历史噪声误杀
if len(seen) > 500:
# 保留后 300 条(最新)
self._seen_messages = set(list(seen)[-300:])
# 单次 poll 最多处理 3 条新消息,防止系统消息海量涌入触发回环
MAX_NEW_ITEMS = 3
new_items = new_items[:MAX_NEW_ITEMS]
# 过滤纯时间/日期文本 → 不应进入 KB 匹配(OCR 时间戳分割遗留的噪音片段)
# 这些文本在 KB 子串匹配阶段会命中文件内任何含相同数字的内容
new_items = [
it for it in new_items
if not re.match(r'^\d{1,2}[::]\d{2}(?:[::]\d{2})?$', it.strip())
and not re.match(r'^\d{4}[/-]\d{1,2}', it.strip())
]
# ⚠️ 文本处理后再次检查急停:防止急停在过滤阶段触发后仍触发回复
if not self._running:
return
# 更新 last_texts 与签名
self._last_texts[platform] = list(texts)
self._last_sig[platform] = sig
self._stale_count[platform] = 0
if new_items:
# ⚠️ 急停检查:处理新消息前最后检查,急停后立即丢弃全部动作
if not self._running:
return
# 清理过期冷却条目(TTL 60 秒)
now = time.time()
self._failed_send_cooldown = {
k: v for k, v in self._failed_send_cooldown.items()
if now - v < 60.0
}
# ⭐ 首次成功轮询(上次无消息→本次有消息):拦截历史基线
is_baseline = self._last_new_msg_time.get(platform, 0) == 0
baseline_handled = False # 基线轮只处理一次(标记第一条消息)
# ⭐ 遍历处理所有新消息(不再只取最后一条)
for item in new_items:
# ⭐ 基线轮:只标记第一条消息,不触发回复
if is_baseline and not baseline_handled:
# 基线轮:日志标记但不触发回复。
# ⚠ 四项关键处置,防止"基线吞消息后永不回复":
# 1. 从 seen 移除 item(4383 循环已 seen.add)——否则用户重发
# 相同内容 → ts in seen 永久拦截
# 2. 清空 _last_sig ——否则下一轮 sig 相同 → 4131 静默跳过,
# 用户刚发的新消息被基线误吞后永远等不到回复
# 3. 不 seen.add ——防重复由 sig 静默跳过承担
# 4. ⭐ 清除截图 hash——否则下次轮询时 hash 相同直接跳过 OCR,
# 消息永远等不到回复(截图内容没变但消息需要被处理)
logger.info(f"[{platform}] 基线↺(不回复): {item[:60]}")
seen.discard(item)
self._last_sig[platform] = ""
self._last_screenshot_hash.pop(platform, None)
self._last_new_msg_time[platform] = time.time()
baseline_handled = True
continue # 基线轮不处理,跳到下一条
# ⭐ 清理OCR噪声前缀(如"必选,")后再搜索知识库
cleaned_item = self._clean_ocr_prefix(item)
# ⭐ 检查是否与最近bot回复内容相同(OCR噪声干扰,把bot回复当作用户消息)
last_reply = self._last_bot_reply_content.get(platform, "")
if last_reply and cleaned_item and len(cleaned_item) >= 4:
similarity = SequenceMatcher(None, cleaned_item, last_reply).ratio()
if similarity >= 0.9:
logger.info(f"[{platform}] OCR噪声检测: 与最近bot回复相似度{similarity:.2%},跳过: {cleaned_item[:60]}")
seen.add(cleaned_item)
self._last_new_msg_time[platform] = time.time()
continue # 跳过本条消息,处理下一条
self._last_new_msg_time[platform] = time.time()
self._last_msg[platform] = cleaned_item
engine_tag = self._last_chosen_engine.get(platform, "")
logger.info(f"[{platform}] 新消息[{engine_tag}]: {item[:60]}")
# ⚠️ 急停检查:回复前最后检查,防止急停后仍然发送
if not self._running:
return
if self._on_message:
self._on_message(platform, cleaned_item)
if platform == "qq":
if stale == 3:
logger.debug(f"[QQ] 冻结 #{stale},强制刷新")
self._force_uia_refresh(platform)
elif stale == 6:
logger.debug(f"[QQ] 冻结 #{stale},重建窗口引用")
self._cached_hwnd.pop(platform, None)
self._cached_pid.pop(platform, None)
else:
if stale == 5:
logger.debug(f"[{platform}] 冻结 #{stale},尝试强制刷新")
self._force_uia_refresh(platform)
elif stale == 10:
logger.debug(f"[{platform}] 冻结 #{stale},重建窗口引用")
self._cached_hwnd.pop(platform, None)
self._cached_pid.pop(platform, None)
def _get_ocr_raw_dir(platform: str = "") -> str:
"""OCR 原始截图(PNG)保存目录:源码目录下的 img/raw[/platform]。
统一存源码目录(历史版本即如此,从未被 360 误报),并按 raw / processed
分目录存放,原图与处理后图片互不混杂,便于排查。
platform 参数非空时返回对应平台子目录(qq / wechat),便于分类存放、
解决定时器轮询清理时图片堆积难以寻找的问题。
"""
try:
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "img", "raw")
if platform:
base = os.path.join(base, platform)
os.makedirs(base, exist_ok=True)
except Exception:
base = os.path.dirname(os.path.abspath(__file__))
return base
def _get_ocr_processed_dir(platform: str = "") -> str:
"""OCR 处理后图片(JPG)保存目录:源码目录下的 img/processed[/platform]。"""
try:
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "img", "processed")
if platform:
base = os.path.join(base, platform)
os.makedirs(base, exist_ok=True)
except Exception:
base = os.path.dirname(os.path.abspath(__file__))
return base
def _get_easyocr_crash_log() -> str:
"""返回 easyocr_worker 崩溃日志路径,必须与 easyocr_worker.py 中 CRASH_LOG 保持一致。
放在源码目录根(easyocr_crash.log),统一管理、便于父进程读取(不写 %LOCALAPPDATA%)。
"""
try:
base = os.path.dirname(os.path.abspath(__file__))
except Exception:
base = "."
return os.path.join(base, "easyocr_crash.log")
def _truncate_easyocr_crash_log():
"""截断崩溃日志,清除旧条目。
在 worker 成功启动后调用,防止旧条目(如之前的预检拦截记录)
被 _read_easyocr_crash_tail 读出混淆用户。
"""
try:
path = _get_easyocr_crash_log()
if os.path.exists(path):
# 只保留文件最后100字节(最新一条崩溃记录)
with open(path, "r", encoding="utf-8", errors="ignore") as f:
data = f.read()
if len(data) > 100:
# 保留最近一条(从最后一个换行符开始)
last_newline = data.rfind("\n")
if last_newline > 0:
data = data[last_newline + 1:]
with open(path, "w", encoding="utf-8") as f:
f.write(data)
logger.debug("[ocr] 已截断崩溃日志,清除旧条目")
except Exception:
pass # 截断失败不阻断
def _read_easyocr_crash_tail(max_bytes: int = 2000, ignore_age: bool = False) -> str:
"""读取 easyocr_worker 崩溃日志尾部,供父进程在检测到 worker 崩溃时把原因写进主日志。
只在文件存在且较新时读取,避免每次都读陈旧历史。
ignore_age=True 用于杀进程诊断路径:worker 死前刚写的崩溃日志可能 mtime 较旧,
此时不按 60s 窗口过滤,确保把死因带出来。
"""
try:
path = _get_easyocr_crash_log()
if not os.path.exists(path):
return ""
# 只看最近 60s 内的崩溃(文件可能被很久以前的残留占据);诊断路径跳过该限制
if not ignore_age and time.time() - os.path.getmtime(path) > 60:
return ""
with open(path, "r", encoding="utf-8", errors="ignore") as f:
data = f.read()
# 取最后一段(最近一次崩溃通常在末尾)
if len(data) > max_bytes:
data = "...(截断)...\n" + data[-max_bytes:]
return data.strip()
except Exception:
return ""
def _get_easyocr_state_path() -> str:
"""返回 worker 状态文件路径,必须与 easyocr_worker.py 中 STATE_PATH 保持一致。"""
try:
base = os.path.dirname(os.path.abspath(__file__))
except Exception:
base = "."
return os.path.join(base, "easyocr_worker_state.json")
def _read_easyocr_worker_state() -> dict:
"""读取 worker 状态文件,返回死前状态(pid/mode/status/last_img_len/ts/iso/error)。
父进程杀子进程前调用,用于回答"worker 死时卡在哪个请求、卡了多久、什么模式"。
"""
try:
path = _get_easyocr_state_path()
if not os.path.exists(path):
return {}
with open(path, "r", encoding="utf-8", errors="ignore") as f:
data = f.read().strip()
if not data:
return {}
import json as _json
return _json.loads(data)
except Exception:
return {}
def _walk_list_controls(ctrl, depth):
if depth > 6:
return []
results = []
try:
cname = ctrl.ControlTypeName
except Exception:
return results
if cname == "ListControl":
results.append(ctrl)
return results
try:
for child in ctrl.GetChildren():
results.extend(_walk_list_controls(child, depth + 1))
except Exception:
pass
return results