diff --git a/CLAUDE.md b/CLAUDE.md index 55f87b7..f726ca6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ # WeChat 消息自动点击器 -自动点击微信桌面端未读消息中的图片和文件,触发原始文件下载到本地目录。 +自动点击微信桌面端未读消息中的图片、视频和文件,触发原始文件下载到本地目录。 ## 项目背景 -配合另一个信息收集项目使用:信息收集脚本负责将微信消息入库,但图片和文件需要被点击预览后微信才会下载原始文件到本地。本工具自动完成这个"点击"操作。 +配合另一个信息收集项目使用:信息收集脚本负责将微信消息入库,但图片、视频和文件需要被点击预览后微信才会下载原始文件到本地。本工具自动完成这个"点击"操作。 ## 技术架构 @@ -20,7 +20,7 @@ wechat_clicker/ ├── ax_bridge.py # AXUIElement 底层封装(属性读取、鼠标点击、键盘事件、滚动) ├── wechat_ui.py # 微信 UI 导航(聊天列表、消息列表、预览界面元素查找) ├── state_machine.py # UI 状态机(窗口状态检测、恢复、Preview.app 管理) -├── automator.py # 主自动化逻辑(扫描→进入聊天→锚点+箭头导航点击图片→点击文件→循环) +├── automator.py # 主自动化逻辑(扫描→进入聊天→锚点+箭头导航点击图片/视频→点击文件→循环) ├── human_like.py # 拟人行为(高斯分布延迟、长休息、工作时间) ├── config.py # YAML 配置加载 └── logger_setup.py # 日志配置 @@ -30,19 +30,25 @@ wechat_clicker/ - 聊天列表项为 AXStaticText(无 AXPress),使用 **CGEvent 鼠标坐标点击** - AXValue 位置/尺寸需用 AXValueGetValue 解包 CGPoint/CGSize -- 消息类型通过 title 内容判断:`"图片"` → 图片,`"文件\n..."` → 文件 +- 消息类型通过 title 内容判断:`"图片"` → 图片,`"视频..."` → 视频,`"文件\n..."` → 文件 - **图片/文件气泡偏移点击**:AXStaticText 覆盖整行(575px宽),实际气泡仅在左侧 ~80-170px 区域,使用 `click_at_element_offset(x=120, y=height/2)` 命中 -- **图片处理(锚点+箭头导航)**:滚到底部 → 找最底部可见图片(锚点)→ 点击锚点进入预览 → 按 i 次左箭头到达第 i 张 → 点"..."→"使用预览打开"→ 关闭 Preview.app → 回到微信 → 再次点击锚点 → 左箭头 i+1 次 → ... 循环直到覆盖 `unread_count + overlap` 张。彻底绕过滚动覆盖、元素可见性、去重等问题 -- **锚点稳定性**:连续 3 次失败自动重锚(重新滚到底部 + 重找锚点 + i 归零),最多重锚 2 次;每聊天最多处理 30 张图片(`MAX_IMAGES_PER_CHAT`),防止单聊天垄断处理时间 +- **图片+视频处理(锚点+箭头导航)**:滚到底部 → 找最底部可见图片或视频(锚点)→ 点击锚点进入预览 → 按 i 次左箭头到达第 i 个 → 检测类型(查找"查看原视频"按钮区分视频/图片)→ 图片:点"..."→"使用预览打开"→ 关闭 Preview.app;视频:点"查看原视频"→ 等 10s → Escape 退出 → 回到微信 → 再次点击锚点 → 左箭头 i+1 次 → ... 循环直到覆盖 `unread_count + overlap` 个 +- **视频类型三层检测**:(1) 有"查看原视频"按钮 → 未下载视频,点击触发下载+等10s;(2) 无"查看原视频"但有"原视频" → 已下载视频,等1s后Escape跳过;(3) 都没有 → 图片,走"..."→"使用预览打开"流程 +- **视频首次点击兜底**:视频首次点击可能不弹出独立预览窗口(微信先下载),等待5s后仍无窗口时,检查主窗口内是否有"查看原视频"按钮(内嵌预览),有则直接处理 +- **锚点稳定性**:连续 3 次失败自动重锚(重新滚到底部 + 重找锚点 + i 归零),最多重锚 2 次;每聊天最多处理 30 个媒体(`MAX_IMAGES_PER_CHAT`),防止单聊天垄断处理时间 - 文件处理:直接点击触发下载(不走箭头导航) - **滚动方向**:macOS CGEvent ScrollWheel **负值=向下滚**(看新消息),**正值=向上滚**(看旧消息) -- 进入聊天后滚到底部(负值),图片通过箭头导航覆盖历史 +- 进入聊天后滚到底部(负值),图片/视频通过箭头导航覆盖历史 - 滚动使用 **kCGEventMouseMoved + ScrollWheel**(不触发点击),避免误点 UI 元素 - "..."按钮搜索限制在预览区域(独立窗口或主窗口 x>200),排除侧边栏 - 可见性检查基于元素**中心点**是否在消息列表可见区域内(30px margin) - 状态检测基于窗口计数 + **AXMinimized 属性**:区分窗口存在但最小化 vs 真正可见 -- **窗口恢复策略(4 层)**:NSRunningApplication.unhide(隐藏)→ 检测 0 窗口时 `open -b` 重新打开(窗口被关闭)→ AX API 设置 AXMinimized=False(最小化)→ activate(前台) -- **UNKNOWN 状态恢复**:先 Escape + Cmd+W 关闭可能的弹窗/多余窗口,再 activate + 点击侧边栏;外层循环连续恢复失败时指数退避(10s → 20s → 40s → ... → 120s 封顶),避免无效高频重试 +- **窗口恢复策略(4 层)**:NSRunningApplication.unhide(隐藏)→ 检测 0 窗口时 `open -b` 重新打开(窗口被关闭,带 polling 验证)→ AX API 设置 AXMinimized=False(最小化)→ activate(前台) +- **UNKNOWN/ABNORMAL 状态恢复**:先关闭所有非主窗口(含空标题遗留预览、小尺寸浮窗),再 Escape + activate + 点击侧边栏 +- **主窗口识别**:多个"微信"窗口时取面积最大的为主窗口(解决视频迷你播放器等小浮窗干扰) +- **WECHAT_ABNORMAL 状态**:有窗口但标题非"微信"时(窗口正在加载/登录界面),先尝试关闭多余窗口再等待就绪 +- **窗口恢复二次确认**:`ensure_wechat_visible` 找到主窗口后等 0.5s 二次确认,防止瞬时窗口骗过检测 +- **连续恢复失败保护**:≥15 次连续失败后发送 macOS 系统通知 + 暂停 600s,避免无限空转;外层循环指数退避(10s → 20s → 40s → ... → 120s 封顶) ## 使用方法 @@ -67,7 +73,9 @@ python main.py --debug # 详细日志 - `config.yaml` 中可设置扫描间隔、延迟范围、白/黑名单、工作时间、媒体类型开关 - `max_chats_per_scan: 0` 表示不限制,处理全部未读聊天(适合大量群聊场景) -- 默认只点击图片(`media.click_files: false`, `media.click_videos: false`) +- 默认点击图片和视频(`media.click_files: false`) +- 默认日志级别 DEBUG,便于问题定位 +- 默认工作时间 7:00-次日1:00(非工作时间 1:00-7:00),支持跨午夜 - 默认黑名单包含微信系统账号和非聊天界面(腾讯新闻、微信支付、微信团队、服务号、订阅号、文件传输助手) - 处理完有未读的聊天后立即重扫,不等待 scan interval;只有无未读时才 sleep - 忙时(未读 > 5)自动跳过随机休息 diff --git a/project.md b/project.md index 892f053..66c6da2 100644 --- a/project.md +++ b/project.md @@ -102,10 +102,43 @@ - [x] **修复: UNKNOWN 状态长时间无法恢复** — 原恢复逻辑只做 activate + 点侧边栏,遇到弹窗/多余窗口无法清理。改为先 Escape + Cmd+W 关闭可能的弹窗,再 activate + 点侧边栏 - [x] **改进: 恢复失败指数退避** — 外层循环连续恢复失败时退避等待:10s → 20s → 40s → 80s → 120s(封顶),避免每 13 秒重复无效尝试。恢复成功后计数器归零 -### v0.9.3 窗口关闭恢复 (2026/04/24) +### v0.9.3 窗口关闭恢复 + 长休息优化 (2026/04/24-25) - [x] **修复: 窗口被关闭(非最小化)时无法恢复** — 微信进程在运行但 AXWindows=0(窗口被关闭而非最小化),原 unhide/AXMinimized=False/activate 均无效。新增策略:检测到 0 窗口时通过 `subprocess.run(["open", "-b", bundle_id])` 重新打开微信窗口 - [x] **窗口恢复策略升级为 4 层** — unhide(隐藏)→ 检测 0 窗口时 open -b 重开(关闭)→ AXMinimized=False(最小化)→ activate(前台) +- [x] **修复: 无可处理聊天时频繁触发长休息** — `should_take_break()` 原先在过滤聊天之前执行,当只有黑名单聊天(如腾讯新闻)有未读时,每 15s 周期仍有 5% 概率触发 30-120s 长休息。改为只在有实际可处理的聊天时才触发长休息 +- [x] **黑名单扩充** — 默认黑名单新增"服务号"、"订阅号"、"文件传输助手",防止进入非聊天界面导致状态异常 + +### v0.9.4 窗口恢复死循环修复 (2026/04/30) + +- [x] **修复(关键): UNKNOWN 状态 Cmd+W 导致恶性循环** — 原 UNKNOWN 状态恢复会发 Cmd+W 关闭窗口,而 open -b 刚重开的窗口标题可能暂未注册为"微信"被识别为 UNKNOWN → Cmd+W 关掉 → 又变成 0 窗口 → 再 open -b → 无限循环(实测连续 787 次/26 小时)。去掉 UNKNOWN 状态下的 Cmd+W,只保留安全的 Escape +- [x] **新增: WECHAT_ABNORMAL 状态** — 区分"有窗口但无'微信'标题"(可能窗口正在加载或登录界面)和完全未知状态,ABNORMAL 状态下等待窗口就绪而非尝试关闭 +- [x] **修复: unminimize_app open -b 后不验证** — 原 `open -b` 后不重新读取窗口列表,始终返回 True。改为 polling 等待(最多 5s)窗口出现,失败返回 False +- [x] **新增: ensure_wechat_visible 二次确认** — 找到主窗口后等 0.5s 再确认一次,防止瞬时窗口骗过检测 +- [x] **新增: 诊断日志增强** — get_main_window 失败时记录实际存在的窗口标题;open -b 后记录新出现的窗口标题 +- [x] **新增: 连续失败上限 + 系统通知** — 连续恢复失败 ≥15 次后发送 macOS 系统通知提醒人工介入,暂停 600s(之前无上限) + +### v1.0.0 视频处理支持 (2026/04/30) + +- [x] **新增: 视频纳入锚点+箭头导航** — 锚点查找同时匹配图片(`title=="图片"`)和视频(`title.startswith("视频")`),箭头导航中自动检测当前预览类型 +- [x] **新增: 视频类型三层检测** — (1) 有"查看原视频"→未下载视频,点击+等10s;(2) 无"查看原视频"但有"原视频"→已下载视频,等1s后Escape跳过;(3) 都没有→图片,走"..."→"使用预览打开" +- [x] **新增: `_find_view_original_video_button`** — 在预览窗口中搜索"查看原视频"按钮(先搜独立窗口,再搜主窗口右侧 x>200) +- [x] **新增: `_is_downloaded_video_preview`** — 检测"原视频"按钮判断已下载视频 +- [x] **新增: `_do_video_download_and_close`** — 点击"查看原视频" → 等待 10s → 关闭额外窗口 → Escape 退出预览 +- [x] **重构: `_process_images_with_arrow` → `_process_media_with_arrow`** — 支持图片/视频混合处理 +- [x] **重构: `_find_anchor_image` → `_find_anchor_media`** — 锚点查找同时匹配图片和视频 +- [x] **重构: `_process_visible_files` 精简** — 视频已移入箭头导航,此方法只处理文件 +- [x] **配置变更: click_videos 默认开启** — `media.click_videos` 默认值从 `false` 改为 `true` + +### v1.0.1 窗口恢复加固 + 视频首次点击兜底 (2026/05/01-06) + +- [x] **修复: 多个"微信"窗口导致 UNKNOWN 状态** — 视频迷你播放器(249x151小浮窗)也叫"微信",导致状态机无法识别。改为取面积最大的"微信"窗口为主窗口,其余归入 other_windows +- [x] **修复: UNKNOWN/ABNORMAL 恢复不关闭多余窗口** — 新增 `_close_non_main_windows` 方法,恢复时先关闭所有非主窗口(含空标题遗留预览、小浮窗),再重试状态检测 +- [x] **修复: `get_main_window` 多窗口误选** — 多个"微信"窗口时按面积排序取最大,避免选中迷你播放器 +- [x] **新增: 视频首次点击兜底** — 视频首次点击微信可能不弹出独立预览窗口(先下载)。等待5s无窗口后,检查是否有"查看原视频"按钮(内嵌预览),有则直接处理,避免误判为失败 +- [x] **新增: 预览窗口检测重试** — 点击锚点后2s无窗口,再等3s重试,应对视频加载慢的情况 +- [x] **配置变更: 工作时间** — 改为 7:00-次日1:00(非工作时间 1:00-7:00),支持跨午夜时间判断 +- [x] **配置变更: 日志级别** — 默认改为 DEBUG,便于问题定位 ### 待验证 @@ -139,19 +172,20 @@ - 计算元素中心坐标 = position + size/2 - 图片预览中的按钮优先尝试 click_at_element,后备 AXPress -### 图片下载流程(锚点+箭头导航) +### 媒体下载流程(锚点+箭头导航,图片+视频统一) 1. 滚到消息列表底部(最新消息) -2. 找到最底部可见的图片元素(锚点图片 N) -3. 对 i = 0, 1, 2, ... image_count-1: - a. 点击锚点图片 N → 打开预览 - b. 按左箭头 i 次 → 到达图片 N-i - c. 点击 "..." (更多) 按钮 - d. 点击 "使用'预览'打开" - e. 等待 macOS Preview.app 打开(原始文件已保存到本地) - f. 关闭 Preview.app + 额外窗口 - g. 回到微信,准备下一轮 -4. image_count = min(max(5, unread_count) + 5, 30)(固定 overlap,上限 30 张/聊天) +2. 找到最底部可见的图片或视频元素(锚点 N) +3. 对 i = 0, 1, 2, ... media_count-1: + a. 点击锚点 N → 打开预览(等 2s,无窗口再等 3s) + b. 若无独立预览窗口但检测到"查看原视频"按钮 → 内嵌预览兜底处理 + c. 按左箭头 i 次 → 到达媒体 N-i + d. 三层类型检测: + - 有"查看原视频" → 未下载视频 → 点击 + 等 10s + Escape + - 无"查看原视频"但有"原视频" → 已下载视频 → 等 1s + Escape + - 都没有 → 图片 → "..." → "使用预览打开" → 关闭 Preview.app + e. 回到微信,准备下一轮 +4. media_count = min(max(5, unread_count) + 5, 30)(固定 overlap,上限 30/聊天) 5. 连续 3 次失败自动重锚(滚到底部 + 重找锚点 + i 归零),最多重锚 2 次 ### 调度策略 diff --git a/wechat_clicker/automator.py b/wechat_clicker/automator.py index 7be4cac..b79e906 100644 --- a/wechat_clicker/automator.py +++ b/wechat_clicker/automator.py @@ -1,12 +1,13 @@ """主自动化逻辑 -编排整个工作流程:扫描未读聊天 → 点击进入 → 点击图片/文件 → 关闭 → 循环 +编排整个工作流程:扫描未读聊天 → 点击进入 → 点击图片/视频/文件 → 关闭 → 循环 -图片处理:锚点+箭头导航 — 点击最底部图片进入预览,左箭头键依次切换到更早的图片 +图片+视频处理:锚点+箭头导航 — 点击最底部图片/视频进入预览,左箭头键依次切换 文件处理:直接点击文件气泡触发下载 """ import logging +import subprocess import time from .ax_bridge import AXBridge @@ -21,6 +22,7 @@ IMAGE_OVERLAP = 5 MAX_IMAGES_PER_CHAT = 30 MAX_CONSECUTIVE_FAILURES = 3 MAX_REANCHOR_ATTEMPTS = 2 +MAX_RECOVER_FAILURES = 15 class WeChatAutomator: @@ -45,6 +47,21 @@ class WeChatAutomator: # 启动检查 # ---------------------------------------------------------------- + def _send_alert(self, message: str): + """发送 macOS 系统通知提醒用户介入。""" + try: + subprocess.run( + [ + "osascript", "-e", + f'display notification "{message}" ' + f'with title "微信自动点击器" sound name "Sosumi"' + ], + capture_output=True, timeout=5, + ) + logger.info(f"已发送系统通知: {message}") + except Exception as e: + logger.warning(f"发送通知失败: {e}") + def verify_setup(self) -> bool: if not self.ax.check_accessibility(): logger.error("缺少辅助功能权限,无法继续") @@ -116,10 +133,24 @@ class WeChatAutomator: if not self.state.recover_to_chat_list(): self._consecutive_recover_failures += 1 + current_state = self.state.current_state + + if self._consecutive_recover_failures >= MAX_RECOVER_FAILURES: + logger.critical( + f"微信恢复连续失败 {self._consecutive_recover_failures} 次 " + f"(状态={current_state.value}), 需要人工介入! 暂停 600s" + ) + self._send_alert( + f"连续恢复失败 {self._consecutive_recover_failures} 次, " + f"状态={current_state.value}" + ) + time.sleep(600) + return + backoff = min(10 * (2 ** (self._consecutive_recover_failures - 1)), 120) logger.warning( - f"无法恢复到聊天列表 (连续失败 {self._consecutive_recover_failures}), " - f"等待 {backoff}s" + f"无法恢复到聊天列表 (连续失败 {self._consecutive_recover_failures}, " + f"状态={current_state.value}), 等待 {backoff}s" ) time.sleep(backoff) return @@ -130,12 +161,6 @@ class WeChatAutomator: global_unread = self.ui.get_global_unread_count() - # 忙时(未读 > 5)跳过随机休息 - if not self._single_mode: - if self.human.should_take_break(global_unread): - self.human.long_break() - return - if global_unread == 0: logger.debug(f"[扫描#{self._scan_count}] 没有未读消息") sleep_time = self.human.scan_interval_with_jitter() @@ -161,6 +186,12 @@ class WeChatAutomator: if filtered_out: logger.info(f" 过滤掉的聊天: {filtered_out}") + # 只在有实际要处理的聊天时才考虑长休息 + if not self._single_mode and all_candidates: + if self.human.should_take_break(global_unread): + self.human.long_break() + return + count = self.human.random_subset_count( len(all_candidates), self.config.max_chats_per_scan ) @@ -244,19 +275,19 @@ class WeChatAutomator: # ---------------------------------------------------------------- def _process_media_with_scroll(self, main_win, msg_list, chat_name: str, unread_count: int = 0) -> int: - """处理聊天中的图片和文件。 - 图片:锚点+箭头导航(内部管理滚动和锚点查找)。 + """处理聊天中的图片、视频和文件。 + 图片+视频:锚点+箭头导航(内部管理滚动和锚点查找)。 文件:直接点击可见的文件元素。""" total_clicked = 0 - # 图片处理:锚点+箭头导航(滚动和锚点查找在内部完成) - if self.config.click_images: - image_count = min(max(5, unread_count) + IMAGE_OVERLAP, MAX_IMAGES_PER_CHAT) - image_clicked = self._process_images_with_arrow(msg_list, chat_name, image_count) - total_clicked += image_clicked + # 图片+视频处理:锚点+箭头导航(滚动和锚点查找在内部完成) + if self.config.click_images or self.config.click_videos: + media_count = min(max(5, unread_count) + IMAGE_OVERLAP, MAX_IMAGES_PER_CHAT) + media_clicked = self._process_media_with_arrow(msg_list, chat_name, media_count) + total_clicked += media_clicked - # 文件/视频处理:直接点击可见元素 - if self.config.click_files or self.config.click_videos: + # 文件处理:直接点击可见元素 + if self.config.click_files: self.ui.ensure_wechat_frontmost() time.sleep(0.3) state = self.state.detect_state() @@ -271,8 +302,8 @@ class WeChatAutomator: # 图片处理:锚点+箭头导航 # ---------------------------------------------------------------- - def _find_anchor_image(self, msg_list): - """在消息列表底部找到最后一个可见的图片元素(作为导航锚点)。 + def _find_anchor_media(self, msg_list): + """在消息列表底部找到最后一个可见的图片或视频元素(作为导航锚点)。 返回 (element, position, size) 或 None。""" list_pos = self.ax.get_position(msg_list) list_size = self.ax.get_size(msg_list) @@ -287,7 +318,7 @@ class WeChatAutomator: if self.ax.get_role(child) != "AXStaticText": continue title = self.ax.get_title(child) - if title != "图片": + if title != "图片" and not (title and title.startswith("视频")): continue size = self.ax.get_size(child) if size[0] == 0 or size[1] == 0: @@ -299,31 +330,31 @@ class WeChatAutomator: return anchor - def _process_images_with_arrow(self, msg_list, chat_name: str, image_count: int) -> int: - """用锚点+箭头导航处理多张图片。 + def _process_media_with_arrow(self, msg_list, chat_name: str, media_count: int) -> int: + """用锚点+箭头导航处理多张图片和视频。 内部管理锚点查找、滚动和失败重锚。""" anchor_element, anchor_size = self._scroll_and_find_anchor(msg_list) if not anchor_element: - logger.debug(f" {chat_name}: 未找到可见图片,跳过图片处理") + logger.debug(f" {chat_name}: 未找到可见图片/视频,跳过媒体处理") return 0 - logger.info(f" {chat_name}: 将处理 {image_count} 张图片") + logger.info(f" {chat_name}: 将处理 {media_count} 个图片/视频") clicked = 0 consecutive_failures = 0 reanchor_count = 0 i = 0 - while i < image_count: - logger.debug(f" [{i+1}/{image_count}] 点击锚点图片") + while i < media_count: + logger.debug(f" [{i+1}/{media_count}] 点击锚点媒体") if not self.ax.click_at_element_offset(anchor_element, 120, anchor_size[1] // 2): - logger.warning(f" [{i+1}] 锚点图片点击失败") + logger.warning(f" [{i+1}] 锚点点击失败") if reanchor_count >= MAX_REANCHOR_ATTEMPTS: logger.warning(f" 重锚次数已达上限({MAX_REANCHOR_ATTEMPTS}),中断") break logger.info(f" 尝试重锚 ({reanchor_count+1}/{MAX_REANCHOR_ATTEMPTS})...") anchor_element, anchor_size = self._scroll_and_find_anchor(msg_list) if not anchor_element: - logger.warning(f" 重锚失败,未找到图片") + logger.warning(f" 重锚失败,未找到媒体") break reanchor_count += 1 i = 0 @@ -332,10 +363,33 @@ class WeChatAutomator: time.sleep(2.0) + # 检测预览窗口是否打开(视频首次点击可能需要更长时间加载) windows = self.ui.get_all_windows() if len(windows) < 2: + # 重试一次:再等3秒(视频首次点击会触发下载,预览窗口延迟弹出) + logger.debug(f" [{i+1}] 预览窗口未立即打开,再等3秒...") + time.sleep(3.0) + windows = self.ui.get_all_windows() + + if len(windows) < 2: + # 兜底:即使没弹出独立预览窗口,也检查是否有"查看原视频"按钮(内嵌预览) + video_btn = self._find_view_original_video_button() + if video_btn: + logger.info(f" [{i+1}/{media_count}] 内嵌预览检测到视频,处理 (箭头×{i})") + success = self._do_video_download_and_close(video_btn) + if success: + clicked += 1 + consecutive_failures = 0 + else: + consecutive_failures += 1 + self.ui.ensure_wechat_frontmost() + time.sleep(0.3) + self.human.delay("between_messages") + i += 1 + continue + consecutive_failures += 1 - logger.warning(f" [{i+1}] 预览窗口未打开 (连续失败 {consecutive_failures})") + logger.warning(f" [{i+1}] 预览窗口未打开 (窗口数={len(windows)}, 连续失败 {consecutive_failures})") if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: if reanchor_count >= MAX_REANCHOR_ATTEMPTS: logger.warning(f" 重锚次数已达上限,中断") @@ -347,10 +401,10 @@ class WeChatAutomator: reanchor_count += 1 i = 0 consecutive_failures = 0 - else: - i += 1 continue + logger.debug(f" [{i+1}] 预览已打开 (窗口数={len(windows)})") + if i > 0: logger.debug(f" [{i+1}] 按左箭头 {i} 次") for _ in range(i): @@ -358,15 +412,34 @@ class WeChatAutomator: time.sleep(0.5) time.sleep(0.5) - logger.info(f" [{i+1}/{image_count}] 处理图片 (箭头×{i})") - success = self._do_preview_and_close() + # 检测当前预览类型并处理: + # 1. 有"查看原视频" → 未下载视频 → 点击触发下载 + # 2. 无"查看原视频"但有"原视频" → 已下载视频 → 直接退出 + # 3. 都没有 → 图片 → "..."→"使用预览打开" + video_btn = self._find_view_original_video_button() + is_downloaded_video = False if video_btn else self._is_downloaded_video_preview() + + logger.debug(f" [{i+1}] 类型检测: 查看原视频={video_btn is not None}, 已下载视频={is_downloaded_video}") + + if video_btn: + logger.info(f" [{i+1}/{media_count}] 处理视频 (箭头×{i})") + success = self._do_video_download_and_close(video_btn) + elif is_downloaded_video: + logger.debug(f" [{i+1}/{media_count}] 已下载视频,跳过 (箭头×{i})") + time.sleep(1.0) + self.ax.send_escape_key() + time.sleep(0.5) + success = True + else: + logger.info(f" [{i+1}/{media_count}] 处理图片 (箭头×{i})") + success = self._do_preview_and_close() if success: clicked += 1 consecutive_failures = 0 else: consecutive_failures += 1 - logger.warning(f" [{i+1}] 预览流程失败 (连续失败 {consecutive_failures})") + logger.warning(f" [{i+1}] 处理失败 (连续失败 {consecutive_failures})") if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: if reanchor_count >= MAX_REANCHOR_ATTEMPTS: logger.warning(f" 重锚次数已达上限,中断") @@ -385,14 +458,14 @@ class WeChatAutomator: self.human.delay("between_messages") i += 1 - logger.info(f" {chat_name}: 箭头导航完成, 成功 {clicked}/{image_count}") + logger.info(f" {chat_name}: 箭头导航完成, 成功 {clicked}/{media_count}") return clicked def _scroll_and_find_anchor(self, msg_list): """滚到底部并找锚点图片,返回 (element, size) 或 (None, None)。""" self.ax.scroll_to_bottom(msg_list, rounds=5, lines_per_round=-20) time.sleep(1.0) - anchor = self._find_anchor_image(msg_list) + anchor = self._find_anchor_media(msg_list) if anchor: pos = self.ax.get_position(anchor[0]) logger.debug(f" 锚点图片: pos={pos} size={anchor[2]}") @@ -454,12 +527,89 @@ class WeChatAutomator: return True + # ---------------------------------------------------------------- + # 视频处理:查找"查看原视频"按钮并触发下载 + # ---------------------------------------------------------------- + + def _find_view_original_video_button(self): + """在视频预览区域查找"查看原视频"按钮(仅未下载过的视频有此按钮)。""" + windows = self.ui.get_all_windows() + for win in windows: + title = self.ax.get_title(win) + if title == "微信": + continue + btn = self.ui._find_element_with_text(win, "查看原视频", max_depth=6) + if btn is not None: + pos = self.ax.get_position(btn) + logger.debug(f" 在独立窗口 '{title}' 中找到'查看原视频'按钮, pos={pos}") + return btn + + main_win = self.ui.get_main_window() + if main_win: + btn = self.ui._find_element_with_text(main_win, "查看原视频", max_depth=6) + if btn is not None: + pos = self.ax.get_position(btn) + if pos[0] > 200: + logger.debug(f" 在主窗口右侧找到'查看原视频'按钮, pos={pos}") + return btn + + return None + + def _is_downloaded_video_preview(self) -> bool: + """检查当前预览是否为已下载的视频(有"原视频"但无"查看原视频")。""" + windows = self.ui.get_all_windows() + for win in windows: + title = self.ax.get_title(win) + if title == "微信": + continue + btn = self.ui._find_element_with_text(win, "原视频", max_depth=6) + if btn is not None: + logger.debug(f" 在独立窗口 '{title}' 中找到'原视频'按钮(已下载)") + return True + + main_win = self.ui.get_main_window() + if main_win: + btn = self.ui._find_element_with_text(main_win, "原视频", max_depth=6) + if btn is not None: + pos = self.ax.get_position(btn) + if pos[0] > 200: + logger.debug(f" 在主窗口右侧找到'原视频'按钮(已下载)") + return True + + return False + + def _do_video_download_and_close(self, video_btn) -> bool: + """在已打开的视频预览中执行: 点击"查看原视频"→ 等待下载 → 关闭预览。""" + self.human.random_delay(0.5, 1.0) + + if not self.ax.click_at_element(video_btn): + if not self.ax.press(video_btn): + logger.warning(" '查看原视频'按钮点击失败") + self.ax.send_escape_key() + time.sleep(0.5) + return False + + logger.debug(" 等待视频下载 (10s)...") + time.sleep(10) + + self._close_extra_windows() + + self.ax.send_escape_key() + time.sleep(0.3) + + state = self.state.detect_state() + if state == UIState.MEDIA_PREVIEW: + self.ax.send_escape_key() + time.sleep(0.3) + + return True + # ---------------------------------------------------------------- # 文件/视频处理:直接点击可见元素 # ---------------------------------------------------------------- def _process_visible_files(self, main_win, msg_list, chat_name: str) -> int: - """处理当前可见的文件和视频(不处理图片,图片走箭头导航)。""" + """处理当前可见的文件(图片和视频走箭头导航,不在此处理)。""" list_pos = self.ax.get_position(msg_list) list_size = self.ax.get_size(msg_list) visible_top = list_pos[1] @@ -480,11 +630,7 @@ class WeChatAutomator: continue msg_type = WeChatUI._classify_message(title, size) - if msg_type == "file" and not self.config.click_files: - continue - if msg_type == "video" and not self.config.click_videos: - continue - if msg_type not in ("file", "video"): + if msg_type != "file": continue pos = self.ax.get_position(child) @@ -497,18 +643,15 @@ class WeChatAutomator: if not targets: return 0 - logger.info(f" {chat_name}: 发现 {len(targets)} 个文件/视频") + logger.info(f" {chat_name}: 发现 {len(targets)} 个文件") clicked = 0 for child, msg_type, title, size in targets: self.human.delay("before_click_media") short_title = title.replace("\n", " ")[:50] - logger.info(f" [{msg_type}] {short_title}") + logger.info(f" [file] {short_title}") msg_item = MessageItem(element=child, msg_type=msg_type, title=title, size=size) - if msg_type == "file": - success = self._click_file(msg_item) - else: - success = self._click_generic(msg_item) + success = self._click_file(msg_item) if success: clicked += 1 diff --git a/wechat_clicker/ax_bridge.py b/wechat_clicker/ax_bridge.py index 4bb5448..d728a11 100644 --- a/wechat_clicker/ax_bridge.py +++ b/wechat_clicker/ax_bridge.py @@ -111,7 +111,11 @@ class AXBridge: return apps is not None and len(apps) > 0 def unminimize_app(self, bundle_id: str) -> bool: - """取消应用窗口的最小化/隐藏状态,多策略依次尝试。""" + """取消应用窗口的最小化/隐藏状态,多策略依次尝试。 + + Returns: + True 如果至少有一个窗口变为可见状态。 + """ apps = NSRunningApplication.runningApplicationsWithBundleIdentifier_(bundle_id) if not apps or len(apps) == 0: logger.error(f"未找到运行中的应用: {bundle_id}") @@ -138,15 +142,20 @@ class AXBridge: ["open", "-b", bundle_id], capture_output=True, timeout=5, ) - time.sleep(1.5) except Exception as e: logger.error(f"open 命令失败: {e}") - else: - for win in windows: - if self.is_window_minimized(win): - title = self.get_title(win) or "?" - logger.info(f"通过 AX API 取消窗口最小化: {title}") - self.set_window_minimized(win, False) + return False + + # open -b 后 polling 等待窗口出现 + windows = self._poll_for_windows(bundle_id, app_name) + if not windows: + return False + # 对已有窗口取消最小化 + for win in windows: + if self.is_window_minimized(win): + title = self.get_title(win) or "?" + logger.info(f"通过 AX API 取消窗口最小化: {title}") + self.set_window_minimized(win, False) time.sleep(0.5) # 策略3: activate 带到前台 @@ -156,6 +165,26 @@ class AXBridge: logger.info(f"窗口恢复流程完成: {app_name}") return True + def _poll_for_windows(self, bundle_id: str, app_name: str, + max_attempts: int = 5, interval: float = 1.0) -> list: + """open -b 后轮询等待窗口出现。""" + for attempt in range(1, max_attempts + 1): + time.sleep(interval) + fresh_ref = self.get_app_ref(bundle_id) + if not fresh_ref: + continue + windows = self.get_windows(fresh_ref) + if windows: + titles = [self.get_title(w) or "(空标题)" for w in windows] + logger.info( + f"{app_name}: open 后第{attempt}次检查发现 " + f"{len(windows)} 个窗口, 标题={titles}" + ) + return windows + logger.debug(f"{app_name}: open 后第{attempt}次检查仍无窗口") + logger.warning(f"{app_name}: open 命令后等待 {max_attempts}s 仍无窗口") + return [] + def is_window_minimized(self, window) -> bool: """检查窗口是否处于最小化状态。""" try: diff --git a/wechat_clicker/config.py b/wechat_clicker/config.py index 63d42ce..0c2358d 100644 --- a/wechat_clicker/config.py +++ b/wechat_clicker/config.py @@ -28,8 +28,8 @@ DEFAULTS = { }, "schedule": { "enabled": True, - "start_hour": 8, - "end_hour": 23, + "start_hour": 7, + "end_hour": 1, "pause_on_weekends": False, }, "filter": { @@ -40,11 +40,11 @@ DEFAULTS = { "media": { "click_images": True, "click_files": False, - "click_videos": False, + "click_videos": True, "max_media_per_chat": 20, }, "logging": { - "level": "INFO", + "level": "DEBUG", "file": "wechat_clicker.log", "max_bytes": 10485760, "backup_count": 5, @@ -181,7 +181,11 @@ class Config: start_hour = self._get("schedule.start_hour") end_hour = self._get("schedule.end_hour") - return start_hour <= now.hour < end_hour + hour = now.hour + if start_hour <= end_hour: + return start_hour <= hour < end_hour + # 跨午夜:如 7:00-1:00 表示工作到凌晨1点 + return hour >= start_hour or hour < end_hour # --- 聊天过滤 --- diff --git a/wechat_clicker/state_machine.py b/wechat_clicker/state_machine.py index 93dae08..a3e31de 100644 --- a/wechat_clicker/state_machine.py +++ b/wechat_clicker/state_machine.py @@ -27,6 +27,7 @@ class UIState(Enum): MEDIA_PREVIEW = "media_preview" WECHAT_NOT_RUNNING = "wechat_not_running" WECHAT_MINIMIZED = "wechat_minimized" + WECHAT_ABNORMAL = "wechat_abnormal" class StateMachine: @@ -67,13 +68,22 @@ class StateMachine: # 检查主窗口是否最小化(AXWindows 会包含最小化的窗口) main_window = None + main_window_area = 0 all_minimized = True other_windows = [] for win in windows: title = self.ax.get_title(win) is_mini = self.ax.is_window_minimized(win) if title == "微信": - main_window = win + size = self.ax.get_size(win) + area = size[0] * size[1] + if area > main_window_area: + if main_window is not None: + other_windows.append((main_window, "微信")) + main_window = win + main_window_area = area + else: + other_windows.append((win, title)) elif title: other_windows.append((win, title)) if not is_mini: @@ -86,8 +96,13 @@ class StateMachine: return self._current_state if main_window is None: - self._current_state = UIState.UNKNOWN - logger.debug("未找到微信主窗口") + # 有窗口但标题都不是"微信"——可能是登录/更新界面、窗口正在加载 + window_titles = [self.ax.get_title(w) or "(空标题)" for w in windows] + self._current_state = UIState.WECHAT_ABNORMAL + logger.warning( + f"微信窗口异常: {len(windows)} 个窗口但无'微信'主窗口, " + f"标题={window_titles}" + ) return self._current_state window_count = len(windows) @@ -142,6 +157,15 @@ class StateMachine: logger.error("微信窗口恢复失败") return False + if state == UIState.WECHAT_ABNORMAL: + # 有窗口但无"微信"主窗口——可能有遗留预览窗口挡住了 + # 先尝试关闭非主窗口,再等待 + logger.warning("微信窗口异常,尝试关闭多余窗口...") + self._close_non_main_windows() + self.ui.ensure_wechat_frontmost() + time.sleep(2.0) + continue + if state == UIState.MEDIA_PREVIEW: # 先关闭预览(Escape) logger.info("关闭媒体预览...") @@ -154,11 +178,11 @@ class StateMachine: time.sleep(0.5) if state == UIState.UNKNOWN: - # 先尝试关闭可能的弹窗/多余窗口 + # 可能有遗留的预览窗口(空标题),先关闭非主窗口 + self._close_non_main_windows() + # Escape 关闭可能的弹窗(安全操作) self.ax.send_escape_key() time.sleep(0.3) - self.ax.send_cmd_w() - time.sleep(0.3) # 重新激活微信并点击侧边栏聊天按钮 self.ui.ensure_wechat_frontmost() time.sleep(0.5) @@ -191,6 +215,26 @@ class StateMachine: self.ax.send_cmd_w() time.sleep(0.3) + def _close_non_main_windows(self): + """关闭所有非主窗口(含空标题的遗留预览窗口和小尺寸浮窗)。""" + main_win = self.ui.get_main_window() + windows = self.ui.get_all_windows() + for win in windows: + if main_win is not None and win == main_win: + continue + title = self.ax.get_title(win) or "(空标题)" + close_btn = self.ui.get_close_button(win) + if close_btn: + logger.debug(f"关闭非主窗口: '{title}'") + self.ax.press(close_btn) + time.sleep(0.3) + else: + logger.debug(f"使用 Cmd+W 关闭非主窗口: '{title}'") + self.ui.ensure_wechat_frontmost() + time.sleep(0.2) + self.ax.send_cmd_w() + time.sleep(0.3) + def close_current_conversation(self) -> bool: """关闭当前打开的会话窗口。""" conv_window = self.ui.get_conversation_window() diff --git a/wechat_clicker/wechat_ui.py b/wechat_clicker/wechat_ui.py index af157b3..19ea0dc 100644 --- a/wechat_clicker/wechat_ui.py +++ b/wechat_clicker/wechat_ui.py @@ -94,7 +94,12 @@ class WeChatUI: for attempt in range(1, max_retries + 1): logger.info(f"微信窗口不可见或已最小化,恢复尝试 {attempt}/{max_retries}...") - self.ax.unminimize_app(self.bundle_id) + success = self.ax.unminimize_app(self.bundle_id) + if not success: + logger.warning(f"恢复尝试 {attempt}: unminimize_app 返回失败") + time.sleep(1.0) + continue + time.sleep(1.0) # 清除缓存的 app_ref,强制重新获取 @@ -102,9 +107,16 @@ class WeChatUI: main_win = self.get_main_window() if main_win is not None and not self.ax.is_window_minimized(main_win): - logger.info("微信主窗口已恢复可见") - self.ensure_wechat_frontmost() - return True + # 二次确认:等 0.5s 后再检查,防止瞬时窗口 + time.sleep(0.5) + self.invalidate_app_ref() + main_win_verify = self.get_main_window() + if main_win_verify is not None and not self.ax.is_window_minimized(main_win_verify): + logger.info("微信主窗口已恢复可见") + self.ensure_wechat_frontmost() + return True + else: + logger.warning("微信主窗口短暂出现后消失(可能窗口尚未稳定)") logger.warning(f"恢复尝试 {attempt} 后窗口仍不可见,等待后重试...") time.sleep(1.0) @@ -124,12 +136,22 @@ class WeChatUI: return self.ax.get_windows(app_ref) def get_main_window(self): - """找到主窗口(名为"微信")。""" - for win in self.get_all_windows(): + """找到主窗口(名为"微信"且最大的那个)。""" + all_windows = self.get_all_windows() + candidates = [] + for win in all_windows: title = self.ax.get_title(win) if title == "微信": - return win - logger.warning("未找到微信主窗口") + size = self.ax.get_size(win) + candidates.append((win, size[0] * size[1])) + if candidates: + candidates.sort(key=lambda x: x[1], reverse=True) + return candidates[0][0] + if all_windows: + found_titles = [self.ax.get_title(w) or "(空标题)" for w in all_windows] + logger.warning(f"未找到微信主窗口, 当前窗口标题: {found_titles}") + else: + logger.warning("未找到微信主窗口") return None def get_conversation_windows(self) -> list: