资讯详情

资讯详情

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;q0.9, } # 输出目录按公众号名称分文件夹避免不同号的文章混在一起 OUTPUT_DIR ./articles os.makedirs(OUTPUT_DIR, exist_okTrue) def fetch_article_list(list_url, max_pages5): 从列表页抓取文章链接返回 [(标题, 链接), ...] articles [] for page in range(max_pages): # 分页参数按实际抓包结果调整这里用 offset 递增做示例 params {offset: page * 10, count: 10} resp requests.get(list_url, headersHEADERS, paramsparams, timeout15) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) # 列表项选择器需根据实际页面结构调整 for item in soup.select(a.article-link): title item.get_text(stripTrue) 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, headersHEADERS, timeout15) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) # 标题优先取 og:title回退到 h1 title_tag soup.find(meta, propertyog:title) or soup.find(h1) title title_tag.get(content) if title_tag and title_tag.name meta else ( title_tag.get_text(stripTrue) if title_tag else untitled ) # 正文容器常见 id 为 js_content取不到时回退到 article 标签 content_div soup.find(idjs_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(stripTrue) 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_timeNone): 保存文章元数据到 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, encodingutf-8) as f: json.dump(meta, f, ensure_asciiFalse, indent2)元数据单独落盘的好处是后续做检索或统计时不用再解析 HTML直接读 JSON 就行。字段命名保持一致性方便批量处理。4. 避坑指南批量下载中最容易翻车的五个地方4.1 链接过期导致批量任务中途失效现象脚本跑到一半后续文章全部返回空内容或跳转到首页。原因是列表页里的文章链接通常带有时效性签名参数超过一定时间后失效。解决方式是在抓取列表后尽快处理详情页不要先抓完几百个链接再统一下载。如果任务量确实大分批处理每批控制在 50 篇以内抓完一批再抓下一批列表。4.2 图片防盗链导致下载全为 403现象图片 URL 在浏览器里能打开但脚本请求返回 403。原因是服务端校验了Referer头。解决方式是在请求图片时带上文章页的 URL 作为Refererheaders {**HEADERS, Referer: article_url} r requests.get(img_url, headersheaders, timeout20, streamTrue)这个改动很小但能解决大部分图片下载失败的问题。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_patharchive.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(pathretry.json): if os.path.exists(path): with open(path, r, encodingutf-8) as f: return json.load(f) return [] def save_retry_queue(queue, pathretry.json): with open(path, w, encodingutf-8) as f: json.dump(queue, f, ensure_asciiFalse, indent2)这个机制看起来简单但能显著提升长期运行的稳定性。网络抖动、临时限制、页面改版导致的单篇失败都能在下一轮自动恢复。5.3 归档结果的检索化处理下载下来的 HTML 和 JSON 只是原料真正发挥价值需要做检索化处理。常见做法是把正文文本抽出来写入支持全文检索的存储比如 SQLite 的 FTS5 扩展或轻量级的 Whoosh 索引。这样就能按关键词搜索历史文章做内容分析时效率提升明显。import sqlite3 def build_fts_index(db_patharchive.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, encodingutf-8) as f: soup BeautifulSoup(f.read(), html.parser) text soup.get_text(separator\n, stripTrue) conn.execute(INSERT INTO articles_fts (title, content) VALUES (?, ?), (name, text)) conn.commit()建好索引后用SELECT * FROM articles_fts WHERE articles_fts MATCH 关键词就能快速定位相关文章。这一步把归档从「存起来」推进到「用起来」对做内容分析的人来说是质变。我自己跑这套流程最大的教训是不要追求一次抓完所有历史文章。分批、增量、带重试队列的方式看起来慢但实际完成率远高于一次性全量抓取。另外每次页面结构变化都会让选择器失效所以脚本里多留几层兜底逻辑比事后修 bug 省心得多。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →