WordPress 文章自动同步公众号:从踩坑到排版还原的完整记录

写在前面

我一直想在 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> 标签包裹(外层结构不是传统表单)。于是:

  1. this.disabled = true → 按钮变灰(用户看到了)
  2. this.form.submit() → this.form 是 null,抛 TypeError
  3. return 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>

传过去会被静默清空——不报错、不留占位,干干净净地消失。

我最初做的是"替换成一张引导卡",告诉读者去点文末阅读原文。用户看了觉得多余:既然留着也会被清空,不如直接删干净。 这个判断是对的,改成默认删除。

想让公众号里真能播放,只有一条路:

  1. 把视频传到腾讯视频(个人号可以传)
  2. 公众号后台 → 草稿箱 → 编辑该文
  3. 工具栏点「视频」→ 选腾讯视频 → 粘贴链接
  4. 手动发表

八、避坑清单

坑 一句话成因 解法
以为个人号没权限 网上说法只覆盖了发布接口 实测:草稿和素材都能用
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 是同一个根因,改完自动推送就能恢复。


发布日期:

所属分类: Wordpress 综合 标签:  



下一篇:

没有了,已经是最新文章