公众号文章没有对外开放“一键导出历史全部文章”的入口,后台只能手动往上翻。真正动手爬的时候又会发现绕不开三个坎:历史列表接口需要带登录态去请求,文章正文的HTML嵌套结构和“用户看到的不一样”,以及翻页流程藏得比较深。这篇内容就是把我自己从零到一跑通“批量抓取公众号历史文章”的完整过程写下来,包含环境搭建、列表接口分析、正文清洗、图片落地、增量更新,以及我在实际抓取中踩过的一堆坑。
如果你是做内容运营、竞品分析、个人知识库归档,或者刚入门爬虫想找一个完整项目练手,这套基于 Python + requests + BeautifulSoup 的轻量方案直接照着做就能跑起来,不需要上重型框架。
1. 爬取公众号文章的前置认知:先搞清楚数据在哪
1.1 公众号文章的两种访问入口
微信公众号的内容其实分成两个层面。第一层是文章详情的公开链接,形如mp.weixin.qq.com/s/xxx,凡是发布出去的文章都会生成一个独立的 URL,这部分是公开的,任何人拿到链接都能在浏览器里打开,不需要登录态;第二层是某个公众号的历史文章列表页,形如mp.weixin.qq.com/mp/profile_ext?action=home&__biz=...,这个页面必须带有登录凭证才能看到完整文章列表,而且它是滚动加载、没有分页按钮的。
这两层数据的获取难度完全不同。文章详情页只要用 requests 发送一个 GET 请求就能拿到 HTML;历史列表接口则需要先让自己的请求“伪装”成微信后台的正常访问,而且每次翻页都要更新特定的偏移参数。换句话说,我们爬取的整体思路是先突破列表接口拿到所有文章 URL,再逐个去抓详情页正文。
这个“先列表、再详情”的拆分非常关键。列表接口如果一页能返回 10 篇文章记录,那抓一个只有 300 篇文章的公众号只需要请求 30 次列表接口;但如果不去拿列表,而是指望从搜索引擎或第三方聚合站反查文章链接,不仅数量不全,发布时间也无法保证准确,效果会差很多。
1.2 历史列表接口的工作机制
微信的公众号主页在前端是一个滚动页面,用户往上拖动时,页面会不断向后端发送请求,返回更早时间的文章记录。这个请求的接口路径就是上面提到的profile_ext,它接收action=home、__biz、offset、count等参数,其中最关键的是__biz,它相当于这个公众号在微信平台的唯一身份标识,排他性很强。
请求返回的内容是一个 JSON 字符串,里面包含当前批次文章列表的完整字段,比如每篇文章的标题、摘要、封面图、发布时间、文章链接等。我在第一次打开这个接口时看到返回结构后,最大的感受是:微信对文章列表这个环节并没有做太复杂的加密,核心限制来自 Cookie 的有效期和访问频率。
也就是说,列表接口的难点不是“能不能模拟”,而是“怎么稳定地模拟”。Cookie 一旦过期,接口会立刻返回错误码或者验证码,程序就会中断。所以我在整个方案里把 Cookie 维护单独拎了出来,后面第 6 节会详细讲怎么处理。
1.3 技术路线选型与合规边界
很多人一提到爬取动态页面,第一反应就是上 Selenium 或者 Playwright 开浏览器模拟点击。这个思路确实可行,但我不推荐作为主方案。原因很简单:历史列表滚动加载一次只出 10 条左右,如果一个公众号有 500 篇文章,浏览器自动化要滚动 50 多次,耗时极长;而且浏览器指纹、加载失败重试这些逻辑都要自己处理,稳定性反而比 requests 直接重放请求差。
我的方案是纯 requests 模拟:从浏览器开发者工具里复制出列表接口的完整请求头和 Cookie,用 Python 原样重放,解析 JSON,更新偏移量,循环直到接口返回“没有更多数据”。这个方案的优点是快、轻、可控,缺点是需要先做一次“手动抓包”,但整个过程只需要 5 分钟。文章详情页的抓取也类似,requests 获取 HTML 后用 BeautifulSoup 解析正文节点即可。
这里必须提合规问题。爬虫技术本身是中性的,但它应该用在你有权使用的场景里。抓取某个公众号的所有历史文章,至少应该满足两个前提:一是你有正当的使用目的,比如做竞品分析、内容备份、个人学习研究;二是抓取频率不能影响对方的正常服务,不要高频请求、不要尝试绕过风控机制。微信公众号平台的服务协议对自动化批量抓取有明确限制,实际操作时我建议把每次请求的间隔控制在 2 秒以上,并且只抓自己确实需要的内容。
2. 环境准备与工具选型:开工前的 20 分钟准备
2.1 搭建 Python 运行环境
我默认你会一点 Python 基础,不需要多深,能看懂函数、循环、字典操作就行。环境方面,建议直接用 Python 3.9 以上的版本,因为 3.8 及以下版本对类型注解和部分语法支持不够好,没必要给自己添麻烦。
准备环境这件事是有讲究的。如果你之前已经装过 Python,我强烈建议为这个项目单独创建一个虚拟环境,而不是直接往全局环境里装依赖。公众号抓取需要的库虽然不多,但涉及 requests、beautifulsoup4、lxml、html2text 等,直接往全局装容易和别的项目冲突。我用的是标准库自带的 venv,命令非常简单:
python -m venv venv # Windows 激活方式 venv\Scripts\activate # macOS / Linux 激活方式 source venv/bin/activate激活后你会看到命令行前缀多了(venv),这就说明已经进入独立的虚拟环境了。接下来安装依赖库,一条命令搞定大部分:
pip install requests beautifulsoup4 lxml html2textrequests负责发 HTTP 请求,beautifulsoup4负责解析 HTML,lxml是比 Python 默认解析器快好几倍的解析引擎,html2text用来把正文 HTML 转成更容易阅读的 Markdown 格式。这四个库的组合对公众号文章来说基本够用了。
如果你用的是 macOS 或者 Linux,系统自带 Python 版本往往比较老,建议先去官网下载安装新版本。Windows 用户则要注意安装时勾选“Add Python to PATH”,否则命令行里敲python会提示找不到命令。这一步卡住的概率其实挺高,所以特意提一句。
2.2 依赖库的功能分配与选型理由
我把这几个库在项目里的分工展开说一下,方便你理解为什么选它们而不是别的。
requests是整个爬虫的主动脉。无论是列表接口还是详情页,所有 HTTP 请求都由它发出。相比 Python 标准库的urllib,requests 的会话保持、请求头设置、超时控制都要直观得多,代码量大减。我经常跟朋友说,requests 的session.get(url, headers=headers, timeout=15)这一行,就是 urllib 要写七八行才能达到的效果。
BeautifulSoup是正文解析的主力。公众号的 HTML 结构和一般网页不同,大量使用<section>标签嵌套,而且很多样式是内联的。BeautifulSoup 可以让我们直接按 id、class、标签名三类维度去定位节点,比如soup.find("div", id="js_content")就能拿到正文根节点,非常方便。它底层的解析器我选用lxml,因为纯 Python 的html.parser在解析大页面时能明显感觉到卡顿,而 lxml 基于 C 语言实现,速度和稳定性都有保障。
html2text是一个小巧精致的转换工具。公众号正文是 HTML,但大家最后多半要存成 Markdown 或 Word 交给业务方。html2text可以把有序列表、无序列表、加粗、标题这些常用标签自动转成 Markdown 语法。不过它对微信特有的某些<section>样式处理一般,所以我在第 4 节里给了更稳妥的提取方案。
2.3 用浏览器开发者工具抓取关键请求参数
依赖装好之后,先别急着写 Python 代码。我们得先从浏览器里拿到两个关键信息:登录 Cookie 和公众号的__biz参数。
操作路径是这样的:先在电脑上打开任意一篇目标公众号的文章链接,微信号处于正常登录状态。按 F12 打开浏览器开发者工具,切到“Network”面板,勾选保留日志。然后在公众号主页历史消息区域往上拖动,让页面加载一批新的文章。这时 Network 面板里会出现一个以https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=开头的请求,右键它,选择“Copy as cURL”或者直接查看它的请求头和 Query String Parameters。
在请求的地址栏里你就能看到__biz的完整值,是一长串 Base64 编码的字符串。请求头里的Cookie是后续程序身份验证的凭证,session 有效期内可以反复使用。这两项数据先保存到本地文本文件里,后面代码中会用到。
这个过程看起来是“手动抓包”,但它其实是理解整个接口的第一步。只有亲眼看到请求是怎么发出的、参数长什么样,后面在 Python 里重放时才不会盲目。
3. 列表抓取与翻页:拿到全量文章的入口
3.1 构造历史列表请求并重放
拿到__biz和 Cookie 后,我们开始写第一段核心代码:重放历史列表请求。我直接给出基础版本,并解释每一部分的作用。
import requests import json import time URL = "https://mp.weixin.qq.com/mp/profile_ext" MY_BIZ = "你的__biz值" COOKIE = "你的完整Cookie字符串" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Cookie": COOKIE, "Referer": "https://mp.weixin.qq.com/", } params = { "action": "home", "__biz": MY_BIZ, "offset": 0, "count": 10, "is_ok": 1, "scene": "", } resp = requests.get(URL, params=params, headers=headers, timeout=15) data = resp.json() print(json.dumps(data, ensure_ascii=False, indent=2)[:2000])这段代码有几个细节需要强调。headers里的User-Agent必须设为浏览器的标准 UA,否则微信服务器很容易识别出是自动化脚本并返回异常;Referer保持为mp.weixin.qq.com,这是为了模拟用户从公众号页面发起的跳转请求;params里的offset是翻页的核心参数,第一次请求从 0 开始。
如果你运气好,返回结果里会有类似于下面的字段结构:
{ "ret": 0, "general_msg_list": "{\"list\": [...]}", "can_msg_continue": 1, "next_offset": 10 }注意,general_msg_list的值本身是一个字符串,而不是嵌套的 JSON 对象。这算是微信接口的一个小坑,解析时需要用json.loads()再解一次才能拿到真正的文章列表。很多新人在这一步卡住,其实只是数据嵌套了两层。
3.2 解析列表 JSON 并提取文章字段
拿到返回结果后,我们把它解析成可以直接操作的数据结构。我把文章列表处理单独抽成一个函数,这样翻页循环里反复调用会更清晰。
def parse_article_list(raw_data): """从返回的 JSON 中解析文章列表,返回文章字段列表""" if raw_data.get("ret") != 0: return [], 0, False can_continue = int(raw_data.get("can_msg_continue", 0)) next_offset = int(raw_data.get("next_offset", 0)) msg_list_str = raw_data.get("general_msg_list", "{}") try: msg_list = json.loads(msg_list_str) except json.JSONDecodeError: return [], next_offset, False articles = [] for item in msg_list.get("list", []): articles.append({ "title": item.get("title"), "digest": item.get("digest"), "link": item.get("link"), "create_time": item.get("create_time"), "cover": item.get("cover"), "appmsgid": item.get("appmsgid"), }) return articles, next_offset, can_continue == 1这个函数做的事很简单:检查接口是否正常,从嵌套字符串中解出文章列表,提取标题、摘要、链接、发布时间、封面图和appmsgid。appmsgid是每篇文章在公众号内部的唯一 ID,后面做增量更新时它就是天然的“去重主键”。
这里还有一个隐含的细节:列表接口返回的字段里,link值是完整的文章 URL,可以直接用。有些旧版本的提取工具需要自己拼接__biz和appmsgid来构造链接,但当前接口直接返回link字段,省了不少事。
3.3 翻页循环与断点续抓设计
列表接口的翻页不是靠页码,而是靠offset。每页返回 10 条记录,第一页完成后next_offset变成了 10,下一次请求就把offset设为 10,以此类推。接口通过can_msg_continue字段告诉我们是否还有更多历史文章,等于 0 就代表到底了。
下面的循环实现了完整翻页,并且加了一个“断点续抓”的思路:
session = requests.Session() session.headers.update(headers) all_articles = [] offset = 0 page_num = 1 while True: params["offset"] = offset try: resp = session.get(URL, params=params, timeout=15) raw = resp.json() except Exception as e: print(f"第 {page_num} 页请求异常: {e}") time.sleep(3) continue articles, next_offset, can_continue = parse_article_list(raw) if not articles: print(f"第 {page_num} 页没有解析到文章,停止翻页") break all_articles.extend(articles) print(f"第 {page_num} 页解析到 {len(articles)} 篇文章,累计 {len(all_articles)} 篇") if not can_continue: print("接口标记列表已结束") break offset = next_offset page_num += 1 time.sleep(2)这段代码里我特意加了异常重试逻辑,因为网络抖动是家常便饭。如果某一次请求超时或者返回的 JSON 格式不对,程序不会直接崩溃,而是等待 3 秒后重试当前页。每次翻页之间睡眠 2 秒,是为了把请求频率控制在比较安全的范围内。
all_articles这个列表会不断累积所有文章记录。抓完一轮后,我建议立刻把列表保存到本地文件里,比如存成 JSON 或 CSV。这样的话,即使后面解析详情页时程序中断,我们也能拿已经抓到的文章 URL 列表继续跑,不需要重新翻页。
4. 文章详情抓取与正文清洗:从链接到可读文本
4.1 定位正文根节点与内容提取
文章 URL 列表到手后,接下来就是对每篇文章发请求、解析正文。公众号详情页的 HTML 结构比普通博客复杂,但正文都固定放在一个div节点里,这个节点的id是js_content。只要定位到它,提取正文就成功了一大半。
from bs4 import BeautifulSoup def fetch_article_detail(url): resp = requests.get(url, headers=headers, timeout=15) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "lxml") title = soup.find("h1", class_="rich_media_title") title_text = title.get_text(strip=True) if title else "无标题" content_div = soup.find("div", id="js_content") if not content_div: return None text = content_div.get_text("\n", strip=True) return { "title": title_text, "text": text, "html": str(content_div), }这里有两个容易犯的错。第一个是编码问题,公众号页面的字符编码是 UTF-8,requests有时会通过响应头自动猜测成 ISO-8859-1,所以最好手动指定resp.encoding = "utf-8"。第二个是id选择器,正文节点在不同版本的页面里可能同时存在js_content和js_article两个 id,取js_content是最稳妥的,它一定是正文根节点。
如果content_div是空节点,那大概率是页面加载了验证页或者 Cookie 过期了,这种文章应该跳过并记录下来,等后面统一重试。
4.2 图片:从>import os import requests def download_images(content_div, save_dir="images"): os.makedirs(save_dir, exist_ok=True) img_urls = [] for img in content_div.find_all("img"): real_url = img.get("data-src") or img.get("src") if not real_url: continue try: img_resp = requests.get( real_url, headers={"Referer": "https://mp.weixin.qq.com/"}, timeout=10, ) if img_resp.status_code == 200: filename = os.path.join(save_dir, real_url.split("/")[-1].split("?")[0]) with open(filename, "wb") as f: f.write(img_resp.content) img_urls.append(filename) except Exception as e: print(f"图片下载失败: {real_url}, {e}") return img_urls
图片下载时的Referer必须带上,而且值是https://mp.weixin.qq.com/。如果不带这个请求头,微信图床可能返回 403,看到“防盗链”字样就是这个原因。另外,微信图片的 URL 里通常带一串长长的签名参数,有效期有限,所以最好在抓取当天就把图片下载到本地,不要只保存 URL。
4.3 正文清洗:合并段落、去脚本、提取纯文本
公众号正文里除了文字和图片,还会混入很多无用的代码块,比如script、style、iframe标签。提取纯文本时,如果没有把这些节点移除,最终文本里会夹杂一堆 JS 逻辑和样式代码,看着非常糟心。
我在get_text前增加一步清理:
for tag in content_div.find_all(["script", "style", "iframe"]): tag.decompose() # 对于 section 标签,每一段后加换行,保证段落结构 for tag in content_div.find_all(["p", "section"]): tag.append("\n")decompose()是 BeautifulSoup 的一个方法,作用是把节点从文档树中彻底移除。处理完之后再调用get_text("\n", strip=True),就能够得到一个段落分明、没有脚本噪音的纯文本。
如果目标格式是 Markdown,我会建议在清理后用html2text转换一次:
import html2text converter = html2text.HTML2Text() converter.ignore_links = False converter.body_width = 0 markdown_text = converter.handle(str(content_div))body_width = 0表示不强制换行,避免长段落被截断成好几行。这样生成的文件可以直接放进笔记软件或者文档系统里,可读性比纯 HTML 强很多。
5. 存储方案与增量更新:爬一次不算完,能重复跑才是真
5.1 本地文件的组织方式
文章抓下来后,最忌讳的就是“都堆在内存里,关掉程序一切消失”。我习惯用“元数据 + 正文文件”分离的方式组织目录结构:
wechat_archive/ ├── articles_meta.json # 所有文章的元数据列表 ├── images/ # 文章图片目录 ├── markdown/ # 每篇文章转成的 Markdown 文件 │ ├── 2024-01-01_文章标题.md │ ├── 2024-01-05_文章标题.md └── logs/ # 抓取日志articles_meta.json保存的是列表接口返回的结构化信息,包括标题、作者、发布时间、文章链接、封面图、摘要。它相当于一个索引,后续要做搜索、统计、筛选都可以基于它操作,不必重新抓网。markdown/目录则是每篇正文的纯文本内容,文件名以发布时间开头,方便按时间排序找文章。
采用这种结构后,就算原始网页被删掉,你本地依然保留着一份可读的内容副本。对于做竞品分析、内容归档的场景,这就足够了。
5.2 增量更新机制:用 appmsgid 去重
公众号是持续更新的。今天抓完全量文章,明天可能又多了两三篇。如果不做增量更新,每次重新全量抓一遍既消耗时间又增加被拦截风险。
去重逻辑的核心是appmsgid。每篇文章发布时都会生成一个唯一的appmsgid,不会重复。增量更新的做法是:
- 抓取前,先把本地
articles_meta.json中已有的所有appmsgid读入一个集合 - 翻页时,每解析到一篇文章,先判断它的
appmsgid是否已经在集合中 - 如果已经存在,说明这篇文章以前抓过,跳过详情页抓取
- 如果不存在,抓详情页正文,并把新的
appmsgid加入集合
这样,翻页过程中即使前面 50 篇都是旧文章,也只需要跳过,并不会无限循环下去。列表接口翻页依然会返回所有数据,但省去的是详情页正文请求那部分开销。
5.3 定时任务与批量导出的扩展思路
增量更新稳定后,可以把它挂到系统的定时任务里。Windows 上用“任务计划程序”,macOS / Linux 上用 cron,每天凌晨 2 点跑一次增量更新脚本,公众号发布新文章后,本地归档会在次日凌晨自动同步。
定时任务脚本里我会额外加一步:生成一个汇总索引页,把当天的文章标题、链接、摘要按时间倒序拼成一个 Markdown 或 HTML 文件。这样即使不打开数据库,也能快速浏览最近更新了什么。
如果需要更大规模的检索,可以把元数据导入 SQLite 甚至 Elasticsearch,按标题、正文关键词做全文检索。但这些都是后话,先跑通本地文件存档,就已经能满足绝大多数内容归档需求了。
6. 常见问题与排查技巧实录:我踩过的坑都在这里
6.1 Cookie 失效与登录态排查
这是整个爬虫流程中出现频率最高的坑。列表接口对 Cookie 依赖很强,Cookie 一旦失效,接口不会正常返回文章列表,而是返回错误码、空列表或者验证码页面。我在调试时遇到过最典型的现象是:第一次运行程序一切正常,第二次运行就请求失败,原因就是上一次手动复制 Cookie 后过了太久,登录态已经过期。
排查方法很简单。直接在浏览器里打开公众号主页,确认是否还能正常加载文章列表,如果浏览器里也要重新扫码登录,那就说明登录态整个失效了,必须重新扫码登录后再复制新的 Cookie。如果你用 requests 重放时遇到验证码,千万不要尝试用任何方式去自动打码绕过,这本质上是在和平台风控对抗。正确做法是停下手里的自动化请求,冷却一段时间,把频率降下来,再继续处理。
6.2 返回异常、字段缺失与频率控制
接口偶尔会返回ret不等于 0 的错误码,或者干脆返回 302 跳转。造成这种现象的原因主要有三种:请求头没模拟到位、Cookie 过期、请求频率太高。前两种只要重新检查 Cookie 和 UA 就行;第三种需要你在代码层面主动控制节奏。
我实测下来,两次请求之间间隔 2 秒,对脚本类操作来说比较安全。如果出现错误码,程序应该自动暂停更长时间,比如 10 分钟,而不是立刻重试撞风控。另外一个容易忽略的点是,解析列表字段时最好用.get()而不是直接["key"]索引,因为某些文章的字段可能缺失,直接索引会抛 KeyError,导致整个程序中断。
6.3 图片下载失败与编码问题
图片下载失败最常见的原因是没带Referer请求头,导致微信图床拒绝访问。其次是图片链接里带参数,保存文件时需要把?后面的参数去掉,否则文件名里会多出一长串无意义字符。
编码问题的表现是正文里出现乱码或者问号。公众号页面的charset明确是 UTF-8,只要在requests.get后主动声明resp.encoding = "utf-8",基本能避免。如果你的程序是在 Windows 老版本终端里运行,可能会出现控制台输出乱码的“假象”,这时把输出重定向到文件再检查,会更准确。
6.4 页面结构变化的应对策略
微信的页面结构不是一成不变的,每隔一段时间可能调整标签、样式或接口参数。如果你的代码昨天还能跑、今天解析不出正文,很可能是页面结构变了。我处理这类问题的方法是:把出错页面的 HTML 保存到本地文件,用编辑器直接搜索关键字,比如搜“js_content”,看它是否还在,如果找不到就搜文章标题,看正文部分换成了什么 id。
下面是我整理的排查速查表,遇到问题可以直接对照处理:
| 现象 | 可能原因 | 排查方向与解法 |
|---|---|---|
列表请求返回ret != 0 | Cookie 失效 / 请求头不完整 | 重新从浏览器复制 Cookie,检查 UA 和 Referer |
| 列表返回空数组 | 翻页 offset 传递错误 | 确认next_offset字段是否在返回 JSON 中 |
general_msg_list解析失败 | 返回值不是字符串而是对象 | 用json.loads前先isinstance判断类型 |
| 正文为空 | 页面结构变动 / 触发验证 | 保存 HTML 本地检查js_content节点是否存在 |
| 图片返回 403 | 防盗链或图片链接过期 | 下载时补全Referer: https://mp.weixin.qq.com/ |
| 程序运行一半崩溃 | 单篇文章异常导致中断 | 给详情页请求加 try/except,失败记录后跳过 |
| 频繁出现验证码 | 请求频率过高 | 每页间隔拉大到 5 秒以上,并暂停一段时间 |
这些坑大多不难解决,但需要在实际运行中积累经验。我的习惯是每次运行都保留日志文件,抓了多少篇、哪些失败、失败原因是什么都记录下来,这样出了问题能快速定位,而不是反复无脑重跑。
最后再分享一个小经验:跑这种长时间抓取任务之前,一定要记得先把文章列表保存下来,这是整个流程里最耗时、最容易失败的一环。列表拿到手,正文就算今天下载失败,明天拿 URL 列表重新跑一遍就行,不用重新翻页。我正是靠着这个习惯,在几次中途断网、Cookie 过期的情况下,都毫无压力地恢复了整个任务。