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.0
// @description  Cleans up YouTube links and adds video titles in 8chan.moe posts
// @author       anon, Gemini, DeepSeek
// @license      MIT
// @match        https://8chan.moe/*
// @match        https://8chan.se/*
// @grant        GM.xmlHttpRequest
// @connect      youtube.com
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const DELAY_MS = 200; // Delay between YouTube API requests to avoid rate limiting

    // --- YouTube Link Cleaning Functions ---

    // Function to clean a single YouTube URL string
    function cleanYouTubeUrl(url) {
        if (!url || (!url.includes('youtube.com') && !url.includes('youtu.be'))) {
            return url; // Not a YouTube link, return as is
        }

        let cleaned = url;

        // 1. Handle youtu.be
        if (cleaned.startsWith('https://youtu.be/')) {
            const videoIdPath = cleaned.substring('https://youtu.be/'.length);
            // Find the end of the video ID or the start of parameters/hash
            const paramIndex = videoIdPath.search(/[?#]/);
            const videoId = paramIndex === -1 ? videoIdPath : videoIdPath.substring(0, paramIndex);
            const rest = paramIndex === -1 ? '' : videoIdPath.substring(paramIndex); // Keep parameters/hash
            cleaned = `https://www.youtube.com/watch?v=${videoId}${rest}`;
        }

        // 2. Handle /live/ (only applies to youtube.com after youtu.be conversion if applicable)
        if (cleaned.includes('youtube.com/live/')) {
             cleaned = cleaned.replace('/live/', '/watch?v=');
        }

        // 3. Remove ?si= parameter (and the preceding ? or &)
        // This regex handles ?si=... at the start of parameters or &si=... later
        cleaned = cleaned.replace(/[?&]si=[^&]+/, '');

        // Clean up potentially resulting trailing ? or & if the removed param was the only one
        if (cleaned.endsWith('?') || cleaned.endsWith('&')) {
            cleaned = cleaned.slice(0, -1);
        }

        return cleaned;
    }

    // Function to process a single link element
    function processLink(link) {
        const currentUrl = link.href; // Get the fully resolved URL from the href attribute

        // Quickly check if it's potentially a YouTube link to avoid unnecessary processing
        if (!currentUrl.includes('youtube.com') && !currentUrl.includes('youtu.be')) {
            return;
        }

        const cleanedUrl = cleanYouTubeUrl(currentUrl);

        // If the URL was changed
        if (cleanedUrl !== currentUrl) {
            // Update the href attribute
            link.href = cleanedUrl;

            // Update the visible text ONLY if it was originally the exact URL string
            // This prevents changing user-provided link text like "My cool video"
            if (link.textContent.trim() === currentUrl.trim()) {
                 link.textContent = cleanedUrl;
            }
        }
    }

    // --- YouTube Link Enhancement Functions ---

    // Red YouTube icon as a masked SVG
    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)}`;

    // Add styles for YouTube links
    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);

    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) {
        for (const link of links) {
            if (link.dataset.ytEnhanced || link.dataset.ytFailed) continue;

            // First clean the link if needed
            processLink(link);

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

            if (!videoId) continue;

            try {
                const data = await fetchVideoData(videoId);
                link.textContent = `${data.title} [${videoId}]`;
                link.classList.add("youtubelink");
                link.dataset.ytEnhanced = "true";
            } catch (e) {
                console.warn(`Error enhancing YouTube link:`, e);
                link.dataset.ytFailed = "true";
            }

            await delay(DELAY_MS);
        }
    }

    // --- Common DOM Functions ---

    function findAndProcessLinksInNode(node) {
        // Check if the node itself is a divMessage or contains divMessage descendants
        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 ---

    // 1. Process all existing links when the script first runs
    document.querySelectorAll('.divMessage a').forEach(processLink);

    // 2. Set up MutationObservers to handle dynamically loaded content
    const observer = new MutationObserver(async (mutationsList) => {
        let newLinks = [];
        let needsCleaning = false;

        for (const mutation of mutationsList) {
            if (mutation.type === 'childList') {
                for (const addedNode of mutation.addedNodes) {
                    // Process any added node that contains or is a .divMessage
                    findAndProcessLinksInNode(addedNode);

                    // Check for new YouTube links
                    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);
        }
    });

    // Start observing the document body for additions of new nodes
    observer.observe(document.body, { childList: true, subtree: true });

    // 3. Initial enhancement of existing links
    (async function init() {
        await enhanceLinks(findYouTubeLinks());
    })();
})();