// ==UserScript== // @name 文本链接自动转换器 // @namespace http://tampermonkey.net/ // @version 2.1 // @description 自动识别页面中的文本链接并转换为可点击的超链接 // @author YourName // @license MIT // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_addStyle // @grant GM_setClipboard // @run-at document-end // @downloadURL none // ==/UserScript== (function () { 'use strict'; // 模式配置:false=默认(点击打开,悬停复制),true=反转(点击复制,悬停打开) let invertMode = false; try { invertMode = JSON.parse(localStorage.getItem('t2l_invert') || 'false'); } catch(e) {} GM_registerMenuCommand( invertMode ? '☑ 反转模式(点击复制)' : '☐ 反转模式(点击打开)', () => { invertMode = !invertMode; localStorage.setItem('t2l_invert', JSON.stringify(invertMode)); 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); } } }; const openUrl = (url) => { window.open(normalizeUrl(url), '_blank', 'noopener,noreferrer'); }; // 按钮相关函数(必须在createLink之前定义) 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, '打开', () => { openUrl(link.href); }); 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 { // 默认模式:点击打开 a.style.cssText = 'color: #0066cc; text-decoration: underline;'; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.addEventListener('mouseenter', (e) => showCopyBtn(e.currentTarget)); a.addEventListener('mouseleave', handleBtnLeave); } return a; }; // 文本处理相关 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(); } })();