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等)画面上のタグとclass | JSON-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 pytestimport 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-ldCLIはライブの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 outparse_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 Noneoffers が無い商品は 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全体が消える点だけは、先にテストで押さえておく価値があります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。