Firecrawl:通常のスクレイパーが失敗するときのAIアプリ向けWebデータ収集

Artificial Intelligence tutorial - IT technology blog
Artificial Intelligence tutorial - IT technology blog

午前2時、AIパイプラインが停止した

技術系ドキュメントページから情報を集約するRAG(Retrieval-Augmented Generation)ツールを構築していた。翌朝にはデモのデッドラインが迫っていた。ローカルではサンプルデータで問題なく動いていた——しかし本番サイトから実データのクロールを始めると、システムがゴミデータを大量に返し始めた。

ログにはこんな内容が溢れていた:

[ERROR] Parsed content: "Please enable JavaScript to view this page"
[ERROR] Parsed content: "Verifying you are human. This may take a few seconds."
[ERROR] Empty markdown extracted from https://docs.example.com/api-reference

BeautifulSoupはHTMLをちゃんと取得していた——でもそれはローディング画面のHTMLであって、実際のコンテンツではなかった。AIにこんなゴミを食わせれば、出力もひどいものになる。

原因:現代のWebは従来のスクレイパーに優しくない

深夜30分ほどデバッグして、ようやく問題の全体像が見えてきた。現代のドキュメントサイトやWebページは、通常のスクレイパーに対して3種類の問題を引き起こす:

  • JavaScriptレンダリング:React/Vue/Next.jsでコンテンツが読み込まれる——BeautifulSoupはJS実行前のHTMLしか見えない。
  • アンチボット保護:Cloudflare、CAPTCHA、User-Agent検知が自動リクエストをブロックする。
  • 動的コンテンツ:無限スクロール、遅延読み込み、ユーザー操作に依存するコンテンツ。

Seleniumに切り替えてみた。動くことは動いた——が、とにかく遅い。50ページのクロールに20分かかり、数時間連続稼働するとメモリリークも発生する。数千ページを処理する必要があるパイプラインには、現実的な選択肢ではなかった。

# BeautifulSoupを使った古いアプローチ - JSレンダリングページで失敗する
import requests
from bs4 import BeautifulSoup

def scrape_old_way(url):
    response = requests.get(url, headers={"User-Agent": "Mozilla/5.0..."})
    soup = BeautifulSoup(response.text, "html.parser")
    # 受け取った結果: "Please enable JavaScript" 😭
    return soup.get_text()

Firecrawlとは何か、そしてなぜ違うのか

午前3時の絶望的なGoogle検索の末、20分でFirecrawlにたどり着いた。これはAPIサービス(セルフホストオプションもあり)で、汎用スクレイパーではない——AIパイプラインにクリーンなデータを提供するという、ただ一つのことのために生まれたツールだ。他のツールと比較すると:

  • コンテンツ抽出前にJavaScriptを完全にレンダリング
  • HTMLをクリーンなMarkdownに自動変換——LLMが最も消化しやすい形式
  • アンチボット対策、レート制限、リトライを完全自動で処理
  • 単一ページだけでなく、深さに応じてウェブサイト全体をクロール

Firecrawl Python SDKのインストール

pip install firecrawl-py

firecrawl.devでAPIキーを取得——テスト用の無料枠がある。その後、環境変数を設定:

export FIRECRAWL_API_KEY="fc-your-api-key-here"

Firecrawlの実践的な使い方

1. 単一ページのスクレイプ

最もシンプルな使い方:URLを渡してクリーンなMarkdownを受け取る。

import os
from firecrawl import FirecrawlApp

app = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])

# 1ページをスクレイプし、Markdownを取得
result = app.scrape_url(
    "https://docs.python.org/3/library/asyncio.html",
    formats=["markdown"]
)

print(result["markdown"][:500])
# 出力: クリーンなコンテンツ、LLMへの入力準備完了

返ってくるのは純粋なMarkdown——余計なHTMLタグなし、ナビゲーションメニューのゴミなし、広告フッターなし。手動でコンテンツ抽出の正規表現を書いた経験があれば、これがどれだけの手間を省いてくれるか、すぐわかるはずだ。

2. 深さに応じてウェブサイト全体をクロール

RAGパイプライン用にdocsサイト全体をインデックスしたい場合——1ページずつではなく:

import os
from firecrawl import FirecrawlApp

app = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])

# docsサイト全体をクロール、最大50ページ
crawl_result = app.crawl_url(
    "https://docs.example.com",
    limit=50,
    scrape_options={"formats": ["markdown"]}
)

for page in crawl_result["data"]:
    print(f"URL: {page['metadata']['sourceURL']}")
    print(f"Content length: {len(page['markdown'])} chars")
    print("---")

3. LLMによる構造化データの抽出

最もよく使う機能——あらかじめ定義したスキーマに従って情報を抽出し、Firecrawlが自動でAIを使って埋めてくれる:

import os
from firecrawl import FirecrawlApp

app = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])

# 商品ページから構造化データを抽出
result = app.extract(
    ["https://example.com/product/123"],
    schema={
        "type": "object",
        "properties": {
            "product_name": {"type": "string"},
            "price": {"type": "number"},
            "features": {
                "type": "array",
                "items": {"type": "string"}
            },
            "availability": {"type": "boolean"}
        },
        "required": ["product_name", "price"]
    }
)

print(result["data"])
# Output: {"product_name": "...", "price": 29.99, "features": [...], ...}

最善の組み合わせ:FirecrawlとLLMの連携

これは深夜2時のパイプライン修復後、現在本番で動かしているパターンだ。FirecrawlでクロールしてClaudeでコンテンツを処理する:

import os
import anthropic
from firecrawl import FirecrawlApp

firecrawl = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])
claude = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

def research_topic(url: str, question: str) -> str:
    """
    URLをクロールし、そのコンテンツからClaudeが質問に答える。
    """
    # Step 1: Webからクリーンなコンテンツを取得
    scrape_result = firecrawl.scrape_url(url, formats=["markdown"])
    content = scrape_result.get("markdown", "")

    if not content:
        return "このURLからコンテンツを取得できません。"

    # Step 2: クロールしたコンテンツからClaudeで回答
    response = claude.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=[
            {
                "role": "user",
                "content": f"""以下のWebコンテンツを基に、質問に答えてください。

コンテンツ:
{content[:8000]}

質問: {question}"""
            }
        ]
    )

    return response.content[0].text

# 使用例
answer = research_topic(
    url="https://docs.python.org/3/library/asyncio-task.html",
    question="asyncio.create_task()とasyncio.ensure_future()の違いは何ですか?"
)
print(answer)

シンプルなRAGインデクサーの構築

データを保存して繰り返しクエリしたい場合はこれ。最小限のインデクサーのスケルトン:

import os
from firecrawl import FirecrawlApp
from typing import List, Dict

firecrawl = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])

class SimpleRAGIndexer:
    def __init__(self):
        self.documents: List[Dict] = []

    def index_website(self, base_url: str, max_pages: int = 20):
        """ウェブサイト全体をクロールしてインデックスする。"""
        print(f"{base_url}をクロール中...")

        result = firecrawl.crawl_url(
            base_url,
            limit=max_pages,
            scrape_options={"formats": ["markdown"]}
        )

        for page in result.get("data", []):
            if page.get("markdown"):
                self.documents.append({
                    "url": page["metadata"]["sourceURL"],
                    "content": page["markdown"],
                    "title": page["metadata"].get("title", "")
                })

        print(f"{len(self.documents)}ページをインデックスしました。")
        return self.documents

    def search(self, query: str, top_k: int = 3) -> List[Dict]:
        """シンプルなキーワード検索 — 実際はベクターDBを使うべき。"""
        query_lower = query.lower()
        results = [
            {**doc, "score": doc["content"].lower().count(query_lower)}
            for doc in self.documents
            if doc["content"].lower().count(query_lower) > 0
        ]
        return sorted(results, key=lambda x: x["score"], reverse=True)[:top_k]

# 使用方法
indexer = SimpleRAGIndexer()
indexer.index_website("https://docs.python.org/3/library/", max_pages=30)

relevant_docs = indexer.search("async await coroutine")
for doc in relevant_docs:
    print(f"URL: {doc['url']} | Score: {doc['score']}")

実践から学んだ注意点

本番で約3ヶ月使ってきた。全体的には問題ない——ただし、事前に知っておくべきことがいくつかある:

  • レート制限:Firecrawlの無料枠には月間リクエスト制限がある。プランを選ぶ前に必要量を見積もっておくこと——本番データのクロール中に止まってしまうのは最悪だ。
  • セルフホストオプション:FirecrawlはオープンソースでGitHubのmendableai/firecrawlにある。データプライバシーが必要な場合や長期的なコストを管理したい場合は、VPSに自分でデプロイできる。
  • 結果のキャッシュ:同じURLを何度もクロールするとクォータを大量消費する。適切なTTLでキャッシュしよう——ドキュメントは24時間、ニュースフィードは1時間が目安。
  • robots.txt:Firecrawlはデフォルトでrobots.txtを尊重する。上書きするには明示的な設定が必要——そして、そのサイトをクロールする権限があるか必ず確認すること。

各ツールの簡単な比較

ツール           | JSレンダリング | クリーン出力 | 使いやすさ | コスト
-----------------|---------------|-------------|-----------|--------------------
BeautifulSoup    |      ❌       |      ❌     |    ✅    | 無料
Selenium         |      ✅       |      ❌     |    ❌    | 無料(遅い)
Playwright       |      ✅       |      ❌     |  中程度  | 無料(インフラ必要)
Firecrawl API    |      ✅       |      ✅     |    ✅    | 有料 / セルフホスト
Firecrawl (self) |      ✅       |      ✅     |  中程度  | インフラのみ

クリーンで一貫したデータが必要なAIパイプラインにおいて、Firecrawlはエッジケース処理のための500行のコードを書かずとも、まさにその課題を解決してくれる。あらゆる面で「最高」というわけではない——静的サイトにはBeautifulSoupで十分だ。自分のRAGパイプラインは現在1日200〜300ページをクロールしており、エラー率は2%以下。そして何より重要なのは——もうスクレイパーのデバッグで夜明かしすることがなくなったことだ。

Share: