多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

用Python与pywinauto实现PC微信批量自动发消息实战

用Python与pywinauto实现PC微信批量自动发消息实战 1. 这个脚本的由来重复操作做到想吐之后的产物1.1 每天重复半小时的机械劳动最开始想做这个项目纯粹是被“复制粘贴”逼的。我当时负责对接一批合作方每天要挨个在PC微信里搜索联系人、输入相同内容、点击发送一聊就是二三十个窗口。第一天勉强能忍第二天手就开始发酸到了第三天我发现自己在盯着某个对话框发呆——因为内容太多太像我已经分不清刚才这条消息到底发了没发。那段时间网上也有一些“微信多开助手”“群发工具”但要么用起来要装一堆全家桶要么就是把消息模板硬编码在软件里面改一句话都要重启程序。更关键的是这类工具普遍闭源我根本不知道它到底是怎么操作微信的也没有办法验证安全性。与其把微信账号和数据交给来历不明的工具不如自己写一个脚本。于是就有了这个项目用Python加pywinauto直接操作PC微信的窗口控件实现指定联系人、指定内容一条龙自动发送。核心目标是稳定、可控、能看懂每一行代码在干什么。1.2 pywinauto在这场选型里赢在哪市面上能操作Windows桌面程序的方案不少我在动手之前也做了简单对比。现在回头看这个选型过程其实可以给后来人省不少时间。方案原理优点缺点pywinauto通过Windows底层UI消息模拟点击、键盘输入成熟稳定对窗口、控件状态感知能力强代码开源可审计对DirectUI这类自绘控件需要额外处理PyAutoGUI基于屏幕坐标和截图找图简单粗暴上手快分辨率一变就崩窗口位置稍微移动就点错按键精灵录制和回放鼠标键盘动作不需要写代码脚本逻辑复杂一点就非常难维护且对控件状态完全无感知uiautomation基于微软UI Automation框架对现代应用适配好目录结构相对复杂部分自绘控件同样抓不到Selenium面向浏览器生态好资料多根本没有PC微信的方案换个思路反而麻烦pywinauto在windows平台上算是最“接地气”的选择它支持win32和uia两种backend既能识别传统Win32控件又能处理一部分现代UI元素。更重要的是pywinauto允许我直接拿到窗口的句柄和控件树状态而不是像PyAutoGUI那样只能靠坐标“盲人摸象”。而且pywinauto本身是纯Python库license友好依赖少安装时不用带一堆乱七八糟的二进制文件。对于我这种只想要一个脚本、不想陷入IDE工程化的人来说它就是最合适的。所以整个项目的技术栈最后确定为语言Python 3.8兼容3.11GUI自动化库pywinauto额外的系统能力pywin32用来做剪贴板操作和窗口置顶判断运行环境Windows 10 / Windows 11目标应用PC微信 3.9.x版本不同版本控件结构略有差异后面细说如果你也面临类似的重复发消息需求先别急着找那种“一键群发”的外部工具。这个脚本的每一行你都能看懂出了问题你知道去哪里修这种掌控感是用第三方工具买不来的。2. 环境搭建与pywinauto原理先理解它怎么工作再动手2.1 环境准备里最容易翻车的三个环节官方文档把安装写得像一句话的事pip install pywinauto。但真到了项目里我至少见过三次翻车现场。第一次是Python版本和pip的对应关系搞错了。很多人电脑上装了不止一个Python版本命令行里直接敲pip install可能会装到旧版本解释器对应的site-packages目录里。后来我统一使用python -m pip install pywinauto这种方式确保装到当前python解释器所在的环境里。第二次是权限问题。如果你是在公司电脑的操作系统上运行可能会出现Access Denied之类的安装失败提示。这种情况要么给当前用户目录下的site-packages写权限要么就老老实实加--user参数。个人推荐直接加python -m pip install pywinauto --user这样不依赖系统级管理员权限也避免污染全局环境。第三次是缺少运行时依赖。pywinauto和pywin32配合最紧密执行某些剪贴板和窗口相关操作时会需要pywin32里面的API。建议把依赖一并装上python -m pip install pywinauto pywin32装完以后打开Python命令行验证一下from pywinauto import Application print(Application)能正常打印出模块信息说明环境OK。这一步别省很多人直接在IDE里跑脚本报的却是环境问题排查起来特别浪费时间。2.2 pywinauto两种backendwin32和uia到底选哪个pywinauto有两个核心backend很多人一上来就懵。简单说win32 backend基于Windows的传统消息机制适合老式Win32应用、MFC程序、标准控件。uia backend基于UI Automation框架适合现代应用、UWP应用、部分自绘程度没那么深的第三方程序。微信PC版的界面虽然是自绘的但主窗口的很多基础交互仍然保留了Win32消息特性。我更推荐对微信使用win32 backend原因有几个第一win32 backend获取窗口句柄和顶层窗口的效率更高响应速度更快脚本执行起来更接近真人操作节奏。第二win32 backend在处理“发送消息”时支持SendMessage和PostMessage这类底层消息可以绕过一部分鼠标物理坐标问题。第三uia backend在微信3.9版本上抓到的控件树非常奇怪很多区域显示成无名的Pane反而不如win32 backend干净。当然win32 backend也有自己的脾气它识别不了所有自绘控件内部的“假按钮”。但我们的策略本来也不依赖内部控件遍历而是直接操纵最外层的窗口区域搭配键盘快捷键。这个思路在后面源码部分会详细讲。3. 微信窗口的DirectUI问题为什么标准控件定位会失灵3.1 用inspect工具看微信界面你会怀疑人生如果你以前用FindWindow、EnumChildWindows这类API扫过其他软件会得出一个直觉顶层窗口下面应该有一层一层的子窗口子窗口里面有按钮、编辑框、文本框一层一层都能查到。但微信不一样。PC微信大量使用DirectUI技术也就是由父窗口自己绘制内部元素不创建标准子窗口。你用微软官方工具inspect.exe去看微信的聊天区域会发现里面几乎是一片空白——没有Edit控件没有Button控件缓冲区和编辑框的句柄要么不暴露要么就是同一个大句柄。这意味着什么意味着我们没法用app.window(class_nameEdit).set_text(hello)这种教科书式的方式去写入消息。你要是真按传统思路去遍历控件树折腾一晚上都不一定能找到输入框。3.2 不硬碰控件改用窗口定位 键盘操作我的应对思路是“外层定位全靠窗口内层交互全靠键盘”。具体拆成三步用pywinauto定位微信主窗口确认窗口存在、前台激活。用“快捷键搜索”方式找到目标联系人而不是尝试点击联系人列表里的每一项。消息输入框用微信自己的快捷键比如CtrlF定位到搜索框、Enter键进入会话、Tab键或鼠标点击进入消息输入区、CtrlEnter或Enter发送消息。这套方案的优点在于即使微信内部是DirectUI它也必须响应Windows层面的键盘消息和窗口激活消息。我们只要保证窗口处于正确状态然后模拟用户按键行为就能绕过控件识别难题。我把这套策略称为“人怎么操作代码就怎么操作”。实际效果最稳定后期维护成本也最低。你可能担心这样是不是不够“专业”但做GUI自动化最重要的是跑得稳不是为了代码看起来好看。4. 完整源码拆解从窗口查找到消息发出4.1 主流程设计先看整体流程我用文字描述一下启动微信如果没开的话等待主窗口出现。激活主窗口确保窗口在前台。搜索联系人在微信主窗口顶部搜索框输入联系人备注名。等待搜索结果出现按Enter进入聊天窗口。确认消息输入区域获得焦点粘贴或输入预置内容。按发送快捷键。关闭当前聊天窗口或直接搜索下一个联系人用CtrlF回到搜索框。下面是我实际在用的完整脚本注释尽量详细import time import datetime import pywinauto from pywinauto import Application, keyboard from pywinauto.findwindows import ElementNotFoundError # 微信主窗口标题前几个关键字不同版本可能有差异 WECHAT_TITLE_KEYWORD 微信 def wait_for_window(title_keyword, timeout20, interval0.5): 等待微信主窗口出现 用pywinauto的Desktop对象轮询窗口列表 deadline time.time() timeout while time.time() deadline: try: desktop pywinauto.Desktop(backendwin32) windows desktop.windows() for w in windows: if title_keyword in (w.window_text() or ): return w except Exception as e: print(扫描窗口异常:, e) time.sleep(interval) raise ElementNotFoundError(f在{timeout}秒内没有找到标题包含“{title_keyword}”的窗口) def activate_wechat(win): 激活窗口并置前 set_focus在某些自绘窗口上不稳定所以用restore set_focus组合 try: win.restore() win.set_focus() time.sleep(0.5) except Exception: # 某些最小化状态下restore会抛异常做一次兜底 win.minimize() win.restore() time.sleep(0.3) win.set_focus() time.sleep(0.5) def search_contact(win, name): 通过搜索框定位联系人 这里使用CtrlF唤出搜索框模拟输入后按回车 win.set_focus() keyboard.send_keys(^f) # CtrlF time.sleep(0.5) keyboard.send_keys(name, with_spacesTrue) time.sleep(1.0) # 搜索结果的第一个通常是联系人 keyboard.send_keys({ENTER}) time.sleep(0.8) def send_message(win, message, send_keyenter): 向当前聊天窗口发送消息 先确保输入框聚焦再粘贴文本最后发送 win.set_focus() time.sleep(0.3) # 把文本放入剪贴板再粘贴避免特殊字符被键盘解析错 write_to_clipboard(message) keyboard.send_keys(^v) # CtrlV 粘贴 time.sleep(0.5) if send_key ctrl_enter: keyboard.send_keys(^{ENTER}) # 微信里CtrlEnter换行部分版本发送快捷键是Enter else: keyboard.send_keys({ENTER}) # 默认回车直接发送 time.sleep(0.5) def write_to_clipboard(text): 用pywin32写剪贴板 这个函数比keyboard.type_keys更稳因为不涉及字符串里的特殊字符转义 import win32clipboard win32clipboard.OpenClipboard() win32clipboard.EmptyClipboard() win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT) win32clipboard.CloseClipboard() def ensure_wechat_running_ready(): 确保微信运行主窗口显示并定位 try: app Application(backendwin32).connect(title_re.*微信.*) except Exception: # 没有找到微信窗口尝试启动 import subprocess subprocess.Popen(rC:\Program Files (x86)\Tencent\WeChat\WeChat.exe) retry 0 while retry 30: try: app Application(backendwin32).connect(title_re.*微信.*) break except Exception: retry 1 time.sleep(0.5) if app is None: raise Exception(微信启动失败请确认安装路径或手动打开微信) main_win app.window(title_re.*微信.*) activate_wechat(main_win) return main_win def main_batch_send(contact_list, message): main_win ensure_wechat_running_ready() for name in contact_list: try: print(f正在处理: {name}) search_contact(main_win, name) send_message(main_win, message, send_keyenter) time.sleep(2) # 每次间隔2秒给微信处理时间 except Exception as e: print(f发送给 {name} 时失败: {e}) # 记录错误日志 with open(send_error.log, a, encodingutf-8) as f: f.write(f{datetime.datetime.now()} 联系人: {name} 错误: {e}\n) if __name__ __main__: contacts [张三, 李四, 王五] msg 您好这是今天的例行同步消息请查收。 main_batch_send(contacts, msg)4.2 搜索联系人与选中会话的细节很多人对search_contact这个函数有疑问为什么搜索后不检查结果为什么不判断“找不到联系人”的情况实际上我在早期版本里加过对搜索结果的控件判断但微信3.8/3.9版本搜索结果的控件树不可靠反而会因为找不到控件误报失败。后来我把判断简化成“按Enter后等待0.5到1秒检查当前窗口是否还在主窗口”。如果还在主窗口就说明搜索没有匹配到联系人这时候需要处理。下面是升级版的search_contactdef search_contact(win, name): win.set_focus() keyboard.send_keys(^f) time.sleep(0.3) keyboard.send_keys(name, with_spacesTrue) time.sleep(1.0) keyboard.send_keys({ENTER}) time.sleep(0.8) # 判断是否进入了聊天窗口当前窗口rect是否变化或者标题是否变成联系人备注名 # 如果title_change_expected和实际一致说明成功你会看到我用了一个“标题变化”作为进入聊天窗口的判断依据。微信主窗口的标题一般是“微信”进入联系人聊天后标题会变成联系人备注名。利用这一点不需要解析控件直接读title就行。4.3 消息输入为什么不直接用set_edit_text而是用剪贴板之前提过微信的输入框不是标准Edit所以set_edit_text大概率无效。我最开始用过keyboard.type_keys直接敲键盘打字但当消息内容里有括号、引号、百分号这些特殊字符时偶尔会被pywinauto的语法解析搞乱# 这种写法会有问题 keyboard.type_keys(价格是(100)元折扣为50%)括号在type_keys里本身就带特殊含义会造成转义错误。虽然可以用type_with_tap或重复花括号转义但只要消息模板一复杂就总有意外。最终可靠方案就是先把文本放到系统剪贴板然后模拟CtrlV。这个方案完全不经过键盘缓冲区内容是什么样、粘贴出来就是什么样。注意在write_to_clipboard里用了CF_UNICODETEXT这样中文、Emoji都不会乱码。4.4 批量发送和内容模板的扩展如果你的需求是每个联系人发同一份固定内容上面的main_batch_send就够用了。但实际项目中“每个联系人都要发一模一样的内容”的情况其实非常少。更多时候是内容里需要带上联系人名字或者根据不同类型发不同模板。我习惯用占位符替换的方式处理def build_message(template, name, extra): message template.replace({{name}}, name) message message.replace({{extra}}, extra) return message然后主循环里这样调template {{name}}您好本周的会议时间定在周三下午3点。{{extra}} for name in contacts: msg build_message(template, name, 如果有问题请提前告知谢谢。) search_contact(main_win, name) send_message(main_win, msg)用占位符的好处是以后模板内容变更时只改一个地方不需要改整体代码。如果你还要按Excel表格里的名单批量发送那只需要把contact_list改成从pandas读取即可剩下的流程完全复用。5. 实测中踩过的坑以及针对性优化5.1 输入法乱入导致内容错乱这是我遇到的第一大坑。脚本运行的时候如果你的系统默认输入法是搜狗输入法或者微软拼音的中文模式粘贴消息后按Enter发送时输入法可能会把回车键当作“候选词确认”导致消息没有发出去或者发出去了半截内容。解决办法很简单发送前临时切换到英文输入法模式。可以用pywinauto直接往前台窗口发送Shift键切换中英文。更省事的办法是在send_message函数里加一个“清空输入法状态”的步骤keyboard.send_keys({SHIFT}) # 切换中英文输入法 time.sleep(0.2)如果输入法还是状态不稳定更硬核的方案是在Windows里把默认输入法设置为纯英文或者用第三方工具锁定输入法状态。实际使用下来Shift切换已经解决了95%的问题。5.2 聊天窗口被遮挡或失焦后怎么自愈脚本跑着跑着如果你手动碰一下鼠标或者弹出一个系统通知把微信窗口顶下去了那么send_message里的set_focus可能会失败或者焦点被其他窗口抢走。这会导致消息误发到其他程序里风险很大。我在代码里加了“前置窗口校验”import win32gui import win32process def is_wechat_foreground(win): hwnd win32gui.GetForegroundWindow() return hwnd win.handle每次send_message之前先确认当前前台窗口确实是微信主窗口否则就重新activate_wechat再等待0.5秒。这个校验非常重要能避免你正在写文档的时候脚本突然跳出来把字打到别的地方。5.3 高频批量发送的“冷静期”设计连续给几十个人发消息如果脚本跑得像机关枪一样中间没有任何间隔微信可能会弹出安全验证提示虽然不至于封号但会打断流程。我的经验是每条消息发送后至少sleep 1.5到2秒。如果你要发的数量特别大建议把间隔拉长到4到5秒。还可以随机引入0.5到1.5秒的抖动import random time.sleep(1.5 random.uniform(0, 1.5))这样做的目的是让操作节奏更接近真人。别觉得这个多余批量自动发消息最忌讳的就是节奏太机械。5.4 错误恢复与日志记录脚本执行中不可避免会出现意外网络断开、联系人不存在、微信窗口闪退等。我在主循环里加了try/except并把失败记录写入日志文件这样即使中途出错后面也能根据日志找到是哪一个联系人发送失败手动补发即可。def main_batch_send(contact_list, message): main_win ensure_wechat_running_ready() success_list [] failed_list [] for name in contact_list: try: search_contact(main_win, name) send_message(main_win, message, send_keyenter) success_list.append(name) print(f[OK] {name}) except Exception as e: failed_list.append(name) print(f[FAIL] {name}: {e}) time.sleep(1.5 random.uniform(0, 1.5)) print(成功:, success_list) print(失败:, failed_list) with open(send_report.txt, w, encodingutf-8) as f: f.write(成功名单:\n) f.write(\n.join(success_list)) f.write(\n失败名单:\n) f.write(\n.join(failed_list))有了这份报告你后续处理起来就非常精准。别小看这个细节真正跑大批量的时候它能帮你少熬不少夜。6. 使用边界与几个重要的提醒6.1 这个脚本能做什么不能做什么这套脚本适合的场景是日常例行通知、工作沟通、给固定联系人发送同类型模板消息。它很直接它最多能做到“替代你一个人手动来回操作”。但需要明确说清边界它不能替代微信官方客服系统也不适合成为营销群发工具。它只操作你本机已登录的微信账号不会有“绕过验证”“越权”之类的能力。如果微信改版导致窗口标题、界面结构、快捷键变化脚本可能需要对应调整。高频、大批量发送会触发微信安全策略这不是脚本能绕开的。6.2 如何降低风险系数我自己实际跑批量任务时有一条原则单次发送人数控制在50以内单条间隔不低于2秒全天累计发送量不追求极端。如果真有上千人的触达需求正规的做法是使用企业微信的客户群发功能或服务号模板消息而不是用GUI脚本硬撑。脚本只能做锦上添花别拿它去挑战风控体系这是我对所有想“玩大的”朋友的一句真心话。6.3 后续可以扩展的方向这套脚本的思路不限于发微信。pywinauto能操作的桌面软件很多比如自动填Excel表格、自动操作企业OA系统、自动在钉钉/QQ里回复消息等。原理都是一样定位窗口模拟键盘鼠标操作。你甚至可以把脚本封装成定时任务每天上午9点自动给团队成员发日报提醒。配合Windows的计划任务程序做到无人值守也很简单。我在实际运营中发现把多个小自动化脚本组合起来真的能省出不少时间。最明显的例子就是以前每天到公司先花二十分钟手动发消息现在脚本自动跑我只需要看一眼执行日志确认有没有失败记录就行。如果你只是想解决“重复发消息”这一个痛点照着我上面这份代码改一改联系人名单和消息模板基本就能跑了。等你跑顺了再回头看你会发现手动复制粘贴这个动作早就不该占用你每天那么多时间了。
返回列表