wechat_msg_clicker/wechat_clicker/automator.py
2026-04-22 19:28:54 +08:00

335 lines
11 KiB
Python

"""主自动化逻辑
编排整个工作流程:扫描未读聊天 → 点击进入 → 点击图片/文件 → 关闭预览 → 关闭会话 → 循环
"""
import logging
import time
from .ax_bridge import AXBridge
from .config import Config
from .human_like import HumanBehavior
from .state_machine import StateMachine, UIState
from .wechat_ui import WeChatUI
logger = logging.getLogger("wechat_clicker.automator")
class WeChatAutomator:
"""微信消息自动点击器"""
def __init__(self, config: Config, dry_run: bool = False):
self.config = config
self.dry_run = dry_run
# 初始化各组件
self.ax = AXBridge()
self.ui = WeChatUI(self.ax, config.bundle_id)
self.state = StateMachine(self.ax, self.ui)
self.human = HumanBehavior(config)
# 统计
self._scan_count = 0
self._total_chats_processed = 0
self._total_media_clicked = 0
# ----------------------------------------------------------------
# 启动检查
# ----------------------------------------------------------------
def verify_setup(self) -> bool:
"""验证环境和权限。"""
# 检查辅助功能权限
if not self.ax.check_accessibility():
logger.error("缺少辅助功能权限,无法继续")
return False
# 检查微信是否运行
app_ref = self.ui.get_app_ref()
if app_ref is None:
logger.error("微信未运行,请先启动微信桌面端")
return False
# 检查主窗口
main_win = self.ui.get_main_window()
if main_win is None:
logger.error("未找到微信主窗口,请确保微信已登录并可见")
return False
logger.info("环境检查通过")
return True
# ----------------------------------------------------------------
# 主循环
# ----------------------------------------------------------------
def run(self):
"""永久运行的主循环。"""
logger.info("微信自动点击器启动")
if not self.verify_setup():
return
while True:
try:
self._run_one_cycle()
except KeyboardInterrupt:
logger.info("用户中断,正在退出...")
break
except Exception as e:
logger.error(f"主循环异常: {e}", exc_info=True)
# 尝试恢复状态
try:
self.state.recover_to_chat_list()
except Exception:
pass
time.sleep(10)
self._print_stats()
def run_once(self):
"""运行一次扫描循环。"""
logger.info("执行单次扫描")
if not self.verify_setup():
return
self._run_one_cycle()
self._print_stats()
def _run_one_cycle(self):
"""执行一个完整的扫描-处理循环。"""
# 检查工作时间
if self.human.is_off_hours():
logger.info("当前为非工作时间,等待中...")
time.sleep(300)
return
# 偶尔触发长休息
if self.human.should_take_break():
self.human.long_break()
return
# 确保微信在前台
self.ui.ensure_wechat_frontmost()
time.sleep(0.5)
# 恢复到聊天列表
if not self.state.recover_to_chat_list():
logger.warning("无法恢复到聊天列表,跳过本次循环")
time.sleep(10)
return
self._scan_count += 1
# 检查是否有未读消息
global_unread = self.ui.get_global_unread_count()
if global_unread == 0:
logger.debug(f"[扫描#{self._scan_count}] 没有未读消息")
sleep_time = self.human.scan_interval_with_jitter()
time.sleep(sleep_time)
return
logger.info(f"[扫描#{self._scan_count}] 全局未读: {global_unread}")
# 获取未读聊天列表
unread_chats = self.ui.get_unread_chats()
if not unread_chats:
logger.debug("聊天列表中未发现未读项(可能需要滚动)")
sleep_time = self.human.scan_interval_with_jitter()
time.sleep(sleep_time)
return
# 过滤和限制数量
chats_to_process = []
for chat in unread_chats:
if self.config.should_process_chat(chat.name):
chats_to_process.append(chat)
count = self.human.random_subset_count(
len(chats_to_process), self.config.max_chats_per_scan
)
chats_to_process = chats_to_process[:count]
logger.info(
f"将处理 {len(chats_to_process)} 个聊天 "
f"(共 {len(unread_chats)} 个未读)"
)
# 逐个处理
for chat in chats_to_process:
self.human.delay("before_click_chat")
self._process_chat(chat)
self.human.delay("before_close_chat")
# 等待下次扫描
sleep_time = self.human.scan_interval_with_jitter()
logger.debug(f"等待 {sleep_time:.0f}s 后进行下次扫描")
time.sleep(sleep_time)
# ----------------------------------------------------------------
# 处理单个聊天
# ----------------------------------------------------------------
def _process_chat(self, chat):
"""打开一个聊天,处理其中的媒体消息,然后关闭。"""
logger.info(f"处理聊天: {chat.name} (未读: {chat.unread_count})")
if self.dry_run:
logger.info(f" [DRY-RUN] 跳过点击: {chat.name}")
return
# 点击聊天项打开会话
if not self.ax.press(chat.element):
logger.warning(f" 点击聊天项失败: {chat.name}")
return
self.human.delay("after_open_chat")
# 验证会话窗口已打开
state = self.state.detect_state()
if state != UIState.CONVERSATION_OPEN:
logger.warning(
f" 会话窗口未打开 (状态: {state.value}),尝试恢复"
)
self.state.recover_to_chat_list()
return
# 处理会话中的媒体
conv_window = self.ui.get_conversation_window()
if conv_window:
media_count = self._process_media(conv_window, chat.name)
self._total_media_clicked += media_count
self._total_chats_processed += 1
# 关闭会话窗口
self.human.delay("before_close_chat")
if not self.state.close_current_conversation():
logger.warning(" 关闭会话失败,尝试恢复")
self.state.recover_to_chat_list()
# ----------------------------------------------------------------
# 处理媒体消息
# ----------------------------------------------------------------
def _process_media(self, conv_window, chat_name: str) -> int:
"""在打开的会话中查找并点击媒体消息。
Returns:
点击的媒体数量。
"""
media_messages = self.ui.get_media_messages(conv_window)
if not media_messages:
logger.debug(f" {chat_name}: 未发现可见媒体消息")
return 0
# 过滤需要点击的类型
targets = []
for msg in media_messages:
if msg.msg_type == "image" and self.config.click_images:
targets.append(msg)
elif msg.msg_type == "file" and self.config.click_files:
targets.append(msg)
elif msg.msg_type == "video" and self.config.click_videos:
targets.append(msg)
if not targets:
logger.debug(f" {chat_name}: 无需处理的媒体")
return 0
# 限制数量,从最新的开始(列表末尾 = 最新)
max_count = self.config.max_media_per_chat
targets = targets[-max_count:] if len(targets) > max_count else targets
# 反转,从最新的开始处理
targets = list(reversed(targets))
logger.info(f" {chat_name}: 发现 {len(targets)} 个媒体消息待处理")
clicked = 0
for msg in targets:
self.human.delay("before_click_media")
short_title = msg.title.replace("\n", " ")[:50]
logger.info(f" 点击{msg.msg_type}: {short_title}")
# 点击媒体
if not self.ax.press(msg.element):
logger.warning(f" 点击失败: {short_title}")
continue
self.human.delay("after_click_media")
# 关闭可能出现的预览
self._dismiss_preview_safe()
clicked += 1
self.human.delay("between_messages")
# 检查连续错误
if self.ax.should_backoff():
logger.warning("连续错误过多,暂停处理")
self.ax.reset_error_count()
break
return clicked
def _dismiss_preview_safe(self):
"""安全地关闭可能出现的媒体预览。"""
# 等一小会儿让预览可能出现
self.human.micro_jitter()
state = self.state.detect_state()
if state == UIState.MEDIA_PREVIEW:
self.human.delay("before_close_preview")
self.state.dismiss_preview()
else:
# 保守策略:即使未检测到预览也发送 Escape
# 因为某些预览可能不会创建新窗口
self.ax.send_escape_key()
time.sleep(0.3)
# ----------------------------------------------------------------
# 调试工具
# ----------------------------------------------------------------
def dump_ui_tree(self):
"""输出微信 UI 元素树(调试用)。"""
if not self.verify_setup():
return
self.ui.ensure_wechat_frontmost()
time.sleep(0.5)
# 输出主窗口
main_win = self.ui.get_main_window()
if main_win:
print("=== 主窗口 (微信) ===")
print(self.ax.dump_element(main_win, max_depth=5))
# 输出会话窗口
conv_windows = self.ui.get_conversation_windows()
for win in conv_windows:
title = self.ax.get_title(win)
print(f"\n=== 会话窗口 ({title}) ===")
print(self.ax.dump_element(win, max_depth=5))
# 输出聊天列表解析结果
print("\n=== 聊天列表解析 ===")
items = self.ui.get_chat_items()
for item in items:
status = f"[未读:{item.unread_count}]" if item.unread_count > 0 else ""
print(f" {item.name} {status} | {item.preview} | {item.timestamp}")
# ----------------------------------------------------------------
# 统计
# ----------------------------------------------------------------
def _print_stats(self):
"""输出运行统计。"""
logger.info(
f"运行统计: 扫描={self._scan_count}, "
f"处理聊天={self._total_chats_processed}, "
f"点击媒体={self._total_media_clicked}"
)