【问题】当前 _strip_qq_nickname 只支持前缀匹配(startswith), 但 OCR 输出中昵称可能出现在文本的任何位置,导致部分昵称无法剥离。 【场景复现】 - 输入: '=>草原探险服务器备用号游戏专用有问题,他们常用的登录插件是在哪里下载的?' - 期望: '有问题,他们常用的登录插件是在哪里下载的?' - 实际(修复前): 部分昵称残留,污染知识库检索 【修复方案】 在 _strip_qq_nickname 主循环中新增步骤5(全局匹配兜底): 1. 前缀匹配优先处理常见粘连格式 2. 若前缀匹配无变化,启用全局正则匹配扫描文本中间 3. 使用前后边界检查避免误删正文中的正常文本 4. 限制最大迭代次数(20次)防止无限循环 【测试验证】 ✓ '=>草原探险服务器备用号游戏专用有个问题...' → '有个问题...' ✓ '草原探险服务器备用号游戏专用有个问题...' → '有个问题...' ✓ '你好草原探险服务器备用号,我想问...' → '我想问...' ✓ '谢谢游戏专用,这个问题解决了' → '这个问题解决了' 【新增non_core_kw】口语填充词过滤(2026-08-16) - '有个', '问题', '常用', '他们', '各位', '大家', '小伙伴' - 防止口语填充虚增核心关键词数量导致误拒绝 【早期昵称剥离】(2026-08-16) - 在_process_ocr_output开头调用_strip_qq_nickname - 防止昵称污染时间戳检测和行合并逻辑 - 位置:垃圾检测之前(第3172行)
7999 lines
427 KiB
Python
7999 lines
427 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)
|
||
|
||
|
||
def _load_radical_mapping() -> dict:
|
||
"""从 unicode_radicals.md 加载偏旁合体映射 + 常用OCR错误修正。
|
||
|
||
返回: {OCR识别的偏旁组合: 正确汉字}
|
||
例如: {'讠吾': '语', '亻牛': '件', '彳亍': '行'}
|
||
|
||
文件格式:
|
||
| 偏旁 | Unicode | 名称 | 示例 | 补充字符 |
|
||
|------|---------|------|------|----------|
|
||
| 亻 | U+4EBB | 单人旁 | 件→亻牛 | 件 |
|
||
↑ 索引3 ↑ 索引4
|
||
|
||
示例列格式:'正确汉字→OCR识别的偏旁组合'
|
||
例如:'件→亻牛' 表示 OCR 把 '件' 识别成了 '亻牛'
|
||
合体映射应为:'亻牛' → '件'
|
||
|
||
⭐ 2026-08-14 补充常用 OCR 错误映射(文件中未记录的常见错误):
|
||
- '亻尔' → '你'(OCR 常把 '你' 拆成 '亻尔')
|
||
- '讠人' → '认'(OCR 常把 '认' 拆成 '讠人')
|
||
"""
|
||
radical_map = {}
|
||
try:
|
||
with open(_RADICALS_MD, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
if not line.startswith("|"):
|
||
continue
|
||
parts = [p.strip() for p in line.strip().split("|")]
|
||
# parts[0] = 空, parts[1] = 偏旁, parts[2] = Unicode,
|
||
# parts[3] = 名称, parts[4] = 示例, parts[5] = 补充字符
|
||
if len(parts) >= 6 and "→" in parts[4]:
|
||
example = parts[4]
|
||
if "→" in example:
|
||
# 示例格式:'件→亻牛' 或 '乍→作'
|
||
# 意思是:正确汉字 → OCR识别的偏旁组合
|
||
left, right = example.split("→")
|
||
left = left.strip() # 正确汉字: '件'
|
||
right = right.strip() # OCR错误: '亻牛'
|
||
if right and left:
|
||
radical_map[right] = left
|
||
except Exception as e:
|
||
logger.warning(f"[ocr] 加载偏旁合体映射失败({e})")
|
||
|
||
# ⭐ 补充常用 OCR 错误映射(文件中未记录的常见错误)
|
||
common_ocr_errors = {
|
||
"亻尔": "你", # OCR 常把 '你' 拆成 '亻尔'
|
||
"讠人": "认", # OCR 常把 '认' 拆成 '讠人'
|
||
"讠我": "认", # OCR 常把 '认' 拆成 '讠我'(方言口音)
|
||
}
|
||
radical_map.update(common_ocr_errors)
|
||
|
||
logger.debug(f"[ocr] 偏旁合体映射: {len(radical_map)} 条")
|
||
return radical_map
|
||
|
||
|
||
_RADICAL_RESTORE_MAP = _load_radical_mapping()
|
||
|
||
|
||
def _restore_radical_text(text: str) -> str:
|
||
"""尝试合体还原偏旁碎片。
|
||
|
||
例如:
|
||
- '讠吾' → '语'
|
||
- '亻尔' → '你'
|
||
- '彳亍' → '行'
|
||
|
||
策略:
|
||
- 只匹配长度 ≥ 2 的偏旁组合(避免单字符误匹配)
|
||
- 按长度降序匹配,避免短模式误匹配长模式
|
||
"""
|
||
restored = text
|
||
# ⭐ 过滤掉单字符映射,避免误还原(如 '儿' → '凵')
|
||
multi_char_map = {k: v for k, v in _RADICAL_RESTORE_MAP.items() if len(k) >= 2}
|
||
for ocr_form, correct in sorted(multi_char_map.items(), key=lambda x: -len(x[0])):
|
||
restored = restored.replace(ocr_form, correct)
|
||
return restored
|
||
|
||
import subprocess
|
||
import json
|
||
import psutil
|
||
import os
|
||
import random
|
||
from datetime import datetime, timedelta
|
||
import signal
|
||
from PIL import Image, ImageGrab, ImageEnhance, ImageFilter
|
||
from winrt.windows.media.ocr import OcrEngine
|
||
from winrt.windows.globalization import Language
|
||
from winrt.windows.graphics.imaging import SoftwareBitmap, BitmapPixelFormat
|
||
|
||
if not _HAS_EASYOCR:
|
||
logger.debug("[ocr] EasyOCR 未就绪,仅使用 Windows OCR")
|
||
|
||
# ===== 主动防御: 全平台 UI 按钮/标签/面板标题黑名单 =====
|
||
# 微信/TIM/QQ 各界面按钮、标签、状态栏文本,OCR 读到后直接丢弃。
|
||
# 仅精确匹配(整行),零误杀风险。
|
||
_UI_EXACT_BLOCKLIST = frozenset({
|
||
# — 微信 顶栏/导航 —
|
||
'微信', '通讯录', '发现', '我',
|
||
# — 微信 发现页 —
|
||
'朋友圈', '视频号', '看一看', '搜一搜', '直播', '附近',
|
||
'扫一扫', '摇一摇', '小程序',
|
||
# — 微信 我页 —
|
||
'服务', '购物', '收藏', '卡包', '表情', '游戏中心', '设置',
|
||
'切换账号', '退出登录',
|
||
# — 微信 设置页 —
|
||
'开启群聊验证', '朋友权限', '停用', '更新',
|
||
'存储空间', '清空缓存', '帮助与反馈',
|
||
# — 微信 桌面版面板 —
|
||
'小程序面板', '朋友圈', # 紧凑版面板标题
|
||
# — 微信 聊天会话列表 —
|
||
'新的朋友', '群聊', '标签', '公众号', '我的设备', '我的收藏',
|
||
# — 微信 输入栏 —
|
||
'输入', '按住说话', '松开发送',
|
||
# — 微信 聊天附加功能 —
|
||
'视频通话', '语音通话', '个人名片', '位置共享',
|
||
'红包', '转账', '相机', '相册', '文件', '音乐',
|
||
# — 微信 群聊信息面板 —
|
||
'群聊名称', '群二维码', '群公告', '群聊信息', '群成员',
|
||
'全部', '未读', '置顶', '免打扰', '置顶聊天',
|
||
'我在本群的昵称', '显示群成员昵称',
|
||
'聊天记录', '查找聊天记录', '聊天文件', '清空聊天记录',
|
||
'投诉', '成员', '邀请', '添加成员', '移出群聊', '解散群聊',
|
||
# — 微信 公众号 —
|
||
'关注', '赞', '在看', '分享', '留言', '作者',
|
||
'阅读原文', '不让看', '取消关注',
|
||
# — TIM/QQ 顶栏/侧栏 —
|
||
'消息', '联系人', '勋章墙', '收藏', '文件', '设置',
|
||
# — TIM/QQ 状态 —
|
||
'在线', '离线', '忙碌', '隐身', 'Q我吧', '离开',
|
||
# — TIM/QQ 搜索 —
|
||
'搜索', '查找', '搜索聊天记录',
|
||
# — TIM/QQ 联系人/群管理 —
|
||
'我的设备', '我的收藏', '最近联系人', '创建群聊',
|
||
'添加好友', '添加群', '添加群聊', '创建讨论组',
|
||
# — TIM/QQ 聊天窗口 —
|
||
'截图', '文件传输', '远程桌面', '远程控制',
|
||
'发送文件', '发送图片',
|
||
'关闭', '最小化', '最大化',
|
||
'窗口抖动', '抖动',
|
||
# — TIM/QQ 面板 —
|
||
'QQ会员', 'QQ空间', '应用宝', '兴趣部落', '同城服务',
|
||
'我的文件', '网络硬盘', '收藏夹', '好友动态',
|
||
# — 自定义插件 —
|
||
'启用监控', '停止监控', '监控已启用', '监控已停止',
|
||
# — 通用系统提示 —
|
||
'加载中', '加载失败', '正在连接', '连接失败',
|
||
'网络连接成功', '网络连接断开', '连接中',
|
||
'已连接', '已断开', '重新连接',
|
||
'消息发送失败', '消息已发出', '对方正在输入',
|
||
'点击查看', '点击进入',
|
||
'已显示全部消息', '以上是历史消息',
|
||
'你已添加了', '现在可以开始聊天了',
|
||
'当前网络不可用', '请检查网络设置',
|
||
'操作失败', '操作成功', '请求超时',
|
||
# — QQ 加载状态提示 —
|
||
'正在加载页面,请稍后',
|
||
# — Smart-Dump 漏网 —
|
||
'手机',
|
||
# — QQ 系统通知 —
|
||
'有人@我',
|
||
})
|
||
|
||
# ===== 主动防御: UI 子串黑名单(含其中任何子串即跳过)=====
|
||
# 这些词在用户日常聊天中极不可能出现,只在 UI 面板/系统标签中出现。
|
||
# 防止 OCR 碎片精确匹配失败但子串命中。
|
||
_UI_SUBSTR_BLOCKLIST = frozenset({
|
||
'启用监控', '停止监控', '数据看板', '应用设置',
|
||
'债券总和', '注册用户总数', 'php管理员', '消息漫游',
|
||
# QQ 群"共同好友"推荐(非聊天内容,OCR误读触发查库)
|
||
'共同好友', '点击添加好友',
|
||
# 微信/QQ "拍了拍" 系统通知(UI按钮触发,非用户消息)
|
||
'拍了拍',
|
||
})
|
||
|
||
|
||
# ⭐ 2026-08-12: OCR 鲁棒去重——中文骨架
|
||
# OCR 常把拉丁字符混淆(php↔4p、o↔0、l↔1、I↔1),导致:
|
||
# 1) 字符级 SequenceMatcher 相似度跌破 90% 门槛(如 php→4p 整句 ~83%),
|
||
# 5 分钟重复识别去重失效;
|
||
# 2) is_user_message_replied 的精确 MD5 哈希因单字符差异而不同,DB 查重放行。
|
||
# 中文是语义主体,去掉所有非中文字符后比较「骨架」,可吸收这类拉丁混淆,
|
||
# 把同一句话的不同 OCR 版本正确识别为重复,杜绝「已回复消息被重复回复」。
|
||
_CN_RE = re.compile(r'[\u4e00-\u9fff]')
|
||
def _cn_skeleton(text: str) -> str:
|
||
"""提取中文骨架:只保留中文字符,忽略拉丁/数字/标点/OCR 易混淆差异。
|
||
|
||
返回空串表示无中文(调用方应跳过骨架匹配,避免误拦纯外文消息)。
|
||
"""
|
||
if not text:
|
||
return ""
|
||
return ''.join(_CN_RE.findall(text))
|
||
|
||
|
||
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:
|
||
# ⭐ 2026-08-12: 精确匹配未命中 → 中文骨架匹配(吸收 OCR 拉丁混淆 php↔4p)
|
||
# 同一句话的不同 OCR 版本(php / 4p)中文骨架相同,应判定为已回复,
|
||
# 否则会被当作「新消息」重复查库回复(自言自语)。
|
||
sk = _cn_skeleton(content)
|
||
if len(sk) >= 6:
|
||
for bot_content in self.get_recent_bot_replies(platform, limit=50):
|
||
if _cn_skeleton(bot_content) == sk:
|
||
# 骨架命中:若用户消息时间晚于该 bot 回复时间,视为新消息放行
|
||
if user_msg_time > 0:
|
||
try:
|
||
c2 = conn.execute(
|
||
"SELECT created_at FROM message_history WHERE platform=? AND sender_type='bot' AND content=? ORDER BY id DESC LIMIT 1",
|
||
(platform, bot_content)
|
||
)
|
||
row = c2.fetchone()
|
||
if row and row[0]:
|
||
from datetime import datetime as _dt
|
||
bot_time = _dt.strptime(row[0], "%Y-%m-%d %H:%M:%S").timestamp()
|
||
if user_msg_time > bot_time:
|
||
continue # 用户消息更新,视为新消息
|
||
except Exception:
|
||
pass
|
||
return True
|
||
return False # 没有相同内容的bot回复,未被回复过
|
||
|
||
# 如果有相同内容的bot回复,但用户消息时间戳晚于最后bot回复时间,则视为新消息
|
||
if user_msg_time > 0:
|
||
cursor = conn.execute(
|
||
"SELECT created_at FROM message_history WHERE platform=? AND sender_type='bot' ORDER BY id DESC LIMIT 1",
|
||
(platform,)
|
||
)
|
||
row = cursor.fetchone()
|
||
if row and row[0]:
|
||
# ⭐ Task #1442: 正确解析本地时间
|
||
# 现在created_at存储的是本地时间(如"2026-08-06 14:30:00")
|
||
try:
|
||
from datetime import datetime
|
||
bot_time_str = row[0]
|
||
bot_time = datetime.strptime(bot_time_str, "%Y-%m-%d %H:%M:%S").timestamp()
|
||
# 如果用户消息时间戳晚于最后bot回复时间,视为新消息
|
||
if user_msg_time > bot_time:
|
||
return False
|
||
except Exception:
|
||
pass # 时间解析失败,按原逻辑处理
|
||
|
||
return True
|
||
finally:
|
||
conn.close()
|
||
|
||
def last_bot_reply_time(self, platform: str) -> float:
|
||
"""获取该平台最近一次bot回复的时间戳(Unix时间)"""
|
||
conn = self._get_conn()
|
||
try:
|
||
cursor = conn.execute(
|
||
"SELECT created_at FROM message_history WHERE platform=? AND sender_type='bot' ORDER BY id DESC LIMIT 1",
|
||
(platform,)
|
||
)
|
||
row = cursor.fetchone()
|
||
if row:
|
||
# ⭐ Task #1442: 正确解析本地时间
|
||
# 现在created_at存储的是本地时间(如"2026-08-06 14:30:00")
|
||
# 直接解析即可,无需时区转换
|
||
from datetime import datetime
|
||
dt = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S')
|
||
return dt.timestamp()
|
||
return 0.0
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_recent_bot_replies(self, platform: str, limit: int = 20, within_minutes: int = None) -> list:
|
||
"""获取该平台最近的bot回复内容列表,用于查库比对
|
||
|
||
Args:
|
||
within_minutes: 只返回最近N分钟内的回复,None表示不过滤时间
|
||
"""
|
||
conn = self._get_conn()
|
||
try:
|
||
if within_minutes is not None and within_minutes > 0:
|
||
from datetime import datetime, timedelta
|
||
five_minutes_ago = (datetime.now() - timedelta(minutes=within_minutes)).strftime("%Y-%m-%d %H:%M:%S")
|
||
cursor = conn.execute(
|
||
"SELECT content FROM message_history WHERE platform=? AND sender_type='bot' AND created_at > ? ORDER BY id DESC LIMIT ?",
|
||
(platform, five_minutes_ago, limit)
|
||
)
|
||
else:
|
||
cursor = conn.execute(
|
||
"SELECT content FROM message_history WHERE platform=? AND sender_type='bot' ORDER BY id DESC LIMIT ?",
|
||
(platform, limit)
|
||
)
|
||
return [row[0] for row in cursor.fetchall() if row[0]]
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
class MessageMonitor:
|
||
"""多线程消息监控器"""
|
||
|
||
def __init__(self, on_message: Optional[Callable[[str, str], None]] = None):
|
||
self._running = False
|
||
self._thread: Optional[threading.Thread] = None
|
||
self._qq_thread: Optional[threading.Thread] = None
|
||
self._emergency_thread: Optional[threading.Thread] = None
|
||
self._poll_interval = 2.0
|
||
self._on_message = on_message
|
||
self._wechat_enabled = True
|
||
self._qq_enabled = False
|
||
|
||
# 全列表签名 & 完整文本列表 (用于新消息比对)
|
||
self._last_sig: dict = {}
|
||
self._last_texts: dict = {}
|
||
# 最新消息文本(供 GUI 展示)
|
||
self._last_msg: dict = {}
|
||
# 最近一次bot回复内容(用于检测OCR噪声导致的重复)
|
||
self._last_bot_reply_content: dict = {} # platform → content
|
||
|
||
# 轮询计数
|
||
self._poll_count: dict = {}
|
||
# QQ 轮询跳过计数(每 3 周期 poll 一次)
|
||
self._qq_skip_counter = 0
|
||
|
||
# 窗口句柄缓存(存 HWND,不存 Control 引用)
|
||
self._cached_hwnd: dict = {}
|
||
# 窗口对应进程 ID(验证窗口是否仍属同一进程)
|
||
self._cached_pid: dict = {}
|
||
# 窗口未找到冷却截止时间(time.time 值),期内跳过搜索
|
||
self._window_not_found_until: dict = {}
|
||
# 连续相同签名计数(用于冻结检测)
|
||
self._stale_count: dict = {}
|
||
# 连续空读
|
||
self._empty_reads: dict = {}
|
||
# 剪贴板原始文本(用于自动回复提取发送者)
|
||
self._raw_clipboard: dict = {}
|
||
|
||
# 发送中事件锁(防止自动回复时监控轮询干扰)
|
||
self._sending_event = threading.Event()
|
||
# 应用变体:qq→"qq_nt"或"tim"
|
||
self._app_variant: dict = {}
|
||
|
||
# 已处理消息的全局持久化集合(跨平台、跨轮次,防回环)
|
||
self._seen_messages: set = set()
|
||
# 已回复文本的永久集—永不裁切(剪贴板读到自己回复时必拦)
|
||
self._replied_texts: set = set()
|
||
# 最近已处理的消息(用于5分钟内OCR重复识别去重)
|
||
self._recent_processed_messages = [] # list of (text, timestamp)
|
||
# 语义指纹缓存(同屏OCR碎片30秒去重)
|
||
self._semantic_fingerprint_cache = [] # list of (fingerprint, timestamp)
|
||
# 上次回复时间戳(冷却期内跳过该平台 poll)
|
||
self._last_reply_time: dict = {}
|
||
# 同平台新消息冷却:同一 poll 或相邻 poll 内多条消息只处理第一条
|
||
self._last_new_msg_time: dict = {}
|
||
self._new_msg_cooldown: float = 3.0 # 同平台 3 秒内不重复处理
|
||
# 发送失败消息冷却:msg_hash → 失败时间,冷却期内不再尝试回复(防止卡死)
|
||
self._failed_send_cooldown: dict = {} # platform → timestamp
|
||
|
||
# 跨平台前台冷却:某平台刚切过前台(回复后),对端平台等待此时间再截图
|
||
self._foreground_cooldown_until: dict = {} # platform → float(time.time)
|
||
# 同平台回复冷却:回复后此秒数内同平台跳过 OCR(防读到自身回复回声)
|
||
self._self_reply_cooldown_until: dict = {} # platform → float(time.time)
|
||
|
||
# 本轮选中的 OCR 引擎(用于日志标记)
|
||
self._last_chosen_engine: dict = {} # platform → "windows"|"easyocr"
|
||
|
||
# OCR 引擎(Windows 原生,lazy init)
|
||
self._ocr_engine = None
|
||
# EasyOCR 备用引擎(lazy init)
|
||
self._easyocr_reader = None
|
||
# EasyOCR 子进程(GPU crash 隔离,崩溃只杀子进程不冻电脑)
|
||
self._easyocr_proc: Optional['subprocess.Popen'] = None
|
||
self._easyocr_broken: bool = False
|
||
# ⭐ Task #1446:worker 是否已完成 ready 握手(就绪感知,避免未就绪即派发请求)
|
||
self._easyocr_ready: bool = False
|
||
self._easyocr_ready_event = threading.Event() # 握手完成信号,并发等待线程用
|
||
self._easyocr_ready_timeout: float = 60.0 # 等待握手的最长预算(=初始化预算,不计入请求超时)
|
||
self._easyocr_starting: bool = False # 是否有线程正在拉起+Popen 子进程
|
||
# EasyOCR 子进程崩溃冷却:崩溃后 30s 内不重试(防快速崩溃循环刷屏)
|
||
self._easyocr_retry_until: float = 0.0
|
||
# EasyOCR 子进程调用计数:累积 GPU 内存泄漏,每 MAX 次主动重启清缓存
|
||
self._easyocr_call_count: int = 0
|
||
self._easyocr_max_calls: int = 3 # 最多 3 次后重启子进程
|
||
# EasyOCR 连续失败计数:前2次保留子进程(预留初始化时间),第3次杀进程重来
|
||
self._easyocr_consecutive_failures: int = 0
|
||
self._easyocr_max_failures: int = 3 # 连续3次失败后杀进程
|
||
# EasyOCR 连续启动失败计数:子进程无法启动时递增,达到阈值时清除冷却重试
|
||
self._easyocr_consecutive_startup_failures: int = 0
|
||
self._easyocr_max_startup_failures: int = 5 # 连续5次启动失败后长冷却(内存不足场景)
|
||
# EasyOCR CUDA 错误计数:≥2 次 OOM/kernel error → 下次启动强制 CPU(防冻结)
|
||
self._easyocr_cuda_oom_count: int = 0
|
||
# EasyOCR 正常调度重启标志:正常资源释放后重启子进程时跳过内存检测
|
||
self._easyocr_normal_restart: bool = False
|
||
# EasyOCR 动态超时:每次超时增加15秒,成功后重置为45秒,最大120秒
|
||
self._easyocr_current_timeout: float = 45.0
|
||
self._easyocr_min_timeout: float = 45.0
|
||
self._easyocr_max_timeout: float = 120.0
|
||
self._easyocr_timeout_step: float = 15.0
|
||
# 引擎选择计数器
|
||
self._ocr_engine_wins: dict = {"windows": 0, "easyocr": 0}
|
||
# 上次 OCR 原始文本(用于增量检测)
|
||
self._ocr_last_text: dict = {}
|
||
|
||
# OCR 垃圾连续计数(连续垃圾超过阈值清缓存重找)
|
||
self._ocr_garbage_count: dict = {}
|
||
|
||
# 截图 hash(像素级,截图不变→OCR 必然无新消息)
|
||
self._last_screenshot_hash: dict = {} # platform → str
|
||
self._ocr_unchanged: dict = {} # platform → bool(本轮因截图不变跳过)
|
||
self._last_ocr_time: dict = {} # platform → float(上次真正做OCR的时间戳)
|
||
# ⭐ hash 跳过鲁棒性:上一帧"已捕捉且已入队"才允许后续相同 hash 帧跳过。
|
||
# 若上一帧 OCR 捕捉到但未被入队(被时间戳/噪声过滤),则不跳过,
|
||
# 让后续相同 hash 帧重新做 OCR,避免因永久跳过导致消息丢失。
|
||
self._last_hash_queued: dict = {} # platform → bool(上一帧 OCR 是否产出可处理消息)
|
||
self._last_hash_replied: dict = {} # platform → bool(上一帧消息是否已被回复)
|
||
|
||
# OCR 截屏定时清理:保存后不再立即删(高频 create+delete 易触发 360 误报),
|
||
# 改由 _maybe_clean_ocr_dir 每 30~60 秒随机间隔清理一次过期截图。
|
||
self._next_ocr_clean_at: float = 0.0
|
||
self._ocr_clean_ttl: float = 60.0 # 保留最近 60 秒内的截图,更早的删除
|
||
|
||
# EasyOCR 子进程锁(QQ/微信独立线程共用同一子进程)
|
||
self._easyocr_lock = threading.Lock()
|
||
# ⭐ Task #1446:串行化子进程启动/Popen,防并发双 Popen 与「未就绪快路径」竞态
|
||
self._easyocr_startup_lock = threading.Lock()
|
||
# QQ 独立轮询线程(不阻塞微信主循环)
|
||
self._qq_thread: Optional[threading.Thread] = None
|
||
# 双击 ESC 急停线程
|
||
self._emergency_thread: Optional[threading.Thread] = None
|
||
|
||
# 消息历史数据库(用于无时间戳消息放行判断)
|
||
self._msg_history = MessageHistory()
|
||
# 启动时加载历史回复到 _replied_texts,防止重启后旧回复被重新处理
|
||
self._load_replied_texts_from_db()
|
||
|
||
# 剪贴板内容 hash(防止跨平台读到过时数据)
|
||
self._last_clipboard_hash: dict = {} # platform → str(hash)
|
||
# 紧急停止回调(通知 GUI 更新状态)
|
||
self._emergency_stop_callback: Optional[Callable[[], None]] = None
|
||
# 底层键盘钩子回调引用(防止 GC 回收导致系统崩溃)
|
||
self._esc_hook_proc = None
|
||
|
||
def _load_replied_texts_from_db(self):
|
||
"""从消息历史数据库加载最近的bot回复到 _replied_texts,
|
||
防止重启后旧回复被OCR重新识别并处理。
|
||
"""
|
||
if not self._msg_history:
|
||
return
|
||
try:
|
||
# 加载两个平台的最近回复
|
||
for platform in ["qq", "wechat"]:
|
||
recent_replies = self._msg_history.get_recent_bot_replies(platform, limit=50)
|
||
for reply in recent_replies:
|
||
if reply and len(reply) >= 6:
|
||
self._replied_texts.add(reply)
|
||
# 添加常见变体(与发送时一致)
|
||
if '\n' in reply:
|
||
self._replied_texts.add(reply.replace('\n', ' '))
|
||
self._replied_texts.add(reply.replace('\n', ' ').rstrip('.'))
|
||
for part in reply.split('\n'):
|
||
part = part.strip()
|
||
if part and len(part) > 3:
|
||
self._replied_texts.add(part)
|
||
flat = reply.replace('\n', ' ')
|
||
if len(flat) > 15:
|
||
self._replied_texts.add(flat[:30])
|
||
if len(flat) > 40:
|
||
self._replied_texts.add(flat[-30:].strip())
|
||
logger.info(f"[msg-history] 加载历史回复到去重集: {len(self._replied_texts)} 条")
|
||
except Exception as e:
|
||
logger.error(f"[msg-history] 加载历史回复失败: {e}")
|
||
|
||
def set_poll_interval(self, seconds: float):
|
||
self._poll_interval = max(0.5, seconds)
|
||
|
||
def set_enabled_platforms(self, wechat: bool = True, qq: bool = False):
|
||
self._wechat_enabled = wechat
|
||
self._qq_enabled = qq
|
||
|
||
def set_callback(self, cb: Callable[[str, str], None]):
|
||
self._on_message = cb
|
||
|
||
def set_emergency_callback(self, cb: Callable[[], None]):
|
||
self._emergency_stop_callback = cb
|
||
|
||
def start(self, full_restart: bool = False):
|
||
"""启动监控。
|
||
|
||
Args:
|
||
full_restart: True=彻底重启(进程级,重新检测内存恢复GPU)
|
||
False=急停/手动停止恢复(软重启,不重新检测内存)
|
||
"""
|
||
if self._running:
|
||
return
|
||
self._running = True
|
||
# ⭐ 重置 EasyOCR 状态:确保停止/急停后双引擎仍能搭档作业
|
||
# stop() 会杀死子进程,但如果此时有 _call_easyocr_safe 在运行,
|
||
# BrokenPipeError 会设置 _easyocr_broken=True,导致重启后 EasyOCR 永久不可用
|
||
self._easyocr_broken = False
|
||
self._easyocr_retry_until = 0.0 # 清除崩溃冷却
|
||
self._easyocr_call_count = 0
|
||
self._easyocr_consecutive_failures = 0 # ⭐ 清除连续超时计数(stop/急停后不残留)
|
||
self._easyocr_consecutive_startup_failures = 0 # ⭐ 清除连续启动失败计数
|
||
|
||
# ⭐ 彻底重启时重新检测内存,尝试恢复GPU模式
|
||
# 急停/手动停止恢复时保持上次状态,不重新检测
|
||
if full_restart:
|
||
self._easyocr_cuda_oom_count = 0 # 重新评估 GPU/CPU
|
||
logger.info("[ocr] 彻底重启:重新检测内存,尝试恢复GPU模式")
|
||
else:
|
||
# 急停/手动停止恢复:保持上次的降级状态(如果上次因OOM降级CPU,这次仍用CPU)
|
||
logger.info(f"[ocr] 软重启:保持上次EasyOCR状态(cuda_oom_count={self._easyocr_cuda_oom_count})")
|
||
|
||
self._easyocr_proc = None # 确保 clean state
|
||
self._easyocr_ready = False
|
||
self._easyocr_ready_event.clear()
|
||
self._easyocr_starting = False
|
||
# ⭐ OCR 状态清零:截图hash/垃圾计数/引擎选择/冷却等,确保重启不残留上一轮状态
|
||
# (上次 CUDA 报错降级 CPU 的计数、超时计数、截图指纹都从头开始)
|
||
self._last_screenshot_hash.clear()
|
||
self._ocr_unchanged.clear()
|
||
self._last_ocr_time.clear()
|
||
self._last_hash_queued.clear() # ⭐ 重置 hash 跳过鲁棒性标志
|
||
self._last_hash_replied.clear() # ⭐ 重置 hash 回复标志
|
||
self._ocr_garbage_count.clear()
|
||
self._ocr_last_text.clear()
|
||
self._last_chosen_engine.clear()
|
||
self._self_reply_cooldown_until.clear()
|
||
self._ocr_engine_wins = {"windows": 0, "easyocr": 0}
|
||
# OCR 截屏定时清理:启动后 30~60 秒随机触发第一次
|
||
self._next_ocr_clean_at = time.time() + random.uniform(30, 60)
|
||
# ⭐ 启动线程错峰:5个线程同时拉起导致 GIL 竞争,GUI 冻结 6-7 秒。
|
||
# 方案:按优先级分批启动,批间 sleep(0.5) 让 GUI 主线程有机会刷新。
|
||
# 第1批(立即):微信主轮询(核心功能) + 急停热键(安全)
|
||
# 第2批(0.5s):QQ轮询(独立平台,不依赖微信)
|
||
# 第3批(1.0s):EasyOCR子进程预热(重操作,延迟避免挤占GUI)
|
||
|
||
# 第1批:核心线程
|
||
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="monitor")
|
||
self._thread.start()
|
||
self._emergency_thread = threading.Thread(target=self._esc_check_loop, daemon=True, name="monitor-esc")
|
||
self._emergency_thread.start()
|
||
|
||
# 第2批:QQ轮询(延迟0.5s,让GUI有机会刷新 + 避免同时初始化Windows消息队列)
|
||
def _delayed_qq_start():
|
||
time.sleep(0.5)
|
||
if self._running:
|
||
self._qq_thread = threading.Thread(target=self._qq_poll_loop, daemon=True, name="monitor-qq")
|
||
self._qq_thread.start()
|
||
threading.Thread(target=_delayed_qq_start, daemon=True, name="qq-delay").start()
|
||
|
||
# 第3批:EasyOCR预热(延迟1s,最重的操作放最后)
|
||
if _HAS_EASYOCR and not self._easyocr_broken:
|
||
def _delayed_easyocr_start():
|
||
time.sleep(1.0)
|
||
if self._running:
|
||
self._ensure_easyocr_subprocess()
|
||
threading.Thread(target=_delayed_easyocr_start, daemon=True, name="easyocr-delay").start()
|
||
|
||
logger.info("消息监控已启动")
|
||
|
||
def stop(self):
|
||
self._running = False
|
||
if self._thread and self._thread.is_alive():
|
||
self._thread.join(timeout=3)
|
||
self._thread = None
|
||
if self._qq_thread and self._qq_thread.is_alive():
|
||
self._qq_thread.join(timeout=3)
|
||
self._qq_thread = None
|
||
if self._emergency_thread and self._emergency_thread.is_alive():
|
||
# Post WM_QUIT 唤醒 GetMessage 阻塞,让钩子线程退出
|
||
try:
|
||
ctypes.windll.user32.PostThreadMessageW(
|
||
self._emergency_thread.ident, 0x0012, 0, 0 # WM_QUIT = 0x0012
|
||
)
|
||
except Exception:
|
||
pass
|
||
self._emergency_thread.join(timeout=2)
|
||
self._emergency_thread = None
|
||
# 杀死 EasyOCR 子进程(防止 GPU 推理残留吃资源/冻电脑)
|
||
self._kill_easyocr_proc("stop")
|
||
# ⭐ 重置 broken 标记:stop 后 start 应能重新拉起 EasyOCR
|
||
self._easyocr_broken = False
|
||
self._easyocr_retry_until = 0.0 # 清除崩溃冷却
|
||
self._easyocr_call_count = 0
|
||
self._easyocr_consecutive_failures = 0 # ⭐ 清除连续超时计数(stop/急停后不残留)
|
||
self._easyocr_consecutive_startup_failures = 0 # ⭐ 清除连续启动失败计数
|
||
self._easyocr_cuda_oom_count = 0 # 重启后重新评估 GPU/CPU
|
||
|
||
# 释放钩子回调引用(防止 GC 延迟导致卸载后崩溃)
|
||
if hasattr(self, '_esc_hook_proc'):
|
||
del self._esc_hook_proc
|
||
self._cached_hwnd.clear()
|
||
self._cached_pid.clear()
|
||
self._last_sig.clear()
|
||
self._last_msg.clear()
|
||
self._stale_count.clear()
|
||
self._empty_reads.clear()
|
||
self._poll_count.clear()
|
||
logger.info("消息监控已停止")
|
||
|
||
# 主动释放内存,交还系统重新分配调度
|
||
self._release_memory()
|
||
|
||
@property
|
||
def is_running(self) -> bool:
|
||
return self._running
|
||
|
||
def _release_memory(self):
|
||
"""主动释放内存,交还系统重新分配调度。只保留GUI需要的基本内存。"""
|
||
import gc
|
||
import ctypes
|
||
import sys
|
||
|
||
# 强制垃圾回收
|
||
gc.collect()
|
||
|
||
# Windows 特定:使用 EmptyProcessWorkingSet 释放工作集内存
|
||
try:
|
||
# 获取当前进程句柄
|
||
kernel32 = ctypes.windll.kernel32
|
||
psapi = ctypes.windll.psapi
|
||
|
||
# EmptyProcessWorkingSet 函数:将进程工作集最小化
|
||
# 参数:进程句柄,最小值(-1表示当前进程),最大值(-1表示当前进程)
|
||
# 返回:非零表示成功
|
||
kernel32.SetProcessWorkingSetSize(
|
||
kernel32.GetCurrentProcess(),
|
||
-1, # 最小工作集:允许系统回收所有可回收页面
|
||
-1 # 最大工作集:允许系统回收所有可回收页面
|
||
)
|
||
logger.debug("[memory] 已调用 SetProcessWorkingSetSize 释放工作集内存")
|
||
except Exception as e:
|
||
logger.debug(f"[memory] SetProcessWorkingSetSize 失败: {e}")
|
||
|
||
# 清空 EasyOCR 相关缓存
|
||
if hasattr(self, '_easyocr_proc') and self._easyocr_proc:
|
||
# 子进程已杀,但引用可能残留
|
||
self._easyocr_proc = None
|
||
|
||
# 清空大对象引用
|
||
for attr in ['_last_texts', '_seen_messages', '_replied_texts']:
|
||
if hasattr(self, attr):
|
||
getattr(self, attr).clear()
|
||
if hasattr(self, '_semantic_fingerprint_cache'):
|
||
self._semantic_fingerprint_cache.clear()
|
||
|
||
# 再次垃圾回收
|
||
gc.collect()
|
||
|
||
logger.info("[memory] 内存释放完成,已交还系统")
|
||
|
||
def _kill_easyocr_proc(self, reason: str = ""):
|
||
"""安全杀死 EasyOCR 子进程:terminate→等待→kill→GC,防 CUDA 内存泄漏。
|
||
|
||
terminate() 发 SIGTERM 让 worker 有机会执行 atexit/signal 清理 CUDA 缓存;
|
||
3s 后仍存活则 kill() 强杀;最后 gc.collect() 释放主进程侧引用。
|
||
|
||
⭐ 杀前诊断:先收集 worker 状态文件 + 残留 stderr + 崩溃日志尾部,
|
||
汇总写入主日志(warning 级别),确保任何崩溃/超时杀进程都有迹可循,
|
||
杜绝"一声不吭就结束进程"导致后续排障无据。
|
||
"""
|
||
import gc
|
||
import threading
|
||
if not self._easyocr_proc or self._easyocr_proc.poll() is not None:
|
||
self._easyocr_proc = None
|
||
return
|
||
_tag = f"[ocr] 杀 EasyOCR 子进程({reason})" if reason else "[ocr] 杀 EasyOCR 子进程"
|
||
_proc = self._easyocr_proc
|
||
|
||
# ── 杀前诊断:收集 worker 死前状态,避免"一声不吭" ──
|
||
_diag = []
|
||
try:
|
||
_state = _read_easyocr_worker_state()
|
||
if _state:
|
||
_st = _state.get("status", "?")
|
||
_iso = _state.get("iso", "")
|
||
_mode = _state.get("mode", "?")
|
||
_img_len = _state.get("last_img_len", 0)
|
||
_err = _state.get("error", "")
|
||
_now = time.time()
|
||
_age = _now - float(_state.get("ts", _now)) if _state.get("ts") else None
|
||
_age_s = f",距现在{_age:.1f}s" if _age is not None else ""
|
||
_diag.append(
|
||
f"worker死前状态={_st}(模式{_mode}, 上次img长度{_img_len}, 更新于{_iso}{_age_s})"
|
||
+ (f", 错误={_err}" if _err else "")
|
||
)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
# 非阻塞+限时读 worker 残留 stderr(崩溃前最后打印的内容),不阻塞杀进程
|
||
if _proc.stderr is not None:
|
||
from queue import Queue, Empty as _QE
|
||
_q = Queue()
|
||
|
||
def _drain_stderr():
|
||
try:
|
||
_b = _proc.stderr.read(65536)
|
||
_q.put(_b or "")
|
||
except Exception:
|
||
_q.put("")
|
||
|
||
_dt = threading.Thread(target=_drain_stderr, daemon=True)
|
||
_dt.start()
|
||
_dt.join(timeout=0.5)
|
||
_stderr_buf = ""
|
||
if not _dt.is_alive():
|
||
try:
|
||
_stderr_buf = _q.get_nowait()
|
||
except _QE:
|
||
pass
|
||
if isinstance(_stderr_buf, str) and _stderr_buf.strip():
|
||
_diag.append(f"worker残留stderr={_stderr_buf.strip()[-500:]}")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
# 崩溃日志尾部(kill 诊断路径不过 60s 窗口过滤)
|
||
_crash = _read_easyocr_crash_tail(ignore_age=True)
|
||
if _crash:
|
||
_diag.append(f"崩溃日志={_crash}")
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
# 1) 先 terminate 让 worker 执行 GPU 清理(atexit/signal handler)
|
||
_proc.terminate()
|
||
try:
|
||
_proc.wait(timeout=3)
|
||
logger.debug(f"{_tag}: terminate 后正常退出")
|
||
except Exception:
|
||
# 2) 3s 内未退出 → 强杀
|
||
try:
|
||
_proc.kill()
|
||
_proc.wait(timeout=3)
|
||
logger.warning(f"{_tag}: terminate 无效,已 kill")
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
logger.debug(f"{_tag} 异常: {e}")
|
||
|
||
# 杀后:把诊断信息汇总写入主日志(warning 确保可见)
|
||
if _diag:
|
||
logger.warning(f"{_tag} 诊断: " + " | ".join(_diag))
|
||
else:
|
||
logger.debug(f"{_tag}: 无可用诊断信息(worker 未留下状态文件/崩溃日志)")
|
||
|
||
self._easyocr_proc = None
|
||
# ⭐ Task #1446:进程已销毁 → 清除就绪标志与握手信号,强制后续调用重新等待/握手
|
||
self._easyocr_ready = False
|
||
self._easyocr_ready_event.clear()
|
||
self._easyocr_starting = False
|
||
# 3) 主进程 GC:释放 PIL Image / numpy array 等残留引用
|
||
try:
|
||
gc.collect()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 内存看门狗(诊断用,定位泄漏源)──────────────────────────
|
||
|
||
def _start_memory_watchdog(self):
|
||
"""内存+资源看门狗:周期性记录主进程 RSS/线程数/GDI 句柄/关键缓存,
|
||
用于定位内存只涨不跌的泄漏源(如永久存活的 OCR 线程持有大图)。"""
|
||
try:
|
||
import threading
|
||
import psutil
|
||
import win32api
|
||
import win32process
|
||
except Exception:
|
||
threading = psutil = win32api = win32process = None
|
||
|
||
def _watch():
|
||
while self._running:
|
||
try:
|
||
rss_mb = 0.0
|
||
n_threads = 0
|
||
gdi = 0
|
||
if psutil is not None:
|
||
try:
|
||
_proc = psutil.Process()
|
||
rss_mb = _proc.memory_info().rss / (1024 * 1024)
|
||
n_threads = _proc.num_threads()
|
||
except Exception:
|
||
pass
|
||
if win32api is not None and win32process is not None:
|
||
try:
|
||
_h = win32api.GetCurrentProcess()
|
||
gdi = win32process.GetGuiResources(_h, 0) # 0 = GDI 对象数
|
||
except Exception:
|
||
pass
|
||
_extra = (f"hash={len(self._last_screenshot_hash)} "
|
||
f"seen={len(getattr(self, '_seen_messages', set()))} "
|
||
f"garbage={dict(self._ocr_garbage_count)} "
|
||
f"easyocr_alive={self._easyocr_proc is not None and self._easyocr_proc.poll() is None}")
|
||
logger.warning(f"[mem-watch] RSS={rss_mb:.0f}MB threads={n_threads} gdi={gdi} {_extra}")
|
||
except Exception:
|
||
pass
|
||
for _ in range(30):
|
||
if not self._running:
|
||
break
|
||
time.sleep(1)
|
||
|
||
if threading is not None:
|
||
_t = threading.Thread(target=_watch, daemon=True, name="mem-watch")
|
||
_t.start()
|
||
logger.info("[mem-watch] 内存看门狗已启动(每30s记录 RSS/线程/GDI)")
|
||
|
||
# ── 轮询主循环 ──────────────────────────────────────────
|
||
|
||
def _run_loop(self):
|
||
# 内存看门狗:周期性记录主进程资源占用,便于排查内存只涨不跌的泄漏
|
||
self._start_memory_watchdog()
|
||
# 创建 Windows 消息队列(使 AttachThreadInput / SetForegroundWindow 在 daemon 线程生效)
|
||
try:
|
||
ctypes.windll.user32.PeekMessageW(None, 0, 0, 0, 1) # PM_NOREMOVE
|
||
except Exception:
|
||
pass
|
||
heartbeat = 0
|
||
logger.debug("_run_loop 进入 while 循环")
|
||
while self._running:
|
||
try:
|
||
# OCR 截屏定时清理(每 30~60 秒随机触发,删除过期截图)
|
||
self._maybe_clean_ocr_dir()
|
||
if self._wechat_enabled and not self._sending_event.is_set():
|
||
self._poll("wechat")
|
||
except Exception as e:
|
||
logger.error(f"轮询异常: {e}\n{traceback.format_exc()}")
|
||
|
||
heartbeat += 1
|
||
if heartbeat % 10 == 0:
|
||
logger.info(f"[♡] 心跳 #{heartbeat}")
|
||
time.sleep(self._poll_interval)
|
||
|
||
def _qq_poll_loop(self):
|
||
"""QQ 独立轮询线程:不阻塞微信 OCR,每轮间隔 ~2s。
|
||
|
||
QQ OCR 截图通常较小(裁剪后),EasyOCR 耗时 1-3s;
|
||
微信 OCR 每轮 ~15-20s。分开线程后 QQ 响应从 ~34s 降到 ~3-5s。
|
||
"""
|
||
try:
|
||
ctypes.windll.user32.PeekMessageW(None, 0, 0, 0, 1)
|
||
except Exception:
|
||
pass
|
||
# 随机 stagger 3-5s,避免 QQ/微信同时 OCR 抢镜
|
||
stagger = random.uniform(3, 5)
|
||
logger.debug(f"[qq] 轮询延迟 {stagger:.1f}s 与微信错开")
|
||
time.sleep(stagger)
|
||
while self._running:
|
||
try:
|
||
if self._qq_enabled and not self._sending_event.is_set():
|
||
self._poll("qq")
|
||
except Exception as e:
|
||
logger.error(f"QQ轮询异常: {e}\n{traceback.format_exc()}")
|
||
time.sleep(self._poll_interval)
|
||
|
||
def _maybe_clean_ocr_dir(self):
|
||
"""OCR 截屏定时清理:每 30~60 秒随机触发一次,删除超过保留期的过期截图。
|
||
|
||
保存截图后不再立即按数量清理(高频 create+delete 循环易触发 360 勒索启发式误报),
|
||
改为低频定时批量清理:保留最近 _ocr_clean_ttl 秒内的截图,删除更早的全部
|
||
(img/raw 原图 + img/processed 处理后图都清)。每次清理后随机设置下一次
|
||
触发时间(30~60 秒),避免固定周期被安全软件识别为规律行为。
|
||
"""
|
||
now = time.time()
|
||
if now < self._next_ocr_clean_at:
|
||
return
|
||
# 下一次清理时间:随机 30~60 秒后
|
||
self._next_ocr_clean_at = now + random.uniform(30, 60)
|
||
cutoff = now - self._ocr_clean_ttl
|
||
# 清理平台子目录(当前存放位置)+ 根目录(历史遗留文件)
|
||
dirs_to_clean = [
|
||
_get_ocr_raw_dir("qq"), _get_ocr_raw_dir("wechat"),
|
||
_get_ocr_processed_dir("qq"), _get_ocr_processed_dir("wechat"),
|
||
_get_ocr_raw_dir(), _get_ocr_processed_dir(),
|
||
]
|
||
for d in dirs_to_clean:
|
||
try:
|
||
for f in os.listdir(d):
|
||
p = os.path.join(d, f)
|
||
try:
|
||
if os.path.getmtime(p) < cutoff:
|
||
os.remove(p)
|
||
logger.debug(f"[ocr] 定时清理过期截图: {f}")
|
||
except OSError:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 双击 ESC 急停 ──────────────────────────────────────
|
||
|
||
def _esc_check_loop(self):
|
||
"""独立 daemon 线程:RegisterHotKey 全局热键检测双击 ESC → 强制急停。
|
||
|
||
使用 RegisterHotKey 注册全局热键 + GetMessage 消息泵,
|
||
不依赖窗口焦点,任何进程按 ESC 都能捕获。
|
||
比 WH_KEYBOARD_LL 更可靠(避免 ctypes 64位调用约定问题)。
|
||
"""
|
||
from ctypes import wintypes
|
||
|
||
_VK_ESCAPE = 0x1B
|
||
_WM_HOTKEY = 0x0312
|
||
_HOTKEY_ID_ESC = 0x4553 # 'ES'
|
||
|
||
# ⭐ 步骤1: 创建消息队列
|
||
_msg = wintypes.MSG()
|
||
ctypes.windll.user32.PeekMessageW(ctypes.byref(_msg), None, 0, 0, 0)
|
||
|
||
# ⭐ 步骤2: 注册全局热键(hwnd=NULL → 投递到线程消息队列)
|
||
# MOD_NOREPEAT = 0x4000:长按不重复触发,只有按下时触发一次
|
||
ok = ctypes.windll.user32.RegisterHotKey(None, _HOTKEY_ID_ESC, 0x4000, _VK_ESCAPE)
|
||
if not ok:
|
||
err = ctypes.windll.kernel32.GetLastError()
|
||
# err=1409 = ERROR_HOTKEY_ALREADY_REGISTERED,其他热键已占 ESC
|
||
logger.error("[急停] RegisterHotKey 失败 (err=%d) — ESC 急停不可用", err)
|
||
return
|
||
logger.info("[急停] 全局热键注册成功,双击 ESC 急停已激活")
|
||
|
||
# ⭐ 步骤3: 消息泵 — 检测双击 ESC
|
||
last_esc_time = 0.0
|
||
while self._running:
|
||
ret = ctypes.windll.user32.GetMessageW(ctypes.byref(_msg), None, 0, 0)
|
||
if ret <= 0:
|
||
break
|
||
if _msg.message == _WM_HOTKEY and _msg.wParam == _HOTKEY_ID_ESC:
|
||
now = time.time()
|
||
if last_esc_time > 0 and (now - last_esc_time) < 0.5:
|
||
last_esc_time = 0.0
|
||
logger.warning("⚠️ 双击ESC急停触发!强制终止所有操作...")
|
||
self._emergency_stop()
|
||
else:
|
||
last_esc_time = now
|
||
|
||
# 线程退出 → 注销热键
|
||
ctypes.windll.user32.UnregisterHotKey(None, _HOTKEY_ID_ESC)
|
||
|
||
def _emergency_stop(self):
|
||
"""强制停止监控+杀死 EasyOCR+清空发送锁。"""
|
||
self._running = False
|
||
|
||
# 释放发送锁(防止被 hang 的发送操作卡住)
|
||
self._sending_event.clear()
|
||
|
||
# 杀死 EasyOCR 子进程(GPU 推理可能卡死不响应)
|
||
self._kill_easyocr_proc("急停")
|
||
|
||
# ⭐ 清零全部 OCR/EasyOCR 计数器:急停后不残留(下次启动从头评估 GPU/CPU)
|
||
# CUDA 报错降级 CPU 的计数、连续超时计数、截图指纹等全部归零
|
||
self._easyocr_broken = False
|
||
self._easyocr_retry_until = 0.0 # 清除崩溃冷却
|
||
self._easyocr_call_count = 0
|
||
self._easyocr_consecutive_failures = 0 # 清除连续超时计数
|
||
self._easyocr_consecutive_startup_failures = 0 # 清除连续启动失败计数
|
||
self._easyocr_cuda_oom_count = 0 # 清除 CUDA 错误计数(不继承本次降级)
|
||
self._last_screenshot_hash.clear()
|
||
self._ocr_unchanged.clear()
|
||
self._last_ocr_time.clear()
|
||
self._last_hash_queued.clear() # ⭐ 重置 hash 跳过鲁棒性标志
|
||
self._last_hash_replied.clear() # ⭐ 重置 hash 回复标志
|
||
self._ocr_garbage_count.clear()
|
||
self._ocr_last_text.clear()
|
||
self._last_chosen_engine.clear()
|
||
self._self_reply_cooldown_until.clear()
|
||
self._ocr_engine_wins = {"windows": 0, "easyocr": 0}
|
||
|
||
# 通知回调(GUI 更新状态)
|
||
try:
|
||
if self._emergency_stop_callback:
|
||
self._emergency_stop_callback()
|
||
except Exception as e:
|
||
logger.debug(f"[急停] 回调异常: {e}")
|
||
|
||
logger.warning("⚠️ 急停完成 — 消息监控已停止,点击开关可重新启动")
|
||
|
||
# ── 窗口定位 ─────────────────────────────────────────────
|
||
|
||
def _ensure_hwnd(self, platform: str) -> Optional[int]:
|
||
"""查找窗口并返回 HWND(不缓存 Control)。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
try:
|
||
if hwnd:
|
||
if win32gui.IsWindow(hwnd):
|
||
# 验证窗口进程是否仍属于目标平台
|
||
if platform == "wechat":
|
||
if self._validate_wechat_window(hwnd):
|
||
return hwnd
|
||
elif platform == "qq":
|
||
if self._validate_qq_window(hwnd):
|
||
return hwnd
|
||
else:
|
||
return hwnd
|
||
except Exception:
|
||
pass
|
||
|
||
# 仅清 HWND,保留 PID 供后续 EnumWindows 快速复用
|
||
old_pid = self._cached_pid.get(platform)
|
||
self._cached_hwnd.pop(platform, None)
|
||
|
||
# 非阻塞:窗口未找到冷却期内直接跳过搜索,不浪费 12s 超时
|
||
cooldown_until = self._window_not_found_until.get(platform, 0)
|
||
if time.time() < cooldown_until:
|
||
return None
|
||
|
||
if platform == "wechat":
|
||
# ── 快速路径:用缓存 PID 直接 EnumWindows(毫秒级,跳过 psutil 全进程扫描)──
|
||
if old_pid:
|
||
try:
|
||
pid_windows = []
|
||
def _fast_enum_cb(h, results, _pid=old_pid):
|
||
try:
|
||
_, found_pid = win32process.GetWindowThreadProcessId(h)
|
||
if found_pid == _pid:
|
||
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
|
||
if top not in results:
|
||
results.append(top)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
win32gui.EnumWindows(_fast_enum_cb, pid_windows)
|
||
for top in pid_windows:
|
||
if not (win32gui.IsWindow(top) and win32gui.IsWindowVisible(top)):
|
||
continue
|
||
rect = win32gui.GetWindowRect(top)
|
||
if (rect[2] - rect[0]) > 300 and (rect[3] - rect[1]) > 200:
|
||
self._cached_hwnd[platform] = top
|
||
self._window_not_found_until.pop(platform, None)
|
||
title = win32gui.GetWindowText(top)
|
||
logger.info(f"[{platform}] 窗口已定位(PID 快速): pid={old_pid} title={title!r} top={top}")
|
||
return top
|
||
except Exception:
|
||
pass
|
||
|
||
# ── UIA 搜索 ──
|
||
for name in ["微信", "WeChat"]:
|
||
try:
|
||
w = auto.WindowControl(Name=name)
|
||
if w.Exists(maxSearchSeconds=0.5):
|
||
rect = w.BoundingRectangle
|
||
if rect.width() > 300 and rect.height() > 200:
|
||
h = w.NativeWindowHandle
|
||
# 确保存顶层窗口句柄(与 GetForegroundWindow 返回值一致)
|
||
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
|
||
# 窗口不可见(最小化/隐藏)→ 跳过
|
||
# 注意:IsWindowVisible 检查 WS_VISIBLE 标志位,只要窗口在 Z 顺序中
|
||
# 未被标记为隐藏即返回 True(即使被其他窗口覆盖),符合需求。
|
||
if not win32gui.IsWindowVisible(top):
|
||
continue
|
||
self._cached_hwnd[platform] = top
|
||
self._cached_pid[platform] = win32process.GetWindowThreadProcessId(top)[1]
|
||
self._window_not_found_until.pop(platform, None)
|
||
logger.info(f"[{platform}] 窗口已定位: {name} handle={top}")
|
||
return top
|
||
except Exception:
|
||
pass
|
||
|
||
# 兜底:通过 Win32 API 按进程名查找微信窗口(UIAutomation 在窗口被覆盖时可能找不到)
|
||
# 只要窗口可见(未被最小化/隐藏)就返回,与 QQ 逻辑一致
|
||
for proc_name in ["WeChat.exe", "微信"]:
|
||
try:
|
||
for proc in psutil.process_iter(['name', 'pid']):
|
||
if proc.info['name'] and proc.info['name'].lower() == proc_name.lower():
|
||
pid = proc.info['pid']
|
||
pid_windows = []
|
||
|
||
def _pid_enum_cb(hwnd, results, _pid=pid):
|
||
try:
|
||
_, found_pid = win32process.GetWindowThreadProcessId(hwnd)
|
||
if found_pid == _pid:
|
||
top = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
if top not in results:
|
||
results.append(top)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
win32gui.EnumWindows(_pid_enum_cb, pid_windows)
|
||
|
||
for top in pid_windows:
|
||
if not (win32gui.IsWindow(top) and win32gui.IsWindowVisible(top)):
|
||
continue
|
||
rect = win32gui.GetWindowRect(top)
|
||
if (rect[2] - rect[0]) > 300 and (rect[3] - rect[1]) > 200:
|
||
self._cached_hwnd[platform] = top
|
||
self._cached_pid[platform] = pid
|
||
self._window_not_found_until.pop(platform, None)
|
||
title = win32gui.GetWindowText(top)
|
||
logger.info(f"[{platform}] 窗口已定位(Win32 进程枚举): {proc_name} pid={pid} title={title!r} top={top}")
|
||
return top
|
||
except Exception:
|
||
continue
|
||
elif platform == "qq":
|
||
# 只按进程名找窗口,不管标题是什么。
|
||
# TIM/QQ 标题是动态的(跟随会话显示),任何标题都合法。
|
||
for proc_name in ["TIM.exe", "QQ.exe"]:
|
||
try:
|
||
for proc in psutil.process_iter(['name', 'pid']):
|
||
if proc.info['name'] and proc.info['name'].lower() == proc_name.lower():
|
||
pid = proc.info['pid']
|
||
pid_windows = []
|
||
|
||
def _pid_enum_cb(hwnd, results, _pid=pid):
|
||
try:
|
||
_, found_pid = win32process.GetWindowThreadProcessId(hwnd)
|
||
if found_pid == _pid:
|
||
top = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
if top not in results:
|
||
results.append(top)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
win32gui.EnumWindows(_pid_enum_cb, pid_windows)
|
||
|
||
# 所有属于该进程的顶层窗口中,取第一个尺寸>=聊天区的、可见的
|
||
for top in pid_windows:
|
||
if not (win32gui.IsWindow(top) and win32gui.IsWindowVisible(top)):
|
||
continue
|
||
rect = win32gui.GetWindowRect(top)
|
||
if (rect[2] - rect[0]) > 300 and (rect[3] - rect[1]) > 200:
|
||
variant = "tim" if "TIM" in proc_name.upper() else "qq_nt"
|
||
self._app_variant[platform] = variant
|
||
self._cached_hwnd[platform] = top
|
||
self._cached_pid[platform] = pid
|
||
self._window_not_found_until.pop(platform, None)
|
||
title = win32gui.GetWindowText(top)
|
||
logger.info(f"[{platform}] 窗口已定位: {proc_name} pid={pid} title={title!r} top={top}")
|
||
return top
|
||
except Exception:
|
||
continue
|
||
# 兜底:UIA 搜索 TXGuiFoundation 类名窗口(进程已启动但窗口尚未就绪时)
|
||
try:
|
||
root = auto.GetRootControl()
|
||
for child in root.GetChildren():
|
||
try:
|
||
if child.ClassName == "TXGuiFoundation":
|
||
rect = child.BoundingRectangle
|
||
if rect.width() > 300 and rect.height() > 200:
|
||
h = child.NativeWindowHandle
|
||
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
|
||
pid = win32process.GetWindowThreadProcessId(top)[1]
|
||
# 验证进程是否属于 QQ/TIM
|
||
try:
|
||
hproc = win32api.OpenProcess(
|
||
0x0400 | 0x0010, # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ
|
||
False, pid
|
||
)
|
||
if hproc:
|
||
exe_path = win32process.GetModuleFileNameEx(hproc, 0)
|
||
win32api.CloseHandle(hproc)
|
||
exe_name = exe_path.split('\\')[-1].lower() if exe_path else ''
|
||
if exe_name not in ('qq.exe', 'tim.exe'):
|
||
continue # 不是QQ/TIM,跳过
|
||
else:
|
||
continue # 无法打开进程,跳过
|
||
except Exception:
|
||
continue # 验证失败,跳过
|
||
self._app_variant[platform] = "qq_nt"
|
||
self._cached_hwnd[platform] = top
|
||
self._cached_pid[platform] = pid
|
||
self._window_not_found_until.pop(platform, None)
|
||
logger.info(f"[{platform}] 窗口已定位(UIA TXGuiFoundation): top={top} pid={pid}")
|
||
return top
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
# 搜索完毕没找到 → 设冷却避免重复 12s 超时(10s 比 30s 更快恢复轮询)
|
||
self._window_not_found_until[platform] = time.time() + 10
|
||
return None
|
||
|
||
def _ensure_hwnd_with_timeout(self, platform: str, timeout: float = 12.0) -> Optional[int]:
|
||
"""_ensure_hwnd 包装 daemon 线程超时保护。
|
||
|
||
ThreadPoolExecutor 创建非 daemon 线程 → Ctrl+C 进程不退出。
|
||
改用 daemon thread + Event.wait(timeout),daemon 线程不阻塞进程退出。
|
||
"""
|
||
# 冷却期内免线程直接跳过
|
||
cooldown_until = self._window_not_found_until.get(platform, 0)
|
||
if time.time() < cooldown_until:
|
||
return None
|
||
|
||
result = [None]
|
||
done = threading.Event()
|
||
|
||
def worker():
|
||
# 如果外层已设冷却(超时后立即设),不再重复 UIA 搜索
|
||
cooldown_until = self._window_not_found_until.get(platform, 0)
|
||
if time.time() < cooldown_until:
|
||
done.set()
|
||
return
|
||
result[0] = self._ensure_hwnd(platform)
|
||
done.set()
|
||
|
||
t = threading.Thread(target=worker, daemon=True)
|
||
t.start()
|
||
if not done.wait(timeout=timeout):
|
||
logger.warning(f"[{platform}] _ensure_hwnd 超时({timeout:.0f}s),跳过本轮")
|
||
self._window_not_found_until[platform] = time.time() + 10
|
||
return None
|
||
return result[0]
|
||
|
||
# ── 强制刷新 UIA ──────────────────────────────────────────
|
||
|
||
def _fresh_window(self, platform: str):
|
||
"""从缓存 HWND 创建全新的 WindowControl(绕过 UIA 缓存)。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return None
|
||
try:
|
||
# 用 NativeWindowHandle 重建,保证 UIA 新缓存
|
||
win = auto.ControlFromHandle(hwnd)
|
||
if win is not None:
|
||
return win
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _find_qq_window_fresh(self):
|
||
"""通过类名重新查找 QQ/TIM 窗口,绕过 UIA 缓存(比 ControlFromHandle 更彻底)。"""
|
||
try:
|
||
# 先按窗口名称查找
|
||
for name in ["QQ", "TIM"]:
|
||
try:
|
||
w = auto.WindowControl(Name=name)
|
||
if w.Exists(maxSearchSeconds=0.2):
|
||
rect = w.BoundingRectangle
|
||
if rect.width() > 300 and rect.height() > 200:
|
||
return w
|
||
except Exception:
|
||
pass
|
||
# 按 TXGuiFoundation 类名深度查找
|
||
root = auto.GetRootControl()
|
||
for child in root.GetChildren():
|
||
try:
|
||
if child.ClassName == "TXGuiFoundation":
|
||
rect = child.BoundingRectangle
|
||
if rect.width() > 300 and rect.height() > 200:
|
||
# 验证进程是否属于 QQ/TIM
|
||
h = child.NativeWindowHandle
|
||
top = win32gui.GetAncestor(h, win32con.GA_ROOT)
|
||
pid = win32process.GetWindowThreadProcessId(top)[1]
|
||
try:
|
||
hproc = win32api.OpenProcess(
|
||
0x0400 | 0x0010, False, pid
|
||
)
|
||
if hproc:
|
||
exe_path = win32process.GetModuleFileNameEx(hproc, 0)
|
||
win32api.CloseHandle(hproc)
|
||
exe_name = exe_path.split('\\')[-1].lower() if exe_path else ''
|
||
if exe_name not in ('qq.exe', 'tim.exe'):
|
||
continue
|
||
else:
|
||
continue
|
||
except Exception:
|
||
continue
|
||
return child
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _force_uia_refresh(self, platform: str):
|
||
"""通过交互强制窗口更新 UIA 控件树。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return
|
||
try:
|
||
# 方式1: 窗口重绘
|
||
win32gui.InvalidateRect(hwnd, None, True)
|
||
win32gui.UpdateWindow(hwnd)
|
||
# QQ: 避免标题栏点击(不会干扰用户操作)
|
||
if platform == "qq":
|
||
win32gui.PostMessage(hwnd, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
|
||
else:
|
||
# 方式2: 向窗口发送非客户区点击(仅微信)
|
||
win32gui.PostMessage(hwnd, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN
|
||
win32gui.PostMessage(hwnd, 0x00A2, 2, 0) # WM_NCLBUTTONUP
|
||
logger.debug(f"[{platform}] UIA强制刷新")
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] 强制刷新失败: {e}")
|
||
|
||
def _force_scroll_bottom(self, platform: str):
|
||
"""滚动窗口到底部,触发 UI 更新。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return
|
||
try:
|
||
# 1. WM_VSCROLL SB_BOTTOM
|
||
win32gui.PostMessage(hwnd, win32con.WM_VSCROLL, win32con.SB_BOTTOM, 0)
|
||
# 2. Ctrl+End 快捷键
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_CONTROL, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_CONTROL, 0)
|
||
# 3. InvalidateRect 强制重绘
|
||
win32gui.InvalidateRect(hwnd, None, True)
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] 滚动到底失败: {e}")
|
||
|
||
def _ensure_visible(self, hwnd) -> bool:
|
||
"""确保窗口可见,尝试置前但不强制要求前台(用户操作不打断截屏)。
|
||
|
||
截屏用 ImageGrab.grab(bbox) 截的是整屏坐标,只要窗口在屏幕上可见
|
||
(未被其他窗口完全遮挡),截屏就能拿到正确内容。用户操作鼠标/键盘
|
||
会抢走前台焦点,但不等于窗口被遮挡——不应因此放弃截屏。
|
||
|
||
流程:恢复最小化/隐藏 → AllowSetForegroundWindow 解除限制 →
|
||
SetForegroundWindow + SwitchToThisWindow 尽力置前(重试3次)→
|
||
若置前成功直接返回;若置前失败但窗口仍然可见(IsWindowVisible + !IsIconic),
|
||
也返回 True 继续截图。仅当窗口真的不可见时返回 False。
|
||
"""
|
||
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
if not root_hwnd or not win32gui.IsWindow(root_hwnd):
|
||
return False
|
||
# 1) 恢复最小化 / 显示隐藏窗口
|
||
if win32gui.IsIconic(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
|
||
time.sleep(0.2)
|
||
if not win32gui.IsWindowVisible(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
|
||
time.sleep(0.2)
|
||
# 2) 解除系统前台限制(ASFW_ANY),让后台线程也能 SetForegroundWindow
|
||
try:
|
||
ctypes.windll.user32.AllowSetForegroundWindow(-1)
|
||
except Exception:
|
||
pass
|
||
# 3) 尽力置前:SetForegroundWindow + SwitchToThisWindow 兜底,重试 3 次
|
||
for _ in range(3):
|
||
try:
|
||
win32gui.SetForegroundWindow(root_hwnd)
|
||
except Exception:
|
||
pass
|
||
time.sleep(0.12)
|
||
try:
|
||
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
|
||
except Exception:
|
||
pass
|
||
time.sleep(0.1)
|
||
if self._check_foreground(root_hwnd):
|
||
return True
|
||
# 4) 前台校验失败 —— 但窗口可能仍然可见(用户操作抢走了前台焦点)
|
||
# ImageGrab.grab 只需要窗口在屏幕上可见即可,不要求前台。
|
||
# 仅当窗口真的不可见(被完全遮挡/最小化)时才放弃。
|
||
if win32gui.IsWindowVisible(root_hwnd) and not win32gui.IsIconic(root_hwnd):
|
||
logger.debug(f"[{self.__class__.__name__}] 窗口 {root_hwnd} 未获得前台但仍然可见,继续截图")
|
||
return True
|
||
logger.warning(f"[{self.__class__.__name__}] 窗口 {root_hwnd} 不可见,跳过本轮")
|
||
return False
|
||
|
||
def _ensure_foreground(self, hwnd, max_retries=5) -> bool:
|
||
"""确保窗口在前台,重试多次。失败返回 False,不强行发送。
|
||
|
||
Windows 限制:仅“最近收到用户输入的线程”可调用 SetForegroundWindow。
|
||
后台 daemon 线程常因此失败。采用多层兜底:
|
||
Method 0: keybd_event 模拟一次 Alt 按键,骗取前台权限(经典 workaround)
|
||
Method 1: AttachThreadInput + SetForegroundWindow(跨线程注入输入)
|
||
Method 2: SwitchToThisWindow(权限要求低,轻量)
|
||
Method 3: SetWindowPos(HWND_TOPMOST) 强置顶 + SetForegroundWindow 最后手段
|
||
"""
|
||
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
if not root_hwnd or not win32gui.IsWindow(root_hwnd):
|
||
logger.warning(f"[前台] 无效句柄 {hwnd}")
|
||
return False
|
||
|
||
# 前置条件:最小化则恢复,隐藏则显示
|
||
if win32gui.IsIconic(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
|
||
time.sleep(0.2)
|
||
if not win32gui.IsWindowVisible(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
|
||
time.sleep(0.2)
|
||
|
||
# 允许任意进程 SetForegroundWindow(移除系统限制,与发送路径一致)
|
||
try:
|
||
ctypes.windll.user32.AllowSetForegroundWindow(-1) # ASFW_ANY
|
||
except Exception:
|
||
pass
|
||
|
||
for i in range(max_retries):
|
||
# Method 0: 模拟一次 Alt 按键,让系统认为当前线程收到输入,
|
||
# 从而解除 SetForegroundWindow 的“最近用户输入”限制
|
||
try:
|
||
ctypes.windll.user32.keybd_event(0x12, 0, 0, 0) # Alt down
|
||
ctypes.windll.user32.keybd_event(0x12, 0, 2, 0) # Alt up
|
||
except Exception:
|
||
pass
|
||
|
||
# Method 1: AttachThreadInput + SetForegroundWindow
|
||
self._attach_set_foreground(root_hwnd)
|
||
time.sleep(0.15)
|
||
if self._check_foreground(root_hwnd):
|
||
return True
|
||
|
||
# Method 2: SwitchToThisWindow(权限要求低,轻量)
|
||
try:
|
||
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
|
||
time.sleep(0.15)
|
||
if self._check_foreground(root_hwnd):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
# Method 3: 强置顶 + SetForegroundWindow(绕过前台限制的最后手段)
|
||
try:
|
||
ctypes.windll.user32.SetWindowPos(
|
||
root_hwnd, win32con.HWND_TOPMOST, 0, 0, 0, 0,
|
||
win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | win32con.SWP_SHOWWINDOW
|
||
)
|
||
ctypes.windll.user32.SetForegroundWindow(root_hwnd)
|
||
time.sleep(0.15)
|
||
if self._check_foreground(root_hwnd):
|
||
return True
|
||
# 取消 TOPMOST,避免窗口一直悬浮在最上层
|
||
ctypes.windll.user32.SetWindowPos(
|
||
root_hwnd, win32con.HWND_NOTOPMOST, 0, 0, 0, 0,
|
||
win32con.SWP_NOMOVE | win32con.SWP_NOSIZE
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
time.sleep(0.1)
|
||
|
||
logger.warning(f"无法将窗口 {hwnd} 带到前台,放弃操作")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _check_foreground(root_hwnd) -> bool:
|
||
"""检查窗口是否在前台(或其子窗口)。"""
|
||
try:
|
||
fg = win32gui.GetForegroundWindow()
|
||
if fg == root_hwnd:
|
||
return True
|
||
if fg and win32gui.IsChild(root_hwnd, fg):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _attach_set_foreground(self, hwnd):
|
||
"""AttachThreadInput + SetForegroundWindow,从 daemon 线程也能成功。"""
|
||
try:
|
||
current_tid = ctypes.windll.kernel32.GetCurrentThreadId()
|
||
target_tid = ctypes.windll.user32.GetWindowThreadProcessId(hwnd, None)
|
||
if ctypes.windll.user32.AttachThreadInput(current_tid, target_tid, True):
|
||
try:
|
||
win32gui.SetForegroundWindow(hwnd)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
ctypes.windll.user32.AttachThreadInput(current_tid, target_tid, False)
|
||
return
|
||
# AttachThreadInput 失败 → 裸 SetForegroundWindow
|
||
try:
|
||
win32gui.SetForegroundWindow(hwnd)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
try:
|
||
win32gui.SetForegroundWindow(hwnd)
|
||
except Exception:
|
||
pass
|
||
|
||
def _read_win32_texts(self, platform: str) -> list:
|
||
"""Win32 API 读窗口文本,递归枚举(depth 不限)。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return []
|
||
texts = []
|
||
|
||
def enum_callback(child_hwnd, _):
|
||
try:
|
||
text = win32gui.GetWindowText(child_hwnd)
|
||
if text and len(text.strip()) >= 2:
|
||
texts.append(text.strip())
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
try:
|
||
main_text = win32gui.GetWindowText(hwnd)
|
||
if main_text and len(main_text.strip()) >= 2:
|
||
texts.append(main_text.strip())
|
||
win32gui.EnumChildWindows(hwnd, enum_callback, None)
|
||
except Exception:
|
||
pass
|
||
return texts
|
||
|
||
# ── OCR 读屏(QQ 专用,Windows 原生 OCR) ──────────────────
|
||
|
||
def _ocr_ensure_engine(self):
|
||
"""惰性初始化 OCR 引擎(中文简体)。"""
|
||
if self._ocr_engine is None:
|
||
try:
|
||
self._ocr_engine = OcrEngine.try_create_from_language(
|
||
Language('zh-CN')
|
||
)
|
||
if self._ocr_engine:
|
||
logger.info(f"[ocr] OCR引擎就绪: {self._ocr_engine.recognizer_language.display_name}")
|
||
else:
|
||
logger.warning("[ocr] 创建OCR引擎失败")
|
||
except Exception as e:
|
||
logger.error(f"[ocr] OCR引擎初始化异常: {e}")
|
||
return self._ocr_engine
|
||
|
||
@staticmethod
|
||
def _normalize_ocr_text(text: str) -> str:
|
||
"""Windows OCR 给 CJK 字符间强加空格 → 去除这些空格使文本可去重。
|
||
|
||
"问 题 1 : 你 答 : 你 好" → "问题1:你答:你好"
|
||
"你 好 he llo" → "你好 he llo"(ASCII 词内空格保留)
|
||
|
||
此外修复常见 Windows OCR 字符混淆错误(只在非 md 文本上运行,
|
||
md 文档由分割后逐片段 md 检测过滤,不受影响)。
|
||
"""
|
||
# 所有紧邻 CJK/全角字符的空格全部去掉
|
||
# CJK 范围:汉字 (u4e00-) + 扩展 (u3400-) + 全角/兼容 (uff00-)
|
||
text = re.sub(
|
||
r'(?<=[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef\u3000-\u303f])'
|
||
r'\s+',
|
||
'', text
|
||
)
|
||
text = re.sub(
|
||
r'\s+'
|
||
r'(?=[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef\u3000-\u303f])',
|
||
'', text
|
||
)
|
||
|
||
# ⭐ Windows OCR 字符混淆修复
|
||
# 《》→ <>:Minecraft 聊天大量使用 <player> 等尖括号,OCR 误读为中文书名号
|
||
text = text.replace('《', '<').replace('》', '>')
|
||
|
||
# l→| 管道分隔符:|quiz| / |start| / |info| 误读为 lquizl / lstartl / linfol
|
||
text = re.sub(r'(?<=\s)l([A-Za-z]\w*?)l(?=\s)', r'|\1|', text)
|
||
|
||
# .→ . 全角点→半角数字小数点 (0.45 → 0.45)
|
||
text = re.sub(r'(\d)[.](\d)', r'\1.\2', text)
|
||
|
||
# 清理消息末尾的状态标记(如 "0~" "O~" "C~" "0~O~"),避免污染 KB 匹配
|
||
text = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', text)
|
||
|
||
# 清理 HTML 标签(OCR 截到网页/终端时常带 <br> <div> 等)
|
||
text = re.sub(r'<\s*br\s*/?\s*>', '\n', text, flags=re.IGNORECASE)
|
||
text = re.sub(r'<\s*div\s*/?\s*>', '\n', text, flags=re.IGNORECASE)
|
||
text = re.sub(r'<[^>]+>', '', text)
|
||
|
||
# ⭐ Windows OCR 常见汉字误识别纠错(词级,降低误伤)
|
||
# OCR 把相似笔画/结构的字搞混,只在确认是误识别的词组上替换
|
||
_ocr_fixes = {
|
||
'手髓': '手续', # 髓(骨髓) → 续(手续),笔画结构相似
|
||
'β余': '多余', # β(希腊字母) → 多,OCR把"多"拆成β形状
|
||
'早原': '草原', # 早 → 草,上半部分相似
|
||
'届件': '插件', # 届 → 插,结构相似
|
||
'文|牛': '文件', # 文|牛 → 文件,OCR把"件"拆成|牛
|
||
'日何': '如何', # 日 → 如,结构相似
|
||
'何使': '如何使', # 兜底:如果"如何"被拆成"日何"但前面有字
|
||
}
|
||
for wrong, right in _ocr_fixes.items():
|
||
text = text.replace(wrong, right)
|
||
|
||
# ⭐ OCR 尾巴清理:句子结束标点后紧跟的垃圾片段
|
||
# 典型: "PHP的用户端有哪些登录模式?,留【" → "PHP的用户端有哪些登录模式?"
|
||
# 判定: 最后一个句末标点后 ≤10字符且满足以下任一:
|
||
# a) 含未闭合括号(【(没有对应】))
|
||
# b) 紧跟逗号 + ≤5实字(OCR把消息边界逗号和下一行黏进来)
|
||
# c) ≤2实字截断碎片(如"留"、"O"等)
|
||
tail_m = re.search(r'([?。?!!?])([^?。?!!?]{1,10})$', text)
|
||
if tail_m:
|
||
tail_text = tail_m.group(2)
|
||
_tail_real = re.sub(r'[,,\s。!?!?]', '', tail_text)
|
||
# 特征a: 未闭合括号 → 垃圾
|
||
if re.search(r'[【「(((]\s*$', tail_text):
|
||
text = text[:tail_m.start(2)]
|
||
# 特征b: 紧跟逗号 + ≤5实字 → OCR截断拼接
|
||
elif re.match(r'[,,]\s*', tail_text) and len(_tail_real) <= 5:
|
||
text = text[:tail_m.start(2)]
|
||
# 特征c: ≤2实字截断碎片
|
||
elif len(_tail_real) <= 2:
|
||
text = text[:tail_m.start(2)]
|
||
|
||
return text.strip()
|
||
|
||
@staticmethod
|
||
def _is_ocr_console_garbage(text: str) -> bool:
|
||
"""OCR 输出的文本是否明显是控制台/Python 垃圾。
|
||
|
||
当 OCR 截错窗口(如读到终端窗口)时,文本包含:
|
||
- Python 路径/模块名:comtypes, uiautomation, sklearn, jieba, winrt
|
||
- 脚本文件名:monitor.py, main.py
|
||
- 虚拟环境路径:.venv
|
||
- 日志关键词:DEBUG, INFO, WARNING, msgbot
|
||
- 控制台序列:←[, [32m, [0m
|
||
返回 True = 应当丢弃。
|
||
"""
|
||
garbage_patterns = [
|
||
r'comtypes', r'uiautomation', r'sklearn', r'jieba',
|
||
r'winrt\.', r'msgbot\.', r'monitor\.py', r'main\.py',
|
||
r'\.venv', r'Loading model', r'Prefix dict',
|
||
r'WARNING', r'UserWarning', r'Building prefix',
|
||
r'←\[', r'\[32m', r'\[0m', r'\\n',
|
||
]
|
||
for pat in garbage_patterns:
|
||
if re.search(pat, text, re.IGNORECASE):
|
||
return True
|
||
cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
||
# 全字母数字+标点无中文 → 肯定是非聊天内容(阈值降到 15)
|
||
if cjk_count == 0 and len(text.strip()) > 15:
|
||
return True
|
||
# OCR 随机字符垃圾:无中文 + 特殊字符比例高(如 = ~ @ # $ % ^ & *)
|
||
# EasyOCR 在包含头像/表情/特殊 UI 的区域会输出 544UUJ1W H V=YJ~JL:e 这类乱码
|
||
if cjk_count == 0 and len(text.strip()) >= 8:
|
||
special_chars = sum(1 for c in text if c in '=~@#$%^&*_+{}[]|\\<>/`!')
|
||
alpha_count = sum(1 for c in text if c.isalpha())
|
||
digit_count = sum(1 for c in text if c.isdigit())
|
||
# 特殊字符占比过高(>15%)且字母数字混杂 → OCR 乱码
|
||
if special_chars / len(text) > 0.15 and alpha_count >= 2 and digit_count >= 2:
|
||
return True
|
||
return False
|
||
|
||
def _validate_qq_window(self, hwnd: int) -> bool:
|
||
"""验证窗口是否确实是 QQ/TIM 进程。
|
||
|
||
不尝试自动恢复隐藏/最小化窗口——交给剪贴板兜底的 _ensure_foreground 处理。
|
||
_ensure_foreground 有多重重试+SetForegroundWindow,比这里抢先 ShowWindow
|
||
更能正确弹出主窗口而非托盘预览。
|
||
"""
|
||
if not win32gui.IsWindow(hwnd):
|
||
return False
|
||
if not win32gui.IsWindowVisible(hwnd):
|
||
return False
|
||
if win32gui.IsIconic(hwnd):
|
||
return False
|
||
# 验证进程名
|
||
try:
|
||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||
hproc = win32api.OpenProcess(
|
||
0x0400 | 0x0010, # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ
|
||
False, pid
|
||
)
|
||
if hproc:
|
||
exe_path = win32process.GetModuleFileNameEx(hproc, 0)
|
||
win32api.CloseHandle(hproc)
|
||
exe_name = exe_path.split('\\')[-1].lower() if exe_path else ''
|
||
if exe_name in ('qq.exe', 'tim.exe'):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _validate_wechat_window(self, hwnd: int) -> bool:
|
||
"""验证窗口是否确实是微信进程。"""
|
||
logger.debug(f"[wechat] 验证窗口 hwnd={hwnd}")
|
||
if not win32gui.IsWindow(hwnd):
|
||
logger.debug(f"[wechat] 窗口验证失败: IsWindow=False")
|
||
return False
|
||
if not win32gui.IsWindowVisible(hwnd):
|
||
logger.debug(f"[wechat] 窗口验证失败: IsWindowVisible=False")
|
||
return False
|
||
if win32gui.IsIconic(hwnd):
|
||
logger.debug(f"[wechat] 窗口验证失败: IsIconic=True (最小化)")
|
||
return False
|
||
# 验证进程名
|
||
try:
|
||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||
logger.debug(f"[wechat] 窗口 hwnd={hwnd} 对应 PID={pid}")
|
||
hproc = win32api.OpenProcess(
|
||
0x0400 | 0x0010, # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ
|
||
False, pid
|
||
)
|
||
if hproc:
|
||
exe_path = win32process.GetModuleFileNameEx(hproc, 0)
|
||
win32api.CloseHandle(hproc)
|
||
exe_name = exe_path.split('\\')[-1].lower() if exe_path else ''
|
||
logger.debug(f"[wechat] 进程路径: {exe_path}, 进程名: {exe_name}")
|
||
if exe_name in ('wechat.exe', 'weixin.exe'): # wechat=海外版, weixin=国内版
|
||
return True
|
||
else:
|
||
logger.debug(f"[wechat] 进程名不匹配: 期望 wechat.exe/weixin.exe, 实际 {exe_name}")
|
||
else:
|
||
logger.debug(f"[wechat] 无法打开进程 PID={pid}")
|
||
except Exception as e:
|
||
logger.debug(f"[wechat] 验证进程异常: {e}")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _fuzzy_duplicate(new_text: str, seen_set: set, threshold: float = 0.80) -> bool:
|
||
"""检查 new_text 是否与 seen_set 中已有文本相似度超阈值。
|
||
|
||
OCR 输出有噪声(每次勖彐碎片、行首数字、尾部截断不同),精确匹配无法去重。
|
||
用 difflib.SequenceMatcher 的 quick_ratio() 做快速上界剪枝。
|
||
"""
|
||
if not new_text or len(new_text) < 4:
|
||
return False
|
||
for existing in seen_set:
|
||
if abs(len(existing) - len(new_text)) > max(len(existing), len(new_text)) * 0.4:
|
||
# 长度差距超 40%,不可能相似(quick_ratio 上界 = 2*min/max < 阈值)
|
||
continue
|
||
ratio = difflib.SequenceMatcher(None, new_text, existing).quick_ratio()
|
||
if ratio >= threshold:
|
||
return True
|
||
return False
|
||
|
||
# ── 双 OCR 引擎 ──────────────────────────────────────────
|
||
|
||
def _init_easyocr(self):
|
||
"""惰性初始化 EasyOCR(首次约 3-10 秒)。"""
|
||
if not _HAS_EASYOCR or self._easyocr_reader is not None:
|
||
return
|
||
try:
|
||
logger.info("[ocr] EasyOCR 初始化中(首次约 3-10 秒)...")
|
||
# 限制 torch 线程数,避免 MKL/OpenMP 在多线程环境段错误
|
||
import torch
|
||
torch.set_num_threads(1)
|
||
|
||
# 检测可用内存,决定是否使用GPU
|
||
use_gpu = torch.cuda.is_available()
|
||
if use_gpu:
|
||
mem = psutil.virtual_memory()
|
||
available_gb = mem.available / (1024**3)
|
||
total_gb = mem.total / (1024**3)
|
||
# 获取页面文件信息
|
||
try:
|
||
swap = psutil.swap_memory()
|
||
swap_total_gb = swap.total / (1024**3)
|
||
swap_avail_gb = swap.free / (1024**3)
|
||
swap_pct = swap.percent
|
||
except:
|
||
swap_total_gb = swap_avail_gb = swap_pct = 0
|
||
|
||
# 可用内存 < 2GB 时强制降级CPU,防止OOM崩溃
|
||
if available_gb < 2.0:
|
||
logger.warning(f"[ocr] 可用内存不足({available_gb:.1f}GB < 2GB,总{total_gb:.1f}GB,使用{mem.percent}%),"
|
||
f"页面文件{swap_avail_gb:.1f}GB可用({swap_pct}%),EasyOCR 降级CPU模式")
|
||
use_gpu = False
|
||
else:
|
||
logger.info(f"[ocr] 可用内存充足({available_gb:.1f}GB/总{total_gb:.1f}GB,使用{mem.percent}%),"
|
||
f"页面文件{swap_avail_gb:.1f}GB可用({swap_pct}%),尝试GPU模式")
|
||
|
||
self._easyocr_reader = _easyocr.Reader(['ch_sim', 'en'], gpu=use_gpu)
|
||
if use_gpu:
|
||
logger.info("[ocr] EasyOCR 就绪(GPU 模式)")
|
||
else:
|
||
logger.info("[ocr] EasyOCR 就绪(CPU 模式)")
|
||
except Exception as e:
|
||
logger.error(f"[ocr] EasyOCR 初始化失败: {e}")
|
||
self._easyocr_reader = None
|
||
|
||
# ── EasyOCR 子进程隔离 ────────────────────────────────────
|
||
# 直接 GPU 推理 crash(access violation)会杀整个进程 + 冻电脑。
|
||
# 子进程方案:GPU crash 只杀子进程,主进程降级到 Windows OCR。
|
||
|
||
def _ensure_easyocr_subprocess(self, is_normal_restart: bool = False) -> bool:
|
||
"""确保 EasyOCR 子进程在运行。返回 True = 可用。
|
||
|
||
启动后等待 worker 的 {"ready": true} 握手(最长 60s 初始化预算),
|
||
初始化时间不计入请求超时。子进程崩溃/超时 → 杀进程 + 设置冷却,
|
||
冷却期后自动重试(不再永久 _easyocr_broken)。
|
||
|
||
is_normal_restart: 正常调度重启时跳过内存检测,避免正常资源释放后重启被误判为错误
|
||
"""
|
||
# ⭐ 检查监控是否仍在运行:stop()/急停后 warmup 线程不应重启子进程
|
||
if not self._running:
|
||
return False
|
||
if self._easyocr_broken:
|
||
return False
|
||
# ⭐ 检查是否为正常调度重启:正常资源释放后重启子进程清GPU缓存
|
||
if hasattr(self, '_easyocr_normal_restart') and self._easyocr_normal_restart:
|
||
is_normal_restart = True
|
||
self._easyocr_normal_restart = False # 重置标志
|
||
logger.debug("[ocr] 检测到正常调度重启标志,跳过内存检测")
|
||
# ── Task #1446 修复:就绪感知 + 启动串行化 ─────────────────────
|
||
# 旧快路径只看 poll() is None(进程存活),并发线程在初始化窗口抢到
|
||
# 「进程已存活但未完成 ready 握手」的快路径 return True,导致在 worker
|
||
# 未就绪时就派发 OCR 请求、触发 10s 请求超时("刚就绪即超时"竞态)。
|
||
# 新逻辑:
|
||
# 1) 快路径要求「进程存活【且】_easyocr_ready(已握手)」;
|
||
# 2) 进程存活但未就绪 → 说明另一线程正在握手,等待握手事件(≤60s
|
||
# 初始化预算),绝不提前派发请求;
|
||
# 3) 需要启动时用 _easyocr_startup_lock 串行化 Popen,仅一个线程真正
|
||
# 拉起,其余线程等待其握手结果。于是 _call_easyocr_safe 的 10s 请求
|
||
# 计时只会在 worker 真正就绪后才开始。
|
||
|
||
# 快路径(稳态):进程存活且已完成 ready 握手
|
||
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and self._easyocr_ready:
|
||
return True
|
||
|
||
# 进程存活但未就绪:另一线程正在握手,等待其完成(≤60s),不重 Popen、不提前派发
|
||
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and not self._easyocr_ready:
|
||
logger.debug("[ocr] EasyOCR 子进程存活但握手未完成,等待就绪(≤60s)...")
|
||
self._easyocr_ready_event.wait(timeout=self._easyocr_ready_timeout)
|
||
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and self._easyocr_ready:
|
||
return True
|
||
# 等待超时或进程已死:清理卡住的未就绪子进程,落到重启逻辑
|
||
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and not self._easyocr_ready:
|
||
logger.warning("[ocr] 等待握手超时,杀掉未就绪子进程并重试")
|
||
self._kill_easyocr_proc("握手等待超时")
|
||
# 继续往下走重启
|
||
|
||
# 进程已退出(poll 非 None)→ 清理后走重启逻辑
|
||
if self._easyocr_proc is not None and self._easyocr_proc.poll() is not None:
|
||
logger.warning("[ocr] EasyOCR 子进程已退出,重启...")
|
||
self._easyocr_proc = None
|
||
self._easyocr_ready = False
|
||
self._easyocr_ready_event.clear()
|
||
|
||
# 冷却期:子进程刚崩溃,30s 内不立即重试(防快速崩溃循环刷屏)
|
||
if time.time() < self._easyocr_retry_until:
|
||
return False
|
||
|
||
# 串行化启动:仅一个线程负责 Popen(防「proc 为 None」时并发双 Popen),
|
||
# 其余线程等待其握手结果。Popen 在锁内完成;握手在锁外执行,避免持锁 60s。
|
||
_i_am_starter = False
|
||
with self._easyocr_startup_lock:
|
||
if self._easyocr_starting or (self._easyocr_proc is not None and self._easyocr_proc.poll() is None):
|
||
_i_am_starter = False # 另一线程已拉起/正在握手 → 本线程不 Popen
|
||
else:
|
||
self._easyocr_starting = True # 持锁标记本线程负责启动
|
||
_i_am_starter = True
|
||
_started = self._start_easyocr_proc_locked(is_normal_restart)
|
||
if not _started:
|
||
self._easyocr_starting = False
|
||
# 锁已释放
|
||
if not _i_am_starter:
|
||
# 另一线程正在握手(或本线程未启动但进程存在)→ 等待其握手结果
|
||
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None:
|
||
self._easyocr_ready_event.wait(timeout=self._easyocr_ready_timeout)
|
||
if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and self._easyocr_ready:
|
||
return True
|
||
return False
|
||
# 本线程成功拉起进程 → 执行握手(锁外,不持锁 60s)
|
||
return self._handshake_easyocr_subprocess(is_normal_restart)
|
||
|
||
def _start_easyocr_proc_locked(self, is_normal_restart: bool) -> bool:
|
||
"""(在 _easyocr_startup_lock 内调用)启动 EasyOCR 子进程:worker 路径检查 + 内存预检 + Popen。
|
||
|
||
返回 True = 子进程已拉起(self._easyocr_proc 存活);False = 启动失败
|
||
(内部已记录日志 / 设置 _easyocr_broken / 冷却)。
|
||
"""
|
||
worker_path = os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)),
|
||
'easyocr_worker.py'
|
||
)
|
||
if not os.path.exists(worker_path):
|
||
logger.error(f"[ocr] EasyOCR worker 不存在: {worker_path}")
|
||
self._easyocr_broken = True
|
||
return False
|
||
|
||
# ⭐ 内存预检:物理内存 + 页面文件(虚拟内存)双重检查
|
||
# 正常调度重启时跳过内存检测,避免正常资源释放后重启被误判为错误
|
||
if not is_normal_restart:
|
||
try:
|
||
import ctypes
|
||
class _MEMORYSTATUSEX(ctypes.Structure):
|
||
_fields_ = [
|
||
("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
|
||
("ullTotalPhys", ctypes.c_ulonglong), ("ullAvailPhys", ctypes.c_ulonglong),
|
||
("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong),
|
||
("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong),
|
||
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||
]
|
||
_mem = _MEMORYSTATUSEX()
|
||
_mem.dwLength = ctypes.sizeof(_MEMORYSTATUSEX)
|
||
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(_mem))
|
||
|
||
# 物理内存
|
||
_phys_total_gb = _mem.ullTotalPhys / (1024**3)
|
||
_phys_avail_gb = _mem.ullAvailPhys / (1024**3)
|
||
_phys_used_gb = _phys_total_gb - _phys_avail_gb
|
||
_phys_used_pct = round(_mem.dwMemoryLoad, 1)
|
||
|
||
# 页面文件(虚拟内存)
|
||
_page_total_gb = _mem.ullTotalPageFile / (1024**3)
|
||
_page_avail_gb = _mem.ullAvailPageFile / (1024**3)
|
||
_page_used_gb = _page_total_gb - _page_avail_gb
|
||
_page_used_pct = round((1 - _mem.ullAvailPageFile / max(_mem.ullTotalPageFile, 1)) * 100, 1)
|
||
|
||
# 虚拟内存(进程地址空间)
|
||
_virt_total_gb = _mem.ullTotalVirtual / (1024**3)
|
||
_virt_avail_gb = _mem.ullAvailVirtual / (1024**3)
|
||
|
||
logger.debug(f"[ocr] 内存状态:物理 {_phys_used_gb:.1f}/{_phys_total_gb:.1f}GB({_phys_used_pct}%),"
|
||
f"页面文件 {_page_used_gb:.1f}/{_page_total_gb:.1f}GB({_page_used_pct}%),"
|
||
f"虚拟地址空间 {_virt_avail_gb:.1f}GB可用")
|
||
|
||
# ⭐ 放宽页面文件百分比阈值:>95% 已用即视为不足(避免内存充足时误判)
|
||
# 用户报告提交量81.8%被误判为不足,提高阈值到95%
|
||
if _page_used_pct > 95.0:
|
||
logger.error(f"[ocr] ⚠️ 页面文件不足: 已用{_page_used_pct}%({_page_used_gb:.1f}/{_page_total_gb:.1f}GB),"
|
||
f"剩余{_page_avail_gb:.1f}GB。EasyOCR 冷却1小时。建议:增大 Windows 页面文件")
|
||
self._easyocr_retry_until = time.time() + 3600
|
||
return False
|
||
|
||
# ⭐ 放宽系统总提交量检查:提交量 > 95% 总commit容量(物理+页面文件) 时冻结风险极高
|
||
# 避免内存充足时误判(用户报告提交量81.8%被误判为不足)
|
||
_commit_gb = _phys_used_gb + _page_used_gb
|
||
_commit_total_gb = _phys_total_gb + _page_total_gb
|
||
_commit_pct = (_commit_gb / _commit_total_gb * 100) if _commit_total_gb > 0 else 0
|
||
if _commit_pct > 95.0:
|
||
logger.error(f"[ocr] ⚠️ 系统提交量过高: {_commit_gb:.1f}GB ({_commit_pct:.0f}%总commit容量{_commit_total_gb:.1f}GB),"
|
||
f"EasyOCR 冷却1小时。当前物理{_phys_used_pct}%+页面{_page_used_pct}%。"
|
||
f"建议:关闭占用内存的程序或增大物理内存")
|
||
self._easyocr_retry_until = time.time() + 3600
|
||
return False
|
||
|
||
# 物理内存严重不足时也跳过(防止系统冻结)
|
||
if _phys_avail_gb < 1.0 and _phys_used_pct > 90:
|
||
logger.error(f"[ocr] ⚠️ 物理内存严重不足: 剩余{_phys_avail_gb:.1f}GB({_phys_used_pct}%),"
|
||
f"EasyOCR 冷却30分钟。建议:关闭其他程序释放内存")
|
||
self._easyocr_retry_until = time.time() + 1800
|
||
return False
|
||
|
||
except Exception:
|
||
pass # 获取失败则不阻断
|
||
else:
|
||
logger.debug("[ocr] 正常调度重启:跳过内存检测,直接启动子进程")
|
||
|
||
try:
|
||
_worker_env = dict(os.environ)
|
||
# ⭐ CUDA_LAUNCH_BLOCKING=1: CUDA 操作同步化,access violation 时只杀子进程不冻整机
|
||
_worker_env["CUDA_LAUNCH_BLOCKING"] = "1"
|
||
# ⭐ EASYOCR_WORKER=1: 让 main.py 检测到 worker 模式,跳过 GUI 初始化
|
||
_worker_env["EASYOCR_WORKER"] = "1"
|
||
|
||
# 内存检测:可用内存 < 2GB 时强制CPU模式,防止CUDA加载导致冻结
|
||
# CUDA加载torch需要大量内存,物理内存不足时强制使用页面文件会导致系统冻结
|
||
# ⭐ 放宽阈值:物理内存<2GB才强制CPU,提交量>95%才强制CPU
|
||
try:
|
||
mem = psutil.virtual_memory()
|
||
available_gb = mem.available / (1024**3)
|
||
total_gb = mem.total / (1024**3)
|
||
used_gb = mem.used / (1024**3)
|
||
# 获取页面文件信息
|
||
try:
|
||
swap = psutil.swap_memory()
|
||
swap_total_gb = swap.total / (1024**3)
|
||
swap_used_gb = swap.used / (1024**3)
|
||
swap_avail_gb = swap.free / (1024**3)
|
||
swap_pct = swap.percent
|
||
except:
|
||
swap_total_gb = swap_used_gb = swap_avail_gb = swap_pct = 0
|
||
|
||
# 详细日志
|
||
logger.debug(f"[ocr] 内存详情:物理 {used_gb:.1f}/{total_gb:.1f}GB({mem.percent}%),"
|
||
f"页面文件 {swap_used_gb:.1f}/{swap_total_gb:.1f}GB({swap_pct}%)")
|
||
|
||
# ⭐ 页面文件百分比 + 总提交量双重检查:超阈值直接强制CPU(防冻结)
|
||
_commit_check_gb = used_gb + swap_used_gb
|
||
_commit_total_check_gb = total_gb + swap_total_gb
|
||
_commit_check_pct = (_commit_check_gb / _commit_total_check_gb * 100) if _commit_total_check_gb > 0 else 0
|
||
# ⭐ 放宽阈值:提交量>95%才强制CPU,避免内存充足时误判
|
||
if swap_pct > 95.0 or _commit_check_pct > 95.0:
|
||
_worker_env["EASYOCR_FORCE_CPU"] = "1"
|
||
logger.warning(f"[ocr] 内存压力高(物理{mem.percent}%+页面{swap_pct}%,"
|
||
f"总提交{_commit_check_gb:.1f}GB ({_commit_check_pct:.0f}%总commit容量{_commit_total_check_gb:.1f}GB),"
|
||
f"EasyOCR 子进程强制CPU模式(避免CUDA加载冻结)")
|
||
elif available_gb < 2.0:
|
||
_worker_env["EASYOCR_FORCE_CPU"] = "1"
|
||
logger.warning(f"[ocr] 可用内存不足({available_gb:.1f}GB/总{total_gb:.1f}GB,"
|
||
f"使用{mem.percent}%),页面文件{swap_avail_gb:.1f}GB可用({swap_pct}%),"
|
||
f"EasyOCR 子进程强制CPU模式(避免CUDA加载冻结)")
|
||
elif self._easyocr_cuda_oom_count >= 2:
|
||
# ⭐ 连续 CUDA OOM/kernel error → 强制 CPU 模式启动(显存不足时防冻结)
|
||
_worker_env["EASYOCR_FORCE_CPU"] = "1"
|
||
logger.warning("[ocr] EasyOCR 连续CUDA错误≥2次,本次强制 CPU 模式启动 worker")
|
||
except Exception as e:
|
||
logger.debug(f"[ocr] 获取内存信息失败({e}),不阻断启动")
|
||
|
||
self._easyocr_proc = subprocess.Popen(
|
||
[sys.executable, '-u', worker_path],
|
||
stdin=subprocess.PIPE,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE, # 捕获 worker 错误日志(防崩溃排查)
|
||
text=True,
|
||
bufsize=1, # 行缓冲
|
||
env=_worker_env,
|
||
)
|
||
logger.debug("[ocr] EasyOCR 子进程已启动,等待初始化握手...")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"[ocr] 启动 EasyOCR 子进程失败: {e}")
|
||
self._easyocr_broken = True
|
||
self._easyocr_proc = None
|
||
return False
|
||
|
||
def _handshake_easyocr_subprocess(self, is_normal_restart: bool) -> bool:
|
||
"""(本线程成功拉起进程后调用)等待 worker 的 {"ready": true} 握手(最长 60s 初始化预算)。
|
||
|
||
握手成功:设置 _easyocr_ready + 触发 _easyocr_ready_event,返回 True。
|
||
握手失败/超时/崩溃:杀进程 + 冷却 + 清除就绪信号,返回 False。
|
||
"""
|
||
try:
|
||
from queue import Queue, Empty as _QueueEmpty
|
||
_q = Queue()
|
||
def _read_ready():
|
||
try:
|
||
_q.put(self._easyocr_proc.stdout.readline())
|
||
except Exception:
|
||
pass
|
||
_t = threading.Thread(target=_read_ready, daemon=True)
|
||
_t.start()
|
||
try:
|
||
line = _q.get(timeout=60)
|
||
except _QueueEmpty:
|
||
logger.error("[ocr] EasyOCR 子进程初始化超时(60s),杀死进程"
|
||
+ ("(正常重启,不施加冷却)" if is_normal_restart else ",30s 后重试"))
|
||
self._kill_easyocr_proc("初始化超时")
|
||
if not is_normal_restart:
|
||
self._easyocr_retry_until = time.time() + 30
|
||
return False
|
||
if not line:
|
||
# stdout 关闭 = worker 崩溃,读 stderr + 崩溃日志看原因
|
||
err = ""
|
||
try:
|
||
err = self._easyocr_proc.stderr.read()[:500]
|
||
except Exception:
|
||
pass
|
||
crash = _read_easyocr_crash_tail()
|
||
detail = err.strip()
|
||
if crash:
|
||
detail = (detail + "\n[ocr] worker 崩溃日志(easyocr_crash.log):\n" + crash).strip()
|
||
logger.error(f"[ocr] EasyOCR worker 启动即崩溃: {detail}")
|
||
# ⭐ 检测 CUDA 相关崩溃(access violation / torch 错误 / 静默原生崩溃)→ 增加 OOM 计数,下次强制 CPU
|
||
_is_cuda_crash = (crash and ("access violation" in crash.lower() or "torch" in crash.lower()))
|
||
_is_silent_native_crash = (not err.strip() and not crash.strip()) # 无stderr无crash log = 原生层崩溃
|
||
if _is_cuda_crash or _is_silent_native_crash:
|
||
self._easyocr_cuda_oom_count += 1
|
||
logger.warning(f"[ocr] 检测到{'CUDA崩溃' if _is_cuda_crash else '静默原生崩溃(无日志)'}, "
|
||
f"累计{self._easyocr_cuda_oom_count}次,下次可能强制 CPU")
|
||
# ⭐ 检测页面文件不足(WinError 1455)→ 长期冷却,不反复重试
|
||
_is_pagefile_error = "页面文件" in str(detail) or "1455" in str(detail)
|
||
if _is_pagefile_error:
|
||
if not is_normal_restart:
|
||
self._easyocr_retry_until = time.time() + 3600 # 1小时冷却(系统级问题)
|
||
logger.error("[ocr] ⚠️ 页面文件不足(WinError 1455),EasyOCR"
|
||
+ ("正常重启不施加冷却,交由系统调度" if is_normal_restart else "冷却1小时。")
|
||
+ "建议:增大 Windows 页面文件大小(系统属性→高级→性能→虚拟内存)")
|
||
else:
|
||
if not is_normal_restart:
|
||
self._easyocr_retry_until = time.time() + 30
|
||
# ⭐ Task #1446:进程已死,清除就绪信号与握手事件
|
||
self._easyocr_proc = None
|
||
self._easyocr_ready = False
|
||
self._easyocr_ready_event.clear()
|
||
self._easyocr_starting = False
|
||
return False
|
||
try:
|
||
payload = json.loads(line)
|
||
if payload.get("ready"):
|
||
logger.info("[ocr] EasyOCR 子进程就绪")
|
||
self._easyocr_consecutive_startup_failures = 0 # 成功启动,重置启动失败计数
|
||
# ⭐ 截断崩溃日志:清除旧条目,防止旧记录混淆用户
|
||
_truncate_easyocr_crash_log()
|
||
# ⭐ Task #1446:握手完成 → 标记就绪并广播事件,唤醒等待中的并发线程,
|
||
# 保证 _call_easyocr_safe 的 10s 请求计时只在 worker 真正就绪后才开始
|
||
self._easyocr_ready = True
|
||
self._easyocr_ready_event.set()
|
||
self._easyocr_starting = False # 启动完成,清除「正在启动」标志
|
||
return True
|
||
except Exception:
|
||
pass
|
||
# 非 ready 响应 → 协议异常
|
||
logger.error(f"[ocr] EasyOCR worker 异常响应: {line[:100]!r}")
|
||
self._kill_easyocr_proc("协议异常")
|
||
if not is_normal_restart:
|
||
self._easyocr_retry_until = time.time() + 30
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"[ocr] EasyOCR 握手失败: {e}")
|
||
self._kill_easyocr_proc("握手失败")
|
||
if not is_normal_restart:
|
||
self._easyocr_retry_until = time.time() + 30
|
||
return False
|
||
|
||
def _call_easyocr_safe(self, img_np, timeout: Optional[float] = None) -> Optional[str]:
|
||
"""通过子进程安全调用 EasyOCR。返回识别文本,失败/崩溃/超时时返回 None。
|
||
|
||
使用 _easyocr_lock 保护子进程 stdin/stdout,避免 QQ/微信独立线程并发写乱。
|
||
timeout — 子进程读取超时秒数,None=无限等待;超时后杀死子进程下次重启。
|
||
"""
|
||
import io
|
||
import base64
|
||
|
||
if not self._ensure_easyocr_subprocess():
|
||
# ⭐ 区分"冷却中等待"和"真正启动失败"
|
||
# 冷却中:_easyocr_retry_until > now → 只是还没到重试时间,不计入失败
|
||
# 真失败:子进程启动命令本身报错 → 计入失败
|
||
if self._easyocr_retry_until > time.time():
|
||
# 冷却期,跳过不计数(正常等待重试)
|
||
logger.debug(f"[ocr] EasyOCR 冷却中({int(self._easyocr_retry_until - time.time())}s),跳过")
|
||
return None
|
||
# 真正启动失败(_ensure_easyocr_subprocess内部报错)
|
||
self._easyocr_consecutive_startup_failures += 1
|
||
if self._easyocr_consecutive_startup_failures >= self._easyocr_max_startup_failures:
|
||
logger.warning(f"[ocr] EasyOCR 连续{self._easyocr_consecutive_startup_failures}次启动失败,"
|
||
f"降级 Windows OCR,5分钟后再试(疑似内存不足,请关闭其他程序释放内存)")
|
||
# 长冷却:内存不足场景下不反复重启刷屏,保留内存恢复后重试能力
|
||
# (彻底重启/用户释放内存后,冷却到期会自动重试)
|
||
self._easyocr_retry_until = time.time() + 300
|
||
# 重置计数器(冷却后重试重新计数)
|
||
self._easyocr_consecutive_startup_failures = 0
|
||
else:
|
||
# ⭐ 短冷却:未达阈值时也设30s冷却,避免反复启动失败刷屏
|
||
if self._easyocr_retry_until <= time.time():
|
||
self._easyocr_retry_until = time.time() + 30
|
||
logger.debug(f"[ocr] EasyOCR 第{self._easyocr_consecutive_startup_failures}次启动失败,"
|
||
f"冷却({int(self._easyocr_retry_until - time.time())}s)")
|
||
return None
|
||
|
||
try:
|
||
# numpy → PIL → PNG bytes → base64
|
||
img_pil = Image.fromarray(img_np)
|
||
buf = io.BytesIO()
|
||
img_pil.save(buf, format='PNG')
|
||
img_b64 = base64.b64encode(buf.getvalue()).decode()
|
||
|
||
# ⚠ 防永久阻塞:QQ/微信 共享单子进程+单锁,若一方慢速 OCR 持有锁,
|
||
# 另一方 acquire 会无限挂起 → daemon 线程永生 → 持有 2x 大图泄漏内存。
|
||
# 改为带超时 acquire,超时即放弃本次请求(下轮重试),线程正常结束释放大图。
|
||
if not self._easyocr_lock.acquire(timeout=15.0):
|
||
# ⭐ 锁竞争超时:另一个线程正在使用EasyOCR处理大图(正常行为)
|
||
# worker没崩溃,只是在忙 → 不计入consecutive_failures
|
||
# 仅重置call_count防止"正常释放"误触发
|
||
logger.debug("[ocr] EasyOCR 锁竞争超时(15s),另一线程正在处理中,本次跳过(正常行为)")
|
||
self._easyocr_call_count = 0 # 中断连续成功计数,防"正常释放"误触发
|
||
return None
|
||
try:
|
||
# ⚠ 竞态保护:另一个线程可能已杀死进程并设 None
|
||
if not self._easyocr_proc or self._easyocr_proc.poll() is not None:
|
||
return None
|
||
self._easyocr_proc.stdin.write(json.dumps({"img": img_b64}) + "\n")
|
||
self._easyocr_proc.stdin.flush()
|
||
|
||
from queue import Queue, Empty as _QueueEmpty
|
||
# readline 包装超时:用 daemon 线程 + Queue 实现跨平台
|
||
if timeout is not None:
|
||
_q = Queue()
|
||
def _read_worker():
|
||
try:
|
||
proc = self._easyocr_proc
|
||
if proc and proc.poll() is None:
|
||
_q.put(proc.stdout.readline())
|
||
except Exception:
|
||
pass
|
||
_t = threading.Thread(target=_read_worker, daemon=True)
|
||
_t.start()
|
||
# ⭐ 急停检查:轮询 _running,急停后立即放弃等待(丢弃未出结果的 OCR)
|
||
_deadline = time.time() + timeout
|
||
line = None
|
||
while self._running and time.time() < _deadline:
|
||
try:
|
||
line = _q.get(timeout=0.05)
|
||
break
|
||
except _QueueEmpty:
|
||
continue
|
||
if line is None:
|
||
if not self._running:
|
||
logger.debug("[ocr] EasyOCR 等待期间急停,丢弃未出结果的 OCR 请求")
|
||
else:
|
||
self._easyocr_consecutive_failures += 1
|
||
if self._easyocr_call_count > 0:
|
||
logger.debug(f"[ocr] EasyOCR 超时重置call_count: {self._easyocr_call_count}→0(防'正常释放'误触发)")
|
||
self._easyocr_call_count = 0 # ⭐ 超时重置call_count,防"正常释放"误触发
|
||
# 动态增加超时时间,避免EasyOCR初始化未完成就被判超时
|
||
old_timeout = self._easyocr_current_timeout
|
||
self._easyocr_current_timeout = min(self._easyocr_current_timeout + self._easyocr_timeout_step, self._easyocr_max_timeout)
|
||
if self._easyocr_current_timeout != old_timeout:
|
||
logger.debug(f"[ocr] EasyOCR 超时增加: {old_timeout}s → {self._easyocr_current_timeout}s")
|
||
if self._easyocr_consecutive_failures >= self._easyocr_max_failures:
|
||
logger.debug(f"[ocr] EasyOCR 连续{self._easyocr_consecutive_failures}次超时,杀子进程重来")
|
||
self._kill_easyocr_proc("连续超时")
|
||
self._easyocr_consecutive_failures = 0
|
||
self._easyocr_consecutive_startup_failures = 0 # 重置启动失败计数
|
||
else:
|
||
logger.debug(f"[ocr] EasyOCR 第{self._easyocr_consecutive_failures}次超时({timeout}s),保留子进程不杀")
|
||
return None
|
||
else:
|
||
line = self._easyocr_proc.stdout.readline()
|
||
if not line:
|
||
crash = _read_easyocr_crash_tail()
|
||
msg = "EasyOCR 子进程 stdout 关闭"
|
||
if crash:
|
||
msg += f"(崩溃日志: {crash})"
|
||
raise ConnectionError(msg)
|
||
finally:
|
||
self._easyocr_lock.release()
|
||
result = json.loads(line)
|
||
if result.get("ok"):
|
||
# 成功 → 重置连续失败计数
|
||
self._easyocr_consecutive_failures = 0
|
||
self._easyocr_consecutive_startup_failures = 0 # 成功启动,重置启动失败计数
|
||
# 成功识别后重置超时到最小值,避免累积
|
||
self._easyocr_current_timeout = self._easyocr_min_timeout
|
||
# 计数 + 主动重启:EasyOCR GPU 内存泄漏累积 → OOM → 冻结
|
||
self._easyocr_call_count += 1
|
||
if self._easyocr_call_count >= self._easyocr_max_calls:
|
||
logger.debug(f"[ocr] EasyOCR 正常资源释放:{self._easyocr_max_calls}次调用完成,重启子进程清GPU缓存(非错误,不计入失败计数器)")
|
||
self._kill_easyocr_proc("正常资源释放")
|
||
self._easyocr_call_count = 0
|
||
# ⭐ 正常调度重启:标记为正常重启,跳过内存检测直接启动
|
||
self._easyocr_normal_restart = True
|
||
# ⭐ 正常调度是系统自主管控的启停,不录入任何错误计数器、不施加冷静期:
|
||
# 清除可能残留的 30s 冷却,重置连续失败计数,交由系统自行调度启停。
|
||
self._easyocr_retry_until = 0.0
|
||
self._easyocr_consecutive_failures = 0
|
||
self._easyocr_consecutive_startup_failures = 0
|
||
return result["text"]
|
||
else:
|
||
err = result.get('error', '?')[:100]
|
||
logger.debug(f"[ocr] EasyOCR worker 错误: {err}")
|
||
# ⭐ CUDA OOM / kernel error:worker 即将自杀退出(context 已损坏),
|
||
# 这里立即确认杀掉 + 冷却,避免后续轮询继续往坏进程发图
|
||
if 'cuda' in err.lower() or 'out of memory' in err.lower():
|
||
self._easyocr_cuda_oom_count += 1
|
||
logger.warning(
|
||
f"[ocr] EasyOCR CUDA 错误(累计{self._easyocr_cuda_oom_count}次),"
|
||
f"杀子进程+冷却,{('下次强制CPU' if self._easyocr_cuda_oom_count >= 2 else '保持GPU')}")
|
||
self._kill_easyocr_proc("CUDA错误")
|
||
self._easyocr_retry_until = time.time() + 30
|
||
return None
|
||
except (BrokenPipeError, ConnectionError, OSError) as e:
|
||
logger.debug(f"[ocr] EasyOCR 子进程通信断开: {e},30s 后自动重试")
|
||
self._easyocr_proc = None
|
||
self._easyocr_call_count = 0 # 进程死了,重置call_count
|
||
self._easyocr_consecutive_failures += 1
|
||
self._easyocr_retry_until = time.time() + 30 # 冷却后自动重试,不永久 broken
|
||
return None
|
||
except Exception as e:
|
||
logger.debug(f"[ocr] EasyOCR 子进程异常: {e}")
|
||
self._easyocr_call_count = 0
|
||
self._easyocr_consecutive_failures += 1
|
||
return None
|
||
|
||
@staticmethod
|
||
def _score_ocr_text(text: str) -> float:
|
||
"""为 OCR 结果质量打分(越高越好)。对比引擎用。"""
|
||
if not text or len(text.strip()) < 5:
|
||
return 0.0
|
||
score = 0.0
|
||
# 1) 有效 CJK 数量(越多 = 识别越好)
|
||
cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
||
score += cjk * 0.5
|
||
# 2) Unicode 垃圾字符扣分(OCR 常见误识碎片)
|
||
junk = set('勖彐丬亻俨冉勹刂冫孑亠丷')
|
||
junk_count = sum(1 for c in text if c in junk)
|
||
score -= junk_count * 3
|
||
# 3) CJK 间空格比例扣分(Windows OCR 特有毛病)
|
||
# "你 好" vs "你好" — 空格越多说明识别越差
|
||
total = len(text)
|
||
spaces = text.count(' ')
|
||
if total > 0 and cjk > 0:
|
||
space_penalty = (spaces / total) * 10
|
||
score -= space_penalty
|
||
# 4) 时间戳加分 = 消息结构更完整
|
||
timestamps = len(re.findall(r'\d{1,2}:\d{2}', text))
|
||
score += min(timestamps * 2, 6)
|
||
return max(score, 0.0)
|
||
|
||
def _pick_ocr_text(self, win_text: str, easy_text: str, image: Image.Image) -> tuple:
|
||
"""双引擎对比,选质量更高的结果。日志记录引擎胜负统计。返回 (text, engine_name)。
|
||
|
||
⭐ 长度保护:当一引擎识别行数显著多于另一方(>=1.5x)时,
|
||
即使分数略低也优先选更长文本——避免 Windows OCR 截断丢消息。
|
||
"""
|
||
if not easy_text:
|
||
return (win_text, "windows")
|
||
if not win_text:
|
||
return (easy_text, "easyocr")
|
||
win_score = self._score_ocr_text(win_text)
|
||
easy_score = self._score_ocr_text(easy_text)
|
||
win_lines = len([l for l in win_text.split('\n') if l.strip()])
|
||
easy_lines = len([l for l in easy_text.split('\n') if l.strip()])
|
||
win_len = len(win_text.strip())
|
||
easy_len = len(easy_text.strip())
|
||
|
||
# 长度保护:一方行数 >= 1.5x 另一方 → 倾向更长文本(分数差 < 10 时触发)
|
||
chosen = "windows" if win_score >= easy_score else "easyocr"
|
||
score_diff = abs(win_score - easy_score)
|
||
if score_diff < 10:
|
||
if easy_lines >= win_lines * 1.5 and easy_len > win_len * 1.3:
|
||
chosen = "easyocr"
|
||
logger.debug(
|
||
f"[ocr] 长度保护: easy行数{easy_lines}>=1.5x win行数{win_lines},"
|
||
f"分数差仅{score_diff:.1f},强制选easyocr"
|
||
)
|
||
elif win_lines >= easy_lines * 1.5 and win_len > easy_len * 1.3:
|
||
chosen = "windows"
|
||
logger.debug(
|
||
f"[ocr] 长度保护: win行数{win_lines}>=1.5x easy行数{easy_lines},"
|
||
f"分数差仅{score_diff:.1f},强制选windows"
|
||
)
|
||
|
||
self._ocr_engine_wins[chosen] = self._ocr_engine_wins.get(chosen, 0) + 1
|
||
total = sum(self._ocr_engine_wins.values())
|
||
ratio = self._ocr_engine_wins.get(chosen, 0) / max(total, 1)
|
||
logger.debug(
|
||
f"[ocr] 引擎对比: win={win_score:.1f}(行{win_lines},字符{win_len}) "
|
||
f"vs easy={easy_score:.1f}(行{easy_lines},字符{easy_len}) "
|
||
f"→ 选{chosen}(分数差{score_diff:.1f}, 胜率{ratio:.0%}, {total}轮)"
|
||
)
|
||
return (win_text, chosen) if chosen == "windows" else (easy_text, chosen)
|
||
|
||
def _safe_screenshot(self, bbox: tuple, timeout: float = 3.0):
|
||
"""带超时的屏幕截图。ImageGrab.grab() 的 BitBlt 可能因 DWM 卡死→整机冻结 2-3min。
|
||
|
||
用线程+timeout 隔离:BitBlt 卡死只杀线程,不波及 DWM/桌面。
|
||
timeout=3.0: 3 秒截不到就跳过本轮,bot 继续下次轮询。
|
||
⭐ 等待期间轮询 _running:急停/停止后立即折返,不等截屏结果。
|
||
"""
|
||
_im = [None]
|
||
def _grab():
|
||
try:
|
||
_im[0] = ImageGrab.grab(bbox=bbox)
|
||
except Exception:
|
||
pass
|
||
|
||
import threading
|
||
t = threading.Thread(target=_grab, daemon=True)
|
||
t.start()
|
||
# ⭐ 急停检查:join 期间轮询 _running,急停/停止后立即折返,不等截屏结果
|
||
deadline = time.time() + timeout
|
||
while self._running and t.is_alive() and time.time() < deadline:
|
||
t.join(0.05)
|
||
if t.is_alive():
|
||
if not self._running:
|
||
logger.debug("[ocr] 截图期间急停,丢弃未完成的截屏")
|
||
else:
|
||
logger.warning(f"[ocr] 截图超时 ({timeout}s),BitBlt 可能卡在 DWM,跳过本轮")
|
||
return None
|
||
return _im[0]
|
||
|
||
def _screenshot_window(self, hwnd: int, timeout: float = 3.0):
|
||
"""使用 PrintWindow API 截取指定窗口内容(即使被其他窗口遮挡)。
|
||
|
||
PrintWindow 可以截取不可见/被遮挡窗口的内容,但某些窗口(如 Electron)可能不支持。
|
||
返回 PIL Image 或 None。
|
||
"""
|
||
logger.debug(f"[screenshot] PrintWindow 开始: hwnd={hwnd}")
|
||
_im = [None]
|
||
def _grab():
|
||
try:
|
||
# 获取窗口尺寸
|
||
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
|
||
width = right - left
|
||
height = bottom - top
|
||
if width <= 0 or height <= 0:
|
||
return
|
||
# 创建设备上下文和位图(先置 None,finally 统一释放,防异常路径 GDI 句柄/位图泄漏累积)
|
||
hwnd_dc = mfc_dc = save_dc = save_bitmap = None
|
||
try:
|
||
hwnd_dc = win32gui.GetWindowDC(hwnd)
|
||
mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
|
||
save_dc = mfc_dc.CreateCompatibleDC()
|
||
save_bitmap = win32ui.CreateBitmap()
|
||
save_bitmap.CreateCompatibleBitmap(mfc_dc, width, height)
|
||
save_dc.SelectObject(save_bitmap)
|
||
# 使用 PrintWindow 截取窗口内容
|
||
# PW_RENDERFULLCONTENT = 0x00000002 (Windows 8.1+)
|
||
result = ctypes.windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), 0x00000002)
|
||
if result:
|
||
# 转换为 PIL Image
|
||
bmpinfo = save_bitmap.GetInfo()
|
||
bmpstr = save_bitmap.GetBitmapBits(True)
|
||
img = Image.frombuffer('RGB', (bmpinfo['bmWidth'], bmpinfo['bmHeight']), bmpstr, 'raw', 'BGRX', 0, 1)
|
||
_im[0] = img
|
||
finally:
|
||
# ⚠ 无论成功/异常都释放 GDI 资源,避免句柄/位图泄漏累积
|
||
try:
|
||
if save_bitmap is not None:
|
||
win32gui.DeleteObject(save_bitmap.GetHandle())
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if save_dc is not None:
|
||
save_dc.DeleteDC()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if mfc_dc is not None:
|
||
mfc_dc.DeleteDC()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if hwnd_dc is not None:
|
||
win32gui.ReleaseDC(hwnd, hwnd_dc)
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
logger.debug(f"[screenshot] PrintWindow 失败: {e}")
|
||
|
||
import threading
|
||
t = threading.Thread(target=_grab, daemon=True)
|
||
t.start()
|
||
deadline = time.time() + timeout
|
||
while self._running and t.is_alive() and time.time() < deadline:
|
||
t.join(0.05)
|
||
if t.is_alive():
|
||
if not self._running:
|
||
logger.debug("[screenshot] PrintWindow 期间急停")
|
||
else:
|
||
logger.warning(f"[screenshot] PrintWindow 超时 ({timeout}s)")
|
||
return None
|
||
logger.debug(f"[screenshot] PrintWindow 结果: {'成功' if _im[0] is not None else '失败'}")
|
||
return _im[0]
|
||
|
||
def _ocr_dual_engine(self, im_processed: Image.Image, im_original: Image.Image, platform: str) -> tuple:
|
||
"""双引擎并发 OCR:Windows OCR + EasyOCR 同时跑,最多等 10 秒。
|
||
|
||
哪個先出用哪個,兩個都出時 _pick_ocr_text 質量對比選優。
|
||
|
||
im_processed: 预处理后的图(2x resize + sharpen + contrast, RGBA)
|
||
im_original: 原始截图(bad allocation 降级用)
|
||
|
||
返回 (raw_text, engine_name) 含義同 _pick_ocr_text。
|
||
|
||
⚠ 不用 ThreadPoolExecutor with 块:__exit__ 调 shutdown(wait=True) 会在
|
||
Windows OCR 卡死时永久阻塞主线程 → 电脑冻结。改用 daemon 线程 + join(timeout)。
|
||
"""
|
||
|
||
engine = self._ocr_ensure_engine()
|
||
if not engine:
|
||
return ("", "")
|
||
|
||
win_text: list[str] = [""]
|
||
easy_text: list[str] = [""]
|
||
|
||
def _win_worker():
|
||
try:
|
||
im = im_processed
|
||
if im.mode != 'RGBA':
|
||
im = im.convert('RGBA')
|
||
sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height)
|
||
sb.copy_from_buffer(im.tobytes())
|
||
# recognize_async().get() 无超时保护——卡死会冻住整个线程
|
||
op = engine.recognize_async(sb)
|
||
result = op.get() # ⚠ 此处可能卡死:Windows OCR 引擎无响应
|
||
win_text[0] = result.text.strip()
|
||
except (RuntimeError, OSError) as e:
|
||
estr = str(e)
|
||
if "bad allocation" in estr or "memory" in estr.lower():
|
||
try:
|
||
im2 = im_original
|
||
if im2.mode != 'RGBA':
|
||
im2 = im2.convert('RGBA')
|
||
sb2 = SoftwareBitmap(BitmapPixelFormat.RGBA8, im2.width, im2.height)
|
||
sb2.copy_from_buffer(im2.tobytes())
|
||
op2 = engine.recognize_async(sb2)
|
||
result2 = op2.get()
|
||
win_text[0] = result2.text.strip()
|
||
except Exception:
|
||
pass
|
||
# 其他异常静默处理
|
||
|
||
_OCR_TIMEOUT = 10.0 # 总超时秒数(单次OCR请求)- 必须在 _easy_worker 之前定义
|
||
|
||
def _easy_worker():
|
||
if not _HAS_EASYOCR or self._easyocr_broken:
|
||
return
|
||
# EasyOCR 用原图(1x):2x 图送 GPU 显存压力大 + 加速泄漏
|
||
img_np = _np.array(im_original)
|
||
# 使用动态超时:初始45秒,每次超时增加15秒,成功后重置
|
||
_inner_timeout = self._easyocr_current_timeout
|
||
text = self._call_easyocr_safe(img_np, timeout=_inner_timeout)
|
||
if text:
|
||
easy_text[0] = text
|
||
|
||
# ── 用 daemon 线程替代 ThreadPoolExecutor ──
|
||
# ThreadPoolExecutor.__exit__ 调 shutdown(wait=True) 会永久阻塞主线程。
|
||
# daemon 线程:join 超时后直接丢弃,线程随进程退出自动回收。
|
||
_t_win = threading.Thread(target=_win_worker, daemon=True, name="ocr-win")
|
||
_t_easy = threading.Thread(target=_easy_worker, daemon=True, name="ocr-easy")
|
||
|
||
_t_win.start()
|
||
_t_easy.start()
|
||
|
||
# ⭐ 急停检查:join 期间轮询 _running,急停/停止后立即折返,不等 OCR 结果
|
||
_deadline = time.time() + _OCR_TIMEOUT
|
||
while self._running and time.time() < _deadline:
|
||
if not _t_win.is_alive() and not _t_easy.is_alive():
|
||
break
|
||
_t_win.join(0.05)
|
||
_t_easy.join(0.05)
|
||
if not self._running:
|
||
logger.debug(f"[{platform}] 双引擎 OCR 期间急停,丢弃未出结果的 OCR")
|
||
|
||
# 超时的线程还在跑?记录警告,不阻塞——daemon 线程随进程退出自动回收
|
||
if _t_win.is_alive():
|
||
logger.warning(f"[{platform}] Windows OCR 超时({_OCR_TIMEOUT}s)线程存活,跳过")
|
||
if _t_easy.is_alive():
|
||
logger.warning(f"[{platform}] EasyOCR 超时({_OCR_TIMEOUT}s)线程存活,跳过")
|
||
# ⭐ EasyOCR线程超时也计入连续失败,防止"超时跳过"绕过杀进程逻辑
|
||
self._easyocr_consecutive_failures += 1
|
||
self._easyocr_call_count = 0 # ⭐ 超时重置call_count,防"正常释放"误触发
|
||
if self._easyocr_consecutive_failures >= self._easyocr_max_failures:
|
||
logger.warning(f"[{platform}] EasyOCR 连续{self._easyocr_consecutive_failures}次超时,杀子进程重来")
|
||
self._kill_easyocr_proc("双引擎连续超时")
|
||
self._easyocr_consecutive_failures = 0
|
||
self._easyocr_consecutive_startup_failures = 0
|
||
|
||
wt = win_text[0]
|
||
et = easy_text[0]
|
||
|
||
# ⭐ EasyOCR超时+Windows OCR混合消息检测:当EasyOCR超时且Windows OCR结果包含
|
||
# 多个时间戳(表明是多条消息混合),返回空结果,避免消息搅和
|
||
if not et and wt:
|
||
timestamps = re.findall(r'\d{1,2}:\d{1,2}', wt)
|
||
if len(timestamps) >= 2:
|
||
logger.warning(f"[{platform}] EasyOCR超时+Windows OCR检测到{len(timestamps)}个时间戳"
|
||
f"({timestamps[:3]}...),疑似消息混合,跳过本轮避免搅和")
|
||
return ("", "")
|
||
|
||
# 日志(原始输出逐行记录,用於長期對比兩引擎能力)
|
||
if wt:
|
||
for i, l in enumerate(wt.split('\n')):
|
||
if l.strip():
|
||
logger.info(f"[ocr] [windows/原始] line[{i}]: {l.strip()[:120]!r}")
|
||
if et:
|
||
for i, l in enumerate(et.split('\n')):
|
||
if l.strip():
|
||
logger.info(f"[ocr] [easyocr/原始] line[{i}]: {l.strip()[:120]!r}")
|
||
|
||
if not wt and not et:
|
||
return ("", "")
|
||
|
||
return self._pick_ocr_text(wt, et, im_processed)
|
||
|
||
def _ocr_read_qq(self) -> list:
|
||
"""OCR 截屏 QQ 消息区域 -> 双引擎(Windows OCR + EasyOCR)提取文本。
|
||
包含窗口验证 + 垃圾过滤 + 连续垃圾清缓存。
|
||
"""
|
||
# 同平台冷却:回复后 15s 内跳过,防读到自身回复回声
|
||
# 冷却期间也设 _ocr_unchanged → _read_messages 跳过所有 fallback
|
||
# (_read_via_clipboard_once 调 _ensure_foreground → AttachThreadInput → 崩 TIM Electron)
|
||
if time.time() < self._self_reply_cooldown_until.get("qq", 0):
|
||
self._ocr_unchanged["qq"] = True
|
||
return []
|
||
hwnd = self._cached_hwnd.get("qq")
|
||
if not hwnd:
|
||
return []
|
||
if not self._validate_qq_window(hwnd):
|
||
gc = self._ocr_garbage_count.get("qq", 0) + 1
|
||
self._ocr_garbage_count["qq"] = gc
|
||
if gc == 1:
|
||
# 首次失败时详细日志
|
||
visible = win32gui.IsWindowVisible(hwnd) if win32gui.IsWindow(hwnd) else False
|
||
iconic = win32gui.IsIconic(hwnd) if win32gui.IsWindow(hwnd) else False
|
||
logger.debug(f"[qq] OCR: 窗口不可用(visible={visible}, iconic={iconic})")
|
||
if gc >= 3:
|
||
logger.info("[qq] OCR: 窗口不合法连续3次,清缓存")
|
||
self._cached_hwnd.pop("qq", None)
|
||
self._cached_pid.pop("qq", None)
|
||
self._app_variant.pop("qq", None)
|
||
self._ocr_garbage_count["qq"] = 0
|
||
return []
|
||
engine = self._ocr_ensure_engine()
|
||
if not engine:
|
||
return []
|
||
try:
|
||
# ⚠️ 急停检查:截屏+OCR是耗时操作,开始前必须检查
|
||
if not self._running:
|
||
return []
|
||
# ⭐ 先唤醒再取窗口 rect(最小化恢复后位置会变化),再截屏
|
||
# ImageGrab.grab 截整屏坐标,若 QQ 被遮挡(用户停在终端)截到的就是遮挡物
|
||
if not self._ensure_visible(hwnd):
|
||
logger.debug("[qq] 窗口不可见,退避5秒(用户可能在接管)")
|
||
# 退避期间也要检查急停,避免阻塞5秒
|
||
for _ in range(50):
|
||
if not self._running:
|
||
return []
|
||
time.sleep(0.1)
|
||
return []
|
||
time.sleep(0.4)
|
||
rect = win32gui.GetWindowRect(hwnd)
|
||
if not rect:
|
||
return []
|
||
left, top, right, bottom = rect
|
||
win_w = right - left
|
||
win_h = bottom - top
|
||
# QQ NT 窗口布局: ~0-5% 标签栏, ~5-30% 联系人/群列表, ~30-55% 会话列表, ~55-100% 消息面板
|
||
|
||
variant = self._app_variant.get("qq", "")
|
||
# TIM (Electron) 安全:不做 PostMessage 点击 + 不做 keybd_event 全局键
|
||
# 只做 PostMessage VK_END(异步,安全),避免干扰 Electron 渲染进程
|
||
if variant == "tim":
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
|
||
time.sleep(0.3)
|
||
else:
|
||
# 点一下消息面板区域设置焦点(QQ NT 默认焦点在会话列表,VK_END 只会滚会话列表)
|
||
try:
|
||
click_x = int(win_w * 0.72) # 消息面板中心偏右
|
||
click_y = int(win_h * 0.50)
|
||
lparam = win32api.MAKELONG(click_x, click_y)
|
||
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
|
||
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
|
||
logger.debug(f"[qq] 点击消息面板({click_x},{click_y})设置焦点")
|
||
time.sleep(0.15)
|
||
except Exception as e:
|
||
logger.debug(f"[qq] 点击消息面板失败: {e}")
|
||
|
||
# Ctrl+End 滚动到底部,只截最新消息(避开历史堆积+bot 旧回复)
|
||
VK_CONTROL = 0x11
|
||
VK_END = 0x23
|
||
# 方式A: keybd_event(窗口在前台时生效)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
|
||
ctypes.windll.user32.keybd_event(VK_END, 0, 0, 0)
|
||
time.sleep(0.05)
|
||
ctypes.windll.user32.keybd_event(VK_END, 0, 2, 0)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
# 方式B: PostMessage 兜底(keybd_event 可能被 TIM 忽略)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_END, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_END, 0)
|
||
time.sleep(0.3)
|
||
# TIM/QQ 窗口布局: ~5-30% 左栏, ~30-50% 会话列表(含文档预览), ~50-100% 消息面板
|
||
# cap_left=25% 继续右移(用户反馈20%仍会捕捉到消息列表的时间戳,干扰消息判定)
|
||
cap_left = int(left + win_w * 0.25)
|
||
cap_top = top + max(80, int(win_h * 0.06)) # 跳过标题栏+群名头栏
|
||
cap_right = int(left + win_w * 0.78) # 跳过右栏群成员列表(~80%+) 和滚动条
|
||
cap_bottom = bottom - max(100, int(win_h * 0.10)) # 输入框上方
|
||
if cap_right <= cap_left or cap_bottom <= cap_top:
|
||
return []
|
||
# 检查窗口是否在前台,如果不在则尝试使用 PrintWindow
|
||
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
is_foreground = self._check_foreground(root_hwnd)
|
||
if not is_foreground:
|
||
# 窗口可能被遮挡,使用 PrintWindow 截取
|
||
im = self._screenshot_window(hwnd, timeout=3.0)
|
||
if im is not None:
|
||
# 裁剪到消息区域
|
||
# 计算相对于窗口的裁剪坐标
|
||
crop_left = cap_left - left
|
||
crop_top = cap_top - top
|
||
crop_right = cap_right - left
|
||
crop_bottom = cap_bottom - top
|
||
# 确保坐标有效
|
||
crop_left = max(0, min(crop_left, im.width))
|
||
crop_top = max(0, min(crop_top, im.height))
|
||
crop_right = max(crop_left + 1, min(crop_right, im.width))
|
||
crop_bottom = max(crop_top + 1, min(crop_bottom, im.height))
|
||
im = im.crop((crop_left, crop_top, crop_right, crop_bottom))
|
||
logger.debug(f"[qq] PrintWindow 裁剪后: {im.width}x{im.height}")
|
||
if im.width < 50 or im.height < 50:
|
||
logger.warning(f"[qq] PrintWindow 结果太小({im.width}x{im.height}),回退屏幕截图")
|
||
im = None
|
||
if im is None:
|
||
# PrintWindow 失败或结果太小,回退到屏幕截图
|
||
im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom))
|
||
if im is None:
|
||
return []
|
||
else:
|
||
im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom))
|
||
if im is None:
|
||
return []
|
||
|
||
# ⭐ 立即保存调试截图(在所有检查之前,确保即使失败也有图可查)
|
||
_dbg_path = ""
|
||
try:
|
||
debug_dir = _get_ocr_raw_dir("qq")
|
||
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
|
||
_dbg_path = os.path.join(debug_dir, f"ocr_debug_qq_{ts}.png")
|
||
im.save(_dbg_path, format="PNG")
|
||
if os.path.exists(_dbg_path):
|
||
logger.info(f"[qq] 调试截图已保存: {_dbg_path} ({im.width}x{im.height})")
|
||
else:
|
||
logger.warning(f"[qq] 调试截图保存后未找到文件: {_dbg_path}")
|
||
except Exception as e:
|
||
logger.warning(f"[qq] 调试截图保存失败: {e} (路径: {_dbg_path})")
|
||
|
||
if im.width < 50 or im.height < 50:
|
||
logger.warning(f"[qq] 截图太小: {im.width}x{im.height}")
|
||
return []
|
||
|
||
# 详细调试日志:记录截图信息和裁剪区域
|
||
logger.debug(f"[qq] OCR前截图信息: 尺寸={im.width}x{im.height}, "
|
||
f"窗口位置=({left},{top})-({right},{bottom}), "
|
||
f"裁剪区域=({cap_left},{cap_top})-({cap_right},{cap_bottom}), "
|
||
f"前台={is_foreground}")
|
||
|
||
# ⭐ 颜色像素检测:如果截图中只有 bot 蓝色气泡,无白色用户消息,跳过
|
||
if not self._has_user_colors(im, "qq"):
|
||
# ⚠️ 不设 _ocr_unchanged——像素检测失败≠截图没变化。
|
||
# 若设 _ocr_unchanged,_read_messages 会跳过所有 fallback(剪贴板/UIA),
|
||
# 导致 bot 回复后用户新消息再也无法入库。只 return 空列表即可。
|
||
logger.debug(f"[qq] 像素检测: 无对方消息, 跳过OCR (截图: {os.path.basename(_dbg_path)})")
|
||
return []
|
||
# ⭐ 截图 hash:像素级指纹——内容没变就直接跳过整轮(easyocr 同一截图每次输出不同文本)
|
||
# dHash + 5秒强制刷新:即使hash相同,每5秒也强制做一次OCR,防止永久卡死
|
||
h = self._image_hash(im)
|
||
now = time.time()
|
||
last_hash = self._last_screenshot_hash.get("qq", "")
|
||
last_time = self._last_ocr_time.get("qq", 0)
|
||
# ⭐ 仅当上一帧"已捕捉且已入队"才跳过:若上一帧 OCR 了但没入队
|
||
# (被时间戳/噪声过滤丢弃),则不跳过,给后续相同截图重新 OCR 的机会,
|
||
# 避免"捕捉到但没入队"的消息被永久跳过。
|
||
if h == last_hash and (now - last_time) < 5:
|
||
# ⭐ 修复:hash相同跳过仅当上一帧已入队且已回复
|
||
# 原逻辑:只要上一帧已入队就跳过,导致消息从未处理过但hash相同时被静默跳过
|
||
# 新逻辑:必须确认消息已被回复(_last_hash_replied),才能跳过
|
||
if self._last_hash_queued.get("qq", False) and self._last_hash_replied.get("qq", False):
|
||
self._ocr_unchanged["qq"] = True
|
||
logger.debug(f"[qq] 截图 hash 相同且上一帧已入队并已回复,屏幕无变化,跳过本轮")
|
||
return []
|
||
else:
|
||
logger.debug(f"[qq] 截图 hash 相同但上一帧未回复,强制重新 OCR")
|
||
self._last_screenshot_hash["qq"] = h
|
||
self._last_ocr_time["qq"] = now
|
||
self._ocr_unchanged.pop("qq", None)
|
||
|
||
# 图像预处理:提升 Windows OCR 识别准确率
|
||
# Windows OCR 对低 DPI/未处理截图识别差——CJK 空格、字符混淆
|
||
# 2x LANCZOS 放大 + 锐化 + 对比度增强
|
||
# ⚠️ 锐化/对比度过高会导致汉字边缘断裂成偏旁部首
|
||
im_original = im # 保留原图,bad allocation 时降级
|
||
try:
|
||
im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS)
|
||
im = ImageEnhance.Sharpness(im).enhance(1.2) # 降低锐化,防字符断裂
|
||
im = ImageEnhance.Contrast(im).enhance(1.2) # 降低对比度,保持笔画连贯
|
||
logger.debug(f"[ocr] 预处理: resize 2x + sharpen(1.2) + contrast(1.2)")
|
||
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
|
||
try:
|
||
debug_dir = _get_ocr_processed_dir("qq")
|
||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||
_processed_path = os.path.join(debug_dir, f"ocr_processed_qq_{ts}.jpg")
|
||
# 转换为RGB模式保存JPG(不支持RGBA)
|
||
if im.mode == 'RGBA':
|
||
im_rgb = im.convert('RGB')
|
||
else:
|
||
im_rgb = im
|
||
im_rgb.save(_processed_path, format="JPEG", quality=95)
|
||
# 删除放缓:不在这里立即清理,改由 _maybe_clean_ocr_dir 每 30~60s 随机定时清理
|
||
if os.path.exists(_processed_path):
|
||
logger.debug(f"[qq] OCR处理后图片已保存: {_processed_path}")
|
||
except Exception as e:
|
||
logger.debug(f"[qq] OCR处理后图片保存失败: {e}")
|
||
except Exception as e:
|
||
logger.debug(f"[ocr] 预处理失败(跳过): {e}")
|
||
|
||
# ── 双引擎并发 OCR(Windows OCR + EasyOCR 同时跑,最多等 10 秒)──
|
||
raw_text, chosen_engine = self._ocr_dual_engine(im, im_original, "qq")
|
||
if not raw_text:
|
||
return []
|
||
self._last_chosen_engine["qq"] = chosen_engine
|
||
return self._process_ocr_output(raw_text, "qq")
|
||
except Exception as e:
|
||
logger.debug(f"[qq] OCR失败: {e}")
|
||
return []
|
||
|
||
def _ocr_read_wechat(self) -> list:
|
||
"""OCR 截屏微信消息区域 -> 双引擎提取文本。
|
||
|
||
微信 UIA 树不暴露聊天消息内容(新版微信 UIA 架构变化),必须用 OCR。
|
||
微信自绘控件,keybd_event 无法滚动,只截屏当前可见区域。
|
||
"""
|
||
# 同平台冷却:回复后 15s 内跳过,防读到自身回复回声
|
||
if time.time() < self._self_reply_cooldown_until.get("wechat", 0):
|
||
return []
|
||
hwnd = self._cached_hwnd.get("wechat")
|
||
if not hwnd:
|
||
logger.debug("[wechat] 无缓存HWND")
|
||
return []
|
||
if not win32gui.IsWindow(hwnd) or not self._validate_wechat_window(hwnd):
|
||
# 详细记录哪个验证失败
|
||
is_window = win32gui.IsWindow(hwnd)
|
||
is_visible = win32gui.IsWindowVisible(hwnd) if is_window else False
|
||
is_iconic = win32gui.IsIconic(hwnd) if is_window else False
|
||
logger.warning(f"[wechat] 窗口无效: hwnd={hwnd}, IsWindow={is_window}, IsVisible={is_visible}, IsIconic={is_iconic}")
|
||
self._cached_hwnd.pop("wechat", None)
|
||
self._cached_pid.pop("wechat", None)
|
||
return []
|
||
|
||
engine = self._ocr_ensure_engine()
|
||
if not engine:
|
||
return []
|
||
|
||
try:
|
||
# ⚠️ 急停检查:截屏+OCR是耗时操作,开始前必须检查
|
||
if not self._running:
|
||
return []
|
||
# ⭐ 先唤醒再截屏:ImageGrab.grab 截整屏坐标,若微信被遮挡(用户停在终端)
|
||
# 截到的就是遮挡物。唤醒后才取窗口 rect(最小化恢复后位置会变化)。
|
||
# 用 _ensure_foreground(AttachThreadInput 强置前):微信原生窗口安全。
|
||
# 若置前失败但窗口仍然可见(未被最小化/隐藏),仍应尝试截图,
|
||
# 与QQ逻辑一致:只要窗口在屏幕上就截,避免漏掉消息。
|
||
logger.debug(f"[wechat] 尝试唤醒窗口 hwnd={hwnd}")
|
||
fg_ok = self._ensure_foreground(hwnd)
|
||
logger.debug(f"[wechat] 唤醒结果: fg_ok={fg_ok}")
|
||
if not fg_ok:
|
||
# 检查窗口是否可见(未被最小化到托盘/隐藏)
|
||
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
is_visible = root_hwnd and win32gui.IsWindowVisible(root_hwnd) and not win32gui.IsIconic(root_hwnd)
|
||
logger.debug(f"[wechat] 可见性检查: root_hwnd={root_hwnd}, is_visible={is_visible}, iconic={win32gui.IsIconic(root_hwnd) if root_hwnd else 'N/A'}")
|
||
if is_visible:
|
||
logger.debug("[wechat] 窗口未获得前台但仍然可见,继续截图")
|
||
else:
|
||
logger.debug("[wechat] 窗口未前置且不可见,退避5秒(用户可能在接管)")
|
||
# ⭐ 保存"窗口不可见"现场截图:即使整轮跳过也有图可查
|
||
try:
|
||
_r = win32gui.GetWindowRect(hwnd)
|
||
if _r and (_r[2] - _r[0]) > 200 and (_r[3] - _r[1]) > 100:
|
||
_im = self._safe_screenshot((_r[0], _r[1], _r[2], _r[3]))
|
||
if _im is not None and _im.width > 50 and _im.height > 50:
|
||
debug_dir = _get_ocr_raw_dir("wechat")
|
||
_ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
|
||
_p = os.path.join(debug_dir, f"ocr_debug_wx_{_ts}.png")
|
||
_im.save(_p, format="PNG")
|
||
logger.info(f"[wechat] 窗口不可见现场截图已保存: {_p}")
|
||
except Exception as _e:
|
||
logger.debug(f"[wechat] 现场截图保存失败: {_e}")
|
||
# 退避期间也要检查急停,避免阻塞5秒
|
||
for _ in range(50):
|
||
if not self._running:
|
||
return []
|
||
time.sleep(0.1)
|
||
return []
|
||
time.sleep(0.4) # 让 DWM 完成窗口激活的合成,防紧接 BitBlt 卡死
|
||
|
||
rect = win32gui.GetWindowRect(hwnd)
|
||
if not rect:
|
||
return []
|
||
left, top, right, bottom = rect
|
||
win_w = right - left
|
||
win_h = bottom - top
|
||
|
||
# WeChat 窗口布局: ~0-6% icon tab栏, ~6-32% 会话列表, ~32-98% 消息面板
|
||
# 左边界 30% 跳过会话列表(含通讯录/视频号/游戏中心等快捷入口)— 原34%太右,用户消息被裁切
|
||
# 顶部跳过导航栏(微信/通讯录/朋友圈/视频号 tab)
|
||
cap_left = int(left + win_w * 0.30)
|
||
cap_top = top + max(80, int(win_h * 0.08))
|
||
cap_right = right - max(20, int(win_w * 0.02)) # 跳过滚动条+右边框(2%宽度)
|
||
cap_bottom = bottom - max(65, int(win_h * 0.07)) # 跳过输入框
|
||
logger.debug(f"[wechat] 窗口区域: ({left},{top},{right},{bottom}) 尺寸: {win_w}x{win_h} 裁剪: ({cap_left},{cap_top},{cap_right},{cap_bottom})")
|
||
if cap_right <= cap_left or cap_bottom <= cap_top:
|
||
logger.warning(f"[wechat] 裁剪区域无效: ({cap_left},{cap_top},{cap_right},{cap_bottom})")
|
||
return []
|
||
|
||
# 检查窗口是否在前台,如果不在则尝试使用 PrintWindow
|
||
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
is_foreground = self._check_foreground(root_hwnd)
|
||
logger.debug(f"[wechat] 前台检查: is_foreground={is_foreground}, root_hwnd={root_hwnd}")
|
||
if not is_foreground:
|
||
# 窗口可能被遮挡,使用 PrintWindow 截取
|
||
logger.debug("[wechat] 窗口不在前台,尝试 PrintWindow")
|
||
im = self._screenshot_window(hwnd, timeout=3.0)
|
||
logger.debug(f"[wechat] PrintWindow 结果: {'成功' if im is not None else '失败'}")
|
||
if im is not None:
|
||
# 裁剪到消息区域
|
||
# 计算相对于窗口的裁剪坐标
|
||
crop_left = cap_left - left
|
||
crop_top = cap_top - top
|
||
crop_right = cap_right - left
|
||
crop_bottom = cap_bottom - top
|
||
# 确保坐标有效
|
||
crop_left = max(0, min(crop_left, im.width))
|
||
crop_top = max(0, min(crop_top, im.height))
|
||
crop_right = max(crop_left + 1, min(crop_right, im.width))
|
||
crop_bottom = max(crop_top + 1, min(crop_bottom, im.height))
|
||
im = im.crop((crop_left, crop_top, crop_right, crop_bottom))
|
||
logger.debug(f"[wechat] PrintWindow 裁剪后: {im.width}x{im.height}")
|
||
# PrintWindow 可能返回过小的图像(如空白窗口),回退到屏幕截图
|
||
if im.width < 50 or im.height < 50:
|
||
logger.warning(f"[wechat] PrintWindow 结果太小({im.width}x{im.height}),回退屏幕截图")
|
||
im = None
|
||
if im is None:
|
||
# PrintWindow 失败或结果太小,回退到屏幕截图
|
||
logger.debug("[wechat] 回退到屏幕截图")
|
||
im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom))
|
||
if im is None:
|
||
logger.warning("[wechat] PrintWindow和屏幕截图均失败")
|
||
return []
|
||
else:
|
||
im = self._safe_screenshot((cap_left, cap_top, cap_right, cap_bottom))
|
||
if im is None:
|
||
logger.warning("[wechat] 前台窗口截图失败")
|
||
return []
|
||
|
||
# ⭐ 立即保存调试截图(在所有检查之前,确保即使失败也有图可查)
|
||
_dbg_path = ""
|
||
try:
|
||
debug_dir = _get_ocr_raw_dir("wechat")
|
||
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
|
||
_dbg_path = os.path.join(debug_dir, f"ocr_debug_wx_{ts}.png")
|
||
im.save(_dbg_path, format="PNG")
|
||
if os.path.exists(_dbg_path):
|
||
logger.info(f"[wechat] 调试截图已保存: {_dbg_path} ({im.width}x{im.height})")
|
||
else:
|
||
logger.warning(f"[wechat] 调试截图保存后未找到文件: {_dbg_path}")
|
||
except Exception as e:
|
||
logger.warning(f"[wechat] 调试截图保存失败: {e} (路径: {_dbg_path})")
|
||
|
||
logger.debug(f"[wechat] 最终截图尺寸: {im.width}x{im.height}")
|
||
if im.width < 50 or im.height < 50:
|
||
logger.warning(f"[wechat] 截图太小: {im.width}x{im.height}")
|
||
return []
|
||
|
||
# ⭐ 颜色像素检测:如果截图中只有自身绿色气泡,无对方白色消息,跳过
|
||
if not self._has_user_colors(im, "wechat"):
|
||
# ⚠️ 不设 _ocr_unchanged——像素检测失败≠截图没变化。
|
||
logger.debug(f"[wechat] 像素检测: 无对方消息(自身消息已过滤), 跳过OCR")
|
||
return []
|
||
|
||
# ⭐ 截图 hash:像素级指纹——内容没变就直接跳过整轮
|
||
# dHash + 5秒强制刷新:即使hash相同,每5秒也强制做一次OCR,防止永久卡死
|
||
h = self._image_hash(im)
|
||
now = time.time()
|
||
last_hash = self._last_screenshot_hash.get("wechat", "")
|
||
last_time = self._last_ocr_time.get("wechat", 0)
|
||
# ⭐ 仅当上一帧"已捕捉且已入队"才跳过(同 QQ 逻辑,防消息永久丢失)
|
||
if h == last_hash and (now - last_time) < 5:
|
||
# ⭐ 修复:hash相同跳过仅当上一帧已入队且已回复
|
||
# 原逻辑:只要上一帧已入队就跳过,导致消息从未处理过但hash相同时被静默跳过
|
||
# 新逻辑:必须确认消息已被回复(_last_hash_replied),才能跳过
|
||
if self._last_hash_queued.get("wechat", False) and self._last_hash_replied.get("wechat", False):
|
||
self._ocr_unchanged["wechat"] = True
|
||
logger.debug(f"[wechat] 截图 hash 相同且上一帧已入队并已回复,屏幕无变化,跳过本轮")
|
||
return []
|
||
else:
|
||
logger.debug(f"[wechat] 截图 hash 相同但上一帧未回复,强制重新 OCR")
|
||
self._last_screenshot_hash["wechat"] = h
|
||
self._last_ocr_time["wechat"] = now
|
||
self._ocr_unchanged.pop("wechat", None)
|
||
|
||
# 图像预处理(同 QQ)
|
||
# ⚠️ 锐化/对比度过高会导致汉字边缘断裂成偏旁部首
|
||
im_original = im # 保留原图,bad allocation 时降级
|
||
try:
|
||
im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS)
|
||
im = ImageEnhance.Sharpness(im).enhance(1.2) # 降低锐化,防字符断裂
|
||
im = ImageEnhance.Contrast(im).enhance(1.2) # 降低对比度,保持笔画连贯
|
||
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
|
||
try:
|
||
debug_dir = _get_ocr_processed_dir("wechat")
|
||
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
|
||
_processed_path = os.path.join(debug_dir, f"ocr_processed_wx_{ts}.jpg")
|
||
# 转换为RGB模式保存JPG(不支持RGBA)
|
||
if im.mode == 'RGBA':
|
||
im_rgb = im.convert('RGB')
|
||
else:
|
||
im_rgb = im
|
||
im_rgb.save(_processed_path, format="JPEG", quality=95)
|
||
# 删除放缓:不在这里立即清理,改由 _maybe_clean_ocr_dir 每 30~60s 随机定时清理
|
||
if os.path.exists(_processed_path):
|
||
logger.debug(f"[wechat] OCR处理后图片已保存: {_processed_path}")
|
||
except Exception as e:
|
||
logger.debug(f"[wechat] OCR处理后图片保存失败: {e}")
|
||
except Exception as e:
|
||
logger.debug(f"[wechat] OCR 预处理失败(跳过): {e}")
|
||
|
||
# ── 双引擎并发 OCR(Windows OCR + EasyOCR 同时跑,最多等 10 秒)──
|
||
raw_text, chosen_engine = self._ocr_dual_engine(im, im_original, "wechat")
|
||
if not raw_text:
|
||
return []
|
||
self._last_chosen_engine["wechat"] = chosen_engine
|
||
return self._process_ocr_output(raw_text, "wechat")
|
||
except Exception as e:
|
||
logger.debug(f"[wechat] OCR失败: {e}")
|
||
return []
|
||
|
||
def _process_ocr_output(self, raw_text: str, platform: str) -> list:
|
||
"""OCR 原始文本的通用后处理:垃圾检测 → 时间戳分割 → 过滤清洗。
|
||
|
||
供 QQ / 微信 OCR 路径共用。
|
||
"""
|
||
# ⚠️ 急停检查:OCR 文本处理耗时,开始前检查
|
||
if not self._running:
|
||
return []
|
||
# —— WeChat 跨窗口污染检测:OCR 读到 QQ/TIM 调试输出时丢弃整批 ——
|
||
if platform == "wechat":
|
||
# 注意: WinOCR 输出带空格和全角符号(如"完 成 : 553 chars"),
|
||
# EasyOCR 用 ASCII 符号。regex 需兼容两种。
|
||
_cross_pollution_markers = [
|
||
r'sharpen', # 图片预处理参数
|
||
r'contrast\b', # 对比度参数
|
||
r'设置焦点', # UIA 调试输出
|
||
r'污染.*标记', # 自家 log: "污染(5/7标记)"
|
||
r'丢弃.*整批', # 自家 log: "丢弃整批13 items"
|
||
r'last=', # 自家 log: "last=\"关闭\""
|
||
r'OCR\s*完\s*[成]', # OCR完成 / OCR 完 成 — 自家 log
|
||
r'(?:完成|完成)\s*[:\uFF1A]\s*\d+\s+Char', # 完成:553 chars / 完成 : 553 Chars
|
||
r'[((]\s*\d+[\s,,\/]*\d+\s*[))]', # (1005,476) / ( 5 / 7 )坐标
|
||
r'原始\s*[\])]', # 原始] / 原始 ]
|
||
r'后处理\s*[\])]', # 后处理] / 后处理 ]
|
||
r'line\s*[\[(]\s*[\d@]\s*[\]))]', # line[0] / line [0] / line(0)
|
||
r'reader\s*就绪', # reader就绪状态 / reader 就绪
|
||
r'(?:扫描|跳过|丢弃)\s*本\s*轮', # 跳过本轮 / 跳 过 本 轮 — 自家 log
|
||
]
|
||
_hit = sum(1 for m in _cross_pollution_markers if re.search(m, raw_text, re.IGNORECASE))
|
||
# WinOCR 给 CJK 加空格("完 成 : 553 chars")→ raw regex 匹配不到
|
||
# 归一化后再查一次,双路覆盖两种引擎格式
|
||
if _hit < 2:
|
||
norm = self._normalize_ocr_text(raw_text)
|
||
_hit = sum(1 for m in _cross_pollution_markers if re.search(m, norm, re.IGNORECASE))
|
||
if _hit >= 2:
|
||
logger.warning(f"[{platform}] 跨窗口污染({_hit}/{len(_cross_pollution_markers)}标记),丢弃整批")
|
||
return []
|
||
|
||
# ⭐ 2026-08-16: 早期基础昵称剥离——在垃圾检测/时间戳检测/行合并等所有后续处理之前执行。
|
||
# 防止 OCR 原始文本中的昵称污染时间戳检测、行合并等早期步骤。
|
||
# 例如:"草原探险服务器备用号,@游戏专用有个问题..." 中的昵称会被提前剥离,
|
||
# 避免昵称字符干扰后续时间戳解析(如"备用号"中的数字被误读为时间戳片段)。
|
||
raw_text = self._strip_qq_nickname(raw_text)
|
||
|
||
# —— 垃圾检测 ——
|
||
if self._is_ocr_console_garbage(raw_text):
|
||
gc = self._ocr_garbage_count.get(platform, 0) + 1
|
||
self._ocr_garbage_count[platform] = gc
|
||
if gc >= 3:
|
||
logger.info(f"[{platform}] OCR: 垃圾连续3次,清缓存")
|
||
self._cached_hwnd.pop(platform, None)
|
||
self._cached_pid.pop(platform, None)
|
||
self._ocr_garbage_count[platform] = 0
|
||
return []
|
||
self._ocr_garbage_count[platform] = 0
|
||
self._ocr_last_text[platform] = raw_text
|
||
|
||
# —— JSON/YAML 配置块剥离 ——
|
||
# 微信 OCR 截屏范围 40%-98%,常把右侧文档/配置面板(JSON/YAML)一起截入
|
||
# JSON 块("key": value, {, })和 YAML 块(key: value)跟用户消息混在一起
|
||
# 在 split 前先剥离这些行,避免 JSON 碎片污染用户消息
|
||
raw_text = self._strip_json_yaml_blocks(raw_text, platform)
|
||
|
||
# —— ⭐ 混合文本预分割:分段噪声隔离 ——
|
||
# Windows OCR 返回整屏文字的大杂烩(bot回复含<br>/图片URL + 用户消息)。
|
||
# 若整体 _is_noise_text() 会因 HTML 标签把整段丢弃,用户消息跟着被杀。
|
||
# 修复:先按消息边界分割成候选段,对各段单独做噪声检测,
|
||
# 只丢弃噪声段(bot回复),保留干净段(用户消息)。
|
||
raw_text = self._split_and_denoise_segments(raw_text, platform)
|
||
|
||
# ⚠️ 急停检查:预分割后立即检查,丢弃半成品
|
||
if not self._running:
|
||
return []
|
||
|
||
# —— 可见时间戳新鲜性检查 ——
|
||
# 若文本中有可见时间戳(HH:MM),但全部过期(>300s),视为历史消息直接丢弃
|
||
# 防止 OCR 混入旧消息内容误触发回复
|
||
if not raw_text or not self._ocr_text_has_fresh_timestamp(raw_text, platform):
|
||
logger.debug(f"[{platform}] OCR 历史消息(无可识别新鲜时间戳),丢弃: {raw_text[:80]!r}")
|
||
return []
|
||
|
||
# —— 行切割 + 尾标清理 ——
|
||
ocr_lines = []
|
||
# ⭐ 2026-08-11: 被")昵称 "前缀剥离的行 → 短句检查豁免(真实用户消息)
|
||
_ui_prefix_stripped = set()
|
||
for l in raw_text.split('\n'):
|
||
l = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', l).strip()
|
||
# ⭐ Task #1476: 行内图片引用清理。
|
||
# QQ 发送图片后 OCR 识别出 !0(img/xxx.png) 格式,混在用户消息文本中。
|
||
# 先剥离这些引用,保留用户真实消息部分。
|
||
l = re.sub(r'!0?\s*\(?\s*img/[\w./\\-]+\)?', '', l, flags=re.IGNORECASE).strip()
|
||
if not l:
|
||
continue
|
||
# ⭐ Task #1475: 单行 Markdown 跳过。
|
||
# 正常用户从不手动打 markdown(# 标题、|表格|、**加粗** 等)。
|
||
# 撞上 md 段落标记 → 跳过该行;没有 md 标记 → 正常收集。
|
||
# 不做"块延续",下一行立即恢复,避免误杀紧随其后的用户消息。
|
||
if self._is_markdown_line(l):
|
||
logger.debug(f"[{platform}] OCR: 跳过Markdown行: {l[:60]!r}")
|
||
continue
|
||
# ⭐ Task #1476: 表情包/贴纸文字碎片过滤。
|
||
# QQ 表情包图片上常有文字(如"点击"、"按钮"),OCR 识别后产出
|
||
# 短文本+大量分隔符混排(如 "点击, ~按钮; ,"),不是真实用户消息。
|
||
# 判定:中文字符≤6 且 分隔符(逗号/分号/波浪号/斜杠等)≥2 → 碎片。
|
||
_delim_count = len(re.findall(r'[,;~!@#%^&,。;~、/\\|]', l))
|
||
_cjk_in_l = sum(1 for c in l if '\u4e00' <= c <= '\u9fff')
|
||
# ⭐ Task #1510: 纯时间戳行保护。EasyOCR 时间戳常用双分号分隔(15;35;38、
|
||
# 14;08;03),含两个分隔符会被误判为表情包碎片丢弃 → 新鲜时间戳丢失,
|
||
# 导致下方消息继承上一行陈旧时间戳被整体误杀(旧历史消息+新消息同屏时)。
|
||
if _cjk_in_l <= 6 and _delim_count >= 2 \
|
||
and not re.fullmatch(r'\d{1,2}[:;.]\d{1,2}(?:[:;.]\d{1,2})?', l):
|
||
logger.debug(f"[{platform}] OCR: 跳过表情包碎片(分隔符密度高): {l[:60]!r}")
|
||
continue
|
||
# ⭐ Task #1476: QQ 非消息文本跳过。
|
||
# QQ 群聊截图中,OCR 会识别出图片引用、用户头衔/徽章、用户标签等
|
||
# 非消息内容,混入消息流后触发残片判定。整行跳过这些 UI 元素。
|
||
if self._is_qq_ui_element(l):
|
||
logger.debug(f"[{platform}] OCR: 跳过QQ UI元素: {l[:60]!r}")
|
||
continue
|
||
# ⭐ 规则4放行的 ")昵称 内容" 格式(2026-08-11):_is_qq_ui_element 规则4
|
||
# 判定带空格的 ")youpaishidifu 关于我们" 为疑似用户消息放行,但 ) 前缀
|
||
# 是 OCR 把 @/冒号误读的结果,会挡在 _strip_qq_nickname 裸昵称剥离前面。
|
||
# 这里剥掉 ")昵称+空白" 前缀,恢复纯消息文本交给后续昵称剥离/查重链路。
|
||
# ⚠ 剥离后的短文本(如"关于我们"4字)会被末尾"短句碎片(≤8字无问词)"规则
|
||
# 误杀,必须记入 _ui_prefix_stripped 在短句检查处豁免——规则4放行贯穿到底。
|
||
_m4strip = re.match(r'^\)([\w\u4e00-\u9fff]{2,})\s+', l)
|
||
if _m4strip:
|
||
l = l[_m4strip.end():]
|
||
_ui_prefix_stripped.add(l)
|
||
logger.debug(f"[{platform}] OCR: 剥离)昵称前缀: {l[:60]!r} (短句豁免)")
|
||
ocr_lines.append(l)
|
||
|
||
# —— 连续行合并(修复 EasyOCR 逗号断行) ——
|
||
# EasyOCR 对长消息在中文逗号","处拆行,导致前半句无时间戳被丢弃。
|
||
# 合并规则:前一行不以句末标点结尾 且 后一行不以时间戳开头 → 合并
|
||
# ⚠ 修复漏消息:前一行若本身是"时间戳行"(如 16;28:40),绝不与其后的消息合并,
|
||
# 否则时间戳被黏进消息文本、re.split 错位,导致该消息被误判为尾部落入旧时间戳、
|
||
# 进而被"继承时间戳过老"丢弃(漏掉用户刚发的新消息)。
|
||
# ⭐ Task #1510: _SENT_END 补 ASCII `?`。OCR 对问句常输出半角 `?`(非全角 `?`),
|
||
# 导致「游戏专用PHP有哪两种登录模式?」这类问句行不与下一行合并,
|
||
# 防止多条用户消息被 `,` 黏成一行、共享同一陈旧继承时间戳被整体误杀。
|
||
_SENT_END = re.compile(r'[。!??…~))」』\]]$')
|
||
_TS_START = re.compile(r'^(?:\d{1,2}[.;:]\d{2}|(?:昨天|前天|大前天|今天|明天|星期[一二三四五六日天]|\d+天前)\s*\d)')
|
||
merged_lines = []
|
||
for rl in ocr_lines:
|
||
if merged_lines and not _SENT_END.search(merged_lines[-1]) \
|
||
and not _TS_START.match(rl) and not _TS_START.match(merged_lines[-1]):
|
||
merged_lines[-1] = merged_lines[-1] + ',' + rl
|
||
else:
|
||
merged_lines.append(rl)
|
||
if len(merged_lines) != len(ocr_lines):
|
||
logger.info(f"[ocr] 行合并: {len(ocr_lines)}行→{len(merged_lines)}行")
|
||
ocr_lines = merged_lines
|
||
|
||
engine_label = self._last_chosen_engine.get(platform, "")
|
||
for i, rl in enumerate(ocr_lines):
|
||
logger.info(f"[ocr] [{engine_label}/后处理] line[{i}]: {rl[:120]!r}")
|
||
|
||
# —— 时间戳分割(跨行继承) ——
|
||
expanded = []
|
||
_inherited_ts = None # 跨行继承:上一行的时间戳,供当前行无时间戳时使用
|
||
self._inherited_ts_from_past = False # 跨行继承:上一行时间戳是否来自过去日期前缀
|
||
# ⭐ QQ系统消息模式:跳过这些消息的时间戳继承,防止旧时间戳覆盖真实用户消息
|
||
_QQ_SYSTEM_MSG_RE = re.compile(
|
||
r'(?:邀请.*加入了|加入了本群|加入了群聊|退出了群聊|被管理员移出群|'
|
||
r'撤回了一条消息|修改了群名称|成为了管理员|取消了管理员|被禁言|被解除禁言|全体禁言|解除全体禁言)'
|
||
)
|
||
for i, rl in enumerate(ocr_lines):
|
||
# ⚠️ 急停检查:stage splitting 逐行处理,每行都检查,保证急停响应 ≤1行处理时间
|
||
if not self._running:
|
||
return []
|
||
before = rl
|
||
rl = self._normalize_ocr_text(rl)
|
||
logger.debug(f"[ocr] stage[{i}] normalize: len={len(before)} -> {rl[:120]!r}")
|
||
rl = rl.replace('&', '')
|
||
rl = rl.replace(':', ':')
|
||
logger.debug(f"[ocr] stage[{i}] clean: len={len(rl)} -> {rl[:120]!r}")
|
||
rl = re.sub(r'\s+', ' ', rl)
|
||
# ⭐ 检测是否为QQ系统消息:如果是,跳过时间戳继承,防止旧时间戳污染后续真实消息
|
||
_is_qq_system_msg = bool(_QQ_SYSTEM_MSG_RE.search(rl))
|
||
if _is_qq_system_msg:
|
||
logger.info(f"[ocr] stage[{i}] 检测到QQ系统消息,跳过时间戳继承: {rl[:60]!r}")
|
||
# 移除中文日期前缀(昨天、前天、星期X等),只保留数字时间
|
||
# ⭐ 检测是否含有"昨天"等过去日期前缀:若有,后续时间戳年龄检查跳过未来时间拦截
|
||
_had_past_date_prefix = bool(re.search(r'(昨天|前天|大前天|\d+天前)', rl))
|
||
rl = re.sub(r'(昨天|前天|大前天|星期[一二三四五六日天]|\d+天前|今天|明天)\s*(?=\d{1,2}[::.;]\d{1,2})', '', rl)
|
||
# 标准化 EasyOCR 时间戳:16.47 → 16:47, 15;39 → 15:39, 16:03.41 → 16:03:41
|
||
# ⭐ Task #1507: 防 URL 片段误转时间戳。QQ 转发消息的链接残片常被 EasyOCR
|
||
# 读成 0.50;57 / 12.12.40xx 这类片段——混合分隔符(0.50;57)或后跟字母数字
|
||
# /URL字符的都不是时间戳,不转换。否则会被当"凌晨 0:50:57 旧时间戳"误杀
|
||
# 相邻的真实新消息(过滤1与stage继承双重误判)。
|
||
# 后顾 ?<![;.0-9A-Za-z%]:匹配起点前不能在URL字符后,防止 0.50;57 中
|
||
# 的子串 50;57 被单独转换(50:57 会被当合法时间戳)。
|
||
# ⭐ 2026-08-12: 第二段分隔符放宽为任意 [.;:](原 \2 要求前后分隔符相同,
|
||
# 但 EasyOCR 常把 15:27:05 读成 15;27.05,';' 与 '.' 不同 → 整串不被识别为
|
||
# 时间戳 → last_ts/_inherited_ts 不更新 → 其下方消息继承陈旧时间戳被"尾部较老"误杀。
|
||
# 与 _line_has_fresh_ts (5179) 的放宽逻辑保持一致。
|
||
rl = re.sub(
|
||
r'(?<![;.0-9A-Za-z%])(\d{1,2})([.;])(\d{2})(?:[.;:](\d{2}))?(?![;.0-9A-Za-z%])',
|
||
lambda m: f'{m.group(1)}:{m.group(3)}:{m.group(4)}' if m.group(4) else f'{m.group(1)}:{m.group(3)}',
|
||
rl)
|
||
# ⭐ 规整「逐字空格」时间戳:OCR 把 16:28:40 拆成 1 6 : 2 8,否则 re.split 找不到时间戳 → 漏消息
|
||
rl = self._normalize_timestamps(rl)
|
||
parts = re.split(r'(\d{1,2}:\d{1,2}(?::\d{1,2})?)', rl)
|
||
logger.debug(f"[ocr] stage[{i}] split -> {len(parts)} parts: "
|
||
f"{[p[:60] for p in parts if p.strip()]}")
|
||
buf = ''
|
||
# ⭐ QQ系统消息不继承时间戳,也不更新继承时间戳,防止旧时间戳污染
|
||
last_ts = None if _is_qq_system_msg else _inherited_ts
|
||
# ⭐ 继承的过去日期标记:若前一阶段有"昨天"等过去前缀,继承的时间戳也跳过未来检查
|
||
_inherited_past = _had_past_date_prefix or getattr(self, '_inherited_ts_from_past', False)
|
||
# ⭐ Task #1510: QQ 时间戳渲染在消息【下方】。消息行通常无自己的时间戳,
|
||
# 时间戳出现在其后续行。若本行或下一行含新鲜时间戳(≤300s),则本行消息
|
||
# 属于新鲜消息组 → 不应因继承上一行的陈旧时间戳而被误杀。
|
||
# 典型场景:旧历史消息(12:12:40)+新消息(15:35:38)同屏被OCR整屏捕捉,
|
||
# 新消息继承 12:12:40 被"尾部时间戳较老"整体丢弃。
|
||
_stage_fresh_hint = (
|
||
self._line_has_fresh_ts(rl, _inherited_past)
|
||
or (i + 1 < len(ocr_lines) and self._line_has_fresh_ts(ocr_lines[i + 1], _inherited_past))
|
||
)
|
||
for part in parts:
|
||
if re.match(r'^\d{1,2}:\d{1,2}', part):
|
||
if buf.strip():
|
||
if not last_ts:
|
||
# ⭐ 无前置时间戳:位于首个时间戳之前的消息。
|
||
# ⚠ 不再整段丢弃:可能是被裁切/漏识别时间戳的新消息,
|
||
# 交由下游 DB查重(避免连续聊天时时间戳被覆盖误杀用户消息)。
|
||
logger.info(f"[ocr] stage[{i}] [filter/放行] 无前置时间戳,交由DB查重: {buf.strip()[:60]!r}")
|
||
elif self._is_timestamp_old(last_ts, 300, skip_future_check=_inherited_past):
|
||
# ⭐ Task #1456: 继承时间戳>5min,直接丢弃,不再进行DB去重检查
|
||
# 时间戳已经表明这些是旧消息,即使它们从未被回复过,也不应该被处理
|
||
# 修复:移除DB去重检查,直接丢弃
|
||
if buf.strip() and _stage_fresh_hint:
|
||
# ⭐ Task #1510: 继承时间戳较老但本/下行含新鲜时间戳 → 新鲜组
|
||
logger.info(f"[ocr] stage[{i}] [age] 继承时间戳{last_ts}较老但本/下行含新鲜时间戳,按新鲜处理: {buf.strip()[:60]!r}")
|
||
elif buf.strip():
|
||
logger.info(f"[ocr] stage[{i}] [age] DROP 继承时间戳{last_ts}较老(>300s),直接丢弃: {buf.strip()[:60]!r}")
|
||
buf = ''
|
||
last_ts = part
|
||
continue
|
||
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
|
||
# ⭐ 2026-08-12 调整顺序:必须先剥离 bot 旧回复片段,再剥 md 尾巴。
|
||
# 原因:OCR 常把「用户新消息 + bot 旧回复」拼成一行,bot 回复若含
|
||
# '::|::' 等 UI 残片,_strip_markdown_tail 会在第一个 '|' 处切断,
|
||
# 误删其后真正的用户提问(如 "@游戏专用 PHP管理员端的那登录密码是多少?"),
|
||
# 导致用户消息被后续 filter4 整体丢弃。先剥离 bot 回复保留用户提问,
|
||
# 再走 md/噪声/残片过滤,用户提问才能存活。
|
||
_msg_bot = self._strip_bot_reply_fragments(buf.strip())
|
||
if not _msg_bot:
|
||
logger.debug(f"[ocr] stage[{i}] [filter/bot] bot旧回复整体剥离后为空,跳过: {buf.strip()[:60]!r}")
|
||
else:
|
||
# ⭐ 剥 md 尾巴:md 在末尾→切尾巴留用户提问;
|
||
# md 大头/混中间→None 整段丢弃,防 md 尾巴入库撞关键词
|
||
md_stripped = self._strip_markdown_tail(_msg_bot)
|
||
if md_stripped is None:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃: {_msg_bot[:80]!r}")
|
||
elif md_stripped != _msg_bot:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴: {_msg_bot[:60]!r} -> {md_stripped!r}")
|
||
if md_stripped and self._is_markdown_document(md_stripped):
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过: {md_stripped[:80]!r}")
|
||
elif md_stripped and self._is_noise_text(md_stripped):
|
||
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过: {md_stripped[:80]!r}")
|
||
else:
|
||
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', md_stripped or '')
|
||
if msg:
|
||
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
|
||
if msg:
|
||
# ⭐ 剥离QQ用户昵称,防止昵称拼凑进消息文本导致查库关键词被压住
|
||
msg = self._strip_qq_nickname(msg)
|
||
# ⭐ 2026-08-15 消息全身检查:剥离所有昵称和联系方式,防止误触回复
|
||
msg = self._sanitize_message(msg)
|
||
# ⭐ 2026-08-15 修复:OCR合并多条消息时,拆分所有消息段
|
||
# 如 "公告信息呀@游戏专用,草原探险服务器备用号,嘿啊。服务器地址"
|
||
# 不应只取最后一段,而应提取所有独立消息
|
||
_has_nick_marker = '@' in msg or ',' in msg
|
||
if _has_nick_marker:
|
||
_multi_msgs = self._split_by_nick_sep(msg)
|
||
for _mm in _multi_msgs:
|
||
if not _mm:
|
||
continue
|
||
if self._is_reply_fragment(_mm):
|
||
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 合并消息中的残片: {_mm[:60]!r}")
|
||
else:
|
||
logger.info(f"[ocr] stage[{i}] [filter/放行] 合并消息片段通过: {_mm[:60]!r}")
|
||
expanded.append(_mm)
|
||
elif not msg:
|
||
logger.debug(f"[ocr] stage[{i}] 剥离后为空,丢弃防空消息: {part[:40]!r}")
|
||
elif self._is_reply_fragment(msg):
|
||
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}")
|
||
else:
|
||
logger.info(f"[ocr] stage[{i}] [filter/放行] 通过所有过滤器,添加到expanded: {msg[:60]!r}")
|
||
expanded.append(msg)
|
||
buf = ''
|
||
last_ts = part
|
||
_inherited_past = False # 新时间戳重置过去日期标记
|
||
else:
|
||
buf += part
|
||
if buf.strip():
|
||
# 尾部检查:尾部消息位于最后一个可见时间戳之后,无独立时间戳。
|
||
# 关键修复:过滤器层(md/噪声/回复残片)必须【始终运行】,无论继承时间戳新旧,
|
||
# 之前错误地嵌在"时间戳较老"分支内,导致新鲜尾部消息(如用户刚发的提问)
|
||
# 被静默吞噬、永不进入 expanded。
|
||
# 仅当继承时间戳较老(>5min)且无新鲜特征且非提问豁免时才丢弃(Task #1440 的5分钟窗口),
|
||
# 其余一律保留交给过滤器层,绝不静默吞噬。
|
||
if not last_ts:
|
||
# 尾部无任何时间戳(整行无时间戳):交给下游DB查重,不整段丢弃
|
||
# 避免连续聊天时时间戳被覆盖误杀用户消息
|
||
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部无时间戳,交由DB查重: {buf.strip()[:60]!r}")
|
||
else:
|
||
# ⭐ Task #1456: 继承时间戳>5min,直接丢弃,不再进行DB去重检查
|
||
# 时间戳已经表明这些是旧消息,即使它们从未被回复过,也不应该被处理
|
||
# 修复:移除DB去重检查,直接丢弃
|
||
if self._is_timestamp_old(last_ts, 300, skip_future_check=_inherited_past):
|
||
if buf.strip() and _stage_fresh_hint:
|
||
# ⭐ Task #1510: 尾部继承时间戳较老但本/下行含新鲜时间戳 → 新鲜组,
|
||
# 不丢弃(QQ 时间戳在消息下方,见 stage 开头注释)
|
||
logger.info(f"[ocr] stage[{i}] [age] 尾部时间戳{last_ts}较老但本/下行含新鲜时间戳,按新鲜处理: {buf.strip()[:60]!r}")
|
||
elif buf.strip():
|
||
logger.info(f"[ocr] stage[{i}] [age] DROP 尾部时间戳{last_ts}较老(>300s),直接丢弃: {buf.strip()[:60]!r}")
|
||
buf = ''
|
||
# else: 时间戳新鲜(<5min),直接保留
|
||
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
|
||
if buf.strip():
|
||
# ⭐ 2026-08-12 必须先剥离 bot 旧回复片段(同 for 循环块理由):
|
||
# OCR 把「用户新消息 + bot 旧回复」拼成一行时,先剥 bot 回复才能
|
||
# 保住用户提问,否则 _strip_markdown_tail 会在 bot 回复的 '::|::'
|
||
# 残片处切断、误删用户提问,导致 filter4 整体丢弃。
|
||
_msg_bot = self._strip_bot_reply_fragments(buf.strip())
|
||
if not _msg_bot:
|
||
logger.debug(f"[ocr] stage[{i}] [filter/bot] 尾部bot旧回复整体剥离后为空,跳过: {buf.strip()[:60]!r}")
|
||
else:
|
||
md_stripped = self._strip_markdown_tail(_msg_bot)
|
||
if md_stripped is None:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃(尾部): {_msg_bot[:80]!r}")
|
||
elif md_stripped != _msg_bot:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴(尾部): {_msg_bot[:60]!r} -> {md_stripped!r}")
|
||
if md_stripped and self._is_markdown_document(md_stripped):
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过(尾部): {md_stripped[:80]!r}")
|
||
elif md_stripped and self._is_noise_text(md_stripped):
|
||
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过(尾部): {md_stripped[:80]!r}")
|
||
else:
|
||
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', md_stripped or '')
|
||
if msg:
|
||
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
|
||
if msg:
|
||
# ⭐ 剥离QQ用户昵称,防止昵称拼凑进消息文本导致查库关键词被压住
|
||
msg = self._strip_qq_nickname(msg)
|
||
# ⭐ 2026-08-15 消息全身检查:剥离所有昵称和联系方式,防止误触回复
|
||
msg = self._sanitize_message(msg)
|
||
# ⭐ 2026-08-15 修复:OCR合并多条消息时,拆分所有消息段
|
||
_has_nick_marker = '@' in msg or ',' in msg
|
||
if _has_nick_marker:
|
||
_multi_msgs = self._split_by_nick_sep(msg)
|
||
for _mm in _multi_msgs:
|
||
if not _mm:
|
||
continue
|
||
if self._is_reply_fragment(_mm):
|
||
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 合并尾部消息残片: {_mm[:60]!r}")
|
||
else:
|
||
logger.info(f"[ocr] stage[{i}] [filter/放行] 合并尾部消息片段通过: {_mm[:60]!r}")
|
||
split_msgs = self._split_merged_ocr_text(_mm)
|
||
for sm in split_msgs:
|
||
expanded.append(sm)
|
||
elif not msg:
|
||
logger.debug(f"[ocr] stage[{i}] 尾部剥离后为空,丢弃防空消息: {part[:40]!r}")
|
||
elif self._is_reply_fragment(msg):
|
||
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过(尾部): {msg[:60]!r}")
|
||
else:
|
||
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部消息通过所有过滤器,添加到expanded: {msg[:60]!r}")
|
||
# EasyOCR 常把横向相邻的两条消息(用户消息+群昵称/UI文字)
|
||
# 检测为同一行,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一"
|
||
# 这种合并文本。尝试在中文句尾标点处拆分为多条。
|
||
split_msgs = self._split_merged_ocr_text(msg)
|
||
for sm in split_msgs:
|
||
expanded.append(sm)
|
||
# ⭐ QQ系统消息不更新继承时间戳,防止旧时间戳污染后续真实消息
|
||
if not _is_qq_system_msg:
|
||
# 更新跨行继承时间戳
|
||
if last_ts:
|
||
_inherited_ts = last_ts
|
||
# ⭐ 若本阶段有过去日期前缀,继承的时间戳也标记为来自过去日期
|
||
self._inherited_ts_from_past = _had_past_date_prefix
|
||
else:
|
||
self._inherited_ts_from_past = False
|
||
else:
|
||
logger.debug(f"[ocr] stage[{i}] QQ系统消息不更新继承时间戳: {rl[:60]!r}")
|
||
|
||
# —— 逐行过滤 ——
|
||
# ⚠️ 急停检查:stage splitting 完成后,逐行过滤前再查一次
|
||
if not self._running:
|
||
return []
|
||
ocr_lines = expanded
|
||
texts = []
|
||
for line in ocr_lines:
|
||
if not line:
|
||
continue
|
||
if line in ("查看更多", "收起", "选择会话开始聊天"):
|
||
continue
|
||
if re.match(r'^[✿♡♥❤★☆✦✧❀❁❂❃❄❅❆❇❈❉❊❋➠➡➢➣➤.·•●○◦◎◉◍◐◑◒◓◔◕◖◗◢◣◤◥☺☻☼♠♣♦♤♧♡♢♔♕♖♗♘♙🕐-🕧\s]{1,3}$', line):
|
||
continue
|
||
line = re.sub(r'^0+', '', line)
|
||
line = re.sub(r'[勖彐"\'\s\u2000-\u206f]+$', '', line)
|
||
# 末尾裸字母:OCR checkbox [V] 碎成 "? O"、" O"、" V"
|
||
line = re.sub(r'\s+[OV]\s*$', '', line)
|
||
if not line:
|
||
continue
|
||
if re.match(r'^问题\d+\s*[::]', line):
|
||
continue
|
||
if re.match(r'^[\d:\s]+$', line):
|
||
continue
|
||
line = re.sub(r'^\d+[\..]\d+s?\s*\d*[.\.]?\d*', '', line)
|
||
line = re.sub(r'^[\d.\.\s]{2,20}[一-鿿]{1,5}?[,。、]', '', line)
|
||
if not line:
|
||
continue
|
||
if self._is_system_message(line):
|
||
continue
|
||
if re.search(r'群成员|索群成员|搜索成员', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 群成员侧栏 line={line[:60]!r}")
|
||
continue
|
||
if re.match(r'^[^)】〕」]{2,40}[(【〔「][^)】〕」]{1,15}[)】〕」]', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 群名头栏 line={line[:60]!r}")
|
||
continue
|
||
if re.match(r'^[一-鿿]{1}$', line):
|
||
continue
|
||
# OCR 碎片:≤8 字符且无中文 → 时间戳/checkbox 残片
|
||
if len(line) <= 8 and not re.search(r'[\u4e00-\u9fff]', line):
|
||
continue
|
||
# OCR 随机字符垃圾:无中文 + 特殊字符比例高(如 `544UUJ1W H V=YJ~JL:e`)
|
||
# EasyOCR 对头像/表情/特殊 UI 区域会输出这类无意义字母数字符号组合
|
||
if not re.search(r'[\u4e00-\u9fff]', line) and len(line) >= 10:
|
||
special_chars = sum(1 for c in line if c in '=~@#$%^&*_+{}[]|\\<>/`')
|
||
alpha_count = sum(1 for c in line if c.isalpha())
|
||
digit_count = sum(1 for c in line if c.isdigit())
|
||
if special_chars / len(line) > 0.15 and alpha_count >= 2 and digit_count >= 2:
|
||
logger.debug(f"[{platform}] OCR 过滤: 随机字符垃圾 line={line!r}")
|
||
continue
|
||
# checkbox 残片:`[V]` `[X]` `[√]` 开头,明显不是用户提问
|
||
if re.match(r'^\[[VX√✗]\s*\](?:\s*[A-Za-z0-9_\-./]+)?\s*$', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: checkbox残片 line={line!r}")
|
||
continue
|
||
# 多 checkbox 碎片:一行含 2+ 个 [V] 标记,md 列表 OCR 残留
|
||
if len(re.findall(r'\[\s*[VXvx√✗]\s*\]', line)) >= 2:
|
||
logger.debug(f"[{platform}] OCR 过滤: 多checkbox碎片 line={line!r}")
|
||
continue
|
||
# 全角括号 md 链接碎片:`[text](url` 或 `](` — md 链接 OCR 全角退化
|
||
if re.search(r'\](', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 全角括号碎片 line={line!r}")
|
||
continue
|
||
if re.match(r'^\d{1,2}[::\s]\d{1,2}', line):
|
||
continue
|
||
if re.match(r'^\d{4}[/-]\d{1,2}', line):
|
||
continue
|
||
# ===== 主动防御: UI 子串黑名单 (全平台) =====
|
||
# 含特定 UI 面板词的碎片直接丢弃,不依赖精确匹配
|
||
if any(sub in line for sub in _UI_SUBSTR_BLOCKLIST):
|
||
logger.debug(f"[{platform}] OCR 过滤: UI子串 line={line!r}")
|
||
continue
|
||
|
||
# ===== 主动防御: UI 按钮/标签黑名单 (精确匹配, 全平台) =====
|
||
if line in _UI_EXACT_BLOCKLIST:
|
||
logger.debug(f"[{platform}] OCR 过滤: UI黑名单 line={line!r}")
|
||
continue
|
||
|
||
# ===== 主动防御: 系统状态计数 (全平台) =====
|
||
if re.search(r'(?:浏览|阅读|查看)\d+[个条款篇张次]', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 浏览状态 line={line!r}")
|
||
continue
|
||
|
||
# ===== 主动防御: 多段数据面板 (全平台) =====
|
||
segs = [s.strip() for s in re.split(r'[。;]', line) if s.strip()]
|
||
if len(segs) >= 3:
|
||
digit_segs = sum(1 for s in segs if re.search(r'\d', s))
|
||
short_segs = sum(1 for s in segs if len(s) < 30)
|
||
if digit_segs >= 2 and short_segs >= 3:
|
||
logger.debug(f"[{platform}] OCR 过滤: 数据面板 line={line!r}")
|
||
continue
|
||
|
||
# ===== log/log排版残余: OCR 截到自身调试日志的尾巴 (全平台) =====
|
||
# "] line[0]" "line[o]:" — 引擎日志行号残留
|
||
if re.search(r'\]\s*line\s*\[\d', line, re.IGNORECASE):
|
||
logger.debug(f"[{platform}] OCR 过滤: log行号 line={line!r}")
|
||
continue
|
||
# "Pkdown" — "markdown" 的 OCR 乱码碎片
|
||
if re.search(r'[Pp]kdown', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: Pkdown碎片 line={line!r}")
|
||
continue
|
||
# "fragment skipped" — log 文本直接裸露
|
||
if 'fragment skipped' in line.lower():
|
||
logger.debug(f"[{platform}] OCR 过滤: fragment-skipped line={line!r}")
|
||
continue
|
||
# 引号前缀 OCR 残余: "' '玩家..." "'注册用户总数' ,"
|
||
if re.match(r"^'+[\s,)]", line) or re.match(r"^'+[^']+'+\s*,", line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 引号残余 line={line!r}")
|
||
continue
|
||
# 引号后缀 OCR 残余: "注册用户总数' ,"(中文后跟引号逗号)
|
||
if re.search(r"[\u4e00-\u9fff]'\s*,?\s*$", line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 引号尾缀 line={line!r}")
|
||
continue
|
||
|
||
# URL-only: 无中文且含 http/https → OCR 面板图片URL碎片
|
||
# OCR 常把 https://img.ypshidifu.cn 识别为 httpsLlimgypshidifu(丢失 ://)
|
||
if not re.search(r'[\u4e00-\u9fff]', line) and re.search(r'https?\S', line, re.IGNORECASE):
|
||
logger.debug(f"[{platform}] OCR 过滤: URL-only碎片 line={line!r}")
|
||
continue
|
||
|
||
# —— 纯非中文乱码:QQ/微信中文群消息都至少有中文 ——
|
||
# URL 被 OCR 啃成 hpsllimgypshidifu cnli/...(丢失 https://)时 URL-only 漏网
|
||
# 排除游戏指令前缀 / 以免误杀 /register password 等合法消息
|
||
if len(line) > 10 and not re.search(r'[\u4e00-\u9fff]', line) and not line.startswith('/'):
|
||
logger.debug(f"[{platform}] OCR 过滤: 纯非中文乱码 line={line!r}")
|
||
continue
|
||
|
||
# —— CJK 比例过低乱码:EasyOCR 把 URL/代码认成罕见汉字混一堆字母 ——
|
||
# 如 "刈甘珂呋; .怂U止X旋XLeJ(ILUS.//OITIUU" — 含中文但中文极少
|
||
# 用户正常消息的 CJK 比例通常 > 30%
|
||
if len(line) > 15 and re.search(r'[A-Za-z]', line):
|
||
cjk_count = sum(1 for c in line if '\u4e00' <= c <= '\u9fff')
|
||
if cjk_count > 0 and cjk_count / max(len(line), 1) < 0.15:
|
||
logger.debug(f"[{platform}] OCR 过滤: CJK比例过低乱码 line={line!r}")
|
||
continue
|
||
|
||
# —— 游戏UI / 非聊天文本乱码(CJK+英文混合) ——
|
||
# QQ/微信 OCR 在非聊天区域(服务器列表/物品栏/群公告)产出
|
||
# CJK+英文混合乱码,特征:下划线英文("Ierite_spear")、CJK后跟大写英文("玩家Ierite")
|
||
has_cjk = bool(re.search(r'[\u4e00-\u9fff]', line))
|
||
has_alpha = bool(re.search(r'[A-Za-z]', line))
|
||
if has_cjk and has_alpha and len(line) > 10:
|
||
# 下划线英文 — 正常中文聊天极少包含("Ierite_spear" 游戏ID/代码残片)
|
||
if re.search(r'[a-zA-Z]+_[a-zA-Z]+', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 游戏UI乱码(下划线英文) line={line!r}")
|
||
continue
|
||
# CJK+大写英文组合("玩家Ierite" "比武Mtp")— 游戏UI公告/服务器列表特有
|
||
# 但正常句子也可能包含英文名词(如"Minecraft"),需区分:
|
||
# 若中文字符数 >= 英文字母数,视为正常句子不过滤
|
||
if re.search(r'[\u4e00-\u9fff][A-Z][a-z]{1,}', line):
|
||
cjk_count = len(re.findall(r'[\u4e00-\u9fff]', line))
|
||
alpha_count = len(re.findall(r'[A-Za-z]', line))
|
||
if alpha_count > 0 and cjk_count < alpha_count:
|
||
logger.debug(f"[{platform}] OCR 过滤: 游戏UI乱码(CJK+大写英文) line={line!r}")
|
||
continue
|
||
|
||
# —— 绿色气泡 OCR 乱码:| 管道符混中文 ——
|
||
# bot 的绿色气泡/蓝色气泡文字经过 OCR 后常把 :; 识别为 |,
|
||
# 正常中文聊天从不混用 | 与中文。如 "GU |进行配置" "SMTP | 设置"
|
||
if '|' in line and re.search(r'[\u4e00-\u9fff]', line) and len(line) > 5:
|
||
logger.debug(f"[{platform}] OCR 过滤: 管道符碎片(绿色气泡) line={line!r}")
|
||
continue
|
||
|
||
# —— 游戏指令列表碎片:重复 /cmd 模式 ——
|
||
# OCR 截到命令帮助文本如 "/sdfl主指令 /sdfl reload /sdfl update"
|
||
# 正常用户消息极少含 ≥2 个独立 /command
|
||
slash_cmds = re.findall(r'(?:^|\s)/(\w{2,})', line)
|
||
if len(slash_cmds) >= 2:
|
||
logger.debug(f"[{platform}] OCR 过滤: 指令列表碎片(多/cmd={slash_cmds}) line={line!r}")
|
||
continue
|
||
|
||
# —— 斜线英文命令碎片:"/sdfl undo撤销导入" "/register密码" ——
|
||
# 首字符是 / 且紧跟 ASCII 字母 → 命令帮助/参考碎片,非用户普通对话
|
||
# 不拦截 "/兑换码怎么用"(斜线后是中文)
|
||
if re.match(r'^/[A-Za-z]', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 斜线英文命令 line={line!r}")
|
||
continue
|
||
# 消息中段含 /英文命令(如"怎么办?/printer玩家名")→ 指令碎片拼接
|
||
if re.search(r'(?<=[\s??。!,,])/[A-Za-z]{2,}', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 斜线命令碎片(中段) line={line!r}")
|
||
continue
|
||
|
||
# —— OCR 误读斜线为 I/J:"/sdfl"→"Isdfl", "/import"→"Jimport" ——
|
||
# 特征1:含已知命令关键词(sdfl/sdf1),零误杀
|
||
if re.match(r'^[A-Za-z]', line) and ('sdfl' in line.lower() or 'sdf1' in line.lower()):
|
||
logger.debug(f"[{platform}] OCR 过滤: 变形命令(sdfl) line={line!r}")
|
||
continue
|
||
# 特征2:大写字母+4+小写 + CJK + >10 字符 → 非自然对话
|
||
if re.match(r'^[A-Z][a-z]{4,}', line) and re.search(r'[\u4e00-\u9fff]', line) and len(line) > 10:
|
||
logger.debug(f"[{platform}] OCR 过滤: 变形命令(大写+字母) line={line!r}")
|
||
continue
|
||
|
||
# —— Bot自身回复过滤:pipe 表格格式(|参数1|参数2|参数3|结果|) ——
|
||
# OCR 截屏可能截到旧的 bot 回复,pipe 表格不应进入 KB 匹配
|
||
pipe_count = line.count('|')
|
||
if pipe_count >= 3 and len(line) > 30:
|
||
logger.debug(f"[{platform}] OCR 过滤: bot表格(pipe={pipe_count}) line={line!r}")
|
||
continue
|
||
|
||
# —— Bot回复内容过滤:英文key=value配置格式 ——
|
||
# OCR截屏截到bot旧回复中的配置内容,如 "invite referral bonus=5afii"
|
||
# 特征:英文key + = + 值,正常中文用户对话不含这种格式
|
||
if re.search(r'[a-zA-Z_][\w]*\s*=\s*\S', line) and not re.search(r'[\u4e00-\u9fff]', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: bot配置(key=value) line={line!r}")
|
||
continue
|
||
|
||
# —— Bot回复内容过滤:配置文件描述片段 ——
|
||
# OCR截屏截到bot旧回复中的配置说明,如 "配置此处的文件,负责展示..."
|
||
# 这些是KB中的配置说明文本,不是用户消息
|
||
# ⚠️ 要求同时包含配置短语 + 英文关键词 + 配置格式(=或:),避免误过滤用户讨论配置的消息
|
||
_config_phrases = ('配置此处的文件', '负责展示', '对应一个商品', '使用md文件格式',
|
||
'配置文件', '该配置', '此配置', '参数配置', '配置项')
|
||
_config_keywords = ('invite', 'referral', 'reward', 'bonus')
|
||
has_config_phrase = any(p in line for p in _config_phrases)
|
||
has_config_keyword = re.search(r'invite|referral|reward|bonus', line, re.IGNORECASE)
|
||
has_config_format = re.search(r'[=:]\s*\S', line) # 包含 = 或 : 后跟非空白字符
|
||
if has_config_phrase and has_config_keyword and has_config_format:
|
||
logger.debug(f"[{platform}] OCR 过滤: bot配置说明(三重匹配) line={line!r}")
|
||
continue
|
||
|
||
# —— KB内容回读过滤:bot旧回复中的KB段落标题/内容 ——
|
||
# OCR截屏截到bot之前回复的KB内容,如"奖励:邀请人可获得该新用户首充金额的30%"
|
||
# 特征:包含"奖励:"、"规则:"、"配置:"等KB段落标记 + 内容描述
|
||
_kb_markers = ('奖励:', '规则:', '配置:', '说明:', '要求:', '限制:', '条件:', '流程:',
|
||
'奖励:', '规则:', '配置:', '说明:', '要求:', '限制:', '条件:', '流程:')
|
||
if any(m in line for m in _kb_markers):
|
||
logger.debug(f"[{platform}] OCR 过滤: KB内容回读 line={line!r}")
|
||
continue
|
||
|
||
# —— Bot回复内容过滤:百分比+金额描述 ——
|
||
# OCR截屏截到bot旧回复中的奖励金额描述,如"首充金额的30 作"
|
||
# 正常用户对话不含这种精确百分比+金额的描述
|
||
if re.search(r'\d+\s*%', line) and re.search(r'(金额|奖励|积分|返利|分成)', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 奖励金额描述 line={line!r}")
|
||
continue
|
||
|
||
# —— JSON/YAML 碎片兜底:OCR 截屏混入配置面板 ——
|
||
# 行首是 "key": value 或 key: value(YAML)→ 配置面板碎片
|
||
if re.match(r'^["\']?[a-zA-Z_][\w]*["\']?\s*:\s*["\d\[\{tfn]', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: JSON行 {line!r}")
|
||
continue
|
||
if re.match(r'^[a-zA-Z_][\w]*\s*:\s*["\w\d]', line) and \
|
||
not re.match(r'^(https?|ftp)://', line) and \
|
||
not re.match(r'^[a-zA-Z_][\w]*\s*:\s*/', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: YAML行 {line!r}")
|
||
continue
|
||
|
||
# —— md 尾巴剥离:末尾 markdown 内容切掉,保留用户原始提问 ——
|
||
# ⭐ 2026-08-11:OCR 把 bot md 回复残片拼在用户消息尾部
|
||
# (如 "群管理员...(头衔UI)...# #来测试# # @C418minecraf")。
|
||
# md 在末尾→切小尾巴;md 大头/混中间→None 整段丢弃。
|
||
# 作用:入库的只有用户原始提问,md 尾巴不撞知识库关键词。
|
||
md_stripped = MessageMonitor._strip_markdown_tail(line)
|
||
if md_stripped is None:
|
||
logger.debug(f"[{platform}] OCR 过滤: md大头/混中间整段丢弃 line={line!r}")
|
||
continue
|
||
if md_stripped != line:
|
||
logger.debug(f"[{platform}] OCR 过滤: 剥离md尾巴 line={line!r} -> {md_stripped!r}")
|
||
line = md_stripped
|
||
|
||
# —— 末尾 #标题 碎片兜底 ——
|
||
# OCR 时间戳分割可能遗漏尾部 " #测试" " #草" 等 md 标题碎片
|
||
# 注:\s+ 要求空格在前,避免误杀 "C#语言" "中文#标签"
|
||
if re.search(r'#[\u4e00-\u9fff]', line):
|
||
cleaned = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', line).strip()
|
||
if cleaned != line:
|
||
if not cleaned or MessageMonitor._is_markdown_document(cleaned):
|
||
logger.debug(f"[{platform}] OCR 过滤: 末尾#标题碎片 line={line!r}")
|
||
continue
|
||
line = cleaned
|
||
# 如果 line 本身以 # 开头(纯 md),前面的 _is_markdown_document 应该已捕获
|
||
# 但此处再补一刀
|
||
if MessageMonitor._is_markdown_document(line):
|
||
logger.debug(f"[{platform}] OCR 过滤: md纯标题碎片 line={line!r}")
|
||
continue
|
||
|
||
# —— OCR 幻觉乱码检测:连续重复字符(LLL / 111 / ===) ——
|
||
# EasyOCR 对头像/特殊 UI 输出如 ":山LeLyeeuCL...ggsegU凶>债券管理"
|
||
# 普通消息不含连续 5+ 相同字符
|
||
if re.search(r'([A-Za-z0-9])\1{4,}', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 幻觉乱码(重复) line={line!r}")
|
||
continue
|
||
|
||
# —— OCR 乱码检测:CJK+拉丁混合乱码(如"默i"、"鼍") ——
|
||
# 正常中文消息不会出现单个拉丁字母夹在CJK之间
|
||
if re.search(r'[\u4e00-\u9fff][a-zA-Z][\u4e00-\u9fff]', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: CJK+拉丁混合乱码 line={line!r}")
|
||
continue
|
||
# 生僻字/罕见字检测:CJK扩展区字符(U+3400-U+4DBF, U+20000-U+2A6DF)
|
||
if re.search(r'[\u3400-\u4dbf\U00020000-\U0002a6df]', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 生僻字/罕见字 line={line!r}")
|
||
continue
|
||
|
||
# —— 英文配置格式+乱码过滤:key=value 或 key: value 后跟乱码 ——
|
||
# 如 "invite referral bonus=5a激<5"、"config=file.txt乱码"
|
||
if re.search(r'[a-zA-Z_]{2,}\s*[=:]\s*\S+', line):
|
||
# 检查是否包含乱码特征:特殊字符、数字字母混合、非正常英文单词
|
||
乱码特征 = False
|
||
# 特征1:等号或冒号后包含特殊字符(<>!@#$%^&*等)
|
||
if re.search(r'[=:]\s*[a-zA-Z0-9]*[<>!@#$%^&*()\[\]{}|\\/:;\'",.?~`]+', line):
|
||
乱码特征 = True
|
||
# 特征2:等号或冒号后包含数字字母混合但无空格(如"5a激<5")
|
||
if re.search(r'[=:]\s*[a-zA-Z0-9]{3,}[^\s,.;:!?。,;:!?\u4e00-\u9fff]', line):
|
||
乱码特征 = True
|
||
if 乱码特征:
|
||
logger.debug(f"[{platform}] OCR 过滤: 英文配置+乱码 line={line!r}")
|
||
continue
|
||
|
||
# —— 系统配置内容过滤:Wiki/文档页面内容 ——
|
||
if re.search(r'概览|数据看板|在线玩家|注册用户|活跃玩家|玩家数据', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 系统配置内容(Wiki) line={line!r}")
|
||
continue
|
||
|
||
# —— 微信专属:气泡上下文验证 ——
|
||
# 微信截图可能混入 Windows 弹窗/通知/非聊天内容
|
||
# 真实用户消息通常含时间戳(14:30格式) 或 冒号分隔符(用户名:消息)
|
||
# 纯粹的碎片文本(无时间戳、无冒号、无标点结尾)→ 高概率是非消息内容
|
||
if platform == "wechat":
|
||
has_timestamp = bool(re.search(r'\d{1,2}:\d{2}', line))
|
||
has_colon = bool(re.search(r'[::]', line))
|
||
has_punct_end = bool(re.search(r'[。!?!?]$', line))
|
||
# 短句(≤8字)含中文 → 可能是合法短消息(如"好的"、"嗯"),不拦截
|
||
if len(line) <= 8 and re.search(r'[\u4e00-\u9fff]', line):
|
||
pass # 放行短中文消息
|
||
# 无时间戳 + 无冒号 + 无标点结尾 → 高概率是非消息碎片(弹窗/通知/UI)
|
||
elif not has_timestamp and not has_colon and not has_punct_end:
|
||
# 再放宽:含问词(怎么办/为什么/能不能)或感叹词 → 可能是真实消息
|
||
if not re.search(r'怎么办|为什么|能不能|怎么|如何|什么|可以|帮忙|谢谢|好的|嗯|收到|OK|ok', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 微信非消息碎片(无上下文) line={line!r}")
|
||
continue
|
||
|
||
# —— 回复内容片段检测:OCR 捕获 bot 自己刚发的回复 ——
|
||
# ⭐ 2026-08-11 修复:阈值对齐 filter4(_is_reply_fragment) 的"大部分才算"标准,
|
||
# 原来 size>=6 无条件命中即丢——历史回复含"youpaishidifu"(@提及旧回复)时,
|
||
# 用户消息"youpaishidifu关于我们..."与之共享13字符公共子串(占比仅45%),
|
||
# filter4 正确放行后这里又被宽松阈值抓回丢弃。
|
||
# 现在要求:公共子串≥15,或(≥8 且 占较短文本≥60%),与 filter4 一致。
|
||
if self._replied_texts and len(line) > 8:
|
||
import difflib
|
||
is_match = False
|
||
# 第一轮:原始文本匹配
|
||
for r in self._replied_texts:
|
||
if len(r) < 5:
|
||
continue
|
||
sm = difflib.SequenceMatcher(None, line, r)
|
||
lm = sm.find_longest_match(0, len(line), 0, len(r)).size
|
||
if lm >= 15 or (lm >= 8 and lm >= 0.6 * min(len(line), len(r))):
|
||
is_match = True
|
||
break
|
||
# 第二轮:CJK-only 降阈值匹配(OCR 碎片字母被啃掉时)
|
||
if not is_match:
|
||
cjk_line = ''.join(c for c in line if '\u4e00' <= c <= '\u9fff')
|
||
if len(cjk_line) >= 5:
|
||
for r in self._replied_texts:
|
||
r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff')
|
||
if len(r_cjk) < 5:
|
||
continue
|
||
sm = difflib.SequenceMatcher(None, cjk_line, r_cjk)
|
||
clm = sm.find_longest_match(0, len(cjk_line), 0, len(r_cjk)).size
|
||
if clm >= 15 or (clm >= 8 and clm >= 0.6 * min(len(cjk_line), len(r_cjk))):
|
||
is_match = True
|
||
break
|
||
if is_match:
|
||
# ⭐ 问句豁免(2026-08-11):短问句与 bot 旧回复共享长短语时,60%标准误杀。
|
||
# "登录插件下载地址是啥啊"(10字) vs bot回复"登录插件下载地址: https://..."
|
||
# 共享"登录插件下载地址"8字符占80%→被判回复片段。但 line 是问句且不是
|
||
# 任何 replied 的完全子串 → 用户提问,放行。真回声 line 整段≈r 仍拦。
|
||
_ask_words = ('怎么办', '为什么', '能不能', '怎么', '如何', '什么', '可以',
|
||
'帮忙', '是啥', '是啥啊', '在哪', '哪个', '哪里', '多少', '几', '吗', '呢')
|
||
_is_ask = any(w in line for w in _ask_words) or line.rstrip().endswith(('?', '?'))
|
||
_is_full_sub = any(line in r for r in self._replied_texts)
|
||
if _is_ask and not _is_full_sub:
|
||
logger.debug(f"[{platform}] OCR 过滤: 疑似回复片段但为问句且非完全子串,视为用户提问放行 line={line[:60]!r}")
|
||
else:
|
||
# ⭐ 用户名@提及豁免(2026-08-12):公共子串主要是用户名/昵称时,
|
||
# 且用户消息含中文内容,视为用户提问而非回复残片。
|
||
# 例:youpaishidifu关于我们 vs @youpaishidifu 你好 → 公共子串youpaishidifu是用户名
|
||
_cjk_line = ''.join(c for c in line if '\u4e00' <= c <= '\u9fff')
|
||
_has_cjk = len(_cjk_line) >= 2
|
||
if _has_cjk and not _is_full_sub:
|
||
_pub_is_username = False
|
||
for r in self._replied_texts:
|
||
if len(r) < 5:
|
||
continue
|
||
sm = difflib.SequenceMatcher(None, line, r)
|
||
lm = sm.find_longest_match(0, len(line), 0, len(r)).size
|
||
if lm >= 8:
|
||
match = sm.find_longest_match(0, len(line), 0, len(r))
|
||
pub_str = line[match.a:match.a + match.size]
|
||
pub_cjk = sum(1 for c in pub_str if '\u4e00' <= c <= '\u9fff')
|
||
if pub_cjk == 0:
|
||
_pub_is_username = True
|
||
break
|
||
if _pub_is_username:
|
||
logger.debug(f"[{platform}] OCR 过滤: 疑似回复片段但公共子串为用户名且消息含中文,视为用户提问放行 line={line[:60]!r}")
|
||
else:
|
||
logger.debug(f"[{platform}] OCR 过滤: 回复内容片段 line={line[:60]!r}")
|
||
continue
|
||
else:
|
||
logger.debug(f"[{platform}] OCR 过滤: 回复内容片段 line={line[:60]!r}")
|
||
continue
|
||
|
||
# ⭐ Task #1441: bot自身消息查库过滤
|
||
# 检查OCR读到的内容是否与数据库中bot回复的消息重合率≥90%
|
||
# 如果是,说明这是bot自己回复的消息,不是用户消息,应丢弃
|
||
if len(line) > 10 and self._msg_history:
|
||
try:
|
||
import difflib
|
||
# 查询数据库中最近的bot回复(限制20条)
|
||
recent_bot_replies = self._msg_history.get_recent_bot_replies(platform, limit=20)
|
||
for bot_reply in recent_bot_replies:
|
||
if len(bot_reply) < 10:
|
||
continue
|
||
# 计算重合率
|
||
sm = difflib.SequenceMatcher(None, line, bot_reply)
|
||
ratio = sm.ratio()
|
||
if ratio >= 0.7:
|
||
logger.debug(f"[{platform}] OCR 过滤: bot自身消息(查库重合率{ratio:.2f}) line={line[:60]!r}")
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] OCR 查库过滤异常: {e}")
|
||
|
||
# ⭐ Task #1515: bot回复菜单模式过滤
|
||
# OCR 把屏幕上的 bot 回复菜单(下载选项/checkbox 列表)重新识别为"新消息"。
|
||
# 典型特征:含版本标签 `(稳定版)` `(最新开发版)` `(官方版)` `(测试版)` 等,
|
||
# 或含 checkbox 标记 `[√]` `[⚠]` `[✗]`。正常用户聊天从不写这些。
|
||
_BOT_MENU_LABELS = re.compile(
|
||
r'\((?:稳定版|最新开发版|官方版|测试版|开发版|正式版|体验版|内测版|公测版|'
|
||
r'推荐|默认|可选|必选|已选|未选|免费|付费|专业版|企业版|个人版)\)'
|
||
)
|
||
if _BOT_MENU_LABELS.search(line):
|
||
logger.debug(f"[{platform}] OCR 过滤: bot回复菜单(版本标签) line={line!r}")
|
||
continue
|
||
# checkbox 标记:[ √ ] [ ⚠ ] [ ✗ ] [ X ] [ √] 等(OCR可能漏空格/括号)
|
||
if re.search(r'[\[【]\s*[√⚠✗×XvV✓✔]\s*[\]】]', line, re.IGNORECASE):
|
||
logger.debug(f"[{platform}] OCR 过滤: bot回复菜单(checkbox标记) line={line!r}")
|
||
continue
|
||
# 纯 checkbox 行:OCR 把 "[ √ ]" 读成 "√ " 或 "√"(无括号)
|
||
if re.match(r'^[√⚠✗×]{1,2}\s*$', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 纯checkbox符号 line={line!r}")
|
||
continue
|
||
# bot 回复中的下载链接格式:[官方下载](url) [bbsmc分流](url) 等
|
||
if re.search(r'\[.+\]\(https?://.+\)', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: bot回复(md链接) line={line!r}")
|
||
continue
|
||
|
||
# === 全面 md 语法兜底:正常用户不写 md,只可能是 bot 回复 ===
|
||
# ** 加粗标记(用户聊天从不打 **)
|
||
if '**' in line:
|
||
logger.debug(f"[{platform}] OCR 过滤: 加粗语法 line={line!r}")
|
||
continue
|
||
# 行首 # 标题(未在前面的 md 检测中被捕获的)
|
||
if re.match(r'^#{1,6}(?:\s|\d|\[|[\u4e00-\u9fff])', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 行首标题 line={line!r}")
|
||
continue
|
||
# 编号列表:1. 2) 3、 开头
|
||
if re.match(r'^\d{1,3}[\.\)、]\s', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 编号列表 line={line!r}")
|
||
continue
|
||
# 全角 md 链接残片:]( 或 [text](url
|
||
if re.search(r'\](', line) or re.search(r'\[.+?\](', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 全角链接残片 line={line!r}")
|
||
continue
|
||
|
||
# —— * 开头 md 残片:OCR 吞了另一半,如 *如有错误。请您 ——
|
||
# 正常用户在 QQ/微信聊天从不以 * 开头
|
||
if line.startswith('*'):
|
||
logger.debug(f"[{platform}] OCR 过滤: *开头残片 line={line!r}")
|
||
continue
|
||
|
||
# —— *...* 配对残片:OCR 把 **使用** 啃成 *x但不衢...x* ——
|
||
# 任何 * 包裹内容(不论内容如何)都是 md 语法残余
|
||
if re.search(r'\*[^*]+\*', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: *配对残片 line={line!r}")
|
||
continue
|
||
|
||
# —— 结构化数据面板:QQ/TIM 指令面板(指令:/rtp 权限类型:玩家 图标:标题:)——
|
||
# 3+ 个 中文短词:值 结构,用户聊天从不会连续3+个冒号键值对
|
||
colon_kv = re.findall(r'[\u4e00-\u9fff]{1,6}[::](?:[A-Za-z0-9/]|[\u4e00-\u9fff])', line)
|
||
if len(colon_kv) >= 3:
|
||
logger.debug(f"[{platform}] OCR 过滤: 结构化面板 line={line!r}")
|
||
continue
|
||
|
||
# —— OCR 偏旁碎片过滤:尝试合体还原 + 阈值丢弃 ——
|
||
# ⭐ 2026-08-14 修复:不再一有偏旁就丢弃,先尝试合体还原
|
||
# 1. 将 OCR 拆分的偏旁组合还原为完整汉字(如 '讠吾'→'语')
|
||
# 2. 还原后再统计偏旁占比,>10% 才丢弃
|
||
# 原因:OCR 常把"默认"识别成"默讠人"、把"个"识别成"亻尔"
|
||
# 这些是单个偏旁的 OCR 噪声,还原后是有效文本,不应丢弃
|
||
restored_line = _restore_radical_text(line)
|
||
radical_count = sum(1 for ch in restored_line if _is_radical_char(ch))
|
||
if radical_count > 0 and radical_count / max(len(restored_line), 1) > 0.1:
|
||
logger.debug(f"[{platform}] OCR 过滤: 偏旁字符过多(占比{radical_count/max(len(restored_line),1)*100:.0f}%) line={line!r} -> {restored_line!r}")
|
||
continue
|
||
# 使用还原后的文本继续后续处理
|
||
line = restored_line
|
||
# —— 系统配置内容过滤:SMTP/邮件地址/代码片段不是用户消息 ——
|
||
if re.search(r'@example|smtp|\.py\b|\.bat\b|\.sh\b|//.*\.\w{2,4}\b', line, re.IGNORECASE):
|
||
logger.debug(f"[{platform}] OCR 过滤: 系统配置内容 line={line!r}")
|
||
continue
|
||
if re.search(r'@\w+\.\w{2,4}[,.\s]', line) and re.search(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 邮件地址格式 line={line!r}")
|
||
continue
|
||
|
||
# —— OCR 短句/病句检测:OCR 把消息"嚼碎"生成碎片 ——
|
||
# 通用疑问词(有这些词说明有查询意向,放行)
|
||
_q_words = {"什么", "怎么", "如何", "为什么", "吗", "呢", "吧", "能", "可以", "有没有", "哪些", "哪", "哪几个",
|
||
"是不是", "会不会", "能不能", "怎么办", "好不好", "行不行"}
|
||
# "自行修?" → 3字无查询意向碎片;"将其修正后重三" → 病句
|
||
if len(line) <= 8:
|
||
# ≤8字短句:必须包含疑问词才放行,否则是OCR碎片
|
||
# 有问号但无疑问词 → 病句(如"自行修?"无查询意向)
|
||
has_q = any(w in line for w in _q_words)
|
||
# ⭐ 2026-08-11: 规则4剥离")昵称 "前缀的行(如"关于我们")是真实用户消息,
|
||
# 豁免短句检查——否则规则4放行+前缀剥离全白做,消息仍被此规则丢弃。
|
||
# ⭐ 2026-08-12: 包含括号的消息(如"端0(基岩版)")可能是有效短消息,放行
|
||
if not has_q and line not in _ui_prefix_stripped and not re.search(r'[()()]', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 短句碎片(≤8字无问词) line={line!r}")
|
||
continue
|
||
# ★ 以特殊符号(~、`等)开头 → OCR把"一键"识别为"~键"等碎片
|
||
if re.match(r'^[~`~]', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 特殊符号开头碎片 line={line!r}")
|
||
continue
|
||
if 5 < len(line) < 50:
|
||
# 病句1:句子以动词/助词结尾但缺少宾语且无标点
|
||
if re.search(r'[后以了过]+.{0,3}$', line) and not re.search(r'[。!?,,;;::?]$', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 病句(动词结尾) line={line!r}")
|
||
continue
|
||
# 病句2:句子以"三"结尾(OCR误读"试"为"三")
|
||
if re.search(r'重三$', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 病句(重三) line={line!r}")
|
||
continue
|
||
# 病句3:句子缺少主语且无标点(如"将其修正后重三" "认管理员密码多少")
|
||
# OCR常把"默认"识别丢掉→"认管理员密码多少"、把"帮"识别丢掉→"查xx"
|
||
# 例外:以"问一嘴"、"问一下"开头的正常查询消息,或包含疑问词且长度>8的消息放行
|
||
if re.match(r'^[其将把被认查帮试想]', line) and not re.search(r'[。!?,,;;::?]$', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: 病句(缺主语) line={line!r}")
|
||
continue
|
||
# "问"开头的消息单独处理:排除"问一嘴"、"问一下"等正常查询
|
||
if re.match(r'^问', line) and not re.search(r'[。!?,,;;::?]$', line):
|
||
# 如果长度>=6且包含疑问词或核心动作词,视为正常查询放行
|
||
if len(line) >= 6 and (re.search(r'[在何哪什怎么多少]', line) or re.search(r'(下载|地址|查询|查看|了解|咨询|知道|怎么|如何)', line)):
|
||
pass # 放行
|
||
else:
|
||
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 ''}\"")
|
||
|
||
# ⭐ 2026-08-16: 同屏OCR碎片语义去重
|
||
# 问题:同一条用户消息被OCR拆成多条文本变体(如"问一嘴...下载"
|
||
# vs "在哪能下载" vs "能不能下载"),因文本差异未被seen/_replied_texts拦截
|
||
# 导致同一意图触发多次回复。
|
||
# 方案:提取中文核心词指纹(去停用词+语气词),30秒内指纹相同视为重复。
|
||
texts = self._dedup_semantic_fingerprint(texts, platform)
|
||
|
||
return texts
|
||
|
||
def _read_via_clipboard_once(self, platform: str) -> list:
|
||
"""剪贴板法单次执行(安全键盘导航:Enter + Ctrl+End + Ctrl+A/C,无 Esc)。
|
||
|
||
去重由 _poll 的 _replied_texts + _seen_messages 负责,本函数只过滤并返回。
|
||
回复回环防御由 _paste_and_send 写入 _replied_texts 承担。
|
||
"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd or not win32gui.IsWindow(hwnd):
|
||
return []
|
||
|
||
import win32clipboard as wc
|
||
|
||
old = None
|
||
try:
|
||
wc.OpenClipboard()
|
||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||
old = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
|
||
texts = []
|
||
raw_lines = [] # 预初始化,防止 content 为空时 UnboundLocalError
|
||
try:
|
||
# 确保窗口在前台(失败则返回空,避免发送到错误窗口)
|
||
# QQ 剪贴板优先路径需要更可靠的前台保证(与发送路径 retries 一致)
|
||
if not self._ensure_foreground(hwnd, max_retries=5):
|
||
return []
|
||
|
||
# PostMessage 点击消息区域(设焦点,不全局,不比鼠标移动)
|
||
rect = win32gui.GetWindowRect(hwnd)
|
||
if rect:
|
||
left, top, right, bottom = rect
|
||
w = right - left
|
||
h = bottom - top
|
||
# QQ NT 消息面板在 ~55-95% 宽度区域
|
||
click_x = int(w * 0.72) # 消息面板中部
|
||
click_y = int(h * 0.60) # 消息面板中部偏下
|
||
lparam = win32api.MAKELONG(click_x, click_y)
|
||
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
|
||
win32gui.PostMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
|
||
time.sleep(0.2)
|
||
|
||
# 滚动到底部(PostMessage 安全,不切焦点,确保新消息可见)
|
||
self._force_scroll_bottom(platform)
|
||
time.sleep(0.2)
|
||
|
||
# TIM/QQ NT: 安全键盘导航(无 Esc,不关窗口)
|
||
# 每步前校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台
|
||
VK_CONTROL = 0x11
|
||
variant = self._app_variant.get(platform, "")
|
||
if variant in ("tim", "qq_nt"):
|
||
VK_RETURN = 0x0D
|
||
VK_END = 0x23
|
||
|
||
# Enter 激活当前对话
|
||
if self._fg_is_target(hwnd):
|
||
ctypes.windll.user32.keybd_event(VK_RETURN, 0, 0, 0)
|
||
time.sleep(0.05)
|
||
ctypes.windll.user32.keybd_event(VK_RETURN, 0, 2, 0)
|
||
time.sleep(0.3)
|
||
|
||
# Ctrl+End 滚动到底部(双保险:PostMessage + keybd_event)
|
||
if self._fg_is_target(hwnd):
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
|
||
ctypes.windll.user32.keybd_event(VK_END, 0, 0, 0)
|
||
time.sleep(0.05)
|
||
ctypes.windll.user32.keybd_event(VK_END, 0, 2, 0)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
time.sleep(0.3)
|
||
|
||
# Ctrl+A (全选) — 仅当目标窗口仍在焦点
|
||
# 先检查发送锁:主线程可能正在 _paste_and_send,避免 Ctrl+C 覆盖其剪贴板
|
||
if self._sending_event.is_set():
|
||
logger.debug(f"[{platform}] 发送中,剪贴板读取跳过防竞争")
|
||
return []
|
||
VK_A = 0x41
|
||
if self._fg_is_target(hwnd):
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
|
||
ctypes.windll.user32.keybd_event(VK_A, 0, 0, 0)
|
||
time.sleep(0.05)
|
||
ctypes.windll.user32.keybd_event(VK_A, 0, 2, 0)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
time.sleep(0.15)
|
||
# Ctrl+C (复制)
|
||
VK_C = 0x43
|
||
if self._fg_is_target(hwnd):
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
|
||
ctypes.windll.user32.keybd_event(VK_C, 0, 0, 0)
|
||
time.sleep(0.05)
|
||
ctypes.windll.user32.keybd_event(VK_C, 0, 2, 0)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
time.sleep(0.3)
|
||
|
||
wc.OpenClipboard()
|
||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||
content = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||
if content:
|
||
raw_lines = [l.strip() for l in content.strip().split('\n') if l.strip()]
|
||
self._raw_clipboard[platform] = content
|
||
wc.CloseClipboard()
|
||
|
||
logger.debug(f"[{platform}] 剪贴板读取 → {len(raw_lines)} lines")
|
||
|
||
# ⭐ 剪贴板 hash:内容没变化 → 跳过(防止跨平台读到过时数据)
|
||
import hashlib
|
||
cur_h = hashlib.md5(content.encode('utf-8') if content else b'').hexdigest()
|
||
last_h = self._last_clipboard_hash.get(platform, '')
|
||
if cur_h == last_h:
|
||
logger.debug(f"[{platform}] 剪贴板内容未变,跳过 stale 数据")
|
||
return []
|
||
self._last_clipboard_hash[platform] = cur_h
|
||
|
||
# ── 过滤:只返回"像用户消息"的行 ──
|
||
sender_pattern = re.compile(r'^[\u4e00-\u9fff·A-Za-z0-9_\-]{1,10}$')
|
||
for line in raw_lines:
|
||
# 清理 QQ 消息末尾的状态标记(如 "O~" "C~" "0~O~"),避免污染 KB 匹配
|
||
line = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', line).strip()
|
||
if not line:
|
||
continue
|
||
# 跳过:系统消息、纯时间、纯数字、无意义短行
|
||
if self._is_system_message(line):
|
||
continue
|
||
# 跳过:控制台/Python 垃圾(剪贴板截错窗口内容)
|
||
if self._is_ocr_console_garbage(line):
|
||
continue
|
||
# 跳过:单行发送者名(1-10汉字/字母/数字/下划线)
|
||
if sender_pattern.match(line):
|
||
continue
|
||
# 跳过:纯时间格式(连字符/斜杠分格日期)
|
||
if re.match(r'^\d{1,2}[::\s]\d{2}', line):
|
||
continue
|
||
if re.match(r'^\d{4}[/-]\d{1,2}', line):
|
||
continue
|
||
# 跳过:纯 emoji / 纯标点(短于 6 个字符且只有符号)
|
||
clean = re.sub('[~!@#$%^&*()_+\\-=\\[\\]{};:\\\'",.<>/?·!@#¥%……&*()—+。,、;:""\'\'【】{}「」『』《》【】 \\t\\n\\r\\u2000-\\u206f\\u2190-\\u2bff\\U0001F000-\\U0001FFFF]', '', line)
|
||
if len(clean) == 0 and len(line) <= 6:
|
||
continue
|
||
# 跳过:游戏UI乱码(CJK+下划线英文、CJK+大写英文组合)
|
||
if re.search(r'[\u4e00-\u9fff]', line) and re.search(r'[A-Za-z]', line) and len(line) > 10:
|
||
if re.search(r'[a-zA-Z]+_[a-zA-Z]+', line):
|
||
logger.debug(f"[{platform}] 剪贴板过滤: 游戏UI乱码(下划线英文) line={line!r}")
|
||
continue
|
||
if re.search(r'[\u4e00-\u9fff][A-Z][a-z]{1,}', line):
|
||
cjk_count = len(re.findall(r'[\u4e00-\u9fff]', line))
|
||
alpha_count = len(re.findall(r'[A-Za-z]', line))
|
||
if alpha_count > 0 and cjk_count < alpha_count:
|
||
logger.debug(f"[{platform}] 剪贴板过滤: 游戏UI乱码(CJK+大写英文) line={line!r}")
|
||
continue
|
||
# 通过全部检查 → 保留
|
||
texts.append(line)
|
||
|
||
logger.debug(f"[{platform}] 剪贴板过滤后 → {len(texts)} 条用户消息")
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] 剪贴板失败: {e}")
|
||
finally:
|
||
if old is not None:
|
||
try:
|
||
wc.OpenClipboard()
|
||
wc.EmptyClipboard()
|
||
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
|
||
return texts
|
||
|
||
def get_raw_clipboard(self, platform: str) -> str:
|
||
"""获取最近一次剪贴板读取的原始文本。"""
|
||
return self._raw_clipboard.get(platform, "")
|
||
|
||
@staticmethod
|
||
def _parse_last_sender(raw_text: str) -> Optional[str]:
|
||
"""从剪贴板原始文本中提取最后一条消息的发送者。
|
||
|
||
TIM/QQ 剪贴板格式通常是:
|
||
[发送者]
|
||
[时间]
|
||
[消息]
|
||
(空行)
|
||
[发送者]
|
||
[时间]
|
||
[消息]
|
||
"""
|
||
if not raw_text:
|
||
return None
|
||
blocks = re.split(r'\n\s*\n', raw_text.strip())
|
||
if not blocks:
|
||
return None
|
||
last_block = blocks[-1].strip()
|
||
if not last_block:
|
||
return None
|
||
lines = [l.strip() for l in last_block.split('\n') if l.strip()]
|
||
if not lines:
|
||
return None
|
||
candidate = lines[0]
|
||
# 排除时间格式
|
||
if re.match(r'^\d{1,2}[::]\d{2}', candidate):
|
||
return None
|
||
if re.match(r'^\d{4}[/-]\d{1,2}', candidate):
|
||
return None
|
||
if len(candidate) > 30:
|
||
return None
|
||
return candidate
|
||
|
||
def _search_contact(self, platform: str, contact: str) -> bool:
|
||
"""在 TIM/QQ 搜索框中搜索联系人并切换到对应会话。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return False
|
||
try:
|
||
win = auto.ControlFromHandle(hwnd)
|
||
search = win.EditControl(Name="搜索", maxSearchSeconds=0.5)
|
||
if not search.Exists(maxSearchSeconds=0.3):
|
||
logger.warning(f"[{platform}] 找不到搜索框")
|
||
return False
|
||
|
||
# 恢复窗口并前置(失败则放弃搜索)
|
||
if win32gui.IsIconic(hwnd):
|
||
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
|
||
if not self._ensure_foreground(hwnd):
|
||
logger.warning(f"[{platform}] 无法前置窗口,放弃搜索联系人")
|
||
return False
|
||
time.sleep(0.2)
|
||
|
||
# SetFocus 到搜索框(不移动鼠标)
|
||
search.SetFocus()
|
||
time.sleep(0.2)
|
||
|
||
# 全选已有内容并输入联系人名
|
||
auto.SendKeys('{Ctrl}a')
|
||
time.sleep(0.1)
|
||
auto.SendKeys(contact)
|
||
time.sleep(0.5)
|
||
|
||
# Enter 打开第一个结果
|
||
auto.SendKeys('{Enter}')
|
||
time.sleep(0.5)
|
||
|
||
logger.info(f"[{platform}] 已搜索并切换到联系人: {contact}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"[{platform}] 搜索联系人失败: {e}")
|
||
return False
|
||
|
||
# ── SendInput 键盘模拟 ─────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _key_down(vk: int):
|
||
"""SendInput KEYBDINPUT keydown."""
|
||
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
|
||
|
||
@staticmethod
|
||
def _key_up(vk: int):
|
||
"""SendInput KEYBDINPUT keyup (KEYEVENTF_KEYUP=2)."""
|
||
ctypes.windll.user32.keybd_event(vk, 0, 2, 0)
|
||
|
||
@staticmethod
|
||
def _send_key_press(vk: int, delay: float = 0.05):
|
||
"""SendInput single key press (down + up)."""
|
||
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
|
||
time.sleep(delay)
|
||
ctypes.windll.user32.keybd_event(vk, 0, 2, 0)
|
||
|
||
@staticmethod
|
||
def _send_ctrl_combo(vk: int):
|
||
"""Send Ctrl+<key>: Ctrl down → key down → key up → Ctrl up."""
|
||
VK_CONTROL = 0x11
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 0, 0)
|
||
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
|
||
ctypes.windll.user32.keybd_event(vk, 0, 2, 0)
|
||
ctypes.windll.user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
|
||
@staticmethod
|
||
def _post_key(hwnd, vk, delay=0.05):
|
||
"""PostMessage 键盘到指定窗口(不全局,安全)。"""
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, vk, 0)
|
||
time.sleep(delay)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, vk, 0)
|
||
|
||
@staticmethod
|
||
def _post_ctrl_combo(hwnd, vk, delay=0.05):
|
||
"""PostMessage Ctrl+组合键到指定窗口(不泄露到控制台)。"""
|
||
VK_CONTROL = 0x11
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, VK_CONTROL, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYDOWN, vk, 0)
|
||
time.sleep(delay)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, vk, 0)
|
||
win32gui.PostMessage(hwnd, win32con.WM_KEYUP, VK_CONTROL, 0)
|
||
|
||
def _fg_is_target(self, hwnd) -> bool:
|
||
"""校验目标窗口仍在焦点,防止 keybd_event 泄露到控制台。"""
|
||
try:
|
||
fg = win32gui.GetForegroundWindow()
|
||
root = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
return fg == root or (fg and win32gui.IsChild(root, fg))
|
||
except Exception:
|
||
return False
|
||
|
||
@staticmethod
|
||
def _image_hash(im) -> str:
|
||
"""感知哈希:缩至 9x8 灰阶 → dHash(差值哈希,对局部变化敏感)。
|
||
|
||
dHash 比较每行相邻像素的梯度,比 aHash(均值哈希)更能捕捉文字变化。
|
||
9 列 × 8 行 = 72 位 hash,比 8×8=64 位 aHash 信息量更大。
|
||
"""
|
||
small = im.convert('L').resize((9, 8), Image.LANCZOS)
|
||
pixels = list(small.getdata())
|
||
# 每行 9 个像素,比较相邻像素:左 > 右 → 1,否则 → 0
|
||
bits = []
|
||
for row in range(8):
|
||
for col in range(8):
|
||
left = pixels[row * 9 + col]
|
||
right = pixels[row * 9 + col + 1]
|
||
bits.append('1' if left > right else '0')
|
||
return ''.join(bits)
|
||
|
||
@staticmethod
|
||
def _is_white_bubble_pixel(px, x, y, w, h) -> bool:
|
||
"""判定 (x,y) 的纯白像素是否属于"白色气泡"(对方消息),而非:
|
||
- 聊天浅灰背景(本身不是 >=245 候选)
|
||
- 彩色(绿/蓝)自身气泡上的白色文字(邻域为彩色底色)
|
||
规则:四方向(±4px)邻域中 >=2 个也为 near-white(>=240) 才算气泡。
|
||
⭐ 放宽条件:>=3→>=2,>=245→>=240,适配新版微信UI颜色变化
|
||
白字笔画的邻域多为彩色气泡底色 → 不通过 → 不被误判为对方消息。
|
||
"""
|
||
d = 4
|
||
hits = 0
|
||
for dx, dy in ((d, 0), (-d, 0), (0, d), (0, -d)):
|
||
nx, ny = x + dx, y + dy
|
||
if 0 <= nx < w and 0 <= ny < h:
|
||
try:
|
||
rr, gg, bb = px[nx, ny][:3]
|
||
if rr >= 240 and gg >= 240 and bb >= 240:
|
||
hits += 1
|
||
except Exception:
|
||
pass
|
||
return hits >= 1
|
||
|
||
|
||
@staticmethod
|
||
def _has_user_colors(im, platform: str) -> bool:
|
||
"""区域颜色检测:判断截图中是否有**对方发来的消息**,决定是否需要OCR。
|
||
|
||
⭐ 核心原则:自身消息 = 最顶层门禁,不过则全部取消。
|
||
- QQ: 用户白色气泡左对齐,bot/对方蓝色气泡右对齐
|
||
- WeChat: 对方白色气泡左对齐,自己绿色气泡右对齐
|
||
|
||
返回 True=有对方消息需要OCR,False=仅自身消息/无消息 → 跳过
|
||
"""
|
||
w, h = im.size
|
||
if w < 10 or h < 10:
|
||
return True
|
||
|
||
# 跳过顶部 20%(群名/大标题栏)
|
||
check = im.crop((0, int(h * 0.20), w, h))
|
||
cw, ch = check.size
|
||
if cw < 20 or ch < 20:
|
||
return True
|
||
|
||
# 左右半区
|
||
left_half = check.crop((0, 0, cw // 2, ch))
|
||
right_half = check.crop((cw // 2, 0, cw, ch))
|
||
|
||
step_x = max(2, cw // 30) # 增加采样密度,提高白色气泡检测灵敏度
|
||
step_y = max(2, ch // 30)
|
||
|
||
# 详细调试日志:记录截图信息和采样参数
|
||
rlogger = logging.getLogger("msgbot.monitor")
|
||
rlogger.debug(f"[{platform}] 像素检测开始: 原始尺寸={w}x{h}, 检测区域={cw}x{ch}, 采样步长=({step_x},{step_y})")
|
||
|
||
if platform == "qq":
|
||
# QQ: 蓝色 = bot/对方气泡(右对齐)
|
||
def is_bot(r, g, b):
|
||
return 110 <= r <= 190 and 170 <= g <= 235 and 220 <= b <= 255
|
||
else:
|
||
# WeChat: 绿色 = 自己发的消息(右对齐)— 不是 bot 色
|
||
def is_self(r, g, b):
|
||
return 135 <= r <= 180 and 205 <= g <= 250 and 75 <= b <= 125
|
||
is_bot = is_self
|
||
|
||
def analyze(img):
|
||
p = img.load()
|
||
bot = other = white = total = 0
|
||
iw, ih = img.width, img.height
|
||
for y in range(0, ih, step_y):
|
||
for x in range(0, iw, step_x):
|
||
total += 1
|
||
try:
|
||
r, g, b = p[x, y][:3]
|
||
except:
|
||
continue
|
||
if r >= 230 and g >= 230 and b >= 230:
|
||
# ⚠️ 纯白像素可能是:①对方白色气泡(背景/白字) ②聊天浅灰背景
|
||
# ③绿色/蓝色自身气泡上的白色文字。
|
||
# ②③ 不能算"对方消息"——否则微信自身绿气泡白字被误判为对方
|
||
# → OCR → 自回复循环。必须确认邻域也为大面积白色(气泡)才计入。
|
||
if MessageMonitor._is_white_bubble_pixel(p, x, y, iw, ih):
|
||
white += 1
|
||
elif is_bot(r, g, b):
|
||
bot += 1
|
||
else:
|
||
other += 1
|
||
return bot, other, white, total
|
||
|
||
left_bot, left_other, left_white, left_total = analyze(left_half)
|
||
right_bot, right_other, right_white, right_total = analyze(right_half)
|
||
|
||
# 详细调试日志:记录像素统计结果
|
||
rlogger.debug(f"[{platform}] 像素统计: 左半区(其他={left_other}/{left_total}, "
|
||
f"{'自身' if platform=='wechat' else 'bot'}={left_bot}, 白色={left_white})")
|
||
rlogger.debug(f"[{platform}] 像素统计: 右半区(其他={right_other}/{right_total}, "
|
||
f"{'自身' if platform=='wechat' else 'bot'}={right_bot}, 白色={right_white})")
|
||
|
||
# ====== 微信专用逻辑 ======
|
||
# 颜色语义:
|
||
# 白色(>225,>225,>225) = 对方消息(左对齐)
|
||
# 绿色(135-180,205-250,75-125) = 自己消息(右对齐)
|
||
# 其它 = 背景/UI元素
|
||
if platform == "wechat":
|
||
# ⭐ 自身消息门禁:右大量绿色(自身) + 左无白色(无对方) → 仅自身,跳过
|
||
if right_bot >= 3 and left_white < 2:
|
||
rlogger.debug(f"[wechat] 像素检测: 仅自身消息(右绿={right_bot},左白={left_white}), 跳过")
|
||
return False
|
||
|
||
# 左半区有白色气泡(>=2) → 有对方消息,必须OCR
|
||
if left_white >= 2:
|
||
rlogger.debug(f"[wechat] 像素检测: 有对方消息(左白={left_white}), 继续")
|
||
return True
|
||
|
||
# ⭐ 放宽兜底:左半区有1个白色像素也尝试OCR,防新版UI检测遗漏
|
||
# (旧逻辑:0个才跳过,可能误杀新UI浅色气泡)
|
||
if left_white == 1:
|
||
rlogger.debug(f"[wechat] 像素检测: 左白=1(边缘), 尝试OCR")
|
||
return True
|
||
|
||
# 左半区无白色气泡 → 无对方消息,跳过
|
||
# (包括:仅自身消息、空聊天、非聊天界面)
|
||
rlogger.debug(f"[wechat] 像素检测: 无对方白色气泡(左白={left_white}), 跳过")
|
||
return False
|
||
|
||
# ====== QQ/TIM 专用逻辑 ======
|
||
# 颜色语义:
|
||
# 白色(>225,>225,>225) = 用户消息(左对齐)
|
||
# 蓝色(110-190,170-235,220-255) = bot/对方消息(右对齐)
|
||
# 其它 = 背景/UI元素
|
||
# ⭐ 核心原则:只要左半区无白色气泡 → 无用户消息 → 跳过
|
||
|
||
# ⭐ 跨窗口污染检测:QQ窗口无绿色气泡,若检测到大量绿色像素,说明截到了微信窗口
|
||
def is_wechat_green(r, g, b):
|
||
return 135 <= r <= 180 and 205 <= g <= 250 and 75 <= b <= 125
|
||
left_green = right_green = 0
|
||
for y in range(0, ch, step_y):
|
||
for x in range(0, cw // 2, step_x):
|
||
try:
|
||
r, g, b = check.load()[x, y][:3]
|
||
if is_wechat_green(r, g, b):
|
||
left_green += 1
|
||
except:
|
||
continue
|
||
for y in range(0, ch, step_y):
|
||
for x in range(cw // 2, cw, step_x):
|
||
try:
|
||
r, g, b = check.load()[x, y][:3]
|
||
if is_wechat_green(r, g, b):
|
||
right_green += 1
|
||
except:
|
||
continue
|
||
total_green = left_green + right_green
|
||
|
||
# 详细调试日志:记录绿色像素检测结果
|
||
rlogger.debug(f"[{platform}] 绿色像素检测: 左绿={left_green}, 右绿={right_green}, 总绿={total_green}")
|
||
|
||
if total_green >= 3:
|
||
rlogger.debug(f"[qq] 像素检测: 检测到微信绿色像素(绿={total_green}), 判定为微信窗口, 跳过")
|
||
return False
|
||
|
||
# 左半区有白色气泡 → 有用户消息,必须OCR
|
||
if left_white >= 1:
|
||
rlogger.debug(f"[qq] 像素检测: 有用户消息(左白={left_white}), 继续")
|
||
return True
|
||
|
||
# 左半区无白色气泡 → 无用户消息,跳过
|
||
# (包括:仅bot回复、空聊天、非聊天界面)
|
||
rlogger.debug(f"[qq] 像素检测: 无用户白色气泡(左白={left_white}), 跳过")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _split_merged_ocr_text(text: str) -> list:
|
||
"""尝试将 OCR 单行合并文本在分隔符处拆分。
|
||
|
||
EasyOCR 常把横向相邻的两条消息(用户问句 + 群昵称/UI文字/其他人消息)
|
||
检测为同一行文本,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一"式合并。
|
||
|
||
⭐ 2026-08-13 修复:不再因为句号/问号就强制拆分。
|
||
语音输入或 casual typing 中,用户可能用"。"、"?"作为语气停顿,
|
||
如"PHP,的那个管理员密码"、"服务器。最近的公告信息?"
|
||
如果机械地在标点后拆分,会把同一条消息拆成多条碎片,
|
||
导致 bot 只回复其中一段,另一段被丢弃。
|
||
|
||
⭐ 2026-08-16 新增:识别 `=>` / `→` 箭头分隔符
|
||
Windows OCR 常把多条消息用箭头连接(如"问题?=>后续内容"),
|
||
箭头是清晰的消息边界,必须拆分。
|
||
|
||
启发式规则:
|
||
1. 优先在 `=>` / `→` 处拆分(最可靠的边界)
|
||
2. 仅在检测到【新昵称/头衔】出现在标点之后时才拆分
|
||
(如"问个问题。草原探险服务器备用号@游戏专用那服务器...")
|
||
3. 如果标点后的文本是续接内容(以"那/这/我/你"等代词开头),不拆分
|
||
4. 最多拆 2 次(3段),防止过度拆分
|
||
"""
|
||
if len(text) < 10:
|
||
return [text]
|
||
|
||
_continuations = {'但', '而', '并', '且', '或', '还', '也', '又',
|
||
'就', '才', '再', '变', '便', '则', '却', '虽',
|
||
'因', '为', '与', '和', '同', '跟',
|
||
# ⭐ 新增:这些代词开头通常是续接,不应拆分
|
||
'那', '这', '我', '你', '他', '她', '它',
|
||
'谁', '什么', '怎么', '如何', '哪', '哪',
|
||
# ⭐ 新增:时间/程度副词开头,通常是续接(如"最近"、"刚才")
|
||
'最', '刚', '还', '又', '也', '便', '即', '则'}
|
||
|
||
# ⭐ 新增:昵称模式,用于判断是否应该拆分
|
||
# 检测[头衔]昵称或@昵称的模式
|
||
_nick_pattern = re.compile(
|
||
r'(?:[\[【][^】\]]+[】\]]\s*|[①②③④⑤⑥⑦⑧⑨⑩]?\s*)'
|
||
r'([A-Za-z0-9_\u4e00-\u9fff]{2,20})'
|
||
r'\s*[@@]?\s*'
|
||
)
|
||
|
||
# ⭐ 2026-08-16: 优先处理箭头分隔符(最可靠的消息边界)
|
||
# 匹配 => 或 → 及其周围的可选空格
|
||
arrow_sep = re.compile(r'\s*(?:=>|→)\s*')
|
||
if arrow_sep.search(text):
|
||
parts = arrow_sep.split(text)
|
||
# 过滤空段和过短的段
|
||
valid_parts = [p.strip() for p in parts if len(p.strip()) >= 3]
|
||
if len(valid_parts) > 1:
|
||
logger.debug(f"[ocr] 箭头分隔拆分: {[p[:40] for p in valid_parts]}")
|
||
return valid_parts
|
||
|
||
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
|
||
|
||
# ⭐ 检查第二段是否以【新昵称/头衔】开头
|
||
# 如果是,说明是新消息开始,可以拆分
|
||
# 如果不是,说明是续接内容,不拆分
|
||
has_nick = bool(_nick_pattern.match(second.strip()))
|
||
if not has_nick:
|
||
# 没有检测到新昵称,且不是续接词开头,检查是否是普通对话续接
|
||
# 如果第二段长度<30且包含疑问词,可能是同一条消息的续问,不拆分
|
||
if len(second) < 50 and any(qw in second for qw in ['?', '?', '吗', '呢', '多少', '怎么', '如何']):
|
||
break
|
||
|
||
# 通过所有检查,执行拆分
|
||
result.append(first)
|
||
remaining = second
|
||
max_splits -= 1
|
||
|
||
result.append(remaining)
|
||
if len(result) > 1:
|
||
logger.debug(f"[ocr] 句尾标点拆分: {[r[:60] for r in result]}")
|
||
return result
|
||
|
||
def _norm_frag(self, s: str) -> str:
|
||
"""bot 回复片段比对用的归一化:去空格、去中英文标点,抵抗 OCR 噪声。"""
|
||
s = re.sub(r'\s+', '', s)
|
||
for a in (',', '。', '、', ';', ':', '!', '?', ',', '.', ';', ':', '!', '?',
|
||
' ', ' ', '~', '~'):
|
||
s = s.replace(a, '')
|
||
return s
|
||
|
||
def _strip_bot_reply_fragments(self, text: str) -> str:
|
||
"""从 OCR 识别出的单条消息中剥离 bot 自己的旧回复片段,保留用户真实提问。
|
||
|
||
与 _poll 中的「反向包含剥离」逻辑保持一致,但此处用于在 _process_ocr_output
|
||
的 filter4(回复残片)判定【之前】清理消息,避免「用户新消息 + bot 旧回复」
|
||
被 OCR 拼成一行后,filter4 因检测到 bot 旧回复文本而把整段(含用户新消息)
|
||
误判为残片整体丢弃。
|
||
|
||
两种策略:
|
||
1. 精确子串:某条 bot 旧回复 r 完整出现在 text 中 → 直接删除 r
|
||
2. 反向包含:OCR 把「用户新消息 + bot 旧回复」按逗号拼成一行,且 r 不是
|
||
text 的子串,但 text 中逗号分隔的片段可能是 r 的子串 → 逐片段判断,
|
||
剥离属于 bot 回复的片段,保留用户片段。
|
||
|
||
⭐ OCR 噪声鲁棒性:bot 旧回复被 OCR 重新识别时,标点(,。)、全/半角、空格
|
||
常与原文不一致(如原文"Minecraft 生存服务器,拥有完善的领地系统"被识别成
|
||
"Minecraft生存服务器。拥有完善的领地系统"),导致精确子串匹配失效。
|
||
比较前对双方做归一化(去空格、去标点),让被 OCR 扭曲的 bot 回复片段也能被识别并剥离。
|
||
"""
|
||
if not text or not text.strip():
|
||
return text
|
||
replied = self._replied_texts
|
||
if not replied:
|
||
return text
|
||
ts = text
|
||
if len(ts) > 5:
|
||
stripped_bot = False
|
||
# 策略1:精确子串(归一化后比对,抵抗 OCR 标点/空格噪声)
|
||
for r in replied:
|
||
if len(r) >= 6 and self._norm_frag(r) and self._norm_frag(r) in self._norm_frag(ts):
|
||
ts = re.sub(re.escape(r), ' ', ts)
|
||
stripped_bot = True
|
||
# 策略2:反向包含剥离——逗号分段后逐段判断是否属于 bot 回复
|
||
_has_comma = ',' in ts or (',' in ts and re.search(r'[\u4e00-\u9fff],[\u4e00-\u9fff]', ts))
|
||
if not stripped_bot and _has_comma:
|
||
parts = re.split(r'[,,]', ts)
|
||
parts = [p.strip() for p in parts if p.strip()]
|
||
if len(parts) >= 2:
|
||
user_parts = []
|
||
for part in parts:
|
||
if len(part) < 2:
|
||
continue
|
||
is_bot_frag = False
|
||
npart = self._norm_frag(part)
|
||
if npart:
|
||
for r in replied:
|
||
nr = self._norm_frag(r)
|
||
if len(nr) >= 2 and npart in nr:
|
||
is_bot_frag = True
|
||
break
|
||
if not is_bot_frag:
|
||
user_parts.append(part)
|
||
if len(user_parts) < len(parts):
|
||
ts = ','.join(user_parts).strip(',').strip()
|
||
stripped_bot = True
|
||
if stripped_bot:
|
||
ts = re.sub(r'\s+', ' ', ts).strip().strip(',').strip()
|
||
return ts
|
||
|
||
def _strip_qq_nickname(self, text: str) -> str:
|
||
"""从消息文本中剥离QQ用户昵称,防止昵称拼凑进消息文本导致查库关键词被压住。
|
||
|
||
QQ群聊截图中,OCR会把用户昵称和消息文本拼在一起,如:
|
||
"草原探险服务器备用号游戏专用在哪下载登陆插件"
|
||
其中"草原探险服务器备用号"和"游戏专用"是用户昵称,"在哪下载登陆插件"是实际消息。
|
||
|
||
策略(混合模式):
|
||
1. 前缀匹配(优先):处理常见的粘连格式("昵称+消息"无分隔符)
|
||
2. 全局匹配(兜底):处理昵称隐藏在文本中间的情况
|
||
3. 分隔符清理:移除昵称后残留的标点/空格
|
||
"""
|
||
if not text or not text.strip():
|
||
return text
|
||
ts = text.strip()
|
||
logger.debug(f"[qq] _strip_qq_nickname输入: {ts[:80]!r}")
|
||
|
||
# 加载配置中的昵称列表
|
||
try:
|
||
from config import load_config
|
||
cfg = load_config()
|
||
nicknames = cfg.get('qq_nicknames', [])
|
||
except Exception:
|
||
nicknames = []
|
||
if not nicknames:
|
||
nicknames = [
|
||
"草原探险服务器备用号",
|
||
"游戏专用",
|
||
]
|
||
|
||
qq_title_pattern = re.compile(r'^[\[【][^】\]]+[】\]]\s*(?:[A-Za-z0-9_\u4e00-\u9fff]{2,30})[,,]?\s*')
|
||
_rank_pattern = re.compile(r'^[((][^))]+[))]\s*')
|
||
# ⭐ Task #1508: 前导垃圾不再吞噬句末标点 !!?? —— OCR 剥离@昵称后残留的
|
||
# "?" 是正文的一部分,吞掉会整条消息变空(见"游戏专用PHP有哪两种登录模式?"变'')。
|
||
# 只清空格/逗号/句号/冒号/分号等纯分隔符。
|
||
_leading_junk = re.compile(r'^[\s,,.。::;;]+')
|
||
_at_user_pattern = re.compile(r'^@([A-Za-z0-9_\u4e00-\u9fff]{2,30})')
|
||
|
||
# ⭐ 主循环:每轮按优先级尝试剥离一层,直到无可剥离
|
||
changed = True
|
||
max_iterations = 20 # 防止无限循环
|
||
iteration = 0
|
||
while changed and iteration < max_iterations:
|
||
changed = False
|
||
iteration += 1
|
||
# 先清理前导标点/空格(昵称后可能残留逗号、分隔符等)
|
||
ts = _leading_junk.sub('', ts)
|
||
if not ts:
|
||
break
|
||
|
||
# 1) 箭头前缀
|
||
if ts.startswith('//') or ts.startswith('=>'):
|
||
ts = ts[2:].lstrip()
|
||
changed = True
|
||
continue # 重新从头检查
|
||
|
||
# 2) QQ群头衔格式: 【头衔】用户名 或 [头衔] 用户名 或 (头衔)用户名
|
||
m = qq_title_pattern.match(ts)
|
||
if m:
|
||
ts = ts[m.end():].lstrip()
|
||
changed = True
|
||
continue
|
||
|
||
# 2b) QQ排名/头衔标记: (旅馆老板) —— 紧跟在用户名后的括号内容
|
||
m_rank = _rank_pattern.match(ts)
|
||
if m_rank:
|
||
ts = ts[m_rank.end():].lstrip()
|
||
changed = True
|
||
continue
|
||
|
||
# 3) @提及格式: @username 或 @昵称 —— QQ的@是UI元素,不是消息内容
|
||
if ts.startswith('@'):
|
||
matched = False
|
||
# 先尝试已知昵称
|
||
for nick in nicknames:
|
||
if ts[1:].startswith(nick):
|
||
ts = ts[1 + len(nick):].lstrip()
|
||
matched = True
|
||
changed = True
|
||
break
|
||
if not matched:
|
||
# 用正则匹配任意用户名(中英文混合,2-30字符)
|
||
m_at = _at_user_pattern.match(ts)
|
||
if m_at:
|
||
username = m_at.group(1)
|
||
rest = ts[m_at.end():]
|
||
ts = rest.lstrip()
|
||
# ⭐ Task #1508: 自动入库前校验,防「昵称+正文」整段被当昵称入库。
|
||
# 典型污染例子:"@游戏专用PHP有哪两种登录模式?" → 正则贪婪吞掉
|
||
# "游戏专用PHP有哪两种登录模式" 整段入库,导致后续匹配时正确正文
|
||
# 被脏昵称先命中剥掉、残留"?"再被前导垃圾清理吞掉 → 消息变空。
|
||
# 仅当 ① 匹配后不紧贴正文(rest 以分隔符/标点/结尾开头)
|
||
# ② 长度≤12(QQ昵称常见上限,超出多半是昵称+正文拼凑)才入库。
|
||
rest_head = rest[:1] if rest else ''
|
||
is_clean_at = (len(username) <= 12) and (not rest_head or rest_head in ' \t,,。.!!??;;::\n')
|
||
if is_clean_at and username not in nicknames:
|
||
nicknames.append(username)
|
||
try:
|
||
from config import load_config, save_config
|
||
cfg = load_config()
|
||
cfg.setdefault('qq_nicknames', []).append(username)
|
||
save_config(cfg)
|
||
logger.info(f"[qq] 自动入库@提及昵称: {username}")
|
||
except Exception as e:
|
||
logger.warning(f"[qq] 自动入库昵称失败: {e}")
|
||
elif username not in nicknames:
|
||
logger.debug(f"[qq] 跳过自动入库(疑似昵称+正文拼凑): {username!r}, rest={rest_head!r}")
|
||
else:
|
||
# 无法识别用户名,只剥离@符号
|
||
ts = ts[1:].lstrip()
|
||
changed = True
|
||
if changed:
|
||
continue
|
||
|
||
# 4) 裸昵称列表 - 前缀匹配
|
||
for nick in nicknames:
|
||
if ts.startswith(nick):
|
||
ts = ts[len(nick):].lstrip()
|
||
changed = True
|
||
break
|
||
|
||
# ⭐ 5) 全局匹配(兜底):昵称可能隐藏在文本中间
|
||
# 例:"=>草原探险服务器备用号游戏专用有问题" → 前缀匹配后剩"游戏专用有问题"
|
||
# 但有时前缀匹配失败,需要全局扫描
|
||
if not changed: # 前缀匹配无变化时才进行全局匹配
|
||
for nick in nicknames:
|
||
# 使用正则进行全局匹配,需要前后边界检查
|
||
# 避免误删正文中的正常文本(如"服务器备用"不应被误删)
|
||
pattern = re.compile(rf'(?:^|[^\w\u4e00-\u9fff]){re.escape(nick)}(?:$|[^а-яА-Яa-zA-Z0-9\u4e00-\u9fff])', re.UNICODE)
|
||
m_global = pattern.search(ts)
|
||
if m_global:
|
||
# 计算实际匹配范围(去除边界字符)
|
||
match_start = m_global.start()
|
||
match_end = m_global.end()
|
||
# 跳过开头的非单词字符
|
||
while match_start < len(ts) and not ts[match_start].isalnum() and ts[match_start] not in '\u4e00-\u9fff':
|
||
match_start += 1
|
||
# 跳过结尾的非单词字符
|
||
while match_end > match_start and not ts[match_end-1].isalnum() and ts[match_end-1] not in '\u4e00-\u9fff':
|
||
match_end -= 1
|
||
|
||
if match_start < match_end and match_start > 0: # 不在开头(已处理过)
|
||
before = ts[:match_start]
|
||
after = ts[match_end:]
|
||
ts = (before + after).lstrip()
|
||
logger.debug(f"[qq] 全局匹配剥除昵称 {nick!r}: {m_global.group()!r}")
|
||
changed = True
|
||
break
|
||
|
||
# ⭐ 群聊多消息拆分:OCR多条消息拼接时,[头衔]用户名 或 裸昵称 作为分隔符出现在文本中间
|
||
# 如 "C4炸弹起来准备测试了,[专业皮肤定制] C4炸弹,[专业皮肤定制] C4炸弹,youpaishidifu关于我们"
|
||
# 策略:找到所有QQ昵称分隔符位置,取最后一个分隔符之后的文本(最新用户消息)
|
||
|
||
# 模式1: [头衔]用户名 后跟逗号/冒号/@(QQ群头衔格式)
|
||
_mid_qq_title = re.compile(r'[\[【][^】\]]+[】\]]\s*(?:[A-Za-z0-9_\u4e00-\u9fff]{2,30})\s*[,,::@]')
|
||
# 模式2: 已知裸昵称 后跟逗号/冒号/@(无头衔格式)
|
||
# ⭐ 2026-08-13 扩展:增加@匹配,支持"昵称@其他用户"式拼接
|
||
_mid_bare_nick = None
|
||
if nicknames:
|
||
# 构造已知昵称的转义正则
|
||
_escaped = [re.escape(n) for n in nicknames]
|
||
_mid_bare_nick = re.compile(r'(?:' + '|'.join(_escaped) + r')\s*[,,::@]')
|
||
|
||
# 找到最后一个分隔符位置,截取其后的文本(最新消息)
|
||
last_sep_end = -1
|
||
|
||
# 查找模式1(QQ头衔格式)的最后一个匹配
|
||
for m in _mid_qq_title.finditer(ts):
|
||
last_sep_end = m.end()
|
||
|
||
# 查找模式2(裸昵称)的最后一个匹配(需排除开头的昵称)
|
||
if _mid_bare_nick:
|
||
for m in _mid_bare_nick.finditer(ts):
|
||
# 跳过开头的昵称(已由上面的主循环处理)
|
||
if m.start() > 0:
|
||
last_sep_end = m.end()
|
||
|
||
if last_sep_end > 0:
|
||
ts = ts[last_sep_end:].lstrip()
|
||
# 清理残留的前导标点/空格
|
||
ts = _leading_junk.sub('', ts)
|
||
logger.info(f"[qq] 群聊拆分: 取最后分隔符后的文本,长度={len(ts)}")
|
||
# ⭐ 2026-08-13 修复:mid-text 剥离后,结果可能以另一个昵称开头
|
||
# 如 "草原探险服务器备用号@游戏专用那我问你个事儿" → 剥离"草原探险服务器备用号@"
|
||
# 后得到 "游戏专用那我问你个事儿","游戏专用" 仍是已知昵称,需继续剥离
|
||
while True:
|
||
_leading_junk.sub('', ts)
|
||
if not ts:
|
||
break
|
||
# 尝试剥开头裸昵称
|
||
_stripped = False
|
||
for nick in nicknames:
|
||
if ts.startswith(nick):
|
||
ts = ts[len(nick):].lstrip()
|
||
_stripped = True
|
||
break
|
||
# 尝试剥 @昵称
|
||
m_at = _at_user_pattern.match(ts)
|
||
if m_at:
|
||
ts = ts[m_at.end():].lstrip()
|
||
_stripped = True
|
||
if not _stripped:
|
||
break
|
||
|
||
logger.debug(f"[qq] _strip_qq_nickname输出: {ts.strip()[:80]!r}")
|
||
# ⭐ Task #1508 防御兜底:剥离后为空但输入非空 → 回退原文
|
||
# (宁可保留昵称在消息里,也不能把整条消息吞成空串)。
|
||
if not ts.strip() and text.strip():
|
||
logger.warning(f"[qq] 剥离后为空,回退原文防消息丢失: {text.strip()[:80]!r}")
|
||
return text.strip()
|
||
return ts.strip()
|
||
|
||
def _sanitize_message(self, text: str) -> str:
|
||
"""消息入队前的全身检查:剥离昵称和联系方式,防止误触回复。
|
||
|
||
从消息中移除:
|
||
1. config.json中已注册的所有QQ昵称(无论出现在何处)
|
||
2. 联系方式:Email、QQ号、手机号、固话、400电话、急救电话等
|
||
|
||
确保敏感信息不进入知识库搜索和回复流程。
|
||
"""
|
||
if not text or not text.strip():
|
||
return text
|
||
|
||
# 加载昵称列表
|
||
try:
|
||
from config import load_config
|
||
cfg = load_config()
|
||
nicks = cfg.get('qq_nicknames', [])
|
||
except Exception:
|
||
nicks = []
|
||
if not nicks:
|
||
nicks = ["草原探险服务器备用号", "游戏专用"]
|
||
|
||
result = text
|
||
removed_items = []
|
||
|
||
# 1. 移除已注册的昵称(全局,不分位置)
|
||
for nick in nicks:
|
||
if nick in result:
|
||
result = result.replace(nick, '')
|
||
removed_items.append(f"昵称:{nick}")
|
||
|
||
# 2. 移除联系方式
|
||
# Email: 匹配 xxx@xxx.xxx 格式
|
||
email_pattern = re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}')
|
||
emails = email_pattern.findall(result)
|
||
if emails:
|
||
for email in emails:
|
||
result = result.replace(email, '')
|
||
removed_items.append(f"邮箱:{email}")
|
||
|
||
# QQ号: 匹配纯数字,长度5-12位(常见QQ号范围)
|
||
# 注意:需要避免匹配时间戳(如16:28:40)
|
||
qq_pattern = re.compile(r'(?<![.\d:/@])\b(\d{5,12})\b(?![.\d:/@])')
|
||
qq_numbers = qq_pattern.findall(result)
|
||
if qq_numbers:
|
||
for qq in qq_numbers:
|
||
result = result.replace(qq, '', 1) # 只替换第一个匹配
|
||
removed_items.append(f"QQ号:{qq}")
|
||
|
||
# 手机号: 匹配11位手机号(1开头,13-19中间)
|
||
phone_pattern = re.compile(r'(?<![.\d:/@])\b(1[3-9]\d{9})\b(?![.\d:/@])')
|
||
phones = phone_pattern.findall(result)
|
||
if phones:
|
||
for phone in phones:
|
||
result = result.replace(phone, '', 1)
|
||
removed_items.append(f"手机号:{phone}")
|
||
|
||
# 固话: 匹配以0开头的区号+号码(如010-12345678、07311234567)
|
||
# 格式:0\d{2,3}[-\s]?\d{6,8} 或 0\d{2,3}\d{6,8}
|
||
landline_pattern = re.compile(r'(?<![.\d:/@])\b(0\d{2,3}[-\s]?\d{6,8})\b(?![.\d:/@])')
|
||
landlines = landline_pattern.findall(result)
|
||
if landlines:
|
||
for landline in landlines:
|
||
result = result.replace(landline, '', 1)
|
||
removed_items.append(f"固话:{landline}")
|
||
|
||
# 400电话: 匹配以400开头的7-8位数字
|
||
# 格式:400-\d{7} 或 400-\d{8} 或 400\d{7} 或 400\d{8}
|
||
phone400_pattern = re.compile(r'(?<![.\d:/@])\b(400[-\s]?\d{7,8})\b(?![.\d:/@])')
|
||
phone400s = phone400_pattern.findall(result)
|
||
if phone400s:
|
||
for p in phone400s:
|
||
result = result.replace(p, '', 1)
|
||
removed_items.append(f"400电话:{p}")
|
||
|
||
# 急救电话: 110、119、120、122
|
||
emergency_phones = ['110', '119', '120', '122']
|
||
for emp in emergency_phones:
|
||
if emp in result:
|
||
result = result.replace(emp, '', 1)
|
||
removed_items.append(f"急救电话:{emp}")
|
||
|
||
# 清理多余的空格和标点
|
||
result = re.sub(r'\s+', ' ', result).strip()
|
||
result = re.sub(r'[,,。::;;]+\s*[,,。::;;]+', '', result).strip()
|
||
|
||
if removed_items:
|
||
logger.info(f"[qq] 消息清理: 移除{removed_items}, 原文={text[:60]!r}, 清理后={result[:60]!r}")
|
||
|
||
return result.strip() if result.strip() else text
|
||
|
||
# —— 2026-08-16: 语义指纹去重 ——
|
||
_SEMANTIC_DUP_TTL = 30.0 # 同一指纹30秒内视为重复
|
||
_SEMANTIC_STOPWORDS = frozenset((
|
||
'那', '那个', '这个', '这个', '这些', '那些', '那么',
|
||
'问', '问一下', '问一嘴', '打听一下',
|
||
'啊', '呢', '吧', '吗', '哈', '哈哈', '嘿嘿', '呵',
|
||
'能', '可以', '能够', '在', '该', '得', '要', '想',
|
||
'哪', '哪里', '哪儿', '何处',
|
||
'到', '个', '的', '了', '是', '不', '还', '也',
|
||
))
|
||
_SEMANTIC_TRIM_PUNCT = frozenset(',。、;:!?,.!?;:\\s')
|
||
|
||
def _extract_semantic_fingerprint(self, text: str) -> str:
|
||
"""提取中文核心词指纹:保留CJK字符,剔除停用词和语气词,
|
||
按 bigram 双字组合(出现频次)排序后拼接。
|
||
|
||
比单字排序更稳定:即使用户消息有少量冗余字,bigram 核心组合仍相同。
|
||
例:"登录插件在哪下载" 与 "问一嘴那个登录插件应该在哪儿去下载"
|
||
的共同 bigram: {'登录', '录插', '插件', '在哪', '下载'} → 指纹一致。
|
||
"""
|
||
cjk = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
|
||
# 去掉末尾标点
|
||
while cjk and cjk[-1] in self._SEMANTIC_TRIM_PUNCT:
|
||
cjk = cjk[:-1]
|
||
# 剔除停用词单字(避免"那""在""个"等干扰 bigram)
|
||
filtered = ''.join(c for c in cjk if c not in self._SEMANTIC_STOPWORDS)
|
||
if len(filtered) < 3:
|
||
return ''
|
||
# 提取 bigram 并统计频次
|
||
from collections import Counter
|
||
bg_counter = Counter()
|
||
for i in range(len(filtered) - 1):
|
||
bg_counter[filtered[i:i+2]] += 1
|
||
# 按 bigram 字典序排序,频次高的重复出现 → 指纹唯一标识语义
|
||
fp_parts = []
|
||
for bg, count in sorted(bg_counter.items()):
|
||
fp_parts.append(bg * count)
|
||
return ''.join(fp_parts)
|
||
|
||
def _semantic_similarity(self, a: str, b: str) -> float:
|
||
"""计算两条文本的语义相似度(Jaccard on CJK bigrams after stopword removal)。"""
|
||
def bigram_set(text):
|
||
cjk = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
|
||
while cjk and cjk[-1] in self._SEMANTIC_TRIM_PUNCT:
|
||
cjk = cjk[:-1]
|
||
filtered = ''.join(c for c in cjk if c not in self._SEMANTIC_STOPWORDS)
|
||
return set(filtered[i:i+2] for i in range(len(filtered) - 1))
|
||
sa, sb = bigram_set(a), bigram_set(b)
|
||
if not sa or not sb:
|
||
return 0.0
|
||
intersection = len(sa & sb)
|
||
union = len(sa | sb)
|
||
return intersection / union if union else 0.0
|
||
|
||
def _dedup_semantic_fingerprint(self, texts: list, platform: str) -> list:
|
||
"""同屏OCR碎片语义去重:30秒内语义相似度>=阈值只保留第一条。
|
||
|
||
典型场景:一条用户消息被OCR拆成多条文本变体(如"问一嘴...下载"
|
||
vs "在哪能下载" vs "能不能下载"),因文本差异未被seen/_replied_texts
|
||
拦截,导致同一意图触发多次回复。
|
||
|
||
方案:逐对计算 Jaccard 相似度(CJK bigram 去停用词),
|
||
若与已保留消息相似度 >= _SEMANTIC_SIM_THRESHOLD,则视为重复跳过。
|
||
"""
|
||
if len(texts) <= 1:
|
||
return texts
|
||
now = time.time()
|
||
# 清理过期条目
|
||
kept = []
|
||
for t, ts in getattr(self, '_semantic_fingerprint_cache', []):
|
||
if now - ts <= self._SEMANTIC_DUP_TTL:
|
||
kept.append((t, ts))
|
||
self._semantic_fingerprint_cache = kept
|
||
|
||
result = []
|
||
SIM_THRESHOLD = 0.3 # Jaccard相似度阈值:>=0.3视为语义重复
|
||
for text in texts:
|
||
is_dup = False
|
||
for kept_text in result:
|
||
sim = self._semantic_similarity(text, kept_text)
|
||
if sim >= SIM_THRESHOLD:
|
||
logger.debug(
|
||
f"[{platform}] OCR语义去重: 跳过相似({sim:.0%}) "
|
||
f"'{text[:40]!r}' ~ '{kept_text[:40]!r}'"
|
||
)
|
||
is_dup = True
|
||
break
|
||
if not is_dup:
|
||
result.append(text)
|
||
self._semantic_fingerprint_cache.append((text, now))
|
||
return result
|
||
|
||
def _split_by_nick_sep(self, text: str) -> list:
|
||
"""将OCR合并的多条消息按昵称分隔符拆分,提取所有消息段。
|
||
|
||
当OCR把多条消息合并成一行时(如"公告信息呀@游戏专用,草原探险服务器备用号,嘿啊"),
|
||
_strip_qq_nickname只保留最后一个分隔符后的内容,导致前面的消息被丢弃。
|
||
|
||
此方法尝试提取所有独立的消息段,而不是只取最后一段。
|
||
"""
|
||
result = []
|
||
# 找到所有分隔符位置(昵称+标点/@)
|
||
separators = []
|
||
|
||
# 加载昵称列表
|
||
try:
|
||
from config import load_config
|
||
cfg = load_config()
|
||
nicks = cfg.get('qq_nicknames', [])
|
||
except Exception:
|
||
nicks = []
|
||
if not nicks:
|
||
nicks = ["草原探险服务器备用号", "游戏专用"]
|
||
|
||
# 匹配模式:[头衔]昵称 或 裸昵称 后跟标点/@
|
||
for nick in nicks:
|
||
escaped = re.escape(nick)
|
||
# 头衔格式:【头衔】昵称 或 [头衔] 昵称
|
||
separators.extend([(m.start(), m.end()) for m in re.finditer(rf'[\[【][^】\]]+[】\]]\s*{escaped}\s*[,,::@]', text)])
|
||
# 裸昵称格式:昵称 后跟标点/@
|
||
separators.extend([(m.start(), m.end()) for m in re.finditer(rf'{escaped}\s*[,,::@]', text)])
|
||
# @提及格式
|
||
separators.extend([(m.start(), m.end()) for m in re.finditer(rf'@{escaped}', text)])
|
||
|
||
if not separators:
|
||
return [text.strip()] if text.strip() else []
|
||
|
||
# 按位置排序,去重
|
||
separators.sort(key=lambda x: x[0])
|
||
merged = [separators[0]]
|
||
for s, e in separators[1:]:
|
||
if s >= merged[-1][1]:
|
||
merged.append((s, e))
|
||
else:
|
||
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
|
||
|
||
# 提取分隔符之间的文本段
|
||
prev_end = 0
|
||
for s, e in merged:
|
||
segment = text[prev_end:e].strip()
|
||
if segment:
|
||
# 清理前导标点/空格
|
||
segment = re.sub(r'^[\s,,.。::;;]+', '', segment)
|
||
if segment:
|
||
result.append(segment)
|
||
prev_end = e
|
||
|
||
# 处理最后一段(最后一个分隔符之后的内容)
|
||
if prev_end < len(text):
|
||
segment = text[prev_end:].strip()
|
||
if segment:
|
||
segment = re.sub(r'^[\s,,.。::;;]+', '', segment)
|
||
if segment:
|
||
result.append(segment)
|
||
|
||
# 清理每个段落的开头昵称残留
|
||
cleaned = []
|
||
for seg in result:
|
||
ts = seg
|
||
# 循环剥开头昵称
|
||
for _ in range(5): # 最多剥5层
|
||
changed = False
|
||
for nick in nicks:
|
||
if ts.startswith(nick):
|
||
ts = ts[len(nick):].lstrip()
|
||
changed = True
|
||
break
|
||
if '@' in ts and ts.startswith('@'):
|
||
m = re.match(r'^@([A-Za-z0-9_\u4e00-\u9fff]{2,30})', ts)
|
||
if m:
|
||
ts = ts[m.end():].lstrip()
|
||
changed = True
|
||
if not changed:
|
||
break
|
||
ts = re.sub(r'^[\s,,.。::;;]+', '', ts)
|
||
if ts and len(ts) >= 3: # 只保留有意义的段(至少3字符)
|
||
cleaned.append(ts)
|
||
|
||
if len(cleaned) > 1:
|
||
_segments = ', '.join(s[:40] for s in cleaned)
|
||
logger.info(f"[qq] OCR合并消息拆分: {len(cleaned)}段 -> {_segments}")
|
||
elif cleaned:
|
||
logger.debug(f"[qq] OCR合并消息仅保留最后一段: {cleaned[0][:60]!r}")
|
||
|
||
return cleaned
|
||
|
||
def _is_reply_fragment(self, text: str) -> bool:
|
||
"""检查文本是否为 bot 回复内容的碎片(OCR 截取的 md 残片/跨行碎片)。
|
||
|
||
⭐ 判定稳健性(防误杀用户新消息):仅当文本「大部分」是 bot 旧回复时才判为残片,
|
||
要求公共子串:长度≥15,或(长度≥8 且 占较短文本≥60%)。这样既能拦住
|
||
「bot 回复回声」(整段≈bot 回复,公共子串占文本大头),又不会因用户消息中恰好
|
||
含有 bot 回复里的词(如服务器名「草原探险服务器」7字、专有名词「Minecraft」)
|
||
而被整体误杀——用户问句只与 bot 回复共享一个短语,远不到 60%。
|
||
"""
|
||
logger.debug(f"[ocr] 过滤器4: _is_reply_fragment 检查: {text[:80]!r}")
|
||
if not self._replied_texts:
|
||
logger.debug(f"[ocr] 过滤器4结果: 无已回复文本,返回False")
|
||
return False
|
||
if len(text) < 4:
|
||
logger.debug(f"[ocr] 过滤器4结果: 文本太短({len(text)}<4),返回False")
|
||
return False
|
||
import difflib
|
||
t_norm = self._norm_frag(text)
|
||
t_cjk = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
|
||
_matched = False
|
||
for r in self._replied_texts:
|
||
if len(r) < 5:
|
||
continue
|
||
# Latin/归一化匹配(要求占比较短文本≥60%,或足够长)
|
||
r_norm = self._norm_frag(r)
|
||
if t_norm and r_norm:
|
||
sm = difflib.SequenceMatcher(None, t_norm, r_norm)
|
||
lm = sm.find_longest_match(0, len(t_norm), 0, len(r_norm)).size
|
||
if lm >= 15 or (lm >= 8 and lm >= 0.6 * min(len(t_norm), len(r_norm))):
|
||
_matched = True
|
||
break
|
||
# CJK-only 匹配(同样要求占比较短文本≥60%,避免用户问句含服务器名被误杀)
|
||
r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff')
|
||
if len(t_cjk) >= 5 and len(r_cjk) >= 5:
|
||
sm = difflib.SequenceMatcher(None, t_cjk, r_cjk)
|
||
clm = sm.find_longest_match(0, len(t_cjk), 0, len(r_cjk)).size
|
||
if clm >= 15 or (clm >= 8 and clm >= 0.6 * min(len(t_cjk), len(r_cjk))):
|
||
_matched = True
|
||
break
|
||
if _matched:
|
||
# ⭐ 问句豁免(2026-08-11):与 3648 行逐行过滤逻辑一致——短问句与 bot 旧回复
|
||
# 共享长短语(如"登录插件下载地址是啥啊" vs 下载地址回复,共享8字占80%)
|
||
# 会命中 60% 标准,但这是用户提问不是 OCR 回声。line 是问句且非任何 replied
|
||
# 的完全子串 → 放行。真回声 text 整段≈回复仍拦。
|
||
_ask_words = ('怎么办', '为什么', '能不能', '怎么', '如何', '什么', '可以',
|
||
'帮忙', '是啥', '是啥啊', '在哪', '哪个', '哪里', '多少', '几', '吗', '呢')
|
||
_is_ask = any(w in text for w in _ask_words) or text.rstrip().endswith(('?', '?'))
|
||
_is_full_sub = any(text in r for r in self._replied_texts)
|
||
if _is_ask and not _is_full_sub:
|
||
logger.debug(f"[ocr] 过滤器4结果: 匹配成功但为问句且非完全子串,视为用户提问返回False")
|
||
return False
|
||
|
||
# ⭐ 用户名@提及豁免(2026-08-12):当公共子串主要是用户名/昵称(不含中文),
|
||
# 且用户消息的其余部分包含中文内容时,视为用户提问而非回复残片。
|
||
# 例:youpaishidifu关于我们 vs @youpaishidifu 你好 → 公共子串youpaishidifu是用户名,
|
||
# 剩余"关于我们"是用户实际提问,不应判为残片。
|
||
_has_cjk_in_text = len(t_cjk) >= 2 # 用户消息至少2个中文字符
|
||
if _has_cjk_in_text and not _is_full_sub:
|
||
# 检查公共子串是否主要是非中文(用户名/昵称)
|
||
# 找最长公共子串对应的r_norm片段
|
||
_pub_is_username = False
|
||
for r in self._replied_texts:
|
||
if len(r) < 5:
|
||
continue
|
||
r_norm = self._norm_frag(r)
|
||
if not r_norm:
|
||
continue
|
||
sm = difflib.SequenceMatcher(None, t_norm, r_norm)
|
||
lm = sm.find_longest_match(0, len(t_norm), 0, len(r_norm)).size
|
||
if lm >= 8:
|
||
# 提取公共子串在t_norm中的位置
|
||
match = sm.find_longest_match(0, len(t_norm), 0, len(r_norm))
|
||
pub_str = t_norm[match.a:match.a + match.size]
|
||
# 检查公共子串是否不含中文(即主要是用户名/昵称)
|
||
pub_cjk = sum(1 for c in pub_str if '\u4e00' <= c <= '\u9fff')
|
||
if pub_cjk == 0 and lm >= 8:
|
||
_pub_is_username = True
|
||
break
|
||
if _pub_is_username:
|
||
logger.debug(f"[ocr] 过滤器4结果: 匹配成功但公共子串为用户名且消息含中文,视为用户提问返回False")
|
||
return False
|
||
|
||
logger.debug(f"[ocr] 过滤器4结果: 文本匹配成功,返回True")
|
||
return True
|
||
logger.debug(f"[ocr] 过滤器4结果: 未匹配到已回复文本,返回False")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _looks_fresh_content(text: str) -> bool:
|
||
"""判断一段 OCR 文本是否含"新鲜消息"特征。
|
||
|
||
只放行明显是用户新发的查询/聊天消息,避免旧历史记录因为含问句被误放行。
|
||
规则严格:短消息 或 明确疑问/请求句(含疑问词+问号/怎么/如何等)。
|
||
"""
|
||
if not text or not text.strip():
|
||
return False
|
||
t = text.strip()
|
||
# 超短中文消息(如"好的"、"嗯")可能无时间戳
|
||
if len(t) <= 4 and re.search(r'[\u4e00-\u9fff]', t):
|
||
return True
|
||
# 明确疑问/请求:含疑问词 + 问号,允许中间隔主语/谓语(同一句内,≤30字)
|
||
# ⚠ 修复漏消息:旧正则用 \s* 只匹配空白不匹配汉字,导致"如何配置的?"、
|
||
# "在哪可以下载?"等正常疑问句(疑问词与问号间隔文字)匹配失败→整块被丢弃。
|
||
# 改为允许疑问词与问号间有 ≤30 个非句末标点字符(同句内)。
|
||
if re.search(r'(?:怎么办|为什么|能不能|怎么|如何|什么|哪些|哪个|吗|呢)[^。!?\n]{0,30}[??]', t):
|
||
return True
|
||
if re.search(r'^(?:怎么|如何|请问|请教|求助)', t):
|
||
return True
|
||
return False
|
||
|
||
def _is_question_message(self, text: str) -> bool:
|
||
"""判断一段文本是否为"明确提问",用于 stage 远古历史过滤的提问豁免。
|
||
|
||
仅当文本以问号(?/?)结尾、含中文、且不是 md 文档/噪声文本时才判定为提问。
|
||
作用:截图内某条消息继承了来自其他内容(历史记录、文档)的极老时间戳,
|
||
但该消息本身是用户刚发的提问(如"A还是B?"型选择问句,不含怎么/如何等疑问词),
|
||
不应因继承时间戳极老被误判为远古历史永久丢弃,应放行交由下游去重处理。
|
||
"""
|
||
if not text or not text.strip():
|
||
return False
|
||
t = text.strip()
|
||
if not t.endswith(('?', '?')):
|
||
return False
|
||
if not re.search(r'[\u4e00-\u9fff]', t):
|
||
return False
|
||
if self._is_markdown_document(t):
|
||
return False
|
||
if self._is_noise_text(t):
|
||
return False
|
||
return True
|
||
|
||
def _block_has_cjk_message(self, normalized_text: str) -> bool:
|
||
"""判断整块 OCR 文本去掉时间戳/噪声后是否仍含中文消息内容。
|
||
|
||
用于修复漏消息:当整屏最新可见时间戳已过期(≥600s)时,不再整块丢弃,
|
||
只要块内残留中文消息内容(可能是无独立时间戳的新消息),就放行给
|
||
stage splitting 逐条切分,由下游 seen/_replied_texts + "取最后一条"去重决定。
|
||
"""
|
||
if not normalized_text:
|
||
return False
|
||
stripped = re.sub(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', ' ', normalized_text)
|
||
stripped = re.sub(r'\s+', ' ', stripped).strip()
|
||
if self._is_noise_text(stripped):
|
||
return False
|
||
return bool(re.search(r'[\u4e00-\u9fff]', stripped))
|
||
|
||
@staticmethod
|
||
def _normalize_timestamps(text: str) -> str:
|
||
"""规整 OCR 逐字输出的时间戳,修复「孤儿空格」导致的漏消息。
|
||
|
||
Windows OCR 常把 `16:28:40` 拆成每个字符间带空格、且冒号多为全角:
|
||
`1 6 : 2 8 : 4 0` → `16:28:40`
|
||
旧逻辑用 `re.sub(r'([\\d:])\\s+([\\d:])', r'\\1\\2')` 单次替换,是非重叠匹配,
|
||
会遗留「成对字符间隙」的孤儿空格(如 `1 6 : 2`→`16 :2`,`:` 前后空格残留),
|
||
导致 `findall(r'\\d{1,2}:\\d{1,2}')` 提取为空 → 整条消息被「无时间戳」丢弃(漏消息根因)。
|
||
|
||
本函数用 token 正则一次性匹配整段时间戳片段,内部去空格 + 全角→半角,天然规避该问题。
|
||
仅对【含冒号类分隔符的时间戳片段】做规整,绝不碰无冒号的纯数字串
|
||
(如兑换码 `1234 5678`、小数 `12.5`、版本号 `1.2.3`),避免误拼。
|
||
|
||
分隔符集合 `[::。.]` 不含 ASCII `.`:小数 `12.5` 不误判为时间;
|
||
ASCII 点分隔的时间(如 `16.28`)由调用处的 `[.;]→:` 兜底步骤处理。
|
||
"""
|
||
if not text:
|
||
return text
|
||
_tok = re.compile(r'\d{1,2}(?:[\s.]*\d{1,2})?(?:\s*[::。.]\s*\d{1,2}(?:[\s.]*\d{1,2})?)+')
|
||
def _fix(m):
|
||
s = m.group(0).replace('\u3000', '').replace(' ', '')
|
||
s = re.sub(r'[:。.]', ':', s)
|
||
return s
|
||
return _tok.sub(_fix, text)
|
||
|
||
def _ocr_text_has_fresh_timestamp(self, text: str, platform: str = "unknown") -> bool:
|
||
"""检查 OCR 文本是否可能包含新消息。
|
||
|
||
有时间戳且至少一个 ≤300s → True(确定新消息)
|
||
无时间戳 → 保守放行(可能是短句无时间戳的新消息)
|
||
有时间戳但全部过期 → 仅当最新时间戳不太老(≤600s)且文本含明确新鲜特征时放行,
|
||
否则视为历史消息丢弃
|
||
"""
|
||
logger.debug(f"[ocr] === 过滤器1: _ocr_text_has_fresh_timestamp 开始检查 ===")
|
||
logger.debug(f"[ocr] 输入文本: {text[:100]!r}")
|
||
if not text:
|
||
logger.debug(f"[ocr] 过滤器1结果: 空文本,返回False")
|
||
return False
|
||
# ⭐ 提前定义 stripped,供后续 record_user_message 使用(修复 NameError)
|
||
stripped = text.strip()
|
||
# 标准化时间戳:规整 OCR 逐字输出的时间戳(清除字符间空格 + 全角冒号→ASCII)
|
||
# 16:28:40 → 16:28:40, 1 6 : 2 8 : 4 0 → 16:28:40(见 _normalize_timestamps,修复孤儿空格导致漏消息)
|
||
normalized = self._normalize_timestamps(text)
|
||
# 点号/分号分隔→冒号(兜底:ASCII 点分隔时间 16.28 等)
|
||
# ⭐ Task #1507: 同 stage splitting 防 URL 残片(0.50;57)误转时间戳——
|
||
# 混合分隔符或后跟字母数字/URL字符的不是时间戳,不转换。
|
||
# 否则假时间戳"0:50:57"过老导致过滤器1整批丢弃真实新消息。
|
||
# 后顾 ?<![;.0-9A-Za-z%]:匹配起点前不能在URL字符后,防止 0.50;57 中
|
||
# 的子串 50;57 被单独转换(50:57 会被当合法时间戳)。
|
||
# ⭐ 2026-08-11: EasyOCR 偶发把冒号误读成混合分隔符(10;20.48 = 10:20:48)。
|
||
# 原正则第二段要求同一分隔符重复(?:\2(\d{2})),混合分隔符无法匹配→
|
||
# 最新时间戳漏提(只提出10:14:21),距截图454s>300s→整批含用户消息
|
||
# ')youpaishidifu 服务器的基本信息有哪些?'被过滤器1丢弃。
|
||
# 第二段放宽为任意[.;:],与stage splitting的_line_has_fresh_ts
|
||
# (\d{1,2}[:;.]\d{1,2}(?:[:;.]\d{1,2})?)对齐;URL残片防护靠前后顾不变。
|
||
normalized = re.sub(
|
||
r'(?<![;.0-9A-Za-z%])(\d{1,2})([.;])(\d{2})(?:[.;:](\d{2}))?(?![;.0-9A-Za-z%])',
|
||
lambda m: f'{m.group(1)}:{m.group(3)}:{m.group(4)}' if m.group(4) else f'{m.group(1)}:{m.group(3)}',
|
||
normalized)
|
||
timestamps = re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', normalized)
|
||
# ⭐ 2026-08-11 放宽:独立6位裸数字→时间戳(HHMMSS)
|
||
# OCR 无法保证时间戳格式(10:20:48 可能被读成 102048 无分隔连体数字),
|
||
# 但能保证读出数字。前后顾挡数字/字母/中文/下划线/常见分隔与%,
|
||
# 防止版本号、日期、URL、小数串中取段。只保留合法HH/MM/SS范围——
|
||
# 非法组合(520131=52:xx、128000=xx:80)不当时间戳,否则 _is_timestamp_old
|
||
# 对非法返回False会被误判"新鲜"整批放行。
|
||
for _m6 in re.finditer(r'(?<![0-9A-Za-z_\u4e00-\u9fff./;:\-%])(\d{6})(?![0-9A-Za-z_\u4e00-\u9fff./;:\-%])', normalized):
|
||
_d6 = _m6.group(1)
|
||
_hh, _mm, _ss = int(_d6[0:2]), int(_d6[2:4]), int(_d6[4:6])
|
||
if _hh <= 23 and _mm <= 59 and _ss <= 59:
|
||
timestamps.append(f'{_d6[0:2]}:{_d6[2:4]}:{_d6[4:6]}')
|
||
logger.debug(f"[ocr] 提取到时间戳: {timestamps}")
|
||
|
||
# 提取日期信息(如2026/8/2、2026-08-02、2026年8月2日等),用于判断时间戳是否真的是"未来"
|
||
# OCR常把日期和时间连在一起:2026/8/319:45:20 → 匹配到2026/8/31(未来/非法)
|
||
# 需要截断到最后一个合法且≤今天的日期
|
||
# ⭐ 2026-08-15 修复:扩展正则支持中文日期格式(2026年8月3日)
|
||
raw_dates = re.findall(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}|\d{4}年\d{1,2}月\d{1,2}日', text)
|
||
raw_dates = [d.replace('-', '/').replace('年', '/').replace('月', '/').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
|
||
# ⭐ 2026-08-15 修复:日期新鲜度检查(修正为辅助检查)
|
||
# 原逻辑:如果文本包含日期信息,必须至少有一个日期是今天,否则拒绝
|
||
# 问题:公告表格包含历史日期(如2026/8/14昨天),但用户最新消息是今天的
|
||
# 修正:日期检查仅作为辅助验证,不能因为历史日期拒绝新鲜消息
|
||
# - 有时戳:优先检查时间戳新鲜度(第二道关卡)
|
||
# - 有日期+无今天日期:仍需检查时间戳,不能直接拒绝
|
||
has_today_date = False
|
||
if dates:
|
||
for date_str in dates:
|
||
try:
|
||
parts = date_str.split('/')
|
||
if len(parts) != 3:
|
||
continue
|
||
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
|
||
date_obj = datetime(y, m, d).date()
|
||
if date_obj == today:
|
||
has_today_date = True
|
||
logger.debug(f"[ocr] 检测到今日日期: {date_str}")
|
||
break
|
||
elif date_obj < today:
|
||
logger.debug(f"[ocr] 检测到历史日期: {date_str} (距今{(today - date_obj).days}天)")
|
||
except (ValueError, IndexError):
|
||
pass
|
||
if not has_today_date:
|
||
logger.debug(f"[ocr] 警告: 所有日期都非今日({dates}),但继续检查时间戳...")
|
||
# 不直接拒绝,继续检查时间戳
|
||
if not timestamps:
|
||
# ⭐ 无可见时间戳:检查噪声文本
|
||
logger.debug(f"[ocr] 无可见时间戳")
|
||
if self._is_noise_text(text):
|
||
logger.debug(f"[ocr] 过滤器1结果: 无时间戳且为噪声文本,丢弃: {text[:80]!r}")
|
||
return False
|
||
# 检查是否为纯中文或中英文混合消息(排除噪声)
|
||
stripped = text.strip()
|
||
if not stripped:
|
||
return False
|
||
# 检查是否包含中文字符(用户消息特征)
|
||
has_chinese = re.search(r'[\u4e00-\u9fff]', stripped)
|
||
if not has_chinese:
|
||
# 纯英文/数字/符号,可能是UI噪声,丢弃
|
||
logger.debug(f"[ocr] 过滤器1结果: 无时间戳且无中文字符,丢弃: {stripped[:80]!r}")
|
||
return False
|
||
# 用户消息挨着bot消息判断:最近一次bot回复在60秒内(放宽窗口)
|
||
last_bot_time = self._msg_history.last_bot_reply_time(platform)
|
||
if last_bot_time > 0 and time.time() - last_bot_time <= 60:
|
||
# 用户消息挨着bot消息
|
||
if not self._msg_history.is_user_message_replied(platform, stripped, time.time()):
|
||
# 数据库无记录,无条件放行
|
||
logger.debug(f"[ocr] 过滤器1结果: 无时间戳但用户消息挨着bot消息且无记录,放行: {stripped[:80]!r}")
|
||
self._msg_history.record_user_message(platform, stripped)
|
||
return True
|
||
else:
|
||
# 数据库有记录,整段丢弃
|
||
logger.debug(f"[ocr] 过滤器1结果: 无时间戳且用户消息挨着bot消息但有记录,丢弃: {stripped[:80]!r}")
|
||
return False
|
||
# ⭐ 无时间戳消息:放行交给DB查重处理(避免连续聊天时时间戳被覆盖误杀用户消息)
|
||
# 无论消息长短,只要包含中文字符且非噪声,都放行
|
||
# DB查重会自动过滤掉已经回复过的消息
|
||
logger.info(f"[ocr] 过滤器1结果: 无时间戳但为中文消息,交由DB查重: {stripped[:80]!r}")
|
||
self._msg_history.record_user_message(platform, stripped)
|
||
return True
|
||
# 如果检测到过去日期(昨天或更早),时间戳不应该是"未来时间戳"
|
||
# 直接放行,让后续逻辑判断是否过期
|
||
if has_past_date:
|
||
logger.debug(f"[ocr] 检测到过去日期,跳过未来时间戳拦截,直接判断是否过期")
|
||
# 检查是否有新鲜时间戳(≤300s)
|
||
for ts in timestamps:
|
||
try:
|
||
if not self._is_timestamp_old(ts, 300, skip_future_check=True):
|
||
logger.debug(f"[ocr] 过滤器1结果: 过去日期+新鲜时间戳{ts}(≤300s),放行")
|
||
return True
|
||
except Exception:
|
||
pass
|
||
# 检查最新时间戳是否在300-600s之间
|
||
newest_fresh_enough = False
|
||
for ts in timestamps:
|
||
try:
|
||
if not self._is_timestamp_old(ts, 600, skip_future_check=True):
|
||
newest_fresh_enough = True
|
||
break
|
||
except Exception:
|
||
pass
|
||
if not newest_fresh_enough:
|
||
# ⭐ 硬截止:最新时间戳 >30min → 无论是否含 CJK 均丢弃(同主路径)
|
||
_hard_cutoff_s = 300 # 严格5分钟报废
|
||
_newest_age_s = None
|
||
for ts in timestamps:
|
||
try:
|
||
_age = self._timestamp_age_seconds(ts)
|
||
if _age is not None and (_newest_age_s is None or _age < _newest_age_s):
|
||
_newest_age_s = _age
|
||
except Exception:
|
||
pass
|
||
if _newest_age_s is not None and _newest_age_s > _hard_cutoff_s:
|
||
logger.debug(f"[ocr] 过滤器1结果: 过去日期+最新时间戳已过期>{_hard_cutoff_s}s({_newest_age_s:.0f}s),严格报废丢弃: {text[:80]!r}")
|
||
return False
|
||
# ⭐ 严格300s:有时间戳且全过期→直接丢弃
|
||
logger.debug(f"[ocr] 过滤器1结果: 过去日期+时间戳全过期(>300s),丢弃: {text[:80]!r}")
|
||
return False
|
||
# ⭐ 严格300s:最新时间戳在300-600s之间也视为过期,直接丢弃
|
||
logger.debug(f"[ocr] 过滤器1结果: 过去日期+最新时间戳已过期(>300s),丢弃: {text[:80]!r}")
|
||
return False
|
||
# 有新鲜时间戳 → 确定有新消息
|
||
for ts in timestamps:
|
||
try:
|
||
if not self._is_timestamp_old(ts, 300):
|
||
logger.debug(f"[ocr] 过滤器1结果: 发现新鲜时间戳{ts}(≤300s),放行")
|
||
# ⭐ 记录用户消息到DB,防止重启后重复处理
|
||
self._msg_history.record_user_message(platform, stripped)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
# ⭐ 全部过期:所有时间戳均超过5分钟(300s) → 严格丢弃
|
||
# 修复:10:59的消息在12:22仍被处理。提取全部时间戳,只有5分钟内的才放行,
|
||
# 其余一律丢弃。不再"放行给DB去重"——DB去重无法判断消息是否真的是用户刚发的。
|
||
_newest_age_s = None
|
||
for ts in timestamps:
|
||
try:
|
||
_age = self._timestamp_age_seconds(ts)
|
||
if _age is not None and (_newest_age_s is None or _age < _newest_age_s):
|
||
_newest_age_s = _age
|
||
except Exception:
|
||
pass
|
||
_age_info = f"({_newest_age_s:.0f}s)" if _newest_age_s is not None else ""
|
||
logger.debug(f"[ocr] 过滤器1结果: 无新鲜时间戳(全部>300s){_age_info},丢弃: {text[:80]!r}")
|
||
return False
|
||
|
||
# 未来时间戳容忍偏差(秒):同日内时间戳比系统时间晚超过此值 = 未到来,直接丢弃。
|
||
# 容忍小量偏差避免误杀:OCR/聊天服务器时钟与系统时钟存在秒级差异,刚到的消息
|
||
# 时间戳可能比系统时间晚几秒(如 14:41:00 消息在 14:40:58 被捕获)。
|
||
_TS_FUTURE_TOLERANCE_S = 15
|
||
|
||
@staticmethod
|
||
def _is_timestamp_old(ts_str: str, max_age: int = 300, skip_future_check: bool = False) -> bool:
|
||
"""检查 HH:MM 或 HH:MM:SS 时间戳是否超过 max_age 秒。
|
||
处理跨午夜边界:如果 diff > 12h 说明是昨天的时间戳。
|
||
未来时间戳硬拦截:同日内 diff < -容差 = 时间未到来(系统14:40不可能有16:00),丢弃。
|
||
OCR 误读兜底:15:31→5:31(`1`笔画细被吞),若 h<12,尝试 h+10/h+12
|
||
取 diff 最小者,若 < max_age 则视为 OCR 误读,放行。
|
||
返回 True = 时间戳太老,应跳过。
|
||
|
||
skip_future_check: 如果为True,则跳过未来时间戳拦截(用于已检测到过去日期的情况)。
|
||
"""
|
||
ts = ts_str.strip()
|
||
if not ts:
|
||
return False
|
||
m = re.match(r'^(\d{1,2}):(\d{2})(?::(\d{2}))?$', ts)
|
||
if not m:
|
||
return False
|
||
h, mi, s = int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)
|
||
if h > 23 or mi > 59 or s > 59:
|
||
return False
|
||
now = datetime.now()
|
||
try:
|
||
parsed = now.replace(hour=h, minute=mi, second=s, microsecond=0)
|
||
except ValueError:
|
||
return False
|
||
diff = (now - parsed).total_seconds()
|
||
# 跨午夜: diff > 12h 说明时间戳来自昨天
|
||
# ⚠ 用 timedelta 回退而非 replace(day-1):每月1号 day=1 时 day-1=0 抛
|
||
# ValueError("day is out of range for month") → 微信 OCR 整轮失败
|
||
if diff > 43200:
|
||
parsed = parsed - timedelta(days=1)
|
||
diff = (now - parsed).total_seconds()
|
||
# 跨午夜反向: diff < -12h 说明是昨天晚些时候(当前 00:05,时间戳 23:55 → 昨天23:55)
|
||
# ⚠ 必须 -1day:之前误用 +1day 得到 -47h 的负 diff → 返回"不旧"=新鲜 → 凌晨误判
|
||
if diff < -43200:
|
||
parsed = parsed - timedelta(days=1)
|
||
diff = (now - parsed).total_seconds()
|
||
# 未来时间戳硬拦截(用户反馈:系统14:40 不能把 16:00 未到来的时间判为新鲜):
|
||
# 跨午夜调整后仍为负 = 同日内未来时间,必为 OCR 误读/异常,直接丢弃
|
||
# skip_future_check=True 时跳过此检查(用于已检测到过去日期的情况)
|
||
if diff < -MessageMonitor._TS_FUTURE_TOLERANCE_S and not skip_future_check:
|
||
logger.debug(f"[ts] 未来时间戳拦截: {ts} 比系统时间 {now.strftime('%H:%M:%S')} 晚 {-diff:.0f}s,未到来,丢弃")
|
||
return True
|
||
# OCR 误读兜底:h<12 且判定为老 → 尝试 h+10 / h+12(OCR 丢失前导1:15→5)
|
||
if diff > max_age and h < 12:
|
||
best_h, best_diff = h, diff
|
||
for offset in (10, 12):
|
||
try:
|
||
h2 = h + offset
|
||
if h2 > 23:
|
||
continue
|
||
parsed2 = now.replace(hour=h2, minute=mi, second=s, microsecond=0)
|
||
diff2 = (now - parsed2).total_seconds()
|
||
if diff2 < 0:
|
||
diff2 += 86400
|
||
if diff2 < best_diff:
|
||
best_diff = diff2
|
||
best_h = h2
|
||
except ValueError:
|
||
pass
|
||
if best_h != h and best_diff < max_age:
|
||
logger.debug(f"[ts] OCR误读兜底: {ts} 可能是 {best_h}:{mi:02d}:{s:02d}, diff={best_diff:.0f}s < {max_age}s, 放行")
|
||
return False
|
||
return diff > max_age
|
||
|
||
def _line_has_fresh_ts(self, rl: str, skip_future_check: bool = False) -> bool:
|
||
"""检查一行是否含新鲜时间戳(≤300s)。
|
||
|
||
⭐ Task #1510: QQ 时间戳渲染在消息【下方】,stage splitting 的"继承上一行时间戳"
|
||
模型会把陈旧历史时间戳继承给下方的新鲜消息,导致新鲜消息被"尾部时间戳较老"误杀。
|
||
此函数用于前向 peek:检查本行或下一行是否含新鲜时间戳,若是则消息属于新鲜组。
|
||
|
||
复用 stage splitting 的规整链:;.分隔→冒号、逐字空格规整,再提取 HH:MM(:SS)。
|
||
"""
|
||
if not rl:
|
||
return False
|
||
n = self._normalize_timestamps(rl)
|
||
# ⭐ 2026-08-11: 第二段分隔符放宽为任意[.;:](原\2同分隔符漏提 10;20.48 之类
|
||
# EasyOCR混合分隔读法),与 _ocr_text_has_fresh_timestamp 保持一致
|
||
n = re.sub(
|
||
r'(?<![;.0-9A-Za-z%])(\d{1,2})([.;])(\d{2})(?:[.;:](\d{2}))?(?![;.0-9A-Za-z%])',
|
||
lambda m: f'{m.group(1)}:{m.group(3)}:{m.group(4)}' if m.group(4) else f'{m.group(1)}:{m.group(3)}',
|
||
n)
|
||
ts_list = list(re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', n))
|
||
# ⭐ 2026-08-11: 独立6位裸数字→时间戳(与 _ocr_text_has_fresh_timestamp 一致的放宽逻辑)
|
||
for _m6 in re.finditer(r'(?<![0-9A-Za-z_\u4e00-\u9fff./;:\-%])(\d{6})(?![0-9A-Za-z_\u4e00-\u9fff./;:\-%])', n):
|
||
_d6 = _m6.group(1)
|
||
_hh, _mm, _ss = int(_d6[0:2]), int(_d6[2:4]), int(_d6[4:6])
|
||
if _hh <= 23 and _mm <= 59 and _ss <= 59:
|
||
ts_list.append(f'{_d6[0:2]}:{_d6[2:4]}:{_d6[4:6]}')
|
||
for ts in ts_list:
|
||
if not self._is_timestamp_old(ts, 300, skip_future_check=skip_future_check):
|
||
return True
|
||
return False
|
||
|
||
@staticmethod
|
||
def _timestamp_age_seconds(ts_str: str):
|
||
"""计算 HH:MM 或 HH:MM:SS 时间戳距现在的秒数。跨午夜处理。
|
||
返回 None 表示无法解析。"""
|
||
ts = ts_str.strip()
|
||
if not ts:
|
||
return None
|
||
m = re.match(r'^(\d{1,2}):(\d{2})(?::(\d{2}))?$', ts)
|
||
if not m:
|
||
return None
|
||
h, mi, s = int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)
|
||
if h > 23 or mi > 59 or s > 59:
|
||
return None
|
||
now = datetime.now()
|
||
try:
|
||
parsed = now.replace(hour=h, minute=mi, second=s, microsecond=0)
|
||
except ValueError:
|
||
return None
|
||
diff = (now - parsed).total_seconds()
|
||
if diff > 43200:
|
||
parsed = parsed - timedelta(days=1)
|
||
diff = (now - parsed).total_seconds()
|
||
if diff < -43200:
|
||
parsed = parsed - timedelta(days=1)
|
||
diff = (now - parsed).total_seconds()
|
||
return max(diff, 0)
|
||
|
||
@staticmethod
|
||
def _is_system_message(text: str) -> bool:
|
||
"""过滤机器人/系统消息,不作为用户消息处理。"""
|
||
if not text:
|
||
return True
|
||
# NFKC 标准化:去除 UIA/OCR 夹杂的零宽字符、不可见 Unicode
|
||
text = unicodedata.normalize('NFKC', text)
|
||
# 系统前缀/标记
|
||
system_markers = [
|
||
"✅", "❌", "📝", "🔍", "📄", "➡", "♡", "✿",
|
||
"[WECHAT]", "[QQ]", "[wechat]", "[qq]",
|
||
]
|
||
for m in system_markers:
|
||
if text.startswith(m):
|
||
return True
|
||
# ⭐ QQ/微信 界面噪声文字(按钮/提示/碎片/导航,非用户消息)
|
||
qq_ui_noise = [
|
||
"查看更多", "可查看在其他设备产生的聊天记录",
|
||
"收藏", "转发", "多选", "引用", "回复",
|
||
"选择会话开始聊天",
|
||
# 微信底部导航
|
||
"通讯录", "发现", "我", "消息", "朋友圈",
|
||
# 微信/QQ 顶部/侧边
|
||
"搜索", "添加到表情", "更多",
|
||
# 微信底部栏 + 附加按钮
|
||
"导航", "视频号",
|
||
# QQ 空间/动态
|
||
"空间", "动态", "说说", "日志", "相册",
|
||
# 通用 UI
|
||
"发送", "接收", "取消", "确认", "确定", "关闭",
|
||
]
|
||
for n in qq_ui_noise:
|
||
if text == n:
|
||
return True
|
||
# 系统事件模板 — 注意:用 startswith 而非 in,避免混合文本(OCR 多行合一)误杀
|
||
system_patterns = [
|
||
"已自动回复", "知识库中未找到", "找到相关结果",
|
||
"监控已启动", "监控已暂停", "设置已保存",
|
||
"🤖", "找不到联系人", "当前会话回复",
|
||
"自动回复触发", "匹配度",
|
||
# KB 文档内容格式(机器人回复格式),OCR 可能把用户消息+KB 回复混成一行
|
||
# 只有全行以"答:"开头才过滤,内含"答:"的混合文本不过滤
|
||
"答:",
|
||
# 纯系统命令反馈
|
||
"PS ", "❯ ", "$ ",
|
||
]
|
||
for p in system_patterns:
|
||
if text.startswith(p):
|
||
return True
|
||
# QQ系统消息残片:撤回了一条消息、你撤回了, 条消息等
|
||
if re.match(r'^(?:你|对方|他|她|管理员)\s*撤回了\s*,?\s*\d*\s*条?\s*消息', text):
|
||
return True
|
||
if re.match(r'^撤回了一条消息', text):
|
||
return True
|
||
# 纯时间格式(不含消息内容)
|
||
if re.match(r'^\d{1,2}:\d{2}', text):
|
||
return True
|
||
return False
|
||
|
||
@staticmethod
|
||
def _strip_json_yaml_blocks(raw_text: str, platform: str = "") -> str:
|
||
"""剥离 OCR 截屏中混入的 JSON/YAML 配置块。
|
||
|
||
微信 OCR 截屏范围 40%-98%,常把右侧文档/配置面板一起截入。
|
||
JSON 块("key": value, {, })和 YAML 块(key: value, - item)
|
||
跟用户消息混在一起,导致 split 后 JSON 碎片污染用户消息行。
|
||
|
||
策略:逐行检测,丢弃明显是 JSON/YAML 语法的行。
|
||
用户聊天不会写 "key": value 或 { } 块。
|
||
"""
|
||
lines = raw_text.split('\n')
|
||
cleaned = []
|
||
in_json_block = False
|
||
brace_depth = 0
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
cleaned.append(line)
|
||
continue
|
||
|
||
# 检测 JSON 对象块开始/结束
|
||
if stripped == '{' or stripped.startswith('{') and '"' in stripped:
|
||
in_json_block = True
|
||
brace_depth = stripped.count('{') - stripped.count('}')
|
||
logger.debug(f"[{platform}] JSON块剥离: 开始 {stripped[:60]!r}")
|
||
continue
|
||
|
||
if in_json_block:
|
||
brace_depth += stripped.count('{') - stripped.count('}')
|
||
if brace_depth <= 0:
|
||
in_json_block = False
|
||
logger.debug(f"[{platform}] JSON块剥离: 结束 {stripped[:60]!r}")
|
||
continue
|
||
|
||
# JSON 行特征: "key": value 或 "key": "value"
|
||
if re.match(r'^["\']?[a-zA-Z_][\w]*["\']?\s*:\s*["\d\[\{tfn]', stripped):
|
||
logger.debug(f"[{platform}] JSON块剥离: key-value行 {stripped[:60]!r}")
|
||
continue
|
||
|
||
# YAML 行特征: key: value(无引号,非 URL)
|
||
if re.match(r'^[a-zA-Z_][\w]*\s*:\s*["\w\d]', stripped) and \
|
||
not re.match(r'^(https?|ftp)://', stripped) and \
|
||
not re.match(r'^[a-zA-Z_][\w]*\s*:\s*/', stripped):
|
||
logger.debug(f"[{platform}] JSON块剥离: YAML行 {stripped[:60]!r}")
|
||
continue
|
||
|
||
# YAML 列表项: - item
|
||
if re.match(r'^-\s+\S', stripped) and \
|
||
not re.match(r'^-\s*[\u4e00-\u9fff]', stripped):
|
||
logger.debug(f"[{platform}] JSON块剥离: YAML列表 {stripped[:60]!r}")
|
||
continue
|
||
|
||
# 纯 JSON 数组: [ "item", ... ]
|
||
if stripped.startswith('[') and stripped.endswith(']') and \
|
||
'"' in stripped:
|
||
logger.debug(f"[{platform}] JSON块剥离: 数组 {stripped[:60]!r}")
|
||
continue
|
||
|
||
cleaned.append(line)
|
||
|
||
return '\n'.join(cleaned)
|
||
|
||
# —— md 尾巴剥离模式 ——
|
||
# ⭐ 2026-08-11:QQ 群截图 OCR 会把 bot 的 md 回复残片拼在用户消息尾部,
|
||
# 如 "群管理员其实都是傲娇小南...(头衔UI)...# #来测试# # @C418minecraf"。
|
||
# 历史做法整条判 md 丢弃 → 用户原始提问陪葬;若放行 → md 尾巴入库,
|
||
# 可能撞知识库关键词把 bot 拽去回复残片内容,脱离用户原始提问。
|
||
# 规则:md 在末尾 → 切掉小尾巴保留前段;md 大头/混中间 → 整段切掉。
|
||
_MD_TAIL_PATTERNS = [
|
||
# # 标题(## 文档 / ##直接跟汉字 / # # 分割 / ###1.)
|
||
# 负向前瞻挡 C#语言 / 中文#标签 前的字母数字下划线#
|
||
re.compile(r'(?<![A-Za-z0-9_#])#{1,6}(?:\s|\[|[\u4e00-\u9fff]|\d)'),
|
||
# **加粗** / 裸 **(用户日常不打)
|
||
re.compile(r'\*\*'),
|
||
# [text](url) 及 OCR 退化 [text](、](
|
||
re.compile(r'\[.+?\]\([^)]*\)?'),
|
||
re.compile(r'\]('),
|
||
# OCR 退化 checkbox [V]/[x]/[√]
|
||
re.compile(r'\[\s*[VXvx√✗]\s*\]'),
|
||
# - 列表 / 编号列表 / > 引用(行内出现,前有空白或行首)
|
||
re.compile(r'(?:^|\s)[-*+]\s+(?=\S)'),
|
||
re.compile(r'(?:^|\s)\d{1,3}[\.\)、]\s+(?=\S)'),
|
||
re.compile(r'(?:^|\s)>\s(?=\S)'),
|
||
# 反引号代码 / .md 文件引用
|
||
re.compile(r'`{1,3}'),
|
||
re.compile(r'\S+\.md\)'),
|
||
]
|
||
|
||
@staticmethod
|
||
def _strip_markdown_tail(text: str):
|
||
"""剥离消息中混入的 markdown 内容。返回剥离后文本;None = 整段丢弃。
|
||
|
||
规则:
|
||
- md 特征在末尾(前面有用户文本)→ 切掉 md 特征及之后 → 返回前段
|
||
- md 特征在开头 / 前段仍含 md 特征 → 返回 None(md 大头/混中间 → 整段切掉)
|
||
- 无 md 特征 → 原样返回
|
||
"""
|
||
if not text or not text.strip():
|
||
return text
|
||
hit = None
|
||
for pat in MessageMonitor._MD_TAIL_PATTERNS:
|
||
m = pat.search(text)
|
||
if m and (hit is None or m.start() < hit):
|
||
hit = m.start()
|
||
if text.count('|') >= 2:
|
||
m = re.search(r'\|', text)
|
||
if m and (hit is None or m.start() < hit):
|
||
hit = m.start()
|
||
if hit is None:
|
||
return text
|
||
head = text[:hit].strip()
|
||
if not head:
|
||
# md 特征在开头 → md 大头,整段切掉
|
||
return None
|
||
# 前段仍含 md 特征 → md 混在中间/多段拼行 → 整段切掉防污染
|
||
for pat in MessageMonitor._MD_TAIL_PATTERNS:
|
||
if pat.search(head):
|
||
return None
|
||
if head.count('|') >= 2:
|
||
return None
|
||
return head
|
||
|
||
@staticmethod
|
||
def _is_markdown_document(text: str) -> bool:
|
||
"""检测是否为 Markdown 格式的文档内容。
|
||
|
||
Bot 回复用 md 格式(# 标题、[链接](url)、列表等),用户日常对话不用。
|
||
借此区分 bot 旧回复和历史堆积 vs 用户新消息。
|
||
"""
|
||
logger.debug(f"[ocr] 过滤器3: _is_markdown_document 检查: {text[:80]!r}")
|
||
if not text:
|
||
logger.debug(f"[ocr] 过滤器3结果: 空文本,返回False")
|
||
return False
|
||
# # 行首标题:短文本也检测(OCR 把 ##协议修 切成 5 字符片段)
|
||
if re.match(r'^#{1,6}(?:\s|\[|[\u4e00-\u9fff])', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到Markdown标题语法,返回True")
|
||
return True
|
||
# 行首列表项:短文本也检测(- 玩家义务 / * 注意)
|
||
# 必须放 len<10 之前,否则 "- 玩家应尊重他"(8c) 漏网
|
||
if re.match(r'^[\-\*]\s', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到Markdown列表语法,返回True")
|
||
return True
|
||
# 全角括号退化 md 链接:短文本也检测
|
||
if re.search(r'\](', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到全角括号退化链接,返回True")
|
||
return True
|
||
if re.search(r'\[.+?\](', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到全角括号退化链接模式2,返回True")
|
||
return True
|
||
# OCR 退化 md checkbox:短文本也检测([V] / [x])
|
||
if re.search(r'\[\s*[VXvx√✗]\s*\]', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到OCR退化checkbox,返回True")
|
||
return True
|
||
# OCR 退化 md 链接缺括号:短文本也检测
|
||
if re.search(r'\[.+?\]\(', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到OCR退化链接,返回True")
|
||
return True
|
||
# **加粗**语法:短文本也检测(bot md 回复标配,用户日常不用)
|
||
if re.search(r'\*\*.+?\*\*', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到加粗语法,返回True")
|
||
return True
|
||
# <br> HTML 换行标记 — bot md 回复中用于换行,用户聊天不打
|
||
if re.search(r'<\s*br\s*/?\s*>', text, re.IGNORECASE):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到HTML换行标记,返回True")
|
||
return True
|
||
# ###1. / ####数字标题 — 数字直接跟在 # 后(OCR 常见)
|
||
if re.match(r'^#{1,6}\d', text):
|
||
return True
|
||
# 编号列表项:1. 2. 12. / 1) 2) / 1、2、
|
||
if re.match(r'^\d{1,3}[\.\)、]\s', text):
|
||
return True
|
||
# 引用块:> 开头
|
||
if re.match(r'^>\s', text):
|
||
return True
|
||
# 水平分隔线:--- 或 === 连续 3+ 个
|
||
if re.match(r'^[-=]{3,}\s*$', text):
|
||
return True
|
||
# |表格行:| 开头和结尾
|
||
if re.match(r'^\|.+\|$', text):
|
||
return True
|
||
# *斜体* / ***粗斜体* 或任何 ** 裸标记
|
||
# 用户日常对话从不打 **,只可能是 bot md 回复
|
||
if '**' in text:
|
||
return True
|
||
if len(text) < 10:
|
||
logger.debug(f"[ocr] 过滤器3结果: 短文本(<10)无Markdown特征,返回False")
|
||
return False
|
||
# ##直接跟汉字(OCR 丢失 ##后空格时常见:##文档导航)
|
||
if re.match(r'^#{1,6}[\u4e00-\u9fff]', text):
|
||
return True
|
||
# 包含 markdown 链接 [text](url)
|
||
if re.search(r'\[.+?\]\(.+?\)', text):
|
||
return True
|
||
# 包含 .md 文件引用(如 "gin.md)" 分割后断开的链接)
|
||
if re.search(r'\S+\.md\)', text):
|
||
return True
|
||
# 包含代码块标记 ``` 或 `code`
|
||
if re.search(r'`{1,3}', text):
|
||
return True
|
||
# 连续 3+ 行包含 markdown 特征:| 表格、--- 分隔线、> 引用
|
||
pipes = text.count('|')
|
||
if pipes >= 3:
|
||
return True
|
||
# 内联 # # 标题标记(如 "# # 必装依赖" → 分割后 `# #` 或 `# #`)
|
||
# ⭐ 2026-08-11 修复:必须用 re.match 而非 re.search。
|
||
# QQ 标签 "##来测试" "## @C418minecraft" 经 OCR normalize 后变成
|
||
# " # #来测试# # @C418minecraf" 拼在用户消息尾部。
|
||
# re.search 在文本中间匹配到 # # → 整条真实用户消息被误判为 md 文档丢弃。
|
||
# 改用 re.match 只检行首,避免 QQ 标签误触发。
|
||
if re.match(r'#\s*#', text):
|
||
return True
|
||
return False
|
||
|
||
@staticmethod
|
||
def _is_markdown_line(line: str) -> bool:
|
||
"""⭐ Task #1475: 检测单行是否为 Markdown 强特征行。
|
||
|
||
正常用户从不手动打 markdown。一旦某一行 OCR 文本命中以下强特征,
|
||
即判定为 markdown 行,整行跳过,避免污染普通消息:
|
||
- # 标题(## 文档 / ##直接跟汉字 / ###1. 数字标题)
|
||
- | 表格行(首尾 | 或含 3+ 个 |)
|
||
- **加粗** / 裸 ** / 反引号 ` 代码
|
||
- <br> HTML 换行 / [text](url) 链接 / > 引用块
|
||
- --- 或 === 水平分隔线
|
||
- 编号列表 1. 2) 3、 (bot md 回复常见,用户日常不打)
|
||
注意:群名片 [专业皮肤定制] 不含 ] 后的 ( ,不会误判为链接;
|
||
纯 [xxx] 也不命中任何强特征,安全保留。
|
||
"""
|
||
if not line:
|
||
return False
|
||
s = line.strip()
|
||
# # 标题(含空格/直接跟汉字/数字/内联#)
|
||
if re.match(r'^#{1,6}(\s|\[|#|\u4e00-\u9fff|\d)', s):
|
||
return True
|
||
# | 表格行
|
||
if re.match(r'^\|.+\|$', s) or s.count('|') >= 3:
|
||
return True
|
||
# **加粗** 或裸 ** 或反引号代码
|
||
if '**' in s or '`' in s:
|
||
return True
|
||
# <br> HTML 换行标记
|
||
if re.search(r'<\s*br\s*/?\s*>', s, re.IGNORECASE):
|
||
return True
|
||
# [text](url) 链接(群名片 [xxx] 后无 ( 不命中)
|
||
if re.search(r'\[.+?\]\(', s):
|
||
return True
|
||
# > 引用块
|
||
if re.match(r'^>\s', s):
|
||
return True
|
||
# --- 或 === 水平分隔线
|
||
if re.match(r'^[-=]{3,}\s*$', s):
|
||
return True
|
||
# 编号列表项(1. 2) 3、)
|
||
if re.match(r'^\d{1,3}[\.\)、]\s', s):
|
||
return True
|
||
return False
|
||
|
||
@staticmethod
|
||
def _is_qq_ui_element(line: str) -> bool:
|
||
"""⭐ Task #1476: 检测单行是否为 QQ 界面 UI 元素(非用户消息)。
|
||
|
||
QQ 群聊截图中,OCR 会把以下非消息文本识别出来混入消息流:
|
||
- 图片引用: !0(img/skin_3.png) 等
|
||
- 用户头衔/徽章: 【专业皮肤定制】C418minecraft
|
||
- 用户标签: ## @C418minecraft, )youpaishidifu关于我们
|
||
这些整行跳过,避免混入消息流触发残片判定。
|
||
"""
|
||
if not line:
|
||
return False
|
||
s = line.strip()
|
||
# ⭐ 2026-08-12 纵深防御:含 @ 且含疑问词的行一律视为用户提问,
|
||
# 绝不被任何 UI 规则(规则1-6)判为界面元素而丢弃。即使后续规则误匹配,
|
||
# 此处直接放行,确保用户提问永远不会因 UI 过滤而丢失。
|
||
if '@' in s:
|
||
_qq_qwords = ('?', '?', '请问', '是不是', '有没有', '怎么', '如何', '什么',
|
||
'哪', '谁', '多少', '几', '为什么', '是否', '能否', '可以吗',
|
||
'好吗', '对吗', '是吗', '对不对', '好不好', '行不行', '可不可以')
|
||
if any(q in s for q in _qq_qwords):
|
||
logger.debug(f"[qq-ui] 含@+疑问词,直接放行(非UI元素): {s}")
|
||
return False
|
||
# 1) 图片文件引用: !0(img/xxx.png) 或 !0(img/xxx.jpg) 等
|
||
# QQ 发送图片后,OCR 识别出的文件引用格式
|
||
if re.match(r'^!0?\s*\(?\s*img/', s, re.IGNORECASE):
|
||
logger.debug(f"[qq-ui] 规则1匹配(图片引用): {s}")
|
||
return True
|
||
# 2) 用户头衔/徽章: 【...】Username
|
||
# QQ 群成员的头衔标签,如【专业皮肤定制】C418minecraft
|
||
if re.match(r'^【[^】]{1,30}】\s*[\w\u4e00-\u9fff]+', s):
|
||
logger.debug(f"[qq-ui] 规则2匹配(头衔徽章): {s}")
|
||
return True
|
||
# 3) 用户标签: ## @Username — QQ 的 @ 提及格式
|
||
if re.match(r'^##\s*@[\w\u4e00-\u9fff]+', s):
|
||
logger.debug(f"[qq-ui] 规则3匹配(##标签): {s}")
|
||
return True
|
||
# 3b) 独立@提及: @Username — 仅当整行只有@用户名时才识别为UI元素
|
||
# 如果@后面还有其他内容(如消息文本),则不识别为UI元素
|
||
if re.match(r'^@[\w\u4e00-\u9fff]+$', s):
|
||
logger.debug(f"[qq-ui] 规则3b匹配(独立@): {s}")
|
||
return True
|
||
# 4) 闭合括号开头的用户标签: )username 或 )username关于我们 — OCR 识别出的 UI 残留
|
||
# ⭐ 修复(2026-08-11):原正则 ^\)[\w\u4e00-\u9fff]{2,}\S 有回溯bug——
|
||
# \S 会回溯吃掉 username 末尾字符,导致 ")youpaishidifu 关于我们"(带空格,
|
||
# 实为用户真实消息"关于我们")整行被误判为 UI 丢弃。
|
||
# 正确判断:)username 后 剩余为空(行尾)或 首字符非空白(粘连UI)
|
||
# → UI元素;剩余首字符是空白 → 真实消息(OCR把@/冒号误读为)),不跳过。
|
||
_m4 = re.match(r'^\)([\w\u4e00-\u9fff]{2,})', s)
|
||
if _m4:
|
||
_rest4 = s[_m4.end():]
|
||
if not _rest4 or not _rest4[0].isspace():
|
||
logger.debug(f"[qq-ui] 规则4匹配(括号用户标签): {s} (剩余={_rest4!r})")
|
||
return True
|
||
# 剩余以空白开头 → 可能是用户消息(如")youpaishidifu 关于我们"),放行
|
||
logger.debug(f"[qq-ui] 规则4不匹配(带空格→疑似消息): {s} (剩余={_rest4!r})")
|
||
return False
|
||
# 5) 纯图片引用后缀: .png .jpg .gif 等,无中文 → 图片文件名碎片
|
||
if re.match(r'^[\w/\\._-]+\.(?:png|jpg|jpeg|gif|bmp|webp|svg|ico)$', s, re.IGNORECASE):
|
||
logger.debug(f"[qq-ui] 规则5匹配(图片后缀): {s}")
|
||
return True
|
||
# 6) 纯 @username 消息:中文/乱码 + @ + 用户名 + 空格 + 用户名
|
||
# 如 "有人@拜 youpaishidifu",OCR 识别的 QQ @ 提及消息,无实质内容
|
||
# 特征:行包含 @ 符号,@ 前后有内容,整行无疑问词且长度 ≤ 50
|
||
if '@' in s and len(s) <= 50:
|
||
# 排除包含疑问词的消息(可能是正常提问)
|
||
_question_words = ('?', '?', '请问', '是不是', '有没有', '怎么', '如何', '什么',
|
||
'哪', '谁', '多少', '几', '为什么', '是否', '能否', '可以吗',
|
||
'好吗', '对吗', '是吗', '对不对', '好不好', '行不行', '可不可以')
|
||
if any(q in s for q in _question_words):
|
||
# ⭐ 2026-08-12 日志措辞修正:此处 return False 表示「不是UI元素、
|
||
# 放行(当作用户消息处理)」,并非丢弃消息。"跳过"易误解为消息被跳过。
|
||
logger.debug(f"[qq-ui] 规则6豁免(含疑问词,放行): {s}")
|
||
return False
|
||
# 匹配模式:可选中文前缀(群名片) + @ + 任意字符(可能乱码) + 空格 + 用户名(可多词)
|
||
# 示例:"有人@拜 youpaishidifu"、"用户@张三 测试"、"@李四 123"
|
||
# 要求 @ 后有空格和用户名,@ 前可有中文(QQ群名片)或无前缀(@开头)
|
||
if re.match(r'^(?:[\u4e00-\u9fff]+)?@[\w\u4e00-\u9fff]+[\s]+[\w\u4e00-\u9fff]+(?:[\s]+[\w\u4e00-\u9fff]+)*$', s):
|
||
logger.debug(f"[qq-ui] 规则6匹配(纯@消息): {s}")
|
||
return True
|
||
return False
|
||
|
||
@staticmethod
|
||
def _is_md_continuation(line: str) -> bool:
|
||
"""⭐ Task #1475: 判断某行是否为 markdown 块被 OCR 拆出来的"内容延续行"。
|
||
|
||
当上一行是 markdown 特征行时,OCR 常把同一 md 块的内容拆成多行,
|
||
带 ## 的头部被识别为 md 并跳过,但尾部纯文本行(如"你就换/个问题。没事/这个不影响")
|
||
无 md 特征会逃逸。此函数识别这些延续行并跳过。
|
||
|
||
判定为"延续行"(返回 True)的条件:
|
||
- 无问号(?/?)、无 @、不以吗/呢/么结尾(非明显提问)
|
||
- 非群名片 [xxx] 开头(群名片代表新消息起点)
|
||
- 非时间戳开头
|
||
- 长度 < 20(短句,符合 md 内容碎片特征)
|
||
否则视为明显用户消息,返回 False 退出 md 块。
|
||
"""
|
||
if not line:
|
||
return True
|
||
s = line.strip()
|
||
# 明显用户消息特征 → 不是延续
|
||
if '?' in s or '?' in s:
|
||
return False
|
||
if '@' in s:
|
||
return False
|
||
if s.endswith(('吗', '呢', '?', '?', '么')):
|
||
return False
|
||
# 群名片 [xxx] 开头 → 新消息起点,退出 md 块
|
||
if re.match(r'^[\[【][^\]】]*[\]】]', s):
|
||
return False
|
||
# 时间戳开头 → 新消息起点
|
||
if re.match(r'^\d{1,2}[::]', s):
|
||
return False
|
||
# 较长陈述句(>=20字)可能为真实消息,退出 md 块
|
||
if len(s) >= 20:
|
||
return False
|
||
# 默认:短、无问号、无@、非群名片 → 视为 md 内容延续
|
||
return True
|
||
|
||
def _split_and_denoise_segments(self, raw_text: str, platform: str) -> str:
|
||
"""混合文本预分割 + 分段噪声隔离。
|
||
|
||
Windows OCR 返回整屏文字的大杂烩(bot 回复含 <br>/图片URL + 用户消息)。
|
||
若整体调用 _is_noise_text() 会因 HTML 标签把整段丢弃,用户消息跟着被杀。
|
||
|
||
策略:
|
||
1. 按消息边界将文本分割成候选段
|
||
2. 对每个段单独做 _is_noise_text() 检查
|
||
3. 只保留干净的用户消息段,丢弃含噪声的 bot 回复段
|
||
4. 返回清理后的文本(空字符串 = 全部是噪声)
|
||
|
||
分割规则(按优先级):
|
||
- <br> 标签:bot markdown 回复的典型特征
|
||
- 时间戳 HH:MM / HH:MM:SS:消息间天然边界
|
||
- 图片 URL (https://img.ypshidifu.cn/...):bot 回复中的图片引用
|
||
- 长空白/间隔:消息间的自然分隔
|
||
"""
|
||
if not raw_text:
|
||
return ""
|
||
|
||
# 快速路径:文本本身不含噪声特征 → 直接原样返回,不做分割开销
|
||
if not self._is_noise_text(raw_text) and '<br' not in raw_text and 'https://img' not in raw_text:
|
||
return raw_text
|
||
|
||
# ---- 分割成候选段 ----
|
||
import copy
|
||
|
||
segments = []
|
||
|
||
# 按多级分隔符分割
|
||
# 1) <br> 或 <br/> 是 bot 回复中最强的噪声标记,优先在此处切分
|
||
parts = re.split(r'(<\s*br\s*/?\s*>|\s*<br>\s*)', raw_text)
|
||
|
||
for part in parts:
|
||
if not part or not part.strip():
|
||
continue
|
||
part = part.strip()
|
||
|
||
# 如果段落仍然很长且包含多种内容,尝试进一步切分
|
||
if len(part) > 100 and ('https://' in part or re.search(r'\d{1,2}:\d{1,2}', part)):
|
||
# 按 URL 前后的问号句或时间戳进一步切分
|
||
sub_parts = re.split(
|
||
r'(?<=。|?|!|\.)\s+(?=[^<])'
|
||
r'|(?<=[^:])\s+(?=\d{1,2}:\d{1,2})'
|
||
r'|(?<=\S)\s{2,}(?=\S)',
|
||
part
|
||
)
|
||
for sp in sub_parts:
|
||
sp = sp.strip()
|
||
if sp:
|
||
segments.append(sp)
|
||
else:
|
||
segments.append(part)
|
||
|
||
if not segments:
|
||
return raw_text # 分割失败,回退到原始文本
|
||
|
||
# ---- 对各段单独做噪声检测 ----
|
||
clean_segments = []
|
||
noise_count = 0
|
||
for i, seg in enumerate(segments):
|
||
# ⭐ 2026-08-13: 先剥离bot旧回复,再检测噪声,防止混合文本被误判为噪声
|
||
stripped_seg = self._strip_bot_reply_fragments(seg)
|
||
is_noise = self._is_noise_text(stripped_seg) if stripped_seg else True
|
||
if is_noise:
|
||
noise_count += 1
|
||
logger.debug(f"[{platform}] 预分割: 段[{i}] 含噪声特征,丢弃: {stripped_seg[:60]!r}" if stripped_seg else f"[{platform}] 预分割: 段[{i}] 剥离bot回复后为空,丢弃: {seg[:60]!r}")
|
||
else:
|
||
clean_segments.append(stripped_seg)
|
||
logger.debug(f"[{platform}] 预分割: 段[{i}] 干净,保留: {stripped_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系统进程名/窗口标题:任务管理器、控制面板、文件资源管理器等
|
||
# ⭐ 修复(2026-08-10):匹配词后必须不是中文字符——词表含"服务/设置/声音/键盘"等
|
||
# 短词,re.search 会把"服务器"("服务"+"器")、"设置公告"误判为进程名。
|
||
# 负向前瞻 (?![\u4e00-\u9fff]):命中词后紧跟中文字符 → 判定为普通文本,不丢弃。
|
||
# 窗口标题如"文件资源管理器 - 此电脑""记事本.txt"(后跟ASCII)仍正常命中。
|
||
_proc_match = re.match(r'^(?:任务管理器|控制面板|文件资源管理器|回收站|计算器|画图|记事本|命令提示符|'
|
||
r'Windows安全中心|设置|网络和共享中心|设备管理器|磁盘管理|注册表编辑器|'
|
||
r'系统信息|系统配置|服务|性能监视器|事件查看器|远程桌面连接|'
|
||
r'程序和功能|Windows更新|防火墙|用户账户|电源选项|显示设置|'
|
||
r'声音|蓝牙|打印机|键盘|鼠标|日期和时间|区域|语言|辅助功能|'
|
||
r'备份和还原|系统保护|激活|防火墙|WindowsDefender|'
|
||
r'资源监视器|性能|任务计划程序|组件服务|计算机管理|'
|
||
r'Windows PowerShell|Windows终端|Visual Studio Code|'
|
||
r'Google Chrome|Microsoft Edge|Mozilla Firefox|'
|
||
r'微信|QQ|TIM|钉钉|飞书|企业微信|'
|
||
r'网易云音乐|酷狗音乐|QQ音乐|酷我音乐|'
|
||
r'腾讯视频|爱奇艺|优酷|哔哩哔哩|'
|
||
r'百度网盘|阿里云盘|腾讯微云|'
|
||
r'WPS Office|Microsoft Word|Microsoft Excel|Microsoft PowerPoint|'
|
||
r'Adobe Photoshop|Adobe Illustrator|Adobe Premiere|'
|
||
r'剪映|必剪|达芬奇|Final Cut Pro|'
|
||
r'Steam|Epic Games|WeGame|暴雪战网|'
|
||
r'微信读书|QQ阅读|Kindle|'
|
||
r'百度|搜狗|有道|金山|'
|
||
r'腾讯会议|Zoom|Teams|飞书会议|'
|
||
r'TeamViewer|向日葵|ToDesk|'
|
||
r'7-Zip|WinRAR|Bandizip|'
|
||
r'火绒|360安全卫士|腾讯电脑管家|卡巴斯基|迈克菲|'
|
||
r'VMware|VirtualBox|Hyper-V|'
|
||
r'Navicat|DBeaver|DataGrip|'
|
||
r'Xshell|SecureCRT|MobaXterm|'
|
||
r'Everything|Listary|Wox|uTools|'
|
||
r'QQ浏览器|360浏览器|搜狗浏览器|UC浏览器|'
|
||
r'迅雷|IDM|Motrix|'
|
||
r'有道翻译|百度翻译|DeepL|'
|
||
r'印象笔记|Notion|Obsidian|语雀|'
|
||
r'GitHub Desktop|SourceTree|TortoiseGit|'
|
||
r'Postman|Apifox|Swagger|'
|
||
r'Figma|Sketch|蓝湖|墨刀)(?![\u4e00-\u9fff])',
|
||
text)
|
||
if _proc_match:
|
||
# ⭐ 命中日志必须爆出具体词——谁撞上、内容是什么,不装哑巴
|
||
logger.warning(f"[ocr] 过滤器2结果: 检测到Windows系统进程名/窗口标题,返回True | "
|
||
f"命中词={_proc_match.group(0)!r} | 全文={text[:80]!r}")
|
||
return True
|
||
# ⭐ 病句过滤:语法不完整片段(缺主语/动词/括号不匹配)
|
||
# 如"不得进行任何形式,[" 缺少主语,不应入库
|
||
if re.match(r'^(?:不得|禁止|允许|应该|必须|可以|需要|支持|提供|进行|进行任何形式|不得进行)', text):
|
||
# 检查是否有主语(我/你/他/我们/你们/他们/玩家/用户/管理员等)
|
||
subjects = ('我', '你', '他', '她', '它', '我们', '你们', '他们', '她们', '玩家', '用户', '管理员', '服务端', '客户端')
|
||
if not any(s in text for s in subjects):
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到病句(缺主语),返回True")
|
||
return True
|
||
# 以逗号/顿号结尾的短片段(可能截断)
|
||
if len(text) < 10 and text.endswith((',', '、', ',', '.')):
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到短截断片段,返回True")
|
||
return True
|
||
# 括号不匹配(可能OCR截断)
|
||
# 清理末尾的不完整括号(OCR噪声),如"消息内容['"
|
||
text_clean = re.sub(r'[\[((]+$', '', text)
|
||
if text_clean.count('[') != text_clean.count(']') or text_clean.count('(') != text_clean.count(')') or text_clean.count('(') != text_clean.count(')'):
|
||
# 仅当文本较短时才判定为病句(避免误杀正常聊天)
|
||
if len(text_clean) < 30:
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到括号不匹配,返回True")
|
||
return True
|
||
# ⭐ 用户协议/服务条款文档内容检测(牛头不对马嘴修复)
|
||
# OCR截屏同时捕获聊天窗口+用户协议文档窗口,协议中的正式法律用语
|
||
# 被误当成用户消息触发自动回复。以下模式匹配典型的协议/条款语言:
|
||
# 1. "玩家/用户 应/必须/须/不得 + 尊重/遵守/履行/维护/泄露/损害"
|
||
# 2. "运营方/服务提供方 + 有权/有义务/保留"
|
||
# 3. "本协议/条款/规定/政策/规则" 开头
|
||
# 4. 同时含"权利"+"义务"的陈述句
|
||
# ⭐ 改用 re.search 匹配(不强制行首),防止前缀绕过
|
||
_AGREEMENT_PLAYER = re.compile(r'(?:玩家|用户)(?:应|必须|须|不得)(?:尊重|遵守|履行|维护|泄露|损坏|损害|侵害)')
|
||
_AGREEMENT_OPERATOR = re.compile(r'(?:运营方|服务提供方|本公司|我方)(?:有权|有义务|保留|不承担)')
|
||
_AGREEMENT_TITLE = re.compile(r'^\s*本(?:协议|条款|规定|政策|规则|指引)')
|
||
# 拓展:协议中常见的"骚扰、辱骂或攻击行为"、"合法权益"等措辞
|
||
_AGREEMENT_KEYWORDS = re.compile(
|
||
r'(?:骚扰|辱骂|攻击行为|合法权益|侵权行为|违规内容|'
|
||
r'不得进行任何形式|恶意行为|滥用|违反.*规定|'
|
||
r'服务条款|隐私政策|用户协议|免责条款|'
|
||
r'运营方.*权利|服务.*提供.*义务|'
|
||
r'我们.*保留.*权利|承担.*责任)'
|
||
)
|
||
# ⭐ 疑问词检测:文本含用户提问特征时,不应丢弃
|
||
_QUESTION_WORDS = ('?', '?', '吗', '呢', '哪些', '什么', '怎么', '如何', '为什么', '能', '可以')
|
||
is_question = any(q in text for q in _QUESTION_WORDS)
|
||
if is_question:
|
||
logger.debug(f"[ocr] 过滤器2: 检测到疑问词,跳过协议噪声检测")
|
||
else:
|
||
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:
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到含'权利'+'义务'的法律文档陈述句,返回True: {text[:60]!r}")
|
||
return True
|
||
# ⭐ 拓展协议关键词检测:短文本中出现多个协议特征词 → 高概率协议内容
|
||
if len(text) < 80 and _AGREEMENT_KEYWORDS.search(text):
|
||
# 至少匹配2个协议特征关键词才拦截,避免误杀正常聊天
|
||
_match_count = len(_AGREEMENT_KEYWORDS.findall(text))
|
||
if _match_count >= 2:
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到多个协议特征词({_match_count}个),返回True: {text[:60]!r}")
|
||
return True
|
||
# ⭐ 混合文字乱码检测:OCR将不同行/区域的文字混在一起
|
||
# 如"HHWH尸端自哪内柙登录槟J!" — Latin大写字母散落在CJK字符中间
|
||
# 正常聊天中Latin缩写是连续块(如"登录PHP服务器"、"GTA5玩家")
|
||
# 乱码特征:Latin大写被CJK分割成多个不连续块(≥2组)
|
||
#
|
||
# ⚠️ Task #1474 群聊例外(2026-08-09):
|
||
# 群聊OCR文本天然包含多个用户名(C418minecraft、BXZYAE、youpaishidifu等)
|
||
# 这些用户名分散在不同位置,会被误判为"混合文字乱码"。
|
||
# 以下模式是群聊正常特征,不应判为乱码:
|
||
# 1. @提及:@username
|
||
# 2. QQ群名片/头衔:【...】Username 或 [...] Username
|
||
# 3. 完整中文句子(含句末标点):说明是正常聊天而非乱码
|
||
if re.search(r'[A-Z]', text) and re.search(r'[\u4e00-\u9fff]', text):
|
||
# ── 群聊正常模式排除 ──
|
||
# 排除@提及(QQ群聊@用户是正常行为)
|
||
if re.search(r'@[A-Za-z0-9_\u4e00-\u9fff]+', text):
|
||
logger.debug(f"[ocr] 过滤器2: 检测到@提及模式,跳过混合乱码检测")
|
||
pass # 不return,继续后续检查
|
||
# 排除QQ群名片/头衔格式:【...】Username 或 [...]
|
||
elif re.search(r'[【\[][^】\]]*[】\]]\s*[A-Za-z]', text):
|
||
logger.debug(f"[ocr] 过滤器2: 检测到QQ群名片/头衔格式,跳过混合乱码检测")
|
||
pass # 不return,继续后续检查
|
||
# 排除含完整中文句子(有句末标点=正常聊天)
|
||
elif re.search(r'[\u4e00-\u9fff]{4,}[。!?!?…]+$|\?$', text):
|
||
logger.debug(f"[ocr] 过滤器2: 检测到完整中文句子,跳过混合乱码检测")
|
||
pass # 不return,继续后续检查
|
||
else:
|
||
_upper_groups = len(re.findall(r'[A-Z]+', text))
|
||
if _upper_groups >= 2:
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到混合文字乱码({_upper_groups}组大写字母分散),"
|
||
f"返回True: {text[:60]!r}")
|
||
return True
|
||
# ⭐ QQ系统消息过滤:群聊系统通知(邀请加入、退出等)
|
||
# 如"你邀请xx加入了本群"、"xx邀请了xx加入了群聊"、"xx加入了群聊"
|
||
_QQ_SYSTEM_MSG_PATTERNS = (
|
||
r'邀请.*加入了',
|
||
r'加入了本群',
|
||
r'加入了群聊',
|
||
r'退出了群聊',
|
||
r'被管理员移出群',
|
||
r'撤回了一条消息',
|
||
r'修改了群名称',
|
||
r'成为了管理员',
|
||
r'取消了管理员',
|
||
r'被禁言',
|
||
r'被解除禁言',
|
||
r'全体禁言',
|
||
r'解除全体禁言',
|
||
)
|
||
for pattern in _QQ_SYSTEM_MSG_PATTERNS:
|
||
if re.search(pattern, text):
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到QQ系统消息,返回True: {text[:60]!r}")
|
||
return True
|
||
# ⭐ Task #1457: 360安全弹窗内容过滤(防误触发查库+回复)
|
||
# 360安全卫士/杀毒弹窗常被OCR识别为用户消息,如"这是敲诈病毒,会勒索您的钱财"
|
||
# 以下关键词覆盖360常见报毒/安全警告弹窗
|
||
_SECURITY_POPUP_KEYWORDS = (
|
||
'敲诈病毒', '勒索您的钱财', '勒索病毒', '安全大脑', '极智守护',
|
||
'安全卫士', '安全防护', '安全中心', '病毒查杀', '木马查杀',
|
||
'恶意软件', '可疑程序', '高危风险', '立即处理', '立即清除',
|
||
'隔离区', '安全体检', '系统修复', '漏洞修复', '驱动大师',
|
||
'360安全', '360杀毒', '360卫士', '360弹窗', '360提示',
|
||
'这是敲诈', '勒索您的', '勒索钱财', '加密文件', '索要赎金',
|
||
'您的文件已被加密', '请支付赎金', '文件恢复',
|
||
)
|
||
if any(kw in text for kw in _SECURITY_POPUP_KEYWORDS):
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到安全软件弹窗内容,返回True: {text[:60]!r}")
|
||
return True
|
||
# ⭐ Task #1514: OCR病句乱码检测:数字混杂在中文字符间(如"游0由")
|
||
# OCR把不同区域文字拼接时,数字被嵌入中文字符中间形成乱码。
|
||
# 正常中文聊天中,数字通常出现在完整数字串(时间戳/数量/版本号)中,
|
||
# 不会单个数字夹在两个不相关的中文字符之间(如"游0由")。
|
||
# 特征:CJK + 单个数字 + CJK(数字前后都是中文字符)
|
||
# ⚠️ 排除正常场景:版本号(v1.0)、数量(3个)、时间(16:28)等
|
||
if re.search(r'[\u4e00-\u9fff]\d[\u4e00-\u9fff]', text):
|
||
# 检查是否为正常场景:数字是完整数字串的一部分(前后有数字或标点)
|
||
# 如"v1.0版本"(v+数字+数字+中文)、"3个"(数字+中文)、"16:28"(数字+冒号+数字)
|
||
# 异常场景:单个数字被两个不相关中文字符包围(如"游0由")
|
||
_cjk_digit_cjk = re.findall(r'([\u4e00-\u9fff])(\d)([\u4e00-\u9fff])', text)
|
||
for prefix, digit, suffix in _cjk_digit_cjk:
|
||
# 排除正常场景:数字前后是连续数字的一部分
|
||
# 检查该数字在原文中的位置,前后是否有其他数字或标点
|
||
_pos = text.find(prefix + digit + suffix)
|
||
if _pos >= 0:
|
||
_before = text[:_pos] if _pos > 0 else ''
|
||
_after = text[_pos + 3:] if _pos + 3 < len(text) else ''
|
||
# 前面有数字或标点 → 可能是正常数字串(如"v1.0版本"中的1)
|
||
# 后面有数字或标点 → 可能是正常数字串(如"16:28"中的6)
|
||
_is_normal = (
|
||
(_before and (_before[-1].isdigit() or _before[-1] in '.,:;:;,、')) or
|
||
(_after and (_after[0].isdigit() or _after[0] in '.,:;:;,、'))
|
||
)
|
||
if not _is_normal:
|
||
# 单个数字被两个不相关中文字符包围 → OCR病句乱码
|
||
# 但需排除常见合法组合:如"第1步"(序号)、"A1纸"(型号)等
|
||
_legal_combos = ('第', '步', '页', '行', '条', '项', '个', '只', '张', '台',
|
||
'台', '把', '件', '套', '组', '份', '篇', '章', '节',
|
||
'级', '等', '类', '种', '型', '号', '版', '次', '回')
|
||
if suffix in _legal_combos or prefix in ('第', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'):
|
||
continue # 合法组合,跳过
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到OCR病句乱码(数字混杂中文间'{prefix}{digit}{suffix}'),返回True: {text[:60]!r}")
|
||
return True
|
||
logger.debug(f"[ocr] 过滤器2结果: 未检测到噪声特征,返回False")
|
||
return False
|
||
|
||
# ⭐ OCR噪声前缀清理:去除UI按钮标签等OCR误识别的前缀
|
||
# 如"必选,运营方有哪些权利和义务?" → "运营方有哪些权利和义务?"
|
||
# 注意:前缀后必须跟标点/空格或字符串结束,不能紧跟中文字符
|
||
# 否则会误删"登录插件"中的"登录"
|
||
_OCR_NOISE_PREFIXES = re.compile(
|
||
r'^(?:必选|必填|可选|请输入|搜索|查找|查询|发送|取消|确认|确定|提交|保存|删除|编辑| '
|
||
r'|添加|新建|创建|设置|修改|返回|关闭|展开|收起|更多|选项|操作|'
|
||
r'选择|查看|下载|上传|导入|导出|刷新|重置|清空|复制|粘贴|剪切|'
|
||
r'同意|拒绝|同意并|下一步|上一步|完成|跳过|继续|登录|注册|退出|'
|
||
r'开启|关闭|启用|禁用|暂停|恢复|开始|停止|运行|'
|
||
r'是|否|有|无|对|错|真|假|好|坏|新|旧|已|未)'
|
||
r'[,,::\s]+|$'
|
||
)
|
||
|
||
@staticmethod
|
||
def _clean_ocr_prefix(text: str) -> str:
|
||
"""清理OCR噪声前缀(UI按钮标签等)。
|
||
|
||
如"必选,运营方有哪些权利和义务?" → "运营方有哪些权利和义务?"
|
||
只清理开头的短前缀(≤6字),避免误删正常消息内容。
|
||
"""
|
||
if not text or len(text) < 4:
|
||
return text
|
||
m = MessageMonitor._OCR_NOISE_PREFIXES.match(text)
|
||
if m and len(m.group(0)) <= 6:
|
||
cleaned = text[len(m.group(0)):].strip()
|
||
if cleaned and len(cleaned) >= 2:
|
||
logger.debug(f"[ocr] 清理噪声前缀: {text[:30]!r} → {cleaned[:30]!r}")
|
||
return cleaned
|
||
return text
|
||
|
||
def _paste_and_send_safe(self, text: str, hwnd: int, msg_hash: int, platform: str) -> bool:
|
||
"""Electron 应用安全发送(TIM/微信)。无 AttachThreadInput、无 SendMessage 同步点击。
|
||
|
||
Electron 多线程架构下 AttachThreadInput 会导致死锁,
|
||
SendMessage 同步阻塞在渲染进程 busy 时直接炸进程。
|
||
只用 SwitchToThisWindow + 前台确认后 keybd_event。
|
||
"""
|
||
import win32clipboard as wc
|
||
|
||
# 保存原始剪贴板
|
||
old = None
|
||
try:
|
||
wc.OpenClipboard()
|
||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||
old = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
|
||
success = False
|
||
try:
|
||
# 设剪贴板
|
||
try:
|
||
wc.OpenClipboard()
|
||
wc.EmptyClipboard()
|
||
wc.SetClipboardText(text, wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception as e:
|
||
logger.error(f"[{platform}] 剪贴板设文本失败: {e}")
|
||
return False
|
||
|
||
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
|
||
# 恢复/显示窗口(仅 SwitchToThisWindow + SetForegroundWindow,无 AttachThreadInput)
|
||
try:
|
||
if win32gui.IsIconic(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
|
||
time.sleep(0.3)
|
||
if not win32gui.IsWindowVisible(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
|
||
time.sleep(0.3)
|
||
ctypes.windll.user32.AllowSetForegroundWindow(-1)
|
||
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
|
||
time.sleep(0.2)
|
||
win32gui.SetForegroundWindow(root_hwnd)
|
||
time.sleep(0.3)
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] 窗口唤醒异常: {e}")
|
||
|
||
# 验证前台 — 失败则放弃,不做 SendMessage 防崩
|
||
fg_now = win32gui.GetForegroundWindow()
|
||
fg_ok = (fg_now == root_hwnd) or (fg_now and win32gui.IsChild(root_hwnd, fg_now))
|
||
if not fg_ok:
|
||
logger.warning(f"[{platform}] 前台失败,放弃发送(不做 SendMessage 防崩)")
|
||
return False
|
||
|
||
# keybd_event 发送(只有在前台确认后才做)
|
||
VK_A = 0x41
|
||
VK_V = 0x56
|
||
VK_BACK = 0x08
|
||
VK_RETURN = 0x0D
|
||
try:
|
||
self._send_ctrl_combo(VK_A)
|
||
time.sleep(0.05)
|
||
self._key_down(VK_BACK)
|
||
self._key_up(VK_BACK)
|
||
time.sleep(0.05)
|
||
self._send_ctrl_combo(VK_V)
|
||
time.sleep(0.3)
|
||
self._key_down(VK_RETURN)
|
||
self._key_up(VK_RETURN)
|
||
time.sleep(0.5)
|
||
except Exception as e:
|
||
logger.error(f"[{platform}] keybd_event 发送失败: {e}")
|
||
return False
|
||
|
||
logger.info(f"[{platform}] 已回复(安全路径): {text[:60]}")
|
||
|
||
# 记录到消息历史数据库
|
||
self._msg_history.record_bot_reply(platform, text.strip())
|
||
|
||
# 记录回复到去重集(与 _paste_and_send 一致)
|
||
reply_text = text.strip()
|
||
self._replied_texts.add(reply_text)
|
||
self._seen_messages.add(reply_text)
|
||
if '\n' in reply_text:
|
||
self._replied_texts.add(reply_text.replace('\n', ' '))
|
||
self._replied_texts.add(reply_text.replace('\n', ' ').rstrip('.'))
|
||
self._seen_messages.add(reply_text.replace('\n', ' '))
|
||
self._seen_messages.add(reply_text.replace('\n', ' ').rstrip('.'))
|
||
for part in reply_text.split('\n'):
|
||
part = part.strip()
|
||
if part and len(part) > 3:
|
||
self._replied_texts.add(part)
|
||
self._seen_messages.add(part)
|
||
if part.startswith('#'):
|
||
no_hash = re.sub(r'^#+\s*', '', part).strip()
|
||
if no_hash and len(no_hash) > 2:
|
||
self._replied_texts.add(no_hash)
|
||
self._seen_messages.add(no_hash)
|
||
self._last_reply_time[platform] = time.time()
|
||
self._self_reply_cooldown_until[platform] = time.time() + 15.0
|
||
flat = reply_text.replace('\n', ' ')
|
||
if len(flat) > 15:
|
||
self._replied_texts.add(flat[:30])
|
||
if len(flat) > 40:
|
||
self._replied_texts.add(flat[-30:].strip())
|
||
self._foreground_cooldown_until[platform] = time.time() + 30.0
|
||
success = True
|
||
return True
|
||
finally:
|
||
# 恢复原始剪贴板
|
||
if old is not None:
|
||
try:
|
||
wc.OpenClipboard()
|
||
wc.EmptyClipboard()
|
||
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
# 失败时记录冷却
|
||
if not success:
|
||
self._failed_send_cooldown[msg_hash] = time.time()
|
||
self._seen_messages.add(text.strip())
|
||
return True
|
||
|
||
def _paste_and_send(self, platform: str, text: str, force_retry: bool = False) -> bool:
|
||
"""粘贴文本到输入框并发送(剪贴板 + SendInput)。"""
|
||
hwnd = self._cached_hwnd.get(platform)
|
||
if not hwnd:
|
||
return False
|
||
|
||
# 失败冷却检查:60 秒内同一消息不重试(防止卡死)
|
||
# force_retry=True 时跳过冷却(用于 _execute_reply 主动重试场景)
|
||
msg_hash = hash(text) % 100000
|
||
if not force_retry:
|
||
last_fail = self._failed_send_cooldown.get(msg_hash, 0.0)
|
||
if time.time() - last_fail < 60.0:
|
||
logger.debug(f"[{platform}] 发送冷却中({int(60-(time.time()-last_fail))}s),跳过")
|
||
return False
|
||
|
||
import win32clipboard as wc
|
||
|
||
# 保存剪贴板
|
||
old = None
|
||
try:
|
||
wc.OpenClipboard()
|
||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||
old = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
# 设置答案到剪贴板
|
||
wc.OpenClipboard()
|
||
wc.EmptyClipboard()
|
||
wc.SetClipboardText(text, wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
|
||
# ⛔ 剪贴板回读校验:设完立即读回,确认内容一致才继续
|
||
# 防止 monitor 线程的 _read_via_clipboard_once 在设与用之间 Ctrl+C 覆盖
|
||
try:
|
||
wc.OpenClipboard()
|
||
if wc.IsClipboardFormatAvailable(wc.CF_UNICODETEXT):
|
||
verify = wc.GetClipboardData(wc.CF_UNICODETEXT)
|
||
else:
|
||
verify = None
|
||
wc.CloseClipboard()
|
||
if verify != text:
|
||
logger.error(f"[{platform}] 剪贴板校验失败: 设了{len(text)}c但读回{len(verify or '')}c,可能被其他线程覆盖")
|
||
raise RuntimeError("clipboard verification failed")
|
||
except RuntimeError:
|
||
raise
|
||
except Exception:
|
||
logger.error(f"[{platform}] 剪贴板校验异常,放弃发送")
|
||
raise
|
||
|
||
# ── Electron 安全发送路径:TIM/微信都无 AttachThreadInput、无 SendMessage ──
|
||
if (platform == "qq" and self._app_variant.get("qq") == "tim") or platform == "wechat":
|
||
return self._paste_and_send_safe(text, hwnd, msg_hash, platform)
|
||
|
||
# ── 更激进的窗口唤醒 ──
|
||
# 1) 允许任意进程 SetForegroundWindow(移除系统限制)
|
||
try:
|
||
ctypes.windll.user32.AllowSetForegroundWindow(-1) # ASFW_ANY
|
||
except Exception:
|
||
pass
|
||
|
||
# 2) 恢复/显示窗口
|
||
root_hwnd = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
|
||
if win32gui.IsIconic(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_RESTORE)
|
||
time.sleep(0.3)
|
||
if not win32gui.IsWindowVisible(root_hwnd):
|
||
win32gui.ShowWindow(root_hwnd, win32con.SW_SHOWNORMAL)
|
||
time.sleep(0.3)
|
||
|
||
# 3) AttachThreadInput + SetForegroundWindow(不发送全局 Alt 键干扰 QQ 焦点)
|
||
try:
|
||
self._attach_set_foreground(root_hwnd)
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] _attach_set_foreground 异常: {e}")
|
||
time.sleep(0.2)
|
||
|
||
# 4) 再 SetForegroundWindow + SwitchToThisWindow
|
||
try:
|
||
win32gui.SetForegroundWindow(root_hwnd)
|
||
ctypes.windll.user32.SwitchToThisWindow(root_hwnd, True)
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] SetForegroundWindow/SwitchToThisWindow 异常: {e}")
|
||
time.sleep(0.3)
|
||
|
||
# 5) 验证 foreground — 不成功则用 SendMessage 点击 + keybd_event
|
||
VK_A = 0x41
|
||
VK_V = 0x56
|
||
VK_BACK = 0x08
|
||
VK_RETURN = 0x0D
|
||
|
||
fg_now = win32gui.GetForegroundWindow()
|
||
fg_ok = (fg_now == root_hwnd) or (fg_now and win32gui.IsChild(root_hwnd, fg_now))
|
||
if not fg_ok:
|
||
logger.warning(f"[{platform}] 前台唤醒失败(fg={fg_now}, target={root_hwnd}),SendMessage 点击+keybd_event 兜底")
|
||
# SendMessage 同步点击设置焦点(Electron 接受 SendMessage 点击)
|
||
rect = win32gui.GetWindowRect(hwnd)
|
||
if rect:
|
||
left, top, right, bottom = rect
|
||
w = right - left
|
||
h = bottom - top
|
||
click_x = int(w * 0.72)
|
||
click_y = int(h * 0.92)
|
||
lparam = win32api.MAKELONG(click_x, click_y)
|
||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
|
||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
|
||
time.sleep(0.3)
|
||
# 再试一次 SetForegroundWindow(SendMessage 点击后可能已生效)
|
||
self._attach_set_foreground(root_hwnd)
|
||
try:
|
||
win32gui.SetForegroundWindow(root_hwnd)
|
||
except Exception:
|
||
pass
|
||
time.sleep(0.2)
|
||
|
||
# keybd_event 发送(不依赖 PostMessage——Electron 不吃 PostMessage 键盘)
|
||
self._send_ctrl_combo(VK_A)
|
||
time.sleep(0.05)
|
||
self._key_down(VK_BACK)
|
||
self._key_up(VK_BACK)
|
||
time.sleep(0.05)
|
||
self._send_ctrl_combo(VK_V)
|
||
time.sleep(0.3)
|
||
self._key_down(VK_RETURN)
|
||
self._key_up(VK_RETURN)
|
||
time.sleep(0.5)
|
||
logger.info(f"[{platform}] 已回复(SendMessage+keybd): {text[:60]}")
|
||
# 记录到消息历史数据库
|
||
self._msg_history.record_bot_reply(platform, text.strip())
|
||
else:
|
||
# ── 前台成功:SendMessage 点击输入框(同步,确保焦点) + keybd_event ──
|
||
rect = win32gui.GetWindowRect(hwnd)
|
||
if rect:
|
||
left, top, right, bottom = rect
|
||
w = right - left
|
||
h = bottom - top
|
||
click_x = int(w * 0.72)
|
||
click_y = int(h * 0.92)
|
||
lparam = win32api.MAKELONG(click_x, click_y)
|
||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lparam)
|
||
win32gui.SendMessage(hwnd, win32con.WM_LBUTTONUP, 0, lparam)
|
||
time.sleep(0.2)
|
||
|
||
# keybd_event 发送(窗口已在前台)
|
||
self._send_ctrl_combo(VK_A)
|
||
time.sleep(0.05)
|
||
self._key_down(VK_BACK)
|
||
self._key_up(VK_BACK)
|
||
time.sleep(0.05)
|
||
self._send_ctrl_combo(VK_V)
|
||
time.sleep(0.3)
|
||
self._key_down(VK_RETURN)
|
||
self._key_up(VK_RETURN)
|
||
time.sleep(0.5)
|
||
logger.info(f"[{platform}] 已回复: {text[:60]}")
|
||
# 记录到消息历史数据库
|
||
self._msg_history.record_bot_reply(platform, text.strip())
|
||
|
||
# 记录回复文本到去重集
|
||
# 将回复文本加入 _replied_texts(永久集,永不裁切,必拦)和 seen
|
||
reply_text = text.strip()
|
||
self._replied_texts.add(reply_text)
|
||
self._seen_messages.add(reply_text)
|
||
# 添加换行符归一化变体(跨频道转发时会替换 \n → 空格)
|
||
if '\n' in reply_text:
|
||
self._replied_texts.add(reply_text.replace('\n', ' '))
|
||
self._replied_texts.add(reply_text.replace('\n', ' ').rstrip('.'))
|
||
self._seen_messages.add(reply_text.replace('\n', ' '))
|
||
self._seen_messages.add(reply_text.replace('\n', ' ').rstrip('.'))
|
||
for part in reply_text.split('\n'):
|
||
part = part.strip()
|
||
if part and len(part) > 3:
|
||
self._replied_texts.add(part)
|
||
self._seen_messages.add(part)
|
||
# 同时追踪去掉 ## 前缀的标题文本(OCR 常丢失 ## 后的空格)
|
||
if part.startswith('#'):
|
||
no_hash = re.sub(r'^#+\s*', '', part).strip()
|
||
if no_hash and len(no_hash) > 2:
|
||
self._replied_texts.add(no_hash)
|
||
self._seen_messages.add(no_hash)
|
||
self._last_reply_time[platform] = time.time()
|
||
# 同平台 15s 冷却:回复回声截到也不触发
|
||
self._self_reply_cooldown_until[platform] = time.time() + 15.0
|
||
# 追加前30字符/后30字符变体(OCR 碎片常截取回复的开头或结尾)
|
||
flat = reply_text.replace('\n', ' ')
|
||
if len(flat) > 15:
|
||
self._replied_texts.add(flat[:30])
|
||
if len(flat) > 40:
|
||
self._replied_texts.add(flat[-30:].strip())
|
||
# 回复涉及前景切换(QQ Alt+Tab / 微信 SendMessage 点击)
|
||
# → 对端平台冷却 30s,避免另一平台截到相同回复内容又触发自动回复
|
||
other = "qq" if platform == "wechat" else "wechat"
|
||
self._foreground_cooldown_until[other] = time.time() + 30.0
|
||
# ⭐ 设置hash回复标志:消息已回复,允许后续相同hash帧跳过
|
||
self._last_hash_replied[platform] = True
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"[{platform}] 回复失败: {e}\n{traceback.format_exc()}")
|
||
# 进入冷却期,60 秒内不重试同一条消息
|
||
self._failed_send_cooldown[msg_hash] = time.time()
|
||
# 加入 seen_messages 防止下一轮 OCR 读到相同文本又触发
|
||
self._seen_messages.add(text.strip())
|
||
return False
|
||
finally:
|
||
# 恢复原始剪贴板
|
||
if old is not None:
|
||
try:
|
||
wc.OpenClipboard()
|
||
wc.EmptyClipboard()
|
||
wc.SetClipboardText(old, wc.CF_UNICODETEXT)
|
||
wc.CloseClipboard()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 核心:读消息 ──────────────────────────────────────────
|
||
|
||
def _read_messages(self, window, platform: str = "") -> list:
|
||
"""每轮重新定位 ListControl。"""
|
||
if platform == "qq":
|
||
# QQ NT: OCR 读屏优先(零依赖,不切焦点,不碰剪贴板)
|
||
texts = self._ocr_read_qq()
|
||
if texts:
|
||
logger.debug(f"[qq] 消息来源: OCR → {len(texts)} 条")
|
||
return texts
|
||
# 截图未变→跳过所有 fallback(clipboard/UIA 只会读旧数据)
|
||
if self._ocr_unchanged.get("qq"):
|
||
return []
|
||
# TIM (Electron) 安全:OC R 空时跳过所有 fallback。
|
||
# 剪贴板路径调 _ensure_foreground → AttachThreadInput → 崩 TIM;
|
||
# UIA / Win32 读 Electron 窗口也无意义(结构不稳定)。
|
||
if self._app_variant.get("qq") == "tim":
|
||
return []
|
||
|
||
# OCR 空 → 剪贴板兜底(窗口置前→滚底→全选→复制,内部自带重试)
|
||
texts = self._read_via_clipboard_once(platform)
|
||
if texts and len(texts) > 5:
|
||
logger.debug(f"[qq] 消息来源: 剪贴板 → {len(texts)} 条")
|
||
return texts
|
||
|
||
# 剪贴板也空 → UIA 最后兜底(可能返回陈旧数据)
|
||
fresh_win = self._find_qq_window_fresh()
|
||
if fresh_win:
|
||
texts = self._read_message_list(fresh_win)
|
||
if texts:
|
||
logger.debug(f"[qq] 消息来源: UIA消息列表 → {len(texts)} 条")
|
||
return texts
|
||
texts = self._read_conversation_list(fresh_win)
|
||
if texts:
|
||
logger.debug(f"[qq] 消息来源: UIA会话列表 → {len(texts)} 条")
|
||
return texts
|
||
try:
|
||
rect = fresh_win.BoundingRectangle
|
||
boundary = rect.left + rect.width() * 0.40
|
||
for lst in self._list_controls(fresh_win):
|
||
try:
|
||
cr = lst.BoundingRectangle
|
||
if cr.left >= boundary:
|
||
texts = self._extract_last_line(lst)
|
||
if texts:
|
||
logger.debug(f"[S-C] ListControl(x={cr.left}) → {len(texts)} items")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
try:
|
||
texts = self._collect_filtered_texts(fresh_win, "qq", max_depth=5)
|
||
if texts:
|
||
_before = len(texts)
|
||
texts = [t for t in texts if t not in _UI_EXACT_BLOCKLIST]
|
||
if texts:
|
||
logger.debug(f"[S-D] 全窗文本 → {_before} items ({_before - len(texts)} UI过滤)")
|
||
return texts
|
||
return []
|
||
except Exception:
|
||
pass
|
||
|
||
# Win32 兜底
|
||
texts = self._read_win32_texts(platform)
|
||
if texts:
|
||
logger.debug(f"[qq] 消息来源: Win32 → {len(texts)} 条")
|
||
return texts
|
||
return []
|
||
|
||
# 微信: OCR 读屏优先(同 QQ 策略,UIA 树不暴露消息内容)
|
||
texts = self._ocr_read_wechat()
|
||
if texts:
|
||
return texts
|
||
# 截图未变→跳过 fallback
|
||
if self._ocr_unchanged.get("wechat"):
|
||
return []
|
||
# 未提供 UIA 控件(_poll 跳过了 _fresh_window 避免 Electron 冻结)
|
||
# 此时惰性创建,仅在 OCR 失败后 fallback 时才需要
|
||
if window is None:
|
||
window = self._fresh_window(platform)
|
||
if window is None:
|
||
return []
|
||
# 微信: UIA 兜底(可能读到陈旧数据或 UI 文字)
|
||
texts = self._read_message_list(window)
|
||
if texts:
|
||
return texts
|
||
# 兜底:会话列表
|
||
texts = self._read_conversation_list(window)
|
||
if texts:
|
||
return texts
|
||
try:
|
||
rect = window.BoundingRectangle
|
||
boundary = rect.left + rect.width() * 0.40
|
||
for lst in self._list_controls(window):
|
||
try:
|
||
cr = lst.BoundingRectangle
|
||
if cr.left >= boundary:
|
||
texts = self._extract_last_line(lst)
|
||
if texts:
|
||
logger.debug(f"[S-C] ListControl(x={cr.left}) → {len(texts)} items")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
try:
|
||
texts = self._collect_filtered_texts(window, "wechat", max_depth=5)
|
||
if texts:
|
||
# Smart-Dump 产出 UI 文本不经过 _process_ocr_output 过滤链
|
||
# → 手动过 UI 块列表
|
||
_before = len(texts)
|
||
texts = [t for t in texts if t not in _UI_EXACT_BLOCKLIST]
|
||
if texts:
|
||
logger.debug(f"[S-D] 全窗文本 → {_before} items ({_before - len(texts)} UI过滤)")
|
||
return texts
|
||
return []
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def _read_message_list(self, window) -> list:
|
||
# 只接受窗口右半侧(>45%)的 ListControl,避开侧栏/文件列表用同名控件
|
||
try:
|
||
wr = window.BoundingRectangle
|
||
boundary = wr.left + wr.width() * 0.45
|
||
except Exception:
|
||
boundary = 0
|
||
for name in ["消息", "Message"]:
|
||
try:
|
||
lst = window.ListControl(Name=name)
|
||
if lst.Exists(maxSearchSeconds=0.2):
|
||
try:
|
||
cr = lst.BoundingRectangle
|
||
if cr.left < boundary:
|
||
continue # 左侧的"消息"列表(文件/笔记预览)跳过
|
||
except Exception:
|
||
pass
|
||
self._scroll_to_bottom(lst)
|
||
texts = self._extract_last_line(lst)
|
||
if texts:
|
||
logger.debug(f"[S-A] ListControl({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def _read_conversation_list(self, window) -> list:
|
||
try:
|
||
wr = window.BoundingRectangle
|
||
boundary = wr.left + wr.width() * 0.45
|
||
except Exception:
|
||
boundary = 0
|
||
for name in ["会话", "聊天", "Chat"]:
|
||
try:
|
||
lst = window.ListControl(Name=name)
|
||
if lst.Exists(maxSearchSeconds=0.2):
|
||
try:
|
||
cr = lst.BoundingRectangle
|
||
if cr.left < boundary:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
self._scroll_to_bottom(lst)
|
||
texts = self._extract_second_line(lst)
|
||
if texts:
|
||
logger.debug(f"[S-B] 会话({name}) → {len(texts)} items, last=\"{texts[-1][:50]}\"")
|
||
return texts
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def _scroll_to_bottom(self, lst):
|
||
"""滚动 ListControl 到底部,触发应用加载新消息。"""
|
||
try:
|
||
scroll = lst.GetScrollPattern()
|
||
if scroll:
|
||
scroll.SetScrollPercent(100, 0)
|
||
except Exception:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _extract_last_line(lst) -> list:
|
||
texts = []
|
||
try:
|
||
for item in lst.GetChildren():
|
||
try:
|
||
name = item.Name
|
||
if name and name.strip():
|
||
lines = [l.strip() for l in name.split("\n") if l.strip()]
|
||
if not lines:
|
||
continue
|
||
# NFKC 标准化(去除零宽字符后比较)
|
||
norm = unicodedata.normalize('NFKC', lines[-1])
|
||
# 跳过末尾按钮文字(微信消息项的"关闭"等)和时间戳
|
||
ui_known = {"关闭", "发送", "接收", "取消", "确定", "更多"}
|
||
if len(lines) >= 2 and (norm in ui_known or re.match(r'^\d{1,2}:\d{2}', norm)):
|
||
texts.append(lines[-2])
|
||
else:
|
||
texts.append(lines[-1])
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return texts
|
||
|
||
@staticmethod
|
||
def _extract_deep_text(lst) -> list:
|
||
"""深度递归提取 ListControl 内所有控件的 Name 文本(最多3层)。"""
|
||
texts = []
|
||
|
||
def walk(ctrl, depth):
|
||
if depth > 3:
|
||
return
|
||
try:
|
||
name = ctrl.Name
|
||
if name and name.strip():
|
||
texts.append(name.strip())
|
||
except Exception:
|
||
pass
|
||
try:
|
||
for child in ctrl.GetChildren():
|
||
walk(child, depth + 1)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
for item in lst.GetChildren():
|
||
walk(item, 0)
|
||
except Exception:
|
||
pass
|
||
return texts
|
||
|
||
@staticmethod
|
||
def _extract_second_line(lst) -> list:
|
||
texts = []
|
||
try:
|
||
for item in lst.GetChildren():
|
||
try:
|
||
name = item.Name
|
||
if name and name.strip():
|
||
lines = [l.strip() for l in name.split("\n") if l.strip()]
|
||
if len(lines) >= 2:
|
||
texts.append(lines[1])
|
||
elif lines:
|
||
texts.append(lines[-1])
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return texts
|
||
|
||
@staticmethod
|
||
def _list_controls(window):
|
||
results = []
|
||
try:
|
||
for ctrl in window.GetChildren():
|
||
results.extend(_walk_list_controls(ctrl, 0))
|
||
except Exception:
|
||
pass
|
||
return results
|
||
|
||
@staticmethod
|
||
def _collect_filtered_texts(window, platform: str = "", max_depth=14) -> list:
|
||
"""深度遍历 + 位置过滤(排除搜索框/标题栏区域)。"""
|
||
items = [] # [(text, rect_top, rect_left, depth)]
|
||
|
||
def walk(ctrl, depth):
|
||
if depth > max_depth:
|
||
return
|
||
try:
|
||
cr = ctrl.BoundingRectangle
|
||
if cr.width() < 20 or cr.height() < 20:
|
||
return
|
||
except Exception:
|
||
return
|
||
try:
|
||
name = ctrl.Name
|
||
if name and name.strip():
|
||
t = name.strip()
|
||
if len(t) >= 2 and not t.isdigit():
|
||
items.append((t, cr.top, cr.left, depth))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
for child in ctrl.GetChildren():
|
||
walk(child, depth + 1)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
for child in window.GetChildren():
|
||
walk(child, 1)
|
||
except Exception:
|
||
pass
|
||
|
||
if not items:
|
||
return []
|
||
|
||
try:
|
||
w_rect = window.BoundingRectangle
|
||
w_width = w_rect.width()
|
||
w_height = w_rect.height()
|
||
except Exception:
|
||
w_width = 1390
|
||
w_height = 953
|
||
|
||
if platform == "qq":
|
||
filtered = [
|
||
t for t, top, left, d in items
|
||
if top > w_height * 0.08
|
||
]
|
||
seen = set()
|
||
deduped = []
|
||
for t in filtered:
|
||
if t not in seen:
|
||
seen.add(t)
|
||
deduped.append(t)
|
||
return deduped
|
||
|
||
return [t for t, _, _, _ in items]
|
||
|
||
# ── 单次轮询 ─────────────────────────────────────────────
|
||
|
||
def _poll(self, platform: str):
|
||
# ⚠️ 急停检查:OCR 期间用户可能已按 ESC 急停,必须立即折返
|
||
if not self._running:
|
||
return
|
||
# 自动回复发送中跳过轮询(防止窗口焦点被抢)
|
||
if self._sending_event.is_set():
|
||
return
|
||
|
||
# 对端平台冷却:刚切过前台(回复发送),给调试覆盖层消失时间
|
||
cooldown = self._foreground_cooldown_until.get(platform, 0.0)
|
||
if time.time() < cooldown:
|
||
return
|
||
|
||
# 冷却期:回复后 5 秒内跳过 poll,防止反馈回环
|
||
last_reply = self._last_reply_time.get(platform, 0.0)
|
||
if time.time() - last_reply < 5.0:
|
||
return
|
||
|
||
counter = self._poll_count.get(platform, 0) + 1
|
||
self._poll_count[platform] = counter
|
||
|
||
# 1. 确保窗口句柄
|
||
# qq 路径纯 Win32+psutil,直接调用不超时;wechat 用 UIA(可能挂死)才套子线程
|
||
if platform == "qq":
|
||
hwnd = self._ensure_hwnd(platform)
|
||
else:
|
||
hwnd = self._ensure_hwnd_with_timeout(platform, timeout=12.0)
|
||
if not hwnd:
|
||
if counter % 10 == 0 or (counter == 1 and platform == "qq"):
|
||
logger.debug(f"[{platform}] 窗口未找到")
|
||
return
|
||
|
||
# QQ 在托盘隐藏(IsWindowVisible=False)时跳过本轮。
|
||
# 任何尝试恢复隐藏窗口的操作(SW_SHOWNORMAL 等)在 QQ NT 上都会触发托盘预览弹窗,
|
||
# 不会恢复主聊天窗口。用户保持 QQ 窗口最小化到任务栏即可。
|
||
if platform == "qq" and hwnd and win32gui.IsWindow(hwnd) and not win32gui.IsWindowVisible(hwnd):
|
||
return
|
||
|
||
# 2. 从 HWND 创建全新 Control(绕过缓存)
|
||
# QQ: 需要 UIA 控件做多种 fallback 读取(ListControl、全窗爬文本等)
|
||
# 微信( Electron ): ControlFromHandle 构建 UIA 控件树冻结 3-5s,
|
||
# 而微信 OCR 读到消息就直接返回,不需要 UIA。只在 OCR 空/fallback 时创建。
|
||
if platform == "qq":
|
||
win = self._fresh_window(platform)
|
||
if not win:
|
||
logger.debug(f"[{platform}] 创建窗口控件失败")
|
||
return
|
||
else:
|
||
win = None # 惰性创建:OCR 失败后需要 fallback 时再建
|
||
|
||
# 3. 读消息(Windows OCR 同步阻塞,可能耗时 2-5s)
|
||
texts = self._read_messages(win, platform)
|
||
|
||
# ⚠️ 急停检查:OCR阻塞可能持续数秒,返回后立即检查,丢弃半成品
|
||
if not self._running:
|
||
return
|
||
|
||
# ⭐ hash 跳过鲁棒性:记录本轮 OCR 是否产出可处理消息。
|
||
# - 被 hash 跳过(_ocr_unchanged)→ 不动标志(沿用上一帧结果)
|
||
# - OCR 产出消息 → 标记已入队,允许后续相同 hash 帧跳过
|
||
# - OCR 运行但无消息(被过滤)→ 标记未入队,后续相同 hash 帧重新 OCR,
|
||
# 避免"捕捉到但没入队"的消息被永久跳过。
|
||
if self._ocr_unchanged.get(platform):
|
||
pass
|
||
elif texts:
|
||
self._last_hash_queued[platform] = True
|
||
# ⭐ OCR返回文本但未被回复,重置hash回复标志(等待消息被回复)
|
||
# 注意:_last_hash_replied 在消息被回复时才设置为True
|
||
if not self._last_hash_replied.get(platform, False):
|
||
self._last_hash_replied[platform] = False
|
||
else:
|
||
self._last_hash_queued[platform] = False
|
||
self._last_hash_replied[platform] = False
|
||
|
||
# ⚠️ OCR 返回后立即检查急停标志:急停可能在 OCR 阻塞期间触发,
|
||
# Windows OCR 无法被中断,但后续文本处理可以立即跳过。
|
||
if not self._running:
|
||
return
|
||
|
||
if not texts:
|
||
# 截图未变→不计入空读,不触发清缓存
|
||
if self._ocr_unchanged.get(platform):
|
||
return
|
||
empty = self._empty_reads.get(platform, 0) + 1
|
||
self._empty_reads[platform] = empty
|
||
if counter % 5 == 0:
|
||
logger.debug(f"[{platform}] 空读 #{empty}")
|
||
if empty >= 5:
|
||
self._cached_hwnd.pop(platform, None)
|
||
self._cached_pid.pop(platform, None)
|
||
self._empty_reads[platform] = 0
|
||
logger.info(f"[{platform}] 连续空读,清窗口缓存重试")
|
||
return
|
||
|
||
self._empty_reads[platform] = 0
|
||
|
||
# ⚠️ 急停检查:OCR返回文本后,处理前再查一次
|
||
if not self._running:
|
||
return
|
||
|
||
# 冻结计数器(不在 early-return 里定义,保证所有路径都能访问)
|
||
stale = 0
|
||
|
||
# 4a. 全文本签名快速跳过 — OCR 输出与上轮完全相同 → 无变化,跳过
|
||
sig = "||".join([t.strip() for t in texts if t.strip()])
|
||
if sig == self._last_sig.get(platform, ""):
|
||
# 检查是否有未回复的消息(防止未回复消息被永久跳过)
|
||
unreplied_texts = [t for t in texts if t.strip() and t.strip() not in self._replied_texts]
|
||
if not unreplied_texts:
|
||
# 所有消息均已回复,正常跳过
|
||
stale = self._stale_count.get(platform, 0) + 1
|
||
self._stale_count[platform] = stale
|
||
logger.debug(f"[{platform}] Poll: 签名相同(#{stale})静默跳过: {sig[:60]!r}")
|
||
return
|
||
else:
|
||
# 有未回复消息,继续处理(不跳过)
|
||
logger.info(f"[{platform}] Poll: 签名相同但有{len(unreplied_texts)}条未回复消息,继续处理: {[t[:30] for t in unreplied_texts]}")
|
||
# 仍然更新 stale 计数器,但不 return
|
||
stale = self._stale_count.get(platform, 0) + 1
|
||
self._stale_count[platform] = stale
|
||
|
||
# 4. 用全局 _seen_messages 去重(跨平台、跨轮次防回环)
|
||
# 精确匹配 + 模糊匹配(OCR 噪声导致精确匹配失效)
|
||
seen = self._seen_messages
|
||
replied = self._replied_texts
|
||
# ⭐ 动态获取近5分钟的bot回复,用于时间窗口查重
|
||
recent_replies = []
|
||
if self._msg_history:
|
||
try:
|
||
recent_replies = self._msg_history.get_recent_bot_replies(platform, limit=50, within_minutes=5)
|
||
except Exception as e:
|
||
logger.error(f"[{platform}] 获取近5分钟回复失败: {e}")
|
||
new_items = []
|
||
for t in texts:
|
||
ts = t.strip()
|
||
if not ts:
|
||
continue
|
||
# === URL-only 兜底:无中文且含 URL → 碎片 ===
|
||
if not re.search(r'[\u4e00-\u9fff]', ts) and re.search(r'https?\S', ts, re.IGNORECASE):
|
||
logger.debug(f"[{platform}] Poll过滤: URL-only碎片 ts={ts!r}")
|
||
|
||
# === Bot自身回复过滤:pipe 表格格式(|参数1|参数2|参数3|结果|) ===
|
||
pipe_count = ts.count('|')
|
||
if pipe_count >= 3 and len(ts) > 30:
|
||
logger.debug(f"[{platform}] Poll过滤: bot表格(pipe={pipe_count}) ts={ts!r}")
|
||
continue
|
||
# === Bot回复内容过滤:英文key=value配置格式 ===
|
||
if re.search(r'[a-zA-Z_][\w]*\s*=\s*\S', ts) and not re.search(r'[\u4e00-\u9fff]', ts):
|
||
logger.debug(f"[{platform}] Poll过滤: bot配置(key=value) ts={ts!r}")
|
||
continue
|
||
# === Bot回复内容过滤:配置文件描述片段 ===
|
||
# ⚠️ 要求同时包含配置短语 + 英文关键词 + 配置格式(=或:),避免误过滤用户讨论配置的消息
|
||
_config_phrases = ('配置此处的文件', '负责展示', '对应一个商品', '使用md文件格式',
|
||
'配置文件', '该配置', '此配置', '参数配置', '配置项')
|
||
_config_keywords = ('invite', 'referral', 'reward', 'bonus')
|
||
has_config_phrase = any(p in ts for p in _config_phrases)
|
||
has_config_keyword = re.search(r'invite|referral|reward|bonus', ts, re.IGNORECASE)
|
||
has_config_format = re.search(r'[=:]\s*\S', ts) # 包含 = 或 : 后跟非空白字符
|
||
if has_config_phrase and has_config_keyword and has_config_format:
|
||
logger.debug(f"[{platform}] Poll过滤: bot配置说明(三重匹配) ts={ts!r}")
|
||
continue
|
||
# === KB内容回读过滤:bot旧回复中的KB段落标题/内容 ===
|
||
_kb_markers = ('奖励:', '规则:', '配置:', '说明:', '要求:', '限制:', '条件:', '流程:',
|
||
'奖励:', '规则:', '配置:', '说明:', '要求:', '限制:', '条件:', '流程:')
|
||
if any(m in ts for m in _kb_markers):
|
||
logger.debug(f"[{platform}] Poll过滤: KB内容回读 ts={ts!r}")
|
||
continue
|
||
# === Bot回复内容过滤:百分比+金额描述 ===
|
||
if re.search(r'\d+\s*%', ts) and re.search(r'(金额|奖励|积分|返利|分成)', ts):
|
||
logger.debug(f"[{platform}] Poll过滤: 奖励金额描述 ts={ts!r}")
|
||
continue
|
||
# === 纯非中文乱码:OCR 阅读到终端 log 或 URL 啃碎,中文群必有中文 ===
|
||
if not re.search(r'[\u4e00-\u9fff]', ts) and len(ts) > 15 and not ts.startswith('/'):
|
||
logger.debug(f"[{platform}] Poll过滤: 无中文乱码 ts={ts!r}")
|
||
continue
|
||
# === CJK 比例过低乱码:EasyOCR 把 URL/代码认成罕见汉字混一堆字母 ===
|
||
if len(ts) > 15 and re.search(r'[A-Za-z]', ts):
|
||
cjk = sum(1 for c in ts if '\u4e00' <= c <= '\u9fff')
|
||
if cjk > 0 and cjk / max(len(ts), 1) < 0.15:
|
||
logger.debug(f"[{platform}] Poll过滤: CJK比例过低乱码 ts={ts!r}")
|
||
continue
|
||
# === 英文配置格式+乱码过滤:key=value 或 key: value 后跟乱码 ===
|
||
# 如 "invite referral bonus=5a激<5"、"config=file.txt乱码"
|
||
if re.search(r'[a-zA-Z_]{2,}\s*[=:]\s*\S+', ts):
|
||
# 检查是否包含乱码特征:特殊字符、数字字母混合、非正常英文单词
|
||
乱码特征 = False
|
||
# 特征1:等号或冒号后包含特殊字符(<>!@#$%^&*等)
|
||
if re.search(r'[=:]\s*[a-zA-Z0-9]*[<>!@#$%^&*()\[\]{}|\\/:;\'",.?~`]+', ts):
|
||
乱码特征 = True
|
||
# 特征2:等号或冒号后包含数字字母混合但无空格(如"5a激<5")
|
||
if re.search(r'[=:]\s*[a-zA-Z0-9]{3,}[^\s,.;:!?。,;:!?\u4e00-\u9fff]', ts):
|
||
乱码特征 = True
|
||
if 乱码特征:
|
||
logger.debug(f"[{platform}] Poll过滤: 英文配置+乱码 ts={ts!r}")
|
||
continue
|
||
# === 微信专属:气泡上下文验证 ===
|
||
# 微信截图可能混入 Windows 弹窗/通知/非聊天内容
|
||
# 真实用户消息通常含时间戳(14:30格式) 或 冒号分隔符(用户名:消息)
|
||
# 纯粹的碎片文本(无时间戳、无冒号、无标点结尾)→ 高概率是非消息内容
|
||
if platform == "wechat":
|
||
has_timestamp = bool(re.search(r'\d{1,2}:\d{2}', ts))
|
||
has_colon = bool(re.search(r'[::]', ts))
|
||
has_punct_end = bool(re.search(r'[。!?!?]$', ts))
|
||
# 短句(≤8字)含中文 → 可能是合法短消息(如"好的"、"嗯"),不拦截
|
||
if len(ts) <= 8 and re.search(r'[\u4e00-\u9fff]', ts):
|
||
pass # 放行短中文消息
|
||
# 无时间戳 + 无冒号 + 无标点结尾 → 高概率是非消息碎片(弹窗/通知/UI)
|
||
elif not has_timestamp and not has_colon and not has_punct_end:
|
||
# 再放宽:含问词(怎么办/为什么/能不能)或感叹词 → 可能是真实消息
|
||
if not re.search(r'怎么办|为什么|能不能|怎么|如何|什么|可以|帮忙|谢谢|好的|嗯|收到|OK|ok', ts):
|
||
logger.debug(f"[{platform}] Poll过滤: 微信非消息碎片(无上下文) ts={ts!r}")
|
||
continue
|
||
|
||
# === 斜线英文命令碎片:"/sdfl undo撤销导入" 命令帮助碎片 ===
|
||
if re.match(r'^/[A-Za-z]', ts):
|
||
logger.debug(f"[{platform}] Poll过滤: 斜线英文命令 ts={ts!r}")
|
||
continue
|
||
# === 消息中段含 /英文命令(如"怎么办?/printer玩家名")→ 指令碎片拼接 ===
|
||
if re.search(r'(?<=[\s??。!,,])/[A-Za-z]{2,}', ts):
|
||
logger.debug(f"[{platform}] Poll过滤: 斜线命令碎片(中段) ts={ts!r}")
|
||
continue
|
||
# === 下划线+斜线:OCR 左对齐气泡残影 "_ /sdfl undo" ===
|
||
if re.match(r'^_[ /]', ts):
|
||
logger.debug(f"[{platform}] Poll过滤: 下划线斜线残影 ts={ts!r}")
|
||
continue
|
||
# === OCR 误读斜线为 I/J:"/sdfl"→"Isdfl", "_ /sdfl"→下划线前缀 ===
|
||
# 常见变体:/sdfl, _ /sdfl, Isdfl, sdfl undo...
|
||
if 'sdfl' in ts.lower() or 'sdf1' in ts.lower():
|
||
logger.debug(f"[{platform}] Poll过滤: 变形命令(sdfl) ts={ts!r}")
|
||
continue
|
||
# === OCR 斜线→I 变形:/skin→Iskin, /reg→Ireg 等 ===
|
||
# 特征:CJK 上下文中出现 I+小写字母(正常中文聊天不会这样写)
|
||
# 用户不会发"使用指令Iskin"这种文本,安全砍掉不进知识库
|
||
if re.search(r'[\u4e00-\u9fff]I[a-z]{2,}', ts):
|
||
logger.debug(f"[{platform}] Poll过滤: 斜线I变形 ts={ts!r}")
|
||
continue
|
||
if re.match(r'^[A-Z][a-z]{4,}', ts) and re.search(r'[\u4e00-\u9fff]', ts) and len(ts) > 10:
|
||
logger.debug(f"[{platform}] Poll过滤: 变形命令(大写+字母) ts={ts!r}")
|
||
continue
|
||
# === 结构化数据面板:QQ/TIM 指令面板(指令:/rtp 权限类型:玩家 图标:标题:)===
|
||
colon_kv = re.findall(r'[\u4e00-\u9fff]{1,6}[::](?:[A-Za-z0-9/]|[\u4e00-\u9fff])', ts)
|
||
if len(colon_kv) >= 3:
|
||
logger.debug(f"[{platform}] Poll过滤: 结构化面板 ts={ts!r}")
|
||
continue
|
||
# === 偏旁碎片过滤:包含偏旁字符就丢弃 ===
|
||
# 偏旁字符(U+2E80-U+2FDF)在正常中文聊天中不会出现
|
||
# OCR 拆字碎片如"亻牛""彡"只要出现就是碎片
|
||
# ⭐ 2026-08-14 修复:放宽过滤,仅当偏旁字符占比>10%时才丢弃
|
||
# 原因:OCR 误识别会把"默认"识别成"默讠"、把"个"识别成"亻尔"
|
||
# 这些是单个偏旁的 OCR 噪声,不应因此丢弃整行
|
||
radical_count = sum(1 for ch in ts if _is_radical_char(ch))
|
||
if radical_count > 0 and radical_count / max(len(ts), 1) > 0.1:
|
||
logger.debug(f"[{platform}] Poll过滤: 偏旁字符过多(占比{radical_count/max(len(ts),1)*100:.0f}%) 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) and \
|
||
not re.match(r'^(github|gitee|gitcode|git)[:\.\s]|git\.', ts, re.IGNORECASE):
|
||
logger.debug(f"[{platform}] Poll过滤: YAML行 ts={ts!r}")
|
||
continue
|
||
# === 纯问句碎片(如"怎么办?" "好不好?")无实质内容 ===
|
||
_pure_q = re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是不是|有没有|是什么|怎么样|在哪里)\s*[??。!]*\s*$', ts)
|
||
if _pure_q:
|
||
logger.debug(f"[{platform}] Poll过滤: 纯问句碎片 ts={ts!r}")
|
||
continue
|
||
# === 以特殊符号(~、`等)开头 → OCR把"一键"识别为"~键"等碎片 ===
|
||
if re.match(r'^[~`~]', ts):
|
||
logger.debug(f"[{platform}] Poll过滤: 特殊符号开头碎片 ts={ts!r}")
|
||
continue
|
||
# === 问句+乱码碎片拼接(如"怎么办?/printer玩家名")===
|
||
if re.match(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)', ts):
|
||
rest = re.sub(r'^(怎么办|好不好|行不行|能不能|可以吗|是什么|怎么样)\s*[??。!]*\s*', '', ts)
|
||
if rest and (re.search(r'/[A-Za-z]{2,}', rest) or re.search(r'[a-zA-Z]{3,}', rest) or len(rest) < 3):
|
||
logger.debug(f"[{platform}] Poll过滤: 问句+乱码拼接 ts={ts!r}")
|
||
continue
|
||
# === 安全阀:UI 子串过滤(兜底,防任何路径漏过)===
|
||
if any(sub in ts for sub in _UI_SUBSTR_BLOCKLIST):
|
||
logger.debug(f"[{platform}] Poll过滤: UI子串 ts={ts!r}")
|
||
continue
|
||
# === 安全阀:UI 块列表过滤(兜底,防任何路径漏过)===
|
||
if ts in _UI_EXACT_BLOCKLIST:
|
||
logger.debug(f"[{platform}] Poll过滤: UI黑名单 line={ts!r}")
|
||
seen.add(ts)
|
||
continue
|
||
|
||
# ⭐ 候选行包含 bot 自己的旧回复(OCR 把用户新消息 + bot 旧回复拼成一行)
|
||
# 例:用户问"PHP管理员端的默认密码"时屏上仍显示 bot 先前关于"全局配置"的回复,
|
||
# 两者被 OCR 拼成一条 → 直接拿去搜 KB 会命中 bot 旧回复(答非所问)。
|
||
# 剥离 bot 旧回复文本,保留用户真正的提问再去走后续过滤与 KB 搜索。
|
||
if len(ts) > 5:
|
||
stripped_bot = False
|
||
for r in replied:
|
||
if len(r) >= 6 and r in ts:
|
||
ts = re.sub(re.escape(r), ' ', ts)
|
||
stripped_bot = True
|
||
# ⭐ 反向包含剥离:OCR 截断导致 r 不是 ts 的子串,
|
||
# 但 ts 中逗号分隔的片段可能是 r 的子串
|
||
# 例: OCR="登录插件在哪下载?,从未注册过草原,使用全新的邮箱"
|
||
# r="从未注册过草原探险服务器的玩家、账号。\n使用全新的邮箱进行验证。"
|
||
# → "从未注册过草原" in r, "使用全新的邮箱" in r → 剥离这两段
|
||
_has_comma = ',' in ts or (',' in ts and re.search(r'[\u4e00-\u9fff],[\u4e00-\u9fff]', ts))
|
||
if not stripped_bot and _has_comma:
|
||
parts = re.split(r'[,,]', ts)
|
||
parts = [p.strip() for p in parts if p.strip()]
|
||
if len(parts) >= 2:
|
||
user_parts = []
|
||
for part in parts:
|
||
if len(part) < 2:
|
||
continue
|
||
is_bot_frag = False
|
||
for r in replied:
|
||
if part in r:
|
||
is_bot_frag = True
|
||
break
|
||
if not is_bot_frag:
|
||
user_parts.append(part)
|
||
if len(user_parts) < len(parts):
|
||
ts = ','.join(user_parts).strip(',').strip()
|
||
if not ts:
|
||
continue
|
||
stripped_bot = True
|
||
logger.debug(f"[{platform}] 反向包含剥离bot回复片段: {ts[:60]}")
|
||
if stripped_bot:
|
||
ts = re.sub(r'\s+', ' ', ts).strip()
|
||
if not ts:
|
||
continue
|
||
# 剥离后继续走后续过滤(不 continue),让干净的用户提问正常进入 new_items
|
||
# ⭐ 近5分钟时间窗口查重:如果消息在近5分钟的bot回复中,跳过
|
||
if recent_replies and ts in recent_replies:
|
||
logger.debug(f"[{platform}] 时间窗口查重跳过(近5分钟): {ts[:50]}")
|
||
continue
|
||
# _replied_texts 优先检查(永不裁切,100% 拦住自己的回复)
|
||
if ts in replied:
|
||
continue
|
||
# _replied_texts 模糊匹配:OCR 噪声导致精确匹配失效(长文本尤甚)
|
||
if self._fuzzy_duplicate(ts, replied, threshold=0.75):
|
||
logger.debug(f"[{platform}] 回复模糊跳过: {ts[:50]}")
|
||
continue
|
||
# _replied_texts 子串包含:OCR 提取的 md 表格细胞值(如"依赖插件可选/必选")
|
||
# 是 bot 回复表的一部分,不是用户提问。长度>5才检查避免误杀短消息。
|
||
# OCR 常把 ,转 , 等标点差异,做双向子串检查(原始+归一化标点)
|
||
if len(ts) > 5:
|
||
if any(ts in r for r in replied):
|
||
logger.debug(f"[{platform}] 回复子串包含跳过({len(ts)}c): {ts[:50]}")
|
||
continue
|
||
# 归一化标点后重试(OCR 噪声:,→, 。→. !→! ?→?)
|
||
norm_ts = ts.replace(',', ',').replace('.', '。').replace('!', '!').replace('?', '?')
|
||
if norm_ts != ts and any(norm_ts in r for r in replied):
|
||
logger.debug(f"[{platform}] 回复子串包含跳过(归一化,{len(ts)}c): {ts[:50]}")
|
||
continue
|
||
# _replied_texts 长公共子串检测:OCR 截取的碎片开头/结尾有噪声,精确/子串匹配失效
|
||
# 先试原始文本(含字母数字),再试 CJK-only(进一步降阈值)
|
||
# ⭐ Task #1534: 问句豁免——用户提问即使与旧回复有部分匹配也放行
|
||
# 例:"PHP默认的那管理后台管理密码是多少,php有2种登陆模式..."
|
||
# 该问句与旧回复共享"php有2种登陆模式"子串,但不应被跳过
|
||
is_question = bool(re.search(r'[??]', ts)) or any(qw in ts for qw in ('吗', '呢', '多少', '怎么', '如何', '哪', '什么'))
|
||
if len(ts) > 8 and not is_question:
|
||
import difflib
|
||
matched_r = False
|
||
# 第一轮:原始文本匹配(阈值 6,OCR 噪声导致连续匹配可能中断)
|
||
for r in replied:
|
||
if len(r) < 5:
|
||
continue
|
||
sm = difflib.SequenceMatcher(None, ts, r)
|
||
if sm.find_longest_match(0, len(ts), 0, len(r)).size >= 6:
|
||
matched_r = True
|
||
break
|
||
# 第二轮:CJK-only 降阈值到 5(去除标点空格干扰后更稳定)
|
||
if not matched_r:
|
||
cjk_ts = ''.join(c for c in ts if '\u4e00' <= c <= '\u9fff')
|
||
if len(cjk_ts) >= 5:
|
||
for r in replied:
|
||
r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff')
|
||
if len(r_cjk) < 5:
|
||
continue
|
||
sm = difflib.SequenceMatcher(None, cjk_ts, r_cjk)
|
||
if sm.find_longest_match(0, len(cjk_ts), 0, len(r_cjk)).size >= 5:
|
||
matched_r = True
|
||
break
|
||
if matched_r:
|
||
logger.debug(f"[{platform}] 回复公共子串跳过: {ts[:50]}")
|
||
continue
|
||
if ts in seen:
|
||
continue
|
||
# 模糊匹配:OCR 输出每次微小差异(勖彐碎片、行首数字、尾部截断)
|
||
# 精确匹配命中不了 → 用 difflib 相似度去重
|
||
# ⭐ 2026-08-12: 包含问号的问句(如"PHP用户端有哪两种登录模式?")不视为模糊重复
|
||
# 用户可能重复问相同问题,允许再次处理
|
||
if not re.search(r'[??]', ts) and self._fuzzy_duplicate(ts, seen, threshold=0.80):
|
||
logger.debug(f"[{platform}] 模糊重复跳过: {ts[:50]}")
|
||
continue
|
||
# 超长文本(>200字符)跳过——大概率是堆积历史或 bot 自己的回复,不是单条新消息
|
||
if len(ts) > 200:
|
||
logger.debug(f"[{platform}] 超长文本跳过({len(ts)}c): {ts[:50]}...")
|
||
continue
|
||
if self._is_system_message(ts):
|
||
continue
|
||
# Markdown 文档过滤:bot 回复以 md 格式为主(#标题、[链接](url)、-列表项),
|
||
# 用户日常对话不用 md 语法。拦截 md 格式文本避免 bot 旧回复被重新处理。
|
||
# ⭐ 2026-08-11 先剥 md 尾巴再判:md 在末尾→切小尾巴留用户提问;
|
||
# md 大头/混中间→None 整段丢弃,确保入库的只有用户原始提问。
|
||
md_stripped = self._strip_markdown_tail(ts)
|
||
if md_stripped is None:
|
||
logger.debug(f"[{platform}] md大头整段丢弃: {ts[:50]}")
|
||
continue
|
||
if md_stripped != ts:
|
||
logger.debug(f"[{platform}] 剥离md尾巴: {ts[:50]} -> {md_stripped[:50]}")
|
||
ts = md_stripped
|
||
if self._is_markdown_document(ts):
|
||
logger.debug(f"[{platform}] md文档跳过: {ts[:50]}")
|
||
continue
|
||
# 纯装饰符号(单字符非汉字非字母数字)跳过
|
||
if len(ts) == 1 and not re.match(r'[\u4e00-\u9fff\w]', ts):
|
||
continue
|
||
# 只有符号/空格/emoji 的非消息文本(<3 字符且长度等于标点去掉后长度)
|
||
clean = re.sub(r'[\s\u2000-\u206f\u2190-\u2bff\U0001F000-\U0001FFFF✿♡♥❤★☆]', '', ts)
|
||
if len(ts) <= 3 and len(clean) == 0:
|
||
continue
|
||
seen.add(ts)
|
||
new_items.append(ts)
|
||
# _seen_messages 上限 500 条,超了裁到 300
|
||
# 防止集合无限膨胀 → 新消息模糊匹配时被历史噪声误杀
|
||
if len(seen) > 500:
|
||
# 保留后 300 条(最新)
|
||
self._seen_messages = set(list(seen)[-300:])
|
||
|
||
# 单次 poll 最多处理 3 条新消息,防止系统消息海量涌入触发回环
|
||
MAX_NEW_ITEMS = 3
|
||
new_items = new_items[:MAX_NEW_ITEMS]
|
||
|
||
# 过滤纯时间/日期文本 → 不应进入 KB 匹配(OCR 时间戳分割遗留的噪音片段)
|
||
# 这些文本在 KB 子串匹配阶段会命中文件内任何含相同数字的内容
|
||
new_items = [
|
||
it for it in new_items
|
||
if not re.match(r'^\d{1,2}[::]\d{2}(?:[::]\d{2})?$', it.strip())
|
||
and not re.match(r'^\d{4}[/-]\d{1,2}', it.strip())
|
||
]
|
||
|
||
# ⚠️ 文本处理后再次检查急停:防止急停在过滤阶段触发后仍触发回复
|
||
if not self._running:
|
||
return
|
||
|
||
# 更新 last_texts 与签名
|
||
self._last_texts[platform] = list(texts)
|
||
self._last_sig[platform] = sig
|
||
self._stale_count[platform] = 0
|
||
|
||
if new_items:
|
||
# ⚠️ 急停检查:处理新消息前最后检查,急停后立即丢弃全部动作
|
||
if not self._running:
|
||
return
|
||
|
||
# 清理过期冷却条目(TTL 60 秒)
|
||
now = time.time()
|
||
self._failed_send_cooldown = {
|
||
k: v for k, v in self._failed_send_cooldown.items()
|
||
if now - v < 60.0
|
||
}
|
||
|
||
# ⭐ 首次成功轮询(上次无消息→本次有消息):拦截历史基线
|
||
is_baseline = self._last_new_msg_time.get(platform, 0) == 0
|
||
baseline_handled = False # 基线轮只处理一次(标记第一条消息)
|
||
|
||
# ⭐ 遍历处理所有新消息(不再只取最后一条)
|
||
for item in new_items:
|
||
# ⭐ 基线轮:只标记第一条消息,不触发回复
|
||
if is_baseline and not baseline_handled:
|
||
# 基线轮:日志标记但不触发回复。
|
||
# ⚠ 四项关键处置,防止"基线吞消息后永不回复":
|
||
# 1. 从 seen 移除 item(4383 循环已 seen.add)——否则用户重发
|
||
# 相同内容 → ts in seen 永久拦截
|
||
# 2. 清空 _last_sig ——否则下一轮 sig 相同 → 4131 静默跳过,
|
||
# 用户刚发的新消息被基线误吞后永远等不到回复
|
||
# 3. ⭐ 重新加入 _seen_messages 防止下次轮询重复处理(修复历史消息重复问题)
|
||
# 4. ⭐ 清除截图 hash——否则下次轮询时 hash 相同直接跳过 OCR,
|
||
# 消息永远等不到回复(截图内容没变但消息需要被处理)
|
||
logger.info(f"[{platform}] 基线↺(不回复): {item[:60]}")
|
||
seen.discard(item)
|
||
self._seen_messages.add(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 # 跳过本条消息,处理下一条
|
||
# ⭐ 2026-08-12: 中文骨架匹配(吸收 OCR 拉丁混淆 php↔4p)
|
||
# 字符级相似度跌破 90% 时(php→4p 整句 ~83%),用中文骨架兜底拦截
|
||
sk_cur = _cn_skeleton(cleaned_item)
|
||
sk_rep = _cn_skeleton(last_reply)
|
||
if len(sk_cur) >= 6 and sk_rep and sk_cur == sk_rep:
|
||
logger.info(f"[{platform}] OCR噪声检测(骨架匹配): 与最近bot回复中文骨架相同,跳过: {cleaned_item[:60]}")
|
||
seen.add(cleaned_item)
|
||
self._last_new_msg_time[platform] = time.time()
|
||
continue
|
||
# ⭐ 文本归一化:全角→半角标点,防止OCR差异导致查重失效
|
||
# 例:第一次OCR"所以那登录插件应该去哪里下载呢?" vs 第二次"所以那登录插件应该去哪里下载呢?"
|
||
_normalized_item = cleaned_item.replace('?', '?').replace('。', '.').replace(',', ',')
|
||
# ⭐ 5分钟内OCR重复识别去重:同一消息被OCR识别两次(乱码+正确文本),相似度>=90%则跳过
|
||
now = time.time()
|
||
# 清理过期条目
|
||
self._recent_processed_messages = [
|
||
(txt, ts) for txt, ts in self._recent_processed_messages
|
||
if now - ts <= 300
|
||
]
|
||
# 检查是否与最近处理的消息相似
|
||
is_duplicate = False
|
||
# ⭐ 使用归一化文本进行比较,防止OCR标点差异
|
||
check_item = _normalized_item
|
||
for recent_text, recent_ts in self._recent_processed_messages:
|
||
if now - recent_ts > 300:
|
||
continue
|
||
# ⭐ 2026-08-12: 中文骨架匹配(吸收 OCR 拉丁混淆 php↔4p)
|
||
# 同一消息被 OCR 识别两次(php / 4p),字符级相似度 <90% 时兜底拦截
|
||
sk_cur = _cn_skeleton(check_item)
|
||
sk_old = _cn_skeleton(recent_text)
|
||
if len(sk_cur) >= 6 and sk_cur == sk_old:
|
||
logger.info(f"[{platform}] OCR重复识别去重(骨架匹配): 中文骨架相同,跳过: {cleaned_item[:60]}")
|
||
is_duplicate = True
|
||
break
|
||
if len(recent_text) < 4 or len(check_item) < 4:
|
||
continue
|
||
# 快速长度剪枝
|
||
if abs(len(recent_text) - len(check_item)) > max(len(recent_text), len(check_item)) * 0.4:
|
||
continue
|
||
sim = SequenceMatcher(None, check_item, recent_text).quick_ratio()
|
||
if sim >= 0.9:
|
||
logger.info(f"[{platform}] OCR重复识别去重: 相似度{sim:.2%},跳过: {cleaned_item[:60]}")
|
||
is_duplicate = True
|
||
break
|
||
if is_duplicate:
|
||
self._last_new_msg_time[platform] = time.time()
|
||
continue
|
||
# 添加到最近处理列表
|
||
self._recent_processed_messages.append((cleaned_item, now))
|
||
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
|