From efcef1baded37e08ce957a4b50c97da1e9a934cb Mon Sep 17 00:00:00 2001 From: HuangHai <10402852@qq.com> Date: Mon, 26 Jan 2026 20:37:36 +0800 Subject: [PATCH] 'commit' --- WeiXin/T2_ChatMonitor.py | 45 +++-- WeiXin/WxUtil.py | 209 ++++++++++++++++++++-- WeiXin/__pycache__/WxUtil.cpython-310.pyc | Bin 24432 -> 27591 bytes 3 files changed, 228 insertions(+), 26 deletions(-) diff --git a/WeiXin/T2_ChatMonitor.py b/WeiXin/T2_ChatMonitor.py index cc0e2ed..dc32c34 100644 --- a/WeiXin/T2_ChatMonitor.py +++ b/WeiXin/T2_ChatMonitor.py @@ -179,16 +179,14 @@ class ChatMonitorBot: logger.info("\n" + "="*50) logger.info("【测试模式】最终提取的对话记录:") for msg in self.dialogue_log: - # 格式化输出:[发送者] 内容 (类型) sender = msg.get('sender', '未知') content = msg.get('content', '') - msg_type = "语音" if msg.get('type') == 'voice' else "文字" + time_str = msg.get('time_display', '') - # 按照用户要求的格式输出 - logger.info(f"说话人: {sender}") - logger.info(f"消息类型: {msg_type}") - logger.info(f"消息内容: {content}") - logger.info("-" * 20) + # 按照用户要求的格式输出: 2026-01-26 10:03 糖豆爸爸 : 老师您好! + log_prefix = f"{time_str} " if time_str else "" + log_line = f"{log_prefix}{sender} : {content}" + logger.info(log_line) logger.info("="*50 + "\n") # --- LLM 总结 --- @@ -198,7 +196,9 @@ class ChatMonitorBot: sender = msg.get('sender', '未知') content = msg.get('content', '') type_str = "[语音]" if msg.get('type') == 'voice' else "[文字]" - chat_history_text += f"{sender}{type_str}: {content}\n" + time_str = msg.get('time_display', '') + time_prefix = f"[{time_str}] " if time_str else "" + chat_history_text += f"{time_prefix}{sender}{type_str}: {content}\n" prompt = ( "请根据以下微信对话记录,总结归纳双方交流的主要信息点。\n" @@ -232,7 +232,7 @@ class ChatMonitorBot: logger.info(f"💡 [首屏] 最后一条消息来自 '{sender}',尝试生成回复...") # 构建上下文 - context_text = "\n".join([f"{m.get('sender')}: {m.get('content')}" for m in self.dialogue_log[:-1]]) + context_text = "\n".join([f"{m.get('time_display', '') + ' ' if m.get('time_display') else ''}{m.get('sender')}: {m.get('content')}" for m in self.dialogue_log[:-1]]) last_content = last_msg.get('content', '') reply = await self.get_reply(last_content, context_text) @@ -324,8 +324,31 @@ class ChatMonitorBot: logger.info(f"💡 [监控] 发现新消息: {last_msg},保存现场截图: {event_shot}") # 获取上下文文本 (格式化为 Sender: Content) - context_text = "\n".join([f"{m.get('sender')}: {m.get('content')}" for m in dialogue_log[:-1]]) - last_content = last_msg.get('content', '') + context_text = "\n".join([f"{m.get('time_display', '') + ' ' if m.get('time_display') else ''}{m.get('sender')}: {m.get('content')}" for m in dialogue_log[:-1]]) + last_content = last_msg.get('content') or "" + + # 兜底逻辑:如果最后一条是语音且内容为空(可能因无红点未被 UNREAD 策略处理),尝试强制转换 + if last_msg.get('type') == 'voice' and not last_content.strip(): + logger.info("⚠️ [监控] 最后一条语音消息未获取到内容(可能已读无红点),尝试强制转换...") + # 强制使用 LAST 策略重试 + dialogue_log_retry, _ = await WxUtil.analyze_chat_image( + self.screenshot_path, + self.debug_view_path, + device=self.device, + process_strategy="LAST" + ) + if dialogue_log_retry: + # 更新引用 + self.dialogue_log = dialogue_log_retry + dialogue_log = dialogue_log_retry + last_msg = dialogue_log[-1] + last_content = last_msg.get('content') or "" + logger.info(f"🔄 [重试] 强制转换后内容: {last_content}") + + # 重新构建 msg_str 和 hash,确保下次循环不会因为内容变化而再次触发(虽然这里已经处理了) + # 但实际上这里是在处理当前事件,更新 hash 是为了避免重复处理 + 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() # 生成回复 reply = await self.get_reply(last_content, context_text) diff --git a/WeiXin/WxUtil.py b/WeiXin/WxUtil.py index ce1c05d..54ef86d 100644 --- a/WeiXin/WxUtil.py +++ b/WeiXin/WxUtil.py @@ -15,7 +15,7 @@ if project_root not in sys.path: sys.path.append(project_root) import json -from datetime import datetime +from datetime import datetime, timedelta from Util.EasyOcrKit import EasyOcrKit # 初始化 EasyOcrKit @@ -34,6 +34,107 @@ TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Templat # 全局调试图片计数器 _debug_counter = 0 +def parse_wechat_time(time_str): + """ + 解析微信时间字符串为标准化格式 (YYYY-MM-DD HH:MM) + 支持: "10:03", "昨天 10:03", "星期三 10:03", "2025年1月1日 10:03" + """ + try: + now = datetime.now() + today = now.date() + clean_str = time_str.strip() + + # 1. HH:mm (当天) + # 注意:有时候 OCR 会把冒号识别成其他字符,这里假设是标准的 HH:mm + if re.match(r'^\d{1,2}:\d{2}$', clean_str): + h, m = map(int, clean_str.split(':')) + dt = datetime.combine(today, datetime.min.time().replace(hour=h, minute=m)) + return dt.strftime("%Y-%m-%d %H:%M") + + # 2. 昨天 HH:mm + if "昨天" in clean_str: + t_part = clean_str.replace("昨天", "").strip() + if re.match(r'^\d{1,2}:\d{2}$', t_part): + h, m = map(int, t_part.split(':')) + yesterday = today - timedelta(days=1) + dt = datetime.combine(yesterday, datetime.min.time().replace(hour=h, minute=m)) + return dt.strftime("%Y-%m-%d %H:%M") + + # 3. 星期X HH:mm + weekdays = {"星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3, "星期五": 4, "星期六": 5, "星期日": 6} + for w_str, w_idx in weekdays.items(): + if w_str in clean_str: + t_part = clean_str.replace(w_str, "").strip() + if re.match(r'^\d{1,2}:\d{2}$', t_part): + h, m = map(int, t_part.split(':')) + current_weekday = now.weekday() + # 计算日期回退天数 (mod 7 确保是过去的一周内) + delta_days = (current_weekday - w_idx) % 7 + # 如果 delta_days 是 0 且当前时间比消息时间早 (不可能发生,除非穿越),说明是今天 + # 但通常"星期X"不显示今天,今天显示 HH:mm + # 如果 delta_days == 0,可能是上周的今天?微信通常显示 "上周X"? + # 简单起见,认为是今天或过去7天内的那天 + if delta_days == 0 and datetime.now().time() < datetime.min.time().replace(hour=h, minute=m): + delta_days = 7 # 上周 + + target_date = today - timedelta(days=delta_days) + dt = datetime.combine(target_date, datetime.min.time().replace(hour=h, minute=m)) + return dt.strftime("%Y-%m-%d %H:%M") + + # 4. YYYY年MM月DD日 HH:mm + # 简单匹配年月日 + match = re.search(r'(\d{4})年(\d{1,2})月(\d{1,2})日', clean_str) + if match: + y, m, d = map(int, match.groups()) + # 找时间部分 + time_match = re.search(r'(\d{1,2}):(\d{2})', clean_str) + if time_match: + hh, mm = map(int, time_match.groups()) + dt = datetime(y, m, d, hh, mm) + return dt.strftime("%Y-%m-%d %H:%M") + else: + # 只有日期,没有时间 (通常是日期分隔符) + # 这种情况下,可能需要给个默认时间?或者就返回日期 + return f"{y:04d}-{m:02d}-{d:02d} 00:00" + + # 5. MM月DD日 HH:mm (跨年但未显示年份?微信通常会显示年份如果跨年) + # 处理 "1月26日 10:00" + match = re.search(r'(\d{1,2})月(\d{1,2})日', clean_str) + if match: + m, d = map(int, match.groups()) + # 默认当年 + y = today.year + # 找时间 + time_match = re.search(r'(\d{1,2}):(\d{2})', clean_str) + if time_match: + hh, mm = map(int, time_match.groups()) + dt = datetime(y, m, d, hh, mm) + # 如果计算出的时间在未来,可能是去年 (比如现在1月,消息是12月) + if dt > now: + dt = datetime(y - 1, m, d, hh, mm) + return dt.strftime("%Y-%m-%d %H:%M") + + # 兜底:如果是 "下午 5:00" 这种格式 + if "下午" in clean_str or "晚上" in clean_str: + t_part = re.sub(r'下午|晚上', '', clean_str).strip() + if re.match(r'^\d{1,2}:\d{2}$', t_part): + h, m = map(int, t_part.split(':')) + if h < 12: h += 12 + dt = datetime.combine(today, datetime.min.time().replace(hour=h, minute=m)) + return dt.strftime("%Y-%m-%d %H:%M") + + if "上午" in clean_str: + t_part = re.sub(r'上午', '', clean_str).strip() + if re.match(r'^\d{1,2}:\d{2}$', t_part): + h, m = map(int, t_part.split(':')) + dt = datetime.combine(today, datetime.min.time().replace(hour=h, minute=m)) + return dt.strftime("%Y-%m-%d %H:%M") + + return clean_str # 解析失败,返回原串 + except Exception as e: + logger.warning(f"时间解析失败 '{time_str}': {e}") + return time_str + def get_next_debug_path(desc="step"): """获取下一个顺序命名的调试图片路径 (debug_N_desc.jpg)""" global _debug_counter @@ -247,10 +348,30 @@ def _scan_chat_messages(image_path): c_x = int((bbox[0][0] + bbox[2][0]) / 2) c_y = int((bbox[0][1] + bbox[2][1]) / 2) - # 判定逻辑:文本在语音下方且水平偏移不大 (放宽 Y 轴限制以包含侧边的时长文本) - # 2025-01-26: 增加 X 轴范围到 900 以适配超长语音条的右侧时长/文本 - # 增加 Y 轴范围到 800 以适配多行转文字内容 - if -50 < c_y - ay < 800 and abs(c_x - ax) < 900: + # 判定逻辑:文本在语音下方且水平偏移不大 + # 1. Y轴限制: -50 < dy < 800 (适配多行文本) + # 2. X轴限制: abs(dx) < 500 (减少误判,防止关联到屏幕另一侧的消息) + # 3. 几何位置强校验 (核心修复) + voice_is_left = ax < w / 2 + + # 获取文本框的左右边界 + min_x = min(p[0] for p in bbox) + max_x = max(p[0] for p in bbox) + + if voice_is_left: + # 语音在左 (对方): 文本必须也是左对齐 + # - min_x 必须靠左 (< 300) + # - max_x 不能太靠右 (> w - 150),否则可能是"我"的消息 + if min_x > 300 or max_x > w - 150: + continue + else: + # 语音在右 (我): 文本必须也是右对齐 + # - max_x 必须靠右 (> w - 300) + # - min_x 不能太靠左 (< 100) + if max_x < w - 300 or min_x < 100: + continue + + if -50 < c_y - ay < 800 and abs(c_x - ax) < 500: # 检查中间是否有其他语音图标 has_intermediate_audio = False for other_ax, other_ay in audio_matches: @@ -298,6 +419,13 @@ def _scan_chat_messages(image_path): # 按 Y 轴排序,如果 Y 接近则按 X 轴排序 associated_texts.sort(key=lambda x: (x[0], x[1])) converted_trigger_text = "".join([t[2] for t in associated_texts]) + + # 去除已知噪音 + noise_patterns = ["42IIhK+-语音输入粘贴#", "语音输入粘贴"] + for np in noise_patterns: + converted_trigger_text = converted_trigger_text.replace(np, "") + converted_trigger_text = converted_trigger_text.strip() + logger.info(f"语音({ax},{ay}) 判定为已转换,最终合并文本: '{converted_trigger_text}'") if is_converted: @@ -328,9 +456,36 @@ def _scan_chat_messages(image_path): if c_y < 150 or c_y > h - 100: continue + # 判定发送者 (增强版几何判定,防止 720p 屏幕下的中心点误判) + # 默认使用中心点判定 + sender = "对方" if c_x < w / 2 else "我" + + # 使用边界特征进行修正 + min_x = min(p[0] for p in bbox) + max_x = max(p[0] for p in bbox) + + # 修正阈值:假设头像+边距约占 15% 宽度 + edge_margin = w * 0.15 + + if max_x > w - edge_margin: + # 文本框延伸到了最右侧 -> 肯定是"我" (因为对方的头像在左,文本不会靠右) + sender = "我" + elif min_x < edge_margin: + # 文本框延伸到了最左侧 -> 肯定是"对方" (因为我的头像在右,文本不会靠左) + sender = "对方" + 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}'") + # 进一步校验是否真的是时间 (通过 parse_wechat_time 尝试解析,或者简单正则) + # 这里我们假设短行的符合 time_pattern 的都是时间标记 + logger.info(f"识别到时间戳/日期: '{text}'") + messages.append({ + "type": "timestamp", + "content": text.strip(), + "y": c_y, + "center": (c_x, c_y) + }) continue clean_text = text.strip() @@ -350,8 +505,6 @@ def _scan_chat_messages(image_path): logger.info(f"忽略系统消息内容: '{clean_text}'") continue - left_x = bbox[0][0] - sender = "对方" if left_x < w * 0.5 else "我" messages.append({ "type": "text", @@ -363,7 +516,30 @@ def _scan_chat_messages(image_path): # 6. 排序 messages.sort(key=lambda x: x['y']) - return messages, debug_img, chat_title + + # 7. 注入时间戳 + current_time_str = None + + # 过滤掉 timestamp 类型的消息,将其作为属性注入到后续消息中 + final_messages_with_time = [] + + for msg in messages: + if msg['type'] == 'timestamp': + # 更新当前时间上下文 + parsed_time = parse_wechat_time(msg['content']) + current_time_str = parsed_time + logger.info(f"更新时间上下文: {msg['content']} -> {parsed_time}") + else: + # 只有语音和文本消息需要注入时间 + if current_time_str: + msg['time_display'] = current_time_str + else: + # 如果上方没有时间戳,尝试默认使用当天日期 (或者保持 None) + # 对于首屏最上面的消息,可能没有时间戳 + pass + final_messages_with_time.append(msg) + + return final_messages_with_time, debug_img, chat_title async def analyze_chat_image(image_path, output_path, device=None, target_name="对方", process_strategy="ALL"): """ @@ -629,18 +805,21 @@ async def analyze_chat_image(image_path, output_path, device=None, target_name=" for msg in final_messages: # 尝试注入异步获取的语音内容 if msg['type'] == 'voice': - # 模糊匹配 Y 坐标 + # 模糊匹配 Y 坐标 (增大容差到 100,应对界面滚动) for y_key, content in captured_voice_contents.items(): - if abs(msg['y'] - y_key) < 20: + if abs(msg['y'] - y_key) < 100: msg['is_converted'] = True msg['content'] = content logger.info(f"注入语音内容到最终消息列表: {content}") break - # 只添加有内容的文本消息,或已转换且有内容的语音消息 - if msg['type'] == 'text' and msg.get('content'): - dialogue_log.append(msg) - elif msg['type'] == 'voice' and msg.get('is_converted') and msg.get('content'): + # 无论是否有内容,都加入 dialogue_log + # 如果是语音且没内容,T2 会有兜底逻辑去处理 + if msg['type'] == 'text': + if msg.get('content'): # 文本消息没内容通常是识别错误,可以丢弃 + dialogue_log.append(msg) + elif msg['type'] == 'voice': + # 语音消息即使没内容也保留,交给上层处理 dialogue_log.append(msg) logger.info(f"📊 [统计] 语音总数: {total_voices_count}, 打开转文字次数: {convert_opened_count}, 关闭转文字次数: {convert_closed_count}") diff --git a/WeiXin/__pycache__/WxUtil.cpython-310.pyc b/WeiXin/__pycache__/WxUtil.cpython-310.pyc index a2634e73fc07633d32f5114b3ec7b1ff87de974d..a30fca3b56ee5f0f303722c5718259f5e8877551 100644 GIT binary patch delta 10032 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 delta 6736 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 J*O+JR{{uz=rQrYo