wechat_msg_clicker/wechat_clicker/automator.py

724 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""主自动化逻辑
编排整个工作流程:扫描未读聊天 → 点击进入 → 点击图片/文件 → 关闭 → 循环
图片处理:锚点+箭头导航 — 点击最底部图片进入预览,左箭头键依次切换到更早的图片
文件处理:直接点击文件气泡触发下载
"""
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, MessageItem
logger = logging.getLogger("wechat_clicker.automator")
IMAGE_OVERLAP = 5
MAX_IMAGES_PER_CHAT = 30
MAX_CONSECUTIVE_FAILURES = 3
MAX_REANCHOR_ATTEMPTS = 2
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.info("未找到微信主窗口,尝试恢复最小化窗口...")
if self.ui.ensure_wechat_visible():
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):
cycle_start = time.time()
if not self._single_mode:
if self.human.is_off_hours():
logger.info("当前为非工作时间,等待中...")
time.sleep(300)
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()
# 忙时(未读 > 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()
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
all_candidates = [
c for c in unread_chats if self.config.should_process_chat(c.name)
]
filtered_out = [
c.name for c in unread_chats
if not self.config.should_process_chat(c.name)
]
if filtered_out:
logger.info(f" 过滤掉的聊天: {filtered_out}")
count = self.human.random_subset_count(
len(all_candidates), self.config.max_chats_per_scan
)
chats_to_process = all_candidates[:count]
logger.info(
f"将处理 {len(chats_to_process)} 个聊天 "
f"(共 {len(unread_chats)} 个未读, 过滤 {len(filtered_out)} 个)"
)
cycle_media_count = 0
media_before = self._total_media_clicked
for chat in chats_to_process:
self.human.delay("before_click_chat")
self._process_chat(chat)
self.human.delay("before_close_chat")
cycle_media_count = self._total_media_clicked - media_before
cycle_duration = time.time() - cycle_start
logger.info(
f"[扫描#{self._scan_count}] 完成: "
f"全局未读={global_unread}, "
f"处理聊天={len(chats_to_process)}/{len(unread_chats)}, "
f"过滤={len(filtered_out)}, "
f"点击媒体={cycle_media_count}, "
f"耗时={cycle_duration:.0f}s"
)
if not self._single_mode:
if chats_to_process:
# 刚处理完聊天,可能还有更多未读,立即重扫
logger.debug("还有可能的未读消息,立即重新扫描")
return
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, chat.unread_count)
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, 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_files or self.config.click_videos:
self.ui.ensure_wechat_frontmost()
time.sleep(0.3)
state = self.state.detect_state()
if state != UIState.MAIN_CHAT_LIST:
self._close_extra_windows()
file_clicked = self._process_visible_files(main_win, msg_list, chat_name)
total_clicked += file_clicked
return total_clicked
# ----------------------------------------------------------------
# 图片处理:锚点+箭头导航
# ----------------------------------------------------------------
def _find_anchor_image(self, msg_list):
"""在消息列表底部找到最后一个可见的图片元素(作为导航锚点)。
返回 (element, position, size) 或 None。"""
list_pos = self.ax.get_position(msg_list)
list_size = self.ax.get_size(msg_list)
visible_top = list_pos[1]
visible_bottom = list_pos[1] + list_size[1]
margin = 30
children = self.ax.get_children(msg_list)
anchor = None
for child in children:
if self.ax.get_role(child) != "AXStaticText":
continue
title = self.ax.get_title(child)
if title != "图片":
continue
size = self.ax.get_size(child)
if size[0] == 0 or size[1] == 0:
continue
pos = self.ax.get_position(child)
center_y = pos[1] + size[1] // 2
if visible_top + margin <= center_y <= visible_bottom - margin:
anchor = (child, pos, size)
return anchor
def _process_images_with_arrow(self, msg_list, chat_name: str, image_count: int) -> int:
"""用锚点+箭头导航处理多张图片。
内部管理锚点查找、滚动和失败重锚。"""
anchor_element, anchor_size = self._scroll_and_find_anchor(msg_list)
if not anchor_element:
logger.debug(f" {chat_name}: 未找到可见图片,跳过图片处理")
return 0
logger.info(f" {chat_name}: 将处理 {image_count} 张图片")
clicked = 0
consecutive_failures = 0
reanchor_count = 0
i = 0
while i < image_count:
logger.debug(f" [{i+1}/{image_count}] 点击锚点图片")
if not self.ax.click_at_element_offset(anchor_element, 120, anchor_size[1] // 2):
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" 重锚失败,未找到图片")
break
reanchor_count += 1
i = 0
consecutive_failures = 0
continue
time.sleep(2.0)
windows = self.ui.get_all_windows()
if len(windows) < 2:
consecutive_failures += 1
logger.warning(f" [{i+1}] 预览窗口未打开 (连续失败 {consecutive_failures})")
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
if reanchor_count >= MAX_REANCHOR_ATTEMPTS:
logger.warning(f" 重锚次数已达上限,中断")
break
logger.info(f" 连续 {consecutive_failures} 次失败,重锚 ({reanchor_count+1}/{MAX_REANCHOR_ATTEMPTS})...")
anchor_element, anchor_size = self._scroll_and_find_anchor(msg_list)
if not anchor_element:
break
reanchor_count += 1
i = 0
consecutive_failures = 0
else:
i += 1
continue
if i > 0:
logger.debug(f" [{i+1}] 按左箭头 {i}")
for _ in range(i):
self.ax.send_left_arrow()
time.sleep(0.5)
time.sleep(0.5)
logger.info(f" [{i+1}/{image_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})")
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
if reanchor_count >= MAX_REANCHOR_ATTEMPTS:
logger.warning(f" 重锚次数已达上限,中断")
break
logger.info(f" 连续 {consecutive_failures} 次失败,重锚 ({reanchor_count+1}/{MAX_REANCHOR_ATTEMPTS})...")
anchor_element, anchor_size = self._scroll_and_find_anchor(msg_list)
if not anchor_element:
break
reanchor_count += 1
i = 0
consecutive_failures = 0
continue
self.ui.ensure_wechat_frontmost()
time.sleep(0.3)
self.human.delay("between_messages")
i += 1
logger.info(f" {chat_name}: 箭头导航完成, 成功 {clicked}/{image_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)
if anchor:
pos = self.ax.get_position(anchor[0])
logger.debug(f" 锚点图片: pos={pos} size={anchor[2]}")
return anchor[0], anchor[2]
return None, None
def _do_preview_and_close(self) -> bool:
"""在已打开的图片预览中执行: 找"..."→ 点击 → 找"使用预览打开"→ 点击 → 关闭 Preview.app。"""
self.human.random_delay(0.5, 1.0)
# 找 "..." 按钮
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
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.0)
# 找"使用预览打开"菜单项
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(" 未找到'使用预览打开'菜单项")
self.ax.send_escape_key()
time.sleep(0.3)
self.ax.send_escape_key()
time.sleep(0.5)
return False
if not self.ax.click_at_element(preview_item):
self.ax.press(preview_item)
self.human.random_delay(2.0, 3.5)
# 关闭 Preview.app + 额外窗口
self._close_extra_windows()
# Escape 关闭残留的内嵌预览
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]
visible_bottom = list_pos[1] + list_size[1]
margin = 30
children = self.ax.get_children(msg_list)
targets = []
for child in children:
if self.ax.get_role(child) != "AXStaticText":
continue
title = self.ax.get_title(child)
if not title:
continue
size = self.ax.get_size(child)
if size[0] == 0 or size[1] == 0:
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"):
continue
pos = self.ax.get_position(child)
center_y = pos[1] + size[1] // 2
if center_y < visible_top + margin or center_y > visible_bottom - margin:
continue
targets.append((child, msg_type, title, size))
if not targets:
return 0
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}")
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)
if success:
clicked += 1
self.human.delay("between_messages")
return clicked
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:
"""点击文件触发下载,然后关闭可能弹出的预览窗口。"""
x_off = 120
y_off = msg.size[1] // 2
if not self.ax.click_at_element_offset(msg.element, x_off, y_off):
logger.warning(" 文件点击失败")
return False
self.human.delay("after_click_media")
self.human.random_delay(1.0, 2.0)
state = self.state.detect_state()
logger.debug(f" 文件点击后状态={state.value}")
if state != UIState.MAIN_CHAT_LIST:
self._close_extra_windows()
return True
# ----------------------------------------------------------------
# 通用处理(视频等)
# ----------------------------------------------------------------
def _click_generic(self, msg) -> bool:
x_off = 120
y_off = msg.size[1] // 2
if not self.ax.click_at_element_offset(msg.element, x_off, y_off):
return False
self.human.delay("after_click_media")
self.human.micro_jitter()
self.ax.send_escape_key()
time.sleep(0.5)
return True
# ----------------------------------------------------------------
# 窗口清理:关闭预览等额外窗口
# ----------------------------------------------------------------
def _close_extra_windows(self):
"""关闭所有非主窗口(文件预览、图片预览等)和 Preview.app恢复干净状态。"""
if self.state.is_preview_app_running():
logger.debug(" 关闭 Preview.app")
self.state.close_preview_app()
conv_wins = self.ui.get_conversation_windows()
for win in conv_wins:
title = self.ax.get_title(win)
logger.debug(f" 关闭额外窗口: {title}")
close_btn = self.ui.get_close_button(win)
if close_btn:
self.ax.press(close_btn)
else:
self.ax.send_cmd_w()
time.sleep(0.5)
self.ui.ensure_wechat_frontmost()
time.sleep(0.3)
# ----------------------------------------------------------------
# 调试工具
# ----------------------------------------------------------------
def dump_ui_tree(self, chat_name: str = None):
if not self.verify_setup():
return
self.ui.ensure_wechat_frontmost()
time.sleep(0.5)
if chat_name:
self._dump_chat_messages(chat_name)
return
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 _dump_chat_messages(self, chat_name: str):
"""进入指定聊天dump 消息列表中每个子元素的完整子树。"""
# 确保在聊天列表
if not self.state.recover_to_chat_list():
print(f"ERROR: 无法恢复到聊天列表")
return
# 在聊天列表中查找匹配的聊天项(子串匹配)
items = self.ui.get_chat_items()
target = None
for item in items:
if chat_name in item.name:
target = item
break
if target is None:
print(f"ERROR: 聊天列表中未找到包含 \"{chat_name}\" 的聊天")
print("当前可见聊天:")
for item in items:
print(f" - {item.name}")
return
print(f"找到聊天: {target.name} (未读: {target.unread_count})")
print(f"点击进入...")
# 点击聊天项(先点其他聊天再点回来,避免重复选中不加载)
other = next((i for i in items if i.name != target.name), None)
if other:
self.ax.click_at_element(other.element)
time.sleep(0.5)
if not self.ax.click_at_element(target.element):
print(f"ERROR: 点击聊天项失败")
return
time.sleep(2.0)
# 在主窗口中找消息列表(重试几次,等待 UI 加载)
main_win = self.ui.get_main_window()
if main_win is None:
print("ERROR: 主窗口丢失")
return
msg_list = None
for attempt in range(3):
msg_list = self.ui.get_message_list(main_win)
if msg_list is not None:
break
print(f" 等待消息列表加载... (尝试 {attempt + 1}/3)")
time.sleep(1.5)
if msg_list is None:
print("ERROR: 未找到消息列表dump 主窗口结构以诊断:")
print(self.ax.dump_element(main_win, max_depth=8))
return
# 滚到底部看最新消息
print("滚动到最新消息...")
self.ax.scroll_to_bottom(msg_list, rounds=10, lines_per_round=-20)
time.sleep(1.5)
# 遍历消息列表所有子元素
children = self.ax.get_children(msg_list)
print(f"\n=== 消息列表 ({target.name}) ===")
print(f"子元素总数: {len(children)}")
print()
for idx, child in enumerate(children):
role = self.ax.get_role(child)
title = self.ax.get_title(child)
size = self.ax.get_size(child)
pos = self.ax.get_position(child)
short_title = (title or "").replace("\n", "\\n")[:60]
print(f"--- [{idx}] role={role} title=\"{short_title}\" size={size} pos={pos} ---")
print(self.ax.dump_element(child, max_depth=10))
print()
# ----------------------------------------------------------------
# 统计
# ----------------------------------------------------------------
def _print_stats(self):
logger.info(
f"运行统计: 扫描={self._scan_count}, "
f"处理聊天={self._total_chats_processed}, "
f"点击媒体={self._total_media_clicked}"
)