ブログ記事

Firecrawl実践ガイド - WebサイトをLLM対応Markdownに変換する最強ツール

WebサイトをLLM学習やRAGシステムに最適なMarkdown形式に変換するFirecrawlの使い方を徹底解説。APIの活用方法からバッチ処理、実装例まで詳しく紹介します。

8分で読めます
R
Rina
Daily Hack 編集長
ツール
Firecrawl Web Scraping LLM Markdown API
Firecrawl実践ガイド - WebサイトをLLM対応Markdownに変換する最強ツールのヒーロー画像

LLM や RAG システムの普及により、Web コンテンツを構造化されたデータとして取り込む需要が急増しています。Firecrawl は、Web サイトを LLM が理解しやすい Markdown 形式に変換する強力なツールです。

この記事で学べること

  • Firecrawl の API を使った基本的な変換方法
  • 大規模サイトのクローリングとバッチ処理
  • LLM トレーニングデータの準備手法
  • RAG システムへの統合方法

Firecrawlとは?Web to Markdownの革命

Firecrawl は、Web ページをクリーンな Markdown 形式に変換する API サービスです。JavaScript実行、動的コンテンツの取得、不要な要素の除去など、LLM 向けのデータ準備に必要な機能を包括的に提供します。

主な特徴と優位性

Firecrawlの主要機能
機能 説明 利点
JavaScript実行 SPAや動的サイトに対応 現代的なWebサイトに対応
クリーンな出力 広告やナビゲーションを除去 LLMに最適化されたデータ
構造化データ メタデータを保持 コンテキスト情報の保存
バッチ処理 大量URLの並列処理 スケーラブルな処理
API統合 RESTful API提供 既存システムへの組み込み
マルチフォーマット Markdown、JSON、HTML対応 用途に応じた出力

私たちの目標は、Web コンテンツを LLM が最も効果的に理解できる形式に変換することです。

Firecrawl Team 開発チーム

セットアップとAPI認証

1. アカウント作成とAPIキー取得

Firecrawlサインアップ

firecrawl.devでアカウント作成

APIキー生成

ダッシュボードからAPIキー取得

SDKインストール

各言語のSDKをセットアップ

テスト実行

基本的なAPI呼び出しを確認

2. SDK/ライブラリのインストール

# npmを使用
npm install @mendable/firecrawl-js

# yarnを使用
yarn add @mendable/firecrawl-js

# 環境変数の設定
export FIRECRAWL_API_KEY="your-api-key-here"
// TypeScriptでの初期化
import FirecrawlApp from '@mendable/firecrawl-js';

const app = new FirecrawlApp({
  apiKey: process.env.FIRECRAWL_API_KEY,
});
# pipを使用
pip install firecrawl-py

# poetryを使用
poetry add firecrawl-py

# 環境変数の設定
export FIRECRAWL_API_KEY="your-api-key-here"
# Pythonでの初期化
from firecrawl import FirecrawlApp

app = FirecrawlApp(api_key=os.environ.get('FIRECRAWL_API_KEY'))
# Go modulesを使用
go get github.com/mendableai/firecrawl-go

# 環境変数の設定
export FIRECRAWL_API_KEY="your-api-key-here"
// Goでの初期化
package main

import (
    "github.com/mendableai/firecrawl-go"
    "os"
)

func main() {
    apiKey := os.Getenv("FIRECRAWL_API_KEY")
    app := firecrawl.New(apiKey)
}
# 基本的なcURL呼び出し
curl -X POST https://api.firecrawl.dev/v0/scrape \
  -H "Authorization: Bearer $FIRECRAWL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "pageOptions": {
      "onlyMainContent": true
    }
  }'

基本的な使い方

1. 単一ページのスクレイピング

// シンプルなページ取得
const result = await app.scrapeUrl('https://example.com');

console.log(result.markdown);
// # Example Domain
// This domain is for use in illustrative examples...
// 詳細なオプションを指定
const result = await app.scrapeUrl('https://example.com', {
  pageOptions: {
    onlyMainContent: true,
    includeHtml: false,
    waitFor: 2000,
    screenshot: true,
    fullPageScreenshot: false,
  },
  extractorOptions: {
    mode: 'llm-extraction',
    extractionPrompt: 'Extract the main article content',
    extractionSchema: {
      type: 'object',
      properties: {
        title: { type: 'string' },
        author: { type: 'string' },
        publishDate: { type: 'string' },
        content: { type: 'string' },
        tags: { 
          type: 'array',
          items: { type: 'string' }
        },
      },
    },
  },
});
基本的なスクレイピング
// シンプルなページ取得
const result = await app.scrapeUrl('https://example.com');

console.log(result.markdown);
// # Example Domain
// This domain is for use in illustrative examples...
高度なオプション付き
// 詳細なオプションを指定
const result = await app.scrapeUrl('https://example.com', {
  pageOptions: {
    onlyMainContent: true,
    includeHtml: false,
    waitFor: 2000,
    screenshot: true,
    fullPageScreenshot: false,
  },
  extractorOptions: {
    mode: 'llm-extraction',
    extractionPrompt: 'Extract the main article content',
    extractionSchema: {
      type: 'object',
      properties: {
        title: { type: 'string' },
        author: { type: 'string' },
        publishDate: { type: 'string' },
        content: { type: 'string' },
        tags: { 
          type: 'array',
          items: { type: 'string' }
        },
      },
    },
  },
});

2. サイト全体のクローリング

site-crawler.ts
import FirecrawlApp from '@mendable/firecrawl-js';
import { writeFile } from 'fs/promises';
import { join } from 'path';

interface CrawlOptions {
url: string;
maxDepth?: number;
limit?: number;
allowedDomains?: string[];
excludePatterns?: string[];
}

class SiteCrawler {
private app: FirecrawlApp;

constructor(apiKey: string) {
  this.app = new FirecrawlApp({ apiKey });
}

async crawlSite(options: CrawlOptions) {
  console.log(`Starting crawl of ${options.url}...`);
  
  const crawlResult = await this.app.crawlUrl(options.url, {
    crawlerOptions: {
      maxDepth: options.maxDepth || 3,
      limit: options.limit || 100,
      allowedDomains: options.allowedDomains,
      excludes: options.excludePatterns || [
        '*/admin/*',
        '*/login/*',
        '*.pdf',
        '*.zip',
      ],
      generateImgAltText: true,
    },
    pageOptions: {
      onlyMainContent: true,
      includeHtml: false,
    },
  });
  
  return crawlResult;
}

async saveResults(results: any[], outputDir: string) {
  for (const [index, page] of results.entries()) {
    const filename = `page-${index + 1}.md`;
    const filepath = join(outputDir, filename);
    
    const content = `---
url: ${page.url}
title: ${page.metadata?.title || 'Untitled'}
description: ${page.metadata?.description || ''}
crawled_at: ${new Date().toISOString()}
---

${page.markdown}
`;
    
    await writeFile(filepath, content, 'utf-8');
    console.log(`Saved: ${filename}`);
  }
}
}

// 使用例
async function main() {
const crawler = new SiteCrawler(process.env.FIRECRAWL_API_KEY!);

const results = await crawler.crawlSite({
  url: 'https://docs.example.com',
  maxDepth: 2,
  limit: 50,
  allowedDomains: ['docs.example.com'],
  excludePatterns: ['*/api-reference/*'],
});

await crawler.saveResults(results.data, './output');
console.log(`Crawled ${results.data.length} pages`);
}

main().catch(console.error);

LLMトレーニングデータの準備

データ処理パイプライン

Firecrawlデータ処理フロー

チャートを読み込み中...

実装例:LLMデータセット作成

// LLMトレーニング用データ収集
interface TrainingDataCollector {
  sources: string[];
  outputFormat: 'jsonl' | 'parquet' | 'csv';
  filters: DataFilter[];
}

class LLMDataCollector {
  private firecrawl: FirecrawlApp;
  private processedUrls: Set<string> = new Set();
  
  async collectFromSources(sources: string[]) {
    const allData: any[] = [];
    
    for (const source of sources) {
      console.log(`Processing source: ${source}`);
      
      try {
        const data = await this.firecrawl.crawlUrl(source, {
          crawlerOptions: {
            maxDepth: 3,
            limit: 1000,
          },
          pageOptions: {
            onlyMainContent: true,
            includeRawHtml: false,
            includeMarkdown: true,
            removeTags: ['script', 'style', 'nav', 'footer'],
          },
        });
        
        const processedData = data.data.map(page => ({
          url: page.url,
          domain: new URL(page.url).hostname,
          title: page.metadata?.title,
          content: this.cleanContent(page.markdown),
          metadata: {
            ...page.metadata,
            collectedAt: new Date().toISOString(),
            source: source,
          },
        }));
        
        allData.push(...processedData);
      } catch (error) {
        console.error(`Error processing ${source}:`, error);
      }
    }
    
    return allData;
  }
  
  private cleanContent(markdown: string): string {
    // 不要な空白行を削除
    let cleaned = markdown.replace(/\n{3,}/g, '\n\n');
    
    // 画像のalt textを保持しつつ、URLを簡略化
    cleaned = cleaned.replace(
      /!\[([^\]]*)\]\([^)]+\)/g,
      '[Image: $1]'
    );
    
    // コードブロックを保持
    // リンクを簡略化
    cleaned = cleaned.replace(
      /\[([^\]]+)\]\([^)]+\)/g,
      '$1'
    );
    
    return cleaned.trim();
  }
}
// データクリーニングとフィルタリング
interface CleaningRule {
  name: string;
  apply: (content: string) => string;
  validate: (content: string) => boolean;
}

class DataCleaner {
  private rules: CleaningRule[] = [
    {
      name: 'remove_email_addresses',
      apply: (content) => content.replace(/[\w.-]+@[\w.-]+\.\w+/g, '[EMAIL]'),
      validate: (content) => !content.match(/[\w.-]+@[\w.-]+\.\w+/),
    },
    {
      name: 'remove_phone_numbers',
      apply: (content) => content.replace(/\+?\d{1,4}?[-.\s]?\(?\d{1,3}?\)?[-.\s]?\d{1,4}[-.\s]?\d{1,4}[-.\s]?\d{1,9}/g, '[PHONE]'),
      validate: (content) => true,
    },
    {
      name: 'normalize_whitespace',
      apply: (content) => content.replace(/\s+/g, ' ').trim(),
      validate: (content) => !content.match(/\s{2,}/),
    },
    {
      name: 'remove_special_chars',
      apply: (content) => content.replace(/[^\w\s\-.,!?'"]/g, ''),
      validate: (content) => true,
    },
  ];
  
  async cleanDataset(data: any[]) {
    const cleaned = [];
    
    for (const item of data) {
      let content = item.content;
      
      // 各ルールを適用
      for (const rule of this.rules) {
        content = rule.apply(content);
      }
      
      // 最小文字数チェック
      if (content.length < 100) {
        console.log(`Skipping short content: ${item.url}`);
        continue;
      }
      
      // 言語検出(オプション)
      const language = await this.detectLanguage(content);
      if (language !== 'en' && language !== 'ja') {
        console.log(`Skipping non-target language: ${language}`);
        continue;
      }
      
      cleaned.push({
        ...item,
        content,
        metadata: {
          ...item.metadata,
          language,
          cleanedAt: new Date().toISOString(),
        },
      });
    }
    
    return cleaned;
  }
  
  private async detectLanguage(text: string): Promise<string> {
    // 簡易的な言語検出(実際はライブラリを使用)
    const japanesePattern = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
    return japanesePattern.test(text) ? 'ja' : 'en';
  }
}
// 構造化とフォーマット変換
interface StructuredData {
  id: string;
  source: string;
  content: string;
  metadata: Record<string, any>;
  embeddings?: number[];
}

class DataStructurer {
  async structureForLLM(data: any[]): Promise<StructuredData[]> {
    const structured = [];
    
    for (const item of data) {
      // チャンク分割
      const chunks = this.splitIntoChunks(item.content, {
        maxTokens: 2048,
        overlap: 200,
      });
      
      for (const [index, chunk] of chunks.entries()) {
        structured.push({
          id: `${item.url}-chunk-${index}`,
          source: item.url,
          content: chunk,
          metadata: {
            ...item.metadata,
            chunkIndex: index,
            totalChunks: chunks.length,
            domain: new URL(item.url).hostname,
          },
        });
      }
    }
    
    return structured;
  }
  
  private splitIntoChunks(
    text: string,
    options: { maxTokens: number; overlap: number }
  ): string[] {
    const chunks: string[] = [];
    const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
    
    let currentChunk = '';
    let currentTokens = 0;
    
    for (const sentence of sentences) {
      const sentenceTokens = this.estimateTokens(sentence);
      
      if (currentTokens + sentenceTokens > options.maxTokens) {
        if (currentChunk) {
          chunks.push(currentChunk.trim());
          
          // オーバーラップ処理
          const overlapSentences = currentChunk
            .split(/[.!?]+/)
            .slice(-2)
            .join('. ');
          currentChunk = overlapSentences + ' ' + sentence;
          currentTokens = this.estimateTokens(currentChunk);
        }
      } else {
        currentChunk += ' ' + sentence;
        currentTokens += sentenceTokens;
      }
    }
    
    if (currentChunk.trim()) {
      chunks.push(currentChunk.trim());
    }
    
    return chunks;
  }
  
  private estimateTokens(text: string): number {
    // 簡易的なトークン推定(実際はtiktokenなどを使用)
    return Math.ceil(text.split(/\s+/).length * 1.3);
  }
  
  async exportToJSONL(data: StructuredData[], filepath: string) {
    const jsonl = data
      .map(item => JSON.stringify({
        text: item.content,
        metadata: item.metadata,
      }))
      .join('\n');
    
    await writeFile(filepath, jsonl, 'utf-8');
  }
}
// データ品質バリデーション
interface ValidationResult {
  valid: boolean;
  errors: string[];
  warnings: string[];
  stats: {
    totalItems: number;
    validItems: number;
    avgContentLength: number;
    uniqueDomains: number;
  };
}

class DataValidator {
  async validateDataset(data: StructuredData[]): Promise<ValidationResult> {
    const errors: string[] = [];
    const warnings: string[] = [];
    const validItems: string[] = [];
    
    // 基本的な統計情報
    const stats = {
      totalItems: data.length,
      validItems: 0,
      avgContentLength: 0,
      uniqueDomains: new Set(data.map(d => d.metadata.domain)).size,
    };
    
    // 各アイテムのバリデーション
    for (const [index, item of data.entries()) {
      const itemErrors: string[] = [];
      
      // 必須フィールドチェック
      if (!item.id) {
        itemErrors.push(`Item ${index}: Missing ID`);
      }
      if (!item.content || item.content.trim().length === 0) {
        itemErrors.push(`Item ${index}: Empty content`);
      }
      
      // コンテンツ品質チェック
      if (item.content.length < 50) {
        warnings.push(`Item ${index}: Very short content (${item.content.length} chars)`);
      }
      
      // 重複チェック
      const contentHash = this.hashContent(item.content);
      if (this.seenHashes.has(contentHash)) {
        warnings.push(`Item ${index}: Duplicate content detected`);
      }
      this.seenHashes.add(contentHash);
      
      // 文字化けチェック
      if (this.hasGarbledText(item.content)) {
        itemErrors.push(`Item ${index}: Contains garbled text`);
      }
      
      if (itemErrors.length === 0) {
        stats.validItems++;
        validItems.push(item.id);
      } else {
        errors.push(...itemErrors);
      }
    }
    
    // 平均コンテンツ長の計算
    stats.avgContentLength = Math.round(
      data.reduce((sum, item) => sum + item.content.length, 0) / data.length
    );
    
    return {
      valid: errors.length === 0,
      errors,
      warnings,
      stats,
    };
  }
  
  private seenHashes = new Set<string>();
  
  private hashContent(content: string): string {
    // 簡易的なハッシュ(実際はcrypto.createHashを使用)
    return content.slice(0, 100).toLowerCase().replace(/\s+/g, '');
  }
  
  private hasGarbledText(text: string): boolean {
    // 文字化けの検出
    const garbledPatterns = [
      /[\x00-\x1F\x7F-\x9F]/,  // 制御文字
      /{3,}/,  // 連続する置換文字
      /[^\x00-\x7F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\uFF00-\uFFEF]/,  // 想定外の文字
    ];
    
    return garbledPatterns.some(pattern => pattern.test(text));
  }
}

RAGシステムへの統合

ベクトルデータベースへの格納

rag-integration.ts
import { Pinecone } from '@pinecone-database/pinecone';
import { OpenAI } from 'openai';
import FirecrawlApp from '@mendable/firecrawl-js';

class RAGPipeline {
private firecrawl: FirecrawlApp;
private pinecone: Pinecone;
private openai: OpenAI;
private index: any;

constructor(config: {
  firecrawlApiKey: string;
  pineconeApiKey: string;
  openaiApiKey: string;
  indexName: string;
}) {
  this.firecrawl = new FirecrawlApp({ apiKey: config.firecrawlApiKey });
  this.pinecone = new Pinecone({ apiKey: config.pineconeApiKey });
  this.openai = new OpenAI({ apiKey: config.openaiApiKey });
  this.index = this.pinecone.index(config.indexName);
}

async ingestWebsite(url: string, namespace: string = 'default') {
  console.log(`Ingesting website: ${url}`);
  
  // Webサイトをクロール
  const crawlResult = await this.firecrawl.crawlUrl(url, {
    crawlerOptions: {
      maxDepth: 3,
      limit: 100,
    },
    pageOptions: {
      onlyMainContent: true,
    },
  });
  
  // 各ページを処理
  const vectors = [];
  for (const page of crawlResult.data) {
    // テキストをチャンクに分割
    const chunks = this.splitIntoChunks(page.markdown);
    
    // 各チャンクの埋め込みを生成
    for (const [index, chunk] of chunks.entries()) {
      const embedding = await this.generateEmbedding(chunk);
      
      vectors.push({
        id: `${page.url}-chunk-${index}`,
        values: embedding,
        metadata: {
          url: page.url,
          title: page.metadata?.title || 'Untitled',
          chunk: chunk,
          chunkIndex: index,
          totalChunks: chunks.length,
          crawledAt: new Date().toISOString(),
        },
      });
    }
  }
  
  // Pineconeに保存
  await this.index.namespace(namespace).upsert(vectors);
  console.log(`Ingested ${vectors.length} chunks from ${crawlResult.data.length} pages`);
}

async query(question: string, namespace: string = 'default', topK: number = 5) {
  // 質問の埋め込みを生成
  const queryEmbedding = await this.generateEmbedding(question);
  
  // 類似検索
  const results = await this.index.namespace(namespace).query({
    vector: queryEmbedding,
    topK,
    includeMetadata: true,
  });
  
  // コンテキストを構築
  const context = results.matches
    .map(match => match.metadata?.chunk)
    .filter(Boolean)
    .join('

');
  
  // LLMで回答生成
  const response = await this.openai.chat.completions.create({
    model: 'gpt-4',
    messages: [
      {
        role: 'system',
        content: 'You are a helpful assistant. Answer based on the provided context.',
      },
      {
        role: 'user',
        content: `Context:
${context}

Question: ${question}`,
      },
    ],
    temperature: 0.7,
  });
  
  return {
    answer: response.choices[0].message.content,
    sources: results.matches.map(m => ({
      url: m.metadata?.url,
      title: m.metadata?.title,
      score: m.score,
    })),
  };
}

private async generateEmbedding(text: string): Promise<number[]> {
  const response = await this.openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  });
  
  return response.data[0].embedding;
}

private splitIntoChunks(text: string, maxLength: number = 1000): string[] {
  const chunks = [];
  const paragraphs = text.split('

');
  let currentChunk = '';
  
  for (const paragraph of paragraphs) {
    if (currentChunk.length + paragraph.length > maxLength) {
      if (currentChunk) {
        chunks.push(currentChunk.trim());
      }
      currentChunk = paragraph;
    } else {
      currentChunk += '

' + paragraph;
    }
  }
  
  if (currentChunk) {
    chunks.push(currentChunk.trim());
  }
  
  return chunks;
}
}

パフォーマンス最適化

処理速度とコストの最適化

Firecrawl最適化手法とその効果
最適化手法 効果 実装難易度 コスト削減
バッチ処理 処理速度向上 20-30%
キャッシュ活用 API呼び出し削減 40-50%
並列処理 時間短縮 0%
差分更新 データ量削減 60-70%
圧縮転送 帯域幅削減 10-15%
API効率化による処理速度向上 85 %
キャッシュヒット率 70 %
データ品質スコア 95 %

ベストプラクティス

実装のポイント

  1. レート制限の考慮:API の制限に応じた適切な待機時間を設定
  2. エラーハンドリング:リトライロジックとフォールバック処理を実装
  3. データ検証:取得したデータの品質を常にチェック
  4. 増分更新:全体再クロールではなく差分更新を活用

まとめ

Firecrawl は、Web コンテンツを LLM や RAG システムに最適な形式に変換する強力なツールです。適切に活用することで、高品質なトレーニングデータの準備や、効果的な知識ベースの構築が可能になります。

Firecrawlの導入効果

  • データ準備時間を90%削減:手動処理からの解放
  • LLM精度の向上:クリーンなデータによる学習効果
  • スケーラブルな処理:大規模サイトにも対応
  • コスト効率:必要なデータのみを効率的に取得

特に、RAG システムの構築や LLM のファインチューニングを行う際には、Firecrawl は不可欠なツールとなるでしょう。

Rinaのプロフィール画像

Rina

Daily Hack 編集長

フルスタックエンジニアとして10年以上の経験を持つ。 大手IT企業やスタートアップでの開発経験を活かし、 実践的で即効性のある技術情報を日々発信中。 特にWeb開発、クラウド技術、AI活用に精通。

この記事は役に立ちましたか?

あなたのフィードバックが記事の改善に役立ちます

この記事は役に立ちましたか?

Daily Hackでは、開発者の皆様に役立つ情報を毎日発信しています。