DedeCMS 未引用图片附件扫描清理工具(CLI模式)
时间:2026-6-12 16:46
浏览:23
DedeCMS 在附件生命周期管理这块一直比较薄弱,常年运营下来,容易积累垃圾图片。主要有几个主要原因:编辑器上传即入库,传错了再传,删除内容并不删图片文件;产品更新,图集模型反复编辑,频繁换图;专题下线等。附件没有自动清理机制,文件仍然躺在磁盘里。日积月累,无用图片会占用大量服务器空间,也给数据备份带来不便,徒增无意义的运营成本。
针对这种情况,做了一个未被引用的图片附件扫描清理脚本 (clean_unused_img.php),基本覆盖了 DedeCMS 里 95% 以上真实图片引用来源。放到站点根目录里,CLI模式执行,一年跑一次即可。
<?php
/**
* 未引用图片附件扫描清理工具(CLI模式)
*
* 功能说明:
* 1. 扫描 DedeCMS 内容模型中的图片引用
* 2. 对比 uploads 目录下实际图片文件
* 3. 输出未引用图片清单
* 4. 支持 Dry Run(仅生成报告)
* 5. 支持 Delete 模式(删除未引用图片)
* 6. 支持模板目录扫描
* 7. 支持目录白名单保护
*
* 扫描范围:
* 文档封面 - #@__archives.litpic
* 资讯正文 - #@__addonarticle.body
* 图集正文 - #@__addonimages.body
* 图集图片 - #@__addonimages.imgurls
* 栏目内容 - #@__arctype.content
* 单页内容 - #@__sgpage.body
* 模板目录 - webtpl / templets
*
* 使用方法:
*
* cd /www/wwwroot/站点目录/
* 进入要清理的站点目录
*
* php clean_unused_img.php
* 仅生成报告(默认安全模式)
*
* php clean_unused_img.php --delete
* 删除未引用图片
*
* 注意事项:
* 删除前请务必备份 uploads 目录。
* 建议每年执行一次,并抽样检查 unused_uploads.txt。
*
* @version $Id: clean_unused_img.php 1.3 2026-06-12 $
* @package DedeCMS.Tools
* @copyright Copyright (c) 2026.6.12
* @license GNU GPL v2 (https://www.gnu.org/licenses/gpl-2.0.html)
* @author Joseffu
* @link https://joseffu.online
*/
error_reporting(E_ALL);
ini_set('display_errors', 'On');
ini_set('memory_limit', '2048M');
set_time_limit(0);
if (php_sapi_name() !== 'cli') {
exit("请在CLI模式执行\n");
}
define('WEB_ADMIN', true);
require_once __DIR__ . '/core/common.inc.php';
$deleteMode = in_array('--delete', $argv);
$rootPath = realpath(__DIR__);
$uploadDir = $rootPath . '/uploads';
if (!is_dir($uploadDir)) {
exit("uploads目录不存在\n");
}
echo "=====================================\n";
echo " 未引用图片附件扫描清理工具\n";
echo "=====================================\n";
echo $deleteMode
?
"当前模式:DELETE\n" :
"当前模式:DRY RUN\n";
/** 扫描配置 */
$scanTables = [
['archives', 'litpic'], /** 文档封面 */
['addonarticle', 'body'], /** 资讯正文 */
['addonimages', 'body'], /** 图集正文 */
['addonimages', 'imgurls'], /** 图集图片 */
['arctype', 'content'], /** 栏目内容 */
['sgpage', 'body'], /** 单页内容 */
];
/** 忽略目录 */
$excludeDirs = [
'/uploads/image/case/',
'/uploads/image/exhibition/',
'/uploads/image/honor/',
'/uploads/image/partner/',
'/uploads/image/team/',
];
/** 模板扫描目录 */
$templateDirs = [
$rootPath . '/webtpl',
$rootPath . '/templets',
];
/** 图片扩展名 */
$imageExts = [
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'webp',
'svg',
];
/** 每批扫描记录数 */
$pageSize = 1000;
/** 已引用文件 */
$usedFiles = [];
/** 输出日志 */
function logMsg($msg) {
echo '[' . date('H:i:s') . '] ' . $msg . PHP_EOL;
}
/** 统一路径格式 */
function normalizePath($path) {
$path = trim($path);
if ($path === '') {
return '';
}
$path = html_entity_decode(
$path,
ENT_QUOTES,
'UTF-8'
);
$path = preg_replace(
'#https?://[^/]+#i',
'',
$path
);
$path = preg_replace(
'#\?.*$#',
'',
$path
);
$path = str_replace(
'\\',
'/',
$path
);
return trim($path);
}
/** 记录引用文件 */
function addUsedFile($path) {
global $usedFiles;
$path = normalizePath($path);
if ($path === '') {
return;
}
$pos = strpos($path, '/uploads/');
if ($pos === false) {
if (strpos($path, 'uploads/') === 0) {
$path = '/' . $path;
} else {
return;
}
} else {
$path = substr($path, $pos);
}
$usedFiles[$path] = true;
preserveThumbs($path);
}
/** 自动保留缩略图 */
function preserveThumbs($path) {
global $usedFiles;
$info = pathinfo($path);
if (
empty($info['dirname']) ||
empty($info['filename']) ||
empty($info['extension'])
) {
return;
}
$dir = $info['dirname'];
$name = $info['filename'];
$ext = $info['extension'];
$thumbs = [
"{$dir}/{$name}_lit.{$ext}",
"{$dir}/{$name}_s.{$ext}",
"{$dir}/{$name}_thumb.{$ext}",
"{$dir}/{$name}-L.{$ext}",
];
foreach ($thumbs as $file) {
$usedFiles[$file] = true;
}
}
/** 提取内容中的uploads路径 */
function extractUploads($content) {
$result = [];
if ($content === '') {
return $result;
}
preg_match_all(
'#(?:/)?uploads/[^"\'<>\s\)]+#i',
$content,
$matches
);
if (!empty($matches[0])) {
foreach ($matches[0] as $file) {
if ($file[0] !== '/') {
$file = '/' . $file;
}
$result[] = $file;
}
}
return array_unique($result);
}
/** 判断是否图片 */
function isImageFile($file) {
global $imageExts;
$ext = strtolower(
pathinfo(
$file,
PATHINFO_EXTENSION
)
);
return in_array($ext, $imageExts);
}
/** =========
* 数据库扫描
* ========== */
/** 从数据库中提取所有引用 */
function scanDatabase() {
global $dsql, $cfg_dbprefix, $scanTables, $pageSize, $usedFiles;
foreach ($scanTables as $tableItem) {
$table = $cfg_dbprefix . $tableItem[0];
$field = $tableItem[1];
logMsg("扫描表:{$table}.{$field}");
// 获取总数
$row = $dsql->GetOne("SELECT COUNT(*) AS c FROM {$table}");
$total = intval($row['c']);
logMsg("记录数:{$total}");
$offset = 0;
while ($offset < $total) {
$sql = "SELECT {$field} FROM {$table} LIMIT {$offset},{$pageSize}";
$dsql->SetQuery($sql);
$dsql->Execute();
while ($row = $dsql->GetArray()) {
$value = trim($row[$field]);
if ($value === '') {
continue;
}
// litpic 单图字段
if ($field === 'litpic') {
addUsedFile($value);
continue;
}
// HTML / 图集字段
$files = extractUploads($value);
foreach ($files as $file) {
addUsedFile($file);
}
}
$offset += $pageSize;
logMsg("已扫描:{$offset}/{$total}");
}
logMsg("完成表:{$table}");
}
}
/** 汇总入口 */
function collectUsedFiles() {
logMsg("开始扫描数据库引用...");
scanDatabase();
logMsg("引用文件总数:" . countUsedFiles());
scanTemplateFiles();
}
/** 统计引用数量 */
function countUsedFiles() {
global $usedFiles;
return count($usedFiles);
}
/** 输出调试(可选) */
function debugUsedSample($limit = 20) {
global $usedFiles;
$i = 0;
foreach ($usedFiles as $k => $v) {
echo $k . PHP_EOL;
if (++$i >= $limit) {
break;
}
}
}
/** =========================
* 文件扫描 + 比对 + 删除
* ========================= */
/** 扫描 uploads 目录所有文件 */
function scanUploadFiles() {
global $uploadDir, $rootPath, $imageExts;
logMsg("开始扫描 uploads 目录...");
$files = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$uploadDir,
FilesystemIterator::SKIP_DOTS
)
);
foreach ($iterator as $file) {
if (!$file->isFile()) {
continue;
}
$full = $file->getPathname();
$ext = strtolower(pathinfo($full, PATHINFO_EXTENSION));
// 只处理图片
if (!in_array($ext, $imageExts)) {
continue;
}
$relative = str_replace($rootPath, '', $full);
$relative = str_replace('\\', '/', $relative);
$files[] = [
'full' => $full,
'relative' => $relative
];
}
logMsg("磁盘文件数量:" . count($files));
return $files;
}
/** 判断是否在排除目录 */
function isExcluded($path) {
global $excludeDirs;
foreach ($excludeDirs as $dir) {
if (strpos($path, $dir) === 0) {
return true;
}
}
return false;
}
/** 计算未引用文件 */
function diffUnusedFiles($allFiles) {
global $usedFiles;
logMsg("开始计算未引用文件...");
$unused = [];
foreach ($allFiles as $file) {
$relative = $file['relative'];
if (isExcluded($relative)) {
continue;
}
if (!isset($usedFiles[$relative])) {
$unused[] = $file;
}
}
logMsg("未引用文件数量:" . count($unused));
return $unused;
}
/** 写入报告 */
function writeReport($unused) {
$file = __DIR__ . '/unused_uploads.txt';
$fp = fopen($file, 'w');
foreach ($unused as $item) {
fwrite($fp, $item['relative'] . PHP_EOL);
}
fclose($fp);
logMsg("报告已生成:" . $file);
}
/** 删除文件 */
function deleteFiles($unused) {
logMsg("开始删除文件...");
$count = 0;
foreach ($unused as $item) {
if (@unlink($item['full'])) {
$count++;
}
}
logMsg("删除完成!数量:" . $count);
}
/** 模板目录扫描 */
function scanTemplateFiles() {
global $templateDirs, $usedFiles;
logMsg("开始扫描模板目录...");
foreach ($templateDirs as $dir) {
if (!is_dir($dir)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$dir,
FilesystemIterator::SKIP_DOTS
)
);
foreach ($iterator as $file) {
if (!$file->isFile()) {
continue;
}
$ext = strtolower(
pathinfo(
$file->getFilename(),
PATHINFO_EXTENSION
)
);
if (!in_array($ext, [
'htm',
'html',
'hwt',
'css',
'js'
])) {
continue;
}
$content = @file_get_contents(
$file->getPathname()
);
if (!$content) {
continue;
}
$images = extractUploads(
$content
);
foreach ($images as $img) {
addUsedFile($img);
}
}
}
logMsg("模板目录扫描完成");
}
/** 主流程 */
function run() {
global $deleteMode;
collectUsedFiles();
$allFiles = scanUploadFiles();
$unused = diffUnusedFiles($allFiles);
writeReport($unused);
if ($deleteMode) {
logMsg("进入删除模式...");
deleteFiles($unused);
} else {
logMsg("当前为安全模式(未删除)");
logMsg("如需删除,请执行:php clean_unused_img.php --delete");
}
logMsg("任务完成。");
}
/** 启动 */
run();
相关文章
本文没有标签

