写在前面
我一直想在 WordPress 后台点一下,文章就能自动跑到公众号草稿箱里,排版不用重调、图片不用一张张传。市面上的插件大多收费,而且描述含糊——到底能不能用于个人主体的订阅号,没人说清楚。
于是自己写了一个。过程比预想的曲折得多:网上流传的说法被实测推翻,服务器缺扩展,图片全军覆没,按钮点了没反应,推送过去排版全崩。
这篇文章记录全过程,重点是那些搜不到的坑——因为它们都发生在具体的环境、具体的主题上。如果你的服务器也是 Windows、主题也用了 Bootstrap,大概率会撞上同样的问题。
适用读者:有 WordPress 插件开发基础,想把文章同步到公众号的站长。
涉及:微信公众平台接口、PHP stream 与 curl、DOM 处理、微信正文 CSS 限制。
一、先探路:个人号到底有没有接口权限
动手之前,我查到的资料几乎一致地说:个人主体的公众号没有接口权限。理由也很"充分"——微信官方对 freepublish(发布接口)的标注确实是"仅认证账号",而个人主体无法申请微信认证。
这话没错,但它只说了一半。真正的情况要分两条链路看:
| 接口 | 个人未认证号 | 说明 |
|---|---|---|
draft/add 新建草稿 |
可用 | 官方未标注"仅认证" |
material/add_material 上传素材 |
可用 | 封面、图片都能传 |
media/uploadimg 上传正文图 |
可用 | 返回微信内链 URL |
freepublish/submit 发布 |
不可用 | 需企业认证,无解 |
所以我最初给用户的结论"个人号做不了自动发布"是错的——准确说法是:能自动进草稿箱,最后一步群发必须人工。
写了个探针脚本实测,四步全过:token 拿到、封面上传成功、正文图上传成功、草稿建成。这个结果推翻了网上"个人号一律没权限"的说法。
最终方案定为:
WP 后台点推送
→ 转换 HTML(内联样式、代码块处理)
→ 图片上传素材库、换成微信内链
→ draft/add 推进草稿箱
→ 你在「公众号助手」App 点一下群发
最后一步手动不是缺陷,是设计如此。真正耗时的排版和传图,全部省掉了。
二、第一道坎:服务器没有 curl
探针第一次运行就报了:
curl 扩展未加载,无法继续。
奇怪的是 php.ini 里 extension=curl 明明白白开着,没有分号,Apache 也重启过。
环境是 Windows Server 2012 R2,PHP 8.3.3。注意 phpinfo 里这两行:
System: Windows NT ... Server 2012 Datacenter Edition AMD64
Build System: Microsoft Windows Server 2022 Datacenter
运行系统是 2012,编译系统是 2022。 用新工具链编出的二进制跑在老系统上,依赖链很容易不完整。之前命令行报过 MSVCP140.dll 缺失,那是同一个根因的信号。
继续深挖要装 VC++ 运行库、可能还要重启服务器,动静太大。其实根本没必要。
微信 API 就是普通的 HTTPS + JSON,PHP 有两种方式能发:
| 方式 | 依赖 | 本机状态 |
|---|---|---|
| curl 扩展 | php_curl.dll + libssl/libcrypto | 起不来 |
| stream 封装 | openssl + allow_url_fopen | 都可用 |
验证方式很简单:
<?php
$r = @file_get_contents('https://api.weixin.qq.com/cgi-bin/token?...');
echo $r === false ? 'stream 不可用' : 'stream 可用:' . $r;
返回 {"errcode":40013,"errmsg":"invalid appid"} —— 凭证是假的,但网络通、HTTPS 通。这条路能走。
于是整个插件改用 WP HTTP API + stream 回退,完全不碰 curl:
function http_post_json( $url, $body ) {
$args = [
'body' => $body,
'timeout' => 20,
'sslverify' => true,
];
$res = wp_remote_post( $url, $args );
if ( is_wp_error( $res ) ) {
// WP HTTP 失败,退回 stream
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json; charset=utf-8\r\n",
'content' => $body,
'timeout' => 20,
'ignore_errors' => true,
],
]);
return @file_get_contents( $url, false, $ctx );
}
return wp_remote_retrieve_body( $res );
}
顺带的收获:之前百度推送一直失败,也是同一个锅——那个请求同样是 curl 发的。改 stream 版就能恢复。
三、白名单:别被内网 IP 骗了
插件设置页显示"服务器 IP:10.31.33.5",用户问是不是要把它填进微信白名单。
不能填。
| IP | 类型 | 谁能看到 |
|---|---|---|
121.42.231.28 |
公网地址 | 微信、访客、全世界 |
10.31.33.5 |
内网私有地址(RFC1918) | 只有这台机器自己 |
阿里云专有网络里,ECS 网卡拿到的就是 10.x,公网 IP 是另外映射上去的。微信在互联网上永远收不到 10. 开头的包。
初版插件用了 $_SERVER['SERVER_ADDR'] 来"提示"IP,在云主机上必然拿错。改成实时探测真实出口:
function public_ip() {
$cached = get_transient( 'wx511_ip' );
if ( $cached ) return $cached;
$ip = trim( @file_get_contents( 'https://api.ipify.org' ) );
if ( $ip ) set_transient( 'wx511_ip', $ip, 6 * HOUR_IN_SECONDS );
return $ip;
}
用 api.ipify.org 而不是查网卡——只有真正发出去的请求,才知道 NAT 之后运营商给你的是哪个 IP。
最权威的办法是让微信自己告诉你:临时清空白名单,调一次接口,报错里会回显:
invalid ip ..., not in whitelist hint: [121.42.231.28]
hint 后面那个就是唯一正确答案。不过清空期间接口会断,能用就别折腾。
四、素材上传 40005:三个成因叠在一起
这是耗时最久的一节。探针自己生成的测试 PNG 能传成功,但文章里的图片一张都传不上去:
40005 invalid file type
同一个上传函数,一个成功一个全灭——问题出在"发给微信的那个文件"本身。
成因一:自己的图片被误判成外链
初版是这么判断本地图的:
if ( 0 === strpos( $url, $upload_dir['baseurl'] ) ) {
// 本地图
}
看起来没问题,但用户的站点地址是 https://www.511yj.com(带 www),而 baseurl 可能存的是不带 www 的版本。更麻烦的是,文章图片在 /pagephoto/ 目录——根本不在 wp-content/uploads 下。
结果:明明在自己服务器上的图,被当成"别人网站的图",绕远路去下载。
成因二:下载后存成了 .tmp
一旦走"外链下载",临时文件统一保存成:
wx511-img-<hash>.tmp ← 没有 .jpg / .png 扩展名
微信按文件名 + 内容判断类型,.tmp 直接判为无效图片类型。这就是 60 张图全军覆没的直接原因。
成因三:文件名里有空格
用户文章里的图片长这样:
https://www.511yj.com/pagephoto/images/20201103/zzd (1).jpg
↑ 空格
URL 里带字面空格,请求行非法,file_get_contents 直接返回 false。而这些文件本来就在本地磁盘上,压根不该走网络。
解法:本地文件直接读盘
核心思路——只要 URL 域名是我自己的,就映射到站点根目录找本地文件,完全不碰网络:
function url_to_path( $url ) {
$home_host = wp_parse_url( home_url(), PHP_URL_HOST );
$url_host = wp_parse_url( $url, PHP_URL_HOST );
if ( ! $url_host || ! $home_host ) return false;
// 归一化:去掉 www. 前缀、忽略大小写
$a = preg_replace( '/^www\./i', '', strtolower( $home_host ) );
$b = preg_replace( '/^www\./i', '', strtolower( $url_host ) );
if ( $a !== $b ) return false; // 真外链
// 取路径部分并解码(关键:还原 %20 等转义)
$path = wp_parse_url( $url, PHP_URL_PATH );
if ( ! $path ) return false;
$path = urldecode( $path );
// 映射到站点根目录 —— 支持 /pagephoto/ 这类自建图库
$full = rtrim( ABSPATH, '/\\' ) . str_replace( '/', DIRECTORY_SEPARATOR, $path );
return file_exists( $full ) ? $full : false;
}
三个要点:
- 归一化域名比较:
www.511yj.com和511yj.com都算本地 - 映射到
ABSPATH:不只认uploads,/pagephoto/这类自建目录也能命中 urldecode:把%20还原成空格,才能正确找到zzd (1).jpg
读盘拿到真实文件内容后,再用 getimagesize() 判断真实类型(不靠扩展名猜),必要时用 GD 把 webp 转成 jpg。
五、按钮点了没反应:this.form 是 null
推送按钮点下去,按钮变灰,然后——什么都没有。草稿箱空的,页面不刷新。
用户怀疑是云锁拦截。其实不是,是两个 bug 叠在一起:
<button type="submit"
onclick="this.disabled=true;this.form.submit();return false;">
问题在于:Gutenberg 编辑页里,metabox 没有 <form> 标签包裹(外层结构不是传统表单)。于是:
this.disabled = true→ 按钮变灰(用户看到了)this.form.submit()→this.form是null,抛 TypeErrorreturn false→ 提交被取消
更糟的是同一个按钮还绑了一段 jQuery 走 admin-ajax.php,而那条路在该服务器上也不通,且没有写失败回调——所以是静默失败,控制台干干净净。
改法很朴素:改成普通链接,服务端执行。
$link = admin_url( 'post.php?post=' . $post->ID . '&action=edit' );
$link = wp_nonce_url( add_query_arg( 'wx511_push', '1', $link ), 'wx511_push_' . $post->ID );
echo '<a href="' . esc_url( $link ) . '" class="button button-primary">'
. '推送到公众号草稿箱</a>';
add_action( 'admin_init', function () {
if ( empty( $_GET['wx511_push'] ) ) return;
$post_id = intval( $_GET['post'] ?? 0 );
if ( ! $post_id ) return;
if ( ! wp_verify_nonce( $_GET['_wpnonce'] ?? '', 'wx511_push_' . $post_id ) ) {
set_notice( '安全校验失败,请刷新页面重试', 'error' );
return;
}
$result = push_post( $post_id );
set_notice( $result['message'], $result['ok'] ? 'success' : 'error' );
});
点击 = 普通页面跳转 → admin_init 检测参数 → PHP 直接跑 → 结果存 transient → 页面顶部显示提示条。不碰 JS、不碰 form、不碰 admin-ajax。
教训:任何 AJAX 操作都要写 .fail() 回调;后台功能能用普通请求就别用 AJAX,少一个环节少一堆排查。
六、排版全崩:微信不加载任何外部 CSS
图片能显示了,但所有图片粘成一坨,毫无间隙。
用户的原文是这样的:
<div class="alert alert-warning">...</div>
<p><img src="..." class="img-thumbnail"></p>
<p><img src="..." class="img-thumbnail"></p>
<p> 标签一个没丢,图片也在——丢的是让它们分开的那套样式。原因是两层:
- 微信正文不加载外部 CSS,只认内联
style。img-thumbnail、alert-warning这些 Bootstrap 类,在微信里就是毫无意义的字符串。 - 微信把
<p>的默认 margin 重置为 0,而img默认还是inline。相邻两张图于是直接贴在一起。
所以用户那句"能保留原来的 CSS"在微信里有个硬前提:必须内联,外链一律进不去。
我犯的思维错误
回头看前面几版迭代,我一直在做清洗:
- 拍平代码块里的
span—— 去掉语法高亮的杂色 - 去掉
srcset、data-src—— 微信不认这些属性 - 保守模式跳过所有样式 —— 怕和原样式打架
每一步单看都"对",但底层逻辑错了:我把作者的源码当成了需要驯服的脏东西。
实际上那些 class 是有意图的:
| 作者写的 | 真实意图 |
|---|---|
alert alert-warning |
这里是重点提示,要醒目 |
img-thumbnail |
图片要有边框和圆角,别太生硬 |
每张图单独一个 <p> |
一张一图,节奏分明 |
正确做法是"翻译",不是"丢弃"。 把 Bootstrap 的类翻译成微信认识的内联样式——保留意图,换个微信能懂的说法。
类内联化实现
function inline_bootstrap( DOMDocument $dom ) {
$map = [
'alert' => 'padding:12px 16px;margin:16px 0;border-radius:4px;',
'alert-warning' => 'background:#fcf8e3;border-left:4px solid #f0ad4e;color:#8a6d3b;',
'alert-success' => 'background:#dff0d8;border-left:4px solid #5cb85c;color:#3c763d;',
'alert-info' => 'background:#d9edf7;border-left:4px solid #5bc0de;color:#31708f;',
'alert-danger' => 'background:#f2dede;border-left:4px solid #d9534f;color:#a94442;',
'img-thumbnail' => 'border:1px solid #ddd;border-radius:4px;padding:4px;',
'table' => 'width:100%;border-collapse:collapse;',
];
$xpath = new DOMXPath( $dom );
foreach ( $xpath->query( '//*[@class]' ) as $node ) {
$classes = preg_split( '/\s+/', trim( $node->getAttribute( 'class' ) ) );
$add = '';
foreach ( $classes as $c ) {
if ( isset( $map[ $c ] ) ) $add .= $map[ $c ];
}
if ( ! $add ) continue;
// 已有 style 的以作者为准,只补齐缺失项
$old = $node->getAttribute( 'style' );
$node->setAttribute( 'style', $add . $old );
}
return $dom;
}
间距兜底
光内联 Bootstrap 还不够,<p> 的 margin 是微信重置掉的,得补回来:
foreach ( $xpath->query( '//p' ) as $p ) {
$style = $p->getAttribute( 'style' );
// 作者自己写过 margin 的一律不动
if ( false !== strpos( $style, 'margin' ) ) continue;
$p->setAttribute( 'style', 'margin:0 0 16px;line-height:1.75;' . $style );
}
foreach ( $xpath->query( '//img' ) as $img ) {
$style = $img->getAttribute( 'style' );
$img->setAttribute(
'style',
'display:block;max-width:100%;height:auto;margin:16px auto;' . $style
);
}
改完之后用户的反馈是:"图片之间有空隙,可以呼吸,没有那种窒息的感觉了。"
技术解决"能不能显示",审美解决"读者愿不愿意看下去"。留白给对了,文章才像文章。
七、视频播放器:微信的硬限制
有一篇带 HTML5 播放器的文章,推送过去播放器消失了,扫码预览也没有。
这不是 bug,是平台限制。 微信公众号正文不支持这几类标签:
<video> <audio> <iframe> <embed> <object>
传过去会被静默清空——不报错、不留占位,干干净净地消失。
我最初做的是"替换成一张引导卡",告诉读者去点文末阅读原文。用户看了觉得多余:既然留着也会被清空,不如直接删干净。 这个判断是对的,改成默认删除。
想让公众号里真能播放,只有一条路:
- 把视频传到腾讯视频(个人号可以传)
- 公众号后台 → 草稿箱 → 编辑该文
- 工具栏点「视频」→ 选腾讯视频 → 粘贴链接
- 手动发表
八、避坑清单
| 坑 | 一句话成因 | 解法 |
|---|---|---|
| 以为个人号没权限 | 网上说法只覆盖了发布接口 | 实测:草稿和素材都能用 |
| curl 未加载 | 老系统跑新工具链编的 PHP | 改走 stream + openssl |
| 白名单填内网 IP | 云主机网卡是 10.x |
用 api.ipify.org 探测真实出口 |
| 40005 全军覆没 | 本地图被判成外链、存成 .tmp |
域名归一化 + 映射 ABSPATH 读盘 |
| 带空格文件名 | URL 请求行非法 | urldecode 后直接读本地文件 |
| 按钮点了没反应 | this.form 是 null + 无失败回调 |
改普通链接,服务端执行 |
| 图片粘成一坨 | 微信重置 p 的 margin、class 失效 |
类内联化 + 间距兜底 |
| 播放器消失 | 微信静默清空 video/iframe | 无解,只能后台手动插腾讯视频 |
九、几点体会
第一,实测比文档可靠。 官方文档对 draft/add 的"必填"列写着"否",但实际不传封面就报 40007;而个人号权限这种关键问题,文档和社区反馈也不一致。写个探针跑一遍,十分钟出结论,比查半天资料强。
第二,报警要响。 这次有两个 bug 都是"静默失败"——按钮变灰但什么都没发生。任何异步操作都要有失败回调,任何后台操作都要在页面上给出反馈,否则排查成本高十倍。
第三,尊重作者的源码。 处理别人写的 HTML,默认态度不该是"清洗成我想要的样子",而是"理解它想表达什么,再翻译成目标平台能懂的话"。这个区别,决定了成品是"能用"还是"好看"。
第四,审美是功能的一部分。 图片之间那 16px 的间距,技术上毫无难度,但它决定了读者是"看下去"还是"划走"。
写在最后
整个插件最终是一个单文件,上传启用、填 AppID/AppSecret 即可用。核心流程其实不复杂,复杂的是每一个环节都有它自己的坑,而且这些坑基本搜不到——它们只在你的具体环境和具体主题上出现。
如果你也在做类似的事,希望这份记录能帮你少踩几个。
下一篇会写百度推送改成 stream 版——跟这次的 curl 是同一个根因,改完自动推送就能恢复。