デプロイ時に新しい記事だけIndexNowへ自動送信する方法(Bing・Naver)
静的ブログをデプロイするたびに、本番のサイトマップと新しくビルドしたサイトマップのlastmodを比べ、追加・更新された記事のURLだけをIndexNowへ自動送信する方法をまとめます。一度送ればBingやNaverなどに共有されます。
新しい記事が検索に反映されるのが遅い問題とIndexNow
ブログに新しい記事を公開しても、検索エンジンがその記事を見つけるまでには時間がかかります。検索エンジンの収集プログラム(クローラー)がいつ再訪問するかは、検索エンジン側が決めるからです。これまではNaver Search AdvisorやBing Webmaster Toolsに入って記事のURLを一つずつ手動で送信していましたが、記事を公開するたびにやるのは面倒で、よく忘れていました。
IndexNowはこの作業を自動化できる公開プロトコルです。サイトの持ち主が「このURLが新しくできた、または変わった」と検索エンジンに直接知らせる仕組みです。一か所に送ると、IndexNowに参加しているほかの検索エンジンにも自動で共有されます1。現在の参加検索エンジンにはBing、Naver、Yandex、Seznamなどがあり2、Googleは参加していません。
そこで、このブログのデプロイコマンドにIndexNowへの送信を組み込みました。目標は2つです。
- デプロイ時に自動で送る。
- すべてのURLではなく、追加・更新された記事だけを送る。変わっていないURLを毎回送るのは、検索エンジンにとって意味のないリクエストです。
IndexNowキーファイルの準備
IndexNowでURLを送るには、そのサイトの持ち主であることを証明するキーが必要です。キーは自分で作る文字列で、英字・数字・-で8〜128文字にする必要があります1。私は32桁の16進数文字列を使いました。ターミナルで次のように作れます。
openssl rand -hex 16
作ったキーは2か所に置きます。
- サイトのルートにあるキーファイル:
public/<IndexNowキー>.txtファイルにキー文字列を1行だけ書きます。デプロイするとhttps://sncstudio.net/<IndexNowキー>.txtで開けるようになります。検索エンジンはこのファイルを開いてキーが正しいか確認します。 - 送信スクリプトで使う定数:
src/lib/env.tsに同じ値を置きます。
export const INDEXNOW_KEY = '<IndexNowキー>'; // public/<키>.txt 와 같아야 함
キーファイルは誰でも見られる公開ファイルなので、パスワードのように隠す必要はありません。ただし2か所の値が違うと送信が拒否(403)されます。
サイトマップのlastmodで変わった記事だけを選ぶ
「追加・更新された記事」をどう見分けるかがポイントでした。別に記録を残すのではなく、サイトマップを使うことにしました。
このブログの記事サイトマップ(post-sitemap.xml)には、記事ごとのURL(loc)と最終更新日時(lastmod)が入っています。lastmodは記事のfrontmatterのupdatedAtの値です。
<url><loc>https://sncstudio.net/korea-covered-call-etf-comparison</loc><lastmod>2026-10-20T11:10:00.000Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
デプロイの直前には、サイトマップが2つあります。
- 本番のサイトマップ:今サイトに公開されている、前回デプロイ時の状態
- 新しくビルドしたサイトマップ:
out/post-sitemap.xml、今回デプロイする状態
2つを比べて、本番側にないURLは新しい記事、lastmodが変わったURLは更新された記事です。そのため既存の記事を直してupdatedAtを変えれば、その記事も自動で再送信されます。
scripts/deploy.tsの全コード
package.jsonのデプロイコマンドは、ビルドしたあとにこのスクリプトを実行します。
"deploy": "bun run build && bun scripts/deploy.ts",
scripts/deploy.tsの全コードです。
// out/을 Cloudflare Pages에 배포하고, 새로 추가·수정된 포스트 URL을 IndexNow로 알린다.
// 배포 전 운영 중인 post-sitemap.xml과 새로 빌드한 out/post-sitemap.xml의 lastmod를 비교해 바뀐 URL만 보낸다.
// IndexNow는 네이버·Bing 등 참여 검색엔진에 공유된다(구글은 미지원).
import {readFileSync} from "node:fs";
import {spawnSync} from "node:child_process";
import {INDEXNOW_KEY, SITE_URL} from "../src/lib/env";
const SITEMAP_PATH = '/post-sitemap.xml';
function parseSitemap(xml: string): Map<string, string> {
const entries = new Map<string, string>();
for (const [, body] of xml.matchAll(/<url>([\s\S]*?)<\/url>/g)) {
const loc = body.match(/<loc>([^<]+)<\/loc>/)?.[1];
const lastmod = body.match(/<lastmod>([^<]+)<\/lastmod>/)?.[1] ?? '';
if (loc) entries.set(loc, lastmod);
}
return entries;
}
async function fetchLiveSitemap(): Promise<Map<string, string> | null> {
try {
const res = await fetch(`${SITE_URL}${SITEMAP_PATH}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return parseSitemap(await res.text());
} catch (e) {
console.warn(`[indexnow] 운영 사이트맵을 가져오지 못해 IndexNow 전송을 건너뜁니다: ${e}`);
return null;
}
}
async function submitIndexNow(urls: string[]) {
const res = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: {'Content-Type': 'application/json; charset=utf-8'},
body: JSON.stringify({
host: new URL(SITE_URL).host,
key: INDEXNOW_KEY,
keyLocation: `${SITE_URL}/${INDEXNOW_KEY}.txt`,
urlList: urls,
}),
});
// 200: 접수, 202: 접수(키 검증 대기), 403: 키 불일치, 422: host와 URL 불일치
console.log(`[indexnow] 응답 ${res.status} ${res.statusText}`);
if (!res.ok) console.log(await res.text());
}
const live = await fetchLiveSitemap();
const built = parseSitemap(readFileSync(`out${SITEMAP_PATH}`, 'utf8'));
const changed = live ? [...built].filter(([loc, lastmod]) => live.get(loc) !== lastmod).map(([loc]) => loc) : [];
const deploy = spawnSync(
'wrangler',
['pages', 'deploy', 'out', '--project-name=sncstudio-blog', '--branch=main', '--commit-dirty=true'],
{stdio: 'inherit'},
);
if (deploy.status !== 0) process.exit(deploy.status ?? 1);
if (changed.length === 0) {
console.log('[indexnow] 새로 추가·수정된 포스트가 없어 전송하지 않습니다.');
} else {
console.log(`[indexnow] ${changed.length}개 URL 전송:\n ${changed.join('\n ')}`);
await submitIndexNow(changed);
}
ハイライトした部分を順に見ていきます。
デプロイ前に本番のサイトマップを取得しておく
順序が大切です。デプロイが終わると本番のサイトマップが新しいものに置き換わり、比較対象がなくなります。そのためデプロイする前に本番のサイトマップを取得し、変わったURLの一覧(changed)を計算しておきます。
本番のサイトマップを取得できなければ(ネットワークエラーなど)、送信をスキップします。比較の基準がないときに全部を送るより、今回だけスキップするほうが安全だと考えました。
デプロイが成功したときだけ送信する
デプロイはCloudflareのwrangler pages deployコマンドで行います。デプロイに失敗したらその場で終了します。デプロイされていないURLを検索エンジンに知らせると、検索エンジンが来たときにまだ存在しないページ(404)を見ることになるからです。
api.indexnow.orgへまとめて送る
https://api.indexnow.org/indexnowはIndexNowの共通エンドポイントです3。ここへJSONで次の値をPOSTします。
host:サイトのホスト(sncstudio.net)key:IndexNowキーkeyLocation:キーファイルのURLurlList:変わった記事のURL一覧(1回で最大10,000件)
レスポンスコードの意味は次のとおりです1。
| コード | 意味 |
|---|---|
200 | 正常に受付 |
202 | 受付済み、キーの確認は処理中 |
400 | リクエストの形式が正しくない |
403 | キーが正しくない(キーファイルが見つからない、または値が違う) |
422 | URLがhostと一致しない |
429 | リクエストが多すぎる |
デプロイで実際に送信された結果
bun run deployを1回実行するだけで、ビルド、デプロイ、IndexNowへの送信が順に進みます。変わった記事がなければ[indexnow] 새로 추가·수정된 포스트가 없어 전송하지 않습니다.(追加・更新された記事がないので送信しません)とだけ出力して終わります。
注意点
- GoogleはIndexNowを使っていません。 GoogleにはSearch Consoleでサイトマップを登録しておき、クローラーに任せれば大丈夫です。
- 更新した記事を送るには
updatedAtを変える必要があります。 記事の内容を直してもupdatedAtをそのままにすると、lastmodが同じなので変わった記事として扱われません。逆に誤字を一つ直すたびにupdatedAtを変えると毎回送信されるので、意味のある修正のときだけ変えるのがよいです。 - サイトマップのURLと実際のURLを一致させましょう。 サイトマップに末尾スラッシュ付きのURLがあり、実際にはリダイレクトされるなら、リダイレクトされるURLを検索エンジンに知らせることになります。URLの形をそろえる方法はCloudflare Pagesで末尾スラッシュ(/)へのリダイレクトをなくす方法にまとめました。
- このブログは韓国語・英語・日本語の記事がすべて一つの
post-sitemap.xmlに入っているので、翻訳を追加したり直したりしても同じ方法で一緒に送信されます。