YouTube Shorts Linkify

Converts URLs into clickable links in descriptions and comments on YouTube Shorts.

目前为 2025-03-28 提交的版本。查看 最新版本

// ==UserScript==
// @name         YouTube Shorts Linkify
// @namespace    http://tampermonkey.net/
// @version      1.5
// @license      GPL-3.0-or-later
// @description  Converts URLs into clickable links in descriptions and comments on YouTube Shorts.
// @match        https://www.youtube.com/shorts/*
// @icon         https://www.google.com/s2/favicons?domain=www.youtube.com&sz=64
// @grant        none
// ==/UserScript==
(function() {
    'use strict';

    const DEBUG = false;

    function log(...args) {
        if (DEBUG) console.log('[YT Shorts Linkify]', ...args);
    }

    function addStyles() {
        const style = document.createElement('style');
        style.textContent = `
            a.yt-short-linkify {
                color: inherit !important;  /* Matches surrounding text color */
                text-decoration: none !important;
                cursor: pointer;
            }
            a.yt-short-linkify:hover,
            a.yt-short-linkify:focus,
            a.yt-short-linkify:active {
                color: inherit !important;
                text-decoration: underline !important;  /* Underline on hover */
                outline: none !important;
            }
        `;
        document.head.appendChild(style);
        log("Styles added.");
    }

    function initLinkify() {
        const policy = window.trustedTypes ? trustedTypes.createPolicy('ytShortsLinkify', {
            createHTML: input => input
        }) : null;
    }

    const urlRegex = /(?<=\s|^|\()((?:https?:\/\/)?(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*))\b(?!\.)/g;

    function isSafeToLinkify(node) {
        let parent = node.parentNode;
        while (parent && parent !== document.body) {
            if (parent.nodeName === 'A' || parent.nodeName === 'SCRIPT' || parent.nodeName === 'STYLE') {
                return false;
            }
            parent = parent.parentNode;
        }
        return true;
    }

    function createLinkElement(url) {
        const a = document.createElement('a');
        a.className = 'yt-short-linkify';
        a.href = /^https?:\/\//i.test(url) ? url : 'https://' + url;
        a.target = '_blank';
        a.rel = 'noopener noreferrer';
        a.textContent = /^https?:\/\//i.test(url) ? url : 'https://' + url;
        return a;
    }

    function linkifyTextNode(textNode) {
        const text = textNode.nodeValue;
        if (!text || !/\.[a-zA-Z]{2}/.test(text)) return;

        urlRegex.lastIndex = 0;
        if (!urlRegex.test(text)) return;

        log("Linkifying text node:", text.substring(0, 50) + "...");

        const fragment = document.createDocumentFragment();
        let lastIndex = 0;
        let match;
        urlRegex.lastIndex = 0;

        while ((match = urlRegex.exec(text)) !== null) {
            const url = match[1];
            const index = match.index;

            if (index > lastIndex) {
                fragment.appendChild(document.createTextNode(text.substring(lastIndex, index)));
            }

            const link = createLinkElement(url);
            fragment.appendChild(link);

            lastIndex = index + match[0].length;
        }

        if (lastIndex < text.length) {
            fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
        }

        textNode.parentNode.replaceChild(fragment, textNode);
        log("Text node replaced.");
    }

    function linkifyElement(element) {
        if (!element || element.nodeType !== Node.ELEMENT_NODE || !element.isConnected) {
            log("Skipping linkifyElement for invalid/disconnected element:", element);
            return;
        }

        log("Scanning element for text nodes:", element.tagName);

        const treeWalker = document.createTreeWalker(
            element,
            NodeFilter.SHOW_TEXT,
            {
                acceptNode: function(node) {
                    if (node.nodeValue.trim() !== '' && isSafeToLinkify(node)) {
                        urlRegex.lastIndex = 0;
                        if (urlRegex.test(node.nodeValue)) {
                            return NodeFilter.FILTER_ACCEPT;
                        }
                    }
                    return NodeFilter.FILTER_REJECT;
                }
            }
        );

        const nodesToProcess = [];
        let currentNode;
        while ((currentNode = treeWalker.nextNode())) {
            nodesToProcess.push(currentNode);
        }

        if (nodesToProcess.length > 0) {
            log(`Found ${nodesToProcess.length} text nodes to process in`, element.tagName);
            nodesToProcess.forEach(linkifyTextNode);
        } else {
            log("No relevant text nodes found in", element.tagName);
        }
    }

    function setupObservers() {
        log("Setting up observers...");

        const observer = new MutationObserver(mutations => {
            requestAnimationFrame(() => {
                let addedNodes = new Set();
                let charDataNodes = new Set();

                mutations.forEach(mutation => {
                    if (mutation.type === 'childList') {
                        mutation.addedNodes.forEach(node => {
                            if (node.nodeType === Node.ELEMENT_NODE && node.isConnected) {
                                addedNodes.add(node);
                            } else if (node.nodeType === Node.TEXT_NODE && node.parentNode?.isConnected) {
                                charDataNodes.add(node.parentNode);
                            }
                        });
                    } else if (mutation.type === 'characterData') {
                        if (mutation.target.parentNode?.isConnected) {
                            charDataNodes.add(mutation.target.parentNode);
                        }
                    }
                });

                addedNodes.forEach(node => {
                    if (node.isConnected) {
                        linkifyElement(node);
                    }
                });

                charDataNodes.forEach(node => {
                    if (node.isConnected && node !== document.body && node !== document.documentElement) {
                        linkifyElement(node);
                    }
                });
            });
        });

        observer.observe(document.body, { childList: true, subtree: true, characterData: true });
        log("MutationObserver attached to body.");

        document.querySelectorAll('body *').forEach(el => {
            if (el.isConnected && el.textContent?.trim() && /\.[a-zA-Z]{2}/.test(el.textContent)) {
                if (!el.matches('a') && !el.closest('a')) {
                    linkifyElement(el);
                }
            }
        });

        log("Initial scan complete.");
    }

    function init() {
        log("Initializing script...");
        initLinkify();
        addStyles();
        setupObservers();
        log("Script initialized.");
    }

    if (document.readyState === 'loading') {
        window.addEventListener('load', init);
    } else {
        init();
    }

})();