Abceed Web 翻译助手

自动翻译 abceed 网页中的英文例句。定制:只有在日文解释出现时才翻译,并插入到日文解释上方。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Abceed Web 翻译助手
// @name:en      Abceed Web Translator
// @namespace    http://tampermonkey.net/
// @version      1.0.1
// @description  自动翻译 abceed 网页中的英文例句。定制:只有在日文解释出现时才翻译,并插入到日文解释上方。
// @description:en Automatically translates English sentence examples on abceed.com. Custom logic: Translation appears above the Japanese commentary.
// @author       MAI XIANG & Gemini
// @match        https://app.abceed.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @connect      openrouter.ai
// @noframes
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ================= 1. 选择器定义 (关键修改) =================

    // --- 场景 A: 普通填空题 (旧逻辑保持不变) ---
    const TARGET_REMOTE = '.commentary-area__paragraph'; // 日文解释区
    const SOURCE_REMOTE = '.word-test-header_paragraph'; // 顶部英文

    // --- 场景 B: 原始词汇 (Original Vocab) ---
    // 触发器 & 插入目标:日文解释例句
    const TARGET_VOCAB_COMMENTARY = '.commentary_paragraph.example';
    // 翻译源:顶部英文例句
    const SOURCE_VOCAB_HEADER = '.original-vocab-header_paragraph.example';

    // --- 场景 C: 直接翻译自身 (问题 / 选项) ---
    const TARGET_DIRECT_QUESTION = '.marksheet-answer__question';
    const TARGET_ANSWER_BODY = '.marksheet-answer-body';

    // 汇总监听列表
    const ALL_TARGETS_SELECTOR = `${TARGET_REMOTE}, ${TARGET_VOCAB_COMMENTARY}, ${TARGET_DIRECT_QUESTION}, ${TARGET_ANSWER_BODY}`;

    const PRESET_FONTS = {
        "system": { name: "系统默认 (推荐)", value: '-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' },
        "macos": { name: "MacOS 苹方 (PingFang SC)", value: '"PingFang SC", "Hiragino Sans GB", sans-serif' },
        "windows": { name: "Windows 微软雅黑", value: '"Microsoft YaHei", "SimHei", sans-serif' },
        "serif": { name: "宋体/衬线 (Serif)", value: '"Songti SC", "SimSun", "Times New Roman", serif' }
    };

    const DEFAULT_CONFIG = {
        enabled: true,
        showFloatingBtn: true,
        apiKey: "",
        model: "google/gemini-2.0-flash-001",
        systemPrompt: "你是一个专业的翻译助手。请将用户提供的英语句子翻译成地道的简体中文。要求:1. 仅输出译文。2. 语气自然流畅。3. 不要包含任何原文或拼音。",
        displayMode: "always",
        style: {
            fontSize: "14px",
            color: "#6B7280",
            fontWeight: "400",
            fontFamilyType: "system",
            customFontFamily: ""
        }
    };

    let config = { ...DEFAULT_CONFIG, ...GM_getValue('user_config', {}) };
    if(!config.style.fontFamilyType) config.style.fontFamilyType = "system";

    const translationCache = new Map();
    let observer = null;
    let debounceTimer = null;

    // ================= CSS 样式系统 =================
    function injectGlobalStyles() {
        let targetFont = PRESET_FONTS[config.style.fontFamilyType] ? PRESET_FONTS[config.style.fontFamilyType].value : PRESET_FONTS["system"].value;
        if (config.style.fontFamilyType === 'custom' && config.style.customFontFamily) {
            targetFont = config.style.customFontFamily;
        }

        const css = `
            :root {
                --ab-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
                --trans-font-size: ${config.style.fontSize};
                --trans-color: ${config.style.color};
                --trans-weight: ${config.style.fontWeight};
                --trans-font-family: ${targetFont};
            }
            .abceed-cn-trans {
                display: block;
                margin-top: 4px; margin-bottom: 8px; /* 调整间距,使其在日文上方更自然 */
                padding-top: 4px;
                border-top: 1px dashed rgba(0,0,0,0.08);
                font-size: var(--trans-font-size); color: var(--trans-color); font-weight: var(--trans-weight); font-family: var(--trans-font-family);
                line-height: 1.5; text-align: left; transition: all 0.3s ease; font-variant-east-asian: normal; width: 100%;
            }
            .marksheet-answer-body .abceed-cn-trans { margin-top: 4px; padding-left: 20px; font-size: calc(var(--trans-font-size) * 0.95); }
            .abceed-cn-trans.blur-mode { filter: blur(6px); opacity: 0.5; cursor: help; user-select: none; }
            .abceed-cn-trans.blur-mode:hover { filter: none; opacity: 1; user-select: text; }

            /* 悬浮按钮 */
            #ab-floating-gear {
                position: fixed; bottom: 20px; right: 20px; width: 40px; height: 40px;
                background: rgba(255, 255, 255, 0.9); border: 1px solid #E5E7EB; border-radius: 50%;
                box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; justify-content: center; align-items: center;
                cursor: pointer; z-index: 9999; font-size: 20px; color: #4B5563; opacity: 0.6; transition: transform 0.2s, opacity 0.2s;
            }
            #ab-floating-gear:hover { opacity: 1; transform: scale(1.1); }
            @media (max-width: 480px) { #ab-floating-gear { bottom: 80px; right: 15px; width: 36px; height: 36px; } }

            /* 设置面板 */
            #ab-settings-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); z-index: 2147483647; display: flex; justify-content: center; align-items: center; padding: 20px; box-sizing: border-box; }
            #ab-settings-panel { background: #fff; width: 500px; max-width: 100%; max-height: 90vh; display: flex; flex-direction: column; border-radius: 16px; box-shadow: var(--ab-shadow); font-family: -apple-system, sans-serif; color: #374151; animation: abZoomIn 0.2s; }
            .ab-panel-header { flex: 0 0 auto; padding: 16px 20px; background: #F9FAFB; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; border-top-left-radius: 16px; border-top-right-radius: 16px; }
            .ab-panel-title { margin:0; font-size:16px; font-weight:600; color:#111; }
            .ab-panel-close { cursor: pointer; color: #9CA3AF; padding: 8px; font-size: 18px; line-height: 1; }
            .ab-panel-body { flex: 1 1 auto; padding: 20px; overflow-y: auto; -webkit-overflow-scrolling: touch; min-height: 0; }
            .ab-panel-footer { flex: 0 0 auto; padding: 16px 20px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; background: #fff; border-bottom-left-radius: 16px; border-bottom-right-radius: 16px; padding-bottom: env(safe-area-inset-bottom, 16px); }
            .ab-form-group { margin-bottom: 20px; text-align: left; }
            .ab-label { display: block; font-size: 13px; font-weight: 600; color: #4B5563; margin-bottom: 8px; }
            .ab-input, .ab-select, .ab-textarea { width: 100%; padding: 10px 12px; border: 1px solid #D1D5DB; border-radius: 8px; font-size: 14px; box-sizing: border-box; background: #fff; color: #1F2937; font-family: inherit; -webkit-appearance: none; }
            .ab-textarea { resize: vertical; min-height: 80px; }
            .ab-row { display: flex; gap: 12px; } .ab-col { flex: 1; }
            .ab-btn { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-size: 14px; font-weight: 500; }
            .ab-btn-save { background: #111; color: white; }
            .ab-btn-cancel { background: #F3F4F6; color: #374151; }
            @media (max-width: 480px) {
                #ab-settings-overlay { padding: 0; align-items: flex-end; }
                #ab-settings-panel { width: 100%; max-height: 85vh; border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
                .ab-row { flex-direction: column; gap: 16px; }
                .ab-col { width: 100%; flex: none; }
                #ab-color { height: 44px; }
            }
            @keyframes abZoomIn { from { transform: scale(0.96); opacity: 0; } to { transform: scale(1); opacity: 1; } }
        `;
        const oldStyle = document.getElementById('abceed-pro-style');
        if (oldStyle) oldStyle.remove();
        const styleEl = document.createElement('style');
        styleEl.id = 'abceed-pro-style';
        styleEl.textContent = css;
        document.head.appendChild(styleEl);
    }

    // ================= 悬浮按钮 & 设置面板 =================
    function createFloatingButton() {
        if (!config.showFloatingBtn) return;
        if (document.getElementById('ab-floating-gear')) return;
        const btn = document.createElement('div');
        btn.id = 'ab-floating-gear'; btn.innerHTML = '⚙️'; btn.onclick = showSettingsPanel;
        document.body.appendChild(btn);
    }

    function showSettingsPanel() {
        if (document.getElementById('ab-settings-overlay')) return;
        let fontOptions = '';
        for (const [key, val] of Object.entries(PRESET_FONTS)) fontOptions += `<option value="${key}" ${config.style.fontFamilyType === key ? 'selected' : ''}>${val.name}</option>`;
        fontOptions += `<option value="custom" ${config.style.fontFamilyType === 'custom' ? 'selected' : ''}>自定义字体...</option>`;

        const html = `
            <div id="ab-settings-panel">
                <div class="ab-panel-header">
                    <h3 class="ab-panel-title">翻译插件配置 (v4.6)</h3>
                    <div class="ab-panel-close" id="ab-close">✕</div>
                </div>
                <div class="ab-panel-body">
                    <div class="ab-form-group" style="display:flex; align-items:center; gap:10px; padding: 4px 0;">
                        <input type="checkbox" id="ab-enabled" style="width:20px; height:20px;" ${config.enabled ? 'checked' : ''}>
                        <label for="ab-enabled" style="font-weight:600; font-size:15px; color:#111;">启用自动翻译</label>
                    </div>
                    <div class="ab-form-group" style="display:flex; align-items:center; gap:10px; padding: 4px 0;">
                        <input type="checkbox" id="ab-show-float" style="width:20px; height:20px;" ${config.showFloatingBtn ? 'checked' : ''}>
                        <label for="ab-show-float" style="font-weight:600; font-size:15px; color:#111;">显示悬浮设置按钮</label>
                    </div>
                    <div class="ab-form-group"><label class="ab-label">OpenRouter API Key</label><input type="password" id="ab-key" class="ab-input" value="${config.apiKey}" placeholder="sk-or-..." autocomplete="off"></div>
                    <div class="ab-row">
                        <div class="ab-col"><div class="ab-form-group"><label class="ab-label">模型 (Model ID)</label><input type="text" id="ab-model" class="ab-input" value="${config.model}"></div></div>
                        <div class="ab-col"><div class="ab-form-group"><label class="ab-label">显示模式</label><select id="ab-mode" class="ab-select"><option value="always" ${config.displayMode === 'always' ? 'selected' : ''}>始终显示</option><option value="hover" ${config.displayMode === 'hover' ? 'selected' : ''}>模糊防剧透</option></select></div></div>
                    </div>
                    <div class="ab-form-group"><label class="ab-label">系统提示词 (Prompt)</label><textarea id="ab-prompt" class="ab-textarea">${config.systemPrompt || ''}</textarea></div>
                    <hr style="border:0; border-top:1px solid #E5E7EB; margin:10px 0 20px;">
                    <label class="ab-label" style="color:#111; margin-bottom:12px;">排版样式</label>
                    <div class="ab-form-group"><select id="ab-font-type" class="ab-select">${fontOptions}</select><input id="ab-font-custom" class="ab-input" style="margin-top:8px; display:${config.style.fontFamilyType === 'custom'?'block':'none'}" value="${config.style.customFontFamily}" placeholder="例如: 'PingFang SC'"></div>
                    <div class="ab-row">
                        <div class="ab-col"><label class="ab-label">字号</label><input type="text" id="ab-size" class="ab-input" value="${config.style.fontSize}" placeholder="14px"></div>
                        <div class="ab-col"><label class="ab-label">字重</label><select id="ab-weight" class="ab-select"><option value="300" ${config.style.fontWeight === '300' ? 'selected' : ''}>细</option><option value="400" ${config.style.fontWeight === '400' ? 'selected' : ''}>常规</option><option value="500" ${config.style.fontWeight === '500' ? 'selected' : ''}>中</option><option value="600" ${config.style.fontWeight === '600' ? 'selected' : ''}>粗</option></select></div>
                        <div class="ab-col ab-col-color"><label class="ab-label">颜色</label><input type="color" id="ab-color" style="width:100%; height:42px; padding:2px; border:1px solid #ddd; border-radius:8px; background:#fff;" value="${config.style.color}"></div>
                    </div>
                </div>
                <div class="ab-panel-footer"><button class="ab-btn ab-btn-cancel" id="ab-btn-cancel">取消</button><button class="ab-btn ab-btn-save" id="ab-btn-save">保存</button></div>
            </div>
        `;
        const overlay = document.createElement('div'); overlay.id = 'ab-settings-overlay'; overlay.innerHTML = html; document.body.appendChild(overlay);
        const close = () => overlay.remove();
        document.getElementById('ab-close').onclick = close; document.getElementById('ab-btn-cancel').onclick = close;
        document.getElementById('ab-font-type').onchange = (e) => { document.getElementById('ab-font-custom').style.display = e.target.value === 'custom' ? 'block' : 'none'; };
        document.getElementById('ab-btn-save').onclick = () => {
            config.enabled = document.getElementById('ab-enabled').checked;
            config.showFloatingBtn = document.getElementById('ab-show-float').checked;
            config.apiKey = document.getElementById('ab-key').value.trim();
            config.model = document.getElementById('ab-model').value.trim();
            config.systemPrompt = document.getElementById('ab-prompt').value.trim();
            config.displayMode = document.getElementById('ab-mode').value;
            config.style.fontFamilyType = document.getElementById('ab-font-type').value;
            config.style.customFontFamily = document.getElementById('ab-font-custom').value.trim();
            config.style.fontSize = document.getElementById('ab-size').value.trim();
            config.style.fontWeight = document.getElementById('ab-weight').value;
            config.style.color = document.getElementById('ab-color').value;
            GM_setValue('user_config', config); injectGlobalStyles(); applyDisplayModeClass();
            config.showFloatingBtn ? createFloatingButton() : (document.getElementById('ab-floating-gear') && document.getElementById('ab-floating-gear').remove());
            if(!config.enabled) { document.querySelectorAll('.abceed-cn-trans').forEach(el => el.style.display = 'none'); }
            else { document.querySelectorAll('.abceed-cn-trans').forEach(el => el.style.display = 'block'); scanPage(); }
            close();
        };
    }
    if (typeof GM_registerMenuCommand !== 'undefined') GM_registerMenuCommand("⚙️ 翻译插件设置", showSettingsPanel);

    // ================= 核心业务逻辑 =================

    function getCleanText(node) {
        if (!node) return "";
        const clone = node.cloneNode(true);
        const transDiv = clone.querySelector('.abceed-cn-trans');
        if (transDiv) transDiv.remove();
        return clone.textContent.replace(/\s+/g, ' ').trim();
    }

    function translateText(text, callback) {
        if (!config.apiKey) return;
        if (translationCache.has(text)) {
            callback(translationCache.get(text));
            return;
        }
        GM_xmlhttpRequest({
            method: "POST",
            url: "https://openrouter.ai/api/v1/chat/completions",
            headers: { "Authorization": `Bearer ${config.apiKey}`, "Content-Type": "application/json", "HTTP-Referer": "https://app.abceed.com/", "X-Title": "Abceed Translator" },
            data: JSON.stringify({ "model": config.model, "messages": [ {"role": "system", "content": config.systemPrompt}, {"role": "user", "content": text} ] }),
            onload: function(response) {
                try {
                    const data = JSON.parse(response.responseText);
                    if (data.choices && data.choices.length > 0) {
                        const result = data.choices[0].message.content.trim();
                        translationCache.set(text, result);
                        callback(result);
                    }
                } catch (e) { console.error(e); }
            }
        });
    }

    function applyDisplayModeClass() {
        document.querySelectorAll('.abceed-cn-trans').forEach(el => {
            config.displayMode === 'hover' ? el.classList.add('blur-mode') : el.classList.remove('blur-mode');
        });
    }

    // 通用处理:manageType: 'append' (插入到底部) | 'prepend' (插入到顶部/前面)
    function updateTranslation(targetNode, cleanText, manageType = 'append') {
        if (!cleanText) return;

        // 查找是否已存在翻译框(查找范围:目标节点内部,或者目标节点的前一个兄弟)
        let transDiv;
        if (manageType === 'prepend') {
            // 如果是 prepend 模式,翻译框应该是 targetNode 的前一个兄弟节点
            const prev = targetNode.previousElementSibling;
            if (prev && prev.classList.contains('abceed-cn-trans')) {
                transDiv = prev;
            }
        } else {
            transDiv = targetNode.querySelector('.abceed-cn-trans');
        }

        // 检查更新
        if (transDiv) {
            const lastText = transDiv.getAttribute('data-source-hash');
            if (lastText === cleanText) return;
            transDiv.innerText = 'Updating...';
            transDiv.setAttribute('data-source-hash', cleanText);
        } else {
            // 创建新翻译框
            transDiv = document.createElement('div');
            transDiv.className = 'abceed-cn-trans';
            transDiv.setAttribute('lang', 'zh-CN');
            transDiv.setAttribute('data-source-hash', cleanText);
            if (config.displayMode === 'hover') transDiv.classList.add('blur-mode');
            transDiv.innerText = 'Trans...';

            // 插入逻辑
            if (manageType === 'prepend') {
                // 插入到 targetNode 之前
                targetNode.parentNode.insertBefore(transDiv, targetNode);
            } else {
                // 插入到 targetNode 内部最末尾
                targetNode.appendChild(transDiv);
            }
        }

        translateText(cleanText, (res) => {
            transDiv.innerText = res;
        });
    }

    function processNode(node) {
        if (!config.enabled) return;

        // --- 场景 A: 普通填空题 (Append) ---
        if (node.matches(TARGET_REMOTE)) {
            const sourceNode = document.querySelector(SOURCE_REMOTE);
            if (sourceNode) {
                const cleanText = getCleanText(sourceNode);
                updateTranslation(node, cleanText, 'append');
            }
        }

        // --- 场景 B: Original Vocab 模式 (Prepend) ---
        // 只有当日文解释 (TARGET_VOCAB_COMMENTARY) 出现时,才触发
        else if (node.matches(TARGET_VOCAB_COMMENTARY)) {
            const sourceNode = document.querySelector(SOURCE_VOCAB_HEADER);
            if (sourceNode) {
                const cleanText = getCleanText(sourceNode);
                // 关键:插入到日文解释的上方 (Prepend)
                updateTranslation(node, cleanText, 'prepend');
            }
        }

        // --- 场景 C: 直接翻译 (Append) ---
        else if (node.matches(TARGET_DIRECT_QUESTION)) {
            const cleanText = getCleanText(node);
            updateTranslation(node, cleanText, 'append');
        }
        else if (node.matches(TARGET_ANSWER_BODY)) {
            const bodyNode = node.querySelector('.marksheet-answer-body__body');
            if (bodyNode) {
                const cleanText = getCleanText(bodyNode);
                updateTranslation(node, cleanText, 'append');
            }
        }
    }

    function scanPage() {
        const targets = document.querySelectorAll(ALL_TARGETS_SELECTOR);
        targets.forEach(processNode);
    }

    const observerCallback = (mutationsList) => {
        if (config.showFloatingBtn && !document.getElementById('ab-floating-gear')) createFloatingButton();
        if (!config.enabled) return;

        if (debounceTimer) clearTimeout(debounceTimer);
        debounceTimer = setTimeout(() => {
            scanPage();
        }, 100);
    };

    function init() {
        injectGlobalStyles();
        if (!config.apiKey) showSettingsPanel();
        else if (config.showFloatingBtn) createFloatingButton();

        const body = document.querySelector('body');
        if (body) {
            observer = new MutationObserver(observerCallback);
            observer.observe(body, { childList: true, subtree: true, characterData: true });
            scanPage();
        } else {
            setTimeout(init, 500);
        }
    }

    init();
})();