しばらく使っていると、以下の問題と要望が出てきました。

  • All-In-One Security (AIOS) を導入すると監査ログに大量のデータ(主に不正アクセスの試行失敗)が溜まってしまう。
  • ページを保存する度にリビジョンが溜まってしまう。
    • 一定期間過ぎたものは不要なので自動削除したい。
  • メディアライブラリに使ってないファイルが溜まってしまう。
    • 一定期間過ぎ、参照されていないものは不要なので自動削除したい。

設定やプラグインで対応できないか調べてみましたが、思ったようにいかないので、プログラムを自作して対応することにしました。

ソースコード

PHP
<?php
// WordPress メンテナンススクリプト Ver.1.3
// Copyright 2025 North Innovations 髙橋一浩
// https://www.north-innovations.com/wordpress/maintenance/

// システム
declare(strict_types=1);
$user = getenv("USER");
ini_set("date.timezone"  , "Asia/Tokyo");
ini_set("display_errors" , "0");
ini_set("log_errors"     , "on");
ini_set("error_log"      , "/home/{$user}/log/maintenance.log");
ini_set("error_reporting", E_ALL);
$useTemporaryTable = true;
$batchSize = 1000;

// データベース
$host     = "mysql80.{$user}.sakura.ne.jp";
$dbname   = "{$user}_YOUR_DATABASE_NAME";
$dbuser   = "{$user}_YOUR_DATABASE_NAME";
$password = "YOUR_DATABASE_PASSWORD";
$prefix   = "YOUR_TABLE_PREFIX";

// ディレクトリ
$baseUrl = "https://www.YOUR_DOMAIN_NAME.com/wp-content/uploads/";
$baseDir = "/home/{$user}/www/YOUR_INSTALL_DIRECTORY/wp-content/uploads";
$filter  = "/^[2-9]\\d{3}$/"; // 2000-9999

// 保持期間等
$auditLogRetention         = "90 DAY";
$revisionRetention         = "90 DAY";
$unusedAttachmentRetention = "90 DAY";
$stackTraceNoSet           = true;

try {
    // データベース接続
    $dsn = "mysql:host={$host};dbname={$dbname};charset=utf8mb4";
    $options = [ PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false ];
    $pdo  = new PDO($dsn, $dbuser, $password, $options);
    $pdo2 = new PDO($dsn, $dbuser, $password, $options);

    // --- All-In-One Security ---
    if (tableExists($pdo, $dbname, "{$prefix}aiowps_audit_log")) {
        // 古い監査ログを削除
        execute($pdo, "削除", "{$prefix}aiowps_audit_log", "
            DELETE FROM {$prefix}aiowps_audit_log
            WHERE created < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL {$auditLogRetention}))
        ");
        
        // 監査ログのスタックトレースを記録しない
        $triggerExists = triggerExists($pdo, $dbname, "{$prefix}aiowps_audit_log", "{$prefix}aiowps_audit_log_stacktrace_noset");
        if ($stackTraceNoSet && !$triggerExists) {
            $pdo->exec("
                CREATE TRIGGER {$prefix}aiowps_audit_log_stacktrace_noset
                BEFORE INSERT ON {$prefix}aiowps_audit_log FOR EACH ROW
                BEGIN SET NEW.stacktrace = ''; END
            ");
            error_log("トリガー {$prefix}aiowps_audit_log_stacktrace_noset を作成しました。");
        }
        if (!$stackTraceNoSet && $triggerExists) {
            $pdo->exec("
                DROP TRIGGER {$prefix}aiowps_audit_log_stacktrace_noset
            ");
            error_log("トリガー {$prefix}aiowps_audit_log_stacktrace_noset を削除しました。");
        }
    }

    // --- WordPress 本体 ---
    // 古いリビジョンを削除
    execute($pdo, "削除", "{$prefix}posts", "
        DELETE FROM {$prefix}posts
        WHERE post_date < DATE_SUB(NOW(), INTERVAL {$revisionRetention})
        AND post_type = 'revision' AND post_status = 'inherit'
    ");
    
    // 参照されている添付ファイル名を抽出
    createTable($pdo, $useTemporaryTable, "tmp_references", "file_name VARCHAR(255) NOT NULL PRIMARY KEY");
    $stmt = $pdo2->query("SELECT post_content FROM {$prefix}posts WHERE post_content LIKE '%{$baseUrl}%'");
    $files = [];
    $isTemporary = false;
    $regex = "/\"" . preg_quote($baseUrl, "/") . "([^\"]+)\"/";
    while ($content = $stmt->fetchColumn()) {
        if (preg_match_all($regex, $content, $matches)) {
            foreach ($matches[1] as $file)
                $files[$file] = null;
            if (count($files) >= $batchSize) {
                if (!$isTemporary) {
                    createTable($pdo, $useTemporaryTable, "tmp_references_tmp", "file_name VARCHAR(255) NOT NULL");
                    $isTemporary = true;
                }
                insertReferences($pdo, true, $files);
                $files = [];
            }
        }
    }
    if (count($files) > 0)
        insertReferences($pdo, $isTemporary, $files);
    if ($isTemporary)
        $pdo->exec("INSERT INTO tmp_references (file_name) SELECT DISTINCT file_name FROM tmp_references_tmp");

    // リサイズ版を含む添付ファイル名を抽出
    createTable($pdo, $useTemporaryTable, "tmp_attachments", "post_id BIGINT UNSIGNED NOT NULL, file_name VARCHAR(255) NOT NULL, PRIMARY KEY(post_id, file_name)");
    $stmt = $pdo2->query("
        SELECT a.post_id, b.guid, a.meta_key, a.meta_value
        FROM {$prefix}postmeta a
        INNER JOIN {$prefix}posts b ON a.post_id = b.ID
        WHERE a.meta_key IN ('_wp_attached_file', '_wp_attachment_metadata', '_wp_attachment_backup_sizes')
        AND b.post_type = 'attachment'
        AND b.guid LIKE '{$baseUrl}%'
        ORDER BY a.post_id
    ");
    $row = $stmt->fetch(PDO::FETCH_NUM);
    $baseLength = mb_strlen($baseUrl);
    $rows = [];
    while ($row) {
        $postId = $row[0];
        $guid = $row[1];
        $meta = [];
        while (true) {
            $meta[$row[2]] = $row[3];
            $row = $stmt->fetch(PDO::FETCH_NUM);
            if (!$row || $row[0] !== $postId)
                break;
        }
        $files = [];
        $files[mb_substr($guid, $baseLength)] = null;
        $directory = getDirectory($guid, $baseLength);
        if ($file = $meta["_wp_attached_file"] ?? null)
            $files[$file] = null;
        if ($attachment = getArray($meta["_wp_attachment_metadata"] ?? null)) {
            if ($file = $attachment["file"] ?? null) {
                $files[$file] = null;
                $directory = getDirectory($file, 0);
            }
            if ($sizes = $attachment["sizes"] ?? null)
                addSizes($files, $directory, $sizes);
        }
        if ($sizes = getArray($meta["_wp_attachment_backup_sizes"] ?? null))
            addSizes($files, $directory, $sizes);
        foreach (array_keys($files) as $file)
            $rows[] = [$postId, $file];
        if (count($rows) >= $batchSize) {
            insertAttachments($pdo, $rows);
            $rows = [];
        }
    }
    if (count($rows) > 0)
        insertAttachments($pdo, $rows);

    // 参照されている添付ファイルの投稿IDを抽出
    createTable($pdo, $useTemporaryTable, "tmp_used_attachments", "post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY");
    $pdo->exec("
        INSERT INTO tmp_used_attachments (post_id)
        SELECT a.post_id
        FROM tmp_attachments a
        INNER JOIN tmp_references b ON a.file_name = b.file_name
        GROUP BY a.post_id
    ");
    $pdo->exec("
        INSERT IGNORE INTO tmp_used_attachments (post_id)
        SELECT meta_value
        FROM {$prefix}postmeta
        WHERE meta_key = '_thumbnail_id'
    ");
    $pdo->exec("
        INSERT IGNORE INTO tmp_used_attachments (post_id)
        SELECT option_value
        FROM {$prefix}options
        WHERE option_name IN ('site_icon', 'site_logo')
    ");

    // 参照されていない古い添付ファイルを削除
    execute($pdo, "削除", "{$prefix}posts", "
        DELETE a
        FROM {$prefix}posts a
        LEFT JOIN tmp_used_attachments b ON a.ID = b.post_id
        WHERE a.post_date < DATE_SUB(NOW(), INTERVAL {$unusedAttachmentRetention})
        AND a.post_type = 'attachment'
        AND a.guid LIKE '{$baseUrl}%'
        AND b.post_id IS NULL
    ");

    // 孤立した添付ファイルの親投稿IDを0に更新
    execute($pdo, "更新", "{$prefix}posts", "
        UPDATE {$prefix}posts a
        LEFT JOIN {$prefix}posts b ON a.post_parent = b.ID
        SET a.post_parent = 0
        WHERE a.post_type = 'attachment'
        AND a.post_parent <> 0
        AND b.ID IS NULL
    ");

    // 投稿メタデータを削除
    execute($pdo, "削除", "{$prefix}postmeta", "
        DELETE a
        FROM {$prefix}postmeta a
        LEFT JOIN {$prefix}posts b ON a.post_id = b.ID
        WHERE b.ID IS NULL
    ");
    
    // コメントを削除
    execute($pdo, "削除", "{$prefix}comments", "
        DELETE a
        FROM {$prefix}comments a
        LEFT JOIN {$prefix}posts b ON a.comment_post_ID = b.ID
        WHERE b.ID IS NULL
    ");

    // コメントメタデータを削除
    execute($pdo, "削除", "{$prefix}commentmeta", "
        DELETE a
        FROM {$prefix}commentmeta a
        LEFT JOIN {$prefix}comments b ON a.comment_id = b.comment_ID
        WHERE b.comment_ID IS NULL
    ");

    // タクソノミー関係を削除
    execute($pdo, "削除", "{$prefix}term_relationships", "
        DELETE a
        FROM {$prefix}term_relationships a
        LEFT JOIN {$prefix}posts b ON a.object_id = b.ID
        WHERE b.ID IS NULL
    ");

    // タクソノミー件数を更新
    createTable($pdo, $useTemporaryTable, "tmp_taxonomy", "term_taxonomy_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, count BIGINT NOT NULL");
    $pdo->exec("
        INSERT INTO tmp_taxonomy (term_taxonomy_id, count)
        SELECT term_taxonomy_id, COUNT(*)
        FROM {$prefix}term_relationships
        GROUP BY term_taxonomy_id
    ");
    execute($pdo, "更新", "{$prefix}term_taxonomy", "
        UPDATE {$prefix}term_taxonomy a
        LEFT JOIN tmp_taxonomy b ON a.term_taxonomy_id = b.term_taxonomy_id
        SET a.count = COALESCE(b.count, 0)
        WHERE a.count <> COALESCE(b.count, 0)
    ");

    // アップロードディレクトリにあるファイル名を抽出
    createTable($pdo, $useTemporaryTable, "tmp_uploads", "file_name VARCHAR(255) NOT NULL PRIMARY KEY");
    $files = [];
    $stack = [];
    if ($items = scandir($baseDir, SCANDIR_SORT_NONE)) {
        foreach (preg_filter($filter, "$0", $items) as $item)
            if ($item !== "." && $item !== "..")
                array_push($stack, $item);
    }
    while (count($stack) > 0) {
        $relativePath = array_pop($stack);
        $fullPath = "{$baseDir}/{$relativePath}";
        if (is_dir($fullPath)) {
            if ($items = scandir($fullPath, SCANDIR_SORT_NONE)) {
                foreach ($items as $item)
                    if ($item !== "." && $item !== "..")
                        array_push($stack, "{$relativePath}/{$item}");
            }
        } else {
            $files[] = $relativePath;
            if (count($files) >= $batchSize) {
                insertUploads($pdo, $files);
                $files = [];
            }
        }
    }
    if (count($files) > 0)
        insertUploads($pdo, $files);

    // 削除分を除いた添付ファイル名を抽出
    createTable($pdo, $useTemporaryTable, "tmp_attachments_2", "file_name VARCHAR(255) NOT NULL PRIMARY KEY");
    $pdo->exec("
        INSERT INTO tmp_attachments_2 (file_name)
        SELECT DISTINCT file_name
        FROM tmp_attachments a
        INNER JOIN {$prefix}posts b ON a.post_id = b.ID
    ");

    // EWWW Image Optimizer を利用している場合は .webp ファイルを追加
    if (tableExists($pdo, $dbname, "{$prefix}ewwwio_images")) {
        $pdo->exec("
            INSERT IGNORE INTO tmp_attachments_2 (file_name)
            SELECT CONCAT(a.file_name, '.webp')
            FROM tmp_attachments a
            INNER JOIN {$prefix}posts b ON a.post_id = b.ID
            WHERE a.file_name LIKE '%.png'
            OR a.file_name LIKE '%.jpg'
            OR a.file_name LIKE '%.jpeg'
            OR a.file_name LIKE '%.gif'
        ");
    }

    // アップロードディレクトリにある参照されていない添付ファイルを削除
    $stmt = $pdo->query("
        SELECT a.file_name
        FROM tmp_uploads a
        LEFT JOIN tmp_attachments_2 b ON a.file_name = b.file_name
        WHERE b.file_name IS NULL
        ORDER BY a.file_name
    ");
    while ($file = $stmt->fetchColumn()) {
        if (unlink("{$baseDir}/{$file}"))
            error_log("{$file} を削除しました。");
        else
            error_log("{$file} の削除に失敗しました。");
    }

} catch (Throwable $t) {
    error_log($t->getMessage() . " (" . $t->getFile() . " " . $t->getLine() . "行目)");
}

function execute(PDO $pdo, string $operation, string $table, string $stmt) {
    $count = $pdo->exec($stmt);
    if ($count > 0)
        error_log("{$table} テーブルのデータを {$count} 行{$operation}しました。");
}

function tableExists(PDO $pdo, string $dbname, string $table) : bool {
    return $pdo->query("
        SELECT COUNT(*)
        FROM information_schema.TABLES
        WHERE TABLE_SCHEMA = '{$dbname}'
        AND TABLE_NAME = '{$table}'
    ")->fetchColumn() > 0;
}

function triggerExists(PDO $pdo, string $dbname, string $table, string $trigger) : bool {
    return $pdo->query("
        SELECT COUNT(*)
        FROM information_schema.TRIGGERS
        WHERE TRIGGER_SCHEMA = '{$dbname}'
        AND EVENT_OBJECT_TABLE = '{$table}'
        AND TRIGGER_NAME = '{$trigger}'
    ")->fetchColumn() > 0;
}

function createTable(PDO $pdo, bool $isTemporary, string $table, string $columns) {
    if (!$isTemporary)
        $pdo->exec("DROP TABLE IF EXISTS {$table}");
    $temporary = $isTemporary ? " TEMPORARY" : "";
    $options = "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci";
    $pdo->exec("CREATE{$temporary} TABLE {$table} ({$columns}) {$options}");
}

function insertReferences($pdo, bool $isTemporary, array $files) {
    $stmt = $pdo->prepare("INSERT INTO tmp_references" . ($isTemporary ? "_tmp" : "") . " (file_name) VALUES " . implode(",", array_fill(0, count($files), "(?)")));
    $stmt->execute(array_keys($files));
}

function getDirectory($path, $baseIndex) : string {
    $index = mb_strrpos($path, "/");
    if ($index !== false) {
        $length = $index - $baseIndex + 1;
        if ($length > 0)
            return mb_substr($path, $baseIndex, $length);
    }
    return "";
}

function getArray(?string $value) : ?array {
    if (isset($value)) {
        $value2 = unserialize($value, ["allowed_classes" => false]);
        if (is_array($value2))
            return $value2;
    }
    return null;
}

function addSizes(array &$files, string $directory, array $sizes) {
    foreach (array_values($sizes) as $size) {
        $file = $size["file"] ?? null;
        if (isset($file))
            $files[$directory . $file] = null;
    }
}

function insertAttachments(PDO $pdo, array &$rows) {
    $values = [];
    foreach ($rows as $row)
        foreach ($row as $value)
            $values[] = $value;
    $stmt = $pdo->prepare("INSERT INTO tmp_attachments (post_id, file_name) VALUES " . implode(",", array_fill(0, count($rows), "(?,?)")));
    $stmt->execute($values);
}

function insertUploads($pdo, array $files) {
    $stmt = $pdo->prepare("INSERT INTO tmp_uploads (file_name) VALUES " . implode(",", array_fill(0, count($files), "(?)")));
    $stmt->execute($files);
}

使い方

  1. ダウンロードしたzipファイルを解凍します。
  2. サーバーの任意の場所にphpファイルをアップロードします。
  3. phpファイルの中にある設定項目をご利用の環境に合わせて書き換えます。
    • ファイル上で右クリック、編集を選ぶと編集できます。
    • コントロールパネルの情報や、wp-config.php の情報が参考になります。
  4. phpファイル名を推測しにくいものに変更します。
    • ファイル上で右クリック、名前の変更を選ぶと変更できます。
    • 例:maintenance.php → maintenance-t473vj.php
  5. CRONを設定します。
    • CRONを設定したい | さくらのサポート情報
    • 実行コマンドは 「php+スペース+スクリプトの絶対パス」です。
      • 例:php /home/USER_NAME/www/INSTALL_DIRECTORY/maintenance-XXXXXX.php
    • 実行時刻は深夜2:00~5:00位からランダムに選んだ時刻にします。
      • 共用サーバーに負荷をかけないよう、あまり利用していない時間帯に設定します。
      • 3:00ちょうどは皆が利用するため避けた方が良いようです。(サポート情報