Twitch OCR for img

Twitchの画面からスレ番号っぽいものを抽出します。視聴者数の左に各種ボタン、結果はチャット欄に表示されます。誤認識することもそれなりにあります。また、実行するたびに共有ウィンドウで画面を選択する必要があります。

// ==UserScript==
// @name         Twitch OCR for img
// @namespace    http://github.com/uzuky
// @version      30.3.1
// @description  Twitchの画面からスレ番号っぽいものを抽出します。視聴者数の左に各種ボタン、結果はチャット欄に表示されます。誤認識することもそれなりにあります。また、実行するたびに共有ウィンドウで画面を選択する必要があります。
// @author       uzuky
// @license      MIT
// @match        https://www.twitch.tv/*
// @require      https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js
// @grant        none
// @icon         https://www.google.com/s2/favicons?sz=64&domain=twitch.tv
// ==/UserScript==

(function() {
    'use strict';

    // --- グローバル定数 ---
    const TARGET_WIDTH_PX = 3000;    // OCR精度向上のため、キャプチャ画像をこの幅にリサイズする
    const INITIAL_THRESHOLD = 200;   // 画像を二値化する際の初期の閾値(0-255)
    const STORAGE_KEY = 'twitch_ocr_thresholds_v2'; // localStorageに保存するときのキー
    const THRESHOLD_EXPIRATION_DAYS = 90; // 設定の有効期限(日)
    const CUSTOM_TOOLTIP_ID = 'ocr-custom-tooltip';

    // --- ストレージ関連ヘルパー関数 ---

    /**
     * 現在のURLからTwitchのチャンネル名を取得する。
     * @returns {string|null} チャンネル名。取得できない場合はnullを返する。
     */
    function getChannelName() {
        const match = window.location.pathname.match(/^\/([a-zA-Z0-9_]+)/);
        if (match && match[1] && !['directory', 'downloads', 'settings', 'p'].includes(match[1])) {
            return match[1];
        }
        return null;
    }

    /**
     * すべてのチャンネルの閾値設定をlocalStorageから読み込み。
     * @returns {object} 保存されているすべての設定オブジェクト。
     */
    function loadAllSettings() {
        try {
            const stored = localStorage.getItem(STORAGE_KEY);
            return stored ? JSON.parse(stored) : {};
        } catch (e) {
            console.error('[OCR Script] 設定の読み込みに失敗しました。', e);
            return {};
        }
    }

    /**
     * すべてのチャンネルの閾値設定をlocalStorageに保存する。
     * @param {object} settings 保存するすべての設定オブジェクト。
     */
    function saveAllSettings(settings) {
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
        } catch (e) {
            console.error('[OCR Script] 設定の保存に失敗しました。', e);
        }
    }

    /**
     * localStorageに保存されている設定のうち、古いものやデフォルト値のものを削除(クリーンアップ)する。
     * スクリプト読み込み時に一度だけ実行する。
     */
    function cleanupStoredSettings() {
        const allSettings = loadAllSettings();
        const now = new Date().getTime();
        const expirationMs = THRESHOLD_EXPIRATION_DAYS * 24 * 60 * 60 * 1000;
        const cleanedSettings = {};
        for (const channel in allSettings) {
            const setting = allSettings[channel];
            // タイムスタンプがあり、有効期限内で、かつ閾値が初期値(200)でないものだけを残する。
            if (setting && setting.timestamp && (now - setting.timestamp < expirationMs) && setting.threshold !== INITIAL_THRESHOLD) {
                cleanedSettings[channel] = setting;
            }
        }
        saveAllSettings(cleanedSettings);
    }

    // --- UI・メイン処理関連関数 ---

    /**
     * ページ全体をスムーズに一番上までスクロールする
     * TwitchのUI構造 (SimpleBarライブラリ) に対応。
     */
    function scrollToPageTop() {
        const SCROLL_TARGET_SELECTOR = '[data-a-target="root-scroller"] .simplebar-scroll-content';
        const scrollableElement = document.querySelector(SCROLL_TARGET_SELECTOR);

        if (scrollableElement) {
            scrollableElement.scrollTo({ top: 0, behavior: 'auto' });
        } else {
            // 見つからない場合のフォールバックとしてwindowをスクロール
            window.scrollTo({ top: 0, behavior: 'auto' });
        }
    }

    /**
     * OCRツール(ボタンやスライダー)のUIを作成し、ページに配置する
     * @param {HTMLElement} parent - UIを追加する親要素。
     */
    function setupUI(parent) {
        if (document.querySelector('#ocr-tool-container')) return;

        const channelName = getChannelName();
        let currentThreshold = INITIAL_THRESHOLD;

        // チャンネルに対応する保存設定があれば読み込み、タイムスタンプを更新する
        if (channelName) {
            const allSettings = loadAllSettings();
            const channelSetting = allSettings[channelName];
            if (channelSetting && typeof channelSetting.threshold === 'number') {
                currentThreshold = channelSetting.threshold;
                allSettings[channelName].timestamp = new Date().getTime();
                saveAllSettings(allSettings);
            }
        }

        // --- UI要素の動的作成 ---
        const mainContainer = document.createElement('div');
        mainContainer.id = 'ocr-tool-container';
        mainContainer.style.cssText = 'display: inline-flex; flex-direction: row; align-items: center; gap: 4px; margin-right: 1rem;';
        const sliderContainer = document.createElement('div');
        sliderContainer.style.cssText = 'display:flex; align-items:center; background-color:rgba(255,255,255,0.1); padding:2px 5px; border-radius:4px;';
        const label = document.createElement('label');
        label.textContent = '閾値:';
        label.style.cssText = 'font-size:10px; margin-right:4px; color:var(--color-text-base); cursor:default;';
        const slider = document.createElement('input');
        slider.type = 'range'; slider.id = 'threshold-slider'; slider.min = 0; slider.max = 255; slider.step = 1;
        slider.value = currentThreshold;
        slider.style.width = '60px';
        const valueDisplay = document.createElement('span');
        valueDisplay.id = 'threshold-value';
        valueDisplay.textContent = currentThreshold;
        valueDisplay.style.cssText = 'font-size:10px; min-width:22px; text-align:right; margin-left:4px; color:var(--color-text-base);';
        slider.oninput = () => { valueDisplay.textContent = slider.value; };
        sliderContainer.append(label, slider, valueDisplay);
        const minusButton = document.createElement('button');
        minusButton.textContent = '-';
        minusButton.style.cssText = 'width: 24px; height: 24px; padding: 0; background-color: #5C5C5E; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; display: flex; justify-content: center; align-items: center; font-size: 16px; line-height: 1;';
        minusButton.onclick = () => {
            slider.value = Math.max(0, Number(slider.value) - 1);
            slider.dispatchEvent(new Event('input'));
        };
        const plusButton = document.createElement('button');
        plusButton.textContent = '+';
        plusButton.style.cssText = 'width: 24px; height: 24px; padding: 0; background-color: #5C5C5E; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; display: flex; justify-content: center; align-items: center; font-size: 16px; line-height: 1;';
        plusButton.onclick = () => {
            slider.value = Math.min(255, Number(slider.value) + 1);
            slider.dispatchEvent(new Event('input'));
        };
        const ocrButton = document.createElement('button');
        ocrButton.id = 'ocr-button';
        ocrButton.textContent = '認識';
        ocrButton.style.cssText = 'padding:0 10px; height:24px; background-color:#FF7F50; color:white; border:none; border-radius:4px; cursor:pointer; font-size:11px; font-weight:bold; line-height: 24px; white-space: nowrap;';
        ocrButton.onclick = captureAndOcr;
        mainContainer.append(sliderContainer, minusButton, plusButton, ocrButton);
        parent.prepend(mainContainer);
    }

    /**
     * 「認識」ボタンが押されたときに実行されるメインの処理フロー
     */
    async function captureAndOcr() {
        scrollToPageTop();

        const captureTimestamp = getJSTTimestamp();
        const thresholdValue = parseInt(document.querySelector('#threshold-slider').value, 10);
        const ocrButton = document.querySelector('#ocr-button');
        const originalText = ocrButton.textContent;
        ocrButton.disabled = true;

        let progressMessage = displayMessageInChat('処理中...');
        if (!progressMessage) {
            console.error('[OCR Script] チャットコンテナが見つかりません。');
            ocrButton.disabled = false;
            return;
        }

        let dotCount = 1;
        const animationInterval = setInterval(() => {
            dotCount = (dotCount % 3) + 1;
            progressMessage.textContent = '処理中' + '.'.repeat(dotCount);
        }, 333);

        try {
            // 通常画像でOCRを実行する。
            const canvas1 = await getProcessedCanvas();
            const result1 = await Tesseract.recognize(canvas1, 'eng', { tessedit_char_whitelist: '0123456789' });
            let finalMatches = findMatchesInText(result1.data.text);

            if (finalMatches.length > 0) {
                handleOcrResult(canvas1, result1, null, null, finalMatches, progressMessage, captureTimestamp, thresholdValue);
                return;
            }

            // 見つからなければ、色を反転させた画像で再実行する。
            const canvas2 = getInvertedCanvas(canvas1);
            const result2 = await Tesseract.recognize(canvas2, 'eng', { tessedit_char_whitelist: '0123456789' });
            finalMatches = findMatchesInText(result2.data.text);
            handleOcrResult(canvas1, result1, canvas2, result2, finalMatches, progressMessage, captureTimestamp, thresholdValue);

        } catch (error) {
            console.error('[OCR Script] 処理中にエラーが発生しました。', error);
            if (error.message && error.message.toLowerCase().includes('user closed')) {
                 progressMessage.textContent = `画面共有がキャンセルされました。`;
            } else {
                 progressMessage.textContent = `エラー: ${error.message}`;
            }
        } finally {
            clearInterval(animationInterval);
            ocrButton.textContent = originalText;
            ocrButton.disabled = false;
        }
    }

    // --- OCR・画像処理関連関数 ---

    /**
     * 画面をキャプチャし、二値化などの前処理を施したCanvas要素を生成する。
     * @returns {Promise<HTMLCanvasElement>} 処理済みのCanvas要素。
     */
    async function getProcessedCanvas() {
        const stream = await navigator.mediaDevices.getDisplayMedia({ video: { mediaSource: "screen", cursor: "never" }, audio: false });
        await new Promise(resolve => setTimeout(resolve, 500)); // 共有メニューが写り込まないように待機
        const tempVideo = document.createElement('video');
        await new Promise((resolve, reject) => {
            tempVideo.onloadedmetadata = () => { tempVideo.play(); resolve(); };
            tempVideo.onerror = (e) => reject(new Error("ビデオ要素の読み込みに失敗しました。"));
            tempVideo.srcObject = stream;
        });
        const canvas = document.createElement('canvas');
        const context = canvas.getContext('2d');
        const aspectRatio = tempVideo.videoHeight / tempVideo.videoWidth;
        canvas.width = TARGET_WIDTH_PX;
        canvas.height = TARGET_WIDTH_PX * aspectRatio;
        context.drawImage(tempVideo, 0, 0, canvas.width, canvas.height);
        stream.getTracks().forEach(track => track.stop());

        // 二値化処理
        const threshold = document.querySelector('#threshold-slider').value;
        const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
        const data = imageData.data;
        for (let i = 0; i < data.length; i += 4) {
            const brightness = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
            let value = brightness > threshold ? 255 : 0;
            data[i] = data[i + 1] = data[i + 2] = value;
        }
        context.putImageData(imageData, 0, 0);
        return canvas;
    }

    /**
     * 指定されたCanvasの白黒を反転させた新しいCanvasを生成する。
     * @param {HTMLCanvasElement} sourceCanvas - 元となるCanvas要素。
     * @returns {HTMLCanvasElement} 色反転した新しいCanvas要素。
     */
    function getInvertedCanvas(sourceCanvas) {
        const canvas = document.createElement('canvas');
        canvas.width = sourceCanvas.width; canvas.height = sourceCanvas.height;
        const context = canvas.getContext('2d');
        const imageData = sourceCanvas.getContext('2d').getImageData(0, 0, sourceCanvas.width, sourceCanvas.height);
        const data = imageData.data;
        for (let i = 0; i < data.length; i += 4) {
            data[i] = 255 - data[i]; data[i + 1] = 255 - data[i + 1]; data[i + 2] = 255 - data[i + 2];
        }
        context.putImageData(imageData, 0, 0);
        return canvas;
    }

    /**
     * OCR結果のテキスト全体から、10桁の数字(スレ番号)を抽出する。
     * @param {string} text - Tesseract.jsが認識したテキスト。
     * @returns {string[]} 見つかった10桁の数字の配列。
     */
    function findMatchesInText(text) {
        const potentialMatches = text.match(/[\d\s]+/g) || [];
        const matches = [];
        potentialMatches.forEach(candidate => {
            const cleaned = candidate.replace(/\s/g, '');
            const spaceCount = candidate.length - cleaned.length;
            if (cleaned.length === 10 && /^\d{10}$/.test(cleaned) && spaceCount <= 2) {
                matches.push(cleaned);
            }
        });
        return matches;
    }

    // --- 結果表示・デバッグ関連関数 ---

    /**
     * OCRの実行結果を処理し、チャット欄に表示する。
     * 認識成功時には、閾値の保存もここで行う。
     */
    function handleOcrResult(canvas1, result1, canvas2, result2, matches, progressMessage, timestamp, threshold) {
        progressMessage.innerHTML = '';
        const uniqueMatches = [...new Set(matches)];

        if (uniqueMatches.length > 0) {
            const channelName = getChannelName();
            if (channelName) {
                const allSettings = loadAllSettings();
                if (threshold !== INITIAL_THRESHOLD) {
                    allSettings[channelName] = { threshold: threshold, timestamp: new Date().getTime() };
                    saveAllSettings(allSettings);
                } else if (allSettings[channelName]) {
                    delete allSettings[channelName];
                    saveAllSettings(allSettings);
                }
            }

            // 成功結果をチャットに表示する。
            uniqueMatches.forEach(match => {
                const fileName = match + '.htm';
                const url = `https://img.2chan.net/b/res/${fileName}`;
                const link = document.createElement('a');
                link.href = url;
                link.textContent = 'スレに飛ぶ';
                link.target = '_blank';
                link.rel = 'noopener noreferrer';
                link.style.cssText = 'color:#a970ff; text-decoration:underline;';
                // マウスがリンクに乗った時の処理
                link.addEventListener('mouseenter', (e) => {
                    // 既存のツールチップがあれば念のため削除
                    const existingTooltip = document.getElementById(CUSTOM_TOOLTIP_ID);
                    if (existingTooltip) existingTooltip.remove();

                    // ツールチップ要素を動的に作成
                    const tooltip = document.createElement('div');
                    tooltip.id = CUSTOM_TOOLTIP_ID;
                    tooltip.textContent = fileName;


                    Object.assign(tooltip.style, {
                        position: 'fixed',
                        left: `${e.clientX + 15}px`,
                        top: `${e.clientY + 15}px`,
                        backgroundColor: 'rgba(20, 20, 20, 0.9)',
                        color: 'white',
                        padding: '5px 10px',
                        borderRadius: '4px',
                        fontSize: '13px',
                        zIndex: '10000',
                        pointerEvents: 'none',
                        fontFamily: 'sans-serif',
                        border: '1px solid #555'
                    });

                    document.body.appendChild(tooltip);
                });

                // マウスがリンクから離れた時の処理
                link.addEventListener('mouseleave', () => {
                    const tooltip = document.getElementById(CUSTOM_TOOLTIP_ID);
                    if (tooltip) tooltip.remove();
                });

                const thresholdInfo = document.createElement('span');
                thresholdInfo.textContent = ` [閾値:${threshold}]`;
                thresholdInfo.style.cssText = 'font-size: 11px; color: var(--color-text-alt-2); margin-left: 4px;';
                const copyButton = document.createElement('button');
                copyButton.textContent = 'コピー';
                copyButton.style.cssText = 'margin-left: 8px; padding: 2px 6px; font-size: 11px; background-color: #5C5C5E; color: white; border: none; border-radius: 3px; cursor: pointer;';
                copyButton.onclick = () => {
                    navigator.clipboard.writeText(url).then(() => {
                        copyButton.textContent = 'OK!'; copyButton.style.backgroundColor = '#00AD80';
                        setTimeout(() => { copyButton.textContent = 'コピー'; copyButton.style.backgroundColor = '#5C5C5E'; }, 2000);
                    });
                };
                const contentContainer = document.createElement('span');
                const debugButton1 = createDebugButton('認識結果', canvas1, result1.data.words, timestamp, threshold);
                contentContainer.append(link, thresholdInfo, copyButton, debugButton1);
                if (canvas2 && result2) {
                    const debugButton2 = createDebugButton('認識結果(反転後)', canvas2, result2.data.words, timestamp, threshold);
                    contentContainer.append(debugButton2);
                }
                progressMessage.appendChild(contentContainer);
            });
        } else {
            // 失敗結果をチャットに表示する
            const messageContainer = document.createElement('span');
            messageContainer.textContent = `10桁の数字は見つかりませんでした。(閾値: ${threshold}) 例えば数字も背景も暗い場合は閾値を下げてみてください。`;
            messageContainer.append(createDebugButton('認識結果', canvas1, result1.data.words, timestamp, threshold));
            if (canvas2 && result2) {
                messageContainer.append(createDebugButton('認識結果(反転後)', canvas2, result2.data.words, timestamp, threshold));
            }
            progressMessage.appendChild(messageContainer);
        }
    }

    /**
     * Twitchのチャット欄にメッセージを表示するためのDOM要素を作成・追加する。
     * @param {string|HTMLElement} content - 表示するテキストまたはHTML要素。
     * @returns {HTMLElement} メッセージ内容を格納するコンテナ要素。
     */
    function displayMessageInChat(content) {
        const chatContainer = document.querySelector('.chat-scrollable-area__message-container');
        if (!chatContainer) return;
        const chatLine = document.createElement('div');
        chatLine.classList.add('chat-line__message');
        chatLine.style.cssText = 'padding: 4px 20px; display: flex; align-items: center; flex-wrap: wrap;';
        const prefix = document.createElement('span');
        prefix.textContent = '[OCR] ';
        prefix.style.cssText = 'color: #ff7f50; font-weight: bold; flex-shrink: 0; margin-right: 4px;';
        const messageContainer = document.createElement('span');
        messageContainer.style.display = 'flex'; messageContainer.style.alignItems = 'center'; messageContainer.style.flexWrap = 'wrap';
        if (typeof content === 'string') {
            messageContainer.textContent = content;
        } else {
            messageContainer.appendChild(content);
        }
        chatLine.append(prefix, messageContainer);
        chatContainer.appendChild(chatLine);

        return messageContainer;
    }

    /**
     * JST(日本標準時)のタイムスタンプ文字列を生成する。
     */
    function getJSTTimestamp() {
        const now = new Date();
        const year = now.getFullYear();
        const month = String(now.getMonth() + 1).padStart(2, '0');
        const day = String(now.getDate()).padStart(2, '0');
        const hours = String(now.getHours()).padStart(2, '0');
        const minutes = String(now.getMinutes()).padStart(2, '0');
        const seconds = String(now.getSeconds()).padStart(2, '0');
        return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
    }

    /**
     * 認識過程の画像を確認・ダウンロードするためのボタンを作成する。
     */
    function createDebugButton(label, canvas, words, timestamp, threshold) {
        const button = document.createElement('button');
        button.textContent = label;
        button.style.cssText = 'margin-left: 8px; padding: 2px 6px; font-size: 11px; background-color: #464649; color: white; border: none; border-radius: 3px; cursor: pointer;';
        button.onclick = () => drawAndDownloadDebugImage(canvas, words, timestamp, threshold);
        return button;
    }

    /**
     * ボタンが押されたときに、認識結果を重ね描きした画像をダウンロードする。
     */
    function drawAndDownloadDebugImage(canvas, words, timestamp, threshold) {
        if (!words) return;
        const debugCanvas = document.createElement('canvas');
        debugCanvas.width = canvas.width; debugCanvas.height = canvas.height;
        const context = debugCanvas.getContext('2d');
        context.drawImage(canvas, 0, 0);
        context.strokeStyle = 'red';
        context.lineWidth = 1;
        context.fillStyle = 'lime';
        const fontSize = 16;
        context.font = `bold ${fontSize}px sans-serif`;
        words.forEach(word => {
            const bbox = word.bbox;
            let textY;
            if (bbox.y0 < fontSize + 2) {
                context.textBaseline = 'top'; textY = bbox.y1 + 2;
            } else {
                context.textBaseline = 'bottom'; textY = bbox.y0 - 2;
            }
            context.fillText(word.text, bbox.x0, textY);
            context.strokeRect(bbox.x0, bbox.y0, bbox.x1 - bbox.x0, bbox.y1 - bbox.y0);
        });
        const dataUrl = debugCanvas.toDataURL("image/png");
        const link = document.createElement('a');
        link.download = `ocr-th-${threshold}_${timestamp}.png`;
        link.href = dataUrl;
        link.click();
    }


    // --- スクリプト実行開始点 ---

    // 1. 古い設定をクリーンアップする。
    cleanupStoredSettings();

    // 2. UIを配置するターゲット要素が表示されるまで監視し、表示されたらUIをセットアップする。
    const interval = setInterval(() => {
        const targetContainer = document.querySelector('.channel-info-content .dglNpm');
        if (targetContainer && !document.querySelector('#ocr-tool-container')) {
            setupUI(targetContainer);
        }
    }, 2000);

})();