微信公众号爬虫实战:wechat_spider抓取历史文章与互动数据
简介:这是一份基于 Node.js 的微信爬虫项目,采用中间人代理与 AnyProxy 解析微信 HTTPS 请求,可批量获取公众号文章正文、阅读量、点赞量、在看数、评论以及历史文章链接等数据,适合需要采集微信公众号数据的开发者、数据分析师或运营人员使用。项目支持本机运行,也能通过 Docker 便捷部署到服务器,降低了环境搭建门槛。资源包共 80 个文件,体积仅 1.13MB,以 JavaScript 源码(40 个 js、9 个 jsx)为主,同时包含 JSON 配置、HTML 页面、CSS 样式、图片资源、HTTPS 证书(crt/key)及 Dockerfile、docker-compose.yml 等容器化部署文件,目录结构清晰,便于直接运行或二次开发。已有 2619 人学习下载,代码内封装了客户端与服务端、代理规则、MongoDB/Redis 存储、文章历史链接抓取、评论与图片处理等完整模块,拿来即可安装使用,也可作为学习微信接口协议与爬虫代理原理的实战参考。 做公众号数据分析这行,手里没有一套顺手的抓取工具,基本等于瞎忙。我最早是手工复制文章链接、每天记录阅读量,后来发现根本扛不住——几十个公众号、每天上百篇更新,光整理数据就能耗掉大半天。后来我把 wechat_spider 这套微信爬虫思路落地,专门用来获取公众号历史文章链接、文章正文、阅读量、点赞量和评论数据,总算把这块时间压缩到了几分钟。如果你也在做竞品监测、内容复盘或者公众号矩阵运营,这篇文章应该能帮你少走不少弯路。
这套方案的核心思路并不复杂:不碰网页版那套受限的登录流程,直接从 PC 微信客户端的流量入口入手,把请求参数抓下来,再模拟接口去拉历史文章、统计数据。下面我按自己的实操过程,把技术选型、关键细节、踩坑记录都拆开讲一遍。
1. 项目概述与整体思路
1.1 这个爬虫能做什么,解决了什么痛点
先说我为什么要折腾这么一套东西。当时的需求是同时跟进二十几个同行公众号,每天记录三件事:更新了哪些文章、阅读量多少、评论区有没有值得关注的反馈。如果全手工做,等于每天多出一个小时的纯体力劳动,而且很容易漏记。
wechat_spider 最终帮我把整个过程变成了三步:拉历史文章链接、批量抓正文和指标、写入数据库。具体能拿到的数据包括:
- 公众号的历史文章链接列表,按时间排序,翻完整个人主页;
- 每篇文章的标题、摘要、正文内容、封面图、作者;
- 阅读量、点赞量(部分情况下还能拿到在看数);
- 文章评论区的内容、点赞数、回复内容;
- 发布时间、图文类型、是否为多图文等基础信息。
这套东西适合谁?一个是做新媒体竞品分析的人,每天要盯着对手动态;另一个是公众号矩阵运营方,需要统一汇总自己多个号的数据;再一个就是想做垂直内容库的开发者,比如做行业资讯聚合、文章存档检索。只要你不是拿它去恶意刷量、搬运抄袭,自己研究数据是完全够用的。
1.2 为什么我选择从 PC 微信进程入手
市面上的思路主要有三条:模拟网页版登录、模拟手机端协议、从 PC 客户端抓包。三条路我都试过,说点真实感受。
网页版微信的登录限制很严,很多账号根本没有网页版入口,而且网页版拿到的公众号文章数据非常有限,历史列表接口基本被风控盯死。模拟手机端协议这条路,需要处理大量加密逻辑和风险校验,自己维护成本极高,不亚于重新实现半个微信客户端,太折腾了。
最终我选了第三条路:让 PC 微信自己干活,我只需要在它和服务器之间加一个代理,把公众号主页的 HTTP 请求照实记录下来。PC 微信在浏览公众号文章和打开公众号主页时,会在本地发出完整的接口请求,这些请求里带了当前登录用户的合法身份参数。我做的就是把这些参数“借”过来,重放同样的请求,拿到数据。
这样做的好处很明显:不需要破解协议、不需要碰加密数据,身份校验由微信客户端自己完成,我只需要模拟 HTTP 请求。缺点也明显,参数有过期时间,过几个小时或者切换账号后就要重新抓包。对个人研究来说,这点麻烦完全能接受。
2. 核心细节解析与关键点梳理
2.1 历史文章列表背后的接口逻辑
想抓全一个公众号的历史文章,首先要搞清楚微信是用哪个接口产数据的。PC 端打开公众号主页、上滑加载更多历史消息时,会触发一个类似这样的请求:
GET https://mp.weixin.qq.com/mp/profile_ext?action=getmsg&__biz=...&f=json&offset=0&count=10&is_ok=1&scene=124&uin=777&key=...&pass_ticket=...
参数解释起来其实不复杂,但每个都很关键:
| 参数 | 作用 | 注意事项 |
|---|---|---|
__biz | 公众号的唯一标识,相当于公众号的身份证号 | 从公众号主页 URL 里就能提取到 |
key | 当前登录态的临时凭证,时效很短 | 过期后接口会报 invalid key,必须重新抓包更新 |
pass_ticket | 另一个登录校验参数,和 key 配套使用 | 切换账号后立刻失效 |
offset | 翻页偏移量,第一次是 0,之后每次递增 | 每次返回的数量是 count 的值 |
count | 每页数量,通常填 10 比较稳 | 填太大容易被风控 |
scene | 场景值,抓历史列表时固定为 124 | 改错了可能拿不到数据 |
返回内容是一个 JSON,里面有一个 general_msg_list 字段,它本身是一段转义后的 JSON 字符串,展开后有一个 list 数组,每一项对应一篇图文消息。每项内部又分为 comm_msg_info (基础信息)和 app_msg_ext_info (文章扩展信息),如果这天的推送是多图文, app_msg_ext_info 下面还会有一个 multi_app_msg_item_list 数组,把副图文放在里面。
拿到这个结构后,我要做的事就是不断把 offset 往上加,直到返回的列表为空,就说明已经翻到最早的历史文章了。
2.2 文章内容、阅读量、点赞量怎么拿
历史列表接口只能拿到文章的标题、摘要、封面图、跳转链接,拿不到正文和阅读数据。正文很好办,从 content_url 直接请求文章页面,再把 HTML 里的正文部分提取出来就行。真正麻烦的是阅读量、点赞量。
如果你只想看一篇文章的阅读数据,就不需要去解析页面上的小字。微信有一个独立的统计接口,通过文章链接里的 sn 参数加上 __biz 等参数就能调出结构化数据:
GET https://mp.weixin.qq.com/mp/getappmsgext?__biz=...&appmsg_type=9&idx=1&sn=...&f=json
这个接口返回的 JSON 里有个 appmsgstat 字段,里面直接就是 read_num 、 like_num 、 old_like_num 这类数字。注意,这个接口同样依赖 key 和 pass_ticket,而且对请求频率异常敏感。我一开始就是一篇文章调一次接口,结果连续请求一百多次后直接触发限制,所有返回都变成 ret: 200003 。后来我改成每篇文章之间随机延迟 2 到 5 秒,并且控制单批任务量,情况才好转。
文章正文和阅读数据之间的关联,我用的是 URL 里的 sn 参数。每篇文章的 content_url 里都带一个唯一的 sn ,它就是这篇文章在公众号内的“文章指纹”,调用统计接口时填它,基本不会错。
2.3 评论数据的获取逻辑
评论是单独一个接口,很多资料里没有提,我一开始也卡了很久。PC 微信打开一篇文章并拉取评论区时,会请求:
GET https://mp.weixin.qq.com/mp/appmsg_comment?action=getcomment&__biz=...&appmsgid=...&idx=1&comment_id=...&key=...&offset=0&count=20
这里需要多说一句: appmsgid 不是 comm_msg_info.id ,而是文章 URL 里那个 mid 参数; comment_id 也藏在页面源码的某个变量里。第一次抓评论时,我怎么都找不到 comment_id ,后来在文章 HTML 的 window.__commentData 或者是页面里的一段 JSON 初始化数据里看到了它。提取方式有点绕:先请求文章页面,从 HTML 中解析出 comment_id ,再拿去调评论接口。
评论接口返回的结构也很标准: comment 数组里是每一条评论,包含昵称、头像、内容、点赞数,每条评论下的回复会放在 reply 字段里。如果文章没有开启评论,接口会返回空列表,这属于正常情况,不是代码写错了。
3. 实操过程与核心环节实现
3.1 环境准备与抓包接入
实际操作前,先把工具链备齐。我用的环境是:
- Windows 10 系统,安装 PC 版微信,并保持登录一个长期使用的账号;
- Python 3.9 以上,装了
requests、beautifulsoup4、lxml; - 抓包工具用的是 mitmproxy,开启后设置 Windows 系统代理,让 PC 微信的流量走本地代理;
- 数据库先用 SQLite 起步,数据结构稳定后可以迁到 MySQL。
第一次配置时,要注意给 mitmproxy 安装 CA 证书,否则抓不到 HTTPS 明文流量。安装证书后,重新启动 PC 微信,然后随便打开一个公众号的主页,向上翻几页历史消息。再回看 mitmproxy 的控制台,就能看到刚才说的 profile_ext 请求。我的习惯是直接在 mitmproxy 的过滤栏里输入 profile_ext ,把无关请求全部滤掉,只看历史文章列表接口。
把这条请求的完整 URL 拷出来,尤其是 key 和 pass_ticket 这两个参数,存到一个配置文件里。这是整个项目的数据源头,后面所有请求都用它来带身份。
3.2 核心代码逻辑演示
整体代码结构不复杂,核心就三块:拉历史列表、解析文章信息、进详情页补全指标。直接看代码。
先定义基础请求参数和拉取历史文章列表的函数:
import requests
import json
import time
from urllib.parse import urlparse, parse_qs
BIZ = "你的__biz值"
KEY = "抓包得到的key"
PASS_TICKET = "抓包得到的pass_ticket"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://mp.weixin.qq.com/",
}
def get_history(offset=0, count=10):
url = "https://mp.weixin.qq.com/mp/profile_ext"
params = {
"action": "getmsg",
"__biz": BIZ,
"f": "json",
"offset": offset,
"count": count,
"is_ok": 1,
"scene": 124,
"uin": "777",
"key": KEY,
"pass_ticket": PASS_TICKET,
"wxtoken": "",
"x": 5,
}
resp = requests.get(url, params=params, headers=HEADERS, timeout=15)
data = resp.json()
if data.get("ret") != 0:
print("请求失败,返回:", data)
return []
msg_json = data.get("general_msg_list", "{}")
return json.loads(msg_json).get("list", [])
这里有个容易踩的坑:返回的 general_msg_list 是字符串,不是对象,必须先 json.loads 一次,否则直接按字典操作会各种报错。
拿到列表后,需要把图文消息展开,因为一篇文章可能包含多张副图文:
def parse_articles_from_msg(msg_item):
base = msg_item.get("comm_msg_info", {})
ext = msg_item.get("app_msg_ext_info", {})
if not ext:
return []
articles = []
articles.append({
"msg_id": base.get("id"),
"publish_time": base.get("datetime"),
"type": base.get("type"),
"title": ext.get("title"),
"digest": ext.get("digest"),
"content_url": ext.get("content_url", "").replace("\\/", "/"),
"cover": ext.get("cover"),
"author": ext.get("author"),
})
for sub in ext.get("multi_app_msg_item_list", []):
articles.append({
"msg_id": base.get("id"),
"publish_time": base.get("datetime"),
"type": base.get("type"),
"title": sub.get("title"),
"digest": sub.get("digest"),
"content_url": sub.get("content_url", "").replace("\\/", "/"),
"cover": sub.get("cover"),
"author": sub.get("author"),
})
return articles
注意 content_url 里有很多转义斜杠 \/ ,不处理的话直接拼链接会出错。我统一做了一次 replace("\\/", "/") ,实测最省事。
接下来是拿阅读量和点赞量。先解析文章链接里的 sn ,再去调统计接口:
def get_article_stat(content_url):
query = urlparse(content_url).query
params = parse_qs(query)
sn = params.get("sn", [""])[0]
if not sn:
return {}
api = "https://mp.weixin.qq.com/mp/getappmsgext"
payload = {
"__biz": BIZ,
"appmsg_type": 9,
"idx": 1,
"sn": sn,
"f": "json",
"key": KEY,
"pass_ticket": PASS_TICKET,
"wxtoken": "",
}
resp = requests.get(api, params=payload, headers=HEADERS, timeout=15)
data = resp.json()
if data.get("appmsgstat"):
return data["appmsgstat"]
return {}
appmsgstat 里通常包含 read_num 、 like_num 、 old_like_num 、 real_read_num 等字段,直接落到库里就行。如果返回错误的 ret ,就把当前 sn 记录到一个文本文件里,后续再重试。
3.3 翻页、去重与断点续传
历史文章列表的翻页其实就是一个 offset 累加的过程,但直接无脑累加会有两个问题:重复数据和中间中断。我的做法是维护一张 article_list 表,用文章链接的唯一性做去重,每篇文章入库前先查一次是否已存在;同时把每个公众号的当前 offset 单独存一个字段,每次任务启动时先读上次的 offset ,这样即使中途断网或者超时,也能接着跑,不用从头翻。
翻页终止条件也需要注意: get_history 返回空列表,或者 ret 不等于 0,就结束当前公众号的抓取。我个人还会加一个保险判断——如果连续三次返回空列表,强制结束,防止极端情况下接口异常导致的死循环。
给一个完整的调度逻辑伪代码:
offset = get_last_offset(BIZ)
while True:
msg_list = get_history(offset=offset)
if not msg_list:
break
for msg in msg_list:
articles = parse_articles_from_msg(msg)
for art in articles:
save_to_db(art)
time.sleep(random.uniform(1, 3))
offset += len(msg_list)
save_offset(BIZ, offset)
time.sleep(random.uniform(2, 5))
随机延迟我写进了循环里,不是为了装模作样,是真的能大幅降低风控命中概率。早期不加延迟,几乎每次跑到两三百条就被限制。
4. 常见问题与排查技巧实录
4.1 高频报错对照表
跑过这个项目的人,基本都会遇到下面几个报错。我整理成一个速查表,遇到问题直接对着排查:
| 报错信息 | 出现原因 | 解决办法 |
|---|---|---|
invalid key / key expired | key 或 pass_ticket 过期 | 重新抓包获取最新的 key 和 pass_ticket,替换后重试 |
freq control / ret: 200003 | 请求频率过高,触发风控 | 增加随机延迟,降低并发,暂停一段时间再跑 |
链接内容不属于当前公众号 | content_url 里的 __biz 与当前配置不匹配 | 核对文章 URL 中的 __biz,确认抓取的是该公众号下的文章 |
| 阅读量和点赞量返回 -1 | 统计接口被风控或条件不满足 | 清理本地代理缓存,更换新 key,降低请求频率 |
| 评论接口返回空 | 文章未开启评论,或 comment_id 获取失败 | 确认页面中能否看到评论;重新从 HTML 解析 comment_id |
| 正文提取为空 | 请求文章页面时被重定向或需要验证 | 补全 headers 里的 Referer、User-Agent,重新请求 |
这里面最坑的是“链接内容不属于当前公众号”。这个报错我刚开始以为是代码问题,排查半天,最后发现是某篇文章的 content_url 里带的 __biz 是另一个公众号的值。出现这种情况,多半是历史列表里混入了一篇转载文章或者广告投放文章。我的处理方式是在解析函数里加一道校验:只有 __biz 和当前配置一致的文章才入库,不一致的直接丢弃并记录日志,不阻塞整个抓取流程。
4.2 降频与合规经验
风控这块是绕不过去的,分享几个实测下来有效的降频手段。
请求节奏上,每篇文章详情页请求之间随机延迟 1 到 3 秒,每翻一页延迟 2 到 5 秒;单公众号单轮任务最多抓 500 篇就主动暂停,等 15 分钟再继续。如果同时跑多个公众号,我采用串行处理,而不是开多个线程同时抓,因为一旦并发,PC 微信端会立刻检测到异常流量,key 失效得极快。
再就是账号管理。我建议用一个日常使用频率低、没有敏感操作的微信号来做这件事,不要用主号。每次抓取前先手动打开几个公众号文章,让微信客户端“热身”,再开始跑任务,实测能降低很多无谓的风控拦截。
合规方面,我的原则只有一条:只抓取用于个人研究和学习的数据,不对外传播、不洗稿、不用于任何商业变现。微信公众平台和各个公众号的资源都属于内容生产者的劳动成果,爬虫只是数据获取工具,最终怎么使用,决定权在每个人自己手上。
5. 从单号抓取到矩阵监控
5.1 接入定时任务与异常通知
跑通单号抓取后,下一步自然是让它每天自动执行。我的方案是在服务器上用系统计划任务每天凌晨定时跑一次,跑完把新增文章写入数据库,再统计出前一天的阅读增量。定时任务最怕的就是静默失败,所以我加了一个异常通知:脚本运行结束时,如果任务过程中有持续的错误日志,或者是完全没有抓取到新数据,就通过企业微信机器人推一条告警到群里。
具体实现不复杂:
def send_alert(msg):
webhook = "你的企业微信机器人webhook"
payload = {"msgtype": "text", "text": {"content": msg}}
requests.post(webhook, json=payload, timeout=10)
这个动作看着小,但非常实用。没有告警之前,我曾经连续三天没发现问题,数据库里全是旧数据,等于白跑。
5.2 多公众号统一管理
单号稳定之后,我把配置改成了列表驱动,把二十几个公众号的 __biz 和对应的抓取状态统一放在一个 JSON 配置文件里,循环遍历。每个公众号维护独立的 offset 和上次抓取时间,互不干扰。
这里会牵扯到一点小技巧:多个公众号的 key 其实是同一个微信登录账号的 key,所以只要在一个公共模块里统一管理 key 和 pass_ticket,所有公众号共用即可。一旦 key 过期,只需要重新抓一次包,全局更新。如果某个公众号接口返回 invalid key ,不影响其他公众号继续抓,因为每个公众号都是独立循环,报错时直接把当前公众号标记为“待重试”,跳过去处理下一个。
这样做到后面,每天的数据自动汇总,我只需要在周报里看一眼阅读曲线就能判断内容走向。整个项目从纯手工到全自动,我个人的体感是省下的不是一点点时间,而是把精力真正还给了内容分析本身。
最后分享一个我踩过多次坑之后总结出来的经验:这类抓取任务,最重要的不是代码写得有多花哨,而是节奏控制。一次抓多少、间隔多久、什么时候停,这些细节决定了你能跑多久不出问题。先小批量调通流程,再逐步加量,远远好过一上来就开全速。我后期跑得最稳的配置,反而是所有策略里最保守的那套。
更多推荐


所有评论(0)