
简介wechat_spider 是一套基于 Node.js 与 AnyProxy 中间人代理实现的微信爬虫项目面向需要批量采集公众号数据的开发者与数据分析人员可获取文章正文、阅读量、点赞量、在看数、评论以及公众号历史文章链接等。压缩包共 80 个文件约 1.13MB以 40 个 JavaScript 业务脚本和 9 个 JSX 前端页面为主并包含 JSON 配置、HTML 模板、图标字体、Dockerfile、docker-compose 与根证书等部署材料目录覆盖客户端、服务端、抓取规则、数据模型、工具函数和证书配置。已有 2630 人学习下载。项目支持在个人电脑或服务器上运行提供 Docker 部署方式代码中包含历史页面解析、文章页处理、评论与文章数据入库、图片处理、数据导出及微信 ID 校验等模块同时涉及 Node、MongoDB、Redis 环境与 HTTPS 证书安装便于读者理解中间人代理抓包流程、复用采集脚本并依据自身场景调整。1. 脚本跑了一整夜阅读量那一列全是 0凌晨两点一个做内容分析的朋友发来截图历史文章列表拉到 3000 多条标题、链接、封面都对唯独「阅读量」整列是 0 或者 null。他以为是自己代码写错了其实是那套用 JavaScript 写的微信爬虫在拿阅读量时用了过期的凭证接口并不报错只是默默返回空数据。这份资源就是一整套针对公众号的抓取实现从客户端流量里把历史文章列表、正文 HTML、阅读量、在看、点赞、评论这些响应截下来再解析成结构化数据写入数据库。它不是「输入公众号名字就出 Excel」的成品工具而是一套要自己搭代理、自己维护凭证、自己写解析的工程骨架。适合做选题库、竞品监控、内容归档的从业者——你得能读懂 Node 代码也得接受它随时会因为接口字段变动而需要修补。2. 抓包链路怎么搭AnyProxy 规则 Node 落盘的最小闭环2.1 三条路线摆在面前为什么选代理拦截抓公众号数据大致三条路各自的天花板差别很大。第一条是登录公众号后台。听起来最正规但后台只给你自己账号的数据想看别人的号没门直接排除。第二条是搜索引擎的微信垂直结果。好处是不用装证书、不用挂代理坏处是列表只有最近若干条翻页到第三四页就开始要验证字段也残缺——正文是快照阅读量根本没有。做选题趋势还行做完整归档不够用。第三条是客户端流量的中间人拦截。手机和电脑在同一个局域网手机 Wi-Fi 手动填代理指向电脑电脑上跑一个代理进程把公众号客户端访问mp.weixin.qq.com的请求响应原样截下来。这条路字段最全列表、正文、阅读量、评论全都能拿到代价是要装根证书、要维持凭证有效、要在可控设备上操作。选型结论很清楚只想要标题和链接走第二条要阅读量和评论这种「客户端才看得到」的字段只能走第三条。这份资源的核心就是第三条。路线能拿到的字段维护成本稳定性公众号后台自有账号的图文数据低高搜索垂直结果标题、摘要、链接最近若干条低中翻页易被拦客户端中间人列表、正文、阅读量、在看、点赞、评论中高依赖凭证生命周期2.2 证书、代理与第一个能跑的抓包进程代理层用 AnyProxy 就够了它是 Node 写的规则文件也是 Node 模块和后面的解析服务共用一套语言不用在两个运行时之间来回倒数据。先装依赖并起进程npm i -g anyproxy # 规则文件放在项目 rule/ 目录下代理端口 8001Web 控制台 8002 anyproxy --rule ./rule/index.js --port 8001 --web 8002 --intercept--intercept是必须的没有它规则文件里的beforeSendResponse不会被触发你会看到流量在控制台里刷屏但一条数据都落不下来。规则文件只做一件事认出目标接口把原始响应转交给下游。// rule/index.js —— 只截公众号域名的四类响应解析逻辑一律不放这里 const { URL } require(url); const sink require(./sink); // 白名单路径命中任意一条才处理避免把图片、字体也塞进管道 const KEEP [ /\/mp\/profile_ext/, // 历史列表首页 /\/mp\/getmsg/, // 历史列表分页 /\/mp\/getappmsgext/, // 阅读量 / 在看 / 点赞 /\/mp\/appmsg_comment/, // 评论 ]; module.exports { summary: wechat-spider-rule, *beforeSendRequest(requestDetail) { const u new URL(requestDetail.url); if (u.hostname ! mp.weixin.qq.com) return null; console.log([req], requestDetail.method, u.pathname); return null; // 不改写请求只观察 }, *beforeSendResponse(requestDetail, responseDetail) { const u new URL(requestDetail.url); if (u.hostname ! mp.weixin.qq.com) return null; if (!KEEP.some((re) re.test(u.pathname))) return null; const ct responseDetail.response.header[Content-Type] || ; if (!/json|javascript|text\/plain/.test(ct)) return null; // 正文 HTML 走另一条路 sink.push({ path: u.pathname, query: Object.fromEntries(u.searchParams), body: responseDetail.response.body.toString(utf8), at: Date.now(), }); return null; // 返回 null 表示不修改响应客户端照常收到 }, };几个参数值得单独说。beforeSendResponse里返回null和返回一个对象是两回事返回null是「我不改你放行」返回{ response: {...} }会把响应体替换掉客户端拿到的是你改过的内容。这里一律返回null避免污染真实数据。responseDetail.response.body是 Buffer直接JSON.stringify会得到一堆字节对象必须显式toString(utf8)。这个坑我在第一次写规则时踩过落盘的文件打开一看全是{type:Buffer,data:[123,...]}。证书这一步绕不过去。代理进程启动后在 8002 的 Web 控制台里能下载rootCA.crt把它装到测试设备的系统证书区。这里有个硬边界Android 7 以后应用默认不信任用户区的证书客户端里打开文章会白屏或者报网络错误不是代码问题是系统策略。所以这套东西只能在你能完全控制的测试设备上跑别指望在主力机上无感使用。提示代理开着的时候设备上所有流量都会走电脑。抓完记得把 Wi-Fi 代理关掉否则电脑一关机设备就断网。2.3 落盘用 JSONL解析单独一层规则文件里不做任何解析只负责把原始响应写成一行一条的 JSONL。这个决定的理由很实际解析逻辑会随着接口字段变化不停改而原始数据是不可再生的——凭证过期了就得重新抓。先把原始响应存下来解析失败还能重跑。// rule/sink.js —— 攒批落盘进程被杀也不至于全丢 const { createWriteStream, mkdirSync } require(fs); mkdirSync(./raw, { recursive: true }); const out createWriteStream(./raw/wechat.jsonl, { flags: a }); let buf []; function push(rec) { buf.push(JSON.stringify(rec)); if (buf.length 20) flush(); } function flush() { if (!buf.length) return; out.write(buf.join(\n) \n); buf []; } setInterval(flush, 3000); // 低频兜底防止流量稀疏时数据一直卡在内存 process.on(SIGINT, () { flush(); process.exit(0); }); module.exports { push, flush };用 JSONL 而不是一个大的 JSON 数组是因为追加写不需要读全文件、进程崩了最多丢最后一批、还能直接grep某个接口。20 条一批是个折中值太小写盘频繁太大丢数据的窗口变长。解析层单独一个模块读 JSONL按path分发// parse/index.js —— 原始行 - 结构化记录 const { parseMsgList } require(./util); const normalizeHome require(./profile); function dispatch(line) { const rec JSON.parse(line); if (rec.path.includes(/mp/profile_ext)) return normalizeHome(rec.body); if (rec.path.includes(/mp/getmsg)) return normalizeHome(rec.body); if (rec.path.includes(/mp/getappmsgext)) return normalizeStat(rec.body); if (rec.path.includes(/mp/appmsg_comment)) return normalizeComment(rec.body); return []; }字段映射这块新手最容易漏的是多图文。一条推送里第一条是主图文第二条到第 N 条挂在multi_app_msg_item_list数组里只取主条目的会漏掉大半内容。原始字段落库字段说明comm_msg_info.idmid一条推送的唯一 id多图文的子条目共用comm_msg_info.datetimepublish_at秒级时间戳app_msg_info.content_urlurl含__biz、mid、idx、sn四个关键参数app_msg_info.digestdigest摘要部分账号为空app_msg_info.is_multiis_multi等于 1 时要展开子列表3. 历史文章列表__biz 与 getmsg 分页的完整取数流程3.1 profile_ext 首页一次请求只给你十条历史列表的入口是profile_ext参数里最关键的只有三个__biz、scene、pass_ticket。__biz是公众号的唯一标识长得像一段 base64结尾常带。它不需要你去猜随便打开该号任意一篇文章链接里就有https://mp.weixin.qq.com/s?__bizMzA5****mid2650****idx1snabcdef0123456789拿到__biz之后拼首页请求// 首页请求只返回最近 10 条翻页要靠后面的 getmsg function buildHomeUrl(ctx) { const p new URLSearchParams({ action: home, __biz: ctx.biz, scene: 124, subscene: 3, a8scene: 3, devicetype: ctx.devicetype, // 从抓包里的 UA 抄 version: ctx.clientversion, lang: zh_CN, nettype: WIFI, pass_ticket: ctx.pass_ticket || , wx_header: 1, }); return https://mp.weixin.qq.com/mp/profile_ext?${p.toString()}; }scene124和a8scene3是客户端内打开历史消息时带的值换成别的值返回的内容结构会变。devicetype和version必须和产生凭证的那台设备一致混用容易出现「请求成功但列表为空」。首页响应里有两个字段要一起取general_msg_list和can_msg_continue。前者是列表数据后者告诉你还有没有下一页。注意general_msg_list在部分版本里是字符串需要再JSON.parse一次在另一些版本里已经是对象。写死一种解析方式换个客户端版本就直接崩。// util.js —— 兼容字符串和对象两种形态别问我为什么要写这个 function parseMsgList(v) { if (!v) return { list: [] }; return typeof v string ? JSON.parse(v) : v; }3.2 getmsg 分页offset 的真相和「没有更多」的判定首页只给 10 条剩下的要靠getmsg一页一页拉。这里有个反复被踩的坑offset不能自己10往上加因为列表里存在被删除、被折叠的条目实际返回条数经常小于请求数用自增会漏数据。正确做法是读响应里的next_offset。// 分页拉全量靠 next_offset 走靠 can_msg_continue 停 async function fetchAllList(ctx, { maxPage 200, pageSize 10 } {}) { const all []; let offset pageSize; // 首页已经给了前 10 条 for (let page 0; page maxPage; page) { const url buildGetmsgUrl(ctx, offset, pageSize); const text await getJson(url, ctx.headers); // 带 uin/key/pass_ticket 的那套头 const json JSON.parse(text); const list parseMsgList(json.general_msg_list).list || []; if (!list.length) break; // 空页 到底了 all.push(...normalizeHome(text)); // 复用同一套字段映射 if (json.can_msg_continue ! 1) break; // 服务端明确说没了 const next Number(json.next_offset); if (!Number.isFinite(next) || next offset) break; // 防御offset 不前进就停 offset next; await sleep(3000 Math.random() * 4000); // 3~7 秒随机间隔 } return all; } function buildGetmsgUrl(ctx, offset, count) { const p new URLSearchParams({ action: getmsg, __biz: ctx.biz, f: json, offset: String(offset), count: String(count), is_ok: 1, scene: 124, uin: ctx.uin, key: ctx.key, pass_ticket: ctx.pass_ticket || , wxtoken: , appmsg_token: ctx.appmsg_token || , x5: 0, }); return https://mp.weixin.qq.com/mp/getmsg?${p.toString()}; }can_msg_continue只有等于 1 才继续等于 0 就结束。有些账号翻到最后一页会返回一个非空列表但can_msg_continue0这时候要保留这一页数据再退出先判断再break的顺序不能反。next_offset offset这个判断看着多余实际救过我一次某次服务端返回的next_offset一直等于当前offset脚本陷入死循环拉同一页一晚上写了几十万条重复记录。加了这个防御之后最多多拉一页就停。3.3 断点续爬与去重键设计一个号动辄几百上千篇中途断掉再从头跑是浪费。去重键要用(biz, mid, idx)三元组别只用mid——一条多图文推送里所有子条目的mid是相同的只用mid会把第 2 到第 N 条全部当成重复丢掉。CREATE TABLE IF NOT EXISTS article ( biz TEXT NOT NULL, mid TEXT NOT NULL, idx INTEGER NOT NULL, -- 1主图文2..N子图文 title TEXT, url TEXT, author TEXT, digest TEXT, publish_at INTEGER, read_num INTEGER DEFAULT 0, wow_num INTEGER DEFAULT 0, -- 在看 like_num INTEGER DEFAULT 0, -- 点赞 content_len INTEGER DEFAULT 0, fetched_at INTEGER, PRIMARY KEY (biz, mid, idx) ); CREATE INDEX IF NOT EXISTS idx_pub ON article(biz, publish_at DESC);入库用INSERT OR IGNORE重复的自动跳过不需要先查一遍// db.js —— better-sqlite3 同步 API省掉回调地狱 const Database require(better-sqlite3); const db new Database(./data/wechat.db); db.pragma(journal_mode WAL); // 边写边读不锁表 const upsert db.prepare( INSERT OR IGNORE INTO article (biz, mid, idx, title, url, author, digest, publish_at, fetched_at) VALUES (biz, mid, idx, title, url, author, digest, publish_at, fetched_at) ); function saveArticles(biz, rows) { const tx db.transaction((list) { for (const r of list) upsert.run({ biz, ...r, fetched_at: Date.now() }); }); tx(rows); // 事务包起来1000 条大概 30ms }idx的来源要留意主条目固定是 1子条目按数组顺序从 2 开始递增不能直接用数组下标下标从 0 开始会和主条目撞车。WAL模式是必须的否则你的解析进程在写、另一个统计脚本在读的时候会直接报database is locked。断点续爬的思路很简单每轮从首页开始拉拉到第一篇已经在库里的(mid, idx)就停。新发的内容永远在最前面历史内容不会变这个「瀑布式停止」比重头扫一遍快两个数量级。4. 正文、阅读量、点赞与评论四个接口的参数拼装4.1 正文抽取从 rich_media_content 到纯文本正文不走代理直接拿文章链接请求就行返回的是完整 HTML。取内容容器#js_content用 cheerio 解析比正则稳。// parse/article.js —— 正文抽取注意图片是懒加载 const cheerio require(cheerio); function extractArticle(html) { const $ cheerio.load(html); const $c $(#js_content); if (!$c.length) { // 常见三种情况文章被删、需要环境校验、页面结构变了 const tip $(.weui-msg__title).text().trim(); return { ok: false, reason: tip || no #js_content }; } const images []; $c.find(img).each((_, el) { // 关键真实地址在>// api/stat.js —— 一次只查一篇失败要能区分「过期」和「网络错」 async function fetchStat(ctx, art) { const q new URLSearchParams({ f: json, mock: , uin: ctx.uin, key: ctx.key, pass_ticket: ctx.pass_ticket, wxtoken: 777, devicetype: ctx.devicetype, clientversion: ctx.clientversion, appmsg_token: ctx.appmsg_token, x5: 0, }); const url https://mp.weixin.qq.com/mp/getappmsgext?${q.toString()}; const body new URLSearchParams({ r: String(Math.random()), __biz: ctx.biz, appmsg_type: 9, mid: art.mid, sn: art.sn, idx: String(art.idx), scene: 124, title: art.title, ct: String(art.publish_at), is_only_read: 1, is_temp_url: 0, appmsg_token: ctx.appmsg_token, pass_ticket: ctx.pass_ticket, }).toString(); const text await postForm(url, body, ctx.headers); const j JSON.parse(text); const ret j.base_resp j.base_resp.ret; if (ret ret ! 0) { const err new Error(stat ret${ret}); err.code TOKEN_EXPIRED; // 交给上层去刷新凭证不要盲目重试 throw err; } const s j.appmsgstat || {}; return { read_num: s.read_num ?? 0, // 阅读量 wow_num: s.like_num ?? 0, // 在看 like_num: s.old_like_num ?? 0, // 点赞 }; }三个字段的对应关系是最容易搞混的地方read_num是阅读数like_num是「在看」old_like_num才是点赞。把like_num当成点赞数写进报表做出来的环比曲线会看起来「点赞暴涨十倍」其实只是把在看当成了点赞。TOKEN_EXPIRED这个错误码要单独处理。凭证过期时接口的 HTTP 状态码依然是 200body 里ret是负数统计字段全是 0。如果不区分你的重试逻辑会疯狂重试同一个必然失败的请求把频率限制撞得更死。4.3 评论接口只能拿精选还有翻页上限评论走appmsg_commentGET 请求limit上限是 100实际用 20 到 50 更稳。// api/comment.js —— 翻页拉评论注意只返回「精选」 async function fetchComments(ctx, art, { limit 30, maxOffset 300 } {}) { const out []; let offset 0; while (offset maxOffset) { const q new URLSearchParams({ action: getcomment, __biz: ctx.biz, appmsgid: art.mid, idx: String(art.idx), comment_id: , offset: String(offset), limit: String(limit), uin: ctx.uin, key: ctx.key, pass_ticket: ctx.pass_ticket, wxtoken: 777, devicetype: ctx.devicetype, clientversion: ctx.clientversion, x5: 0, f: json, }); const text await getJson(https://mp.weixin.qq.com/mp/appmsg_comment?${q}, ctx.headers); const j JSON.parse(text); const list j.elected_comment || []; // 字段名就叫「精选评论」 if (!list.length) break; for (const c of list) { out.push({ content: (c.content || ).trim(), // 昵称属于个人信息落库前做哈希保留可聚合性但不留原文 nick_hash: require(crypto).createHash(sha1).update(String(c.nick_name || )).digest(hex).slice(0, 12), like_num: Number(c.like_num || 0), create_time: Number(c.create_time || 0), }); } if (list.length limit) break; // 不满一页就是到底了 offset limit; await sleep(4000 Math.random() * 3000); } return out; }elected_comment这个字段名已经把限制写明白了只有被作者选中展示的评论才拿得到未精选的、被过滤的任何参数都拉不出来。这是接口层面的边界不是代码能绕过的。做舆情分析的同学要提前把预期调对别拿着「精选评论」去代表全部评论。5. 避坑与排查五个把我卡了半天的现场5.1 现象阅读量整列为 0接口 HTTP 200原因key或appmsg_token过期。这两个凭证生命周期短抓包时是新的跑一晚上就失效了。接口不会报错appmsgstat依然返回只是所有数字都是 0。解决在统计层加健康检查。抓到第一条时先判断read_num是否大于 0如果前十篇全是 0直接抛TOKEN_EXPIRED终止任务并告警而不是继续跑几千篇写一堆假数据。凭证刷新只能靠重新抓包没有别的路子。5.2 现象同一页数据被反复写入数据库涨到几十万条原因offset用自增值或者服务端返回的next_offset和当前值相等循环没有出口。解决两处防御一起上。第一offset一律取next_offset第二加if (next offset) break;。再加一条保险用(biz, mid, idx)作为主键物理上把重复堵死。5.3 现象JSON.parse报Unexpected token o in JSON at position 1原因general_msg_list有时是字符串、有时已经是对象对着对象再JSON.parse就会拿到[object Object]的开头字母o。这个报错信息看着莫名其妙其实就是把对象转成了字符串。解决统一走parseMsgList这种类型判断的包装函数别在业务代码里裸写JSON.parse。5.4 现象正文文本很干净但图片全是空白占位原因图片懒加载真实地址在style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />