DedeCMS 未引用图片附件扫描清理工具(CLI模式)
  • 首页 > 电脑网络
  • 作者: Joseffu
  • 2026年6月12日 16:46 星期五
  • 浏览:23 次
  • 字号:
  • 评论:0 条  
  • 时间: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();


  • 本文没有标签
    昵称 邮箱 主页
    表情1 表情2 表情3 表情4 表情5 表情6 表情7 表情8 表情9 表情10 表情11 表情12 表情13 表情14 表情15 表情16 表情17 表情18 表情19 表情20 表情21

    10 + 65 =