简介:一套以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?__biz=MzA3MjEyNTEwMg==&mid=2651234567&idx=1&sn=8f3f9a2b1c4d5e6f7a8b9c0d1e2f3a4b四个参数各管一件事:
__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, headers=headers, timeout=10) resp.raise_for_status() # 此时拿到的是完整的 HTML 页面,后续章节再解析正文 return resp.text if __name__ == "__main__": # 从浏览器或后台复制一条完整链接,填到下面 article_url = "https://mp.weixin.qq.com/s?__biz=xxx&mid=xxx&idx=1&sn=xxx" 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是否被服务端标记。timeout=10这个参数建议保留,公众号文章页响应不稳定,不加超时会让任务卡死在某篇文章上。
3.3 参数调整与首轮报错排查
跑通上面这段代码后,你可能会遇到几类情况,这里先把最普遍的排查逻辑列出来:
- 返回 403:Cookie 失效或
sn过期,重新从浏览器复制链接和 Cookie。 - 返回 200 但页面长度只有几百字节:说明被重定向到了验证页,检查 User-Agent 是否完整。
- 返回 200 且页面长度正常:进入下一章解析环节。
另外建议把 Cookie 单独存成配置文件,不要硬编码在脚本里。公众号登录 Cookie 的有效期一般几天到几周不等,过期后只需要重新登录一次后台,复制新的 Cookie 替换即可。我一般用一个config.json保存 Cookie 和待抓链接,脚本启动时读进来,改配置不用动代码。
4. 正文解析与清洗:从 HTML 到可用的纯文本、Markdown 和图片
请求到 HTML 只是第一步,公众号文章页里有大量导航、推荐位、广告和脚本代码,直接存下来没法用。这一章解决一个核心问题:把 HTML 里真正属于正文的内容提取出来,并处理图片懒加载和防盗链。
4.1 正文区域定位:id="js_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(strip=True) 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", id="js_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, headers=headers, timeout=15) 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(strip=True)}") elif elem.name == "p": lines.append(elem.get_text(strip=True)) 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_fmt=jpeg之类的参数,保存文件时注意根据 Content-Type 或 URL 后缀决定扩展名,不要一律存成.jpg。
5.4 重复数据:增量字段选了 URL 却还是翻倍
现象:同一篇文章在数据库里出现了多条,用完整 URL 做了唯一约束,但去重没生效。
原因:微信文章链接在分享时会追加追踪参数,比如chksm=xxxx、scene=xxx、subscene=xxxx。同一条文章在不同渠道看到的 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 到窗口滑过。这样即使某批链接异常集中,也能自动限速,不会把账号推上风控。
我现在每跑一轮同步前,都会先看失败表里有没有积压,再决定要不要调整频率上限。这个习惯帮我省掉了不少半夜的告警,也让整个脚本从“能跑”变成了“能一直跑”。如果你也打算把公众号爬虫做成长期任务,建议从一开始就把去重、重试、频控这三件事写进骨架里,而不是等出问题再补。希望帮到你。
本文还有配套的精品资源,点击获取