☰
Python批量下载公众号文章:工程化归档与避坑指南
2026/10/20 21:14:30 网站建设 项目流程

简介:这是一套面向内容运营、数据分析与爬虫开发者的微信公众号文章批量下载工具源码,解决公众号文章、阅读量、评论等数据难以批量留存与二次分析的问题。项目支持关键字搜索公众号及文章、按作者/标题/发布时间/原创标识/合集等条件过滤,可导出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 省心得多。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询