0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Astro製の攻略サイトで「検索に出すページ」を1か所の条件で揃える:noindex判定・サイトマップのlastmod・OG画像・IndexNow送信

0
Posted at

筆者は攻略サイト『ポケチャン攻略ラボ』の運営者です。 この記事では、サイト(Astro の静的サイト)で「どのページを検索エンジンに出すか」をどう決め、その判断を noindex・サイトマップ・OG 画像・IndexNow 送信にどう揃えているかを解説します。コードはリポジトリから要点だけを抜き出して短くしたものです。
※ポケチャン攻略ラボは非公式のファンサイトです。任天堂・クリーチャーズ・ゲームフリーク・株式会社ポケモンとは関係ありません。

はじめに

ゲームの攻略サイトは、記事のほかに図鑑系のページ(ポケモン・技・特性・持ち物)を自動生成すると、ページ数が一気に増えます。このサイトでも 2026年11月5日時点のビルドで HTML ページが 1,392 件ありますが、そのうち 717 件は noindex にしていて、検索に出すのは 675 件です。

自動生成ページの多くは「一覧表と同じ情報しかない薄いページ」になりがちです。そういうページは利用者が内部リンクから開く分には便利でも、検索結果に出す意味は薄いので、次の 4 つを 同じ判断 に揃える必要があります。

  1. ページの <meta name="robots" content="noindex,follow">
  2. サイトマップに載せるかどうか(と <lastmod>)
  3. ページ別 OG 画像を作るかどうか
  4. IndexNow で検索エンジンに通知するかどうか

どれか 1 つでもずれると、「サイトマップには載っているのに noindex」「noindex なのに OG 画像だけ作っている」といった食い違いが起きます。

1. noindex の判定を純粋関数にする

図鑑系ページの方針は「個別ページに一覧表以上の情報があるものだけ index」です。判定は関数にして、ページ側とサイトマップ側の両方から同じものを使います。

// src/data/dex.mjs(抜粋)
// 技: チャンピオンズで変更があった技のみ index
export const moveIndexable = (m) => m.changedInChampions === true;
// 持ち物: 2 ソース照合済みの説明があり、かつ(メガストーン/使用率データに出る)
export const itemIndexable = (i) =>
  i.verified?.description === true && !!i.descriptionJa && (!!i.megaStone || USED_ITEMS.has(i.id));
// 特性: 2 ソース照合済みの説明があり、かつ(新特性/使用率データに出る/持つポケモンが 3 匹以上)
export const abilityIndexable = (a) =>
  a.verified?.description === true && !!a.descriptionJa &&
  (a.newInChampions === true || USED_ABILITIES.has(a.id) || (a.pokemon?.length ?? 0) >= 3);

export const NOINDEX_DB_PATHS = [
  ...ABILITIES.filter((a) => !abilityIndexable(a)).map((a) => `/abilities/${a.id}/`),
  ...MOVES.filter((m) => !moveIndexable(m)).map((m) => `/moves/${m.id}/`),
  ...ITEMS.filter((i) => !itemIndexable(i)).map((i) => `/items/${i.id}/`),
];

USED_ITEMS / USED_ABILITIES は、使用率データ(シングル・ダブル)に一度でも出てくる持ち物・特性の ID の集合です。つまり 使用率データが更新されると、index 対象のページも自動で入れ替わります。

「説明文が 2 つの情報源で一致しているか」(verified.description)を条件に入れているのは、内容に自信のないページを検索に出さないためです。

この方針の結果、11月5日時点のビルドでは次のようになっています。

種類 ページ数 noindex サイトマップに掲載
技 511 477 34
特性 215 93 122
持ち物 166 26 140
タグ 158 117 41

技は「チャンピオンズで変更があった技」だけなので、index は 34 件にとどまります。

2. サイトマップ:@astrojs/sitemap の filter と serialize

サイトマップは @astrojs/sitemap で作り、astro.config.mjs の filter で除外、serialize で <lastmod> を付けます。

// astro.config.mjs(抜粋)
export const NOINDEX = ['/my/', '/tools/usage/', '/tools/party-builder/', ...NOINDEX_DB_PATHS];

sitemap({
  filter: (page) => {
    const p = new URL(page).pathname;
    if (p === '/search/') return false;
    if (p.startsWith('/tags/') && p !== '/tags/')
      return INDEXED_TAGS.has(decodeURIComponent(p.slice(6, -1)));
    return !NOINDEX.includes(p);
  },
  serialize: (item) => ({ ...item, lastmod: LASTMOD.get(new URL(item.url).pathname) ?? LATEST }),
}),

/my/(ブラウザ内に保存するマイページ)や準備中のツールも、ここで一緒に外しています。

lastmod は「ビルド日時」ではなく「内容の更新日」

<lastmod> を毎回ビルド日時にすると、全ページが毎日「更新された」ことになってしまい、値として意味がなくなります。そこで、パスごとに本当の更新日を Map に入れておきます。

  • 記事: frontmatter の updatedDate
  • 図鑑系ページ: データ JSON の各レコードの updatedAt
  • 一覧ページ(/pokemon/ など): データファイルの meta.updatedAt
  • タグページ: そのタグが付いた記事の updatedDate のうち最新のもの
  • それ以外(トップ・カテゴリ・ツール): 記事・ポケモンデータ・技データの更新日のうち最新のもの(LATEST)

記事の frontmatter は astro.config.mjs の実行時点ではまだコンテンツコレクションとして読めないので、ファイルを直接読んで正規表現で取り出しています。

for (const f of walk(ART).filter((f) => /\.mdx?$/.test(f))) {
  const fm = fs.readFileSync(f, 'utf8').match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '';
  if (/^draft:\s*true/m.test(fm)) continue;               // 下書きは載せない
  const d = fm.match(/^updatedDate:\s*['"]?(\d{4}-\d{2}-\d{2})/m)?.[1];
  if (!d) continue;
  const id = path.relative(ART, f).replace(/\.mdx?$/, '').split(path.sep).join('/');
  LASTMOD.set(`/${id}/`, d);
  // タグも集計(後述)
}

タグページは「記事が 2 本以上」で index

記事が 1 本しかないタグページは、その記事へのリンクが 1 つあるだけの薄いページです。そこで、タグページ側では

<BaseLayout ... noindex={items.length < 2}>

とし、サイトマップ側でも上のループでタグごとの記事数を数えて、2 本以上のタグだけを INDEXED_TAGS に入れています。

ここは 同じ条件を 2 か所に書いている ので、ずれる可能性があるところです。ビルド結果で数を突き合わせると、タグページ 158 件のうち noindex が 117 件、サイトマップ掲載が 41 件で、合計が一致しています。サイト全体でも「HTML 1,392 件 − noindex 717 件 = サイトマップ 675 件」で一致しています。

3. 構造化データ(JSON-LD)

各ページの <head> には、レイアウト(BaseLayout.astro)が JSON-LD を出します。

  • 記事ページ: Article(headline・dateModified・datePublished・author/publisher は運営チームの Organization、参考にした情報源を citation に CreativeWork として列挙、about に VideoGame)
  • FAQ がある記事: FAQPage(frontmatter の faq から生成)
  • ホーム以外: BreadcrumbList
// BaseLayout.astro(抜粋)
if (faq.length) ld.push({
  '@context': 'https://schema.org', '@type': 'FAQPage',
  mainEntity: faq.map((f) => ({ '@type': 'Question', name: f.q,
    acceptedAnswer: { '@type': 'Answer', text: f.a } })),
});

datePublished / dateModified も、サイトマップと同じ frontmatter の日付から作っているので、ページ上の表示・JSON-LD・サイトマップの日付が食い違いません。

4. OG 画像は「noindex でないページ」だけ生成

ページごとの OG 画像(1200×630 PNG)は、ビルド後に satori(HTML/CSS 風の要素ツリー → SVG)と @resvg/resvg-js(SVG → PNG)で作っています。

ここでは設定を読み直すのではなく、出力された HTML そのもの を見て判定しています。

// scripts/og/generate.mjs(抜粋)
const html = fs.readFileSync(p, 'utf8');
if (/<meta name="robots" content="noindex/.test(html)) continue;   // noindex は共通画像

noindex のページは共通の /og.png を使うように、レイアウト側の og:image と JSON-LD の image も同じ条件で切り替えています。11月5日時点のビルドでは、生成された OG 画像は 675 枚で、サイトマップの件数と同じです。

画像の生成はワーカースレッドで並列化していて、ワーカー数は os.availableParallelism() と 6 の小さいほうです。

5. IndexNow:サイトマップをそのまま送信リストにする

IndexNow は、URL の追加・更新・削除を検索エンジンに通知する仕組みです(Bing などが参加しています)。仕様は IndexNow のドキュメント にあります。

送信スクリプトは、ビルド済みの dist/sitemap-*.xml から <loc> を抜き出したもの を送信リストにしています。こうすると、noindex のページを誤って通知することがありません(サイトマップの時点で除外されているため)。

// scripts/indexnow.mjs(抜粋)
const keyFile = fs.readdirSync(path.join(root, 'public')).find((f) => /^[0-9a-f]{32}\.txt$/.test(f));
const key = fs.readFileSync(path.join(root, 'public', keyFile), 'utf8').trim();
if (keyFile !== `${key}.txt`) throw new Error('キーファイル名と中身が一致しません');

function fromSitemap() {
  const files = fs.readdirSync(dist).filter((f) => /^sitemap-\d+\.xml$/.test(f));
  return files.flatMap((f) =>
    [...fs.readFileSync(path.join(dist, f), 'utf8').matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]));
}

let urls = explicit.length ? explicit.map((u) => new URL(u, SITE).href) : fromSitemap();
urls = [...new Set(urls)].filter((u) => new URL(u).host === HOST);   // 重複と他ホストを除く

for (let i = 0; i < urls.length; i += 10000) {                        // 1 回の POST は最大 10,000 件
  const res = await fetch('https://api.indexnow.org/indexnow', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json; charset=utf-8' },
    body: JSON.stringify({ host: HOST, key, keyLocation: `${SITE}/${key}.txt`, urlList: urls.slice(i, i + 10000) }),
  });
  console.log(`HTTP ${res.status}`);
  if (res.status >= 400) process.exitCode = 1;
}

ポイントは次のとおりです。

  • キーファイル: キーは public/<キー>.txt に置き、中身もキーそのものにします。ファイル名と中身が違うと送信前にエラーにしています。IndexNow の仕様ではキーは 8〜128 文字ですが、このサイトでは 32 桁の 16 進数にしています。
  • 送信はデプロイ後: 検索エンジンはキーファイルを取りに来て所有確認をするので、キーファイルが公開されてから送ります。仕様上、200 は受理、202 は受理(キーの確認待ち)、403 はキーが無効(キーファイルが見つからない等)です。
  • ふだんは更新したページだけ: 引数に /news/daily-2026-10-30/ のようなパスを渡すと、そのページだけを送ります。全ページ送信はサイトマップの全件(現在 675 件)です。--dry-run で件数だけ確認できます。

なお Google は IndexNow に参加していません。Google にはサイトマップを Search Console に登録しておき、サイトマップの <lastmod> を正しく保つことで対応しています(そのために 2 章の lastmod を内容の更新日にしています)。

おまけ:プレビュー URL を検索に出さない

Cloudflare Pages では、本番ドメインのほかに *.pages.dev のプレビュー URL でも同じ内容が見られます。public/_headers で、pages.dev 側にだけ X-Robots-Tag: noindex を付けています。

https://pokechamp-jp.pages.dev/*
  X-Robots-Tag: noindex

https://:version.pokechamp-jp.pages.dev/*
  X-Robots-Tag: noindex

まとめ

  • 「検索に出すか」の判断は純粋関数で 1 か所に書き、ページの noindex とサイトマップの filter の両方から使う
  • <lastmod> はビルド日時ではなく、frontmatter やデータの更新日から作る
  • OG 画像の生成と IndexNow の送信は、ビルド結果(HTML・サイトマップ)を入力にする ことで、設定とのずれが起きにくくなる
  • 同じ条件を 2 か所に書いている部分は、ビルド結果の件数で突き合わせる

サイト: ポケチャン攻略ラボ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?