8chan YouTube Link Enhancer

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

目前為 2025-04-22 提交的版本,檢視 最新版本

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==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());
    })();
})();