尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
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带上actiongetmsg。关键参数有这么几个缺一个就返回空参数含义从哪来常见取值__biz账号唯一标识任意一篇该号文章的链接里base64 风格串固定不变uin当前登录用户标识登录后页面 JS 变量base64 风格串key本次会话密钥登录后页面 JS 变量约 20 分钟失效pass_ticket会话票据同上与 key 同期offset起始偏移自己维护首页 0之后每次加 10count单次条数固定一般写 10scene场景标识固定124is_ok校验位固定1最容易被忽略的一点是返回体general_msg_list不是一个 JSON 对象而是一段被转义的 JSON 字符串得再JSON.parse一次才能拿到list数组。每条list项里comm_msg_info放的是id、datetime、typeapp_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(/amp;/g, ), // 链接里的 amp; 要还原 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、还原amp;、展开多图文。content_url里带amp;是常态不还原的话请求详情页会被参数截断表现就是正文页一直跳回首页。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解析 HTMLbetter-sqlite3落库。p-limit可选用来控并发。npm init -y npm i puppeteer axios cheerio better-sqlite3 p-limit # 首次安装会下载 Chromium网络不好时用 PUPPETEER_SKIP_DOWNLOAD1 跳过挂本地浏览器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 参数表间隔、并发、超时、重试次数参数建议值调整依据列表翻页间隔35 秒低于 2 秒容易连续返回空页详情页并发23单机超过 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和actiongetcomment而且翻页有自己的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 小时的阅读增速」时数据已经补不回来了只能重新挂了两个月的定时任务。从那以后我每次建采集工程第一件事就是把快照表和全量字段一起设计好宁可多存一列也不给未来的自己留遗憾。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

CNware虚拟化平台落地实践:组件选型、部署与避坑指南

CNware虚拟化平台落地实践:组件选型、部署与避坑指南

简介:CNware虚拟化平台解决方案是一份聚焦企业级虚拟化与云计算规划的技术演示PPT,适合IT决策者、云平台架构师及运维管理人员阅读。内容以航天云宏自研云操作系统为基础,系统梳理了虚拟化引擎、虚拟化管理、云服务管理三个核心层次&#xff…

📅 2026/10/25 15:21:26
WebSocket+SQLite构建IM通信骨架:仿QQ即时通讯系统实战

WebSocket+SQLite构建IM通信骨架:仿QQ即时通讯系统实战

简介:本资源是一套基于C实现的仿腾讯QQ即时通讯软件源码,面向高校计算机专业学生及初级C开发者,用于深入理解桌面级IM系统的核心架构与关键技术实践。项目完整覆盖UI界面构建、TCP网络通信、多线程消息收发、本地数据库(SQLite&am…

📅 2026/10/25 15:16:26
机房搬迁标准方案:零中断切换的五大核心阶段与基线测绘

机房搬迁标准方案:零中断切换的五大核心阶段与基线测绘

简介:本资源是一份专业、完整的机房搬迁标准实施方案文档,面向IT基础设施运维工程师、数据中心项目经理及企业信息化负责人,解决物理迁移过程中业务连续性保障、设备安全转移与系统快速恢复等核心问题。文档覆盖项目背景分析、目标原则设定、…

📅 2026/10/25 15:16:26
MORE NEWS

更多资讯

📰

AI技术预期管理与主理人责任重构

1. 项目概述"天辛大师揭秘AI信仰崩盘"这个标题引发了我对当前AI行业现状的深度思考。作为一名长期关注AI技术发展的从业者,我注意到近期确实出现了一些对AI技术过度期待后的"信仰崩塌"现象。但问题的本质不在于AI技术本身,而在于人们…

📰

AI提效悖论:当工具变忙源,人机协作如何重建责任契约

1. 这不是效率革命,而是负荷重构:当AI工具上线后,我的日均会议反而多了2.3场“AI提效”这个词,最近半年在我们团队的周报里出现频率比咖啡消耗量还高。上个月刚上线一套智能会议纪要系统,能自动识别发言、提取待办、生…

📰

FDK算法与GPU加速:CBCT三维重建的CUDA实践与避坑指南

简介:面向CBCT三维图像重建开发者的一份FDK算法实现资源,聚焦GPU加速下的锥束CT重建流程,适合医学影像算法初学者、研究人员及CUDA/OpenCL工程师参考。资源共138个文件,压缩包仅9.76MB,以C/C、CUDA、MATLAB源码为主&am…

📰

ASP.NET Core 零依赖 Excel 读写方案:EPPlus + ExcelDataReader 实战

简介:本资源是一套基于ASP.NET Core WebAPI的Excel文件处理实战项目,面向C#后端开发者及.NET全栈工程师,解决Web服务中高频的Excel导入与导出需求。项目完整集成EPPlus(用于高效生成/导出OpenXML格式Excel)与ExcelData…

📰

美业门店管理系统设计要点:从在线预约、开单划卡到提成结算

美业门店管理系统设计要点:从在线预约、开单划卡到提成结算美业门店的账难算,不在「收钱」这个动作,而在于人和事都在动:预约在微信里接龙,卡项记在表格上,划卡凭记忆,提成月底手工统计&#xf…

📰

基于YOLOv5的绝缘子缺陷检测实战:训练配置、评估指标与避坑指南

简介:基于YOLOv5算法实现的绝缘子及绝缘子缺陷识别检测系统,面向电力巡检、计算机视觉学习者与深度学习开发者,解决输电线路中绝缘子目标检测与缺陷定位的实际问题。资源包含完整Python源码、训练好的模型文件、多种评估指标曲线及详细使用说…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬