レジデンシャルプロキシの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-en | 1件(48コード) |
| ハイフンの代わりにアンダースコア | en_GB、de_DE | 1件(25コード) |
| ISO 3166-1に無い地域コード | en-uk、en-eu、sq-xk | 3件 |
| ラテンアメリカのスペイン語 | es-419 | 2件 |
| ISO 639-1に無い言語コード | ceb、skr、旧式のヘブライ語iw | 3件 |
| 独自に作られたコード | tc、zh-FT、ms-en | 3件 |
一部のサイトは複数の行に該当します。国を先に置くパターンは、パーサーにとって最も厄介です。というのも、これらのコードのいくつかは逆向きでも偶然有効だからです。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を経由して、上記のコードを用いて取得。