🔗 文本快链

自动识别文本中的网址和邮箱并转换为可点击链接

// ==UserScript==

// @name          🔗 文本快链

// @description   自动识别文本中的网址和邮箱并转换为可点击链接

// @version       1.0.0

// @author        aiccest

// @namespace     Aiccest

// @license       AGPL

// @include       *

// @exclude       *pan.baidu.com/*

// @exclude       *renren.com/*

// @exclude       *exhentai.org/*

// @exclude       *music.google.com/*

// @exclude       *play.google.com/music/*

// @exclude       *mail.google.com/*

// @exclude       *docs.google.com/*

// @exclude       *www.google.*

// @exclude       *acid3.acidtests.org/*

// @exclude       *.163.com/*

// @exclude       *.alipay.com/*

// @grant         unsafeWindow

// @run-at        document-end

// @require       https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js

// ==/UserScript==

"use strict";

class TextLinkConverter {

  static CONFIG = {

    ALLOWED_PROTOCOLS: new Set([

      'http:', 'https:', 'ftp:', 'mailto:',

      'magnet:', 'ed2k:', 'thunder:',

      'irc:', 'git:', 'ssh:', 'tel:', 'sms:'

    ]),



    EXCLUDED_TAGS: new Set([

      'a', 'svg', 'canvas', 'applet', 'input', 'button', 'area', 'pre',

      'embed', 'frame', 'frameset', 'head', 'iframe', 'img', 'option',

      'map', 'meta', 'noscript', 'object', 'script', 'style', 'textarea', 'code'

    ]),



    URL_REGEX_PARTS: {

      PROTOCOLS: '(?:https?|ftp|file|chrome|edge|magnet|irc|ssh|git|svn)',

      DOMAIN: '(?:[\\w-]+\\.)+[\\w-]+',

      PORT: '(?::\\d+)?',

      PATH: '(?:/[\\x21-\\x7e]*[\\w/=#-])?',

      EMAIL: '\\b[\\w.-]+@[\\w.-]+\\.(?:[a-z]{2,}|xn--[a-z0-9]+)\\b',

      SPECIAL_LINKS: '(?:ed2k|thunder|flashget|qqdl):\\/\\/[\\x21-\\x7e]+'

    },



    BATCH_SIZE: 100,

    IDLE_TIMEOUT: 1000

  };

  constructor() {

    if (window !== window.top || document.title === "") return;

    this.urlRegex = new RegExp([

      `(?:(?:(?:${TextLinkConverter.CONFIG.URL_REGEX_PARTS.PROTOCOLS}:\\/\\/|www\\.)`,

      `${TextLinkConverter.CONFIG.URL_REGEX_PARTS.DOMAIN}`,

      `${TextLinkConverter.CONFIG.URL_REGEX_PARTS.PORT}`,

      `${TextLinkConverter.CONFIG.URL_REGEX_PARTS.PATH})`,

      `|${TextLinkConverter.CONFIG.URL_REGEX_PARTS.EMAIL}`,

      `|${TextLinkConverter.CONFIG.URL_REGEX_PARTS.SPECIAL_LINKS})`

    ].join(''), 'gi');

    this.init();

  }

  init() {

    this.setupMutationObserver();

    this.addEventListeners();

    this.processDocument();

  }

  setupMutationObserver() {

    this.observer = new MutationObserver(mutations => {

      mutations.forEach(mutation => {

        if (mutation.type === "childList") {

          mutation.addedNodes.forEach(node => this.processNode(node));

        }

      });

    });



    this.observer.observe(document.body, {

      childList: true,

      subtree: true,

      attributes: false,

      characterData: false

    });

  }

  addEventListeners() {

    document.addEventListener("mouseover", this.clearLink.bind(this));

  }

  processDocument() {

    requestIdleCallback(() => {

      const nodes = [];

      const walker = document.createTreeWalker(

        document.body,

        NodeFilter.SHOW_TEXT,

        { acceptNode: node => this.isExcluded(node.parentNode)

          ? NodeFilter.FILTER_REJECT

          : NodeFilter.FILTER_ACCEPT },

        false

      );



      while (walker.nextNode()) nodes.push(walker.currentNode);

      this.processInBatches(nodes);

    }, { timeout: TextLinkConverter.CONFIG.IDLE_TIMEOUT });

  }

  processNode(node) {

    if (node.nodeType === Node.ELEMENT_NODE) {

      const nodes = [];

      const walker = document.createTreeWalker(

        node,

        NodeFilter.SHOW_TEXT,

        { acceptNode: node => this.isExcluded(node.parentNode)

          ? NodeFilter.FILTER_REJECT

          : NodeFilter.FILTER_ACCEPT },

        false

      );



      while (walker.nextNode()) nodes.push(walker.currentNode);

      this.processInBatches(nodes);

    }

    else if (node.nodeType === Node.TEXT_NODE && !this.isExcluded(node.parentNode)) {

      this.safeConvertTextNode(node);

    }

  }

  processInBatches(nodes, batchSize = TextLinkConverter.CONFIG.BATCH_SIZE) {

    let index = 0;



    const processBatch = deadline => {

      while (index < nodes.length && (deadline.timeRemaining() > 0 || deadline.didTimeout)) {

        const node = nodes[index++];

        if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {

          this.safeConvertTextNode(node);

        }

        if (index % batchSize === 0) break;

      }



      if (index < nodes.length) {

        requestIdleCallback(processBatch, { timeout: TextLinkConverter.CONFIG.IDLE_TIMEOUT });

      }

    };



    requestIdleCallback(processBatch, { timeout: TextLinkConverter.CONFIG.IDLE_TIMEOUT });

  }

  isExcluded(node) {

    return TextLinkConverter.CONFIG.EXCLUDED_TAGS.has(node.localName.toLowerCase());

  }

  safeConvertTextNode(node) {

    if (!node.textContent.trim() || this.isExcluded(node.parentNode)) return;



    const text = node.textContent;

    const html = text.replace(this.urlRegex, match => {

      if (!this.isValidUrl(match)) return match;



      const url = this.normalizeUrl(match);

      return `<a href="${DOMPurify.sanitize(url)}" target="_blank" rel="noopener noreferrer" class="textToLink">${match}</a>`;

    });



    if (html !== text) this.safeInsertHTML(node, html);

  }

  isValidUrl(url) {

    try {

      if (url.includes('@')) return this.isValidEmail(url);



      const normalizedUrl = url.includes('://') ? url : `http://${url}`;

      const parsedUrl = new URL(normalizedUrl);



      return TextLinkConverter.CONFIG.ALLOWED_PROTOCOLS.has(parsedUrl.protocol.toLowerCase()) &&

             this.isValidDomain(parsedUrl.hostname) &&

             this.hasValidPath(parsedUrl);

    } catch {

      return false;

    }

  }

  isValidEmail(email) {

    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

  }

  isValidDomain(hostname) {

    return /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(hostname) ||

           /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(hostname);

  }

  hasValidPath(url) {

    return !/[<>"']/.test(url.pathname + url.search + url.hash);

  }

  normalizeUrl(url) {

    if (url.includes('@')) return `mailto:${url}`;

    if (url.startsWith('www.')) return `http://${url}`;

    if (!url.includes('://')) return `http://${url}`;

    return url;

  }

  safeInsertHTML(element, html) {

    const template = document.createElement('template');

    template.innerHTML = DOMPurify.sanitize(html, {

      ALLOWED_TAGS: ['a'],

      ALLOWED_ATTR: ['href', 'target', 'rel', 'class']

    });



    const parent = element.parentNode;

    if (parent) parent.replaceChild(template.content, element);

  }

  clearLink(event) {

    const link = event.target.closest('a.textToLink');

    if (!link) return;



    const url = link.getAttribute('href');

    if (!url) return;



    if (TextLinkConverter.CONFIG.ALLOWED_PROTOCOLS.has(url.split(':')[0] + ':')) return;



    link.setAttribute('href', url.includes('@') ? `mailto:${url}` : `http://${url}`);

  }

}

// 初始化

if (document.readyState === 'loading') {

  document.addEventListener('DOMContentLoaded', () => new TextLinkConverter());

} else {

  new TextLinkConverter();

}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址