简介这是一份基于 Node.js 的微信爬虫项目源码采用中间人代理方式拦截并解析微信 HTTPS 请求用于抓取公众号历史文章链接及正文、阅读量、点赞量、在看数、评论等数据适合需要批量采集公众号内容做数据分析或运营监控的开发者。项目已适配 AnyProxy 4并支持 Docker 部署与个人电脑/服务器运行安装门槛主要体现在 Node、MongoDB、Redis 及手机证书配置上。压缩包共 80 个文件大小约 1.13MB以 40 个 JavaScript 与 9 个 JSX 文件为主涵盖客户端界面、服务端接口、代理规则和数据处理模块另含 Dockerfile、docker-compose.yml、HTTPS 证书及 README 等部署与使用文档目录规划清晰。已有 2622 人学习下载。通过学习源码可掌握微信爬虫的代理抓包原理、内容清洗与导出流程、数据存储设计并可直接修改扩展后用于自己的采集任务。1. 微信爬虫不是玄学先想清楚你到底要抓哪一层公众号文章采集这个需求听起来简单做起来容易翻车。wechat_spider 这个标题看着像个“万能抓取器”实际拆开只有三件事拿到公众号的历史文章链接、抓文章正文和互动数据、把数据存下来供后续分析。真正的难点不在 requests 和正则而在微信的链接签名校验、文章页面里的动态加载、以及反爬风控的节奏控制。这个方案适合谁想批量备份某类公众号内容做知识库、做行业监测、或者做阅读量/点赞量趋势分析的从业者。不适合谁想抓取用户隐私数据、想绕过公众号原创保护、或者想大规模爬全站的人——技术上做得到但合规风险很高我自己的原则是只抓文章正文和已有互动数据绝不碰任何用户维度信息。开篇先把坑挑明微信文章的链接分两种一种是mp.weixin.qq.com/s?src...这种带签名参数的临时链接一种是mp.weixin.qq.com/s/xxxxx这种短链接。前者几小时就失效后者基本稳定。wechat_spider 这类项目第一步做的所有事本质上是把“能用的稳定链接”找出来再去解析页面。2. 从公众号主页到历史文章链接两条路线与选型对比2.1 路线一通过公众号主页的 history 接口翻页在微信客户端里打开任意公众号的主页下拉历史消息时客户端实际请求的是一个带actiongetmsg参数的历史消息接口。用 Charles 或 mitmproxy 配好证书抓包能看到类似这样的请求结构https://mp.weixin.qq.com/mp/profile_ext?actiongetmsg__bizMzA3NDIyNTM3NQfjsonoffset10count10is_ok1scene124uin777key888pass_ticketxxxwxtokenappmsg_tokenxxxx50参数核心是__biz、offset、count和appmsg_token。__biz是公众号的唯一IDBase64 编码在整个站点里不变offset是翻页游标第一次请求为 0响应里会返回next_offset下一次请求直接拿它继续count表示每页条数普遍设为 10调大容易触发风控。实际落地时我见过两种常见做法。第一种是直接模拟这个 HTTP 接口需要先手动在微信里打开一次公众号主页抓包拿到appmsg_token和pass_ticket这两个参数有效期只有几小时适合短时间批量抓取。第二种是用微信客户端内置的 WebView 调试接口通过agentweb或webview_devtools方式注入脚本让页面自己滚动翻页再拦截网络请求。这种方式更稳但需要处理安卓端调试桥的兼容性。2.2 路线二搜狗微信搜索兜底搜狗微信搜索是公众号文章的另一个入口支持按公众号名称搜索文章并分页。这个方案的优点是无需登录微信拿到的是公开搜索结果缺点是覆盖不全——原创文章搜得到转载文章经常漏且每页只有 10 条翻页超过一定页数会出验证码。这一路适合做“定向补全”主流程用actiongetmsg接口拿历史链接发现有缺失时再用搜狗按标题手动补。两条路结合能覆盖 99% 的场景只走一条路就等着事后发现漏数据。2.3 我为什么选 actiongetmsg 作为主路径核心原因只有一个actiongetmsg返回的 JSON 里包含了app_msg列表每项自带title、digest、cover、link和update_time一次翻页就能拿到完整索引不需要二次解析列表页。而搜狗返回的是 HTML需要嵌套正则匹配且正文页里的content字段可能是压缩后的 HTML处理成本高。另一个原因是actiongetmsg支持fjson参数响应是标准 JSON方便直接落库。搜狗返回的 HTML 里同样有 JSON 数据但压缩和转义级别不同抓错一次就要重新清洗。实际项目中我通常把接口返回的原始 JSON 先原样存一份再解析出结构化字段这样即使后续解析逻辑出错还能从原始数据补救。import requests import json import time # 从抓包工具中获取的临时参数使用前必须手动更新 params { action: getmsg, __biz: MzA3NDIyNTM3NQ, # 公众号唯一IDbase64编码 f: json, offset: 0, count: 10, is_ok: 1, scene: 124, uin: 777, key: 需要从抓包中替换, pass_ticket: 需要从抓包中替换, wxtoken: , appmsg_token: 需要从抓包中替换, x5: 0, } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://mp.weixin.qq.com/, } all_msgs [] for _ in range(50): # 最多翻50页防止死循环 resp requests.get(https://mp.weixin.qq.com/mp/profile_ext, paramsparams, headersheaders, timeout10) data resp.json() if data.get(ret) ! 0: print(接口返回异常:, data.get(errmsg)) break msg_list data.get(general_msg_list, ) if not msg_list: break msg_data json.loads(msg_list) all_msgs.extend(msg_data.get(list, [])) # 翻页游标 next_offset data.get(next_offset) if next_offset is None or next_offset params[offset]: break params[offset] next_offset time.sleep(2) # 限速避免高频请求被封 print(f共获取 {len(all_msgs)} 条文章索引)这段代码的逻辑很直白第一次请求 offset 为 0拿到响应后解析general_msg_list把文章元数据追加到总列表然后用next_offset替换当前 offset继续下一次请求。循环退出条件有两个接口返回异常码或者next_offset和当前 offset 相同说明已经翻到了最后一页。参数调整建议count我一般保持 10调大虽然单次请求拿到的文章多但被风控的概率显著上升time.sleep的时间在 13 秒之间波动不要固定值固定间隔反而容易被识别为脚本。appmsg_token和pass_ticket过期后接口会返回ret: 200003或类似错误码这时候需要回到微信客户端重新打开一次公众号主页再抓包更新参数。2.4 历史链接去重与增量更新拿到文章链接列表后去重是第一步。用__biz加mid加idx三个字段拼一个唯一键。mid是消息 ID同一篇文章在不同分组里可能出现多次但同一个mid idx唯一对应一篇。例如mid2650001234idx1和mid2650001234idx2是不同的文章。增量更新的策略是每篇存一个crawled_at时间戳下次抓取时只更新最近七天的文章遇到已有记录就跳过。这里有个隐藏坑公众号推文可以修改正文和标题链接不变但内容变了所以只对链接去重不够最好对title或正文哈希做二次校验发现变化就重新抓取。3. 抓取文章正文与互动数据从 HTML 里把数据抠出来3.1 文章页面结构为什么不能用常规 xpath历史链接拿到之后打开文章页表面是干净的文章排版但 HTML 源码里到处都是var ct 1234567890和var msg_title 测试这类 JS 变量赋值。常规的 XPath 和 CSS 选择器在这里会遇到一个问题部分字段尤其是阅读量和点赞量不在 HTML 静态源码里而是通过 JS 异步加载后写入 DOM 的。正文内容本身是用div classrich_media_content idjs_content包裹的这一步用 XPath 就能提取。真正麻烦的是阅读量和点赞数早期版本会直接在 HTML 里输出read_num和like_num变量后来改版成了动态请求——页面加载完成后再请求一个getappmsg或者类似的计数接口把数值填进页面。因此纯静态解析只能拿到正文拿不到互动数据。解决方式分两种。第一种是对页面做 JS 渲染用 Playwright 或 Selenium 加载完整页面后再提取 DOM。这种方案兼容性最好但速度慢多线程并发需要控制资源占用。第二种是直接找动态计数接口比如通过抓包看到浏览器加载完文章后又发起了哪些请求一般是一个带__biz、appmsg_token和mid参数的 JSON 接口。我一般在项目里先用 selenium 观察一次再改成 requests 模拟那个计数接口这样能减少渲染开销。3.2 用 requests 抓正文的完整脚本含动态字段兜底import requests import re import json from bs4 import BeautifulSoup def fetch_article(link, biz, mid, idx): headers { User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15, Referer: https://mp.weixin.qq.com/, } resp requests.get(link, headersheaders, timeout10) resp.encoding utf-8 html resp.text # 从 HTML 中提取正文 soup BeautifulSoup(html, lxml) content_div soup.find(div, idjs_content) if content_div is None: # 遇到需验证的页面标记待重试 return {error: content_not_found, html: html[:500]} # 提取标题HTML 源码里可能有三处优先取标准字段 title_match re.search(rvar msg_title (.?), html) title title_match.group(1) if title_match else soup.find(h1).get_text() # 抓动态计数接口可能需要预先从页面提取 appmsg_token read_num None like_num None token_match re.search(rwindow\.appmsg_token (.?), html) if token_match: appmsg_token token_match.group(1) count_api https://mp.weixin.qq.com/mp/getappmsgext count_params { appmsg_token: appmsg_token, x5: 0, __biz: biz, mid: mid, idx: idx, is_only_read: 1, } count_resp requests.post(count_api, datacount_params, headersheaders, timeout10) count_data count_resp.json() if count_data.get(appmsgstat): read_num count_data[appmsgstat][read_num] like_num count_data[appmsgstat][old_like_num] return { title: title, content_text: content_div.get_text(stripTrue), read_num: read_num, like_num: like_num, comment_count: get_comment_count(html), link: link, } def get_comment_count(html): # 新版本评论数在页面源码中以 comment_count 变量出现找不到就返回 None match re.search(rcomment_count (\d), html) return int(match.group(1)) if match else None这段脚本的核心逻辑分三步先解析静态 HTML 拿正文和标题再通过页面里附带的appmsg_token去请求计数接口拿阅读量/点赞量最后从源码里顺带提取评论数。mid和idx在历史链接的 URL query 里就有不用额外猜测。我在实际操作里content_text拿到的是去掉标签后的全文适合存进去做搜索索引。但如果你要保留图片、音频、视频的位置信息就不要用get_text而是把rich_media_content的原始 HTML 整个存下来。做数据分析用纯文本做存档和二次排版用原始 HTML两者各存一份更稳妥。3.3 阅读量缺失的兜底方案抓计数接口偶尔会失败最常见的原因是appmsg_token为空或过期。这时我会做一个降级策略把 HTML 里能读到的like_num变量先存为默认值然后标记这条记录为read_num_pending留待下次更新时再补抓。另一个兜底是从搜狗缓存页抓取阅读数但搜狗页面里的数值经常滞后一天只适合做粗略参考。还有一个容易被忽略的点文章如果被发布者删除了历史接口里那篇文章的链接还在点开后页面会提示“该内容已被发布者删除”。这种情况对应 HTML 里div classweui-msg__title的文本是“该内容已被发布者删除”代码里要加这个判断否则会存一堆空内容记录污染数据库。3.4 评论抓取需要判断公众号是否开启了评论功能阅读量和点赞量是公开数据评论不是。如果公号开了评论功能文章页面的js_comment_area里才有评论数据。未开启评论的公号页面连评论容器都没有。抓取评论的方式是在页面加载完成后请求getcomment接口参数需要post_id和comment_id这两个值藏在页面源码的appmsg_id和comment_area相关字段里。def fetch_comments(html, post_id): comment_area re.search(rcomment_area (.?);, html) if not comment_area: return [] try: comment_data json.loads(comment_area.group(1)) except json.JSONDecodeError: return [] if not comment_data or comment_data.get(comment_total_count, 0) 0: return [] comments [] for item in comment_data.get(comment_list, []): comments.append({ nickname: item.get(nick_name), content: item.get(content), like_count: item.get(like_num), is_top: item.get(is_top), }) return comments评论数据的核心注意点comment_area里只有第一页评论要看全部评论需要走分页接口。另外作者回复的评论在reply_list字段里和用户评论是两套结构别混在一起。4. 数据存储与任务调度SQLAlchemy 落库与断点续爬4.1 用 SQLAlchemy 设计文章表结构爬虫数据存 CSV 是最省事的但到了增量更新和补抓阶段就痛苦了。用 SQLAlchemy 定义 ORM 模型留足扩展字段后面查数、对接后台都用得上。from sqlalchemy import create_engine, Column, String, Integer, Text, DateTime, Index from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from datetime import datetime Base declarative_base() class Article(Base): __tablename__ articles id Column(Integer, primary_keyTrue, autoincrementTrue) biz Column(String(64), nullableFalse) mid Column(String(64), nullableFalse) idx Column(Integer, nullableFalse) link Column(String(512), nullableFalse) title Column(String(512)) digest Column(String(1024)) content_text Column(Text) content_html Column(Text) read_num Column(Integer) like_num Column(Integer) comment_count Column(Integer) update_time Column(DateTime) first_crawled_at Column(DateTime, defaultdatetime.now) last_crawled_at Column(DateTime, defaultdatetime.now) __table_args__ ( Index(idx_biz_mid_idx, biz, mid, idx, uniqueTrue), )设计这张表时我踩过一个坑只存了link当唯一键结果同一篇文章在历史接口里出现两次不同分组导致重复入库。后面加了biz mid idx的联合唯一索引问题才解决。content_html存原始 HTML 的体量可能很大建议单独分一张表存或者用压缩存储别和主查询混在一起。4.2 断点续爬的任务表单纯的散抓和一次性抓取不需要任务表但公众号历史文章通常一次抓不完需要恢复重跑因此任务表必须有。我常用的设计是记录当前抓到的 offset 和该 offset 对应文章的时间戳每次重启任务时从任务表恢复。class CrawlState(Base): __tablename__ crawl_state id Column(Integer, primary_keyTrue) biz Column(String(64), uniqueTrue) next_offset Column(Integer) last_success_time Column(DateTime) status Column(String(16), defaultrunning) # running/done/paused恢复逻辑很直接启动时查CrawlState表里该biz的next_offset有值就从那里继续没有就从 0 开始。任务完成或异常退出后更新next_offset和status。4.3 爬虫调度频率每天一次增量还是实时抓取公众号文章发布后阅读量并不是固定的它会在发布后几小时到几天内持续增长。如果做趋势分析我一般按“发布后 1 小时、6 小时、24 小时、7 天”四个时间点抓阅读量。如果只是做内容备份每天固定时间抓一次就够。调度用 APScheduler 或者系统 crontab 都行重点是把每次抓取的时间和拿到的数值一并记录。高频抓取同样的公众号容易触发风控我遇到过三次连续高频请求后接口返回 40001 错误需要重新抓包更新参数。经验是每篇文章的抓取间隔至少 30 秒以上同一个公众号整体请求频率控制在每分钟不超过 5 次。4.4 代理与登录态失效的处理微信的接口不强制用代理但大量同一 IP 请求同样会被限流。建议备一个 3~5 个住宅 IP 的代理池按请求量轮换。登录态appmsg_token和pass_ticket失效后接口会连续返回错误此时程序需要自动怎么做常见做法是发通知让人重新抓包或者通过浏览器自动化方式在微信公众平台登录后重新拉取参数。我自己的实现里加了一个“失效自愈”逻辑连续三次请求返回 200003就暂停这个公众号的抓取等人工更新参数后手动恢复。越早发现越省事别等到数据缺口大到补不回来再处理。5. 避坑指南微信公众号采集的八个真实踩坑记录5.1 链接签名过期导致的数据缺口现象第一次抓取正常第二天再跑数据只回来一小部分且全部是当天发布的历史数据拿不到。原因appmsg_token和pass_ticket有时效性过期后接口虽然能返回 HTTP 200但 JSON 里的ret字段是错误码请求直接失败。程序如果没判断ret会把失败当成空数据入库。解决每次请求后先校验ret非零立即停止并发出告警参数更新后主动清理缓存状态再启动。5.2 阅读量和点赞量为什么抓到了 0现象部分文章解析出来的read_num是 0但手动打开公众号文章能看到正常阅读量。原因这类文章走的是另一个数据接口或文章页里根本没有read_num变量而是通过一个叫appmsgext的 POST 请求返回。我最早在脚本里只解析了静态 HTML自然拿不到数值。解决把计数接口的请求补进去并在请求前从页面源码提取最新的appmsg_token不要复用历史 token。5.3 翻页到最后变成死循环现象next_offset始终不推进程序陷入同一个 offset 无限请求。原因公众号文章总数恰好是 count 的整数倍最后一次翻页接口返回的next_offset与当前 offset 相同部分版本还会返回空列表。解决循环里增加判断next_offset current_offset或列表为空时直接退出再加一个最大翻页次数兜底。5.4 正文里全是 “该内容已被发布者删除”现象数据库里存了一堆空文章记录标题还在正文是删除提示。原因历史接口不筛除已删除文章删除后链接存在于列表里但正文页已经变成错误信息。解决解析正文前先查weui-msg__title出现“该内容已被发布者删除”时直接标记为is_deleted1不存正文。5.5 同一篇文章被抓了多次现象articles 表里同一标题出现多次link相同但日期不同。原因公众号可以多次群发同一篇图文每次会生成不同的mid但link相同。解决唯一索引改为link mid或对title first_send_time做重复检测。5.6 抓取中途被封 IP 的节奏现象连续抓取 2 小时后所有请求开始返回 503重启后依然无效。原因请求频率太高微信侧已对该 IP 做了临时封禁通常持续数小时至一天。解决请求间隔随机化不要在固定秒数上保持不变同一 IP 并发数限制在 3 以内。被封后立即切换代理池而不是重启硬等。5.7 搜狗微信搜索的验证码拦截现象搜狗返回页面里出现“请输入验证码”连 HTML 都变了。原因搜狗对非浏览器请求的识别比微信严格频繁请求分分钟出验证码。解决搜狗只做低频补全频率控制在每分钟 1 个请求且用和浏览器一致的 UA。验证码出现后程序要能识别并暂停等待人工处理。5.8 SQLAlchemy 插入数据时的编码问题现象文章标题是中文时插入数据库报 UnicodeEncodeError。原因数据库连接字符串没指定字符集默认用了 latin1。解决连接串里加?charsetutf8mb4并且建表时确认CREATE DATABASE ... DEFAULT CHARACTER SET utf8mb4。正文里可能有表情符号必须用 utf8mb4 而不是 utf8。6. 验证爬虫数据可靠性的三个技巧6.1 和微信客户端手动打开对比抽查跑完一批数据后随机挑 10 篇文章用手机手动打开对比标题、正文前 200 字、阅读量是否一致。这个操作笨但最有效。我常遇到的情况是脚本解析出正文正常但阅读量比实际低几千问题一般出在计数接口拿到了缓存值还没更新到最新。抽查方法每条记录输出link title read_num人工核对 10 条即可。如果发现不一致但其他 90 条都正常推测是个别文章特殊字段格式导致的解析故障按异常记录处理不值得为此重跑全量。6.2 抓取时间与数据粒度的验证阅读量数据在采集后被保存但不同时间的抓取结果数值不同。验证趋势分析时要注意删除之前某天的抓取记录不会影响后续分析但同一篇文章不同日期抓到的阅读量不能直接做横向对比。最好在读取时固定一个规则取距离发布时间最近一次抓取的数值。这个验证其实是在检查你的表结构是否存了crawled_at没存就真的补救不了。6.3 用独立采集方式交叉验证如果你只用了actiongetmsg一条路拿链接建议另一天用搜狗微信搜索按公众号名再抓一次对比两边文章总量。两个方式拿到的文章数量差 10% 以内就算正常超过 20% 就要检查你的翻页是否提前退出了。这个验证是判断批次任务完整度的关键手段比我上面任何优化都重要。我从第一次做微信公众号采集时踩到 token 过期和图片反爬开始到现在基本能做到两小时抓完一个千篇历史文章的公众号并完成解析入库。大部分时间花在限速和异常处理上而不是抓取本身。如果你想做公众号内容的知识库或数据分析wechat_spider 这个方向是对的但一开始不要贪多拿一个小号公众号来调通全链路再上量。希望帮到你。本文还有配套的精品资源点击获取
