😺

Tampermonkeyで3日溶かした末に辿り着いた、動的サイトスクレイピングの5つの壁

に公開

最初は「ちょっとメッセージ履歴を抽出するだけ」のつもりだった。Tampermonkeyでサクッと書けば1時間で終わるだろうと。結果的に3日間、延べ20時間以上を費やすことになるとは思ってもいなかった。

この記事は、その過程で遭遇した5つの壁と、それぞれをどう乗り越えたかの記録である。同じ道を通る人の時間を少しでも節約できれば幸いだ。

第1の壁:静的なHTMLなんてもう存在しない

最初のコードは本当にシンプルだった。

// ==UserScript==
// @name         メッセージ抽出
// @match        https://example.com/*
// ==/UserScript==

(function() {
    'use strict';
    
    const messages = document.querySelectorAll('.message-content');
    const data = Array.from(messages).map(msg => ({
        text: msg.textContent,
        time: msg.querySelector('.timestamp').textContent
    }));
    
    console.log(JSON.stringify(data, null, 2));
})();

実行結果:空の配列。

開発者ツールで確認すると、確かに.message-contentは存在する。なのに取得できない。理由は簡単で、ページロード時にはまだ存在していないからだ。

現代のウェブサイトは、最初に空っぽのHTMLを返し、その後JavaScriptで内容を動的に生成する。つまり、DOMContentLoadedもwindow.onloadも、もはや「ページが完成した」タイミングを意味しない。

// 改良版:MutationObserverで監視
const observer = new MutationObserver((mutations) => {
    const messages = document.querySelectorAll('.message-content');
    if (messages.length > 0) {
        // やっと要素が見つかった!
        extractMessages();
        observer.disconnect();
    }
});

observer.observe(document.body, {
    childList: true,
    subtree: true
});

これで第1の壁は越えられた。しかし、これは序章に過ぎなかった。

第2の壁:無限スクロールという名の無限地獄

メッセージは100件以上あるのに、画面には最新の20件しか表示されない。スクロールすると過去のメッセージが読み込まれる、いわゆる無限スクロールだ。

最初は簡単に考えていた。スクロールイベントを発火させればいいだけだろうと。

// 失敗例1:scrollイベントを発火
window.dispatchEvent(new Event('scroll'));

// 失敗例2:scrollToを使う
window.scrollTo(0, document.body.scrollHeight);

// 失敗例3:要素に対してscrollIntoView
const container = document.querySelector('.message-container');
container.scrollTop = container.scrollHeight;

どれも動かない。いや、正確には「スクロールはするが、新しいメッセージが読み込まれない」。

デバッグを重ねて分かったのは、このサイトが使っているフレームワークは、単純なscrollイベントではなく、wheelイベントやtouchイベント、さらには独自のカスタムイベントを組み合わせて無限スクロールを実装していることだった。

// それでも失敗する例
const wheelEvent = new WheelEvent('wheel', {
    deltaY: 100,
    bubbles: true,
    cancelable: true
});
container.dispatchEvent(wheelEvent);

ここで気づいた。プログラムからのイベント発火と、実際のユーザー操作には決定的な違いがある。isTrustedプロパティだ。

// ユーザーが実際にスクロールした場合
console.log(event.isTrusted); // true

// プログラムから発火した場合
console.log(event.isTrusted); // false

多くのフレームワークは、このisTrustedをチェックして、プログラムからの操作を無視する。セキュリティ的には正しい実装だが、自動化したい側からすると厄介極まりない。

第3の壁:Virtual Scrollingの迷宮

さらに調査を進めると、このサイトはVirtual Scrollingを使っていることが判明した。Virtual Scrollingとは、大量のデータを扱う際に、実際に画面に表示される部分だけをDOMに展開し、見えない部分は削除することでパフォーマンスを向上させる技術だ。

つまり、1000件のメッセージがあっても、DOMには常に20〜30件程度しか存在しない。スクロールに応じて、上の要素は削除され、下の要素が追加される。

// Virtual Scrollingの動作を観察
let messageSet = new Set();

const observer = new MutationObserver((mutations) => {
    mutations.forEach(mutation => {
        // 追加されたノード
        mutation.addedNodes.forEach(node => {
            if (node.classList?.contains('message-content')) {
                const text = node.textContent;
                if (!messageSet.has(text)) {
                    messageSet.add(text);
                    console.log('新しいメッセージ:', text);
                }
            }
        });
        
        // 削除されたノード
        mutation.removedNodes.forEach(node => {
            if (node.classList?.contains('message-content')) {
                console.log('削除されたメッセージ:', node.textContent);
            }
        });
    });
});

ここで新たな問題が発生した。自動スクロールができない以上、手動でスクロールするしかない。しかし、手動スクロール中にどうやって全てのメッセージを漏れなく収集するか?

削除される前に素早くデータを保存する必要がある。しかも、重複なく、順序も保持して。

第4の壁:非同期処理とタイミングの罠

メッセージの読み込みは非同期で行われる。スクロールしてから実際にメッセージが表示されるまでには、ネットワーク遅延も含めて数百ミリ秒のラグがある。

// タイミングの問題で失敗する例
const messages = [];

container.addEventListener('scroll', () => {
    // スクロール直後に取得しても、まだ新しいメッセージは読み込まれていない
    const currentMessages = document.querySelectorAll('.message-content');
    currentMessages.forEach(msg => {
        messages.push(msg.textContent);
    });
});

この問題を解決するには、MutationObserverとスクロールイベントを組み合わせる必要があった。さらに、メッセージの一意性を保証するために、各メッセージのIDや内容のハッシュ値を使った重複チェックも必要だった。

const collectedMessages = new Map(); // IDをキーにして重複を防ぐ
let isProcessing = false;

const processNewMessages = () => {
    if (isProcessing) return;
    isProcessing = true;
    
    const messages = document.querySelectorAll('.message-content');
    messages.forEach(msg => {
        const id = msg.getAttribute('data-message-id') || 
                  generateHashFromContent(msg);
        
        if (!collectedMessages.has(id)) {
            collectedMessages.set(id, {
                text: msg.textContent,
                timestamp: msg.querySelector('.timestamp')?.textContent,
                author: msg.querySelector('.author')?.textContent
            });
        }
    });
    
    isProcessing = false;
};

// MutationObserverで新しい要素を検知
const observer = new MutationObserver(debounce(processNewMessages, 100));

第5の壁:メモリリークと性能劣化

1000件以上のメッセージを処理していると、ブラウザが重くなってきた。メモリ使用量を確認すると、なんと1GB以上消費していた。

原因はいくつかあった:

  1. MutationObserverが全ての変更を記録していた
  2. 削除されたDOMノードへの参照が残っていた
  3. イベントリスナーが蓄積していた
// メモリリークを起こすパターン
const allMutations = []; // これが際限なく増える

const observer = new MutationObserver((mutations) => {
    allMutations.push(...mutations); // 危険!
    processNewMessages();
});

// 改善版:必要な情報だけを保持
const messageData = new Map();

const observer = new MutationObserver((mutations) => {
    mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
            if (isMessageNode(node)) {
                // DOMノードではなく、必要なデータだけを保存
                const data = extractMessageData(node);
                messageData.set(data.id, data);
            }
        });
    });
    
    // 処理が終わったらmutationsへの参照を解放
    mutations = null;
});

さらに、定期的にメモリを解放する仕組みも実装した:

// 定期的なクリーンアップ
setInterval(() => {
    // 画面から消えたメッセージのデータを別の場所に退避
    const visibleIds = new Set(
        Array.from(document.querySelectorAll('[data-message-id]'))
            .map(el => el.getAttribute('data-message-id'))
    );
    
    const archive = [];
    for (const [id, data] of messageData.entries()) {
        if (!visibleIds.has(id)) {
            archive.push(data);
            messageData.delete(id);
        }
    }
    
    // アーカイブデータを保存(IndexedDBやファイルダウンロード等)
    if (archive.length > 0) {
        saveToStorage(archive);
    }
}, 5000);

最終的な解決策:ハイブリッドアプローチ

3日間の試行錯誤の末、完全自動化は諦めた。代わりに、人間の操作とプログラムの処理を組み合わせたハイブリッドアプローチを採用した。

// ==UserScript==
// @name         メッセージ収集アシスタント
// @match        https://example.com/*
// @grant        GM_download
// ==/UserScript==

(function() {
    'use strict';
    
    const state = {
        messages: new Map(),
        isCollecting: false,
        startTime: null,
        stats: {
            total: 0,
            duplicates: 0,
            errors: 0
        }
    };
    
    // UIの追加
    const createUI = () => {
        const panel = document.createElement('div');
        panel.innerHTML = `
            <div style="position: fixed; top: 10px; right: 10px; 
                        background: rgba(0,0,0,0.8); color: white; 
                        padding: 10px; border-radius: 5px; z-index: 9999;">
                <h3>メッセージ収集</h3>
                <button id="startCollection">開始</button>
                <button id="downloadData" disabled>ダウンロード</button>
                <div id="stats">
                    収集数: <span id="count">0</span><br>
                    重複: <span id="duplicates">0</span><br>
                    エラー: <span id="errors">0</span>
                </div>
                <div id="instructions" style="display: none; margin-top: 10px;">
                    ゆっくりと一番下までスクロールしてください
                </div>
            </div>
        `;
        document.body.appendChild(panel);
        
        // イベントハンドラ
        document.getElementById('startCollection').onclick = startCollection;
        document.getElementById('downloadData').onclick = downloadData;
    };
    
    const startCollection = () => {
        state.isCollecting = true;
        state.startTime = Date.now();
        document.getElementById('instructions').style.display = 'block';
        document.getElementById('startCollection').disabled = true;
        
        // MutationObserverを起動
        startObserver();
    };
    
    const startObserver = () => {
        const observer = new MutationObserver((mutations) => {
            if (!state.isCollecting) return;
            
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === 1) { // Element node
                        const messages = node.classList?.contains('message-content') 
                            ? [node] 
                            : node.querySelectorAll?.('.message-content') || [];
                        
                        Array.from(messages).forEach(processMessage);
                    }
                });
            });
            
            updateStats();
        });
        
        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
        
        // 初期メッセージも処理
        document.querySelectorAll('.message-content').forEach(processMessage);
    };
    
    const processMessage = (element) => {
        try {
            const data = {
                id: element.getAttribute('data-message-id') || 
                    generateId(element),
                text: element.textContent.trim(),
                timestamp: element.querySelector('.timestamp')?.textContent,
                author: element.querySelector('.author')?.textContent,
                capturedAt: new Date().toISOString()
            };
            
            if (state.messages.has(data.id)) {
                state.stats.duplicates++;
            } else {
                state.messages.set(data.id, data);
                state.stats.total++;
            }
        } catch (error) {
            state.stats.errors++;
            console.error('メッセージ処理エラー:', error);
        }
    };
    
    const generateId = (element) => {
        const text = element.textContent;
        const timestamp = element.querySelector('.timestamp')?.textContent || '';
        return btoa(encodeURIComponent(text + timestamp)).substring(0, 20);
    };
    
    const updateStats = () => {
        document.getElementById('count').textContent = state.stats.total;
        document.getElementById('duplicates').textContent = state.stats.duplicates;
        document.getElementById('errors').textContent = state.stats.errors;
        
        if (state.stats.total > 0) {
            document.getElementById('downloadData').disabled = false;
        }
    };
    
    const downloadData = () => {
        const data = Array.from(state.messages.values())
            .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
        
        const json = JSON.stringify({
            metadata: {
                totalMessages: state.stats.total,
                duplicatesFound: state.stats.duplicates,
                errors: state.stats.errors,
                collectionTime: Date.now() - state.startTime,
                exportDate: new Date().toISOString()
            },
            messages: data
        }, null, 2);
        
        const blob = new Blob([json], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `messages_${new Date().toISOString().slice(0, 10)}.json`;
        a.click();
        URL.revokeObjectURL(url);
    };
    
    // 初期化
    createUI();
})();

このアプローチの利点:

  1. 確実性: ユーザーの手動スクロールは必ずisTrusted: true
  2. シンプル: 複雑なイベントシミュレーションが不要
  3. 汎用性: ほぼすべてのサイトで動作する
  4. 透明性: ユーザーが処理状況を把握できる

TL;DR

  • 現代のウェブサイトで単純なdocument.querySelectorは通用しない
  • 無限スクロールの自動化はisTrustedの壁にぶつかる
  • Virtual Scrollingは要素の追加・削除を常に監視する必要がある
  • メモリリークを防ぐため、DOMノードへの参照は最小限に
  • 完全自動化にこだわらず、人間とプログラムの協調が現実解

3日間の格闘の末に学んだのは、「動的サイトのスクレイピングは、サイトの実装を深く理解し、その挙動に合わせた戦略を立てる必要がある」ということだ。そして時には、完璧な自動化よりも、実用的な半自動化の方が価値があることもある。

Discussion