Python+uiautomation实现QQ群聊天记录自动抓取
接手这个自动抓取QQ群聊天记录的需求时我脑子里第一反应是千万别去碰解析本地数据库这种路。QQ的存储结构和加密策略一直在变一升级就崩维护成本极高。后来我改用Python uiautomation走UI自动化路线直接从窗口控件里读文本反而稳定得多。这篇文章就把我这次实践从思路到代码完整拆给你包括踩过的几个大坑。1. 项目背景与技术路线选择1.1 为什么要做这件事起因其实很朴素有同学想把自己所在几个技术群的聊天记录按月归档方便日后检索某条讨论、找回某句话。这事如果在手机上手动翻几千条消息翻到怀疑人生。而且群聊记录是重要的知识沉淀不仅仅是个闲聊工具很多有价值的讨论和答疑都淹没在不断刷屏的消息里。于是想找一个能定期、批量、自动获取QQ群聊天记录的办法。获取到什么粒度我的目标很简单群名、消息发送者、发送时间、消息内容按时间排序存成结构化文件。能把文本消息抓到就行图片、表情另算。1.2 三条路线摆在一起比一比做之前我认真对比过三条常见路线方案优点缺点维护成本官方开放API数据规范稳定应用资质要求高个人拿不到消息接口低解析本地数据库数据完整离线可用加密、版本升级、关键字段不透明高UI自动化抓取绕开API权限纯前端操作依赖界面结构运行需要窗口环境中解析本地数据库这条水太深。QQ本地消息库经过多层处理字段名没有公开文档每次客户端升级都可能换结构还有加密逻辑在里面。如果你只想给自己做个小工具在这上面投入时间实在不划算。1.3 为什么会选uiautomation我最终选的是Windows UI AutomationUIA路线对应到Python就是uiautomation这个库。它本质上是一套Windows系统级的无障碍接口应用程序把界面控件暴露成带属性名称、类型、层级等的节点我们用代码去遍历、定位、调用这些节点模拟用户点击和滚动。QQ客户端虽然没开放给个人开发者什么接口但它躲不开Windows的辅助功能框架所以控件信息都能被读到。好处很明显不用处理任何加密和协议拿到的是最终渲染在界面上的文本不依赖特定版本的消息存储格式QQ怎么改内部逻辑都不影响对个人和小工具来说写起来快看效果直观。缺点是运行时要开着QQ窗口、界面不能完全锁屏但这对我来说完全能接受。2. 环境准备与基础概念2.1 安装依赖在Windows机器上先确保Python是64位的我用的3.10然后直接装库pip install uiautomation pip install pywin32uiautomation底层依赖comtypes和Windows自带的UIA框架基本装上就能跑。pywin32不是必须的但我在滚动和窗口置顶时会用到建议一起装。然后有一个关键前置操作打开QQ的设置找到安全设置里的辅助功能勾选允许辅助功能访问。这一步不做你会发现自己能看到窗口但遍历不到子控件全是空白。这是第一个大坑。2.2 先搞懂控件树UIA把整个界面看成一棵树。根节点是桌面往下是每一个顶层窗口窗口里有面板、列表、按钮、文本控件。定位一个控件本质就是沿着树找节点。每个节点都有几个关键属性ControlType控件类型比如窗口、列表、列表项、文本Name控件的名称通常是显示出来的文字ClassName控件的内部类名用来区分同一个Name下的不同控件AutomationId开发者在代码里指定的唯一ID有的话最可靠。uiautomation提供的查找模式是链式的。比如import uiautomation as auto root auto.GetRootControl() qq_window root.WindowControl(ClassNameQt515QWindowIcon, NameQQ)这一句的意思是从桌面开始找到一个类名匹配且名字为QQ的窗口控件。拿到窗口之后再往里找列表控件找列表项控件逐层深入。2.3 验证一下能不能看到QQ窗口写任何代码之前先花两分钟确认窗口能被UIA看到。运行下面这段import uiautomation as auto root auto.GetRootControl() for w in root.GetChildren(): if QQ in w.Name: print(w.ControlTypeName, w.Name, w.ClassName)如果终端里能打印出QQ窗口信息说明基础链路通了。打印不出来先检查辅助功能是否打开、QQ是否在前台运行。我习惯在任何正式代码里先做这一步探路确认环境没问题再往下写省得后面排错排到怀疑人生。3. 定位QQ窗口和群聊会话3.1 找到QQ主窗口不同版本的QQ主窗口的ClassName不一样。我之前见到过Qt515QWindowIcon、QQUI也遇到过一种变体名。所以我的策略是不写死类名而是遍历所有顶层窗口按Name做模糊匹配。import uiautomation as auto def find_qq_window(): root auto.GetRootControl() for w in root.GetChildren(): if QQ in w.Name and w.ControlTypeName WindowControl: return w return None注意一点QQ窗口如果处于最小化状态UIA可能读不到内部控件。我踩过这个坑——窗口最小化之后依然能找到窗口节点但是找子控件就找不到。所以脚本运行前最好把QQ窗口还原到桌面可见状态或者用代码强制恢复import win32con import win32gui def restore_qq(qq_window): hwnd qq_window.NativeWindowHandle win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)这一步治标治本强烈建议加上。3.2 解析会话列表QQ主窗口左侧是会话列表里面一列都是会话项。这个列表在UIA里通常是列表控件每个会话项是一个ListItem。我们要找到目标群本质就是在这一堆ListItem里按Name匹配。我的做法是先打印所有会话项的名字和索引人工确认目标群的名称长什么样def list_sessions(qq_window): items qq_window.ListControl(Depth1).GetChildren() for i, item in enumerate(items): print(i, item.Name, item.ControlTypeName)聚类很重要。会话列表控件的层级在不同版本里可能不一样Depth1不一定准。如果你打印出来的全是空名字就放宽条件直接把窗口下所有ListItem都捞出来items qq_window.GetChildren()[0].GetChildren()[0].GetChildren()这里没法给一个通用死板的层级因为你打开的QQ版本和布局会变。但思路是一样的从窗口控件出发找列表容器再找列表项。3.3 点击群聊进入聊天页找到目标会话项之后调用Click()方法模拟点击进入聊天界面def select_group(qq_window, group_name): items qq_window.ListControl(Depth1).GetChildren() for item in items: if group_name in item.Name: item.Click() return True return False这里要注意群聊名称可能长得像#技术交流群或者带个括号模糊匹配更稳。点击之后右侧聊天区域会切换成对应群的内容。等待页面加载我只用了一个time.sleep(1)加后续轮询。如果你机器性能一般可以多等一会儿但别一上来就sleep(5)死等后面我会讲怎么动态等待。4. 提取聊天记录与自动加载4.1 解析消息列表进入聊天页面后右侧消息区域是一列消息气泡。每条消息结构大概是这样一个ListItem里面嵌套了发送者昵称、发送时间、正文内容。我用下面这段把可视区域里的消息全部捞出来def extract_messages(qq_window): messages [] # 消息区域一般是窗体内的第一个列表控件 msg_list qq_window.ListControl(ClassNameQAbstractScrollArea) if not msg_list.Exists(3, 0): return messages for item in msg_list.GetChildren(): texts [] for t in item.GetChildren(): if t.ControlTypeName TextControl and t.Name: texts.append(t.Name) if texts: # 第一条一般是昵称/时间最后一条是正文自己按需处理 messages.append(texts) return messages这段代码只是示范思路。不同QQ版本的消息控件结构差异很大有的昵称和时间是两个独立的Text有的正文是一个EditControl有的文字被拆成了好几个小控件。我的做法是先把一条消息的所有文本子控件打出来看清楚结构再去解析比盲猜快得多。建议你拿到真实界面后先做一次解剖把item.GetChildren()完整打印出来再看字段怎么提取。4.2 自动向上滚动加载QQ聊天窗口的历史记录是向上翻页加载的。滚动由消息区域的滚动条控制。UIA里通常对包含滚动条的控件实现了ScrollPattern但QQ这种自绘控件支持不稳定我实测过直接调用ScrollPattern.Scroll有时没反应。更稳的方式是模拟鼠标滚轮。uiautomation给控件封装了WheelUp方法def scroll_up(msg_list, times30): for _ in range(times): msg_list.WheelUp(waitTime0.1) time.sleep(0.5)滚动之后要给QQ一点时间加载更多历史消息所以sleep必不可少。我实测0.5秒在自己机器上够用如果网络差或者聊天记录加载慢可以改成1秒。关键点滚动一定要定位在消息区域控件上。如果你定位错了对象滚半天还是同一屏看着就跟没反应一样。4.3 等待与去重别把记录抓重复一个极易忽略的问题滚动加载是增量式的上一次滚动后已经抓过的消息下一次很容易又被抓一次。我的去重策略有两层第一层在单次抓取里用消息时间和发送者昵称组合成唯一键压进一个集合里判断是否见过seen set() for texts in extract_messages(qq_window): key (texts[0], texts[1]) # 假设texts[0]是时间texts[1]是昵称 if key in seen: continue seen.add(key) # 写入记录第二层跨批次抓取时记录上次滚动前第一条消息的位置作为停止条件。当一次滚动之后抓取到的消息集合没有新增时就认为已经到顶了last_size -1 while True: current_msgs extract_messages(qq_window) if len(current_msgs) last_size: break last_size len(current_msgs) scroll_up(msg_list, 10)这个抓不出新消息就停的策略比固定滚动次数聪明得多也更能应对慢加载。5. 完整代码与数据整理5.1 控制流程串联把前面几段逻辑串起来形成一个完整的主流程import time import uiautomation as auto import win32gui import win32con def main(group_name, total_limit5000): qq_window find_qq_window() if not qq_window: raise RuntimeError(找不到QQ窗口) restore_qq(qq_window) qq_window.SetActive() # 激活窗口 if not select_group(qq_window, group_name): raise RuntimeError(找不到目标群{}.format(group_name)) time.sleep(1) msg_list qq_window.ListControl(ClassNameQAbstractScrollArea) records [] seen set() no_new_count 0 while len(records) total_limit: messages extract_messages(msg_list) added 0 for texts in messages: key tuple(texts[:2]) if key in seen: continue seen.add(key) records.append(texts) added 1 if added 0: no_new_count 1 else: no_new_count 0 if no_new_count 3: break msg_list.WheelUp(waitTime0.1) time.sleep(0.5) print(抓取完成共{}条记录.format(len(records))) save_records(records, group_name)no_new_count 3连续3次没有新消息就认为已经翻到历史顶部。这个阈值是我试出来的翻到最顶上时确实只是连续刷不出新内容不会误判早期网络慢导致的短暂停顿。注意代码里的extract_messages要在滚动后重新获取控件子节点。GetChildren()每次调用都会重新向系统查询一遍所以滚完后再调用extract_messages(msg_list)拿到的就是新内容。5.2 存成结构化文件我只存CSV和JSON格式够用且通用。CSV方便Excel打开JSON方便后面做检索。import csv import json def save_records(records, group_name): base_name qq_{}.format(group_name.replace( , )) with open(base_name .csv, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([时间, 发送者, 内容]) writer.writerows(records) with open(base_name .json, w, encodingutf-8) as f: json.dump(records, f, ensure_asciiFalse, indent2)CSV用utf-8-sig是为了让Excel直接打开不乱码。这个细节我吃过亏之前用utf-8存别人拿到Windows上用Excel打开全乱了换了utf-8-sig才正常。extract_messages里返回的texts长度可能不是固定的3个所以我做数据清理时统一补全def normalize(texts, length3): if len(texts) length: return texts[:length] return texts [] * (length - len(texts))5.3 增量备份思路如果只想每天备份新消息不需要每次全量重抓。我用的增量方案是局部抓取时先滚动到最早一次备份的时间点附近然后只抓取之后的记录。具体实现是读取上次CSV最后一条消息的时间在本次自动化抓取时先往前多滚一些再开始记录。这样程序每次跑十几秒就能结束不用反复翻几千条老记录。增量逻辑不复杂但很实在def get_last_time(group_name): try: with open(qq_{}.csv.format(group_name), r, encodingutf-8-sig) as f: rows list(csv.reader(f)) return rows[-1][0] if len(rows) 1 else except FileNotFoundError: return 6. 常见问题与排查实录这一节我用遇到过的高频问题做个小速查表问题表现解决思路控件全是空白窗口能找到子控件读取Name都为空检查辅助功能是否开启重启QQ后再试找不到会话项ListControl匹配不到任何群遍历打印所有ListItem的Name看层级对不对消息被截断长消息只抓到前半段部分文本是分段渲染的尝试拼接同一条消息的所有Text子控件滚动无效WheelUp后内容没变确认滚动的是消息区域不是整个窗口抓取重复相邻批次有相同记录用时间去重并设置停止条件程序卡死滚太快QQ响应不过来加大sleep间隔降低滚动频率6.1 控件找不到怎么办最典型的就是ListControl(ClassNameQAbstractScrollArea)找不到。QQ换版本后这个类名经常变。我建议先写一个小工具脚本把QQ窗口内所有控件的信息全量导出到txtdef dump_tree(control, depth0): with open(dump.txt, a, encodingutf-8) as f: f.write( * depth control.ControlTypeName | control.Name | control.ClassName \n) for child in control.GetChildren(): dump_tree(child, depth 1)跑一次看一下消息列表到底叫什么类名、在哪个层级再改主程序。凡是遇到找不到控件第一反应应该是去dump而不是瞎改参数。6.2 滚动加载不生效我有一个印象很深的案例脚本滚动完全没效果排查了半天发现WheelUp作用在了窗口控件上而不是消息列表。因为WheelUp是往控件中心点发滚轮事件如果那个位置正好在消息区域之外当然没反应。解决办法是先选中消息列表里的一个消息item然后在这个item上滚动或者在调用WheelUp前确保控件坐标正确。还有一种情况微信式的自绘滚动区域屏蔽了滚轮事件那就需要模拟键盘的 PageUp 键。QQ实测滚轮有效所以这条只做备选方案。6.3 其它几个高频坑第一个是启动顺序。脚本运行前QQ必须已经登录成功并且聊天界面处于可交互状态。如果QQ弹出的安全验证窗口盖在主窗口上UIA读到的焦点控件会变成验证窗口导致后续操作全部错位。我的解决办法是启动脚本前设置QQ记住登录状态跳过二次验证。第二个是控件文本刷新速度。QQ加载历史消息不是瞬间完成的抓取了消息之后立刻滚动有可能消息还没完全渲染出来。加一小段等待渲染完成的逻辑会更稳def wait_for_render(msg_list): last_names [x.Name for x in msg_list.GetChildren()] time.sleep(1) new_names [x.Name for x in msg_list.GetChildren()] if len(new_names) ! len(last_names): time.sleep(0.5)第三个更隐蔽QQ在夜间模式或深色皮肤下控件Name不受影响但某些特殊表情消息会渲染成图片Name为空。这类消息要跳过还是转成[图片]占位自己决定。7. 使用边界与注意事项7.1 这个方案能干什么不能干什么UI自动化抓取聊天记录的手段只应该用于自己有权访问的账号和群聊。用来做个人知识归档、群聊内容整理、自己的聊天记录备份完全没问题。但它不应该被用来批量窃取他人聊天内容、绕过隐私限制、恶意爬取。技术上能做到不等于你可以这么用。明文写进日志、到处传播他人私密对话这个风险自己掂量。我这篇文章讲的是如何用uiautomation读取当前界面内容这是Windows UI自动化框架正当能力的一部分就像辅助软件帮助视障用户读取屏幕内容一样。关键是使用目的必须正当。7.2 关于风险控制如果你是做正规的群聊数据整理还有几条经验分享抓取结果里的聊天文件建议加密保存尤其群聊里可能出现身份证号、手机号、住址等敏感信息不要把这套脚本对外开放也不要做成在线服务自己本地用就够了如果群里有成员明确提出不希望内容被收集尊重别人把这个群排除在归档范围之外控制抓取频率不要频繁循环滚动造成QQ界面卡顿更不要把脚本写成无限循环。轻量、低频、有明确目的这三条放之四海而皆准。我在实际使用中一般一周归档一次每次跑一分钟左右。QQ长时间在线开着脚本静默执行完CSV文件自动落盘整个过程基本不用干预。结尾做这个项目时我最大的体会是读UI自动化方案之前先想清楚自己为什么需要它。如果只是为了备份它足够轻量灵活如果是要做大规模数据分析那这条路效率太低可能还是需要找正规接口。最后分享一个小技巧每次抓取完成后把本次结果的文件大小记录下来。如果某次结果大小异常比如突然只有上次的十分之一说明可能选错了群、或者窗口没切对。我用这个办法提前发现过两次会话定位失败比事后检查数据快多了。脚本不大、逻辑不难但这类体检式的检查能帮你少走很多弯路。