您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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); })();