Python批量下载公众号文章:工程化归档与避坑指南

简介:这是一套面向内容运营、数据分析与爬虫开发者的微信公众号文章批量下载工具源码,解决公众号文章、阅读量、评论等数据难以批量留存与二次分析的问题。项目支持关键字搜索公众号及文章、按作者/标题/发布时间/原创标识/合集等条件过滤,可导出html、json、excel、txt多种格式,其中html会打包图片与样式文件以还原文章样式,并支持合集下载、图片与视频分享消息、评论回复及转发量导出,还提供订阅机制与开放API接口,可私有化部署于Windows、macOS、Linux或Docker环境。资源包共109个文件,约12.02MB,以34个ts与24个vue构成前后端主体逻辑,辅以29个png界面素材、10个md说明文档及json、js、css等配置与样式文件,结构完整便于二次开发。目前已有738人学习下载,适合希望快速搭建公众号数据采集与归档流程的开发者参考。

1. 批量下载公众号文章:从手动复制到工程化归档的转折点

做内容归档的同行大概都经历过这种场景:某个公众号积累了三四年的深度文章,想一次性保存下来做离线检索或二次分析,结果只能一篇篇点开、复制、粘贴,几十篇下来手腕发酸,几百篇直接劝退。Wechat Article Exporter 这类工具解决的正是这个痛点——把公众号文章从「逐篇手动搬运」变成「批量自动落盘」。它的核心逻辑并不复杂:给定一个公众号的历史文章列表入口,程序遍历每篇文章的详情页,提取标题、作者、发布时间、正文 HTML 和配图,按规则命名后写入本地目录。适合做竞品内容监测的运营、需要建垂直语料库的算法工程师,以及想给自己关注的号做一份离线备份的普通用户。但批量下载这件事,真正动手才会发现坑远比想象中密集:链接有效期、正文懒加载、图片防盗链、频率限制,每一个都能让脚本跑到一半直接翻车。

2. 拆解批量下载的技术链路:从列表页到本地文件

2.1 公众号文章的三层页面结构

要理解批量下载为什么容易出问题,得先看清公众号文章在 Web 端的页面组织方式。它大致分三层:第一层是公众号主页或历史消息列表页,承载文章标题、封面图和跳转链接;第二层是文章详情页,正文内容在这里渲染;第三层是图片和样式资源,通常托管在独立的 CDN 域名下。批量下载工具要做的就是按顺序穿透这三层,把每一层需要的数据抽出来。

列表页的翻页机制是第一个需要吃透的点。公众号历史消息在 Web 端通常以「滚动加载 + 分页参数」的方式呈现,常见做法是构造带offset和count的请求,每次拿一页数据。这里有个容易忽略的细节:offset的步长不一定等于count,有些实现里它是按「天」或按「批次」递增的。我一般会先手动翻两三页,用浏览器开发者工具抓请求,确认分页参数的真实递增规律,再写进代码。

详情页的正文提取是第二个关键点。正文容器的 id 在不同时期有过变化,常见的是js_content这类标识。但直接取innerHTML往往会带上一堆内联样式和空标签,需要做一轮清洗。图片的>import os import re import time import requests from bs4 import BeautifulSoup from urllib.parse import urljoin # 请求头:模拟常规浏览器访问,避免被识别为异常流量 HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ), "Accept-Language": "zh-CN,zh;q=0.9", } # 输出目录:按公众号名称分文件夹,避免不同号的文章混在一起 OUTPUT_DIR = "./articles" os.makedirs(OUTPUT_DIR, exist_ok=True) def fetch_article_list(list_url, max_pages=5): """从列表页抓取文章链接,返回 [(标题, 链接), ...]""" articles = [] for page in range(max_pages): # 分页参数按实际抓包结果调整,这里用 offset 递增做示例 params = {"offset": page * 10, "count": 10} resp = requests.get(list_url, headers=HEADERS, params=params, timeout=15) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") # 列表项选择器需根据实际页面结构调整 for item in soup.select("a.article-link"): title = item.get_text(strip=True) href = urljoin(list_url, item.get("href", "")) if title and href: articles.append((title, href)) time.sleep(1.5) # 控制频率,给服务端留缓冲 return articles def extract_content(article_url): """提取单篇文章的标题、正文 HTML 和图片地址列表""" resp = requests.get(article_url, headers=HEADERS, timeout=15) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") # 标题:优先取 og:title,回退到 h1 title_tag = soup.find("meta", property="og:title") or soup.find("h1") title = title_tag.get("content") if title_tag and title_tag.name == "meta" else ( title_tag.get_text(strip=True) if title_tag else "untitled" ) # 正文容器:常见 id 为 js_content,取不到时回退到 article 标签 content_div = soup.find(id="js_content") or soup.find("article") if not content_div: return title, "", [] # 图片地址:优先>def clean_html(content_html): """清洗正文 HTML:去属性、去空标签、保留文本和图片""" soup = BeautifulSoup(content_html, "html.parser") # 移除 script 和 style 标签 for tag in soup.find_all(["script", "style"]): tag.decompose() # 清理所有标签的属性,只保留 img 的 src for tag in soup.find_all(True): if tag.name == "img": src = tag.get("data-src") or tag.get("src") tag.attrs = {"src": src} if src else {} else: tag.attrs = {} # 移除空段落 for p in soup.find_all("p"): if not p.get_text(strip=True) and not p.find("img"): p.decompose() return str(soup)

清洗的核心目的是让后续处理更省心。去掉属性后,HTML 体积通常能缩小一半以上;移除空段落能避免检索时出现大量无意义的分段。注意img标签要保留src,否则图片引用就丢了。

3.2 图片本地化与路径重写

图片下载到本地后,正文 HTML 里的图片地址还是远程 URL,离线打开时依然加载不出来。需要做一步路径重写,把远程地址替换成本地相对路径。做法是在下载图片时记录 URL 到本地文件名的映射,然后统一替换。

def rewrite_image_paths(content_html, url_to_local): """把正文中的远程图片地址替换为本地相对路径""" soup = BeautifulSoup(content_html, "html.parser") for img in soup.find_all("img"): src = img.get("src") or img.get("data-src") if src in url_to_local: img["src"] = url_to_local[src] return str(soup)

url_to_local是一个字典,键是远程 URL,值是类似images/001.jpg的相对路径。替换后,整个文章目录可以打包带走,离线打开content.html就能看到完整内容。这一步对做长期归档特别重要,否则过一段时间远程图片失效,归档就只剩文字了。

3.3 元数据落盘:标题、时间、来源的规范化

除了正文和图片,每篇文章的元数据也值得单独存一份 JSON。标题、发布时间、原文链接、抓取时间这四个字段是基础。发布时间在页面里通常藏在meta标签或特定的em元素里,提取方式因页面结构而异。我一般会写一个兜底逻辑:先找meta里的og:release_date或类似字段,找不到再找正文附近的日期文本,最后用抓取时间兜底。

import json from datetime import datetime def save_metadata(article_dir, title, url, publish_time=None): """保存文章元数据到 meta.json""" meta = { "title": title, "url": url, "publish_time": publish_time or "", "crawled_at": datetime.now().isoformat(), } with open(os.path.join(article_dir, "meta.json"), "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2)

元数据单独落盘的好处是,后续做检索或统计时不用再解析 HTML,直接读 JSON 就行。字段命名保持一致性,方便批量处理。

4. 避坑指南:批量下载中最容易翻车的五个地方

4.1 链接过期导致批量任务中途失效

现象:脚本跑到一半,后续文章全部返回空内容或跳转到首页。原因是列表页里的文章链接通常带有时效性签名参数,超过一定时间后失效。解决方式是在抓取列表后尽快处理详情页,不要先抓完几百个链接再统一下载。如果任务量确实大,分批处理,每批控制在 50 篇以内,抓完一批再抓下一批列表。

4.2 图片防盗链导致下载全为 403

现象:图片 URL 在浏览器里能打开,但脚本请求返回 403。原因是服务端校验了Referer头。解决方式是在请求图片时带上文章页的 URL 作为Referer:

headers = {**HEADERS, "Referer": article_url} r = requests.get(img_url, headers=headers, timeout=20, stream=True)

这个改动很小,但能解决大部分图片下载失败的问题。

4.3 正文提取为空:容器选择器失效

现象:extract_content返回的 HTML 为空字符串。原因是页面结构改版,js_content这个 id 不存在了。解决方式是加多层兜底:先找js_content,再找article标签,再找class包含content的div,最后找页面中文本密度最高的div。文本密度可以用「文本长度除以标签数量」来粗略估算。

4.4 文件名冲突导致文章互相覆盖

现象:下载完成后发现文章数量比预期少。原因是不同文章的标题清理后可能相同,比如都叫「通知」或「公告」。解决方式是在文件名里加上文章链接的哈希值或发布时间前缀:

import hashlib def build_dir_name(title, url): safe = sanitize_filename(title) hash_suffix = hashlib.md5(url.encode()).hexdigest()[:8] return f"{safe}_{hash_suffix}"

这样即使标题相同,目录也不会冲突。

4.5 频率限制触发后任务静默失败

现象:脚本没有报错,但后续文章内容全是空的。原因是服务端返回了 200 状态码,但内容是验证页或空页。解决方式是在解析正文前先做一次内容校验:如果正文长度小于 50 个字符,或者页面里包含「验证」「频繁」这类关键词,就判定为异常,暂停 60 秒后重试当前文章。

注意:频率限制的判定不要只看状态码,200 不代表成功。内容长度和关键词双重校验更可靠。

5. 进阶技巧:把下载工具变成可持续的内容归档管道

5.1 增量更新:只抓新文章

第一次全量抓取后,后续只需要抓新增文章。实现方式是在本地维护一个已抓取链接的集合,每次抓列表时先比对,遇到已存在的链接就跳过。集合可以存成 JSON 文件,也可以直接用 SQLite。SQLite 的好处是支持按时间范围查询,方便做增量统计。

import sqlite3 def init_db(db_path="archive.db"): conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS articles ( url TEXT PRIMARY KEY, title TEXT, publish_time TEXT, crawled_at TEXT ) """) conn.commit() return conn def is_crawled(conn, url): cur = conn.execute("SELECT 1 FROM articles WHERE url = ?", (url,)) return cur.fetchone() is not None def mark_crawled(conn, url, title, publish_time): conn.execute( "INSERT OR IGNORE INTO articles (url, title, publish_time, crawled_at) VALUES (?, ?, ?, ?)", (url, title, publish_time, datetime.now().isoformat()), ) conn.commit()

增量更新的价值在于把一次性脚本变成可重复运行的管道。每天跑一次,只处理新文章,几分钟就能完成。

5.2 定时任务与失败重试队列

把脚本挂到定时任务上,比如每天凌晨跑一次。失败的文章不要直接丢弃,写入一个重试队列文件,下次运行时优先处理。重试队列可以用简单的 JSON 数组实现,记录失败的 URL 和失败次数,超过 3 次就标记为「需人工检查」。

def load_retry_queue(path="retry.json"): if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) return [] def save_retry_queue(queue, path="retry.json"): with open(path, "w", encoding="utf-8") as f: json.dump(queue, f, ensure_ascii=False, indent=2)

这个机制看起来简单,但能显著提升长期运行的稳定性。网络抖动、临时限制、页面改版导致的单篇失败,都能在下一轮自动恢复。

5.3 归档结果的检索化处理

下载下来的 HTML 和 JSON 只是原料,真正发挥价值需要做检索化处理。常见做法是把正文文本抽出来,写入支持全文检索的存储,比如 SQLite 的 FTS5 扩展或轻量级的 Whoosh 索引。这样就能按关键词搜索历史文章,做内容分析时效率提升明显。

import sqlite3 def build_fts_index(db_path="archive.db", articles_dir="./articles"): conn = sqlite3.connect(db_path) conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, content)") for name in os.listdir(articles_dir): html_path = os.path.join(articles_dir, name, "content.html") if not os.path.exists(html_path): continue with open(html_path, "r", encoding="utf-8") as f: soup = BeautifulSoup(f.read(), "html.parser") text = soup.get_text(separator="\n", strip=True) conn.execute("INSERT INTO articles_fts (title, content) VALUES (?, ?)", (name, text)) conn.commit()

建好索引后,用SELECT * FROM articles_fts WHERE articles_fts MATCH '关键词'就能快速定位相关文章。这一步把归档从「存起来」推进到「用起来」,对做内容分析的人来说是质变。

我自己跑这套流程最大的教训是:不要追求一次抓完所有历史文章。分批、增量、带重试队列的方式看起来慢,但实际完成率远高于一次性全量抓取。另外,每次页面结构变化都会让选择器失效,所以脚本里多留几层兜底逻辑,比事后修 bug 省心得多。希望帮到你。

本文还有配套的精品资源,点击获取

相关新闻

高并发会话状态同步优化:ConcurrentHashMap与分片锁实践

高并发会话状态同步优化:ConcurrentHashMap与分片锁实践

1. 会话状态同步为什么这么容易成为性能瓶颈1.1 会话状态不只是readSeqNo那么简单做消息网关后台这些年,我最大的感受是:网络层和消息队列往往不是最麻烦的,真正让人半夜爬起来看告警的,往往是内存里的会话状态同步。很多人一听到…

2026/10/20 21:14:39 阅读更多 →
公寓管理系统租务闭环:合同、账单与退租清算的状态机设计

公寓管理系统租务闭环:合同、账单与退租清算的状态机设计

整套公寓管理系统做到第三个阶段,我明显感觉画风变了。前两个阶段还在跟公寓、房间、租客档案这些基础数据打交道,写起来就是标准的增删改查;到了第三阶段,突然要面对的是租赁合同、账单、退款、逾期这些带着钱和时间的业务&#…

2026/10/22 0:08:20 阅读更多 →
从零构建skills能力封装系统:注册、发现与调度实战

从零构建skills能力封装系统:注册、发现与调度实战

1. 当“skills”成为一个项目标题:我看到的不是技能清单,而是一套能力封装系统第一次看到“skills”这个项目标题时,我下意识以为又是一个技能树展示页面或者简历生成器。但仔细琢磨这个命名方式——全小写、单数形式、没有任何修饰词——反而…

2026/10/20 21:13:37 阅读更多 →

最新新闻

Electron桌面应用开发实战:从环境搭建到打包发布全流程

Electron桌面应用开发实战:从环境搭建到打包发布全流程

做前端这些年,我大部分时间都在跟浏览器标签页打交道,直到第一次把桌面应用跑起来,才发现原来自己一直被限制在地址栏和刷新按钮围起来的框框里。Electron这个词,最开始是在某段时间频繁出现在同事的讨论里,一个用HTML…

2026/10/21 9:32:40 阅读更多 →
滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

简介:亚太天能T109滑盖智能锁产品说明书是一份PDF格式的官方技术文档,主要面向智能锁安装人员、终端用户及售后维护者,帮助快速掌握产品安装、配置与常见故障处理。全包仅1个PDF文件,大小4.37MB,内容结构清晰&#xff…

2026/10/21 9:32:40 阅读更多 →
PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

上个月我在调试一台高速AGV的驱动系统,整车要求空载跑到8m/s,电机转速已经拉到额定点,驱动器的输出电压也顶到了母线电压极限附近。再往上踩速度指令,转速纹丝不动,三相电流反而开始震荡。那会儿我的第一反应就是&…

2026/10/21 9:32:40 阅读更多 →
C++核心知识点:从内存管理到STL的实战指南

C++核心知识点:从内存管理到STL的实战指南

C 这门语言我前前后后折腾了十几年,用它写过嵌入式驱动、做过游戏服务端,也搭过基础组件库。每次看到新人一头扎进"C 核心知识点"这个搜索词,我其实挺感慨的——这门语言的难点从来不是语法多,而是知识点之间的耦合太深…

2026/10/21 9:32:40 阅读更多 →
T109滑盖智能锁怎么装?安装调试与故障排查全攻略

T109滑盖智能锁怎么装?安装调试与故障排查全攻略

简介:亚太天能T109滑盖智能锁产品说明书以PDF格式发布,适合智能锁用户、安装人员与售后维修人员阅读。内容围绕T109系列指纹密码刷卡锁展开,先介绍前面板、后面板、锁体、机械钥匙、电池盒盖等部件名称与功能,再给出完整安装步骤&…

2026/10/21 9:32:40 阅读更多 →
LLM知识降熵:从信息垄断到理解权下放

LLM知识降熵:从信息垄断到理解权下放

你有没有过这种经历:翻开源码注释、论文摘要或者一份专业报告,每个字都认识,连在一起却像天书。想找人聊聊,身边要么没人懂,要么懂的懒得跟你解释。后来我试着把同一段内容丢给一个大语言模型,几秒钟之后&a…

2026/10/21 9:31:32 阅读更多 →

日新闻

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/22 1:47:45 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/21 21:32:28 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/21 4:27:17 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 21:50:56 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 21:51:55 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 16:24:48 阅读更多 →