- 修复_is_noise_text函数:改用re.search替代re.match,新增_AGREEMENT_KEYWORDS多关键词检测 - 短文本(<80字)中匹配>=2个协议特征词才拦截,提高准确率 - 加强停止信号优先级:在OCR处理各阶段添加_running检查点 - 预分割后、stage splitting循环内、逐行过滤前均检查_running - OCR返回后立即检查_running,确保急停/手动停止响应时间≤单行处理时间
6284 lines
325 KiB
Python
6284 lines
325 KiB
Python
"""消息监控 — 全列表指纹对比 + 窗口冻结检测 + 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空间', '应用宝', '兴趣部落', '同城服务',
|
||
'我的文件', '网络硬盘', '收藏夹', '好友动态',
|
||
# — 自定义插件 —
|
||
'启用监控', '停止监控', '监控已启用', '监控已停止',
|
||
# — 通用系统提示 —
|
||
'加载中', '加载失败', '正在连接', '连接失败',
|
||
'网络连接成功', '网络连接断开', '连接中',
|
||
'已连接', '已断开', '重新连接',
|
||
'消息发送失败', '消息已发出', '对方正在输入',
|
||
'点击查看', '点击进入',
|
||
'已显示全部消息', '以上是历史消息',
|
||
'你已添加了', '现在可以开始聊天了',
|
||
'当前网络不可用', '请检查网络设置',
|
||
'操作失败', '操作成功', '请求超时',
|
||
# — 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) -> list:
|
||
"""获取该平台最近的bot回复内容列表,用于查库比对"""
|
||
conn = self._get_conn()
|
||
try:
|
||
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
|
||
# 引擎选择计数器
|
||
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()
|
||
|
||
# 剪贴板内容 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 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("消息监控已停止")
|
||
|
||
@property
|
||
def is_running(self) -> bool:
|
||
return self._running
|
||
|
||
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)
|
||
|
||
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:
|
||
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,防"正常释放"误触发
|
||
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 # 成功启动,重置启动失败计数
|
||
# 计数 + 主动重启: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)
|
||
# inner timeout 必须 < 外层 _OCR_TIMEOUT (10s),
|
||
# 否则线程永远在外层 join 超时前不会结束 → 永远 "线程存活,跳过"。
|
||
_inner_timeout = max(_OCR_TIMEOUT - 1, 1)
|
||
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]
|
||
|
||
# 日志(原始输出逐行记录,用於長期對比兩引擎能力)
|
||
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 []
|
||
|
||
# ⭐ 颜色像素检测:如果截图中只有 bot 蓝色气泡,无白色用户消息,跳过
|
||
if not self._has_user_colors(im, "qq"):
|
||
self._ocr_unchanged["qq"] = True
|
||
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"):
|
||
self._ocr_unchanged["wechat"] = True
|
||
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 = []
|
||
for l in raw_text.split('\n'):
|
||
l = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', l).strip()
|
||
if l:
|
||
ocr_lines.append(l)
|
||
|
||
# —— 连续行合并(修复 EasyOCR 逗号断行) ——
|
||
# EasyOCR 对长消息在中文逗号","处拆行,导致前半句无时间戳被丢弃。
|
||
# 合并规则:前一行不以句末标点结尾 且 后一行不以时间戳开头 → 合并
|
||
# ⚠ 修复漏消息:前一行若本身是"时间戳行"(如 16;28:40),绝不与其后的消息合并,
|
||
# 否则时间戳被黏进消息文本、re.split 错位,导致该消息被误判为尾部落入旧时间戳、
|
||
# 进而被"继承时间戳过老"丢弃(漏掉用户刚发的新消息)。
|
||
_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 # 跨行继承:上一行时间戳是否来自过去日期前缀
|
||
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)
|
||
# 移除中文日期前缀(昨天、前天、星期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
|
||
rl = re.sub(r'(\d{1,2})[.;](\d{2})(?:[.;](\d{2}))?', r'\1:\2:\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 = ''
|
||
last_ts = _inherited_ts # 继承前一行的时间戳
|
||
# ⭐ 继承的过去日期标记:若前一阶段有"昨天"等过去前缀,继承的时间戳也跳过未来检查
|
||
_inherited_past = _had_past_date_prefix or getattr(self, '_inherited_ts_from_past', False)
|
||
for part in parts:
|
||
if re.match(r'^\d{1,2}:\d{1,2}', part):
|
||
if buf.strip():
|
||
if not last_ts:
|
||
# ⭐ 无前置时间戳:位于首个时间戳之前的消息。
|
||
# ⚠ 不再整段丢弃:可能是被裁切/漏识别时间戳的新消息,
|
||
# 交由下游 seen/_replied_texts 去重(基线轮会吸收历史)。
|
||
logger.debug(f"[ocr] stage[{i}] 无前置时间戳归属,交由下游去重: {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():
|
||
logger.info(f"[ocr] stage[{i}] [age] DROP 继承时间戳{last_ts}较老(>300s),直接丢弃: {buf.strip()[:60]!r}")
|
||
buf = ''
|
||
last_ts = part
|
||
continue
|
||
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
|
||
if self._is_markdown_document(buf.strip()):
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过: {buf.strip()[:80]!r}")
|
||
elif self._is_noise_text(buf.strip()):
|
||
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过: {buf.strip()[:80]!r}")
|
||
else:
|
||
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip())
|
||
if msg:
|
||
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
|
||
if msg:
|
||
if 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:
|
||
# 尾部无任何时间戳(整行无时间戳):交给下游去重,不整段丢弃
|
||
logger.debug(f"[ocr] stage[{i}] [age] PASS 尾部无时间戳归属,交由下游去重: {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():
|
||
logger.info(f"[ocr] stage[{i}] [age] DROP 尾部时间戳{last_ts}较老(>300s),直接丢弃: {buf.strip()[:60]!r}")
|
||
buf = ''
|
||
# else: 时间戳新鲜(<5min),直接保留
|
||
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
|
||
if buf.strip():
|
||
if self._is_markdown_document(buf.strip()):
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过(尾部): {buf.strip()[:80]!r}")
|
||
elif self._is_noise_text(buf.strip()):
|
||
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过(尾部): {buf.strip()[:80]!r}")
|
||
else:
|
||
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip())
|
||
if msg:
|
||
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
|
||
if msg:
|
||
if 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)
|
||
# 更新跨行继承时间戳
|
||
if last_ts:
|
||
_inherited_ts = last_ts
|
||
# ⭐ 若本阶段有过去日期前缀,继承的时间戳也标记为来自过去日期
|
||
self._inherited_ts_from_past = _had_past_date_prefix
|
||
else:
|
||
self._inherited_ts_from_past = False
|
||
|
||
# —— 逐行过滤 ——
|
||
# ⚠️ 急停检查: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
|
||
|
||
# —— 末尾 #标题 碎片兜底 ——
|
||
# 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 自己刚发的回复 ——
|
||
# 用 SequenceMatcher 找 OCR 行与 replied 文本的最长公共子串
|
||
# 先试原始文本(含字母数字),再试 CJK-only(阈值 6,防短碎片漏网)
|
||
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)
|
||
if sm.find_longest_match(0, len(line), 0, len(r)).size >= 6:
|
||
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)
|
||
if sm.find_longest_match(0, len(cjk_line), 0, len(r_cjk)).size >= 5:
|
||
is_match = True
|
||
break
|
||
if is_match:
|
||
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.9:
|
||
logger.debug(f"[{platform}] OCR 过滤: bot自身消息(查库重合率{ratio:.2f}) line={line[:60]!r}")
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] OCR 查库过滤异常: {e}")
|
||
|
||
# === 全面 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)
|
||
if not has_q:
|
||
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 >= 2
|
||
|
||
|
||
@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 // 15)
|
||
step_y = max(2, ch // 15)
|
||
|
||
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 >= 245 and g >= 245 and b >= 245:
|
||
# ⚠️ 纯白像素可能是:①对方白色气泡(背景/白字) ②聊天浅灰背景
|
||
# ③绿色/蓝色自身气泡上的白色文字。
|
||
# ②③ 不能算"对方消息"——否则微信自身绿气泡白字被误判为对方
|
||
# → 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 = logging.getLogger("msgbot.monitor")
|
||
rlogger.debug(f"[{platform}] 像素: 左其它={left_other}/{left_total} "
|
||
f"{'自身' if platform=='wechat' else 'bot'}={left_bot} 白={left_white} | "
|
||
f"右其它={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
|
||
if total_green >= 3:
|
||
rlogger.debug(f"[qq] 像素检测: 检测到微信绿色像素(绿={total_green}), 判定为微信窗口, 跳过")
|
||
return False
|
||
|
||
# 左半区有白色气泡 → 有用户消息,必须OCR
|
||
if left_white >= 3:
|
||
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 _is_reply_fragment(self, text: str) -> bool:
|
||
"""检查文本是否为 bot 回复内容的碎片(OCR 截取的 md 残片/跨行碎片)。"""
|
||
logger.debug(f"[ocr] 过滤器4: _is_reply_fragment 检查: {text[:80]!r}")
|
||
if not self._replied_texts:
|
||
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
|
||
# 原始文本匹配(含字母数字)
|
||
for r in self._replied_texts:
|
||
if len(r) < 5:
|
||
continue
|
||
sm = difflib.SequenceMatcher(None, text, r)
|
||
if sm.find_longest_match(0, len(text), 0, len(r)).size >= 6:
|
||
logger.debug(f"[ocr] 过滤器4结果: 原始文本匹配成功,返回True")
|
||
return True
|
||
# CJK-only 降阈值
|
||
cjk_t = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
|
||
if len(cjk_t) >= 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_t, r_cjk)
|
||
if sm.find_longest_match(0, len(cjk_t), 0, len(r_cjk)).size >= 5:
|
||
logger.debug(f"[ocr] 过滤器4结果: CJK文本匹配成功,返回True")
|
||
return True
|
||
logger.debug(f"[ocr] 过滤器4结果: 未匹配到已回复文本,返回False")
|
||
return False
|
||
|
||
@staticmethod
|
||
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 等)
|
||
normalized = re.sub(r'(\d{1,2})[.;](\d{2})(?:[.;](\d{2}))?', r'\1:\2:\3', normalized)
|
||
timestamps = re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', normalized)
|
||
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
|
||
# 无bot回复上下文,但消息较短且为中文,可能是新消息
|
||
if len(stripped) <= 20:
|
||
logger.debug(f"[ocr] 过滤器1结果: 无时间戳但为短中文消息(≤20字),放行: {stripped[:80]!r}")
|
||
self._msg_history.record_user_message(platform, stripped)
|
||
return True
|
||
logger.debug(f"[ocr] 过滤器1结果: 无时间戳且长消息(>20字),丢弃: {stripped[:80]!r}")
|
||
return False
|
||
# 如果检测到过去日期(昨天或更早),时间戳不应该是"未来时间戳"
|
||
# 直接放行,让后续逻辑判断是否过期
|
||
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
|
||
|
||
@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)
|
||
|
||
@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
|
||
# 内联 # # 标题标记(如 "# # 必装依赖" → 分割后 `# #` 或 `# #`)
|
||
if re.search(r'#\s*#', text):
|
||
return True
|
||
return False
|
||
|
||
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
|
||
# 图片路径引用:、!0(Jimg/...)、=等
|
||
if re.search(r'!\s*\[.*?\]\s*\(.*?\)', text):
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到图片路径引用模式1,返回True")
|
||
return True
|
||
# 简化图片路径:!0(Jimg/...) 或 
|
||
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系统进程名/窗口标题:任务管理器、控制面板、文件资源管理器等
|
||
if re.search(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|蓝湖|墨刀)',
|
||
text):
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到Windows系统进程名,返回True")
|
||
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截断)
|
||
if text.count('[') != text.count(']') or text.count('(') != text.count(')') or text.count('(') != text.count(')'):
|
||
# 仅当文本较短时才判定为病句(避免误杀正常聊天)
|
||
if len(text) < 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
|
||
# ⭐ 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
|
||
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, ""):
|
||
stale = self._stale_count.get(platform, 0) + 1
|
||
self._stale_count[platform] = stale
|
||
logger.debug(f"[{platform}] Poll: 签名相同(#{stale})静默跳过: {sig[:60]!r}")
|
||
return
|
||
|
||
# 4. 用全局 _seen_messages 去重(跨平台、跨轮次防回环)
|
||
# 精确匹配 + 模糊匹配(OCR 噪声导致精确匹配失效)
|
||
seen = self._seen_messages
|
||
replied = self._replied_texts
|
||
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
|
||
# _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 相似度去重
|
||
if 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 旧回复被重新处理。
|
||
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
|