1 前言

新年到来,要给很多老师、长辈、朋友、师兄师姐发拜年祝福,但是一条一条发过于效率低下,群发相同的祝福又显得没有诚意。因此,我计划写一个微信群发祝福脚本,目的是在正月初一零点的时候,能够自动地、一次性发送出所有的事先写好的新年祝福给对应的联系人。

去年,我使用的框架是 GitHub: cluic/wxauto,但是由于众所周知的律师函原因,该仓库删库了。而且,我手中也没有保留该仓库的 fork 版本,因此,我只能基于往年残留的一些代码片段重新开发。

声明

本文所提及、探讨及涉及的微信群发祝福机器人、微信自动化脚本、微信消息批量发送相关开发思路仅用于个人学习、技术研究与思路交流,所有内容均为技术探讨性质,不用于任何商业用途,不提供可直接运行的成品程序,不进行任何形式的传播、售卖与部署指导

微信客户端及相关知识产权归腾讯公司所有,本文严格遵守《微信个人帐号使用规范》《计算机信息网络国际联网安全保护管理办法》等相关法律法规与平台规则。

本文仅记录个人技术思考,不鼓励、不引导、不支持任何人将相关思路用于实际微信环境中。请勿使用任何自动化工具对微信进行批量消息发送、好友操作、群聊操作等违规行为,由此产生的账号限制、法律责任及一切后果,均由操作者自行承担,本人不承担任何相关责任。

2 设计思路

本项目基于 Windows 平台开发,核心采用 Windows 无障碍技术(UI Automation)实现界面交互逻辑。开发语言选用 Python,使用 GitHub: yinkaisheng/Python-UIAutomation-for-Windows 开源库这个轮子。

该脚本总共分为三个步骤:

  1. 提取联系人

  2. 手动填写祝福语

  3. 零点批量发送

2.1 步骤一:提取联系人

为了实现定向发送,首先需要获取当前的微信好友列表。通过 wxauto 库,我们本来应该可以轻松地驱动微信客户端获取好友信息。但是在过去的一年中,微信发生了更新,一年前的 wxauto 库已经不能正常工作。

因此,我深入修改了旧版本的 wxauto 库底层的 elements.py 文件,对其联系人的提取逻辑进行了修改。

2.1.1 利用 inspect.exe 洞察微信 UI 布局

在开展 UI 自动化之前,我们需要知道目标元素处于哪个控制层级。这里我借助了 Windows SDK 自带的 inspect.exe 工具。通过这个工具,我可以清晰地透视微信客户端界面底层的 UI 树结构。若读者的电脑没有此工具,可以自行下载。

笔者注:好像已经有更加现代化的 Accessibility Insights for Windows,不过我以前用过 inspect.exe,因此我还是直接用顺手的工具,就没有去研究去研究新工具了。

借助 inspect.exe,我成功定位到了承载所有通讯录好友的容器节点。观察发现,系统并不存在单独的“昵称、备注、标签”字段(我们只关注这三个字段)。暴露出来的每一个好友节点的 Name 字段是以空格分割的属性。

具体而言,Name 字段格式如 <nickname> <remark> <tags>。举个栗子,应该是 昵称 备注 标签1,标签2,标签3

2.1.2 深度定制解析逻辑

那现在可以开始写代码了。

首先,就是获取通讯录的这个元素:

class ContactWnd:
    def __init__(self):
		# 这里的 uia 是我前文提及的 yinkaisheng/Python-UIAutomation-for-Windows 这个库
        self.UiaAPI = uia.WindowControl(ClassName='mmui::ContactsManagerWindow', Name='通讯录管理', searchDepth=1)
        # 我对这个库不是很熟,这里写的比较丑,如果读者有更好的方式,欢迎交流~
        self.ContactBox = self.UiaAPI.GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetChildren()[1].GetFirstChildControl().GetChildren()[1]

接着,我遍历 ContactBox.GetChildren() 获取到当前屏幕可见的好友节点。

以下是我魔改的底层核心解析逻辑片段:

def GetAllFriends(self):
    contacts_list = []
    # 提前定义好我的常用标签白名单,避免误触发解析错误
    valid_tags = ['标签1', '标签2']
    
    while True:
        contact_ele_list = self.ContactBox.GetChildren()
        for ele in contact_ele_list:
            name = ele.Name
            parts = name.split(' ') # 根据空格切分提取到的名称长串
            tags = None
            remark = None
            nickname = name
            
            # 当切分出至少三个部分时,开始匹配特征
            if len(parts) >= 3:
                candidate_tags = parts[-1].split(',')
                # 如果尾部恰好全部落在我的白名单标签内,即可认定该段为标签
                if all(t in valid_tags for t in candidate_tags):
                    tags = candidate_tags
                    remark = parts[-2]
                    nickname = ' '.join(parts[:-2]) # 剩下的全视作昵称
                    
            contacts_info = {'nickname': nickname, 'remark': remark, 'tags': tags}
            if contacts_info not in contacts_list:
                contacts_list.append(contacts_info)
                
        # 记录当前页面最后一个元素的顶部坐标
        bottom = self.ContactBox.GetChildren()[-1].BoundingRectangle.top
        # 触发UI模拟向下滚动滑轮加载下一页
        self.ContactBox.WheelDown(wheelTimes=5, waitTime=0.1)
        # 探底判断:如果滑动后坐标无变化,说明到底了,结束循环
        if bottom == self.ContactBox.GetChildren()[-1].BoundingRectangle.top:
            return contacts_list

另外,wxauto 也需要简单改一改:

```python
def GetAllFriends(self, keywords=None):
    """获取所有好友列表
    
    Args:
        keywords (str, optional): 搜索关键词,只返回包含关键词的好友列表
        
    Returns:
        list: 所有好友列表
    """
    self._show()
    self.SwitchToContact()
    self.MainControl_B.ListControl(Name="通讯录").GetFirstChildControl().Click(simulateMove=False)
    contactwnd = ContactWnd()
    if keywords:
        contactwnd.Search(keywords)
    friends = contactwnd.GetAllFriends()
    contactwnd.Close()
    self.SwitchToChat()
    return friends

这里可以看到,很多和原框架不同的对象名,在后续 2.3 章节我会详细说明这些是啥。

2.1.3 主程序调用及保存

现在,我们在外部业务脚本 get_all_friends.py 就可以直接调用了:

from wxauto import WeChat
import json

def get_all_friends():
    wx = WeChat(debug=True)
    # 这时调用 GetAllFriends 拿到的将是完美包含昵称、备注和标签的字典列表
    friend_list = wx.GetAllFriends()
    with open('friends.json', 'w', encoding='utf-8') as f:
        json.dump(friend_list, f, indent=2)

if __name__ == '__main__':
    get_all_friends()

2.2 步骤二:手动填写祝福语

在获取到好友列表后,我们可以为每个人填写专属的新年祝福语。

我们可以在本地整理好需要发送的对象,并组织成一个简单的 JSON 格式文件。JSON 文件中只需包含每个人的核心信息:昵称、备注和我们决定发送给该好友的具体祝福。

以下是需要发祝福和定制好的待发送数据(例如 greetings.json)的示例格式(数据为模拟数据):

[
    {
        "nickname": "阿珍",
        "remark": "阿珍",
        "greeting": "珍姐,新年快乐!祝你新的一年万事顺利,天天开心!"
    },
    {
        "nickname": "阿强",
        "remark": "张强(大学同桌)",
        "greeting": "强哥新年好!祝新的一年工作顺利,心想事成!"
    },
    {
        "nickname": "张三",
        "remark": "张老师",
        "greeting": "张老师新年快乐!感谢您过去的关照,祝您新的一年身体健康,万事如意!"
    }
]

将准备好的对象排好序放在这个文件里,接下来的工作就可以交给脚本自动化处理了。

2.3 步骤三:零点批量发送

所有的祝福语准备完毕后,我们只需要让程序在零点的时候,读取 JSON 文件的数据并发送即可。

2.3.1 获取界面元素

既然是底层基于 UI Automation,那么如何能够精确定位到你的好友并把内容填进去呢?如前文所述,一年前的 wxauto 在新版微信上可以说是完全不能用。

首先,是获取到界面的每个元素:

class WeChat(WeChatBase):
	def __init__(
	        self, 
	        language: Literal['cn', 'cn_t', 'en'] = 'cn', 
	        debug: bool = False
	    ) -> None:
	    """微信UI自动化实例
	
	    Args:
	        language (str, optional): 微信客户端语言版本, 可选: cn简体中文  cn_t繁体中文  en英文, 默认cn, 即简体中文
	    """
	    self.UiaAPI: uia.WindowControl = uia.WindowControl(
	        ClassName='mmui::MainWindow', Name="微信", searchDepth=1)
	    print(f"正在初始化微信UI自动化实例,找到窗口: {self.UiaAPI.Name}")
	    set_debug(debug)
	    self.language = language
	    # self._checkversion()
	    self._show()
	    # 初始化三个布局
	    # 同样地,由于我不知道怎么定位,我这边写的比较死,不够鲁棒,如果读者有更好的解决方案可以与我交流。
	    self.MainControl_A = self.UiaAPI.GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetFirstChildControl()
	    self.MainControl_B = self.UiaAPI.GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetChildren()[2].GetChildren()[1].GetFirstChildControl().GetChildren()[1]
	    self.MainControl_C = self.UiaAPI.GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetChildren()[2].GetChildren()[1].GetFirstChildControl().GetChildren()[0]
	    # 三个布局,导航栏(A)、聊天列表(B)、聊天框(C)
	    # _______________
	    # |■|———|    -□×|
	    # | |———|       |
	    # |A| B |   C   |   <--- 微信窗口布局简图示意
	    # | |———|———————|
	    # |=|———|       |
	    # ———————————————
	    
	    # 初始化导航栏,以A开头 | self.NavigationBox  -->  A_xxx
	    self.A_WeChatIcon = self.MainControl_A.ButtonControl(Name='微信')
	    self.A_ContactsIcon = self.MainControl_A.ButtonControl(Name='通讯录')
	    self.A_FavoritesIcon = self.MainControl_A.ButtonControl(Name='收藏')
	    self.A_MomentsIcon = self.MainControl_A.ButtonControl(Name='朋友圈')
	    self.A_VideoIcon = self.MainControl_A.ButtonControl(Name='视频号')
	    self.A_GameCenter = self.MainControl_A.ButtonControl(Name='游戏中心')
	    self.A_MiniProgram = self.MainControl_A.ButtonControl(Name='小程序面板')
	    self.A_Phone = self.MainControl_A.ButtonControl(Name='手机')
	    self.A_Settings = self.MainControl_A.ButtonControl(Name='更多')
	    
	    # 初始化聊天列表,以B开头
	    self.B_Search = self.MainControl_B.EditControl(Name='搜索')
	
	    # 初始化聊天列表,以C开头
	    self.C_ChatEdit = None
	    self.C_ChatTitle = None
	    self.C_SendButton = None
	
	    print(f'初始化成功,获取到已登录窗口')
	
	def _update_MainControl_C(self):
		# 由于 MainControl_C 及其子元素是会变的,这边写一个函数用于更新
	    self.MainControl_C = self.UiaAPI.GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetChildren()[2].GetChildren()[1].GetFirstChildControl().GetChildren()[0]
	    # 消息输入框
	    self.C_ChatEdit = self.MainControl_C.GetFirstChildControl().GetFirstChildControl().GetChildren()[1].GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().EditControl()
	    # 发送按钮
	    self.C_SendButton = self.MainControl_C.ButtonControl(Name='发送(S)')
	    # 当前聊天的标题,用于判断有没有找到正确的用户
	    self.C_ChatTitle = self.MainControl_C.GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetFirstChildControl().GetChildren()[1].GetChildren()[1].GetFirstChildControl()

2.3.2 根据备注搜索用户

直接通过模拟键盘上的 Ctrl+F 唤起微信自带的全局搜索框,将好友名字贴进去,并严格比对弹出的搜索结果节点 ListItemControl(Name=who)。只有精准备匹配到了这个节点,才执行点击,打开好友对话框:

def ChatWith(self, who, timeout=2):
    # 唤出搜索并清空、粘贴目标好友名字
    self.UiaAPI.SendKeys('{Ctrl}f', waitTime=0.5)
    SetClipboardText(who)
    self.B_Search.SendKeys('{Ctrl}v', waitTime=0.5)
    # 这里一定要加一个 0.5 秒!因为这个搜索框是慢慢加载出来的,如果不等加载完直接查的话,可能会查不到。
    time.sleep(0.5)
    
    # 严格匹配精确的结果并点击
    target_control = self.UiaAPI.WindowControl(Name='Weixin').ListControl().ListItemControl(Name=who)
    if target_control.Exists(timeout):
        target_control.Click(simulateMove=False)
        return who
    else:
        return False

2.3.3 发送消息

发消息时,直接用 SetClipboardText(msg) 将祝福语写入剪贴板,然后焦点选中输入框 Ctrl+V,最后模拟 Ctrl+Enter 瞬间发出(我的微信的发送快捷键是这一个,如果读者是 Enter 键发送的话请自行修改):

def SendMsg(self, msg, who=None, clear=True, at=None):
    # 如果指定了目标,先验证当前聊天框是不是他,如果不是就去搜索他
    if who:
        try:
            self._update_MainControl_C()
            if who != self.C_ChatTitle.Name:
                self.ChatWith(who)
        except:
            self.ChatWith(who)

    self._show()
    # 强制获取焦点并清空输入框
    self.C_ChatEdit.Click(simulateMove=False)
    if clear:
        self.C_ChatEdit.SendKeys('{Ctrl}a', waitTime=0)

    # 通过剪贴板秒贴文本
    if msg:
        t0 = time.time()
        while True:
            if time.time() - t0 > 10:
                raise TimeoutError(f'发送消息超时')
            SetClipboardText(msg)
            self.C_ChatEdit.SendKeys('{Ctrl}v')
            # 必须验证输入框已经有值才跳出循环
            if self.C_ChatEdit.GetValuePattern().Value:
                break
                
    # 发送
    self.C_ChatEdit.SendKeys('{Ctrl}{Enter}')

2.3.4 上层发信调用

终于到最后一步了,发送祝福。

为了在出现错误时能及时强制暂停,在上层业务代码 send_wish.py 中,我引入了基于 keyboard 模块的全局热键干预(也就是“熔断”机制)。一旦发现情况不对,跑偏了,毫不犹豫地按下 F10 键立刻终止群发进程。

下面是群发脚本业务端的核心代码:

from wxauto import WeChat
import keyboard
import time
import json

wx = WeChat(debug=True)

# 熔断机制:全局监听 F10 停止发送标记
stop_running = False

def on_stop_key():
    global stop_running
    stop_running = True
    print("\n[User Request] 检测到退出键F10,正在停止发送...")

# 注册全局热键拦截
keyboard.add_hotkey('f10', on_stop_key)
print("提示:在运行过程中按下 F10 可以随时停止脚本。")

def send_wish(wish, to_who):
    """发送单条文本消息"""
    wx.SendMsg(wish, to_who)
    print(f"已向 {to_who} 发送祝福:{wish}")
    # 增加延时,模拟真人操作频率,防止微信风控
    time.sleep(0.5)

if __name__ == '__main__':
    # 载入步骤二整理好的待发送 json 数据
    with open('greetings.json', 'r', encoding='utf-8') as f:
        json_data = json.load(f)
    
    print(f"共加载 {len(json_data)} 条祝福数据。")
    
    for item in json_data:
        # 每次循环发送前都要检查一下断路器状态
        if stop_running:
            print("脚本已异常终止。")
            break
            
        send_wish(item["greeting"], item["remark"])

    if not stop_running:
        print("所有祝福发送完毕。")

3 总结

通过 Python + UIAutomation,我们用非常工程化的思路完成了春节拜年这一“繁重任务”。

从数据的自动化提取解析,接着直观的手工排版本地 JSON 文件,最后再结合深度魔改的 wxauto 发送模块,整体流程既节省了时间,大大提高了效率,也没有丢失给朋友拜年时那份专属你的定制感。

不过,技术归技术,真正的春节氛围依然需要在欢声笑语的当面寒暄中才有年味。