ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PHP实战:微信公众号历史文章批量采集与备份方案

2026/9/3 19:37:09 拓冰建站 浏览量
PHP实战:微信公众号历史文章批量采集与备份方案 简介一个面向开发者的微信公众号文章采集程序基于PHP实现适用于需要批量获取公众号内容、进行内容聚合、数据统计或科研分析的场景目标用户是对PHP爬虫感兴趣的初中级开发者。程序主要采集文章内容、发表时间、公众号ID、公众号名称、头像、文章封面、标题、BizID及文章摘要等关键字段能够较为完整地还原一篇公众号文章的元数据。资源包整体仅约1KB总共包含1个PHP文件结构简单无需复杂安装直接打开即可查看采集逻辑。目前已有1170人浏览学习说明在公众号数据采集需求中具备一定参考价值。读者可以从中了解文章采集的基本流程与字段解析思路并基于现有代码快速二次开发例如对接数据库、改写为命令行工具或扩展更多信息抓取对想用PHP实现内容采集的开发者来说是一份小巧实用的入门参考。 前阵子接手了一个“杂活儿”帮朋友把某个公众号的历史文章批量备份下来按月份归档成本地文档。本来想着公众号后台有现成接口结果一查个人主体没有素材管理接口权限登录态接口又经常触发风控最后直接被提示“操作频繁”。于是老老实实写了一个PHP采集程序通过解析公众号文章落地页把标题、正文、作者、发布时间、封面图全部提取出来再统一存库。这篇文章就把这套实现从头到尾拆开讲包括链接参数、正文清洗、图片防盗链、批量并发、频控策略以及我踩过的几个典型坑。不管你是想给公众号文章做本地备份、转成Markdown喂给笔记工具还是做内容聚合分析都可以直接参考这套方案。1. 为什么绕开官方接口公众号文章获取的路径对比选型1.1 我最初的思路先试官方接口最早我确实想走“正统路线”——调用公众号后台的素材管理接口。思路是公众号后台能查看已发布文章列表理论上拿到登录Cookie后可以用内部接口批量拉取文章数据。实测下来问题比想象中多。首先是权限个人主体公众号的后台页面根本没有素材管理入口内部接口返回的都是权限不足其次是登录态后台接口依赖CookieCookie过期时间短频繁访问还会触发验证码甚至直接把账号风控。网上很多人说“微信公众号爬取但接口被封住了”说的就是这条路。接口方案在稳定性上完全不可控我跑了一个多小时就放弃了。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?__bizMzA5xxxmid2651xxxidx1sn3a2b9cxxxchksm8f9xxx我在程序里加了一段日志把每个参数单独打印出来方便排查问题参数名含义备注__biz公众号唯一标识Base64编码字符串用于识别文章归属哪个公众号mid消息ID每条群发消息的唯一编号类似自增IDidx当次群发的第几条从1开始表示这篇文章在当次推送里的位置sn签名串防参数篡改的校验值核心鉴权参数chksm校验码辅助校验部分链接没有scene访问来源场景非必需用于统计渠道“sn 是整条链接里最重要的参数”。我试过把链接里的__biz、mid保留只改idx页面会直接返回错误提示。所以批量采集时链接必须原样保存不能自己拼接。2.2 参数缺失时怎么兜底实际采集过程中会遇到一些“短链接”或没有参数的分享链接。比如从朋友圈分享流出的链接可能只有s?__bizxxxmidxxx没有sn。这种情况我在程序里做了两层兜底第一层直接请求原链接如果微信允许访问部分老文章不校验sn就直接用落地页的HTML。第二层如果返回错误页或验证页尝试提取页面里的window.__mp_article_data部分接口会返回完整文章JSON。实测下来第二层在当前版本的页面结构里偶尔能命中但不稳定还是以参数完整的链接为准。2.3 链接从哪来批量任务的入口问题既然单个链接能解析那批量任务的关键就是——链接从哪来。我常用的入口有三个手机端公众号的历史消息页需要登录态电脑端公众号后台的“已发表”列表以及搜狗微信的文章搜索页。最稳定的还是公众号后台的已发表列表把页面里的文章链接和标题批量正则提取出来即可注意提取时一定要把__biz、mid、idx、sn这四个参数完整保留缺一个后面都可能出问题。这里有个容易忽略的细节历史消息接口返回的数据里文章URL有时候是http://mp.weixin.qq.com/s?__biz...的明文形式有时候是url字段已经带了转义符。导入到采集队列之前建议做一次html_entity_decode转码否则URL里会出现amp;这样的实体符请求时会直接失败。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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.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(//*[idactivity-name])-item(0); $title $titleNode ? trim($titleNode-textContent) : ; // 提取发布时间 $timeNode $xpath-query(//*[idpublish_time])-item(0); $publishTime $timeNode ? trim($timeNode-textContent) : ; // 提取正文 $contentNode $xpath-query(//*[idjs_content])-item(0); $contentHtml $contentNode ? $this-cleanContent($contentNode, $doc) : ; return [ title $title, publish_time $publishTime, content_html $contentHtml, ]; }标题节点命不中的时候还有一个备用方案正则提取meta propertyog: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 encodingutf-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否则同样403private 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尾部做文件名会遇到两个问题一是命名太长无法作为文件名二是同一张图可能在文章里出现多次会产生重复下载。我的做法是取src中mmbiz.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?actionhome__bizxxx#wechat_redirect这个页面需要登录Cookie但不用额外权限。浏览器打开后滚动请求的接口是profile_ext的分页接口返回JSON里面包含publish_time、content_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 并发请求大型项目可维护性要求高中高依赖Composerpcntl_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里是否包含verify或verify相关关键词同时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[propertyog: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[propertyog:image]或页面里的js_cover节点提取我因为漏了封面图导致归档展示缺图返工了一次。整套程序跑通之后我自己的用法是直接把它封装成一个CLI脚本传入公众号历史消息URL自动拉取列表、批量抓取文章、清洗正文、下载图片并生成Markdown文件再整体丢给笔记工具做二次归档。如果你也要做类似的事情建议从单篇文章解析开始验证确认节点选择器都命中后再上批量逻辑。批量任务里一定要把频控做在前面否则被风控中断后重新续跑的成本远高于慢慢抓完。本文还有配套的精品资源点击获取