您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Codewars 沉浸式题目汉化工具,支持中英对照、用户 API Key(Gemini),并在 Kata 切换时自动刷新页面。
// ==UserScript== // @name Codewars 沉浸式题目汉化工具 // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description Codewars 沉浸式题目汉化工具,支持中英对照、用户 API Key(Gemini),并在 Kata 切换时自动刷新页面。 // @author Cerry2025 & AI Assistant & User Request // @license MIT // @homepageURL https://github.com/Cerry2022/Codewars-Immersive-Translator // @supportURL https://github.com/Cerry2022/Codewars-Immersive-Translator/issues // @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: 0, STORAGE_KEY_MODE: 'codewars_translate_mode', STORAGE_KEY_APIKEY: 'codewars_gemini_apikey', ROUTE_CHECK_INTERVAL: 500, API_ENDPOINT: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', TRANSLATION_STATE_ATTR: 'data-translation-state', NOTIFICATION_DURATION: 3000, // 通知显示时长 }; // 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(); // 简单验证 API Key 格式 (可以根据 Gemini API 的 Key 格式进行更严格的验证) if (!apiKey.startsWith('AIza')) { alert('API 密钥格式不正确,请检查!'); return null; } GM_setValue(CONFIG.STORAGE_KEY_APIKEY, apiKey); showNotification('API 密钥已保存。', 'success'); return apiKey; } else { showNotification('未提供有效的 API 密钥。翻译功能将无法使用。\n您可以通过油猴菜单 "设置/更新 API 密钥" 来设置。', 'warning'); 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(); // 简单验证 API Key 格式 if (trimmedKey && !trimmedKey.startsWith('AIza')) { alert('API 密钥格式不正确,请检查!'); return; } GM_setValue(CONFIG.STORAGE_KEY_APIKEY, trimmedKey); showNotification(trimmedKey ? 'API 密钥已更新!请刷新页面或导航到新题目以生效。' : 'API 密钥已清除。', 'info'); } else { showNotification('操作已取消。', 'info'); } }); // 样式与 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 { padding: 5px; /* 设置译文的内边距 */ line-height: 1.3; /* 设置译文的行高 */ margin-top: 0; /* 移除译文顶部的外边距 */ } /* 原文和译文中 <strong> 标签的样式 (通常用于标题或关键词) */ .bilingual-container .original-text strong, .bilingual-container .translated-text strong { display: block; /* 使 <strong> 标签占据整行 */ margin-bottom: 0px; /* 移除 <strong> 标签底部的外边距 */ font-size: 0.8em; /* 缩小 <strong> 标签的字体大小 */ color: #777; /* 设置 <strong> 标签的颜色为深灰色 */ text-transform: uppercase; /* 将 <strong> 标签的文本转换为大写 */ font-weight: bold; /* 加粗 <strong> 标签的文本 */ } /* 头部切换按钮的样式 */ .header-toggle { margin-left: auto; /* 将按钮推到右侧 */ display: flex; /* 使用 Flexbox 布局 */ 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; /* 设置错误信息的上下外边距 */ } /* 通知消息的样式 */ .notification { position: fixed; /* 使用固定定位,使其始终显示在屏幕的固定位置 */ top: 20px; /* 距离顶部 20px */ right: 20px; /* 距离右侧 20px */ background-color: #f0f0f0; /* 设置背景色为浅灰色 */ padding: 10px 15px; /* 设置内边距 */ border-radius: 5px; /* 添加圆角 */ box-shadow: 0 2px 5px rgba(0,0,0,0.2); /* 添加阴影 */ z-index: 1000; /* 设置 z-index 属性,使其显示在其他元素的上方 */ font-size: 14px; /* 设置字体大小 */ color: #333; /* 设置颜色 */ } /* 成功通知的样式 */ .notification.success { background-color: #d4edda; /* 设置背景色为浅绿色 */ color: #155724; /* 设置颜色为深绿色 */ border: 1px solid #c3e6cb; /* 设置边框 */ } /* 警告通知的样式 */ .notification.warning { background-color: #fff3cd; /* 设置背景色为浅黄色 */ color: #856404; /* 设置颜色为深黄色 */ border: 1px solid #ffeeba; /* 设置边框 */ } /* 信息通知的样式 */ .notification.info { background-color: #bee5eb; /* 设置背景色为浅蓝色 */ color: #0c5460; /* 设置颜色为深蓝色 */ border: 1px solid #b7e1e8; /* 设置边框 */ } /* 加载动画的样式 */ .loading-animation { border: 5px solid #f3f3f3; /* 浅灰色边框 */ border-top: 5px solid #3498db; /* 蓝色顶部边框 */ border-radius: 50%; /* 圆形 */ width: 20px; /* 宽度 */ height: 20px; /* 高度 */ animation: spin 2s linear infinite; /* 应用旋转动画 */ display: inline-block; /* 行内块元素,可以设置宽高 */ margin-right: 5px; /* 右侧外边距 */ } /* 旋转动画的关键帧 */ @keyframes spin { 0% { transform: rotate(0deg); } /* 初始角度 */ 100% { transform: rotate(360deg); } /* 旋转 360 度 */ } `; if (!document.getElementById(style.id)) { document.head.appendChild(style); } } /** * 显示通知消息。 * @param {string} message 通知内容 * @param {string} type 通知类型 (success, warning, info) */ function showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.className = `notification ${type}`; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.remove(); }, CONFIG.NOTIFICATION_DURATION); } /** * 使用 MutationObserver 添加中英对照切换开关到页面头部。 * 目标:添加到 "Instructions"/"Output" 选项卡右侧。 */ function addHeaderSwitch() { console.log("开始监控 DOM 以添加头部切换开关..."); // 更精确的目标容器选择器,基于提供的 HTML const headerTargetSelector = '.mb-2.border-0.overflow-hidden.flex.items-center.justify-start'; const toggleContainerId = 'codewars-translator-header-toggle-container'; // 唯一 ID const observer = new MutationObserver((mutationsList, obs) => { // 检查开关是否已存在 if (document.getElementById(toggleContainerId)) { // console.log("头部开关已存在,停止监控。"); // 可以取消注释以进行调试 obs.disconnect(); return; } // 查找目标容器 const targetArea = document.querySelector(headerTargetSelector); // 检查目标容器是否存在,并确保它至少包含一个选项卡链接作为稳定标志 const stableElementCheck = targetArea?.querySelector('a.inline-block.px-4.py-2'); if (targetArea && stableElementCheck) { console.log("找到头部目标区域,尝试添加开关..."); // 再次确认开关不存在(双重检查,虽然有 ID 可能多余,但保险) if (!targetArea.querySelector(`#${toggleContainerId}`)) { const isBilingual = localStorage.getItem(CONFIG.STORAGE_KEY_MODE) === 'bilingual'; // 创建开关的容器 div const toggleWrapperDiv = document.createElement('div'); toggleWrapperDiv.id = toggleContainerId; // 设置唯一 ID // 使用 margin-left: auto 将其推到 flex 容器的右侧 toggleWrapperDiv.style.marginLeft = 'auto'; // 保持 flex 布局以垂直居中 toggleWrapperDiv.style.display = 'flex'; toggleWrapperDiv.style.alignItems = 'center'; toggleWrapperDiv.style.gap = '6px'; // 复刻之前的样式 toggleWrapperDiv.style.paddingRight = '10px'; // 增加一些右边距,避免太贴边 // 创建开关内部的 HTML toggleWrapperDiv.innerHTML = ` <input type="checkbox" id="bilingualToggleHeader" style="cursor: pointer;" ${isBilingual ? 'checked' : ''}> <label for="bilingualToggleHeader" style="cursor: pointer; user-select: none;">中英对照</label> `; // 将开关容器添加到目标区域(flex 容器)的末尾 // 由于设置了 margin-left: auto,它会被推到右边 targetArea.appendChild(toggleWrapperDiv); console.log("头部开关已添加。"); // 获取新添加的 checkbox 并添加事件监听器 const toggle = toggleWrapperDiv.querySelector('#bilingualToggleHeader'); toggle.addEventListener('change', () => { localStorage.setItem(CONFIG.STORAGE_KEY_MODE, toggle.checked ? 'bilingual' : 'replace'); showNotification('模式已切换。刷新页面或导航到新题目以查看效果。', 'info'); location.reload(); // 保留刷新逻辑 }); // 成功添加后,停止观察 obs.disconnect(); console.log("停止监控 DOM (开关已添加)。"); } else { // console.log("开关已存在于目标区域内(二次检查),停止监控。"); obs.disconnect(); } } // else { // console.log("未找到目标区域或稳定元素,继续监控..."); // 可选日志 //} }); // 配置观察选项: 监控整个文档的子节点添加/删除 const config = { childList: true, subtree: true }; // 开始观察 document.body observer.observe(document.body, config); // 添加一个超时,以防万一目标元素永远不出现 setTimeout(() => { observer.disconnect(); }, 5000); // 设置 5 秒超时 } /** * 设置状态提示。 * @param {HTMLElement} element 目标元素 * @param {string} text 提示文本 * @param {boolean} isError 是否为错误提示 */ function setStatusTip(element, text, isError = false, showLoading = false) { removeStatusTip(element); const tipElement = document.createElement('div'); tipElement.className = `translation-status-tip ${isError ? 'error' : 'tip'}`; if(showLoading){ const loadingAnimation = document.createElement('span'); loadingAnimation.className = 'loading-animation'; tipElement.appendChild(loadingAnimation); } tipElement.appendChild(document.createTextNode(text)); // 使用 createTextNode 防止 HTML 注入 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 */ /** * 处理单个元素:检查状态、加载、API Key,然后翻译。 * @param {HTMLElement} element */ async function processElement(element) { const currentState = element.getAttribute(CONFIG.TRANSLATION_STATE_ATTR); // 如果元素已处理、正在处理、出错或明确标记为空,则跳过 if (['processing', 'translated', 'error', 'empty'].includes(currentState)) { // console.log(`Skipping element, state: ${currentState}`); // 可选调试日志 return; } console.log("Processing element:", element.id || element.className); let originalHTML; // --- 步骤 1: 处理 Codewars 自身的加载状态 --- if (isLoading(element)) { console.log("Element is initially loading (Codewars)..."); element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'loading_codewars'); // 标记为 Codewars 加载状态 setStatusTip(element, '等待题目内容加载...'); // 显示 Codewars 加载提示 await waitForContentReady(element); removeStatusTip(element); // 移除 Codewars 加载提示 console.log("Codewars loading finished."); // 再次检查状态,因为 waitForContentReady 可能改变了元素状态或内容 if (!element.textContent || element.textContent.trim() === '') { console.log("Element became empty after Codewars loading."); element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'empty'); // 如果希望显示空状态,可以保留元素为空,或者恢复可能的原始空结构 // element.innerHTML = ''; // 确保是空的 return; // 内容为空,不进行翻译 } // 内容加载完成后,清除 Codewars 加载状态标记,以便后续捕获 HTML element.removeAttribute(CONFIG.TRANSLATION_STATE_ATTR); } // --- 步骤 2: 捕获干净的原始 HTML --- // 确保在添加我们自己的翻译提示之前捕获 if (!element.dataset.originalHtml) { // 再次检查内容是否为空(可能在非 loading 状态下初始为空) if (!element.textContent || element.textContent.trim() === '') { console.log("Element is initially empty."); element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'empty'); return; } originalHTML = element.innerHTML; element.dataset.originalHtml = originalHTML; console.log("Original HTML captured successfully."); // console.log("Captured HTML:", originalHTML.substring(0, 200) + "..."); // 调试日志,只显示部分 } else { originalHTML = element.dataset.originalHtml; console.log("Reusing cached original HTML."); } // --- 步骤 3: 检查 API Key --- const apiKey = getApiKey(); if (!apiKey) { // 只有在尚未显示错误时才添加提示 if (element.getAttribute(CONFIG.TRANSLATION_STATE_ATTR) !== 'error') { setStatusTip(element, '错误:未设置 API 密钥。请通过脚本菜单设置。', true); element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'error'); } return; } // --- 步骤 4: 设置处理状态并显示我们的翻译加载提示 --- console.log("Setting state to 'processing' and showing translation tip."); element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'processing'); // *** 确保此时才添加我们自己的加载提示 *** setStatusTip(element, '正在翻译 (使用 Gemini)...', false, true); // --- 步骤 5: 执行翻译 --- try { // console.log("Extracting placeholders from original HTML..."); const { cleanedHTML, placeholders } = extractPlaceholders(originalHTML); // 必须使用捕获的 originalHTML // 检查清理后的 HTML 是否只包含占位符或为空 if (cleanedHTML.replace(/<!-- PLACEHOLDER_\d+ -->/g, '').trim() === '') { console.log("Cleaned HTML is effectively empty, skipping API call."); removeStatusTip(element); // 移除我们的加载提示 element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'translated'); // 标记为已翻译(即使是空的) // 恢复原始 HTML(可能只包含占位符或确实是空的) element.innerHTML = originalHTML; return; } // console.log("Calling translation API..."); const translatedHTMLRaw = await callTranslationAPI(cleanedHTML, apiKey); console.log("Translation API call successful."); // *** 在应用翻译之前移除我们的加载提示 *** console.log("Removing translation status tip before applying translation."); removeStatusTip(element); // 关键一步! // 应用翻译结果 console.log("Applying translation..."); applyTranslation(element, originalHTML, translatedHTMLRaw, placeholders); // 使用正确的 originalHTML element.setAttribute(CONFIG.TRANSLATION_STATE_ATTR, 'translated'); console.log("Translation applied. Final state: translated."); } catch (error) { console.error('翻译流程出错:', error); // 移除可能存在的加载提示,并显示错误信息 removeStatusTip(element); // 先尝试移除加载提示 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) { const maxRetries = 2; // 最大重试次数 let retryCount = 0; return new Promise((resolve, reject) => { function makeRequest() { 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); } // 重试机制 if (res.status >= 500 && retryCount < maxRetries) { // 仅对服务器错误进行重试 retryCount++; console.log(`API 请求失败,正在重试 (${retryCount}/${maxRetries})...`); setTimeout(makeRequest, 1000 * retryCount); // 延迟重试 } else { reject(new Error(errorMsg)); } } }, onerror: (err) => reject(new Error(`翻译期间的网络错误: ${err.error || '未知网络问题'}`)), ontimeout: () => reject(new Error('翻译请求超时')) }); } makeRequest(); // 启动首次请求 }); } /** * 应用翻译结果。 * @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初始化..."); addStyles(); addHeaderSwitch(); // 使用 MutationObserver 监听目标元素的变化 const observer = new MutationObserver((mutations) => { const targetElement = document.querySelector(CONFIG.TARGET_SELECTOR); if (targetElement) { observer.disconnect(); // 找到目标元素后停止监听 processElement(targetElement); } }); // 开始监听 document body 的变化 observer.observe(document.body, { childList: true, subtree: true }); console.log("Codewars Translator 初始化完成。 监控路由变化以进行页面刷新。"); } // 启动脚本 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } })();