您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Show user chat history with Alt+Click on usernames
// ==UserScript== // @name Chat History Viewer for Twitch // @namespace https://github.com/TimFinitor/twitch-chat-history // @version 1.0.1 // @description Show user chat history with Alt+Click on usernames // @author TimFinitor // @match https://*.twitch.tv/* // @icon https://www.twitch.tv/favicon.ico // ==/UserScript== (function () { 'use strict'; const CONFIG = { enableLogging: false, maxHistoryEntries: 100, duplicateThresholdMs: 5000, captureEmotes: true, shortcutKey: 'alt', enableFFZIntegration: true }; const log = (...args) => { if (CONFIG.enableLogging) { console.log('[Chat History]', ...args); } }; const chatHistory = new Map(); const interceptedSockets = new WeakSet(); function interceptWebSocket() { const originalWebSocket = window.WebSocket; window.WebSocket = function(...args) { const ws = new originalWebSocket(...args); if (args[0] && ( args[0].includes('pubsub-edge') || args[0].includes('irc-ws') || args[0].includes('chat') ) && !interceptedSockets.has(ws)) { interceptedSockets.add(ws); log('Intercepted WebSocket:', args[0]); const originalOnMessage = ws.onmessage; ws.onmessage = function(event) { try { if (args[0].includes('pubsub-edge')) { handlePubSubMessage(event); } else if (args[0].includes('irc-ws') || args[0].includes('chat')) { handleIRCMessage(event); } } catch (error) { log('Message processing error (ignored):', error.message); } if (originalOnMessage) { try { originalOnMessage.call(this, event); } catch (error) { log('Original handler error (ignored):', error.message); } } }; const originalOnError = ws.onerror; ws.onerror = function(error) { log('WebSocket error (passing through):', error); if (originalOnError) { originalOnError.call(this, error); } }; } return ws; }; Object.setPrototypeOf(window.WebSocket, originalWebSocket); Object.defineProperty(window.WebSocket, 'prototype', { value: originalWebSocket.prototype, writable: false }); } function handlePubSubMessage(event) { try { const data = JSON.parse(event.data); if (data.data && data.data.message) { const messageData = JSON.parse(data.data.message); if (messageData.type === 'MESSAGE') { processChatMessage(messageData); } } } catch (e) { } } function handleIRCMessage(event) { try { if (typeof event.data === 'string' && event.data.includes('PRIVMSG')) { processIRCMessage(event.data); } } catch (e) { } } function processIRCMessage(ircMessage) { try { const privmsgMatch = ircMessage.match(/^@([^:]*):([^!]+)[^:]*PRIVMSG #[^:]*:(.*)$/); if (!privmsgMatch) return; const [, tags, username, message] = privmsgMatch; const tagMap = {}; if (tags) { tags.split(';').forEach(tag => { const [key, value] = tag.split('='); if (key) tagMap[key] = value || ''; }); } const displayName = tagMap['display-name'] || username; const color = tagMap.color || '#9147FF'; addToHistory(username, { text: message.trim(), timestamp: Date.now(), color: color, displayName: displayName, badges: tagMap.badges || '', source: 'IRC' }); log(`IRC Message from ${displayName}: ${message.trim()}`); } catch (error) { log('IRC parsing error (ignored):', error.message); } } function processChatMessage(messageData) { try { if (!messageData.data || !messageData.data.user_name) return; const username = messageData.data.user_name; const displayName = messageData.data.display_name || username; const message = messageData.data.body || ''; const color = messageData.data.user_color || '#9147FF'; addToHistory(username, { text: message, timestamp: Date.now(), color: color, displayName: displayName, source: 'PubSub' }); log(`PubSub Message from ${displayName}: ${message}`); } catch (error) { log('PubSub parsing error (ignored):', error.message); } } function extractMessageWithEmotes(messageElement) { if (!messageElement || !CONFIG.captureEmotes) { return messageElement?.textContent?.trim() || ''; } try { let messageText = ''; const walker = document.createTreeWalker( messageElement, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { acceptNode: function(node) { if (node.nodeType === Node.TEXT_NODE) { return NodeFilter.FILTER_ACCEPT; } if (node.nodeType === Node.ELEMENT_NODE) { const tagName = node.tagName.toLowerCase(); const className = node.className || ''; if (tagName === 'img' || className.includes('emote') || className.includes('emoji') || node.hasAttribute('data-emote-name') || node.hasAttribute('alt')) { return NodeFilter.FILTER_ACCEPT; } if (className.includes('seventv') || className.includes('bttv') || className.includes('ffz') || className.includes('chat-emote') || node.dataset.emoteName || node.title) { return NodeFilter.FILTER_ACCEPT; } } return NodeFilter.FILTER_SKIP; } } ); let node; while (node = walker.nextNode()) { if (node.nodeType === Node.TEXT_NODE) { messageText += node.textContent; } else if (node.nodeType === Node.ELEMENT_NODE) { const emoteName = node.dataset.emoteName || node.getAttribute('data-emote-name') || node.getAttribute('alt') || node.getAttribute('title') || node.textContent?.trim(); if (emoteName && emoteName.length > 0 && emoteName.length < 50) { messageText += emoteName; } else { messageText += node.textContent || ''; } } } return messageText.trim(); } catch (error) { log('Emote extraction error, falling back to text:', error.message); return messageElement.textContent?.trim() || ''; } } function setupDOMObserver() { let observerSetup = false; function trySetupObserver() { const chatContainer = document.querySelector('.chat-scrollable-area__message-container, .chat-list, [data-test-selector="chat-scrollable-area__message-container"]'); if (!chatContainer && !observerSetup) { setTimeout(trySetupObserver, 1000); return; } if (observerSetup) return; observerSetup = true; log('Setting up DOM observer for chat messages'); const observer = new MutationObserver((mutations) => { try { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { extractAndProcessMessage(node); addClickHandlers(node); } }); }); } catch (error) { log('DOM observer error (ignored):', error.message); } }); if (chatContainer) { observer.observe(chatContainer, { childList: true, subtree: true }); try { const existingMessages = chatContainer.querySelectorAll('[data-test-selector="chat-line-message"], .chat-line__message'); existingMessages.forEach(msg => { extractAndProcessMessage(msg); addClickHandlers(msg); }); } catch (error) { log('Processing existing messages error (ignored):', error.message); } } const docObserver = new MutationObserver(() => { try { if (!document.querySelector('.chat-history-handlers-added')) { addClickHandlersToAllMessages(); } } catch (error) { log('Document observer error (ignored):', error.message); } }); docObserver.observe(document.body, { childList: true, subtree: true }); } trySetupObserver(); } function setupGlobalClickHandler() { document.addEventListener('click', (e) => { if (isFeatureDisabledInFFZ()) return; if (!checkShortcut(e)) return; let target = e.target; let attempts = 0; while (target && attempts < 5) { const username = extractUsernameFromElement(target); if (username && username.length > 0) { e.preventDefault(); e.stopPropagation(); showUserHistory(username, target); return; } const isUsernameElement = target.className && ( target.className.includes('username') || target.className.includes('author') || target.className.includes('name') || target.className.includes('seventv') || target.className.includes('bttv') || target.className.includes('ffz') ) || target.getAttribute('data-username') || (target.tagName === 'SPAN' && target.style.color && target.textContent && target.textContent.trim().length < 30 && !target.textContent.includes(' ')); if (isUsernameElement) { const username = extractUsernameFromElement(target); if (username) { e.preventDefault(); e.stopPropagation(); showUserHistory(username, target); return; } } target = target.parentElement; attempts++; } }, true); } function extractAndProcessMessage(element) { try { if (!element.querySelector) return; let usernameEl = element.querySelector('[data-test-selector="chat-author-name"]') || element.querySelector('.chatAuthor, .chat-author') || element.querySelector('[class*="author"]') || element.querySelector('span[style*="color"]:first-child'); let messageEl = element.querySelector('[data-test-selector="chat-line-message-body"]') || element.querySelector('.message, .chat-line__message-body') || element.querySelector('[class*="message-body"]') || element.querySelector('[class*="message"]'); if (!usernameEl || !messageEl) { const textContent = element.textContent || ''; const messageMatch = textContent.match(/^([^:]+):\s*(.+)$/); if (messageMatch) { const [, username, message] = messageMatch; if (username && message && username.length < 30) { addToHistory(username.trim(), { text: message.trim(), timestamp: Date.now(), color: '#9147FF', displayName: username.trim(), source: 'DOM-fallback' }); log(`Extracted from text: ${username.trim()}: ${message.trim()}`); } } return; } const username = usernameEl.textContent.trim(); const messageText = extractMessageWithEmotes(messageEl); if (!username || !messageText) return; let color = '#9147FF'; const coloredEl = usernameEl.closest('[style*="color"]') || usernameEl; if (coloredEl.style.color) { color = coloredEl.style.color; } addToHistory(username, { text: messageText, timestamp: Date.now(), color: color, displayName: username, source: 'DOM-enhanced' }); log(`DOM extracted (enhanced): ${username}: ${messageText}`); } catch (error) { log('Message extraction error (ignored):', error.message); } } function addToHistory(username, messageData) { try { if (!username || !messageData.text) return; const lowerUsername = username.toLowerCase(); if (!chatHistory.has(lowerUsername)) { chatHistory.set(lowerUsername, []); } const userHistory = chatHistory.get(lowerUsername); const now = Date.now(); const isDuplicate = userHistory.some(existingMsg => existingMsg.text === messageData.text && Math.abs(existingMsg.timestamp - now) < CONFIG.duplicateThresholdMs ); if (isDuplicate) { log(`Duplicate message ignored for ${username}: ${messageData.text}`); return; } userHistory.push(messageData); if (userHistory.length > CONFIG.maxHistoryEntries) { userHistory.shift(); } log(`Added message for ${username} (${userHistory.length} total): ${messageData.text}`); const modal = document.getElementById('chat-history-modal'); if (modal && modal.style.display === 'block' && modal.dataset.currentUser === lowerUsername) { const content = document.getElementById('history-content'); const noMessagesNode = content.querySelector('div[style*="text-align: center"]'); if (noMessagesNode) { content.innerHTML = ''; } content.insertAdjacentHTML('afterbegin', createMessageHTML(messageData, true)); const messagesToShow = 50; while (content.children.length > messagesToShow) { content.removeChild(content.lastElementChild); } } } catch (error) { log('History storage error (ignored):', error.message); } } function createMessageHTML(msg, isNew = false) { const time = new Date(msg.timestamp).toLocaleString('en-US', { hour: '2-digit', minute: '2-digit' }); const messageText = escapeHtml(msg.text || '[Empty Message]'); const entryClass = isNew ? 'chat-message new-entry' : 'chat-message'; return ` <div class="${entryClass}"> <div class="message-content"> <div class="message-text">${messageText}</div> <div class="message-timestamp">${time}</div> </div> </div> `; } function addClickHandlersToAllMessages() { try { const messages = document.querySelectorAll('[data-test-selector="chat-line-message"], .chat-line__message'); messages.forEach(addClickHandlers); document.body.classList.add('chat-history-handlers-added'); } catch (error) { log('Adding click handlers error (ignored):', error.message); } } function addClickHandlers(messageElement) { try { if (!messageElement.querySelector) return; const usernameElements = messageElement.querySelectorAll(` [data-test-selector="chat-author-name"], .chatAuthor, .chat-author, [class*="author"], [class*="username"], [class*="name"], .seventv-chat-username, .bttv-username, .ffz-username, [data-username], [title*="@"], span[style*="color"]:first-of-type, a[href*="/"]:first-of-type `); const additionalElements = messageElement.querySelectorAll('span, a, div'); additionalElements.forEach(el => { const text = el.textContent?.trim(); const isUsernameLink = el.href && el.href.includes('twitch.tv/'); const hasUsernameClass = el.className && ( el.className.includes('username') || el.className.includes('author') || el.className.includes('name') ); const isFirstColoredSpan = el.tagName === 'SPAN' && el.style.color && el.parentElement?.firstElementChild === el; if ((isUsernameLink || hasUsernameClass || isFirstColoredSpan) && text && text.length < 30 && !text.includes(' ')) { if (!Array.from(usernameElements).includes(el)) { usernameElements.push ? usernameElements.push(el) : null; } } }); const elementsArray = Array.from(usernameElements); elementsArray.forEach(usernameEl => { if (usernameEl.dataset.historyHandlerAdded) return; usernameEl.style.cursor = 'pointer'; const shortcutText = CONFIG.shortcutKey.charAt(0).toUpperCase() + CONFIG.shortcutKey.slice(1); usernameEl.title = `${shortcutText} + Click for history`; usernameEl.addEventListener('click', (e) => { if (checkShortcut(e)) { e.preventDefault(); e.stopPropagation(); let username = extractUsernameFromElement(usernameEl); if (username) { showUserHistory(username, usernameEl); } } }); usernameEl.dataset.historyHandlerAdded = 'true'; }); } catch (error) { log('Click handler error (ignored):', error.message); } } function createHistoryModal() { if (document.getElementById('chat-history-modal')) return; const modalHTML = ` <div id="chat-history-modal" style="display: none; position: fixed; z-index: 9999; background: #111; width: 320px; border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,0.4); overflow: hidden; resize: both;"> <div id="modal-header" style="padding: 10px 12px; background: #222; color: white; cursor: move; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #333;"> <h3 id="history-modal-title" style="margin: 0; font-size: 14px; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></h3> <button id="close-history-modal" style="background: transparent; border: none; color: #aaa; font-size: 16px; cursor: pointer; padding: 0; line-height: 1; transition: color 0.2s;">×</button> </div> <div id="history-content" style="padding: 8px; max-height: 320px; overflow-y: auto; background: #111; color: #eee;"> <p style="text-align: center; color: #777;">Loading...</p> </div> </div> `; document.body.insertAdjacentHTML('beforeend', modalHTML); const closeButton = document.getElementById('close-history-modal'); closeButton.addEventListener('mouseover', () => { closeButton.style.color = '#fff'; }); closeButton.addEventListener('mouseout', () => { closeButton.style.color = '#aaa'; }); closeButton.addEventListener('click', () => { document.getElementById('chat-history-modal').style.display = 'none'; }); const modal = document.getElementById('chat-history-modal'); const header = document.getElementById('modal-header'); let isDragging = false; let offsetX = 0; let offsetY = 0; header.addEventListener('mousedown', (e) => { isDragging = true; offsetX = e.clientX - modal.getBoundingClientRect().left; offsetY = e.clientY - modal.getBoundingClientRect().top; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const x = e.clientX - offsetX; const y = e.clientY - offsetY; modal.style.left = `${x}px`; modal.style.top = `${y}px`; }); document.addEventListener('mouseup', () => { isDragging = false; }); const historyContent = document.getElementById('history-content'); historyContent.style.scrollbarWidth = 'thin'; historyContent.style.scrollbarColor = '#444 #222'; } function showUserHistory(username, clickedElement) { try { if (window.FrankerFaceZ && window.FrankerFaceZ.get && window.FrankerFaceZ.get().resolve && window.FrankerFaceZ.get().resolve('site.chat') && window.FrankerFaceZ.get().resolve('site.chat').settings && window.FrankerFaceZ.get().resolve('site.chat').settings.get('chat_history.enabled') === false) { return; } const modal = document.getElementById('chat-history-modal'); if (!modal) { createHistoryModal(); setTimeout(() => showUserHistory(username, clickedElement), 100); return; } modal.dataset.currentUser = username.toLowerCase(); const title = document.getElementById('history-modal-title'); const content = document.getElementById('history-content'); title.textContent = `${username} chat history`; const userHistory = chatHistory.get(username.toLowerCase()) || []; log(`Showing history for ${username}: ${userHistory.length} messages`); if (userHistory.length === 0) { content.innerHTML = ` <div style="text-align: center; padding: 30px 10px;"> <p style="color: #777; margin: 0;">No messages yet</p> </div> `; } else { const messagesToShow = Math.min(50, userHistory.length); const messagesHTML = userHistory .slice(-messagesToShow) .reverse() .map(msg => createMessageHTML(msg)) .join(''); content.innerHTML = messagesHTML; } if (clickedElement && clickedElement.getBoundingClientRect) { const rect = clickedElement.getBoundingClientRect(); const viewportWidth = window.innerWidth; const modalWidth = 320; if (rect.right + modalWidth + 10 < viewportWidth) { modal.style.left = `${rect.right + 10}px`; } else { modal.style.left = `${Math.max(10, rect.left - modalWidth - 10)}px`; } modal.style.top = `${rect.top}px`; } else { modal.style.left = '20px'; modal.style.top = '100px'; } modal.style.display = 'block'; content.scrollTop = 0; } catch (error) { log('Show history error:', error.message); } } function showUsernamePrompt() { try { const username = prompt('Enter username for Chat History:'); if (username && username.trim()) { showUserHistory(username.trim()); } } catch (error) { log('Username prompt error:', error.message); } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } function checkShortcut(event) { if (isFeatureDisabledInFFZ()) return false; switch (CONFIG.shortcutKey) { case 'ctrl': return event.ctrlKey; case 'shift': return event.shiftKey; case 'alt': default: return event.altKey; } } function isFeatureDisabledInFFZ() { return window.FrankerFaceZ && window.FrankerFaceZ.get && window.FrankerFaceZ.get().resolve && window.FrankerFaceZ.get().resolve('site.chat') && window.FrankerFaceZ.get().resolve('site.chat').settings && window.FrankerFaceZ.get().resolve('site.chat').settings.get('chat_history.enabled') === false; } function extractUsernameFromElement(element) { try { let username = element.textContent?.trim(); if (!username || username.length === 0) { username = element.getAttribute('data-username') || element.getAttribute('title')?.replace('@', '') || element.getAttribute('alt') || element.href?.split('/').pop(); } if (username) { username = username.replace(/^@/, ''); username = username.replace(/[:,\s].*$/, ''); if (username.length > 0 && username.length <= 25 && /^[a-zA-Z0-9_]+$/.test(username)) { return username; } } let parent = element.parentElement; let attempts = 0; while (parent && attempts < 3) { const parentText = parent.textContent?.trim(); if (parentText) { const match = parentText.match(/^([a-zA-Z0-9_]{1,25}):/); if (match) { return match[1]; } } parent = parent.parentElement; attempts++; } return null; } catch (error) { log('Username extraction error:', error.message); return element.textContent?.trim() || null; } } function updateTooltips() { const shortcutText = CONFIG.shortcutKey.charAt(0).toUpperCase() + CONFIG.shortcutKey.slice(1); document.querySelectorAll('[data-history-handler-added="true"]').forEach(el => { el.title = `${shortcutText} + Click for history`; }); } function setupFFZIntegration() { if (!CONFIG.enableFFZIntegration) return; const checkFFZ = setInterval(() => { if (window.FrankerFaceZ) { clearInterval(checkFFZ); try { const ffz = window.FrankerFaceZ.get(); if (!ffz || !ffz.resolve || !ffz.resolve('site.chat')) { log('FrankerFaceZ chat module not found'); return; } const chatModule = ffz.resolve('site.chat'); if (chatModule.settings.get('chat_history.enabled') !== undefined) { log('FFZ settings already registered'); syncFFZSettingsToConfig(chatModule); return; } chatModule.settings.add('chat_history.enabled', { default: true, ui: { path: 'Chat > Behavior >> Chat History', title: 'Enable Chat History', description: 'Show user chat history with configurable shortcuts', component: 'setting-check-box' }, changed: (val) => { if (!val) { if (document.getElementById('chat-history-modal')) { document.getElementById('chat-history-modal').style.display = 'none'; } } } }); chatModule.settings.add('chat_history.shortcut', { default: CONFIG.shortcutKey, ui: { path: 'Chat > Behavior >> Chat History', title: 'History Shortcut Key', description: 'Key to hold while clicking username', component: 'setting-select-box', data: [ { value: 'alt', title: 'Alt + Click' }, { value: 'ctrl', title: 'Ctrl + Click' }, { value: 'shift', title: 'Shift + Click' } ] }, changed: (val) => { CONFIG.shortcutKey = val; updateTooltips(); } }); chatModule.settings.add('chat_history.max_entries', { default: CONFIG.maxHistoryEntries, ui: { path: 'Chat > Behavior >> Chat History', title: 'Maximum History Size', description: 'Maximum number of messages to store per user', component: 'setting-select-box', data: [ { value: 50, title: '50 messages' }, { value: 100, title: '100 messages' }, { value: 200, title: '200 messages' }, { value: 300, title: '300 messages' } ] }, changed: (val) => { CONFIG.maxHistoryEntries = val; for (const [username, history] of chatHistory.entries()) { if (history.length > CONFIG.maxHistoryEntries) { chatHistory.set(username, history.slice(-CONFIG.maxHistoryEntries)); } } } }); syncFFZSettingsToConfig(chatModule); log('✅ FFZ integration enabled'); } catch (error) { log('FFZ integration error:', error.message); } } }, 1000); setTimeout(() => clearInterval(checkFFZ), 30000); } function syncFFZSettingsToConfig(chatModule) { try { if (chatModule && chatModule.settings) { const shortcutKey = chatModule.settings.get('chat_history.shortcut'); const maxEntries = chatModule.settings.get('chat_history.max_entries'); if (shortcutKey !== undefined) CONFIG.shortcutKey = shortcutKey; if (maxEntries !== undefined) CONFIG.maxHistoryEntries = maxEntries; log('Synced FFZ settings to CONFIG'); } } catch (error) { log('Error syncing FFZ settings:', error.message); } } document.addEventListener('keydown', (e) => { try { if (e.ctrlKey && e.key === 'h') { e.preventDefault(); showUsernamePrompt(); } } catch (error) { log('Keyboard shortcut error:', error.message); } }); function injectModalStyles() { const css = ` #chat-history-modal .chat-message { margin-bottom: 6px; padding: 8px; background: #181818; border-radius: 4px; transition: background-color 0.2s ease; } #chat-history-modal .chat-message:hover { background-color: #222; } #chat-history-modal .message-content { display: flex; align-items: center; } #chat-history-modal .message-text { flex: 1; color: #ddd; line-height: 1.4; word-wrap: break-word; font-size: 13px; } #chat-history-modal .message-timestamp { margin-left: 8px; font-size: 11px; color: #666; white-space: nowrap; } #chat-history-modal .chat-message.new-entry { animation: fadeInHighlight 1.5s ease-out forwards; } @keyframes fadeInHighlight { 0% { opacity: 0; transform: translateY(-10px); background-color: #31313c; } 30% { opacity: 1; transform: translateY(0); background-color: #31313c; } 100% { background-color: #181818; } } `; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } function init() { try { log('🚀 Chat History Viewer starting...'); injectModalStyles(); setTimeout(createHistoryModal, 500); setTimeout(interceptWebSocket, 1000); setTimeout(setupDOMObserver, 1500); setTimeout(addClickHandlersToAllMessages, 3000); setTimeout(setupGlobalClickHandler, 2000); setTimeout(setupFFZIntegration, 4000); log('✅ Chat History Viewer initialized'); } catch (error) { log('Initialization error:', error.message); } } let currentUrl = location.href; const navObserver = new MutationObserver(() => { try { if (currentUrl !== location.href) { currentUrl = location.href; log('📍 Page navigation detected, reinitializing...'); setTimeout(() => { setupDOMObserver(); addClickHandlersToAllMessages(); }, 2000); } } catch (error) { log('Navigation observer error:', error.message); } }); navObserver.observe(document, { subtree: true, childList: true }); init(); })();