翻訳ページを無視させる canonical のミス

agentready.md はパスの先頭に言語を付ける方式で10言語を公開しています。どのページも /about/es/about/ja/about ほか計10本のURLを持ちます。その9言語すべてが、何か月ものあいだ <head> に同じ1行を出していました。

<link rel="canonical" href="https://agentready.md/about">

/es/about は検索エンジンにこう言っていたわけです。私は英語ページの複製なので、インデックスするならあちらにしてほしい、と。/ja/about も同じ。残りの7言語も同じ。10言語あって、インデックスされうるのは1つだけでした。

canonicalが実際に主張していること

rel="canonical" は「どのURLが見た目にきれいか」のヒントではありません。このページと指し示したページは同一であり、インデックスに値するのは指し示したほうだ、という主張です。リンクも評価のシグナルもインデックス上の項目も、すべて指し示された先に集約されます。このページ自身は競争から降ります。

/product?color=blue/product を指すときには、それがまさに望む動きです。翻訳ではまったく逆になります。スペイン語のページは英語ページの複製ではありません。別の読者に向けた別のページであり、スペイン語の検索で単独で順位が付くべきものです。

なぜ長く気づかれなかったのか

私たちのルーターはルーティングの前に言語プレフィックスを取り除きます。/es/about へのリクエストはハンドラに /about として届き、言語は別経路で運ばれます。ルートを一度定義すれば全言語で動くので設計としては妥当ですが、request.url からプレフィックスが消えるため、<head> のパーシャルはその裸のパスからcanonicalを組み立てていました。

英語ではこの不具合が正しい出力を生みます。英語にはプレフィックスがないからです。自分の言語で開発しながら見ているページは全部正しく、壊れているのは読まない9言語だけでした。

修正は、URLを出力する前にプレフィックスを戻す1回の呼び出しです。

<%# request.url からは /es/ が既に落ちている — localizedUrl が戻す。 %>
<% const canonicalHref = localizedUrl(canonicalPath); %>
<link rel="canonical" href="<%= baseUrl %><%= canonicalHref %>">

原則、各言語版は自分自身をcanonicalにする

自己参照のcanonicalを必ず置きます。/es/about/es/about を宣言し、/ja/about/ja/about を宣言する。翻訳に例外はありません。2つの言語版がほとんど同じ内容でも、たとえば3語しか違わない商品ページでも、結論は変わりません。言語の違いは重複ではないからです。

hreflang、すべての版がすべての版を挙げる

canonicalはどのページをインデックスするかを、hreflang はインデックスされたページのどれをどの読者に見せるかを決めます。両方が必要で、hreflang には見落とされやすいルールが2つあります。

すべての版が、自分自身を含めてすべての版を挙げる。 10言語なら、10ページすべてに自己参照込みで10本の alternate が並びます。この自己参照の欠落は、それ以外は正しい組でいちばんよくある不備です。

リンクは相互でなければならない。 英語ページがスペイン語ページを挙げているのに、スペイン語ページが英語ページを挙げ返していなければ、検索エンジンはその関係を、多くの場合その組全体を破棄します。検証できないからです。誰でも「自分は誰かの翻訳だ」と名乗れます。それを裏づけられるのは相手のページだけです。

10か所を手で管理せず、1つのリストから生成します。

<% ['en','es','fr','de','pt','it','ja','zh','ko','ru'].forEach(l => { %>
<link rel="alternate" hreflang="<%= l %>" href="<%= baseUrl %><%= localizedUrl(canonicalPath, l) %>">
<% }) %>
<link rel="alternate" hreflang="x-default" href="<%= baseUrl %><%= canonicalPath %>">

その言語の話者を1つの版でまかなえるなら、言語コードだけ(es)で十分です。地域付き(es-ESpt-BR)にするのは、市場ごとに本当に別の版を出している場合だけ。存在しない地域を足すと、組が無意味に分かれます。

x-defaultは「もう一つの翻訳」ではない

x-default は、公開していない言語の読者に何を見せるかを指定します。オランダ語で検索した人がいて、オランダ語版がなければ、その人は x-default の指す先を受け取ります。

「いちばん重要な言語」でもなければ、並びに加わるもう1件でもありません。私たちの x-default はプレフィックスなしの英語URLのままです。サイト全体がもともとそこへフォールバックするからです。ここを /es/about のような特定言語のページに向けると、翻訳を用意していない読者を全員スペイン語のページへ送ることになり、既定より悪くなります。

1行で確かめる

この失敗は端末から丸ごと見えます。言語を順に回して、各ページが何を宣言しているか表示させます。

for p in "" es/ fr/ de/ pt/ it/ ja/ zh/ ko/ ru/; do
  printf '%-4s ' "${p:-en}"
  curl -s "https://example.com/${p}about" | grep -o '<link rel="canonical"[^>]*>'
done

正しい出力は、各行が自分自身のパスを名乗ります。

en   <link rel="canonical" href="https://example.com/about">
es   <link rel="canonical" href="https://example.com/es/about">
fr   <link rel="canonical" href="https://example.com/fr/about">

10行が同じURLなら、それがこの記事の不具合です。トップページだけでなく、触ったテンプレートすべてに対して実行してください。canonicalはたいていレイアウト単位で組み立てられるので、レイアウトが3種類あるサイトは2種類だけ正しい、ということが起こります。

ほかに壊れる道筋

canonicalに計測用パラメータが入る。 リクエストURLをそのまま使ってcanonicalを組み立てると、?utm_source=newsletter で来た訪問者には ?utm_source=newsletter を指すcanonicalが返ります。共有されるたびに「自分がcanonicalだ」と名乗るURLが増え、1ページに対してURLが無限に生えます。パス部分から組み立て、本当に別ページを指し示すパラメータ、たとえば絞り込み一覧やページ送りだけを決まった順序で戻してください。同じページが自分の名前を2通りに書けなくなります。

canonicalがリダイレクトを指している。 /es/about/es/about/ を宣言し、それが301で /es/about に戻るなら、クローラーに解決すべき往復と、シグナルを疑う理由を渡したことになります。200 を返すURLを指してください。

2つのcanonicalが食い違う。 HTML内の <link> と、HTTPヘッダの Link: <...>; rel="canonical" はどちらも有効で、CDNもプラグインもリバースプロキシもこれを付けます。両者が食い違ったときの結果は不定で、たいてい望んだほうにはなりません。ヘッダも見ておきます。

curl -sI https://example.com/es/about | grep -i "^link:"

何も出ないのが正解です。自分で意図して付けた場合は別ですが。

サイトを分析する · メタタグ生成ツール · AI対応チェックリスト