335 lines
11 KiB
Python
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}"
|
|
)
|