Twitch OCR for img

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

当前为 2025-06-21 提交的版本,查看 最新版本

// ==UserScript==
// @name         Twitch OCR for img
// @namespace    http://github.com/uzuky
// @version      28.0.0
// @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)

    /**
     * OCRツール(ボタンやスライダー)のUIをページにセットアップする関数
     * @param {HTMLElement} parent - UIを追加する親要素
     */
    function setupUI(parent) {
        if (document.querySelector('#ocr-tool-container')) return;

        // --- 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 = INITIAL_THRESHOLD;
        slider.style.width = '60px';

        // 閾値の数値を表示する
        const valueDisplay = document.createElement('span');
        valueDisplay.id = 'threshold-value';
        valueDisplay.textContent = INITIAL_THRESHOLD;
        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);

        // 閾値を1減らすボタン
        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'));
        };

        // 閾値を1増やすボタン
        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'));
        };

        // OCRを実行するボタン
        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() {
        const captureTimestamp = getJSTTimestamp();

        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処理の実行 ---
            // 1. 通常の画像で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);
                return;
            }

            // 2. 見つからなかった場合、色を反転させた画像で再度OCRを実行
            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);

        } catch (error) {
            console.error('[OCR Script] 処理中にエラーが発生しました。', error);
            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;
    }

    /**
     * OCRの実行結果をチャット欄に表示する関数
     * @param {HTMLCanvasElement} canvas1
     * @param {object} result1
     * @param {HTMLCanvasElement|null} canvas2
     * @param {object|null} result2
     * @param {string[]} matches
     * @param {HTMLElement} progressMessage
     * @param {string} timestamp キャプチャ時のタイムスタンプ
     */
    function handleOcrResult(canvas1, result1, canvas2, result2, matches, progressMessage, timestamp) {
        progressMessage.innerHTML = '';
        const uniqueMatches = [...new Set(matches)];

        if (uniqueMatches.length > 0) {
            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 = fileName; link.target = '_blank';
                link.rel = 'noopener noreferrer'; link.style.cssText = 'color:#a970ff; text-decoration:underline;';
                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);
                contentContainer.append(link, copyButton, debugButton1);
                if (canvas2 && result2) {
                    const debugButton2 = createDebugButton('認識結果(反転後)', canvas2, result2.data.words, timestamp);
                    contentContainer.append(debugButton2);
                }
                progressMessage.appendChild(contentContainer);
            });
        } else {
            const messageContainer = document.createElement('span');
            messageContainer.textContent = '10桁の数字は見つかりませんでした。数字も背景も暗い場合は閾値を下げてみてください。';
            messageContainer.append(createDebugButton('認識結果', canvas1, result1.data.words, timestamp));
            if (canvas2 && result2) {
                messageContainer.append(createDebugButton('認識結果(反転後)', canvas2, result2.data.words, timestamp));
            }
            progressMessage.appendChild(messageContainer);
        }
    }

    /**
     * Tesseractが認識したテキスト全体から、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;
    }

    /**
     * Twitchのチャット欄にメッセージを表示するためのヘルパー関数
     */
    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);
        chatContainer.scrollTop = chatContainer.scrollHeight;
        return messageContainer;
    }

    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}`;
    }

    /**
     * 画像ダウンロードボタンを作成する関数
     * @param {string} label
     * @param {HTMLCanvasElement} canvas
     * @param {object[]} words
     * @param {string} timestamp キャプチャ時のタイムスタンプ
     * @returns {HTMLButtonElement}
     */
    function createDebugButton(label, canvas, words, timestamp) {
        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);
        return button;
    }

    /**
     * 指定されたCanvasの白黒を反転させた新しい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;
    }

    /**
     * 認識結果を画像に重ねて描画し、ダウンロードさせる関数
     * @param {HTMLCanvasElement} canvas
     * @param {object[]} words
     * @param {string} timestamp キャプチャ時のタイムスタンプ
     */
    function drawAndDownloadDebugImage(canvas, words, timestamp) {
        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-debug_${timestamp}.png`;
        link.href = dataUrl;
        link.click();
    }


    const interval = setInterval(() => {
        const targetContainer = document.querySelector('.channel-info-content .dglNpm');
        if (targetContainer && !document.querySelector('#ocr-tool-container')) {
            setupUI(targetContainer);
        }
    }, 2000);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址