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 від сміттєвих файлів