简介一套以Python为核心的微信公众号文章链接采集工程面向Web爬虫初中级开发者解决从公众号平台批量获取文章URL这一典型需求也可作为研究公众号内容结构、接口鉴权与自动化测试的参考项目。工程共30个文件压缩包约17.22MB主体由13个Py脚本组成分别承担文章URL抓取、公众号参数获取、文章信息解析、API调用及自动化测试等职责另有5个Markdown文档专门讲解接口使用、Cookie与Token获取和参数自动配置方法并搭配依赖清单、测试脚本、界面截图与chromedriver驱动目录划分清晰按README即可快速跑通流程。项目从登录鉴权到抓取落地形成完整链路docs中关于微信公众号接口的说明能帮助理解后台数据交互细节规避常见的参数与反爬坑点。当前已有582人浏览学习适合拿来即用或作为二次开发脚手架。1. Python 抓微信公众号文章真正的难点在入口而不是解析Python 抓微信公众号文章第一道坎往往不是解析代码而是拿到那条带签名参数的合法链接。很多人装好 requests 就以为可以开爬真正动手才发现公开搜索入口封得严后台链接需要权限文章 URL 里的签名参数还有时效性一套流程下来大半时间在 403 和验证码之间打转。这篇文章沿着一条更可靠的链路走通全流程——从获取合法请求链接到解析正文、落地图片、增量去重把每一步的关键参数、代码和坑位都铺开。适合做公众号内容归档、舆情分析和私有知识库备份的团队也适合想把单篇抓取升级成稳定同步服务的人。2. 链接从哪来搜狗、后台导出与第三方接口的取舍爬公众号第一步不是写代码是搞清楚文章链接的获取方式。微信公众号的文章链接没有开放给普通开发者的官方检索接口但每篇文章本身是公开的只要能拿到 URL就能直接请求到 HTML 正文。所以入口方案的稳定性直接决定这个爬虫项目能不能跑长久。2.1 搜狗微信搜索曾经最方便现在基本不可用搜狗微信搜索是最早被广泛使用的公众号文章入口按公众号名称或文章标题就能搜到结果页。但最近两三年它的反爬策略明显收紧搜索结果页使用动态 id 渲染直接 requests 拿到的 HTML 里没有文章链接必须执行 JavaScript 才能拼出真实 URL连续请求几次就会弹验证码且验证码形态不固定自动化绕过的成本极高。我现在的建议很直接不要把搜狗作为批量抓取的入口。它适合偶尔人工检索一篇两篇不适合做成每日定时任务。如果你是新入坑想快速验证“能不能抓到文章”可以先用浏览器打开搜狗微信搜索人工搜索目标公众号手动从地址栏复制文章链接再用下面的请求代码去抓。这个流程虽然慢但能帮你先跑通解析和清洗这部分等熟悉了再换更稳定的入口。2.2 后台导出最稳但需要公众号权限最可靠的链接来源是微信公众平台后台的素材管理接口。只要你有目标公众号的运营权限或者拿到了某个公众号文章的阅读权限登录后台后打开“素材管理 - 已发表”每一篇文章都有一个可复制的永久链接。程序化的做法是调用后台内部的素材列表接口按页拉取文章 URL再配合 Cookie 去请求正文。这个方案的好处是链接字段完整__biz、mid、idx、sn四个参数全部齐全而且sn的有效期足够长不需要频繁更新。坏处是权限门槛高——你只能抓自己有权限的公众号不能覆盖全网。对于团队内部做自有公众号的归档、多账号内容汇总这是最推荐的做法。我一般会专门维护一个“授权公众号清单”把有权限的账号逐条登记按后台接口分批拉取跑完增量更新到数据库。2.3 第三方聚合接口少一层解析多一层授权风险市面上有一些第三方内容平台提供公众号文章聚合服务按关键词或公众号维度返回文章列表。这类接口替你解决了链接获取问题返回的数据也做过结构化处理用起来确实省事。但代价是你的请求会经过第三方服务器对方能看到你的全部查询意图和流量特征更麻烦的是部分平台要求授权你的公众号或绑定管理员账号这等于把后台权限交出去。我的建议是内部原型验证可以用生产环境优先不用。如果一定要用选那些不需要公众号授权的、以公开数据聚合为主的服务并且做好请求频率控制避免给对方服务器造成压力。把第三方接口定位成“补充数据源”而不是“唯一数据源”这样即使某天接口失效你的主链路也不会断。入口方案权限要求链接完整度稳定性风险点搜狗微信搜索无需动态解析低频繁验证码反爬升级快批量易封后台素材接口公众号权限高四参数齐全高仅限授权账号第三方聚合接口视平台而定高中授权风险依赖对方稳定性3. 用 Python 跑通单篇文章抓取构造 Header、请求正文拿到链接之后请求本身也有讲究。公众号文章对请求头校验很严格直接裸 requests 抓大概率返回 200 但内容为空或者干脆 403。这一章从 URL 参数讲起给一个能跑通的单篇抓取脚本。3.1 URL 结构与四个关键参数__biz、mid、idx、sn一条典型的公众号文章链接长这样https://mp.weixin.qq.com/s?__bizMzA3MjEyNTEwMgmid2651234567idx1sn8f3f9a2b1c4d5e6f7a8b9c0d1e2f3a4b四个参数各管一件事__biz公众号的唯一标识Base64 编码后的字符串类似账号的身份证号。mid消息 ID表示这篇内容属于哪一批推送。idx同一次推送里的第几条从 1 开始。比如当天推了 3 篇idx 就是 1、2、3。sn签名摘要是服务端根据链接内容生成的一串哈希用来校验请求是否合法。关键点是sn。它有时间属性过期之后再请求会返回 403。从浏览器地址栏复制的链接sn是新鲜的能直接抓程序化从后台接口拉取的链接sn有效期通常也够长。真正麻烦的是自己拼链接——如果你拿不到sn后面什么都白搭。所以我的做法是永远不自己构造链接只处理“已经存在的完整 URL”把精力放在请求、解析和存储上。3.2 最小抓取脚本构造 Header、请求正文下面这段代码是单篇文章抓取的最小实现核心是 Headers 里的两个字段User-Agent必须带微信内置浏览器的特征Referer必须指向微信域名否则服务端会直接拒绝或返回空壳页面。import requests from bs4 import BeautifulSoup def fetch_article(url: str, cookie: str) - str: headers { User-Agent: ( Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 MicroMessenger/7.0.15 ), Referer: https://mp.weixin.qq.com/, Cookie: cookie, } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() # 此时拿到的是完整的 HTML 页面后续章节再解析正文 return resp.text if __name__ __main__: # 从浏览器或后台复制一条完整链接填到下面 article_url https://mp.weixin.qq.com/s?__bizxxxmidxxxidx1snxxx user_cookie 你的登录 Cookie html_text fetch_article(article_url, user_cookie) print(页面长度:, len(html_text))这个脚本里最常见的报错是requests.exceptions.HTTPError: 403 Client Error。如果遇到先检查 Cookie 是否过期再检查sn是否失效最后检查User-Agent是否被服务端标记。timeout10这个参数建议保留公众号文章页响应不稳定不加超时会让任务卡死在某篇文章上。3.3 参数调整与首轮报错排查跑通上面这段代码后你可能会遇到几类情况这里先把最普遍的排查逻辑列出来返回 403Cookie 失效或sn过期重新从浏览器复制链接和 Cookie。返回 200 但页面长度只有几百字节说明被重定向到了验证页检查 User-Agent 是否完整。返回 200 且页面长度正常进入下一章解析环节。另外建议把 Cookie 单独存成配置文件不要硬编码在脚本里。公众号登录 Cookie 的有效期一般几天到几周不等过期后只需要重新登录一次后台复制新的 Cookie 替换即可。我一般用一个config.json保存 Cookie 和待抓链接脚本启动时读进来改配置不用动代码。4. 正文解析与清洗从 HTML 到可用的纯文本、Markdown 和图片请求到 HTML 只是第一步公众号文章页里有大量导航、推荐位、广告和脚本代码直接存下来没法用。这一章解决一个核心问题把 HTML 里真正属于正文的内容提取出来并处理图片懒加载和防盗链。4.1 正文区域定位idjs_content 与懒加载的>def parse_article(html_text: str) - dict: soup BeautifulSoup(html_text, html.parser) title soup.find(h1, class_article-title) title title.get_text(stripTrue) if title else author_meta soup.find(meta, attrs{property: og:article:author}) author author_meta.get(content, ) if author_meta else content_div soup.find(div, idjs_content) if not content_div: return {title: title, author: author, content: } # 注意图片 src 可能是空的真实地址在>REMOVE_CLASSES [rich_media_tool, js_pc_qr_code, qr_code, reward_area] def clean_content(content_div): for script in content_div.find_all([script, style]): script.decompose() for node in content_div.find_all(attrs{class: REMOVE_CLASSES}): node.decompose() for img in content_div.find_all(img): if not img.get(src): img.decompose() return content_div这里容易踩一个坑find_all(attrs{class: REMOVE_CLASSES})传数组在某些新版 BeautifulSoup 里行为不一致保险做法是写class_lambda c: c and any(x in c for x in REMOVE_CLASSES)。另外在清理时要先删大区块再删内部小节点不然先删了子节点父节点匹配可能失效。4.3 图片落地与防盗链Referer 决定你能活多久文章正文清理干净后图片还挂在微信图床mmbiz.qpic.cn上。直接下载大概率 403因为图片请求也做防盗链校验要求Referer必须来自微信域名。下载图片时把 Referer 固定成https://mp.weixin.qq.com/再带上一样的 User-Agent基本就能稳定拿图。def download_image(img_url: str, save_path: str): headers { Referer: https://mp.weixin.qq.com/, User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, } resp requests.get(img_url, headersheaders, timeout15) if resp.status_code 200: with open(save_path, wb) as f: f.write(resp.content) else: print(f图片下载失败: {img_url}, 状态码: {resp.status_code})图片命名不建议直接用 URL 里的一段因为不同文章可能引用同一张图重名导致覆盖。我一般用hashlib.md5(img_url.encode()).hexdigest()生成文件名天然去重重复抓同一篇文章时图片不会重复落盘。4.4 输出格式清洗结果怎么存解析和清洗完成后输出格式决定了下游好不好用。我通常会同时落两份一份是纯文本用get_text()提取正文去掉所有标签用于全文检索和 NLP 分析。另一份是 Markdown用自定义转换逻辑把h2转成##p转成普通段落img转成![]()引用本地图片路径。存文件时按年/月/公众号名/文章标题建目录方便人工回溯。def to_markdown(content_div, img_prefix: str) - str: lines [] for elem in content_div.descendants: if elem.name h2: lines.append(f## {elem.get_text(stripTrue)}) elif elem.name p: lines.append(elem.get_text(stripTrue)) elif elem.name img: src elem.get(src, ) if src: img_name hashlib.md5(src.encode()).hexdigest() .jpg lines.append(f) return \n\n.join(lines)5. 避坑公众号爬虫最常见的翻车现场与排查手册跑一段时间你就会发现公众号爬虫的坑不在某一篇代码上而在运行过程中慢慢浮出来的各种边界情况。这里挑五个最常见的翻车现场每条按现象、原因、解决来写都是我实际遇到过的问题。5.1 403 Forbidden签名过期还是 Cookie 失效现象脚本跑着跑着某篇文章突然返回 403前后文章都正常。原因sn参数有时效性。如果链接是几分钟前从浏览器复制的问题不大但如果是程序从后台接口批量拉取的接口返回的链接可能已经缓存了一段时间sn在新旧之间过期了。另外一个常见情况是 Cookie 被服务端主动失效比如同一账号在别处登录导致会话被踢。解决优先重试一次用新 Cookie 重新请求如果依然 403跳过该条记录到失败列表下次跑增量时从后台拉最新的链接再试。要特别注意不要让脚本在拿到 403 后立刻全量重试那会把账号推上风控。5.2 状态码 200 但正文是空的现象请求返回 200页面长度也正常但解析出来js_content找不到。原因这种情况多半不是被反爬而是文章类型不对。公众号文章有多种形态图文消息、视频消息、小程序消息、音频消息。视频消息和小程序消息的页面结构完全不同根本没有js_content这个 div。还有一种是“分享卡片”类型的页面正文在 iframe 里外层 HTML 只有个占位。解决解析前先判断页面类型。检测og:typemeta 标签如果是video或music跳过正文解析只存标题和链接如果要抓 iframe 里的内容需要单独针对子页面再发一次请求成本较高非必要不建议做。5.3 图片全部 403现象正文解析正常文字都在但下载图片时一片红全部 403。原因图片防盗链校验没过。很多人请求图片时沿用了抓文章时的 Header但 Referer 没改成微信域名。防盗链系统看的是 Referer 域名如果带了自己的服务器地址或空值直接拒绝。解决下载图片必须单独设置Referer: https://mp.weixin.qq.com/。另外部分图片 URL 里带wx_fmtjpeg之类的参数保存文件时注意根据 Content-Type 或 URL 后缀决定扩展名不要一律存成.jpg。5.4 重复数据增量字段选了 URL 却还是翻倍现象同一篇文章在数据库里出现了多条用完整 URL 做了唯一约束但去重没生效。原因微信文章链接在分享时会追加追踪参数比如chksmxxxx、scenexxx、subscenexxxx。同一条文章在不同渠道看到的 URL 参数不完全一致直接用完整 URL 做唯一键同一篇会被当成多篇。解决只截取 URL 里__biz、mid、idx三个参数做签名sn和后面的追踪参数全部丢掉用这个稳定三元组生成去重主键。sn可能会变但mid和idx在一篇文章的生命周期内不变。from urllib.parse import urlparse, parse_qs def stable_key(url: str) - str: query parse_qs(urlparse(url).query) biz query.get(__biz, [])[0] mid query.get(mid, [])[0] idx query.get(idx, [1])[0] return f{biz}|{mid}|{idx}5.5 跑几十分钟后被限频现象脚本跑得挺顺但某段时间后请求全部超时随后出现大量 403账号在手机上打开文章也异常。原因触发频率限制。公众号文章接口没有公开的配额说明但实际表现上单 IP 单账号的短时间请求量过大就会触发风控。控制不好节奏轻则本次任务失败重则账号被短期封禁。解决两层控制。第一层每次请求之间随机 sleep 3 到 7 秒不要固定间隔固定间隔容易被识别。第二层加一个滑动窗口计数器每分钟最多请求 20 次超过就暂停一段时间。真要跑大批量建议用多账号轮询每个账号单独限速互相不干扰。6. 把单篇爬虫升级成增量同步去重、重试与频控前面的脚本能抓单篇但要变成一个每天自动跑的增量同步工具还需要补三个机制去重主键、失败重试、频控队列。这三件事做好脚本才敢挂定时任务无人值守。6.1 用 URL 哈希做主键增量同步的第一步是建表。我一般用 SQLite 做主存储原因是不需要额外部署服务单文件备份也方便。表结构里除了存正文内容还要存stable_key和url_hash两个字段stable_key用于业务去重url_hash用于快速索引查询。def save_article(db_conn, article: dict): key stable_key(article[url]) url_hash hashlib.md5(article[url].encode()).hexdigest() db_conn.execute( INSERT OR IGNORE INTO articles (stable_key, url_hash, title, author, content, html, created_at) VALUES (?, ?, ?, ?, ?, ?, ?), (key, url_hash, article[title], article[author], article[content], article[html], time.time()) )INSERT OR IGNORE配合stable_key唯一索引重复抓取时自动跳过省去了先查再插的两步逻辑。跑完一轮用SELECT COUNT(*) FROM articles WHERE created_at ?统计本轮新增量方便看任务是否正常。6.2 失败重试队列与滑动窗口频控增量任务里网络抖动和接口偶发异常是常态不能一失败就放弃。我维护一张fetch_failures表记录失败的 URL、失败原因和重试次数每次启动任务先从失败表里取出重试次数小于 3 的链接补抓。频控用简单的滑动窗口实现维护一个请求时间戳列表每次请求前检查窗口内次数是否达到上限到了就 sleep 到窗口滑过。这样即使某批链接异常集中也能自动限速不会把账号推上风控。我现在每跑一轮同步前都会先看失败表里有没有积压再决定要不要调整频率上限。这个习惯帮我省掉了不少半夜的告警也让整个脚本从“能跑”变成了“能一直跑”。如果你也打算把公众号爬虫做成长期任务建议从一开始就把去重、重试、频控这三件事写进骨架里而不是等出问题再补。希望帮到你。本文还有配套的精品资源点击获取