拓冰建站拓冰建站
首页 / 资讯中心 / 正文

微信爬虫技术详解:从后台接口抓取公众号文章全流程

简介这是一份基于 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 抓下来分析。相比在公众号主页手动滚页面接口方式有三个实际好处返回结构稳定、字段带时间戳、分页可控。主页渲染出来的页面会掺杂大量推荐内容和广告位干扰解析而后台的列表接口只关心你搜索的目标干净得多。常见做法是固定一组参数actionlist_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?__bizMzA3XXXXmid2650XXXXidx1sn9a2b3c...。手动去重写规则的人最容易踩的坑就是看到 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.jsmodule.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 分页拉取全部历史文章链接接下来是核心循环从begin0开始每轮请求一页拿到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 格式一行一篇文章后续追加增量数据时不用重写整个文件。实际跑的时候第一次可以先用begin0拉 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\sread_num\s*\s*?(\d)?/); const likeNumMatch html.match(/var\slike_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 开始返回空数组现象前几页正常某一页开始数组为空且ret0没有任何报错。原因请求频率触发了隐式限流。公众号后台的列表接口虽然没有把错误码抛给你但会悄悄停止返回真实数据。整个过程像黑匣子一样没有明显信号。解决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% 手动打开无 404app_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字段和总数对账脚本的结果。这些看似啰嗦的例行检查救过我好几次希望帮到你。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门