直接跳转文本链接(支持异步加载和 Shadow DOM)

自动识别文本中的 URL 并将其转为可点击的链接,支持异步加载和 Shadow DOM

// ==UserScript==
// @name         直接跳转文本链接(支持异步加载和 Shadow DOM)
// @namespace    http://tampermonkey.net/
// @version      1.8
// @description  自动识别文本中的 URL 并将其转为可点击的链接,支持异步加载和 Shadow DOM
// @author       cunshao
// @match        *://*/*
// @exclude      https://greasyfork.org/*
// @grant        none
// @license No License
// ==/UserScript==

(function () {
  'use strict';

  // 你的原始逻辑保持不变
  function removeDuplicates(arr) {
    return arr.filter((item, index, self) => self.indexOf(item) === index);
  }

  function traverseArrayBackward(arr, callback) {
    for (let i = arr.length - 1; i >= 0; i--) {
      callback(arr[i], i, i === 0);
    }
  }

  function getTextLinks(text) {
    const linkRegex = /((https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z]{2,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))/g;
    const links = text.match(linkRegex);
    return links || [];
  }

  function getTextLinksList(text) {
    const linkRegex = /((https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z]{2,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))/g;
    const hostRegex = /\b(?!\/\/)((?:www\.)?[a-zA-Z0-9_.-]+(?:\.[a-zA-Z0-9_.-]+)*\.[a-zA-Z]{2,})\b/g;
    let newText = text;

    function matchFunc(reg, type) {
      const matches = newText.matchAll(reg);
      const matchArr = [];
      for (const match of matches) {
        const matchStr = match[0];
        const len = matchStr.length;
        newText = newText.replace(matchStr, ' '.repeat(len));
        match.type = type;
        matchArr.push(match);
      }
      return matchArr;
    }
    return [...matchFunc(linkRegex, 'url'), ...matchFunc(hostRegex, 'host')];
  }

  function splitText(text, arr) {
    if (!arr.length) return [{ text, type: 'text' }];

    let lastIndex = 0;
    let returnArr = [];

    arr.forEach((item, i) => {
      const link = item[0];
      const textObj = { text: text.slice(lastIndex, item.index), type: 'text' };
      const linkObj = { text: link, type: 'link' };

      returnArr.push(textObj, linkObj);
      lastIndex = item.index + link.length;

      if (i === arr.length - 1 && lastIndex < text.length) {
        returnArr.push({ text: text.slice(lastIndex), type: 'text' });
      }
    });
    return returnArr;
  }

  function createLink(link) {
    const a = document.createElement("a");
    a.href = link.startsWith('http') ? link : 'https://' + link;
    a.textContent = link;
    a.target = "_blank";
    a.rel = 'noopener noreferrer nofollow';
    return a;
  }

  function createTextNode(text) {
    const textNode = document.createTextNode(text);
    return textNode;
  }

  function createNode(type, text) {
    switch (type) {
      case 'link':
        return createLink(text);
      case 'text':
        return createTextNode(text);
      default:
        throw new Error('Invalid type');
    }
  }

  const excludeList = ['A', 'SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT'];

  function createTextNodeTree(matchObj = {}, node, parent) {
    if (node.nodeType === 3 && !excludeList.includes(parent.nodeName)) {
      const textContent = node.textContent;
      let matches = getTextLinks(textContent);
      matches.forEach((match) => {
        if (matchObj[match] && matchObj[match].length > 0) {
          matchObj[match].push(node);
        } else {
          matchObj[match] = [node];
        }
      });
    }

    if (node.shadowRoot) {
      const shadowRoot = node.shadowRoot;
      for (const shadowRootChild of shadowRoot.childNodes) {
        createTextNodeTree(matchObj, shadowRootChild, shadowRoot);
      }
    }
    for (let child of node.childNodes) {
      createTextNodeTree(matchObj, child, node);
    }

    return matchObj;
  }

  function debounce(func, delay) {
    let timer;
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, args);
      }, delay);
    };
  }

  function callback() {
    const matchObj = createTextNodeTree({}, document.body, null);
    for (const match in matchObj) {
      if (Object.hasOwnProperty.call(matchObj, match)) {
        const nodeList = removeDuplicates(matchObj[match]);
        nodeList.forEach((node) => {
          const generateNodeList = splitText(node.textContent, getTextLinksList(node.textContent));
          traverseArrayBackward(generateNodeList, ({ type, text }, i, isLast) => {
            try {
              const newNode = createNode(type, text);
              if (isLast) {
                node.parentNode.replaceChild(newNode, node);
              } else if (node.nextSibling) {
                node.parentNode.insertBefore(newNode, node.nextSibling);
              } else {
                node.parentNode.appendChild(newNode);
              }
            } catch (error) {
              console.error(error);
            }
          });
        });
      }
    }
  }

  // 保证页面加载后尽快执行
  function runWhenReady() {
    if (document.readyState === 'complete' || document.readyState === 'interactive') {
      callback();
    } else {
      window.addEventListener('DOMContentLoaded', callback);
    }

    // 确保异步加载的内容也会被处理
    let observer = new MutationObserver(debounce(callback, 300));
    observer.observe(document.body, {
      childList: true,
      attributes: true,
      subtree: true,
    });
  }

  // 尽早执行脚本
  runWhenReady();
})();