使い倒し研究所
SEO・LLMO

構造化データを手で書くのをやめた|HTMLから自動抽出する仕組みにした理由

PR本記事にはプロモーション(アフィリエイト広告)が含まれます。

記事を更新するたびに構造化データも直す運用は、必ず破綻します。ページ本文から自動で抽出する方式に変えたところ、本文と食い違うという事故がなくなりました。実装の考え方を書きます。

結論:構造化データは本文から抽出する

構造化データ(JSON-LD)を手で書くと、必ず本文とズレます。

記事を直したときに構造化データも直す、という運用は続きません。忘れます。そしてズレたまま公開されると、検索エンジンからは実態と違う情報を出しているサイトに見えます。

そこで方針を1つ決めました。

ページに書いていない内容を、構造化データに書かない。

具体的には、FAQの構造化データをHTML本文のFAQ部分から自動抽出する形にしました。設定ファイルにQ&Aを持たせていません。

なぜ設定ファイルに持たせないのか

一般的な実装は、設定ファイルにQ&Aを書き、そこからJSON-LDを生成します。楽なのですが、本文と二重管理になります。

方式 起きること
設定ファイルに持たせる 本文を直しても構造化データが古いまま
本文から抽出する 常に一致する

本文から抽出すれば、構造化データを更新し忘れることが原理的になくなります。 本文が唯一の情報源になるからです。

実装:決まった形で書いて、そこから抜く

記事のFAQは、決まったマークアップで書きます。

<div class="faq-item">
  <h3>質問文</h3>
  <p>回答文</p>
</div>

生成スクリプトはこれを走査して、JSON-LDを組み立てます。

def extract_faq(source):
    items = []
    for block in re.findall(r'<div class="faq-item">(.*?)</div>', source, re.S):
        q = re.search(r"<h3[^>]*>(.*?)</h3>", block, re.S)
        a = re.search(r"<p[^>]*>(.*?)</p>", block, re.S)
        if q and a:
            qt, at = strip_tags(q.group(1)), strip_tags(a.group(1))
            # 書きかけの目印が入っていないかを確認してから採用する
            if qt and at and not is_placeholder(qt):
                items.append((qt, at))
    return items

書きかけの箇所を弾く条件も入れています。(実際のコードでは、原稿に残す目印の文字列を検出しています) 未完成の内容が構造化データに混ざらないようにするためです。

注入はマーカーの中だけ

生成した構造化データは、HTMLの決まった位置に入れます。

<!-- AUTO-SEO:START 自動生成。編集は設定ファイルで -->
<script type="application/ld+json">
{ ... }
</script>
<!-- AUTO-SEO:END -->

このマーカーの内側だけを毎回書き換えます。 外側には触りません。

こうすると2つの効果があります。

  1. 手で書いた部分と混ざらない
  2. 「ここは自動生成だから触るな」がHTMLを読む人にも伝わる

コメントに「編集は設定ファイルで」と書いてあるのは、未来の自分に向けたメッセージです。半年後にHTMLを直接直そうとして、次の生成で消える——という事故を防ぎます。

冪等にする

生成処理は、何度実行しても同じ結果になるようにしてあります。

これが効くのは、差分が出たときだけ「何かが変わった」と分かるからです。毎回全ファイルが書き換わる実装だと、意図しない変更に気づけません。

$ ./build.sh
変更なし(冪等)。

この表示が出れば、生成物と公開中のものが一致していると分かります。

実装上の注意

HTMLを作り直す処理と、構造化データを注入する処理を分けている場合、作り直しのたびに注入内容が消えます。

対策として、作り直すときに既存の注入部分を保持するようにしました。

def keep_seo_block(new_html, path):
    """作り直しても、注入済みの構造化データを保持する"""
    if not os.path.exists(path):
        return new_html
    old = open(path, encoding="utf-8").read()
    m = SEO_BLOCK.search(old)
    if not m:
        return new_html
    return SEO_BLOCK.sub(lambda _: m.group(0), new_html, count=1)

これを入れる前は、実行するたびに全ファイルが変更扱いになっていました。差分確認が意味をなさない状態だったわけです。

何を生成しているか

このサイトで自動生成しているものです。

生成物 内容
JSON-LD 記事・著者・パンくず・FAQ
llms.txt AI向けのサイト要約
robots.txt AIクローラーの許可設定
sitemap.xml 全ページの一覧

すべて記事のファイルから自動で作られます。 記事を1本追加すれば、4つとも更新されます。

つまずきやすい点

robots.txt は継承しない

AIクローラーを個別に許可するとき、共通のルールは継承されません。

User-agent: *
Disallow: /private/

User-agent: GPTBot
Allow: /          ← /private/ の禁止が効かない

個別のブロックを書くなら、そのブロックにも同じDisallowを書く必要があります。生成スクリプトでは、全ブロックに同じ内容を出力するようにしています。

実在しない情報を書かない

構造化データには、いくらでも情報を追加できます。しかしページに書いていない実績や数字を足してはいけません。

自動生成にしておくと、そもそも本文にない情報が入りようがなくなります。仕組みで防ぐのが確実でした。

検証は外部ツールで

生成したJSON-LDが正しいかは、検索エンジンが提供する検証ツールで確認できます。

あわせて、生成後にJSONとしてパースできるかを自動でチェックしておくと、壊れた状態で公開する事故を防げます。

よくある質問

構造化データは設定ファイルと本文のどちらで管理すべきですか?

本文から抽出する方式を推奨します。設定ファイルに持たせると本文と二重管理になり、記事を更新したときに構造化データが古いまま残ります。本文を唯一の情報源にすれば、ズレが原理的に起きません。

自動生成した構造化データをHTMLのどこに入れればいいですか?

コメントでマーカーを置き、その内側だけを書き換える方式が安全です。手で書いた部分と混ざらず、自動生成の範囲がHTMLを読む人にも伝わります。

生成処理を何度実行しても差分が出てしまいます。

生成が冪等になっていない可能性があります。HTMLの再生成と構造化データの注入を分けている場合、再生成のたびに注入内容が消えるため、毎回差分が出ます。再生成時に既存の注入部分を保持する処理を入れてください。

まとめ

構造化データを手で管理するのはやめました。

手で書くと、いつか必ずズレます。ズレない仕組みにするほうが、注意深く運用するより確実でした。

関連:llms.txtを実装した結果静的サイトジェネレータを自作した

この記事を書いた人
カズト(ETERNAL d.c.t)

ETERNAL d.c.t 代表。Web制作・生成AI導入支援・ライバー事務所運営を手がける。Claude Code / Codex CLI / n8n / Google Apps Script を実務で毎日使い、自社サイト・学習アプリ・業務自動化を内製している。このサイトでは、実際に自分が金を払って使ったツールだけを扱う。