Greasy Fork

8chan YouTube Link Enhancer

Cleans up YouTube links and adds video titles in 8chan.moe posts

目前为 2025-04-22 提交的版本。查看 最新版本

// ==UserScript==
// @name         8chan YouTube Link Enhancer
// @namespace    sneed
// @version      1.2.1
// @description  Cleans up YouTube links and adds video titles in 8chan.moe posts
// @author       DeepSeek
// @license      MIT
// @match        https://8chan.moe/*
// @match        https://8chan.se/*
// @grant        GM.xmlHttpRequest
// @grant        GM.setValue
// @grant        GM.getValue
// @connect      youtube.com
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const DELAY_MS = 200; // Delay between YouTube API requests (only for uncached)
    const CACHE_EXPIRY_DAYS = 7;
    const CACHE_CLEANUP_PROBABILITY = 0.1; // 10% chance to run cleanup

    // --- YouTube Link Cleaning (unchanged) ---
    function cleanYouTubeUrl(url) {
        if (!url || (!url.includes('youtube.com') && !url.includes('youtu.be'))) {
            return url;
        }

        let cleaned = url;
        if (cleaned.startsWith('https://youtu.be/')) {
            const videoIdPath = cleaned.substring('https://youtu.be/'.length);
            const paramIndex = videoIdPath.search(/[?#]/);
            const videoId = paramIndex === -1 ? videoIdPath : videoIdPath.substring(0, paramIndex);
            const rest = paramIndex === -1 ? '' : videoIdPath.substring(paramIndex);
            cleaned = `https://www.youtube.com/watch?v=${videoId}${rest}`;
        }

        if (cleaned.includes('youtube.com/live/')) {
             cleaned = cleaned.replace('/live/', '/watch?v=');
        }

        cleaned = cleaned.replace(/[?&]si=[^&]+/, '');

        if (cleaned.endsWith('?') || cleaned.endsWith('&')) {
            cleaned = cleaned.slice(0, -1);
        }

        return cleaned;
    }

    function processLink(link) {
        const currentUrl = link.href;
        if (!currentUrl.includes('youtube.com') && !currentUrl.includes('youtu.be')) {
            return;
        }

        const cleanedUrl = cleanYouTubeUrl(currentUrl);
        if (cleanedUrl !== currentUrl) {
            link.href = cleanedUrl;
            if (link.textContent.trim() === currentUrl.trim()) {
                 link.textContent = cleanedUrl;
            }
        }
    }

    // --- YouTube Enhancement with Smart Caching ---
    const svgIcon = `
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
            <path d="M549.7 124.1c-6.3-23.7-24.9-42.4-48.6-48.6C456.5 64 288 64 288 64s-168.5 0-213.1 11.5
            c-23.7 6.3-42.4 24.9-48.6 48.6C16 168.5 16 256 16 256s0 87.5 10.3 131.9c6.3 23.7
            24.9 42.4 48.6 48.6C119.5 448 288 448 288 448s168.5 0 213.1-11.5
            c23.7-6.3 42.4-24.9 48.6-48.6 10.3-44.4 10.3-131.9 10.3-131.9s0-87.5-10.3-131.9zM232
            334.1V177.9L361 256 232 334.1z"/>
        </svg>
    `.replace(/\s+/g, " ").trim();

    const encodedSvg = `data:image/svg+xml;base64,${btoa(svgIcon)}`;

    const style = document.createElement("style");
    style.textContent = `
        .youtubelink {
            position: relative;
            padding-left: 20px;
        }
        .youtubelink::before {
            content: '';
            position: absolute;
            left: 2px;
            top: 1px;
            width: 16px;
            height: 16px;
            background-color: #FF0000;
            mask-image: url("${encodedSvg}");
            mask-repeat: no-repeat;
            mask-size: contain;
            opacity: 0.8;
        }
    `;
    document.head.appendChild(style);

    // Cache management (unchanged)
    async function getCachedTitle(videoId) {
        try {
            const cache = await GM.getValue('ytTitleCache', {});
            const item = cache[videoId];
            if (item && item.expiry > Date.now()) {
                return item.title;
            }
            return null;
        } catch (e) {
            console.warn('Failed to read cache:', e);
            return null;
        }
    }

    async function setCachedTitle(videoId, title) {
        try {
            const cache = await GM.getValue('ytTitleCache', {});
            cache[videoId] = {
                title: title,
                expiry: Date.now() + (CACHE_EXPIRY_DAYS * 24 * 60 * 60 * 1000)
            };
            await GM.setValue('ytTitleCache', cache);
        } catch (e) {
            console.warn('Failed to update cache:', e);
        }
    }

    async function clearExpiredCache() {
        try {
            const cache = await GM.getValue('ytTitleCache', {});
            const now = Date.now();
            let changed = false;

            for (const videoId in cache) {
                if (cache[videoId].expiry <= now) {
                    delete cache[videoId];
                    changed = true;
                }
            }

            if (changed) {
                await GM.setValue('ytTitleCache', cache);
            }
        } catch (e) {
            console.warn('Failed to clear expired cache:', e);
        }
    }

    function getVideoId(href) {
        const YOUTUBE_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
        const match = href.match(YOUTUBE_REGEX);
        return match ? match[1] : null;
    }

    function delay(ms) {
        return new Promise((resolve) => setTimeout(resolve, ms));
    }

    function fetchVideoData(videoId) {
        const url = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: "GET",
                url: url,
                responseType: "json",
                onload: function (response) {
                    if (response.status === 200 && response.response) {
                        resolve(response.response);
                    } else {
                        reject(new Error(`Failed to fetch data for ${videoId}`));
                    }
                },
                onerror: function (err) {
                    reject(err);
                },
            });
        });
    }

    async function enhanceLinks(links) {
        // Clear expired cache entries occasionally
        if (Math.random() < CACHE_CLEANUP_PROBABILITY) {
            await clearExpiredCache();
        }

        // Process cached links first (no delay)
        const uncachedLinks = [];

        for (const link of links) {
            if (link.dataset.ytEnhanced || link.dataset.ytFailed) continue;

            processLink(link);
            const href = link.href;
            const videoId = getVideoId(href);

            if (!videoId) continue;

            // Check cache first
            const cachedTitle = await getCachedTitle(videoId);
            if (cachedTitle) {
                link.textContent = `[YouTube] ${cachedTitle} [${videoId}]`;
                link.classList.add("youtubelink");
                link.dataset.ytEnhanced = "true";
                continue;
            }

            // If not cached, add to queue for delayed processing
            uncachedLinks.push({ link, videoId });
        }

        // Process uncached links with delay
        for (const { link, videoId } of uncachedLinks) {
            try {
                const data = await fetchVideoData(videoId);
                const title = data.title;
                link.textContent = `[YouTube] ${title} [${videoId}]`;
                link.classList.add("youtubelink");
                link.dataset.ytEnhanced = "true";

                await setCachedTitle(videoId, title);
            } catch (e) {
                console.warn(`Error enhancing YouTube link:`, e);
                link.dataset.ytFailed = "true";
            }

            // Only delay if there are more links to process
            if (uncachedLinks.length > 1) {
                await delay(DELAY_MS);
            }
        }
    }

    // --- DOM Functions ---
    function findAndProcessLinksInNode(node) {
        if (node.nodeType === Node.ELEMENT_NODE) {
            let elementsToSearch = [];
            if (node.matches('.divMessage')) {
                elementsToSearch.push(node);
            }
            elementsToSearch.push(...node.querySelectorAll('.divMessage'));

            elementsToSearch.forEach(divMessage => {
                const links = divMessage.querySelectorAll('a');
                links.forEach(processLink);
            });
        }
    }

    function findYouTubeLinks() {
        return [...document.querySelectorAll('.divMessage a[href*="youtu.be"], .divMessage a[href*="youtube.com/watch?v="]')];
    }

    // --- Main Execution ---
    document.querySelectorAll('.divMessage a').forEach(processLink);

    const observer = new MutationObserver(async (mutationsList) => {
        let newLinks = [];

        for (const mutation of mutationsList) {
            if (mutation.type === 'childList') {
                for (const addedNode of mutation.addedNodes) {
                    findAndProcessLinksInNode(addedNode);

                    if (addedNode.nodeType === Node.ELEMENT_NODE) {
                        const links = addedNode.querySelectorAll ?
                            addedNode.querySelectorAll('a[href*="youtu.be"], a[href*="youtube.com/watch?v="]') : [];
                        newLinks.push(...links);
                    }
                }
            }
        }

        if (newLinks.length > 0) {
            await enhanceLinks(newLinks);
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // Initial enhancement
    (async function init() {
        await enhanceLinks(findYouTubeLinks());
    })();
})();