ほとんどのスクレイパーは同じ方法で作られている。ページを開き、価格を保持する要素を見つけ、CSSセレクタを書き、すべてのフィールドについてそれを繰り返す。これはサイトが再デザインされたり、クラス名が変更されたり、価格が新しいコンポーネントに包まれたりするまでは機能する。その後、セレクタは何も返さなくなるか、もっと悪いことに間違ったものを返すようになり、それでもパイプラインは動き続ける。
多くのページは、すでに機械向けの形式で同じデータを公開している。検索エンジンがそれを求め、サイト側がそれを提供し、それはずっとページのソースの中に存在していた。このチュートリアルでは、それを見つけ、数十行のコードで抽出し、データが欠落していたり不完全だったりする場合にどう対処するかを示す。そして以下の実例が示すように、それはドキュメントが示唆するよりも頻繁に起こる。
要点
- JSON-LDは2024年版Web Almanacでは41%のページに出現しており、2022年の34%から増加している。製品、記事、イベント、組織については、それがページ上で最も安定した情報源であることが多い。
- 構造化データはページのレイアウトよりもはるかに変化が少ない。サイトが検索結果のためにそれに依存しているからだ。セレクタは再デザインで壊れるが、構造化データは通常それを生き延びる。
- 常に完全とは限らない。ある製品ページでは、画面上のバリアントには完全なレコードを公開し、他のすべてのバリアントには裸のリンクしか公開しないことがある。すべてのレコードを検証すること。
- 最も堅牢な抽出器は、まず構造化データを試し、次に埋め込みJSONを試し、最後にCSSセレクタを試し、どれを使用したかを記録する。
Webページにおける「構造化データ」とは
機械可読データが一般的に存在する場所は4つある。
| ソース | 見た目 | 典型的な内容 |
|---|---|---|
| JSON-LD | <script type="application/ld+json"> ブロック | schema.orgオブジェクト: Product、Offer、NewsArticle、Organization、Event、BreadcrumbList |
| マイクロデータ | 表示要素上のitemprop属性 | 同じschema.org語彙が、マークアップ全体に散らばっている |
| Open Graphとmetaタグ | <meta property="og:..."> | タイトル、説明、画像、時には価格 |
| 埋め込みアプリケーション状態 | ページのJavaScriptが読み込む大きなJSONオブジェクト。例えば__NEXT_DATA__ | ページが表示するほぼすべて、そしてそれ以上 |
HTTP Archiveの2024年版Web Almanacは、Web全体でのこれらの普及度を測定した。JSON-LDは「2022年の34%から2024年には41%」に成長し、マイクロデータは26%で横ばい、RDFaとOpen Graph(ほとんどのサイトが追加するソーシャルシェア用タグを含む)はそれぞれ66%と64%のページに出現した。JSON-LDはまず手を伸ばすべきものである。なぜならそれはレイアウト全体に散らばった属性ではなく、自己完結したデータのブロックだからだ。
セレクタよりも信頼できる理由
CSSセレクタはページの見た目に依存する。構造化データはページの意味に依存する。サイトはページの見た目を絶えず変える。しかし構造化データが伝える内容を変えることははるかに少ない。なぜならそれは検索エンジンのリッチリザルトに供給されており、それを壊すことはサイトにとって目に見えるコストになるからだ。
また、構造化データはより正直に失敗する。マッチしなくなったセレクタは、別の要素に密かにマッチしてしまい、もっともらしい間違った値を返すことがある。これはsilent failure rateで述べられている種類のエラーだ。JSON-LDオブジェクトはpriceフィールドを含むか含まないかのどちらかであり、これは検証を単純にする。
PythonでJSON-LDを抽出する
標準ライブラリで十分である。この抽出器は、すべてのJSON-LDブロックを収集し、壊れたブロックにも対応し、サイトがオブジェクトをネストするために使うコンテナ、つまりリスト、@graph、そしてschema.orgが製品バリアントに使うhasVariantを辿る。
import json
from html.parser import HTMLParser
class _Collector(HTMLParser):
"""Collect JSON-LD blocks and embedded JSON state from an HTML page."""
def __init__(self):
super().__init__()
self.blocks, self._buf, self._kind = [], None, None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "script" and a.get("type", "").lower() == "application/ld+json":
self._buf, self._kind = [], "json-ld"
elif tag == "script" and a.get("id") == "__NEXT_DATA__":
self._buf, self._kind = [], "next-data"
def handle_data(self, data):
if self._buf is not None:
self._buf.append(data)
def handle_endtag(self, tag):
if tag == "script" and self._buf is not None:
raw = "".join(self._buf).strip()
try:
self.blocks.append((self._kind, json.loads(raw)))
except json.JSONDecodeError:
self.blocks.append((self._kind + "-invalid", raw[:200]))
self._buf = self._kind = None
def _walk(node):
"""Yield every JSON-LD object, flattening lists and nested containers."""
if isinstance(node, list):
for item in node:
yield from _walk(item)
elif isinstance(node, dict):
yield node
for key in ("@graph", "mainEntity", "itemListElement", "hasVariant"):
if key in node:
yield from _walk(node[key])
def _types(obj):
t = obj.get("@type", [])
return {t} if isinstance(t, str) else set(t)
def jsonld_objects(html, wanted_type=None):
"""Every JSON-LD object on the page, optionally filtered by schema.org type."""
collector = _Collector()
collector.feed(html)
objs = [o for kind, data in collector.blocks if kind == "json-ld" for o in _walk(data)]
return [o for o in objs if wanted_type is None or wanted_type in _types(o)]
実際のGuardianの記事に対してjsonld_objects(html, "NewsArticle")を実行すると、セレクタを一切使わずに見出し、公開と更新のタイムスタンプ、著者を返す。このタイムスタンプだけでも労力に見合う価値がある。それらは正確で、機械可読であり、サイト内のすべての記事にわたって一貫している。
製品の正規化
価格は入れ子になったoffersの中にあり、時には単一のOffer、時には価格範囲を持つAggregateOfferであるため、製品にはもう少し注意が必要である。
def products(html):
"""Return normalised product records found in a page's JSON-LD."""
out = []
for obj in jsonld_objects(html, "Product"):
offers = obj.get("offers") or {}
offer = offers[0] if isinstance(offers, list) and offers else offers
if isinstance(offer, dict) and "AggregateOffer" in _types(offer):
price = offer.get("lowPrice")
else:
price = offer.get("price") if isinstance(offer, dict) else None
brand = obj.get("brand")
out.append({
"name": obj.get("name"),
"sku": obj.get("sku") or obj.get("gtin13") or obj.get("mpn"),
"brand": brand.get("name") if isinstance(brand, dict) else brand,
"price": float(price) if price not in (None, "") else None,
"currency": offer.get("priceCurrency") if isinstance(offer, dict) else None,
"availability": (offer.get("availability") or "").rsplit("/", 1)[-1] if isinstance(offer, dict) else None,
})
return out
標準的な製品とオファーを持つページに対して実行すると、ページのスタイルに関わらず{"name": "Trail Runner", "sku": "TR-01", "brand": "Acme", "price": 89.0, "currency": "EUR", "availability": "InStock"}のようなクリーンなレコードを返す。
構造化データが不完全な場合: 実例
ドキュメントの例はこれを簡単そうに見せる。実際のページはもっと乱雑であり、一つ例を示す価値がある。
大手Shopifyストアの、人気の靴の製品ページに対してこの抽出器を実行した。ページのJSON-LDはProductGroup(バリアントで販売される製品のためのschema.orgタイプ)を記述しており、製品名、ブランド、説明、画像、そして49個のバリアントを含んでいた。そのうち完全な製品として価格$100.00と在庫状況を持っていたのはわずか7個で、画面上に表示されている色のサイズ違いだった。他の42個、つまり他のすべての色とサイズは、裸の参照、つまりタイプとURLだけで、それ以外は何もなかった。レビューの評価は2つ目の別のJSON-LDブロックに入っていた。
つまり構造化データはページを記述していたのであって、カタログを記述していたのではない。「すべてのバリアントはJSON-LDの中にある」と仮定したパイプラインは、1つの色に密かに価格をつけ、残りには何も記録しなかっただろう。
同じストアは各製品の公開JSON表現も公開しており、これは一致していた。その色については7個のバリアントがあり、それぞれに価格と在庫フラグがあった。しかしそこでは価格は10000という小さい単位で来ていた。正規化せずに両方のソースを混ぜたパイプラインは、同じ靴を$100と一万ドルの両方で記録しただろう。
そこから3つの教訓が導かれ、それらはShopifyをはるかに超えて適用できる。
- 検証すること、仮定しないこと。 パースできるJSON-LDブロックは完全なレコードとは限らない。必要なすべてのフィールドが存在することを確認し、得られたものを期待していたものと照合すること。
- 構造化データが部分的な場合は参照を辿ること。 バリアントのURL、埋め込みJSON、公開の製品エンドポイントはしばしば隙間を埋めてくれ、通常はページ自体よりもクリーンである。
- 単位を明示的に正規化すること。 小さい単位、価格範囲、税込みと税抜きの価格、そして通貨はすべて、仮定によってではなくコードの中で処理する必要がある。
ソースを記録するフォールバックチェーン
各要素をチェーンとして組み合わせる。まず構造化データを試し、次に埋め込みJSONを試し、最後にセレクタを試し、それぞれのレコードをどれが生成したかのメモを残す。
REQUIRED = ("name", "price", "currency")
def extract_product(html, embedded=None, css_fallback=None):
for source, candidates in (
("json-ld", products(html)),
("embedded-json", embedded(html) if embedded else []),
("css", css_fallback(html) if css_fallback else []),
):
for record in candidates:
if all(record.get(f) not in (None, "") for f in REQUIRED):
return {**record, "source": source}
return None
sourceフィールドはすぐにその存在価値を示す。常にjson-ldレコードを生成していたサイトがcssレコードを生成し始めたら、そのサイトの構造化データが変化したか消えたということであり、セレクタのフォールバックも壊れる前にそれを知りたいはずだ。これはターゲットヘルススコアへの有用な入力でもある。
Web Scraping APIでの実施方法
ShifterのWeb Scraping API経由でページを取得すれば、自分でブラウザを動かさなくても同じアプローチが機能する。APIのextract_rulesパラメータはCSSセレクタをJSONフィールドにマッピングし、そのhtml出力は要素の内部HTMLを返すため、JSON-LDのscriptを選択するルールは、パース対象の生のブロックをそのまま返してくれる。
{
"jsonld": { "selector": "script[type='application/ld+json']", "output": "html" }
}
単一のルールは最初にマッチした要素を返すため、複数のJSON-LDブロックを持つページでは、完全なHTMLをリクエストして上記の抽出器をそれに対して実行すること。JavaScriptで構造化データを注入するページにはrender_js=1をどちらのアプローチとも組み合わせて使い、製品JSONのURLなどJSONエンドポイントを直接取得する場合はパース済みの本文を得るためにauto_parser=1を使うこと。欠落しているフィールドはリクエストを失敗させるのではなくnullとして返ってくるため、上記の検証してからフォールバックするパターンに適合する。完全な構文は抽出ルールのドキュメントにある。
構造化データが与えてくれないもの
構造化データは、サイトが検索エンジン向けに公開することを選んだものを記述する。表示されているページより遅れていたり、サイトが公開する気のないフィールドを省いていたり、画面上のバリアントではなくデフォルトのバリアントを記述していたりすることがある。特に価格については、サンプルベースで表示されているページと照合すること。なぜなら古いJSON-LDの価格と現在のページ上の価格は、異なる視点から見ればどちらも「正しい」からだ。そして訪問者の所在地によってコンテンツが変わるサイトでは、構造化データも変わるため、フライトとホテルの価格のスクレイピングで扱われているように、関心のある市場からそれを取得すること。
結論
次のセレクタを書く前に、ページソースを開いてapplication/ld+jsonを検索すること。Webの大きな割合において、欲しいデータはすでにそこに存在し、共有された語彙でラベル付けされており、周囲のレイアウトよりもはるかに変化しにくい。
まずそれを抽出し、検証し、不完全な場合は埋め込みJSONへ、そしてセレクタへとフォールバックし、各レコードがどのソースから来たかを記録すること。あなたの抽出器はより壊れにくくなり、壊れたときにはそれを教えてくれるようになる。
ソースと参考文献
- HTTP Archive, Web Almanac 2024: Structured Data, 11 November 2024.
- Schema.org, Product, ProductGroup, Offer and AggregateOffer.
- Shifter, Web Scraping API extraction rules and rendering JavaScript documentation.