首页 / 资讯中心 / 文章详情

Python公众号爬虫实战:历史文章批量采集与避坑指南

Python公众号爬虫实战:历史文章批量采集与避坑指南 ★ FEATURED ARTICLE
简介面向Python爬虫开发者和公众号数据分析学习者这套资源用Python实现了微信公众号文章爬虫可批量获取公众号历史文章的URL并整理标题、链接等关键信息适合想绕过登录限制、掌握微信端采集思路的初中级开发者。压缩包共30个文件主体为13个Python脚本覆盖文章URL获取、文章信息抓取、结果读取与辅助工具等环节另有5个Markdown说明文档、多张界面及抓包截图、Chromedriver驱动与少量配置文件整体约17.22MB。其中脚本是主要功能实现Markdown文档侧重接口与配置说明截图则便于对照抓包过程模块划分清晰。已有582人学习下载。配套文档针对get_appmsg_token、get_cookie_token、微信公众号接口调用和自动获取微信参数等关键问题展开说明结合Fiddler抓包图可帮助理解微信平台的参数传递机制和常见反爬限制的应对思路边看边练即可搭建自己的公众号采集工具。1. 用 Python 做公众号爬虫为什么先解决数据入口而不是先写解析代码想把公众号的历史文章批量变成结构化数据手动一篇篇复制是最不划算的做法标题、发布时间、封面、正文全都要人工整理几十个号翻下来人先垮了。用 Python 做公众号爬虫真正要接的是历史消息的数据入口让翻页、抓正文、落库变成一条可重跑的命令这是内容监控、语料建设、竞品分析最常用的一条技术路径。这篇笔记从抓包开始把历史消息列表的请求参数、翻页与降速、入库与增量更新依次讲清楚最后给一份踩坑清单。适合已经能写 Python、但对公众号数据接入不熟的开发者也适合需要小批量、持续采集公开文章内容的运营和数据分析从业者。先给结论这个方向值得投入麻烦不在写代码而在参数理解和请求频率控制。2. 历史消息从哪来抓包定位接口与四个关键参数2.1 抓包 5 步拿到历史消息接口的完整请求写代码之前先花半小时做一次抓包把「公众号历史消息页到底请求了什么接口」看清楚。这个动作能省掉后面大量的瞎猜时间因为公众号页面是动态加载的直接看页面源码只能看到骨架真正的文章列表是页面加载后由脚本异步拉取的。抓包的步骤我一般这样走。手机和电脑连同一个局域网电脑上打开抓包工具手机安装对应的调试证书在微信里进入目标公众号的资料页点右上角「历史消息」入口在历史消息页里慢慢往下滑翻两页让页面触发「加载更多」同时盯住抓包列表在抓包结果里筛选包含getappmsgext的请求这就是历史消息的数据接口把这个请求的完整 URL、请求头、Cookie 和返回的 JSON 样例复制下来存成一份本地文件备用。为什么强调用抓包而不是直接在电脑浏览器里打开因为历史消息页运行在微信内部的网页容器里它依赖的 Cookie 和会话标识与普通浏览器不完全一致只有从手机端抓包拿到的请求内容才是代码要复刻的完整现场。第一次抓包时不用管参数含义先确保你能把「列表接口」和「普通的页面加载请求」区分开区分标准很简单返回内容里有appmsg_list字段的那个才是列表接口。2.2 四个关键请求参数__biz、mid、idx、sn 的角色分工把抓包得到的 URL 拆开你会发现大部分参数都是固定的真正影响请求成败的是下面这几个。先把它们的关系理清楚后面写脚本才不会拼错。参数来源作用能否推算__biz历史消息页 URL公众号唯一标识所有请求都要带直接从 URL 拿到mid列表接口返回某一次群发消息的批次 ID不能推算idx列表接口返回该批次内的第几条图文从 1 开始不能推算sn列表接口返回正文内容的签名参数防串改不能推算appmsg_token历史主页 HTML会话令牌会过期从页面初始化代码中提取key历史主页 HTML翻页签名参数从页面初始化代码中提取这里最容易踩的坑是很多人以为mid和sn可以按照某种规律自己拼出来于是花大量时间去研究 URL 的生成算法。实际上这两个值不需要拼每次请求列表接口时返回的appmsg_list数组里已经带好了每篇文章的mid、idx、sn你只需要把它们取出并保存。sn与文章内容绑定一个正文链接能不能打开取决于sn是否还有效所以拿到列表后尽快消费比攒一批再处理可靠得多。appmsg_token和key也不是永久的。历史消息页每打开一次页面 HTML 里就会写入新的值微信客户端发起列表请求时会带上它们。写爬虫时不能把这两个值写死在配置里而应该从最近一次的历史主页 HTML 中动态提取。后面第 3 章的最小脚本会演示这一步。2.3 列表接口返回 JSON为什么比解析 HTML 可靠历史消息页本身就是一个 Web 应用它的文章列表是 JavaScript 动态渲染出来的。如果只对着 HTML 写解析规则会遇到三个问题第一初次请求返回的 HTML 里根本没有文章条目文章是后续接口加载的第二页面模板改版频繁上一周能用的 CSS 选择器下周就失效第三翻页需要模拟点击「加载更多」按钮用请求库模拟这个交互很别扭。列表接口直接返回 JSON当fjson时里面的appmsg_list数组每项是一个图文概要包含标题、摘要、封面、发布时间、mid、idx、sn。结构清楚字段稳定接口一旦调通页面怎么改版都不会影响你的解析逻辑。这也是我建议新手不要一上来就去解析 HTML 的原因把目标锁定在数据接口上工作量直接砍掉一半。还有一个容易忽略的点历史消息页的「加载更多」本质上就是同一个接口带着不同的offset再请求一次。理解了这一点翻页逻辑就变得非常直白下一章直接写代码。3. 用 Python 拉历史消息列表最小脚本、翻页与落库3.1 最小可运行脚本先取令牌再请求列表动手前先准备好三样东西上一章抓到的 Cookie、目标公众号的__biz值、历史消息页地址。下面的代码把「初始化会话 → 提取令牌 → 请求列表」三步连了起来你可以在交互环境里逐段跑也可以整段保存为文件运行。import requests import re import time import random # 从抓包结果里取历史消息页 URL 和完整 Cookie # 下面三个值换成你自己抓到的实际内容 biz_id MzAxxxxxxxxxxx home_url ( https://mp.weixin.qq.com/mp/profile_ext ?actionhome__biz biz_id scene124#wechat_redirect ) cookie_str 抓包得到的完整 Cookie 字符串 session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0 Mobile Safari/537.36, Referer: home_url, Cookie: cookie_str, }) # 历史主页的 HTML 里带有后续列表接口要用的会话令牌 resp session.get(home_url, timeout10) html resp.text match re.search( rvar appmsg_token (.*?);\s*var uin (.*?);\s*var key (.*?);, html, ) if not match: raise RuntimeError(页面变量结构有变化重新抓包确认字段名) appmsg_token, uin, key match.groups() print(token 已就绪:, appmsg_token[:8], ...)这段代码的重点是session对象。后续所有请求都通过它发出Cookie 会被自动带上避免每次手动拼 Cookie。正则一次性提取三个变量是因为它们都是在同一个页面初始化代码里声明的分三次请求反而容易造成会话状态不一致。如果匹配失败多半是页面变量名变化回到抓包里看一眼原始 HTML 再调整正则可用的字段名即可。有了令牌下一步就可以请求列表接口。list_url https://mp.weixin.qq.com/mp/getappmsgext params { __biz: biz_id, appmsg_token: appmsg_token, uin: uin, key: key, offset: 0, count: 10, f: json, } r session.get(list_url, paramsparams, timeout10) data r.json() print(ret:, data.get(ret), 本次返回:, len(data.get(appmsg_list, []))) for item in data.get(appmsg_list, []): c item[commondata] print(c.get(title), c.get(datetime, ))参数里fjson表示让接口返回 JSON 而不是 HTML 片段这个参数不能漏。ret字段是接口的健康信号等于 0 表示正常非 0 就说明令牌失效或请求被限制。如果这一步能打印出标题列表最小链路就算通了后面所有工作都建立在这条链路上。3.2 翻页参数 offset 与 count常见误用与安全取值历史消息列表的分页逻辑和普通分页不太一样它用的不是「页码」而是offset偏移量。当count10时第一页offset0第二页offset10第三页offset20依次往后推。很多人习惯把offset理解成页码从 1 开始递增结果第一页和第二页返回相同数据然后以为自己写错了其实只是没搞懂偏移量的含义。翻页循环我一般这样写offset 0 all_items [] while True: params[offset] str(offset) r session.get(list_url, paramsparams, timeout10) data r.json() if data.get(ret) ! 0: print(ret 非 0先停一下, data.get(ret)) break items data.get(appmsg_list, []) if not items: print(列表为空可能已到底也可能被临时限制) break all_items.extend(items) offset len(items) time.sleep(random.uniform(2, 5))count 的取值需要克制。实践中建议设置在 5 到 10 之间一次性请求 20 条会明显提高被限制的概率。另一个容易误判的情况是某一次返回的条目数小于 count并不一定代表文章已经到了尽头服务端可能会根据负载动态调整单次返回数量。所以不要用「返回条数小于 count」作为结束条件而应该用「返回空列表」或者「连续两次返回空列表」来判断。提示如果你的采集任务需要长时间运行光靠这种简单循环不够还需要把每一页的原始 JSON 落盘留档方便事后排查。后续小节会给更完整的封装。3.3 请求频率控制把速度降到人工翻页水平公众号的请求限制主要看两个特征单位时间内的请求次数以及请求失败率。想长期顺畅地采集最有效的自我保护不是把请求头伪装得多么华丽而是把请求密度降到接近人工翻页的水平。人工翻页一页大概需要几秒你就让脚本也保持这个节奏。我习惯把列表请求封装成一个带重试的函数失败时采用指数退避而不是立刻疯狂重试。def fetch_with_retry(params, session, list_url, max_retries3): for attempt in range(max_retries): try: r session.get(list_url, paramsparams, timeout10) data r.json() if data.get(ret) ! 0: raise ValueError(ret%s % data.get(ret)) return data except Exception as e: wait (2 ** attempt) * 10 # 退避等待 10s / 20s / 40s print(f第 {attempt 1} 次失败等待 {wait}s 后重试{e}) time.sleep(wait) return None这个函数的参数值得说明一下。max_retries3表示最多重试三次三次都失败就放弃当前页避免无限循环timeout10让请求在十秒内得不到响应就触发异常不会一直挂起退避时间按 10 秒、20 秒、40 秒递增给服务端留出恢复时间。主循环里每次翻页再加一个random.uniform(2, 5)的随机间隔让请求节奏不那么规律。另外要提醒Cookie 里的会话信息不要频繁更换。如果你每次请求都换一个新 Cookie反而更容易触发风控因为正常用户不会在几秒钟内频繁切换身份。一个会话用到底只要频率控制好通常能跑很久。3.4 清洗列表字段并写入 SQLite列表接口返回的字段名是嵌套的直接存原始 JSON 不是不行但后续查询会很痛苦。我一般先把数据清洗成一张扁平的表再写入 SQLite。建表语句如下CREATE TABLE IF NOT EXISTS article ( biz TEXT, mid TEXT, idx INTEGER, sn TEXT, title TEXT, digest TEXT, link TEXT, publish_time INTEGER, cover TEXT, created_at INTEGER, UNIQUE(biz, mid, idx, sn) );唯一键直接放在表结构里这样重复采集同一篇文章时INSERT OR IGNORE会自动跳过。清洗函数负责把嵌套 JSON 拍平import sqlite3 def clean_item(raw_item, fallback_biz): c raw_item[commondata] link raw_item.get(link) or c.get(link) or if link.startswith(//): link https: link elif link.startswith(/s?): link https://mp.weixin.qq.com link return { biz: fallback_biz, mid: str(c.get(mid, )), idx: int(c.get(idx, 0)), sn: c.get(sn, ), title: (c.get(title) or ).strip(), digest: c.get(digest, ), link: link, publish_time: int(c.get(datetime, 0)), cover: c.get(cover, ) or , created_at: int(time.time()), } db sqlite3.connect(mp_articles.db) for item in all_items: row clean_item(item, biz_id) db.execute( INSERT OR IGNORE INTO article (biz, mid, idx, sn, title, digest, link, publish_time, cover, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (row[biz], row[mid], row[idx], row[sn], row[title], row[digest], row[link], row[publish_time], row[cover], row[created_at]), ) db.commit()清洗时把link的前缀补全逻辑写进去是因为抓包里返回的链接格式不统一有的是协议相对地址有的是站内相对路径。字段类型也要注意mid和sn必须按字符串存否则后续拼接正文链接时会遇到精度问题。4. 公众号采集避坑清单5 个高频问题与处理方案4.1 列表没拉完就停了把「到底」和「被限制」区分开现象脚本跑了十几页某一页返回空列表程序就停了但手机端打开公众号历史消息页还能继续翻到更早的文章。原因服务端对高频请求的反馈不一定是报错也可能是静默返回空列表。这时你把空列表当成「到底」就会漏掉大量历史文章。解决把「到底」的判定条件收紧一点连续三次返回空列表才停止同时记录每页第一篇和最后一篇的publish_time如果本次循环中时间戳间距异常比如突然跳了几周大概率是被限制了而不是真的没有数据。我的习惯是在日志里打印「本次返回 0 条已连续 N 次空列表」用这个 N 做决策而不是立刻 break。4.2 token 过期导致 ret 非 0自动刷新会话现象脚本跑了一段时间突然列表接口返回ret非 0打印出来的响应内容不再是正常的文章列表而是一段提示信息。原因appmsg_token和key有时效性Cookie 里的会话也可能过期。特别是跨天运行的采集任务第二天旧 token 基本必失效。解决封装一个刷新函数检测到ret非 0 时重新请求一次历史主页从 HTML 里提取最新 token并更新会话。def reload_token(session, home_url): html session.get(home_url, timeout10).text m re.search(rvar appmsg_token (.*?);, html) if not m: return None return m.group(1)刷新后还要同步更新uin和key。我的做法是把三个变量的提取逻辑单独提出来任何一处校验失败都调用同一个函数保证三个值来自同一次页面加载避免新旧混用。4.3 正文链接拿不到内容sn 有时效性要尽快消费现象列表里明明有文章打开拼接好的正文链接却返回失效提示或 404。原因sn参数有时效性也与请求方身份有关。列表数据拿到后搁置太久再拼链接去抓正文就可能失效另外如果这篇文章在群发后被删除也会出现同样现象。解决把「抓列表」和「抓正文」放在同一次运行流程里中间间隔控制在分钟级。先拿到mid、idx、sn立刻拼接链接并请求正文不要把列表存下来等明天再用。如果任务是先跑列表再跑正文建议把两步合并成一个 command减少中间等待时间。4.4 弹验证码不要硬刚先停跑再降速现象请求返回的内容突然变成一段完整 HTML里面有验证码相关的提示列表接口不再返回 JSON。原因单位时间请求次数太多或者失败率太高触发了验证机制。解决立即停止当前任务不要去尝试任何自动破解也不要高强度重试重试只会把临时限制升级成更长时间的限制。把每次请求的间隔从 2 到 5 秒上调到 5 到 10 秒让任务停止半小时以上再继续。如果频繁触发就把采集窗口分散到一天中的不同时间段每次只跑少量数据。注意验证码是被限制的明确信号处理原则只有一条——减速、停止、等待。把时间拉长通常能自然恢复正常。4.5 正文抓回来是空模板类型判断与兜底字段现象正文解析结果为空检查 HTML 发现js_content节点存在但里面没有文字。原因公众号图文并不全是普通文章还有视频消息、小程序卡片、外部链接分享等类型它们的正文容器本来就为空。用统一模板解析所有图文遇到这些特殊类型就会得到空结果。解决解析正文时先判断容器节点是否有内容为空就记录content_type为特殊类型并把列表接口返回的摘要字段digest作为兜底保存。这样即使正文为空库里仍然有一条可读的文本记录不会白白丢数据。5. 增量更新与持续监控从一次性抓取变成后台任务5.1 唯一键设计为什么标题不能做主键第一次全量采集完成后后续每天只需要抓新增文章。这里最核心的问题是怎么判断一篇文章已经存在很多人会下意识用标题去重但标题并不可靠。公众号文章在发布后仍然可以二次编辑标题可能被修改同一次推送里的多篇图文标题也可能相似甚至相同。用biz mid idx sn做唯一键四者同时决定的记录才是真正确定的一篇。mid标识某一次群发批次idx标识批次内的位置sn标识正文内容的签名只要这几个值一致就可以认定是同一篇文章。SQLite 里建唯一索引后插入时用INSERT OR IGNORE就能天然去重不用先查一遍再决定是否写入。如果你需要保留标题变更的历史可以在基表之外加一张更新记录表每次发现同一mid的标题与库中不同就把旧值归档。这个设计对内容监控特别有用因为标题往往是最早被修改的信息之一。5.2 增量策略锚点法代替 offset 恢复有些人做增量更新时会记录上次抓到的offset下次从那里继续。这个思路在公众号场景下并不稳妥因为offset依赖会话状态重新打开历史消息页后会话参数变化可能导致偏移量语义改变。更可靠的做法是锚点法。历史消息列表按时间倒序排列新发布的文章只会出现在列表前面。每次增量采集时从第一页开始逐条往下读一旦遇到一个已经入库的mid就说明从这个位置往后的文章都采集过了可以停止。# 从数据库读出已采集的 mid 集合 known_mids set(row[0] for row in db.execute(SELECT mid FROM article)) new_items [] # 假设 first_items 是第一页的列表数据 for item in first_items: mid item[commondata][mid] if mid in known_mids: break new_items.append(item)这个策略的请求量非常小通常只会请求一页或两页对服务端压力几乎可以忽略。但有一个边界要注意如果公众号把某篇旧文章置顶列表顺序被改变锚点可能提前触发导致排在锚点之后的新内容漏抓。所以针对内容监控要求高的场景我一般建议每周做一次全量对账用第 6 章的基线方法检验增量是否完整。5.3 删除与失效监控列表里消失的文章怎么处理竞品内容监控除了关注新增还需要知道哪些文章消失了。公众号文章被删除后不会出现在历史消息列表里但它可能已经在你库里留下记录。差集检测可以把这个变化找出来用「库里状态为正常的 mid 集合」减去「今天列表里出现的 mid 集合」得到的差集就是疑似消失的文章。alive_mids set(row[0] for row in db.execute( SELECT mid FROM article WHERE status alive )) seen_mids set(item[commondata][mid] for item in today_items) candidate_gone alive_mids - seen_mids注意差集并不等于真的删除。历史消息列表本身有长度限制较早的文章可能会从列表里滚出去但它仍然存在且可访问。所以对差集中的每一篇再向正文链接发一次请求只有返回 404 或明确失效时才把状态更新为gone。这个两步确认逻辑能显著减少误报。定时执行可以交给系统自带的任务计划每天早上固定时间跑一次增量脚本日志重定向到文件。脚本里保留上次运行状态的标记文件即使某天任务失败下次运行也会基于库里已有的数据继续不会造成重复或丢数据。6. 采集质量验证三个随手能用的小技巧6.1 全量基线和增量差值帮你判断跑没跑全第一次对目标公众号做全量采集时把拉到的文章总数记录为基线。之后每次增量任务完成后理想的新增数量等于「今天应该发布的新文章数」实际入库数应该与之接近。如果连续几天增量入库数明显偏低首先去检查列表接口返回和ret字段而不是改解析代码。把一个玄学问题变成可观测的数字排查效率会高很多。6.2 空正文率与手机抽样是两条最常用的验证路径维护一个content_status字段脚本里统计每天抓取文章中正文为空的占比。空正文率突然升高说明解析模板或正文接口出了问题正常波动一般来自视频消息和外部链接类型的图文。同时随机抽五篇文章在手机端公众号历史消息页对比标题、发布时间和正文开头能验证从列表到正文的整条链路是否还畅通。6.3 把日志当成第一排查现场每个采集任务结束时打印三样东西本次返回条数、列表里第一篇的发布时间、连续空列表次数。这三行日志足以判断大部分异常。我曾经因为把「拉空」当成「到底」让脏数据静默入库后来定了这个规矩再没犯过同样的错。这个方向做下来前期投入主要在抓包和避坑代码量并不大一旦跑通日常维护基本就是看日志收报表的事。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站