简介:这是一份基于 Node.js 的微信爬虫项目资源,通过中间人代理原理,利用 AnyProxy 拦截解析微信 HTTPS 请求,批量获取公众号历史文章正文、阅读量、点赞量、在看数及评论数据。项目面向有一定 JavaScript 基础、希望搭建公众号数据采集体系的开发者与爬虫工程师,既可在个人电脑运行,也可通过 Docker 部署至服务器。压缩包共 80 个文件,容量 1.13MB,以 40 个 js 文件和 9 个 jsx 文件为核心逻辑,配套 4 个 json 配置、3 个 html 页面、证书 crt/key 文件及 Dockerfile、docker-compose.yml 等内容,便于一键构建运行环境。资源内含完整的客户端与服务端代码、代理规则、数据模型及导出工具,还包含 MongoDB/Redis 接入和微信 ID 校正等辅助脚本,可帮助读者深入理解微信网页版接口的抓取思路。目前已有 2622 人学习,适合作为公众号数据采集、爬虫代理技术的实战参考。
1. 微信爬虫的开局:先把后台接口当成唯一可靠的数据源
微信爬虫要做的事情,说穿了就是把公众号某一天发过的所有文章、每篇的阅读量点赞量评论全部留下来。很多人一上来就去公众号主页模拟滚动加载,或者去翻朋友圈里转发的链接,结果要么被大量推荐流干扰,要么抓下来的 URL 残缺不全。wechat_spider 这个项目换了个思路:绕开主页,直接对接微信公众平台网页后台的列表数据接口。把这些接口放倒,你就能拿到某个公众号全部历史文章的链接、标题、发布时间,以及正文、阅读量、点赞量和评论数据。适合做内容备份、竞品账号分析和自媒体数据复盘的人。
2. 原理先行:数据源头与四个 URL 参数的拼接规则
2.1 数据源头:先学会在浏览器里找到那个 appmsg 请求
微信的所有公众号文章数据,最终都收口在微信公众平台网页后台。你肉眼能看到的“素材库”“超链接搜索”“历史消息”,前端都是通过 cgi-bin 下的接口从服务端拿数据。wechat_spider 的做法,就是把这个后台操作模拟出来:用 JavaScript 直接请求接口,把返回的 JSON 抓下来分析。相比在公众号主页手动滚页面,接口方式有三个实际好处:返回结构稳定、字段带时间戳、分页可控。主页渲染出来的页面会掺杂大量推荐内容和广告位,干扰解析,而后台的列表接口只关心你搜索的目标,干净得多。
常见做法是固定一组参数:action=list_ex、begin表示从第几条开始、count表示每页条数、fakeid是目标公众号在后台体系里的唯一标识、token是你当前登录会话的凭证。部分资料里会建议用search类型接口做广义搜索,但对于“拿到全部历史文章链接”这个目标,list_ex更直接,因为它的分页语义就是按发表时间倒序翻完整列表。下面的代码是我平时初始化项目时的依赖安装和请求入口封装:
mkdir wechat_spider && cd wechat_spider npm init -y npm install axios cheerio node -e "console.log('ready')"安装部分还好说,真正要命的是登录态。axios 本身没有浏览器那种 Cookie 自动管理机制,所以得在请求头里手动带上公众号后台的 Cookie。我的习惯是把 Cookie 和 token 抽到一个本地配置文件里,不进版本库。请求封装差不多长这样:
const axios = require('axios'); const config = { cookie: '你的公众号后台Cookie', token: '你的会话token', fakeid: '目标公众号的fakeid', begin: 0, count: 10 }; axios.defaults.headers.common['Cookie'] = config.cookie; async function fetchAppMsgList(begin = 0, count = 10) { const url = 'https://mp.weixin.qq.com/cgi-bin/appmsg'; const params = { action: 'list_ex', begin, count, fakeid: config.fakeid, token: config.token }; const rsp = await axios.get(url, { params }); return rsp.data; }fetchAppMsgList返回的是整个后台列表接口的 JSON。最外层有ret和errmsg两个字段,ret为 0 表示这次拉取有效;真正的文章数据在app_msg_list数组里。begin是偏移量,第一页传 0,第二页传 10,以此类推。count我固定写 10,这个接口对每页条数卡得比较死,试着传 20 或 50 很容易被直接拒绝,属于典型的“看着参数合理但拿不到数据”的情况。
2.2 四个关键参数:__biz、mid、idx、sn 到底是谁生成的
拿到的app_msg_list里每个元素都带一个link字段,那才是文章的完整地址,形如https://mp.weixin.qq.com/s?__biz=MzA3XXXX==&mid=2650XXXX&idx=1&sn=9a2b3c...。手动去重写规则的人最容易踩的坑,就是看到 link 太长就想自己拼接简化,结果拼出来的地址要么打不开,要么打开后提示“链接内容不属于当前公众号”。
这四个参数的分工如下:
| 参数 | 作用 | 是否能自己生成 |
|---|---|---|
| __biz | 公众号唯一标识,类似账号 ID | 可从列表中提取,不要手改 |
| mid | 消息 ID,对应一篇图文素材 | 服务端生成 |
| idx | 当日多图文里的第几条,1 为主图文 | 服务端生成 |
| sn | 防伪签名,校验链接合法性 | 不能生成,必须原样使用 |
注意,sn是整条链接里最容易出问题的一个。它包含数字、字母和下划线,偶尔还带%2F之类的 URL 编码,复制到代码里时如果被 IDE 自动转义,或者被 JSON 解析器吃掉一部分,访问就会 404。所以我的习惯是:无论列表里拿到的是什么,就原样存什么,绝不在这一步做任何字符串加工。idx和mid配合起来可以定位到同一篇文章,但真正能校验链接合法性的只有sn,它是由服务端算好下发的,前端无法伪造。
2.3 详情页数据分布:正文、阅读量、点赞量与评论各在哪
文章链接拿到之后,详情页本身是一段渲染完整的 HTML。正文文本和图片在#js_content节点里,可以用 cheerio 按选择器摘出来。阅读量和点赞量不在 HTML 里,它们通过页内 JavaScript 变量注入,常见的是window.read_num和window.like_num。评论则要走另一个独立接口,参数是__biz、appmsgid、idx、comment_id,返回值里包含评论列表和总条数。
这里有个取舍点:为什么不直接模拟浏览器渲染拿数据?因为渲染完的 DOM 里仍然只有正文,阅读量这类数据同样来自异步接口。与其多开一个无头浏览器增加资源消耗,还不如直接用 axios 请求页面源码,再用正则把页内变量抠出来,可靠性更高,也更好排查。评论区接口单独走的路径和文章详情页不同,我在后面的实现章节里会给出完整请求方案。
3. 落地实现:抓正文、阅读量、点赞量与评论的完整流程
3.1 前置准备:从浏览器复制 Cookie 和 token
先把登录态准备好。打开浏览器登录微信公众平台后台,在“已发表”列表页按 F12 打开开发者工具,切到 Network 面板,随便点击一次翻页操作,找到名为appmsg的请求。从 Request Headers 里复制 Cookie,从 Query String Parameters 里复制 token。这两个值就是后面所有请求的通行证。
然后写一个简单的配置模块 config.js:
module.exports = { cookie: '粘贴你从浏览器复制的Cookie', token: '粘贴当前会话的token', fakeid: '目标公众号的数字fakeid', baseURL: 'https://mp.weixin.qq.com/cgi-bin', interval: 3000, output: './output' };interval是每两次请求之间的间隔毫秒数,我一般设 3000,也就是 3 秒一次。别嫌慢,公众号后台接口对频率的敏感度比普通网站高一个数量级,连续快速翻页很容易把ret变成错误码。output决定数据文件落在哪里,建议单独建目录,避免和代码混在一起。fakeid这一项要注意,它不是公众号的微信号,而是后台接口里那个一串数字的 ID,同一个公众号在不同登录账号下看到的 fakeid 可能不一样,一定要以当前登录会话里抓到的为准。
3.2 分页拉取全部历史文章链接
接下来是核心循环:从begin=0开始,每轮请求一页,拿到app_msg_list后把文章对象完整保存,然后将begin增加 10,直到接口返回空列表。注意,退出条件必须写成“app_msg_list为空数组”,并且每次要检查ret字段,因为登录态失效时接口也可能返回空列表,两种情况的处理逻辑完全不同。
const config = require('./config'); const axios = require('axios'); const fs = require('fs'); const path = require('path'); const client = axios.create({ baseURL: config.baseURL, headers: { Cookie: config.cookie } }); async function pullAllArticles(fakeid, token) { const items = []; let begin = 0; const seen = new Set(); while (true) { const rsp = await client.get('/appmsg', { params: { action: 'list_ex', begin, count: 10, fakeid, token } }); const data = rsp.data; if (data.ret !== 0) { console.log('接口返回异常,ret=', data.ret, data.errmsg); break; } const list = data.app_msg_list || []; if (list.length === 0) { console.log('列表为空,采集结束'); break; } for (const item of list) { if (!seen.has(item.appmsgid)) { seen.add(item.appmsgid); items.push(item); } } begin += 10; await sleep(config.interval); } fs.writeFileSync( path.join(config.output, 'articles.jsonl'), items.map((it) => JSON.stringify(it)).join('\n'), 'utf-8' ); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }seen集合用来防止同一篇图文在翻页时重复出现,appmsgid是图文素材的全局唯一 ID,比用链接做去重更可靠。sleep放在每次请求之后,包括最后一次成功请求,目的是把请求节奏摊开。数据落盘用 JSONL 格式,一行一篇文章,后续追加增量数据时不用重写整个文件。
实际跑的时候,第一次可以先用begin=0拉 3 页试试水,看返回里的app_msg_count字段。那个字段是后台记录的总篇数,用它做结束条件的前置判断,能提前发现分页是否漏数据。
3.3 解析详情页:正文、阅读量、点赞量一次拿全
拿到文章链接后,下一步是请求详情页并解析。cheerio 处理 HTML,正则处理页内阅读量和点赞量的 JavaScript 变量,整个过程不需要渲染浏览器,速度和稳定性都更有保障。
const cheerio = require('cheerio'); async function parseDetail(link) { const rsp = await client.get(link); const html = rsp.data; const $ = cheerio.load(html); const title = $('#activity-name').text().trim(); const content = $('#js_content').text().trim(); const publishTime = $('#publish_time').text().trim(); const readNumMatch = html.match(/var\s+read_num\s*=\s*"?(\d+)"?/); const likeNumMatch = html.match(/var\s+like_num\s*=\s*"?(\d+)"?/); return { title, content, publishTime, readNum: readNumMatch ? Number(readNumMatch[1]) : 0, likeNum: likeNumMatch ? Number(likeNumMatch[1]) : 0, link }; }正则匹配var read_num = 123这种写法,针对的是绝大多数公众号详情页面的标准输出。如果页面升级成从异步接口加载阅读量,页面上就不会再有这个变量,这时候readNum会是 0。判断的要点不是数字本身,而是页面里有没有read_num字样,没有就说明这台页面的数据源变了,需要改用下面的补充方案。保底做法是抓正文和标题的同时去请求评论区接口,评论区响应里通常附带阅读量参照值,虽然口径不完全一致,但能确认页面是否拉到了最新数据。
3.4 评论数据:独立接口与字段解析
评论区接口的路径和文章列表接口不在同一个 cgi-bin 目录下,常见形式是https://mp.weixin.qq.com/mp/comment/show。这个路径偶尔会变,但参数语义一直很稳定:__biz、appmsgid、idx、comment_id。稳妥的做法是从详情页 HTML 的comment_id变量里直接读值,再去请求评论接口。
async function fetchComments(link, { __biz, appmsgid, idx }) { const page = await client.get(link); const commentIdMatch = page.data.match(/comment_id\s*=\s*"(\d+)"/); if (!commentIdMatch) { return []; } const url = 'https://mp.weixin.qq.com/mp/comment/show'; const rsp = await client.get(url, { params: { action: 'get', __biz, appmsgid, idx, comment_id: commentIdMatch[1] } }); const comments = rsp.data.comment || []; return comments.map((c) => ({ nickname: c.nick_name, content: c.content, createTime: c.create_time, likeNum: c.like_num })); }comment_id是一次性 ID,和appmsgid必须配套使用,单独拿comment_id去请求别的文章拿不到数据。评论接口返回的字段里还有精选评论和未精选评论的区别,普通页面默认只显示精选部分,统计评论总数时要以接口返回的total_count字段为准,不要自己数数组长度。
4. 避坑指南:微信爬虫的 5 个翻车现场
4.1 阅读量永远是 0 或 undefined
现象:文章链接能打开,正文、标题都正常,但readNum和likeNum全部是 0。
原因:详情页的变量名不是read_num,而是在某些账号体系里变成了appmsg_read_num,或者页面压根没有变量,阅读量由单独的异步接口返回。不同认证状态下的公众号页面输出并不完全一致,这事只能认版本不能靠猜。
解决:正则放宽条件,匹配任意window.*read.*=模式,先打印原始匹配结果确认字段名,再写死解析规则。我一般会在解析函数里加一行console.log(html.slice(0, 2000)),看看页面顶部到底有哪些变量再做正则。这类问题十次有九次是页面结构升级造成的,属于爬虫家常便饭。
4.2 分页拉到 50 篇后开始重复返回同一批数据
现象:begin已经按 10 递增,但返回的app_msg_list永远是最开始那 10 篇。
原因:cookie 或 token 失效后,接口不再返回真实分页数据,而是兜底给了默认列表。接口本身不报错,ret仍然是 0,单看返回很难发现问题。
解决:每次请求后用appmsgid做去重,并统计本轮新增数量。如果连续两轮新增数都是 0,就停止循环并打印“疑似登录态失效”。从那以后我每轮都会把ret、begin、list.length三个值打出来,看输出就能定位问题,不用等全部跑完才发现数据是重复的。
4.3 手动打开链接提示“链接内容不属于当前公众号”
现象:抓下来的链接手动访问,页面提示链接内容不属于当前公众号,文章打不开。
原因:链接被二次加工过。最常见的动作是有人试图把__biz里的%3D还原成等号,或把sn里的+号转义成空格,结果破坏了签名校验。微信的链接参数只认原样字符串。
解决:不要手工拼接链接,直接用列表接口返回的link字段。存储时用 JSON 里那份原始值,不经过任何 URL 解析库。这一步省掉之后,这类提示几乎不会再出现。
4.4 抓了 40 篇后 app_msg_list 开始返回空数组
现象:前几页正常,某一页开始数组为空,且ret=0,没有任何报错。
原因:请求频率触发了隐式限流。公众号后台的列表接口虽然没有把错误码抛给你,但会悄悄停止返回真实数据。整个过程像黑匣子一样没有明显信号。
解决:interval调大到 5 秒以上,并且把并发数锁死为 1。同时记录最近 5 次请求的实际响应时间,如果响应时间异常缩短,大概率是数据被缓存了,应该在本地做一次计数校验。这算是我踩出来的血泪经验,不要依赖接口报错来判断是否限流。
4.5 评论接口返回空,但页面上明明有留言
现象:页面上能看到几十条评论,评论接口返回comment数组却是空的。
原因:请求评论接口时少了idx参数,或者用的appmsgid和详情页里的实际appmsgid不一致。评论接口校验的是“文章级”标识,任何一个参数不匹配都会返回空结果。
解决:从详情页 HTML 里提取appmsgid、idx、comment_id三个值,而不是用列表接口里那个appmsgid。列表接口的appmsgid在特殊账号下可能和详情页的 id 指向不同,这种偏差只有复现时才会被发现。
5. 数据校验:如何证明抓到的就是全部历史文章
5.1 两个口径对账:接口统计 vs 本地去重数
采集结束后最怕的不是没抓到,而是抓漏了还当成完整数据用。校验思路也不复杂:把“后台认为的总数”和“本地实际落盘的去重数”做一次对账。
| 校验项 | 数据来源 | 通过标准 |
|---|---|---|
| 接口总篇数 | app_msg_count 字段 | 与本地总数接近或一致 |
| 去重文章数 | 本地按 appmsgid 去重 | 无重复 |
| 时间范围 | update_time 最小值到最大值 | 覆盖目标起始时间 |
| 链接可访问 | 抽样 5% 手动打开 | 无 404 |
app_msg_count在接口返回里是最权威的总数,但它可能包含已删除文章的残留计数,所以本地数量略少是正常现象,差距超过 10% 就要回头查分页循环了。
5.2 四参数指纹与标题双重校验
只对上总数还不够,万一中间漏了一页,总数反而对得上呢。所以要做第二层校验:把每一篇文章的__biz、mid、idx、sn四个参数拼接成指纹,再结合标题和update_time做一次完整性检查。
const fs = require('fs'); function verify() { const lines = fs.readFileSync('./output/articles.jsonl', 'utf-8') .trim().split('\n'); const map = new Map(); for (const line of lines) { const item = JSON.parse(line); map.set(item.link, item); } const mids = [...map.values()].map((it) => it.mid).sort((a, b) => a - b); console.log('总数:', map.size); console.log('mid 最小:', mids[0], '最大:', mids[mids.length - 1]); for (let i = 0; i < mids.length - 1; i++) { if (mids[i + 1] - mids[i] > 100) { console.log('检测到可能的跳跃区间:', mids[i], '到', mids[i + 1]); } } } verify();mid反映的是发表时间线上的相对顺序,跳跃区间超过 100 通常意味着中间有连续文章没抓到,要去检查那段时间的分页请求是否被限流过。这个脚本最大的价值是在数据入场前就发现缺口,而不是等做统计时才发现少了几个月的内容。阈值 100 不是固定死的,文章更新频率低的账号可以调到 500,更新频繁的账号可以调到 30,核心是找到当前数据量下的合理波动范围。
5.3 复查清单与补救手段
到最后一步,我一般会跑一遍完整复查:从articles.jsonl里随机抽 20 篇请求详情页,打印标题、阅读量、点赞量三项,和列表接口返回的字段对比。如果不一致,优先怀疑解析正则写错了,而不是微信改了页面。
如果确实存在缺口,补救手段是先记录跳跃区间的begin值和对应时间点,再用二分法缩小范围,重跑这一段分页。不要从 0 开始全部重跑,那样既浪费时间,又会让接口频率压力成倍增加。
6. 把它变成定时任务:增量采集与断点续跑
增量采集的核心是记住上次跑到哪里。公众号文章只有新增和删除两种变化,没有修改历史数据的场景,所以维护一个lastUpdateTime时间戳就够了。每次启动采集时,只抓列表里update_time大于这个值的文章,抓完用新的最大值覆盖旧时间戳。
const cron = require('node-cron'); cron.schedule('0 3 * * *', async () => { console.log('开始增量采集', new Date().toISOString()); await pullAllArticles(config.fakeid, config.token); await syncLastUpdateTime(); });凌晨三点跑定时任务是我的习惯,那时接口负载低,翻页成功率高。同步时间戳务必放在整轮采集完成之后,不要抓一篇更新一次,否则中途断掉没法判断断点位置。每次跑完我会顺手统计新增了多少篇:如果是 0,说明数据源大概率没有更新;如果突然多出几百篇,先检查是不是上次漏跑把缺口补回来了。
从那以后我每次部署这类采集任务,第一件事不是看抓了多少篇,而是先确认接口的ret字段和总数对账脚本的结果。这些看似啰嗦的例行检查,救过我好几次,希望帮到你。
本文还有配套的精品资源,点击获取