// ==UserScript== // @name Twitch Emotes Cache // @namespace http://tampermonkey.net/ // @version 1.31.3 // @description Cache frequently used Twitch emotes to reduce load delay // @author You // @license MIT // @match https://*.twitch.tv/* // @icon https://yt3.googleusercontent.com/ytc/AIdro_nAFS_oYf_Gt3hs5y97Zri6PDs1-oDFyOcfCkjyHlgNEfQ=s900-c-k-c0x00ffffff-no-rj // @downloadURL // @updateURL // @downloadURL none // ==/UserScript== (function() { 'use strict'; // Инициализация хранилища let emoteCache = JSON.parse(localStorage.getItem('twitchEmoteCache')) || {}; const MAX_CACHE_SIZE = 500; // Увеличено до 500 const CACHE_EXPIRY = 2 * 60 * 60 * 1000; // 2 часа let pendingSave = null; // Для дебонсинга let currentChannel = getCurrentChannel(); // Текущий канал // Получение текущего канала из URL function getCurrentChannel() { const path = window.location.pathname; const match = path.match(/^\/([a-zA-Z0-9_]+)/); return match ? match[1].toLowerCase() : 'global'; } // Очистка кэша при смене канала function clearChannelCache() { if (emoteCache[currentChannel]) { delete emoteCache[currentChannel]; saveCache(); } } // Сохранение кэша с дебонсингом function saveCache() { if (pendingSave) return; pendingSave = setTimeout(() => { requestIdleCallback(() => { try { localStorage.setItem('twitchEmoteCache', JSON.stringify(emoteCache)); } catch (e) { console.error('Ошибка сохранения кэша:', e); // Очистка кэша при переполнении emoteCache = { [currentChannel]: {} }; localStorage.setItem('twitchEmoteCache', JSON.stringify(emoteCache)); } pendingSave = null; }, { timeout: 1000 }); }, 1000); } // Кэширование эмодзи function cacheEmote(url, dataUrl, code, provider, countIncrement = 1) { const now = Date.now(); const channel = currentChannel || 'global'; if (!emoteCache[channel]) emoteCache[channel] = {}; emoteCache[channel][url] = { dataUrl: dataUrl || emoteCache[channel][url]?.dataUrl, code: code || emoteCache[channel][url]?.code, provider: provider || emoteCache[channel][url]?.provider, timestamp: now, count: (emoteCache[channel][url]?.count || 0) + countIncrement }; // Ограничение размера кэша для канала const emoteKeys = Object.keys(emoteCache[channel]); if (emoteKeys.length > MAX_CACHE_SIZE) { const leastUsedKey = emoteKeys.reduce((a, b) => emoteCache[channel][a].count < emoteCache[channel][b].count ? a : b ); delete emoteCache[channel][leastUsedKey]; } saveCache(); } // Очистка устаревшего кэша function cleanOldCache() { const now = Date.now(); for (const channel in emoteCache) { for (const url in emoteCache[channel]) { if (now - emoteCache[channel][url].timestamp > CACHE_EXPIRY) { delete emoteCache[channel][url]; } } if (!Object.keys(emoteCache[channel]).length) { delete emoteCache[channel]; } } saveCache(); preloadPopularEmotes(); // Перезагрузка популярных эмодзи после очистки } // Загрузка эмодзи async function fetchAndCacheEmote(imgElement, url, code, provider, countIncrement = 1, retries = 3) { const channel = currentChannel || 'global'; if (emoteCache[channel]?.[url]?.dataUrl) { if (imgElement) imgElement.src = emoteCache[channel][url].dataUrl; cacheEmote(url, null, code, provider, countIncrement); return; } for (let attempt = 1; attempt <= retries; attempt++) { try { const response = await fetch(url, { mode: 'cors' }); if (!response.ok) throw new Error('Network response was not ok'); const blob = await response.blob(); const reader = new FileReader(); reader.onloadend = () => { const dataUrl = reader.result; cacheEmote(url, dataUrl, code, provider, countIncrement); if (imgElement) imgElement.src = dataUrl; }; reader.readAsDataURL(blob); return; } catch (error) { console.error(`Попытка ${attempt} загрузки эмодзи ${url} не удалась:`, error); if (attempt === retries) { console.error('Все попытки загрузки эмодзи провалились:', url); if (imgElement && code) { imgElement.src = 'data:image/png;base64,...'; // Заглушка imgElement.title = `Failed to load: ${code}`; } } } } } // Предзагрузка популярных эмодзи function preloadPopularEmotes() { requestIdleCallback(() => { const channel = currentChannel || 'global'; if (!emoteCache[channel]) return; const popularEmotes = Object.keys(emoteCache[channel]) .filter(url => ['ffz', '7tv', 'bttv'].includes(emoteCache[channel][url].provider)) .sort((a, b) => emoteCache[channel][b].count - emoteCache[channel][a].count) .slice(0, 20); // Топ-20 эмодзи popularEmotes.forEach((url) => { if (!emoteCache[channel][url].dataUrl) { fetchAndCacheEmote(null, url, emoteCache[channel][url].code, emoteCache[channel][url].provider, 0); } }); }, { timeout: 5000 }); } // Замена текстовых кодов на кэшированные эмодзи // Замена текстовых кодов на кэшированные эмодзи function replaceTextCodes(message) { const channel = currentChannel || 'global'; if (!emoteCache[channel]) return; const textNodes = getTextNodes(message); textNodes.forEach((node) => { // Пропускаем текстовые узлы внутри элементов ника if (node.parentNode.closest('.chat-author__display-name') || node.parentNode.closest('.chat-line__username')) { return; } let text = node.textContent; for (const url in emoteCache[channel]) { const emote = emoteCache[channel][url]; if (emote.code && text.includes(emote.code)) { const img = document.createElement('img'); img.className = 'chat-line__message--emote'; img.src = emote.dataUrl || 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAB9SURBVEhLY2AYBaNgFIyCUTAKRsEoGAWjYBSMglEwCkbBKBgFo2AUjIJRMAoGIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQAANHYvmHjk1oAAAAAASUVORK5CYII='; img.alt = emote.code; img.title = emote.code; const parts = text.split(emote.code); text = parts.join(''); node.parentNode.insertBefore(img, node); cacheEmote(url, emote.dataUrl, emote.code, emote.provider, 1); } } if (text !== node.textContent) { node.textContent = text; } }); } // Получение текстовых узлов function getTextNodes(node) { const textNodes = []; const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false); let currentNode; while ((currentNode = walker.nextNode())) { textNodes.push(currentNode); } return textNodes; } // Обработка эмодзи в чате function processEmotes(mutations) { console.log('Обработка мутаций:', mutations.length); const channel = currentChannel || 'global'; if (!emoteCache[channel]) emoteCache[channel] = {}; mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (!node.querySelectorAll) return; const message = node.classList?.contains('chat-line__message') ? node : node.querySelector('.chat-line__message'); if (!message) return; const emotes = message.querySelectorAll('.bttv-emote, .seventv-emote, .ffz-emote'); console.log('Найдено эмодзи:', emotes.length); emotes.forEach((emote) => { const url = emote.src; if (!url) return; const code = emote.alt || emote.title || emote.getAttribute('data-tooltip-type') || ''; const provider = emote.classList.contains('bttv-emote') ? 'bttv' : emote.classList.contains('seventv-emote') ? '7tv' : emote.classList.contains('ffz-emote') ? 'ffz' : ''; if (!provider) return; if (emoteCache[channel][url]?.dataUrl) { emote.src = emoteCache[channel][url].dataUrl; cacheEmote(url, null, code, provider, 1); } else { console.log('Кэширование нового эмодзи:', url); fetchAndCacheEmote(emote, url, code, provider, 1); } }); replaceTextCodes(message); }); }); } // Отслеживание смены канала function monitorChannelSwitch() { let lastChannel = currentChannel; // Проверка изменения URL window.addEventListener('popstate', () => { const newChannel = getCurrentChannel(); if (newChannel !== lastChannel) { clearChannelCache(); currentChannel = newChannel; lastChannel = newChannel; preloadPopularEmotes(); } }); // Отслеживание изменений в DOM для SPA-навигации const observer = new MutationObserver(() => { const newChannel = getCurrentChannel(); if (newChannel !== lastChannel) { clearChannelCache(); currentChannel = newChannel; lastChannel = newChannel; preloadPopularEmotes(); } }); observer.observe(document.body, { childList: true, subtree: true }); } // Наблюдатель criação const observer = new MutationObserver((mutations) => { requestIdleCallback(() => processEmotes(mutations), { timeout: 500 }); }); // Инициализация cleanOldCache(); preloadPopularEmotes(); monitorChannelSwitch(); // Настройка наблюдателя за чатом const chatContainer = document.querySelector('.chat-scrollable-area__message-container') || document.body; observer.observe(chatContainer, { childList: true, subtree: true }); // Периодическая очистка кэша (каждые 30 минут) setInterval(cleanOldCache, 30 * 60 * 1000); })();