Greasy Fork is available in English.
自动识别页面中的文本链接并转换为可点击的超链接,支持后台静默打开新标签页(类似Ctrl+点击)
// ==UserScript==
// @name 文本链接自动转换器
// @namespace http://tampermonkey.net/
// @version 2.3
// @description 自动识别页面中的文本链接并转换为可点击的超链接,支持后台静默打开新标签页(类似Ctrl+点击)
// @author YourName
// @license MIT
// @match *://*/*
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_openInTab
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
// ========== 模式配置 ==========
// 模式1: invertMode - false=默认(点击打开,悬停复制),true=反转(点击复制,悬停打开)
let invertMode = false;
try {
invertMode = JSON.parse(localStorage.getItem('t2l_invert') || 'false');
} catch(e) {}
// 模式2: backgroundOpen - 仅在默认模式(invertMode=false)下生效,点击链接时后台静默打开新标签页(类似Ctrl+点击)
let backgroundOpen = false;
try {
backgroundOpen = JSON.parse(localStorage.getItem('t2l_background') || 'false');
} catch(e) {}
// 注册菜单:反转模式切换
GM_registerMenuCommand(
invertMode ? '☑ 反转模式(点击复制)' : '☐ 反转模式(点击打开)',
() => {
invertMode = !invertMode;
localStorage.setItem('t2l_invert', JSON.stringify(invertMode));
location.reload();
}
);
// 注册菜单:后台打开模式切换(仅在默认模式下生效)
GM_registerMenuCommand(
backgroundOpen ? '☑ 后台打开链接' : '☐ 后台打开链接',
() => {
backgroundOpen = !backgroundOpen;
localStorage.setItem('t2l_background', JSON.stringify(backgroundOpen));
location.reload();
}
);
// ========== 工具函数 ==========
const URL_REGEX = /((https?:\/\/|www\.)[\x21-\x7e]+[\w\/=]|\w([\w._-])+@\w[\w\._-]+\.(com|cn|org|net|info|tv|cc|gov|edu)|(\w[\w._-]+\.(com|cn|org|net|info|tv|cc|gov|edu))(\/[\x21-\x7e]*[\w\/])?|ed2k:\/\/[\x21-\x7e]+\|\/|thunder:\/\/[\x21-\x7e]+=)/gi;
const PROCESSED_MARKER = 'data-link-converted';
const URL_PREFIXES = ['http://', 'https://', 'ftp://', 'thunder://', 'ed2k://', 'mailto:', 'file://'];
const SKIP_TAGS = ['A', 'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'CODE', 'PRE', 'TEXTAREA', 'INPUT', 'SVG', 'CANVAS', 'VIDEO', 'AUDIO'];
// 添加样式
GM_addStyle(`
.tm-link-btn {
position: absolute;
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 12px;
padding: 2px 8px;
cursor: pointer;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.4;
color: #333;
transition: background 0.2s ease;
user-select: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.tm-link-btn:hover { background: #e0e0e0; }
.tm-link-btn:active { background: #d0d0d0; }
.tm-copy-tooltip {
position: fixed;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 9999999;
pointer-events: none;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
animation: tm-fade-in-out 1.5s ease forwards;
}
@keyframes tm-fade-in-out {
0% { opacity: 0; transform: translateY(5px); }
15% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-5px); }
}
`);
// 按钮状态
let activeBtn = null;
let activeLink = null;
let hideTimer = null;
const normalizeUrl = (url) => {
if (!url) return '';
for (const prefix of URL_PREFIXES) {
if (url.startsWith(prefix)) return url;
}
if (url.startsWith('www.')) return `https://${url}`;
if (url.includes('@') && url.match(/^[^@\s]+@[^@\s]+\.\w+$/)) return `mailto:${url}`;
return `https://${url}`;
};
const showTip = (msg, x, y) => {
const tip = document.createElement('div');
tip.className = 'tm-copy-tooltip';
tip.textContent = msg;
tip.style.left = `${x}px`;
tip.style.top = `${y - 40}px`;
document.body.appendChild(tip);
setTimeout(() => tip.remove(), 1500);
};
const copyUrl = async (url, x, y) => {
const normalized = normalizeUrl(url);
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(normalized);
} else {
GM_setClipboard(normalized);
}
showTip('已复制', x, y);
} catch (e) {
try {
GM_setClipboard(normalized);
showTip('已复制', x, y);
} catch (e2) {
showTip('复制失败', x, y);
}
}
};
// 后台打开链接(使用 GM_openInTab 实现真正的静默后台打开)
const openUrlInBackground = (url) => {
const normalized = normalizeUrl(url);
if (typeof GM_openInTab !== 'undefined') {
// active: false 表示新标签页在后台加载,不获得焦点
GM_openInTab(normalized, { active: false, insert: true });
return true;
} else {
// 回退方案:尝试模拟 Ctrl+点击,但不如 GM_openInTab 稳定
console.warn('GM_openInTab 不可用,后台打开可能失败');
try {
const newWin = window.open(normalized, '_blank', 'noopener,noreferrer');
if (newWin) window.focus(); // 尽力拉回焦点
return !!newWin;
} catch (e) {
return false;
}
}
};
// 按钮相关函数
const hideActionBtn = () => {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (activeBtn) {
activeBtn.remove();
activeBtn = null;
}
activeLink = null;
}, 100);
};
const handleBtnLeave = (e) => {
const related = e.relatedTarget;
if (related === activeBtn || (activeBtn && activeBtn.contains(related))) return;
hideActionBtn();
};
const createActionBtn = (link, text, onClick) => {
const btn = document.createElement('button');
btn.className = 'tm-link-btn';
btn.textContent = text;
btn.type = 'button';
const rect = link.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const btnHeight = 20;
const center = rect.top + rect.height / 2;
btn.style.left = `${rect.right + scrollX + 4}px`;
btn.style.top = `${center + scrollY - btnHeight / 2}px`;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
onClick(e);
});
btn.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
btn.addEventListener('mouseleave', hideActionBtn);
return btn;
};
const showCopyBtn = (link) => {
if (activeLink === link) {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
return;
}
if (activeBtn) activeBtn.remove();
activeLink = link;
activeBtn = createActionBtn(link, '复制', (e) => {
copyUrl(link.href, e.clientX, e.clientY);
});
document.body.appendChild(activeBtn);
};
const showOpenBtn = (link) => {
if (activeLink === link) {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
return;
}
if (activeBtn) activeBtn.remove();
activeLink = link;
activeBtn = createActionBtn(link, '打开', () => {
// 悬停按钮的“打开”仍然使用普通前台打开(因为用户主动点击按钮)
window.open(link.href, '_blank', 'noopener,noreferrer');
});
document.body.appendChild(activeBtn);
};
// ========== 创建链接元素(核心) ==========
const createLink = (url) => {
const a = document.createElement('a');
const normalized = normalizeUrl(url);
a.href = normalized;
a.textContent = url;
a.setAttribute(PROCESSED_MARKER, 'true');
if (invertMode) {
// 反转模式:点击复制,悬停显示打开按钮
a.style.cssText = 'color: #0066cc; text-decoration: underline; cursor: copy;';
a.addEventListener('click', (e) => {
e.preventDefault();
copyUrl(a.href, e.clientX, e.clientY);
});
a.addEventListener('mouseenter', (e) => showOpenBtn(e.currentTarget));
a.addEventListener('mouseleave', handleBtnLeave);
} else {
// 默认模式:悬停显示复制按钮,点击行为根据 backgroundOpen 决定
a.style.cssText = 'color: #0066cc; text-decoration: underline;';
a.rel = 'noopener noreferrer';
if (backgroundOpen) {
// 后台打开模式:使用 GM_openInTab 实现类似 Ctrl+点击 的后台打开
a.addEventListener('click', (e) => {
// 仅处理普通左键点击(不带任何修饰键)
const isPlainLeftClick = e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
if (isPlainLeftClick) {
e.preventDefault();
openUrlInBackground(a.href);
}
// 如果用户按住了 Ctrl/Cmd/Shift 或使用中键,让浏览器默认行为生效(通常也是新标签页,尊重用户习惯)
});
} else {
// 普通前台打开模式:不添加 click 拦截,让浏览器默认行为打开(target 将会让浏览器自动处理)
a.target = '_blank';
}
// 悬停显示复制按钮(两种子模式都保留)
a.addEventListener('mouseenter', (e) => showCopyBtn(e.currentTarget));
a.addEventListener('mouseleave', handleBtnLeave);
}
return a;
};
// ========== 文本处理与DOM遍历 ==========
const containsUrl = (text) => {
if (!text || typeof text !== 'string') return false;
URL_REGEX.lastIndex = 0;
return URL_REGEX.test(text);
};
const shouldSkip = (node) => {
if (!node) return true;
const parent = node.parentElement;
if (!parent) return true;
if (SKIP_TAGS.includes(parent.tagName)) return true;
if (parent.isContentEditable || parent.closest('[contenteditable="true"]')) return true;
if (parent.matches && parent.matches('a[href]')) return true;
if (parent.hasAttribute(PROCESSED_MARKER)) return true;
return false;
};
const convertTextNode = (textNode) => {
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return false;
const parent = textNode.parentNode;
if (!parent || shouldSkip(textNode)) return false;
const text = textNode.textContent;
if (!containsUrl(text)) return false;
URL_REGEX.lastIndex = 0;
const fragment = document.createDocumentFragment();
let lastIndex = 0, match;
while ((match = URL_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
fragment.appendChild(createLink(match[0]));
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
}
parent.replaceChild(fragment, textNode);
return true;
};
const processBatch = (nodes, index = 0) => {
const batchSize = 1000;
const startTime = performance.now();
while (index < nodes.length) {
convertTextNode(nodes[index++]);
if (index % batchSize === 0 || performance.now() - startTime > 50) {
setTimeout(() => processBatch(nodes, index), 0);
return;
}
}
};
const processNode = (node) => {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
convertTextNode(node);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE || SKIP_TAGS.includes(node.tagName)) return;
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
acceptNode: (n) => shouldSkip(n) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
});
const nodes = [];
let n;
while ((n = walker.nextNode()) !== null) nodes.push(n);
processBatch(nodes);
};
// Shadow DOM支持
const shadowSet = new WeakSet();
const processShadow = (root) => {
if (!root || shadowSet.has(root)) return;
processNode(root);
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
for (const n of m.addedNodes) {
processNode(n);
if (n.nodeType === Node.ELEMENT_NODE) {
n.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) processShadow(el.shadowRoot);
});
}
}
}
}
});
obs.observe(root, { childList: true, subtree: true, characterData: true });
shadowSet.add(root);
};
const findShadows = (node) => {
if (!node) return;
if (node.shadowRoot) processShadow(node.shadowRoot);
if (node.nodeType === Node.ELEMENT_NODE) {
node.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) processShadow(el.shadowRoot);
});
}
};
// SPA路由监听
let lastUrl = location.href;
const onUrlChange = () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(() => {
processNode(document.body);
findShadows(document.body);
}, 500);
}
};
// MutationObserver防抖
let pending = new Set();
let mutationTimer = null;
const flushPending = () => {
if (pending.size === 0) return;
const nodes = Array.from(pending);
pending.clear();
for (const n of nodes) {
try {
processNode(n);
findShadows(n);
} catch (err) {}
}
};
const schedule = (node) => {
if (!node) return;
pending.add(node);
if (mutationTimer) clearTimeout(mutationTimer);
mutationTimer = setTimeout(() => {
flushPending();
mutationTimer = null;
}, 100);
};
// ========== 启动 ==========
const init = () => {
processNode(document.body);
findShadows(document.body);
// B站特殊处理
if (location.hostname.includes('bilibili.com')) {
[1000, 2000, 3000, 5000].forEach(delay => {
setTimeout(() => {
const container = document.querySelector('.reply-container, #comment, .comment');
if (container) {
processNode(container);
findShadows(container);
}
}, delay);
});
}
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
for (const n of m.addedNodes) {
if (n.nodeType === Node.ELEMENT_NODE || n.nodeType === Node.TEXT_NODE) {
schedule(n);
if (n.nodeType === Node.ELEMENT_NODE) findShadows(n);
}
}
}
if (m.type === 'characterData' && m.target && m.target.parentElement && !m.target.parentElement.hasAttribute(PROCESSED_MARKER)) {
schedule(m.target);
}
}
});
obs.observe(document.body, { childList: true, subtree: true, characterData: true });
const originalPush = history.pushState;
const originalReplace = history.replaceState;
history.pushState = function(...args) {
originalPush.apply(this, args);
onUrlChange();
};
history.replaceState = function(...args) {
originalReplace.apply(this, args);
onUrlChange();
};
window.addEventListener('popstate', onUrlChange);
window.addEventListener('hashchange', onUrlChange);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();