Web の「iffthen hangul」は、日本語・韓国語・英語・スペイン語の 4 か国語で、ページは 9,432 枚あります。このサイトを、フレームワークを使わずに作り、SEO の作業を自動にした方法をまとめます。
なぜフレームワークなしなのか
ページの構成は単純です。言語ごとに、トップ・書く画面・文字の一覧・文字のページ 2,351 枚・規約 3 種類です。動きのある部分(判定・読みの変換)は、小さなスクリプトがいくつかあるだけです。
そこで、Node のスクリプトひとつ(build.mjs)が HTML の文字列を作ってファイルに書き出す方式を選びました。依存しているのは、部分フォントを作るためのものひとつだけです。ビルドの結果は、共有ホスティングに rsync で上げています。
[hangul-build] 配信ビルド → dist-hangul/ · ページ 9432 · アセット /assets/201c9bfa66/ · フォント 12 個 398 KB · 書き順動画 2351
[hangul-check] 異常なし (ページ 9432 · サイトマップ 9430 · lastmod 9430 · 構造化データ 9413 ページ · IndexNow キー · リンク · 検索の基本)
1. アセットの URL = 中身のハッシュ(キャッシュの無効化が手間なしで済む)
CSS・JS・フォントは /assets/<中身のハッシュ>/ の下に置きます。ページはまず /assets/HASH/ という仮の文字で作り、最後に実際のハッシュに置き換えます。
const pending = { hash: 'HASH' }; // すべてのページはこの仮の文字で作る
// ... pages[...] = topPage(lang, pending) ...
const hash = createHash('sha256');
for (const [n, c] of assetFiles) hash.update(n).update(c);
const h = hash.digest('hex').slice(0, 10);
for (const [n, c] of assetFiles) write(`assets/${h}/${n}`, c);
for (const [rel, html] of Object.entries(pages)) write(rel, html.replaceAll('/assets/HASH/', `/assets/${h}/`));
アセットが変わると URL も変わるので、アセットは長くキャッシュしても安全です。HTML は no-cache にしています。
2. サイトマップの lastmod = 「中身が変わった日」だけ
よくある間違いは、ビルドのたびにすべてのページの lastmod を今日の日付にすることです。Google は、正確でない lastmod を無視します。
ここでは、ページごとに中身の指紋を記録しておき、指紋が変わったページだけ日付を今日にします。ポイントは、指紋をアセットのハッシュに置き換える前の HTML(/assets/HASH/ のまま)で計算することです。CSS だけを直した配信で、9,400 ページの日付が一斉に変わるのを防げます。
function sitemapDates(urls, pages) {
const prev = existsSync(SITEMAP_DATES) ? JSON.parse(readFileSync(SITEMAP_DATES, 'utf8')) : {};
const today = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Tokyo' }).format(new Date()); // YYYY-MM-DD
const next = {};
for (const u of urls) {
const html = pages[`${u.slice(1)}index.html`];
const h = createHash('sha256').update(html).digest('hex').slice(0, 16);
next[u] = prev[u]?.h === h ? prev[u] : { h, d: today }; // 同じなら古い日付のまま
}
if (!DEV) { // ローカルの点検ビルドは広告・API の URL が違い、指紋も違うので記録しない
const body = `{\n${Object.keys(next).sort()
.map((u) => `${JSON.stringify(u)}: ${JSON.stringify(next[u])}`).join(',\n')}\n}\n`;
writeFileSync(SITEMAP_DATES, body);
}
return Object.fromEntries(urls.map((u) => [u, next[u].d]));
}
- 記録のファイル(
sitemap-dates.json)は、1 行に 1 つの URL で並べて保存し、git にコミットします。次のビルドの基準になり、diff を見れば「どのページが変わったか」がすぐ分かります 'sv-SE'のロケールは、日付をYYYY-MM-DDで出してくれる小さな工夫です
3. IndexNow:変わった URL だけを Bing などに知らせる
IndexNow は、Bing・Yandex・Naver などが共通で使っている「この URL が変わった」という通知の仕組みです(Google は参加していないので、lastmod で対応します)。上の指紋をそのまま使い回します。
// 前回知らせた指紋と違う URL だけを送る
const changed = Object.keys(dates).filter((u) => sent[u] !== dates[u].h);
if (changed.length === 0) return log('変わったページなし · 飛ばす');
// 送る前に、本番サイトのキーファイルが同じかを確かめる(違えば検索エンジンが断る)
const live = await fetch(keyLocation).then(async (r) => (r.ok ? (await r.text()).trim() : '')).catch(() => '');
if (live !== key) return log('本番サイトのキーファイルがない、または違う · 送らない');
for (let i = 0; i < changed.length; i += 10000) { // 仕様の上限 1 万件ずつ
const part = changed.slice(i, i + 10000);
const res = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ host, key, keyLocation, urlList: part.map((u) => origin + u) }),
});
if (res.status !== 200 && res.status !== 202) return log(`警告: 応答 ${res.status} · 次の配信でもう一度送る`);
for (const u of part) sent[u] = dates[u].h; // 成功したものだけ記録
writeFileSync(SENT, JSON.stringify(sent));
}
本番の配信スクリプトの最後で自動に動きます。失敗しても配信は止めず、記録も残さないので、次の配信でもう一度送ります。
4. 構造化データ(JSON-LD)は関数ひとつで
function ldTags(list) {
return (list ?? []).map((d) =>
`\n<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', ...d })
.replace(/</g, '\\u003c')}</script>`).join('');
}
< を \u003c に置き換えるのが要です。データの中に </script> が入っていても、タグが途中で切れません(注入の防止)。
ページの種類ごとに入れているものは、次のとおりです。
- トップ =
WebSite+WebApplication(無料・ブラウザ・教育用) - 一覧・文字のページ =
BreadcrumbList(画面のパンくずと同じ順番)
スポンサーリンク
スポンサーリンク
5. 配信前の検査(check.mjs)が配信を止める
ビルドの直後に、別のスクリプトが作ったものをすべて読み、ひとつでも引っかかれば配信を中止します。
- サイトマップの URL ごとにファイルがあるか、lastmod が
YYYY-MM-DDで未来の日付になっていないか - ページの種類ごとに必要な構造化データがあり、JSON として読めるか
- 内部リンクが切れていないか、IndexNow のキーファイルがルートにひとつだけあるか
- 隠すべきデータ(画の座標・会社の情報の原文)が、作ったものに混ざっていないか
const LD_NEED = [
[/^[a-z]{2}\/index\.html$/, ['WebSite', 'WebApplication']],
[/^[a-z]{2}\/chars\/index\.html$/, ['BreadcrumbList']],
[/^[a-z]{2}\/char\/[0-9a-f]+\/index\.html$/, ['BreadcrumbList']],
];
「配信は、人が覚えていて確かめるもの」ではなく、「検査を通らないとできないもの」にします。
6. 多言語:hreflang+言語の自動切り替え(検索ロボットは動かさない)
言語版は /ja/ /ko/ /en/ /es/ のような URL で分け、すべてのページで hreflang によって互いを結んでいます。ルートの / は、サーバー(.htaccess)が言語ごとに 302 で移動させます。
RewriteCond %{HTTP_COOKIE} (^|;\s*)lang=(ja|ko|en|es)(-|;|$)
RewriteRule ^$ /%2/ [R=302,L]
RewriteCond %{HTTP:Accept-Language} ^ja [NC]
RewriteRule ^$ /ja/ [R=302,L]
RewriteCond %{HTTP:Accept-Language} ^ko [NC]
RewriteRule ^$ /ko/ [R=302,L]
RewriteCond %{HTTP:Accept-Language} ^es [NC]
RewriteRule ^$ /es/ [R=302,L]
RewriteRule ^$ /en/ [R=302,L]
言語版のページの中では、ブラウザのスクリプトが判断します。決まりは次のとおりです。
- 最初に入ってきた URL を尊重する。保存された値がなければ、今のページの言語を保存し、移動しません。検索結果から日本語のページに入ってきた人と、検索ロボットを動かさないためです
- 機器の言語が保存したときと変わっていたら、機器の言語に移す
- ひとつの言語だけのページは移さない
const saved = document.cookie.match(/(?:^|;\s*)lang=([a-z]+)-([a-z]{2})/);
const fromDevice = locales.includes(device) ? device : 'en';
let want;
if (!saved || !locales.includes(saved[1])) want = page; // 最初 = 今のページ
else if (saved[2] !== device) want = fromDevice; // 機器の言語が変わった
else want = saved[1];
document.cookie = `lang=${want}-${device}; path=/; max-age=31536000; samesite=lax; secure`;
if (want === page || root.hasAttribute('data-only')) return;
location.replace(location.pathname.replace(prefix, `/${want}`) + location.search + location.hash);
クッキーに「保存したときの機器の言語」も一緒に入れておくのがコツです(lang=ja-ko)。利用者が自分で選んだ言語は守り、機器の言語を変えた場合だけ追いかけます。
言語をひとつ足すとき(スペイン語の実例)
文言のキーをひとつでも漏らすと、ページに undefined と表示されます。追加のパッチに、「英語の文言とキー・差し込み({c} {n})が完全に同じか」を照らし合わせる検査を入れました。言語の一覧を使う場所(サーバーの移動の規則・ブラウザのスクリプト・共有用の画像・配信の確認先 URL)も、一度に変えます。
つまずいたこと
- lastmod をビルドの日付にすると、9,400 枚が毎日「変わったページ」になる。Google はこの値を信じなくなり、IndexNow も毎回すべてを送ることになります。中身の指紋の方式に変えました
- IndexNow の最初の送信が 403。キーファイルを上げた直後で、検索エンジンがまだキーを確認できていなかったのです。数分後に 200 になりました。送る前に、キーファイルを自分で取得して確かめる段階を入れました
- VSCode が、設定ファイルを保存するときに書き換える。
.htaccessの*が_に変わったことがあります。作ったファイルや設定ファイルは、エディタで保存せず、スクリプトだけで作ります - ブラウザは 301 を覚えている。リダイレクトの確認は、シークレットウィンドウか curl で行います
スポンサーリンク
スポンサーリンク
この記事について
自社の Web「iffthen hangul」を、フレームワークなしで作ったときの記録です。コードとログは実際のソースとビルドから抜き出し、コメントを読みやすく縮めています。動作を変える修正はしていません。ページ数は、ビルドのログの実測値です。IndexNow のキーや広告・アクセス解析の ID は載せていません。
- できあがったサイト(日・韓・英・スペイン語):iffthen hangul
- アプリ:推し韓マル(App Store・Google Play)


コメント