微信自动化进阶:用 uiautomation
和 uiautomation2
打造你的智能小助手 (自动收发消息)
在上一篇文章中,我们探讨了如何使用 Python 的 uiautomation
(PC端) 和 uiautomation2
(Android端) 库来实现微信消息的自动发送。这次,我们将更进一步,让我们的Python脚本不仅能发送消息,还能监控新消息、提取内容,并根据预设逻辑自动回复,就像一个简单的客服机器人或个人助理。
本文目标
- 回顾消息自动发送的基础。
- 学习如何使用
uiautomation
和uiautomation2
检测微信新消息。 - 掌握从聊天界面提取最新消息内容和发送者(初步)。
- 实现基于关键词的简单自动回复逻辑。
- 构建一个持续运行的微信自动收发消息脚本。
为何需要自动收发消息?
- 简单客服: 自动回复常见问题 (FAQ),引导用户。
- 信息筛选与通知: 监控特定群聊或联系人的消息,当出现关键词时,自动回复或转发给指定的人。
- 离线自动应答: 当你不在时,礼貌性地回复收到的消息。
- 趣味互动: 创建一个简单的聊天机器人与朋友互动。
核心思路
无论是PC端还是Android端,自动收发消息的基本流程是相似的:
- 启动并登录微信。 (手动或脚本控制)
- 循环监控:
a. 检测未读消息: 扫描聊天列表,寻找有未读标记的会话。
b. 选择会话: 如果有未读,选择第一个未读会话进入。
c. 提取消息: 获取当前聊天窗口的最新一条(或几条)消息。
d. 判断来源: 确保是对方发来的消息,而不是自己刚发出的。 (这是关键且有挑战的一点)
e. 处理消息: 根据消息内容,决定是否回复以及回复什么。
f. 发送回复: 调用之前实现的发送消息逻辑。
g. 标记已读/返回: 通常进入会话并回复后,消息会自动标记为已读。然后返回聊天列表继续监控。 - 适当延时: 在每次循环之间加入延时,避免CPU占用过高和操作过快。
准备工作
与上一篇文章相同,请确保:
- Python 环境 已配置。
- 微信客户端 已安装 (PC 或 Android)。
- 库已安装:
pip install uiautomation pyperclip
(Windows)pip install uiautomation2 weditor
(Android)
- 辅助工具:
Inspect.exe
(Windows),weditor
(Android) 用于查看UI元素属性。 - Android环境: ADB 可用,USB调试已开启。
一、PC端微信自动收发 (uiautomation
)
PC端微信的挑战在于其UI元素属性可能不那么稳定,且未读消息的标记方式、消息内容的提取需要仔细观察。
增强逻辑:
- 检测未读消息:
- PC微信的聊天列表项,未读消息通常会有红点数字或仅红点提示。这些提示本身也是UI元素,可以尝试定位它们。
- 或者,未读会话的列表项
Name
属性可能会包含未读消息数,例如 “文件传输助手[1]”。
- 提取最新消息:
- 进入聊天后,消息区域是一个列表。最新消息通常在最底部。
- 消息内容可能是
TextControl
或DataItemControl
。 - 难点:区分消息是对方发的还是自己发的。 可以通过消息元素在界面上的左右位置、是否有头像、或消息元素父控件的某些特征来尝试区分。对于简单实现,可以假设进入未读会话后,最新的那条就是对方发的。
- 防止重复回复/回复自己:
- 记录下自己最后发送的消息,避免对自己的消息进行响应。
- 或者,在回复后,等待一小段时间再检查新消息,期望对方的消息在此之后到达。
示例代码 (PC 端 - 概念性增强):
import uiautomation as auto
import time
import pyperclip
# --- 复用上一篇文章的发送函数 ---
def send_wechat_message_pc(wechat_window, message: str):
"""在当前打开的聊天窗口发送消息 (简化版)"""
input_box = wechat_window.EditControl(searchDepth=20) # 假设已在聊天窗口,输入框是最后一个Edit
if not input_box.Exists(1,0.1):
print("错误:PC - 未找到当前聊天窗口的输入框。")
return False
pyperclip.copy(message)
input_box.SendKeys('{Ctrl}v', waitTime=0.1)
time.sleep(0.2)
send_button = wechat_window.ButtonControl(Name='发送')
if send_button.Exists(1,0.1):
send_button.Click(simulateMove=False)
else: # 尝试回车发送
input_box.SendKeys('{Enter}', waitTime=0.1)
print(f"PC - 已发送消息: {message}")
return True
# --- 新增的回复逻辑 ---
def get_reply_message(received_msg: str) -> str | None:
"""根据收到的消息生成回复 (简单关键词匹配)"""
received_msg_lower = received_msg.lower()
if "你好" in received_msg_lower or "hello" in received_msg_lower:
return "你好!很高兴为您服务。"
elif "价格" in received_msg_lower or "多少钱" in received_msg_lower:
return "我们的产品价格请参考官网 XXX.com/pricing。"
elif "帮助" in received_msg_lower or "help" in received_msg_lower:
return "请问有什么可以帮助您的吗?您可以描述具体问题。"
elif "再见" in received_msg_lower or "拜拜" in received_msg_lower:
return "再见!"
# 默认不回复,返回None
return None
# --- 主监控循环 ---
def main_pc_auto_reply():
print("PC微信自动回复程序启动...")
wechat_window = auto.WindowControl(ClassName='WeChatMainWndForPC')
if not wechat_window.Exists(5, 1):
print("错误:未找到微信主窗口。请确保微信已登录并打开。")
return
wechat_window.SetTopmost(True) # 方便调试
# 获取左侧聊天列表 Pane
# 这个控件的Name属性可能为空,或者叫“会话”,需要用Inspect.exe确认
# 假设聊天列表是一个 ListControl
# chat_list_pane = wechat_window.ListControl(searchDepth=5) # 根据实际情况调整
# 如果聊天列表没有明确的ListControl,可以尝试获取包含聊天项的Pane
# 微信更新后,聊天列表可能在名为 "会话" 的 Pane 下
session_pane = wechat_window.PaneControl(Name='会话') # 较新版微信
if not session_pane.Exists(2,0.5):
# 尝试旧版可能的父控件
# chat_list_parent = wechat_window.Control(...) # 需要用Inspect找到聊天列表的直接父容器
print("PC - 未找到聊天列表容器。脚本可能需要更新。")
# 尝试点击 "聊天" 按钮确保在聊天列表界面
chat_button = wechat_window.ButtonControl(Name="聊天")
if chat_button.Exists(1,0.1):
chat_button.Click(simulateMove=False)
time.sleep(0.5)
# 再次尝试找会话面板
session_pane = wechat_window.PaneControl(Name='会话')
if not session_pane.Exists(2,0.5):
print("PC - 仍然找不到聊天列表容器。退出。")
wechat_window.SetTopmost(False)
return
processed_chats_today = set() # 简单防止对同一会话在短时间内重复处理(更复杂需要基于时间戳)
last_replied_message_content = {} # key: chat_name, value: last_replied_to_message
while True:
try:
# 0. 确保焦点在微信,并返回到聊天列表的顶层(如果适用)
# wechat_window.Click() # 点击一下窗口,确保激活
# auto.SendKeys('{Esc}') # 有时按Esc可以返回列表,但要小心副作用
# time.sleep(0.5)
# 1. 寻找未读消息
# 聊天列表项通常是 ListItemControl
# 未读标记可能是子控件 TextControl(Name包含数字) 或 ImageControl (红点)
# 或者ListItem的Name属性可能包含未读数,如 "张三[2]"
# 优先确保在聊天列表界面
chat_button = wechat_window.ButtonControl(Name="聊天")
if chat_button.Exists(1,0.1) and not chat_button.IsSelected():
chat_button.Click(simulateMove=False)
time.sleep(0.5) # 等待列表加载
unread_chat_item = None
# 遍历会话列表中的所有ListItem
# 这里的定位非常依赖微信版本和UI结构
chat_items = session_pane.GetChildren() # 获取Pane下的所有子项
potential_unread_items = []
for item in chat_items:
if item.ControlTypeName == 'ListItemControl':
# 检查是否有红点数字提示 (通常是一个TextControl作为子元素)
# 微信的红点元素比较难直接抓取,因为它可能没有稳定的Name或AutomationId
# 我们可以检查ListItem的Name属性是否包含 '[数字]' 这样的模式
# 或者检查是否有特定的子元素指示未读
# 例如,未读消息的ListItem下可能会有一个特定的子元素,其Name是数字
# red_dot_texts = item.TextControl(searchDepth=2) # 查找子Text控件
# for rd_text in red_dot_texts:
# if rd_text.Name and rd_text.Name.isdigit(): # 如果名字是纯数字
# unread_chat_item = item
# break
# 另一种方式:检查 Name 属性,如 "联系人名称[1]"
if "[" in item.Name and item.Name.endswith("]"):
# 进一步确认是未读标记,而不是群成员数量等
content_in_bracket = item.Name[item.Name.rfind("[")+1:-1]
if content_in_bracket.isdigit(): # 确保是数字
potential_unread_items.append(item)
# print(f"PC - 发现潜在未读: {item.Name}")
# if unread_chat_item:
# break
# 从潜在未读中选择一个处理 (例如第一个)
if potential_unread_items:
unread_chat_item = potential_unread_items[0] # 取第一个
chat_name_full = unread_chat_item.Name # 如 "张三[2]"
# 提取纯粹的聊天名称
chat_name = chat_name_full.split('[')[0] if '[' in chat_name_full else chat_name_full
print(f"PC - 检测到未读消息来自: {chat_name_full}")
# 避免短时间内重复处理同一个已回复的对话的第一条消息
if chat_name in processed_chats_today and chat_name not in last_replied_message_content:
print(f"PC - {chat_name} 今日已处理过,且无新消息记录,跳过。")
time.sleep(5) # 避免快速循环同样内容
continue
# 2. 点击进入聊天
unread_chat_item.Click(simulateMove=False)
time.sleep(1) # 等待聊天内容加载
# 3. 提取最新消息
# 聊天记录通常在 ScrollBar控件内部的List或Pane里
# 最新消息在最下方。消息项可能是GroupControl, ListItemControl或CustomControl
# 这是一个非常棘手的部分,因为消息结构复杂
# 假设聊天记录在一个ListControl中,每个消息是一个ListItemControl
message_list_control = wechat_window.ListControl(searchDepth=15) # 需要用Inspect工具确认
if not message_list_control.Exists(2,0.5):
print("PC - 未找到消息列表控件。")
auto.SendKeys('{Esc}') # 尝试返回聊天列表
time.sleep(1)
continue
messages = message_list_control.GetChildren()
if not messages:
print(f"PC - {chat_name} 聊天窗口没有消息内容。")
auto.SendKeys('{Esc}') # 尝试返回聊天列表
time.sleep(1)
continue
# 最新消息通常是最后一个,但要判断是不是对方发的
# 简化:取倒数第一个文本内容不为空的ListItem的Name
last_message_text = None
for i in range(len(messages) - 1, -1, -1):
msg_item = messages[i]
# 尝试获取消息文本,消息文本可能在Name属性,也可能在子TextControl中
current_msg_text = msg_item.Name
# 很多消息控件的Name属性可能为空,文本在更深层的子控件
# 可以尝试找 msg_item 下的 TextControl
# text_children = msg_item.findAll(controlType=auto.ControlType.TextControl, searchDepth=1)
# if text_children:
# current_msg_text = "".join([child.Name for child in text_children])
if current_msg_text and current_msg_text.strip():
# 还需要判断这条消息是不是自己发的
# 这是一个难点。PC版微信UI上区分不明显。
# 简单策略:如果这条消息和我们上次对这个会话回复的消息不同,就认为是新消息
if chat_name in last_replied_message_content and \
last_replied_message_content[chat_name] == current_msg_text:
print(f"PC - {chat_name} 的最新消息 '{current_msg_text}' 与上次回复的相同,跳过。")
last_message_text = None # 表示没有新消息要处理
break
last_message_text = current_msg_text
break
if last_message_text:
print(f"PC - 收到来自 {chat_name} 的消息: {last_message_text}")
# 4. 获取回复
reply = get_reply_message(last_message_text)
if reply:
# 5. 发送回复
if send_wechat_message_pc(wechat_window, reply):
last_replied_message_content[chat_name] = last_message_text # 记录回复过的消息
processed_chats_today.add(chat_name) # 标记今天处理过
else:
print(f"PC - 向 {chat_name} 发送回复失败。")
else:
print(f"PC - 对于消息 '{last_message_text}' 无预设回复。")
# 即使不回复,也标记为已处理,避免下次还当新消息
last_replied_message_content[chat_name] = last_message_text
processed_chats_today.add(chat_name)
else:
print(f"PC - 未能从 {chat_name} 提取到有效的新消息。")
# 认为这个未读提示是旧的或者无法解析
if chat_name not in processed_chats_today: # 如果之前没处理过,现在也标记下
processed_chats_today.add(chat_name)
# 6. 返回聊天列表 (或者等待下一个循环自动处理)
# 通常点击其他聊天或顶部的“聊天”按钮会返回
# 也可以尝试发送Esc键
# auto.SendKeys('{Esc}')
# time.sleep(0.5)
# 点击聊天按钮,确保返回列表界面,以便扫描其他未读
chat_button_nav = wechat_window.ButtonControl(Name="聊天")
if chat_button_nav.Exists(1,0.1):
chat_button_nav.Click(simulateMove=False)
time.sleep(1)
else:
print(f"PC - 当前无未读消息... ({time.strftime('%Y-%m-%d %H:%M:%S')})")
# 清理一天前的processed_chats_today, 避免集合过大 (简单实现)
if len(processed_chats_today) > 100: # 假设一天不会处理超过100个不同对话
processed_chats_today.clear()
last_replied_message_content.clear()
except Exception as e:
print(f"PC - 发生错误: {e}")
auto.PrintControl(wechat_window, depth=3) # 打印当前窗口结构,帮助调试
# 尝试恢复
try:
if wechat_window.Exists(1,0.1) and not wechat_window.IsTopmost(): # 如果窗口还在但不是最前
wechat_window.SetTopmost(True)
# 可以尝试按 ESC 返回到稳定状态
auto.SendKeys('{Esc}')
time.sleep(1)
auto.SendKeys('{Esc}') # 多按几次
time.sleep(1)
# 点击聊天按钮尝试恢复
chat_btn_recover = wechat_window.ButtonControl(Name="聊天")
if chat_btn_recover.Exists(1,0.1):
chat_btn_recover.Click(simulateMove=False)
except Exception as e_recover:
print(f"PC - 恢复操作时出错: {e_recover}")
time.sleep(10) # 每10秒检查一次
wechat_window.SetTopmost(False)
# main_pc_auto_reply() # 取消注释以运行PC版
PC端 uiautomation
实现要点与难点:
- UI元素稳定性: 这是最大的挑战。微信PC版更新可能导致控件的
Name
,ClassName
,AutomationId
改变。需要频繁使用Inspect.exe
和automation.py -t 3
来调试和更新定位器。 - 未读标记: 不同版本的微信,未读消息的红点或数字提示的UI元素可能完全不同,甚至难以直接定位。有时需要通过父级ListItem的
Name
属性中是否包含[数字]
来间接判断。 - 消息归属: 判断最新消息是对方发的还是自己发的,在PC版UI上直接获取此信息比较困难。上面的示例代码做了一些简化处理。更可靠的方法可能需要分析消息控件的布局属性(如X坐标判断左右)或者其子元素中是否有头像控件等,但这会使代码更复杂且更易受UI变更影响。
- 滚动问题: 如果聊天列表或消息列表过长,可能需要模拟滚动才能找到所有元素。
- 焦点管理: 确保操作时微信窗口是激活状态,且焦点在正确的控件上。
二、Android端微信自动收发 (uiautomation2
)
Android端借助 weditor
查看元素属性会方便很多,但同样面临UI变化的问题。
增强逻辑:
- 检测未读消息:
- 微信主界面的聊天列表,未读消息通常有红色圆圈和数字。这些是
TextView
控件,可以通过resourceId
(可能不稳定) 或其text
(数字) 来定位。
- 微信主界面的聊天列表,未读消息通常有红色圆圈和数字。这些是
- 提取最新消息:
- 进入聊天界面后,消息列表通常是
RecyclerView
或ListView
。 - 最新消息在列表底部。每个消息项是一个布局,包含头像、发送者昵称(群聊中)、消息气泡等。
- 消息内容通常在
TextView
(resourceId 如com.tencent.mm:id/b47
或类似,会变!)。 - 判断消息归属: Android版微信,对方消息和自己消息的布局通常不同(例如,对方在左,自己在右)。可以通过消息项根布局的子元素
resourceId
来区分。例如,自己发的消息可能有一个特定的已发送状态图标,而对方消息没有。或者消息气泡的背景resourceId
不同。
- 进入聊天界面后,消息列表通常是
- 防止重复回复: 与PC端类似,记录已回复的消息或对话。
示例代码 (Android 端 - 概念性增强):
import uiautomator2 as u2
import time
# --- 复用上一篇文章的发送函数 ---
def send_wechat_message_android(d, message: str):
"""在当前Android微信聊天界面发送消息 (简化版)"""
# 假设已在聊天界面
# 输入框ID,用weditor查找,例如 com.tencent.mm:id/auj (会变!)
input_field_id = "com.tencent.mm:id/auj"
if not d(resourceId=input_field_id).exists(timeout=3):
print("错误:Android - 未找到聊天输入框。")
return False
d(resourceId=input_field_id).set_text(message)
time.sleep(0.5)
# 发送按钮ID或Text,用weditor查找,例如 com.tencent.mm:id/ay5 或 text="发送" (会变!)
send_button_selector = {"text": "发送", "className": "android.widget.Button"}
if not d(**send_button_selector).exists(timeout=2):
# 有时发送按钮是 ImageView
send_button_selector = {"resourceId": "com.tencent.mm:id/ay5"} # 举例
if not d(**send_button_selector).exists(timeout=2):
print("错误:Android - 未找到发送按钮。")
return False
d(**send_button_selector).click()
print(f"Android - 已发送消息: {message}")
return True
# --- 回复逻辑 (与PC版共用) ---
def get_reply_message(received_msg: str) -> str | None:
"""根据收到的消息生成回复 (简单关键词匹配)"""
received_msg_lower = received_msg.lower()
if "你好" in received_msg_lower or "hello" in received_msg_lower:
return "你好呀!我是机器人小助手。"
elif "你是谁" in received_msg_lower:
return "我是一个基于uiautomation2的Python程序。"
elif "图片" in received_msg_lower:
return "抱歉,我暂时还不能处理图片哦。"
elif "再见" in received_msg_lower or "拜拜" in received_msg_lower:
return "再见啦!"
return None # 默认不回复
# --- 主监控循环 ---
def main_android_auto_reply(device_serial=None):
print("Android微信自动回复程序启动...")
try:
d = u2.connect(device_serial) if device_serial else u2.connect()
d.app_start("com.tencent.mm", stop=True) # 确保从干净状态启动或切换到前台
print(f"已连接到设备: {d.device_info['serial']},等待微信加载...")
time.sleep(7) # 等待微信完全启动并加载主界面
# 用weditor获取这些ID
# 微信主界面下方 "微信" tab 的ID (确保在聊天列表页)
wechat_tab_id = "com.tencent.mm:id/dub" # 示例ID,会变! (text="微信")
# 聊天列表项中未读消息红点数字的ID
unread_badge_id = "com.tencent.mm:id/o_f" # 示例ID,会变! (通常是个TextView)
# 聊天列表项联系人名称的ID
contact_name_id = "com.tencent.mm:id/b5o" # 示例ID,会变!
# 进入聊天后,消息列表的ID (通常是RecyclerView或ListView)
# message_list_id = "com.tencent.mm:id/b26" # 示例ID (RecyclerView)
# 消息内容TextView的ID (区分对方和自己可能需要更复杂的选择器)
message_content_id = "com.tencent.mm:id/b47" # 示例ID,非常容易变!
# 返回按钮的description或ID
back_button_desc = "返回"
processed_message_texts = {} # key: contact_name, value: set of processed message texts
while True:
try:
# 0. 确保在微信主界面的聊天列表
# 如果不在,尝试点击 "微信" tab
if not d(resourceId=wechat_tab_id, text="微信").exists(timeout=2) and \
not d(description="当前所在页面,与对应底部标签为微信").exists(timeout=1): # 有些版本的微信tab没有固定id,但有这个desc
print("Android - 不在主聊天列表,尝试返回或重启微信。")
d.press("back") # 尝试返回
time.sleep(1)
d.press("back") # 多按几次
time.sleep(1)
if not d(resourceId=wechat_tab_id, text="微信").exists(timeout=2):
d.app_start("com.tencent.mm", stop=True)
time.sleep(7)
# 点击"微信"确保在聊天列表 (如果它不是selected状态)
# 有时微信tab是用description定位, e.g. d(description="微信").click()
# 假设微信tab的父控件可选
# wechat_tab_element = d(resourceId=wechat_tab_id, text="微信")
# if wechat_tab_element.exists and not wechat_tab_element.parent().selected: # 检查父控件是否被选中
# wechat_tab_element.click()
# time.sleep(1)
# 1. 寻找未读消息
# 查找带有未读标记的聊天项
# 未读标记是一个TextView, 其父容器是聊天列表项
unread_chat_item_parent = None
# 查找所有未读红点
unread_indicators = d(resourceId=unread_badge_id)
if not unread_indicators.exists:
print(f"Android - 当前无未读消息... ({time.strftime('%Y-%m-%d %H:%M:%S')})")
time.sleep(15)
d.swipe_ext("up", scale=0.7, duration=0.5) # 下拉刷新一下列表
time.sleep(2)
continue
# 处理第一个可见的未读消息
# uiautomator2的元素选择器返回的是一个集合,即使只有一个元素
# 我们需要迭代或者取第一个
found_specific_unread = False
for indicator_ui_object in unread_indicators: # 遍历所有匹配的未读标记
# 通过未读标记向上找到整个聊天项 (通常是父级的父级等,结构依赖weditor观察)
# 假设未读标记的直接父级或爷爷级是可点击的聊天项
# chat_item_candidate = indicator_ui_object.parent() # .parent().parent() 等
# 这个定位非常依赖实际的UI层次结构
# 一个更通用的方法是,如果未读标记和联系人名在同一个父容器下
# 那么