// ==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 });
})();