修复OCR回复牛头不对马嘴+残留消息+EasyOCR崩溃捕获+360误报
1. knowledge_base.py: 检索排序惩罚标题-only/短正文段,降低heading bonus,奖励正文命中,避免纯标题/免责声明段排第一 2. auto_reply.py: 动作类查询要求正文命中才放行;拒绝纯标题/免责声明答案 3. monitor.py: 调试截图改存%LOCALAPPDATA%/windowmsgbot/ocr_debug避开源码目录(360); 收紧残留消息fresh gate(600s硬门); 启动即崩溃/请求崩溃时读取easyocr_crash.log写入主日志 4. easyocr_worker.py: 加faulthandler+sys.excepthook全局捕获,崩溃traceback落盘%LOCALAPPDATA%/windowmsgbot/easyocr_crash.log
This commit is contained in:
+31
-5
@@ -80,8 +80,10 @@ class AutoReply:
|
||||
"""查询词与匹配文档至少有一个共同2字+词,防偶然匹配。
|
||||
短查询(≤2个2字+词)直接放行,不做严格检查。
|
||||
检查顺序:heading → title → content/body。
|
||||
动作/配置类查询额外要求正文含核心词,不能只命中标题。
|
||||
高匹配度(≥10.0):如果heading+title+body都无共同词,直接拒绝。
|
||||
"""
|
||||
import re
|
||||
q_tokens = set(t for t in jieba.cut(query) if len(t) >= 2)
|
||||
if len(q_tokens) <= 2:
|
||||
return True # 短查询不拦截
|
||||
@@ -89,20 +91,35 @@ class AutoReply:
|
||||
# 1. 检查 heading + title
|
||||
doc_text = doc.get("heading", "") + " " + doc.get("title", "")
|
||||
d_tokens = set(t for t in jieba.cut(doc_text) if len(t) >= 2)
|
||||
if q_tokens & d_tokens:
|
||||
return True
|
||||
has_heading_hit = bool(q_tokens & d_tokens)
|
||||
|
||||
# 2. 检查 content/body(TF-IDF匹配的段落正文)
|
||||
body = doc.get("content", "") or doc.get("body", "")
|
||||
b_tokens = set()
|
||||
has_body_hit = False
|
||||
if body:
|
||||
# 截断前500字防超长文档
|
||||
b_tokens = set(t for t in jieba.cut(body[:500]) if len(t) >= 2)
|
||||
if q_tokens & b_tokens:
|
||||
return True
|
||||
has_body_hit = bool(q_tokens & b_tokens)
|
||||
|
||||
# 动作/配置类查询:不能只命中标题,正文必须也含核心词
|
||||
# 否则"# Sdf1系列登录插件使用说明"会因标题含"登录/插件/使用"被误放
|
||||
action_keywords = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法"}
|
||||
if action_keywords & q_tokens:
|
||||
core_q_tokens = q_tokens - {"插件", "使用", "怎么", "如何", "吗", "呢", "吧"}
|
||||
if core_q_tokens and not (core_q_tokens & b_tokens):
|
||||
logger.debug(
|
||||
f"动作查询正文相关性不足: query={query[:40]!r} "
|
||||
f"heading_hit={has_heading_hit} body_hit={has_body_hit}"
|
||||
)
|
||||
return False
|
||||
|
||||
if has_heading_hit or has_body_hit:
|
||||
return True
|
||||
|
||||
# 3. 高匹配度但无共同词:可能是TF-IDF误匹配,拒绝
|
||||
# 用户问"PVP击杀播报",KB返回"json格式说明"(匹配度9.53)→ 应拒绝
|
||||
if score >= 10.0 and not (q_tokens & d_tokens) and not (q_tokens & (b_tokens if body else set())):
|
||||
if score >= 10.0 and not has_heading_hit and not has_body_hit:
|
||||
return False
|
||||
|
||||
return False
|
||||
@@ -128,6 +145,15 @@ class AutoReply:
|
||||
if not answer:
|
||||
return None
|
||||
|
||||
# 拒绝把纯标题/免责声明当作答案发送
|
||||
import re
|
||||
if answer.startswith("# ") and len(answer) < 60:
|
||||
logger.debug(f"[{platform}] 答案为纯标题,跳过: {answer[:60]!r}")
|
||||
return None
|
||||
if re.search(r'特别说明|免责声明|本插件.*自行修改|仅供参考|版权所有', answer):
|
||||
logger.debug(f"[{platform}] 答案为免责声明/通用说明,跳过: {answer[:60]!r}")
|
||||
return None
|
||||
|
||||
# 防重复指纹
|
||||
fp = hash(f"{platform}:{msg}:{answer[:80]}")
|
||||
replied_set = self._replied.setdefault(platform, set())
|
||||
|
||||
+49
-2
@@ -16,13 +16,58 @@ import base64
|
||||
import io
|
||||
import os
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
||||
# ── 顶层异常捕获:确保崩溃原因写入 stderr(否则父进程看到空 stderr) ──
|
||||
# ── 崩溃日志文件:写入 %LOCALAPPDATA%/windowmsgbot/,避开源码目录(防 360 误报 + 父进程方便取) ──
|
||||
def _get_crash_log_path() -> str:
|
||||
try:
|
||||
local = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
|
||||
base = os.path.join(local, "windowmsgbot") if local \
|
||||
else os.path.join(os.path.expanduser("~"), "windowmsgbot")
|
||||
except Exception:
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
try:
|
||||
os.makedirs(base, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return os.path.join(base, "easyocr_crash.log")
|
||||
|
||||
CRASH_LOG = _get_crash_log_path()
|
||||
|
||||
def _write_crash_log(msg: str):
|
||||
try:
|
||||
with open(CRASH_LOG, "a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
f.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 顶层异常捕获:确保崩溃原因写入 stderr + 落盘(否则父进程看到空 stderr) ──
|
||||
def _flush_exit(msg, code=1):
|
||||
sys.stderr.write(f"[easyocr_worker] FATAL: {msg}\n")
|
||||
full = f"[easyocr_worker] FATAL: {msg}"
|
||||
sys.stderr.write(full + "\n")
|
||||
sys.stderr.flush()
|
||||
_write_crash_log(f"{datetime.now()} {full}")
|
||||
sys.exit(code)
|
||||
|
||||
# ── 全局未捕获异常钩子:任何漏网的 Python 异常都落盘 ──
|
||||
def _global_excepthook(exc_type, exc_val, exc_tb):
|
||||
msg = "".join(traceback.format_exception(exc_type, exc_val, exc_tb))
|
||||
sys.stderr.write(f"[easyocr_worker] 未捕获异常:\n{msg}")
|
||||
sys.stderr.flush()
|
||||
_write_crash_log(f"{datetime.now()} [easyocr_worker] 未捕获异常:\n{msg}")
|
||||
|
||||
sys.excepthook = _global_excepthook
|
||||
|
||||
# ── faulthandler:捕获原生层崩溃(segfault / CUDA 初始化竞态),Python try/except 抓不到 ──
|
||||
# 必须在重型 import 之前启用,连 import 阶段的原生崩溃也能 dump 出来。
|
||||
try:
|
||||
import faulthandler
|
||||
_fh = open(CRASH_LOG, "a", encoding="utf-8")
|
||||
faulthandler.enable(file=_fh, all_threads=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# ── 线程/进程限制:避免 MKL/OpenMP 在子进程多线程冲突 ──
|
||||
os.environ.setdefault("OMP_NUM_THREADS", "1")
|
||||
@@ -74,12 +119,14 @@ def _process_image(img_b64: str, reader) -> str:
|
||||
|
||||
|
||||
def _main():
|
||||
_write_crash_log(f"{datetime.now()} [easyocr_worker] 启动 (pid={os.getpid()})")
|
||||
reader = None
|
||||
try:
|
||||
reader = _init_reader()
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[easyocr_worker] Reader 初始化失败: {e}\n")
|
||||
sys.stderr.flush()
|
||||
_write_crash_log(f"{datetime.now()} [easyocr_worker] Reader 初始化失败: {e}")
|
||||
# ⭐ 就绪握手:即使 Reader 失败也要发 ready,否则父进程永远等握手
|
||||
sys.stdout.write(json.dumps({"ready": True}, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
+30
-5
@@ -300,6 +300,24 @@ class KnowledgeBase:
|
||||
|
||||
combined_scores = 0.7 * tfidf_scores + 0.3 * keyword_scores
|
||||
|
||||
# 内容质量调整:惩罚标题-only/正文过短的 section,奖励正文含查询词的 section
|
||||
# 避免"# Sdf1系列登录插件使用说明"这种纯标题段因为标题词匹配就排第一
|
||||
for i, doc in enumerate(self.docs):
|
||||
body = doc.get("body", "")
|
||||
body_len = len(body.strip())
|
||||
heading = doc.get("heading", "")
|
||||
# 单层 # 标题且无实质正文 → 大幅降权
|
||||
if heading.startswith("# ") and body_len < 50:
|
||||
combined_scores[i] *= 0.2
|
||||
elif body_len < 30:
|
||||
combined_scores[i] *= 0.5
|
||||
# 正文含查询词 → 奖励(说明 section 真的在讲用户问的内容)
|
||||
body_hits = sum(1 for t in query_tokens_set if t in self._body_token_sets[i])
|
||||
if body_hits >= 2:
|
||||
combined_scores[i] *= 1.3
|
||||
elif body_hits >= 1:
|
||||
combined_scores[i] *= 1.1
|
||||
|
||||
# 短查询惩罚:分词数 ≤2 的短查询(如图标残片/短词)得分大幅降权
|
||||
# 防止 OCR 碎片中的高 IDF 词(如"下载")获得超过真实用户消息的分数
|
||||
n_tokens = len(tokens)
|
||||
@@ -413,19 +431,26 @@ class KnowledgeBase:
|
||||
heading = doc.get("heading", "")
|
||||
body = doc.get("body", doc.get("content", ""))
|
||||
h_tokens = set(t for t in jieba.cut(heading) if len(t) >= 2)
|
||||
# heading 包含查询关键词 → bonus(5.0 每命中词,足够翻转 TF-IDF 差距)
|
||||
# heading 包含查询关键词 → 适度 bonus(2.0 每命中词)
|
||||
heading_hits = len(query_tokens & h_tokens)
|
||||
bonus = heading_hits * 5.0
|
||||
# heading 包含查询连续短语 → 额外 bonus(如查询含"奖励配置"且 heading 含"奖励配置")
|
||||
bonus = heading_hits * 2.0
|
||||
# heading 包含查询连续短语 → 额外 bonus
|
||||
for qlen in range(min(len(query_stripped), 8), 3, -1):
|
||||
for start in range(len(query_stripped) - qlen + 1):
|
||||
phrase = query_stripped[start:start+qlen]
|
||||
if len(phrase) >= 4 and phrase in heading:
|
||||
bonus += 8.0
|
||||
bonus += 6.0
|
||||
break
|
||||
# body 包含查询连续短语 → 强 bonus(说明正文真的在讲用户问的内容)
|
||||
for qlen in range(min(len(query_stripped), 8), 3, -1):
|
||||
for start in range(len(query_stripped) - qlen + 1):
|
||||
phrase = query_stripped[start:start+qlen]
|
||||
if len(phrase) >= 4 and phrase in body:
|
||||
bonus += 10.0
|
||||
break
|
||||
# 文档头部惩罚:heading 是单层 # + 文档名(如 "# php管理员后端"),且无 body
|
||||
if heading.startswith("# ") and (not body or len(body) < 50):
|
||||
bonus -= 5.0
|
||||
bonus -= 15.0
|
||||
reranked.append((idx, doc, score + bonus))
|
||||
reranked.sort(key=lambda x: x[2], reverse=True)
|
||||
doc_sections = reranked
|
||||
|
||||
+115
-34
@@ -1108,13 +1108,17 @@ class MessageMonitor:
|
||||
self._easyocr_retry_until = time.time() + 30
|
||||
return False
|
||||
if not line:
|
||||
# stdout 关闭 = worker 崩溃,读 stderr 看原因
|
||||
# stdout 关闭 = worker 崩溃,读 stderr + 崩溃日志看原因
|
||||
err = ""
|
||||
try:
|
||||
err = self._easyocr_proc.stderr.read()[:500]
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"[ocr] EasyOCR worker 启动即崩溃: {err}")
|
||||
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}")
|
||||
self._easyocr_proc = None
|
||||
self._easyocr_retry_until = time.time() + 30
|
||||
return False
|
||||
@@ -1201,7 +1205,11 @@ class MessageMonitor:
|
||||
else:
|
||||
line = self._easyocr_proc.stdout.readline()
|
||||
if not line:
|
||||
raise ConnectionError("EasyOCR 子进程 stdout 关闭")
|
||||
crash = _read_easyocr_crash_tail()
|
||||
msg = "EasyOCR 子进程 stdout 关闭"
|
||||
if crash:
|
||||
msg += f"(崩溃日志: {crash})"
|
||||
raise ConnectionError(msg)
|
||||
result = json.loads(line)
|
||||
if result.get("ok"):
|
||||
# 成功 → 重置连续失败计数
|
||||
@@ -1496,12 +1504,13 @@ class MessageMonitor:
|
||||
return []
|
||||
|
||||
# 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查)
|
||||
_dbg_path = ""
|
||||
try:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
debug_dir = _get_ocr_debug_dir()
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
|
||||
_dbg_path = os.path.join(script_dir, f"ocr_debug_qq_{ts}.png")
|
||||
_dbg_path = os.path.join(debug_dir, f"ocr_debug_qq_{ts}.png")
|
||||
im.save(_dbg_path, format="PNG")
|
||||
_clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_debug_qq_")
|
||||
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_debug_qq_")
|
||||
if os.path.exists(_dbg_path):
|
||||
logger.info(f"[qq] 调试截图已保存: {_dbg_path}")
|
||||
else:
|
||||
@@ -1540,16 +1549,16 @@ class MessageMonitor:
|
||||
logger.debug(f"[ocr] 预处理: resize 2x + sharpen(1.2) + contrast(1.2)")
|
||||
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
|
||||
try:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
debug_dir = _get_ocr_debug_dir()
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
|
||||
_processed_path = os.path.join(script_dir, f"ocr_processed_qq_{ts}.jpg")
|
||||
_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)
|
||||
_clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_processed_qq_")
|
||||
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_processed_qq_")
|
||||
if os.path.exists(_processed_path):
|
||||
logger.debug(f"[qq] OCR处理后图片已保存: {_processed_path}")
|
||||
except Exception as e:
|
||||
@@ -1628,12 +1637,13 @@ class MessageMonitor:
|
||||
return []
|
||||
|
||||
# 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查)
|
||||
_dbg_path = ""
|
||||
try:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
debug_dir = _get_ocr_debug_dir()
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
|
||||
_dbg_path = os.path.join(script_dir, f"ocr_debug_wx_{ts}.png")
|
||||
_dbg_path = os.path.join(debug_dir, f"ocr_debug_wx_{ts}.png")
|
||||
im.save(_dbg_path, format="PNG")
|
||||
_clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_debug_wx_")
|
||||
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_debug_wx_")
|
||||
if os.path.exists(_dbg_path):
|
||||
logger.info(f"[wechat] 调试截图已保存: {_dbg_path}")
|
||||
else:
|
||||
@@ -1670,16 +1680,16 @@ class MessageMonitor:
|
||||
im = ImageEnhance.Contrast(im).enhance(1.2) # 降低对比度,保持笔画连贯
|
||||
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
|
||||
try:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
debug_dir = _get_ocr_debug_dir()
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
|
||||
_processed_path = os.path.join(script_dir, f"ocr_processed_wx_{ts}.jpg")
|
||||
_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)
|
||||
_clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_processed_wx_")
|
||||
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_processed_wx_")
|
||||
if os.path.exists(_processed_path):
|
||||
logger.debug(f"[wechat] OCR处理后图片已保存: {_processed_path}")
|
||||
except Exception as e:
|
||||
@@ -1796,8 +1806,9 @@ class MessageMonitor:
|
||||
if buf.strip():
|
||||
_fresh = self._looks_fresh_content(buf.strip())
|
||||
if last_ts and self._is_timestamp_old(last_ts, 300):
|
||||
if _fresh:
|
||||
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}过旧({300}s)但含新鲜特征,放行: {buf.strip()[:60]!r}")
|
||||
# 仅当时间戳不是太久(<600s)且内容明确新鲜时才放行
|
||||
if _fresh and not self._is_timestamp_old(last_ts, 600):
|
||||
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}过旧({300}s)但较新且含新鲜特征,放行: {buf.strip()[:60]!r}")
|
||||
else:
|
||||
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}过旧({300}s),跳过: {buf.strip()[:60]!r}")
|
||||
buf = ''
|
||||
@@ -1821,11 +1832,11 @@ class MessageMonitor:
|
||||
if buf.strip():
|
||||
# 尾部检查:如果最后一个可见时间戳已过期(≥300s),尾部内容也是历史消息
|
||||
# 注释:21:56:17清晰可见但已过期 → 尾部内容不应被当成新消息
|
||||
# ⚠ 例外:尾部含新鲜特征(疑问词/问句/标点结尾)→ 可能是无独立时间戳的新消息,放行
|
||||
# ⚠ 例外:尾部含明确新鲜特征且时间戳<600s → 可能是无独立时间戳的新消息,放行
|
||||
_fresh_tail = self._looks_fresh_content(buf.strip())
|
||||
if last_ts and self._is_timestamp_old(last_ts, 300):
|
||||
if _fresh_tail:
|
||||
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}已过期但含新鲜特征,放行: {buf.strip()[:60]!r}")
|
||||
if _fresh_tail and not self._is_timestamp_old(last_ts, 600):
|
||||
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}过旧但较新且含新鲜特征,放行: {buf.strip()[:60]!r}")
|
||||
else:
|
||||
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}已过期(≥{300}s),跳过尾部: {buf.strip()[:60]!r}")
|
||||
buf = ''
|
||||
@@ -2863,20 +2874,19 @@ class MessageMonitor:
|
||||
def _looks_fresh_content(text: str) -> bool:
|
||||
"""判断一段 OCR 文本是否含"新鲜消息"特征。
|
||||
|
||||
OCR 常把多条消息挤在同一行/同一时间戳块(微信时间戳不是每条消息都有),
|
||||
新消息(≤300s)和旧消息共享一个前置旧时间戳。仅凭时间戳年龄会误杀整块。
|
||||
若文本含疑问词 / 问句 / 句尾标点 / 冒号 → 视为可能是新鲜用户消息,放行。
|
||||
只放行明显是用户新发的查询/聊天消息,避免旧历史记录因为含问句被误放行。
|
||||
规则严格:短消息 或 明确疑问/请求句(含疑问词+问号/怎么/如何等)。
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return False
|
||||
t = text.strip()
|
||||
if len(t) <= 8 and re.search(r'[\u4e00-\u9fff]', t):
|
||||
return True # 短中文消息("好的"、"嗯")直接放行
|
||||
# 疑问词/请求词
|
||||
if re.search(r'怎么办|为什么|能不能|怎么|如何|什么|可以|帮忙|谢谢|好的|嗯|收到|OK|ok|吗|呢|吧', t):
|
||||
# 超短中文消息(如"好的"、"嗯")可能无时间戳
|
||||
if len(t) <= 4 and re.search(r'[\u4e00-\u9fff]', t):
|
||||
return True
|
||||
# 句尾标点(。!?…)或冒号(发送者:内容)
|
||||
if re.search(r'[。!?!?…]$', t) or re.search(r'[::]', t):
|
||||
# 明确疑问/请求:含疑问词+问号,或"怎么/如何/怎么办"等
|
||||
if re.search(r'(?:怎么办|为什么|能不能|怎么|如何|什么|哪些|哪个|吗|呢)\s*[??]', t):
|
||||
return True
|
||||
if re.search(r'^(?:怎么|如何|请问|请教|求助)', t):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -2885,8 +2895,8 @@ class MessageMonitor:
|
||||
|
||||
有时间戳且至少一个 ≤300s → True(确定新消息)
|
||||
无时间戳 → 保守放行(可能是短句无时间戳的新消息)
|
||||
有时间戳但全部过期 → 检查文本是否含新鲜内容特征(疑问词/问句等),
|
||||
若含则放行(新消息可能没有独立时间戳),否则丢弃
|
||||
有时间戳但全部过期 → 仅当最新时间戳不太老(≤600s)且文本含明确新鲜特征时放行,
|
||||
否则视为历史消息丢弃
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
@@ -2895,16 +2905,28 @@ class MessageMonitor:
|
||||
timestamps = re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', normalized)
|
||||
if not timestamps:
|
||||
return True
|
||||
# 有新鲜时间戳 → 确定有新消息
|
||||
for ts in timestamps:
|
||||
try:
|
||||
if not self._is_timestamp_old(ts, 300):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# 所有时间戳都过期,但文本含新鲜内容特征 → 可能是旧时间戳后紧跟新消息
|
||||
# 让 stage splitting 的逐条放行逻辑做精细过滤
|
||||
# 全部过期:若最新时间戳已 ≥600s,整屏视为历史消息,直接丢弃
|
||||
newest_fresh_enough = False
|
||||
for ts in timestamps:
|
||||
try:
|
||||
if not self._is_timestamp_old(ts, 600):
|
||||
newest_fresh_enough = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not newest_fresh_enough:
|
||||
logger.debug(f"[ocr] 最新可见时间戳已过期≥600s,丢弃: {text[:80]!r}")
|
||||
return False
|
||||
# 最新时间戳在 300-600s 之间,且文本含明确新鲜特征 → 放行给 stage splitting
|
||||
if self._looks_fresh_content(text):
|
||||
logger.debug(f"[ocr] 时间戳全过期但含新鲜特征,放行: {text[:80]!r}")
|
||||
logger.debug(f"[ocr] 时间戳全过期但最新≤600s且含新鲜特征,放行: {text[:80]!r}")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -4224,6 +4246,65 @@ class MessageMonitor:
|
||||
self._cached_hwnd.pop(platform, None)
|
||||
self._cached_pid.pop(platform, None)
|
||||
|
||||
def _get_ocr_debug_dir() -> str:
|
||||
"""返回 OCR 调试截图保存目录。
|
||||
|
||||
放在用户本地数据目录(%LOCALAPPDATA%/windowmsgbot/ocr_debug),
|
||||
避免在源码目录频繁创建/删除图片被安全软件误报为勒索行为。
|
||||
"""
|
||||
try:
|
||||
local_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
|
||||
if local_appdata:
|
||||
debug_dir = os.path.join(local_appdata, "windowmsgbot", "ocr_debug")
|
||||
else:
|
||||
debug_dir = os.path.join(os.path.expanduser("~"), "windowmsgbot", "ocr_debug")
|
||||
except Exception:
|
||||
debug_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ocr_debug")
|
||||
try:
|
||||
os.makedirs(debug_dir, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return debug_dir
|
||||
|
||||
|
||||
def _get_easyocr_crash_log() -> str:
|
||||
"""返回 easyocr_worker 崩溃日志路径,必须与 easyocr_worker.py 中 CRASH_LOG 保持一致。
|
||||
|
||||
放在 %LOCALAPPDATA%/windowmsgbot/easyocr_crash.log,避免在源码目录写文件(防 360)。
|
||||
"""
|
||||
try:
|
||||
local_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
|
||||
if local_appdata:
|
||||
base = os.path.join(local_appdata, "windowmsgbot")
|
||||
else:
|
||||
base = os.path.join(os.path.expanduser("~"), "windowmsgbot")
|
||||
except Exception:
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(base, "easyocr_crash.log")
|
||||
|
||||
|
||||
def _read_easyocr_crash_tail(max_bytes: int = 2000) -> str:
|
||||
"""读取 easyocr_worker 崩溃日志尾部,供父进程在检测到 worker 崩溃时把原因写进主日志。
|
||||
|
||||
只在文件存在且较新时读取,避免每次都读陈旧历史。
|
||||
"""
|
||||
try:
|
||||
path = _get_easyocr_crash_log()
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
# 只看最近 60s 内的崩溃(文件可能被很久以前的残留占据)
|
||||
if 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 _clean_old_ocr_debug(dir_path: str, keep: int = 20, prefix: str = "ocr_debug_"):
|
||||
"""清理 ocr_debug_*.png 和 ocr_processed_*.jpg 调试截图,按 prefix 分别保留最近 keep 张。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user