Skip to content

codewars-e4left|520

js
// ==UserScript==
// @name         Codewars 题目汉化工具(Reload on Route Change)
// @namespace    http://tampermonkey.net/
// @version      1.5.0_reload
// @description  Codewars 题目汉化工具,支持中英对照、用户 API Key,并在 Kata 切换时自动刷新页面。
// @author       Cerry2025 & AI Assistant & User Request
// @match        https://*.codewars.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      generativelanguage.googleapis.com
// ==/UserScript==

(function() {
    'use strict';

    // 配置
    const CONFIG = {
        TARGET_SELECTOR: '#description',             // 题目描述选择器
        LOADING_TEXT: 'Loading description...',      // 加载中文本
        TRANSLATE_DELAY: 350,                        // 翻译延迟(毫秒)
        STORAGE_KEY_MODE: 'codewars_translate_mode', // 翻译模式存储键
        STORAGE_KEY_APIKEY: 'codewars_gemini_apikey',// API Key 存储键
        ROUTE_CHECK_INTERVAL: 500,                   // 路由检查间隔(毫秒)
        API_ENDPOINT: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent', // Gemini API 端点
        TRANSLATION_STATE_ATTR: 'data-translation-state' // 翻译状态属性
    };

    // API Key 管理

    /**
     * 获取 API Key。
     * 如果未设置,则提示用户输入并存储。
     * @returns {string|null} API Key 或 null(如果未设置)
     */
    function getApiKey() {
        let apiKey = GM_getValue(CONFIG.STORAGE_KEY_APIKEY, null);
        if (!apiKey || apiKey.trim() === '') {
            apiKey = prompt('请输入您的 Google AI Gemini API 密钥以启用翻译功能。\n(您可以在 Google AI Studio 免费获取)');
            if (apiKey && apiKey.trim() !== '') {
                apiKey = apiKey.trim();
                GM_setValue(CONFIG.STORAGE_KEY_APIKEY, apiKey);
                alert('API 密钥已保存。');
                return apiKey;
            } else {
                alert('未提供有效的 API 密钥。翻译功能将无法使用。\n您可以通过油猴菜单 "设置/更新 API 密钥" 来设置。');
                return null;
            }
        }
        return apiKey;
    }

    // 注册油猴菜单命令,用于设置或更新 API Key
    GM_registerMenuCommand('设置/更新 API 密钥', () => {
        const currentKey = GM_getValue(CONFIG.STORAGE_KEY_APIKEY, '');
        const newKey = prompt('请输入或更新您的 Google AI Gemini API 密钥:', currentKey);
        if (newKey !== null) {
            const trimmedKey = newKey.trim();
            GM_setValue(CONFIG.STORAGE_KEY_APIKEY, trimmedKey);
            alert(trimmedKey ? 'API 密钥已更新!请刷新页面或导航到新题目以生效。' : 'API 密钥已清除。');
        } else {
            alert('操作已取消。');
        }
    });

    // 样式与 UI

    /**
     * 注入 CSS 样式。
     */
    function addStyles() {
        const style = document.createElement('style');
        style.id = 'codewars-translator-styles';
        style.textContent = `
            .bilingual-container .original-text { opacity: 0.75; font-size: 0.95em; line-height: 1.3; margin-bottom: 0; }
            .bilingual-container hr.translation-separator { margin: 15px 0; border: none; border-top: 1px solid #eee; }
            .bilingual-container .translated-text { border: 1px dashed #ccc; padding: 5px; line-height: 1.3; margin-top: 0; }
            .bilingual-container .original-text strong,
            .bilingual-container .translated-text strong { display: block; margin-bottom: 0px; font-size: 0.8em; color: #777; text-transform: uppercase; font-weight: bold; }
            .header-toggle { margin-left: auto; display: flex; align-items: center; gap: 6px; cursor: pointer; padding-right: 10px; }
            .translation-status-tip { padding: 5px 0; margin-bottom: 10px; display: block; font-size: 0.9em; }
            .translation-status-tip.tip { color: #666; font-style: italic; border-bottom: 1px dashed #eee; }
            .translation-status-tip.error { color: #f44336; font-weight: bold; border: 1px solid #f44336; padding: 8px 10px; background-color: #ffebee; border-radius: 4px; margin: 10px 0; }
        `;
        if (!document.getElementById(style.id)) {
            document.head.appendChild(style);
        }
    }

    /**
     * 添加中英对照切换开关到页面头部。
     */
    function addHeaderSwitch() {
        const checkHeaderInterval = setInterval(() => {
            const headerContainer = document.querySelector('.flex.items-center.justify-start .bg-ui-section');
            const targetArea = headerContainer?.closest('.px-4.md\\:px-6');

            if (targetArea && !targetArea.querySelector('.header-toggle')) {
                clearInterval(checkHeaderInterval);

                const isBilingual = localStorage.getItem(CONFIG.STORAGE_KEY_MODE) === 'bilingual';
                const toggleDiv = document.createElement('div');
                toggleDiv.className = 'header-toggle';
                toggleDiv.innerHTML = `
                    <input type="checkbox" id="bilingualToggleHeader" style="cursor: pointer; margin-left: 10px;" ${isBilingual ? 'checked' : ''}>
                    <label for="bilingualToggleHeader" style="cursor: pointer; user-select: none;">中英对照</label>
                `;

                const referenceNode = targetArea.querySelector('button, a');
                if(referenceNode){
                    targetArea.insertBefore(toggleDiv, referenceNode);
                } else {
                     targetArea.appendChild(toggleDiv);
                }

                const toggle = toggleDiv.querySelector('#bilingualToggleHeader');
                toggle.addEventListener('change', () => {
                    localStorage.setItem(CONFIG.STORAGE_KEY_MODE, toggle.checked ? 'bilingual' : 'replace');
                    alert('模式已切换。刷新页面或导航到新题目以查看效果。');
                     location.reload();
                });
            }
        }, 300);

        setTimeout(() => clearInterval(checkHeaderInterval), 10000);
    }

    /**
     * 设置状态提示。
     * @param {HTMLElement} element 目标元素
     * @param {string} text 提示文本
     * @param {boolean} isError 是否为错误提示
     */
    function setStatusTip(element, text, isError = false) {
        removeStatusTip(element);
        const tipElement = document.createElement('div');
        tipElement.className = `translation-status-tip ${isError ? 'error' : 'tip'}`;
        tipElement.textContent = text;
        element.insertBefore(tipElement, element.firstChild);
    }

    /**
     * 移除状态提示。
     * @param {HTMLElement} element
     */
    function removeStatusTip(element) {
        const tipElement = element.querySelector(':scope > .translation-status-tip');
        if (tipElement) {
            tipElement.remove();
        }
    }

    // 路由变化检测(简化:页面刷新)
    const initialPath = location.pathname;

    function checkForRouteChange() {
        if (location.pathname !== initialPath && location.pathname.includes('/kata/')) {
            console.log(`Codewars Translator: Kata 切换,从 ${initialPath} 到 ${location.pathname}。 刷新页面。`);
            clearInterval(routeCheckInterval);
            location.reload();
        }
    }

    const routeCheckInterval = setInterval(checkForRouteChange, CONFIG.ROUTE_CHECK_INTERVAL);

    // 翻译核心逻辑

    /**
     * 检查元素是否包含加载文本。
     * @param {HTMLElement} element
     * @returns {boolean}
     */
    const isLoading = (element) => element.textContent.includes(CONFIG.LOADING_TEXT);

    /**
     * 等待内容加载完成。
     * @param {HTMLElement} element
     * @returns {Promise<void>}
     */
    function waitForContentReady(element) {
        return new Promise((resolve, reject) => {
            if (!isLoading(element)) return resolve();

            let resolved = false;
            const observer = new MutationObserver(() => {
                if (!isLoading(element)) {
                    if(resolved) return;
                    resolved = true;
                    observer.disconnect();
                    resolve();
                }
            });
            observer.observe(element, { childList: true, subtree: true, characterData: true });

             const timeoutId = setTimeout(() => {
                if (resolved) return;
                observer.disconnect();
                console.warn("waitForContentReady 超时。");
                resolve();
            }, 10000);

             const originalResolve = resolve;
             resolve = () => {
                 clearTimeout(timeoutId);
                 originalResolve();
             }
        });
    }


    /**
     * 处理单个元素:检查状态、加载、API Key,然后翻译。
     * @param {HTMLElement} element
     */
    async function processElement(element) {
        const currentState = element.getAttribute(CONFIG.TRANSLATION_STATE_ATTR);

        if (['processing', 'translated', 'error'].includes(currentState)) {
            return;
        }

        if (!element.textContent || element.textContent.trim() === '') {
             setTimeout(() => {
                if (!element.textContent || element.textContent.trim() === '') {
                    element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'empty');
                } else {
                     processElement(element);
                }
             }, 300);
            return;
        }

        if (isLoading(element)) {
            element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'loading');
            setStatusTip(element, '等待题目内容加载...');
            await waitForContentReady(element);
            removeStatusTip(element);
            if (!element.textContent || element.textContent.trim() === '') {
                element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'empty');
                return;
            }
            element.removeAttribute(CONFIG.TRANSLATION_STATE_ATTR);
        }

        const apiKey = getApiKey();
        if (!apiKey) {
            setStatusTip(element, '错误:未设置 API 密钥。请通过脚本菜单设置。', true);
            element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'error');
            return;
        }

        element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'processing');
        setStatusTip(element, '正在翻译 (使用 Gemini)...');

        if (!element.dataset.originalHtml) {
             element.dataset.originalHtml = element.innerHTML;
        }
        const originalHTML = element.dataset.originalHtml;

        try {
            const { cleanedHTML, placeholders } = extractPlaceholders(originalHTML);

            if (cleanedHTML.replace(/<!-- PLACEHOLDER_\d+ -->/g, '').trim() === '') {
                 removeStatusTip(element);
                 element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'translated');
                 element.innerHTML = originalHTML;
                 return;
            }

            const translatedHTMLRaw = await callTranslationAPI(cleanedHTML, apiKey);
            applyTranslation(element, originalHTML, translatedHTMLRaw, placeholders);

            removeStatusTip(element);
            element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'translated');

        } catch (error) {
            console.error('翻译错误:', error);
            setStatusTip(element, `翻译失败:${error.message || error}`, true);
            element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'error');
        }
    }

    /**
     * 提取 HTML 中的 img 和 pre/code 标签,替换为占位符。
     * @param {string} html
     * @returns {{cleanedHTML: string, placeholders: Array<string>}}
     */
    function extractPlaceholders(html) {
        const placeholders = [];
        let index = 0;
        const cleanedHTML = html.replace(/<(img|pre|code)\b[^>]*>.*?<\/\1>|<(img)\b[^>]*?\/?>(?!\s*<\/(img)>)/gis, (match) => {
            placeholders.push(match);
            return `<!-- PLACEHOLDER_${index++} -->`;
        });
        return { cleanedHTML, placeholders };
    }

    /**
     * 恢复占位符。
     * @param {string} translatedHTML
     * @param {Array<string>} placeholders
     * @returns {string}
     */
    function restorePlaceholders(translatedHTML, placeholders) {
        return translatedHTML.replace(/<!-- PLACEHOLDER_(\d+) -->/g, (_, indexStr) => {
            const index = parseInt(indexStr, 10);
            return placeholders[index] !== undefined ? placeholders[index] : `<!-- MISSING_PLACEHOLDER_${index} -->`;
        });
    }

    /**
     * 调用 Gemini API 翻译。
     * @param {string} htmlToTranslate - 清理后的 HTML
     * @param {string} apiKey
     * @returns {Promise<string>} 翻译后的 HTML
     */
    function callTranslationAPI(htmlToTranslate, apiKey) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: `${CONFIG.API_ENDPOINT}?key=${apiKey}`,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify({
                    contents: [{
                        parts: [{
                            text: `将以下 HTML 片段中的可读文本内容翻译成 **简体中文**。
请严格保留所有原始 HTML 标签(例如 <img>、<pre>、<code>、<a>、<strong>、<em> 等),其属性、结构以及任何占位符(例如 <!-- PLACEHOLDER_0 -->)。
**不要翻译** <pre>...</pre> 或 <code>...</code> 标签内的内容。
仅翻译这些受保护元素之外的用户可见文本。确保输出是有效的 HTML。

输入 HTML:
\`\`\`html
${htmlToTranslate}
\`\`\`

翻译后的 HTML (简体中文):`
                        }]
                    }],
                     generationConfig: {
                    }
                }),
                responseType: 'json',
                timeout: 45000,
                onload: (res) => {
                    if (res.status === 200 && res.response) {
                        const candidate = res.response.candidates?.[0];
                        let text = candidate?.content?.parts?.[0]?.text;
                        const finishReason = candidate?.finishReason;
                        const blockReason = res.response.promptFeedback?.blockReason;

                        if (blockReason) {
                            return reject(new Error(`API 请求被阻止: ${blockReason}。检查内容安全设置或提示。`));
                        }
                        if (finishReason && finishReason !== "STOP" && finishReason !== "MAX_TOKENS") {
                             if(finishReason === "MAX_TOKENS"){
                                console.warn("翻译可能由于 MAX_TOKENS 限制而不完整。");
                             } else {
                                return reject(new Error(`API 完成原因问题: ${finishReason}。内容可能不安全或发生错误。`));
                             }
                        }
                        if (text) {
                            text = text.replace(/^```(?:html)?\s*|```$/gi, '').trim();
                            resolve(text);
                        } else if (finishReason === "STOP" && !text) {
                            console.warn("API 返回 STOP 但没有文本。假设没有可翻译内容。");
                            resolve("");
                        }
                        else {
                            console.error("API 响应详情:", JSON.stringify(res.response, null, 2));
                            reject(new Error('API 响应格式错误:在 candidate 部分中未找到有效的文本。'));
                        }
                    } else {
                        let errorMsg = `API 请求失败,状态码 ${res.status}`;
                        let errorDetails = '(无更多详细信息)';
                         try {
                             if (res.response && res.response.error) {
                                 errorDetails = res.response.error.message || JSON.stringify(res.response.error);
                             } else if (res.responseText) {
                                 try {
                                    const errJson = JSON.parse(res.responseText);
                                    errorDetails = errJson.error?.message || res.responseText;
                                 } catch(e) { errorDetails = res.responseText; }
                             }
                             if (errorDetails) errorMsg += `: ${errorDetails}`;
                             if (res.status === 400) errorMsg += " (Bad request - 检查 API key/请求格式)";
                             if (res.status === 403) errorMsg += " (Forbidden - 检查 API key 权限)";
                             if (res.status === 429) errorMsg += " (Rate limit exceeded)";
                             if (res.status >= 500) errorMsg += " (服务器端 API 错误)";
                         } catch (e) {
                             console.error("错误解析错误响应:", e);
                         }
                         reject(new Error(errorMsg));
                    }
                },
                onerror: (err) => reject(new Error(`翻译期间的网络错误: ${err.error || '未知网络问题'}`)),
                ontimeout: () => reject(new Error('翻译请求超时'))
            });
        });
    }

    /**
     * 应用翻译结果。
     * @param {HTMLElement} element 目标元素
     * @param {string} originalHTML 原始 HTML
     * @param {string} translatedRaw 翻译后的原始文本
     * @param {Array<string>} placeholders 占位符
     */
    function applyTranslation(element, originalHTML, translatedRaw, placeholders) {
        const isBilingual = localStorage.getItem(CONFIG.STORAGE_KEY_MODE) === 'bilingual';
        const cleanTranslation = translatedRaw
            .replace(/^```(?:html)?\s*|```$/gi, '')
            .trim();

        const translatedWithContent = restorePlaceholders(cleanTranslation, placeholders);

        element.innerHTML = '';
        element.classList.remove('bilingual-container');

        if (isBilingual) {
            element.classList.add('bilingual-container');
            element.innerHTML = `
                <div class="original-text">
                  <strong>原文:</strong>
                  <div>${originalHTML}</div>
                </div>
                <hr class="translation-separator">
                <div class="translated-text">
                   <strong>翻译:</strong>
                   <div>${translatedWithContent || '(翻译为空)'}</div>
                </div>
            `;
        } else {
            element.innerHTML = translatedWithContent || originalHTML;
        }
    }

    // 初始化

    /**
     * 脚本初始化。
     */
    function initialize() {
        console.log("Codewars Translator (Reload Version) 初始化...");
        addStyles();
        addHeaderSwitch();

        setTimeout(() => {
            const targetElement = document.querySelector(CONFIG.TARGET_SELECTOR);

            if (targetElement) {
                processElement(targetElement);
            } else {
                console.warn(`在延迟后未找到目标元素 "${CONFIG.TARGET_SELECTOR}"。`);
                 const fallbackObserver = new MutationObserver((mutations, obs) => {
                    const element = document.querySelector(CONFIG.TARGET_SELECTOR);
                     if (element) {
                        console.log("通过回退 MutationObserver 找到目标元素。");
                        processElement(element);
                        obs.disconnect();
                     }
                 });
                 fallbackObserver.observe(document.body, { childList: true, subtree: true });
                 setTimeout(() => fallbackObserver.disconnect(), 8000);
            }
        }, CONFIG.TRANSLATE_DELAY);

        console.log("Codewars Translator 初始化完成。 监控路由变化以进行页面刷新。");
    }

    // 启动脚本
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initialize);
    } else {
        initialize();
    }

})();