ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Python+requests+BeautifulSoup批量抓取微信公众号历史文章全流程

用Python+requests+BeautifulSoup批量抓取微信公众号历史文章全流程 公众号文章没有对外开放“一键导出历史全部文章”的入口后台只能手动往上翻。真正动手爬的时候又会发现绕不开三个坎历史列表接口需要带登录态去请求文章正文的HTML嵌套结构和“用户看到的不一样”以及翻页流程藏得比较深。这篇内容就是把我自己从零到一跑通“批量抓取公众号历史文章”的完整过程写下来包含环境搭建、列表接口分析、正文清洗、图片落地、增量更新以及我在实际抓取中踩过的一堆坑。如果你是做内容运营、竞品分析、个人知识库归档或者刚入门爬虫想找一个完整项目练手这套基于 Python requests BeautifulSoup 的轻量方案直接照着做就能跑起来不需要上重型框架。1. 爬取公众号文章的前置认知先搞清楚数据在哪1.1 公众号文章的两种访问入口微信公众号的内容其实分成两个层面。第一层是文章详情的公开链接形如mp.weixin.qq.com/s/xxx凡是发布出去的文章都会生成一个独立的 URL这部分是公开的任何人拿到链接都能在浏览器里打开不需要登录态第二层是某个公众号的历史文章列表页形如mp.weixin.qq.com/mp/profile_ext?actionhome__biz...这个页面必须带有登录凭证才能看到完整文章列表而且它是滚动加载、没有分页按钮的。这两层数据的获取难度完全不同。文章详情页只要用 requests 发送一个 GET 请求就能拿到 HTML历史列表接口则需要先让自己的请求“伪装”成微信后台的正常访问而且每次翻页都要更新特定的偏移参数。换句话说我们爬取的整体思路是先突破列表接口拿到所有文章 URL再逐个去抓详情页正文。这个“先列表、再详情”的拆分非常关键。列表接口如果一页能返回 10 篇文章记录那抓一个只有 300 篇文章的公众号只需要请求 30 次列表接口但如果不去拿列表而是指望从搜索引擎或第三方聚合站反查文章链接不仅数量不全发布时间也无法保证准确效果会差很多。1.2 历史列表接口的工作机制微信的公众号主页在前端是一个滚动页面用户往上拖动时页面会不断向后端发送请求返回更早时间的文章记录。这个请求的接口路径就是上面提到的profile_ext它接收actionhome、__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负责解析 HTMLlxml是比 Python 默认解析器快好几倍的解析引擎html2text用来把正文 HTML 转成更容易阅读的 Markdown 格式。这四个库的组合对公众号文章来说基本够用了。如果你用的是 macOS 或者 Linux系统自带 Python 版本往往比较老建议先去官网下载安装新版本。Windows 用户则要注意安装时勾选“Add Python to PATH”否则命令行里敲python会提示找不到命令。这一步卡住的概率其实挺高所以特意提一句。2.2 依赖库的功能分配与选型理由我把这几个库在项目里的分工展开说一下方便你理解为什么选它们而不是别的。requests是整个爬虫的主动脉。无论是列表接口还是详情页所有 HTTP 请求都由它发出。相比 Python 标准库的urllibrequests 的会话保持、请求头设置、超时控制都要直观得多代码量大减。我经常跟朋友说requests 的session.get(url, headersheaders, timeout15)这一行就是 urllib 要写七八行才能达到的效果。BeautifulSoup是正文解析的主力。公众号的 HTML 结构和一般网页不同大量使用section标签嵌套而且很多样式是内联的。BeautifulSoup 可以让我们直接按 id、class、标签名三类维度去定位节点比如soup.find(div, idjs_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?actionhome__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, paramsparams, headersheaders, timeout15) data resp.json() print(json.dumps(data, ensure_asciiFalse, indent2)[: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, paramsparams, timeout15) 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, headersheaders, timeout15) resp.encoding utf-8 soup BeautifulSoup(resp.text, lxml) title soup.find(h1, class_rich_media_title) title_text title.get_text(stripTrue) if title else 无标题 content_div soup.find(div, idjs_content) if not content_div: return None text content_div.get_text(\n, stripTrue) return { title: title_text, text: text, html: str(content_div), }这里有两个容易犯的错。第一个是编码问题公众号页面的字符编码是 UTF-8requests有时会通过响应头自动猜测成 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_dirimages): os.makedirs(save_dir, exist_okTrue) 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/}, timeout10, ) 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, stripTrue)就能够得到一个段落分明、没有脚本噪音的纯文本。如果目标格式是 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 ! 0Cookie 失效 / 请求头不完整重新从浏览器复制 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 过期的情况下都毫无压力地恢复了整个任务。
返回列表