LLMスクレイパーの作り方:取得・軽量化・抽出・検証の4層設計
LLMスクレイパーとは、ページの内容をLLMに渡し、JSONスキーマに沿った構造化データを取り出すスクレイピングの方式です。CSSセレクタやXPathの代わりに、「商品名・税込価格・在庫をJSONで返す」といった指示とスキーマによって抽出します。
作り方は以下の4段階です。
- HTMLを取得する。
- script・nav等を除去し、Markdownやテキストに軽量化する。
- PydanticやZodのスキーマと一緒にLLMへ渡す。
- 返却されたJSONを検証する。
LLMが担当するのは3のみであり、1のブロック対策や動的ページのレンダリングは別途必要です。取得には Bright Data の Web Unlocker API を利用でき、主要サイトには1300以上の構築済みスクレイパーが用意されているため、そもそもLLM抽出が不要となる場合もあります。
LLMスクレイパーとは何か、従来のスクレイパーと何が異なるのか?
LLMスクレイパーは、テキストの「意味」を手がかりに値を取り出すスクレイピング手法の総称であり、以下の3つの形態で提供されています。
- 取得基盤型:ブロック対策付きでHTMLやJSONを返却し、抽出処理は別層に任せる形態(例:Bright Data)。
- API型:URLを送信した場合にMarkdownや構造化JSONが返却される形態(例:Firecrawl)。
- ライブラリ型:自社環境で動作するコードとして組み込む形態(例:Crawl4AI、ScrapeGraphAI、llm-scraper)。
重要な前提として、LLMはログイン・CAPTCHA・JavaScriptレンダリング・アクセス制限のいずれも解決しません。ページ本文が手元に届かなければ、LLMには抽出する対象自体が存在しないためです。
| 比較軸 | セレクタ型(従来) | LLM抽出型 |
|---|---|---|
| 抽出方法 | CSSセレクタ・XPathによる位置指定 | 自然言語の指示とスキーマによる意味指定 |
| サイト構造変更への耐性 | 低い(クラス名変更で破損) | 高い(文言が残っていれば抽出可能) |
| コスト | ほぼ取得コストのみ | 取得コストに加えてトークン課金が発生 |
| 速度 | 高速(HTML解析のみ) | LLM呼び出し分のレイテンシが発生 |
| 再現性 | 高い(同一入力で同一出力) | 揺らぎあり(検証層で補完が必要) |
| 向く対象 | 構造が安定した大量ページ | 構造がサイトごとに異なる少量〜中量の収集 |
LLMスクレイパー4層パイプラインとは何か?
「LLMスクレイパー4層パイプライン」は、スクレイピング処理を「取得」「軽量化」「抽出」「検証」の4層に分け、LLMの使用を抽出層のみに限定する設計です。各層の失敗原因が異なるため、層を分離することで原因の切り分けが容易になります。
[1 取得層] URL ──► HTML(ブロック対策・JSレンダリング)
│
[2 軽量化層] HTML ──► Markdown / テキスト(script・nav・footer除去)
│
[3 抽出層] Markdown + スキーマ ──► JSON ← LLMを使用するのはここのみ
│
[4 検証層] JSON ──► 型・範囲・原文との突合 ──► 保存 / 再試行
| 層 | 使用する技術の例 | 従来技術で十分な場合 | LLMを使用する場合 |
|---|---|---|---|
| 取得 | httpx、Playwright | 静的HTMLでブロックが発生しない | 該当なし(LLMでは解決不可) |
| 軽量化 | BeautifulSoup、Trafilatura | 本文領域のセレクタが固定されている | 該当なし(ルールベースで十分) |
| 抽出 | Pydantic、Zod、Instructor | 価格・IDなど厳密な値で構造が安定 | 表記が揺れる文章やサイトごとに構造が異なる収集 |
| 検証 | Pydantic、独自の突合ロジック | 型と範囲のチェックで足りる | 抽出値の原文突合や差分監視が必要 |
各層の失敗例と切り分けの方法は以下の通りです。
- 取得層の失敗:403エラーが返却される、本文が空である、CAPTCHAページが返却される。保存したHTMLをブラウザで開き、目的の情報が含まれているかを確認します。
- 軽量化層の失敗:必要な情報が除去される。軽量化前後のテキストで目的の値を検索し、どちらの段階で消失したかを確認します。
- 抽出層の失敗:入力テキストには値が存在するのに、JSONで欠落または誤っている。プロンプトとスキーマの
descriptionを見直します。 - 検証層の失敗:誤った値が検証を通過する。型検査に加えて、原文との突合を追加します。
どのLLMスクレイパーツールを選択すればよいか?
ブロックの強いサイトや大規模収集では、取得層を持つ Bright Data が第一選択肢となります。小規模な試作、社内完結のローカル実行、TypeScript環境では、他の選択肢が適する場合があります。
LLMスクレイパー構成の選定表
| 製品 | 導入形態 | HTML取得とブロック対策 | 出力形式 | LLM差し替え | 向く規模 | 弱点 |
|---|---|---|---|---|---|---|
| Bright Data(Web Unlocker API + Scraper Studio + 構築済みスクレイパー) | 取得基盤型(API) | あり(フィンガープリント・CAPTCHA・再試行・JSレンダリング) | HTML、構築済みスクレイパーはJSON・CSV | 抽出LLMは任意(取得層とは独立) | 中規模〜大規模 | 抽出層は別途構築が必要 |
| Firecrawl | API型(マネージド) | APIが取得を実行(ローカルブラウザ不要) | Markdown・HTML・構造化JSON | API仕様に依存 | 試作〜中規模 | JSON形式は1ページあたりのクレジット消費が増加 |
| Crawl4AI | オープンソースライブラリ(Cloud APIも提供) | Playwrightベースのブラウザで取得 | Markdown・構造化JSON | 可(LLM設定で指定) | 小規模〜中規模(自社運用) | 非同期API(AsyncWebCrawler)前提で、ライブラリ版はブラウザ実行環境の自社運用が必要 |
| ScrapeGraphAI | Pythonライブラリ(API版も提供) | ライブラリ版は自前構成、API版はstealthモード | JSON(スキーマ指定)・Markdown・HTML | 可(Ollama等のローカルLLMに対応) | 小規模〜中規模 | グラフ型パイプラインの概念理解が必要 |
| llm-scraper | TypeScriptライブラリ | Playwrightで取得(ブロック対策は自前) | Zodスキーマ準拠のオブジェクト | 可(AI SDKのプロバイダ) | 小規模 | 取得層の失敗に対する支援機能なし |
Bright Data(Web Unlocker API + Scraper Studio + 構築済みスクレイパー)
Bright Data の Web Unlocker API は、単一のエンドポイントから公開ページのクリーンなHTMLを返却する取得サービスです。フィンガープリント・CAPTCHA・IPローテーション・JavaScriptレンダリング・再試行を自動で処理するため、LLM抽出の前段で発生する取得失敗を取り除けます。
- 構築済みスクレイパー:1300以上が用意されており、構造化JSONを直接返却するため、対象サイトがカバーされている場合はLLM抽出は不要です。
- Scraper Studio:構築済みスクレイパーが存在しない対象に対し、自然言語の指示からスクレイパーを作成し、サイト変更時には自己修復します。
- 無料枠:月5,000クレジットで、Web Unlocker API・SERP API・Web Scraper API・Scraper Studioに共通です。
- 料金:Web Unlocker API は $1/1Kリクエストから利用可能です。
- 留意点:単一ページの試作では取得基盤が過剰となる場合があります。また、Web Unlocker API にはLLMによる抽出層が含まれないため、抽出層は別途構築する必要があります。
Firecrawl
Firecrawl は、URLからMarkdownや構造化JSONを返却するマネージドAPIサービスです。ローカルのブラウザやPlaywrightが不要であり、URLを送信するのみで構造化JSONまで取得できるため、導入の手軽さに優れています。
- 無料枠:月1,000クレジット(クレジットカード不要)。
- 有料プラン:Hobby プランは年払いで月 $16(5,000クレジット)、月払いでは $19。
- クレジット消費:基本のスクレイピングは1ページあたり1クレジットで、JSON形式を指定した場合は1ページあたり4クレジットが加算されます。
- 失敗時の扱い:結果が返却されないスクレイピングは課金されません。一方、403や404エラーのページは返却され、1クレジットが消費される点に留意する必要があります。
Crawl4AI
Crawl4AI は、Webページを LLM 向けのMarkdownや構造化データへ変換するオープンソース(OSS)のクローラです。ローカルで実行でき、ページ単位の従量課金が発生しないため、社内で完結させたい用途に適しています。
- API設計:非同期の
AsyncWebCrawlerを中心とした設計であり、asyncioを前提としたコードの記述が必要。 - 抽出方式:LLMを使用しない抽出戦略とLLM抽出戦略の双方が用意されています。
- Cloud API:無料枠は月1,000クレジット、Starter プランは月 $10(10,000クレジット)。
- 留意点:ライブラリ版ではブラウザ実行環境を自社で運用する必要があり、運用負荷を避けたい場合はCloud APIの利用が選択肢となります。
ScrapeGraphAI
ScrapeGraphAI は、グラフ型パイプラインでスクレイピングを組み立てるPythonライブラリであり、API版も提供されています。ローカルLLM(Ollama)を使用できるため、データを社外へ送信できない環境でも抽出層を構築できます。
- 規模感:GitHubのスター数は2.73万以上。
- API版の消費量:抽出は1リクエストあたり5クレジット、Markdown取得は1クレジット。stealthモードを有効にした場合、1リクエストあたり5クレジットが加算されます。
- 料金:無料プランは500クレジット(一回限り)、Starter プランは月 $20(10,000クレジット)。
- 留意点:グラフ型の概念を理解する必要があり、単純な1ページ抽出ではコード量が増える場合があります。
llm-scraper
llm-scraper は、Zodスキーマを軸にPlaywrightで取得したページからデータを抽出するTypeScriptライブラリです。型定義とスキーマを一体で管理でき、Node.js環境のチームにとって導入の障壁が低い選択肢です。
- 取得方式:Playwrightのページオブジェクトを渡す構成です。
- 留意点:ブロック対策・CAPTCHA・再試行は組み込まれていないため、取得層は別途用意する必要があります。
用途別の推奨
- ブロックの強いサイト・大規模収集:Bright Data の Web Unlocker API で取得し、抽出層を自社構築します。
- 小規模な試作:Firecrawl で、1URLから構造化JSONを取得します。
- 社内完結のローカル実行:Crawl4AI あるいは ScrapeGraphAI + Ollama を採用します。
- TypeScript環境:llm-scraper を採用します。
PythonでLLMスクレイパーを自作する手順は?
最小実装に必要なライブラリは、httpx・beautifulsoup4・markdownify・pydantic・openai の5つです。JavaScriptでレンダリングされるページを自前で扱う場合のみ、playwright を追加します。
pip install httpx beautifulsoup4 markdownify pydantic openai
# JavaScript描画が必要な場合のみ
pip install playwright
playwright install chromium
以下のコードは4層のうち、取得(手順1)、軽量化(手順2)、抽出(手順3・4)、検証(手順5)を1ファイルにまとめたものです。環境変数 OPENAI_API_KEY を設定して使用します。
import re
import time
import unicodedata
from typing import Literal
import httpx
from bs4 import BeautifulSoup
from markdownify import markdownify as to_markdown
from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError
MODEL_NAME = "gpt-4o-mini"
USER_AGENT = "Mozilla/5.0 (compatible; MyScraper/1.0)"
# 手順3:日本語のdescription付きスキーマを定義する
class Product(BaseModel):
"""抽出対象の商品情報を表すスキーマ。"""
name: str = Field(description="商品名。型番・セール文言・店舗名は含めない")
price_jpy: int = Field(description="税込価格。円記号・カンマを除いた整数")
currency: Literal["JPY"] = Field(description="通貨コード。常にJPY")
in_stock: bool = Field(description="在庫がある場合はtrue、売り切れの場合はfalse")
def fetch_html(url: str, max_retries: int = 3, timeout: float = 20.0) -> str:
"""URLからHTMLを取得し、失敗時は指数バックオフで再試行する。"""
headers = {"User-Agent": USER_AGENT, "Accept-Language": "ja,en;q=0.8"}
for attempt in range(max_retries):
try:
# 手順1:timeoutとUser-Agentを指定してHTMLを取得する
response = httpx.get(url, headers=headers, timeout=timeout, follow_redirects=True)
response.raise_for_status()
return response.text
except httpx.HTTPError:
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt)
raise RuntimeError("到達しないコードパス")
def clean_to_markdown(html: str) -> str:
"""不要タグを除去してMarkdownに変換し、全角数字などをNFKCで正規化する。"""
soup = BeautifulSoup(html, "html.parser")
# 手順2:script・style・nav・footerなど本文以外の要素を除去する
for tag in soup(["script", "style", "noscript", "nav", "footer", "header"]):
tag.decompose()
markdown = to_markdown(str(soup), heading_style="ATX")
# 全角数字・全角記号を半角へ正規化し、連続する空行を圧縮する
markdown = unicodedata.normalize("NFKC", markdown)
return re.sub(r"\n{3,}", "\n\n", markdown).strip()
def verify_against_source(product: Product, source_text: str) -> None:
"""抽出した価格が原文に存在するかを突合し、存在しない場合は例外を送出する。"""
normalized = source_text.replace(",", "")
if str(product.price_jpy) not in normalized:
raise ValueError(f"価格 {product.price_jpy} が原文に存在しません")
if product.price_jpy <= 0:
raise ValueError("価格が0以下です")
def extract_product(markdown: str, max_retries: int = 3) -> Product:
"""構造化出力でLLMを呼び出し、検証に失敗した場合は上限回数まで再試行する。"""
client = OpenAI()
last_error: Exception | None = None
for _ in range(max_retries):
# 手順4:構造化出力でスキーマに沿ったJSONを生成する
completion = client.beta.chat.completions.parse(
model=MODEL_NAME,
temperature=0,
messages=[
{"role": "system", "content": "商品ページの本文から商品情報のみを抽出する。本文に存在しない値は推測しない。"},
{"role": "user", "content": markdown},
],
response_format=Product,
)
try:
# 手順5:model_validate_jsonで型を検証し、原文と突合する
product = Product.model_validate_json(completion.choices[0].message.content)
verify_against_source(product, markdown)
return product
except (ValidationError, ValueError) as error:
last_error = error
raise RuntimeError(f"検証に失敗しました: {last_error}")
if __name__ == "__main__":
target_url = "https://example.com/product/123"
page_html = fetch_html(target_url)
page_markdown = clean_to_markdown(page_html)
print(extract_product(page_markdown).model_dump_json(indent=2, ensure_ascii=False))
実装上のポイントは以下の4点です。
- スキーマの
description:価格は「税込・円記号とカンマを除いた整数」のように、日本語ページの表記ゆれを打ち消す指示を記述します。 - NFKC正規化:全角数字(1,280円)や全角記号を半角へ変換し、原文突合を容易にします。
- 構造化出力:OpenAI Structured Outputs はJSON Schemaに沿った出力を保証する機能であり、
Instructorを使用しても同様の構成が可能です。 - 再試行の上限:検証失敗時に無制限に再試行した場合、トークン費用が際限なく増加するため、上限回数を設定する必要があります。
TypeScriptであればllm-scraperでどう記述するのか?
TypeScriptでは、Zodスキーマを定義し、Playwrightのページオブジェクトと共にllm-scraperへ渡します。Pythonの4層との対応は、取得がPlaywright、軽量化がフォーマット指定、抽出がLLM呼び出し、検証がZodによる型検証となります。
import { chromium } from 'playwright'
import { z } from 'zod'
import { openai } from '@ai-sdk/openai'
import LLMScraper from 'llm-scraper'
// 1. 抽出スキーマをZodで定義する
const schema = z.object({
name: z.string().describe('商品名。型番やセール文言は含めない'),
priceJpy: z.number().int().describe('税込価格。円記号とカンマを除いた整数'),
inStock: z.boolean().describe('在庫がある場合はtrue'),
})
// 2. 使用するLLMプロバイダを指定する
const llm = openai.chat('gpt-4o-mini')
const scraper = new LLMScraper(llm)
// 3. Playwrightでページを取得する
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto('https://example.com/product/123')
// 4. 抽出を実行し、Zodによる検証済みのデータを受け取る
const { data } = await scraper.run(page, schema, { format: 'markdown' })
console.log(data)
await browser.close()
フォーマットの選択は以下の基準で行います。
- html:セレクタで取得できない属性値(
data-*属性やリンク先URL)が必要な場合。 - markdown:本文の意味を抽出する通常の用途で、トークン消費を削減する場合。
- text:装飾が不要で、最小のトークン数に抑える場合。
- 画像:レイアウト上の位置関係が意味を持つページや、テキストとして取得できない表示を扱う場合。
LLMプロバイダの切り替えは、LLMScraper に渡すモデルのインスタンスを変更するのみで行えます。AI SDKに対応したプロバイダであれば、OpenAIからOllama等のローカルLLMへの差し替えも同様の方法で可能です。
LLMスクレイピングは1ページあたりいくらかかるのか?
1ページあたりのコストは、取得コストとLLMのトークン課金の合計で計算できます。
1ページのコスト = 取得コスト
+ 入力トークン数 × 入力単価
+ 出力トークン数 × 出力単価
月額コスト = 1ページのコスト × 月間ページ数
各変数の求め方は以下の通りです。
- 入力トークン数:軽量化後のMarkdownとプロンプト・スキーマの合計。tiktokenで計測します。
- 出力トークン数:抽出するJSONのサイズ。項目数が少なければ小さくなります。
- 単価:使用するモデルの公式料金表の値を使用します。
- 取得コスト:Web Unlocker API は $1/1Kリクエストから利用可能であり、月5,000クレジットの無料枠(1リクエストあたり1クレジット)が適用されます。
日本語ページはトークナイザの仕様上、同じ文字数でも英語より入力トークン数が多くなりやすい傾向があります。また、生HTMLにはscript・style・属性値が大量に含まれるため、軽量化前後で入力トークン数は大きく異なります。自社の対象ページで計測する際は、以下のコードを使用します。
import tiktoken
def count_tokens(text: str, encoding_name: str = "o200k_base") -> int:
"""テキストのトークン数を計測して返却する。"""
encoding = tiktoken.get_encoding(encoding_name)
return len(encoding.encode(text))
# 1. 軽量化前後のトークン数を比較する
raw_tokens = count_tokens(page_html)
clean_tokens = count_tokens(page_markdown)
print(f"生HTML: {raw_tokens} / 軽量化後: {clean_tokens}")
月1万ページと10万ページの試算は、計測したトークン数と選択したモデルの単価を上記の式へ代入して算出します。取得コストが月間ページ数に比例して加算される点に留意する必要があります。
LLMコストが取得コストを上回るかどうかの境界は、主に入力トークン数によって決まります。軽量化によって入力トークン数を削減した場合、LLMコストは取得コストに近づきます。
なお、API型の製品では、構造化JSONで抽出する場合のクレジット消費が通常の取得より多くなります。Firecrawl ではJSON形式を指定した場合、基本の1クレジットに4クレジットが加算され、ScrapeGraphAI では抽出が1リクエストあたり5クレジット(Markdown取得は1クレジット)です。
ブロックされるサイトや動的ページではLLMスクレイパーをどう動かすのか?
取得層で失敗したページは、LLMでは抽出できません。403エラーページやCAPTCHA画面のHTMLをLLMに渡しても、返却されるのは「該当情報なし」か、場合によっては誤った推測値です。したがって、ブロックされるサイトでは取得層をWeb Unlocker APIに任せ、受け取ったHTMLを軽量化層以降へ渡す構成とします。
Web Unlocker API は、フィンガープリント・CAPTCHA・再試行・JavaScriptレンダリングを処理した上でHTMLを返却します。以下は、4層パイプラインの fetch_html を差し替える例です。
import os
import httpx
def fetch_html_via_unlocker(url: str, timeout: float = 60.0) -> str:
"""Web Unlocker APIにURLを送信し、ブロック対策済みのHTMLを返却する。"""
response = httpx.post(
os.environ["BRIGHTDATA_UNLOCKER_ENDPOINT"],
headers={"Authorization": f"Bearer {os.environ['BRIGHTDATA_API_KEY']}"},
json={"zone": os.environ["BRIGHTDATA_ZONE"], "url": url, "format": "raw"},
timeout=timeout,
)
response.raise_for_status()
return response.text
# 1. 取得結果をそのまま軽量化層・抽出層へ渡す
html = fetch_html_via_unlocker("https://example.com/product/123")
product = extract_product(clean_to_markdown(html))
複数ステップの操作(ログイン後の画面遷移、ボタンのクリック、無限スクロール等)が必要な場合のみ、Browser API を使用します。単純な取得であれば、Web Unlocker API で十分です。
BeautifulSoup単体ではレンダリングされないSPA(Single Page Application)ページの例を以下に示します。httpx で取得したHTMLには、JavaScript実行前の空の枠のみが含まれています。
<html>
<body>
<div id="root"></div>
<script src="/static/app.js"></script>
</body>
</html>
このHTMLから軽量化層でscriptを除去した場合、本文は空になります。LLMに渡しても抽出できないため、JavaScriptレンダリングを実行する取得層(Web Unlocker API、あるいはPlaywright等)が必要です。
LLM抽出とセレクタ抽出はどのように使い分けるべきか?
厳密な値が必要で構造が安定している場合はセレクタ、表現が揺れるテキストを扱う場合はLLMが適しています。
| 条件 | 推奨方式 | 理由 |
|---|---|---|
| 価格・IDなど厳密な値 | セレクタ | 同一入力で同一出力となり、誤抽出が発生しない |
| 構造が安定したサイト | セレクタ | LLMコストが不要 |
| 数万ページ以上の収集 | セレクタ | トークン課金が月間ページ数に比例して増加する |
| レビュー・会社概要など表現が揺れる非構造テキスト | LLM | 文言の違いにスキーマで対応可能 |
| サイトごとに構造が異なる収集 | LLM | サイト別のセレクタ作成・メンテナンスが不要 |
| 少量の収集 | LLM | セレクタ作成の工数のほうが大きい |
| Amazon・LinkedInなど構築済みスクレイパーが存在するサイト | 構築済みスクレイパー | 構造化JSONが直接返却され、LLMは不要 |
| 構築済みスクレイパーが存在しない対象 | Scraper Studio | 自然言語の指示から作成でき、サイト変更時に自己修復する |
セレクタで取得し、LLMは分類や要約のみに使用するハイブリッド構成も有効です。以下は、価格をセレクタで取得し、レビューの感情分類のみをLLMに任せる例です。
from bs4 import BeautifulSoup
def extract_hybrid(html: str) -> dict:
"""価格はセレクタで取得し、レビュー文のみをLLMで分類する。"""
soup = BeautifulSoup(html, "html.parser")
# 1. 厳密な値はセレクタで取得する
price_text = soup.select_one(".price").get_text(strip=True)
price_jpy = int("".join(ch for ch in price_text if ch.isdigit()))
# 2. 表現が揺れるレビュー文のみをLLMで分類する
review_text = soup.select_one(".review").get_text(strip=True)
sentiment = classify_sentiment(review_text) # LLM呼び出し
return {"price_jpy": price_jpy, "sentiment": sentiment}
LLMが誤って抽出した値はどのように検出するのか?
誤抽出の検出は、検証層で以下の5点を組み合わせて実施します。
- スキーマ検証:Pydanticによる型・必須項目・範囲のチェックです。価格が0以下、あるいは異常に大きい場合は、範囲チェックで除外します。
- 原文との突合:抽出した価格の数字が、軽量化後のテキストに含まれているかを確認します。全角数字やカンマの違いは、NFKC正規化によって吸収します。
- 差分チェックによるサンプル監視:一定割合のページをサンプルとして抜き出し、セレクタ抽出とLLM抽出の結果を比較します。乖離率が基準値を超えた場合に、プロンプトやスキーマを見直します。
- 決定論的な設定:temperatureを0に設定して出力の揺らぎを抑え、再試行しても検証を通過しないページは失敗として記録します。
- 構造変更時のアラート:検証失敗率・必須項目の欠落率・原文突合の失敗率を日次で集計し、急上昇した場合に通知します。サイト構造の変更は、これらの指標の変化として最初に現れます。
セレクタ抽出が壊れた場合は即座にエラーとなりますが、LLM抽出は誤った値を返却しても処理が継続する点に注意が必要です。そのため、検証層の指標を継続的に監視することが重要です。


