Converts URLs into clickable links in descriptions and comments on YouTube Shorts.
目前為
// ==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();
}
})();