Previewer Media on Chats 1.2.39

Preview media links including shortened URLs kappa.lol

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Previewer Media on Chats  1.2.39
// @namespace    http://tampermonkey.net/
// @version      1.2.39
// @description  Preview media links including shortened URLs kappa.lol
// @author       Gullampis810 
// @license      MIT
// @grant        GM_xmlhttpRequest
// @match        https://www.twitch.tv/* 
// @match        https://*.imgur.com/*
// @match        https://7tv.app/* 
// @icon         https://yt3.googleusercontent.com/ytc/AOPolaS0epA6kuqQqudVFRN0l9aJ2ScCvwK0YqC7ojbU=s900-c-k-c0x00ffffff-no-rj
// @downloadURL
// @updateURL
// ==/UserScript==

(function() {
    'use strict';

    const urlCache = new Map();

    // Поддерживаемые типы файлов
    const fileTypes = {
        image: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']
    };

    // Поддерживаемые платформы эмодзи и изображений
    const emotePlatforms = {
        '7tv.app': (url) => {
            const emoteIdMatch = url.match(/7tv\.app\/emotes\/([a-zA-Z0-9]+)/);
            return emoteIdMatch ? `https://cdn.7tv.app/emote/${emoteIdMatch[1]}/2x.webp` : url;
        },
        'frankerfacez.com': (url) => {
            const emoteIdMatch = url.match(/frankerfacez\.com\/emoticon\/(\d+)/);
            return emoteIdMatch ? `https://cdn.frankerfacez.com/emoticon/${emoteIdMatch[1]}/2` : url;
        },
        'betterttv.com': (url) => {
            const emoteIdMatch = url.match(/betterttv\.com\/emotes\/([a-zA-Z0-9]+)/);
            return emoteIdMatch ? `https://cdn.betterttv.net/emote/${emoteIdMatch[1]}/2x` : url;
        },
        'imgur.com': (url) => {
            const albumMatch = url.match(/imgur\.com\/a\/([a-zA-Z0-9]+)/);
            const imageMatch = url.match(/imgur\.com\/([a-zA-Z0-9]+)$/);
            if (albumMatch) return `https://i.imgur.com/${albumMatch[1]}.jpg`;
            if (imageMatch) return `https://i.imgur.com/${imageMatch[1]}.jpg`;
            return url;
        }
    };

    // Определение типа файла
    function getFileType(url) {
        const cleanUrl = url.split('?')[0].toLowerCase();
        const extension = cleanUrl.substring(cleanUrl.lastIndexOf('.'));

        console.debug(`Checking file type for URL: ${url}`);
        if (fileTypes.image.includes(extension)) {
            console.debug(`Extension matched: ${extension}`);
            return 'image';
        }
        if (Object.keys(emotePlatforms).some(platform => url.includes(platform))) {
            console.debug(`Platform matched: ${url}`);
            return 'image';
        }
        if (url.includes('cdn.7tv.app') || url.includes('7tv.app/emotes') || url.includes('i.imgur.com') || url.includes('yandex.net') || url.includes('susanin.news')) {
            console.debug(`Domain matched: ${url}`);
            return 'image';
        }
        return null;
    }

    // Определение типа по Content-Type
    function getFileTypeFromContentType(contentType) {
        if (!contentType) {
            console.debug('No Content-Type received');
            return null;
        }
        if (contentType.includes('image')) {
            console.debug(`Content-Type is image: ${contentType}`);
            return 'image';
        }
        return null;
    }

    // Трансформация URL для эмодзи и Imgur
    function transformEmoteUrl(url) {
        for (const [platform, transformer] of Object.entries(emotePlatforms)) {
            if (url.includes(platform)) {
                const transformed = transformer(url);
                console.debug(`Transformed URL: ${url} -> ${transformed}`);
                return transformed;
            }
        }
        return url;
    }

    // Разрешение коротких URL и Imgur альбомов
    async function resolveShortUrl(url) {
        if (urlCache.has(url)) {
            console.debug(`Using cached URL: ${url}`);
            return urlCache.get(url);
        }

        try {
            const response = await fetch(url, {
                method: 'HEAD',
                headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PreWatcher/1.2.4)' }
            });
            const finalUrl = response.url || url;
            const contentType = response.headers.get('content-type');
            const result = { resolvedUrl: finalUrl, contentType };
            urlCache.set(url, result);
            console.debug(`Resolved URL: ${url} -> ${finalUrl}, Content-Type: ${contentType}`);
            return result;
        } catch (error) {
            console.error(`Error resolving URL ${url}:`, error);
            return { resolvedUrl: url, contentType: null };
        }
    }

    // Извлечение прямой ссылки на изображение из Imgur
    async function extractImgurImage(url) {
        if (!url.includes('imgur.com/a/')) {
            console.debug(`Not an Imgur album: ${url}`);
            return url;
        }
        try {
            const response = await fetch(url);
            const text = await response.text();
            const doc = new DOMParser().parseFromString(text, 'text/html');
            const img = doc.querySelector('img[src*="i.imgur.com"]');
            const directUrl = img ? img.getAttribute('src') : url;
            console.debug(`Extracted Imgur image: ${url} -> ${directUrl}`);
            return directUrl;
        } catch (error) {
            console.error(`Imgur extraction error for ${url}:`, error);
            return url;
        }
    }

    // Проверка доступности изображения и получение размеров
    async function testImage(url) {
        if (urlCache.has(url)) {
            console.debug(`Using cached image test for ${url}`);
            return urlCache.get(url);
        }

        return new Promise((resolve) => {
            const img = new Image();
            let timedOut = false;

            const timeout = setTimeout(() => {
                timedOut = true;
                urlCache.set(url, { valid: false });
                console.debug(`Image test timeout for ${url}`);
                resolve({ valid: false });
            }, 2000); // Таймаут 2 секунды

            img.onload = () => {
                if (!timedOut) {
                    clearTimeout(timeout);
                    urlCache.set(url, { valid: true, width: img.naturalWidth, height: img.naturalHeight });
                    console.debug(`Image loaded: ${url}, size: ${img.naturalWidth}x${img.naturalHeight}`);
                    resolve({ valid: true, width: img.naturalWidth, height: img.naturalHeight });
                }
            };
            img.onerror = () => {
                if (!timedOut) {
                    clearTimeout(timeout);
                    urlCache.set(url, { valid: false });
                    console.debug(`Image failed to load: ${url}`);
                    resolve({ valid: false });
                }
            };
            img.src = url;
        });
    }

    // Замена ссылки на изображение с сохранением URL и адаптивным размером
  // Замена ссылки на изображение с сохранением URL и адаптивным размером
async function replaceLinkWithImage(link) {
    let url = link.getAttribute('href');
    let fileType = getFileType(url);
    let mediaUrl = transformEmoteUrl(url);

    console.debug(`Processing link: ${url}`);

    // Проверяем короткие ссылки и Imgur альбомы
    if (!fileType || url.match(/t\.co|bit\.ly|imgur\.com/)) {
        const { resolvedUrl, contentType } = await resolveShortUrl(url);
        mediaUrl = resolvedUrl;
        fileType = getFileType(mediaUrl) || getFileTypeFromContentType(contentType);
        console.debug(`After resolve: ${mediaUrl}, fileType: ${fileType}`);
    }

    // Для Imgur альбомов извлекаем прямую ссылку
    if (mediaUrl.includes('imgur.com/a/')) {
        mediaUrl = await extractImgurImage(mediaUrl);
        fileType = getFileType(mediaUrl);
        console.debug(`After Imgur extraction: ${mediaUrl}`);
    }

    if (!fileType) {
        const imageInfo = await testImage(mediaUrl);
        fileType = imageInfo.valid ? 'image' : null;
        console.debug(`After testImage: fileType=${fileType}`);
    }

    if (!fileType) {
        console.debug(`Skipping non-image URL: ${url}`);
        return;
    }

    const imageInfo = await testImage(mediaUrl);
    if (!imageInfo.valid) {
        console.debug(`Image not valid: ${mediaUrl}`);
        return;
    }

    // Определяем размер изображения
    const maxSize = 512;
    let width = imageInfo.width;
    let height = imageInfo.height;
    if (width > maxSize || height > maxSize) {
        const ratio = Math.min(maxSize / width, maxSize / height);
        width = Math.round(width * ratio);
        height = Math.round(height * ratio);
    }

    // Создаем изображение
    const img = document.createElement('img');
    Object.assign(img, {
        src: mediaUrl,
        alt: link.textContent,
        draggable: false
    });
    Object.assign(img.style, {
        width: `520px`,
        height: `300px`,
        verticalAlign: 'middle',
        margin: '0 4px',
        objectFit: 'contain',
        pointerEvents: 'none'
    });

    // Заменяем оригинальную ссылку только на изображение
    link.replaceWith(img);
    link.dataset.processed = 'true';
    console.debug(`Replaced link with image (no link): ${url} -> ${mediaUrl}`);
}
    // Обработка ссылок
    async function processLinks() {
        const chatContainer = document.querySelector('.chat-scrollable-area__message-container');
        if (!chatContainer) {
            console.debug('Chat container not found');
            return;
        }

        const messages = chatContainer.querySelectorAll('.chat-line__message:not([data-processed])');
        for (const message of messages) {
            // Учитываем ссылки с классом ffz-tooltip link-fragment
            const links = message.querySelectorAll('a[href]:not([data-processed]), a.ffz-tooltip.link-fragment:not([data-processed])');
            for (const link of links) {
                await replaceLinkWithImage(link);
            }
            message.dataset.processed = 'true';
        }
    }

    // Дебаунс функция
    const debounce = (func, wait) => {
        let timeout;
        return (...args) => {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    };

    const debouncedProcessLinks = debounce(processLinks, 100); // 100 мс для быстрой обработки

    // Инициализация
    document.addEventListener('DOMContentLoaded', debouncedProcessLinks);
    new MutationObserver(debouncedProcessLinks).observe(document.body, { childList: true, subtree: true });

    window.previewLinks = debouncedProcessLinks;

    // Добавление стилей
    const style = document.createElement('style');
    style.textContent = `
        .chat-line__message img {
            display: inline-block;
            vertical-align: middle;
        }
        .chat-line__message a {
            display: inline-block;
            vertical-align: middle;
        }
    `;
    document.head.appendChild(style);
})();