"""主自动化逻辑 编排整个工作流程:扫描未读聊天 → 点击进入 → 滚动加载历史 → 点击图片/文件 → 关闭 → 循环 图片处理流程:点击缩略图 → 打开大图 → 点击"..." → 点击"使用预览打开" → 关闭 Preview.app 文件处理流程:点击文件气泡触发下载 """ 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") SCROLL_ROUNDS = 5 SCROLL_LINES_PER_ROUND = -10 class WeChatAutomator: """微信消息自动点击器""" def __init__(self, config: Config, dry_run: bool = False): self.config = config self.dry_run = dry_run self._single_mode = False 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._single_mode = True self._run_one_cycle() self._print_stats() def _run_one_cycle(self): if not self._single_mode: 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 = [ c for c in unread_chats if self.config.should_process_chat(c.name) ] 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") if not self._single_mode: 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 chat_pos = self.ax.get_position(chat.element) chat_size = self.ax.get_size(chat.element) logger.debug(f" 聊天项位置: pos={chat_pos} size={chat_size}") if not self.ax.click_at_element(chat.element): logger.warning(f" 点击聊天项失败: {chat.name}") return self.human.delay("after_open_chat") # 单窗口模式:会话在主窗口内打开,直接在主窗口中查找消息列表 main_win = self.ui.get_main_window() if main_win is None: logger.warning(" 主窗口丢失") return msg_list = self.ui.get_message_list(main_win) if msg_list is None: logger.warning(f" 未找到消息列表,可能聊天未成功打开: {chat.name}") return msg_list_pos = self.ax.get_position(msg_list) msg_list_size = self.ax.get_size(msg_list) logger.debug(f" 消息列表: pos={msg_list_pos} size={msg_list_size}") media_count = self._process_media_with_scroll(main_win, msg_list, chat.name) self._total_media_clicked += media_count self._total_chats_processed += 1 logger.info(f" {chat.name}: 本次处理了 {media_count} 个媒体") # ---------------------------------------------------------------- # 带滚动的媒体处理 # ---------------------------------------------------------------- def _process_media_with_scroll(self, main_win, msg_list, chat_name: str) -> int: """先滚到底部看最新消息,再向上滚动加载历史并处理媒体。""" total_clicked = 0 # 先滚到消息列表底部(看到最新消息) logger.info(f" {chat_name}: 滚动到最新消息...") self.ax.scroll_to_bottom(msg_list, rounds=10, lines_per_round=20) self.human.random_delay(1.0, 2.0) # 处理当前可见的媒体(最新消息) clicked = self._process_visible_media(main_win, chat_name) total_clicked += clicked # 向上滚动加载更多历史消息 for round_idx in range(SCROLL_ROUNDS): self.human.delay("between_messages") logger.debug(f" {chat_name}: 向上滚动第 {round_idx + 1}/{SCROLL_ROUNDS} 轮") self.ax.scroll_at_element(msg_list, lines=SCROLL_LINES_PER_ROUND) self.human.random_delay(1.0, 2.5) clicked = self._process_visible_media(main_win, chat_name) total_clicked += clicked return total_clicked def _process_visible_media(self, main_win, chat_name: str) -> int: """处理当前可见的所有媒体消息。""" media_messages = self.ui.get_media_messages(main_win) 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}: 发现 {len(media_messages)} 个媒体, " f"但无符合配置的处理目标" ) return 0 logger.info(f" {chat_name}: 当前可见 {len(targets)} 个媒体消息") clicked = 0 for idx, msg in enumerate(targets): self.human.delay("before_click_media") pos = self.ax.get_position(msg.element) short_title = msg.title.replace("\n", " ")[:50] logger.info( f" [{msg.msg_type}] {short_title} " f"(pos={pos}, size={msg.size}, #{idx+1}/{len(targets)})" ) if msg.msg_type == "image": success = self._click_image(msg) elif msg.msg_type == "file": success = self._click_file(msg) else: success = self._click_generic(msg) if success: clicked += 1 logger.debug(f" 处理成功: {msg.msg_type}") else: logger.warning(f" 处理失败: {msg.msg_type}") self.human.delay("between_messages") if self.ax.should_backoff(): logger.warning("连续错误过多,暂停处理") self.ax.reset_error_count() break return clicked # ---------------------------------------------------------------- # 图片处理:点击 → "..." → "使用预览打开" → 关闭 Preview # ---------------------------------------------------------------- def _click_image(self, msg) -> bool: """处理一张图片:点开大图 -> 点... -> 点使用预览打开 -> 关闭 Preview.app。""" # Step 1: 点击图片缩略图打开大图预览 logger.debug(" Step1: 点击图片缩略图") if not self.ax.click_at_element(msg.element): logger.warning(" 图片缩略图点击失败") return False self.human.delay("after_click_media") # Step 2: 检查预览窗口状态 state = self.state.detect_state() logger.debug(f" Step2: 点击后状态={state.value}") if state != UIState.MEDIA_PREVIEW: logger.debug(" 图片预览窗口未出现,尝试在内嵌预览中继续") # Step 3: 查找 "..." 按钮 self.human.random_delay(0.5, 1.5) logger.debug(" Step3: 搜索'...'按钮") more_btn = self._find_more_button_in_preview() if more_btn is None: logger.warning(" 未找到'...'按钮,Escape 退出") self.ax.send_escape_key() time.sleep(0.5) return False # Step 4: 点击 "..." 按钮 logger.debug(" Step4: 点击'...'按钮") if not self.ax.click_at_element(more_btn): if not self.ax.press(more_btn): logger.warning(" '...'按钮点击失败") self.ax.send_escape_key() time.sleep(0.5) return False self.human.random_delay(0.5, 1.5) # Step 5: 查找"使用预览打开"菜单项 logger.debug(" Step5: 搜索'使用预览打开'菜单项") preview_item = self.ui.find_menu_item('使用"预览"打开') if preview_item is None: preview_item = self.ui.find_menu_item("预览") if preview_item is None: preview_item = self.ui.find_menu_item("Preview") if preview_item is None: logger.warning(" 未找到'使用预览打开'菜单项,Escape 退出") self.ax.send_escape_key() time.sleep(0.3) self.ax.send_escape_key() time.sleep(0.5) return False # Step 6: 点击"使用预览打开" logger.debug(" Step6: 点击'使用预览打开'") if not self.ax.click_at_element(preview_item): self.ax.press(preview_item) self.human.random_delay(2.0, 4.0) # Step 7: 关闭 Preview.app if self.state.is_preview_app_running(): logger.debug(" Step7: 关闭 Preview.app") self.state.close_preview_app() else: logger.debug(" Step7: Preview.app 未运行,跳过关闭") # Step 8: 确保微信回到前台,关闭大图预览 logger.debug(" Step8: 恢复微信前台,Escape 关闭预览") self.ui.ensure_wechat_frontmost() time.sleep(0.3) self.ax.send_escape_key() time.sleep(0.5) state = self.state.detect_state() if state == UIState.MEDIA_PREVIEW: logger.debug(" 仍在预览状态,再次 Escape") self.ax.send_escape_key() time.sleep(0.3) return True def _find_more_button_in_preview(self): """在图片预览区域查找"..."按钮(排除侧边栏区域)。""" # 先在非主窗口(独立预览窗口)中找 windows = self.ui.get_all_windows() for win in windows: title = self.ax.get_title(win) if title == "微信": continue btn = self.ui.find_preview_more_button(win) if btn is not None: pos = self.ax.get_position(btn) logger.debug(f" 在独立窗口 '{title}' 中找到'...'按钮, pos={pos}") return btn # 内嵌预览:只在主窗口的右侧区域找(排除左侧侧边栏,x > 200) main_win = self.ui.get_main_window() if main_win: btn = self.ui.find_preview_more_button(main_win, min_x=200) if btn is not None: pos = self.ax.get_position(btn) logger.debug(f" 在主窗口右侧区域找到'...'按钮, pos={pos}") return btn logger.debug(" 未在任何窗口中找到'...'按钮") return None # ---------------------------------------------------------------- # 文件处理:直接点击触发下载 # ---------------------------------------------------------------- def _click_file(self, msg) -> bool: """点击文件触发下载。""" if not self.ax.click_at_element(msg.element): logger.warning(" 文件点击失败") return False self.human.delay("after_click_media") # 文件点击后可能会打开文件预览,关闭它 self.human.random_delay(1.0, 2.0) state = self.state.detect_state() if state == UIState.MEDIA_PREVIEW: self.ax.send_escape_key() time.sleep(0.5) return True # ---------------------------------------------------------------- # 通用处理(视频等) # ---------------------------------------------------------------- def _click_generic(self, msg) -> bool: if not self.ax.click_at_element(msg.element): return False self.human.delay("after_click_media") self.human.micro_jitter() self.ax.send_escape_key() time.sleep(0.5) return True # ---------------------------------------------------------------- # 调试工具 # ---------------------------------------------------------------- def dump_ui_tree(self): 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: pos = self.ax.get_position(item.element) size = self.ax.get_size(item.element) status = f"[未读:{item.unread_count}]" if item.unread_count > 0 else "" print( f" {item.name} {status} | {item.preview} | {item.timestamp} " f"| pos={pos} size={size}" ) # ---------------------------------------------------------------- # 统计 # ---------------------------------------------------------------- def _print_stats(self): logger.info( f"运行统计: 扫描={self._scan_count}, " f"处理聊天={self._total_chats_processed}, " f"点击媒体={self._total_media_clicked}" )