筆者は攻略サイト『ポケチャン攻略ラボ』の運営者です。 この記事では、サイト(Astro の静的サイト)で「どのページを検索エンジンに出すか」をどう決め、その判断を
noindex・サイトマップ・OG 画像・IndexNow 送信にどう揃えているかを解説します。コードはリポジトリから要点だけを抜き出して短くしたものです。
※ポケチャン攻略ラボは非公式のファンサイトです。任天堂・クリーチャーズ・ゲームフリーク・株式会社ポケモンとは関係ありません。
はじめに
ゲームの攻略サイトは、記事のほかに図鑑系のページ(ポケモン・技・特性・持ち物)を自動生成すると、ページ数が一気に増えます。このサイトでも 2026年11月5日時点のビルドで HTML ページが 1,392 件ありますが、そのうち 717 件は noindex にしていて、検索に出すのは 675 件です。
自動生成ページの多くは「一覧表と同じ情報しかない薄いページ」になりがちです。そういうページは利用者が内部リンクから開く分には便利でも、検索結果に出す意味は薄いので、次の 4 つを 同じ判断 に揃える必要があります。
- ページの
<meta name="robots" content="noindex,follow"> - サイトマップに載せるかどうか(と
<lastmod>) - ページ別 OG 画像を作るかどうか
- 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 か所に書いている部分は、ビルド結果の件数で突き合わせる
サイト: ポケチャン攻略ラボ