スクレイピングツール開発で押さえておくべき技術的なポイントをまとめました。
これからPuppeteerでスクレイピングツールを作る方の参考になれば幸いです。

この記事で解説すること

  • スクレイピングツールのアーキテクチャ設計
  • Puppeteerでの実装テクニック
  • よくあるエラーと対処法
  • セキュリティ・品質を高めるコーディングパターン
  • 利用規約遵守のチェックリスト

    1. アーキテクチャ設計のポイント

    構成:Express.js + Puppeteer + WebSocket

    実際に開発したツールでは、以下の構成を採用しました。

    クライアント(ブラウザ)
        ↓ HTTP / WebSocket
    Express.jsサーバー
        ↓
    SearchManager(検索オーケストレーション)
        ↓
    ScraperRegistry(スクレイパー管理)
        ↓
    各サイト専用スクレイパー
        ↓
    Puppeteer(ヘッドレスブラウザ)
    

    この構成のメリットは3つあります。

    1. 非同期検索: 検索開始後すぐにレスポンスを返し、進捗はWebSocketで通知
    2. 拡張性: 新規サイト追加時はスクレイパーファイルを追加するだけ
    3. 再利用性: 検索ロジックとUI/APIが分離されている

    基底クラスによる統一インターフェース

    複数サイトに対応する場合、基底クラスを作っておくと保守が楽になります。

    // scrapers/base-scraper.js
    class BaseScraper {
      constructor(config) {
        this.id = config.id;           // 'photo-ac'
        this.name = config.name;       // '写真AC'
        this.baseUrl = config.baseUrl;
        this.mediaType = config.mediaType; // 'photo' | 'illust' | 'video'
      }
    
      // サブクラスで実装必須
      buildSearchUrl(keyword) {
        throw new Error('buildSearchUrl must be implemented');
      }
    
      async parseResults(page) {
        throw new Error('parseResults must be implemented');
      }
    
      // 共通処理
      async search(browser, keyword, onProgress) {
        const page = await browser.newPage();
        await this.setupPage(page);
        
        try {
          const url = this.buildSearchUrl(keyword);
          await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
          await this.autoScroll(page);
          return await this.parseResults(page);
        } finally {
          await page.close();
        }
      }
    }
    

    サイト固有の実装は継承して作成します。

    // scrapers/photo-ac.js
    class PhotoACScraper extends BaseScraper {
      constructor() {
        super({
          id: 'photo-ac',
          name: '写真AC',
          baseUrl: 'https://www.photo-ac.com',
          mediaType: 'photo'
        });
      }
    
      buildSearchUrl(keyword) {
        return `${this.baseUrl}/main/search?q=${encodeURIComponent(keyword)}`;
      }
    
      async parseResults(page) {
        return await page.evaluate(() => {
          // サイト固有のDOM解析ロジック
        });
      }
    }
    

    2. Puppeteerスクレイピングの実装テクニック

    リクエスト間隔は3秒以上空ける

    サーバー負荷を考慮し、リクエスト間隔は最低3秒以上空けましょう。ランダム要素を入れるとより自然です。

    async randomSleep(minMs = 3000, maxMs = 5000) {
      const delay = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
    

    自動スクロールで遅延読み込みに対応

    多くのサイトは無限スクロールや遅延読み込みを採用しています。スクロールしないと画像が読み込まれません。

    async autoScroll(page) {
      await page.evaluate(async () => {
        await new Promise(resolve => {
          let totalHeight = 0;
          const distance = 500;
          const timer = setInterval(() => {
            window.scrollBy(0, distance);
            totalHeight += distance;
            if (totalHeight >= document.body.scrollHeight - window.innerHeight) {
              clearInterval(timer);
              resolve();
            }
          }, 200);
        });
      });
    }
    

    SPA対応:動的コンテンツの待機

    React/Next.js等のSPAサイトでは、DOMが動的に生成されます。適切な待機処理が必要です。

    // 画像が読み込まれるまで待機
    await page.waitForFunction(() => {
      return document.readyState === 'complete' && 
             document.querySelectorAll('img').length > 0;
    }, { timeout: 20000 });
    

    複数セレクターでDOM解析の堅牢性を高める

    サイトのHTML構造は変更されることがあります。複数のセレクターパターンを用意しておくと安心です。

    const searchPatterns = [
      '[data-hero-key^="image-"]',           // data属性パターン
      'img[src*="cdn.example.com"]',         // CDN画像
      'a[href*="/image/"] img',              // 詳細リンク内の画像
      'img[src*=".webp"], img[src*=".jpg"]'  // 拡張子パターン
    ];
    
    let imageElements = [];
    searchPatterns.forEach(pattern => {
      try {
        const elements = document.querySelectorAll(pattern);
        imageElements.push(...elements);
      } catch (e) {
        console.warn('Pattern failed:', pattern);
      }
    });
    

    広告・外部コンテンツの除外

    素材サイトにはiStockやShutterstockの広告が混在していることがあります。CDNドメインでフィルタリングしましょう。

    const thumbDomain = 'thumb.photo-ac.com';
    
    // 正規の素材のみ抽出
    const validImages = images.filter(img => {
      const src = img.src || '';
      return src.includes(thumbDomain) && 
             !src.includes('istockphoto') &&
             !src.includes('shutterstock');
    });
    

    3. よくあるエラーと対処法

    実際の開発で遭遇したエラーと解決策をまとめます。

    URL形式の間違い

    症状: スクレイパーは動くのに検索結果が0件

    原因: 推測したURL形式が実際と異なっていた

    // 間違い:推測ベース
    return `${this.baseUrl}/search?query=${keyword}`;
    
    // 正解:実サイト検証済み
    return `${this.baseUrl}/?q=${keyword}`;
    

    教訓: 実サイトのブラウザ開発者ツールでURL形式を必ず確認する。ドキュメントや推測より実動作が正しい。

    undefined値の隠蔽問題

    症状: プログレスバーが0%にリセットされる

    原因: fallback処理でundefined問題が見えにくくなっていた

    // 問題:undefinedが0に変換される
    const percent = data.progress || 0;
    
    // 解決:明示的に値を設定
    onProgress?.({
      type: 'progress',
      progress: Math.round(currentProgress * 100), // 必ず計算値を渡す
      ...otherData
    });
    

    教訓: || 0等のfallbackは便利だが、バグを隠すこともある。デバッグ時は実際の値を確認する。

    DOM構造変更への対応

    症状: ある日突然、検索結果が取得できなくなる

    原因: サイトのリニューアルでHTML構造が変わった

    対策:

    • 複数のセレクターパターンを用意
    • 結果が0件の場合は警告ログを出力
    • 定期的な動作確認
    if (results.length === 0) {
      logger.warn('parse_results_empty', '検索結果が0件です', {
        context: { siteId: this.id, url: currentUrl },
        aiTodo: 'DOM構造の再確認が必要'
      });
    }
    

    ブラウザインスタンスのリーク

    症状: メモリ使用量が増え続ける

    原因: エラー時にブラウザが閉じられていない

    // 問題:エラー時にpageが閉じられない
    const page = await browser.newPage();
    try {
      // 処理
    } finally {
      await page.close(); // ここに到達しないケースがある
    }
    
    // 解決:nullチェック付きで確実にクローズ
    let page = null;
    try {
      page = await browser.newPage();
      // 処理
    } finally {
      if (page) {
        await page.close();
      }
    }
    

    4. セキュリティ・品質を高めるコーディングパターン

    CodeRabbitでのコードレビューで指摘された内容を含め、重要なパターンをまとめます。

    XSS対策:innerHTML → createElement

    // 危険:XSS脆弱性
    siteDiv.innerHTML = `<strong>${siteName}</strong>`;
    
    // 安全:createElement + textContent
    const strong = document.createElement('strong');
    strong.textContent = siteName;
    siteDiv.appendChild(strong);
    

    ディープクローンでデータ汚染を防ぐ

    // 危険:ネストされたオブジェクトで共有参照
    this.currentSettings = { ...this.defaultSettings };
    
    // 安全:完全なディープクローン
    deepClone(obj) {
      if (typeof structuredClone !== 'undefined') {
        return structuredClone(obj);
      }
      return JSON.parse(JSON.stringify(obj));
    }
    

    パストラバーサル対策

    // 危険:正規表現ベースの不完全なサニタイズ
    const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '');
    
    // 安全:path.basename()で完全な対策
    const path = require('path');
    const sanitized = path.basename(decodeURIComponent(filename));
    if (!sanitized || sanitized === '.' || sanitized === '..') {
      throw new Error('無効なファイル名です');
    }
    

    メモリリーク対策:完了タスクの定期クリーンアップ

    const activeTasks = new Map();
    const TASK_RETENTION_MS = 60 * 60 * 1000; // 1時間
    
    function cleanupOldTasks() {
      const now = Date.now();
      for (const [taskId, task] of activeTasks.entries()) {
        const isCompleted = task.status === 'completed' || task.status === 'failed';
        const isOld = task.endTime && (now - task.endTime) > TASK_RETENTION_MS;
        if (isCompleted && isOld) {
          activeTasks.delete(taskId);
        }
      }
    }
    
    setInterval(cleanupOldTasks, 60 * 60 * 1000);
    

    真のLRUキャッシュ実装

    JavaScriptのMapは挿入順を記録しますが、アクセス順ではありません。LRUを実現するには削除→再挿入が必要です。

    getMemoryCache(key) {
      if (this.cache.has(key)) {
        const value = this.cache.get(key);
        this.cache.delete(key);   // 削除
        this.cache.set(key, value); // 再挿入で最新化
        return value;
      }
      return null;
    }
    

    5. 利用規約遵守のチェックリスト

    スクレイピングは法的グレーゾーンになりやすい領域です。以下を必ず確認しましょう。

    事前確認

    • robots.txtでスクレイピング可否を確認
    • 利用規約でスクレイピング禁止が明記されていないか確認
    • API提供があればそちらを優先検討

    実装時の配慮

    • リクエスト間隔は3秒以上
    • User-Agentは適切に設定(ボット明記も検討)
    • 同時接続数は控えめに
    • ダウンロード機能は実装しない(検索・表示のみ)

    運用時の管理

    • 四半期ごとに利用規約の変更を確認
    • 規約変更時は該当サイトを無効化できる設計
    • 商用利用禁止サイトには警告表示

    スクレイピング vs 手動ブラウザの負荷比較

    適切に実装されたスクレイパーは、実は手動ブラウザより負荷が低いこともあります。

    項目 手動ブラウザ Puppeteerスクレイパー
    HTTPリクエスト数 多い(CSS, JS, 広告, トラッキング等) 少ない(不要リソースをブロック可能)
    画像読み込み 全て読み込み サムネイルのみ
    リクエスト間隔 不規則 制御可能(3秒以上)

    6. 開発プロセスのコツ

    CodeRabbitでのコードレビュー

    AIコードレビューツール「CodeRabbit」を活用すると、以下のような問題を検出できます。

    • セキュリティ脆弱性(XSS、パストラバーサル)
    • 非同期処理の問題(await漏れ、競合状態)
    • メモリリーク
    • 非推奨APIの使用

    実際のプロジェクトでは6回のレビュー→修正サイクルで、深刻な問題を2件→0件に削減できました。

    テストは「壊れたら困る部分」を優先

    カバレッジ80%のような数値目標より、重要な機能のテストを優先しましょう。

    優先度 テスト対象 理由
    parseResults DOM変更で壊れやすい
    URL生成ロジック バグると検索自体が失敗
    設定の読み込み・保存 データ消失を防ぐ
    UI関連 手動確認で十分

    ドキュメントを残す

    将来の自分や他のメンバーのために、以下を記録しておくと便利です。

    • CHANGELOG.md: 変更履歴
    • ERRORLOG.md: エラーと解決策
    • PATTERNS.md: コーディングパターン集

    まとめ

    スクレイピングツール開発のポイントをまとめます。

    1. 設計: 基底クラスで共通処理を抽出し、サイト固有処理は継承で実装
    2. 実装: リクエスト間隔3秒以上、自動スクロール、複数セレクター対応
    3. エラー対策: URL形式は実サイト確認、undefined隠蔽に注意
    4. 品質: XSS対策、ディープクローン、メモリリーク対策
    5. 規約遵守: robots.txt確認、利用規約の定期チェック

    スクレイピングは便利な技術ですが、対象サイトへの配慮を忘れずに、節度ある利用を心がけましょう。