YouTube Shorts Linkify

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

当前为 2025-03-28 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Shorts Linkify
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.5
  5. // @license GPL-3.0-or-later
  6. // @description Converts URLs into clickable links in descriptions and comments on YouTube Shorts.
  7. // @match https://www.youtube.com/shorts/*
  8. // @icon https://www.google.com/s2/favicons?domain=www.youtube.com&sz=64
  9. // @grant none
  10. // ==/UserScript==
  11. (function() {
  12. 'use strict';
  13.  
  14. const DEBUG = false;
  15.  
  16. function log(...args) {
  17. if (DEBUG) console.log('[YT Shorts Linkify]', ...args);
  18. }
  19.  
  20. function addStyles() {
  21. const style = document.createElement('style');
  22. style.textContent = `
  23. a.yt-short-linkify {
  24. color: inherit !important; /* Matches surrounding text color */
  25. text-decoration: none !important;
  26. cursor: pointer;
  27. }
  28. a.yt-short-linkify:hover,
  29. a.yt-short-linkify:focus,
  30. a.yt-short-linkify:active {
  31. color: inherit !important;
  32. text-decoration: underline !important; /* Underline on hover */
  33. outline: none !important;
  34. }
  35. `;
  36. document.head.appendChild(style);
  37. log("Styles added.");
  38. }
  39.  
  40. function initLinkify() {
  41. const policy = window.trustedTypes ? trustedTypes.createPolicy('ytShortsLinkify', {
  42. createHTML: input => input
  43. }) : null;
  44. }
  45.  
  46. const urlRegex = /(?<=\s|^|\()((?:https?:\/\/)?(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*))\b(?!\.)/g;
  47.  
  48. function isSafeToLinkify(node) {
  49. let parent = node.parentNode;
  50. while (parent && parent !== document.body) {
  51. if (parent.nodeName === 'A' || parent.nodeName === 'SCRIPT' || parent.nodeName === 'STYLE') {
  52. return false;
  53. }
  54. parent = parent.parentNode;
  55. }
  56. return true;
  57. }
  58.  
  59. function createLinkElement(url) {
  60. const a = document.createElement('a');
  61. a.className = 'yt-short-linkify';
  62. a.href = /^https?:\/\//i.test(url) ? url : 'https://' + url;
  63. a.target = '_blank';
  64. a.rel = 'noopener noreferrer';
  65. a.textContent = /^https?:\/\//i.test(url) ? url : 'https://' + url;
  66. return a;
  67. }
  68.  
  69. function linkifyTextNode(textNode) {
  70. const text = textNode.nodeValue;
  71. if (!text || !/\.[a-zA-Z]{2}/.test(text)) return;
  72.  
  73. urlRegex.lastIndex = 0;
  74. if (!urlRegex.test(text)) return;
  75.  
  76. log("Linkifying text node:", text.substring(0, 50) + "...");
  77.  
  78. const fragment = document.createDocumentFragment();
  79. let lastIndex = 0;
  80. let match;
  81. urlRegex.lastIndex = 0;
  82.  
  83. while ((match = urlRegex.exec(text)) !== null) {
  84. const url = match[1];
  85. const index = match.index;
  86.  
  87. if (index > lastIndex) {
  88. fragment.appendChild(document.createTextNode(text.substring(lastIndex, index)));
  89. }
  90.  
  91. const link = createLinkElement(url);
  92. fragment.appendChild(link);
  93.  
  94. lastIndex = index + match[0].length;
  95. }
  96.  
  97. if (lastIndex < text.length) {
  98. fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
  99. }
  100.  
  101. textNode.parentNode.replaceChild(fragment, textNode);
  102. log("Text node replaced.");
  103. }
  104.  
  105. function linkifyElement(element) {
  106. if (!element || element.nodeType !== Node.ELEMENT_NODE || !element.isConnected) {
  107. log("Skipping linkifyElement for invalid/disconnected element:", element);
  108. return;
  109. }
  110.  
  111. log("Scanning element for text nodes:", element.tagName);
  112.  
  113. const treeWalker = document.createTreeWalker(
  114. element,
  115. NodeFilter.SHOW_TEXT,
  116. {
  117. acceptNode: function(node) {
  118. if (node.nodeValue.trim() !== '' && isSafeToLinkify(node)) {
  119. urlRegex.lastIndex = 0;
  120. if (urlRegex.test(node.nodeValue)) {
  121. return NodeFilter.FILTER_ACCEPT;
  122. }
  123. }
  124. return NodeFilter.FILTER_REJECT;
  125. }
  126. }
  127. );
  128.  
  129. const nodesToProcess = [];
  130. let currentNode;
  131. while ((currentNode = treeWalker.nextNode())) {
  132. nodesToProcess.push(currentNode);
  133. }
  134.  
  135. if (nodesToProcess.length > 0) {
  136. log(`Found ${nodesToProcess.length} text nodes to process in`, element.tagName);
  137. nodesToProcess.forEach(linkifyTextNode);
  138. } else {
  139. log("No relevant text nodes found in", element.tagName);
  140. }
  141. }
  142.  
  143. function setupObservers() {
  144. log("Setting up observers...");
  145.  
  146. const observer = new MutationObserver(mutations => {
  147. requestAnimationFrame(() => {
  148. let addedNodes = new Set();
  149. let charDataNodes = new Set();
  150.  
  151. mutations.forEach(mutation => {
  152. if (mutation.type === 'childList') {
  153. mutation.addedNodes.forEach(node => {
  154. if (node.nodeType === Node.ELEMENT_NODE && node.isConnected) {
  155. addedNodes.add(node);
  156. } else if (node.nodeType === Node.TEXT_NODE && node.parentNode?.isConnected) {
  157. charDataNodes.add(node.parentNode);
  158. }
  159. });
  160. } else if (mutation.type === 'characterData') {
  161. if (mutation.target.parentNode?.isConnected) {
  162. charDataNodes.add(mutation.target.parentNode);
  163. }
  164. }
  165. });
  166.  
  167. addedNodes.forEach(node => {
  168. if (node.isConnected) {
  169. linkifyElement(node);
  170. }
  171. });
  172.  
  173. charDataNodes.forEach(node => {
  174. if (node.isConnected && node !== document.body && node !== document.documentElement) {
  175. linkifyElement(node);
  176. }
  177. });
  178. });
  179. });
  180.  
  181. observer.observe(document.body, { childList: true, subtree: true, characterData: true });
  182. log("MutationObserver attached to body.");
  183.  
  184. document.querySelectorAll('body *').forEach(el => {
  185. if (el.isConnected && el.textContent?.trim() && /\.[a-zA-Z]{2}/.test(el.textContent)) {
  186. if (!el.matches('a') && !el.closest('a')) {
  187. linkifyElement(el);
  188. }
  189. }
  190. });
  191.  
  192. log("Initial scan complete.");
  193. }
  194.  
  195. function init() {
  196. log("Initializing script...");
  197. initLinkify();
  198. addStyles();
  199. setupObservers();
  200. log("Script initialized.");
  201. }
  202.  
  203. if (document.readyState === 'loading') {
  204. window.addEventListener('load', init);
  205. } else {
  206. init();
  207. }
  208.  
  209. })();