Greasy Fork

Universal DeepSeek Text Selection

通用型选中文本翻译/解释工具,支持复杂动态网页

// ==UserScript==
// @name         Universal DeepSeek Text Selection
// @namespace    http://tampermonkey.net/
// @version      3.7
// @description  通用型选中文本翻译/解释工具,支持复杂动态网页
// @author       You
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @connect      api.deepseek.com
// @connect      api.deepseek.ai
// @connect      *
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = {
        API_KEY: '',
        API_URL: 'https://api.deepseek.com/v1/chat/completions',
        MAX_RETRIES: 3,
        RETRY_DELAY: 1000,
        RETRY_BACKOFF_FACTOR: 1.5,
        DEBOUNCE_DELAY: 200,
        SHORTCUTS: {
            translate: 'Alt+T',
            explain: 'Alt+E',
            summarize: 'Alt+S'
        },
        MAX_TEXT_LENGTH: 5000,
        MIN_TEXT_LENGTH: 1,
        ERROR_DISPLAY_TIME: 3000,
        ANIMATION_DURATION: 200,
        MENU_FADE_DELAY: 150,
        CACHE_DURATION: 3600000, // 1小时
        MAX_CACHE_ITEMS: 50,
        LOADING_MESSAGES: [
            '正在思考中...',
            '处理中,请稍候...',
            '马上就好...',
            '正在分析文本...'
        ],
        LOADING_INTERVAL: 2000,
        MAX_RESULT_HEIGHT: 400,
        SCROLLBAR_WIDTH: 15,
    };

    // 样式注入
    GM_addStyle(`
        #ai-floating-menu {
            all: initial;
            position: fixed;
            z-index: 2147483647;
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.2);
            padding: 5px;
            opacity: 1;
            visibility: visible;
            transition: opacity ${CONFIG.ANIMATION_DURATION}ms ease,
                        visibility ${CONFIG.ANIMATION_DURATION}ms ease;
            font-family: system-ui, -apple-system, sans-serif;
            animation: fadeIn 0.3s ease;
        }

        @keyframes fadeIn {
            0% { opacity: 0; transform: scale(0.9); }
            100% { opacity: 1; transform: scale(1); }
        }

        #ai-floating-menu.hiding {
            opacity: 0;
            visibility: hidden;
        }

        #ai-floating-menu button {
            all: initial;
            display: block;
            width: 120px;
            margin: 3px;
            padding: 8px 12px;
            background: #2c3e50;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-family: inherit;
            font-size: 14px;
            text-align: center;
            transition: all 0.2s;
            position: relative;
            overflow: hidden;
        }

        #ai-floating-menu button:hover {
            background: #34495e;
            transform: translateY(-1px);
        }

        #ai-floating-menu button:active {
            transform: translateY(1px);
        }

        #ai-floating-menu button.processing {
            pointer-events: none;
            opacity: 0.7;
        }

        #ai-floating-menu button.processing::after {
            content: '';
            position: absolute;
            bottom: 0;
            left: 0;
            height: 2px;
            width: 100%;
            background: linear-gradient(to right, #3498db, #2ecc71);
            animation: loading-bar 2s infinite linear;
        }

        #ai-floating-menu .shortcut {
            float: right;
            font-size: 12px;
            opacity: 0.7;
        }

        #ai-result-box {
            all: initial;
            position: fixed;
            z-index: 2147483648;
            background: white;
            border-radius: 8px;
            box-shadow: 0 3px 15px rgba(0,0,0,0.2);
            padding: 15px;
            min-width: 200px;
            max-width: 500px;
            max-height: ${CONFIG.MAX_RESULT_HEIGHT}px;
            opacity: 1;
            visibility: visible;
            transition: opacity ${CONFIG.ANIMATION_DURATION}ms ease,
                        visibility ${CONFIG.ANIMATION_DURATION}ms ease,
                        transform 0.2s ease;
            font-family: system-ui, -apple-system, sans-serif;
            font-size: 14px;
            line-height: 1.6;
            color: #333;
            overflow: auto;
            transform: translateY(0);
            animation: fadeIn 0.3s ease;
            cursor: grab;
            user-select: none;
        }

        #ai-result-box .content {
            cursor: default;
            user-select: text;
        }

        #ai-result-box.hiding {
            opacity: 0;
            visibility: hidden;
            transform: translateY(10px);
        }

        #ai-result-box .close-btn {
            all: initial;
            position: absolute;
            top: 8px;
            right: 8px;
            width: 20px;
            height: 20px;
            line-height: 20px;
            text-align: center;
            background: #f0f0f0;
            border: none;
            border-radius: 50%;
            cursor: pointer;
            font-family: inherit;
            font-size: 14px;
            color: #666;
            transition: all 0.2s;
        }

        #ai-result-box .close-btn:hover {
            background: #e0e0e0;
            transform: rotate(90deg);
        }

        #ai-result-box .content {
            margin-top: 5px;
            white-space: pre-wrap;
            word-break: break-word;
            line-height: 1.6;
            font-size: 14px;
            color: #2c3e50;
        }

        #ai-result-box .error {
            color: #e74c3c;
            background: #fde8e7;
            padding: 10px;
            border-radius: 4px;
            margin-bottom: 10px;
            animation: shake 0.5s ease-in-out;
        }

        #ai-result-box .loading-container {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            padding: 20px;
            text-align: center;
        }

        .loading-spinner {
            display: inline-block;
            width: 30px;
            height: 30px;
            border: 3px solid #f3f3f3;
            border-top: 3px solid #3498db;
            border-radius: 50%;
            animation: spin 1s linear infinite;
            margin-bottom: 10px;
        }

        .loading-text {
            color: #666;
            font-size: 14px;
            margin-top: 10px;
            min-height: 20px;
            transition: opacity 0.3s;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        @keyframes loading-bar {
            0% { transform: translateX(-100%); }
            100% { transform: translateX(100%); }
        }

        @keyframes shake {
            0%, 100% { transform: translateX(0); }
            25% { transform: translateX(-5px); }
            75% { transform: translateX(5px); }
        }

        @media (prefers-color-scheme: dark) {
            #ai-floating-menu,
            #ai-result-box {
                background: #2c3e50;
                color: #ecf0f1;
            }

            #ai-result-box .content {
                color: #ecf0f1;
            }

            #ai-result-box .error {
                background: #4a1c17;
            }

            .loading-text {
                color: #ecf0f1;
            }
        }
    `);
    // 工具函数
    const utils = {
        debounce(func, wait) {
            let timeout;
            return function (...args) {
                clearTimeout(timeout);
                timeout = setTimeout(() => func.apply(this, args), wait);
            };
        },

        async retry(fn, retries = CONFIG.MAX_RETRIES, delay = CONFIG.RETRY_DELAY) {
            try {
                return await fn();
            } catch (error) {
                if (retries === 0) throw error;
                await new Promise(resolve => setTimeout(resolve, delay));
                return this.retry(fn, retries - 1, delay * CONFIG.RETRY_BACKOFF_FACTOR);
            }
        },

        createLoadingSpinner() {
            return `
                <div class="loading-container">
                    <div class="loading-spinner"></div>
                    <div class="loading-text">${CONFIG.LOADING_MESSAGES[0]}</div>
                </div>
            `;
        },

        isValidText(text) {
            return text &&
                text.length >= CONFIG.MIN_TEXT_LENGTH &&
                text.length <= CONFIG.MAX_TEXT_LENGTH;
        },

        rotateLoadingMessage() {
            const loadingText = document.querySelector('.loading-text');
            if (!loadingText) return;

            let currentIndex = 0;
            return setInterval(() => {
                currentIndex = (currentIndex + 1) % CONFIG.LOADING_MESSAGES.length;
                loadingText.style.opacity = '0';
                setTimeout(() => {
                    loadingText.textContent = CONFIG.LOADING_MESSAGES[currentIndex];
                    loadingText.style.opacity = '1';
                }, 300);
            }, CONFIG.LOADING_INTERVAL);
        }
    };

    // 缓存管理类
    class CacheManager {
        static getKey(text, action) {
            return `${action}_${text}`;
        }

        static async get(text, action) {
            const key = this.getKey(text, action);
            const cached = GM_getValue(key);
            if (cached && Date.now() - cached.timestamp < CONFIG.CACHE_DURATION) {
                return cached.data;
            }
            return null;
        }

        static async set(text, action, data) {
            const key = this.getKey(text, action);
            const cache = {
                data,
                timestamp: Date.now()
            };

            const keys = Object.keys(GM_getValue('cache_keys', {}));
            if (keys.length >= CONFIG.MAX_CACHE_ITEMS) {
                const oldestKey = keys[0];
                GM_deleteValue(oldestKey);
                keys.shift();
            }

            keys.push(key);
            GM_setValue('cache_keys', keys);
            GM_setValue(key, cache);
        }
    }

    // API调用类
    class APIClient {
        static async call(text, action) {
            const cached = await CacheManager.get(text, action);
            if (cached) return cached;

            if (!utils.isValidText(text)) {
                throw new Error(`文本长度应在${CONFIG.MIN_TEXT_LENGTH}至${CONFIG.MAX_TEXT_LENGTH}字符之间`);
            }

            const prompts = {
                translate: '将以下内容翻译成中文,保持专业性和准确性:',
                explain: '请详细解释以下内容,如果包含专业术语请着重说明:',
                summarize: '请提炼以下内容的关键要点,以简洁的要点形式列出:'
            };

            let retryCount = 0;
            const maxRetries = CONFIG.MAX_RETRIES;

            while (retryCount < maxRetries) {
                try {
                    const response = await this.makeRequest(text, prompts[action]);
                    const result = this.processResponse(response);
                    await CacheManager.set(text, action, result);
                    return result;
                } catch (error) {
                    retryCount++;
                    if (retryCount === maxRetries) throw error;

                    await new Promise(resolve =>
                        setTimeout(resolve, CONFIG.RETRY_DELAY * Math.pow(CONFIG.RETRY_BACKOFF_FACTOR, retryCount))
                    );
                }
            }
        }

        static async makeRequest(text, prompt) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'POST',
                    url: CONFIG.API_URL,
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `Bearer ${CONFIG.API_KEY}`
                    },
                    data: JSON.stringify({
                        model: 'deepseek-chat',
                        messages: [{
                            role: 'user',
                            content: `${prompt}\n\n${text}`
                        }],
                        temperature: 0.7,
                        max_tokens: 2000,
                        presence_penalty: 0.6,
                        frequency_penalty: 0.5
                    }),
                    timeout: 30000,
                    onload: resolve,
                    onerror: reject,
                    ontimeout: () => reject(new Error('请求超时'))
                });
            });
        }

        static processResponse(res) {
            if (res.status !== 200) {
                throw new Error(`API错误: ${res.status}`);
            }

            try {
                const data = JSON.parse(res.responseText);
                if (!data.choices?.[0]?.message?.content) {
                    throw new Error('API返回格式错误');
                }
                return data.choices[0].message.content;
            } catch (e) {
                throw new Error('解析响应失败');
            }
        }

        static getErrorMessage(error) {
            const errorMessages = {
                'Network Error': '网络连接失败',
                'Timeout': '请求超时',
                'API错误: 429': '请求过于频繁,请稍后再试',
                'API错误: 401': 'API密钥无效',
                'API错误: 403': '没有访问权限'
            };
            return errorMessages[error.message] || error.message;
        }
    }
    // UI管理类
    class UIManager {
        static ensureElementsExist() {
            if (!document.getElementById('ai-floating-menu')) {
                const menu = document.createElement('div');
                menu.id = 'ai-floating-menu';
                menu.style.display = 'none';
                menu.innerHTML = `
                    <button data-action="translate">翻译为中文 <span class="shortcut">Alt+T</span></button>
                    <button data-action="explain">解释内容 <span class="shortcut">Alt+E</span></button>
                    <button data-action="summarize">总结要点 <span class="shortcut">Alt+S</span></button>
                `;
                document.body.appendChild(menu);
            }

            if (!document.getElementById('ai-result-box')) {
                const resultBox = document.createElement('div');
                resultBox.id = 'ai-result-box';
                resultBox.style.display = 'none';
                resultBox.innerHTML = `
                    <button class="close-btn">×</button>
                    <div class="content"></div>
                `;
                document.body.appendChild(resultBox);
            }
        }

        static async showMenu(x, y) {
            this.ensureElementsExist();
            await this.hideAll();

            const menu = document.getElementById('ai-floating-menu');
            const { left, top } = this.calculateOptimalPosition(x, y, menu);

            menu.style.left = `${left}px`;
            menu.style.top = `${top}px`;
            menu.style.display = 'block';
            menu.offsetHeight; // 触发重排
            menu.classList.remove('hiding');
        }

        static async showResult(content, x, y) {
            this.ensureElementsExist();
            await this.hideMenu();

            const resultBox = document.getElementById('ai-result-box');
            const contentDiv = resultBox.querySelector('.content');

            if (content.startsWith('错误:')) {
                contentDiv.classList.add('error');
                setTimeout(() => {
                    this.hideAll();
                    contentDiv.classList.remove('error');
                }, CONFIG.ERROR_DISPLAY_TIME);
            } else {
                contentDiv.classList.remove('error');
            }

            contentDiv.innerHTML = content;

            const { left, top } = this.calculateResultPosition(x, y, resultBox);
            resultBox.style.left = `${left}px`;
            resultBox.style.top = `${top}px`;
            resultBox.style.display = 'block';
            resultBox.offsetHeight; // 触发重排
            resultBox.classList.remove('hiding');

            return content.includes('loading-container') ? utils.rotateLoadingMessage() : null;
        }

        static calculateOptimalPosition(x, y, element) {
            const margin = 10;
            const maxWidth = Math.min(500, window.innerWidth - 2 * margin);
            element.style.maxWidth = `${maxWidth}px`;

            let left = Math.max(margin, Math.min(x, window.innerWidth - element.offsetWidth - margin));
            let top = Math.max(margin, Math.min(y, window.innerHeight - element.offsetHeight - margin));

            return { left, top };
        }

        static calculateResultPosition(x, y, element) {
            const margin = 20;
            const maxWidth = Math.min(500, window.innerWidth - 2 * margin);
            element.style.maxWidth = `${maxWidth}px`;

            const selection = window.getSelection();
            let selectionRect = null;
            if (selection.rangeCount > 0) {
                selectionRect = selection.getRangeAt(0).getBoundingClientRect();
            }

            let left, top;

            if (selectionRect) {
                // 优先显示在选区下方
                left = selectionRect.left;
                top = selectionRect.bottom + margin;

                // 如果底部空间不足,则显示在选区上方
                if (top + element.offsetHeight > window.innerHeight - margin) {
                    top = Math.max(margin, selectionRect.top - element.offsetHeight - margin);
                }

                // 如果水平方向超出屏幕,进行调整
                if (left + maxWidth > window.innerWidth - margin) {
                    left = Math.max(margin, window.innerWidth - maxWidth - margin);
                }
            } else {
                // 如果没有选区,则根据鼠标位置
                left = Math.max(margin, Math.min(x, window.innerWidth - maxWidth - margin));
                top = Math.max(margin, Math.min(y, window.innerHeight - element.offsetHeight - margin));
            }

            return { left, top };
        }

        static async hideMenu() {
            const menu = document.getElementById('ai-floating-menu');
            if (menu && menu.style.display !== 'none') {
                menu.classList.add('hiding');
                await new Promise(resolve => setTimeout(resolve, CONFIG.ANIMATION_DURATION));
                menu.style.display = 'none';
            }
        }

        static async hideAll() {
            const menu = document.getElementById('ai-floating-menu');
            const resultBox = document.getElementById('ai-result-box');

            const promises = [];

            if (menu && menu.style.display !== 'none') {
                menu.classList.add('hiding');
                promises.push(new Promise(resolve => setTimeout(resolve, CONFIG.ANIMATION_DURATION)));
            }

            if (resultBox && resultBox.style.display !== 'none') {
                resultBox.classList.add('hiding');
                promises.push(new Promise(resolve => setTimeout(resolve, CONFIG.ANIMATION_DURATION)));
            }

            await Promise.all(promises);

            if (menu) menu.style.display = 'none';
            if (resultBox) resultBox.style.display = 'none';
        }
    }

    // 文本选择管理类
    class SelectionManager {
        static getSelectedText() {
            let text = '';
            let range = null;

            const selection = window.getSelection();
            text = selection.toString().trim();
            if (text && selection.rangeCount > 0) {
                range = selection.getRangeAt(0);
                return { text, range };
            }

            try {
                const iframes = document.getElementsByTagName('iframe');
                for (const iframe of iframes) {
                    try {
                        const iframeSelection = iframe.contentWindow.getSelection();
                        const iframeText = iframeSelection.toString().trim();
                        if (iframeText) {
                            return {
                                text: iframeText,
                                range: iframeSelection.rangeCount > 0 ? iframeSelection.getRangeAt(0) : null
                            };
                        }
                    } catch (e) {
                        console.debug('无法访问iframe内容:', e);
                    }
                }
            } catch (e) {
                console.debug('处理iframe时出错:', e);
            }

            const activeElement = document.activeElement;
            if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
                const start = activeElement.selectionStart;
                const end = activeElement.selectionEnd;
                if (start !== end) {
                    text = activeElement.value.substring(start, end).trim();
                    return { text, range: null };
                }
            }

            return { text: '', range: null };
        }
    }

    // 事件处理类
    class EventHandler {
        static init() {
            UIManager.ensureElementsExist();
            this.setupEventListeners();
            this.setupIntersectionObserver();
            this.setupResizeObserver();
            this.setupDraggable();
        }

        static setupEventListeners() {
            const menu = document.getElementById('ai-floating-menu');
            const resultBox = document.getElementById('ai-result-box');

            // 使用事件委托处理按钮点击
            document.addEventListener('click', async (e) => {
                const button = e.target.closest('#ai-floating-menu button');
                if (!button) return;

                const action = button.dataset.action;
                const { text } = SelectionManager.getSelectedText();
                if (!text) return;

                button.classList.add('processing');
                await this.handleAction(action, text, e.clientX, e.clientY);
                button.classList.remove('processing');
            });

            // 关闭按钮
            resultBox.querySelector('.close-btn').addEventListener('click', () => {
                UIManager.hideAll();
            });

            // 点击外部隐藏菜单和结果框
            document.addEventListener('mousedown', (e) => {
                if (!menu.contains(e.target) && !resultBox.contains(e.target)) {
                    UIManager.hideAll();
                }
            }, true);

            // 快捷键支持
            document.addEventListener('keydown', (e) => {
                for (const [action, shortcut] of Object.entries(CONFIG.SHORTCUTS)) {
                    const [modifier, key] = shortcut.split('+');
                    if (e[`${modifier.toLowerCase()}Key`] && e.key.toUpperCase() === key) {
                        e.preventDefault();
                        const { text } = SelectionManager.getSelectedText();
                        if (text) {
                            this.handleAction(action, text, e.clientX, e.clientY);
                        }
                    }
                }
            });

            // 文本选择监听
            this.addSelectionListeners();

            // 触摸屏支持
            document.addEventListener('touchend', (e) => {
                const { text } = SelectionManager.getSelectedText();
                if (text) {
                    const touch = e.changedTouches[0];
                    UIManager.showMenu(touch.clientX, touch.clientY);
                }
            });
        }

        static setupDraggable() {
            const resultBox = document.getElementById('ai-result-box');
            let isDragging = false;
            let currentX;
            let currentY;
            let initialX;
            let initialY;

            resultBox.addEventListener('mousedown', (e) => {
                if (e.target.classList.contains('close-btn') ||
                    e.target.closest('.content')) return;

                isDragging = true;
                initialX = e.clientX - resultBox.offsetLeft;
                initialY = e.clientY - resultBox.offsetTop;

                resultBox.style.cursor = 'grabbing';
            });

            document.addEventListener('mousemove', (e) => {
                if (!isDragging) return;

                e.preventDefault();
                currentX = e.clientX - initialX;
                currentY = e.clientY - initialY;

                // 限制在可视区域内
                currentX = Math.max(0, Math.min(currentX, window.innerWidth - resultBox.offsetWidth));
                currentY = Math.max(0, Math.min(currentY, window.innerHeight - resultBox.offsetHeight));

                resultBox.style.left = `${currentX}px`;
                resultBox.style.top = `${currentY}px`;
            });

            document.addEventListener('mouseup', () => {
                isDragging = false;
                resultBox.style.cursor = 'grab';
            });
        }

        static addSelectionListeners(target = document) {
            const handleSelection = utils.debounce(async (e) => {
                const { text, range } = SelectionManager.getSelectedText();
                if (!text) {
                    await UIManager.hideAll();
                    return;
                }

                let x = e?.clientX || 0;
                let y = e?.clientY || 0;

                if (range) {
                    try {
                        const rect = range.getBoundingClientRect();
                        if (rect.width > 0 && rect.height > 0) {
                            x = rect.right;
                            y = rect.bottom + 5;
                        }
                    } catch (e) {
                        console.debug('获取选区位置失败:', e);
                    }
                }

                await UIManager.showMenu(x, y);
            }, CONFIG.DEBOUNCE_DELAY);

            target.addEventListener('mouseup', handleSelection);
            target.addEventListener('keyup', handleSelection);
            target.addEventListener('selectionchange', handleSelection);
        }

        static setupIntersectionObserver() {
            const observer = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if (!entry.isIntersecting) {
                        UIManager.hideAll();
                    }
                });
            });

            const menu = document.getElementById('ai-floating-menu');
            const resultBox = document.getElementById('ai-result-box');
            observer.observe(menu);
            observer.observe(resultBox);
        }

        static setupResizeObserver() {
            const observer = new ResizeObserver(utils.debounce(() => {
                const menu = document.getElementById('ai-floating-menu');
                const resultBox = document.getElementById('ai-result-box');
                if (menu.style.display === 'block' || resultBox.style.display === 'block') {
                    UIManager.hideAll();
                }
            }, 100));

            observer.observe(document.body);
        }

        static async handleAction(action, text, x, y) {
            let loadingMessageInterval;
            try {
                await UIManager.hideAll();
                loadingMessageInterval = await UIManager.showResult(utils.createLoadingSpinner(), x, y);

                const response = await APIClient.call(text, action);
                clearInterval(loadingMessageInterval);
                UIManager.showResult(response, x, y);
            } catch (error) {
                if (loadingMessageInterval) clearInterval(loadingMessageInterval);
                UIManager.showResult(`错误: ${error.message}`, x, y);
            }
        }
    }

    // 初始化
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => EventHandler.init());
    } else {
        EventHandler.init();
    }
})();