// ==UserScript== // @name 文本链接转换超链接 // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description 自动识别页面中的文本链接并转换为可点击的超链接 // @author Assistant // @match *://*/* // @grant none // @license MIT // @run-at document-end // @downloadURL https://update.greasyfork.icu/scripts/557819/%E6%96%87%E6%9C%AC%E9%93%BE%E6%8E%A5%E8%BD%AC%E6%8D%A2%E8%B6%85%E9%93%BE%E6%8E%A5.user.js // @updateURL https://update.greasyfork.icu/scripts/557819/%E6%96%87%E6%9C%AC%E9%93%BE%E6%8E%A5%E8%BD%AC%E6%8D%A2%E8%B6%85%E9%93%BE%E6%8E%A5.meta.js // ==/UserScript== (function() { 'use strict'; // 调试开关 const DEBUG = false; /** * 日志函数 */ function log(message, level = 'info') { if (DEBUG) { console.log(`[文本链接转换器] ${level.toUpperCase()}: ${message}`); } } /** * 检查元素是否为可见文本节点 * @param {Element} element - 要检查的DOM元素 * @returns {boolean} 是否为可见元素 */ function isVisible(element) { // 检查元素是否存在 if (!element) return false; // 检查是否为script或style标签 if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE') { return false; } // 检查display样式 const style = window.getComputedStyle(element); if (style.display === 'none' || style.visibility === 'hidden') { return false; } // 检查offsetParent(排除通过opacity:0或position:absolute;left:-9999px等方式隐藏的元素) if (element.offsetParent === null && style.position !== 'fixed') { return false; } return true; } /** * 改进的URL正则表达式,匹配更多类型的链接 * 匹配以http://或https://开头的有效URL */ const urlRegex = /\b(https?:\/\/[^\s<>"]{4,})\b/gi; /** * 测试用的文本,用于验证正则表达式 */ function testRegex() { const testTexts = [ "https://www.google.com", "http://example.com/path", "https://github.com/user/repo", "https://www.bilibili.com/video/BV1234567890" ]; log('开始测试URL正则表达式...'); testTexts.forEach(text => { const matches = text.match(urlRegex); log(`测试文本: ${text}, 匹配结果: ${matches ? matches.join(', ') : '无匹配'}`); }); log('URL正则表达式测试完成'); } /** * 检查节点是否已被处理过(避免重复处理) * @param {Node} node - 要检查的节点 * @returns {boolean} 是否已处理过 */ function isProcessed(node) { if (node.nodeType !== Node.ELEMENT_NODE) return false; return node.hasAttribute && node.hasAttribute('data-link-converted'); } /** * 创建带有样式的链接元素 * @param {string} url - URL地址 * @param {string} originalText - 原始文本 * @returns {HTMLAnchorElement} 创建的链接元素 */ function createStyledLink(url, originalText) { try { if (!url || !originalText) { log('createStyledLink: 无效的参数', 'error'); return document.createTextNode(originalText || url || ''); } const link = document.createElement('a'); link.href = url; link.textContent = originalText; link.target = '_blank'; // 在新标签页中打开 link.rel = 'noopener noreferrer'; // 安全性考虑 link.style.textDecoration = 'underline'; // 添加下划线以区分链接 link.style.color = '#0066cc'; // 使用蓝色链接色 // 标记为已转换,避免重复处理 link.setAttribute('data-link-converted', 'true'); log(`创建链接: ${url.substring(0, 50)}...`); return link; } catch (error) { log(`创建链接时出错: ${error.message}`, 'error'); return document.createTextNode(originalText || url || ''); } } /** * 处理文本节点,查找并替换其中的URL链接 * @param {Text} textNode - 要处理的文本节点 */ function processTextNode(textNode) { try { if (!textNode || !textNode.textContent) { return; } const text = textNode.textContent.trim(); // 跳过太短的文本 if (text.length < 8) { return; } // 调试:记录包含http的文本 if (text.toLowerCase().includes('http')) { log(`发现包含http的文本: ${text.substring(0, 100)}...`); } const matches = text.match(urlRegex); if (!matches || matches.length === 0) { return; // 没有找到URL,直接返回 } log(`在文本中找到 ${matches.length} 个URL: ${matches.join(', ')}`); let lastIndex = 0; const fragment = document.createDocumentFragment(); for (const match of matches) { const url = match; const urlIndex = text.indexOf(url, lastIndex); // 添加URL之前的文本 if (urlIndex > lastIndex) { const beforeText = text.substring(lastIndex, urlIndex); fragment.appendChild(document.createTextNode(beforeText)); } const parentElement = textNode.parentElement; // 检查父元素是否已被处理过 if (isProcessed(parentElement)) { fragment.appendChild(document.createTextNode(url)); } else { // 创建带样式的链接 const link = createStyledLink(url, url); fragment.appendChild(link); } lastIndex = urlIndex + url.length; } // 添加剩余的文本 if (lastIndex < text.length) { const remainingText = text.substring(lastIndex); fragment.appendChild(document.createTextNode(remainingText)); } // 替换原文本节点 if (fragment.childNodes.length > 0 && textNode.parentNode) { textNode.parentNode.replaceChild(fragment, textNode); log(`成功转换文本节点中的 ${matches.length} 个链接`); } } catch (error) { log(`处理文本节点时出错: ${error.message}`, 'error'); } } /** * 递归遍历DOM树,处理所有可见的文本节点 * @param {Node} node - 当前处理的节点 */ function traverseDOM(node) { try { if (!node) { return; } // 跳过已处理的链接 if (isProcessed(node)) { return; } // 跳过script和style标签 if (node.nodeType === Node.ELEMENT_NODE && (node.tagName === 'SCRIPT' || node.tagName === 'STYLE' || node.tagName === 'NOSCRIPT')) { return; } // 检查元素是否可见 if (node.nodeType === Node.ELEMENT_NODE && !isVisible(node)) { return; } // 如果是文本节点且有内容,处理其中的URL if (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim()) { processTextNode(node); return; } // 递归处理子节点 if (node.childNodes && node.childNodes.length > 0) { // 使用静态快照,避免在遍历过程中DOM变化导致的问题 const children = Array.from(node.childNodes); for (const child of children) { traverseDOM(child); } } } catch (error) { log(`遍历DOM时出错: ${error.message}`, 'error'); } } /** * 观察DOM变化,动态处理新增内容 */ function observeChanges() { try { if (!document.body) { log('无法创建DOM观察器:document.body不存在', 'error'); return null; } const observer = new MutationObserver(function(mutations) { try { mutations.forEach(function(mutation) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(function(node) { // 只处理元素节点和文本节点 if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) { // 延迟处理,避免在DOM变化过程中处理 setTimeout(() => traverseDOM(node), 100); } }); } }); } catch (error) { log(`处理DOM变化时出错: ${error.message}`, 'error'); } }); // 配置观察器 observer.observe(document.body, { childList: true, subtree: true, attributes: false, characterData: false }); log('DOM变化观察器已启动'); return observer; } catch (error) { log(`创建DOM观察器时出错: ${error.message}`, 'error'); return null; } } /** * 初始化函数 */ function init() { try { log('开始初始化文本链接转换器'); // 等待页面完全加载 if (document.readyState !== 'complete' && document.readyState !== 'interactive') { log(`页面状态: ${document.readyState},等待加载完成`); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { setTimeout(init, 500); }); } else { window.addEventListener('load', function() { setTimeout(init, 500); }); } return; } // 检查document.body是否存在 if (!document.body) { log('document.body不存在,延迟初始化', 'warn'); setTimeout(init, 1000); return; } log('开始处理页面链接'); // 测试正则表达式 testRegex(); // 开始遍历DOM树 traverseDOM(document.body); // 启动DOM变化观察器 const observer = observeChanges(); if (observer) { // 在页面卸载时停止观察 window.addEventListener('beforeunload', function() { try { observer.disconnect(); log('停止DOM变化观察器'); } catch (e) { log(`停止观察器时出错: ${e.message}`, 'error'); } }); } log('文本链接转换器初始化完成'); } catch (error) { log(`初始化过程中出错: ${error.message}`, 'error'); log(`错误堆栈: ${error.stack}`, 'error'); } } // 立即启动脚本,或者延迟启动 try { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { // 页面已加载,直接初始化 setTimeout(init, 100); } } catch (error) { log(`启动脚本时出错: ${error.message}`, 'error'); } // 创建测试按钮(仅在用户配置中启用时) if (window.location.href.includes('link-converter-test=true')) { setTimeout(() => { try { const testBtn = document.createElement('button'); testBtn.textContent = '添加测试链接'; testBtn.style.position = 'fixed'; testBtn.style.top = '10px'; testBtn.style.right = '10px'; testBtn.style.zIndex = '99999'; testBtn.style.background = '#4CAF50'; testBtn.style.color = 'white'; testBtn.style.border = 'none'; testBtn.style.padding = '10px'; testBtn.style.borderRadius = '5px'; testBtn.style.cursor = 'pointer'; testBtn.onclick = function() { const testDiv = document.createElement('div'); testDiv.innerHTML = '这里有一些测试链接:
' + '1. https://www.google.com
' + '2. http://github.com/test
' + '3. https://www.bilibili.com/video/BV1234567890
' + '4. http://example.com/path/to/resource'; testDiv.style.position = 'fixed'; testDiv.style.top = '60px'; testDiv.style.right = '10px'; testDiv.style.background = 'white'; testDiv.style.border = '1px solid #ccc'; testDiv.style.padding = '15px'; testDiv.style.borderRadius = '5px'; testDiv.style.zIndex = '99999'; testDiv.style.maxWidth = '400px'; document.body.appendChild(testDiv); // 处理新添加的内容 setTimeout(() => traverseDOM(testDiv), 100); }; document.body.appendChild(testBtn); log('测试按钮已添加到页面'); } catch (e) { log(`创建测试按钮失败: ${e.message}`, 'error'); } }, 1000); } })();