微信爬虫实战:公众号历史文章链接与阅读量抓取全解析
简介:这是一份基于 Node.js 的微信爬虫项目源码,面向需要批量采集公众号数据的开发者与数据分析人员,通过 AnyProxy 中间人代理原理抓取微信文章内容、阅读量、点赞量、在看数及评论等指标,并支持获取公众号全部历史文章链接。项目已适配 AnyProxy 4,可运行于个人电脑或服务器,并提供 Docker 部署方案,使用前需准备 Node 8.8.1 以上、MongoDB 3.4.6 以上及 Redis 环境。压缩包共 80 个文件,约 1.13MB,以 40 个 js 与 9 个 jsx 业务代码为主,辅以 json 配置、html 页面、png/svg 图标、woff2/ttf 等字体资源,以及 Dockerfile、docker-compose.yml、证书文件与 README 说明,结构覆盖客户端、服务端、数据模型与导出脚本。目前已有 2623 人学习下载,适合想研究代理抓包、微信数据采集与 Node 全栈实现的读者参考,可从中了解请求拦截、数据落库与导出流程的完整思路。
1. 微信爬虫到底能拿到什么:从阅读量到历史文章链接的完整数据面
做公众号内容分析的人,迟早会撞上一堵墙:后台只给你看自己的数据,别人的号你只能靠肉眼一条条翻。想批量拿到文章正文、阅读量、点赞量、评论,甚至把某个号从注册第一天到昨天的所有历史文章链接一次性拉出来,靠手工复制粘贴根本不现实。 wechat_spider 这类微信爬虫要解决的正是这个问题——它把公众号当成一个可枚举、可抓取的数据源,把散落在文章页里的结构化字段抽出来落库。适合做竞品内容分析、选题库搭建、行业舆情监控,以及把公众号文章喂进知识库或 AI 摘要流水线的团队。但先说清楚:这不是一个装完就能跑的傻瓜工具,微信的反爬策略和页面结构变化会让它时不时翻车,你得理解它怎么工作,才能在自己环境里调通。
2. 抓取链路拆解:从公众号到文章链接的三种入口
2.1 公众号历史文章链接是怎么被枚举出来的
公众号本身没有公开的「全部文章列表」接口,历史文章链接的获取依赖几个入口的组合。最常见的是通过公众号的「历史消息」页面,这个页面在移动端和 PC 端都能访问,返回的是按时间倒序排列的文章卡片,每张卡片带标题、封面、发布时间和一个指向文章详情的链接。翻页靠的是 offset 和 count 参数,但微信对翻页深度有限制,翻到一定页数后会返回空列表或要求验证。
另一种入口是搜狗微信搜索,它能按公众号名检索到该号最近的文章,但收录不全,老文章经常缺失。第三种是通过文章底部的「阅读原文」或关联号跳转,适合做定向补漏。实操中我一般以历史消息页为主,搜狗做补充,两者取并集再去重。
枚举阶段的核心是维护一个 biz 参数,这是公众号的唯一标识,形如 MzA5MTc0... 的一串 Base64 编码。拿到 biz 之后,历史消息接口的请求才能定位到具体公众号。 biz 通常从任意一篇该号文章的页面源码里提取,搜索 var biz = 就能找到。
import re
import requests
def extract_biz(article_url):
"""
从一篇文章的页面源码里提取公众号 biz 标识
article_url: 任意一篇该公众号文章的链接
"""
headers = {
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
}
resp = requests.get(article_url, headers=headers, timeout=10)
resp.encoding = "utf-8"
# biz 一般出现在 var biz = "xxx" 或 window.biz = "xxx"
match = re.search(r'var\s+biz\s*=\s*"([^"]+)"', resp.text)
if not match:
match = re.search(r'window\.biz\s*=\s*"([^"]+)"', resp.text)
return match.group(1) if match else None
这段代码的逻辑很直接:请求文章页,用正则从源码里抠出 biz 。参数上要注意 User-Agent 必须伪装成移动端,PC 端的 UA 拿到的页面结构不同, biz 可能藏在别的位置。 timeout 设 10 秒是经验值,微信页面偶尔响应慢,设太短会误判失败。如果正则匹配不到,先检查页面是不是被重定向到了验证页,那种情况下源码里根本没有 biz 。
拿到 biz 之后,构造历史消息请求。这个请求需要带上 Cookie ,而 Cookie 里最关键的是 wap_sid2 之类的登录态字段。没有有效 Cookie ,历史消息接口会返回 ret: -3 或直接跳验证。 Cookie 的获取方式是在浏览器或抓包工具里登录一次公众号文章页,把请求头里的 Cookie 整段复制出来。
def fetch_history_links(biz, cookie, offset=0, count=10):
"""
拉取公众号历史文章列表
biz: 公众号唯一标识
cookie: 登录态 Cookie 字符串
offset: 翻页偏移
count: 每页数量,一般不超过 10
"""
url = "https://mp.weixin.qq.com/mp/profile_ext"
params = {
"action": "getmsg",
"biz": biz,
"offset": offset,
"count": count,
"f": "json"
}
headers = {
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
"Cookie": cookie,
"Referer": "https://mp.weixin.qq.com/"
}
resp = requests.get(url, params=params, headers=headers, timeout=10)
data = resp.json()
# 返回结构里 general_msg_list 是 JSON 字符串,需要二次解析
return data
这里 offset 每次递增 count ,直到返回的 general_msg_list 为空或 can_msg_continue 为 0。 count 设 10 是稳妥值,设太大容易被限。返回的 general_msg_list 是一个被转义的 JSON 字符串,里面每条消息的 comm_msg_info 带发布时间, app_msg_ext_info 带标题、摘要、封面和 content_url ,也就是文章链接。多图文消息里 multi_app_msg_item_list 会包含第二条及以后的链接,遍历时别漏掉。
2.2 文章正文、阅读量、点赞量的字段定位
拿到文章链接后,正文和互动数据的抓取是另一套逻辑。文章详情页的 HTML 里,正文在 <div id="js_content"> 里,阅读量和点赞量不在初始 HTML 中,而是通过页面里的 JS 变量或异步接口返回。常见做法是请求文章页后,从源码里搜 var read_num 、 var like_num 、 var old_like_num 这些变量,它们通常以 var read_num = "12345" 的形式内联在页面里。
def parse_article_detail(article_url, cookie):
"""
解析文章正文、阅读量、点赞量
"""
headers = {
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
"Cookie": cookie
}
resp = requests.get(article_url, headers=headers, timeout=10)
html = resp.text
# 正文提取
content_match = re.search(r'<div[^>]*id="js_content"[^>]*>(.*?)</div>\s*<script', html, re.S)
content_html = content_match.group(1) if content_match else ""
# 阅读量、点赞量
read_num = re.search(r'var\s+read_num\s*=\s*"(\d+)"', html)
like_num = re.search(r'var\s+like_num\s*=\s*"(\d+)"', html)
old_like = re.search(r'var\s+old_like_num\s*=\s*"(\d+)"', html)
return {
"content_html": content_html,
"read_num": int(read_num.group(1)) if read_num else 0,
"like_num": int(like_num.group(1)) if like_num else 0,
"old_like_num": int(old_like.group(1)) if old_like else 0
}
正文正则用 re.S 让 . 匹配换行,因为正文里肯定有换行。 js_content 后面的 </div> 要跟 \s*<script 配合,避免匹配到正文内部嵌套的 div 提前截断。阅读量变量名在不同时期有变化,有的页面用 read_num ,有的用 appmsg_read_num ,实际跑的时候把几个候选都试一遍。点赞量分新旧两套, like_num 是「在看」数, old_like_num 是老的点赞数,做数据分析时建议两个都存。
评论数据不在文章页里,而是通过 https://mp.weixin.qq.com/mp/appmsg_comment 接口拉取,需要传 appmsgid 、 idx 、 comment_id 等参数,且必须带有效 Cookie 。评论接口的翻页用 offset ,返回的 elected_comment 数组里每条带 content 、 like_num 、 create_time 。这个接口对频率很敏感,连续请求几次就会触发验证,建议加 2 到 5 秒随机延迟。
2.3 数据落库与去重策略
抓下来的数据建议直接进 SQLite 或 MySQL,字段至少包括 biz 、 title 、 content_url 、 publish_time 、 read_num 、 like_num 、 comment_count 、 crawl_time 。去重靠 content_url 建唯一索引,因为同一篇文章可能从历史消息和搜狗两个入口各抓到一次。
CREATE TABLE IF NOT EXISTS wechat_articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
biz TEXT NOT NULL,
title TEXT,
content_url TEXT UNIQUE,
publish_time INTEGER,
read_num INTEGER DEFAULT 0,
like_num INTEGER DEFAULT 0,
comment_count INTEGER DEFAULT 0,
content_html TEXT,
crawl_time INTEGER
);
content_url 加 UNIQUE 约束后,插入时用 INSERT OR IGNORE 就能自动跳过重复。 publish_time 存 Unix 时间戳,方便按时间范围查询。 content_html 字段可能很大,如果只做分析不存原文,可以改成存正文纯文本或摘要。
3. 参数调优与反爬对抗:让抓取稳定跑下去的几个关键设置
3.1 Cookie 有效期与刷新节奏
Cookie 是整个链路里最容易过期的一环。微信的登录态一般能维持几小时到一天,具体取决于账号活跃度和请求频率。判断 Cookie 失效的信号是接口返回 ret: -3 或 base_resp.ret: -3 ,或者历史消息接口返回空列表但 biz 确认无误。我一般会在代码里加一个检测逻辑:每次请求后检查 ret 字段,一旦为负值就暂停任务并告警,而不是继续硬跑,因为硬跑会加速账号被限制。
刷新 Cookie 的方式是重新登录一次文章页,把新的 Cookie 替换进去。如果做长期任务,建议准备多个账号轮换,每个账号的 Cookie 独立维护,请求时随机选一个。轮换的粒度可以按请求次数,比如每 50 次请求换一个 Cookie 。
3.2 请求频率与延迟控制
微信对高频请求的容忍度很低。实测下来,历史消息接口每分钟超过 20 次请求就容易触发验证,文章详情页每分钟超过 30 次也会被限。稳妥的做法是给每个请求加随机延迟,延迟范围设在 2 到 5 秒之间,并且每抓完一个公众号就休息 30 到 60 秒。
import time
import random
def safe_request(url, headers, params=None, min_delay=2, max_delay=5):
"""
带随机延迟的请求封装
"""
time.sleep(random.uniform(min_delay, max_delay))
resp = requests.get(url, headers=headers, params=params, timeout=10)
# 检测是否被限
if resp.status_code == 200 and '"ret":-3' in resp.text:
raise RuntimeError("Cookie 失效或被限,需要更换")
return resp
min_delay 和 max_delay 根据实际被封情况调整,如果发现频繁触发验证,把下限提到 5 秒。 random.uniform 比固定 sleep 更不容易被识别为脚本,因为固定间隔的请求模式太规律。
3.3 正文提取的容错处理
正文提取翻车的情况主要有三种:页面被重定向到验证页、 js_content 的 id 变了、正文里嵌套了复杂的 div 导致正则截断。应对方式是在提取前先判断页面标题是否包含「验证」或「环境异常」,如果是就直接跳过并记录该链接待重试。 js_content 的 id 目前稳定,但保险起见可以同时匹配 id="js_content" 和 class="rich_media_content" 。
正则截断的问题可以用 HTML 解析库替代,比如 BeautifulSoup 或 lxml ,它们能正确处理嵌套结构。但解析库会慢一些,如果追求速度且页面结构稳定,正则够用;如果页面结构经常变,上解析库更省心。
from bs4 import BeautifulSoup
def parse_content_bs(html):
"""
用 BeautifulSoup 提取正文,容错更好
"""
soup = BeautifulSoup(html, "lxml")
content_div = soup.find("div", id="js_content")
if not content_div:
content_div = soup.find("div", class_="rich_media_content")
if not content_div:
return ""
# 去掉脚本和样式
for tag in content_div(["script", "style"]):
tag.decompose()
return str(content_div)
lxml 解析器比默认的 html.parser 快,但需要额外安装。 decompose() 把脚本和样式标签整个删掉,避免正文里混入 JS 代码。返回的是 HTML 字符串,如果只要纯文本,再加一步 content_div.get_text(strip=True) 。
4. 避坑与排查:微信爬虫最常见的五类翻车现场
4.1 历史消息翻页到一半返回空列表
现象:前几页正常返回文章,翻到第 5 页或第 10 页后 general_msg_list 变成空数组, can_msg_continue 为 0。
原因:微信对历史消息的翻页深度有限制,通常只能翻到最近几个月或最近一定数量的文章,更早的文章不再通过这个接口返回。另外 Cookie 中途失效也会导致后续页返回空。
解决:先确认 Cookie 是否还有效,换一个新 Cookie 重试。如果 Cookie 没问题,说明已经到达翻页上限,此时改用搜狗微信搜索按时间范围补抓老文章,或者通过已知的老文章链接反查同号的其他文章。不要试图通过改 offset 绕过,微信服务端有硬限制。
4.2 阅读量抓下来全是 0
现象:文章正文能抓到,但 read_num 和 like_num 都是 0,正则匹配不到。
原因:阅读量变量不在初始 HTML 里,而是通过异步接口返回,或者变量名变了。部分文章在未登录状态下不展示阅读量。
解决:先检查 Cookie 是否带上,未登录状态下阅读量字段确实可能缺失。如果 Cookie 正常,把页面源码保存下来,搜 read_num 、 appmsg_read_num 、 read_count 等候选变量名,找到实际使用的那个。如果都不在 HTML 里,说明阅读量走的是异步接口,需要抓 https://mp.weixin.qq.com/mp/getappmsgext 这个接口,传 appmsgid 和 idx 参数。
4.3 评论接口返回 ret: -3 或空数据
现象:评论接口请求后返回 ret: -3 ,或者 elected_comment 为空但文章明明有评论。
原因:评论接口对 Cookie 和请求频率的要求比文章页更严, Cookie 稍有问题就返回 -3 。另外评论接口需要传 comment_id ,这个值要从文章页源码里提取,传错或漏传也会返回空。
解决:确认 Cookie 有效,并且请求间隔至少 3 秒。 comment_id 在文章页源码里搜 comment_id 就能找到,形如 var comment_id = "1234567890" 。如果评论接口持续返回 -3 ,换一个账号的 Cookie 再试,有些账号的评论接口权限会被单独限制。
4.4 正文里的图片链接失效
现象:抓下来的正文 HTML 里图片 src 是 https://mmbiz.qpic.cn/... ,但过一段时间图片打不开。
原因:微信的图片链接带防盗链,直接访问会返回 403,而且部分链接有时效性。
解决:抓取时把图片下载到本地或自己的对象存储,替换正文里的 src 为本地地址。下载图片时请求头要带 Referer: https://mp.weixin.qq.com/ ,否则会被拒。如果只需要文字内容,可以在入库前把 img 标签整个去掉。
4.5 多图文消息只抓到第一条
现象:一个公众号一次推送了 3 篇文章,但只抓到第一条的链接。
原因:历史消息接口返回的每条消息里,第一条在 app_msg_ext_info ,第二条及以后在 multi_app_msg_item_list 数组里,遍历时只处理了 app_msg_ext_info 就会漏掉后面的。
解决:解析时先取 app_msg_ext_info ,再检查 multi_app_msg_item_list 是否存在,存在就遍历追加。每条子消息的 content_url 字段名和第一条一致,直接复用提取逻辑即可。
5. 把抓取结果接进知识库与 AI 摘要流水线
抓下来的文章如果只是躺在数据库里,价值有限。真正让这套东西产生复利的是把正文清洗后送进知识库或 AI 摘要流程。我一般会做三步:正文转纯文本、按段落切分、调用摘要接口生成结构化摘要。
正文转纯文本用 BeautifulSoup 的 get_text 就行,但要注意去掉多余空行和特殊字符。切分按段落切,每段控制在 500 到 800 字,太短会丢上下文,太长摘要接口可能截断。摘要接口的调用这里不展开具体平台,核心是把切分后的段落拼成 prompt,要求返回「主题 + 关键结论 + 涉及数据」三段式结构。
def clean_and_split(content_html, max_len=800):
"""
正文转纯文本并按段落切分
"""
soup = BeautifulSoup(content_html, "lxml")
text = soup.get_text(separator="\n", strip=True)
# 去掉连续空行
lines = [line.strip() for line in text.split("\n") if line.strip()]
paragraphs = []
current = ""
for line in lines:
if len(current) + len(line) > max_len:
paragraphs.append(current)
current = line
else:
current += line
if current:
paragraphs.append(current)
return paragraphs
separator="\n" 让每个块级元素后换行, strip=True 去掉首尾空白。切分逻辑是贪心拼接,超过 max_len 就另起一段。 max_len 设 800 是平衡摘要质量和接口限制的经验值,如果摘要接口支持更长输入,可以调到 1500。
验证抓取质量的一个实用技巧是随机抽 10 篇文章,人工核对标题、发布时间、阅读量三个字段是否和手机端看到的一致。如果阅读量偏差超过 5%,说明抓取时机和页面展示时机有差异,属于正常现象,因为阅读量是动态增长的。但标题和发布时间必须完全一致,不一致就说明解析逻辑有问题。
最后说个我自己的习惯:每次改完解析代码,先拿 3 个不同公众号各跑 10 篇,确认字段完整再放量。直接全量跑一旦解析出错,浪费的是时间和账号信誉。这套东西的边界在于,它适合做公开数据的批量采集和分析,不适合做实时监控,因为延迟和限频决定了它快不起来。希望帮到你。
更多推荐
所有评论(0)