// ==UserScript== // @name 让链接可点击 (Enhanced) // @namespace https://viayoo.com/ // @version 2.2.1 // @description 自动识别页面中的文本链接并使其可点击,支持高级配置和特殊链接确认。 // @homepageURL https://app.viayoo.com/addons/31 // @author cumt-feng // @run-at document-start // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // ==/UserScript== /* * @name: 让链接可点击 (Enhanced) * @Author: cumt-feng * @version: 2.2.1 * @description: 不用再复制链接打开这么麻烦了,增加黑名单管理和特殊链接确认功能。 * @include: * * @createTime: 2019-11-12 13:47:41 * @updateTime: 2025-06-05 13:00:00 */ (function () { 'use strict'; // --- Configuration Defaults --- const DEFAULT_BLACKLIST = ['example.com', 'localhost']; // 脚本不运行的默认域名黑名单 const DEFAULT_OPEN_IN_NEW_TAB = true; // 默认是否在新标签页打开链接 // --- GM_* 函数的辅助函数,提高可读性 --- function getStoredValue(key, defaultValue) { try { return GM_getValue(key, defaultValue); } catch (e) { console.error(`Tampermonkey Script: Error getting GM_value for ${key}:`, e); return defaultValue; } } function setStoredValue(key, value) { try { GM_setValue(key, value); } catch (e) { console.error(`Tampermonkey Script: Error setting GM_value for ${key}:`, e); } } // --- Script Execution Check --- const SCRIPT_RUNNING_KEY = encodeURIComponent('谷花泰:让链接可点击:执行判断'); // 防止在同一页面上多次执行并检查黑名单 if (window[SCRIPT_RUNNING_KEY]) { console.log('Tampermonkey Script: Already running or marked to prevent re-execution.'); return; } window[SCRIPT_RUNNING_KEY] = true; // 标记为正在运行 class ClickLink { constructor() { // 改进后的正则表达式,更精确地匹配URL,排除中文标点 // 匹配 http/https/www 开头的链接,或者 ed2k/thunder 等特殊协议, // 并在匹配时排除中文标点符号(,。?!、)。 this.url_regexp = /(https?:\/\/(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,6}(?:\/[^\s,。?!、]*)?|[a-zA-Z0-9.\-]+(?:\.[a-zA-Z]{2,6})+(?:\/[^\s,。?!、]*)?|ed2k:\/\/[^\s]+|thunder:\/\/[^\s]+|\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b)/gi; this.urlPrefixes = ['http://', 'https://', 'ftp://', 'thunder://', 'ed2k://', 'mailto:']; this.excludedTags = "a,svg,canvas,applet,input,button,area,pre,embed,frame,frameset,head,iframe,img,option,map,meta,noscript,object,script,style,textarea,code".split(","); this.xPath = "//text()[not(ancestor::" + this.excludedTags.join(') and not(ancestor::') + ")]"; this.blacklist = getStoredValue('blacklist', DEFAULT_BLACKLIST); this.openInNewTab = getStoredValue('openInNewTab', DEFAULT_OPEN_IN_NEW_TAB); this.init(); } init() { console.log('Tampermonkey Script: Initializing ClickLink functionality.'); // 检查当前域名是否在黑名单中 const hostname = window.location.hostname; const isBlacklisted = this.blacklist.some(keyword => hostname.includes(keyword)); if (isBlacklisted) { console.log(`Tampermonkey Script: Current domain '${hostname}' is blacklisted. Script will not execute.`); return; } document.addEventListener("mouseover", this.clearLink.bind(this)); this.setupMenuCommands(); this.startObserve(); // 延迟初始链接化,确保DOM大致准备就绪 setTimeout(this.linkMixInit.bind(this), 100); } // --- Tampermonkey 菜单命令 --- setupMenuCommands() { GM_registerMenuCommand('显示/隐藏黑名单', this.toggleBlacklistDisplay.bind(this)); GM_registerMenuCommand('添加当前域名到黑名单', this.addCurrentDomainToBlacklist.bind(this)); GM_registerMenuCommand('从黑名单移除当前域名', this.removeCurrentDomainFromBlacklist.bind(this)); GM_registerMenuCommand(`切换链接新标签页打开 (${this.openInNewTab ? '是' : '否'})`, this.toggleOpenInNewTab.bind(this)); GM_registerMenuCommand('重新扫描页面', this.rescanPage.bind(this)); } toggleBlacklistDisplay() { const currentBlacklist = getStoredValue('blacklist', DEFAULT_BLACKLIST); const message = `当前黑名单:\n${currentBlacklist.join('\n')}\n\n请手动编辑脚本或使用菜单命令添加/移除。`; this.showCustomModal('黑名单列表', message); } addCurrentDomainToBlacklist() { const currentDomain = window.location.hostname; let currentBlacklist = getStoredValue('blacklist', DEFAULT_BLACKLIST); if (!currentBlacklist.includes(currentDomain)) { currentBlacklist.push(currentDomain); setStoredValue('blacklist', currentBlacklist); this.blacklist = currentBlacklist; // 更新实例 this.showCustomModal('黑名单更新', `已将 '${currentDomain}' 添加到黑名单。\n页面将重新加载。`); setTimeout(() => window.location.reload(), 1500); // 重新加载以应用黑名单 } else { this.showCustomModal('黑名单', `'${currentDomain}' 已在黑名单中。`); } } removeCurrentDomainFromBlacklist() { const currentDomain = window.location.hostname; let currentBlacklist = getStoredValue('blacklist', DEFAULT_BLACKLIST); const initialLength = currentBlacklist.length; currentBlacklist = currentBlacklist.filter(d => d !== currentDomain); if (currentBlacklist.length < initialLength) { setStoredValue('blacklist', currentBlacklist); this.blacklist = currentBlacklist; // 更新实例 this.showCustomModal('黑名单更新', `已将 '${currentDomain}' 从黑名单移除。\n页面将重新加载。`); setTimeout(() => window.location.reload(), 1500); // 重新加载以应用更改 } else { this.showCustomModal('黑名单', `'${currentDomain}' 不在黑名单中。`); } } toggleOpenInNewTab() { this.openInNewTab = !this.openInNewTab; setStoredValue('openInNewTab', this.openInNewTab); this.showCustomModal('设置更新', `链接现在将${this.openInNewTab ? '新标签页' : '当前标签页'}打开。`); // 不需要重新加载,新链接将直接使用新设置 } rescanPage() { console.log('Tampermonkey Script: Rescanning page for links.'); this.linkify(document.body); // 重新对整个页面进行链接化 this.showCustomModal('页面扫描', '页面链接已重新扫描。'); } // --- 自定义模态框(替代 alert/confirm) --- showCustomModal(title, message, isConfirm = false, onConfirm = null) { let modal = document.getElementById('tm-clickable-link-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'tm-clickable-link-modal'; modal.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 99999; font-family: 'Inter', sans-serif; color: #333; max-width: 90%; width: 400px; display: flex; flex-direction: column; gap: 15px; `; const style = document.createElement('style'); style.textContent = ` #tm-clickable-link-modal button { padding: 10px 15px; border: none; border-radius: 5px; cursor: pointer; font-size: 1em; transition: background-color 0.3s ease; font-family: 'Inter', sans-serif; } #tm-clickable-link-modal .primary-button { background-color: #4CAF50; /* Green */ color: white; } #tm-clickable-link-modal .primary-button:hover { background-color: #45a049; } #tm-clickable-link-modal .secondary-button { background-color: #f44336; /* Red */ color: white; } #tm-clickable-link-modal .secondary-button:hover { background-color: #da190b; } #tm-clickable-link-modal h3 { margin-top: 0; color: #007bff; font-size: 1.2em; text-align: center; } #tm-clickable-link-modal p { margin-bottom: 0; line-height: 1.5; white-space: pre-wrap; /* Preserve newlines in message */ } #tm-clickable-link-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 99998; } .tm-button-group { display: flex; justify-content: flex-end; gap: 10px; margin-top: 10px; } `; document.head.appendChild(style); document.body.appendChild(modal); const overlay = document.createElement('div'); overlay.id = 'tm-clickable-link-modal-overlay'; document.body.appendChild(overlay); } modal.innerHTML = `

${title}

${message}

${isConfirm ? `` : ''}
`; const okButton = modal.querySelector('#tm-modal-ok'); okButton.onclick = () => { modal.style.display = 'none'; document.getElementById('tm-clickable-link-modal-overlay').style.display = 'none'; if (isConfirm && onConfirm) { onConfirm(true); } }; if (isConfirm) { const cancelButton = modal.querySelector('#tm-modal-cancel'); cancelButton.onclick = () => { modal.style.display = 'none'; document.getElementById('tm-clickable-link-modal-overlay').style.display = 'none'; if (onConfirm) { onConfirm(false); } }; } modal.style.display = 'flex'; document.getElementById('tm-clickable-link-modal-overlay').style.display = 'block'; } // --- 链接处理逻辑 --- clearLink(event) { // 此函数检查已被脚本修改的链接,并确保它们有正确的协议前缀 const link = event.originalTarget || event.target; // 仅处理带有我们标记类 'textToLink' 的 'a' 标签 if (!(link && link.localName === "a" && link.classList.contains("textToLink"))) { return; } let url = link.getAttribute("href"); // 如果 URL 已有已知协议前缀,则不作处理 for (let i = 0; i < this.urlPrefixes.length; i++) { if (url.startsWith(this.urlPrefixes[i])) { return; } } // 如果是邮件地址但缺少前缀 if (url.includes('@') && !url.startsWith('mailto:')) { link.setAttribute("href", "mailto:" + url); } else if (!url.startsWith('http://') && !url.startsWith('https://')) { // 否则,默认为 http:// link.setAttribute("href", "http://" + url); } } setLink(candidate) { if (!candidate || candidate.parentNode?.classList.contains("textToLink") || candidate.nodeName === "#cdata-section") { console.log('Tampermonkey Script: Skipping candidate (already processed or cdata):', candidate); return; } const originalText = candidate.textContent; console.log('Tampermonkey Script: Processing text node:', originalText); // 用锚点标签替换检测到的 URL const processedText = originalText.replace(this.url_regexp, (match, p1) => { let url = p1; let targetAttribute = this.openInNewTab ? ' target="_blank"' : ''; // 处理可能需要确认的特殊协议 if (url.startsWith('ed2k://') || url.startsWith('thunder://')) { // 对于这些特殊链接,我们不直接设置 href。 // 而是使用 data-attribute 并在点击时手动处理。 return `${match}`; } // 确保 URL 有正确的协议前缀 if (!url.match(/^[a-zA-Z]+:\/\//)) { // 不以协议开头 (如 http://, ftp://) if (url.startsWith('www.')) { url = 'http://' + url; } else if (url.includes('@')) { // 可能是邮箱 url = 'mailto:' + url; } else { // 否则假定为 http url = 'http://' + url; } } return `${match}`; }); // 如果没有找到链接,则不作处理 if (originalText.length === processedText.length) { console.log('Tampermonkey Script: No links found in text node:', originalText); return; } const span = document.createElement("span"); span.innerHTML = processedText; // 为特殊链接添加点击事件监听器 span.querySelectorAll('.special-link').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); // 阻止默认导航 const specialUrl = link.getAttribute('data-special-url'); this.showCustomModal( '确认打开特殊链接', `您正尝试打开一个特殊链接:\n${specialUrl}\n\n这可能会启动外部应用程序。您确定要继续吗?`, true, (confirm) => { if (confirm) { window.open(specialUrl, this.openInNewTab ? '_blank' : '_self'); } else { console.log('Tampermonkey Script: User cancelled opening special link.'); } } ); }); }); try { candidate.parentNode.replaceChild(span, candidate); } catch (e) { console.warn('Tampermonkey Script: Could not replace text node, possibly due to concurrent DOM modification:', e); } } linkPack(result, start) { const startTime = Date.now(); let i = start; console.log(`Tampermonkey Script: Starting link processing for ${result.snapshotLength} nodes.`); // 分块处理以避免阻塞UI const processChunk = () => { let chunkEnd = Math.min(i + 10000, result.snapshotLength); for (; i < chunkEnd; i++) { this.setLink(result.snapshotItem(i)); } if (i < result.snapshotLength && (Date.now() - startTime < 2500)) { // 如果时间允许,继续处理下一块 requestAnimationFrame(processChunk); } else if (i < result.snapshotLength) { // 如果达到时间限制,则让出给浏览器,然后继续 console.log(`Tampermonkey Script: Yielding after processing ${i - start} nodes. Remaining: ${result.snapshotLength - i}`); setTimeout(processChunk, 50); // 短暂延迟,让浏览器喘息 } else { console.log('Tampermonkey Script: Finished initial link processing.'); } }; requestAnimationFrame(processChunk); } linkify(node) { if (!node) return; const result = document.evaluate(this.xPath, node, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null); console.log(`Tampermonkey Script: Found ${result.snapshotLength} text nodes for linkification in provided node.`); this.linkPack(result, 0); } linkFilter(node) { // 根据排除的标签过滤节点 for (let i = 0; i < this.excludedTags.length; i++) { if (this.excludedTags[i] === node.parentNode?.localName?.toLowerCase()) { return NodeFilter.FILTER_REJECT; } } return NodeFilter.FILTER_ACCEPT; } observePage(root) { if (!root || root.nodeType !== Node.ELEMENT_NODE) { // 只观察元素节点 return; } const tW = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: this.linkFilter.bind(this) // 为过滤器绑定 'this' }, false); let nodesToProcess = []; while (tW.nextNode()) { nodesToProcess.push(tW.currentNode); } if (nodesToProcess.length > 0) { console.log(`Tampermonkey Script: Found ${nodesToProcess.length} new text nodes to process in added element.`); // 批量处理节点 nodesToProcess.forEach(node => this.setLink(node)); } } startObserve() { this.observer = new window.MutationObserver(mutations => { for (const mutation of mutations) { if (mutation.type === "childList" && mutation.addedNodes.length > 0) { for (const Node of mutation.addedNodes) { // 只观察元素节点(避免文本、注释节点作为根节点) if (Node.nodeType === Node.ELEMENT_NODE) { this.observePage(Node); } } } } }); } linkMixInit() { // 检查我们是否在主窗口且页面有标题 if (window !== window.top || document.title === "") { console.log('Tampermonkey Script: Not running in main window or title not set. Skipping initial linkify.'); return; } if (document.body) { this.linkify(document.body); // 开始观察 body 的DOM变化 this.observer.observe(document.body, { childList: true, subtree: true }); console.log('Tampermonkey Script: MutationObserver started on document.body.'); } else { console.error('Tampermonkey Script: document.body not found during initial linkMixInit.'); } } } // --- 脚本入口点 --- try { new ClickLink(); } catch (err) { console.error('Tampermonkey Script: Failed to load ClickLink functionality:', err); } })();