ナレッジ

hreflangを使ってページのすべての言語・国別バージョンを見つける方法

hreflangはページのローカライズされたすべてのバージョンを一覧化します。主要サイトがこれをどう使っているか、何が壊れているか、そして一部のバージョンが特定の国内からしか開けない理由を調査しました。

James Meadow

James Meadow

2026年10月1日 · 4 分で読める

レジデンシャルプロキシのmarketごとに価格やコンテンツ、在庫状況を比較する場合、最初の問題は各marketで同じページを見つけることです。URLパターンを推測する方法は一部のサイトでは機能しますが、ほとんどでは失敗します。あるサイトは/de/を使い、別のサイトはde.example.comを使い、また別のサイトはクエリパラメータを使い、さらに別のサイトは国ごとに別ドメインを用意しています。

多くのサイトはすでにその答えを公開しています。hreflangアノテーションは検索エンジンのためにサイトが追加するもので、ページのあらゆる言語版・国版とその所在地を列挙しています。これを読めば、1回の取得だけでセット全体が手に入ります。このガイドでは、その仕組み、トップサイトでの実際の使われ方を調べた結果、そして正しいURLを持っているだけでは正しいページが見られるとは限らない理由を説明します。

要点

  • 2026年10月1日時点で、取得した253件のトップサイトのホームページのうち82件(32%)がhreflangの代替版を宣言しており、1件あたりの中央値は17.5バージョン、最大で157バージョンでした。
  • アノテーション済みのページを1回取得するだけで、推測なしにそのページのすべてのローカライズされたURLが得られます。
  • アノテーションは必ずしもきれいではありません。82サイトのうち12サイトは、Googleがサポートしないコードを少なくとも1つ使用していました。例えばen-uk、es-419、アンダースコア付きのen_GB、あるいは言語の前に国を置くものなどです。
  • 確認した代替版のうち86%は、Googleが要求する通りに直接リンクし返していました。残りの大半は同じページの別のURLを指しているか、リダイレクトしていました。
  • 一部のバージョンは正しい場所からしか開けません。あるニュースサイトの米国版ホームページと、あるマーケットプレイスのドイツ向けストアフロントは、ルーマニアからの接続を別の場所へリダイレクトし、米国とドイツのexitを経由すると正常に読み込まれました。

hreflangの仕組み

ページは<head>内で代替版を列挙します。

<link rel="alternate" hreflang="en" href="https://example.com/" />
<link rel="alternate" hreflang="de" href="https://example.com/de/" />
<link rel="alternate" hreflang="pt-BR" href="https://example.com/pt/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />

各値は言語コードで、任意でスクリプトまたは地域が続きます。deはどこでもドイツ語、en-GBは英国の英語、zh-Hantは繁体字中国語です。x-defaultは、どれにも一致しない訪問者向けのフォールバックを指定します。Googleのドキュメントは、ほとんどのサイトが従うルールを定めています。

  • 言語はISO 639-1コード、スクリプトはISO 15924、地域はISO 3166-1 alpha-2を使用します。地域単独では無効であり、es-419(ラテンアメリカのスペイン語)のようなこれらの規格外のコードはサポートされません。
  • URLは完全修飾でなければならず、https://を含める必要があります。
  • 各バージョンは自分自身と他のすべてのバージョンを列挙しなければならず、2つのページが互いを指していない場合、アノテーションは無視されることがあります。
  • 同じ情報はHTML内、HTTPのLinkヘッダー内(PDFに有用)、またはサイトマップ内で提供できます。

データ収集においては、3番目のルールが有用です。各バージョンは他のすべてを列挙するはずなので、そのうちどれか1つがセット全体の完全な地図になります。

トップサイトで見つかったこと

Trancoの人気サイトランキングの上位500ドメインのホームページを取得しました。253件の異なるサイトがHTMLのホームページを返しました。残りはコンテンツネットワーク、APIホスト、ホームページを持たないその他のドメイン、またはすでにカウント済みのサイトの重複でした。

指標結果
hreflangアノテーションを持つホームページ253件中82件(32%)
アノテーション済みページあたりの代替版数中央値17.5、最大157
x-defaultを持つページ82件中63件
HTTPのLinkヘッダーでhreflangを送信するホームページ0
サポート外のコードを少なくとも1つ持つサイト82件中12件
相対URLを使用するサイト2件
同じコードを2回列挙するサイト6件

サポート外のコードはいくつかのパターンに分類されました。

パターン例サイト数
言語の前に国、さらに地域ラベルmx-es、cz-cs、emea_africa-en1件(48コード)
ハイフンの代わりにアンダースコアen_GB、de_DE1件(25コード)
ISO 3166-1に無い地域コードen-uk、en-eu、sq-xk3件
ラテンアメリカのスペイン語es-4192件
ISO 639-1に無い言語コードceb、skr、旧式のヘブライ語iw3件
独自に作られたコードtc、zh-FT、ms-en3件

一部のサイトは複数の行に該当します。国を先に置くパターンは、パーサーにとって最も厄介です。というのも、これらのコードのいくつかは逆向きでも偶然有効だからです。caはカタルーニャ語のコードであり、idはインドネシア語、thはタイ語のコードなので、ca-enは「カナダ向け英語」ではなく「カタルーニャ語」として読まれてしまいます。コードだけから市場を導き出し、意味が通るか確認せずに信用してはいけません。

戻りリンク

アノテーション済みの各ホームページから最大2つの代替版を取得し、計155件を取得して、それぞれがホームページへ戻るリンクを列挙しているかを確認しました。

結果代替版
直接戻りリンクを張っていた133件(86%)
同じページの別URLへ戻りリンクを張っていた9件
別の場所へリダイレクトした9件
実際にはそのページを列挙していなかった4件、2サイト

2番目の行は微妙です。いくつかのサイトでは、到達したホームページが1つのURLにあり、セットは別のURLを指していました。/に対して/en/、/homepage、/home.html、あるいは言語パラメータ付きのURLに対して素のURLなどです。アノテーション自体は一貫しており、こちらの入り口が単にそのサイトが正規と見なすURLではなかっただけです。収集する際は、たまたまたどり着いたURLではなく、セット自体が使用するURLでバージョンをキー付けしてください。

正しいURLだけでは十分でないこともある

リダイレクトは最も示唆に富む部分でした。リダイレクトする各代替版を、直接、およびターゲット国にあるShifterのexitを経由して、英語とローカルのAccept-Languageヘッダーの両方で再取得しました。

サイトの種類代替版ルーマニアからターゲット国から決定要因
ニュースサイト米国版ホームページ国際版へリダイレクト読み込み成功、米国exit経由場所
マーケットプレイスドイツ向けストアフロントグローバルサイトへリダイレクト読み込み成功、ドイツexit経由場所
ビデオ通話ベンダー日本語版ホームページ日本語のAccept-Languageの場合のみ読み込み成功同様、日本のexit経由言語
ゲームパブリッシャーアラビア語版ホームページリダイレクトリダイレクト、サウジのexit経由どちらでもない:列挙されているが到達不可

2サイトは訪問者の場所によって決定され、マーケットプレイスは言語に関係なく決定されていました。つまり自身のアノテーションが、他の場所からの訪問者には開けないページを指していたのです。1サイトは場所に関係なく言語によって決定されていました。そして1サイトは、試したすべての組み合わせでリダイレクトするバージョンを列挙していました。これはmarket比較を構築する前に知っておく価値があります。

ここでhreflangとexitの場所が交わります。アノテーションはどのバージョンが存在し、どこにあるかを教えてくれますが、忠実に収集するには、現地の訪問者がするように、その国からその言語でそれぞれを要求する必要があります。これはプロキシの地理・タイムゾーン・ロケールを一致させるで扱っています。そうしないと、国際版をドイツ向けのラベルで記録してしまう恐れがあります。

コード

以下のモジュールは、ページのHTML内の代替版を読み取り、サポート外のコードや相対URLにフラグを立て、戻りリンクを確認し、失敗した各代替版がなぜ失敗したかを報告します。

from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit, urlunsplit

import requests
from babel import Locale

# Google accepts ISO 639-1 languages, ISO 15924 scripts and ISO 3166-1 alpha-2 regions.
LANGUAGES = {code for code in Locale("en").languages if len(code) == 2}
NOT_ISO_3166 = {"AC", "CP", "CQ", "DG", "EA", "EU", "EZ", "IC", "QO", "TA", "UN", "XA", "XB", "XK", "ZZ"}
REGIONS = {code for code in Locale("en").territories if code.isalpha()} - NOT_ISO_3166
SCRIPTS = set(Locale("en").scripts)


class AlternateLinks(HTMLParser):
    """Collect <link rel="alternate" hreflang="..."> elements from a page's markup."""

    def __init__(self):
        super().__init__()
        self.links = []

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "link" and "alternate" in (a.get("rel") or "").lower().split() and a.get("hreflang") and a.get("href"):
            self.links.append((a["hreflang"].strip(), a["href"].strip()))


def valid_hreflang(code):
    """True for x-default, a language, or language-region / language-script(-region) codes."""
    if code.lower() == "x-default":
        return True
    parts = code.split("-")
    if parts[0].lower() not in LANGUAGES:
        return False
    rest = parts[1:]
    if rest and rest[0].title() in SCRIPTS:
        rest = rest[1:]
    if rest and rest[0].upper() in REGIONS:
        rest = rest[1:]
    return not rest


def hreflang_map(url, session=None, timeout=20):
    """Fetch a page and return its declared alternates as {hreflang: absolute URL}, plus any problems found."""
    session = session or requests.Session()
    response = session.get(url, timeout=timeout)
    parser = AlternateLinks()
    parser.feed(response.text)  # link tags are normally ASCII, so the decoding choice rarely matters here
    alternates, problems = {}, []
    for code, href in parser.links:
        if not href.startswith(("http://", "https://")):
            problems.append(f"relative URL for {code}: {href}")
        if not valid_hreflang(code):
            problems.append(f"invalid code: {code}")
        alternates[code] = urljoin(response.url, href)
    return response.url, alternates, problems


def normalise(url):
    """Compare URLs without default ports, host case or a trailing slash getting in the way."""
    parts = urlsplit(url)
    port = f":{parts.port}" if parts.port and parts.port not in (80, 443) else ""
    return urlunsplit((parts.scheme.lower(), (parts.hostname or "") + port, parts.path.rstrip("/") or "/", parts.query, ""))


def check_return_links(url, alternates, session=None, timeout=20):
    """Fetch each alternate and report why it breaks the return-link rule. An empty result means all link back."""
    session = session or requests.Session()
    target = normalise(url)
    problems = {}
    for code, alternate in alternates.items():
        if normalise(alternate) == target:
            continue
        final, back, _ = hreflang_map(alternate, session, timeout)
        if normalise(final) != normalise(alternate):
            problems[code] = f"redirects to {final}"
        elif not back:
            problems[code] = "has no hreflang annotations"
        elif target not in {normalise(u) for u in back.values()}:
            problems[code] = "does not link back"
    return problems

自社のホームページに対して実行すると次のようになります。

import requests

from hreflang import hreflang_map, check_return_links

session = requests.Session()
session.headers["User-Agent"] = "ExampleCollector/1.0 (+https://example.com/bot)"

url, alternates, problems = hreflang_map("https://shifter.io/", session)
for code, alternate in sorted(alternates.items()):
    print(code, alternate)
print("problems:", problems)
print("return-link problems:", check_return_links(url, alternates, session))
de https://shifter.io/de
en https://shifter.io/
es https://shifter.io/es
fr https://shifter.io/fr
ja https://shifter.io/ja
ko https://shifter.io/ko
pt-BR https://shifter.io/pt
x-default https://shifter.io/
zh-Hans https://shifter.io/cn
problems: []
return-link problems: {}

知っておくべき限界が2つあります。このコードはHTML内のアノテーションのみを読み取るため、HTTPヘッダーやサイトマップで公開しているサイトは、それらも読み取る必要があります。サイトマップ側については発見ソースとしてのサイトマップで扱っています。また、check_return_linksは与えられたすべての代替版を取得するため、150バージョンあるページではいくつかをサンプリングするか、リクエストの速度を調整してください。

実践への応用

  • テンプレートごとに1ページから始める。 製品ページ、カテゴリページ、記事ページは通常同じアノテーションパターンを共有しているため、それぞれ1回取得すれば、サイト全体がどのようにmarketを対応付けているかがわかります。
  • セット自体のURLでバージョンをキー付けする。 各代替版が宣言するURLを保存し、リダイレクトはノイズではなく発見として扱ってください。
  • 各バージョンを現地の訪問者として取得する。 そのmarketのexitとそのmarketの言語を使用し、リダイレクトされていないか確認してください。4件のリダイレクト事例のうち2件は場所によって決定されており、これはどの国が最もジオブロックされているかでも扱っています。
  • コードを信用する前に検証する。 逆転したコードや独自に作られたコードは、気づかないうちにページを誤ったmarketに割り当てる可能性があります。
  • 収集したものを正規化する。 すべてのバージョンが揃っても、価格、日付、数値の書式はなお異なります。これは各ロケール間での価格・数値・日付の正規化で扱っており、比較自体はジオパーソナライズされた価格設定の検出の主題です。

結論

hreflangは、ウェブ上でページのmarketを公開した索引に最も近いものです。トップサイトの3分の1がこれを提供しており、1回の取得でセット全体が明らかになり、セットのほとんどは本来あるべき通りに互いにリンクしています。

ただしこれは地図であり、保証ではありません。コードは誤っていることがあり、たどり着いたURLがセット自体の使うURLとは限らず、一部のバージョンは正しい場所、あるいは正しい言語の訪問者にしか開かないことがあります。アノテーションを読み、検証し、現地の訪問者がするように各バージョンを取得してください。

出典と参考資料

  • Google Search Central, ページのローカライズ版についてGoogleに知らせる。
  • Tranco, リストQ2K34、2026年9月1日から30日までのドメインランキングを集計。
  • Babel、そのCLDRデータがコード内の言語・スクリプト・地域のリストを提供。
  • ホームページと代替版は、2026年10月1日にShifterが直接、およびShifterのexitを経由して、上記のコードを用いて取得。

始める準備はできていますか?

Shifterのレジデンシャルプロキシをお試しください。IP 205M+件、195+カ国、$0.10/GBから。

始める