写一个 WordPress 插件:修好散落在上千篇文章里的坏掉短码
发布于
网站搬迁或换编辑器之后,最麻烦的不是版面跑掉——那看得见。最麻烦的是短码被改坏:页面上什么都不显示,但你要一篇一篇打开才知道哪些坏了。
我写了一个插件处理这件事,源代码在 GitHub。这篇不是教学,是把几个当下做过取舍的决定写下来,包括一个我觉得很难自己想到、只能踩过才知道的 WordPress 行为。
坏掉的长什么样
[pdfjs-viewer] 这个短码被改坏成两种形状。
第一种是多出一个 attachment_id,而且常常直接黏在前一个属性后面,中间没有空白:
[pdfjs-viewer url="https://example.com/file.pdf"attachment_id="123" ...]
第二种更恶劣,url 的值被 auto-linkify 转成了 Markdown 链接语法:
url="[https://example.com/file.pdf"](https://example.com/file.pdf%22 "https://example.com/file.pdf%22")
它应该只是:
url="https://example.com/file.pdf"
两种都不会让 WordPress 报错,只会让 viewer 安静地不显示。
为什么是单一文件、没有类别
整个插件是一个 .php 档、514 行,没有 class、没有 composer、没有建置步骤。所有函数用 pjvsf_ 前缀避免撞名。
这不是偷懒。WordPress 插件的部署现实是「把文件丢进 wp-content/plugins/」,加一层 autoloader 只是让自己多一个要维护的东西。功能单一、生命周期短的工具插件,单档就是正确答案。
开头那行是规矩,不是装饰:
defined( 'ABSPATH' ) || exit;
没有它,别人直接用网址打你的 .php 就会在 WordPress 环境外运行。
修字符串:先切出短码,再分两段洗
直觉做法是写一个超大的正规表达式一次比对整段内容。我没有这样做,因为那种正规表达式三个月后自己也看不懂。
实际做法是先把每个短码整段抓出来,再在里面做两次独立替换:
function pjvsf_clean_content( $content ) {
return preg_replace_callback(
'/\[pdfjs-viewer\b[^\]]*\]/iu',
static function ( $match ) {
$shortcode = $match[0];
// 1. 拿掉多餘的 attachment_id
$shortcode = preg_replace( '/[\p{Z}\s]*attachment_id[\p{Z}\s]*=.../iu', '', $shortcode );
// 2. 把被轉成 Markdown 的 url 修回來
$shortcode = preg_replace( '/url\s*=\s*.../iu', 'url="$1"', $shortcode );
return $shortcode;
},
$content
);
}
两次替换是无条件运行的,不先判断「这篇是哪一种坏法」。少一组分支,也少一组会出错的判断。没中就原字符串返回,没有副作用。
另外注意 [\p{Z}\s]* 而不是 \s*。\p{Z} 是 Unicode 的空白类别,涵盖不断行空格那种从 Word 贴进来的东西。搭配 /u 修饰符才会生效。
那个只能踩过才知道的坑
第二段正规表达式里,引号的部分我写成 [”"“]——同时接受直引号和两种弯引号。
原因是 WordPress 有个叫 wptexturize() 的函数,会自动把直引号 " 转成排版用的弯引号 “ ”。它是为了让文章排版好看,但它不管那个引号是不是短码属性的一部分。
所以同一个坏掉的短码,在数据库里可能是直引号,也可能是弯引号,取决于它当初怎么被存进去。只认直引号的正规表达式会漏掉一半的数据,而且你完全不会发现——因为它们不会报错,只是没被修到。
这种行为在文档里不会写成「警告」,只能在数据上撞到。
分批:不需要 OFFSET
要处理几千篇文章就得分批。传统做法是 LIMIT 50 OFFSET 0、OFFSET 50、OFFSET 100 一路往下记页码。
这里不用,因为修好的文章会自动从结果集消失——它的内容已经不符合任何一种坏掉的形状了。所以每次都查「第一批还坏着的」就好,天然就是下一批:
// 修好的文章下次查詢就不會再出現,
// 所以用同樣的 limit 再跑一次,拿到的自然是下一批。
$posts = pjvsf_get_affected_posts( $batch_size );
代价是不能靠 SQL 直接筛出「坏掉的」——LIKE '%pdfjs-viewer%' 只能筛出「含有这个短码的」,里面有很多是好的。所以要多捞一点再用 PHP 过滤:
LIMIT %d // max( $limit * 4, $limit + 200 )
捞四倍是个经验值,不是算出来的。坏掉比例很低的网站可能要调高。
查找还排除了四种状态:
WHERE post_status NOT IN ('auto-draft', 'inherit', 'revision', 'trash')
revision 这个特别重要。WordPress 的修订版本也是 posts 表里的一列,内容是旧版全文。不排除的话同一篇文章会被算很多次,而且你去改修订版本没有任何意义。
一次修一篇,不要循环跑完
修改的部分走 AJAX,一次只处理一篇文章。
理由是 PHP 有运行时间上限。一个循环跑两千次 wp_update_post(),在共享主机上几乎一定会超时,然后你不知道它做到哪里、有没有做完。
一次一篇的话:超时最多影响一篇,进度条是真的进度,每篇的结果(修好/跳过/失败)都看得到。前端用原生 fetch,没有 jQuery。
$result = wp_update_post( array( 'ID' => $post_id, 'post_content' => $cleaned ), true );
if ( is_wp_error( $result ) ) {
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
}
wp_update_post() 第二个参数传 true 才会回传 WP_Error,否则失败时只回传 0,你拿不到原因。
教学文章最常省略的那几行
每个 AJAX handler 开头都有这两段:
check_ajax_referer( PJVSF_NONCE_ACTION, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ) );
}
第一行挡 CSRF,第二行挡权限。两个都要,缺一不可,因为它们防的是不同的事:nonce 防「别的网站诱导你的管理员发出这个请求」,权限检查防「已登录的订阅者直接调用这个端点」。
只做 nonce 不做权限,任何登录用户都能改你全站的文章内容。这是 WordPress 插件最常见的漏洞形状之一,而网络上大量的 AJAX 教学只写了 nonce。
所有进来的 ID 也都过 absint(),批量大小另外夹在 1–5000 之间,避免一个手残的输入让数据库一次捞几十万列。
刻意不做的事
插件只改 post_content,不碰附件文章、不碰媒体文件、不碰短码里其他任何属性。
范围定得越窄,出事的时候越好回滚。这个工具的最坏情况是「改坏了文章内容」,而那个有数据库备份就能救。如果它还会动媒体文件,最坏情况就变成不可逆了。
同样的理由,它也没有做预览 diff。要做就要处理短码在编辑器里的各种呈现差异,复杂度会翻倍,但真正需要的信息——「这篇会不会被改」——列表已经回答了。
回头看,这个插件真正花时间的不是写功能,是搞清楚数据到底坏成什么形状。wptexturize() 那个弯引号如果没发现,功能全都写对也只会修好一半,而且你会以为自己修完了。
写工具型插件的顺序大概是:先把坏掉的样本捞几十笔出来看清楚,再决定正规表达式怎么写。反过来做一定会漏。