IndexNowをCloudflare Pagesのデプロイに組み込み、Bingに新しいURLを知らせる実装。鍵の置き方、送るURLの選び方、送るタイミング、応答コードの意味と、このブログでの送信記録
IndexNowを
この/<鍵>.txtと
この
IndexNowは、URLの追加・更新・削除をサイトの側から検索エンジンへ伝える仕組み
IndexNowを
仕組みの
- 鍵で
サイトの 送信には持ち主である ことを 示す。 鍵を 付け、 同じ鍵を サイトに 置いた ファイルで 公開します。 検索エンジンは その ファイルを 読んで、 送ってきたのが サイトの 持ち主かを 確かめます。 - 1か所に
送れば、 IndexNowの参加している 検索エンジンに 共有される。 ドキュメントでは、 参加している 検索エンジンの どれか 1つに 送れば、 ほかの 参加している エンジンにも 共有されると 説明されています。 この ブログでは、 api.indexnow.orgに送っています。 - 送っても、
載る IndexNowのことは 約束されない。 FAQでも Bingの 案内でも、 送った URLが すぐに クロールや インデックスの 対象に なるとは 限らないと 書かれています。 クロールするか どうかは、 検索エンジンが 決めます。
IndexNowの
鍵は、サイトのルートに「鍵.txt」として置く
鍵の<鍵>.txtと
このpublic/フォルダに<鍵>.txtとpublic/のtext/plain; charset=utf-8でした。
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" https://www.yamayamabloglink.com/<鍵>.txt
200 text/plain; charset=utf-8鍵はkeyLocationで
この
このブログでの送信記録:移行直後は全URL、その後は1件ずつ
この
送信のdocs/indexnow-log.csv)は、
| 日時 |
件数 | 送ったもの | 応答 |
|---|---|---|---|
| 2026-10-02 夕方 | 98 | サイトマップの |
記録なし |
| 2026-10-02 23時ごろ | 記録なし | サイトマップの |
記録なし |
| 2026-10-03 朝 | 169 | サイトマップの |
記録なし |
| 2026-10-04 14時 | 1 | 公開した |
記録なし |
送信に
#!/usr/bin/env node
// IndexNow:指定した URL(または sitemap の全URL)を Bing など参加している検索エンジンへ知らせる。
// 使い方: node scripts/indexnow.mjs https://www.yamayamabloglink.com/xxx/ ... (引数なしなら sitemap の全URL)
// 鍵は public/<32桁>.txt(IndexNow の仕様で公開してよい値)
// 実行ごとに docs/indexnow-log.csv へ1行追記する(時刻,件数,応答,送り方,URL)。記録しないときは INDEXNOW_LOG=0
import { appendFileSync, existsSync, readdirSync, readFileSync } from 'node:fs';
const HOST = 'www.yamayamabloglink.com';
const keyFile = readdirSync('public').find((f) => /^[0-9a-f]{32}\.txt$/.test(f));
const key = readFileSync(`public/${keyFile}`, 'utf8').trim();
let urls = process.argv.slice(2);
const source = urls.length ? 'args' : 'sitemap';
if (!urls.length) {
const sm = await (await fetch(`https://${HOST}/sitemap-0.xml`)).text();
urls = [...sm.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
}
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: `https://${HOST}/${keyFile}`, urlList: urls }),
});
console.log(`IndexNow: ${urls.length}件 → ${res.status}`);
if (process.env.INDEXNOW_LOG !== '0') {
const LOG = 'docs/indexnow-log.csv';
try {
if (!existsSync(LOG)) appendFileSync(LOG, 'timestamp,count,status,source,urls\n');
// sitemap 全件のときは URL を並べず、件数だけ(全件=その時点の sitemap-0.xml)
const list = source === 'args' ? urls.map((u) => u.replace(`https://${HOST}`, '')).join(' ') : '';
appendFileSync(LOG, `${new Date().toISOString()},${urls.length},${res.status},${source},"${list.replaceAll('"', '""')}"\n`);
} catch (e) {
console.warn(`記録できませんでした: ${e.message}`);
}
}振り返ると、
WordPressから
送るURLは、前回のサイトマップとの差分で選ぶ
送るlastmodが
2026年10月4日にlastmodがlastmodを
- 増えた:今の
サイトマップに あり、 前回には 無い URL。 一覧の ページも 含む - 変わった:両方に
あり、 lastmodが変わった URL。 lastmodの無い 一覧の ページは 対象に ならない - 消えた:前回に
あり、 今の サイトマップには 無い URL
使い
送るのは、デプロイが終わり、公開側に反映されたのを確かめてから
送信は、
そこでHEADで
消えた
Cloudflareには、
差分だけを送るスクリプト
次のDRY_RUN=1を
#!/usr/bin/env node
// デプロイが終わったあとに実行し、前回から「増えた・変わった・消えた」URLだけを IndexNow で送る。
// node indexnow-deploy.mjs … 差分を送る
// node indexnow-deploy.mjs --init … 今の状態を記録するだけ(使い始めの1回。過去のURLは送らない)
// DRY_RUN=1 node indexnow-deploy.mjs … 送らずに、送る中身だけを表示する
import { existsSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from 'node:fs';
const HOST = process.env.INDEXNOW_HOST ?? 'www.yamayamabloglink.com';
const PUBLIC_DIR = process.env.PUBLIC_DIR ?? 'public'; // 鍵のファイルがあるフォルダ
const SNAPSHOT = process.env.SNAPSHOT ?? '.indexnow-snapshot.json'; // 前回送ったときのサイトマップ
const LOG = process.env.INDEXNOW_LOG_FILE ?? 'indexnow-log.csv';
const DRY_RUN = process.env.DRY_RUN === '1';
const INIT = process.argv.includes('--init');
// 1. 鍵:public/<鍵>.txt を探す。ファイル名と中身が同じであることも確かめる
const keyFile = readdirSync(PUBLIC_DIR).find((f) => /^[0-9a-f]{32}\.txt$/.test(f));
if (!keyFile) throw new Error(`${PUBLIC_DIR} に鍵のファイルがありません`);
const key = readFileSync(`${PUBLIC_DIR}/${keyFile}`, 'utf8').trim();
if (`${key}.txt` !== keyFile) throw new Error('鍵のファイル名と中身が一致しません');
// 2. 公開中のサイトマップを読む(loc と lastmod)
async function readSitemap() {
const xml = await (await fetch(`https://${HOST}/sitemap-0.xml`)).text();
const map = {};
for (const [, body] of xml.matchAll(/<url>(.*?)<\/url>/gs)) {
const loc = body.match(/<loc>([^<]+)<\/loc>/)?.[1];
const lastmod = body.match(/<lastmod>([^<]+)<\/lastmod>/)?.[1] ?? null;
if (loc) map[loc] = lastmod;
}
return map;
}
// 3. 公開側が新しい状態になっているかを確かめる(送ったURLを開いたら古い・無い、を防ぐ)
async function waitUntil(url, ok, { tries = 10, intervalMs = 30_000 } = {}) {
for (let i = 1; i <= tries; i++) {
const res = await fetch(url, { method: 'HEAD', redirect: 'manual' });
if (ok(res.status)) return res.status;
if (i < tries) await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`${url} がまだ反映されていません`);
}
const now = await readSitemap();
const prev = existsSync(SNAPSHOT) ? JSON.parse(readFileSync(SNAPSHOT, 'utf8')) : null;
if (INIT || !prev) {
writeFileSync(SNAPSHOT, JSON.stringify(now, null, 1));
console.log(`今の ${Object.keys(now).length} 件を記録しました(送信はしていません)`);
process.exit(0);
}
const added = Object.keys(now).filter((u) => !(u in prev));
const changed = Object.keys(now).filter((u) => u in prev && now[u] && now[u] !== prev[u]);
const removed = Object.keys(prev).filter((u) => !(u in now));
console.log(`増えた ${added.length} / 変わった ${changed.length} / 消えた ${removed.length}`);
// 増えた・変わったURLは 200、消えたURLは 404・410・301・308 になっていることを確かめる
for (const u of [...added, ...changed]) console.log(` ${await waitUntil(u, (s) => s === 200)} ${u}`);
for (const u of removed) console.log(` ${await waitUntil(u, (s) => [404, 410, 301, 308].includes(s))} ${u}`);
const urlList = [...added, ...changed, ...removed];
if (!urlList.length) {
console.log('送るURLはありません');
process.exit(0);
}
if (urlList.length > 10_000) throw new Error('1回に送れるのは 10,000 件までです');
const payload = { host: HOST, key, keyLocation: `https://${HOST}/${keyFile}`, urlList };
if (DRY_RUN) {
// 鍵は公開してよい値だが、記事やログに貼るときのために先頭だけ見せる
const shown = { ...payload, key: `${key.slice(0, 4)}…`, keyLocation: payload.keyLocation.replace(key, `${key.slice(0, 4)}…`) };
console.log('DRY_RUN:送らずに終わります。送る中身:');
console.log(JSON.stringify(shown, null, 2));
process.exit(0);
}
const res = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload),
});
const line = `${new Date().toISOString()},${urlList.length},${res.status},diff,"${urlList.map((u) => u.replace(`https://${HOST}`, '')).join(' ')}"\n`;
appendFileSync(LOG, line);
if (res.status === 200 || res.status === 202) {
writeFileSync(SNAPSHOT, JSON.stringify(now, null, 1)); // 受け付けられたときだけ「前回」を更新する
console.log(`IndexNow: ${urlList.length} 件 → ${res.status}`);
} else if (res.status === 429) {
console.warn('429:送りすぎ。前回の記録は更新せず、次の実行でもう一度送ります');
} else {
// 400・403・422 は、送り方か鍵の置き方が間違っている。CI を止めて直す
console.error(`IndexNow: ${res.status} ${await res.text()}`);
process.exit(1);
}前回の.indexnow-snapshot.json)は、
送らずに中身を確かめた結果
2026年10月4日に、DRY_RUN=1でpublic/に
まず、
$ DRY_RUN=1 node indexnow-deploy.mjs --init
今の 242 件を記録しました(送信はしていません)
$ DRY_RUN=1 node indexnow-deploy.mjs
増えた 0 / 変わった 0 / 消えた 0
送るURLはありません次に、lastmodを
// テスト用:前回の記録を書き換えて「増えた・変わった・消えた」を1件ずつ作る
import { readFileSync, writeFileSync } from 'node:fs';
const F = '.indexnow-snapshot.json';
const s = JSON.parse(readFileSync(F, 'utf8'));
const H = 'https://www.yamayamabloglink.com';
delete s[`${H}/wedding-venue-web-staff-weekly-checklist/`]; // → 増えた
s[`${H}/ai-crawler-robots-txt-cloudflare/`] = '2026-10-01T00:00:00.000Z'; // → 変わった
s[`${H}/indexnow-dry-run-removed-test/`] = '2026-10-01T00:00:00.000Z'; // → 消えた(実在しないURL)
writeFileSync(F, JSON.stringify(s, null, 1));$ DRY_RUN=1 node indexnow-deploy.mjs
増えた 1 / 変わった 1 / 消えた 1
200 https://www.yamayamabloglink.com/wedding-venue-web-staff-weekly-checklist/
200 https://www.yamayamabloglink.com/ai-crawler-robots-txt-cloudflare/
404 https://www.yamayamabloglink.com/indexnow-dry-run-removed-test/
DRY_RUN:送らずに終わります。送る中身:
{
"host": "www.yamayamabloglink.com",
"key": "49d7…",
"keyLocation": "https://www.yamayamabloglink.com/49d7….txt",
"urlList": [
"https://www.yamayamabloglink.com/wedding-venue-web-staff-weekly-checklist/",
"https://www.yamayamabloglink.com/ai-crawler-robots-txt-cloudflare/",
"https://www.yamayamabloglink.com/indexnow-dry-run-removed-test/"
]
}増えた・
応答コードの意味と、受け取ったあとの扱い
IndexNowの
| 応答 | 公式の |
この |
|---|---|---|
| 200 | 受け付けた | 前回の |
| 202 | 受け取った。 |
前回の |
| 400 | 形式が |
止める。Content-Typeを見直す |
| 403 | 鍵が |
止める。 |
| 422 | URLが |
止める。hostとurlListのwwwの |
| 429 | 送りすぎ |
記録を |
200に
1回に
Bingに届いたかは、Bing Webmaster Toolsで確かめる
送った
この
- Bing Webmaster Toolsに
サイトを 追加し、 持ち主の 確認を 済ませる。 - 送った
URLを、 Bing Webmaster Toolsの URLの 検査で 1件ずつ 調べ、 Bingが その ページを 知っているか、 インデックスに 載っているかを 見る。 docs/indexnow-log.csvの送った 日時と 並べて、 「送った 日」 「Bingが クロールした 日」 「載った 日」を 記録する。
site:での
会社の
動作確認した環境
確認日は
| 項目 | バージョン・内容 |
|---|---|
| OS | macOS 26 |
| Node.js | 22.23.2fetchを |
| 相手に |
https://www.yamayamabloglink.com sitemap-0.xmlとHEAD) |
| 確かめた |
--initでの |
DRY_RUNを
参照した公式ドキュメント(確認日:2026年10月4日)
- IndexNow「Documentation」:鍵の
長さと 使える 文字、 ルートの 鍵ファイルと keyLocation、まとめて 送る ときの JSON、 1回10,000件まで、 応答コードの 一覧、 200は 受け取っただけである こと - IndexNow「FAQ」:送っても
載るとは 限らない こと、 202の 意味、 全URLを 一度に 送る ものではない こと (移転の 直後は 可)、 クロールの 割り 当てに 数えられる こと、 送り直しは 間を あける こと (5分・10分の 両方の 記述あり)、 サイトマップと 併用する こと - Bing Webmaster Tools「How to add IndexNow to your website」:
4つの 手順 (鍵を 作る ・置く ・送る ・Bing Webmaster Toolsで 確かめる)、 応答コード、 使い 始める 前に 変わった URLは 送らない こと、 Cloudflareの Crawler Hints - Cloudflare Docs「Crawler Hints」:キャッシュの
状態から 変更を 推測して IndexNowで 知らせる 機能、 使える プラン
よくある質問
IndexNowの鍵のファイルは、どこに置けばよいですか?
サイトの
IndexNowでは、サイトマップの全URLを毎回送ってもよいですか?
IndexNowの
IndexNowで200が返ってきたら、Bingに載ったということですか?
違います。
IndexNowの送信は、Cloudflare Pagesのビルドコマンドの中で行ってもよいですか?
ビルドの