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