update video click

This commit is contained in:
cris 2026-05-06 14:31:56 +08:00
parent 392af1de29
commit 2861115407
7 changed files with 383 additions and 99 deletions

View File

@ -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自动跳过随机休息

View File

@ -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 次
### 调度策略

View File

@ -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

View File

@ -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:

View File

@ -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
# --- 聊天过滤 ---

View File

@ -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()

View File

@ -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: