// ==UserScript== // @name Auto Link Button for All Sites // @name:zh-CN 全站纯文本网址一键转链接 // @namespace https://auto-link-button.local/ // @version 1.1.0 // @description Automatically detects plain-text URLs (www.xxx / http(s)://xxx / bare domains with common TLDs) anywhere on a page and turns them into clickable links that open in a new tab. Existing hyperlinks, code blocks, inputs and editable areas are left untouched. // @description:zh-CN 自动识别网页中纯文本形式的网址(www.xxx / http(s)://xxx / 带常见后缀的裸域名),一键转换为可点击超链接,新标签页打开。已有 链接、代码块、输入框、可编辑区域均不受影响。 // @author you // @match *://*/* // @icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%230A84FF' stroke-width='2.2' stroke-linecap='round'%3E%3Crect x='3' y='9' width='10' height='6' rx='3' transform='rotate(-40 8 12)'/%3E%3Crect x='11' y='9' width='10' height='6' rx='3' transform='rotate(-40 16 12)'/%3E%3C/g%3E%3C/svg%3E // @run-at document-idle // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; // 常见 TLD 白名单,用于裸域名(无 www / http 前缀)匹配,降低误判率 // 例如 "e.g." "Fig.1" "v2.3" 这类不会被误当成网址 const TLDS = [ 'com','net','org','edu','gov','mil','info','biz','name','pro', 'io','co','ai','app','dev','xyz','top','site','online','store', 'hk','cn','tw','sg','jp','kr','uk','us','ca','au','nz','de','fr', 'it','es','nl','se','no','fi','dk','ch','at','be','ie','pl','ru', 'in','id','th','vn','ph','my','br','mx','za','ae','sa','il', 'me','tv','cc','ws','ly','gg','so','to','fm','io','gl','vc', 'edu.hk','gov.hk','org.hk','com.hk','com.cn','com.tw','co.uk','co.jp','co.kr','com.sg','com.au' ] .sort((a, b) => b.length - a.length) // 长的(多级 TLD)优先匹配 .join('|'); const URL_REGEX = new RegExp( String.raw`\bhttps?:\/\/[^\s<>"'\)\]]+` + // 1) http(s):// 完整 URL String.raw`|\bwww\.[\w-]+(?:\.[\w-]+)+(?:\/[^\s<>"'\)\]]*)?` + // 2) www. 开头 String.raw`|(?"'\)\]]*)?`, // 3) 裸域名 + 有效 TLD 'gi' ); function isSkippableAncestor(el) { if (!el) return false; const skipEl = el.closest('a, script, style, textarea, input, code, pre, [contenteditable="true"]'); return !!skipEl; } function linkify(textNode) { const text = textNode.nodeValue; if (!text) return; URL_REGEX.lastIndex = 0; if (!URL_REGEX.test(text)) return; URL_REGEX.lastIndex = 0; const frag = document.createDocumentFragment(); let lastIndex = 0; let match; let hit = false; while ((match = URL_REGEX.exec(text)) !== null) { let url = match[0]; const start = match.index; // 去掉网址末尾常见的标点残留,如 "www.gavekal.com," 或 "(www.gavekal.com)" const trailingPunct = /[.,;:!?,。;:!?]+$/; const trimmed = url.replace(trailingPunct, ''); const diff = url.length - trimmed.length; url = trimmed; if (start > lastIndex) { frag.appendChild(document.createTextNode(text.slice(lastIndex, start))); } const a = document.createElement('a'); a.href = url.startsWith('http') ? url : `http://${url}`; a.textContent = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.style.color = '#0645AD'; a.style.textDecoration = 'underline'; frag.appendChild(a); hit = true; lastIndex = start + url.length + diff; if (diff > 0) { frag.appendChild(document.createTextNode(text.slice(start + url.length, lastIndex))); } } if (!hit) return; if (lastIndex < text.length) { frag.appendChild(document.createTextNode(text.slice(lastIndex))); } textNode.parentNode.replaceChild(frag, textNode); } function walk(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; if (isSkippableAncestor(node.parentElement)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, }); const targets = []; let n; while ((n = walker.nextNode())) targets.push(n); targets.forEach(linkify); } // 初次扫描(注意:root 若本身就是 内部的 text node 会在 walk 内被跳过, // 但若 root 本身是新插入的元素节点,需要先判断该元素本身是否在可跳过祖先内) function safeWalk(root) { if (root.nodeType === Node.ELEMENT_NODE) { if (isSkippableAncestor(root) || root.closest?.('a')) return; walk(root); } else if (root.nodeType === Node.TEXT_NODE) { if (!isSkippableAncestor(root.parentElement)) linkify(root); } } walk(document.body); let debounceTimer = null; const pending = new Set(); const observer = new MutationObserver((mutations) => { for (const m of mutations) { m.addedNodes.forEach((node) => pending.add(node)); } clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { pending.forEach(safeWalk); pending.clear(); }, 200); }); observer.observe(document.body, { childList: true, subtree: true }); })();