根因:基线处理调用_add_to_recent_user_queries(item)将消息写入跨轮去重记录, 导致后续每轮OCR检测到相同消息时被自身匹配(100% Jaccard相似度)跳过。 修复: - 移除基线处理中的_add_to_recent_user_queries调用(line 9321) - if ts in seen: continue 处增加debug日志便于排查静默跳过 验证:剩余_add_to_recent_user_queries调用仅在消息被实际回复后触发, 不影响Task #1581双回复防护。 Closes #1589
9607 lines
526 KiB
Python
9607 lines
526 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
|
||
# ⭐ Task #1556: 关键词打分去重系统
|
||
from _keyword_scorer import KeywordScorer
|
||
|
||
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):
|
||
"""初始化数据库表 - 重构为3表结构"""
|
||
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
|
||
# 1. 用户消息原文表
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS user_messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
platform 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_user_content_hash ON user_messages(content_hash)
|
||
""")
|
||
conn.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_user_platform_time ON user_messages(platform, created_at)
|
||
""")
|
||
|
||
# 2. Bot消息原文表
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS bot_messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
platform 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_bot_content_hash ON bot_messages(content_hash)
|
||
""")
|
||
conn.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_bot_platform_time ON bot_messages(platform, created_at)
|
||
""")
|
||
|
||
# 3. OCR暂存表(重启清空防自言自语)
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS ocr_staging (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
platform TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
content_hash TEXT NOT NULL,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
""")
|
||
|
||
# 检查是否存在旧表,如果存在则迁移数据
|
||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='message_history'")
|
||
if cursor.fetchone():
|
||
self._migrate_from_old_table(conn)
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# 启动时清空OCR暂存表
|
||
self.clear_ocr_staging()
|
||
|
||
def _get_conn(self):
|
||
"""获取数据库连接"""
|
||
return sqlite3.connect(self.db_path, check_same_thread=False)
|
||
|
||
def _migrate_from_old_table(self, conn):
|
||
"""从旧表迁移数据到新表"""
|
||
logger.info("检测到旧表,开始迁移数据...")
|
||
|
||
# 迁移用户消息
|
||
try:
|
||
cursor = conn.execute("SELECT platform, content, content_hash, created_at FROM message_history WHERE sender_type='user'")
|
||
rows = cursor.fetchall()
|
||
if rows:
|
||
conn.executemany(
|
||
"INSERT INTO user_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)",
|
||
rows
|
||
)
|
||
logger.info(f"迁移了 {len(rows)} 条用户消息")
|
||
except Exception as e:
|
||
logger.warning(f"迁移用户消息失败: {e}")
|
||
|
||
# 迁移bot消息
|
||
try:
|
||
cursor = conn.execute("SELECT platform, content, content_hash, created_at FROM message_history WHERE sender_type='bot'")
|
||
rows = cursor.fetchall()
|
||
if rows:
|
||
conn.executemany(
|
||
"INSERT INTO bot_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)",
|
||
rows
|
||
)
|
||
logger.info(f"迁移了 {len(rows)} 条bot消息")
|
||
except Exception as e:
|
||
logger.warning(f"迁移bot消息失败: {e}")
|
||
|
||
# 删除旧表
|
||
try:
|
||
conn.execute("DROP TABLE IF EXISTS message_history")
|
||
logger.info("已删除旧表")
|
||
except Exception as e:
|
||
logger.warning(f"删除旧表失败: {e}")
|
||
|
||
def record_bot_reply(self, platform: str, content: str):
|
||
"""记录bot回复到bot_messages表"""
|
||
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 bot_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)",
|
||
(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):
|
||
"""记录用户消息到user_messages表(用于后续去重)"""
|
||
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 user_messages (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)",
|
||
(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回复(查询bot_messages表)
|
||
cursor = conn.execute(
|
||
"SELECT COUNT(*) FROM bot_messages WHERE platform=? 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 bot_messages WHERE platform=? 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 bot_messages WHERE platform=? 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 bot_messages WHERE platform=? 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 bot_messages WHERE platform=? AND created_at > ? ORDER BY id DESC LIMIT ?",
|
||
(platform, five_minutes_ago, limit)
|
||
)
|
||
else:
|
||
cursor = conn.execute(
|
||
"SELECT content FROM bot_messages WHERE platform=? ORDER BY id DESC LIMIT ?",
|
||
(platform, limit)
|
||
)
|
||
return [row[0] for row in cursor.fetchall() if row[0]]
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_recent_user_messages(self, platform: str, limit: int = 20, within_minutes: int = None) -> list:
|
||
"""获取该平台最近的用户原始消息内容列表,用于查库比对
|
||
|
||
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 user_messages WHERE platform=? AND created_at > ? ORDER BY id DESC LIMIT ?",
|
||
(platform, five_minutes_ago, limit)
|
||
)
|
||
else:
|
||
cursor = conn.execute(
|
||
"SELECT content FROM user_messages WHERE platform=? ORDER BY id DESC LIMIT ?",
|
||
(platform, limit)
|
||
)
|
||
return [row[0] for row in cursor.fetchall() if row[0]]
|
||
finally:
|
||
conn.close()
|
||
|
||
def record_ocr_message(self, platform: str, content: str):
|
||
"""记录OCR识别的消息到暂存表"""
|
||
content_hash = hashlib.md5(content.strip().encode('utf-8')).hexdigest()
|
||
conn = self._get_conn()
|
||
try:
|
||
from datetime import datetime
|
||
local_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
conn.execute(
|
||
"INSERT INTO ocr_staging (platform, content, content_hash, created_at) VALUES (?, ?, ?, ?)",
|
||
(platform, content.strip(), content_hash, local_time)
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def clear_ocr_staging(self):
|
||
"""清空OCR暂存表(重启时调用)"""
|
||
conn = self._get_conn()
|
||
try:
|
||
conn.execute("DELETE FROM ocr_staging")
|
||
conn.commit()
|
||
logger.info("已清空OCR暂存表")
|
||
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)
|
||
# 最近处理过的用户查询(用于语义去重,5分钟内相似查询跳过)
|
||
self._recent_user_queries = [] # 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
|
||
# ⭐ 连续失败计数:同一消息连续3次发送失败则跳过本轮(用户可能已接管)
|
||
self._failed_consecutive_count: dict = {} # platform → int
|
||
|
||
# 跨平台前台冷却:某平台刚切过前台(回复后),对端平台等待此时间再截图
|
||
self._foreground_cooldown_until: dict = {} # platform → float(time.time)
|
||
# 同平台回复冷却:回复后此秒数内同平台跳过 OCR(防读到自身回复回声)
|
||
self._self_reply_cooldown_until: dict = {} # platform → float(time.time)
|
||
# ⭐ 回复进行中标志:防止bot回复发送被打断时,下次轮询重复触发
|
||
self._reply_in_progress: dict = {} # platform → bool
|
||
|
||
# 本轮选中的 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()
|
||
# ⭐ Task #1556: 关键词打分去重器(替代OCR时间戳判断)
|
||
self._keyword_scorer = KeywordScorer(self._msg_history.db_path) if self._msg_history else None
|
||
# 启动时加载历史回复到 _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 _force_clear_all_residual_data(self, clear_db: bool = False):
|
||
"""启动时强制清理所有残留数据,避免重复捕捉被时间戳达标而跳过。
|
||
|
||
问题场景:
|
||
- 急停后恢复,_seen_messages/_replied_texts 中残留上次已处理的消息
|
||
- _recent_user_queries 中残留上次查询的语义指纹
|
||
- _semantic_fingerprint_cache 中残留同屏碎片指纹
|
||
- 导致新轮次OCR捕捉到相同内容时,时间戳已达标但被去重逻辑跳过
|
||
|
||
清理范围:所有内存中的去重/冷却/缓存状态
|
||
clear_db=True 时还会清空数据库历史,用于物理重启场景
|
||
|
||
Args:
|
||
clear_db: True=同时清空数据库历史(物理重启时使用)
|
||
False=仅清空内存(急停恢复时使用)
|
||
"""
|
||
# 消息去重集
|
||
self._seen_messages.clear()
|
||
self._replied_texts.clear()
|
||
self._recent_processed_messages.clear()
|
||
self._recent_user_queries.clear()
|
||
self._semantic_fingerprint_cache.clear()
|
||
# 平台级时间戳冷却
|
||
self._last_reply_time.clear()
|
||
self._last_new_msg_time.clear()
|
||
self._failed_send_cooldown.clear()
|
||
self._failed_consecutive_count.clear()
|
||
self._foreground_cooldown_until.clear()
|
||
self._self_reply_cooldown_until.clear()
|
||
self._reply_in_progress.clear() # ⭐ 清理回复进行中标志
|
||
# OCR/cache 状态
|
||
self._last_texts.clear()
|
||
self._raw_clipboard.clear()
|
||
self._last_clipboard_hash.clear()
|
||
self._app_variant.clear()
|
||
# 数据库清理(仅物理重启时)
|
||
if clear_db and self._msg_history:
|
||
try:
|
||
conn = self._msg_history._get_conn()
|
||
conn.execute("DELETE FROM user_messages")
|
||
conn.execute("DELETE FROM bot_messages")
|
||
conn.execute("DELETE FROM ocr_staging")
|
||
conn.execute("DELETE FROM keyword_scores")
|
||
conn.commit()
|
||
conn.close()
|
||
logger.info("[start] 数据库历史已清空(物理重启模式)")
|
||
except Exception as e:
|
||
logger.error(f"[start] 清空数据库失败: {e}")
|
||
logger.info("[start] 强制清理所有残留数据完成")
|
||
|
||
def start(self, full_restart: bool = False):
|
||
"""启动监控。
|
||
|
||
Args:
|
||
full_restart: True=彻底重启(进程级,重新检测内存恢复GPU)
|
||
False=急停/手动停止恢复(软重启,不重新检测内存)
|
||
"""
|
||
if self._running:
|
||
return
|
||
self._running = True
|
||
# ⭐ 启动时强制清理所有残留数据,避免重复捕捉被时间戳达标而跳过
|
||
# full_restart=True 时清空数据库历史(物理重启),False 时仅清内存(急停恢复)
|
||
self._force_clear_all_residual_data(clear_db=full_restart)
|
||
# ⭐ 重置 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._reply_in_progress.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._reply_in_progress.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() 做快速上界剪枝。
|
||
⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写。
|
||
"""
|
||
if not new_text or len(new_text) < 4:
|
||
return False
|
||
new_text_lower = new_text.lower()
|
||
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_lower, existing.lower()).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"
|
||
)
|
||
|
||
# ⭐ 2026-08-19 新增:行数差异保护(不受分数差影响)
|
||
# Windows OCR 在复杂群聊环境常把多行消息合并成一条长行(如68行→1行,1495字符)。
|
||
# EasyOCR 切分准确但每行短 → CJK总数少 → 分数低 → 看似"输了"。
|
||
# 但行数差异本身就是质量信号:行数多=切分准确,行数少=Windows合并了消息。
|
||
# 当 EasyOCR 行数 >= Windows 3x 时,无条件选 EasyOCR(防分数差大的误判)。
|
||
if easy_lines >= win_lines * 3 and win_lines >= 1:
|
||
chosen = "easyocr"
|
||
logger.debug(
|
||
f"[ocr] 行数差异保护(无条件): easy行数{easy_lines}>=3x win行数{win_lines},"
|
||
f"分数差{score_diff:.1f},Windows把多行合并成一条,强制选easyocr"
|
||
)
|
||
|
||
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 = 50.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]}...),提取最后一条消息避免搅和")
|
||
# 提取最后一条消息:找到最后一个时间戳的位置,截取其后内容
|
||
last_ts_pos = -1
|
||
for m in re.finditer(r'\d{1,2}:\d{1,2}', wt):
|
||
last_ts_pos = m.start()
|
||
if last_ts_pos >= 0:
|
||
# 截取从最后一个时间戳到末尾的文本
|
||
extracted = wt[last_ts_pos:].strip()
|
||
if extracted:
|
||
wt = extracted
|
||
logger.info(f"[{platform}] 提取最后一条消息: {wt[:100]!r}")
|
||
else:
|
||
logger.warning(f"[{platform}] 提取最后一条消息为空,保留原文本")
|
||
else:
|
||
logger.warning(f"[{platform}] 未找到时间戳位置,保留原文本")
|
||
|
||
# 日志(原始输出逐行记录,用於長期對比兩引擎能力)
|
||
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, _mention_remainders = 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)
|
||
# —— 编程格式块剥离(头部整段跳过,尾部切掉)——
|
||
raw_text = self._strip_programming_format_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 []
|
||
|
||
# —— 可见时间戳新鲜性检查 ——
|
||
# ⭐ Task #1558 补充修复:OCR时间戳仅作参考,不再因时间戳过期整段丢弃。
|
||
# 时间戳可能被OCR继承/识别错误(旧消息13:32:36继承给新消息14:42:57),
|
||
# 历史消息去重交由下游关键词打分+DB查重负责。
|
||
# 仅当文本无中文字符(纯UI噪声/bot菜单)时才丢弃。
|
||
if not raw_text:
|
||
return []
|
||
if not self._ocr_text_has_fresh_timestamp(raw_text, platform):
|
||
# ⭐ 2026-08-24 新逻辑:_ocr_text_has_fresh_timestamp 返回 False 表示
|
||
# 检测到清晰年月日(历史消息),应丢弃整段文本
|
||
logger.debug(f"[{platform}] OCR 有清晰年月日(历史消息),丢弃整段: {raw_text[:80]!r}")
|
||
return []
|
||
|
||
# —— 行切割 + 尾标清理 ——
|
||
ocr_lines = []
|
||
# ⭐ 2026-08-11: 被")昵称 "前缀剥离的行 → 短句检查豁免(真实用户消息)
|
||
_ui_prefix_stripped = set()
|
||
# ⭐ 2026-08-18: @提及剥离后的真实用户消息余量("@游戏专用关于我们"→"关于我们"):
|
||
# 这些行是"用户@机器人提问",必须豁免规则3b/规则4/短句碎片,否则用户消息被当UI吞掉。
|
||
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
|
||
# ⭐ 2026-08-18: 提及衍生豁免判定——行剥掉孤立 @/括号/分隔符后 ∈ 余量集合
|
||
# 则它是"用户@机器人"的真实消息(余量 = 剥离昵称后的正文证据)
|
||
_norm_mention = l.strip().lstrip('@()()').rstrip('@()(),,。.::;;')
|
||
_mention_derived = _norm_mention in _mention_remainders
|
||
if _mention_derived:
|
||
_ui_prefix_stripped.add(l)
|
||
logger.debug(f"[{platform}] OCR: @提及衍生消息(豁免UI/短句): {l[:60]!r}")
|
||
# ⭐ 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 元素。
|
||
# ⭐ 2026-08-18: 提及衍生消息豁免——"@游戏专用关于我们"剥离后是"@关于我们",
|
||
# 规则3b会整行当UI杀;它是用户真实消息,跳过UI检查。
|
||
if not _mention_derived and 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有哪两种登录模式?」这类问句行不与下一行合并,
|
||
# 防止多条用户消息被 `,` 黏成一行、共享同一陈旧继承时间戳被整体误杀。
|
||
# ⭐ Task #1555: 防止跨消息边界合并。当前行是已知bot回复时,不与其后行合并,
|
||
# 避免 "用户问句 + bot旧回复 + 用户新问句" 被合成一条污染消息。
|
||
_SENT_END = re.compile(r'[。!??…~))」』\]呀啊呢吧嘛]$')
|
||
_TS_START = re.compile(r'^(?:\d{1,2}[.;:]\d{2}|(?:昨天|前天|大前天|今天|明天|星期[一二三四五六日天]|\d+天前)\s*\d)')
|
||
# 收集已知bot回复用于行合并判断
|
||
_known_bot_replies = set()
|
||
for _r in self._replied_texts:
|
||
if _r and len(_r) >= 3:
|
||
_known_bot_replies.add(self._norm_frag(_r))
|
||
merged_lines = []
|
||
for i, rl in enumerate(ocr_lines):
|
||
# 当前行含疑问特征(裸@末尾或问号)→ 不合并,防止用户问句被黏进bot回复
|
||
if rl.endswith('@') or re.search(r'[??]', rl):
|
||
merged_lines.append(rl)
|
||
continue
|
||
# ⭐ Task #1555: 若当前行是已知bot回复内容,不合并到下一行
|
||
# 防止 "用户问句 + bot回复" 合并后污染消息
|
||
_is_bot_reply_line = False
|
||
if _known_bot_replies and len(rl) >= 3:
|
||
_norm_rl = self._norm_frag(rl)
|
||
if _norm_rl and _norm_rl in _known_bot_replies:
|
||
_is_bot_reply_line = True
|
||
logger.debug(f"[ocr] 行合并跳过(bot回复): {rl[:60]!r}")
|
||
# ⭐ Task #1557: 单字感叹词(嘿/哈/哦/呃等)不应与后续问句合并
|
||
# OCR常把"嘿,php..."拆成两行:"嘿" 和 "php..."
|
||
# 如果当前行或上一行是单字感叹词,不合并
|
||
_is_single_excl = len(rl.strip()) == 1 and re.match(r'^[嘿哈哦呃啊哎嗬]$', rl.strip())
|
||
_prev_is_single_excl = merged_lines and len(merged_lines[-1].strip()) == 1 \
|
||
and re.match(r'^[嘿哈哦呃啊哎嗬]$', merged_lines[-1].strip())
|
||
# ⭐ 2026-08-18: 短@噪声行(含@、无问号、中文≤4字,如"有人@拜")不并入上行——
|
||
# 它是OCR对@提及的误读残片,会被短句碎片拦截;并入会把上行用户消息污染。
|
||
_cjk_in_rl = sum(1 for c in rl if '\u4e00' <= c <= '\u9fff')
|
||
_at_noise_line = ('@' in rl) and not re.search(r'[??]', rl) and _cjk_in_rl <= 4
|
||
# ⭐ 2026-08-19: 下一条是短消息/用户名前缀时不合并
|
||
# EasyOCR常把相邻用户消息切成多行(如"没有个西瓜"+"关于我们"),
|
||
# 若下一行是短句(<12字)或包含昵称前缀(/@/括号),不应并入上行。
|
||
_next_is_short_or_name = False
|
||
if i + 1 < len(ocr_lines):
|
||
_next_line = ocr_lines[i + 1].strip()
|
||
_next_cjk = sum(1 for c in _next_line if '\u4e00' <= c <= '\u9fff')
|
||
if len(_next_line) < 12 or '@' in _next_line \
|
||
or re.match(r'^[\u4e00-\u9fff]{2,8}[@,,]', _next_line) \
|
||
or re.match(r'^[/@((]', _next_line):
|
||
_next_is_short_or_name = True
|
||
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]) \
|
||
and not _is_bot_reply_line and not _is_single_excl and not _prev_is_single_excl \
|
||
and not _at_noise_line and not _next_is_short_or_name:
|
||
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 #1558: 完全用关键词打分控制消息留舍,OCR时间戳仅作为参考
|
||
# - 不依赖时间戳丢弃消息(时间戳可能被OCR继承错误)
|
||
# - 消息抓来后直接打分:历史消息≥2分则丢弃,新消息≤1分继续处理
|
||
# - OCR时间戳±10秒容差,超过用本地时间
|
||
# 典型场景:旧消息(13:32:36)+新消息(14:42:57)同屏,新消息不应被旧时间戳误杀
|
||
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}")
|
||
else:
|
||
# ⭐ Task #1558: 不再因时间戳陈旧而丢弃,统一交由过滤器层+关键词打分
|
||
# 时间戳仅记录,不参与丢弃判断
|
||
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}更新,消息交由过滤器层: {buf.strip()[:60]!r}")
|
||
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
|
||
# ⭐ 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:
|
||
# ⭐ 2026-08-21 清理 bot markdown 加粗残留:中文字符旁的孤立 *
|
||
# OCR 把 bot 的 **小姐姐** 识别成 小姐姐*,单个 * 粘在昵称后,
|
||
# 导致 _is_noise_text 误判为 OCR 残片丢弃整条消息。
|
||
# 模式:中文*(*后非*)→ 删除 *;不匹配 **,不匹配英文场景(100*2)
|
||
_msg_bot_before_star = _msg_bot
|
||
_msg_bot = re.sub(r'([\u4e00-\u9fff])\*(?!\*)', r'\1', _msg_bot)
|
||
if _msg_bot != _msg_bot_before_star:
|
||
logger.info(f"[ocr] stage[{i}] [filter/star] 清理markdown加粗残留*: {_msg_bot_before_star[:60]!r} -> {_msg_bot[:60]!r}")
|
||
# ⭐ 2026-08-19 新增:先剥【头部】md(bot md 列表项被 OCR 拼到用户消息前面,
|
||
# 如 "- 关于我们" / "-关于我们")。_strip_markdown_tail 只剥尾部,
|
||
# 对头部 md 无能为力(有空格→整段丢弃丢消息;无空格→漏检污染查库)。
|
||
# 剥完若整体是 md → None 整段丢弃;否则把干净文本交给 _strip_markdown_tail。
|
||
_head_stripped = self._strip_markdown_head(_msg_bot)
|
||
if _head_stripped is None:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP 全md头部整段丢弃: {_msg_bot[:80]!r}")
|
||
else:
|
||
# ⭐ 剥 md 尾巴:md 在末尾→切尾巴留用户提问;
|
||
# md 大头/混中间→None 整段丢弃,防 md 尾巴入库撞关键词
|
||
md_stripped = self._strip_markdown_tail(_head_stripped)
|
||
if md_stripped is None:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃: {_head_stripped[:80]!r}")
|
||
elif md_stripped != _head_stripped:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴: {_head_stripped[: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。
|
||
# ⭐ Task #1558: 不再因时间戳陈旧而丢弃,统一交由过滤器层+关键词打分
|
||
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部消息交由DB查重+关键词打分: {buf.strip()[:60]!r}")
|
||
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
|
||
if buf.strip():
|
||
# ⭐ 2026-08-12 必须先剥离 bot 旧回复片段(同 for 循环块理由):
|
||
# OCR 把「用户新消息 + bot 旧回复」拼成一行时,先剥 bot 回复才能
|
||
# 保住用户提问,否则 _strip_markdown_tail 会在 bot 回复的 '::|::'
|
||
# 残片处切断、误删用户提问,导致 filter4 整体丢弃。
|
||
# ⭐ 2026-08-17 修复:问句保护——若文本含问号但被 _strip_markdown_tail
|
||
# 在 | 处切断导致问句丢失(如"php有2种登陆模式:...]否|,那那个登录插件
|
||
# 应该又去哪儿下载呢?"→head='php...是',问句被吞),先提问答句再剥离。
|
||
_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:
|
||
# ⭐ 2026-08-21 清理 bot markdown 加粗残留(同 for 循环块)
|
||
_msg_bot_before_star = _msg_bot
|
||
_msg_bot = re.sub(r'([\u4e00-\u9fff])\*(?!\*)', r'\1', _msg_bot)
|
||
if _msg_bot != _msg_bot_before_star:
|
||
logger.info(f"[ocr] stage[{i}] [filter/star] 清理markdown加粗残留*(尾部): {_msg_bot_before_star[:60]!r} -> {_msg_bot[:60]!r}")
|
||
# ⭐ 问句保护:若文本含问号且首段无问句,先分离尾部问句
|
||
# 例: "php直登,是|,游戏里输,Jweb拿授权token,否|,那那个登录插件应该又去哪儿下载呢?"
|
||
# _strip_markdown_tail 在首个 | 处切→只留"php直登,是"→问句丢失
|
||
# 修复:在最后一个 | 处拆分,尾部含问号的部分单独保留,
|
||
# head 部分正常走 md 剥离流程(会正确切到首个 |)
|
||
_question_suffix = ''
|
||
if '?' in _msg_bot or '?' in _msg_bot:
|
||
_first_seg = _msg_bot.split('|')[0] if '|' in _msg_bot else _msg_bot
|
||
_has_q_in_head = '?' in _first_seg or '?' in _first_seg
|
||
if not _has_q_in_head:
|
||
# 在最后一个 | 处拆分
|
||
_last_pipe = _msg_bot.rfind('|')
|
||
if _last_pipe >= 0 and _last_pipe < len(_msg_bot) - 1:
|
||
_question_suffix = _msg_bot[_last_pipe + 1:].strip()
|
||
_before_pipe = _msg_bot[:_last_pipe].rstrip(',,|').strip()
|
||
if _before_pipe and _question_suffix:
|
||
_msg_bot = _before_pipe
|
||
logger.debug(f"[ocr] stage[{i}] 问句保护分离: head={_msg_bot[:40]!r}, suffix={_question_suffix[:40]!r}")
|
||
# ⭐ 2026-08-19 新增:先剥【头部】md(bot md 列表项被 OCR 拼到用户消息前面,
|
||
# 如 "- 关于我们" / "-关于我们")。_strip_markdown_tail 只剥尾部,
|
||
# 对头部 md 无能为力(有空格→整段丢弃丢消息;无空格→漏检污染查库)。
|
||
_head_stripped = self._strip_markdown_head(_msg_bot)
|
||
if _head_stripped is None:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP 全md头部整段丢弃(尾部): {_msg_bot[:80]!r}")
|
||
else:
|
||
md_stripped = self._strip_markdown_tail(_head_stripped)
|
||
# 把分离出的问句追加回来(如果 md 剥离丢弃了尾部内容)
|
||
if _question_suffix and md_stripped is not None:
|
||
md_stripped = (md_stripped + ' ' + _question_suffix).strip()
|
||
if md_stripped is None:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃(尾部): {_head_stripped[:80]!r}")
|
||
elif md_stripped != _head_stripped:
|
||
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴(尾部): {_head_stripped[: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 残片:单字符方括号 = md checkbox([V]/[X]/[√]/[ ]),
|
||
# 不是用户提问(用户方括号是多字词 [钻石] [VIP],此处不命中)
|
||
if re.match(r'^\[\s*.?\s*\](?!\()(?:\s*[A-Za-z0-9_\-./]+)?\s*$', line):
|
||
logger.debug(f"[{platform}] OCR 过滤: checkbox残片 line={line!r}")
|
||
continue
|
||
# 多 checkbox 碎片:一行含 2+ 个单字符方括号,md 列表 OCR 残留
|
||
if len(re.findall(r'\[\s*.?\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
|
||
# ⭐ 平台名碎片:纯GitHub/gitee/gitcode+稳定版字样,无真实用户提问
|
||
# OCR把bot回复中的列表项(如"[√] GitHub(稳定版)")误认成用户消息
|
||
# 特征:含平台名 + "(稳定版)" / "(最新版)" / 等版本标识,无实质问句
|
||
_platform_frag_pattern = r'\b(?:github|gitee|gitcode|gitlab)\b.*(?:稳定版|最新版|release|version)'
|
||
if re.search(_platform_frag_pattern, line, re.IGNORECASE):
|
||
# 进一步检查:是否只有平台碎片,没有真实用户提问
|
||
# 提取不重复的中文词(避免"稳定版稳定版稳定版"重复计数)
|
||
_content_words = list(set(re.findall(r'[\u4e00-\u9fff]{2,}', line)))
|
||
_question_marks = '?' in line or '?' in line
|
||
# 统计checkbox模式数量(单字符方括号,如[ ]、[√],一刀切不枚举符号)
|
||
_checkbox_count = len(re.findall(r'\[\s*.?\s*\](?!\()', line))
|
||
# 统计平台名出现次数
|
||
_platform_count = len(re.findall(r'\b(?:github|gitee|gitcode)\b', line, re.I))
|
||
# 判定规则:
|
||
# 1. 多个checkbox + 仅1个唯一中文词 → 列表碎片
|
||
# 2. 多个平台名 + 无问号 + 中文词≤2 → 平台名堆砌
|
||
# 3. 单checkbox + 单平台名 + 无问号 + 仅1个中文词 → 单条列表项碎片
|
||
if _checkbox_count >= 2 and len(_content_words) <= 1:
|
||
logger.debug(f"[{platform}] OCR 过滤: 平台名碎片(list)+checkbox(line={line!r})")
|
||
continue
|
||
if _platform_count >= 2 and len(_content_words) <= 2 and not _question_marks:
|
||
logger.debug(f"[{platform}] OCR 过滤: 平台名碎片(multi)+无问句(line={line!r})")
|
||
continue
|
||
if _checkbox_count >= 1 and _platform_count == 1 and not _question_marks and len(_content_words) <= 1:
|
||
logger.debug(f"[{platform}] OCR 过滤: 平台名碎片(single)+checkbox(line={line!r})")
|
||
continue
|
||
# 若有问号但无实质提问内容(如"GitHub(稳定版)?"),也是残片
|
||
if _question_marks and len(_content_words) == 0:
|
||
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
|
||
|
||
# ⭐ 2026-08-21 清理 bot markdown 加粗残留(逐行过滤管道)
|
||
line_before_star = line
|
||
line = re.sub(r'([\u4e00-\u9fff])\*(?!\*)', r'\1', line)
|
||
if line != line_before_star:
|
||
logger.debug(f"[{platform}] OCR 过滤: 清理markdown加粗残留* line={line_before_star!r} -> {line!r}")
|
||
|
||
# —— md 尾巴剥离:末尾 markdown 内容切掉,保留用户原始提问 ——
|
||
# ⭐ 2026-08-11:OCR 把 bot md 回复残片拼在用户消息尾部
|
||
# (如 "群管理员...(头衔UI)...# #来测试# # @C418minecraf")。
|
||
# md 在末尾→切小尾巴;md 大头/混中间→None 整段丢弃。
|
||
# 作用:入库的只有用户原始提问,md 尾巴不撞知识库关键词。
|
||
# ⭐ 2026-08-19 新增:先剥【头部】md(bot md 列表项被 OCR 拼到用户消息前面,
|
||
# 如 "- 关于我们" / "-关于我们")。_strip_markdown_tail 只剥尾部,对头部 md 无能为力。
|
||
_head_stripped = MessageMonitor._strip_markdown_head(line)
|
||
if _head_stripped is None:
|
||
logger.debug(f"[{platform}] OCR 过滤: 全md头部整段丢弃 line={line!r}")
|
||
continue
|
||
line = _head_stripped
|
||
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 + Task #1572: bot自身消息查库过滤(增强版)
|
||
# 检查OCR读到的内容是否与数据库中bot回复的消息或用户原始消息重合率
|
||
# - 得分≥2时:重合率≥50%丢弃(防bot自言自语/OCR残留)
|
||
# - 得分<2时:重合率≥70%丢弃(放宽阈值)
|
||
# 原理:用户每个问法不可能完全一样,重合率≥50%大概率是OCR抓到残留消息
|
||
if len(line) > 10 and self._msg_history:
|
||
try:
|
||
import difflib
|
||
# 获取当前消息的关键词得分
|
||
score = 0
|
||
threshold = 0.7 # 默认阈值
|
||
if self._keyword_scorer:
|
||
try:
|
||
score = self._keyword_scorer.get_last_score(platform, line)
|
||
if score >= 2:
|
||
threshold = 0.5 # 高分消息降低阈值
|
||
except Exception:
|
||
pass
|
||
|
||
# 检查bot消息
|
||
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 >= threshold:
|
||
logger.debug(f"[{platform}] OCR 过滤: bot自身消息(查库重合率{ratio:.2f}≥{threshold:.0%}) line={line[:60]!r}")
|
||
break # 匹配到任一bot回复即丢弃
|
||
else:
|
||
# 检查用户原始消息(仅高分消息才检查,避免误杀)
|
||
if score >= 2:
|
||
recent_user_msgs = self._msg_history.get_recent_user_messages(platform, limit=20)
|
||
for user_msg in recent_user_msgs:
|
||
if len(user_msg) < 10:
|
||
continue
|
||
sm = difflib.SequenceMatcher(None, line, user_msg)
|
||
ratio = sm.ratio()
|
||
if ratio >= 0.5: # 与用户消息重合率≥50%即丢弃
|
||
logger.debug(f"[{platform}] OCR 过滤: 用户残留消息(查库重合率{ratio:.2f}≥50%) line={line[:60]!r}")
|
||
break
|
||
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 标记(行首):md checkbox 在头部 → 整段跳过。
|
||
# 单字符方括号(含全角【】变体)= md checkbox,[ √ ] [ ⚠ ] [ ✗ ] [ X ] 等
|
||
# [钻石] [VIP] 等多字词绝不命中,保护用户正文;
|
||
# 中间位置 → 交给 _MD_TAIL_PATTERNS 切尾巴,不整行丢(用户策略:md在头整段跳,在尾一刀切)
|
||
if re.match(r'^[\[【]\s*.?\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(基岩版)")可能是有效短消息,放行
|
||
# ⭐ 2026-08-18: 含句末标点(。)或口语确认词(好的/嗯/明白)的短句放行
|
||
# ⭐ Task #1557: 含疑问词或口语感叹词的短句放行(如"那关于我们"含"那")
|
||
has_period_end = line.endswith('。') or line.endswith('!')
|
||
has_confirm = any(w in line for w in ('好的', '嗯', '明白', '收到', '了解', '知道了'))
|
||
# 口语前缀词(那、这、啥等)提示真实用户消息
|
||
_spoken_prefix = any(w in line for w in ('那', '这', '啥', '咋', '嗯', '哦', '嘿', '哈'))
|
||
if not has_q and line not in _ui_prefix_stripped and not re.search(r'[()()]', line) \
|
||
and not has_period_end and not has_confirm and not _spoken_prefix:
|
||
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 回复的片段,保留用户片段。
|
||
3. 尾部匹配:OCR 把 bot 旧回复拼在用户消息末尾(无分隔符或仅空格),
|
||
文本末尾子串是某条 bot 回复的子串 → 切掉尾部,保留前面用户消息。
|
||
例:"个登录插件是在哪个地方下载? 4001234S67 ]插件下载:见github..."
|
||
→ "个登录插件是在哪个地方下载?"
|
||
使用宽松匹配(≥15字符连续匹配),抵抗OCR噪声导致的微小差异。
|
||
|
||
⭐ 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:
|
||
# ⭐ 2026-08-17 问句豁免:用户提问即使含与旧回复共享的片段,也不应被剥离
|
||
# 例:"那领地系统有哪两种操作模式啊?游戏专" — "操作"与旧回复表头匹配
|
||
# 但这是用户新问句,不是旧回复的回声
|
||
is_question_mark = bool(re.search(r'[??]', ts))
|
||
is_question_word = any(qw in ts for qw in ('吗', '呢', '多少', '怎么', '如何', '哪', '什么', '几'))
|
||
is_question = is_question_mark or is_question_word
|
||
if not is_question:
|
||
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
|
||
# 策略3:尾部匹配——OCR把bot回复直接拼在用户消息末尾
|
||
# 使用纯中文相似度匹配,抵抗OCR噪声和英文/数字干扰
|
||
if not stripped_bot:
|
||
q_mark_pos = max(ts.find('?'), ts.find('?'))
|
||
# 确保问句前有足够内容(避免"? 插件下载..."这种误触发)
|
||
if q_mark_pos >= 0 and q_mark_pos >= 8 and q_mark_pos < len(ts) - 15:
|
||
before_q = ts[:q_mark_pos]
|
||
after_q = ts[q_mark_pos + 1:]
|
||
# 问句前必须有足够中文内容(至少4个汉字)
|
||
before_chinese = re.sub(r'[^\u4e00-\u9fff]', '', before_q)
|
||
after_chinese = re.sub(r'[^\u4e00-\u9fff]', '', after_q)
|
||
if len(before_chinese) >= 4 and len(after_chinese) >= 4:
|
||
for r in replied:
|
||
if len(r) < 10:
|
||
continue
|
||
r_chinese = re.sub(r'[^\u4e00-\u9fff]', '', r)
|
||
if len(r_chinese) < 4:
|
||
continue
|
||
# 计算纯中文相似度
|
||
sim = SequenceMatcher(None, after_chinese, r_chinese).ratio()
|
||
if sim >= 0.85:
|
||
ts = ts[:q_mark_pos + 1]
|
||
logger.debug(
|
||
f"[qq] 尾部匹配剥离bot回复: {ts!r} <- "
|
||
f"{ts + after_q[:40]!r} (中文相似度={sim:.0%})"
|
||
)
|
||
stripped_bot = True
|
||
break
|
||
# 策略4:贪婪匹配剥离——不管bot回复拼在哪(头/尾/中),都摘掉
|
||
# 例:
|
||
# "官方下载 bbsmc分流 那我知道公告信息" → "那我知道公告信息"
|
||
# "问问那个登录插件在哪儿能下载 见github" → "问问那个登录插件在哪儿能下载"
|
||
# "[√] GitHub(稳定版) 那个登录插件在哪?" → "那个登录插件在哪?"
|
||
if not stripped_bot:
|
||
for _r in replied:
|
||
if len(_r) < 5:
|
||
continue
|
||
# 归一化比较:去空格标点,抵抗OCR噪声
|
||
_r_norm = self._norm_frag(_r)
|
||
if not _r_norm:
|
||
continue
|
||
_ts_norm = self._norm_frag(ts)
|
||
if not _ts_norm:
|
||
continue
|
||
# 贪婪匹配:如果文本包含bot回复,移除匹配部分
|
||
if _r_norm in _ts_norm:
|
||
# 定位匹配位置
|
||
_r_idx = _ts_norm.find(_r_norm)
|
||
if _r_idx >= 0:
|
||
# 在原始文本中精确定位匹配范围
|
||
# 注意:只计算内容字符(非标点/空白),确保定位准确
|
||
_start = 0
|
||
_end = len(ts)
|
||
_norm_i = 0
|
||
_found_start = False
|
||
for _i, _c in enumerate(ts):
|
||
# 只计数内容字符(汉字、字母、数字、特殊符号[]等)
|
||
if re.sub(r'[\s\W_]+', '', _c) or _c in '[]()【】()√✓✗':
|
||
if not _found_start and _norm_i == _r_idx:
|
||
_start = _i
|
||
_found_start = True
|
||
_norm_i += 1
|
||
if _found_start and _norm_i >= _r_idx + len(_r_norm):
|
||
_end = _i + 1
|
||
break
|
||
|
||
# 移除匹配的bot回复部分,保留其余内容
|
||
_cleaned = ts[:_start] + ts[_end:]
|
||
if _cleaned.strip():
|
||
logger.debug(
|
||
f"[qq] 贪婪匹配剥离bot回复: {ts!r} -> {_cleaned.strip()!r} "
|
||
f"(移除: {ts[_start:_end]!r})"
|
||
)
|
||
ts = _cleaned.strip()
|
||
stripped_bot = True
|
||
break
|
||
if stripped_bot:
|
||
ts = re.sub(r'\s+', ' ', ts).strip().strip(',').strip()
|
||
return ts
|
||
|
||
@staticmethod
|
||
def _fuzzy_find(text: str, pattern: str, max_edits: int = 1) -> int:
|
||
"""在text中查找与pattern最多允许max_edits个编辑距离的子串(滑窗版)。
|
||
|
||
用于OCR误识别场景:如"草原探险"被识别为"草原深险",仅1字之差。
|
||
返回匹配起始位置,未找到返回-1。
|
||
"""
|
||
n, m = len(text), len(pattern)
|
||
if m == 0:
|
||
return 0
|
||
if n < m:
|
||
return -1
|
||
|
||
# 滑窗比较:每次移动一位,逐字符对比
|
||
for i in range(n - m + 1):
|
||
edits = 0
|
||
for j in range(m):
|
||
if text[i + j] != pattern[j]:
|
||
edits += 1
|
||
if edits > max_edits:
|
||
break
|
||
if edits <= max_edits:
|
||
return i
|
||
return -1
|
||
|
||
def _strip_qq_nickname(self, text: str):
|
||
"""从消息文本中剥离QQ用户昵称,防止昵称拼凑进消息文本导致查库关键词被压住。
|
||
|
||
QQ群聊截图中,OCR会把用户昵称和消息文本拼在一起,如:
|
||
"草原探险服务器备用号游戏专用在哪下载登陆插件"
|
||
其中"草原探险服务器备用号"和"游戏专用"是用户昵称,"在哪下载登陆插件"是实际消息。
|
||
|
||
策略(混合模式):
|
||
1. 前缀匹配(优先):处理常见的粘连格式("昵称+消息"无分隔符)
|
||
2. 全局匹配(兜底):处理昵称隐藏在文本中间的情况
|
||
3. 分隔符清理:移除昵称后残留的标点/空格
|
||
|
||
返回 (剥离后文本, mention_remainders集合):
|
||
mention_remainders = QQ @提及行剥离昵称后的余量(如"@游戏专用关于我们"→"关于我们")。
|
||
下游规则3b/规则4/短句碎片用它识别"用户@机器人提问"的真实消息,避免误当UI元素吞掉。
|
||
"""
|
||
if not text or not text.strip():
|
||
return text, set()
|
||
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 = [
|
||
"草原探险服务器备用号",
|
||
"游戏专用",
|
||
]
|
||
|
||
# ⭐ 辅助函数:标准化昵称,去除空格、括号、引号等,便于模糊匹配
|
||
def _normalize_nick(s: str) -> str:
|
||
"""标准化昵称,去除空格、中英文括号、双引号、单引号等。"""
|
||
# 去除空格、中英文括号、双引号、单引号
|
||
s = re.sub(r'[\s()()""'']+', '', s)
|
||
return s
|
||
|
||
# ⭐ 检查消息是否只是某个昵称(忽略空格和括号)
|
||
# 防止玩家昵称(如"班伽璐(woloqp)")通过所有过滤器进入查库
|
||
ts_normalized = _normalize_nick(ts)
|
||
for nick in nicknames:
|
||
nick_normalized = _normalize_nick(nick)
|
||
if ts_normalized == nick_normalized:
|
||
logger.debug(f"[qq] 消息仅为昵称,丢弃: {ts!r}")
|
||
return '', set()
|
||
|
||
# ⭐ 2026-08-18: 先收集@提及行剥离后的余量(真实用户消息证据)
|
||
# "那关于我们@游戏专用" → "那关于我们";"@游戏专用关于我们" → "关于我们";
|
||
# ")游戏专用关于我们"(OCR把@误读成))→ "关于我们";行首裸昵称"游戏专用关于我们"→"关于我们"
|
||
# ⚠ 纯提及无正文("@游戏专用"余量为空)不入集合——那才是真正的UI元素,仍需规则3b拦截
|
||
_mention_remainders = set()
|
||
for _line in ts.split('\n'):
|
||
for _nick in sorted(nicknames, key=len, reverse=True):
|
||
_search_from = 0
|
||
while True:
|
||
_pos = _line.find(_nick, _search_from)
|
||
if _pos < 0:
|
||
break
|
||
_before = _line[_pos - 1] if _pos > 0 else ''
|
||
_after = _line[_pos + len(_nick)] if _pos + len(_nick) < len(_line) else ''
|
||
# ⚠ 注意:'' in '任意串' 恒为 True!必须用 (_before and _before in ...) 短路
|
||
_is_mention = _pos == 0 or (_before and _before in '@))((') \
|
||
or (_after and _after in '@))((')
|
||
if _is_mention:
|
||
_s = _pos - 1 if (_before and _before in '@))((') else _pos
|
||
_e = _pos + len(_nick) + 1 if (_after and _after in '@))((') else _pos + len(_nick)
|
||
_rem = (_line[:_s] + _line[_e:]).strip(' @))((,,。.;;::')
|
||
if len(_rem) >= 2:
|
||
_mention_remainders.add(_rem)
|
||
_search_from = _pos + len(_nick)
|
||
|
||
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():]
|
||
# ⭐ 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:
|
||
# 干净@提及:剥掉昵称,保留 rest
|
||
ts = rest.lstrip()
|
||
if 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}")
|
||
else:
|
||
# ⭐ 2026-08-17 修复:疑似「昵称+正文」拼凑时**保留正文**!
|
||
# 原先 ts=rest.lstrip() 会把正文整段吞掉:
|
||
# "@咱想了解一下咱那个登录插件去哪儿下载?" → username=18字符,
|
||
# rest='?' → ts='?' 只剩问号 → 整条问句丢失。
|
||
# 未知昵称+正文粘连无法可靠切分,宁可保留所有字符(只剥@),
|
||
# 让 KB 用正文匹配;脏文本进查询的代价 << 消息全部丢失。
|
||
ts = ts[1:].lstrip()
|
||
logger.debug(f"[qq] @提及疑似昵称+正文拼凑,保留正文: ts={ts[:40]!r} username={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
|
||
# ⭐ 标准化前缀匹配:忽略空格、括号差异
|
||
ts_norm = _normalize_nick(ts)
|
||
nick_norm = _normalize_nick(nick)
|
||
if ts_norm.startswith(nick_norm):
|
||
# 使用正则表达式匹配原始文本中的昵称(忽略空格和括号)
|
||
pattern = re.escape(nick).replace(r'\ ', r'\s*').replace(r'\(', r'[((]?').replace(r'\)', r'[))]?')
|
||
m = re.match(pattern, ts)
|
||
if m:
|
||
ts = ts[m.end():].lstrip()
|
||
logger.debug(f"[qq] 标准化前缀匹配剥除昵称 {nick!r} (规范化={nick_norm!r})")
|
||
changed = True
|
||
break
|
||
|
||
# ⭐ 5) 全局匹配(兜底):昵称可能隐藏在文本中间
|
||
# 例:"=>草原探险服务器备用号游戏专用有问题" → 前缀匹配后剩"游戏专用有问题"
|
||
# 但有时前缀匹配失败,需要全局扫描
|
||
# ⭐ 2026-08-16: 添加OCR容错匹配——当精确匹配失败时,允许1字符差异
|
||
# 例:OCR把"草原探险服务器备用号"误识别为"草原深险服务器备用号"
|
||
# ⭐ 2026-08-18: 紧邻提及符(@ ) ( ( ))一并剥除——OCR把QQ @提及读成
|
||
# "那关于我们@游戏专用" / ")游戏专用关于我们" 等格式,只剥昵称会残留
|
||
# 孤立的 @/) 字符,导致下方规则3b/规则4把这些真实用户消息当UI元素误杀。
|
||
# ⭐ 2026-08-21: 添加标准化匹配——忽略空格、括号差异(如"班伽璐(woloqp)"匹配"班伽璐(woloqp)")
|
||
if not changed: # 前缀匹配无变化时才进行全局匹配
|
||
# 按长度降序排序,优先匹配长昵称(避免短前缀误匹配)
|
||
for nick in sorted(nicknames, key=len, reverse=True):
|
||
# 先尝试精确匹配
|
||
start = ts.find(nick)
|
||
if start >= 0:
|
||
# 连同紧邻的提及符一起剥(@昵称 / 昵称@ / )昵称 / (昵称)
|
||
_e = start + len(nick)
|
||
if start > 0 and ts[start - 1] in '@))((':
|
||
start -= 1
|
||
elif _e < len(ts) and ts[_e] in '@))((':
|
||
_e += 1
|
||
ts = (ts[:start] + ts[_e:]).lstrip()
|
||
logger.debug(f"[qq] 全局匹配剥除昵称 {nick!r} @ [{start}:{_e}]")
|
||
changed = True
|
||
break
|
||
|
||
# ⭐ 标准化匹配:忽略空格、括号差异
|
||
# 例:消息"班伽璐(woloqp)"匹配昵称"班伽璐(woloqp)"
|
||
ts_norm = _normalize_nick(ts)
|
||
nick_norm = _normalize_nick(nick)
|
||
if nick_norm in ts_norm:
|
||
# 找到匹配位置(在原始文本中)
|
||
# 使用正则表达式匹配,忽略空格和括号差异
|
||
# 构建模式:将昵称中的空格和括号替换为可选的模式
|
||
pattern = re.escape(nick).replace(r'\ ', r'\s*').replace(r'\(', r'[((]?').replace(r'\)', r'[))]?')
|
||
m = re.search(pattern, ts)
|
||
if m:
|
||
start = m.start()
|
||
_e = m.end()
|
||
# 连同紧邻的提及符一起剥
|
||
if start > 0 and ts[start - 1] in '@))((':
|
||
start -= 1
|
||
elif _e < len(ts) and ts[_e] in '@))((':
|
||
_e += 1
|
||
ts = (ts[:start] + ts[_e:]).lstrip()
|
||
logger.debug(f"[qq] 标准化匹配剥除昵称 {nick!r} (规范化={nick_norm!r}) @ [{start}:{_e}]")
|
||
changed = True
|
||
break
|
||
|
||
# 精确匹配失败,尝试模糊匹配(OCR常见形近字误识别)
|
||
# ⭐ 长度保护:只有长度>=4的昵称才允许模糊匹配,防止单字符昵称误删正常文本
|
||
# 例:单字符昵称'祁'模糊匹配会把'一'/'唉'/'他'等正常汉字误删
|
||
if len(nick) >= 4:
|
||
fuzzy_start = self._fuzzy_find(ts, nick, max_edits=1)
|
||
if fuzzy_start >= 0:
|
||
fuzzy_match = ts[fuzzy_start:fuzzy_start + len(nick)]
|
||
_e = fuzzy_start + len(nick)
|
||
if fuzzy_start > 0 and ts[fuzzy_start - 1] in '@))((':
|
||
fuzzy_start -= 1
|
||
elif _e < len(ts) and ts[_e] in '@))((':
|
||
_e += 1
|
||
ts = (ts[:fuzzy_start] + ts[_e:]).lstrip()
|
||
logger.debug(f"[qq] 模糊匹配剥除OCR误识别昵称 {nick!r} -> {fuzzy_match!r} @ [{fuzzy_start}:{_e}]")
|
||
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-16 修复:@后面不能是邮箱格式(排除youpaishidifu@qq.com误匹配)
|
||
# 原正则 `_mid_bare_nick = re.compile(r'(?:' + '|'.join(_escaped) + r')\s*[,,::@]')`
|
||
# 会匹配 "youpaishidifu@" → 导致群聊拆分只保留 "qq.com",消息内容丢失
|
||
# 新正则:使用前瞻 (?=[\n|\t|,|:]) 匹配空白/标点,或使用 (?=@(?![^@]*\.)) 匹配@但排除邮箱
|
||
_mid_bare_nick = None
|
||
if nicknames:
|
||
# 构造已知昵称的转义正则
|
||
_escaped = [re.escape(n) for n in nicknames]
|
||
# 关键改进:显式列出空白字符(\n|\t| )而非\s(\s不匹配换行),并排除邮箱格式
|
||
_mid_bare_nick = re.compile(
|
||
rf'(?:' + '|'.join(_escaped) + r')(?=\n| |\t|,|:)|'
|
||
rf'(?:' + '|'.join(_escaped) + r')(?=@(?![^@]*\\.))'
|
||
)
|
||
|
||
# 找到最后一个分隔符位置,截取其后的文本(最新消息)
|
||
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():
|
||
# 进一步检查:如果原文本身就是单个@或极短文本,直接返回空而不是回退
|
||
# 防止"@"这类无意义字符进入后续处理流程
|
||
cleaned = text.strip()
|
||
if len(cleaned) <= 1 or cleaned == '@':
|
||
logger.debug(f"[qq] 原文本身就是无意义字符,返回空: {cleaned!r}")
|
||
return '', _mention_remainders
|
||
logger.warning(f"[qq] 剥离后为空,回退原文防消息丢失: {cleaned!r}")
|
||
return cleaned, _mention_remainders
|
||
return ts.strip(), _mention_remainders
|
||
|
||
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_USER_DUP_TTL = 300.0 # 用户查询跨轮语义去重:5分钟内相似查询跳过
|
||
_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.75 # Jaccard相似度阈值:>=0.75视为语义重复(提高防误杀)
|
||
for text in texts:
|
||
is_dup = False
|
||
# ⭐ 问句豁免:含问号/疑问词的消息不视为语义重复(用户可能重复提问)
|
||
# 例:"登录插件在哪儿下载?" vs baseline轮次的同问句 → 应放行而非静默丢弃
|
||
if re.search(r'[??]', text) or any(qw in text for qw in ('吗', '呢', '多少', '怎么', '如何', '哪', '什么')):
|
||
result.append(text)
|
||
self._semantic_fingerprint_cache.append((text, now))
|
||
continue
|
||
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 _is_semantically_duplicate(self, text: str, platform: str) -> bool:
|
||
"""跨轮语义去重:5分钟内语义相似度>=阈值的用户查询跳过。
|
||
|
||
典型场景:用户发送"服务器最近有哪些公告信息?",机器人回复后,
|
||
几分钟后又发送相似查询(如"@服务器最近有哪些公告信息?"),
|
||
因OCR变体或细微措辞差异,被当作新查询再次处理。
|
||
|
||
方案:双层匹配——
|
||
1. Jaccard相似度(CJK bigram) >= 0.75 → 语义重复
|
||
2. 核心关键词完全一致 → 语义重复(兜底:Jaccard对"服务器"等修饰词敏感)
|
||
|
||
返回:True = 语义重复,应跳过;False = 新查询,可继续处理。
|
||
"""
|
||
if not text or not text.strip():
|
||
return False
|
||
|
||
now = time.time()
|
||
# 清理过期条目
|
||
self._recent_user_queries = [
|
||
(t, ts) for t, ts in self._recent_user_queries
|
||
if now - ts <= self._SEMANTIC_USER_DUP_TTL
|
||
]
|
||
|
||
# ⭐ 提取当前查询的核心关键词(去停用词后)
|
||
current_keywords = self._extract_content_keywords(text)
|
||
|
||
# 与最近查询比较
|
||
SIM_THRESHOLD = 0.75 # Jaccard相似度阈值:>=0.75视为语义重复(提高防误杀)
|
||
for recent_text, recent_ts in self._recent_user_queries:
|
||
# 层1: Jaccard相似度
|
||
sim = self._semantic_similarity(text, recent_text)
|
||
if sim >= SIM_THRESHOLD:
|
||
logger.debug(
|
||
f"[{platform}] 用户查询语义去重: 跳过相似({sim:.0%}) "
|
||
f"'{text[:40]!r}' ~ '{recent_text[:40]!r}'"
|
||
)
|
||
return True
|
||
|
||
# 层2: 核心关键词高重叠(兜底:Jaccard对"服务器"等修饰词敏感时)
|
||
# 例: "咱服务器那个登录插件去哪儿下载" vs "咱那个登录插件去哪儿下载"
|
||
# Jaccard=55%,但核心词{登录,插件,下载}高度重叠 → 视为重复
|
||
# 使用Szymkiewicz-Simpson系数: |A∩B| / min(|A|,|B|)
|
||
recent_keywords = self._extract_content_keywords(recent_text)
|
||
if current_keywords and recent_keywords and len(current_keywords) >= 2 and len(recent_keywords) >= 2:
|
||
overlap = len(current_keywords & recent_keywords)
|
||
min_size = min(len(current_keywords), len(recent_keywords))
|
||
overlap_ratio = overlap / min_size if min_size > 0 else 0
|
||
if overlap_ratio >= 0.80: # 80%重叠视为语义重复
|
||
logger.debug(
|
||
f"[{platform}] 用户查询语义去重: 核心词重叠({overlap_ratio:.0%}) "
|
||
f"'{text[:40]!r}' ~ '{recent_text[:40]!r}'"
|
||
)
|
||
return True
|
||
|
||
# 层3: 核心词子集检测(兜底:q2 vs q4 大小写差异时 overlap 偏低)
|
||
# 例: "登录插件儿下载" vs "登录插件下载"("儿"是OCR/口语变体)
|
||
# 检查过滤后文本是否为对方子串,或核心词bigram是否为对方子集
|
||
if current_keywords and recent_keywords and len(current_keywords) >= 3 and len(recent_keywords) >= 3:
|
||
# 核心词子集:小集合的bigram是否大部分在大集合中
|
||
smaller, larger = (current_keywords, recent_keywords) if len(current_keywords) <= len(recent_keywords) else (recent_keywords, current_keywords)
|
||
subset_ratio = len(smaller & larger) / len(smaller) if smaller else 0
|
||
if subset_ratio >= 0.90: # 90%子集视为语义重复
|
||
logger.debug(
|
||
f"[{platform}] 用户查询语义去重: 核心词子集({subset_ratio:.0%}) "
|
||
f"'{text[:40]!r}' ~ '{recent_text[:40]!r}'"
|
||
)
|
||
return True
|
||
|
||
return False
|
||
|
||
def _extract_content_keywords(self, text: str) -> frozenset:
|
||
"""提取文本的核心内容关键词(CJK bigram,去停用词)。
|
||
|
||
用于跨轮去重的关键词匹配兜底:当Jaccard相似度因修饰词差异偏低时,
|
||
核心关键词一致仍可判定为重复。
|
||
|
||
例: "咱服务器那个登录插件去哪儿下载啊" → frozenset{"登录", "插件", "下载", ...}
|
||
"""
|
||
if not text:
|
||
return frozenset()
|
||
# 提取CJK字符
|
||
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)
|
||
if len(filtered) < 2:
|
||
return frozenset()
|
||
# 提取bigram作为关键词
|
||
return frozenset(filtered[i:i+2] for i in range(len(filtered) - 1))
|
||
|
||
def _add_to_recent_user_queries(self, text: str):
|
||
"""将当前用户查询添加到最近查询列表,用于跨轮语义去重。"""
|
||
if text and text.strip():
|
||
self._recent_user_queries.append((text.strip(), time.time()))
|
||
|
||
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()
|
||
# ⭐ 2026-08-21 清理 bot markdown 加粗残留
|
||
t_before_star = t
|
||
t = re.sub(r'([\u4e00-\u9fff])\*(?!\*)', r'\1', t)
|
||
if t != t_before_star:
|
||
logger.debug(f"[ocr] _is_question_message: 清理markdown加粗残留*: {t_before_star[:60]!r} -> {t[:60]!r}")
|
||
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 _score_message_quality(text: str) -> float:
|
||
"""评估消息质量分,用于同轮择优(防bot抢跑)。
|
||
|
||
真实用户消息通常:
|
||
- 较短(污染历史消息常较长)
|
||
- 含疑问词或问号(用户提问)
|
||
- 无前缀噪声(如"必选,"、"运营方"等UI标签)
|
||
- 含有效关键词(插件、下载、地址等)
|
||
- 自然语言格式(非URL/链接碎片)
|
||
|
||
污染历史消息通常:
|
||
- 较长(混合了多条消息或UI元素)
|
||
- 无前缀噪声
|
||
- 不含疑问词
|
||
- 含URL/链接碎片(bot回复的典型特征)
|
||
|
||
返回质量分(越高越可能是真实用户消息)。
|
||
"""
|
||
if not text or len(text.strip()) < 2:
|
||
return -100.0 # 太短,可疑
|
||
|
||
score = 0.0
|
||
length = len(text)
|
||
|
||
# 长度惩罚:越长越可疑(污染消息通常较长)
|
||
if length > 50:
|
||
score -= (length - 50) * 0.5 # 每超1字扣0.5分
|
||
elif length <= 20:
|
||
score += 5.0 # 短消息加分
|
||
|
||
# 疑问词奖励:用户提问通常含疑问词
|
||
_QUESTION_WORDS = ('?', '?', '吗', '呢', '哪些', '什么', '怎么', '如何', '为什么', '能', '可以', '哪', '哪儿')
|
||
if any(q in text for q in _QUESTION_WORDS):
|
||
score += 10.0
|
||
|
||
# 噪声前缀惩罚:UI标签(如"必选,"、"运营方")提示历史污染
|
||
_NOISE_PREFIXES = ('必选', '运营方', '玩家', '服务器', '公告', '规则', '下载', '注意', '重要')
|
||
if text.startswith(_NOISE_PREFIXES) or any(text.startswith(p + ',') or text.startswith(p + ',') for p in _NOISE_PREFIXES):
|
||
score -= 15.0
|
||
|
||
# 有效关键词奖励:插件、下载、地址等提示真实用户意图
|
||
_GOOD_KEYWORDS = ('插件', '下载', '地址', '安装', '配置', '教程', '问题', '错误')
|
||
if any(kw in text for kw in _GOOD_KEYWORDS):
|
||
score += 3.0
|
||
|
||
# 英文/数字比例惩罚:污染消息常含乱码英文
|
||
cjk_count = len(re.findall(r'[\u4e00-\u9fff]', text))
|
||
ascii_count = len(re.findall(r'[a-zA-Z0-9]', text))
|
||
if ascii_count > cjk_count * 2 and cjk_count < 5:
|
||
score -= 10.0 # 纯英文/数字消息,可疑
|
||
|
||
# 标点混乱惩罚:OCR噪声常导致标点异常
|
||
if text.count(',') > 3 or text.count(',') > 3:
|
||
score -= 5.0
|
||
|
||
# ⭐ 新增:URL/链接格式惩罚(2026-08-16 抢跑问题修复)
|
||
# 历史bot回复常包含URL或链接格式,用户新消息通常是自然语言
|
||
# 检测模式:
|
||
# 1. 包含http://或https:// → 直接丢弃(纯URL消息)
|
||
# 2. 包含多个.分隔的域名片段(如"git.ypshidifu.cn")→ 降低分数
|
||
# 3. 包含Markdown链接格式(如"[官方下载](url)")→ 降低分数
|
||
# 4. 包含多个空格分隔的英文单词(如"GitHub gitee gitcode")→ 降低分数
|
||
_URL_PATTERNS = [
|
||
(r'https?://', -20.0), # 完整URL
|
||
(r'\.[a-z]{2,4}(?:\.[a-z]{2,4}){1,2}', -8.0), # 域名片段(如.cn .net)
|
||
(r'\[[^\]]+\]\([^)]+\)', -10.0), # Markdown链接
|
||
(r'\b(?:github|gitee|gitcode|ypshidifu|bbsmc|pan\.)[a-z.]*\b', -5.0), # 特定平台名
|
||
]
|
||
for pattern, penalty in _URL_PATTERNS:
|
||
matches = re.findall(pattern, text, re.I)
|
||
if matches:
|
||
score += penalty * min(len(matches), 2) # 最多扣2倍
|
||
|
||
# ⭐ 新增:中文+大量英文混合惩罚
|
||
# 真实用户消息通常是纯中文或中英混写,但不会包含多个英文平台名
|
||
if cjk_count > 0 and ascii_count > cjk_count * 1.5:
|
||
# 英文占比过高,可能是bot回复的链接列表
|
||
score -= 5.0
|
||
|
||
return score
|
||
|
||
@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 _has_past_date_timestamp(self, text: str) -> bool:
|
||
"""快速检测OCR文本是否包含过去日期的时间戳(昨天或更早)。
|
||
|
||
用于调用方在 _ocr_text_has_fresh_timestamp 返回 False 后,
|
||
区分"无时间戳"(可放行)和"过去日期时间戳已过期"(应丢弃)。
|
||
"""
|
||
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()
|
||
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])
|
||
# 截断到合法日期
|
||
for try_d in range(d, 0, -1):
|
||
try:
|
||
date_obj = datetime(y, m, try_d).date()
|
||
if date_obj <= today:
|
||
date_diff = (today - date_obj).days
|
||
if date_diff > 0:
|
||
return True
|
||
break
|
||
except ValueError:
|
||
continue
|
||
except (ValueError, IndexError):
|
||
continue
|
||
return False
|
||
|
||
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)
|
||
# ⭐ 预处理:拆分OCR拼接的时间戳(日期+时间粘连)
|
||
# 例如:2026/8/2322:30:07 → 2026/8/23 22:30:07
|
||
# 2026年8月23日22:30:07 → 2026年8月23日 22:30:07
|
||
normalized = re.sub(
|
||
r'(\d{4}[/-]\d{1,2}[/-]\d{1,2})(\d{1,2}:\d{1,2}(?::\d{1,2})?)',
|
||
r'\1 \2',
|
||
normalized
|
||
)
|
||
normalized = re.sub(
|
||
r'(\d{4}年\d{1,2}月\d{1,2}日)(\d{1,2}:\d{1,2}(?::\d{1,2})?)',
|
||
r'\1 \2',
|
||
normalized
|
||
)
|
||
# 点号/分号分隔→冒号(兜底: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-24 新逻辑:OCR时间戳作为丢弃依据仅当清晰可见的年月日过期
|
||
# - 有清晰年月日(无论今天还是过去)→ 整段丢弃(历史消息)
|
||
# - 无年月日(只有时间)→ 放行给下游DB查重(当天消息不带日期)
|
||
# - 年月日不清晰(如两位年份)→ 用本地时间辅助判断
|
||
# 根因:QQ当天消息不显示日期,历史消息才显示"年/月/日 时:分"
|
||
if dates:
|
||
# 有日期信息:检查是否清晰(四位年+月+日)
|
||
clear_date = False
|
||
unclear_date = False
|
||
for date_str in dates:
|
||
parts = date_str.split('/')
|
||
if len(parts) == 3:
|
||
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
|
||
# 清晰日期:四位年+月+日完整
|
||
if y >= 1000 and 1 <= m <= 12 and 1 <= d <= 31:
|
||
clear_date = True
|
||
break
|
||
else:
|
||
unclear_date = True
|
||
if clear_date:
|
||
# 清晰年月日:检查日期是否为过去
|
||
has_past = False
|
||
has_today = False
|
||
for date_str in dates:
|
||
try:
|
||
parts = date_str.split('/')
|
||
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 = True
|
||
elif date_diff == 0:
|
||
has_today = True
|
||
except (ValueError, IndexError):
|
||
pass
|
||
if has_today:
|
||
# 有今天日期:历史消息特征 → 整段丢弃
|
||
logger.debug(f"[ocr] 过滤器1结果: 检测到今天日期,丢弃历史消息: {text[:80]!r}")
|
||
return False
|
||
if has_past:
|
||
# 有过去日期:检查是否有新鲜时间戳(可能OCR拼接了今天的独立时间戳)
|
||
if timestamps:
|
||
for ts in timestamps:
|
||
try:
|
||
if not self._is_timestamp_old(ts, 300):
|
||
logger.debug(f"[ocr] 过滤器1结果: 过去日期+新鲜时间戳{ts}(≤300s),放行")
|
||
return True
|
||
except Exception:
|
||
pass
|
||
# 无新鲜时间戳:丢弃
|
||
logger.debug(f"[ocr] 过滤器1结果: 过去日期+无新鲜时间戳,丢弃: {text[:80]!r}")
|
||
return False
|
||
# 其他情况:丢弃(理论上不会到这里)
|
||
logger.debug(f"[ocr] 过滤器1结果: 检测到清晰年月日,丢弃历史消息: {text[:80]!r}")
|
||
return False
|
||
elif unclear_date:
|
||
# 年月日不清晰:用本地时间辅助判断时间戳是否过期
|
||
logger.debug(f"[ocr] 检测到不清晰年月日,用本地时间辅助判断")
|
||
# 如果有时间戳,检查时间戳是否过期(用本地时间比较)
|
||
if timestamps:
|
||
for ts in timestamps:
|
||
try:
|
||
if not self._is_timestamp_old(ts, 300):
|
||
logger.debug(f"[ocr] 过滤器1结果: 不清晰日期+新鲜时间戳{ts}(≤300s),放行")
|
||
return True
|
||
except Exception:
|
||
pass
|
||
# 所有时间戳都过期 → 丢弃
|
||
logger.debug(f"[ocr] 过滤器1结果: 不清晰日期+时间戳全过期,丢弃: {text[:80]!r}")
|
||
return False
|
||
# 无时间戳:放行给下游
|
||
logger.debug(f"[ocr] 过滤器1结果: 不清晰日期+无时间戳,放行")
|
||
return True
|
||
# ⭐ 无日期信息:放行给下游DB查重(当天消息不带日期)
|
||
if not dates:
|
||
logger.debug(f"[ocr] 过滤器1结果: 无日期信息,放行给下游DB查重")
|
||
# 如果有时间戳,检查时间戳新鲜度(可选,用于快速放行)
|
||
if timestamps:
|
||
for ts in timestamps:
|
||
try:
|
||
if not self._is_timestamp_old(ts, 300):
|
||
logger.debug(f"[ocr] 过滤器1结果: 无日期+新鲜时间戳{ts}(≤300s),放行")
|
||
return True
|
||
except Exception:
|
||
pass
|
||
# 时间戳过期但无日期:仍放行给DB查重(可能是当天消息,OCR时间戳不准确)
|
||
logger.debug(f"[ocr] 过滤器1结果: 无日期+时间戳过期,但放行给DB查重")
|
||
return True
|
||
# 无时间戳:需要检查噪声和中文字符(UI噪声应丢弃)
|
||
logger.debug(f"[ocr] 无日期+无时间戳,检查噪声和中文字符")
|
||
# 清理 bot markdown 加粗残留
|
||
text_before_star = text
|
||
text = re.sub(r'([\u4e00-\u9fff])\*(?!\*)', r'\1', text)
|
||
if text != text_before_star:
|
||
logger.info(f"[ocr] 清理markdown加粗残留*(无时间戳): {text_before_star[:60]!r} -> {text[:60]!r}")
|
||
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
|
||
# 放行给下游DB查重
|
||
logger.debug(f"[ocr] 过滤器1结果: 无日期+无时间戳+中文消息,放行给下游DB查重")
|
||
return True
|
||
if not timestamps:
|
||
# ⭐ 无可见时间戳:检查噪声文本
|
||
logger.debug(f"[ocr] 无可见时间戳")
|
||
# ⭐ 2026-08-21 清理 bot markdown 加粗残留(无时间戳分支)
|
||
text_before_star = text
|
||
text = re.sub(r'([\u4e00-\u9fff])\*(?!\*)', r'\1', text)
|
||
if text != text_before_star:
|
||
logger.info(f"[ocr] 清理markdown加粗残留*(无时间戳): {text_before_star[:60]!r} -> {text[:60]!r}")
|
||
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
|
||
# 有新鲜时间戳 → 确定有新消息
|
||
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)
|
||
|
||
@staticmethod
|
||
def _strip_programming_format_blocks(raw_text: str, platform: str = "") -> str:
|
||
"""剥离消息头部/尾部的编程格式块(JSON、YAML、Markdown表格等)。
|
||
|
||
用户要求:所有JSON、YAML、Markdown等编程语言格式,如果在消息头部,则整段跳过不处理;
|
||
如果在尾部,则一刀切掉尾部。
|
||
|
||
策略:
|
||
1. 检测消息头部(去除空行后)是否为JSON对象、YAML块、Markdown表格。
|
||
如果是,整段丢弃(返回空字符串)。
|
||
2. 如果不是头部,则检测消息尾部是否包含这些格式。
|
||
如果是,切掉尾部(从格式开始到末尾),返回前段。
|
||
3.否则原样返回。
|
||
|
||
安全边界:
|
||
- 用户聊天不会以 { 开头(JSON),也不会以 | 表格行开头(Markdown表格)。
|
||
- YAML 块以 key: value 开头,用户聊天不会这样开头。
|
||
- 因此头部检测误杀风险低。
|
||
"""
|
||
if not raw_text or not raw_text.strip():
|
||
return raw_text
|
||
|
||
lines = raw_text.split('\n')
|
||
# 去除空行,得到非空行列表
|
||
non_empty_lines = [line.strip() for line in lines if line.strip()]
|
||
if not non_empty_lines:
|
||
return raw_text
|
||
|
||
# —— 兼容格式:检测不完整JSON片段(中英文双引号包裹键值对,以逗号/括号结尾)——
|
||
def _is_json_fragment(line: str) -> bool:
|
||
"""检测单行是否为JSON片段(键值对模式),即使不完整。"""
|
||
# 匹配 "key": "value", 或 "key": value} 或 "key": "value"]
|
||
# 支持中英文双引号,键和值都可能被引号包裹,也可能没有
|
||
pattern = r'["\u201c\u201d].*?["\u201c\u201d]\s*:\s*["\u201c\u201d].*?["\u201c\u201d]\s*[,}\])]'
|
||
if re.search(pattern, line):
|
||
return True
|
||
# 也匹配不完整情况: "key": "value" 后缺少闭合引号,但有逗号/括号
|
||
# 例如:"text":"test", 或 "text":"test}
|
||
pattern2 = r'["\u201c\u201d].*?["\u201c\u201d]\s*:\s*["\u201c\u201d].*?[,}\])]'
|
||
if re.search(pattern2, line):
|
||
return True
|
||
# 匹配键没有引号的情况: key: "value", 或 key: value}
|
||
pattern3 = r'^[a-zA-Z_][\w]*\s*:\s*["\u201c\u201d].*?["\u201c\u201d]\s*[,}\])]'
|
||
if re.search(pattern3, line):
|
||
return True
|
||
return False
|
||
|
||
# —— 头部检测 ——
|
||
# 如果非空行首行以 { 开头,可能为JSON对象
|
||
first_line = non_empty_lines[0]
|
||
if first_line.startswith('{'):
|
||
# 尝试检测完整的JSON对象(大括号深度归零)
|
||
depth = 0
|
||
json_end_index = -1
|
||
for idx, line in enumerate(non_empty_lines):
|
||
depth += line.count('{') - line.count('}')
|
||
if depth <= 0:
|
||
json_end_index = idx
|
||
break
|
||
if json_end_index >= 0:
|
||
# 找到完整JSON对象,整段丢弃
|
||
logger.debug(f"[{platform}] 编程格式剥离: 头部JSON对象,整段丢弃: {first_line[:60]!r}")
|
||
return ''
|
||
|
||
# 兼容格式:如果首行是JSON片段(即使不完整),整段丢弃
|
||
if _is_json_fragment(first_line):
|
||
logger.debug(f"[{platform}] 编程格式剥离: 头部JSON片段(兼容),整段丢弃: {first_line[:60]!r}")
|
||
return ''
|
||
|
||
# 如果非空行首行以 | 开头,可能为Markdown表格
|
||
if first_line.startswith('|'):
|
||
# 检测至少两行以 | 结尾(表格特征)
|
||
table_lines = 0
|
||
for line in non_empty_lines[:5]: # 只检查前5行
|
||
if line.endswith('|'):
|
||
table_lines += 1
|
||
if table_lines >= 2:
|
||
logger.debug(f"[{platform}] 编程格式剥离: 头部Markdown表格,整段丢弃: {first_line[:60]!r}")
|
||
return ''
|
||
|
||
# 如果非空行首行匹配YAML键值对模式(key: value)
|
||
if re.match(r'^[a-zA-Z_][\w]*\s*:\s*', first_line) and \
|
||
not re.match(r'^(https?|ftp)://', first_line) and \
|
||
not re.match(r'^[a-zA-Z_][\w]*\s*:\s*/', first_line):
|
||
# 检测后续行是否有缩进或 - 列表项(YAML块特征)
|
||
yaml_block_lines = 1
|
||
for line in non_empty_lines[1:10]: # 检查后续9行
|
||
if re.match(r'^\s+', line) or re.match(r'^-\s+\S', line):
|
||
yaml_block_lines += 1
|
||
else:
|
||
break
|
||
if yaml_block_lines >= 2: # 至少2行符合YAML特征
|
||
logger.debug(f"[{platform}] 编程格式剥离: 头部YAML块,整段丢弃: {first_line[:60]!r}")
|
||
return ''
|
||
|
||
# —— 尾部检测 ——
|
||
# 从尾部向前扫描,找到编程格式开始的位置
|
||
last_line = non_empty_lines[-1]
|
||
# 尾部JSON对象:以 } 结尾,且前面有对应的 {
|
||
if last_line.endswith('}'):
|
||
#向前找到匹配的 {
|
||
depth = 0
|
||
json_start_index = -1
|
||
for idx in range(len(non_empty_lines)-1, -1, -1):
|
||
line = non_empty_lines[idx]
|
||
depth += line.count('}') - line.count('{')
|
||
if depth <= 0:
|
||
json_start_index = idx
|
||
break
|
||
if json_start_index >= 0 and json_start_index < len(non_empty_lines)-1:
|
||
# 切掉从json_start_index开始到末尾的所有行
|
||
cut_lines = lines[:json_start_index]
|
||
result = '\n'.join(cut_lines).strip()
|
||
if result:
|
||
logger.debug(f"[{platform}] 编程格式剥离: 尾部JSON对象,切掉尾部: {last_line[:60]!r}")
|
||
return result
|
||
else:
|
||
# 切完为空,说明整段都是JSON,丢弃
|
||
return ''
|
||
|
||
# 兼容格式:如果尾行是JSON片段(即使不完整),切掉尾部
|
||
if _is_json_fragment(last_line):
|
||
# 找到JSON片段开始的行(从尾部向上找第一个不是JSON片段的行)
|
||
json_start = len(non_empty_lines) - 1
|
||
while json_start > 0 and _is_json_fragment(non_empty_lines[json_start-1]):
|
||
json_start -= 1
|
||
# 切掉从json_start开始到末尾的所有行
|
||
cut_lines = lines[:json_start]
|
||
result = '\n'.join(cut_lines).strip()
|
||
if result:
|
||
logger.debug(f"[{platform}] 编程格式剥离: 尾部JSON片段(兼容),切掉尾部: {last_line[:60]!r}")
|
||
return result
|
||
else:
|
||
return ''
|
||
|
||
# 尾部Markdown表格:以 | 结尾,且前一行也以 | 结尾
|
||
if last_line.endswith('|') and len(non_empty_lines) >= 2 and non_empty_lines[-2].endswith('|'):
|
||
# 找到表格开始的行(从尾部向上找第一个不以 | 结尾的行)
|
||
table_start = len(non_empty_lines) - 1
|
||
while table_start > 0 and non_empty_lines[table_start-1].endswith('|'):
|
||
table_start -= 1
|
||
# 切掉从table_start开始到末尾的所有行
|
||
cut_lines = lines[:table_start]
|
||
result = '\n'.join(cut_lines).strip()
|
||
if result:
|
||
logger.debug(f"[{platform}] 编程格式剥离: 尾部Markdown表格,切掉尾部: {last_line[:60]!r}")
|
||
return result
|
||
else:
|
||
return ''
|
||
|
||
# 尾部YAML块:以 key: value 或 - item 结尾,且前面有缩进
|
||
if (re.match(r'^[a-zA-Z_][\w]*\s*:\s*', last_line) or re.match(r'^-\s+\S', last_line)) and \
|
||
len(non_empty_lines) >= 2:
|
||
# 向前找到YAML块开始(第一个不缩进且不匹配YAML特征的行)
|
||
yaml_start = len(non_empty_lines) - 1
|
||
while yaml_start > 0:
|
||
prev_line = non_empty_lines[yaml_start-1]
|
||
if re.match(r'^\s+', prev_line) or re.match(r'^[a-zA-Z_][\w]*\s*:\s*', prev_line) or re.match(r'^-\s+\S', prev_line):
|
||
yaml_start -= 1
|
||
else:
|
||
break
|
||
if yaml_start < len(non_empty_lines)-1:
|
||
cut_lines = lines[:yaml_start]
|
||
result = '\n'.join(cut_lines).strip()
|
||
if result:
|
||
logger.debug(f"[{platform}] 编程格式剥离: 尾部YAML块,切掉尾部: {last_line[:60]!r}")
|
||
return result
|
||
else:
|
||
return ''
|
||
|
||
# 无编程格式,原样返回
|
||
return raw_text
|
||
|
||
# —— md 尾巴剥离模式(一刀切)——
|
||
# ⭐ 2026-08-17 原则:用户聊天从不手打 markdown(- [ ] 列表、# 标题、**加粗**、
|
||
# [x](url) 链接、| 表格、` 代码),任何 md 特有结构都可安全一刀切。
|
||
# OCR 偏差(如 [ √ ] 带空格、[✔]、【√】、[x、[ ])会让"逐个符号匹配"漏网,
|
||
# 所以 checkbox 用"任意短方括号"结构匹配,而非枚举具体勾选符号。
|
||
# 规则: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'\]('),
|
||
# ⭐ 一刀切:md checkbox 内容=单符号或空白([√] [x] [ ] [⚠]),
|
||
# 用户游戏名词方括号是多字词([钻石] [VIP])绝不命中。
|
||
# \s*.?\s* = 空 或 单字符(√✗xXVv⚠!✔ 等任意符号,OCR 变体如 [ √ ] 也覆盖)
|
||
# 不再枚举具体符号——OCR 偏差永远撞不全符号,单字符结构才是稳定特征。
|
||
# 负向前瞻 (?!\() 保护 md 链接 [text](url) 不被算作 checkbox。
|
||
re.compile(r'\[\s*.?\s*\](?!\()\s*(?=\(|[\u4e00-\u9fffA-Za-z]|$)'),
|
||
# 全角 checkbox:OCR 把 [√] 读成 【√】(内容仍为单符号或空白)
|
||
re.compile(r'【\s*.?\s*】\s*(?=\(|[\u4e00-\u9fffA-Za-z]|$)'),
|
||
# - 列表 / 编号列表 / > 引用(行内出现,前有空白或行首)
|
||
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\)'),
|
||
]
|
||
|
||
# —— md 头部剥离模式 ⭐ 2026-08-19 新增 ——
|
||
# 背景:OCR 把 bot 的 md 列表行(- 玩家义务 / * 注意 / > 引用 / # 标题 /
|
||
# 1. 步骤 / [√]勾选)拼到用户【新消息前面】,例:
|
||
# "- 关于我们" / "-关于我们" / ">关于我们" / "#关于我们" / "1.关于我们"
|
||
# 旧 _strip_markdown_tail 只剥尾部,对这类【头部 md】无能为力:
|
||
# - 有空格 "- 关于我们" → 被它判 md大头 → 整段丢弃(用户消息丢失!)
|
||
# - 无空格 "-关于我们" → 列表模式要求 \s+ → 漏检 → 带 md 碎片入库污染查库
|
||
# 本组专门剥【开头】md 标记,容忍缺失空格,保留尾部用户真实提问。
|
||
# 安全边界:仅匹配单字符/空方括号([√]/[ ]/[x]),绝不命中用户游戏名词
|
||
# 方括号([钻石] 多字词);用户日常不以 - * > # 1. 起头,剥这些前缀安全。
|
||
_MD_HEAD_PATTERNS = [
|
||
# 列表项 - / * / +(容忍无空格:-关于我们 也要处理)
|
||
re.compile(r'^[-\*\+]\s*'),
|
||
# 编号列表 1. / 2) / 12、 (容忍无空格)
|
||
re.compile(r'^\d{1,3}[\.\)、]\s*'),
|
||
# 引用块 > (容忍无空格)
|
||
re.compile(r'^>\s*'),
|
||
# 标题 # / ## (容忍直接跟汉字/数字、容忍无空格)
|
||
# ⭐ 2026-08-22 修复:增加无空格模式,确保 "#@xxx" 或 "#xxx" 能被剥离
|
||
re.compile(r'^#{1,6}\s*'),
|
||
re.compile(r'^#{1,6}(?=[@A-Za-z0-9\u4e00-\u9fff])'),
|
||
# md checkbox 残片(单字符/空方括号,OCR 退化 [ √ ] [x] [ ] 全覆盖)
|
||
# 仅单字符/空白内容,用户游戏名词方括号[钻石](多字词)绝不命中
|
||
re.compile(r'^\[\s*.?\s*\]\s*'),
|
||
# 全角 checkbox 退化 【√】
|
||
re.compile(r'^【\s*.?\s*】\s*'),
|
||
]
|
||
|
||
@staticmethod
|
||
def _strip_markdown_head(text: str):
|
||
"""⭐ 2026-08-19 新增:剥离拼在【开头】的 markdown 标记。
|
||
|
||
返回剥离后文本;None = 整段都是 md(丢弃)。
|
||
|
||
背景:OCR 常把 bot 的 md 列表行(- 玩家义务 / * 注意 / > 引用 / # 标题 /
|
||
1. 步骤 / [√]勾选)拼到用户【新消息前面】,典型:
|
||
"- 关于我们" / "-关于我们" / ">关于我们" / "#关于我们" / "1.关于我们"
|
||
旧 _strip_markdown_tail 只剥尾部,对这类头部 md 无能为力:
|
||
- 有空格 "- 关于我们" → 被它判 md大头 → 整段丢弃(用户消息丢失!)
|
||
- 无空格 "-关于我们" → 列表模式要求 \\s+ → 漏检 → 带 md 碎片入库污染查库
|
||
本函数循环剥离开头 md 标记(容忍缺失空格),保留尾部用户真实提问。
|
||
|
||
安全边界(防误杀正常消息):
|
||
- 仅剥单字符/空方括号 [√]/[ ]/[x];多字游戏名词 [钻石] 绝不命中
|
||
- 用户日常对话不会以 - * > # 1. 起头,剥这些前缀安全
|
||
- 循环剥离(如 "- > # 关于我们")但每次至少消耗 1 字符,_guard 兜底必终止
|
||
"""
|
||
if not text or not text.strip():
|
||
return text
|
||
result = text
|
||
_guard = 0
|
||
while _guard < 20:
|
||
_guard += 1
|
||
_matched = False
|
||
for pat in MessageMonitor._MD_HEAD_PATTERNS:
|
||
m = pat.match(result)
|
||
if m:
|
||
result = result[m.end():]
|
||
_matched = True
|
||
break
|
||
if not _matched:
|
||
break
|
||
result = result.strip()
|
||
if not result:
|
||
# 剥完啥也不剩 → 整段都是 md → 丢弃
|
||
return None
|
||
return result
|
||
|
||
@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 字符片段)
|
||
# ⭐ 2026-08-22 修复:增加无空格检测,防止 "#@xxx" 格式漏网
|
||
if re.match(r'^#{1,6}(?:\s|\[|[\u4e00-\u9fff]|[@A-Za-z0-9])', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到Markdown标题语法,返回True")
|
||
return True
|
||
# 中文编号列表:第X步 / 步骤X(Bot md 回复常见,用户日常不打)
|
||
# ⭐ 2026-08-21 修复:OCR碎片把第X步拼到文本中部(如 'cdkl 1分 第三步:...'),
|
||
# re.match行首匹配漏网 → 改用 re.search 全文匹配
|
||
if re.search(r'第[一二三四五六七八九十百千万零\d]+步', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到中文编号列表(全文匹配),返回True")
|
||
return True
|
||
if re.search(r'步骤[::]', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到步骤说明(全文匹配),返回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] / [ √ ] / [ ])
|
||
# ⭐ 一刀切:单字符方括号即 md checkbox 特征(用户聊天方括号是多字词 [钻石])
|
||
if re.search(r'\[\s*.?\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 回复标配,用户日常不用)
|
||
# 用户日常对话从不打 *...*, 只可能是 bot md 回复
|
||
if '**' in text:
|
||
return True
|
||
# 代码块内容检测:包含多个编程关键字(Bot md 回复常见,用户日常不打)
|
||
# ⭐ 2026-08-21 扩展关键词列表:增加游戏服管理术语(控制台/执行/导入/插件)
|
||
code_keywords = ['import', 'def', 'class', 'function', 'return', 'print', 'if', 'for', 'while',
|
||
'控制台', '执行', '导入', '插件']
|
||
code_keyword_count = sum(1 for kw in code_keywords if re.search(r'\b' + kw + r'\b', text))
|
||
if code_keyword_count >= 2:
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到代码块内容({code_keyword_count}个编程关键字),返回True")
|
||
return True
|
||
# ⭐ 2026-08-21 新增:bot回复格式说明碎片拦截
|
||
# 用户反馈 "格式2: json格式/" 这类碎片触发自动回复(内容牛头不对马嘴)
|
||
# 特征:格式编号 + 格式名称(json/php/yaml/xml等)
|
||
if re.search(r'格式\d+', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到格式编号(如'格式2'),返回True")
|
||
return True
|
||
# 组合模式:格式\d+.*格式(如 "格式2: json格式/")
|
||
if re.search(r'格式\d+[::].*格式', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到格式编号+格式名称组合,返回True")
|
||
return True
|
||
# 文件扩展名检测(Bot md 回复中常引用文件)
|
||
if re.search(r'\S+\.(txt|json|yml|yaml|md|py|js|ts|html|css|xml|java|c|cpp|h|rs|go|sh|bat|cmd)\b', text):
|
||
logger.debug(f"[ocr] 过滤器3结果: 检测到文件扩展名,返回True")
|
||
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
|
||
# *斜体* 标记:*content* 或 ***粗斜体***
|
||
if re.search(r'\*[^*\n]+\*', 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 或 [...]Username
|
||
# QQ 群成员的头衔标签,如【专业皮肤定制】C418minecraft
|
||
# ⭐ 2026-08-19: OCR 常把全角【】识别成半角[],两种都匹配
|
||
# ⚠ 扩展:行内含头衔+无问号→bot消息(头衔出现在消息体中间,说明是bot回复,
|
||
# 用户消息头衔在用户名上方不在消息体里);行首头衔+独立用户名→同样过滤。
|
||
if re.match(r'^(?:【[^】]{1,30}】|\[[^\]]{1,30}\])\s*[\w\u4e00-\u9fff]+', s):
|
||
logger.debug(f"[qq-ui] 规则2匹配(头衔徽章): {s}")
|
||
return True
|
||
# ⭐ 2026-08-19: 行内含半角/全角头衔 + 无问号 + 长度≤30字 → bot回复
|
||
# 如"来吧小姐姐,[专业皮肤定制]"(12字)、"我>新问题,[专业皮肤定制]"(16字)
|
||
# 头衔出现在消息体中间 = bot消息(用户消息头衔不在消息体里)
|
||
# ⚠ 长度限制:用户正常消息含头衔(如"专业皮肤定制怎么配?")>30字不拦截
|
||
_q_in_s = ('?' in s or '?' in s or any(q in s for q in ('怎么', '如何', '什么', '请问', '哪')))
|
||
if not _q_in_s and len(s) <= 30 and (re.search(r'(?:【[^】]{1,30}】|\[[^\]]{1,30}])', s)):
|
||
logger.debug(f"[qq-ui] 规则2b匹配(消息体嵌入头衔): {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():]
|
||
# ⭐ 2026-08-18: 如果是已知用户昵称,视为OCR误读(把@读成)),放行而非丢弃
|
||
# 如 ")游戏专用关于我们" → OCR把"@游戏专用 关于我们"误读
|
||
_known_nicks = {'游戏专用', '草原探险服务器备用号', 'youpaishidifu'}
|
||
if _m4.group(1) in _known_nicks:
|
||
logger.debug(f"[qq-ui] 规则4豁免(已知用户名OCR误读): {s} (用户名={_m4.group(1)})")
|
||
return False
|
||
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
|
||
# 7) Bot账号自身消息:行内含bot账号名(youpaishidifu)+ 无疑问词
|
||
# 重启后历史清空,bot旧回复被OCR重新识别当成新消息入库。
|
||
# 特征:含账号名 + 无问号/疑问词 → bot系统消息,过滤。
|
||
# ⚠ 含疑问词时放行(如"youpaishidifu是什么?"可能是用户真实查询)
|
||
_bot_accounts = ('youpaishidifu',)
|
||
if any(acc in s for acc in _bot_accounts):
|
||
_qwords7 = ('?', '?', '请问', '是不是', '怎么', '如何', '什么', '哪', '谁', '多少', '几')
|
||
if not any(q in s for q in _qwords7):
|
||
logger.debug(f"[qq-ui] 规则7匹配(bot账号消息): {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)
|
||
# ⭐ 2026-08-21 清理 bot markdown 加粗残留(同 filter 管道)
|
||
if stripped_seg:
|
||
stripped_seg_before_star = stripped_seg
|
||
stripped_seg = re.sub(r'([\u4e00-\u9fff])\*(?!\*)', r'\1', stripped_seg)
|
||
if stripped_seg != stripped_seg_before_star:
|
||
logger.debug(f"[{platform}] 预分割: 清理markdown加粗残留*: {stripped_seg_before_star[:60]!r} -> {stripped_seg[:60]!r}")
|
||
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
|
||
|
||
# ⭐ 闲聊消息检测(2026-08-22 新增)
|
||
# 特征:短文本 + 疑问词 + 无具体查询主题
|
||
# 例:"有时间吗"、"在不在"、"好的"、"嗯嗯" → 缺乏可查库的具体主题
|
||
# 检测逻辑:
|
||
# 1. 长度 ≤ 8 字符(太短无法构成完整查询)
|
||
# 2. 包含疑问词 "吗" 或 "呢",或匹配闲聊模式
|
||
# 3. 无核心关键词(无插件名、功能词、名词等)
|
||
_chat_patterns = [
|
||
r'^有[\s\S]*?[吗呢]$', # "有时间吗"、"有东西吗"、"有人吗"
|
||
r'^在不在$', # "在不在"
|
||
r'^在[嘛吗]$', # "在吗"、"在嘛"
|
||
r'^你好吗?$', # "你好"、"你好吗"
|
||
r'^好(?:的|吧)?$', # "好"、"好的"、"好吧"
|
||
r'^嗯(?:嗯|哼|哦)?$', # "嗯"、"嗯嗯"、"嗯哼"、"嗯哦"
|
||
r'^哦(?:哦)?$', # "哦"、"哦哦"
|
||
r'^收到$', # "收到"
|
||
r'^明白(?:了)?$', # "明白"、"明白了"
|
||
r'^知道(?:了|道)?$', # "知道"、"知道了"、"知道道"
|
||
r'^嗯?啊$|啊?$|嗯?唉$', # "啊"、"唉"、"嗯唉"
|
||
]
|
||
_clean_text = re.sub(r'[;;,,。!?!?.\s]', '', text) # 去除标点
|
||
if len(_clean_text) <= 8 and ('吗' in _clean_text or '呢' in _clean_text or '在不在' in _clean_text):
|
||
for pat in _chat_patterns:
|
||
if re.match(pat, _clean_text):
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到闲聊消息(无具体主题),返回True: {text!r}")
|
||
return True
|
||
|
||
# ⭐ 闲聊消息检测(2026-08-22 新增)
|
||
# 特征:短文本 + 疑问词 + 无具体查询主题
|
||
# 例:"有时间吗"、"在不在"、"好的"、"嗯嗯" → 缺乏可查库的具体主题
|
||
# 检测逻辑:
|
||
# 1. 长度 ≤ 8 字符(太短无法构成完整查询)
|
||
# 2. 包含疑问词 "吗" 或 "呢"(疑问句式)
|
||
# 3. 无核心关键词(无插件名、功能词、名词等)
|
||
_chat_patterns = [
|
||
r'^有[\s\S]*?吗$', # "有时间吗"、"有东西吗"
|
||
r'^在不在$', # "在不在"
|
||
r'^你好吗?$', # "你好"、"你好吗"
|
||
r'^好的?$', # "好"、"好的"
|
||
r'^嗯(?:嗯)?$', # "嗯"、"嗯嗯"
|
||
r'^哦?$', # "哦"
|
||
r'^嗯哼$', # "嗯哼"
|
||
r'^收到$', # "收到"
|
||
r'^明白$', # "明白"
|
||
r'^知道了$', # "知道了"
|
||
]
|
||
if len(text) <= 8 and ('吗' in text or '呢' in text):
|
||
for pat in _chat_patterns:
|
||
if re.match(pat, text.strip()):
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到闲聊消息(无具体主题),返回True: {text!r}")
|
||
return True
|
||
# 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残片检测:表格单元格/孤立名词短语(无主语无谓语)
|
||
# 如"端口(基岩版)"、"等级"、"评判规则"等——这些是bot回复表格内容的残片
|
||
# 特征:
|
||
# 1. 长度≤10字符(太短无法构成完整问句)
|
||
# 2. 无疑问词(用户真问句会含"吗呢多少怎么"等)
|
||
# 3. 无问号
|
||
# 4. 无动词(判断是否为名词短语)
|
||
# 5. 无主语(无人称代词)
|
||
# 放行条件:含疑问词或有问号→可能是用户真问句,不拦截
|
||
#
|
||
# ⚠️ 2026-08-20 修复:行合并可能把"残片+正常内容"拼成一条
|
||
# 例:'个桓温'(残片)+ ')youpaishidifu 关于我们'(正常查询)
|
||
# → 合并后 '个桓温,关于我们' → 残片检测误杀整条
|
||
# 处理:若文本含逗号分隔且后半部分含疑问词/动词/主语 → 提取后半部分放行
|
||
if len(text) <= 10:
|
||
_has_question_word = any(w in text for w in ('吗', '呢', '多少', '怎么', '如何', '哪', '什么', '几', '?', '?'))
|
||
_has_verb = any(v in text for v in ('怎么', '如何', '进行', '设置', '配置', '操作', '使用', '打开', '下载', '安装', '登录', '进入', '查看', '查询', '问', '找', '要', '可以', '能', '会', '请', '帮', '给', '有'))
|
||
_has_subject = any(s in text for s in ('我', '你', '他', '我们', '你们', '他们', '玩家', '用户', '管理员'))
|
||
# 无疑问词 + 无动词 或 无疑问词 + 无主语 → 可能是残片
|
||
if not _has_question_word and (not _has_verb or not _has_subject):
|
||
# ⭐ 核心名词放行:包含核心名词的短文本可能是用户查询词,不视为残片
|
||
_core_nouns = {'公告', '更新', '通知', '说明', '规则', '版本', '下载', '安装', '登录', '配置', '设置', '操作', '模式', '列表', '详情', '信息', '内容', '介绍', '功能', '系统', '服务', '玩家', '用户', '管理员', '服务器', '客户端', '插件', '模块', '接口', 'API', 'BUG', '问题', '错误', '异常', '日志', '配置', '文件', '目录', '路径', '地址', '链接', '网址', '网站', '网页', '页面', '界面', '菜单', '按钮', '图标', '图片', '视频', '音频', '文档', '手册', '指南', '教程', '帮助', '支持', '反馈', '建议', '意见', '投诉', '举报', '奖励', '惩罚', '活动', '比赛', '排名', '积分', '等级', '权限', '角色', '职业', '技能', '装备', '道具', '货币', '交易', '市场', '商店', '背包', '仓库', '邮件', '聊天', '组队', '公会', '领地', '地图', '任务', '副本', 'BOSS', '怪物', '掉落', '经验', '属性', '状态', '效果', 'BUFF', 'DEBUFF', '冷却', '消耗', '需求', '条件', '限制'}
|
||
if any(noun in text for noun in _core_nouns):
|
||
logger.debug(f"[ocr] 过滤器2结果: 包含核心名词,跳过残片判定: {text!r}")
|
||
# 不return True,继续后续检查(但后续检查可能也会return True,所以我们直接return False)
|
||
return False
|
||
# ⭐ 修复(2026-08-21):短文本逗号分段放行
|
||
# 原 elif 分支只在 len>10 时运行,短文本永远不走逗号分段。
|
||
# 例:'小姐姐*, 关于我们'(len=10) → _has_verb=False → 误判残片→丢消息
|
||
# 修复:len<=10 分支内也检查逗号分段,后半部分含有效查询→放行
|
||
if ',' in text or ',' in text:
|
||
_parts = re.split(r'[,,]', text)
|
||
if len(_parts) >= 2:
|
||
_second_part = _parts[-1].strip()
|
||
_has_q2 = any(w in _second_part for w in ('吗', '呢', '多少', '怎么', '如何', '哪', '什么', '几', '?', '?'))
|
||
_has_v2 = any(v in _second_part for v in ('怎么', '如何', '进行', '设置', '配置', '操作', '使用', '打开', '下载', '安装', '登录', '进入', '查看', '查询', '问', '找', '要', '可以', '能', '会', '请', '帮', '给', '有'))
|
||
_has_s2 = any(s in _second_part for s in ('我', '你', '他', '我们', '你们', '他们', '玩家', '用户', '管理员'))
|
||
if len(_second_part) >= 3 and (_has_q2 or _has_v2 or _has_s2):
|
||
logger.debug(f"[ocr] 过滤器2结果: 短文本逗号分段放行(后半含有效查询): {text!r} -> {_second_part!r}")
|
||
# 不return True,继续后续检查
|
||
else:
|
||
_common_query = any(text.startswith(q) for q in ('怎么', '如何', '什么', '哪里', '哪个', '几', '多少', '为什么', '在哪', '如何'))
|
||
_ends_with_question = text.rstrip().endswith(('?', '?', '!', '!'))
|
||
if not _common_query and not _ends_with_question:
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到OCR残片(无疑问词+无动词/主语),返回True: {text!r}")
|
||
return True
|
||
else:
|
||
_common_query = any(text.startswith(q) for q in ('怎么', '如何', '什么', '哪里', '哪个', '几', '多少', '为什么', '在哪', '如何'))
|
||
_ends_with_question = text.rstrip().endswith(('?', '?', '!', '!'))
|
||
if not _common_query and not _ends_with_question:
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到OCR残片(无疑问词+无动词/主语),返回True: {text!r}")
|
||
return True
|
||
else:
|
||
# 额外防御:检查是否是常见用户问句模式
|
||
_common_query = any(text.startswith(q) for q in ('怎么', '如何', '什么', '哪里', '哪个', '几', '多少', '为什么', '在哪', '如何'))
|
||
_ends_with_question = text.rstrip().endswith(('?', '?', '!', '!'))
|
||
if not _common_query and not _ends_with_question:
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到OCR残片(无疑问词+无动词/主语),返回True: {text!r}")
|
||
return True
|
||
elif ',' in text or ',' in text:
|
||
# 逗号分隔的混合文本:检查后半部分是否为正常查询
|
||
# 例:'个桓温,关于我们' → 前半是残片,后半是正常查询
|
||
_parts = re.split(r'[,,]', text)
|
||
if len(_parts) >= 2:
|
||
_second_part = _parts[-1].strip()
|
||
# 后半部分含疑问词/动词/主语 → 视为正常查询,放行
|
||
_has_q2 = any(w in _second_part for w in ('吗', '呢', '多少', '怎么', '如何', '哪', '什么', '几', '?', '?'))
|
||
_has_v2 = any(v in _second_part for v in ('怎么', '如何', '进行', '设置', '配置', '操作', '使用', '打开', '下载', '安装', '登录', '进入', '查看', '查询', '问', '找', '要', '可以', '能', '会', '请', '帮', '给', '有'))
|
||
_has_s2 = any(s in _second_part for s in ('我', '你', '他', '我们', '你们', '他们', '玩家', '用户', '管理员'))
|
||
if len(_second_part) >= 3 and (_has_q2 or _has_v2 or _has_s2):
|
||
logger.debug(f"[ocr] 过滤器2结果: 检测到混合文本(残片+正常查询),提取后半部分放行: {text!r} -> {_second_part!r}")
|
||
# 放行,但记录日志供后续调试
|
||
# 括号不匹配(可能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,继续后续检查
|
||
# ⭐ 2026-08-17 问句豁免:含问号'??'的用户问句即使带分散Latin缩写
|
||
# (如"pHP用户后端有哪两种登录模式啊?")也不应判为乱码。
|
||
# 案例: OCR把"PHP"打散成"pH P",大写被CJK分割≥2组→误判乱码→用户消息整条丢弃
|
||
# ⚠ 只认问号不认疑问词:'哪'等疑问词在真乱码中常见(Task#1474 'HHWH...槟J!')
|
||
elif re.search(r'[??]', 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'解除全体禁言',
|
||
# ⭐ 2026-08-20 新增:自定义撤回提示(用户撤回消息后显示的系统提示)
|
||
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())
|
||
|
||
# ⭐ Task #1556: 记录bot回复的关键词得分
|
||
if self._keyword_scorer:
|
||
try:
|
||
keywords = self._keyword_scorer.extract_keywords(text.strip())
|
||
self._keyword_scorer.record_message_score(
|
||
platform, text.strip(), keywords, 0, [], is_new=False
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] 关键词记录失败: {e}")
|
||
|
||
# 记录回复到去重集(与 _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) and not self._reply_in_progress.get(platform, False):
|
||
self._last_hash_replied[platform] = False
|
||
else:
|
||
self._last_hash_queued[platform] = False
|
||
# ⭐ 修复:如果正在回复中,不重置标志(防止打断后重复触发)
|
||
if not self._reply_in_progress.get(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
|
||
|
||
# ⭐ 修复:如果正在回复中,跳过本轮处理(防止打断后重复触发)
|
||
if self._reply_in_progress.get(platform, False):
|
||
logger.debug(f"[{platform}] Poll: 正在回复中,跳过本轮")
|
||
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回复中,跳过
|
||
# ⭐ v2:拆行匹配——recent_replies存完整多行回复,OCR只读首行
|
||
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
|
||
if recent_replies:
|
||
_in_recent = False
|
||
ts_lower = ts.lower()
|
||
for _rr in recent_replies:
|
||
if ts_lower in _rr.lower():
|
||
_in_recent = True
|
||
break
|
||
# 拆行:OCR读到的可能是bot回复的某一行
|
||
for _rr_line in _rr.split('\n'):
|
||
if ts_lower == _rr_line.strip().lower():
|
||
_in_recent = True
|
||
break
|
||
if _in_recent:
|
||
break
|
||
if _in_recent:
|
||
logger.debug(f"[{platform}] 时间窗口查重跳过(近5分钟): {ts[:50]}")
|
||
continue
|
||
# _replied_texts 优先检查(永不裁切,100% 拦住自己的回复)
|
||
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
|
||
if any(ts.lower() == r.lower() for r in replied):
|
||
continue
|
||
# _replied_texts 模糊匹配:OCR 噪声导致精确匹配失效(长文本尤甚)
|
||
if self._fuzzy_duplicate(ts, replied, threshold=0.75):
|
||
logger.debug(f"[{platform}] 回复模糊跳过: {ts[:50]}")
|
||
continue
|
||
# ⭐ Task #1574: Bot自言自语拦截(归一化比对)
|
||
# 根因:OCR读取bot自己的回复时,文本格式与存储的回复有差异
|
||
# (时间戳前缀、标点全/半角、空格数量),导致上述精确/模糊匹配全部失效。
|
||
# 修复:对OCR文本做归一化(去时间戳、去标点、去空格、CJK骨架提取),
|
||
# 与最近bot回复做归一化比对。阈值宽松(0.60)确保拦截,同时用CJK骨架
|
||
# 做二重确认防误杀(用户真实新消息的CJK骨架不可能与bot回复完全相同)。
|
||
# ⭐ Task #1574 v2:拆行比对——OCR只读bot回复的首行(如"php有2种登陆模式:"),
|
||
# 但recent_replies存的是完整多行回复(含表格)。整块比对Jaccard必然低分漏过。
|
||
# 修复:把每条bot回复拆成行,逐行比对。策略3新增:子串包含检查。
|
||
if len(ts) >= 6 and self._msg_history and recent_replies:
|
||
try:
|
||
# 归一化函数:去时间戳、去标点空格、去英文
|
||
def _norm_self_check(s):
|
||
s = re.sub(r'\d{1,2}:\d{2}(?::\d{2})?', '', s) # 去时间戳
|
||
s = re.sub(r'[^\u4e00-\u9fff]', '', s) # 只保留CJK
|
||
return s
|
||
ts_norm = _norm_self_check(ts)
|
||
if len(ts_norm) >= 4:
|
||
# CJK骨架提取:连续中文字符序列
|
||
sk_ts = ''.join(c for c in ts if '\u4e00' <= c <= '\u9fff')
|
||
_self_skip = False
|
||
# 与最近5分钟的bot回复做归一化比对
|
||
for br in recent_replies:
|
||
# ⭐ 拆行:把多行回复拆成独立行,逐行比对
|
||
# 防止OCR只读首行时被完整回复的长文本"稀释"导致Jaccard不达标
|
||
br_lines = [ln.strip() for ln in br.split('\n') if ln.strip()]
|
||
# 也把完整br加入比对列表(保留向后兼容)
|
||
_br_candidates = br_lines if br_lines else [br]
|
||
for _br in _br_candidates:
|
||
br_norm = _norm_self_check(_br)
|
||
if not br_norm or len(br_norm) < 4:
|
||
continue
|
||
# 策略1:CJK骨架完全相同(OCR把同一段中文读了两遍)
|
||
sk_br = ''.join(c for c in _br if '\u4e00' <= c <= '\u9fff')
|
||
if len(sk_ts) >= 4 and sk_ts == sk_br:
|
||
logger.info(f"[{platform}] Bot自言自语拦截(骨架相同): {ts[:50]}")
|
||
_self_skip = True
|
||
break
|
||
# 策略2:归一化后Jaccard相似度>=0.6(吸收OCR标点/空格差异)
|
||
set_ts = set(ts_norm)
|
||
set_br = set(br_norm)
|
||
inter = len(set_ts & set_br)
|
||
union = len(set_ts | set_br)
|
||
if union > 0 and inter / union >= 0.60:
|
||
logger.info(f"[{platform}] Bot自言自语拦截(归一化相似): "
|
||
f"jaccard={inter/union:.2f} ts={ts[:40]}")
|
||
_self_skip = True
|
||
break
|
||
# 策略3(v2新增):子串包含——OCR文本是bot某行的子串
|
||
# 例:OCR "php有2种登陆模式:" vs bot行 "php有2种登陆模式:| 模式 |"
|
||
# 归一化后用CJK做子串检查
|
||
if len(ts_norm) >= 4 and len(br_norm) >= 6:
|
||
if ts_norm in br_norm or br_norm in ts_norm:
|
||
logger.info(f"[{platform}] Bot自言自语拦截(子串包含): "
|
||
f"ts_norm={ts_norm[:30]} br_norm={br_norm[:30]}")
|
||
_self_skip = True
|
||
break
|
||
if _self_skip:
|
||
break
|
||
if _self_skip:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
# _replied_texts 子串包含:OCR 提取的 md 表格细胞值(如"依赖插件可选/必选")
|
||
# 是 bot 回复表的一部分,不是用户提问。长度>5才检查避免误杀短消息。
|
||
# OCR 常把 ,转 , 等标点差异,做双向子串检查(原始+归一化标点)
|
||
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
|
||
if len(ts) > 5:
|
||
ts_lower = ts.lower()
|
||
if any(ts_lower in r.lower() 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.lower() in r.lower() 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种登陆模式"子串,但不应被跳过
|
||
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
|
||
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
|
||
ts_lower = ts.lower()
|
||
# 第一轮:原始文本匹配(阈值 6,OCR 噪声导致连续匹配可能中断)
|
||
for r in replied:
|
||
if len(r) < 5:
|
||
continue
|
||
sm = difflib.SequenceMatcher(None, ts_lower, r.lower())
|
||
if sm.find_longest_match(0, len(ts_lower), 0, len(r.lower())).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:
|
||
logger.debug(f"[{platform}] seen去重跳过: {ts[:50]}")
|
||
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
|
||
# ⭐ 2026-08-21 清理 bot markdown 加粗残留(去重后过滤管道)
|
||
ts_before_star = ts
|
||
ts = re.sub(r'([\u4e00-\u9fff])\*(?!\*)', r'\1', ts)
|
||
if ts != ts_before_star:
|
||
logger.info(f"[{platform}] 清理markdown加粗残留*: {ts_before_star[:60]!r} -> {ts[:60]!r}")
|
||
# Markdown 文档过滤:bot 回复以 md 格式为主(#标题、[链接](url)、-列表项),
|
||
# 用户日常对话不用 md 语法。拦截 md 格式文本避免 bot 旧回复被重新处理。
|
||
# ⭐ 2026-08-11 先剥 md 尾巴再判:md 在末尾→切小尾巴留用户提问;
|
||
# md 大头/混中间→None 整段丢弃,确保入库的只有用户原始提问。
|
||
# ⭐ 2026-08-19 新增:先剥【头部】md(bot md 列表项被 OCR 拼到用户消息前面,
|
||
# 如 "- 关于我们" / "-关于我们")。_strip_markdown_tail 只剥尾部,对头部 md 无能为力。
|
||
_head_stripped = self._strip_markdown_head(ts)
|
||
if _head_stripped is None:
|
||
logger.debug(f"[{platform}] 全md头部整段丢弃: {ts[:50]}")
|
||
continue
|
||
ts = _head_stripped
|
||
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
|
||
|
||
# ⭐ Task #1558 新逻辑: 关键词打分前置(所有消息必须先打分)
|
||
# 流程:
|
||
# 1. 提取OCR时间戳(用于时间比较)
|
||
# 2. 调用关键词打分器 → ≤1分无条件放行,≥1分时间比较
|
||
# 3. 时间比较:OCR时间 vs 系统时间(差值≤10秒用OCR,否则用系统时间)
|
||
# 4. 判断:当前时间 > DB落库时间+5分钟 → 放行,否则拦截
|
||
# (bot回复碎片检测已在上方统一过滤,此处无需重复)
|
||
ocr_time = None
|
||
if self._keyword_scorer:
|
||
# 从文本中提取最近的时间戳(格式如'22:54:16')
|
||
_timestamps = re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', ts)
|
||
if _timestamps:
|
||
ocr_time = _timestamps[-1] # 取最后一个时间戳
|
||
|
||
should_skip, score, matched_kws = self._keyword_scorer.should_skip_message(
|
||
ts, platform, ocr_time=ocr_time, threshold=1
|
||
)
|
||
if should_skip:
|
||
logger.debug(f"[{platform}] 关键词打分跳过(score={score}): {ts[:50]}... "
|
||
f"匹配: {matched_kws[:5]}")
|
||
seen.add(ts)
|
||
continue
|
||
elif score > 0:
|
||
logger.info(f"[{platform}] 关键词得分(score={score},新消息): {ts[:50]}... "
|
||
f"匹配: {matched_kws[:5]}")
|
||
else:
|
||
logger.debug(f"[{platform}] 关键词得分(score={score},新消息): {ts[:50]}")
|
||
|
||
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())
|
||
]
|
||
|
||
# ⭐ 新增:bot回复碎片检测(在基线轮判断之前过滤,防止OCR残片入库)
|
||
# 如果文本与已回复内容高度重叠(字符重叠率≥50%),直接丢弃
|
||
# 例:"端0(基岩版)" vs 已回复表格含"端口(基岩版) | 30679" → "基岩版"3字重叠 → 拦截
|
||
if self._replied_texts:
|
||
import difflib
|
||
filtered_new_items = []
|
||
for item in new_items:
|
||
ts = item.strip()
|
||
if len(ts) < 3:
|
||
filtered_new_items.append(item)
|
||
continue
|
||
is_fragment = False
|
||
t_norm = self._norm_frag(ts)
|
||
for r in self._replied_texts:
|
||
if len(r) < 5:
|
||
continue
|
||
r_norm = self._norm_frag(r)
|
||
if not t_norm or not r_norm:
|
||
continue
|
||
# 策略1:最长公共子串占比≥60%且≥3字符(严格匹配)
|
||
sm = difflib.SequenceMatcher(None, t_norm, r_norm)
|
||
lm = sm.find_longest_match(0, len(t_norm), 0, len(r_norm)).size
|
||
if lm >= 0.6 * len(t_norm) and lm >= 3:
|
||
logger.debug(f"[{platform}] OCR过滤: bot回复碎片(lm={lm},ratio={lm/len(t_norm):.0%}) ts={ts!r}")
|
||
is_fragment = True
|
||
break
|
||
# 策略2:字符级重叠率≥50%且核心词命中(宽松匹配,防OCR噪声)
|
||
# 计算t_norm中有多少字符出现在r_norm中
|
||
r_char_set = set(r_norm)
|
||
overlap = sum(1 for c in t_norm if c in r_char_set)
|
||
overlap_ratio = overlap / len(t_norm) if t_norm else 0
|
||
# 如果重叠率≥50%且至少有2个中文字符重叠 → 可能是碎片
|
||
cjk_overlap = sum(1 for c in t_norm if '\u4e00' <= c <= '\u9fff' and c in r_char_set)
|
||
if overlap_ratio >= 0.5 and cjk_overlap >= 2:
|
||
logger.debug(f"[{platform}] OCR过滤: bot回复碎片(char_overlap={overlap}/{len(t_norm)},cjk={cjk_overlap}) ts={ts!r}")
|
||
is_fragment = True
|
||
break
|
||
if not is_fragment:
|
||
filtered_new_items.append(item)
|
||
new_items = filtered_new_items
|
||
|
||
# ⚠️ 文本处理后再次检查急停:防止急停在过滤阶段触发后仍触发回复
|
||
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
|
||
}
|
||
|
||
# ⭐ 同轮择优:当一轮检测到多条候选消息时,只选最可能是真实用户消息的那条
|
||
# 防bot抢跑:污染历史消息(长、含噪声前缀)vs 真实用户消息(短、含疑问词)
|
||
_candidates = [] # list of (score, item, cleaned_item)
|
||
|
||
# ⭐ 首次成功轮询(上次无消息→本次有消息):拦截历史基线
|
||
is_baseline = self._last_new_msg_time.get(platform, 0) == 0
|
||
baseline_handled = False # 基线轮只处理一次(标记第一条消息)
|
||
|
||
# ⭐ 同轮择优:当一轮检测到多条候选消息时,只选最可能是真实用户消息的那条
|
||
# 防bot抢跑:污染历史消息(长、含噪声前缀)vs 真实用户消息(短、含疑问词)
|
||
_candidates = [] # list of (score, item, cleaned_item)
|
||
|
||
# ⭐ 第一轮:评估所有候选消息质量
|
||
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,否则未来轮次永远跳过
|
||
self._last_sig[platform] = ""
|
||
self._last_screenshot_hash.pop(platform, None)
|
||
self._last_new_msg_time[platform] = time.time()
|
||
# ⭐ Task #1589: 基线消息不加入跨轮去重记录,否则下一轮自我阻塞
|
||
baseline_handled = True
|
||
continue # 基线轮不处理,跳到下一条
|
||
|
||
# ⭐ 清理OCR噪声前缀(如"必选,")后再搜索知识库
|
||
cleaned_item = self._clean_ocr_prefix(item)
|
||
# ⭐ 检查是否与最近bot回复内容相同(OCR噪声干扰,把bot回复当作用户消息)
|
||
# ⭐ Task #1574: 降低阈值 0.9→0.65,骨架长度 6→4,防止自言自语漏过
|
||
# ⭐ Task #1574 v2: 拆行比对——bot回复是多行(含表格),OCR只读首行
|
||
# 整块比对骨架/相似度都会被表格行"稀释"而漏过。改为逐行比对。
|
||
last_reply = self._last_bot_reply_content.get(platform, "")
|
||
if last_reply and cleaned_item and len(cleaned_item) >= 4:
|
||
_last_reply_lines = [ln.strip() for ln in last_reply.split('\n') if ln.strip()]
|
||
if not _last_reply_lines:
|
||
_last_reply_lines = [last_reply]
|
||
_blocked_by_reply = False
|
||
for _lr in _last_reply_lines:
|
||
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
|
||
similarity = SequenceMatcher(None, cleaned_item.lower(), _lr.lower()).ratio()
|
||
if similarity >= 0.65:
|
||
logger.info(f"[{platform}] OCR噪声检测: 与最近bot回复相似度{similarity:.2%},跳过: {cleaned_item[:60]}")
|
||
_blocked_by_reply = True
|
||
break
|
||
# ⭐ 2026-08-12: 中文骨架匹配(吸收 OCR 拉丁混淆 php↔4p)
|
||
# 字符级相似度跌破 65% 时,用中文骨架兜底拦截
|
||
# ⭐ Task #1574: 放宽骨架长度 6→4,覆盖短回复场景
|
||
sk_cur = _cn_skeleton(cleaned_item)
|
||
sk_rep = _cn_skeleton(_lr)
|
||
if len(sk_cur) >= 4 and sk_rep and sk_cur == sk_rep:
|
||
logger.info(f"[{platform}] OCR噪声检测(骨架匹配): 与最近bot回复中文骨架相同,跳过: {cleaned_item[:60]}")
|
||
_blocked_by_reply = True
|
||
break
|
||
# ⭐ v2新增:子串包含——OCR文本是bot行的子串(如首行不含表格列)
|
||
if len(sk_cur) >= 4 and sk_rep and (sk_cur in sk_rep or sk_rep in sk_cur):
|
||
logger.info(f"[{platform}] OCR噪声检测(骨架子串): {sk_cur[:20]} in {sk_rep[:30]}, 跳过: {cleaned_item[:60]}")
|
||
_blocked_by_reply = True
|
||
break
|
||
if _blocked_by_reply:
|
||
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%则跳过
|
||
# 清理过期条目
|
||
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
|
||
# ⭐ 跨轮语义去重:5分钟内相似用户查询跳过
|
||
if self._is_semantically_duplicate(cleaned_item, platform):
|
||
logger.info(f"[{platform}] 用户查询语义去重: 5分钟内相似查询,跳过: {cleaned_item[:60]}")
|
||
self._last_new_msg_time[platform] = time.time()
|
||
continue
|
||
# ⭐ 添加到候选列表(含质量分)
|
||
score = self._score_message_quality(cleaned_item)
|
||
_candidates.append((score, item, cleaned_item))
|
||
|
||
# ⭐ 同轮择优:只选质量分最高的那条消息触发回复
|
||
if _candidates:
|
||
# 按质量分降序排序,选最高分
|
||
_candidates.sort(key=lambda x: x[0], reverse=True)
|
||
_best_score, _best_raw, _best_cleaned = _candidates[0]
|
||
# 如果有多条同分,优先选较短的(真实用户消息通常更短)
|
||
_top_candidates = [c for c in _candidates if c[0] == _best_score]
|
||
_top_candidates.sort(key=lambda x: len(x[2]))
|
||
_best_score, _best_raw, _best_cleaned = _top_candidates[0]
|
||
|
||
logger.info(
|
||
f"[{platform}] 同轮择优: 选中({_best_score:.1f}分) {_best_cleaned[:50]!r}, "
|
||
f"候选{len(_candidates)}条"
|
||
)
|
||
if len(_candidates) > 1:
|
||
for _sc, _raw, _cl in _candidates[1:]:
|
||
logger.debug(f"[{platform}] 同轮择优: 丢弃({_sc:.1f}分) {_cl[:50]!r}")
|
||
|
||
# ⭐ 添加到最近处理列表
|
||
self._recent_processed_messages.append((_best_cleaned, now))
|
||
# ⭐ 添加到最近用户查询列表(用于跨轮语义去重)
|
||
self._add_to_recent_user_queries(_best_cleaned)
|
||
# ⭐ Task #1556: 记录关键词得分到数据库
|
||
if self._keyword_scorer:
|
||
try:
|
||
keywords = self._keyword_scorer.extract_keywords(_best_cleaned)
|
||
# 新消息得分为0(未匹配历史回复),记录为已处理
|
||
self._keyword_scorer.record_message_score(
|
||
platform, _best_cleaned, keywords, 0, [], is_new=True
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] 关键词记录失败: {e}")
|
||
self._last_new_msg_time[platform] = time.time()
|
||
self._last_msg[platform] = _best_cleaned
|
||
engine_tag = self._last_chosen_engine.get(platform, "")
|
||
logger.info(f"[{platform}] 新消息[{engine_tag}]: {_best_raw[:60]}")
|
||
# ⭐ Task #1572: 记录OCR消息到暂存表(用于后续查重)
|
||
if self._msg_history:
|
||
try:
|
||
self._msg_history.record_ocr_message(platform, _best_cleaned)
|
||
except Exception as e:
|
||
logger.debug(f"[{platform}] OCR暂存记录失败: {e}")
|
||
# ⚠️ 急停检查:回复前最后检查,防止急停后仍然发送
|
||
if not self._running:
|
||
return
|
||
if self._on_message:
|
||
self._on_message(platform, _best_cleaned)
|
||
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
|