原本1本から ja/en/ko の全言語ページを機械生成する仕組みを組み、骨格照合・未翻訳残留・hreflang相互の検査をすべてグリーンにして本番公開しました。ところが公開後の点検で、翻訳ページの canonical が日本語版を指していたこと、そして言語版があるのに日本語へ飛ぶ内部リンクが各言語961本残っていたことが分かりました。どちらも見た目には出ず、検索と回遊を静かに壊す不具合です。この記事では、原因の3パターンと、検査に足した2項目を手順としてまとめます。
前提
- 対象: 自社サイト(ja/en/ko の3言語・348URL)
- フレームワーク: Astro(コンテンツコレクションを使用)
- 運用: 原文(日本語)の1ファイルを直すと、残りの言語をAIエージェントのサブエージェントに並行で展開させる
- 公開前の検査(もともとあったもの)
- 骨格照合(同じデザイン・同じ構造になっているか)
- 未翻訳残留(訳し漏れがないか)
- hreflang の相互参照
この3つが緑なら公開してよい、という運用でした。結論から言うと、この3つだけでは足りませんでした。
手順1: 生成の仕組みを把握する(どこを書き換えているか)
生成側は、日本語ページをもとに各言語のページを作る際、属性として書かれた path="..." を言語付きのパスに書き換えていました。
<!-- 生成側が書き換えていた形(属性) -->
<Layout path="/posts/xxx">
問題は、パスがこの「属性」以外の形でも書かれていたことです。生成の仕組みを作るときは、まずパスがどんな書き方で出てくるかを洗い出すのが先でした。後から分かった書き方は次の手順で扱います。
手順2: canonical の出どころを確認する
記事・実績・カテゴリのページでは、canonical が変数から作られていました。
---
// canonical の元になっていた変数
const path = `/posts/${slug}`;
---
生成側は属性の path="..." しか書き換えていなかったため、この変数は日本語のパスのまま残ります。結果として、英語ページも韓国語ページも canonical が日本語版を指す状態で公開されていました。
ここが落とし穴で、hreflang の検査は緑のままです。hreflang は「別の言語版がここにある」という相互の宣言で、canonical は「このページの正規URLはどれか」という宣言です。canonical が日本語版を指していれば、翻訳ページは重複として畳まれ、検索結果に出ない状態になります。hreflang と canonical は別の検査項目として扱う必要がありました。
手順3: 日本語へ飛ぶ内部リンクの原因を分類する
英語の記事一覧をクリックすると日本語の記事に着地する、という状態でした。言語版が存在するのに日本語版へ飛ぶリンクが、各言語961本。原因は3種類ありました。
① テンプレートリテラル型の href
<a href={`/posts/${slug}`}>...</a>
href="..." という属性の形を前提にした正規表現では、この書き方に当たりません。
② href で終わる prop 名
<Section moreHref="/posts">...</Section>
moreHref のように href で終わる prop 名は、\bhref という正規表現では拾えません。\b は語境界なので、more と Href の間には当たらないためです。
// 動作の確認用(考え方の例)
/\bhref=/.test('moreHref="/posts"'); // false:語境界が無いので当たらない
③ 翻訳した本文HTMLの中のリンク
翻訳者(サブエージェント)は日本語版の本文をコピーして訳すので、本文中のリンクは日本語版のままになります。しかも本文HTMLはページ生成の書き換え対象ではありません。ここは生成処理の中ではなく、別のスクリプトで付け替える必要がありました。
①②はコンポーネント側の書き方の問題、③はコンテンツ側の問題で、直す場所が違います。分類せずに一つの正規表現で全部を直そうとすると、どれかが漏れます。
起きたこと/落とし穴のまとめ
| 症状 | 原因 | 既存の検査で検出できたか |
|---|---|---|
| 翻訳ページの canonical が日本語版 |
const path = \/posts/${slug}`` の変数が書き換え対象外 |
できない(hreflang は緑) |
| 日本語へ飛ぶ内部リンク(各言語961本) | ①テンプレート型 href ②moreHref 等の prop ③本文HTML内のリンク |
できない(骨格・訳し漏れは緑) |
骨格照合は「同じデザインか」、未翻訳残留は「訳し漏れがないか」を見る検査です。どちらもページが検索に出る状態か、読者が同じ言語の中で回遊できるかは見ていません。見ていない以上、緑になるのは当然でした。
対処: 検査に2項目を足す
検査A: canonical の自己参照
各言語のページで、canonical がそのページ自身のURLを指しているかを確認します。英語ページの canonical が英語ページ自身でなければNGです。
// 考え方を最小にしたもの(実際のスクリプトそのものではない)
// 各ページについて
if (canonicalUrl !== ownUrl) {
fail(`canonical が自己参照になっていない: ${ownUrl} -> ${canonicalUrl}`);
}
検査B: dist 全体の内部リンクで「言語版があるのに別言語へ飛ぶもの」を0にする
ビルド後の dist 全体から href="/..." を拾い、同じ言語の版が存在するのに別言語を指しているリンクを数えて、0であることを条件にします。ソースではなくビルド後の出力を見るのがポイントで、①②③のどの書き方で書かれていても、最終的なHTMLでは href="/..." になるため、書き方の差を吸収できます。
ただし、言語切替リンク(hreflang 属性つき)は除外します。言語切替リンクは他言語を指すのが仕事なので、除外しないと切替リンクそのものがNGとして引っかかります。
// 考え方を最小にしたもの(実際のスクリプトそのものではない)
for (const link of linksInDist) {
if (link.hasHreflang) continue; // 言語切替リンクは対象外
if (langVersionExists(link.href, pageLang) && !isSameLang(link.href, pageLang)) {
fail(`言語版があるのに別言語へ飛ぶ: ${page} -> ${link.href}`);
}
}
本文HTML内のリンク(③)は、この検査で検出したうえで、別スクリプトでの付け替えで直します。
ついでに踏んだ型ズレ: astro build は通るのに astro check だけ落ちる
翻訳用のコレクションを作るとき、日本語側のコレクションの schema を works.schema! で使い回していました。
// NG の形(イメージ)
const worksEn = defineCollection({
schema: works.schema!,
});
この書き方だと Astro が data の型を unknown と推論します。厄介なのは、astro build は通るのに astro check だけが108件のエラーで落ちることです。ビルドが通るので気づくのが遅れます。
対処は、schema の実体を切り出して共有することです。
// OK の形(イメージ)
const workSchema = z.object({
/* 共通の項目 */
});
const works = defineCollection({ schema: workSchema });
const worksEn = defineCollection({ schema: workSchema });
コレクションのオブジェクトから schema を取り出すのではなく、schema そのものを変数にして両方から参照します。
まとめ
- 多言語の検品は「骨格」「訳し漏れ」だけでは足りない。canonical の自己参照と同一言語内の内部リンクを独立した検査項目として持つ
- hreflang が緑でも canonical は別物。変数から組み立てた canonical は、属性の書き換えでは直らない
- 内部リンクの漏れは「テンプレート型 href」「href で終わる prop 名」「翻訳本文内のリンク」の3種類に分けると直す場所が見える
- 内部リンク検査はソースではなく dist のHTMLを見る。言語切替リンク(
hreflangつき)は除外する - schema は
works.schema!で使い回さず実体を切り出して共有する。astro buildが通ってもastro checkも回す