スクレイピングツール開発で押さえておくべき技術的なポイントをまとめました。
これからPuppeteerでスクレイピングツールを作る方の参考になれば幸いです。
この記事で解説すること
- スクレイピングツールのアーキテクチャ設計
- Puppeteerでの実装テクニック
- よくあるエラーと対処法
- セキュリティ・品質を高めるコーディングパターン
- 利用規約遵守のチェックリスト
1. アーキテクチャ設計のポイント
構成:Express.js + Puppeteer + WebSocket
実際に開発したツールでは、以下の構成を採用しました。
クライアント(ブラウザ)
↓ HTTP / WebSocket
Express.jsサーバー
↓
SearchManager(検索オーケストレーション)
↓
ScraperRegistry(スクレイパー管理)
↓
各サイト専用スクレイパー
↓
Puppeteer(ヘッドレスブラウザ)
この構成のメリットは3つあります。
- 非同期検索: 検索開始後すぐにレスポンスを返し、進捗はWebSocketで通知
- 拡張性: 新規サイト追加時はスクレイパーファイルを追加するだけ
- 再利用性: 検索ロジックと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: コーディングパターン集
まとめ
スクレイピングツール開発のポイントをまとめます。
- 設計: 基底クラスで共通処理を抽出し、サイト固有処理は継承で実装
- 実装: リクエスト間隔3秒以上、自動スクロール、複数セレクター対応
- エラー対策: URL形式は実サイト確認、undefined隠蔽に注意
- 品質: XSS対策、ディープクローン、メモリリーク対策
- 規約遵守: robots.txt確認、利用規約の定期チェック
スクレイピングは便利な技術ですが、対象サイトへの配慮を忘れずに、節度ある利用を心がけましょう。

