メインコンテンツへスキップ
kt-tech.blog

【SEO】Google Search Consoleの「検出 - インデックス未登録」を18記事分解消した全手順

設定・環境構築12分で読めます

この記事でわかること

  • Notion の page ID がそのまま URL になっていると、クロールさえされなくなるという話
  • Slug 未設定の記事を洗い出して一括で入れる手順
  • カテゴリ / タグ / アーカイブに generateMetadata を入れる必要性
  • Indexing API は一般のブログ記事には効かない(200 が返っても騙されない)

Notion を CMS にした Next.js ブログを運用していること。Google Search Console にサイトを登録済みであること。

概要: Google Search Consoleで36ページが「検出 - インデックス未登録」になっていた問題を調査。Notion page IDがそのままURLになっていたことが主因と判明し、Slug一括設定・metadataの追加・Indexing APIでの登録リクエストで解消した全記録。

はじめに

Search Consoleを見たら、68ページが未登録、25ページのみ登録済みという状態だった。「検出 - インデックス未登録」が36ページもあり、しかもクロールすらされていない(「前回のクロール: 該当なし」)。

URLを確認すると、/blogs/328a0ffb73d1813eacd7f8a0ee759f1a のようなNotion page IDがそのままURLになっている記事ばかりだった。


1. 問題の全体像

Search Consoleのインデックス登録レポート:

指標
インデックス登録済み 25ページ
未登録 68ページ
「検出 - インデックス未登録」 36ページ

URLを見ると、すべて Notion page IDがそのままURL になっていた:

https://example.com/blogs/328a0ffb73d1813eacd7f8a0ee759f1a
https://example.com/blogs/328a0ffb73d181429677ece0e30e193a

Googleはこれらを「意味のないランダム文字列のURL」と判断し、クロール優先度を下げていた。

2. 原因: NotionのSlugプロパティが未設定

Notion DBにはSlugプロパティ(rich_text型)があり、記事のURLスラッグとして使われる:

TypeScript
const slug = richTextToPlain(props.Slug?.rich_text || []);
const id = slug || page.id.replace(/-/g, '');
// Slugがなければpage IDがそのままURLになる

Slugが空 → page.idがサイトマップに載る → Googleが「意味のないURL」と判断 → 「検出 - インデックス未登録」

3. 対策1: Slugの一括設定

3.1 未設定記事の洗い出し

Bash
curl -s -X POST \
  "https://api.notion.com/v1/databases/${DATABASE_ID}/query" \
  -H "Authorization: Bearer ${NOTION_API_KEY}" \
  -H "Notion-Version: 2022-06-28" \
  -H "Content-Type: application/json" \
  -d '{"page_size": 100}' | python3 -c "
import json, sys
data = json.load(sys.stdin)
for page in data['results']:
    props = page['properties']
    title = ''.join(
        t['plain_text']
        for t in props.get('Title',{}).get('title',[])
    )
    slug_arr = props.get('Slug',{}).get('rich_text',[])
    slug = ''.join(t['plain_text'] for t in slug_arr)
    if not slug:
        print(f'{page[\"id\"]} | {title}')
"

結果: 18件のSlug未設定記事が見つかった。

3.2 Slugの命名規則

タイトル Slug
Cloudflare Pages × Next.js のSSR応答を66倍高速化 cloudflare-pages-ssr-66x-speedup
microCMSからNotion APIへCMS完全移行 microcms-to-notion-api-migration
Next.js 13→16メジャーアップグレード nextjs-13-to-16-upgrade

3.3 一括設定

Bash
set_slug() {
  local page_id="$1" slug="$2"
  curl -s -o /dev/null -w "%{http_code}" \
    -X PATCH "https://api.notion.com/v1/pages/${page_id}" \
    -H "Authorization: Bearer ${NOTION_API_KEY}" \
    -H "Notion-Version: 2022-06-28" \
    -H "Content-Type: application/json" \
    -d "{\"properties\":{\"Slug\":{\"rich_text\":[{\"text\":{\"content\":\"${slug}\"}}]}}}"
}

set_slug "[page-id-1]" "notion-api-blog-system-nextjs"
set_slug "[page-id-2]" "cloudflare-pages-ssr-66x-speedup"

18件すべてHTTP 200で設定完了。

4. 対策2: カテゴリ/タグ/アーカイブにmetadata追加

カテゴリ・タグ・アーカイブページにgenerateMetadata()が未設定だった。title/descriptionが親レイアウトの継承のみで、OGPもなかった。

TypeScript
import { Metadata } from 'next';

const siteUrl = process.env.SITE_URL || 'https://example.com';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ categoryId: string; pageId: string }>;
}): Promise<Metadata> {
  const { categoryId, pageId } = await params;
  const category = await getCategoryDetail(
    decodeURIComponent(categoryId)
  ).catch(() => null);
  const name = category?.name
    || decodeURIComponent(categoryId);
  const title = `${name}の記事一覧${
    Number(pageId) > 1 ? ` (${pageId}ページ目)` : ''
  }`;
  const description =
    `${name}に関する技術記事の一覧です。`;
  const url =
    `${siteUrl}/categories/${categoryId}/page/${pageId}`;

  return {
    title,
    description,
    alternates: { canonical: url },
    openGraph: {
      title, description, url, type: 'website',
    },
    twitter: { card: 'summary', title, description },
  };
}

タグ・アーカイブも同様に実装。

5. 対策3: Indexing API でクロールリクエスト(これは効かない)

Slug 設定だけでは Google が新 URL を知るまで時間がかかる。そこで Indexing API で能動的にリクエストしました。

重要

この対策は効果がありません。後から調べて分かりました。Google の Indexing API は JobPosting(求人)と BroadcastEvent(ライブ配信)のページ専用で、通常のブログ記事は対象外です。

たちの悪いことに、対象外の URL を送っても API は HTTP 200 を返します。下の「18 件すべて HTTP 200 で成功」は、受け付けられたことしか意味していません

一般のページで早くクロールさせたいなら、Search Console の「URL 検査」→「インデックス登録をリクエスト」を使います(手動ですがこちらは実際に効きます)。

実際に効いたのは対策 1(Slug 設定)と対策 2(metadata 追加)です。以下の手順は、JobPosting 等を扱うサイトでの参考として残しています。

5.1 認証

GCPサービスアカウントのJWTを生成しOAuth2トークンを取得:

Python
import json, time, base64, urllib.request
import subprocess, tempfile

with open('/tmp/sc-auth.json') as f:
    sa = json.load(f)

header = base64.urlsafe_b64encode(
    json.dumps({"alg":"RS256","typ":"JWT"}).encode()
).rstrip(b'=')
now = int(time.time())
claims = {
    "iss": sa["client_email"],
    "scope":
      "https://www.googleapis.com/auth/indexing",
    "aud": sa["token_uri"],
    "iat": now, "exp": now + 3600
}
payload = base64.urlsafe_b64encode(
    json.dumps(claims).encode()
).rstrip(b'=')

# opensslで署名
with tempfile.NamedTemporaryFile(
    mode='w', suffix='.pem', delete=False
) as kf:
    kf.write(sa["private_key"])
    key_file = kf.name
sign_input = header + b'.' + payload
result = subprocess.run(
    ['openssl','dgst','-sha256','-sign',key_file],
    input=sign_input, capture_output=True
)
signature = base64.urlsafe_b64encode(
    result.stdout
).rstrip(b'=')
jwt_token = (
    header + b'.' + payload + b'.' + signature
).decode()

data = urllib.parse.urlencode({
    'grant_type':
      'urn:ietf:params:oauth:grant-type:jwt-bearer',
    'assertion': jwt_token
}).encode()
req = urllib.request.Request(
    sa["token_uri"], data=data
)
resp = urllib.request.urlopen(req)
access_token = json.loads(
    resp.read()
)['access_token']

5.2 リクエスト送信

Bash
TOKEN=$(cat /tmp/sc-token.txt)
for slug in \
  "notion-api-blog-system-nextjs" \
  "cloudflare-pages-ssr-66x-speedup" \
  "microcms-to-notion-api-migration"; do
  curl -s -X POST \
    "https://indexing.googleapis.com/v3/\
url Notifications:publish" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "Content-Type: application/json" \
    -d "{\"url\":\"https://example.com/blogs/${slug}\",\
\"type\":\"URL_UPDATED\"}"
done

18件すべてHTTP 200で成功。クォータは1日200リクエスト。

6. 再発防止

記事作成テンプレートにSlugを必須項目として追加し、品質チェックリストにも「Slugが設定されているか」を追加した。

Tips

  • Notion page IDがURLになるとGoogleに「低品質URL」と判断される。意味のあるSlugを必ず設定
  • generateMetadata()は各page.tsxで個別に定義が必要。layout.tsxの継承だけでは不十分
  • Indexing APIはURL_UPDATEDURL_DELETEDの2種類。非公開時はURL_DELETEDを送る
  • Indexing APIはクロールの「リクエスト」であり登録を保証するものではない
  • サービスアカウントの鍵ファイルは処理後に必ず削除。gitにコミットしない

参考リンク

まとめ

  • 原因: Notion page IDがそのままURLになり、Googleがクロール優先度を下げていた
  • 対策1: 18記事にSEOフレンドリーなSlugを一括設定
  • 対策2: カテゴリ/タグ/アーカイブにgenerateMetadata()追加
  • 対策3(効果なし): Indexing API でのクロールリクエスト。一般ページは対象外だった
  • 再発防止: Slug 必須ルールを追加
  • 早くクロールさせたいときは、Search Console の URL 検査から手動でリクエストする

更新履歴

  1. 対策3(Indexing API)の評価を訂正。Indexing API は JobPosting / BroadcastEvent 専用で一般のブログ記事には効かず、対象外の URL でも HTTP 200 が返るため「成功」に見えていたことを警告として追加(別記事の SEO 対策とも矛盾していた)。代替として Search Console の URL 検査を提示。まとめと Tips も合わせて修正。コードブロックの言語指定を javascript から text に修正。箸条書きの Tips を callout に統一。「この記事でわかること」「対象読者」「前提条件」を概要プロパティへ移動。

この記事のタグ