データベース型サイトを運営していると、「インデックスされているページ」と「されていないページ」を正確に把握したい場面は少なくありません。特に数万〜数十万ページ規模のサイトでは、インデックス状況を継続的に確認することが、SEO施策の成果を左右する重要なポイントになります。

しかし、Google Search Console(GSC)だけでは、未登録ページをすべて一覧で確認することはできません。「ページ」レポートではインデックス状況の集計は確認できるものの、未登録ページとして表示されるURLはあくまでサンプルです。そのため、「どのページがインデックスされていないのか」「改善施策を実施したページが本当に登録されたのか」をページ単位で追いかけたい場合には、GSCだけでは限界があります。

私自身、データベース型サイトの開発・SEOに携わる中で、インデックス状況を継続的に監視したいものの、確認作業に多くの工数がかかるという課題に何度も直面してきました。URL検査ツールで1ページずつ確認する方法では、数百〜数千ページを管理する現場では現実的とは言えません。

そこで本記事では、Google Apps Script(GAS)とSearch Console APIの「URL Inspection API」を組み合わせて、ページ単位でインデックス状況を自動取得・管理する方法をご紹介します。

「インデックスされていないページを一覧で把握したい」「定期的にインデックス状況を監視したい」「大規模サイトでも運用できる仕組みを作りたい」という方は、ぜひ参考にしてみてください。


解決策:URL Inspection APIをGASから叩いて自動チェックする

Search Console APIには urlInspection.index:inspect というエンドポイントがあり、指定したURL1件ごとにインデックス登録状況を取得できます。これをGoogle Apps Scriptから呼び出し、スプレッドシート上でキーワード(URLの元になる情報)を入力するだけで対象URLを自動生成し、インデックス状況を自動で埋めていく仕組みを作りました。

仕組みの全体像

項目 内容
対象API Search Console API / URL Inspection(urlInspection.index:inspect)
認証方式 サービスアカウント(JWT)
必要なGSC権限 制限付きユーザーで可(読み取りのみ)
APIクォータ サイトごとに2,000件/日、600件/分
自主上限 1,900件/日(安全マージンを確保)
実行方式 時間主導型トリガー(5分おき)

スプレッドシートの構成はシンプルです。

  • A列:チェックしたいキーワード(入力)
  • B列:A列を全角小文字化・URLエンコードして自動生成したURL
  • C列:インデックス登録状況(自動出力)
  • F1セル:ベースURL(末尾スラッシュ必須)

A列にキーワードを流し込むだけで、B列にURLが自動生成され、C列にインデックス状況が入っていく形です。キーワードからURLを機械的に組み立てられるデータベース型サイト(一覧ページのURLがキーワードのスラッグと連動しているようなサイト)と相性が良い設計になっています。

実行時間制限への対応

Apps Scriptは1回の実行につき最大6分という制限があります。今回のスクリプトでは1回の実行を最大5分で安全に打ち切り、続きは次回のトリガー実行時に再開する設計にしています。日次の上限(1,900件)に達した場合はトリガーを削除せずに待機し、翌日以降に自動で再開します。全行のC列が埋まった時点でトリガーは自動的に削除されます。

完成版コード(全体)

// ================= 配置設定 =================

// 1. Google Cloudで取得したJSONファイル(credentials.json)の中身をそのままここに貼り付けてください
const CREDENTIALS = {
  "type": "service_account",
  "project_id": "xxxxxxxxxxxxxxxxxxxxxxxx",
  "private_key_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
  "client_email": "xxxxxxxxxxxx@xxxxxxxxxxxxxxxxxxxxxxxx.iam.gserviceaccount.com",
  "client_id": "xxxxxxxxxxxxxxxxxxxxx",
  "auth_uri": "<https://accounts.google.com/o/oauth2/auth>",
  "token_uri": "<https://oauth2.googleapis.com/token>",
  "auth_provider_x509_cert_url": "<https://www.googleapis.com/oauth2/v1/certs>",
  "client_x509_cert_url": "<https://www.googleapis.com/robot/v1/metadata/x509/xxxxxxxxxxxx%40xxxxxxxxxxxxxxxxxxxxxxxx.iam.gserviceaccount.com>",
  "universe_domain": "googleapis.com"
};

// 1日の自主上限(Googleの実上限2,000件に対し、安全マージンを見て1,900に設定)
const DAILY_LIMIT = 1900;

// ============================================
// 注意:SITE_URLはF1セルの値(baseUrl)に統一したため削除済み
// ============================================

// 半角文字列を「小文字化 → 全角化」する関数(スペースも全角化)
function toFullWidthLower(str) {
  const lower = str.toLowerCase();
  // 半角のスペース・英数字・記号(0x20〜0x7E)を全角に変換
  return lower.replace(/[\x20-\x7E]/g, function(ch) {
    if (ch === " ") {
      return "\u3000"; // 半角スペースは全角スペース(U+3000)に個別変換
    }
    return String.fromCharCode(ch.charCodeAt(0) + 0xFEE0); // それ以外は+0xFEE0
  });
}

// JWTトークン(アクセストークン)を生成する関数
function getAccessToken() {
  const header = Utilities.base64EncodeWebSafe(JSON.stringify({ alg: "RS256", typ: "JWT" }));
  const now = Math.floor(Date.now() / 1000);
  const claim = Utilities.base64EncodeWebSafe(JSON.stringify({
    iss: CREDENTIALS.client_email,
    scope: "<https://www.googleapis.com/auth/webmasters.readonly>",
    aud: CREDENTIALS.token_uri,
    exp: now + 3600,
    iat: now
  }));
  const signature_input = header + "." + claim;
  const signature = Utilities.base64EncodeWebSafe(
    Utilities.computeRsaSha256Signature(signature_input, CREDENTIALS.private_key)
  );
  const jwt = signature_input + "." + signature;

  const options = {
    method: "post",
    payload: {
      grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
      assertion: jwt
    },
    muteHttpExceptions: true
  };
  const response = UrlFetchApp.fetch(CREDENTIALS.token_uri, options);
  const data = JSON.parse(response.getContentText());
  return data.access_token;
}

// ボタン・トリガーから実行されるメイン関数
function checkIndexStatus() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) {
    Logger.log("A列にキーワードを入力してください。");
    return;
  }

  const baseUrl = sheet.getRange("F1").getValue();
  if (!baseUrl) {
    Logger.log("F1セルにベースURLを入力してください。");
    return;
  }

  // ===== 日次カウントの管理 =====
  const props = PropertiesService.getScriptProperties();
  // Googleのクォータの実際のリセットタイミング(太平洋時間の日付境界)に合わせて判定
  const today = Utilities.formatDate(new Date(), "America/Los_Angeles", "yyyy-MM-dd");
  const storedDate = props.getProperty('quota_date');
  let dailyCount = parseInt(props.getProperty('quota_count') || '0', 10);

  if (storedDate !== today) {
    dailyCount = 0;
    props.setProperty('quota_date', today);
    props.setProperty('quota_count', '0');
    Logger.log("新しい日になったため、日次カウントをリセットしました。");
  }

  if (dailyCount >= DAILY_LIMIT) {
    Logger.log("本日の上限(" + DAILY_LIMIT + "件)にすでに達しています。翌日のリセット後に自動継続します。");
    return;
  }
  // ===========================

  const startTime = new Date().getTime();
  const MAX_RUNTIME = 5 * 60 * 1000; // 5分で安全に打ち切り

  const keywords = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
  const token = getAccessToken();

  for (let i = 0; i < keywords.length; i++) {
    // 実行時間チェック:上限に近づいたら中断して次回トリガーに委ねる
    if (new Date().getTime() - startTime > MAX_RUNTIME) {
      Logger.log((i) + "件処理後、時間切れのため中断。次回トリガーで再開します。");
      return;
    }

    // 1日の自主上限に達したら、その場で停止(翌日リセット後に自動再開)
    if (dailyCount >= DAILY_LIMIT) {
      Logger.log("本日の上限(" + DAILY_LIMIT + "件)に達したため停止します。翌日自動的に再開します。");
      return;
    }

    const keyword = keywords[i][0];
    if (!keyword) continue;

    // すでにC列に結果が入っている行はスキップ(再開用)
    const currentStatus = sheet.getRange(i + 2, 3).getValue();
    if (currentStatus !== "") continue;

    // 全角小文字化 → URLエンコード → URL組み立て → B列にセット
    const fullWidthLower = toFullWidthLower(String(keyword));
    const encoded = encodeURIComponent(fullWidthLower);
    const url = baseUrl + encoded + "/";
    sheet.getRange(i + 2, 2).setValue(url);

    const apiUrl = "<https://searchconsole.googleapis.com/v1/urlInspection/index:inspect>";
    const payload = {
      inspectionUrl: url,
      siteUrl: baseUrl,
      languageCode: "ja-JP"
    };
    const options = {
      method: "post",
      contentType: "application/json",
      headers: { Authorization: "Bearer " + token },
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    };

    try {
      const response = UrlFetchApp.fetch(apiUrl, options);

      // リクエストを1件消費したのでカウントを増やして保存
      dailyCount++;
      props.setProperty('quota_count', String(dailyCount));

      const resData = JSON.parse(response.getContentText());

      if (resData.error) {
        const errMsg = resData.error.message || "";
        if (errMsg.includes("Quota") || resData.error.status === "RESOURCE_EXHAUSTED") {
          // 実際に上限に達したことが確定したので、以降は今日はもう試さない
          dailyCount = DAILY_LIMIT;
          props.setProperty('quota_count', String(dailyCount));
          sheet.getRange(i + 2, 3).setValue(""); // 未処理のまま(空欄)に戻して次回再開できるようにする
          Logger.log("クォータ上限に到達。本日はここまで。翌日自動的に再開します。");
          return;
        }
        sheet.getRange(i + 2, 3).setValue("エラー: " + errMsg);
      } else {
        const verdict = resData.inspectionResult.indexStatusResult.verdict;
        let displayStatus = "";
        if (verdict === "PASS") displayStatus = "インデックス登録済み";
        else if (verdict === "FAIL") displayStatus = "問題あり";
        else if (verdict === "NEUTRAL") displayStatus = "インデックス未登録(中立)";
        else if (verdict === "PARTIAL") displayStatus = "部分的に問題あり";
        else displayStatus = "その他 (" + verdict + ")";
        sheet.getRange(i + 2, 3).setValue(displayStatus);
      }
    } catch (e) {
      sheet.getRange(i + 2, 3).setValue("通信エラー: " + e.message);
    }

    Utilities.sleep(1500);
  }

  // 全件処理が完了した場合のみここに到達
  Logger.log("インデックスの確認が完了しました!");
  deleteAllTriggers();
}

// ============ トリガー管理 ============

// 5分おきの自動実行トリガーを設定する(メニューから実行)
function setupTrigger() {
  deleteAllTriggers(); // 重複防止のため既存のトリガーを削除してから設定
  ScriptApp.newTrigger('checkIndexStatus')
    .timeBased()
    .everyMinutes(5)
    .create();
  Browser.msgBox("5分おきの自動実行トリガーを設定しました。全件完了まで自動で継続します。");
}

// すべてのトリガーを削除する(メニューから実行)
function deleteAllTriggers() {
  const triggers = ScriptApp.getProjectTriggers();
  triggers.forEach(t => ScriptApp.deleteTrigger(t));
}

// スプレッドシートを開いた時に実行ボタン(メニュー)を作る
function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('インデックス確認')
    .addItem('一括チェックを開始(トリガー設定)', 'setupTrigger')
    .addItem('トリガーを停止', 'deleteAllTriggers')
    .addToUi();
}

導入手順

1. サービスアカウントを作成する

  1. Google Cloud コンソールにログインし、対象プロジェクトを選択
  2. 「IAM と管理」→「サービス アカウント」を開く
  3. 「+ サービス アカウントを作成」をクリックし、名前を入力して作成
  4. 必要に応じてロールを付与し完了

2. JSONキー(認証情報)を取得する

  1. 作成したサービスアカウントのメールアドレスをクリック
  2. 「キー」タブを開き、「キーを追加」→「新しいキーを作成」
  3. キーのタイプで「JSON」を選択して作成
  4. ダウンロードされたJSONファイルの中身をスクリプト内に貼り付ける

3. GSC側でサービスアカウントを登録する

対象プロパティに、サービスアカウントのメールアドレスを制限付きユーザーとして登録します。これを忘れると403エラーになるので要注意です。読み取りのみの用途なので、権限は制限付きユーザーで十分です。

4. スプレッドシートを準備して実行する

  1. A列にキーワードを入力
  2. F1セルにベースURL(末尾スラッシュ付き)を入力
  3. メニュー「インデックス確認」→「一括チェックを開始(トリガー設定)」をクリック
  4. 初回のみGoogleアカウントの権限承認が必要
  5. あとは放置。5分おきに自動実行され、1日の自主上限に達したら翌日まで自動待機、全件完了で自動的にトリガーが停止する

運用時に気をつけるべきポイント

  • サービスアカウントの**private_key**は機密情報。漏洩した場合はGoogle Cloud Consoleから鍵を失効させ、再発行する
  • クォータはサイト(プロパティ)ごとに独立しているため、他サイトを並列で回しても基本的には影響しない
  • ただし同一プロジェクトを複数サイトで使い回す場合、プロジェクト全体の上限(1,000万件/日、15,000件/分)は共有されるため、極端に多いサイト数を並列実行するときは要注意
  • 1日の上限(2,000件)ギリギリまで攻めるのはNG。手動でのGSC操作分もカウントされるため、余裕を持った自主上限(1,900件程度)にしておく
  • トリガーはGoogleのサーバー側で動作するため、PCの電源やブラウザの状態に関係なく、土日を含めて自動実行され続ける
  • エラー行は「行ごと削除(キーワードごと除外)」と「C列だけクリア(その行だけ再チェック対象に)」のどちらでも正しく再処理される。行の位置は判定に影響しない

進捗はApps Scriptエディタ左メニューの「実行数」(時計アイコン)から、各回の実行ログを確認できます。途中で止めたい場合はメニュー「インデックス確認」→「トリガーを停止」で止められます。


既知の制約


まとめ

サーチコンソールのUI単体では「未登録ページの全件把握」ができないという構造的な制約は、大規模サイトのSEO運用では避けて通れません。URL Inspection APIをGASから定期実行する仕組みを作ることで、この制約を回避し、ページ単位でのインデックス状況を継続的にモニタリングできるようになります。

初期設定(サービスアカウントの作成とGSCへの登録)さえ済ませてしまえば、あとはスプレッドシートにキーワードを入力するだけで自動的にチェックが進むため、運用負荷はほとんどかかりません。数百〜数千ページ規模のデータベース型サイトを運用している場合は、ぜひ導入を検討してみてください。


データベース型サイトのSEOでお悩みの方は、LPクラフトにぜひ一度ご相談ください!

LPクラフトは、ユーザーの検索意図に合わせて、独自のアルゴリズムが、膨大なデータから最適な一覧ページを生成。

データベース型サイトのインデックスを最大化し、検索流入を最大化するSEO対策サービスです。

データベース型サイトのSEO対策でお困りなことがありましたら、まずはお気軽にお問い合わせください!

https://lp-craft.biz/simulation