
简介这是一套面向Python初学者与新媒体运营人员的微信公众号内容批量采集实战工具集解决日常工作中文章、图片、音频及PDF文档难以高效下载与归档的痛点。资源包共37个文件包含10个核心Python脚本如wechatpho.py系列用于图文下载、linktest.py处理链接队列、Audiodownload模块提取音频、12张示例图片与2张PNG图标、6个文本配置文件含link.txt等链接清单模板、2个可执行工具含免安装版wkhtmltopdf.exe用于HTML转PDF以及PDF说明文档和压缩工具包整体80.23MB结构清晰、即装即用。已有3205人学习下载覆盖从单链接抓取到多链接批量导出文本、图片、音频及PDF的全流程所有脚本均经实际测试附带备份版本与分阶段命名如wechatpho2.py、wechatpho3OK.py便于理解迭代逻辑与排错验证是快速上手公众号内容自动化采集的实用型入门方案。 把整个公众号的文章连同图片、音频完整拉到本地这个需求我断断续续折腾了一周多。起因是想把某个技术号的系列教程离线归档在通勤路上慢慢看浏览器右键“另存为”只能拿个半成品图片还全挂在CDN上音频更是藏在各种动态加载的标签里。后来我用 Python 写了一套批量下载脚本专门处理微信公众号文章、图片和音频的抓取与落盘。本文就是这套脚本的实战记录包含完整的解析思路、批量并发框架以及我在实跑中踩过并修复的坑。这套东西适合谁读如果你手里攒了一批公众号文章链接想备份到本地或者你维护着某个领域的知识库需要把分散在公众号里的图文和音频资料系统化归档又或者你刚开始接触 Python 爬虫想找一个信息密度高、不是“hello world”级别的练手项目——都可以参考这篇文章的拆解思路。1. 公众号文章批量下载的常见链路拆解在写第一行代码之前我先把一篇文章从前台到后台的完整链路画了一遍。这一步非常值得做因为公众号文章的下载不是简单 GET 一个 HTML 就完事它比普通网页多绕了好几层。1.1 一次请求里到底藏了几层地址公众号文章的标准链接是https://mp.weixin.qq.com/s?__biz...mid...idx...sn...其中__biz是公众号的唯一标识mid是消息 ididx是当天第几条sn是一串防伪参数。这几个字段组合在一起基本就是一篇文章的身份证。但我们在浏览器里打开的链接往往不是长这样。你从搜狗微信、朋友圈、或者第三方转载页点进去看到的可能是https://mp.weixin.qq.com/s/xxxxx这种短链也可能是带了一堆srcid、chksm参数的重定向链接。服务端会先做一次 302 跳转把短链还原成带__biz的标准链再返回真正的 HTML。这意味着如果你用程序直接请求短链要开启allow_redirectsTrue这是 requests 的默认行为并且要把最后落地到response.url的地址当作真正的文章地址来保存。很多新手脚本下载完发现文章内容对不上就是因为只存了第一次请求的 URL没有更新成跳转后的最终地址。1.2 图片、音频不是普通直链公众号正文里的图片绝大多数不会直接出现在src属性里。微信为了优化加载速度图片用的是懒加载策略src里是一个灰色占位图真实的图片地址在>pip install requests beautifulsoup4 lxml urllib3如果你要处理特别多文章还可以加一个fuzzywuzzy做标题模糊匹配、pandas做去重统计但基础版本不需要别把项目一开始就搞复杂。2.2 第一道坎UA、Cookie 与 SSL微信文章页其实不太校验 Cookie直接带 UA 就能请求。但搜狗微信列表页不一样它对新设备、新 IP 的请求很敏感轻则返回验证码页重则直接拒绝。所以我准备了两个头列表页请求头带完整 Cookie文章页请求头带 UA 和 Referer我在脚本里维护了一个会话对象import requests import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) UA Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 COOKIE 你的搜狗微信 Cookie def make_session(): s requests.Session() s.headers.update({ User-Agent: UA, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, }) s.verify False return s为什么把verifyFalse全局关掉不是说不重视安全而是微信和搜狗的 SSL 证书链在某些网络环境下校验不稳定经常报SSLCertVerificationError。个人脚本为了稳定直接关掉再配合urllib3.disable_warnings去掉终端里烦人的警告。这个做法只建议在你自己的爬虫脚本中使用涉及用户敏感信息的场景请不要这样干。2.3 单篇文章抓取的完整骨架先写一个最基础的函数用来验证请求链路是否通def get_article_html(s, url): resp s.get(url, timeout20) resp.encoding utf-8 if resp.status_code ! 200: raise Exception(f文章请求失败: {resp.status_code}) final_url resp.url return resp.text, final_url这里有几个细节要注意都是后来实跑发现的微信文章页是 UTF-8但有些第三方转载页可能是 GBK不能硬编码resp.encoding最好用resp.apparent_encoding做兜底。如果返回内容里有“环境异常”或者“去验证”字样就说明被风控了要立刻停止并发等待一段时间。单篇抓取可以用timeout(5, 15)区分连接超时和读取超时避免一个坏链接把线程池拖死。3. 列表页获取从搜狗微信搜索批量拿文章 URL单篇下载跑通之后下一步就是解决“一批文章从哪里来”的问题。我的做法是从搜狗微信搜索入口出发用关键词把相关文章 URL 批量捞出来。3.1 搜索入口与参数搜狗微信的 PC 端搜索地址是https://weixin.sogou.com/weixin?type2query关键词page1type2表示搜索文章query是关键词page是页码。这个接口返回的是一个标准 HTML里面按时间倒序列出了搜狗收录的公众号文章。要注意的是搜狗对搜索词做了编码你直接往 URL 里塞中文会报错。我习惯用urllib.parse.quote先编码from urllib.parse import quote def build_search_url(keyword, page1): kw quote(keyword) return fhttps://weixin.sogou.com/weixin?type2query{kw}page{page}3.2 列表解析与跳转拿到搜索列表页 HTML 后先定位所有“文章项”。搜狗列表的结构大致是每个li里放一个h3h3 里面是a这个a的 href 看起来像/link?urlxxxkxxx并不能直接用。我一开始走了弯路以为这个/link是最终地址直接 requests 请求结果得到的是一个包含 JS 跳转逻辑的中间页。正确的做法是拿到这个 href 之后拼接完整路径然后用带 Cookie 的 session 去请求它它会 302 到mp.weixin.qq.com的最终文章页。这部分代码长这样from bs4 import BeautifulSoup from urllib.parse import urljoin def extract_article_urls(s, list_html): soup BeautifulSoup(list_html, lxml) urls [] for a in soup.select(h3 a): href a.get(href, ) if not href or weixin.sogou.com in href and not href.startswith(http): continue if href.startswith(/link): full_url urljoin(https://weixin.sogou.com, href) try: resp s.get(full_url, timeout15, allow_redirectsTrue) if resp.status_code 200 and mp.weixin.qq.com in resp.url: urls.append(resp.url) except Exception: continue return list(set(urls))这里有两个细节值得单独说第一搜狗的/link跳转不是每次都能成功有一定概率会跳到“请通过微信客户端打开”的桥接页。所以我在拿到resp.url之后要判断 URL 里是否包含mp.weixin.qq.com不包含就丢弃。第二同一个关键词翻页之后可能会出现重复文章我用了一个list(set())做初筛。但实际命名时更好的做法是用文章 URL 中的sn参数做唯一 ID这样即使 URL 里的chksm参数变了也能识别出是同一篇。3.3 翻页与去重搜狗搜索的翻页逻辑第一眼看觉得很简单直接改page参数就行。但实操一次就会发现搜狗会校验SNUID这个 Cookie以及页面里生成的sst参数。如果你直接改参数去翻页大概率翻不了几页就被封。我最后采用的方案是在页面解析时顺手把“下一页”按钮的href直接捞出来用它做翻页而不是手工拼 URL。这样搜狗当前采用的任何临时参数都能原样带上。def has_next_page(soup): nxt soup.select_one(a#sogou_next) if nxt and nxt.get(href): return nxt[href] return None每次翻页之间我加了 3 到 5 秒的随机延时。搜狗对短时间连续翻页的风控非常严格我个人测试的经验是每页至少等 3 秒连续翻 5 页左右休息半分钟。这个节奏虽然慢但胜在稳定。4. 详情页深度解析正文、图片、音频的提取逻辑文章 URL 拿到手之后真正的主角才登场——详情页解析。这一步决定你最终归档的内容是“完整多媒体包”还是“残缺的网页快照”。4.1 正文与元信息正文提取的关键不是找正文本身而是找容器。公众号的正文包装在一个div idjs_content的节点下面理论上一篇文章的正文都在这个容器里。但要注意这个容器内部可能会有很多嵌套的section、p、img、audio所以不要用get_text()一把梭要按标签分别处理。元信息我通常从两个地方拿标题从h1.rich_media_title拿发布时间从页面源码里的og:article:published_time这个 meta 标签拿。作者信息比较悬有的页面有meta[nameauthor]有的没有我做了多级兜底def parse_basic_info(soup): title untitled t soup.select_one(h1.rich_media_title) if t: title t.get_text().strip() pub_time meta_time soup.select_one(meta[propertyog:article:published_time]) if meta_time and meta_time.get(content): pub_time meta_time[content] author meta_author soup.select_one(meta[nameauthor]) if meta_author and meta_author.get(content): author meta_author[content] return title, pub_time, author发布时间我建议一定要解析出来因为后面的目录结构是按日期组织的没有时间字段文件一多就乱了。4.2 图片地址的两种写法我在不同文章里见过两种图片存储方式。第一种也是最常见的一种图片在img标签的>def extract_images(content_div): images [] for img in content_div.find_all(img): url img.get(data-src) or img.get(src) or if url.startswith(http) and mmbiz.qpic.cn in url: # 去掉微信图片 URL 里可能导致保存失败的空格和转义 url url.replace(\\, ).strip() images.append(url) return images看上面的代码有一个细节是url.replace(\\, )。微信返回的 HTML 源码里有一段 JSON 转换过的字符图片链接里的反斜杠会被保留下来直接请求会 404必须清掉。4.3 音频地址的三种藏身处音频是所有资源里最麻烦的。我在一期文章里完整列了三种可能出现的位置之后的解析都是按这个清单来的正文中的audio标签其src属性直接指向 mp3 或类似音频地址这种情况最省事。正文中的iframesrc指向https://mp.weixin.qq.com/mp/readtemplate?tpages/audio_detail_pageactionmpvoicevoice_encode_fileidxxx真正的音频地址要根据voice_encode_fileid拼出来。源码里出现res.wx.qq.com/voice/getvoice?mediaidxxx这种链接这种情况最容易漏因为标签里可能根本看不到。我的提取函数是这么写的import re def extract_audios(html_text, content_div): audios [] for audio_tag in content_div.find_all(audio): src audio_tag.get(src) or audio_tag.get(data-src) or if src: audios.append(src) iframe_ids re.findall(rvoice_encode_fileid([\w\-]), html_text) for vid in iframe_ids: audios.append(fhttps://res.wx.qq.com/voice/getvoice?mediaid{vid}) direct_links re.findall(rhttps?://[^\\s]?\.mp3, html_text) audios.extend(direct_links) return list(set(audios))这个函数同时兼顾了三种情况而且用set做了去重。实际跑下来发现不少文章会同时命中第一种和第三种导致同一个音频重复下载所以最后一定要list(set())。顺便说一句音频文件的网络地址一般不带扩展名比如getvoice?mediaidxxx保存的时候要根据响应头的Content-Type判断扩展名不能想当然写成.mp3。如果响应头是audio/mp4你强行存成.mp3也能播放但规范一点还是自己加一层类型判断。5. 批量下载框架多线程、目录组织与断点续传单篇解析跑通功能其实已经完成了一小半。接下来要做的是把单篇能力包装成批量框架。这一块的工程性更强也最容易写出“跑起来像老牛拉车”的脚本。5.1 下载任务的结构化设计我做了个ArticleTask结构并不需要额外引入面向对象的复杂设计一个dataclass就够了from dataclasses import dataclass from typing import List dataclass class ArticleTask: url: str title: str pub_time: str author: str images: List[str] None audios: List[str] None每个任务在执行时动态决定哪些资源要下载、存到哪个目录而不是把所有文章先全量抓一遍再统一下载。这样内存占用小而且即使中途崩了已经下载完的任务也不会受影响。5.2 多线程并发多线程这里我用的是concurrent.futures.ThreadPoolExecutor。为什么不直接用requests的异步因为公众号文章服务器对并发耐受度确实一般异步爬得飞快但被封锁的速度也飞快。我最终把线程数控制在 3 到 5 个是实测下来最快又不容易被封的区间。并发框架长这样from concurrent.futures import ThreadPoolExecutor, as_completed def run_batch(tasks, max_workers4): with ThreadPoolExecutor(max_workersmax_workers) as executor: futures {executor.submit(process_one_article, task): task for task in tasks} for future in as_completed(futures): task futures[future] try: result future.result() except Exception as e: print(f任务失败: {task.url} - {e})这里有个常见的坑requests.Session不是线程安全的。我在批量框架里直接放弃共用 session每个线程内部自己创建一个 session。代价是每篇文章要重新握手 TLS但换来的是稳定值。5.3 文件命名与目录文件命名规范直接决定归档质量。我的规则是downloads/ articles/ 2025-01-05_公众号文章标题.html images/ 2025-01-05_公众号文章标题_001.jpg audios/ 2025-01-05_公众号文章标题_001.mp3标题里的非法字符必须清掉否则 Windows 上会报错。我是用正则统一替换def safe_filename(name): return re.sub(r[\\/:*?|\r\n], _, name)有些公众号的标题特别长还会被截断。我的经验是保留前 50 个字符就够了太长的文件名在迁移、压缩时会引发一堆莫名其妙的问题。5.4 去重与断点续传批量下载最怕重复下载同一张图、同一个音频。我做了两层去重资源层面全局维护一个seen_urls集合URL 已出现则跳过。文件层面保存前检查目标文件是否存在且大小大于 100 字节满足条件就视为已下载跳过去。第一层防止文章列表里出现重复链接第二层实现断点续传即使脚本中途崩了重启之后也能“续着走”。def download_file(url, save_path, refererhttps://mp.weixin.qq.com/): if os.path.exists(save_path) and os.path.getsize(save_path) 100: return skipped resp requests.get( url, headers{User-Agent: UA, Referer: referer}, timeout(5, 20), streamTrue, verifyFalse, ) resp.raise_for_status() with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): f.write(chunk) return done注意下载图片或音频时Referer一定要带否则部分 CDN 会返回 403。这个是公众号资源下载最核心的坑之一我会在下一章专门展开。6. 实测中绕不开的坑Cookie 失效、403 防盗链与网络错误这套脚本理论上写完就能跑但“理论上”和“实跑”之间隔着一整条踩坑横沟。我根据自己的试错经历把最有代表性的坑列在这里每一条都付出了实打实的时间代价。6.1 搜狗列表页反爬与 Cookie 失效搜狗微信的反爬机制有几个阶段。刚开始你频繁请求会返回一个“请输入验证码”的页面这时候脚本拿到的是验证码 HTML里面也有h3但解析出来的文章 URL 全是空值列表为空或者只有“验证码”两个字。再往后搜狗会限制整个SNUID的请求频率表现为翻页时页面仍然返回 200但内容明显不对文章数量越来越少。我的对策是在真实浏览器里登录搜狗微信把 Cookie 复制进脚本每页之间至少间隔 3 秒设置一个“请求次数熔断”连续拿到 5 个空列表就直接退出不强行重试。Cookie 失效是必然的不能说一个 Cookie 用一辈子。我做了个简单的失败检测当响应文本里出现antispider或验证码字样时抛出专门异常提示更新 Cookie。6.2 图片 403 防盗链这是我踩得最深的一个坑。第一次跑出来的脚本前 30 篇文章下载得顺顺当当到第 31 篇开始大量图片返回 403。排查了半天发现问题不在代码逻辑而在Referer。微信 CDNmmbiz.qpic.cn对图片请求有防盗链策略要求Referer必须是https://mp.weixin.qq.com/或https://mmbiz.qpic.cn/自己的域名。如果你像请求普通网页一样裸 GETCDN 会认为你是盗链直接拒绝。修复方式就是前面代码里体现的所有资源请求统一带Referer头。图片和音频的 Referer 都指向https://mp.weixin.qq.com/即可。6.3 链接失效与重试机制公众号文章有“被删除”的可能。我批量下载时大概有 3% 到 5% 的文章已经是 404 状态。这个比例在你用搜索引擎或第三方采集工具拿链接时会更高。所以下载函数必须带重试但不能无脑重试。我的策略是第一次请求失败等待 1 秒重试一次第二次失败等待 3 秒重试第二次第三次失败记录失败原因到failed.log跳过这条继续下一条。这个策略对偶发的网络抖动、SSL 重置非常有效。如果连续三次都失败大概率是链接本身已失效再重试就是浪费时间。6.4 SSL 验证问题这个问题前面提了一嘴但这里我再展开一下。微信公众号的 SSL 证书是正常的但如果你处于某些网络环境或者用了本地代理插件requests 会报SSLError: HTTPSConnectionPool。我最初不想关掉验证于是在请求里加了verify参数使用系统证书路径。结果发现在不同机器上表现不一致我的主力机好好的到了云服务器上就报错。后来干脆在脚本里统一verifyFalse配合urllib3.disable_warnings从此再没在这个问题上浪费过时间。要提醒的是verifyFalse只适合这种明知道域名可信、但证书链路校验有问题的场景。生产级的爬虫服务建议还是把证书链搞正确不要给用户留下安全隐患。7. 从“能跑”到“好用”我自己的几个小优化这套脚本跑通之后我并没有立刻收工。真正让它从“玩具”变成“工具”的是下面这几个优化点。它们不复杂但实际使用体验差别很大。7.1 下载完成后的本地关系映射图片和音频下载完成后原始 HTML 里还是网络链接。我后来加了一步解析 HTML 时把每个资源 URL 和本地文件路径的对应关系存成一个 JSON。这样归档后的 HTML 即使没法直接用也能通过 JSON 快速定位到某个资源在本地哪个目录。这个 JSON 的格式很简单{ article_url: https://mp.weixin.qq.com/s/xxx, title: xxx, images: { https://mmbiz.qpic.cn/xxx: downloads/images/2025-01-05_xxx_001.jpg }, audios: {} }有了这个映射之后想做离线阅读器、本地知识库索引都是水到渠成的事。7.2 增量更新我刚开始是每次跑全量后来发现公众号会更新文章全量下载浪费请求还容易被风控。于是改成增量模式每次跑之前把downloads/articles/目录下的文件名读到集合里如果新抓到的标题和日期组合已经在集合里就跳过。这个逻辑并不复杂核心就是把“有没有下载过”的判断前置。批量任务在执行前先过滤一遍能省掉大量无意义的请求也让整套工具更适合持续运行。7.3 合规边界与使用建议最后说一点个人看法。公众号文章的版权属于作者和平台批量下载工具更适合做“个人学习备份”和“资料归档”不建议把下载来的图文、音频重新整理后公开传播更不要用于商业项目。我在实际使用时基本限定在备份自己付费购买、有阅读权限或已获授权的课程类文章这个边界要把握好。另外搜狗微信和微信文章页都有反爬策略脚本请求频率不要太高。我的经验是每篇文章间隔控制在 1 到 2 秒图片和音频的并发线程不超过 5基本不会触发风控。你要真想高速批量抓取建议先征得平台和权利人的同意用官方接口或合作方式去拿数据而不是和反爬系统持续对抗。毕竟写爬虫的人都知道封锁永远在升级稳定的工程方案永远是合规和克制的。本文还有配套的精品资源点击获取