// ==UserScript== // @name 让链接可点击 (Enhanced) // @namespace https://viayoo.com/ // @version 2.2 // @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 * @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']; // Default domains where the script will not run const DEFAULT_OPEN_IN_NEW_TAB = true; // Default behavior for opening links // --- Utility Functions for GM_ Functions (for better readability) --- 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('谷花泰:让链接可点击:执行判断'); // Prevent multiple executions on the same page and check blacklist if (window[SCRIPT_RUNNING_KEY]) { console.log('Tampermonkey Script: Already running or marked to prevent re-execution.'); return; } window[SCRIPT_RUNNING_KEY] = true; // Mark as running class ClickLink { constructor() { // Updated regex for better URL matching, including common domain patterns and special protocols this.url_regexp = /((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|[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:']; // mailto changed from mailto:// to 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.'); // Check blacklist 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(); // Delay initial linkify to ensure DOM is somewhat ready setTimeout(this.linkMixInit.bind(this), 100); } // --- Tampermonkey Menu Commands --- 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; // Update instance this.showCustomModal('黑名单更新', `已将 '${currentDomain}' 添加到黑名单。\n页面将重新加载。`); setTimeout(() => window.location.reload(), 1500); // Reload to apply blacklist } 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; // Update instance this.showCustomModal('黑名单更新', `已将 '${currentDomain}' 从黑名单移除。\n页面将重新加载。`); setTimeout(() => window.location.reload(), 1500); // Reload to apply changes } else { this.showCustomModal('黑名单', `'${currentDomain}' 不在黑名单中。`); } } toggleOpenInNewTab() { this.openInNewTab = !this.openInNewTab; setStoredValue('openInNewTab', this.openInNewTab); this.showCustomModal('设置更新', `链接现在将${this.openInNewTab ? '新标签页' : '当前标签页'}打开。`); // No reload needed, `setLink` will pick up the change for new links } rescanPage() { console.log('Tampermonkey Script: Rescanning page for links.'); this.linkify(document.body); // Re-run linkify on the entire body this.showCustomModal('页面扫描', '页面链接已重新扫描。'); } // --- Custom Modal Implementation (replaces 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'; } // --- Link Processing Logic --- clearLink(event) { // This function checks links that were already modified by the script // and ensures they have the correct prefix (http/https/mailto) const link = event.originalTarget || event.target; // Only process if it's an 'a' tag and has our marker class if (!(link && link.localName === "a" && link.classList.contains("textToLink"))) { return; } let url = link.getAttribute("href"); // If the URL already has a known prefix, do nothing for (let i = 0; i < this.urlPrefixes.length; i++) { if (url.startsWith(this.urlPrefixes[i])) { return; } } // Fallback for mailto if it's just an email address without the prefix if (url.includes('@') && !url.startsWith('mailto:')) { link.setAttribute("href", "mailto:" + url); } else if (!url.startsWith('http://') && !url.startsWith('https://')) { // Otherwise, default to 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); // Replace detected URLs with anchor tags const processedText = originalText.replace(this.url_regexp, (match, p1) => { let url = p1; let targetAttribute = this.openInNewTab ? ' target="_blank"' : ''; // Handle special protocols that might need confirmation if (url.startsWith('ed2k://') || url.startsWith('thunder://')) { // For these special links, we don't directly set href. // Instead, we use a data-attribute and handle click manually. return `${match}`; } // Ensure the URL has a proper protocol prefix if missing if (!url.match(/^[a-zA-Z]+:\/\//)) { // Does not start with a protocol (e.g., http://, ftp://) if (url.startsWith('www.')) { url = 'http://' + url; } else if (url.includes('@')) { // Likely an email url = 'mailto:' + url; } else { // Assume http for others url = 'http://' + url; } } return `${match}`; }); // If no links were found, do nothing 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; // Attach click listener for special links span.querySelectorAll('.special-link').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); // Prevent default navigation 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.`); // Process in chunks to avoid blocking 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)) { // Continue processing next chunk if time allows requestAnimationFrame(processChunk); } else if (i < result.snapshotLength) { // Yield to browser if time limit reached, then continue console.log(`Tampermonkey Script: Yielding after processing ${i - start} nodes. Remaining: ${result.snapshotLength - i}`); setTimeout(processChunk, 50); // Small delay to let browser breathe } 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) { // Filter nodes based on excluded tags 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) { // Only observe element nodes return; } const tW = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: this.linkFilter.bind(this) // Bind 'this' for filter }, 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.`); // Process nodes in a batch 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) { // Only observe element nodes (avoid text, comment nodes as root) if (Node.nodeType === Node.ELEMENT_NODE) { this.observePage(Node); } } } } }); } linkMixInit() { // Check if we are in the main window and page has a title 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); // Start observing the body for future DOM changes 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.'); } } } // --- Script Entry Point --- try { new ClickLink(); } catch (err) { console.error('Tampermonkey Script: Failed to load ClickLink functionality:', err); } })();