简介:这是一套面向爬虫开发者与数据分析人员的微信公众号文章自动化采集方案,针对公众号历史文章难以批量获取、元数据分散等痛点,借助pywinauto驱动微信客户端实现文章抓取、全文爬取、发布时间采集以及阅读量与点赞数统计,适合具备一定Python基础、希望搭建稳定采集流程的中级开发者参考。压缩包共52个文件,约3.11MB,以Java源码为主体,辅以Python脚本、XML与properties配置、HTML页面、PNG与GIF示意图及说明文档,并附带微信、MySQL、JDK等环境安装程序,覆盖客户端、服务端与配置模块,目录结构清晰。目前已有362人学习下载。读者可从中获得完整的采集工程骨架、客户端与服务端协同的实现思路、依赖清单与配置模板,以及环境搭建与排错参考,便于快速复现并二次扩展。
1. 用 pywinauto 做微信公众号文章采集:为什么我放弃了接口方案
去年帮一家做行业情报的团队搭内容中台,需求很朴素:把几十个公众号的历史文章批量下载下来,正文、发布时间、阅读量、点赞数都要,最好还能按关键词筛。第一反应当然是走接口,但公众号的接口权限卡得死,历史文章列表接口个人号基本拿不到,测试号能调的服务 API 也覆盖不了历史数据。折腾了两周,最后回到最土的路子——用 pywinauto 驱动微信 PC 客户端,模拟人点开公众号、翻历史消息、逐篇打开复制内容。
这套方案的核心逻辑是:微信 PC 端本身能看历史文章,pywinauto 通过 Windows UI Automation 拿到窗口控件树,定位到公众号会话窗口、历史消息列表、文章详情页,把控件里的文本读出来。它不碰网络协议,不依赖任何私有接口,只要客户端能显示的内容,理论上都能采。适合谁?做内容聚合、舆情监控、竞品分析的小团队,手里有台常开的 Windows 机器,能接受「慢但稳」的采集节奏。不适合追求高并发、要采几百万篇的场景,那是另一套工程。
2. pywinauto 驱动微信客户端的原理与最小可跑环境
2.1 为什么是 UI Automation 而不是图像识别
pywinauto 底层走的是 Windows 的 UI Automation(UIA)框架,微信 PC 端虽然自绘程度高,但关键控件——会话列表、消息气泡、文章标题、正文区域——在 UIA 树里是有节点暴露的。这意味着我们不需要截图做 OCR,直接读控件的 Name、Value 属性就能拿到文本,速度和准确率都高一个量级。图像识别方案(比如 pyautogui 截图 + OCR)在分辨率变化、主题切换、字体渲染差异下极容易翻车,而 UIA 拿的是逻辑文本,跟显示效果解耦。
选型上,pywinauto 有两种 backend:win32 和 uia。微信必须用 uia,因为 win32 backend 读不到微信自绘控件的内部结构。这一点是血泪经验,我一开始用默认 backend,print_control_identifiers()打出来一片空白,换成 uia 才看到完整树。
2.2 环境准备与依赖安装
先确认微信 PC 版能正常登录并打开目标公众号。pywinauto 对 Python 版本要求不严,3.8 以上都行,但建议 3.10 左右,兼容性好。
pip install pywinauto==0.6.8 pip install comtypes pip install pandas openpyxlcomtypes是 uia backend 的依赖,不装会报 COM 相关错误。pandas和openpyxl用于后续把采集结果落成 Excel,方便非技术同事查看。
装完后先跑一个探测脚本,确认能连上微信窗口:
from pywinauto import Application # 连接已运行的微信,注意 backend 必须是 uia app = Application(backend="uia").connect(path="WeChat.exe") main_win = app.window(class_name="WeChatMainWndForPC") print(main_win.window_text()) # 打印控件树,确认能看到会话列表节点 main_win.print_control_identifiers(depth=3)逻辑说明:connect是连接已存在的进程,不是启动新进程,所以微信要先手动登录好。class_name="WeChatMainWndForPC"是微信主窗口的类名,不同版本可能略有差异,如果连不上,用Application(backend="uia").connect(title_re="微信")按标题匹配更稳。print_control_identifiers的depth参数控制打印层级,太深会刷屏,先看 3 层找到会话列表的大致位置。
参数上,depth建议从 2 开始逐层加,print_control_identifiers输出的是控件类型、标题、自动化 ID 的树状结构,找到List或ListItem类型的节点就是会话列表。如果输出里全是Pane没有具体文本,说明微信版本对 UIA 暴露不完整,需要换用更细的descendants()遍历。
3. 定位公众号会话与历史文章列表的实操步骤
3.1 搜索并进入目标公众号
微信主界面左上角有个搜索框,UIA 里通常是一个Edit控件。我们要做的是:点搜索框、输入公众号名、等搜索结果、点进公众号会话。
import time from pywinauto.keyboard import send_keys def open_official_account(main_win, account_name): # 定位搜索框,微信搜索框的 automation_id 通常是 search_input search_box = main_win.child_window(auto_id="search_input", control_type="Edit") search_box.click_input() time.sleep(0.5) # 清空已有内容 send_keys("^a") send_keys("{BACKSPACE}") # 输入公众号名称 send_keys(account_name, with_spaces=True) time.sleep(2) # 等搜索结果加载 # 回车进入第一个结果 send_keys("{ENTER}") time.sleep(2)逻辑说明:click_input()是模拟真实鼠标点击,比click()更可靠,因为微信有些控件需要真实焦点。send_keys走的是键盘事件,^a是 Ctrl+A 全选,{BACKSPACE}删除,{ENTER}回车。with_spaces=True保证公众号名里的空格不被吞掉。
参数上,time.sleep的时长是关键。搜索结果加载受网络影响,2 秒是保守值,如果机器慢可以加到 3 秒。这里没有用wait系列方法,因为微信的加载状态没有明确的 UIA 事件,只能靠固定延时,这是这套方案最不优雅但最实用的地方。
3.2 打开历史消息并滚动加载
进入公众号会话后,右上角有个「...」菜单,点开有「查看历史消息」。这一步的控件定位需要根据实际 UIA 树调整,常见做法是先找到菜单按钮,再找菜单项。
def open_history(main_win): # 公众号会话窗口右上角菜单按钮 menu_btn = main_win.child_window(title="更多", control_type="Button") menu_btn.click_input() time.sleep(1) # 弹出菜单里的「查看历史消息」 history_item = main_win.child_window(title="查看历史消息", control_type="MenuItem") history_item.click_input() time.sleep(3) # 等历史消息窗口打开历史消息窗口是一个独立的窗口,里面是文章列表。列表项通常是ListItem或Text控件,每项包含标题和日期。滚动加载是难点:微信历史消息是懒加载的,滚到底才会加载更多。
def scroll_history_list(history_win, max_scroll=50): # 定位列表容器 msg_list = history_win.child_window(control_type="List") collected = [] last_count = 0 for i in range(max_scroll): # 收集当前可见的列表项 items = msg_list.descendants(control_type="ListItem") for item in items: title = item.window_text() if title and title not in collected: collected.append(title) # 如果数量没变化,说明到底了 if len(collected) == last_count: break last_count = len(collected) # 滚动:鼠标滚轮向下 msg_list.scroll("down", "page") time.sleep(1.5) return collected逻辑说明:descendants会递归拿所有子节点,比children更彻底。每次滚动后重新收集,用collected列表去重。scroll("down", "page")是 pywinauto 封装的滚动方法,按页滚比按行滚效率高。
参数上,max_scroll是安全上限,防止死循环。time.sleep(1.5)是等加载,网络差要加长。这里有个坑:滚动太快会导致列表项还没渲染出来就跳过,所以宁可慢一点。如果发现漏采,把 sleep 加到 2 秒以上。
4. 文章正文、发布时间、阅读量点赞数的提取与落库
4.1 逐篇打开文章并抓取正文
拿到历史列表后,逐篇点击打开。文章详情页在微信里是一个内嵌浏览器窗口,UIA 能读到的正文通常是Document或Text控件。
def extract_article(history_win, title): # 在列表里找到对应标题的项并点击 item = history_win.child_window(title=title, control_type="ListItem") item.click_input() time.sleep(3) # 等文章加载 # 文章窗口通常是新开的独立窗口 from pywinauto import Desktop article_win = Desktop(backend="uia").window(title_re=".*" + title[:10] + ".*") # 抓正文 doc = article_win.child_window(control_type="Document") content = doc.window_text() # 抓发布时间,通常在标题下方 try: pub_time = article_win.child_window(auto_id="publish_time", control_type="Text").window_text() except Exception: pub_time = "" return {"title": title, "content": content, "pub_time": pub_time}逻辑说明:Desktop是顶层窗口容器,用来找新开的文章窗口。title_re用正则匹配标题前 10 个字符,避免标题里的特殊字符导致匹配失败。正文用Document控件的window_text()拿,这是最直接的方式。
参数上,time.sleep(3)是等文章渲染,图文混排的文章加载慢,可以加到 5 秒。publish_time的auto_id是假设值,实际要用print_control_identifiers确认,不同版本微信的 ID 不一样。如果找不到,可以退而求其次,从历史列表项的文本里正则提取日期。
4.2 阅读量和点赞数的采集
阅读量和点赞数在文章底部,需要滚动到页面底部才能看到。这两个数据在 UIA 里通常是Text控件,文本形如「阅读 10万+」「点赞 500」。
def extract_stats(article_win): # 滚动到文章底部 doc = article_win.child_window(control_type="Document") doc.scroll("down", "page") time.sleep(2) # 抓取所有文本节点,正则匹配 import re stats = {"read": "", "like": ""} for text_ctrl in article_win.descendants(control_type="Text"): t = text_ctrl.window_text() if "阅读" in t: m = re.search(r"阅读\s*([\d万+]+)", t) if m: stats["read"] = m.group(1) if "点赞" in t or "在看" in t: m = re.search(r"(?:点赞|在看)\s*([\d万+]+)", t) if m: stats["like"] = m.group(1) return stats逻辑说明:滚动到底部后,遍历所有Text控件,用正则匹配「阅读」和「点赞」后面的数字。微信的阅读量显示是「10万+」这种格式,正则里要包含「万」和「+」。
参数上,scroll("down", "page")可能一次滚不到底,可以循环滚 2-3 次。正则[\d万+]+能匹配「10万+」「5000」「1.2万」等格式。注意「在看」和「点赞」是两个不同指标,早期文章只有点赞,新版有在看,代码里都匹配上,按需取用。
4.3 数据落库与去重
采集结果建议先落 CSV 或 Excel,再用数据库做去重。用 pandas 处理最方便。
import pandas as pd import os def save_articles(articles, output_path="articles.csv"): df = pd.DataFrame(articles) # 按标题去重,保留最新采集的 df.drop_duplicates(subset=["title"], keep="last", inplace=True) # 追加模式写入 if os.path.exists(output_path): old = pd.read_csv(output_path) df = pd.concat([old, df]).drop_duplicates(subset=["title"], keep="last") df.to_csv(output_path, index=False, encoding="utf-8-sig") print(f"已保存 {len(df)} 篇")逻辑说明:drop_duplicates按标题去重,keep="last"保证新采集的覆盖旧的。encoding="utf-8-sig"是为了 Excel 打开不乱码。
参数上,subset可以改成["title", "pub_time"]组合去重,防止同名文章误删。如果文章量大,建议换成 SQLite,用INSERT OR REPLACE更高效。
5. 避坑与排查:那些让我加班到凌晨的坑
5.1 控件定位失效:微信一更新,脚本就废
现象:昨天还能跑的脚本,今天child_window全部报ElementNotFound。原因:微信 PC 版自动更新后,控件类名或 automation_id 变了。解决:不要硬编码auto_id,尽量用title或control_type加层级关系定位。更稳的做法是写一个「控件探测」函数,每次运行前先打印关键控件的属性,发现变化及时调整。我一般会把定位逻辑抽成一个配置文件,改起来不用动主代码。
5.2 滚动加载漏采:列表项还没渲染就滚过去了
现象:采集 100 篇,实际只拿到 60 篇,中间缺了一段。原因:scroll之后sleep太短,列表项还在渲染中就执行了下一次滚动。解决:把sleep加到 2 秒以上,并且在每次滚动后检查列表项数量,如果数量没增加就多等一轮再滚。另一个技巧是不要用scroll("down", "page"),改用scroll("down", "line")多次小步滚,虽然慢但漏采率低。
5.3 文章窗口标题匹配失败:特殊字符导致正则崩溃
现象:title_re匹配不到文章窗口,报MatchError。原因:文章标题里包含(、)、[、]等正则元字符,直接拼进title_re会破坏正则结构。解决:用re.escape()转义标题,或者干脆不用标题匹配,改用窗口的class_name加创建顺序来定位。我现在的做法是:打开文章后,用Desktop().windows()拿所有窗口,按process_id过滤出微信的,再取最后一个,基本不会错。
5.4 阅读量抓不到:控件文本被截断或懒加载
现象:正文能抓到,但阅读量、点赞数全是空。原因:这两个数据在页面底部,且是异步加载的,滚动到底部后可能还需要等接口返回。解决:滚动到底部后sleep3 秒以上,然后重新遍历Text控件。如果还是抓不到,检查是不是被「广告」或「推荐阅读」模块遮挡了,可以尝试用descendants深度遍历,不要只找直接子节点。
5.5 微信多开导致连接错窗口
现象:脚本连上了微信,但操作的是另一个账号的窗口。原因:机器上开了多个微信实例,connect(path="WeChat.exe")默认连第一个,不一定是目标账号。解决:用connect(process=pid)指定进程 ID,pid 可以通过tasklist | findstr WeChat拿到。或者在脚本开头加一个确认步骤,打印当前连接的窗口标题,人工核对。
6. 进阶:把采集频率压到最低、把稳定性拉到最高
跑通单次采集只是开始,真正要长期用,得解决两个问题:一是别被微信的风控盯上,二是脚本崩了能自己恢复。我的做法是给采集加「节奏控制」和「断点续采」。
节奏控制上,不要连续快速打开文章。每篇之间随机sleep3 到 8 秒,模拟人的阅读节奏。翻历史列表时,每滚 5 页停 10 秒。这些延时看起来拖慢速度,但能大幅降低被限制的概率。我一般会在配置里写一个min_delay和max_delay,用random.uniform取值。
断点续采靠一个简单的状态文件。每采完一篇,把标题写进done.txt,下次启动先读这个文件,跳过已采的。这样即使脚本半夜崩了,第二天接着跑不用从头来。
import random import os def human_like_delay(min_s=3, max_s=8): time.sleep(random.uniform(min_s, max_s)) def load_done(done_file="done.txt"): if os.path.exists(done_file): with open(done_file, "r", encoding="utf-8") as f: return set(line.strip() for line in f) return set() def mark_done(title, done_file="done.txt"): with open(done_file, "a", encoding="utf-8") as f: f.write(title + "\n")验证采集质量有个笨但有效的办法:随机抽 10 篇,手动打开对比正文首段和阅读量,看是否一致。我一般会在采集完成后跑一个校验脚本,检查正文长度是否大于 100 字、发布时间是否匹配正则\d{4}-\d{2}-\d{2},不满足的标出来人工复核。
这套方案我前后迭代了三个版本,从最初的一把梭到现在的带节奏控制和断点续采,最大的体会是:UI 自动化采集的稳定性不取决于代码多优雅,而取决于你对「慢」的容忍度。把延时给足,把异常捕获写全,把状态存好,它就能像老黄牛一样一直跑。希望帮到你。
本文还有配套的精品资源,点击获取