Skip to content

Всё для WordPress, веб-разработки — и не только

🗑 Программная очистка медиатеки WordPress: PHP-скрипты для удаления мусорных файлов

🗑 Программная очистка медиатеки WordPress: PHP-скрипты для удаления мусорных файлов

Медиатека WordPress устроена как чердак: удалили пост, картинки остались. Сменили тему, старые размеры изображений лежат мёртвым грузом. Перенесли сайт, половина эскизов бьёт в 404.

Ручная чистка через админку на сайте с парой тысяч файлов, занятие для медитации. Но есть способ быстрее: пять PHP-функций, которые находят и удаляют мусор за один проход. Без плагинов, с понятным кодом и контролем над каждым удалённым файлом.

Перед запуском, полный бэкап. Функции удаляют файлы безвозвратно: ни корзины, ни «undo» у них нет. Сомневаетесь, прогоните на staging-копии.

💡 Быстрый обзор:

  • Удаление неприкреплённых вложений, файлов, оставшихся после удаления постов
  • Зачистка медиафайлов конкретного произвольного типа записи (CPT)
  • Очистка медиатеки от битых ссылок, 404-вложения без файла на сервере
  • Поиск и удаление файлов в wp-content/uploads, не зарегистрированных как вложения WordPress
  • Сценарий для сайтов, где изображения хранятся в произвольных полях (ACF, Meta Box), а не как вложения

О чём важно знать до запуска

Код ниже удаляет файлы физически, с диска и из базы. Три момента, которые сберегут сайт.

Первое: изображения на страницах архивов тегов или в SEO-описаниях часто не привязаны ни к какому посту. Они висят «сиротами», но сайту нужны. Если у вас есть такие, исключите их из области действия функций или доработайте условия.

Второе: WordPress создаёт несколько размеров каждого изображения. Миниатюры наследуют post_parent оригинала, поэтому функция delete_unattached_attachments() их не трогает, она фильтрует строго по post_parent = 0. Проблема возникает, только если сам оригинал потерял привязку к посту.

Третье: если на удаляемый файл есть ссылка в тексте поста, после чистки она станет битой. Перед запуском прогоните сайт через Screaming Frog или аналог и составьте карту ссылок.

1. Удаление неприкреплённых вложений

Самый частый сценарий: удалили пост, вложения остались в базе с post_type = 'attachment' и post_parent = 0. Занимают место на диске и в бэкапах.

Функция ниже находит все такие записи и удаляет их. Разместите в functions.php дочерней темы или через плагин для сниппетов вроде WPCode. Сама по себе она не запустится, это определение, которому нужен вызов.

1function delete_unattached_attachments() {
2 $attachments = get_posts( array(
3 'post_type' => 'attachment',
4 'numberposts' => -1,
5 'fields' => 'ids',
6 'post_parent' => 0,
7 ) );
8
9 if ( $attachments ) {
10 foreach ( $attachments as $attachment_id ) {
11 $attachment_path = get_attached_file( $attachment_id );
12 wp_delete_attachment( $attachment_id, true );
13 unlink( $attachment_path );
14 }
15 }
16}

get_posts() выбирает все вложения без родительской записи. wp_delete_attachment() с параметром true стирает и запись в базе, и файл с мини-копиями. Дополнительный unlink(), страховка: если файл по какой-то причине остался на диске, он удаляется принудительно.

Обратите внимание: featured images (изображения записи) тоже имеют post_parent = 0 в некоторых конфигурациях. Перед боевым запуском замените wp_delete_attachment на echo $attachment_id . '<br>', увидите список ID, которые попадут под удаление. Убедились, что всё верно, возвращайте боевой вариант.

После однократного запуска функцию из functions.php уберите. Незачем держать её на каждом init.

2. Удаление вложений конкретного CPT

Бывший интернет-магазин на WooCommerce, старый раздел портфолио, удалённый custom post type, все их картинки продолжают лежать на сервере. Функция ниже вычищает вложения, прикреплённые к записям указанного типа.

1function delete_cpt_attachments( $cpt = 'card' ) {
2 $attachments = get_posts( array(
3 'post_type' => 'attachment',
4 'numberposts' => -1,
5 ) );
6
7 if ( $attachments ) {
8 foreach ( $attachments as $attachment ) {
9 $parent_id = $attachment->post_parent;
10
11 if ( $cpt === get_post_type( $parent_id ) ) {
12 $attachment_path = get_attached_file( $attachment->ID );
13 wp_delete_attachment( $attachment->ID, true );
14 unlink( $attachment_path );
15 }
16 }
17 }
18}

Замените 'card' на slug вашего CPT. Для WooCommerce-товаров, 'product'. Если CPT уже удалён, get_post_type() вернёт false, вложения такого типа задеты не будут. Для удалённых CPT логику нужно скорректировать: проверять не тип родителя, а принадлежность к таксономии или мета-полю.

На больших базах будьте аккуратны: 'numberposts' => -1 без 'fields' => 'ids' загружает полные объекты WP_Post. На 10 000+ вложений это может упереться в memory_limit. Для production-объёмов добавьте 'fields' => 'ids' и получайте только ID, get_post_type() отработает и по ID родителя.

3. Очистка медиатеки от 404-вложений

Битые эскизы в медиатеке, симптом того, что файл на диске удалён (вручную, крахом хостинга или кривым плагином), а запись в базе осталась. WordPress показывает серый прямоугольник, но при клике, 404.

Функция опрашивает каждый URL вложения и удаляет те, что отдают 404.

1function delete_404_attachments() {
2 $attachments = get_posts( array(
3 'post_type' => 'attachment',
4 'numberposts' => -1,
5 'fields' => 'ids',
6 ) );
7
8 if ( $attachments ) {
9 foreach ( $attachments as $attachment_id ) {
10 $file_url = wp_get_attachment_url( $attachment_id );
11 $file_headers = @get_headers( $file_url );
12
13 if ( $file_headers && strpos( $file_headers[0], '404' ) !== false ) {
14 wp_delete_attachment( $attachment_id, true );
15 }
16 }
17 }
18}

Важно: функция ресурсоёмкая. Каждый вызов get_headers(), это HTTP-запрос к вашему же серверу. На тысяче вложений вы делаете тысячу HTTP-запросов за один проход. Результат: долго, нагрузка на сервер, некоторые хостинги прибивают процесс по таймауту.

Для больших медиатек разбейте на порции через 'offset' и 'numberposts' либо запускайте через WP-CLI с лимитом на пакет. Если сайт за CDN или прокси, замените проверку на wp_remote_head() с таймаутом, get_headers() не всегда корректно обрабатывает редиректы и не поддерживает аутентификацию.

4. Обратная проверка: файлы в uploads без записи в базе

Предыдущие три функции чистят базу, удаляют записи вложений. Но в wp-content/uploads могут лежать файлы, которые вообще не зарегистрированы как вложения: загружены через FTP, оставлены плагинами, сгенерированы кэшем.

Эта функция идёт обратным путём: не от базы к файлам, а от файлов к базе. Рекурсивно сканирует wp-content/uploads и для каждого файла проверяет через attachment_url_to_postid(), является ли он вложением. Не является, удаляет.

1function clean_uploads_from_nonattachments() {
2 $uploads_dir = wp_upload_dir();
3 $search = $uploads_dir['basedir'];
4 $replace = $uploads_dir['baseurl'];
5 $root = $uploads_dir['basedir'];
6
7 $iter = new RecursiveIteratorIterator(
8 new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ),
9 RecursiveIteratorIterator::SELF_FIRST,
10 RecursiveIteratorIterator::CATCH_GET_CHILD
11 );
12
13 foreach ( $iter as $fileinfo ) {
14 if ( $fileinfo->isFile() ) {
15 $image_path = $fileinfo->getPathname();
16 $image_url = str_replace( $search, $replace, $image_path );
17 $attachment_id = attachment_url_to_postid( $image_url );
18
19 if ( ! $attachment_id ) {
20 unlink( $image_path );
21 }
22 }
23 }
24}

На тестовом сервере с 1 ГБ загрузок функция отработала за 15 секунд и освободила 700 МБ, осталось 300 МБ реально используемых файлов. Для папок крупнее 5 ГБ разбейте сканирование по годам: замените $root на $uploads_dir['basedir'] . '/2025/', затем '/2024/' и так далее.

Сначала запустите вариант без удаления, замените unlink( $image_path ) на echo $image_path . PHP_EOL. Увидите полный список файлов, которые функция считает мусором. Проверили глазами, возвращайте unlink().

5. Сценарий с произвольными полями: когда изображения не являются вложениями

Самый сложный случай: сайт, где картинки хранятся не как вложения WordPress, а как URL в произвольных полях (ACF, Meta Box, самописные поля темы). Типичный пример, книжный интернет-магазин: обложка книги в поле bookcover, фото автора в bookauthor_picture, изображение списка в book_list_pictrue.

В такой архитектуре у всех файлов в uploads attachment_url_to_postid() вернёт 0. Предыдущая функция удалит вообще всё, включая реально используемые изображения. Нужен другой подход.

5.1. Составляем белый список

Сначала собираем URL всех изображений из всех нужных произвольных полей. В примере ниже, три CPT и три поля:

1$all_good_pictures = array();
2
3// Обложки книг (CPT 'post', поле 'bookcover')
4$posts = get_posts( array(
5 'post_type' => 'post',
6 'posts_per_page' => -1,
7 'post_status' => 'any',
8 'fields' => 'ids',
9) );
10foreach ( $posts as $post_id ) {
11 $cover = get_field( 'bookcover', $post_id );
12 if ( $cover ) {
13 $all_good_pictures[] = $cover;
14 }
15}
16
17// Изображения списков (CPT 'book_list', поле 'book_list_pictrue')
18$lists = get_posts( array(
19 'post_type' => 'book_list',
20 'posts_per_page' => -1,
21 'post_status' => 'any',
22 'fields' => 'ids',
23) );
24foreach ( $lists as $list_id ) {
25 $pic = get_field( 'book_list_pictrue', $list_id );
26 if ( $pic ) {
27 $all_good_pictures[] = $pic;
28 }
29}
30
31// Фото авторов (CPT 'bookauthor', поле 'bookauthor_picture')
32$authors = get_posts( array(
33 'post_type' => 'bookauthor',
34 'posts_per_page' => -1,
35 'post_status' => 'any',
36 'fields' => 'ids',
37) );
38foreach ( $authors as $author_id ) {
39 $pic = get_field( 'bookauthor_picture', $author_id );
40 if ( $pic ) {
41 $all_good_pictures[] = $pic;
42 }
43}
44
45$all_good_pictures = array_filter( $all_good_pictures );

На реальном проекте, книжном интернет-магазине, этот подход позволил вычислить большинство мусорных файлов и освободить значительную часть дискового пространства.

5.2. Удаляем всё, чего нет в белом списке

Теперь проходим по wp-content/uploads и удаляем каждый файл, которого нет в $all_good_pictures:

1$uploads_dir = wp_upload_dir();
2$search = $uploads_dir['basedir'];
3$replace = $uploads_dir['baseurl'];
4$root = $uploads_dir['basedir'];
5
6$iter = new RecursiveIteratorIterator(
7 new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ),
8 RecursiveIteratorIterator::SELF_FIRST,
9 RecursiveIteratorIterator::CATCH_GET_CHILD
10);
11
12foreach ( $iter as $fileinfo ) {
13 if ( $fileinfo->isFile() ) {
14 $image_path = $fileinfo->getPathname();
15 $image_url = str_replace( $search, $replace, $image_path );
16
17 if ( ! in_array( $image_url, $all_good_pictures, true ) ) {
18 unlink( $image_path );
19 }
20 }
21}

Метод in_array() со строгим сравнением на массиве из 10 000+ элементов, не самый быстрый. Для production-объёмов замените обычный массив на ассоциативный: $all_good_pictures = array_fill_keys( $all_good_pictures, true ) и проверяйте через isset(). Разница на 40 000 элементов, с десятка секунд до долей секунды.

Как запустить эти функции

Все сниппеты выше, это определения функций. Они ничего не делают, пока вы их не вызовете. Три безопасных способа запуска:

Способ

Когда использовать

Откат

WP-CLI wp eval-file

Одноразовая чистка, доступ к консоли

Нет - только бэкап

Хук admin_init + параметр URL

Нет консоли, нужно запустить из админки

Нет - только бэкап

Плагин для сниппетов (WPCode)

Удобное хранение и включение/выключение

Отключили сниппет - функция не активна

Пример разового запуска через админку:

1add_action( 'admin_init', 'run_cleanup_once' );
2function run_cleanup_once() {
3 if ( isset( $_GET['cleanup'] ) && 'confirmed' === $_GET['cleanup'] ) {
4 delete_unattached_attachments();
5 }
6}

Переходите по https://вашсайт.com/wp-admin/?cleanup=confirmed, функция отрабатывает однократно. После выполнения сниппет удалите.

Для WP-CLI, рекомендуемый способ для production, сохраните код функции во временный файл и выполните:

1wp eval-file cleanup.php

Перед очисткой полезно увидеть процесс визуально. В видео ниже, пошаговый разбор чистки медиатеки WordPress с ручными и автоматическими методами.

⁉️🤔 Частые вопросы

Можно ли восстановить файлы после удаления?

Нет. Функции используют wp_delete_attachment() с true и unlink(), файлы удаляются физически, минуя корзину. Единственная страховка: полный бэкап файлов и базы данных перед запуском. Проверьте, есть ли у хостинг-провайдера автоматические ежедневные бэкапы, у Kinsta, WP Engine и SiteGround они включены по умолчанию. Это даёт дополнительную точку восстановления помимо вашего ручного бэкапа.

Почему функция не сработала, файлы остались на месте?

Самая частая причина: вы добавили определение функции в functions.php, но не вызвали её. Блок function ... { }, это лишь инструкция. Чтобы код выполнился, функцию нужно привязать к хуку через add_action() или запустить вручную через WP-CLI. В разделе «Как запустить», три способа, выберите под свой уровень доступа к серверу.

Удалит ли функция неприкреплённые вложения миниатюры используемых изображений?

Нет. Миниатюры (thumbnail, medium, large) имеют тот же post_parent, что и оригинальное вложение. Функция фильтрует строго по post_parent = 0, только записи, у которых вообще нет родительского поста. Размеры оригиналов наследуют post_parent и в выборку не попадают. Проблема возникает, только если сам оригинал потерял привязку, тогда функция удалит его вместе со всеми размерами.

Что делать, если часть изображений в произвольных полях, а часть, как обычные вложения?

Комбинируйте подходы из разделов 4 и 5. Сначала соберите белый список из произвольных полей (раздел 5.1). Затем при сканировании uploads (раздел 4) для каждого файла проверяйте оба условия: является ли файл вложением WordPress через attachment_url_to_postid() И присутствует ли он в белом списке. Файл удаляется, только если не выполняется ни одно из условий: if ( ! $attachment_id && ! isset( $good_pictures[ $image_url ] ) ) { unlink( $image_path ); }.

Насколько это безопасно для WooCommerce-сайта?

WooCommerce хранит изображения товаров как стандартные вложения WordPress, они прикреплены к посту типа product. Функция удаления неприкреплённых вложений (раздел 1) их не тронет. А вот функция для конкретного CPT (раздел 2), да, если передать 'product'. Для WooCommerce безопаснее всего использовать метод из раздела 5 (белый список): он оперирует тем, что реально используется, а не тем, что прикреплено. Перед запуском сделайте выгрузку ID всех продуктовых вложений для сверки.

Что ставить на своём проекте: итоговый расклад

Выбор метода зависит от архитектуры сайта:

  • Стандартный блог или новостной сайт, хватит функций из раздела 1 (неприкреплённые вложения) и раздела 3 (404-вложения). Запустите раз в полгода, медиатека будет в порядке.
  • Сайт со старыми CPT (портфолио, каталог, доска объявлений), добавьте раздел 2. Точечно вычистите мусор от удалённых или заброшенных типов записей.
  • Проект на ACF/Meta Box с кастомными полями для изображений, ваш вариант: раздел 5. Соберите белый список, остальное удалите. Один раз настроить, потом только повторять по необходимости.
  • Всё вместе и непонятно что, начните с рекурсивного сканирования uploads (раздел 4). Посмотрите, сколько мусора лежит на диске. Затем точечно примените разделы 1-3 и 5 по ситуации.

Ни один из этих скриптов не заменит регулярную гигиену сайта. Но один раз написав нужную функцию и сохранив её в документации проекта, вы сэкономите часы ручной работы при следующей ревизии.

И да, бэкап вы уже сделали.

Очистка медиатеки WordPress от мусорных файлов