PHP实战:微信公众号历史文章批量采集与备份方案
2026/9/12 1:46:46 网站建设 项目流程

简介:一个面向开发者的微信公众号文章采集程序,基于PHP实现,适用于需要批量获取公众号内容、进行内容聚合、数据统计或科研分析的场景,目标用户是对PHP爬虫感兴趣的初中级开发者。程序主要采集文章内容、发表时间、公众号ID、公众号名称、头像、文章封面、标题、BizID及文章摘要等关键字段,能够较为完整地还原一篇公众号文章的元数据。资源包整体仅约1KB,总共包含1个PHP文件,结构简单,无需复杂安装,直接打开即可查看采集逻辑。目前已有1170人浏览学习,说明在公众号数据采集需求中具备一定参考价值。读者可以从中了解文章采集的基本流程与字段解析思路,并基于现有代码快速二次开发,例如对接数据库、改写为命令行工具或扩展更多信息抓取,对想用PHP实现内容采集的开发者来说是一份小巧实用的入门参考。 前阵子接手了一个“杂活儿”:帮朋友把某个公众号的历史文章批量备份下来,按月份归档成本地文档。本来想着公众号后台有现成接口,结果一查,个人主体没有素材管理接口权限,登录态接口又经常触发风控,最后直接被提示“操作频繁”。于是老老实实写了一个PHP采集程序,通过解析公众号文章落地页把标题、正文、作者、发布时间、封面图全部提取出来,再统一存库。

这篇文章就把这套实现从头到尾拆开讲,包括链接参数、正文清洗、图片防盗链、批量并发、频控策略,以及我踩过的几个典型坑。不管你是想给公众号文章做本地备份、转成Markdown喂给笔记工具,还是做内容聚合分析,都可以直接参考这套方案。

1. 为什么绕开官方接口:公众号文章获取的路径对比选型

1.1 我最初的思路:先试官方接口

最早我确实想走“正统路线”——调用公众号后台的素材管理接口。思路是:公众号后台能查看已发布文章列表,理论上拿到登录Cookie后,可以用内部接口批量拉取文章数据。

实测下来,问题比想象中多。首先是权限,个人主体公众号的后台页面根本没有素材管理入口,内部接口返回的都是权限不足;其次是登录态,后台接口依赖Cookie,Cookie过期时间短,频繁访问还会触发验证码,甚至直接把账号风控。网上很多人说“微信公众号爬取,但接口被封住了”,说的就是这条路。接口方案在稳定性上完全不可控,我跑了一个多小时就放弃了。

1.2 四条可行路径的对比

我把当时调研过的路径整理了一下:

方案获取难度稳定性成本适合场景
公众号后台官方接口需要管理员权限稳定但门槛高免费自有账号运维
搜狗微信搜索聚合页需要处理Cookie和反爬中低,经常变更免费按关键词搜索采集
第三方内容数据平台申请API Key较高收费企业级内容监控
文章落地页解析低,只需拿到链接免费批量备份、归档、自用

第三方平台虽然稳,但按篇计费,几千篇文章跑下来费用不低。搜狗微信的问题在于反爬严格,请求频率稍高就会被跳验证码,而且搜狗能搜到的文章范围有限,历史文章不全。

1.3 为什么最终选了详情页解析

最后选的方案是“文章落地页解析+自定义采集程序”。核心逻辑很简单:只要能拿到一篇文章的URL,就可以用PHP模拟浏览器请求详情页,再从HTML里提取正文和元信息。

这个方案的优点在于稳定——微信文章详情页是落地页,基本不做JS动态渲染,纯抓HTML就能拿到完整内容。而且对频率的容忍度相对较高,不像登录接口那样敏感。配合合理的频控策略,几百篇文章分批抓完完全没问题。

这套程序后来我用原生PHP写成了一个独立的采集类,不依赖任何框架,但可以无缝集成到ThinkPHP、Laravel这类项目中。如果你的老项目还在用ThinkPHP 3.2,直接把它放到Library目录或者Common模块里也能跑。

2. 公众号文章链接的参数逻辑:__biz、mid、idx、sn 分别是什么

2.1 一个链接拆开看

要采集,第一步是读得懂链接。公众号文章的标准URL长这样:

https://mp.weixin.qq.com/s?__biz=MzA5xxx==&mid=2651xxx&idx=1&sn=3a2b9cxxx&chksm=8f9xxx

我在程序里加了一段日志,把每个参数单独打印出来,方便排查问题:

参数名含义备注
__biz公众号唯一标识Base64编码字符串,用于识别文章归属哪个公众号
mid消息ID每条群发消息的唯一编号,类似自增ID
idx当次群发的第几条从1开始,表示这篇文章在当次推送里的位置
sn签名串防参数篡改的校验值,核心鉴权参数
chksm校验码辅助校验,部分链接没有
scene访问来源场景非必需,用于统计渠道

sn 是整条链接里最重要的参数”。我试过把链接里的__bizmid保留,只改idx,页面会直接返回错误提示。所以批量采集时,链接必须原样保存,不能自己拼接。

2.2 参数缺失时怎么兜底

实际采集过程中,会遇到一些“短链接”或没有参数的分享链接。比如从朋友圈分享流出的链接可能只有s?__biz=xxx&mid=xxx,没有sn

这种情况我在程序里做了两层兜底:

  • 第一层:直接请求原链接,如果微信允许访问(部分老文章不校验sn),就直接用落地页的HTML。
  • 第二层:如果返回错误页或验证页,尝试提取页面里的window.__mp_article_data,部分接口会返回完整文章JSON。

实测下来,第二层在当前版本的页面结构里偶尔能命中,但不稳定,还是以参数完整的链接为准。

2.3 链接从哪来:批量任务的入口问题

既然单个链接能解析,那批量任务的关键就是——链接从哪来

我常用的入口有三个:手机端公众号的历史消息页(需要登录态),电脑端公众号后台的“已发表”列表,以及搜狗微信的文章搜索页。最稳定的还是公众号后台的已发表列表,把页面里的文章链接和标题批量正则提取出来即可,注意提取时一定要把__bizmididxsn这四个参数完整保留,缺一个后面都可能出问题。

这里有个容易忽略的细节:历史消息接口返回的数据里,文章URL有时候是http://mp.weixin.qq.com/s?__biz=...的明文形式,有时候是url字段已经带了转义符。导入到采集队列之前,建议做一次html_entity_decode转码,否则URL里会出现&这样的实体符,请求时会直接失败。

3. PHP 实操:用 curl + DOMDocument 把一篇文章完整抠下来

3.1 抓取阶段的请求头细节

采集程序第一步是抓取落地页HTML。这里不建议用file_get_contents,因为公众号文章页面对UA、Referer有基础校验,我在开发中发现直接file_get_contents偶尔会拿到一个“环境异常”的拦截页。框架上用PHP的curl扩展,设置合理的请求头:

class MpArticleParser { private $cookie = ''; // 可选,填入公众号后台Cookie可提高稳定性 public function fetch(string $url): string { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 20, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', CURLOPT_REFERER => 'https://mp.weixin.qq.com/', CURLOPT_HTTPHEADER => [ 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Language: zh-CN,zh;q=0.9', ], ]); if ($this->cookie !== '') { curl_setopt($ch, CURLOPT_COOKIE, $this->cookie); } $html = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($code !== 200 || $html === false) { throw new \RuntimeException("抓取失败,HTTP状态码:{$code},错误:{$error}"); } return $html; } }

一个小技巧:抓到的HTML是UTF-8编码,但DOMDocument默认按ISO-8859-1解析,直接用会导致中文乱码。所以加载HTML前先转编码:

$html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');

3.2 DOM 解析与正文提取

加载完HTML后,用DOMDocument和DOMXPath来提取内容。公众号文章页面的结构比较稳定,核心节点如下:

内容选择器/节点
标题h1.rich_media_title#activity-name
作者#js_name
发布时间#publish_time
正文#js_content
公众号名称.rich_media_meta_nickname或 meta 标签

我封装了一个方法,专门取正文节点,并基于XPath查询:

public function parse(string $html): array { $doc = new \DOMDocument(); @$doc->loadHTML($html); $xpath = new \DOMXPath($doc); // 提取标题 $titleNode = $xpath->query('//h1[contains(@class, "rich_media_title")]')->item(0) ?? $xpath->query('//*[@id="activity-name"]')->item(0); $title = $titleNode ? trim($titleNode->textContent) : ''; // 提取发布时间 $timeNode = $xpath->query('//*[@id="publish_time"]')->item(0); $publishTime = $timeNode ? trim($timeNode->textContent) : ''; // 提取正文 $contentNode = $xpath->query('//*[@id="js_content"]')->item(0); $contentHtml = $contentNode ? $this->cleanContent($contentNode, $doc) : ''; return [ 'title' => $title, 'publish_time' => $publishTime, 'content_html' => $contentHtml, ]; }

标题节点命不中的时候,还有一个备用方案:正则提取<meta property="og:title" content="...">。这个meta标签在分享链接时用到,基本都会带上。

3.3 正文清洗:去掉脚本、样式和无关标签

#js_content里的HTML是很“脏”的,包含大量行内样式、>private function cleanContent(\DOMElement $node, \DOMDocument $doc): string { // 删除脚本与样式 foreach ($node->getElementsByTagName('script') as $child) { $child->parentNode->removeChild($child); } foreach ($node->getElementsByTagName('style') as $child) { $child->parentNode->removeChild($child); } foreach ($node->getElementsByTagName('iframe') as $child) { $child->parentNode->removeChild($child); } // 图片懒加载处理 foreach ($node->getElementsByTagName('img') as $img) { $dataSrc = $img->getAttribute('data-src'); if ($dataSrc !== '') { $img->setAttribute('src', $dataSrc); } // 去掉data-*属性 foreach ($img->attributes as $attr) { if (strpos($attr->nodeName, 'data-') === 0) { $img->removeAttribute($attr->nodeName); } } } return $doc->saveHTML($node); }

注意:saveHTML保存的是带有>$img->setAttribute('referrerpolicy', 'no-referrer');

另外一个办法是:PHP写一个proxy.php?url={图片地址},在代理脚本里用curl带上正确的Referer去拉图,再原样输出给浏览器。缺点是每张图都要走一层代理,图片量大时服务器压力不小。

方案二:采集时直接下载图片到本地,替换src

这是我最推荐的方案,适合长久存档。采集后将微信图片下载到本地服务器磁盘,再把正文里对应的src改成相对路径或自己的图床地址。代码逻辑如下:

public function downloadImages(string $contentHtml, string $saveDir): string { $doc = new \DOMDocument(); @$doc->loadHTML('<?xml encoding="utf-8" ?>' . $contentHtml); foreach ($doc->getElementsByTagName('img') as $img) { $src = $img->getAttribute('src'); if (strpos($src, 'mmbiz.qpic.cn') === false && strpos($src, 'mmbiz.qlogo.cn') === false) { continue; } $fileName = $this->generateFileName($src); $savePath = rtrim($saveDir, '/') . '/' . $fileName; if (!file_exists($savePath)) { $this->downloadFile($src, $savePath); } $img->setAttribute('src', $fileName); // 存相对路径,或改成你自己的图片域名 } return $doc->saveHTML(); }

下载图片时,请求头里必须带一个微信域名下的Referer,否则同样403:

private function downloadFile(string $url, string $savePath): void { $ch = curl_init($url); $fp = fopen($savePath, 'wb'); curl_setopt_array($ch, [ CURLOPT_FILE => $fp, CURLOPT_TIMEOUT => 30, CURLOPT_USERAGENT => 'Mozilla/5.0 (...)', CURLOPT_REFERER => 'https://mp.weixin.qq.com/', CURLOPT_SSL_VERIFYPEER => false, ]); curl_exec($ch); curl_close($ch); fclose($fp); }

4.3 文件名去重与内容安全

微信公众号正文图片的URL都带一大串参数,直接拿URL尾部做文件名会遇到两个问题:一是命名太长无法作为文件名;二是同一张图可能在文章里出现多次,会产生重复下载。

我的做法是取srcmmbiz.qpic.cn路径段,去掉wx_fmt等可变参数,再MD5生成文件名,后缀根据wx_fmt参数判断:

private function generateFileName(string $src): string { $parse = parse_url($src); $path = $parse['path'] ?? 'img'; $ext = 'jpg'; if (preg_match('/wx_fmt=(\w+)/', $src, $m)) { $ext = $m[1]; } return md5($path) . '.' . $ext; }

这样同一张图片无论URL参数怎么变,最终落盘的文件名都一样,天然去重。

5. 批量采集落地:历史文章列表、并发拉取与频控策略

5.1 从历史消息页拿到完整链接列表

只做单篇解析没有意义,批量采集才是核心。我实现批量任务的第一步,是从公众号的历史消息页抓取文章列表。

历史消息URL样式如下:

https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=xxx#wechat_redirect

这个页面需要登录Cookie,但不用额外权限。浏览器打开后,滚动请求的接口是profile_ext的分页接口,返回JSON,里面包含publish_timecontent_url和标题。直接在PHP里模拟这个分页接口,就能拿到全部文章链接。

分页接口返回的content_url是经过转义的相对路径,要拼接成完整URL:

$contentUrl = html_entity_decode($item['content_url']); $fullUrl = 'https://mp.weixin.qq.com' . $contentUrl;

这里注意,content_url内部可能包含&amp;,一定要先html_entity_decode,不然参数会被截断。

5.2 PHP 下的并发方案怎么选

拿到URL列表后,如果一篇一篇顺序抓,200篇文章每篇耗时1~2秒,总耗时接近10分钟,效率确实太低。我对比了几个PHP并发方案:

方案适用场景复杂度说明
curl_multi几十篇文章的小批量无需额外扩展,适合大多数环境
Guzzle + 并发请求大型项目,可维护性要求高中高依赖Composer
pcntl_fork长期运行的任务脚本仅CLI环境,需要pcntl扩展

我后来选的是curl_multi,原因很实际:不需要安装额外扩展,兼容性好,代码也直观。但要注意并发数别开太大,我控制在5个并发,每个请求间隔200ms左右。

5.3 频率控制是保命项

“能抓”和“能稳定抓完”是两件事。我前几次跑批量任务时,就是因为完全没做频控,抓了200篇左右就触发了微信的访问频率限制,连续好几天访问文章详情页都会先跳到安全验证页。

后来我总结了一套三级频控策略:

  • 单篇文章间隔:每次请求完成之后,usleep(500000 + mt_rand(100000, 500000)),即等待0.6~1秒。同时随机切换User-Agent。
  • 批次暂停:每抓完50篇,暂停10~20秒。给接口缓冲时间。
  • 失败退避:连续收到3次验证码页面或HTTP 4xx状态码时,立即暂停5分钟,不能继续硬抓。

验证码页面在PHP端怎么识别?我判断的核心逻辑是看响应HTML里是否包含verifyverify相关关键词,同时URL是否跳转到了mp.weixin.qq.com/mp/verify。只要命中,就说明当前IP或Cookie被标记了,必须停。

private function isVerifyPage(string $html, string $finalUrl): bool { return strpos($finalUrl, '/mp/verify') !== false || strpos($html, '验证码') !== false || strpos($html, '环境异常') !== false; }

这个判断逻辑虽然简单,但在实测中效果很好。一旦检测到异常,我会把任务状态标记为paused,下次启动时从上次断点继续跑。

6. 实测踩坑记录:接口封禁、改版解析失败、内存溢出

6.1 官方接口请求频繁后被封的体验

前面说的“接口被封住了”,我实际遇到过两次。第一次是直接调用公众号后台的历史消息接口,连续翻页不到30次,就返回了invalid session并跳出登录二维码。第二次是模拟网页版接口拉了300多篇文章后,接口返回了“操作频繁”的提示码。

解决办法没有捷径:一是把Cookie拿出来保持在本地,定期重新登录刷新;二是严格控制频率,并给每轮请求加随机延迟;三是在程序里加入“封禁感知”逻辑,遇到invalid session立即停止任务,人工更换Cookie后再继续。

后台接口和详情页接口的封禁阈值完全不同。详情页接口相对“宽容”,但我仍然不敢大意,因为一旦触发风控,后面所有任务都得停摆。

6.2 微信改版导致选择器失效

公众号页面不是一成不变的。我跑这个程序期间,遇到过两次标题节点变化:一次是#activity-name被换成了h1.rich_media_title,另一次是正文字号样式从font-size: 17px变成了font-size: 16px,类名里多了rich_media_content_old

这说明写采集程序时,选择器不能写死。我在解析方法里做了多级备选,比如标题节点:先查h1.rich_media_title,如果为空再查#activity-name,再为空就查meta[property="og:title"]。就算某天某个节点改版,程序也不会立刻崩掉。

6.3 其他几个低频但致命的坑

除了节点变更是常客,还有几个坑值得单独提醒:

  • PHP内存溢出:DOMDocument加载超大HTML时很吃内存,尤其是正文带大量图片和样式时。建议解析完节点后立刻unset($doc)释放内存。我连续抓几百篇文章时,如果不释放,内存可以从128M涨到1G以上。
  • 发布时间格式不统一:有的页面显示“2024-12-01 10:30”,有的显示“2024-12-01”,还有的显示“昨天”。统一用正则提取\d{4}-\d{2}-\d{2}作为归档日期,其他格式直接丢弃。
  • 正文内的外部链接:公众号允许插入“阅读原文”跳转链接,有些文章内部还埋了其他外链。归档时要决定是保留原链接还是统一清洗,如果要转Markdown,建议只保留a标签的href和文案,去掉target属性。
  • 封面图不在正文里:标题和正文能抓到,但封面图往往挂在正文外侧的布局里。需要额外从meta[property="og:image"]或页面里的js_cover节点提取,我因为漏了封面图,导致归档展示缺图,返工了一次。

整套程序跑通之后,我自己的用法是直接把它封装成一个CLI脚本,传入公众号历史消息URL,自动拉取列表、批量抓取文章、清洗正文、下载图片并生成Markdown文件,再整体丢给笔记工具做二次归档。如果你也要做类似的事情,建议从单篇文章解析开始验证,确认节点选择器都命中后,再上批量逻辑。批量任务里一定要把频控做在前面,否则被风控中断后重新续跑的成本远高于慢慢抓完。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询