翻訳ページを無視させる 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-ES、pt-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:"
何も出ないのが正解です。自分で意図して付けた場合は別ですが。