Greasy Fork

Greasy Fork is available in English.

文本链接自动转换器

自动识别页面中的文本链接并转换为可点击的超链接,支持后台静默打开新标签页(类似Ctrl+点击)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
    }
})();