// ==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/dompurify@2.3.3/dist/purify.min.js // @downloadURL none // ==/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 `${match}`; }); 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(); }