YouTube Shorts Linkify

Converts URLs into clickable links on YouTube Shorts.

目前為 2025-02-10 提交的版本,檢視 最新版本

// ==UserScript==
// @name         YouTube Shorts Linkify
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Converts URLs into clickable links on YouTube Shorts.
// @author       UniverseDev
// @license      GPL-3.0-or-later
// @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';

    function initLinkify(){
        const policy = window.trustedTypes ? trustedTypes.createPolicy('ytShortsLinkify', { createHTML: input => input }) : null;
        const style = document.createElement('style');
        style.textContent = `
            a.yt-short-linkify {
                color: inherit;
                text-decoration: underline;
                cursor: pointer;
            }
            .custom-tooltip {
                position: absolute;
                background: #333;
                color: #fff;
                padding: 4px 8px;
                border-radius: 4px;
                font-size: 12px;
                pointer-events: none;
                z-index: 10000;
                opacity: 0.9;
                white-space: nowrap;
            }
        `;
        document.head.appendChild(style);

        const urlRegex = /(?<!@)\b((?:https?:\/\/)?(?:www\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}(?:\/\S*?))(?=\s|$)/g;

        function isInsideTooltip(node) {
            let current = node.parentNode;
            while (current) {
                if (current.classList && current.classList.contains("custom-tooltip")) {
                    return true;
                }
                current = current.parentNode;
            }
            return false;
        }

        function linkifyTextNode(textNode) {
            if (!textNode.nodeValue) return;
            urlRegex.lastIndex = 0;
            if (!urlRegex.test(textNode.nodeValue)) return;
            const span = document.createElement('span');
            let text = textNode.nodeValue, lastIndex = 0;
            urlRegex.lastIndex = 0;
            let match;
            while ((match = urlRegex.exec(text)) !== null) {
                const url = match[1], index = match.index;
                span.appendChild(document.createTextNode(text.substring(lastIndex, index)));
                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 = url;
                a.addEventListener('mouseenter', function(){
                    const tooltip = document.createElement('div');
                    tooltip.className = 'custom-tooltip';
                    tooltip.textContent = url;
                    document.body.appendChild(tooltip);
                    const rect = a.getBoundingClientRect();
                    const tooltipRect = tooltip.getBoundingClientRect();
                    tooltip.style.left = (rect.left + window.pageXOffset) + 'px';
                    tooltip.style.top = (rect.top + window.pageYOffset - tooltipRect.height - 5) + 'px';
                    a._tooltip = tooltip;
                });
                a.addEventListener('mouseleave', function(){
                    if (a._tooltip) {
                        a._tooltip.remove();
                        a._tooltip = null;
                    }
                });
                span.appendChild(a);
                lastIndex = index + url.length;
            }
            span.appendChild(document.createTextNode(text.substring(lastIndex)));
            textNode.parentNode.replaceChild(span, textNode);
        }

        function linkifyElement(element) {
            const treeWalker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
                acceptNode: function(node) {
                    if (node.parentNode && node.parentNode.nodeName === 'A') {
                        return NodeFilter.FILTER_REJECT;
                    }
                    if (isInsideTooltip(node)) {
                        return NodeFilter.FILTER_REJECT;
                    }
                    urlRegex.lastIndex = 0;
                    return (node.nodeValue && urlRegex.test(node.nodeValue))
                        ? NodeFilter.FILTER_ACCEPT
                        : NodeFilter.FILTER_REJECT;
                }
            });
            const nodes = [];
            while (treeWalker.nextNode()) {
                nodes.push(treeWalker.currentNode);
            }
            nodes.forEach(linkifyTextNode);
        }

        const io = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    linkifyElement(entry.target);
                    io.unobserve(entry.target);
                }
            });
        }, { threshold: 0.1 });
        document.querySelectorAll('body *:not(.custom-tooltip)').forEach(el => io.observe(el));

        const mutationObserver = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE && !node.classList.contains("custom-tooltip")) {
                            io.observe(node);
                        }
                    });
                } else if (mutation.type === 'characterData') {
                    if (mutation.target.parentNode && !isInsideTooltip(mutation.target)) {
                        io.observe(mutation.target.parentNode);
                    }
                } else if (mutation.type === 'attributes') {
                    if (mutation.target && !mutation.target.classList.contains("custom-tooltip")) {
                        io.observe(mutation.target);
                    }
                }
            });
        });
        mutationObserver.observe(document.body, {
            childList: true,
            subtree: true,
            characterData: true,
            attributes: true,
            attributeFilter: ['class', 'style']
        });
    }

    window.addEventListener('load', initLinkify);
})();