Node.js+Puppeteer 抓取公众号历史文章:正文清洗与断点续爬
简介:wechat_spider 是一套基于 Node.js 的微信爬虫项目,借助 AnyProxy 中间人代理批量抓取微信公众号数据,可获取历史文章链接、正文、阅读量、点赞量、在看数和评论等字段,适合需要做公众号内容归档、数据监测或选题分析的个人开发者与数据采集学习者,也提供 Docker 部署方式,便于在个人电脑或服务器上运行。压缩包共 80 个文件,约 1.13MB,其中 JavaScript 文件近四十个、JSX 文件九个,另有 JSON 配置、HTML 页面、图片和字体资源,并包含 Docker、证书与说明文件;核心模块覆盖客户端、服务端、数据模型、代理规则、导出工具与容器编排。已有 2630 人学习下载。读者可从中梳理微信 HTTPS 请求解析、代理规则编写、MongoDB 与 Redis 存储、历史文章批量采集及容器化部署的完整链路,并获得可直接参考运行的代码骨架、配置示例和后续扩展思路。
1. 从一篇 10w+ 的字段说起:wechat_spider 抓的到底是什么
手里有一批公众号清单,想知道哪几篇真的带来了转化,光看标题排不出优先级。真要做选题库、竞品跟踪、内容复盘,需要的是每篇文章的正文、阅读量、点赞、评论入口数、发布时间,以及一个能把这些记录串起来的历史文章链接。wechat_spider 就是干这件事的 JavaScript 工程:用 Node.js 驱动无头浏览器,走一遍公众号的历史文章列表,再逐篇打开详情页,把正文 HTML 和互动数据一起落到本地。它适合做内容分析、选题挖掘、账号数据归档的从业者,也适合想拿真实中文语料做语料清洗和文本实验的人。不适合指望它当通用搜索引擎爬虫的人——它的边界很硬:只能拿到你有权限看到的那个账号视角下的公开数据,抓不到后台的粉丝画像和完整评论树。
2. 采集链路拆解:历史文章列表 → 正文 → 互动数据的三级抓取
这个工程的骨架其实就三级:先拿到一个账号的历史文章分页,再从每条记录里取详情页地址,最后逐篇解析。三级之间靠的参数不一样,失败原因也不一样,所以别把它当成一个循环写完,得按层拆开。
2.1 列表页:历史文章的分页参数与 msgList 结构
历史文章列表走的是公众号后台自己用的那个分页接口,路径大概是/mp/profile_ext,带上action=getmsg。关键参数有这么几个,缺一个就返回空:
| 参数 | 含义 | 从哪来 | 常见取值 |
|---|---|---|---|
__biz | 账号唯一标识 | 任意一篇该号文章的链接里 | base64 风格串,固定不变 |
uin | 当前登录用户标识 | 登录后页面 JS 变量 | base64 风格串 |
key | 本次会话密钥 | 登录后页面 JS 变量 | 约 20 分钟失效 |
pass_ticket | 会话票据 | 同上 | 与 key 同期 |
offset | 起始偏移 | 自己维护 | 首页 0,之后每次加 10 |
count | 单次条数 | 固定 | 一般写 10 |
scene | 场景标识 | 固定 | 124 |
is_ok | 校验位 | 固定 | 1 |
最容易被忽略的一点是返回体:general_msg_list不是一个 JSON 对象,而是一段被转义的 JSON 字符串,得再JSON.parse一次才能拿到list数组。每条list项里,comm_msg_info放的是id、datetime、type,app_msg_ext_info放的是title、digest、content_url、cover、is_multi。is_multi = 1表示这是一次群发里的多图文,后面的multi_app_msg_item_list里还有几条子文章,这些照样是独立文章,漏了它们你的历史文章总数会少一大截。
翻页的终止条件看can_msg_continue,返回 0 就说明到底了。别用msg_count判断,它在最后一页经常还是 10。
// 解析历史文章列表返回体,展开多图文 function parseHistoryPayload(raw) { const data = typeof raw === 'string' ? JSON.parse(raw) : raw; if (data.ret !== 0) throw new Error(`list ret=${data.ret} ${data.errmsg}`); // general_msg_list 是字符串,必须二次解析 const general = JSON.parse(data.general_msg_list); const out = []; for (const item of general.list) { const info = item.app_msg_ext_info; const common = item.comm_msg_info; if (!info || !info.content_url) continue; // 纯图片/视频消息没有正文 const push = (node, isSub) => { out.push({ msgId: common.id, publishAt: common.datetime, // 秒级时间戳 title: node.title, digest: node.digest, // 摘要,可用于去重校验 url: node.content_url.replace(/&/g, '&'), // 链接里的 & 要还原 cover: node.cover, isSub, // 是否多图文的子条目 }); }; push(info, false); if (info.is_multi && Array.isArray(info.multi_app_msg_item_list)) { info.multi_app_msg_item_list.forEach(sub => push(sub, true)); } } return { articles: out, hasMore: data.can_msg_continue === 1 }; }这段代码做了三件事:校验ret、还原&、展开多图文。content_url里带&是常态,不还原的话请求详情页会被参数截断,表现就是正文页一直跳回首页。isSub字段留着有用,后面排查「条数对不上」时能分清是群发次数还是文章篇数。
2.2 详情页:正文清洗与阅读量/点赞字段定位
详情页的 DOM 结构每隔一段时间会变一次,所以定位策略要分层:优先从页面里的 JS 变量取数字,取不到再退回 DOM 选择器。
数字类字段基本都在<script>里以赋值形式出现,形如var read_num = "1234";、var like_num = "12";、var comment_id = "..."。正文容器是#js_content,点赞和评论的展示位在#js_like_num、#js_cmt_num附近。用正则直接从 HTML 抓变量,比等 DOM 渲染完再取文本快,也少受懒加载影响。
正文清洗的目标是「能读、能检索、体积可控」。必须做的三件事:把img上的>const cheerio = require('cheerio'); // 解析详情页:数字字段 + 正文 function parseArticlePage(html) { const pick = (name) => { const m = html.match(new RegExp(`var\\s+${name}\\s*=\\s*["']?([^"';]*)`)); return m ? m[1].trim() : ''; }; const $ = cheerio.load(html); const $body = $('#js_content'); if (!$body.length) return { ok: false, reason: 'no-content' }; // 常见于被拦截或需验证 // 图片懒加载地址回填 $body.find('img').each((_, el) => { const $el = $(el); const src = $el.attr('data-src') || $el.attr('src') || ''; $el.attr('src', src); $el.removeAttr('data-src'); }); // 去样式、去脚本、去嵌入块 $body.find('*').removeAttr('style').removeAttr('class'); $body.find('script, iframe, mp-common-videosnap, mpvoice').remove(); return { ok: true, readNum: Number(pick('read_num')) || 0, likeNum: Number(pick('like_num')) || 0, commentId: pick('comment_id'), // 后续抓评论要用 title: $('#activity-name').text().trim(), author: $('#js_name').text().trim(), publishText: $('#publish_time').text().trim(), html: $body.html() || '', text: $body.text().replace(/\s+/g, ' ').trim(), }; }
pick函数用的是宽松匹配,因为变量声明有的带引号有的不带。返回ok: false的分支一定要显式处理,不能当成空文章写库——#js_content不存在时,九成是页面被拦了或者会话过期,重试一次往往就好了,直接落库会污染数据集。
2.3 数据落库:字段设计与去重主键
两张表就够:一张文章表,一张抓取日志表。文章表的主键别用自增 ID,用msg_id + idx或 URL 里的sn参数,这样重复跑不会产生脏数据。
CREATE TABLE article ( sn TEXT PRIMARY KEY, -- URL 里的 sn,天然去重 biz TEXT NOT NULL, msg_id TEXT, idx INTEGER DEFAULT 0, -- 群发内第几条 title TEXT, digest TEXT, url TEXT, publish_at INTEGER, -- 秒级时间戳 read_num INTEGER DEFAULT 0, like_num INTEGER DEFAULT 0, comment_id TEXT, content_html TEXT, content_text TEXT, fetched_at INTEGER, status TEXT DEFAULT 'ok' -- ok / no-content / blocked ); CREATE INDEX idx_biz_time ON article(biz, publish_at DESC);阅读量和点赞是随时间增长的,所以要么每次抓都覆盖更新、另开一张快照表存历史曲线,要么就只保留最近一次。做选题分析的话,快照表更有价值:隔一周再抓一遍,能算出真实的增长斜率,比单点数字有用得多。快照表只存sn、read_num、like_num、snapshot_at四个字段,加个联合唯一键防止同一小时重复写。
3. 工程骨架:Node.js + Puppeteer 的项目搭建与登录态注入
列表页和详情页都能用纯 HTTP 请求打通,但有两个环节绕不开浏览器:一是登录拿key和pass_ticket,二是部分账号的详情页会做人机校验。所以工程里保留一个 Puppeteer 实例专门处理这两件事,其余请求走axios或fetch,速度快一个量级。
3.1 目录结构与依赖
wechat_spider/ src/ auth.js # 登录、凭证提取与刷新 list.js # 历史文章分页 detail.js # 详情页解析 store.js # 落库与断点 index.js # 调度入口 data/ auth.json # 凭证缓存,不进版本库 spider.db package.json依赖只有四个:puppeteer负责登录和兜底渲染,axios走接口,cheerio解析 HTML,better-sqlite3落库。p-limit可选,用来控并发。
npm init -y npm i puppeteer axios cheerio better-sqlite3 p-limit # 首次安装会下载 Chromium,网络不好时用 PUPPETEER_SKIP_DOWNLOAD=1 跳过,挂本地浏览器puppeteer的版本跟着 Node 版本走,Node 18 以上问题不大。落库用better-sqlite3而不是sqlite3,因为它是同步 API,抓取脚本里少一层回调嵌套,代码短一半。
3.2 登录态注入:cookie 与 URL 参数两套凭证
凭证分两块,缺一不可。cookie 决定「你是谁」,__biz / uin / key / pass_ticket决定「你能翻哪个号的历史」。前者从浏览器里导出,后者从登录后的页面 JS 里正则提取。
// auth.js:从已登录页面提取凭证 async function extractAuth(page) { const html = await page.content(); const pick = (name) => { const m = html.match(new RegExp(`var\\s+${name}\\s*=\\s*["']?([^"';]*)`)); return m ? m[1].trim() : ''; }; const auth = { key: pick('key'), uin: pick('uin'), passTicket: pick('pass_ticket'), biz: pick('__biz') || new URL(page.url()).searchParams.get('__biz') || '', savedAt: Date.now(), }; if (!auth.key || !auth.uin) throw new Error('凭证提取失败,页面可能未登录'); return auth; }key的有效期很短,二十分钟左右就失效,所以脚本里要有一个「凭证过期」的判定:任何一次请求返回ret !== 0且errmsg里带invalid或expired,就重新走一遍登录流程刷新auth.json。别写死刷新间隔,按返回码触发更省事。
cookie 这块我一般手动导出一次存进auth.json,让 Puppeteer 用page.setCookie()注入。这样脚本不用每次输账号,也方便在另一台机器上跑同样的数据。
3.3 单篇抓取的完整函数与调用入口
把前面几块拼起来,单篇抓取就是一个「请求 → 判定 → 解析 → 落库」的四步。
// detail.js async function fetchOne(client, article, auth) { const url = article.url; const sn = new URL(url).searchParams.get('sn') || article.msgId; let html; try { const res = await client.get(url, { timeout: 15000, headers: { 'User-Agent': UA, Referer: 'https://mp.weixin.qq.com/', // 缺 Referer 容易被判异常 }, }); html = res.data; } catch (e) { return { sn, status: 'blocked', reason: e.message }; } const parsed = parseArticlePage(html); if (!parsed.ok) return { sn, status: parsed.reason }; return { sn, status: 'ok', ...parsed, url, biz: auth.biz, msgId: article.msgId, publishAt: article.publishAt, fetchedAt: Math.floor(Date.now() / 1000), }; }sn从 URL 的查询串里取,取不到才退回msgId,这是去重的第一道闸。Referer必须带,不然大量请求会被识别为直接访问。返回值里status有三种取值,调度层按status决定重试还是跳过,比用异常好维护。
调用入口按「列表 → 逐篇 → 落库」串,注意列表翻页和详情抓取要用不同的节流策略,前者慢、后者快。
4. 节奏与重试:翻页间隔、并发数、失败降级的参数怎么定
抓取脚本写得对不对,跑一百篇看不出来;跑到第两千篇还没被拦、数据还没缺,才算过关。节奏控制这块没有标准答案,但有一套能照着调的参数基线。
4.1 参数表:间隔、并发、超时、重试次数
| 参数 | 建议值 | 调整依据 |
|---|---|---|
| 列表翻页间隔 | 3~5 秒 | 低于 2 秒容易连续返回空页 |
| 详情页并发 | 2~3 | 单机超过 5 时失败率明显上升 |
| 单请求超时 | 15 秒 | 详情页偶尔加载慢,低于 8 秒误判多 |
| 失败重试 | 2 次,间隔 5 秒、15 秒 | 指数退避,第三次直接标记跳过 |
| 凭证刷新阈值 | 连续 3 次 invalid | 单次失败可能是偶发 |
| 单账号翻页上限 | 无硬限,按 can_msg_continue | 老号能翻到几百页 |
这些数字不是拍脑袋,是从失败率反推的:并发调到 5 以上时,no-content的比例会从个位数跳到三成,说明已经是限速而不是网络抖动。翻页间隔低于 2 秒,general_msg_list会开始返回空数组但ret还是 0,这种「假装成功」最坑,所以列表页每次拿到结果都要校验list.length > 0,为 0 就退避重试。
还有一点,别把列表和详情混在一个并发池里。列表是「一次翻页决定后面十条」的关键路径,慢一点无妨;详情是纯 IO,可以适度并发。分成两个p-limit实例,各自控速,出问题好定位。
4.2 断点续爬:用 offset + 已抓列表做幂等
抓几千篇的脚本一定会中断,所以断点必须落盘,不能只存内存。断点记录三样东西:当前账号的offset、已抓到的最新一页的msg_id、以及本次任务的起始时间。
// store.js:断点续爬 function loadCheckpoint(db, biz) { const row = db.prepare( 'SELECT offset, last_msg_id FROM checkpoint WHERE biz = ?' ).get(biz); return row || { offset: 0, last_msg_id: null }; } function saveCheckpoint(db, biz, offset, lastMsgId) { db.prepare(` INSERT INTO checkpoint (biz, offset, last_msg_id, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(biz) DO UPDATE SET offset = excluded.offset, last_msg_id = excluded.last_msg_id, updated_at = excluded.updated_at `).run(biz, offset, lastMsgId, Date.now()); }last_msg_id的作用是兜底核对:如果重启后offset对应的那页第一条不是上次记录的msg_id,说明期间有新发文,列表整体后移了,得回退一页重新对。这个判断能挡掉「漏抓三篇」这种最难受的情况——它不会报错,只会让你在月底对账时发现总数少了一截。
再叠加一层文章级的幂等:落库用INSERT OR IGNORE,主键是sn。这样即使重复翻页、重复抓同一篇,也不会产生重复行,脚本可以放心大胆地重跑。
5. 避坑与排查:七个让我返工的场景
5.1 凭证与会话类问题
现象:跑了两百篇之后,列表页开始稳定返回空数组,ret仍然是 0。原因不是被封,是key过期了,接口对过期凭证的返回就是空列表而不是错误码。解决办法是在列表层加一层「连续两次空页即触发凭证刷新」的逻辑,刷新后从原offset重试,别从头再来。
现象:详情页全部返回no-content,但用浏览器打开同一个链接完全正常。原因通常是 cookie 过期或者Referer缺失。先检查请求头,再检查auth.json的savedAt是不是超过一天。我的做法是把 cookie 有效期当成本地状态维护,超过 12 小时就主动重新导出一次,不等到报错。
现象:换了一台机器跑,同一份auth.json直接失效。原因是uin和key与登录会话绑定,换 IP 或者换设备容易触发重新验证。解决办法是凭证跟着机器走,别跨机器复制,脚本启动时先做一次轻量探测请求,通过了再开始跑批。
5.2 数据与字段类问题
现象:抓到的阅读量全是 0。原因是详情页变量名变了,新版本把阅读量挪到了别的位置,正则没匹配上。排查办法是打开一篇详情页的 HTML 搜read_num,看它还在不在。如果确实没了,就退回 DOM 取#js_read_area3的文本,再不行就用带渲染的 Puppeteer 兜底。数字字段一定要有「取不到就标记为 unknown,不要写 0」的约定,0 和未知在分析里完全是两回事。
现象:评论数有值,但抓不到任何评论内容。这是设计使然:前台展示的评论是作者精选后放出来的,完整评论需要走单独的评论接口,参数里要带页面上的comment_id和action=getcomment,而且翻页有自己的offset。想清这一点就不会白折腾——你拿到的是「精选评论」,不是「全部评论」,做情感分析时要在这个前提下设预期。
现象:文章总数比对方后台显示的少。八成是漏了多图文的子条目。is_multi = 1的那次群发,multi_app_msg_item_list里每条都是独立文章,只取主条目会稳定少 20%~40%。核对办法很简单:把抓到的条数按msg_id分组,看每组的条数分布,如果绝大多数是 1,说明子条目没展开。
现象:正文里的图片全是空白或者裂图。原因是懒加载的>-- 体检一:按账号核对条数与时间空洞 SELECT biz, COUNT(*) AS total, MIN(publish_at) AS first_at, MAX(publish_at) AS last_at, SUM(CASE WHEN content_text IS NULL OR content_text = '' THEN 1 ELSE 0 END) AS empty_body FROM article GROUP BY biz HAVING empty_body * 1.0 / COUNT(*) > 0.01; -- 体检四:同一篇文章的阅读量是否倒退 SELECT a.sn, a.read_num AS now_num, b.read_num AS prev_num FROM snapshot a JOIN snapshot b ON a.sn = b.sn AND b.snapshot_at < a.snapshot_at WHERE a.read_num < b.read_num;
这两条 SQL 花不了几秒钟,但能挡掉八成的「数据看起来没问题、分析结论却不对」的情况。尤其是第二条,抓取脚本加了并发之后,快照写串行的概率并不低,而它不会报错,只会让你的增长率算出来是负的。
还有一个技巧值得单独提:给content_text建一个全文索引,再配一张定时任务,每天对同一批sn重抓一次阅读量。一周之后你手上就不只是静态数据,而是一条条增长曲线,选题判断的颗粒度会完全不一样。我自己在这上面栽过一次——早期只存了最终值,等想回头算「发布后 48 小时的阅读增速」时,数据已经补不回来了,只能重新挂了两个月的定时任务。从那以后我每次建采集工程,第一件事就是把快照表和全量字段一起设计好,宁可多存一列,也不给未来的自己留遗憾。希望帮到你。
本文还有配套的精品资源,点击获取