From cb72f020302b7188793650687ee6c777e5e6f823 Mon Sep 17 00:00:00 2001 From: HuangHai <10402852@qq.com> Date: Mon, 26 Jan 2026 19:57:19 +0800 Subject: [PATCH] 'commit' --- WeiXin/T2_ChatMonitor.py | 35 ++++--- WeiXin/WxUtil.py | 108 +++++++++++++++++----- WeiXin/__pycache__/WxUtil.cpython-310.pyc | Bin 22276 -> 23816 bytes 3 files changed, 108 insertions(+), 35 deletions(-) diff --git a/WeiXin/T2_ChatMonitor.py b/WeiXin/T2_ChatMonitor.py index c41d31e..e4a0979 100644 --- a/WeiXin/T2_ChatMonitor.py +++ b/WeiXin/T2_ChatMonitor.py @@ -144,6 +144,16 @@ class ChatMonitorBot: """主运行循环""" logger.info("🚀 大张老师自动巡课系统启动 (T2 增强版)...") + # 定义 JSON 序列化辅助函数 + def numpy_serializer(obj): + if isinstance(obj, np.integer): + return int(obj) + if isinstance(obj, np.floating): + return float(obj) + if isinstance(obj, np.ndarray): + return obj.tolist() + raise TypeError(f"Type {type(obj)} not serializable") + # 1. 环境准备 if not self.step_1_prepare_env(): return if not self.step_2_connect_device(): return @@ -171,22 +181,18 @@ class ChatMonitorBot: # 格式化输出:[发送者] 内容 (类型) sender = msg.get('sender', '未知') content = msg.get('content', '') - msg_type = msg.get('type', 'unknown') - logger.info(f"[{sender}] {content} ({msg_type})") + msg_type = "语音" if msg.get('type') == 'voice' else "文字" + + # 按照用户要求的格式输出 + logger.info(f"说话人: {sender}") + logger.info(f"消息类型: {msg_type}") + logger.info(f"消息内容: {content}") + logger.info("-" * 20) logger.info("="*50 + "\n") # 初始化最后处理的消息哈希,避免重复回复第一条 last_msg = self.dialogue_log[-1] # last_msg 是字典,需要转字符串再 encode - def numpy_serializer(obj): - if isinstance(obj, np.integer): - return int(obj) - if isinstance(obj, np.floating): - return float(obj) - if isinstance(obj, np.ndarray): - return obj.tolist() - raise TypeError(f"Type {type(obj)} not serializable") - msg_str = json.dumps(last_msg, sort_keys=True, ensure_ascii=False, default=numpy_serializer) self.last_processed_msg_hash = hashlib.md5(msg_str.encode('utf-8')).hexdigest() self.last_screen_hash = self.get_image_hash(self.screenshot_path) @@ -233,10 +239,13 @@ class ChatMonitorBot: # D. 只关注最后一条消息 last_msg = dialogue_log[-1] - current_msg_hash = hashlib.md5(last_msg.encode('utf-8')).hexdigest() + # last_msg 是字典,需要序列化 + msg_str = json.dumps(last_msg, sort_keys=True, ensure_ascii=False, default=numpy_serializer) + current_msg_hash = hashlib.md5(msg_str.encode('utf-8')).hexdigest() # E. 判断是否需要回复 (对方发送且非重复消息) - if "对方:" in last_msg: + sender = last_msg.get('sender', '') + if sender == "对方": if current_msg_hash != self.last_processed_msg_hash: event_shot = WxUtil.get_next_debug_path("event_new_msg") self.device.screenshot(event_shot) diff --git a/WeiXin/WxUtil.py b/WeiXin/WxUtil.py index 17e7088..1f69cc5 100644 --- a/WeiXin/WxUtil.py +++ b/WeiXin/WxUtil.py @@ -173,10 +173,35 @@ def _scan_chat_messages(image_path): logger.info("正在执行 OCR 识别...") ocr_results = ocr_kit.read_text(image_path) + # 4.5 尝试提取聊天标题 (对方昵称) + chat_title = "对方" + potential_titles = [] + for bbox, text, conf in ocr_results: + c_y = int((bbox[0][1] + bbox[2][1]) / 2) + c_x = int((bbox[0][0] + bbox[2][0]) / 2) + # 标题区域通常在顶部 (状态栏下方,消息列表上方) + if 60 < c_y < 140: + clean = text.strip() + # 排除时间、信号、返回按钮等 + if re.match(r'^\d{1,2}:\d{2}$', clean): continue + if "微信" in clean or "WeChat" in clean: continue + if clean in ["<", "返回", "消息", "(", ")"]: continue + if re.match(r'^\d+$', clean): continue # 排除纯数字(如未读数) + if len(clean) > 0: + potential_titles.append((c_x, clean)) + + if potential_titles: + # 优先取最接近水平中心的文本作为标题 + potential_titles.sort(key=lambda x: abs(x[0] - w/2)) + chat_title = potential_titles[0][1] + # 去除可能包含的括号(比如备注名后的群聊人数,虽然后面会被截断) + chat_title = re.sub(r'\(\d+\)$', '', chat_title).strip() + logger.info(f"识别到聊天标题/对方昵称: {chat_title}") + # 微信菜单关键字(用于排除干扰) MENU_KEYWORDS = ["听筒播放", "收藏", "背景播放", "删除", "多选", "取消转文字", "转文字", "引用", "提醒"] # 忽略的系统消息内容 - IGNORE_CONTENT = ["撤回了一条消息", "打招呼的消息", "拍了拍", "你撤回了一条消息", "引用"] + IGNORE_CONTENT = ["撤回了一条消息", "打招呼的消息", "拍了拍", "你撤回了一条消息", "引用", "Clear Text", "Switch IME", "Done"] # 5. 整合所有消息 messages = [] @@ -188,6 +213,8 @@ def _scan_chat_messages(image_path): cv2.putText(debug_img, "TOP_FILTER", (10, 140), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 255), 1) cv2.putText(debug_img, "BOTTOM_FILTER", (10, h - 110), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 255), 1) + claimed_ocr_indices = set() + # A. 添加语音消息 for ax, ay in audio_matches: # 标记所有找到的语音图标 (用于调试) @@ -211,12 +238,18 @@ def _scan_chat_messages(image_path): # 改进:判断是否已转文字 is_converted = False converted_trigger_text = "" - for bbox, text, conf in ocr_results: + associated_texts = [] # 存储关联的多行文本 [(y, x, text)] + + for i, (bbox, text, conf) in enumerate(ocr_results): + if i in claimed_ocr_indices: continue + c_x = int((bbox[0][0] + bbox[2][0]) / 2) c_y = int((bbox[0][1] + bbox[2][1]) / 2) - # 判定逻辑:文本在语音下方且水平偏移不大 - if 30 < c_y - ay < 600 and abs(c_x - ax) < 600: + # 判定逻辑:文本在语音下方且水平偏移不大 (放宽 Y 轴限制以包含侧边的时长文本) + # 2025-01-26: 增加 X 轴范围到 900 以适配超长语音条的右侧时长/文本 + # 增加 Y 轴范围到 800 以适配多行转文字内容 + if -50 < c_y - ay < 800 and abs(c_x - ax) < 900: # 检查中间是否有其他语音图标 has_intermediate_audio = False for other_ax, other_ay in audio_matches: @@ -236,24 +269,35 @@ def _scan_chat_messages(image_path): # 判定是否为系统消息 is_ignored = any(k in clean_text for k in IGNORE_CONTENT) - if not is_duration and not is_timestamp and clean_text not in MENU_KEYWORDS and not is_ignored: + # 噪音判定 (例如 "少3"") + is_noise = "少" in clean_text and len(clean_text) < 8 and re.search(r'\d', clean_text) + + if not is_duration and not is_timestamp and clean_text not in MENU_KEYWORDS and not is_ignored and not is_noise: is_converted = True - # 针对 "少3"" 这种特殊噪点进行过滤,但仍标记为已转换 - # 如果包含 "少" 且长度短且包含数字,视为噪点 (例如 "少3"") - if "少" in clean_text and len(clean_text) < 6 and re.search(r'\d', clean_text): - logger.info(f"语音({ax},{ay}) 判定为已转换,但内容判定为噪点('{clean_text}'),置为空") - converted_trigger_text = "" - else: - converted_trigger_text = clean_text - logger.info(f"语音({ax},{ay}) 判定为已转换,关联到有效文本: '{clean_text}'") - break + associated_texts.append((c_y, c_x, clean_text)) + claimed_ocr_indices.add(i) + # 不再 break,继续寻找后续文本行 else: + # 这些文本虽然不作为内容,但它们属于语音消息的附属信息,标记为已处理 + claimed_ocr_indices.add(i) + if is_timestamp: logger.info(f"语音({ax},{ay}) 忽略下方时间戳文本: '{clean_text}'") elif is_duration: logger.info(f"语音({ax},{ay}) 忽略时长文本: '{clean_text}'") + elif is_noise: + logger.info(f"语音({ax},{ay}) 忽略噪音文本: '{clean_text}'") elif is_ignored: logger.info(f"语音({ax},{ay}) 忽略系统消息文本: '{clean_text}'") + else: + logger.info(f"语音({ax},{ay}) 忽略其他文本(可能是菜单): '{clean_text}'") + + # 整合所有关联文本 + if associated_texts: + # 按 Y 轴排序,如果 Y 接近则按 X 轴排序 + associated_texts.sort(key=lambda x: (x[0], x[1])) + converted_trigger_text = "".join([t[2] for t in associated_texts]) + logger.info(f"语音({ax},{ay}) 判定为已转换,最终合并文本: '{converted_trigger_text}'") if is_converted: logger.info(f"语音消息 ({ax}, {ay}) 已有转换文字: '{converted_trigger_text}',跳过") @@ -275,7 +319,8 @@ def _scan_chat_messages(image_path): }) # B. 添加文本消息 - for bbox, text, conf in ocr_results: + for i, (bbox, text, conf) in enumerate(ocr_results): + if i in claimed_ocr_indices: continue c_x = int((bbox[0][0] + bbox[2][0]) / 2) c_y = int((bbox[0][1] + bbox[2][1]) / 2) @@ -284,12 +329,25 @@ def _scan_chat_messages(image_path): time_pattern = r'(\d{4}年|\d{1,2}月|\d{1,2}日|\d{1,2}:\d{2}|昨天|今天|星期|上午|下午|晚上)' if len(text) < 20 and (re.search(time_pattern, text) or re.match(r'^[0-9:\s日年月\-]+$', text)): + logger.info(f"忽略时间戳/日期文本: '{text}'") continue clean_text = text.strip() - if re.match(r'^.?[0-9]{1,2}"?$', clean_text): continue - if clean_text in MENU_KEYWORDS: continue - if any(k in clean_text for k in IGNORE_CONTENT): continue + if re.match(r'^.?[0-9]{1,2}"?$', clean_text): + logger.info(f"忽略疑似时长文本: '{clean_text}'") + continue + + # 噪音判定 (例如 "少3"") + if "少" in clean_text and len(clean_text) < 8 and re.search(r'\d', clean_text): + logger.info(f"忽略噪音文本: '{clean_text}'") + continue + + if clean_text in MENU_KEYWORDS: + logger.info(f"忽略菜单关键词: '{clean_text}'") + continue + if any(k in clean_text for k in IGNORE_CONTENT): + logger.info(f"忽略系统消息内容: '{clean_text}'") + continue left_x = bbox[0][0] sender = "对方" if left_x < w * 0.5 else "我" @@ -304,7 +362,7 @@ def _scan_chat_messages(image_path): # 6. 排序 messages.sort(key=lambda x: x['y']) - return messages, debug_img + return messages, debug_img, chat_title async def analyze_chat_image(image_path, output_path, device=None, target_name="对方", process_strategy="ALL"): """ @@ -339,10 +397,16 @@ async def analyze_chat_image(image_path, output_path, device=None, target_name=" logger.info(f"--- 分析循环 第 {loop_count} 次 ---") # 1. 扫描当前屏幕 - messages, debug_img = _scan_chat_messages(current_image_path) + messages, debug_img, chat_title = _scan_chat_messages(current_image_path) if messages is None: # 读取失败 return [], None - + + # 更新消息发送者名称 (将 "对方" 替换为 实际标题) + if chat_title and chat_title != "对方": + for m in messages: + if m['sender'] == "对方": + m['sender'] = chat_title + # 保存当前状态的调试图 if current_output_path: cv2.imwrite(current_output_path, debug_img) @@ -432,7 +496,7 @@ async def analyze_chat_image(image_path, output_path, device=None, target_name=" peek_shot = get_next_debug_path("step_peek_content") d.screenshot(peek_shot) logger.info("正在读取转换后的语音内容...") - peek_messages, _ = _scan_chat_messages(peek_shot) + peek_messages, _, _ = _scan_chat_messages(peek_shot) # 2. 查找并保存内容 found_content = None diff --git a/WeiXin/__pycache__/WxUtil.cpython-310.pyc b/WeiXin/__pycache__/WxUtil.cpython-310.pyc index 850720d1e2fe70ea947830cc8eaaac997e8396fb..2508ae47d5564e4ea861114ba22e82631d526f9c 100644 GIT binary patch delta 7036 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 QEKX~_waD6R9kf^f4>}W0Q2+n{ delta 5447 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