プラグインなしで本文中の画像リンク切れをチェックする方法

Cocoonカスタマイズ集
この記事は約19分で読めます。
記事内に広告が含まれています。

はじめに

プラグイン「Broken Image Checker」を使わずに、投稿本文中の<img>タグで指定された画像がメディアフォルダーに存在するかどうかをチェックする機能を追加します。

この記事では、この方法について説明します。

完成イメージ

実装手順

以下のコードをfunctions.phpに追加します。

// ダッシュボードウィジェットを追加
add_action('wp_dashboard_setup', function () {
  wp_add_dashboard_widget(
    'broken-images-checker',
    '投稿内画像リンク切れチェック',
    'render_broken_images_checker'
  );
});


/**
 * ダッシュボードにウィジェットを出力
 *
 * @return void
 */
if (!function_exists('render_broken_images_checker')):
function render_broken_images_checker() {
  ?>
  <div class="broken-images-checker">
    <!-- 説明テキスト -->
    <p class="broken-images-checker__description" style="margin-top: 0;">
      公開済み投稿本文内の画像ファイル存在チェックを行います。<br>
      対象:<code>wp-content/uploads</code>内の画像
    </p>

    <!-- チェック開始ボタン -->
    <p class="broken-images-checker__start">
      <button type="button" class="button button-primary broken-images-checker__button">チェック開始</button>
    </p>

    <!-- 進捗ステータス表示エリア -->
    <div class="broken-images-checker__status" style="margin-top: 10px; font-weight: bold;"></div>

    <!-- 進捗バー -->
    <progress class="broken-images-checker__progress" value="0" max="100" style="width: 100%; height: 18px; margin-top: 8px;"></progress>

    <!-- チェック結果出力エリア -->
    <div class="broken-images-checker__result" style="margin-top: 15px; max-height: 400px; overflow-y: auto;"></div>
  </div>
  <?php
}
endif;


// 管理画面用JS・CSSを追加
add_action('admin_enqueue_scripts', function ($hook_suffix) {
  if ($hook_suffix !== 'index.php') {
    return;
  }

  $config_json = wp_json_encode([
    'ajaxUrl' => admin_url('admin-ajax.php'),
    'nonce' => wp_create_nonce('broken_images_checker_action'),
    'batchSize' => 150,
  ]);

  ob_start();
  ?>
  var brokenImagesChecker = <?= $config_json; ?>;

  jQuery(function ($) {
    // DOM要素を取得
    const $startBtn = $('.broken-images-checker__button');
    const $statusEl = $('.broken-images-checker__status');
    const $progressEl = $('.broken-images-checker__progress');
    const $resultEl = $('.broken-images-checker__result');
 
    // 開始ボタンなしは処理対象外
    if (!$startBtn.length) {
      return;
    }

    // 設定値を取得
    const ajaxUrl = brokenImagesChecker.ajaxUrl;
    const nonce = brokenImagesChecker.nonce;
    const BATCH_SIZE = parseInt(brokenImagesChecker.batchSize, 10);

    $startBtn.on('click', async function () {
      // ボタンを無効化して表示を初期化
      $startBtn.prop('disabled', true);
      $statusEl.empty();
      $progressEl.val(0);
      $resultEl.empty();

      try {
        // チェック対象となる全ての公開済み投稿IDを取得
        const postIds = await fetchAllPostIds();

        // 対象投稿なしは終了
        if (!postIds || postIds.length === 0) {
          $statusEl.text('( 0 / 0 )');
          $startBtn.prop('disabled', false);
          return;
        }

        // 進捗管理用の値を設定
        const total = postIds.length;
        let processed = 0;
        let allBroken = {};

        // 初期進捗状況を設定
        $statusEl.text(`チェック( 0 / ${total} )`);

        // 投稿IDをバッチ単位で処理
        for (let i = 0; i < total; i += BATCH_SIZE) {
          // 現在のバッチを取得
          const chunk = postIds.slice(i, i + BATCH_SIZE);

          // バッチ単位で画像をチェック
          const res = await checkBatch(chunk);

          if (res) {
            // リンク切れ情報を統合
            $.extend(allBroken, res);
          }

          // 処理済み件数を更新
          processed += chunk.length;

          // 処理済み件数を変更
          $statusEl.text(`チェック( ${processed} / ${total} )`);
          $progressEl.val(Math.round((processed / total) * 100));
        }

        // チェック結果を出力
        renderResult($resultEl, allBroken);

      } catch (err) {
        // エラーを表示
        console.error(err);
        $statusEl.text('エラーが発生しました。');
      } finally {
        // 処理終了後にボタンを再有効化
        $startBtn.prop('disabled', false);
      }
    });


    /**
     * チェック対象となる全ての投稿IDを取得するAjax通信
     *
     * @return {Promise<Array>} 投稿IDの配列
     */
    function fetchAllPostIds() {
      return $.ajax({
        url: ajaxUrl,
        type: 'POST',
        dataType: 'json',
        data: {
          action: 'get_all_post_ids_for_check',
          broken_images_checker_nonce: nonce
        }
      }).then(response => {
        if (!response.success) {
          throw new Error(response.data || '投稿ID一覧の取得に失敗しました。');
        }

        return response.data;
      });
    }


    /**
     * バッチ単位で画像のリンク切れをチェックするAjax通信
     *
     * @param {Array} postIds 投稿IDの配列
     * @return {Promise<Object>} リンク切れ結果オブジェクト
     */
    function checkBatch(postIds) {
      return $.ajax({
        url: ajaxUrl,
        type: 'POST',
        dataType: 'json',
        data: {
          action: 'check_broken_images_batch',
          broken_images_checker_nonce: nonce,
          post_ids: postIds
        }
      }).then(response => {
        if (!response.success) {
          throw new Error('バッチチェック処理に失敗しました。');
        }

        return response.data;
      });
    }


    /**
     * チェック結果をHTML文字列として組み立ててコンテナに出力
     *
     * @param {jQuery} $container 結果出力用コンテナ要素
     * @param {Object} brokenPosts リンク切れ情報オブジェクト
     * @return {void}
     */
    function renderResult($container, brokenPosts) {
      const ids = Object.keys(brokenPosts);

      // リンク切れなしは終了
      if (ids.length === 0) {
        const html = `
          <p class="broken-images-checker__message broken-images-checker__message--success">
            <span class="dashicons dashicons-yes-alt"></span>
            <span> リンク切れ画像はありませんでした。</span>
          </p>
        `;

        $container.html(html);
        return;
      }

      // リンク切れありのテンプレートリテラルを設定
      let html = `
        <p class="broken-images-checker__message broken-images-checker__message--error">
          <span class="dashicons dashicons-warning"></span>
          <span> リンク切れ画像が見つかりました(${ids.length}件の投稿)</span>
        </p>
        <ul class="broken-images-checker__posts">
      `;

      // 投稿ごとの結果を設定
      $.each(brokenPosts, function (id, item) {
        html += `
          <li class="broken-images-checker__post">
            <a class="broken-images-checker__post-link" href="${item.edit_url}" target="_blank" rel="noopener noreferrer">
              ${escapeHtml(item.title)} (ID:${id})
            </a>
            <ul class="broken-images-checker__files">
        `;

        // リンク切れ画像を設定
        $.each(item.broken, function (_, path) {
          html += `
            <li class="broken-images-checker__file">
              ${escapeHtml(path)}
            </li>
          `;
        });

        html += `
            </ul>
          </li>
        `;
      });

      html += `</ul>`;

      // HTMLを出力
      $container.html(html);
    }


    /**
     * HTML特殊文字のエスケープ処理
     *
     * @param {string} str エスケープ対象の文字列
     * @return {string} エスケープ後の文字列
     */
    function escapeHtml(str) {
      if (!str) {
        return '';
      }

      return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
    }
  });
  <?php
  wp_add_inline_script('jquery', ob_get_clean());
});


// チェック対象となる投稿ID一覧の取得処理
add_action('wp_ajax_get_all_post_ids_for_check', function () {
  // nonceチェック
  check_ajax_referer('broken_images_checker_action', 'broken_images_checker_nonce');

  // 権限チェック
  if (!current_user_can('manage_options')) {
    wp_send_json_error('権限がありません。');
  }

  // 公開済みの全投稿IDを一括取得
  $post_ids = get_posts([
    'post_type' => 'post',
    'post_status' => 'publish',
    'posts_per_page' => -1,
    'fields' => 'ids',
    'no_found_rows' => true,
  ]);

  // 更新情報をJSON形式で返却
  wp_send_json_success($post_ids);
});


// 投稿本文内画像リンク切れチェック処理
add_action('wp_ajax_check_broken_images_batch', function () {
  // nonceチェック
  check_ajax_referer('broken_images_checker_action', 'broken_images_checker_nonce');

  // 権限チェック
  if (!current_user_can('manage_options')) {
    wp_send_json_error('権限がありません。');
  }

  // POSTデータから対象投稿ID配列を取得
  $post_ids = isset($_POST['post_ids']) ? array_map('intval', $_POST['post_ids']) : [];

  // 配列が空の場合は空配列を返して終了
  if (empty($post_ids)) {
    wp_send_json_success([]);
  }

  // uploadsディレクトリ情報の準備
  $upload_dir = wp_upload_dir();
  $base_url = trailingslashit($upload_dir['baseurl']);
  $base_path = trailingslashit($upload_dir['basedir']);

  // 対象の投稿オブジェクトを一括取得
  $posts = get_posts([
    'post_type' => 'post',
    'post_status' => 'publish',
    'post__in' => $post_ids,
    'posts_per_page' => count($post_ids),
    'no_found_rows' => true,
  ]);

  $broken_posts = [];

  // 各投稿の本文をチェック
  foreach ($posts as $post) {
    // 本文から画像URL群を抽出
    $image_urls = get_image_urls_from_content($post->post_content);
    $broken = [];

    // 各画像URLをファイルパスに変換
    foreach ($image_urls as $image_url) {
      $file_path = convert_upload_url_to_path($image_url, $base_url, $base_path);

      // 実ファイルが存在しない場合、相対パスとして記録
      if ($file_path && !file_exists($file_path)) {
        $broken[] = str_replace($base_path, '', $file_path);
      }
    }

    // リンク切れ画像が1件以上検出された場合、結果配列に追加
    if (!empty($broken)) {
      $broken_posts[$post->ID] = [
        'title' => get_the_title($post->ID),
        'edit_url' => get_edit_post_link($post->ID, ''),
        'broken' => array_values(array_unique($broken)),
      ];
    }
  }

  // 検出された結果を出力
  wp_send_json_success($broken_posts);
});


/**
 * 投稿本文HTMLから全ての画像URLを抽出
 *
 * @param string $content 投稿本文HTML文字列
 * @return array 抽出された一意の画像URL配列
 */
if (!function_exists('get_image_urls_from_content')):
function get_image_urls_from_content($content) {
  if (empty($content)) {
    return [];
  }

  $urls = [];

  libxml_use_internal_errors(true);

  // DOMDocumentオブジェクトの生成とHTML読み込み
  $dom = new DOMDocument();
  $dom->loadHTML(
    '<?xml encoding="utf-8" ?>' . $content,
    LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
  );

  // 内部エラーバッファのクリア
  libxml_clear_errors();

  // imgタグ要素を全て取得してループ処理
  foreach ($dom->getElementsByTagName('img') as $img) {
    // 画像URL属性を取得
    $attributes_to_check = ['src', 'data-src', 'data-original', 'data-lazy-src', 'data-url'];

    foreach ($attributes_to_check as $attribute) {
      $value = $img->getAttribute($attribute);

      if (!empty($value)) {
        $urls[] = $value;
      }
    }

    // srcset属性から画像URLを取得
    $srcset = $img->getAttribute('srcset');

    if (!empty($srcset)) {
      foreach (explode(',', $srcset) as $item) {
        $parts = preg_split('/\s+/', trim($item));

        if (!empty($parts[0])) {
          $urls[] = $parts[0];
        }
      }
    }
  }

  // 重複を削除してインデックスを振り直して返却
  return array_values(array_unique($urls));
}
endif;


/**
 * uploadsディレクトリの画像URLをサーバー上の絶対物理パスへ変換
 *
 * @param string $url 変換対象の画像URL
 * @param string $base_url uploadsのベースURL
 * @param string $base_path uploadsのベース物理パス
 * @return string|false 変換後の絶対パス
 */
if (!function_exists('convert_upload_url_to_path')):
function convert_upload_url_to_path($url, $base_url, $base_path) {
  if (empty($url)) {
    return false;
  }

  // URLの正規化
  $url = html_entity_decode($url, ENT_QUOTES, 'UTF-8');
  $url = preg_replace('#^https?:#', '', $url);
  $base = preg_replace('#^https?:#', '', $base_url);

  // 自サイトのuploadsディレクトリURL以外は処理対象外
  if (strpos($url, $base) !== 0) {
    return false;
  }

  // 相対パスを取得
  $relative = substr($url, strlen($base));
  $relative = rawurldecode(strtok($relative, '?'));

  // ベースパスと結合してフルパスを生成し、スラッシュを統一
  $candidate = $base_path . ltrim($relative, '/');
  $normalized = wp_normalize_path($candidate);
  $base_norm = wp_normalize_path($base_path);

  // uploadsディレクトリ外は処理対象外
  if (strpos($normalized, $base_norm) !== 0) {
    return false;
  }

  return $normalized;
}
endif;

さいごに

今回紹介した方法を使えば、プラグインを使わずに、投稿本文内の画像リンク切れを簡単にチェックできるようになります。
メディア削除や移転に伴う画像の欠損を早期に発見でき、サイトの品質維持にも役立ちますので、ぜひ管理運用に取り入れてみてください。

タイトルとURLをコピーしました