WordPress 附件(媒体文件)清理通用指南

背景

关于本指南:核心逻辑为本人经过多次实践总结而成。建议在 IDE(如 Cursor、Trae 等)中将本指南全文喂给 AI,作为实现该清理工具的参考依据,可大幅提高开发效率和准确性。

WordPress 站点在长期运行过程中,wp-content/uploads 目录会不断积累大量媒体文件(图片、PDF、视频等)。这些文件的主要来源包括:

  • 文章/页面内容:编辑器插入的图片、附件下载、相册等;
  • 主题/插件自动生成:缩略图裁剪、缓存、日志、占位图等;
  • 直接上传未使用:后台或 FTP 上传后从未被任何内容引用;
  • 内容删除后的残留:文章或产品已删除,但关联的媒体文件仍留在磁盘上。

随着时间推移,未被引用的"垃圾文件"会带来以下问题:

  • 浪费存储空间:尤其是图片密集的站点(电商、新闻、图库),未用文件可能占用数 GB 到数十 GB;
  • 备份体积膨胀:全量备份中包含大量无用文件,拖慢备份/恢复速度,增加存储成本;
  • 迁移/部署效率低:站点迁移或搭建测试环境时,大量无用文件被一并拷贝;
  • 安全隐患:残留的敏感文件(如发票截图、内部文档)可能被公开访问。

然而,手动清理风险极高

  • 仅凭文件名或目录结构无法判断文件是否被引用;
  • 缩略图与原始图片之间存在关联关系,误删会导致页面破图;
  • 媒体库有登记记录 ≠ 文件正在被使用,反之亦然。

因此,需要一套自动化的、数据驱动的、可审计的清理方案——通过扫描文件系统、分析数据库引用关系,精准识别"无实际引用"的附件,并提供安全、可逆的删除操作。这就是本指南的目的。

 

一、适用范围与目标

  1. 扫描 wp-content/uploads 下全部文件,建立文件索引;
  2. 排除插件自用目录(不参与计算,仅列出并打标签);
  3. 从全站数据(除附件表自身登记外)与附件特殊引用状态两个来源,给文件打"引用标记";
  4. 原图与缩略图关联传播标记(同生共死);
  5. 提供 Web 页面:展示清单(文件名、大小、修改时间等)、图片预览、批量下载、勾选删除;
  6. 所有产出物(脚本、结果、日志、页面)放在独立目录(如站点根下的 doc/)。
建议:一律在备份环境执行(不触碰线上);删除默认先移入回收站,双保险。

 

二、前置条件

项目 要求
站点环境 完整的 WordPress 文件(含 wp-content),uploads 目录可读
数据库 SQL 备份已导入本机 MySQL(或可直连线上库只读);需知道 host/port/库名/账号
PHP CLI 8.x(脚本)+ 内置服务器(预览页面);ZipArchive 扩展(批量下载)
表结构 标准 WP 前缀 wp_(会自动适配,含 WooCommerce / WPML / Gravity Forms / Elementor 等插件表亦可)

 

三、核心模型:标记引用,未标记即无用

只做一件事:给每个文件打"引用标记"。三步完成:

第 1 步:扫描文件,建立索引

  • 遍历 uploads 下全部文件,建立索引(相对路径、文件名、大小、修改时间、扩展名、是否图片);
  • 插件/系统自用目录(cache、wc-logs、cartflows-logs、wpcom、astra-sites、flags、woocommerce_uploads、wpforms、gravity_forms 等)与根目录 WooCommerce 占位图:不参与引用计算,打 plugin 标签单独列出(可由用户自行决定处理)。

 

第 2 步:全站引用标记(两个来源)

来源 A · 全站内容引用:附件表自身存在登记数据(登记 ≠ 引用),必须排除,否则会自我标记。因此搜索除附件表外的其余所有表的文本字段,出现该文件路径/文件名 → 打上"引用"标记。

来源 B · 附件 ID 指名引用:附件被"指名引用"(数据库中以附件 post_id 指向该附件)。核心原则:凡出现指向附件 ID 的位置都算来源 B,新插件/新引用方式按此原则补充即可,不必改动其他逻辑。严格分为两大类:

类 1 · 附件表中的特殊引用(附件体系内的标准引用状态,通过明确的 meta 键 / option 名存储附件 ID):

引用方式 存储位置 说明
特色图 _thumbnail_id 文章/页面/产品设定的特色图片
产品/变体画廊 _product_image_gallery / _variation_image_gallery WooCommerce 产品与变体图片画廊
站点 Logo options site_logo / custom_logo 网站 logo
主题背景/页头图 options background_image / header_image 主题自定义背景/页头
菜单图标/附件 _menu_item_icon / _menu_item_attachment_id 菜单项图标或附件
本地头像 用户设置 本地化头像设置
自定义图片字段 ACF 等插件 meta 值为附件 ID 的图片字段

类 2 · 附件表外所有表的附件 ID 引用(文本指名,范围与来源 A 一致——除附件登记数据外的所有表文本字段):

  • 文本中出现附件 ID:Gutenberg 块 {"id":123}、短代码  / 、链接 ?attachment_id=123、构建器媒体 ID 等 → 对应附件标记引用。

命中任意一条 → 打上"引用"标记。

 

第 3 步:原图 ↔ 缩略图关联传播

通过 _wp_attachment_metadata(PHP 序列化,需 unserialize)的 sizes 建立附件组(原图 + 其全部尺寸图/缩略图):

  • 组内任一文件被来源 A(全站内容)标记 → 整组全部标记为引用
  • 组内任一文件被来源 B(附件特殊引用状态)标记 → 整组全部标记为引用

原图与缩略图同生共死:只要原图或任一缩略图被标记引用,关联的全部文件都保留;全部未被标记,则关联的全部文件都是无用文件。

结论:三态

状态 判定 处理
referenced 被标记引用(来源 A / 来源 B,经第 3 步传播) 保留,只读
unused 未被标记引用 无用,可删除(是否在媒体库有登记记录不影响结论,登记信息仅作证据展示)
plugin 插件/系统自用目录或占位图 不参与判定,单独列出

 

四、计算逻辑(对应上节的实现步骤)

第 1 步:扫描 uploads,建立文件索引

  • 递归遍历全部文件,记录 rel / name / size / mtime / ext / is_image;
  • 位于插件/系统自用目录或为占位图 → 直接标 plugin,不再参与后续计算。

 

第 2 步:收集"全站内容"(来源 A 的检索底稿)

扫描范围:排除 wp_posts 中 post_type='attachment' 的行(附件自身的登记记录),以及 _wp_attached_file / _wp_attachment_metadata 两个登记类 meta;其余全部表的所有文本列都必须纳入扫描——图片引用可能出现在任何一张表的任何一个文本字段中。主要来源:

  • 文章表:非附件行的 post_content / post_excerpt / post_title
  • postmeta:全部 meta_value,排除 _wp_attached_file / _wp_attachment_metadata 两个登记类 meta(否则登记路径会被误判为引用)
  • options:全量 option_value(小工具 widget_*、主题设置 theme_mods_*、site_logo 等)
  • WPML 翻译表(icl_translate)、评论表(comments)
  • 其余全部表SHOW TABLES + SHOW COLUMNS 自动发现文本类列(varchar/char/text/longtext/json)并全量取值(覆盖 termmeta、terms 菜单/分类、Gravity Forms 表单设置与提交、WPML 字符串、用户、插件自有表等)——这是"图片不在文章表中"的核心兜底

 

第 3 步:收集"附件 ID 指名引用"(来源 B)

核心原则:凡是数据库中以附件 post_id 指名引用的位置,都是来源 B,按两大类收集:

类 1 · 附件表中的特殊引用(结构化键,明确 meta 键 / option 名):

  • postmeta:_thumbnail_id(特色图)、_product_image_gallery / _variation_image_gallery(产品/变体画廊)、_menu_item_icon / _menu_item_attachment_id(菜单)等,meta_value 是附件 post_id → 对应附件标记引用(证据注明引用者文章 ID);
  • options:site_logo / custom_logo / background_image / header_image 等,值为附件 ID 时 → 对应附件标记引用(值为 URL 的走第 4 步路径检索);
  • 其他值为附件 ID 的图片字段(ACF 等)同理。

类 2 · 附件表外所有表的附件 ID 引用(文本指名,与第 2 步同范围):

  • 全站文本中出现附件 ID(Gutenberg 块 {"id":123}、短代码 

    、链接 ?attachment_id=123、构建器媒体 ID 等)→ 对应附件标记引用。

 

第 4 步:从全站内容提取引用路径(来源 A)

基于第 2 步收集的"全站内容"(除附件登记数据外的所有表),提取所有引用路径:

  • 正则匹配 uploads/<路径>(兼容 wp-content/uploads/ 与 uploads/ 两种前缀、URL 编码 urldecode、去除查询参数/锚点);
  • 得到路径集合;再取其 basename 集合(兜底:换域名 / 相对路径引用)。

 

第 5 步:标记与传播

  1. 插件/特殊目录 → plugin
  2. 命中来源 A(路径精确 / basename / 全文包含)或来源 B(附件特殊引用状态)→ referenced
  3. 附件组传播:原图与其全部尺寸图/缩略图视为一组,组内任一 referenced → 整组 referenced(即:先判原图,尺寸图跟随原图;原图未标记 → 整组 unused);
  4. 其余 → unused(evidence 注明是否有媒体库登记 post_id,供核对,不影响结论)。

 

五、脚本实现说明

5.1 目录结构(产出物集中在单独的自定义目录)

文件 类型 作用
db_config.php 配置 数据库连接参数(scan / 页面共用,建议 chmod 600);返回关联数组:host / port / dbname / user / password / prefix
scan.php PHP CLI 脚本 连接 MySQL + 扫描 uploads + 标记引用 → 生成 useless_files.json
media_clean.php PHP Web 单文件 展示清单 / 预览 / 下载 / 删除(含安全校验)
useless_files.json 数据 全量文件索引 + 引用标记结果 + 引用证据(扫描产物)
delete_log.jsonl 日志 每次删除/移入回收站的操作记录
trash/ 目录 回收站(删除的文件先移到这里,不直接删)

 

5.2 scan.php 关键实现点

  1. 数据库读取PDO 直连;wp_posts(附件行 guid + 非附件行内容)、wp_postmeta(含登记类与特殊引用 meta)、wp_options(全量)、icl_translatecomments、其余表全文本列;
  2. 附件组构建_wp_attached_file / guid 找原图,_wp_attachment_metadata.sizes 把尺寸图归属到原图;
  3. 来源 A:拼接全站内容 → 提取 uploads/ 路径集合与 basename 集合;
  4. 来源 B_thumbnail_id_product_image_gallerysite_logo 等指向的附件 ID 集合;
  5. 标记 + 传播:插件目录 → plugin;来源 A/B 命中 → referenced;尺寸图随原图 → 整组同状态;未标记 → unused;
  6. 输出 JSON:含 meta(扫描时间、总数、三态计数)与 files(rel/name/size/mtime/ext/is_image/status/evidence);evidence 为字符串数组(来源 + 命中内容,如 ["内容引用: 文章 ID 12", "特色图: 文章 ID 8"]),供页面展示与人工复核。

 

5.3 media_clean.php 功能(Web 页面)

  • 列表区:分 Tab ——「无用文件」(默认,未标记引用的文件) /「插件/特殊目录」/「被引用」;列:勾选框、缩略预览、文件名、相对路径、大小、修改时间、判定依据/引用证据;
  • 筛选:关键字搜索、扩展名筛选;
  • 预览:图片在表格内 <img> 缩略预览,点击放大;
  • 下载:勾选后批量打包下载(ZipArchive 生成临时 zip),或单文件直下 readfile;
  • 删除:勾选 → 二次确认弹窗 → 默认"移入 trash/<相对路径>"(保留相对结构便于还原),可切换直接删除;每步写 delete_log.jsonl

 

六、安全与注意事项

  1. 先备份再删:全程在备份环境执行;回收站保留 7 天双保险。
  2. 判定只漏判、不误判:宁可把"疑似无用"标为证据较弱供人工确认,也不自动全删。
  3. 插件目录不参与判定:cache、wc-logs、cartflows-logs、wpcom、astra-sites、flags、woocommerce_uploads、wpforms、gravity_forms 等插件自用目录单独列出、默认不勾选。
  4. 性能:判定数据一次性读入内存;全库全文约 10MB,数百文件 × 全文/集合检索 1~2s;页面读 JSON 无数据库开销。
  5. 删除权限:备份文件可能为 root 属主,页面删除失败时提示先 chown -R www:www wp-content/uploads(宝塔默认 www 用户)或手动执行终端命令删除。
  6. 域名差异:guid 中域名可能与环境不一致,比对一律用"uploads 之后的路径"而非完整 URL。
  7. 凭据安全:数据库密码记录于 db_config.php,建议 doc 目录在 nginx 中禁止访问、chmod 600;任务结束后修改该账号密码。

 

附录:清理附件表中的"幽灵记录"(文件已删,记录残留)

⚠ 本附录与主流程完全独立,明确不参与清理过程本身:必须先完成全部文件清理(附件删除、核对、回收站留存期满)之后,才考虑执行本附录。

本工具只删除实际文件,不删除数据库记录。文件清理完成后,附件表(wp_posts 中 post_type='attachment' 及其 postmeta)里仍会残留已删文件的登记记录,媒体库中显示为破图。如需彻底清理,按以下步骤操作。

1. 前置条件(必须)

  • 文件清理已全部完成(本附录不参与清理过程,任何文件操作都必须在此之前结束);
  • 已备份数据库(mysqldump);
  • 已确认这些记录对应的文件确实已从 uploads 删除(先跑完文件清理并核对);
  • 只处理"文件不存在"的记录,不影响任何仍存在的文件。

 

2. 找出"文件不存在但记录存在"的附件

SQL 无法直接访问文件系统,用一个小脚本比对(复用 db_config.php 连接,改前缀后即可通用):

<?php
// cleanup_orphan_records.php — 找出已删文件的附件记录(CLI 运行:先只打印,核对后再执行删除)
$CFG     = require __DIR__ . '/db_config.php';
$UPLOADS = dirname(__DIR__) . '/wp-content/uploads';
$pdo = new PDO(
    sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $CFG['host'], $CFG['port'], $CFG['dbname']),
    $CFG['user'], $CFG['password']
);
$P = rtrim($CFG['prefix'], '_') . '_';

// 取所有附件登记的原图路径,检查文件是否仍存在
$rows = $pdo->query("SELECT pm.post_id, pm.meta_value FROM {$P}postmeta pm WHERE pm.meta_key='_wp_attached_file'")->fetchAll(PDO::FETCH_NUM);
$missing = [];
foreach ($rows as [$postId, $rel]) {
    if ($rel === '') continue;
    if (!file_exists($UPLOADS . '/' . ltrim($rel, '/'))) {
        $missing[] = (int)$postId;
    }
}
echo '文件已不存在的附件记录: ' . count($missing) . " 条\n";
echo 'post_id 列表: ' . implode(',', $missing) . "\n";

// ⚠ 人工核对无误后,再取消下面两行注释执行删除:
// $ids = implode(',', $missing);
// $pdo->exec("DELETE FROM {$P}postmeta WHERE post_id IN ({$ids}); DELETE FROM {$P}posts WHERE ID IN ({$ids}) AND post_type='attachment';");

 

3. 核对与执行

  1. 先运行脚本只打印(删除被注释),人工核对 post_id 清单;
  2. 确认无误后,取消删除注释再运行一次;
  3. 也可把打印的 post_id 列表手动带入 SQL 执行(将 {PREFIX} 替换为实际表前缀,如 wp_)。

 

4. 执行前必查的"反向引用"

删除记录前,确认这些附件没有被反向引用,否则应先处理引用(例如先恢复文件):

-- 被设为特色图 / 产品画廊的附件记录,不应删除
SELECT meta_key, meta_value
FROM {PREFIX}postmeta
WHERE meta_key IN ('_thumbnail_id', '_product_image_gallery')
  AND meta_value IN (待删除的 post_id 列表);
  • 有结果 → 该附件仍被使用,属于文件误删,应先从回收站恢复文件,再重新走引用判定;
  • 无结果 → 可以执行删除。

 

5. 收尾

  • 删除记录后刷新媒体库,确认不再显示破图;
  • 保留本脚本与操作日志(可与 delete_log.jsonl 一并归档);
  • 若站点使用 WPML 等翻译插件,可顺带核对 {PREFIX}icl_translations 中对应元素是否一并清理。
上一篇 花了两天捣鼓了个比亚迪闪充计算器