
简介微信公众号历史文章全量爬取工具基于Node.js开发面向需要批量采集公众号内容的产品运营、内容分析师及爬虫开发者解决按时间线自动抓取指定公众号全部历史文章的需求。资源共12个文件核心代码为6个JS脚本含spider.js、headers.js、index.js等搭配package.json、.babelrc用于依赖管理和语法转换另有JSON配置文件、README说明文档及附赠资源压缩包整体仅39KB轻量易部署。目前已有111人学习下载。通过微信PC端与手机端配合完成登录态获取从而实现公众号历史消息的自动遍历与JSON结构化存储可直接用于公众号数据分析、内容备份或二次聚合开发。目录结构简洁主模块与辅助配置分离适合有一定Node.js基础、希望快速搭建公众号采集脚本的开发者参考使用。1. 微信公众号历史文章抓取为什么最后还得靠PC端加手机端配合打开一个公众号的主页微信默认只给你最近十篇二十篇。真要分析一个公众号从建号第一天到现在的完整内容轨迹、把历史图文做成离线备份、或者给内容聚合系统补数据时这种“只给一个窗口”的交互根本不够用。标题里这套方案看起来很绕微信PC端负责发起网络请求手机端负责扫码授权和风控兜底Node脚本拿到凭证后自动翻页并把JSON落盘。但它绕开了三条死路网页版没有历史消息入口手机端直接抓包被TLS证书和App加固拦住官方接口又必须有对应权限。只要顺着微信PC端这个“活的登录态”走半天就能跑出第一份全量历史文章JSON。这篇文章把链路、参数、代码和常见翻车现场一次讲透。2. 微信PC端和手机端为什么能配合抓全历史链路与关键凭证2.1 你以为在“翻历史记录”其实在调一个JSON接口在微信PC端打开任意公众号点“查看历史消息”页面会往上加载一屏又一屏的文章。多数人以为这是微信在渲染一个网页实际上微信PC端是把“当前登录用户”的身份塞进一个请求发给https://mp.weixin.qq.com/mp/profile_ext服务端返回的是结构化JSON页面再渲染给你看。这意味着什么意味着只要把那个请求从界面里摘出来用脚本直接调用拿到的数据比界面渲染更干净直接就是general_msg_list这种带完整文章列表的字段。微信PC端的价值就在这它是你登录态的“活载体”。脚本本身没有微信号但它可以借用PC端微信已经建立好的会话去请求后端。后端判断的是Cookie里有授权标识、有appmsg_token、有公众号的__biz并不关心请求是界面发的还是Node进程发的。手机端在这个方案里不是摆设。最开始的PC端登录必须用手机微信扫码确认中途当接口触发风控或验证码时最终兜底动作也要回到手机端。也就是说PC端负责“主力输出”手机端负责“开锁和救火”。这种配合正是标题里“PC端和手机端配合”的含义。2.2 一次历史消息请求长什么样参数直接决定能不能翻页从抓包里定位到profile_ext请求后你会看到类似下面的地址。这段地址就是整个抓取工具的“种子”。https://mp.weixin.qq.com/mp/profile_ext ?actionhome __bizMzIxNDA1... fjson offset0 count10 is_ok1 scene appmsg_token7e2d... x50其中actionhome表示加载历史列表首页__biz是公众号的唯一ID同一个公众号固定不变fjson强制返回JSONoffset0表示从第一条开始count10是每页条数appmsg_token是会话级token每次重新登录会变x50是PC端兼容标记不要随便改。返回的JSON里真正要关心的响应字段就五个。general_msg_list是一个JSON字符串里面再解析出list数组才是文章列表next_offset是下一次翻页位置can_msg_continue等于0时说明没有更多了appmsg_token会在响应里更新base_resp则用来判断接口是否正常。这些字段建议直接记在项目README里因为后面写翻页循环时每一步都绕不开它们。响应字段类型用途base_resp对象接口状态err_msg 为空或 ret0 才正常general_msg_list字符串文章列表的JSON字符串需要二次解析next_offset数字下一页的偏移量翻页就是把 offset 换成这个值can_msg_continue数字0 表示到底1 表示还有下一页appmsg_token字符串每次响应都会带新token抓包时一并更新抓包阶段最容易忽略的一步你打开历史消息只算请求了actionhome往下滚动才触发后续分页请求。如果只抄第一页就进Node脚本最多只能拿首页那十来条。正确的做法是在微信PC端把历史消息页面手动划到不能再往下让Fiddler把整个翻页过程记录完整再从中挑一个带有完整token和cookie的请求当种子。2.3 手机端的三个作用扫码登录、内容解锁、风控恢复手机端在这个方案里的作用经常被低估。第一个作用是扫码登录这个不用多说。第二个作用是“解锁内容”部分公众号的历史消息在微信PC端第一次点开时是空白或只有部分数据手机端先打开历史消息列表再在PC端刷新数据就会补全。第三个作用最现实抓取频率一高接口会返回ret: 200013或要求验证码这时需要在手机微信里手动打开同一公众号的历史消息滑动停留一会儿再退出把账号状态恢复到正常。实操顺序一般是这样手机微信扫PC端登录码手机端先打开目标公众号历史消息页PC端随后打开同一公众号历史消息页最后才在Fiddler里清空旧会话重新抓包。这套顺序能让抓包结果更完整也能减少后续接口被风控的概率。2.4 抓包环境配置以Fiddler为主三分钟跑通抓包工具我用Fiddler最多Charles也能做同样的事这里只说Fiddler的关键三步。第一打开Tools → Options → HTTPS勾上Decrypt HTTPS traffic并把根证书安装到Windows“受信任的根证书颁发机构”。第二确认系统代理已经指向Fiddler的监听端口默认是127.0.0.1:8888。第三完全退出微信进程再重新登录不是关窗口是要到任务管理器把WeChat.exe结束否则微信还攥着旧连接代理里什么都看不到。抓包时只看mp.weixin.qq.com这个域名就够了。在Fiddler底部设置过滤器把其他域名过滤掉操作体验会清爽很多。如果你在手机端也要抓包那需要把Fiddler监听端口在防火墙里放行手机WiFi代理指向PC的局域网IP还要在手机安装并信任Fiddler根证书比PC端麻烦不少。所以能靠PC端搞定的抓包就不要去折腾手机端。3. 把抓取工具跑起来Node环境、项目初始化与第一次JSON输出3.1 装Node环境别老拿最新版稳定LTS够用这个工具基于Node.js实现抓包拿到的凭证最终都要交给Node脚本去循环请求。Node安装有个常见误区看到官网有最新版本就直接装结果和旧项目依赖冲突。这里建议用nvm管理版本抓公众号历史文章这种脚本不需要最新特性用16以上、最好是18或20的LTS版本长期支持、行为稳定。# macOS / Linux 用 nvm 装 LTSWindows 可以用 nvm-windows nvm install 18 nvm use 18 node -v npm -v先解释一下为什么用nvm而不是直接下载安装包爬取脚本不一定会一直用同一个Node版本微信接口策略经常调整当工具需要跟着升级依赖时可以在nvm里切版本而不污染系统环境。node -v和npm -v这两条命令是确认Node和包管理器都可用后续所有依赖都靠npm拉取。如果你已经装了某个版本但node -v出来的不是预期结果多半是PATH里还留着旧安装包的路径。Windows用户建议在“系统环境变量”里把C:\Program Files\nodejs\移到nvm路径下方避免版本冲突。这种环境问题看着是玄学其实九成是PATH顺序和nvm软链没生效。3.2 初始化项目并安装请求库新建一个目录专门放这个抓取项目把所有脚本、配置和输出都隔离在里面。用npm init生成 package.json然后安装 axios。选择axios而不是Node内置fetch并不是说fetch不行而是axios对错误处理更直观状态码非200时会直接走 catch而且对Cookie头的处理不需要额外设置。mkdir wechat-article-backup cd wechat-article-backup npm init -y npm i axios这里不推荐额外装cheerio之类的HTML解析库因为接口返回是JSON不是HTML。装了解析库反而会让代码多一层无用的依赖。如果后续要做正文离线备份再考虑是不是需要现在这一步保持最小依赖即可。3.3 把抓包结果转成config.json种子请求里的三个值接下来要把抓包拿到的东西填进配置文件。从Fiddler里选中那一条profile_ext请求右键复制URL再从请求头的Raw视图里复制完整Cookie。URL里取__biz和appmsg_token这两个值加上Cookie就是三个关键凭证。把它们写进项目根目录的 config.json{ biz: MzIxNDA1..., token: 7e2d..., cookie: wxuin...; 完整粘贴抓包里的Cookie, maxPages: 500, delayMs: 2500 }参数解释biz对应__biz后面请求每次都带token是 appmsg_token失效后需要重新抓包cookie直接粘贴整个Cookie头不要手改手改很容易丢字段导致请求被拒maxPages是安全阀防止接口异常后无限循环delayMs是每页之间的等待时间设2500毫秒比较稳。抓回来的历史文章条数如果超过5000篇maxPages调大即可。注意Cookie头里的字符多而杂复制的时候一定从开头的wxuin或pgv_pvid一直复制到最后一个字段。很多新手只复制了抓包工具里显示的截断内容结果请求发出后拿到的全是ret: 200013。3.4 第一轮跑通先抓一页确认凭证没问题不要一上来就写完整循环。先写一个30行左右的单页脚本验证凭证和参数是否有效出了问题能一眼定位。const axios require(axios); const fs require(fs); const config require(./config.json); async function fetchOnePage(offset) { const resp await axios.get(https://mp.weixin.qq.com/mp/profile_ext, { params: { action: home, __biz: config.biz, f: json, offset, count: 10, is_ok: 1, scene: , appmsg_token: config.token, x5: 0 }, headers: { Cookie: config.cookie, User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) } }); const data resp.data; const list data.general_msg_list ? JSON.parse(data.general_msg_list).list : []; console.log(offset:, offset, 返回条数:, list.length, next_offset:, data.next_offset); if (list.length 0) { fs.writeFileSync(messages.json, JSON.stringify(list, null, 2)); console.log(第一条标题:, list[0].app_msg_ext_info.title); } } fetchOnePage(0);这段脚本的逻辑是构造profile_ext请求参数与抓包时完全一致count: 10是每页条数拿到响应后先解析general_msg_list字符串再从列表里取第一条标题打印。如果打印结果正常说明凭证有效可以进入下一章的全量循环如果提示Cannot read properties of undefined第一件事不是改代码而是检查config里的字段名是否和抓包URL一致。运行命令就一行node fetchOnePage.js输出里如果看到返回条数: 10和正常标题恭喜整个链路已经通了。项目目录下会多出一个messages.json里面是微信接口返回的原始文章列表JSON格式可以直接用任意文本编辑器或IDE打开查看。接下来只需要把offset从0翻到next_offset循环下去就是完整的历史文章抓取。4. 全量拉取与字段解析核心代码和JSON落地规范4.1 翻页循环从offset0到can_msg_continue0单页能抓到数据之后全量抓取就只是把“翻页”交给代码。先给出通用的循环实现再讲每一段结构为什么这么写。// fetchAll.js const axios require(axios); const fs require(fs); const config require(./config.json); function sleep(ms) { return new Promise(resolve setTimeout(resolve, ms)); } async function fetchAll() { let offset 0; let articles []; const seen new Set(); for (let page 0; page config.maxPages; page) { try { const resp await axios.get(https://mp.weixin.qq.com/mp/profile_ext, { params: { action: home, __biz: config.biz, f: json, offset, count: 10, is_ok: 1, scene: , appmsg_token: config.token, x5: 0 }, headers: { Cookie: config.cookie, User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) } }); const data resp.data; // general_msg_list 是字符串要二次解析才能拿到 list 数组 const rawList data.general_msg_list ? JSON.parse(data.general_msg_list).list : []; if (rawList.length 0) break; for (const item of rawList) { const article normalize(item); if (!seen.has(article.msgId)) { seen.add(article.msgId); articles.push(article); } } // 每翻一页就落一次盘避免进程中断把已抓数据全丢掉 fs.writeFileSync(backup.json, JSON.stringify({ articles }, null, 2)); offset Number(data.next_offset); console.log(第${page 1}页完成累计${articles.length}篇offset${offset}); if (data.can_msg_continue 0) break; // 随机延时降低集中请求带来的风控概率 await sleep(config.delayMs Math.random() * 1000); } catch (err) { console.error(第 (page 1) 页失败, err.message); fs.writeFileSync(last_offset.txt, String(offset)); return; } } fs.writeFileSync(last_offset.txt, String(offset)); console.log(抓取结束共, articles.length, 条); } fetchAll();这段代码里有三个地方是血泪经验沉淀下来的第一seen集合按msgId去重因为翻页过程中偶尔会返回和上一页重叠的数据不去重会导致最终JSON里有大量重复第二每翻一页就writeFileSync落盘一次而不是全部抓完再写脚本跑一两千篇时中途断掉是常事边抓边存相当于后悔药第三延时用config.delayMs Math.random() * 1000固定延时容易被识别成机器行为。4.2 把微信原始字段转成能分析的规范JSON微信接口返回的原始字段名是comm_msg_info和app_msg_ext_info直接存下来虽然信息完整但后续用jq或Python分析很别扭。我会在正则化函数里做一层映射把常用字段提出来同时保留原文信息。function normalize(item) { const comm item.comm_msg_info || {}; const ext item.app_msg_ext_info || {}; const base { msgId: comm.id, type: comm.msg_type, title: ext.title, digest: ext.digest, author: ext.author, link: ext.content_url || , cover: ext.cover, publishTime: new Date(comm.datetime * 1000).toISOString() }; // 清洗链接里的转义字符避免后续拼接URL出错 base.link base.link.replace(/amp;/g, ).replace(/\\u0026/g, ); // 多图文主文章之外还有子文章拆成独立条目方便分析 if (ext.multi_app_msg_item_list ext.multi_app_msg_item_list.length 0) { base.multipage ext.multi_app_msg_item_list.map(sub ({ title: sub.title, link: (sub.content_url || ).replace(/amp;/g, ) })); } return base; }字段说明msgId是微信消息的唯一ID去重和断点续传都靠它comm.datetime是Unix秒级时间戳要乘1000再交给Datecontent_url里经常带amp;或\u0026转义不替换会导致保存的URL多一串字符浏览器打开直接404。多图文是个容易漏的点一条公众号消息里可能有主文加若干子文如果把multi_app_msg_item_list丢了等于丢了这批公众号推文的一半内容。4.3 一个适合备份和聚合的JSON结构落盘文件不应该是一个扁平的数组我会把它包装成带元数据的对象这样后续做增量更新、按公众号归档都方便。{ account: 某公众号名称, biz: MzIxNDA1..., crawledAt: 2025-06-01T10:00:00.000Z, total: 3, articles: [ { msgId: 121212, title: 第一篇文章标题, digest: 摘要文字, author: 作者名, link: https://mp.weixin.qq.com/s/abc123, cover: https://mmbiz.qpic.cn/..., publishTime: 2024-11-01T08:30:00.000Z, multipage: [ { title: 第一条子文章标题, link: https://mp.weixin.qq.com/s/def456 } ] } ] }这个结构里articles数组是给机器读的主体account和biz是给人工核对的索引。实际抓取时如果公众号改名了account字段正好记录抓取时刻的显示名比较有价值。publishTime统一用ISO 8601格式后面对比时区、按月统计都不会乱。如果你的用途是内容聚合而不是逐篇分析可以把multipage里的子文章也摊平成独立的articles条目再加一个groupId字段标识它们是同一条推送。这样聚合系统不用递归处理嵌套结构。摊平操作放在抓取完后的二次脚本里做不要在抓取循环里做保持抓取脚本职责单一。4.4 断点续传进程中断后从上次位置接着跑全量抓取最尴尬的不是抓不到而是抓到一千篇时脚本崩了重新跑又要从第一篇开始。解决办法是维护一个last_offset.txt每次成功翻页后写入当前offset。// 在 fetchAll 的开头增加续传逻辑 let offset 0; if (fs.existsSync(last_offset.txt)) { offset Number(fs.readFileSync(last_offset.txt, utf8)); console.log(检测到续传点从 offset , offset, 继续); } // 在每次成功写盘后追加 // fs.writeFileSync(last_offset.txt, String(offset));原理很简单文件里只存一个数字脚本重启后先读这个数字把offset跳过去再开始请求。要注意的是last_offset只在微信接口没有重置翻页语义时有效。如果你的抓取间隔太久token已经失效光读offset没有意义得先回抓包流程更新config。所以这里我会把断点续传设计成“先更新token再读offset”顺序反了容易白跑一轮。5. 避坑与排查微信PC端抓取公众号历史文章的翻车清单5.1 Fiddler里只有CONNECT隧道看不到mp.weixin.qq.com请求现象微信PC端历史消息页面正常打开Fiddler会话列表里却只有一条条CONNECT点开全是加密流量看不到JSON。原因最常见的是微信进程在代理生效之前就已经启动。微信网络层会缓存代理状态不会因为系统代理变了就立刻切换。其次是你没有把Fiddler根证书真正装进Windows“受信任的根证书颁发机构”只装了当前用户存储区部分客户端校验时仍然不认。解决完全退出微信进程在任务管理器里确认WeChat.exe已结束再重新启动并扫码登录确认Fiddler的HTTPS解密勾选是打开的把根证书同时装到“当前用户”和“本地计算机”两个证书存储区。改完这三步九成能看到mp.weixin.qq.com的明文JSON。5.2 接口返回 ret200013或遇到验证码现象第一次抓包请求正常脚本跑了几页之后突然返回ret: 200013有的版本会附带一段提示文字说明需要验证。原因短期请求频率过高或者token被服务端判定过期。ret: 200013多发生在异常环境调用接口的场景尤其是伴随着脚本高频翻页时。解决用手机微信打开目标公众号历史消息页手动滑动并停留几十秒相当于人工确认账号无异常然后重新在微信PC端走一遍历史消息流程抓取新的config凭证。不要试图在同一token下反复重试越试越容易被限制。恢复间隔建议至少半小时再继续。5.3 翻页过程中 next_offset 倒回文章出现重复现象打印日志里看到offset从120跳到60或者总条数增长得很慢去重之后发现很多文章已经出现过。原因微信服务端在返回列表时会根据当前登录态动态调整分页游标如果请求频率过高它会把下一页游标重置到前面某个位置而不是顺着上一页继续。另一个可能是next_offset字段解析类型不是数字字符串拼接时把offset写错了。解决每次翻页强制Number(data.next_offset)对每篇文章用msgId去重如果发现next_offset 当前offset说明被重置了停止请求并等待3到5分钟再继续。配套做法是保存原始列表文件等风控解除后从头解析一次用msgId集合合并两批数据。5.4 保存下来的文章链接打不开或图片链接带转义现象抓取到的link在浏览器里打开跳到“环境异常”或者404图片链接也经常出现amp;字样。原因content_url里的引号、符号是从JSON字符串里来的很多工具抓包后直接复制没做转义还原。另外部分文章链接带临时签名一旦离开发送时间窗口微信会拒绝访问。解决在normalize函数里做两步清洗把amp;替换成把\u0026还原成。对于“环境异常”的链接需要在登录微信PC端的浏览器环境里才能打开所以备份时不要只存链接要把标题、摘要、作者、发布时间一并存下来必要时再抓正文HTML离线保存。只靠URL做长期备份是行不通的。5.5 多图文只抓到主文章子文章全部丢失现象保存的JSON里articles数量远小于公众号实际发文数点开那些推文发现明明是两条标题却只存了一条。原因解析时只取app_msg_ext_info顶层字段忽略了multi_app_msg_item_list数组。微信的图文消息一次可以带多条内容主文章在ext.title里子文章全在multi_app_msg_item_list里。解决在4.2节的normalize函数里把multi_app_msg_item_list遍历出来每组子文章单独生成一个条目并记录同一个msgId。这样最终结果可能比公众号推文数量多但这恰好反映了真实的内容数量聚合分析时也更准确。6. 进阶用法JSON再加工、正文备份与接口变化的应对6.1 用jq把备份JSON变成统计表抓完历史文章后最常用的分析动作是按月份看发布密度、按标题关键词做筛选。不需要写Pythonjq一行命令就能在终端里把JSON转成表格。# 提取发布时间和标题制表符分隔输出 jq -r .articles[] | [.publishTime[0:10], .title, .link] | tsv backup.json # 按月统计文章数量 jq -r .articles[].publishTime[0:7] backup.json | sort | uniq -c第一条命令输出日期 标题 链接的清单可以重定向到文本文件里给Excel用。第二条命令按月计数能直观看出公众号在哪些月份更新频繁。publishTime[0:10]是jq的字符串切片相当于只取YYYY-MM-DD部分不引入额外依赖。6.2 把正文也离线保存从JSON索引到本地文件库JSON里的link有失效风险所以我会在抓取列表之外另写一个正文抓取脚本读取backup.json里的link逐个请求文章页面把HTML存成以msgId命名的文件。请求时同样带上Cookie登录态能保证大多数历史文章正文可以访问。mkdir -p articles # 伪代码示意node fetchBody.js articles # 输出为 articles/121212.html标题和元信息仍以 backup.json 为准正文文件只解决“内容还在不在”的问题标题、作者、摘要等结构化字段仍然从JSON索引读取。这样即便以后接口变化你手里已经有了不可变的正文快照再做内容分析不需要依赖线上URL。抓正文的频率比列表接口还要低建议每秒最多一个请求不然很容易触发前面说的200013。6.3 微信接口策略变化时怎么保住工具不报废微信对这类接口的调整从没停过可能改参数名、改响应结构、也可能直接换endpoint。我的习惯是第一每次抓包保留原始请求头不只是URL因为请求头里的动态字段可能比URL参数更重要第二把微信返回的原始响应和解析后的JSON分开存原始响应留底解析后的JSON方便直接用第三工具代码写成“配置驱动”所有URL和参数都从config读取而不是散落在代码里。我自己跑备份时每翻一页都顺手把微信原始JSON存到raw/子目录。某次微信接口改了某个响应字段名我的解析脚本跑一半全部报错但raw/里的原始数据还完好。第二天基于新字段重新解析raw文件之前的抓取没有白做。抓取公众号历史文章这件事本质上是和微信的风控策略赛跑代码可以随时改但你抓到的原始数据才是真正不会背叛你的资产。希望这个方案能帮你把公众号历史文章的备份和分析跑通。抓取时记得控制频率别拿别人的公众号压测接口数据合规的边界一定要守住。本文还有配套的精品资源点击获取