Claude Media
Claude CodeでJSON-LDを抽出する — extructで@graphと欠損を扱う

Claude CodeでJSON-LDを抽出する — extructで@graphと欠損を扱う

script要素のJSON-LDをextructで取り出し、@graph・型の配列・欠損フィールド・壊れたscriptをfixtureのpytestで検証する手順。Claude Codeに渡す完了条件も示します。

HTMLからProductやArticleの情報を集めるとき、CSSセレクタを書く前に <script type="application/ld+json"> を探すと、セレクタ改修の影響を受けにくい取得ができます。Claude Codeにこの抽出処理を書かせるなら、ライブラリはextruct、検証は「保存済みHTMLをfixtureにしたpytest」の組み合わせが扱いやすい構成です。

JSON-LDは、<script> 要素にJSON形式のデータを埋め込む仕組みです。W3CのJSON-LD 1.1仕様は、type 属性を application/ld+json にした script 要素に置く方法を定めています。画面用のタグとは別のブロックなので、デザイン改修でclass名が変わっても影響を受けにくい入口になります。

ただし、取り出すだけでは終わりません。1ページに複数のscriptがある、@graph で包まれている、@type が配列になっている、項目が欠けている、JSON自体が壊れている。こうしたページを素のループで読むと、KeyError になるか、気づかないうちに0件になります。この記事は、extruct 0.18.0(Python 3.12)で試した挙動をもとに、3種類のfixtureで失敗を再現し、テストで固定するところまで進めます。サイト側にJSON-LDを出力する実装はClaude CodeでJSON-LD構造化データを実装し、テストで検証する手順が扱っています。

セレクタ方式とJSON-LD抽出の使い分け

JSON-LDが向くのは、ページが「商品」「記事」「イベント」のような型付きの情報を機械向けに公開しているときです。セレクタ方式は画面に見えている要素を読む方法で、JSON-LD方式はサイトが機械向けに用意した記述を読む方法です。

観点セレクタ方式(BeautifulSoup等)JSON-LD抽出(extruct)
読む対象セレクタ方式(BeautifulSoup等)画面上のタグとclassJSON-LD抽出(extruct)script 内のJSON
デザイン改修の影響セレクタ方式(BeautifulSoup等)受けやすいJSON-LD抽出(extruct)受けにくい
取れる項目セレクタ方式(BeautifulSoup等)HTMLに見えているものなら何でもJSON-LD抽出(extruct)サイトがJSON-LDに入れた項目だけ
画面との一致セレクタ方式(BeautifulSoup等)見えているものを読むので一致するJSON-LD抽出(extruct)一致は保証されない

最後の行が落とし穴です。JSON-LDは運営側が別途書いたデータなので、画面の価格とJSON-LDの価格がずれていても不思議ではありません。金額のように業務で使う値は、JSON-LDを入口にしつつ、画面側の値と突き合わせる前提で組むと安全です。

セレクタ方式のテストの組み立て方は、BeautifulSoupのスクレイパーをfixtureで検証する記事にあります。ここで使うfixture中心の考え方は、そちらと同じです。

extructでJSON-LDとmicrodataを取り出す

extructは、HTMLに埋め込まれたメタデータを取り出すライブラリです。READMEによると、W3C Microdata、埋め込みJSON-LD、Microformat、Open Graph、RDFa(実験的)、Dublin Coreに対応しています。インストールは pip install extruct です。

呼び出しは extruct.extract() にHTML文字列を渡すだけです。取り出す形式を syntaxes で絞れます。

python -m venv .venv
.venv/bin/pip install extruct pytest
import extruct
 
html = open("fixtures/product_graph.html", encoding="utf-8").read()
data = extruct.extract(html, syntaxes=["json-ld", "microdata"])
print(data["json-ld"])

戻り値は、形式名をキーにした辞書です。json-ld の値は、ページ内のJSON-LDをscript順に並べたリストになります。

URLを渡して取得から行うコマンドも付属します。pip install 'extruct[cli]' で requests が入り、次のように使えます。

extruct "https://example.com/" --syntaxes microdata json-ld

CLIはライブのURLにアクセスするため、テストの入力には使わず、手元でページの中身をのぞく用途に留めます。

3種類のfixtureで失敗を先に作る

先にHTMLを3つ作ります。1つ目は、@graph を使う Product・Article・Organization と、配列で書かれた Event が同居するページです。Event の片方には startDate がありません。

<script type="application/ld+json">
{"@context":"https://schema.org","@graph":[
 {"@type":"Organization","@id":"#org","name":"Example Shop"},
 {"@type":"Product","name":"Desk Lamp","sku":"L-100",
  "offers":{"@type":"Offer","price":"4980","priceCurrency":"JPY"}},
 {"@type":"Article","headline":"Lamp review","datePublished":"2026-10-01"}
]}
</script>
<script type="application/ld+json">
[{"@context":"https://schema.org","@type":"Event","name":"Launch","startDate":"2026-11-01"},
 {"@context":"https://schema.org","@type":"Event","name":"No date"}]
</script>

2つ目は、JSONが壊れたscriptを含むページです("Broken" の後ろのカンマが抜けています)。

<script type="application/ld+json">{"@type":"Product","name":"Broken" "sku":"x"}</script>
<script type="application/ld+json">{"@type":"Product","name":"OK"}</script>

3つ目は、JSON-LDではなくmicrodata(itemscope / itemprop)で書かれた Product です。

<div itemscope itemtype="https://schema.org/Product">
 <span itemprop="name">Mug</span>
 <div itemprop="offers" itemscope itemtype="https://schema.org/Offer">
  <meta itemprop="price" content="1200"><meta itemprop="priceCurrency" content="JPY">
 </div>
</div>

1つ目のfixtureを extruct.extract() に通すと、json-ld は3要素のリストで返ります。1要素目は @graph を持つ辞書、2要素目と3要素目が配列から展開された Event です。つまり、配列は展開されますが、@graph は展開されません。@type が Product の辞書を探す単純なループでは、1要素目の中にいる Product を見落とします。

@graphと型の配列を平らにする

W3C仕様では、@graph を持つオブジェクトをグラフオブジェクトと呼びます。サイトによっては、すべてのノードを @graph に入れて1つのscriptで出します。取り出し側は、トップレベルと @graph の中身を同列に扱うと決めておくのが簡単です。

もう1つの注意点が @type です。"@type": ["Product", "IndividualProduct"] のように配列で書くことも許されるため、== "Product" の比較だけでは取りこぼします。

# jsonld_tools.py
import logging
from lxml import etree
from extruct.jsonld import JsonLdExtractor
from extruct.utils import parse_html
 
log = logging.getLogger(__name__)
 
 
def extract_jsonld(html: str) -> list[dict]:
    """script単位でtryし、壊れた1本が他を巻き込まないようにする。"""
    tree = parse_html(html, "utf-8")
    items: list[dict] = []
    for node in tree.xpath('//script[@type="application/ld+json"]'):
        try:
            items += JsonLdExtractor().extract(etree.tostring(node, encoding="unicode"))
        except ValueError as e:
            log.warning("skip broken JSON-LD: %s", e)
    return items
 
 
def flatten_graph(items: list[dict]) -> list[dict]:
    """@graph の中身を平らにし、トップレベルのnodeと同列に並べる。"""
    flat: list[dict] = []
    for it in items:
        if "@graph" in it:
            flat += [n for n in it["@graph"] if isinstance(n, dict)]
        else:
            flat.append(it)
    return flat
 
 
def nodes_of_type(items: list[dict], *types: str) -> list[dict]:
    out = []
    for n in flatten_graph(items):
        t = n.get("@type", [])
        t = [t] if isinstance(t, str) else t
        if set(t) & set(types):
            out.append(n)
    return out

parse_html の第2引数は文字コードです。0.18.0では必須の位置引数でした。nodes_of_type(items, "Product") が、@graph の中の Product も配列型の Product も返します。

extract_jsonld が extruct.extract() を使わず、script要素を1本ずつ読んでいる理由は、次の節にあります。

壊れたscriptが1本あると、全部が消える

extruct.extract() には errors という引数があり、strict(既定)、log、ignore を選べます。READMEの本文だけでは、この3つが壊れたJSON-LDにどう効くかは読み取れないので、2つ目のfixtureで試しました。

呼び出し結果
errors="strict"(既定)結果JSONDecodeError で例外
errors="ignore"結果戻り値が {}(json-ld キー自体が無い)
errors="log"結果例外をログに出して {}
上のscript単位の読み方結果[{'@type': 'Product', 'name': 'OK'}]

ignore と log は、壊れていない OK の方まで消えます。形式単位で try しているためで、data["json-ld"] と書くと KeyError になります。「エラーを無視」を選んだつもりが、ページ全体の構造化データを失う設定になっています。

もう1つ、0.18.0では空白だけのscript(<script type="application/ld+json"> </script>)でも例外になりました。CMSがテンプレートの穴を空のまま出力することはあります。GitHubのmasterにあるソースは、空のscriptを読み飛ばす実装になっているので、バージョンによって挙動が違います。手元のバージョンで確かめられるよう、空script用のfixtureをテストに加えておきます。

<script type="application/ld+json">   </script>
<script type="application/ld+json">{"@type":"Product","name":"OK"}</script>

なお、末尾のカンマ({"name":"Broken",})のようなJSONは、0.18.0では例外にならず値が取れました。JavaScript風のコメントや末尾カンマを許す読み方が入っているためです。壊れ方によって「救われる」ものと「例外になる」ものがあり、その境目はバージョンに左右されます。

欠損・型の揺れをテストで固定する

3つのfixtureができたら、挙動をテストにします。ネットワークに出ないので、何度回しても結果は同じです。

# test_jsonld.py
from pathlib import Path
from jsonld_tools import extract_jsonld, nodes_of_type
 
FIX = Path(__file__).parent / "fixtures"
def load(name): return extract_jsonld((FIX / name).read_text(encoding="utf-8"))
 
def test_graph_and_list_are_flattened():
    items = load("product_graph.html")
    assert [p["name"] for p in nodes_of_type(items, "Product")] == ["Desk Lamp"]
    assert len(nodes_of_type(items, "Event")) == 2
 
def test_missing_field_is_none_not_keyerror():
    events = nodes_of_type(load("product_graph.html"), "Event")
    assert [e.get("startDate") for e in events] == ["2026-11-01", None]
 
def test_broken_script_does_not_hide_valid_one():
    items = load("broken.html")
    assert [n["name"] for n in nodes_of_type(items, "Product")] == ["OK"]
 
def test_empty_script_is_skipped():
    items = load("empty.html")
    assert [n["name"] for n in nodes_of_type(items, "Product")] == ["OK"]

3つ目と4つ目のテストが、前節の「全部消える」を防ぐ番人です。extruct.extract() をそのまま使う実装に戻すと、この2本が落ちます。

欠損の扱いには、もう1つ決めごとが要ります。offers は、辞書で来るページと、配列で来るページがあります。試すと、"offers":[{"price":"100"},{"price":"90"}] はそのままリストで返りました。price も数値ではなく文字列("4980")のことがあります。取り出す側で型をそろえる関数を1つ作り、そこにもfixtureを足します。

def as_list(v):
    return v if isinstance(v, list) else ([] if v is None else [v])
 
def lowest_price(product: dict) -> int | None:
    prices = [int(o["price"]) for o in as_list(product.get("offers")) if "price" in o]
    return min(prices) if prices else None

offers が無い商品は None を返す、という欠損時の戻り値も、テストで決めておきます。

microdataとJSON-LDで出力の形が違う

3つ目のfixtureは、同じ Product でもJSON-LDとは返り方が異なることを示します。syntaxes=["microdata"] の戻り値は、型が type: 'https://schema.org/Product'、項目が properties の中に入る形でした。JSON-LDの @type: 'Product' とは形が違うので、同じ nodes_of_type は使えません。

READMEには、出力を @context と @type を持つ平らな辞書にそろえる uniform=True の説明があります。microdataに使うと、次のように返りました。

extruct.extract(html, syntaxes=["microdata"], uniform=True)
# {'microdata': [{'@context': 'https://schema.org', '@type': 'Product',
#   'name': 'Mug',
#   'offers': {'@type': 'Offer', 'price': '1200', 'priceCurrency': 'JPY'}}]}

JSON-LDとmicrodataの両方を拾うサイトを相手にするなら、uniform=True で形をそろえてから、同じ nodes_of_type に通す設計が見通しよく収まります。READMEは、RDFaについてはまだ形がそろわないと書いています。

Claude Codeに渡す完了条件

Claude Codeの公式ドキュメントは、結果を確かめる手段(テスト、期待出力との突き合わせ)を渡すと、Claudeが実行と修正を自分で繰り返せると説明しています。抽出処理では、前節のfixtureとテストがその手段になります。

CLAUDE.md には、次のような規約を書いておくと、依頼のたびに繰り返さずに済みます。

## JSON-LD抽出の規約
- 抽出は extruct を使い、`extruct.extract()` の `errors="ignore"` / `"log"` は使わない
  (壊れたscript1本でjson-ld全体が消えるため)。script単位でtryする `extract_jsonld()` を通す
- `@graph` と `@type` の配列を考慮する。比較は `nodes_of_type()` 経由にする
- 任意項目は `.get()` で読む。`KeyError` を出さず、欠損は `None` で返す
- 変更後は `pytest` を実行し、全件通るまで完了としない
- fixtureはバイト列のまま保存し、本番のURLにはテストからアクセスしない

依頼は、先に失敗するテストを書かせる順にします。

fixtures/ の3つのHTMLを入力に、JSON-LD抽出の失敗するテストを先に書いてください。
@graph、@typeの配列、startDateの欠損、壊れたscriptをそれぞれ1本ずつ。
テストが落ちることを確認してから、jsonld_tools.py を実装し、pytestを全件通してください。

スクリプトの出力をそのまま別の処理に渡したい場合は、公式の「Pipe Claude into scripts」にある claude -p が使えます。たとえば抽出した Product のJSONを標準入力で渡し、要約や分類を任せる使い方です。抽出自体は決まった入力に決まった出力を返す処理なので、Claudeには渡さず、pytestで固定します。

取り出したデータを使う前に見る点

抽出ロジックが固まっても、次の点は残ります。

  • サイトの利用規約とrobots.txt。取得してよい範囲は、サイトごとに決まっています。抽出コードの正しさとは別の問題です。
  • JSON-LDが空、またはテンプレートのまま。CMSの初期値のまま公開されたページは、name が空文字のことがあります。空文字を欠損として扱うかどうかも、テストで決めます。
  • 画面との不一致。価格や在庫のように変動する値は、JSON-LDが古いままのページがあり得ます。

自サイトのJSON-LDが正しく出ているかを調べたい場合は、取り出し側のコードをそのまま自分のテンプレートの出力に向けて、出力側の検証に流用できます。出力の実装はClaude CodeでJobPosting構造化データを実装し、掲載期限まで検証する手順のように、型ごとの必須項目まで含めて検証します。

よくある質問

extructのJSON-LD抽出はJavaScriptで後から挿入されたscriptも拾えますか。

extructにはHTML文字列(またはパース済みの木)を渡す作りで、JavaScriptを実行する機能の記載はありません。ブラウザ実行後に挿入されるJSON-LDは、レンダリング後のHTMLを別に取得して渡す必要があります。

@graph 内のノード同士の参照(@id)はたどれますか。

試した範囲では、戻り値はJSON-LDをそのまま辞書にしたもので、{"@id": "#org"} のような参照を実体に置き換える処理は含まれません。必要なら @id を索引にした辞書を自分で作ります。

まとめ

JSON-LDの抽出で壊れるのは、セレクタではなく「想定外の形」です。@graph、@type の配列、欠けた項目、壊れたscript。この4つをfixtureにしておけば、抽出処理を直すたびに、Claude Codeが自分で実行して確かめられます。errors="ignore" は一見安全な設定ですが、script1本の破損でjson-ld全体が消える点だけは、先にテストで押さえておく価値があります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. code.claude.com/docs/en/common-workflows
  3. github.com/scrapinghub/extruct
  4. w3.org/TR/json-ld11
この記事を共有:XはてブLinkedIn