
AIエージェントに「外部のWebサイトを調べて」と頼む場面が増えています。
BeautifulSoupで書いたスクレイパーを使いまわそうとした瞬間に、何かがかみ合わない感覚になりますよね。
その「かみ合わなさ」の正体と、そこにフィットするツールが crawl4ai です。
LLMエージェントへのデータ供給 という目的軸で crawl4ai がどう機能するかを、AsyncWebCrawler の最小コードから並列クロール・LangChain 接続まで実務コードで整理します。
BeautifulSoup・Playwright との使い分け基準も含め、チームの技術選定で自分の言葉で説明できるレベルを目指します。
BeautifulSoupやPlaywrightで書いたコードが、LLMに渡す段階でつらくなる理由
スクレイピングのゴールが「データを取り出す」だった時代は、BeautifulSoup はとても頼りになるツールでした。
CSSセレクタで要素を絞り込んで、テキストを抜き出す。
それで十分だったはずです。
LLMに渡す、という目的が加わると、話が変わります。
LLMが扱いやすいのは自然言語に近いテキストです。
HTMLのタグ構造はブラウザにとっては意味のある情報ですが、LLMのコンテキストウィンドウに詰め込むとノイズになります。
記事本文1ページ分を取り出したいのに、HTMLそのままだとナビゲーションやフッターのゴミが大量についてくる。
これを BeautifulSoup で綺麗にしようとすると、ページごとに違うDOM構造に対応するカスタム処理を延々と書き続けることになります。
JS遅延レンダリングも厄介です。
SPAや動的コンテンツが多い現代のWebでは、BeautifulSoup だけでは初期HTMLしか取れないケースが頻繁にあります。
「ちゃんとスクレイプしたのに内容が空」という経験、ありませんか。
ページネーションも同様で、複数ページにまたがる情報を集めようとすると「次のページへ」の処理と各ページの整形をどちらも自前で書くことになります。
Playwright を使えば JS 実行の問題は解決できますが、今度は取得した HTML を LLM 向けのきれいなテキストに変換する処理は自分で書かなければなりません。
Playwright は ブラウザ操作 のレイヤーに立っているツールで、クリック・フォーム入力・特定要素の出現待ちといった細粒度の制御が得意です。
LLM 向けの Markdown を作るという目的は、もともと設計の射程外にあります。
BeautifulSoup も Playwright も優れたツールです。
ただ「LLMのコンテキストに入れるデータを作る」という目的では、橋渡しのコードを自前でたくさん書くことになる。
crawl4ai はその橋渡しを最初から引き受けている、という設計思想のツールです。
crawl4aiの核心:AsyncWebCrawlerがMarkdownを返すまでに何をしているか
crawl4ai を使うと何が変わるか、最小のコードで確認してみます。
import asyncio
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
from crawl4ai.content_filter_strategy import PruningContentFilter
from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator
async def main():
browser_config = BrowserConfig(headless=True)
md_generator = DefaultMarkdownGenerator(
content_filter=PruningContentFilter()
)
run_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
markdown_generator=md_generator,
)
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(
url="https://example.com/article",
config=run_config,
)
if result.success:
# LLMに渡すなら fit_markdown を使う
print(result.markdown.fit_markdown)
asyncio.run(main())
このコードが返すのはHTMLではなく、すでにMarkdownに変換されたテキストです。
内部では Playwright がページをレンダリングしてJSを実行し、その結果のDOMをMarkdownに変換しています。
crawl4ai は「Playwrightのラッパー」ではなく、Playwrightを内包した上で LLM入力を作るための上位レイヤー として設計されています。
注目してほしいのが PruningContentFilter の役割です。
ページ内のコンテンツを意味密度で評価して、ナビゲーション・広告・フッターといった「本文ではない部分」を刈り込むフィルタです。
HTMLを機械的にMarkdownに変換するだけでは、メニューリンクやサイドバーのゴミまで全部入ってきます。
PruningContentFilter はそれをカットします。
フィルタを通した結果として、result.markdown には2種類の出力が入ってきます。
raw_markdown は変換後の Markdown 全体(フィルタリング前)、fit_markdown は本文と判断した部分だけを残したより絞られたテキストです。
LLM に渡すなら fit_markdown を選ぶのが基本で、コンテキストウィンドウのトークンを無駄遣いせずに済みます。
BrowserConfig と CrawlerRunConfig が分離されているのも意図的な設計です。
ブラウザの起動設定とクロールのふるまい(キャッシュ・タイムアウト・Markdown生成の設定)をそれぞれ独立して管理できるので、次節で説明する並列処理でブラウザインスタンスを使い回せるのもこの分離があるからです。
複数URLを並列処理してエージェントに渡す:arun_manyとパイプラインの最小構成
エージェントに「複数のニュースサイトを調べてまとめて」と頼む場合、URLを一つずつ同期的にクロールするのは遅すぎます。
arun_many() はその問題を解消します。
import asyncio
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig, CacheMode
from crawl4ai.content_filter_strategy import PruningContentFilter
from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator
urls = [
"https://example.com/news/1",
"https://example.com/news/2",
"https://example.com/news/3",
]
async def crawl_parallel():
md_generator = DefaultMarkdownGenerator(
content_filter=PruningContentFilter()
)
run_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
markdown_generator=md_generator,
stream=True, # 完了したものから逐次処理する
)
collected = []
async with AsyncWebCrawler() as crawler:
async for result in await crawler.arun_many(
urls=urls,
config=run_config,
):
if result.success:
collected.append(result.markdown.fit_markdown)
else:
print(f"失敗: {result.url} - {result.error_message}")
return collected
results = asyncio.run(crawl_parallel())
arun_many() はブラウザインスタンスを使い回しながら複数のURLを並列に処理します。
同期的なループで1件ずつ await するのと違い、I/O 待ち時間が重なる分だけ全体の処理時間が短くなります。
URL が 10 件・20 件と増えるほど、その差は体感できるレベルになります。
stream=True を設定すると、全URLの完了を待たず終わったものから順に結果を受け取れます。
後続の処理をできるだけ早く動かしたいパイプラインでは積極的に使いたい設定です。
取得した Markdown を LangChain のエージェントに渡す最小構成はこんなイメージです。
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
context = "\n\n---\n\n".join(results)
llm = ChatOpenAI(model="gpt-4o-mini")
response = llm.invoke([
SystemMessage(content="あなたは調査アシスタントです。提供された記事の内容をもとに回答してください。"),
HumanMessage(content=f"以下の記事を参照して、主要なトピックをまとめてください。\n\n{context}"),
])
print(response.content)
接続点はシンプルです。
fit_markdown をそのままコンテキストに差し込むだけで、crawl4ai がノイズ除去と Markdown 変換を済ませているので、エージェント側に残る操作は「情報を渡す」だけです。
LlamaIndex との接続も同じ考え方で、取得した Markdown を Document オブジェクトに変換して VectorStore に渡す流れになります。
crawl4aiを選ぶ場面・選ばない場面、そしてセルフホストという選択肢
正直なところ、crawl4ai がどんな場面にも最適というわけではありません。
向く場面:
- React や Vue 製のSPAなど、JSが動かないとコンテンツが表示されないページを大量にクロールしたい。内部の Playwright が JS 実行まで面倒を見るので、追加処理が要らない。
- 10件・100件単位のURLをまとめてクロールして LLM に食わせたい。arun_many() の並列処理がそのまま使える。
- RAGパイプラインやAIエージェントの情報収集ステップを作っている。LLM 向け Markdown が直接出てくるので前処理コストが下がる。
向かない場面:
- 静的なHTMLで JS 実行が不要なページをただパースしたい。Playwright ベースなのでリソースコストが高く、requests + BeautifulSoup の方が軽くて速い。
- ボタンクリックやフォーム操作など、細粒度のブラウザ制御が必要なケース。Playwright を直接扱う方が柔軟に書ける。
- スクレイピング結果を LLM に渡す予定がなく、DBに格納して終わりというユースケース。Markdown 変換が完全に無駄になる。
crawl4ai は Apache 2.0 ライセンス のOSSで、商用利用も制限なく、コードの改変も自由です。
社内の文書管理システムや顧客情報が載ったイントラページをクロールして社内 LLM に食わせる場合、外部のマネージドAPIを使うとクロールしたコンテンツがその事業者のサーバーを経由することになります。
情報セキュリティポリシー上の懸念が出やすい構成です。
crawl4ai は Docker イメージで提供されているので、社内ネットワークやプライベートクラウドに閉じた状態で動かせます。
docker run -d \
-p 11235:11235 \
-e CRAWL4AI_API_TOKEN=your_token \
unclecode/crawl4ai:latest
このコマンドで API サーバーとして起動すれば、HTTP エンドポイント経由でクロールを呼び出せます。
データが外部に出ない状態を保ちながら、LLM エージェントのバックエンドに組み込める構成です。
似たポジションの Firecrawl はマネージドのクラウドサービスとして使えるぶんセットアップが楽で、LangChain・LlamaIndex との公式インテグレーションも充実しています。
一方、ページ数に応じたクレジット課金なので大量クロールはコストの見積もりが必要になります。
「データのコントロールとランニングコストを優先したい」という場合に、crawl4ai のセルフホストが選択肢として浮かび上がります。
crawl4ai の立ち位置をひと言で言えば、「Webから情報を取ってLLMが読めるテキストにする」という一点に特化したツールです。
BeautifulSoup や Playwright とは競合というより、目的に応じたレイヤーの違いです。
「LLMに渡す前提かどうか」という問いを起点にすると、チームへの説明もシンプルになるはずです。
株式会社ホコサキは、山口県宇部を拠点にAI活用支援・業務システム開発・Web制作を手がけています。
「AIエージェントを実際の業務フローに組み込みたい」「どのOSSをどう使うか整理したい」といったご相談も歓迎です。
お問い合わせページ からお気軽にご連絡ください。

