Claude Media
hreflangをClaude Codeで検証する — 相互参照の欠落とx-defaultを洗う

hreflangをClaude Codeで検証する — 相互参照の欠落とx-defaultを洗う

多言語サイトのhreflangを、サイトマップから読み取るスクリプトで検査します。戻りリンクの欠落・言語コードの誤り・x-default抜けを、Claude Codeに書かせて回す手順です。

hreflangは、同じ内容の言語別・地域別ページを検索エンジンに結びつけるための注釈です。書くこと自体は難しくありません。つまずくのは、言語を1つ足したときに、既存ページ側の注釈を直し忘れる場面です。

この記事では、サイトマップに書かれたhreflangを機械的に検査するスクリプトをClaude Codeに作らせ、編集のたびに走らせる流れを説明します。検査項目は、Googleのドキュメントが挙げる条件から決めます。

何を検査するか — ドキュメントの条件を判定に落とす

検査の基準は、思いつきで増やさず、Googleの「Tell Google about localized versions of your page」に書かれた条件から取ります。スクリプトの判定にできるものは次の5つです。

検査項目ドキュメントの記述スクリプトの判定
自己参照ドキュメントの記述各言語版は自分自身と他の全言語版を列挙するスクリプトの判定自分のURLが注釈に含まれるか
戻りリンクドキュメントの記述互いに指し合っていないと注釈は無視されるスクリプトの判定AがBを指すなら、BにもAがあるか
言語・地域コードドキュメントの記述言語はISO 639-1、地域はISO 3166-1 Alpha 2スクリプトの判定形式と、よくある取り違えに当たらないか
x-defaultドキュメントの記述言語が一致しない利用者向けの代替ページスクリプトの判定x-defaultが1件あるか
絶対URLドキュメントの記述httpまたはhttpsを含む完全なURLスクリプトの判定https://で始まるか

戻りリンクの欠落は特に見つけにくい不具合です。ドキュメントには、2つのページが互いに指し合っていなければタグは無視される、とあります。他人のサイトが勝手にあなたのページを代替版だと名乗れないようにするための仕様です。

裏を返せば、片側の追加漏れがあっても、ブラウザで見たページには何の変化もありません。目視では拾えず、全ページの組み合わせを機械で突き合わせる意味はここにあります。

コードの誤りも同じ性質です。Googleは、国コードだけを書いても国から言語を推測しません。悪い例として挙がるのが be で、ベルギーではなくベラルーシ語の言語コードです。地域コードのEU・UN・UKは予約済みで、hreflangに書いても効果がありません。英国向けは en-GB です。

方式はどれを選ぶか

hreflangの指定方法はHTMLの <link>、HTTPヘッダー、サイトマップの3つで、Googleの見方は同等です。3つを併用しても検索上の利点はなく、管理が難しくなるだけです。

この記事はサイトマップ方式を前提にします。理由は検査のしやすさです。1つのXMLに全ページの注釈が集まるため、HTMLを1ページずつ取得しなくても、ファイル1つで相互参照を突き合わせられます。HTML方式で運用しているサイトは、ビルド後のHTMLから <link rel="alternate"> を読む処理への差し替えが必要です。手順は後の節に置きました。

サイトマップ方式では、各 <url> が自分を含む全言語版の <xhtml:link> を持ちます。xmlns:xhtml="http://www.w3.org/1999/xhtml" の宣言も必要です。

Claude Codeに検査スクリプトを書かせる

依頼文は、判定基準を箇条書きで渡すと外れにくくなります。上の表をそのまま貼り、入出力の形を指定します。

sitemap.xmlのhreflangを検査するPythonスクリプトをscripts/に作って。
標準ライブラリのみ。検査は自己参照・戻りリンク・言語コード・
x-default・絶対URLの5つ。問題があれば「NG URL」と理由を出し、
1件でもあれば終了コード1にする。

Claude Codeが書くコードは毎回少しずつ違いますが、骨格は次のようになります。実際に手元で動かしたスクリプトです。言語コードの判定は、ISO 639-1の全リストを持たず、形式の検査と取り違えやすいコードの拒否に絞っています。

#!/usr/bin/env python3
"""sitemap.xml の hreflang を検査する。使い方: python3 check_hreflang.py sitemap.xml"""
import re
import sys
import xml.etree.ElementTree as ET
 
NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9",
      "xhtml": "http://www.w3.org/1999/xhtml"}
RESERVED_REGIONS = {"EU", "UN", "UK"}          # 効果がない地域コード
COUNTRY_AS_LANG = {"jp", "kr", "cn", "gb", "us", "tw"}  # よくある取り違え
CODE_RE = re.compile(r"^[A-Za-z]{2}(-[A-Za-z]{4})?(-[A-Za-z]{2})?$")
 
 
def check_code(code):
    if code.lower() == "x-default":
        return None
    if not CODE_RE.match(code):
        return f"形式が不正: {code}"
    parts = code.split("-")
    if parts[0].lower() in COUNTRY_AS_LANG:
        return f"先頭が国コードに見える: {code}"
    if parts[-1].upper() in RESERVED_REGIONS and len(parts) > 1:
        return f"予約済みの地域コード: {code}"
    return None
 
 
def main(path):
    root = ET.parse(path).getroot()
    pages = {}
    for url in root.findall("sm:url", NS):
        loc = url.findtext("sm:loc", namespaces=NS).strip()
        alts = {}
        for link in url.findall("xhtml:link", NS):
            if link.get("rel") == "alternate":
                alts[link.get("hreflang")] = link.get("href")
        pages[loc] = alts
 
    errors = []
    for loc, alts in pages.items():
        if "x-default" not in alts:
            errors.append((loc, "x-default がない"))
        if loc not in alts.values():
            errors.append((loc, "自分自身を含んでいない"))
        for code, href in alts.items():
            msg = check_code(code)
            if msg:
                errors.append((loc, msg))
            if not href.startswith("https://"):
                errors.append((loc, f"絶対 URL(https)でない: {href}"))
            elif href != loc and href in pages and loc not in pages[href].values():
                errors.append((loc, f"{href} からの戻りリンクがない"))
            elif href not in pages:
                errors.append((loc, f"サイトマップに <url> がない: {href}"))
    for loc, msg in errors:
        print(f"NG {loc}\n   {msg}")
    print(f"{len(pages)} URL を検査 / {len(errors)} 件の問題")
    return 1 if errors else 0
 
 
if __name__ == "__main__":
    sys.exit(main(sys.argv[1]))

全リストを持たない判断は割り切りです。xx のような存在しない言語コードは通ります。厳密にしたい場合は、ISO 639-1の一覧をデータとして渡し、辞書で引く形に差し替えるようClaude Codeに頼めます。

わざと壊したサイトマップで動かす

検査が効くかどうかは、壊れた入力で確かめます。日本語・英語・英国英語の3ページで、x-default抜け、jp という誤り、en-UK という予約済み地域コード、戻りリンクの欠落を仕込みました。

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://example.com/ja/page</loc>
    <xhtml:link rel="alternate" hreflang="ja" href="https://example.com/ja/page"/>
    <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/page"/>
    <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/en/page"/>
  </url>
  <url>
    <loc>https://example.com/en/page</loc>
    <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/page"/>
    <xhtml:link rel="alternate" hreflang="jp" href="https://example.com/ja/page"/>
  </url>
  <url>
    <loc>https://example.com/en-uk/page</loc>
    <xhtml:link rel="alternate" hreflang="en-UK" href="https://example.com/en-uk/page"/>
    <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/en/page"/>
  </url>
</urlset>

実行すると、次のように出ます。

python3 check_hreflang.py sitemap.xml
NG https://example.com/en/page
   x-default がない
NG https://example.com/en/page
   先頭が国コードに見える: jp
NG https://example.com/en-uk/page
   予約済みの地域コード: en-UK
NG https://example.com/en-uk/page
   https://example.com/en/page からの戻りリンクがない
3 URL を検査 / 4 件の問題

仕込んだ問題がすべて拾われています。ja のページ自体は、x-defaultも自己参照もあるため指摘されません。

最後の戻りリンクは、en-uk のx-defaultが指す /en/page に、en-uk への注釈がないために出ています。自分と相手の2ページだけでなく、x-defaultの参照先にも戻りリンクが要る、という例です。

出力に「どのURLの、何が悪いか」が1行ずつ載っている点が、Claude Codeと組み合わせるときに効きます。このまま会話に流せば、Claude Codeは該当の <url> を直しに行けます。

HTML方式のサイトでは読み込み部分だけ差し替える

HTMLの <link> で指定しているサイトでも、検査の規則は変わりません。変えるのはサイトマップを読む部分だけです。ビルド出力のディレクトリにある *.html を走査し、各ページの canonical をキー、rel="alternate" の注釈を値にして、サイトマップ版と同じ pages 辞書を組みます。

from html.parser import HTMLParser
from pathlib import Path
 
 
class LinkParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.canonical = None
        self.alts = {}
 
    def handle_starttag(self, tag, attrs):
        if tag != "link":
            return
        a = dict(attrs)
        rel = (a.get("rel") or "").lower()
        if rel == "canonical":
            self.canonical = a.get("href")
        elif rel == "alternate" and a.get("hreflang"):
            self.alts[a["hreflang"]] = a.get("href")
 
 
def load_pages(build_dir):
    pages = {}
    for f in Path(build_dir).rglob("*.html"):
        p = LinkParser()
        p.feed(f.read_text(encoding="utf-8"))
        if p.canonical:
            pages[p.canonical] = p.alts
    return pages

main の中で pages を作っていた部分を pages = load_pages(path) に置き換え、引数にはサイトマップではなくビルド出力のディレクトリ(dist など)を渡します。errors を集めて表示する後半はそのまま使えます。Claude Codeに頼むときは「check_hreflang.pyの読み込み部分を、ディレクトリ内のHTMLから組む形に変えて。検査ロジックは触らないで」と範囲を絞ると、規則側の改変を防げます。

canonical を持たないページは辞書に入らず、検査の対象外になります。canonicalの付け忘れは別の問題なので、気になる場合は pages に入らなかったファイル名を出す処理を足してもらうとよいでしょう。

CLAUDE.mdに検証の手順を固定する

次に、サイトマップを触るたびに検査を回す約束を、CLAUDE.mdに書きます。CLAUDE.mdは強制される設定ではなく文脈として扱われるため、検証できる具体的な書き方のほうが守られやすい、とmemoryのページにあります。「hreflangを正しく保つ」ではなく、コマンドで書きます。

## 多言語サイトマップ
- 言語を追加・変更したら、完了前に必ず
  `python3 scripts/check_hreflang.py public/sitemap.xml` を実行する
- 終了コードが0になるまで修正を続ける
- 言語コードは ja / en / en-GB の形で書く。jp と UK は使わない
- 手動で全言語版の注釈を書き足さず、生成スクリプトを直す

最後の1行は設計の話です。注釈を手で書き足すと、言語が増えるたびに全ページが対象になり、戻りリンクの欠落が起きやすくなります。サイトマップを生成しているなら、直すのは生成側です。

hookで編集のたびに検査する

CLAUDE.mdの記述は守られない場合があります。サイトマップを編集した直後に必ず検査したいなら、PostToolUse のhookにします。hooksのガイドでは、Edit|Write のmatcherで、ファイルを編集するツールの後だけ動かす例が示されています。ここではそれを、サイトマップの編集だけに絞ります。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | grep -q sitemap || exit 0; python3 scripts/check_hreflang.py public/sitemap.xml >&2 || exit 2"
          }
        ]
      }
    ]
  }
}

ポイントは終了コード2です。hooksのリファレンスによれば、PostToolUse ではツールがすでに実行された後でも、終了コード2を返すとstderrがClaudeに届きます。終了コード0のhookのstderrはClaudeに見えません。そのためスクリプトの出力を >&2 でstderrに回し、失敗時に2で終わらせています。jq が必要です。

サイトマップ以外のファイルを編集したときは、grep が外れて即座に0で終わります。

PostToolUse で足りない場合は、Stop に同じ検査を置く選択肢もあります。Stop の終了コード2は、Claudeが止まるのを防いで作業を続けさせます。ただし毎回の応答終了で走るため、サイトマップを触っていない会話でも検査が動きます。テスト実行をどのイベントに置くかの比較は、hooksでテストを自動実行する方法にまとめています。設定全体はClaude Code Hooks完全ガイドで確認できます。

CIで回す — claude -pは検査の外に置く

デプロイ前の検査は、CIで同じスクリプトを直接呼ぶのが基本です。判定は決まった規則の突き合わせなので、LLMを挟まなくても結果は変わりません。Claude Codeの出番は、スクリプトを書く工程と、NGが出たあとの修正案を出す工程です。

修正案まで自動化するなら、非対話モードの claude -p に出力を渡せます。headlessのページには、ビルドエラーのファイルをパイプで渡して原因を説明させる例があります。

python3 scripts/check_hreflang.py public/sitemap.xml \
  | claude -p "このNG一覧を、sitemapの修正方針にまとめて"

この使い方は、検査の合否をClaudeに判断させるものではありません。合否は終了コードで決め、修正方針だけを読み物として受け取ります。-p の基本はClaude Code -pモードの記事で説明しています。構造化データの検証を同じ型で組む例は、JSON-LDの実装と検証にあります。

このスクリプトで分からないこと

スクリプトが保証するのは、サイトマップの注釈が公式の条件と矛盾しないことまでです。次の点は別の確認が要ります。

  • 注釈が検索結果に反映されるか: Googleは、hreflangやHTMLのlang属性でページの言語を判定していないと述べています。言語の判定は独自のアルゴリズムで行われます。注釈は、同じ内容の言語別ページを結びつけるためのものです
  • ページの中身が翻訳済みか: 重複とみなされるのは、主要コンテンツが翻訳されていないページだけです。テンプレートだけ翻訳したページに注釈を付けるのは想定された使い方ですが、その確認は人の仕事です
  • 未対応の書式: es-419 のようにISO 639-1とISO 3166-1 Alpha 2に載らないコードは、Googleがサポートしていません。スクリプトの形式検査は通過するので、必要なら禁止リストに足します
  • スクリプト指定のコード: zh-Hans(簡体字)や zh-Hant(繁体字)のように、ISO 15924の4文字でスクリプトを明示する書き方は、Googleが受け付けます。zh-Hans-US のように地域を続けることもできます。スクリプトの CODE_RE は4文字の2つ目を許す形なので、これらは通ります。ただし4文字の中身までは見ないため、zh-Abcd のような存在しないスクリプトも通ります
  • HTTPヘッダー方式: PDFなどにはLinkヘッダーで指定する方法があり、サイトマップの検査では見えません

すべての言語で双方向のリンクを維持するのが難しいときは、一部の言語を省略しても構いません。その場合も、新しく追加した言語は元の主要言語と双方向に結ぶことが重要です。このスクリプトは全言語の完全な相互参照を求めているため、意図的に省略している構成では、出たNGを仕様として許容するかどうかの判断が要ります。

まとめ

hreflangの不具合は、書いた直後には見えず、言語が増えたときに表に出ます。ドキュメントの条件を5つの判定に落とし、サイトマップ1つを読むスクリプトにすれば、検査は終了コードで済みます。

Claude Codeには、スクリプトの作成と修正を任せます。合否の判定は固定の規則に任せ、CLAUDE.mdの約束とhookで、サイトマップを編集するたびに検査が走る状態を作ります。

この記事を共有:XはてブLinkedIn