拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Python爬虫实战:批量抓取微信公众号历史文章并存入SQLite

Python爬虫实战:批量抓取微信公众号历史文章并存入SQLite

公众号没开放批量导出,网页端又不给复制全文,我为了把某个号的历史文章整理成资料库,硬是用 Python 把抓取链路整条跑通了。这篇文章就把完整方案写出来:从登录态的获取、列表接口的翻页、正文解析,到入库、断点续爬和常见风控问题,照着做就能从最新一篇一直翻到最早一篇,把标题、摘要、作者、封面、正文、发布时间全部落成本地 SQLite 数据库。适合想批量整理公众号内容的人,也适合想通过真实场景练手 Python 爬虫的同学。

先泼一盆冷水:这不是一个“填个 URL 就全自动”的开箱工具,因为微信的接口参数会变、风控也会升级。但它是一套可以复现的方法论。我实际跑下来,几百篇文章的公众号,控制好节奏半小时内能抓完,全程没触发严重风控。下面直接进入正题。

1. 整体思路与方案选型

1.1 公众号文章列表背后到底走的是什么接口

很多人第一反应是去搜狗微信搜索或者各种第三方聚合站。搜狗确实能搜公众号文章,但它只提供最近十页的收录结果,而且搜索词是必要条件,根本没法把一个号的历史文章全量拉出来。第三方聚合站数据不全、更新滞后,还经常包装成付费服务。真正靠谱的入口是微信自己在用的“历史消息”页面。

你在微信里点开任意公众号右上角的“历史消息”,其实访问的是mp/profile_ext?action=home&__biz=...这个地址。浏览器打开后,页面往下翻时,会继续调用mp/appmsglist?action=getmsg这个 JSON 接口,把文章以分页形式返回。这个接口返回的字段很全:文章 ID、标题、摘要、作者、封面图、发布时间、原文链接,如果图文包含多篇文章,还会在multi_app_msg_item_list里返回子文章列表。

所以抓取的核心不是去解析 HTML 正文页面,而是先攻下这个列表接口。拿到列表数据后,再顺着content_url去抓每篇文章的正文 HTML,最后用解析器把正文提取出来。整体链路很清晰:列表接口负责“翻目录”,正文页面负责“取内容”。

1.2 常见抓取方式横向对比

我在选型时列过一张表,把能想到的途径全过了一遍:

方案能做到全量吗主要限制
搜狗微信搜索否只能按关键词搜,收录不全,页码受限
第三方聚合站点/API否数据源不稳定,常有延迟和缺漏
微信公众平台后台素材接口否仅限自己账号,且需要公众号管理员权限
手机端物流化手动复制否效率极低,碰到长文想死
appmsglist列表接口直接抓是需要登录态和 token,要控频

冰点的问题就在“登录态”上。微信不会让匿名用户翻历史列表,所以你需要用浏览器扫码登录一次,把 Cookie、appmsg_token、pass_ticket等参数拿到手。这不是绕过风控,只是让程序使用一个正常用户的身份去访问公开内容。

1.3 这套方案到底能拿到哪些数据

实测下来,单篇文章可以稳定拿到以下字段:

  • 文章唯一 ID(msgid)
  • 标题、摘要、作者
  • 封面图 URL、原文链接
  • 发布时间(Unix 时间戳)
  • 图文消息里包含的所有子文章标题和链接
  • 正文 HTML 和纯文本(通过正文页解析)

意味着你可以用这套数据做本地全文搜索、生成离线 PDF、导出成 Markdown 笔记库,或者做数据分析。这也正是大多数人来写这类脚本的真实需求。

2. 准备工作:拿到“敲门砖”和运行环境

2.1 从公众号主页找到__biz参数

__biz是每个公众号的唯一标识,形如MzA3ND...,一长串 Base64 风格的字符串。它藏在公众号主页链接里。

获取路径:手机微信打开目标公众号 → 点右上角“...” → 找到“历史消息”或“更多资料” → 复制链接。链接长这样:

https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzA3NDExxxxxxxxx==&scene=124&from=...

把这个链接里的__biz参数值复制出来备用。这是后面所有请求都需要的身份标识。

2.2 浏览器扫码一次性拿到 Cookie 和 token

在 Chrome 中打开上面复制出来的历史消息链接,会跳出一个二维码,扫码确认后页面就能正常加载。此时按 F12 打开开发者工具,切到 Network 面板,勾选 Preserve log,再手动刷新页面并往下滚动几次,找到profile_ext或appmsglist开头的请求。

需要从请求头里复制三样东西:

  • 完整的 Cookie 值(请求头里那一大串)
  • 查询字符串里的appmsg_token
  • 查询字符串里的pass_ticket

注意这三个值都跟扫码登录的会话绑定,过一段时间可能失效,失效后重新扫码一遍即可。

提示:别把这个会话泄露给任何人。它等同于你的微信网页登录态,云端爬虫如果被别人拿去,后果跟微信账号被盗差不多。

2.3 Python 环境和依赖库

本地建议用 Python 3.8 及以上版本。只需要四个依赖,尽量装最新版:

pip install requests beautifulsoup4 lxml html2text

requests 负责发 HTTP 请求,beautifulsoup4 和 lxml 负责解析 HTML 正文,html2text 负责把正文 HTML 转成 Markdown。如果你想存 JSON 或纯文本,html2text 可以不要,但我个人强烈建议留一个,因为公众号正文的 HTML 结构很乱,直接转 Markdown 后扔进笔记软件阅读体验会好很多。

3. 核心实现:从列表到正文一把梭

3.1 拉取文章列表接口的请求与响应结构

拿到__biz、Cookie 和 token 之后,先用一个 requests.Session 把基础请求头设置好。请求头里必须带Referer: https://mp.weixin.qq.com/,否则接口容易返回异常。

列表接口真实请求长这样:

import requests session = requests.Session() session.headers.update({ "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", "Referer": "https://mp.weixin.qq.com/", "Cookie": "这里粘贴你的完整Cookie" }) url = "https://mp.weixin.qq.com/mp/appmsglist" params = { "action": "getmsg", "__biz": "这里填__biz参数", "f": "json", "from_msgid": 0, "count": 10, "is_ok": 1, "scene": 124, "wxtoken": "", "appmsg_token": "这里填appmsg_token", "pass_ticket": "这里填pass_ticket" } resp = session.get(url, params=params) data = resp.json()

第一次请求时from_msgid传 0,代表从最新一篇文章开始。返回的 JSON 里,最关键的是general_msg_list字段。注意,它不是一个 JSON 对象,而是一个 JSON 字符串,必须二次解析,这是新手最容易踩的坑:

import json if data.get("ret") == 0: msg_list = json.loads(data["general_msg_list"]) for item in msg_list["list"]: comm = item["comm_msg_info"] ext = item["app_msg_ext_info"] msgid = comm["id"] title = ext["title"] publish_time = comm["datetime"] content_url = ext.get("content_url", "")

content_url字段是相对路径,请求正文前要拼上https://mp.weixin.qq.com前缀。另外微信返回的content_url里带有&符号,是正常的转义,直接使用即可。

3.2 翻页逻辑:从最新往最老单向推进

列表接口的翻页不是页码式,而是游标式。每一页返回 10 条文章,拉到页尾时,把当前页最后一条的comm_msg_info.id作为下一页的from_msgid。

具体流程是:

  1. 第一次请求from_msgid=0,拿到第一页。
  2. 从返回结果里取最后一条的id。
  3. 把from_msgid换成这个 id,重新请求。
  4. 重复直到can_msg_continue返回 0,或者返回的列表为空。

示例代码:

from_msgid = 0 while True: params["from_msgid"] = from_msgid resp = session.get(url, params=params) data = resp.json() if data.get("ret") != 0: print("接口返回异常:", data.get("err_msg")) break msg_list = json.loads(data["general_msg_list"]) items = msg_list.get("list", []) if not items: break # 处理当前页文章 for item in items: # 本文省略处理逻辑,见下一节 pass last_id = items[-1]["comm_msg_info"]["id"] if data.get("can_msg_continue") == 0 or last_id == from_msgid: break from_msgid = last_id time.sleep(random.uniform(3, 8))

last_id == from_msgid这个判断很重要,防止某些情况下微信返回了完全相同的列表导致死循环。我在实际调试中就遇到过因为参数格式不对,连续几页返回同一批数据,差点把请求刷爆。

3.3 处理图文消息里的多篇文章

很多公众号发文章时,不是只发一篇,而是一条图文里带“头条 + 次条”。列表接口返回时,app_msg_ext_info是头条文章,is_multi为 1 时,multi_app_msg_item_list里还会带一串子文章。子文章的字段和头条类似,同样有title、content_url、cover等。

如果只处理头条,漏掉的次条可能比你想象的多得多。我抓过一个科技号,两年内 600 篇文章里,图文合集里藏的子文章有 40 多篇,全是那种“每日快讯”的延伸阅读。所以处理每条数据时,必须把multi_app_msg_item_list也遍历一遍。

def process_items(items): result = [] for item in items: comm = item["comm_msg_info"] ext = item["app_msg_ext_info"] result.append(extract_article(comm, ext)) if ext.get("is_multi") and ext.get("multi_app_msg_item_list"): for sub in ext["multi_app_msg_item_list"]: sub_copy = dict(ext) sub_copy.update(sub) result.append(extract_article(comm, sub_copy)) return result

注意子文章没有独立的发布时间,继承头条的comm_msg_info即可。

3.4 正文抓取与内容净化

列表接口拿到的是文章摘要和链接,要获取完整正文,还得顺着content_url请求文章详情页。正文在 HTML 里位于div id="js_content"中,但也有的页面结构是老版class="rich_media_content",解析时两个都要兼容。

正文 HTML 有三个必须处理的细节,不处理就会出现“抓回来一堆代码”或者“图片全是裂图”的问题:

第一个是图片懒加载。微信正文里<img>标签的真实地址在>from bs4 import BeautifulSoup def parse_content(html: str): soup = BeautifulSoup(html, "lxml") content = soup.find("div", id="js_content") or soup.find( "div", class_="rich_media_content" ) if not content: return "", "" for tag in content.select(".js_share_source, .rich_media_tool, .qr_code, .rich_media_tool__ft, .poem, .toast"): tag.decompose() for img in content.find_all("img"): src = img.get("data-src") or img.get("src") img["src"] = src or "" if src: img["data-src"] = src for a in content.find_all("a"): href = a.get("href", "") if href.startswith("//"): a["href"] = "https:" + href elif href.startswith("/") and not href.startswith("//"): a["href"] = "https://mp.weixin.qq.com" + href content_html = str(content) content_text = content.get_text("\n", strip=True) return content_text, content_html

纯文本用get_text就行,但注意get_text会把段落之间的换行吃掉,所以用"\n"作为分隔符。想要更好看的结果,可以用html2text.HTML2Text().handle(content_html)转成 Markdown。

import html2text h = html2text.HTML2Text() h.ignore_images = False h.body_width = 0 markdown_text = h.handle(content_html)

4. 把数据存起来,并且做到可增量续爬

4.1 用 SQLite 保存文章信息

所有文章信息统一落库,我选择了 SQLite。零配置、单文件、好迁移,对这种单机个人抓取任务足够用。建表语句如下:

CREATE TABLE IF NOT EXISTS articles ( msgid INTEGER PRIMARY KEY, title TEXT, author TEXT, digest TEXT, url TEXT, cover TEXT, publish_time INTEGER, content_text TEXT, content_html TEXT );

msgid是列表接口里文章的唯一 id,直接设为主键,天然去重。content_text和content_html分别存纯文本和 HTML 原文,方便后续生成 Markdown 或做全文检索。你也可以再加一个content_md字段,在抓取时直接用html2text转换后存入。

4.2 断点续爬:中断后再也不用从头开始

全量爬取几百篇文章时,网络抖动、接口反爬、token 过期都有可能中断。如果每次都从头开始抓,既浪费时间又增加被风控的风险。正确做法是把抓取进度记录到单独的配置表或本地文件中。

最简单的方案是维护一个crawl_state.txt,每次成功处理完一批就把当前的from_msgid写进去。下次启动脚本时读取这个文件,从上次断点继续。

def save_state(value): with open("crawl_state.txt", "w", encoding="utf-8") as f: f.write(str(value)) def load_state(): try: with open("crawl_state.txt", "r", encoding="utf-8") as f: return int(f.read().strip()) except (FileNotFoundError, ValueError): return 0

配合INSERT OR IGNORE插入数据库,即使因为翻页重叠导致数据重复获取,数据库也不会出现重复行。这就是最简单可靠的增量续爬方案。

4.3 图片下载与防盗链处理

如果想把图片也下载到本地,让资料库完全离线可用,需要在下载图片时额外设置Referer。微信图床mmbiz.qpic.cn有防盗链,不带 Referer 很容易返回 403。

img_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://mp.weixin.qq.com/" } def download_image(url, filepath): resp = requests.get(url, headers=img_headers, timeout=15) if resp.status_code == 200: with open(filepath, "wb") as f: f.write(resp.content)

图片统一按msgid/图片序号命名,存放目录建议与数据库文件同级,方便后续生成离线电子书时引用本地路径。

5. 风控规避与常见问题排查实录

5.1 为什么会被风控,怎么样把风险降到最低

微信对接口的请求频率非常敏感。我在测试时有过惨痛教训:脚本不加延迟连续翻页,翻到第六七页直接弹出滑块验证,整个会话进入观察状态,短时间内什么都抓不了。

后来总结出一套比较稳的节奏:

  • 每次翻页之间加 3 到 8 秒的随机延时
  • 每抓完 30 篇文章,停 30 到 60 秒
  • 单次任务总量在 1000 篇文章以内时,分几批跑,中间间隔几小时
  • 请求头里的 User-Agent 用真实浏览器 UA,不要用默认 requests 的 UA

如果你的需求很大,比如上万篇文章,更稳妥的做法是拉长总时长,甚至分几天完成。公众号历史文章不会跑,你的 IP 和账号如果被限制了,反而更麻烦。

注意:一旦遇到滑块验证码,立刻停止脚本,不要再尝试重试。等待一段时间后重新扫码登录再继续。反复触发滑块会把这个会话的风控等级拉得很高,得不偿失。

5.2 高频问题速查表

现象可能原因排查方向
ret返回非 0,提示invalid参数缺失、参数过期重新复制appmsg_token、pass_ticket、Cookie
连续几页返回完全相同列表from_msgid格式错误或类型不对确认传入的是 int,不是字符串
翻几页后列表为空但can_msg_continue=1触发风控,接口静默封禁停止任务,降低频率,隔段时间再试
正文图片全挂图片防盗链给图片请求加Referer: https://mp.weixin.qq.com/
解析出来的正文为空页面结构变化在浏览器里查看当前文章 DOM,重新定位正文节点
老文章链接 404原文章已删除或迁移放弃该链接,保留列表元信息

5.3 接口路径和参数变了怎么办

微信的接口不是永远不变。我最早跑的时候用的是profile_ext?action=getmsg,后来又出现过appmsglist这个新路径,再过段时间可能又会加新的参数。遇到接口 404 或者参数报错,别急着改代码猜,老老实实重新打开历史页面,用浏览器 DevTools 抓一次当前最新的实际请求,把 URL、参数名、请求头逐一对照更新即可。这套“抓包 → 对照 → 适配”的流程才是这类脚本的核心能力。

5.4 老文章链接失效的兜底策略

公众号历史上有些文章确实会被删除、设为“已删除”或转为“部分可见”。列表接口里还能看到元信息,但点击content_url时可能返回“该内容已被发布者删除”。这时候我的策略是保留列表信息,把content_text置为空字符串,标注status=deleted,不做强制重试。这种文章占比通常很低,不值得为它死磕。

6. 合规、边界与长期使用建议

6.1 哪些用途危险,哪些用途安全

我把话说明白:这篇文章提供的方法,适合个人做内容归档、本地检索、学习研究,前提是你对内容的使用符合版权规范。不要做以下事情:

  • 未授权转载到其他公共平台
  • 打包售卖他人公众号的原创内容
  • 抓取后用于流量造假、恶意营销
  • 构建高并发采集服务对目标接口持续施压

使用任何爬虫技术时,都要遵守目标平台的用户协议、法律法规以及版权要求。技术本身是中性的,但用法有边界。对自己负责,也对内容创作者负责。

6.2 接口失效后的快速适配思路

微信网页端接口每隔一段时间就可能发生变化,依赖这套方案长期热更新不现实。我的建议是把关键参数配置化,不要硬编码在代码里,改起来会舒服很多。

config = { "biz": "__biz值", "cookie": "完整Cookie", "appmsg_token": "token", "pass_ticket": "ticket", }

保存成config.json,抓包更新后只需要改配置文件,脚本主体不用动。如果你要管理多个公众号,用字典维护多份配置也很方便。

6.3 更好的替代和扩展方向

如果只是偶尔想要某几篇文章的离线版本,完全没必要写脚本,直接在微信里使用“在浏览器打开”再转 PDF 即可。批量场景下,这套抓取脚本跑完的数据还可以继续扩展成很多有价值的东西:把全量正文导入 Elasticsearch 做全文检索、生成公众号专属 RSS 订阅、定期增量更新并推送摘要到自己的私人笔记。把它当成一个数据管道的第一环,价值会比“抓完就完事”大得多。

我个人在实际操作中的体会是:写这个脚本最耗时间的不是代码,而是调试各种边界情况。比如图文合集到底怎么处理、老文章链接失效时怎么兜底、翻页到最后怎么判断结束。这些细节一次一次踩坑之后形成的经验,才是这篇内容真正值钱的部分。另外最后再分享一个小技巧,每次抓完数据先别着急转 PDF 或 Markdown,把原始 JSON 和 HTML 都留一份,等哪天想换个姿势重新整理数据时,不需要重新访问微信,离线就能搞定。

返回列表