起きていたこと
不動産会社のWeb面を運用している。コーポレートサイト、サービスサイト、LPの3ドメイン構成で、それぞれに JSON-LD(schema.org)を実装していた。
ある日、3ドメインの構造化データを突き合わせて気づいた。同じ会社なのに、@id が3系統に割れていた。
サイトA: "@id": "https://a.example.jp/#organization"
サイトB: "@id": "https://b.example.jp/#organization"
サイトC: "@id": "https://c.example.jp/#company"
人間が読めば同じ会社だが、機械にとって @id は実体の識別子そのものだ。識別子が違えば別実体。検索エンジンとLLMクローラーには「よく似た3つの会社」が見えていたことになる。
さらに悪いことに、住所の書式(ビル名の有無)、電話番号の書式(045-... と +81-45-...)、代表者名の表記まで媒体ごとに揺れていた。名寄せの手がかりも壊れていた。
方針:正典(canonical)を1つ決めて、全ドメインから参照する
RDFの発想と同じで、実体の @id はどのページに書かれていても1つのURIであるべきだ。
- 会社・代表者・住所の「正典URI」をコーポレートサイト側に定める
- 会社:
https://a.example.jp/#organization - 代表:
https://a.example.jp/#representative
- 会社:
- 他ドメインのJSON-LDは、実体を定義せず参照する
- LPの
publisherやaboutには{"@id": "https://a.example.jp/#organization"}とだけ書く - LP固有の実体(
WebPageやFAQPage)だけをLPのURIで定義する
- LPの
- NAP(名称・住所・電話)の値は1か所のソースから生成する
- 電話番号は国番号付きの国際表記に統一する
(schema.org/telephoneは+81-45-287-1058のような区切り付きも許容する。
厳密な E.164 は+81452871058と区切りなし。どちらを採るにせよ全ドメインで同一の文字列にする) - 住所は番地・ビル名まで省略しない
- 電話番号は国番号付きの国際表記に統一する
実装で効いた小技
参照だけのノードには型を書かない
参照側で @type まで書くと、正典側と型の食い違いが起きたときに矛盾ノードが生まれる。参照は {"@id": "..."} の1プロパティに徹する。
@graph の実体数をテストで固定する
JSON-LDは壊れても画面に出ない。開発者ツールにもエラーが出ない。だからCIで守るしかない。
test('正典の@graphに未解決の@id参照がない', async () => {
const graph = await fetchJsonLd(CANONICAL_URL)
const defined = new Set(graph.map(n => n['@id']))
const refs = collectIdRefs(graph) // ネストを走査して {"@id": ...} を集める
for (const r of refs) {
expect(defined.has(r) || EXTERNAL_CANONICAL_IDS.has(r)).toBe(true)
}
})
このテストは後日、リファクタで #company に「逆戻り」しかけた変更を実際に検知した。書いた瞬間より、忘れた頃に効く。
検証は必ず公開URLで行う
編集画面のプレビューやローカルのビルド結果ではなく、curl で公開URLを取得してパースする。CMSやホスティングのパイプラインで何が起きるかは、出口でしか確認できない。
# grep -oP は GNU grep 限定で、かつ `.` が改行に一致しないため
# 整形済み(複数行)の JSON-LD を取り逃がす。複数ブロックある場合も json.tool は失敗する。
# 全ブロックを取り出して1件ずつ検証する。
curl -s https://a.example.jp/ | python - <<'EOF'
import sys, json, re
html = sys.stdin.read()
blocks = re.findall(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
html, re.S | re.I)
assert blocks, "JSON-LD が1件も無い"
for b in blocks:
json.loads(b) # 壊れていれば例外で落ちる
print(f"OK: {len(blocks)} blocks")
EOF
結果
- 3ドメインの会社・代表者・住所の
@idが1系統に統一された -
sameAsで宣言していたSNSプロフィールとの対応も、単一実体にぶら下がる形になった - 統一後、LLM系クローラー向けの到達性テスト(各UAでの取得検証)でも、どのドメイン経由でも同一実体として辿れることを確認した
まとめ
-
@idは「ページの飾り」ではなく実体の識別子。ドメインをまたぐなら正典を1つ決めて参照に徹する - 表記揺れ(電話・住所・代表者名)は名寄せを壊す。値は1か所から生成する
- JSON-LDの破損は画面に出ない。公開URLに対するCIテストでしか守れない
執筆・運用:株式会社HY(神奈川県横浜市西区/相続・空き家・終活の不動産相談窓口)
本記事は、自社が実際に運用する3ドメインの構造化データを1系統へ統一した際の作業記録です。
会社情報:https://hyconsulting2021.jp/