您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Twitchの画面からスレ番号っぽいものを抽出します。視聴者数の左に各種ボタン、結果はチャット欄に表示されます。誤認識することもそれなりにあります。また、実行するたびに共有ウィンドウで画面を選択する必要があります。
当前为
// ==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或关注我们的公众号极客氢云获取最新地址