${line}
`).join('')}]*)>/gi, '')
.replace(/<\/code>/gi, ' ')
.replace(/]*)>/gi, '')
.replace(/<\/pre>/gi, ' ')
.replace(/]*)>/gi, '')
.replace(/<\/kbd>/gi, ' ');
}
/**
* 还原被伪装的 HTML 标签
*/
function unmaskProtectedTags(html) {
if (!html) return html;
return html
.replace(/]*)>/gi, '')
.replace(/<\/v-tr-code>/gi, '')
.replace(/]*)>/gi, '')
.replace(/<\/pre>/gi, '
')
.replace(/]*)>/gi, '')
.replace(/<\/v-tr-kbd>/gi, '');
}
/**
* 处理对谷歌翻译接口的特定请求流程
*/
async function _handleGoogleRequest(engineConfig, paragraphs, fromLang, toLang) {
await GoogleTranslateHelper.findAuth();
if (!GoogleTranslateHelper.translateAuth) {
throw new Error('无法获取谷歌翻译的授权凭证');
}
const headers = {
...engineConfig.headers,
'X-goog-api-key': GoogleTranslateHelper.translateAuth
};
const sourceTexts = paragraphs.map(p => maskProtectedTags(p.outerHTML));
const requestData = JSON.stringify([
[sourceTexts, fromLang, toLang], "te"
]);
Logger.info('网络', '发起请求: 谷歌翻译', {
url: engineConfig.url_api,
from: fromLang,
to: toLang,
paragraphs: paragraphs.length
});
const res = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: engineConfig.method,
url: engineConfig.url_api,
headers: headers,
data: requestData,
responseType: 'json',
timeout: 45000,
onload: resolve,
onerror: () => reject(new Error('网络请求错误')),
ontimeout: () => reject(new Error('请求超时'))
});
});
if (res.status !== 200) {
throw new Error(`谷歌翻译 API 错误 (代码: ${res.status}): ${res.statusText}`);
}
const translatedHtmlSnippets = getNestedProperty(res.response, '0');
if (!translatedHtmlSnippets || !Array.isArray(translatedHtmlSnippets)) {
throw new Error('从谷歌翻译接口返回的响应结构无效');
}
return translatedHtmlSnippets.map(html => unmaskProtectedTags(html));
}
/**
* 处理对微软翻译接口的特定请求流程
*/
async function _handleBingRequest(engineConfig, paragraphs, fromLang, toLang, isRetry = false) {
const token = await BingTranslateHelper.getToken();
const bingFrom = BING_LANG_CODE_MAP[fromLang] || fromLang;
const bingTo = BING_LANG_CODE_MAP[toLang] || toLang;
let url = `${engineConfig.url_api}&to=${bingTo}`;
if (bingFrom !== 'auto-detect') {
url += `&from=${bingFrom}`;
}
const requestBody = JSON.stringify(paragraphs.map(p => ({
text: p.innerHTML
})));
if (!isRetry) {
Logger.info('网络', '发起请求: 微软翻译', {
url: url,
from: bingFrom,
to: bingTo,
paragraphs: paragraphs.length
});
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: url,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
},
data: requestBody,
responseType: 'json',
timeout: 45000,
onload: async (res) => {
if (res.status === 401 && !isRetry) {
Logger.warn('网络', '微软翻译 Token 过期,正在重试');
BingTranslateHelper.clearToken();
try {
const retryResult = await _handleBingRequest(engineConfig, paragraphs, fromLang, toLang, true);
resolve(retryResult);
} catch (retryError) {
reject(retryError);
}
return;
}
if (res.status !== 200) {
const e = new Error(`Microsoft API Error: ${res.status} ${res.statusText}`);
e.type = res.status === 429 ? 'rate_limit' : 'api_error';
reject(e);
return;
}
const responseData = res.response;
if (!Array.isArray(responseData)) {
const e = new Error('Invalid response format');
e.type = 'invalid_json';
reject(e);
return;
}
resolve(responseData.map(item => item.translations[0].text));
},
onerror: (err) => {
const e = new Error('Network Error');
e.type = 'network';
reject(e);
},
ontimeout: () => {
const e = new Error('Timeout');
e.type = 'timeout';
reject(e);
}
});
});
}
/**
* OpenAI 的专属错误处理策略
*/
function _handleOpenaiError(res, name, responseData) {
const apiErrorMessage = getNestedProperty(responseData, 'error.message') || res.statusText;
const apiErrorCode = getNestedProperty(responseData, 'error.code');
let userFriendlyError;
const error = new Error();
error.noRetry = false;
switch (res.status) {
case 400:
if (apiErrorCode === 'model_not_found') {
userFriendlyError = `模型不存在 (400):您选择的模型当前不可用或您无权访问。请在设置中更换模型。`;
} else {
userFriendlyError = `错误的请求 (400):请求的格式或参数有误。`;
}
error.noRetry = true;
break;
case 401:
userFriendlyError = `API Key 无效或认证失败 (401):请在设置面板中检查您的 ${name} API Key。`;
error.noRetry = true;
break;
case 403:
userFriendlyError = `权限被拒绝 (403):您的 API Key 无权访问所请求的资源,或您所在的地区不受支持。`;
error.noRetry = true;
break;
case 404:
userFriendlyError = `资源未找到 (404):请求的 API 端点不存在。`;
error.noRetry = true;
break;
case 429:
if (apiErrorCode === 'insufficient_quota') {
userFriendlyError = `账户余额不足 (429):您的 ${name} 账户已用尽信用点数或达到支出上限。请前往服务官网检查您的账单详情。`;
error.noRetry = true;
error.type = 'billing_error';
} else {
userFriendlyError = `请求频率过高 (429):已超出 API 的速率限制。`;
error.type = 'rate_limit';
}
break;
case 500:
userFriendlyError = `服务器内部错误 (500):${name} 的服务器遇到问题。`;
error.type = 'server_overloaded';
break;
case 503:
if (apiErrorMessage && apiErrorMessage.includes('Slow Down')) {
userFriendlyError = `服务暂时过载 (503 - Slow Down):由于您的请求速率突然增加,服务暂时受到影响。`;
} else {
userFriendlyError = `服务器当前过载 (503):${name} 的服务器正经历高流量。`;
}
error.type = 'server_overloaded';
break;
default:
userFriendlyError = `发生未知 API 错误 (代码: ${res.status})。`;
error.noRetry = true;
break;
}
error.message = userFriendlyError + `\n\n原始错误信息:\n${apiErrorMessage}`;
return error;
}
/**
* Anthropic 的专属错误处理策略
*/
function _handleAnthropicError(res, name, responseData) {
const apiErrorType = getNestedProperty(responseData, 'error.type');
const apiErrorMessage = getNestedProperty(responseData, 'error.message') || res.statusText;
let userFriendlyError;
const error = new Error();
error.noRetry = false;
switch (apiErrorType) {
case 'invalid_request_error':
userFriendlyError = `无效请求 (${res.status}):请求的格式或参数有误。如果问题持续,可能是模型名称不受支持或已更新。`;
error.noRetry = true;
break;
case 'authentication_error':
userFriendlyError = `API Key 无效或认证失败 (401):请在设置面板中检查您的 ${name} API Key。`;
error.noRetry = true;
break;
case 'permission_error':
userFriendlyError = `权限被拒绝 (403):您的 API Key 无权访问所请求的资源。`;
error.noRetry = true;
break;
case 'not_found_error':
userFriendlyError = `资源未找到 (404):请求的 API 端点或模型不存在。`;
error.noRetry = true;
break;
case 'request_too_large':
userFriendlyError = `请求内容过长 (413):发送的文本量超过了 API 的单次请求上限。`;
error.noRetry = true;
break;
case 'rate_limit_error':
userFriendlyError = `请求频率过高 (429):已超出 API 的速率限制。`;
error.type = 'rate_limit';
break;
case 'api_error':
userFriendlyError = `服务器内部错误 (500):${name} 的服务器遇到问题。`;
error.type = 'server_overloaded';
break;
case 'overloaded_error':
userFriendlyError = `服务器过载 (529):${name} 的服务器当前负载过高。`;
error.type = 'server_overloaded';
break;
default:
if (res.status === 413) {
userFriendlyError = `请求内容过长 (413):发送的文本量超过了 API 的单次请求上限。`;
} else {
userFriendlyError = `发生未知 API 错误 (代码: ${res.status})。`;
}
error.noRetry = true;
break;
}
error.message = userFriendlyError + `\n\n原始错误信息:\n${apiErrorMessage}`;
return error;
}
/**
* Zhipu AI 的专属错误处理策略
*/
function _handleZhipuAiError(res, name, responseData) {
const businessErrorCode = getNestedProperty(responseData, 'error.code');
const apiErrorMessage = getNestedProperty(responseData, 'error.message') || res.statusText;
let userFriendlyError;
const error = new Error();
if (businessErrorCode) {
switch (businessErrorCode) {
case '1001':
case '1002':
case '1003':
case '1004':
userFriendlyError = `API Key 无效或认证失败 (${businessErrorCode}):请在设置面板中检查您的 ${name} API Key 是否正确填写。`;
error.noRetry = true;
break;
case '1112':
userFriendlyError = `账户异常 (${businessErrorCode}):您的 ${name} 账户已被锁定,请联系平台客服。`;
error.noRetry = true;
break;
case '1113':
userFriendlyError = `账户余额不足 (${businessErrorCode}):您的 ${name} 账户已欠费,请前往 Zhipu AI 官网充值。`;
error.noRetry = true;
break;
case '1301':
userFriendlyError = `内容安全策略阻止 (${businessErrorCode}):因含有敏感内容,请求被 Zhipu AI 安全策略阻止。`;
error.noRetry = true;
error.type = 'content_error';
break;
case '1302':
case '1303':
error.message = `请求频率过高 (${businessErrorCode}):已超出 API 的速率限制。\n\n原始错误信息:\n${apiErrorMessage}`;
error.type = 'rate_limit';
return error;
case '1304':
userFriendlyError = `调用次数超限 (${businessErrorCode}):已达到当日调用次数限额,请联系 Zhipu AI 客服。`;
error.noRetry = true;
break;
default:
userFriendlyError = `发生未知的业务错误 (代码: ${businessErrorCode})。`;
error.noRetry = true;
break;
}
} else {
return new BaseApiClient({ name })._handleError(res, responseData);
}
error.message = userFriendlyError + `\n\n原始错误信息:\n${apiErrorMessage}`;
return error;
}
/**
* DeepSeek AI 的专属错误处理策略
*/
function _handleDeepseekAiError(res, name, responseData) {
const apiErrorMessage = getNestedProperty(responseData, 'error.message') || getNestedProperty(responseData, 'message') || res.statusText;
let userFriendlyError;
const error = new Error();
switch (res.status) {
case 400:
case 422:
userFriendlyError = `请求格式或参数错误 (${res.status}):请检插件是否为最新版本。如果问题持续,可能是 API 服务端出现问题。`;
error.noRetry = true;
break;
case 401:
userFriendlyError = `API Key 无效或认证失败 (401):请在设置面板中检查您的 ${name} API Key 是否正确填写。`;
error.noRetry = true;
break;
case 402:
userFriendlyError = `账户余额不足 (402):您的 ${name} 账户余额不足。请前往 DeepSeek 官网充值。`;
error.noRetry = true;
break;
case 429:
userFriendlyError = `请求频率过高 (429):已超出 API 的速率限制。`;
error.type = 'rate_limit';
break;
case 500:
userFriendlyError = `服务器内部故障 (500):${name} 的服务器遇到未知问题。`;
error.type = 'server_overloaded';
break;
case 503:
userFriendlyError = `服务器繁忙 (503):${name} 的服务器当前负载过高。`;
error.type = 'server_overloaded';
break;
default:
return new BaseApiClient({ name })._handleError(res, responseData);
}
error.message = userFriendlyError + `\n\n原始错误信息:\n${apiErrorMessage}`;
return error;
}
/**
* Google AI 的专属错误处理策略
*/
function _handleGoogleAiError(errorData) {
const { type, message, res, name } = errorData;
const error = new Error();
let userFriendlyError;
if (type === 'content_error') {
userFriendlyError = `内容安全策略阻止:${message}。请尝试修改原文内容。`;
error.noRetry = true;
} else if (type === 'key_invalid') {
userFriendlyError = `API Key 无效或认证失败:${message}。请在设置面板中检查您的 API Key。`;
error.noRetry = true;
} else if (res) {
switch (res.status) {
case 400:
userFriendlyError = `请求格式错误 (400):您的国家/地区可能不支持 Gemini API 的免费套餐,请在 Google AI Studio 中启用结算。`;
error.noRetry = true;
break;
case 429:
userFriendlyError = `请求频率过高 (429):已超出 API 的速率限制。`;
error.type = 'rate_limit';
break;
default:
return new BaseApiClient({ name })._handleError(res, res.response);
}
} else {
userFriendlyError = `发生未知错误:${message}`;
error.noRetry = (type !== 'network' && type !== 'timeout');
}
error.message = userFriendlyError + `\n\n原始错误信息:\n${message}`;
return error;
}
/**
* SiliconFlow 的专属错误处理策略
*/
function _handleSiliconFlowError(res, name, responseData) {
const apiErrorMessage = getNestedProperty(responseData, 'message') || res.statusText;
const apiErrorCode = getNestedProperty(responseData, 'code');
let userFriendlyError;
const error = new Error();
switch (res.status) {
case 400:
if (apiErrorCode === 20012) {
userFriendlyError = `模型不存在 (400):您选择的模型名称无效或已下线。请在设置中更换模型。`;
} else {
userFriendlyError = `请求参数错误 (400):请检查插件版本或配置。`;
}
error.noRetry = true;
break;
case 401:
userFriendlyError = `API Key 无效 (401):请在设置面板中检查您的 ${name} API Key。`;
error.noRetry = true;
break;
case 403:
userFriendlyError = `权限不足或余额不足 (403):可能是账户余额不足,或该模型需要实名认证。请前往 SiliconFlow 官网检查账户状态。`;
error.noRetry = true;
break;
case 429:
userFriendlyError = `请求频率过高 (429):触发了速率限制 (RPM/TPM)。`;
error.type = 'rate_limit';
break;
case 503:
case 504:
userFriendlyError = `服务系统负载高 (${res.status}):SiliconFlow 服务器暂时繁忙。`;
error.type = 'server_overloaded';
break;
case 500:
userFriendlyError = `服务器内部错误 (500):服务发生了未知错误。`;
error.type = 'server_overloaded';
break;
default:
userFriendlyError = `发生未知 API 错误 (代码: ${res.status})。`;
error.noRetry = true;
break;
}
error.message = userFriendlyError + `\n\n原始错误信息:\n${apiErrorMessage}`;
return error;
}
/**
* Groq AI 的专属错误处理策略
*/
function _handleGroqAiError(res, name, responseData) {
const apiErrorMessage = getNestedProperty(responseData, 'error.message') || getNestedProperty(responseData, 'message') || res.statusText;
let userFriendlyError;
const error = new Error();
switch (res.status) {
case 400:
userFriendlyError = `请求无效 (400):请求语法错误。请检查请求格式。`;
error.noRetry = true;
break;
case 401:
userFriendlyError = `API Key 无效或认证失败 (401):请在设置面板中检查您的 ${name} API Key。`;
error.noRetry = true;
break;
case 403:
userFriendlyError = `权限被拒绝 (403):您的网络或 API Key 无权访问所请求的资源。`;
error.noRetry = true;
break;
case 404:
userFriendlyError = `资源未找到 (404):请求的模型或端点不存在。请检查模型名称或接口地址。`;
error.noRetry = true;
break;
case 413:
userFriendlyError = `请求内容过长 (413):发送的文本量超过了限制。请尝试减少单次翻译的文本量。`;
error.noRetry = true;
break;
case 422:
userFriendlyError = `无法处理的实体 (422):请求格式正确但包含语义错误。`;
error.noRetry = true;
break;
case 424:
userFriendlyError = `依赖失败 (424):依赖请求失败(可能是 Remote MCP 认证问题)。`;
error.noRetry = true;
break;
case 429:
userFriendlyError = `请求频率过高 (429):已超出 API 的速率限制。`;
error.type = 'rate_limit';
break;
case 498:
userFriendlyError = `Flex Tier 容量超限 (498):当前 Flex Tier 已满。`;
error.type = 'server_overloaded';
break;
case 500:
userFriendlyError = `服务器内部错误 (500):${name} 服务器发生通用错误。`;
error.type = 'server_overloaded';
break;
case 502:
userFriendlyError = `网关错误 (502):上游服务器响应无效。`;
error.type = 'server_overloaded';
break;
case 503:
userFriendlyError = `服务不可用 (503):服务器正在维护或过载。`;
error.type = 'server_overloaded';
break;
default:
userFriendlyError = `发生未知 API 错误 (代码: ${res.status})。`;
error.noRetry = true;
break;
}
error.message = userFriendlyError + `\n\n原始错误信息:\n${apiErrorMessage}`;
return error;
}
/**
* Cerebras AI 的专属错误处理策略
*/
function _handleCerebrasAiError(res, name, responseData) {
const apiErrorMessage = getNestedProperty(responseData, 'error.message') || getNestedProperty(responseData, 'message') || res.statusText;
let userFriendlyError;
const error = new Error();
switch (res.status) {
case 400:
userFriendlyError = `请求无效 (400):请求参数有误。请检查模型名称或输入格式。`;
error.noRetry = true;
break;
case 401:
userFriendlyError = `认证失败 (401):API Key 无效或缺失。请在设置面板中检查您的 ${name} API Key。`;
error.noRetry = true;
break;
case 402:
userFriendlyError = `需要付款 (402):账户余额不足或需要充值。`;
error.noRetry = true;
break;
case 403:
userFriendlyError = `权限被拒绝 (403):无权访问该资源。`;
error.noRetry = true;
break;
case 404:
userFriendlyError = `资源未找到 (404):请求的模型或端点不存在。`;
error.noRetry = true;
break;
case 408:
userFriendlyError = `请求超时 (408):服务器处理请求超时。`;
error.type = 'timeout';
break;
case 409:
userFriendlyError = `请求冲突 (409):资源状态冲突。`;
error.type = 'server_overloaded';
break;
case 422:
userFriendlyError = `无法处理的实体 (422):请求格式正确但包含语义错误(如无效的模型参数)。`;
error.noRetry = true;
break;
case 429:
userFriendlyError = `请求频率过高 (429):已超出 API 的速率限制。`;
error.type = 'rate_limit';
break;
default:
if (res.status >= 500) {
userFriendlyError = `服务器内部错误 (${res.status}):${name} 服务器发生错误。`;
error.type = 'server_overloaded';
} else {
userFriendlyError = `发生未知 API 错误 (代码: ${res.status})。`;
error.noRetry = true;
}
break;
}
error.message = userFriendlyError + `\n\n原始错误信息:\n${apiErrorMessage}`;
return error;
}
/**
* Together AI 的专用错误处理策略
*/
function _handleTogetherAiError(res, name, responseData) {
const apiErrorMessage = getNestedProperty(responseData, 'error.message') || getNestedProperty(responseData, 'message') || res.statusText;
let userFriendlyError;
const error = new Error();
switch (res.status) {
case 400:
case 422:
userFriendlyError = `请求格式或参数错误 (${res.status}):请检查插件是否为最新版本。如果问题持续,可能是 API 服务端出现问题。`;
error.noRetry = true;
break;
case 401:
userFriendlyError = `API Key 无效或认证失败 (401):请在设置面板中检查您的 ${name} API Key 是否正确填写。`;
error.noRetry = true;
break;
case 402:
userFriendlyError = `需要付费 (402):您的 ${name} 账户已达到消费上限或需要充值。请检查您的账户账单设置。`;
error.noRetry = true;
break;
case 403:
case 413:
userFriendlyError = `请求内容过长 (${res.status}):发送的文本量超过了模型的上下文长度限制。请尝试翻译更短的文本段落。`;
error.noRetry = true;
break;
case 404:
userFriendlyError = `模型或接口地址不存在 (404):您选择的模型名称可能已失效,或接口地址不正确。请尝试在设置面板中切换至其她模型或检查接口地址。`;
error.noRetry = true;
break;
case 429:
userFriendlyError = `请求频率过高 (429):已超出 API 的速率限制。`;
error.type = 'rate_limit';
break;
case 500:
userFriendlyError = `服务器内部错误 (500):${name} 的服务器遇到问题。`;
error.type = 'server_overloaded';
break;
case 502:
userFriendlyError = `网关错误 (502):上游服务器响应无效。这通常是临时问题。`;
error.type = 'server_overloaded';
break;
case 503:
userFriendlyError = `服务过载 (503):${name} 的服务器当前流量过高。`;
error.type = 'server_overloaded';
break;
default:
return new BaseApiClient({ name })._handleError(res, responseData);
}
error.message = userFriendlyError + `\n\n原始错误信息:\n${apiErrorMessage}`;
return error;
}
/**
* API 错误处理策略注册表
*/
const API_ERROR_HANDLERS = {
'openai': _handleOpenaiError,
'siliconflow': _handleSiliconFlowError,
'anthropic': _handleAnthropicError,
'zhipu_ai': _handleZhipuAiError,
'deepseek_ai': _handleDeepseekAiError,
'google_ai': _handleGoogleAiError,
'groq_ai': _handleGroqAiError,
'together_ai': _handleTogetherAiError,
'cerebras_ai': _handleCerebrasAiError,
'modelscope_ai': _handleTogetherAiError
};
/**************************************************************************
* 术语表系统、工具函数与核心逻辑
**************************************************************************/
/**
* 为词形变体创建正则表达式
*/
function createSmartRegexPattern(forms) {
if (!forms || forms.size === 0) {
return '';
}
const sortedForms = Array.from(forms).sort((a, b) => b.length - a.length);
const escapedForms = sortedForms.map(form =>
form.replace(/([.*+?^${}()|[\]\\])/g, '\\$&')
);
const pattern = escapedForms.join('|');
const longestForm = sortedForms[0];
const startsWithWordChar = /^[a-zA-Z0-9_]/.test(longestForm);
const endsWithWordChar = /[a-zA-Z0-9_]$/.test(longestForm);
const prefix = startsWithWordChar ? '\\b' : '';
const suffix = endsWithWordChar ? '\\b' : '';
return `${prefix}(?:${pattern})${suffix}`;
}
/**
* 生成一个随机的6位数字字符串
*/
function generateRandomPlaceholder() {
const chars = '0123456789';
let result = '';
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* 在DOM节点内查找一个由多部分文本组成的、有序的邻近序列
*/
function findOrderedDOMSequence(rootNode, rule) {
const { parts: partsWithForms, isGeneral } = rule;
const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
if (node.parentElement.closest('[data-glossary-applied="true"]')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
});
const textNodes = [];
let node;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
if (textNodes.length === 0) return null;
for (let i = 0; i < textNodes.length; i++) {
for (let j = 0; j < textNodes[i].nodeValue.length; j++) {
const matchResult = findSequenceFromPosition(i, j);
if (matchResult) {
return matchResult;
}
}
}
return null;
function findSequenceFromPosition(startNodeIndex, startOffset) {
let currentNodeIndex = startNodeIndex;
let currentOffset = startOffset;
const matchedWords = [];
const endPoints = [];
for (let partIndex = 0; partIndex < partsWithForms.length; partIndex++) {
const currentPartForms = partsWithForms[partIndex].sort((a, b) => b.length - a.length);
let bestMatch = null;
let searchStr = textNodes[currentNodeIndex].nodeValue.substring(currentOffset);
let lookaheadIndex = currentNodeIndex + 1;
while (lookaheadIndex < textNodes.length && searchStr.length < 200) {
searchStr += textNodes[lookaheadIndex].nodeValue;
lookaheadIndex++;
}
const textToSearch = isGeneral ? searchStr.toLowerCase() : searchStr;
for (const form of currentPartForms) {
const formToMatch = isGeneral ? form.toLowerCase() : form;
if (textToSearch.startsWith(formToMatch)) {
const prevChar = (startNodeIndex === 0 && startOffset === 0) ? ' ' : textNodes[startNodeIndex].nodeValue[startOffset - 1] || ' ';
if (partIndex === 0 && /[a-zA-Z0-9]/.test(prevChar)) {
continue;
}
bestMatch = form;
break;
}
}
if (bestMatch) {
matchedWords.push(bestMatch);
let consumedLength = bestMatch.length;
currentOffset += consumedLength;
while (currentOffset >= textNodes[currentNodeIndex].nodeValue.length && currentNodeIndex < textNodes.length - 1) {
currentOffset -= textNodes[currentNodeIndex].nodeValue.length;
currentNodeIndex++;
}
endPoints.push({ nodeIndex: currentNodeIndex, offset: currentOffset });
if (partIndex < partsWithForms.length - 1) {
let separatorFound = false;
while (currentNodeIndex < textNodes.length) {
const remainingInNode = textNodes[currentNodeIndex].nodeValue.substring(currentOffset);
const separatorMatch = remainingInNode.match(/^[\s--﹣—–]+/);
if (separatorMatch) {
currentOffset += separatorMatch[0].length;
separatorFound = true;
break;
}
if (remainingInNode.trim() !== '') {
return null;
}
currentNodeIndex++;
currentOffset = 0;
if (currentNodeIndex < textNodes.length) {
separatorFound = true;
} else {
return null;
}
}
if (!separatorFound) return null;
}
} else {
return null;
}
}
const finalEndPoint = endPoints[endPoints.length - 1];
const nextChar = textNodes[finalEndPoint.nodeIndex].nodeValue[finalEndPoint.offset] || ' ';
if (/[a-zA-Z0-9]/.test(nextChar)) {
return null;
}
return {
startNode: textNodes[startNodeIndex],
startOffset: startOffset,
endNode: textNodes[finalEndPoint.nodeIndex],
endOffset: finalEndPoint.offset,
matchedWords: matchedWords
};
}
}
/**
* 在DOM节点内查找一个由多部分文本组成的、无序但邻近的序列
*/
function findUnorderedDOMSequence(rootNode, rule) {
const { parts: partsWithForms, isGeneral } = rule;
const HTML_TAG_PLACEHOLDER = '\u0001';
const ALLOWED_SEPARATORS_REGEX = /^[\s\u0001--﹣—–]*$/;
const WORD_CHAR_REGEX = /[a-zA-Z0-9]/;
const MAX_DISTANCE_FACTOR = 2.5;
const MAX_DISTANCE_BASE = 30;
const textMap = [];
let normalizedText = '';
const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
acceptNode: (node) => {
if (node.parentElement.closest('[data-glossary-applied="true"]')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
});
let node;
while ((node = walker.nextNode())) {
if (node.nodeType === Node.TEXT_NODE) {
const nodeValue = node.nodeValue;
for (let i = 0; i < nodeValue.length; i++) {
textMap.push({ node: node, offset: i });
}
normalizedText += nodeValue;
} else if (node.nodeType === Node.ELEMENT_NODE) {
if (['EM', 'STRONG', 'B', 'I', 'U', 'SPAN', 'CODE'].includes(node.tagName)) {
textMap.push({ node: node, offset: -1 });
normalizedText += HTML_TAG_PLACEHOLDER;
}
}
}
if (!normalizedText.trim()) return null;
const searchText = isGeneral ? normalizedText.toLowerCase() : normalizedText;
const originalTermLength = partsWithForms.map(p => p[0]).join(' ').length;
const maxDistance = Math.max(originalTermLength * MAX_DISTANCE_FACTOR, MAX_DISTANCE_BASE);
const partPositions = partsWithForms.map(partSet => {
const positions = [];
for (const form of partSet) {
const term = isGeneral ? form.toLowerCase() : form;
let lastIndex = -1;
while ((lastIndex = searchText.indexOf(term, lastIndex + 1)) !== -1) {
positions.push({ start: lastIndex, end: lastIndex + term.length });
}
}
return positions;
});
if (partPositions.some(p => p.length === 0)) {
return null;
}
function getCombinations(arr) {
if (arr.length === 1) {
return arr[0].map(item => [item]);
}
const result = [];
const allCasesOfRest = getCombinations(arr.slice(1));
for (let i = 0; i < allCasesOfRest.length; i++) {
for (let j = 0; j < arr[0].length; j++) {
result.push([arr[0][j]].concat(allCasesOfRest[i]));
}
}
return result;
}
const allCombinations = getCombinations(partPositions);
for (const combination of allCombinations) {
combination.sort((a, b) => a.start - b.start);
const overallStart = combination[0].start;
const overallEnd = combination[combination.length - 1].end;
if (overallEnd - overallStart > maxDistance) {
continue;
}
let isValid = true;
for (let i = 0; i < combination.length - 1; i++) {
const betweenText = normalizedText.substring(combination[i].end, combination[i + 1].start);
if (!ALLOWED_SEPARATORS_REGEX.test(betweenText)) {
isValid = false;
break;
}
}
if (isValid) {
const prevChar = normalizedText[overallStart - 1];
const nextChar = normalizedText[overallEnd];
const startBoundaryOK = !prevChar || !WORD_CHAR_REGEX.test(prevChar);
const endBoundaryOK = !nextChar || !WORD_CHAR_REGEX.test(nextChar);
if (startBoundaryOK && endBoundaryOK) {
const startMapping = textMap[overallStart];
const endMapping = textMap[overallEnd - 1];
if (startMapping && endMapping) {
return {
startNode: startMapping.node,
startOffset: startMapping.offset,
endNode: endMapping.node,
endOffset: endMapping.offset + 1
};
}
}
}
}
return null;
}
/**
* 预处理单个段落 DOM 节点,应用所有术语表规则并替换为占位符
*/
function _preprocessParagraph(p, preparedRules, placeholders, placeholderCache, engineName) {
const clone = p.cloneNode(true);
const { domRules, executionPlan } = preparedRules;
if (executionPlan && executionPlan.length > 0) {
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
if (node.parentElement.closest('[data-glossary-applied="true"]')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
});
const nodesToProcess = [];
let n;
while (n = walker.nextNode()) {
nodesToProcess.push(n);
}
while (nodesToProcess.length > 0) {
const currentNode = nodesToProcess.shift();
if (!currentNode.parentNode) continue;
const text = currentNode.nodeValue;
if (!text) continue;
let matchFound = false;
for (const planItem of executionPlan) {
const regex = planItem.regex || planItem.rule.regex;
regex.lastIndex = 0;
const match = regex.exec(text);
if (match) {
const fragment = document.createDocumentFragment();
const matchedText = match[0];
const matchIndex = match.index;
if (matchIndex > 0) {
fragment.appendChild(document.createTextNode(text.substring(0, matchIndex)));
}
let rule, finalValue;
if (planItem.type === 'combined') {
const captureIndex = match.slice(1).findIndex(m => m !== undefined);
rule = planItem.rules[captureIndex];
finalValue = rule.type === 'forbidden' ? matchedText : rule.replacement;
} else {
rule = planItem.rule;
if (rule.type === 'forbidden') {
finalValue = matchedText;
} else {
finalValue = matchedText.replace(regex, rule.replacement);
}
}
let placeholder;
if (placeholderCache.has(finalValue)) {
placeholder = placeholderCache.get(finalValue);
} else {
do {
placeholder = `ph_${generateRandomPlaceholder()}`;
} while (placeholders.has(placeholder));
placeholderCache.set(finalValue, placeholder);
placeholders.set(placeholder, { value: finalValue, rule: rule, originalHTML: matchedText });
}
fragment.appendChild(document.createTextNode(placeholder));
if (matchIndex + matchedText.length < text.length) {
fragment.appendChild(document.createTextNode(text.substring(matchIndex + matchedText.length)));
}
const newNodes = Array.from(fragment.childNodes).filter(n => n.nodeType === Node.TEXT_NODE && n.nodeValue);
if (newNodes.length > 0) {
nodesToProcess.unshift(...newNodes);
}
currentNode.parentNode.replaceChild(fragment, currentNode);
matchFound = true;
break;
}
}
}
}
if (domRules.length > 0) {
let domReplaced;
do {
domReplaced = false;
for (const rule of domRules) {
let match;
if (rule.isUnordered) {
match = findUnorderedDOMSequence(clone, rule);
} else {
match = findOrderedDOMSequence(clone, rule);
}
if (match) {
const range = document.createRange();
range.setStart(match.startNode, match.startOffset);
range.setEnd(match.endNode, match.endOffset);
const contents = range.extractContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(contents);
const originalHTML = tempDiv.innerHTML;
const finalValue = rule.type === 'forbidden' ? originalHTML : rule.replacement;
let placeholder;
if (placeholderCache.has(finalValue)) {
placeholder = placeholderCache.get(finalValue);
} else {
do {
placeholder = `ph_${generateRandomPlaceholder()}`;
} while (placeholders.has(placeholder));
placeholderCache.set(finalValue, placeholder);
placeholders.set(placeholder, { value: finalValue, rule: rule, originalHTML: originalHTML });
}
const placeholderNode = document.createTextNode(placeholder);
range.insertNode(placeholderNode);
clone.normalize();
domReplaced = true;
break;
}
}
} while (domReplaced);
}
return clone;
}
/**
* 替换一个 DOM 节点并完整保留所有 HTML 标签结构
*/
function replaceTextInNode(node, newText) {
if (node.nodeType === Node.TEXT_NODE) {
node.nodeValue = newText;
return;
}
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
const textNodes = [];
let currentNode;
while ((currentNode = walker.nextNode())) {
textNodes.push(currentNode);
}
if (textNodes.length > 0) {
textNodes[0].nodeValue = newText;
for (let i = 1; i < textNodes.length; i++) {
textNodes[i].nodeValue = '';
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
node.textContent = newText;
}
}
/**
* 译文后处理与占位符还原
*/
function _postprocessAndRestoreText(translatedText, placeholders) {
let processedText = translatedText;
try {
const fuzzyPlaceholderRegex = /(?:ph|p)[\s_\--_—::]*(\d{6})/gi;
processedText = processedText.replace(fuzzyPlaceholderRegex, (match, digits) => {
const standardPlaceholder = `ph_${digits}`;
if (placeholders.has(standardPlaceholder)) {
return standardPlaceholder;
}
return match;
});
} catch (e) {
Logger.warn('翻译', '占位符模糊还原出错', e);
}
if (placeholders.size === 0) {
return applyPostTranslationReplacements(processedText);
}
for (const [placeholder, data] of placeholders.entries()) {
const { value: replacement, originalHTML, rule } = data;
const escapedPlaceholder = placeholder.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(escapedPlaceholder, 'g');
if (rule.matchStrategy === 'dom' && originalHTML) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = originalHTML;
const htmlChunks = Array.from(tempDiv.childNodes).filter(node =>
!(node.nodeType === Node.TEXT_NODE && !node.nodeValue.trim())
);
let finalHTML = '';
if (htmlChunks.length === 1) {
const singleChunk = htmlChunks[0];
replaceTextInNode(singleChunk, replacement);
finalHTML = singleChunk.nodeType === Node.ELEMENT_NODE ? singleChunk.outerHTML : singleChunk.nodeValue;
} else {
const separator = replacement.includes('·') || replacement.includes('・') ? /[·・]/ : /[\s--﹣—–]+/;
const joinSeparator = replacement.includes('·') || replacement.includes('・') ? '·' : ' ';
const translationParts = replacement.split(separator);
if (htmlChunks.length === translationParts.length) {
htmlChunks.forEach((chunk, index) => {
const part = translationParts[index];
replaceTextInNode(chunk, part);
});
finalHTML = htmlChunks.map(chunk => {
return chunk.nodeType === Node.ELEMENT_NODE ? chunk.outerHTML : chunk.nodeValue;
}).join(joinSeparator);
} else {
tempDiv.innerHTML = originalHTML;
tempDiv.textContent = replacement;
finalHTML = tempDiv.innerHTML;
}
}
processedText = processedText.replace(regex, finalHTML);
} else {
processedText = processedText.replace(regex, replacement);
}
}
return applyPostTranslationReplacements(processedText);
}
/**
* 特殊字符标准化工具
*/
const TextNormalizer = {
smallCapsMap: {
'ᴀ': 'A', 'ʙ': 'B', 'ᴄ': 'C', 'ᴅ': 'D', 'ᴇ': 'E', 'ғ': 'F', 'ɢ': 'G', 'ʜ': 'H', 'ɪ': 'I',
'ᴊ': 'J', 'ᴋ': 'K', 'ʟ': 'L', 'ᴍ': 'M', 'ɴ': 'N', 'ᴏ': 'O', 'ᴘ': 'P', 'ǫ': 'Q', 'ʀ': 'R',
'ꜱ': 'S', 'ᴛ': 'T', 'ᴜ': 'U', 'ᴠ': 'V', 'ᴡ': 'W', 'ʏ': 'Y', 'ᴢ': 'Z',
'Ɪ': 'I', 'Ɡ': 'G', 'Ʞ': 'K', 'Ɬ': 'L', 'Ɜ': 'E', 'Ꞷ': 'W'
},
regex: null,
init() {
if (this.regex) return;
const chars = Object.keys(this.smallCapsMap).join('');
this.regex = new RegExp(`[${chars}]`, 'g');
},
normalizeNode(rootNode) {
this.init();
const walker = document.createTreeWalker(
rootNode,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
const nodes = [];
while (node = walker.nextNode()) {
nodes.push(node);
}
nodes.forEach(textNode => {
const text = textNode.nodeValue;
if (!text) return;
if (this.regex.test(text)) {
this.regex.lastIndex = 0;
const newText = text.replace(this.regex, (char) => this.smallCapsMap[char] || char);
textNode.nodeValue = newText;
}
});
}
};
/**
* 段落翻译主函数
*/
async function translateParagraphs(paragraphs, { maxRetries = 3, isCancelled = () => false, knownFromLang = null, reqId = 'Unknown', skipRateLimit = false } = {}) {
const createCancellationError = () => {
const error = new Error('用户已取消翻译。');
error.type = 'user_cancelled';
error.noRetry = true;
return error;
};
if (isCancelled()) throw createCancellationError();
if (!paragraphs || paragraphs.length === 0) return new Map();
const indexedParagraphs = paragraphs.map((p, index) => ({
original: p,
index: index,
isSeparator: p.tagName === 'HR' || /^\s*[-—*~<>=.]{3,}\s*$/.test(p.textContent),
content: p.innerHTML
}));
const contentToTranslate = indexedParagraphs.filter(p => !p.isSeparator);
if (contentToTranslate.length === 0) {
const results = new Map();
indexedParagraphs.forEach(p => results.set(p.original, { status: 'success', content: p.content }));
return results;
}
const engineName = getValidEngineName();
const preparedRules = await getPreparedGlossaryRules();
const placeholders = new Map();
const placeholderCache = new Map();
const preprocessedParagraphs = [];
for (let i = 0; i < contentToTranslate.length; i++) {
if (isCancelled()) throw createCancellationError();
const p = contentToTranslate[i];
const processedNode = _preprocessParagraph(p.original, preparedRules, placeholders, placeholderCache, engineName);
TextNormalizer.normalizeNode(processedNode);
preprocessedParagraphs.push(processedNode);
}
Logger.info('翻译', `[${reqId}] 任务开始`, {
engine: engineName,
paragraphs: contentToTranslate.length,
placeholders: placeholders.size
});
for (let retryCount = 0; retryCount <= maxRetries; retryCount++) {
try {
let combinedTranslation = await requestRemoteTranslation(preprocessedParagraphs, {
isCancelled,
knownFromLang,
reqId,
skipRateLimit
});
combinedTranslation = combinedTranslation.replace(/[\u200B\u200C\u200D\uFEFF]/g, '');
const fuzzyPlaceholderRegex = /(?:ph|p)[\s\-_]*(\d{6})/gi;
const suspectedPlaceholders = [];
let match;
while ((match = fuzzyPlaceholderRegex.exec(combinedTranslation)) !== null) {
suspectedPlaceholders.push(`ph_${match[1]}`);
}
const legalPlaceholders = new Set(placeholders.keys());
const actualCounts = {};
legalPlaceholders.forEach(key => actualCounts[key] = 0);
let hasUnknownPlaceholders = false;
for (const suspected of suspectedPlaceholders) {
if (legalPlaceholders.has(suspected)) {
actualCounts[suspected]++;
} else {
hasUnknownPlaceholders = true;
}
}
let absoluteLossThreshold, proportionalLossThreshold, proportionalTriggerCount, catastrophicLossThreshold;
const defaults = CONFIG.SERVICE_CONFIG[engineName]?.VALIDATION || CONFIG.SERVICE_CONFIG.default.VALIDATION;
if (engineName === 'google_translate' || engineName === 'bing_translator') {
absoluteLossThreshold = defaults.absolute_loss;
proportionalLossThreshold = defaults.proportional_loss;
proportionalTriggerCount = defaults.proportional_trigger_count;
catastrophicLossThreshold = defaults.catastrophic_loss || 5;
} else {
const params = ProfileManager.getParamsByEngine(engineName);
const parts = (params.validation_thresholds || '').split(/[,,]/).map(s => parseFloat(s.trim()));
const isValid = parts.length >= 3 && !parts.slice(0, 3).some(isNaN);
absoluteLossThreshold = isValid ? parts[0] : defaults.absolute_loss;
proportionalLossThreshold = isValid ? parts[1] : defaults.proportional_loss;
proportionalTriggerCount = isValid ? parts[2] : defaults.proportional_trigger_count;
catastrophicLossThreshold = (isValid && !isNaN(parts[3])) ? parts[3] : (defaults.catastrophic_loss || 5);
}
let hasMissingPlaceholders = false;
let totalLoss = 0;
const expectedCounts = {};
const preprocessedText = preprocessedParagraphs.map(p => p.innerHTML).join(' ');
for (const key of placeholders.keys()) {
expectedCounts[key] = (preprocessedText.match(new RegExp(key, 'g')) || []).length;
}
for (const key of legalPlaceholders) {
const expected = expectedCounts[key];
const actual = actualCounts[key];
const loss = expected - actual;
if (loss > 0) {
totalLoss += loss;
const isCatastrophicLoss = expected > catastrophicLossThreshold && actual === 0;
const isAbsoluteLoss = loss >= absoluteLossThreshold;
const isProportionalLoss = expected >= proportionalTriggerCount && (loss / expected) >= proportionalLossThreshold;
if (isCatastrophicLoss || isAbsoluteLoss || isProportionalLoss) {
hasMissingPlaceholders = true;
break;
}
}
}
if (hasMissingPlaceholders || hasUnknownPlaceholders) {
const errorReason = hasUnknownPlaceholders ? "检测到未知占位符" : "占位符大量缺失";
Logger.warn('翻译', `[${reqId}] 占位符校验失败: ${errorReason}`, { totalLoss });
const err = new Error(`占位符校验失败 (${errorReason})`);
err.type = 'validation_failed';
throw err;
}
const restoredTranslation = _postprocessAndRestoreText(combinedTranslation, placeholders, engineName);
let translatedParts = [];
if (contentToTranslate.length === 1 && !restoredTranslation.trim().startsWith('1.')) {
translatedParts.push(restoredTranslation.trim());
} else {
const regex = /\d+\.\s*([\s\S]*?)(?=\n\d+\.|$)/g;
let match;
while ((match = regex.exec(restoredTranslation)) !== null) {
translatedParts.push(match[1].trim());
}
if (translatedParts.length !== contentToTranslate.length && restoredTranslation.includes('\n')) {
const potentialParts = restoredTranslation.split('\n').filter(p => p.trim().length > 0);
if (potentialParts.length === contentToTranslate.length) {
translatedParts = potentialParts.map(p => p.replace(/^\d+\.\s*/, '').trim());
}
}
}
if (translatedParts.length !== contentToTranslate.length) {
throw new Error('AI 响应格式不一致,分段数量不匹配');
}
const finalResults = new Map();
indexedParagraphs.forEach(p => {
if (p.isSeparator) {
finalResults.set(p.original, { status: 'success', content: p.content });
} else {
const originalPara = contentToTranslate.find(item => item.index === p.index);
if (originalPara) {
const transIndex = contentToTranslate.indexOf(originalPara);
const cleanedContent = AdvancedTranslationCleaner.clean(translatedParts[transIndex] || p.content);
finalResults.set(p.original, { status: 'success', content: cleanedContent });
}
}
});
return finalResults;
} catch (e) {
if (isCancelled() || e.type === 'user_cancelled') {
throw createCancellationError();
}
if (e.noRetry) {
Logger.error('翻译', `[${reqId}] 发生不可重试错误`, e.message);
throw e;
}
if (retryCount < maxRetries) {
let baseDelay = 5000;
if (e.type === 'rate_limit' || e.type === 'server_overloaded') {
baseDelay = 10000;
} else if (e.type === 'validation_failed') {
baseDelay = 2000;
}
const delay = baseDelay * Math.pow(2, retryCount);
const jitter = delay * 0.2 * (Math.random() * 2 - 1);
const finalDelay = Math.floor(delay + jitter);
Logger.warn('翻译', `[${reqId}] 任务重试 (${retryCount + 1}/${maxRetries})`, {
reason: e.message,
waitingFor: `${Math.round(finalDelay / 1000)}s`
});
await sleep(finalDelay);
if (isCancelled()) throw createCancellationError();
continue;
}
Logger.error('翻译', `[${reqId}] 重试次数耗尽,任务失败: ${e.message}`);
if (e.message.includes('分段数量不匹配') && paragraphs.length > 1) {
Logger.warn('翻译', `[${reqId}] 触发逐段回退策略`);
if (isCancelled()) throw createCancellationError();
const fallbackResults = new Map();
for (const p of paragraphs) {
if (isCancelled()) break;
const singleResultMap = await translateParagraphs([p], {
maxRetries: 1,
isCancelled,
knownFromLang,
reqId: `${reqId}-FB`,
skipRateLimit
});
const singleResult = singleResultMap.get(p);
fallbackResults.set(p, singleResult || { status: 'error', content: '逐段翻译失败' });
}
return fallbackResults;
}
const finalResults = new Map();
indexedParagraphs.forEach(p => {
if (p.isSeparator) {
finalResults.set(p.original, { status: 'success', content: p.content });
} else {
finalResults.set(p.original, { status: 'error', content: `翻译失败:${e.message}` });
}
});
return finalResults;
}
}
}
/**
* 通用翻译控制器基座:管理状态切换、RunID 校验及 UI 更新
*/
function createBaseController(config) {
const { buttonWrapper, originalButtonText, onStart, onPause, onClear } = config;
const controller = {
state: 'idle',
currentRunId: 0,
updateButtonState: function (text, stateClass = '') {
if (buttonWrapper) {
const button = buttonWrapper.querySelector('div');
if (button) button.textContent = text;
buttonWrapper.className = `translate-me-ao3-wrapper ${stateClass}`;
}
},
getCancelChecker: function (runId) {
return () => this.state !== 'running' || this.currentRunId !== runId;
},
start: async function () {
if (this.state === 'running') return;
this.currentRunId++;
const myRunId = this.currentRunId;
this.state = 'running';
this.updateButtonState('翻译中…', 'state-running');
await sleep(10);
const isCancelled = this.getCancelChecker(myRunId);
const onDone = () => {
if (!isCancelled()) {
this.state = 'complete';
this.updateButtonState('清除译文', 'state-complete');
}
};
await onStart(isCancelled, onDone);
},
pause: function () {
if (this.state !== 'running') return;
this.state = 'paused';
this.currentRunId++;
if (onPause) onPause();
this.updateButtonState('暂停中…', 'state-paused');
},
resume: function () {
if (this.state !== 'paused') return;
this.start();
},
clear: function () {
this.state = 'idle';
this.currentRunId++;
if (onPause) onPause();
onClear();
this.updateButtonState(originalButtonText, 'state-idle');
},
handleClick: function () {
switch (this.state) {
case 'idle':
this.start();
break;
case 'running':
this.pause();
break;
case 'paused':
this.resume();
break;
case 'complete':
this.clear();
break;
}
}
};
return controller;
}
/**
* 块级文本翻译控制器(支持正文、评论、动态等)
*/
function createTranslationController(options) {
const { containerElement, buttonWrapper, originalButtonText, isLazyLoad } = options;
let activeTask = null;
let translatedTitleElement = null;
const controller = createBaseController({
buttonWrapper,
originalButtonText,
onStart: async (isCancelled, onDone) => {
let titleNode = null;
let titleTempDiv = null;
let isChapterTitle = false;
let prefaceGroup = containerElement.previousElementSibling;
while (prefaceGroup && !prefaceGroup.classList.contains('preface')) {
prefaceGroup = prefaceGroup.previousElementSibling;
}
if (!prefaceGroup && containerElement.parentElement.classList.contains('chapter')) {
prefaceGroup = containerElement.parentElement.querySelector('.chapter.preface.group');
}
if (prefaceGroup) {
const titleEl = prefaceGroup.querySelector('h3.title');
if (titleEl && !titleEl.nextElementSibling?.classList.contains('translated-title-element')) {
const fullText = titleEl.textContent.trim();
const simpleChapterRegex = /^(?:Chapter|第)\s*\d+\s*(?:章)?$/i;
const link = titleEl.querySelector('a');
let textToTranslate = fullText;
let isValid = true;
if (link) {
const linkText = link.textContent;
const remaining = fullText.replace(linkText, '').trim();
if (!remaining || /^[::]\s*$/.test(remaining)) {
isValid = false;
} else {
textToTranslate = remaining.replace(/^[::]\s*/, '');
}
} else {
if (simpleChapterRegex.test(fullText)) {
isValid = false;
}
}
if (isValid && textToTranslate) {
titleNode = titleEl;
titleTempDiv = document.createElement('div');
titleTempDiv.textContent = textToTranslate;
isChapterTitle = true;
}
}
}
if (!titleNode) {
const workPreface = containerElement.closest('.preface.group');
if (workPreface) {
const workTitleEl = workPreface.querySelector('h2.title.heading');
if (workTitleEl && !workTitleEl.nextElementSibling?.classList.contains('translated-title-element')) {
const clone = workTitleEl.cloneNode(true);
clone.querySelectorAll('img, svg').forEach(el => el.remove());
const textContent = clone.textContent.trim();
if (textContent) {
titleNode = workTitleEl;
titleTempDiv = document.createElement('div');
titleTempDiv.textContent = textContent;
isChapterTitle = false;
}
}
}
}
const customRenderers = new Map();
if (titleTempDiv) {
customRenderers.set(titleTempDiv, (_node, result) => {
if (result.status === 'success') {
const translatedTitle = titleNode.cloneNode(true);
translatedTitle.classList.add('translated-title-element');
translatedTitle.removeAttribute('id');
if (isChapterTitle) {
const link = translatedTitle.querySelector('a');
if (link) {
let next = link.nextSibling;
while (next) {
const toRemove = next;
next = next.nextSibling;
toRemove.remove();
}
translatedTitle.appendChild(document.createTextNode(`: ${result.content}`));
} else {
translatedTitle.textContent = result.content;
}
} else {
let textNodeFound = false;
Array.from(translatedTitle.childNodes).forEach(child => {
if (child.nodeType === Node.TEXT_NODE && child.nodeValue.trim()) {
if (!textNodeFound) {
child.nodeValue = ` ${result.content} `;
textNodeFound = true;
} else {
child.nodeValue = '';
}
}
});
if (!textNodeFound) {
translatedTitle.appendChild(document.createTextNode(result.content));
}
}
titleNode.after(translatedTitle);
translatedTitleElement = translatedTitle;
const currentMode = GM_getValue('translation_display_mode', 'bilingual');
if (currentMode === 'translation_only') {
titleNode.style.display = 'none';
}
}
});
}
const instanceState = {
elementState: new WeakMap(),
isFirstTranslationChunk: true,
};
activeTask = runUniversalTranslationEngine({
containerElement,
isCancelled,
onComplete: onDone,
instanceState,
useObserver: isLazyLoad,
prependNodes: titleTempDiv ? [titleTempDiv] : [],
customRenderers: customRenderers,
onRetry: () => {
const failedUnits = Array.from(containerElement.querySelectorAll('[data-translation-state="error"]'));
if (failedUnits.length === 0) return;
failedUnits.forEach(unit => {
const errorNode = unit.nextElementSibling;
if (errorNode && errorNode.classList.contains('translated-by-ao3-translator-error')) {
errorNode.remove();
}
delete unit.dataset.translationState;
});
if (controller.state === 'complete') {
controller.state = 'running';
controller.updateButtonState('翻译中…', 'state-running');
}
if (activeTask) {
activeTask.addUnits(failedUnits);
activeTask.scheduleProcessing(true);
}
}
});
},
onPause: () => {
if (activeTask && activeTask.disconnect) {
activeTask.disconnect();
}
activeTask = null;
containerElement.querySelectorAll('[data-translation-state="translating"]').forEach(unit => {
delete unit.dataset.translationState;
});
},
onClear: () => {
const internalNodes = containerElement.querySelectorAll('.translated-by-ao3-translator, .translated-by-ao3-translator-error');
internalNodes.forEach(node => node.remove());
let nextNode = containerElement.nextElementSibling;
while (nextNode && nextNode !== buttonWrapper) {
if (nextNode.classList.contains('translated-by-ao3-translator') || nextNode.classList.contains('translated-by-ao3-translator-error')) {
const nodeToRemove = nextNode;
nextNode = nextNode.nextElementSibling;
nodeToRemove.remove();
} else {
nextNode = nextNode.nextElementSibling;
}
}
containerElement.querySelectorAll('[data-translation-state]').forEach(unit => {
unit.style.display = '';
delete unit.dataset.translationState;
});
if (containerElement.dataset.translationState) {
containerElement.style.display = '';
delete containerElement.dataset.translationState;
}
if (translatedTitleElement) {
const originalTitle = translatedTitleElement.previousElementSibling;
if (originalTitle) originalTitle.style.display = '';
translatedTitleElement.remove();
translatedTitleElement = null;
}
}
});
return controller;
}
/**
* 标签区域翻译控制器
*/
function createTagsTranslationController(options) {
const { containerElement, buttonWrapper, originalButtonText } = options;
let errorElement = null;
const controller = createBaseController({
buttonWrapper,
originalButtonText,
onStart: async (isCancelled, onDone) => {
try {
await runTagsTranslationEngine(containerElement, isCancelled);
if (isCancelled()) return;
onDone();
} catch (error) {
if (isCancelled()) return;
onDone();
const errorDiv = document.createElement('div');
errorDiv.className = 'translated-by-ao3-translator-error';
errorDiv.style.margin = '15px 0';
errorDiv.innerHTML = `翻译失败:${error.message}`;
const retryBtn = document.createElement('span');
retryBtn.className = 'retry-translation-button';
retryBtn.title = '重试';
retryBtn.innerHTML = ``;
retryBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (errorElement) {
errorElement.remove();
errorElement = null;
}
controller.start();
});
errorDiv.appendChild(retryBtn);
buttonWrapper.before(errorDiv);
errorElement = errorDiv;
Logger.error('翻译', '标签翻译失败', error);
}
},
onClear: () => {
const translations = containerElement.querySelectorAll('.ao3-tag-translation');
translations.forEach(el => el.remove());
const originals = containerElement.querySelectorAll('.ao3-tag-original');
originals.forEach(el => el.style.display = '');
if (errorElement) {
errorElement.remove();
errorElement = null;
}
containerElement.style.display = '';
}
});
return controller;
}
/**
* 混合作品卡片(Blurb)翻译控制器,同步管理简介与标签的翻译任务
*/
function createBlurbTranslationController(options) {
const { summaryElement, tagsElement, buttonWrapper, originalButtonText } = options;
let errorElement = null;
let activeSummaryTask = null;
const controller = createBaseController({
buttonWrapper,
originalButtonText,
onStart: async (isCancelled, onDone) => {
try {
const tagsPromise = runTagsTranslationEngine(tagsElement, isCancelled);
const summaryPromise = new Promise((resolve) => {
const instanceState = {
elementState: new WeakMap(),
isFirstTranslationChunk: true,
};
activeSummaryTask = runUniversalTranslationEngine({
containerElement: summaryElement,
isCancelled,
onComplete: resolve,
instanceState,
useObserver: false,
onRetry: () => {
const failedUnits = Array.from(summaryElement.querySelectorAll('[data-translation-state="error"]'));
if (failedUnits.length === 0) return;
failedUnits.forEach(unit => {
const errorNode = unit.nextElementSibling;
if (errorNode && errorNode.classList.contains('translated-by-ao3-translator-error')) {
errorNode.remove();
}
delete unit.dataset.translationState;
});
if (controller.state === 'complete') {
controller.state = 'running';
controller.updateButtonState('翻译中…', 'state-running');
}
if (activeSummaryTask) {
activeSummaryTask.addUnits(failedUnits);
activeSummaryTask.scheduleProcessing(true);
}
}
});
});
await Promise.all([tagsPromise, summaryPromise]);
if (isCancelled()) return;
onDone();
} catch (error) {
if (isCancelled()) return;
onDone();
const errorDiv = document.createElement('div');
errorDiv.className = 'translated-by-ao3-translator-error';
errorDiv.style.margin = '15px 0';
errorDiv.innerHTML = `翻译失败:${error.message}`;
const retryBtn = document.createElement('span');
retryBtn.className = 'retry-translation-button';
retryBtn.title = '重试';
retryBtn.innerHTML = ``;
retryBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (errorElement) {
errorElement.remove();
errorElement = null;
}
controller.start();
});
errorDiv.appendChild(retryBtn);
buttonWrapper.before(errorDiv);
errorElement = errorDiv;
Logger.error('翻译', 'Blurb 翻译失败', error);
}
},
onPause: () => {
if (activeSummaryTask && activeSummaryTask.disconnect) {
activeSummaryTask.disconnect();
}
activeSummaryTask = null;
summaryElement.querySelectorAll('[data-translation-state="translating"]').forEach(unit => {
delete unit.dataset.translationState;
});
},
onClear: () => {
const internalTranslationNodes = summaryElement.querySelectorAll('.translated-by-ao3-translator, .translated-by-ao3-translator-error');
internalTranslationNodes.forEach(node => node.remove());
let nextNode = summaryElement.nextSibling;
while (nextNode && (nextNode.classList?.contains('translated-by-ao3-translator') || nextNode.classList?.contains('translated-by-ao3-translator-error'))) {
const nodeToRemove = nextNode;
nextNode = nextNode.nextSibling;
nodeToRemove.remove();
}
summaryElement.querySelectorAll('[data-translation-state]').forEach(unit => {
unit.style.display = '';
delete unit.dataset.translationState;
});
const translations = tagsElement.querySelectorAll('.ao3-tag-translation');
translations.forEach(el => el.remove());
const originals = tagsElement.querySelectorAll('.ao3-tag-original');
originals.forEach(el => el.style.display = '');
if (errorElement) {
errorElement.remove();
errorElement = null;
}
tagsElement.style.display = '';
if (tagsElement.parentElement.classList.contains('wrapper') && tagsElement.tagName === 'DL') {
tagsElement.parentElement.style.display = '';
}
}
});
return controller;
}
/**
* 标签区域翻译引擎
*/
async function runTagsTranslationEngine(containerElement, isCancelled) {
if (isCancelled()) return null;
const targetSelectors = [
'dd.fandom a.tag', 'dd.relationship a.tag', 'dd.character a.tag', 'dd.freeform a.tag',
'dd.series a:not(.previous):not(.next)', // <--- 修改了这一行
'dd.collections a', 'dd.language', 'li.fandoms a.tag',
'li.relationships a.tag', 'li.characters a.tag', 'li.freeforms a.tag',
'a.tag:not(.rating):not(.warning):not(.category)'
];
const nodesToTranslate = [];
const wrapperMap = new Map();
const processedElements = new Set();
targetSelectors.forEach(selector => {
containerElement.querySelectorAll(selector).forEach(el => {
if (processedElements.has(el)) return;
processedElements.add(el);
if (el.closest('.rating, .warnings, .category, .warning')) return;
if (el.querySelector('.ao3-tag-translation')) return;
const text = el.textContent.trim();
const fullDictionary = {
...pageConfig.staticDict,
...pageConfig.globalFlexibleDict,
...pageConfig.pageFlexibleDict
};
if (text && !/^\d+$/.test(text) && !fullDictionary[text]) {
let originalSpan = el.querySelector('.ao3-tag-original');
if (!originalSpan) {
originalSpan = document.createElement('span');
originalSpan.className = 'ao3-tag-original';
while (el.firstChild) {
originalSpan.appendChild(el.firstChild);
}
el.appendChild(originalSpan);
}
nodesToTranslate.push(originalSpan);
wrapperMap.set(originalSpan, el);
}
});
});
if (nodesToTranslate.length > 0) {
try {
const reqId = 'Tags-' + Math.random().toString(36).substring(2, 6).toUpperCase();
const translationResults = await translateParagraphs(nodesToTranslate, { isCancelled, reqId });
if (isCancelled()) return null;
nodesToTranslate.forEach(originalSpan => {
const result = translationResults.get(originalSpan);
const parentLink = wrapperMap.get(originalSpan);
if (result && result.status === 'success' && parentLink) {
if (parentLink.querySelector('.ao3-tag-translation')) return;
const translationSpan = document.createElement('span');
translationSpan.className = 'ao3-tag-translation';
const cleanedContent = result.content.trim().replace(/[。\.]$/, '');
translationSpan.textContent = cleanedContent;
parentLink.appendChild(translationSpan);
}
});
} catch (error) {
if (isCancelled()) return null;
throw error;
}
}
const currentMode = GM_getValue('translation_display_mode', 'bilingual');
applyDisplayModeChange(currentMode);
return containerElement;
}
/**
* DOM 处理与遍历器
*/
class DOMNormalizer {
constructor() {
this.elementState = new WeakMap();
this.splitThreshold = 200;
this.yieldInterval = 30;
}
async *generateUnits(container) {
const splitSelectors = 'p, blockquote';
const elementsToSplit = Array.from(container.querySelectorAll(splitSelectors))
.filter(el => !this._isTranslated(el));
for (let i = 0; i < elementsToSplit.length; i++) {
const el = elementsToSplit[i];
if (!this.elementState.has(el) && el.textContent.length > this.splitThreshold && el.querySelector('br')) {
const newElements = this._splitElement(el);
if (newElements.length > 0) el.replaceWith(...newElements);
} else {
this.elementState.set(el, { preprocessed: true });
}
if ((i + 1) % this.yieldInterval === 0) await sleep(0);
}
const liElements = Array.from(container.querySelectorAll('li'));
for (let i = 0; i < liElements.length; i++) {
this._wrapListContent(liElements[i]);
if ((i + 1) % this.yieldInterval === 0) await sleep(0);
}
const selectors = 'p, blockquote, li, h1, h2, h3, h4, h5, h6, hr, center';
const skipHeaders = ['Summary', 'Notes', 'Work Text', 'Chapter Text'];
const candidates = Array.from(container.querySelectorAll(selectors))
.filter(el => !this._isTranslated(el));
let yieldedAny = false;
for (let i = 0; i < candidates.length; i++) {
const unit = candidates[i];
const content = unit.textContent.trim();
if (!content && unit.tagName !== 'HR') continue;
if (skipHeaders.includes(content)) continue;
if (unit.querySelector(selectors)) continue;
yieldedAny = true;
yield unit;
if ((i + 1) % this.yieldInterval === 0) await sleep(0);
}
if (!yieldedAny && container.textContent.trim().length > 0) {
yield container;
}
}
_isTranslated(el) {
return el.closest('.translated-by-ao3-translator, .translated-by-ao3-translator-error');
}
_splitElement(el) {
const newElements = [];
let contentBuffer = [];
const flushBuffer = () => {
if (contentBuffer.length === 0) return;
const hasContent = contentBuffer.some(node =>
(node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) ||
(node.nodeType === Node.ELEMENT_NODE)
);
if (hasContent) {
const newEl = document.createElement(el.tagName);
if (el.className) newEl.className = el.className;
contentBuffer.forEach(node => newEl.appendChild(node));
this.elementState.set(newEl, { preprocessed: true });
newElements.push(newEl);
}
contentBuffer = [];
};
Array.from(el.childNodes).forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') flushBuffer();
else contentBuffer.push(node);
});
flushBuffer();
return newElements;
}
_wrapListContent(li) {
if (li.querySelector('ul, ol')) {
const childNodes = Array.from(li.childNodes);
let contentBuffer = [];
const flushBuffer = () => {
if (contentBuffer.length === 0) return;
if (contentBuffer.some(n => (n.nodeType === Node.TEXT_NODE && n.nodeValue.trim().length > 0) || (n.nodeType === Node.ELEMENT_NODE && n.textContent.trim().length > 0))) {
const p = document.createElement('p');
p.style.margin = '0'; p.style.padding = '0'; p.style.display = 'inline-block'; p.style.width = '100%';
contentBuffer[0].parentNode.insertBefore(p, contentBuffer[0]);
contentBuffer.forEach(n => p.appendChild(n));
this.elementState.set(p, { preprocessed: true });
}
contentBuffer = [];
};
childNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE && (node.tagName === 'UL' || node.tagName === 'OL')) flushBuffer();
else contentBuffer.push(node);
});
flushBuffer();
}
}
}
/**
* 优先级队列管理器
*/
class PriorityQueueManager {
constructor(prependNodes = []) {
this.queue = new Set();
this.prependSet = new Set(prependNodes);
prependNodes.forEach(n => this.queue.add(n));
}
add(unit, delay = 0) {
if (!unit.dataset.translationState) {
if (delay > 0) {
unit.dataset.readyAt = Date.now() + delay;
} else {
delete unit.dataset.readyAt;
}
this.queue.add(unit);
}
}
remove(unit) {
this.queue.delete(unit);
delete unit.dataset.readyAt;
}
get size() {
return this.queue.size;
}
getSortedList() {
if (this.queue.size === 0) return [];
const high = [];
const medium = [];
const low = [];
const viewportHeight = window.innerHeight;
const now = Date.now();
for (const unit of this.queue) {
if (unit.dataset.readyAt) {
const readyAt = parseInt(unit.dataset.readyAt, 10);
if (now < readyAt) {
continue;
}
}
if (this.prependSet.has(unit)) {
high.push(unit);
continue;
}
const rect = unit.getBoundingClientRect();
const isVisible = (rect.top < viewportHeight && rect.bottom >= 0);
if (isVisible) {
medium.push(unit);
} else {
low.push(unit);
}
}
return [...high, ...medium, ...low];
}
}
/**
* 分包策略
*/
class BatchStrategy {
constructor(configProvider) {
this.config = configProvider;
}
createBatch(sortedList) {
if (sortedList.length === 0) return { batch: [], reason: 'empty' };
const { chunkSize, paragraphLimit } = this.config.getLimits();
const batch = [];
let currentChars = 0;
let reason = 'underfilled';
for (const unit of sortedList) {
const isSeparator = unit.tagName === 'HR' || /^\s*[-—*~<>=.]{3,}\s*$/.test(unit.textContent);
if (isSeparator) {
if (batch.length > 0) {
reason = 'separator_cut';
break;
} else {
batch.push(unit);
reason = 'separator_single';
break;
}
}
batch.push(unit);
currentChars += unit.textContent.length;
if (batch.length >= paragraphLimit || currentChars >= chunkSize) {
reason = 'full';
break;
}
}
return { batch, reason };
}
}
/**
* 渲染代理
*/
class RenderDelegate {
constructor(customRenderersMap, displayModeGetter) {
this.customRenderers = customRenderersMap || new Map();
this.getDisplayMode = displayModeGetter;
}
applyResult(unit, result, onRetry) {
if (this.customRenderers.has(unit)) {
try {
const renderer = this.customRenderers.get(unit);
renderer(unit, result);
unit.dataset.translationState = 'translated';
} catch (e) {
Logger.error('翻译', '自定义渲染失败', e);
unit.dataset.translationState = 'error';
}
return;
}
const transNode = document.createElement('div');
const newTranslatedElement = unit.cloneNode(false);
newTranslatedElement.removeAttribute('data-translation-state');
if (result.status === 'success') {
delete unit.dataset.batchRetryCount;
transNode.className = 'translated-by-ao3-translator';
newTranslatedElement.innerHTML = result.content;
if (this.getDisplayMode() === 'translation_only') {
unit.style.display = 'none';
}
unit.dataset.translationState = 'translated';
} else {
transNode.className = 'translated-by-ao3-translator-error';
newTranslatedElement.innerHTML = `翻译失败:${result.content.replace('翻译失败:', '')}`;
unit.dataset.translationState = 'error';
const retryBtn = this._createRetryButton(unit, onRetry);
newTranslatedElement.appendChild(retryBtn);
}
transNode.appendChild(newTranslatedElement);
unit.after(transNode);
}
_createRetryButton(_unit, onRetry) {
const span = document.createElement('span');
span.className = 'retry-translation-button';
span.title = '重试';
span.innerHTML = ``;
span.addEventListener('click', (e) => {
e.stopPropagation();
onRetry();
});
return span;
}
}
/**
* 资源管理器
*/
class ResourceManager {
constructor(engineName, maxConcurrency = 5) {
this.engineName = engineName;
this.maxConcurrency = maxConcurrency;
this.activeCount = 0;
this.tokenBucket = new GlobalTokenBucket();
}
canSchedule() {
return this.activeCount < this.maxConcurrency;
}
async acquireToken() {
if (!this.canSchedule()) {
return { success: false, waitTime: 1000 };
}
const result = await this.tokenBucket.consume(1, this.engineName);
if (result.success) {
this.activeCount++;
}
return result;
}
release() {
this.activeCount = Math.max(0, this.activeCount - 1);
}
reportError(error) {
if (error.type === 'rate_limit' || error.type === 'server_overloaded' || (error.message && (error.message.includes('429') || error.message.includes('503')))) {
const freezeTime = 10000;
this.tokenBucket.triggerFreeze(freezeTime);
}
}
}
/**
* 通用翻译调度引擎
*/
class UniversalEngine {
constructor(options) {
this.container = options.containerElement;
this.isCancelled = options.isCancelled;
this.onComplete = options.onComplete;
this.onProgress = options.onProgress;
this.onRetryCallback = options.onRetry;
this.normalizer = new DOMNormalizer();
this.queueManager = new PriorityQueueManager(options.prependNodes);
const engine = getValidEngineName();
this.batchStrategy = new BatchStrategy({
getLimits: () => {
const isSpecial = ['google_translate', 'bing_translator'].includes(engine);
const base = CONFIG.SERVICE_CONFIG[engine] || CONFIG.SERVICE_CONFIG.default;
if (isSpecial) {
return {
chunkSize: base.CHUNK_SIZE,
paragraphLimit: base.PARAGRAPH_LIMIT
};
}
const params = ProfileManager.getParamsByEngine(engine);
return {
chunkSize: params.chunk_size,
paragraphLimit: params.para_limit
};
}
});
this.renderer = new RenderDelegate(options.customRenderers, () => GM_getValue('translation_display_mode'));
this.resourceManager = new ResourceManager(engine);
this.observer = null;
this.timer = null;
this.detectedLang = null;
this.totalUnits = 0;
this.processedUnits = 0;
this.prependNodes = options.prependNodes || [];
this.hasSkippedFirstLimit = false;
}
async start(useObserver = true) {
const unitGenerator = this.normalizer.generateUnits(this.container);
if (useObserver) {
const rootMargin = this._getRootMargin();
this.observer = new IntersectionObserver((entries) => {
if (this.isCancelled()) return;
let added = false;
entries.forEach(entry => {
if (entry.isIntersecting && !entry.target.dataset.translationState) {
this.queueManager.add(entry.target);
added = true;
}
});
if (added) this.schedule(false);
}, { rootMargin });
}
let sampleBatch = [];
let detectionAttempted = false;
for await (const unit of unitGenerator) {
if (this.isCancelled()) break;
if (unit.dataset.translationState) continue;
this.totalUnits++;
if (!this.detectedLang && !detectionAttempted && !this.prependNodes.includes(unit)) {
sampleBatch.push(unit);
if (sampleBatch.length >= 5) {
await this._detectLanguage(sampleBatch);
detectionAttempted = true;
sampleBatch = [];
}
}
if (useObserver) {
this.observer.observe(unit);
if (this._isInViewport(unit)) this.queueManager.add(unit);
} else {
this.queueManager.add(unit);
}
if (this.totalUnits <= 10 || this.totalUnits % 50 === 0) {
this.schedule(this.totalUnits <= 10);
}
}
if (!this.detectedLang && !detectionAttempted && sampleBatch.length > 0) {
await this._detectLanguage(sampleBatch);
}
if (!useObserver) {
this.schedule(true);
}
if (this.totalUnits === 0) {
this._finish();
}
}
schedule(force = false) {
if (this.isCancelled()) return;
clearTimeout(this.timer);
this.timer = setTimeout(() => this.runLoop(force), 100);
}
async runLoop(force) {
if (this.isCancelled()) return;
if (!this.resourceManager.canSchedule()) return;
if (this.queueManager.size === 0) {
if (this.processedUnits >= this.totalUnits) this._finish();
return;
}
const sortedList = this.queueManager.getSortedList();
const { batch, reason } = this.batchStrategy.createBatch(sortedList);
if (batch.length === 0) {
if (this.queueManager.size > 0) {
this.timer = setTimeout(() => this.schedule(true), 1000);
}
return;
}
const isFull = reason === 'full';
const isSeparatorAction = reason.startsWith('separator');
if (!force && !isFull && !isSeparatorAction) {
this.timer = setTimeout(() => this.schedule(true), 4000);
return;
}
const tokenResult = await this.resourceManager.acquireToken();
if (!tokenResult.success) {
setTimeout(() => this.schedule(force), tokenResult.waitTime + 50);
return;
}
this._executeBatch(batch);
if (this.resourceManager.canSchedule() && this.queueManager.size > 0) {
this.schedule(false);
}
}
async _executeBatch(batch) {
batch.forEach(el => {
this.queueManager.remove(el);
if (this.observer) this.observer.unobserve(el);
el.dataset.translationState = 'translating';
});
try {
const validUnits = batch.filter(el => el.tagName !== 'HR' && el.textContent.trim());
const reqId = `Batch-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
let results = new Map();
if (validUnits.length > 0) {
const isFirstBatch = !this.hasSkippedFirstLimit;
if (isFirstBatch) {
this.hasSkippedFirstLimit = true;
}
results = await translateParagraphs(validUnits, {
isCancelled: this.isCancelled,
knownFromLang: this.detectedLang,
reqId: reqId,
maxRetries: 3,
skipRateLimit: isFirstBatch
});
}
batch.forEach(el => {
if (el.dataset.translationState !== 'translating') {
return;
}
this.processedUnits++;
if (el.tagName === 'HR') {
el.dataset.translationState = 'translated';
return;
}
const res = results.get(el) || { status: 'error', content: 'Unknown error' };
this.renderer.applyResult(el, res, this.onRetryCallback);
});
} catch (e) {
if (!this.isCancelled() && e.type !== 'user_cancelled') {
Logger.error('翻译', '批次执行发生未捕获错误', e);
batch.forEach(el => {
if (el.dataset.translationState !== 'translating') {
return;
}
this.processedUnits++;
this.renderer.applyResult(el, { status: 'error', content: e.message }, this.onRetryCallback);
});
}
} finally {
this.resourceManager.release();
if (this.onProgress) this.onProgress(this.processedUnits, this.totalUnits);
this.schedule(false);
}
}
async _detectLanguage(samples) {
const userSelectedFromLang = GM_getValue('from_lang', 'auto');
if (userSelectedFromLang === 'script_auto') {
const textToDetect = samples.map(p => p.textContent).join(' ').substring(0, 200);
this.detectedLang = await LanguageDetectService.detect(textToDetect);
Logger.info('翻译', `自动检测源语言: ${this.detectedLang}`);
} else {
this.detectedLang = userSelectedFromLang;
}
}
_getRootMargin() {
const engineName = getValidEngineName();
const isSpecial = ['google_translate', 'bing_translator'].includes(engineName);
const base = CONFIG.SERVICE_CONFIG[engineName] || CONFIG.SERVICE_CONFIG.default;
if (isSpecial) {
return base.LAZY_LOAD_ROOT_MARGIN;
}
const params = ProfileManager.getParamsByEngine(engineName);
return params.lazy_load_margin;
}
_isInViewport(el) {
const rect = el.getBoundingClientRect();
return (rect.top < window.innerHeight && rect.bottom >= 0);
}
_finish() {
if (this.observer) this.observer.disconnect();
if (this.onComplete) this.onComplete();
}
disconnect() {
if (this.observer) this.observer.disconnect();
}
addUnits(units) {
units.forEach(u => this.queueManager.add(u));
this.processedUnits = Math.max(0, this.processedUnits - units.length);
}
scheduleProcessing(force) {
this.schedule(force);
}
}
/**
* 通用翻译引擎启动入口
*/
function runUniversalTranslationEngine(options) {
const engine = new UniversalEngine(options);
const useObserver = options.useObserver !== false;
engine.start(useObserver);
return {
disconnect: () => engine.disconnect(),
addUnits: (units) => engine.addUnits(units),
scheduleProcessing: (force) => engine.scheduleProcessing(force)
};
}
/**
* 各种术语表变量
*/
const CUSTOM_GLOSSARIES_KEY = 'ao3_custom_glossaries';
const IMPORTED_GLOSSARY_KEY = 'ao3_imported_glossary';
const GLOSSARY_METADATA_KEY = 'ao3_glossary_metadata';
const ONLINE_GLOSSARY_ORDER_KEY = 'ao3_online_glossary_order';
const POST_REPLACE_STRING_KEY = 'ao3_post_replace_string';
const POST_REPLACE_MAP_KEY = 'ao3_post_replace_map';
const POST_REPLACE_RULES_KEY = 'ao3_post_replace_rules';
const POST_REPLACE_SELECTED_ID_KEY = 'ao3_post_replace_selected_id';
const POST_REPLACE_EDIT_MODE_KEY = 'ao3_post_replace_edit_mode';
const LAST_SELECTED_GLOSSARY_KEY = 'ao3_last_selected_glossary_url';
const GLOSSARY_RULES_CACHE_KEY = 'ao3_glossary_rules_cache';
const GLOSSARY_STATE_VERSION_KEY = 'ao3_glossary_state_version';
/**
* 解析自定义的、非 JSON 格式的术语表文本
*/
function parseCustomGlossaryFormat(text) {
const result = {
metadata: {},
terms: {},
generalTerms: {},
multiPartTerms: {},
multiPartGeneralTerms: {},
forbiddenTerms: [],
regexTerms: []
};
const lines = text.split('\n');
const sectionHeaders = {
TERMS: ['terms', '词条'],
GENERAL_TERMS: ['general terms', '通用词条'],
FORBIDDEN_TERMS: ['forbidden terms', '禁翻词条'],
REGEX_TERMS: ['regex', '正则表达式']
};
const sections = [];
let metadataLines = [];
let inMetadata = true;
for (let i = 0; i < lines.length; i++) {
const trimmedLine = lines[i].trim().toLowerCase().replace(/[::\s]*$/, '');
let isHeader = false;
for (const key in sectionHeaders) {
if (sectionHeaders[key].includes(trimmedLine)) {
sections.push({ type: key, start: i + 1 });
isHeader = true;
inMetadata = false;
break;
}
}
if (inMetadata && lines[i].trim()) {
metadataLines.push(lines[i]);
}
}
const metadataRegex = /^\s*(maintainer|version|last_updated|维护者|版本号|更新时间)\s*[::]\s*(.*?)\s*[,,]?\s*$/;
for (const line of metadataLines) {
const metadataMatch = line.match(metadataRegex);
if (metadataMatch) {
let key = metadataMatch[1].trim();
let value = metadataMatch[2].trim();
const keyMap = { '维护者': 'maintainer', '版本号': 'version', '更新时间': 'last_updated' };
result.metadata[keyMap[key] || key] = value;
}
}
const processLine = (line, target, multiPartTarget) => {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('//')) return;
const multiPartParts = trimmedLine.split(/[==]/, 2);
if (multiPartParts.length === 2) {
const key = multiPartParts[0].trim();
const value = multiPartParts[1].trim().replace(/[,,]$/, '');
if (key && value) multiPartTarget[key] = value;
return;
}
const singleParts = trimmedLine.split(/[::]/, 2);
if (singleParts.length === 2) {
const key = singleParts[0].trim();
const value = singleParts[1].trim().replace(/[,,]$/, '');
if (key && value) target[key] = value;
}
};
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
const end = (i + 1 < sections.length) ? sections[i + 1].start - 1 : lines.length;
const sectionLines = lines.slice(section.start, end);
for (const line of sectionLines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('//')) continue;
switch (section.type) {
case 'TERMS':
processLine(line, result.terms, result.multiPartTerms);
break;
case 'GENERAL_TERMS':
processLine(line, result.generalTerms, result.multiPartGeneralTerms);
break;
case 'FORBIDDEN_TERMS':
const term = trimmedLine.replace(/[,,]$/, '');
if (term) result.forbiddenTerms.push(term);
break;
case 'REGEX_TERMS':
const match = trimmedLine.match(/^(.+?)\s*[::]\s*(.*)$/s);
if (match) {
const pattern = match[1].trim();
const replacement = match[2].trim().replace(/[,,]$/, '');
if (pattern) {
result.regexTerms.push({ pattern, replacement });
}
}
break;
}
}
}
if (!result.metadata.version) {
throw new Error('文件格式错误:必须在文件头部包含 "版本号" 或 "version" 字段。');
}
if (Object.keys(result.terms).length === 0 && Object.keys(result.generalTerms).length === 0 &&
Object.keys(result.multiPartTerms).length === 0 && Object.keys(result.multiPartGeneralTerms).length === 0 &&
result.forbiddenTerms.length === 0 && result.regexTerms.length === 0) {
throw new Error('文件格式错误:必须包含至少一个有效词条区域 (词条, 通用词条, 禁翻词条, 正则表达式)。');
}
return result;
}
/**
* 从 GitHub 或 jsDelivr 导入在线术语表文件
*/
function importOnlineGlossary(url, options = {}) {
const { silent = false } = options;
return new Promise((resolve) => {
if (!url || !url.trim()) {
return resolve({ success: false, name: '未知', message: 'URL 不能为空。' });
}
const glossaryUrlRegex = /^(https:\/\/(raw\.githubusercontent\.com\/[^\/]+\/[^\/]+\/(?:refs\/heads\/)?[^\/]+|cdn\.jsdelivr\.net\/gh\/[^\/]+\/[^\/]+@[^\/]+)\/.+)$/;
if (!glossaryUrlRegex.test(url)) {
const message = "链接格式不正确。请输入一个有效的 GitHub Raw 或 jsDelivr 链接。";
if (!silent) alert(message);
return resolve({ success: false, name: url, message });
}
const filename = url.split('/').pop();
const lastDotIndex = filename.lastIndexOf('.');
const baseName = (lastDotIndex > 0) ? filename.substring(0, lastDotIndex) : filename;
const glossaryName = decodeURIComponent(baseName);
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: function (response) {
if (response.status !== 200) {
const message = `下载 ${glossaryName} 术语表失败!服务器返回状态码: ${response.status}`;
if (!silent) notifyAndLog(message, '导入错误', 'error');
return resolve({ success: false, name: glossaryName, message });
}
try {
const onlineData = parseCustomGlossaryFormat(response.responseText);
const allImportedGlossaries = GM_getValue(IMPORTED_GLOSSARY_KEY, {});
allImportedGlossaries[url] = {
terms: onlineData.terms,
generalTerms: onlineData.generalTerms,
multiPartTerms: onlineData.multiPartTerms,
multiPartGeneralTerms: onlineData.multiPartGeneralTerms,
forbiddenTerms: onlineData.forbiddenTerms,
regexTerms: onlineData.regexTerms
};
GM_setValue(IMPORTED_GLOSSARY_KEY, allImportedGlossaries);
const metadata = GM_getValue(GLOSSARY_METADATA_KEY, {});
const existingMetadata = metadata[url] || {};
metadata[url] = { ...existingMetadata, ...onlineData.metadata, last_imported: getShanghaiTimeString() };
if (typeof metadata[url].enabled !== 'boolean') {
metadata[url].enabled = true;
}
GM_setValue(GLOSSARY_METADATA_KEY, metadata);
invalidateGlossaryCache();
const importedCount = Object.keys(onlineData.terms).length + Object.keys(onlineData.generalTerms).length +
Object.keys(onlineData.multiPartTerms).length + Object.keys(onlineData.multiPartGeneralTerms).length +
onlineData.regexTerms.length;
const message = `已成功导入 ${glossaryName} 术语表,共 ${importedCount} 个词条。版本号:v${onlineData.metadata.version || '未知'},维护者:${onlineData.metadata.maintainer || '未知'}。`;
if (!silent) {
notifyAndLog(message, '导入成功');
}
resolve({ success: true, name: glossaryName, message });
} catch (e) {
const message = `导入 ${glossaryName} 术语表失败:${e.message}`;
if (!silent) notifyAndLog(message, '处理错误', 'error');
resolve({ success: false, name: glossaryName, message });
}
},
onerror: function () {
const message = `下载 ${glossaryName} 术语表失败!请检查网络连接或链接。`;
if (!silent) notifyAndLog(message, '网络错误', 'error');
resolve({ success: false, name: glossaryName, message });
}
});
});
}
/**
* 比较版本号的函数
*/
function compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
const len = Math.max(parts1.length, parts2.length);
for (let i = 0; i < len; i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 > p2) return 1;
if (p1 < p2) return -1;
}
return 0;
}
/**
* 检查术语表更新
*/
async function checkForGlossaryUpdates() {
const metadata = GM_getValue(GLOSSARY_METADATA_KEY, {});
const urls = Object.keys(metadata);
if (urls.length === 0) {
return;
}
for (const url of urls) {
try {
const response = await new Promise((resolve, reject) => {
const urlWithCacheBust = url + '?t=' + new Date().getTime();
GM_xmlhttpRequest({ method: 'GET', url: urlWithCacheBust, onload: resolve, onerror: reject, ontimeout: reject });
});
if (response.status !== 200) {
throw new Error(`服务器返回状态码: ${response.status}`);
}
const onlineData = parseCustomGlossaryFormat(response.responseText);
const currentMetadata = GM_getValue(GLOSSARY_METADATA_KEY, {});
const localVersion = currentMetadata[url]?.version;
const onlineVersion = onlineData.metadata.version;
const glossaryName = decodeURIComponent(url.split('/').pop().replace(/\.[^/.]+$/, ''));
if (!localVersion || compareVersions(onlineVersion, localVersion) > 0) {
const allImportedGlossaries = GM_getValue(IMPORTED_GLOSSARY_KEY, {});
allImportedGlossaries[url] = {
terms: onlineData.terms,
generalTerms: onlineData.generalTerms,
multiPartTerms: onlineData.multiPartTerms,
multiPartGeneralTerms: onlineData.multiPartGeneralTerms,
forbiddenTerms: onlineData.forbiddenTerms,
regexTerms: onlineData.regexTerms
};
currentMetadata[url] = { ...onlineData.metadata, last_updated: getShanghaiTimeString() };
GM_setValue(IMPORTED_GLOSSARY_KEY, allImportedGlossaries);
GM_setValue(GLOSSARY_METADATA_KEY, currentMetadata);
invalidateGlossaryCache();
Logger.info('数据', `术语表 ${glossaryName} 更新成功: v${localVersion} -> v${onlineVersion}`);
GM_notification(`检测到术语表 ${glossaryName} 新版本,已自动更新至 v${onlineVersion} 。`, 'AO3 Translator');
} else {
Logger.info('数据', `术语表 ${glossaryName} 已是最新版本 (v${localVersion})`);
}
} catch (e) {
Logger.warn('数据', `检查术语表更新失败 (${url})`, e.message);
}
}
}
/**
* 获取术语表规则,优先从缓存读取
*/
function getGlossaryRules() {
const cache = GM_getValue(GLOSSARY_RULES_CACHE_KEY, null);
const currentStateVersion = GM_getValue(GLOSSARY_STATE_VERSION_KEY, 0);
const currentScriptVersion = GM_info.script.version;
if (cache &&
cache.version === currentStateVersion &&
cache.scriptVersion === currentScriptVersion &&
cache.rules) {
Logger.info('数据', '命中术语表规则缓存');
return cache.rules.map(rule => {
if (rule.regex && typeof rule.regex === 'object' && rule.regex.source) {
try {
return { ...rule, regex: new RegExp(rule.regex.source, rule.regex.flags) };
} catch (e) {
return null;
}
}
return rule;
}).filter(Boolean);
}
Logger.info('数据', '缓存未命中、已失效或插件已更新,正在重建规则');
return buildPrioritizedGlossaryMaps();
}
/**
* 获取已预处理并编译完成的术语表规则
*/
async function getPreparedGlossaryRules() {
let currentStateVersion = GM_getValue(GLOSSARY_STATE_VERSION_KEY, 0);
if (runtimePreparedGlossaryCache && runtimePreparedGlossaryCache.version === currentStateVersion) {
Logger.info('数据', '命中术语表正则二级缓存');
return runtimePreparedGlossaryCache.preparedRules;
}
Logger.info('数据', '二级缓存未命中,正在构建术语匹配策略');
const rules = getGlossaryRules();
currentStateVersion = GM_getValue(GLOSSARY_STATE_VERSION_KEY, 0);
const domRules = rules.filter(r => r.matchStrategy === 'dom');
const regexStrategyRules = rules.filter(r => r.matchStrategy === 'regex');
const executionPlan = [];
let currentBatch = {
flags: null,
rules: []
};
const flushBatch = () => {
if (currentBatch.rules.length === 0) return;
const flags = currentBatch.flags;
const combinedPattern = currentBatch.rules.map(r => {
const source = r.regex.source;
return `(${source})`;
}).join('|');
try {
const combinedRegex = new RegExp(combinedPattern, flags);
executionPlan.push({
type: 'combined',
regex: combinedRegex,
rules: [...currentBatch.rules]
});
} catch (e) {
Logger.error('数据', `合并正则失败: ${e.message}`);
}
currentBatch.rules = [];
currentBatch.flags = null;
};
for (const rule of regexStrategyRules) {
if (rule.type === 'regex') {
flushBatch();
executionPlan.push({
type: 'single',
rule: rule
});
} else {
const flags = rule.regex.flags;
if (currentBatch.rules.length > 0 && currentBatch.flags !== flags) {
flushBatch();
}
currentBatch.flags = flags;
currentBatch.rules.push(rule);
}
}
flushBatch();
const preparedRules = {
domRules,
executionPlan
};
runtimePreparedGlossaryCache = {
version: currentStateVersion,
preparedRules
};
return preparedRules;
}
/**
* 安全解析术语表键值对
*/
function parseGlossaryKeyValuePair(entry) {
if (!entry) return null;
let inQuote = false;
let expectedCloseQuote = '';
let splitIndex = -1;
let separator = '';
const quotePairs = {
'"': '"',
"'": "'",
'“': '”',
'‘': '’'
};
const rawChars = entry.split('');
for (let i = 0; i < rawChars.length; i++) {
const char = rawChars[i];
if (inQuote) {
if (char === expectedCloseQuote) {
let nextNonSpaceChar = null;
for (let k = i + 1; k < rawChars.length; k++) {
if (!/\s/.test(rawChars[k])) {
nextNonSpaceChar = rawChars[k];
break;
}
}
const isSeparator =
nextNonSpaceChar === ':' ||
nextNonSpaceChar === ':' ||
nextNonSpaceChar === '=' ||
nextNonSpaceChar === '=';
if (isSeparator) {
inQuote = false;
}
}
} else {
if (quotePairs.hasOwnProperty(char)) {
inQuote = true;
expectedCloseQuote = quotePairs[char];
} else {
if (char === ':' || char === ':') {
splitIndex = i;
separator = ':';
break;
}
if (char === '=' || char === '=') {
splitIndex = i;
separator = '=';
break;
}
}
}
}
if (splitIndex === -1) return null;
const key = entry.substring(0, splitIndex).trim();
const value = entry.substring(splitIndex + 1).trim();
return { key, value, separator };
}
/**
* 构建并排序所有术语表规则
*/
function buildPrioritizedGlossaryMaps() {
const allImportedGlossaries = GM_getValue(IMPORTED_GLOSSARY_KEY, {});
const glossaryMetadata = GM_getValue(GLOSSARY_METADATA_KEY, {});
const localGlossaries = GM_getValue(CUSTOM_GLOSSARIES_KEY, []);
const onlineOrder = GM_getValue(ONLINE_GLOSSARY_ORDER_KEY, []);
const orderedGlossaries = [];
localGlossaries.forEach(g => {
if (g.enabled !== false) {
orderedGlossaries.push({ ...g, type: 'LOCAL', sourceName: g.name });
}
});
const onlineUrls = Object.keys(allImportedGlossaries);
const onlineUrlSet = new Set(onlineUrls);
onlineOrder.forEach(url => {
if (onlineUrlSet.has(url) && glossaryMetadata[url]?.enabled !== false) {
orderedGlossaries.push({ ...allImportedGlossaries[url], type: 'ONLINE', sourceName: decodeURIComponent(url.split('/').pop()) });
onlineUrlSet.delete(url);
}
});
onlineUrlSet.forEach(url => {
if (glossaryMetadata[url]?.enabled !== false) {
orderedGlossaries.push({ ...allImportedGlossaries[url], type: 'ONLINE', sourceName: decodeURIComponent(url.split('/').pop()) });
}
});
const validRules = [];
const processedInsensitiveTerms = new Set();
const processedSensitiveTerms = new Set();
const termSeparatorRegex = /[\s--﹣—–]+/;
const translationSeparatorRegex = /[\s·・]+/;
const quoteRegex = /["“‘'”’]/;
const smartSplit = (str, regex) => {
if (!quoteRegex.test(str)) return str.split(regex);
const parts = [];
let current = '';
let inQuote = false;
let currentQuote = '';
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (quoteRegex.test(char)) {
if (!inQuote) {
inQuote = true;
currentQuote = char;
} else if (char === currentQuote || (currentQuote === '“' && char === '”') || (currentQuote === '‘' && char === '’')) {
inQuote = false;
}
current += char;
} else if (!inQuote && regex.test(char)) {
if (current.trim()) parts.push(current.trim());
current = '';
} else {
current += char;
}
}
if (current.trim()) parts.push(current.trim());
return parts;
};
const sanitizeTranslation = (term, trans) => {
if (!trans || !quoteRegex.test(term)) return trans;
const match = trans.match(/^["“‘'](.*)["”’']$/);
if (match) return match[1].trim();
return trans;
};
const tryAddRule = (term, translation, glossaryIndex, sourceName, isSensitive, isForbidden, isRegex = false, isUnordered = false) => {
let normalizedTerm = term.trim();
if (!normalizedTerm) return;
let isLiteral = false;
const unquoted = smartUnquote(normalizedTerm);
if (unquoted !== normalizedTerm) {
isLiteral = true;
normalizedTerm = unquoted.trim();
}
const sanitizedTrans = sanitizeTranslation(normalizedTerm, translation);
const lowerTerm = normalizedTerm.toLowerCase();
if (processedInsensitiveTerms.has(lowerTerm)) {
return;
}
if (isSensitive) {
if (processedSensitiveTerms.has(normalizedTerm)) {
return;
}
processedSensitiveTerms.add(normalizedTerm);
} else {
processedInsensitiveTerms.add(lowerTerm);
}
let ruleObject;
const lengthBonus = normalizedTerm.length;
if (isRegex) {
try {
const testRegex = new RegExp(normalizedTerm);
if (testRegex.test("")) {
Logger.warn('数据', `术语表 ${sourceName} 中的正则 "${normalizedTerm}" 匹配空字符串,已跳过以防止死循环`);
return;
}
} catch (e) {
Logger.error('数据', `术语表 ${sourceName} 中的正则 "${normalizedTerm}" 非法: ${e.message}`);
return;
}
ruleObject = {
type: 'regex', matchStrategy: 'regex',
regex: new RegExp(normalizedTerm, 'g'),
replacement: translation,
glossaryIndex, source: sourceName, originalTerm: `${normalizedTerm}:${translation}`,
sortLength: lengthBonus, isSensitive
};
} else if (isLiteral) {
const escaped = normalizedTerm.replace(/([.*+?^${}()|[\]\\])/g, '\\$&');
const prefix = /^[a-zA-Z0-9]/.test(normalizedTerm) ? '\\b' : '';
const suffix = /[a-zA-Z0-9]$/.test(normalizedTerm) ? '\\b' : '';
const pattern = prefix + escaped + suffix;
const flags = isSensitive ? 'g' : 'gi';
ruleObject = {
type: isForbidden ? 'forbidden' : 'term', matchStrategy: 'regex',
regex: new RegExp(pattern, flags),
replacement: isForbidden ? normalizedTerm : sanitizedTrans,
glossaryIndex, source: sourceName, originalTerm: normalizedTerm,
sortLength: lengthBonus, isSensitive
};
} else {
const termParts = smartSplit(normalizedTerm, termSeparatorRegex);
const termForms = termParts.map(part => {
const partLiteralMatch = part.match(/^["“‘'](.*)["”’']$/);
if (partLiteralMatch) {
return new Set([partLiteralMatch[1].trim()]);
}
return Array.from(generateWordForms(part, { preserveCase: isForbidden, forceLowerCase: !isSensitive }));
});
ruleObject = {
type: isForbidden ? 'forbidden' : 'term', matchStrategy: 'dom',
parts: termForms,
replacement: isForbidden ? termForms.map(partForms => Array.from(partForms)[0]).join(' ') : sanitizedTrans,
glossaryIndex, isGeneral: !isSensitive, source: sourceName, originalTerm: normalizedTerm,
isUnordered: isUnordered,
sortLength: lengthBonus, isSensitive
};
}
validRules.push(ruleObject);
};
const processEqualsSyntax = (term, translation, glossaryIndex, sourceName, isSensitive) => {
tryAddRule(term, translation, glossaryIndex, sourceName, isSensitive, false, false, true);
if (term.match(/^["“‘'](.*)["”’']$/)) return;
const termParts = smartSplit(term, termSeparatorRegex);
const transParts = smartSplit(translation, translationSeparatorRegex);
if (termParts.length > 1 && termParts.length === transParts.length) {
for (let i = 0; i < termParts.length; i++) {
tryAddRule(termParts[i], transParts[i], glossaryIndex, sourceName, isSensitive, false, false, false);
}
}
};
const processStringRules = (rawString, glossaryIndex, sourceName, isSensitive, isForbidden) => {
if (!rawString) return;
const tokens = tokenizeQuoteAware(rawString, [',', ',']);
tokens.forEach(token => {
const entry = token.value.trim();
if (!entry) return;
if (isForbidden) {
tryAddRule(entry, null, glossaryIndex, sourceName, isSensitive, true);
return;
}
const parsed = parseGlossaryKeyValuePair(entry);
if (parsed) {
if (parsed.separator === '=') {
processEqualsSyntax(parsed.key, parsed.value, glossaryIndex, sourceName, isSensitive);
} else {
tryAddRule(parsed.key, parsed.value, glossaryIndex, sourceName, isSensitive, false);
}
}
});
};
orderedGlossaries.forEach((glossary, index) => {
const sourceName = glossary.sourceName;
if (glossary.forbidden) {
processStringRules(glossary.forbidden, index, sourceName, true, true);
}
(glossary.forbiddenTerms || []).forEach(term => {
tryAddRule(term, null, index, sourceName, true, true);
});
if (glossary.sensitive) {
processStringRules(glossary.sensitive, index, sourceName, true, false);
}
Object.entries(glossary.terms || {}).forEach(([k, v]) => tryAddRule(k, v, index, sourceName, true, false));
Object.entries(glossary.multiPartTerms || {}).forEach(([k, v]) => processEqualsSyntax(k, v, index, sourceName, true));
if (glossary.insensitive) {
processStringRules(glossary.insensitive, index, sourceName, false, false);
}
Object.entries(glossary.generalTerms || {}).forEach(([k, v]) => tryAddRule(k, v, index, sourceName, false, false));
Object.entries(glossary.multiPartGeneralTerms || {}).forEach(([k, v]) => processEqualsSyntax(k, v, index, sourceName, false));
(glossary.regexTerms || []).forEach(({ pattern, replacement }) => {
tryAddRule(pattern, replacement, index, sourceName, true, false, true);
});
});
validRules.sort((a, b) => {
if (a.glossaryIndex !== b.glossaryIndex) {
return a.glossaryIndex - b.glossaryIndex;
}
const typeScore = { 'forbidden': 100, 'term': 50, 'regex': 50 };
const scoreA = typeScore[a.type] || 0;
const scoreB = typeScore[b.type] || 0;
if (scoreB !== scoreA) {
return scoreB - scoreA;
}
if (b.sortLength !== a.sortLength) {
return b.sortLength - a.sortLength;
}
return (b.isSensitive ? 1 : 0) - (a.isSensitive ? 1 : 0);
});
const currentStateVersion = generateGlossaryStateVersion();
const serializedRules = validRules.map(rule => {
if (rule.regex instanceof RegExp) {
return { ...rule, regex: { source: rule.regex.source, flags: rule.regex.flags } };
}
return rule;
});
GM_setValue(GLOSSARY_RULES_CACHE_KEY, {
version: currentStateVersion,
scriptVersion: GM_info.script.version,
rules: serializedRules
});
Logger.info('数据', `术语表规则重建完成,当前版本: v${currentStateVersion}`);
return validRules;
}
/**
* 为单个英文单词生成其常见词形变体
*/
function generateWordForms(baseTerm, options = {}) {
const { preserveCase = false, forceLowerCase = false } = options;
const forms = new Set();
if (!baseTerm || typeof baseTerm !== 'string') {
return forms;
}
forms.add(baseTerm);
if (!/[a-zA-Z]$/.test(baseTerm)) {
if (forceLowerCase) {
forms.add(baseTerm.toLowerCase());
}
return forms;
}
const lowerBase = baseTerm.toLowerCase();
let pluralEnding;
let baseWithoutEnding = baseTerm;
if (lowerBase.endsWith('y') && !['a', 'e', 'i', 'o', 'u'].includes(lowerBase.slice(-2, -1))) {
pluralEnding = 'ies';
baseWithoutEnding = baseTerm.slice(0, -1);
} else if (/[sxz]$/i.test(lowerBase) || /(ch|sh)$/i.test(lowerBase)) {
pluralEnding = 'es';
} else {
pluralEnding = 's';
}
let pluralForm;
if (preserveCase) {
if (baseTerm === lowerBase) {
pluralForm = baseWithoutEnding + pluralEnding;
} else if (baseTerm === baseTerm.toUpperCase()) {
pluralForm = (baseWithoutEnding + pluralEnding).toUpperCase();
} else if (baseTerm.length > 0 && baseTerm[0] === baseTerm[0].toUpperCase() && baseTerm.slice(1) === baseTerm.slice(1).toLowerCase()) {
const pluralBase = baseWithoutEnding + pluralEnding;
pluralForm = pluralBase.charAt(0).toUpperCase() + pluralBase.slice(1).toLowerCase();
} else {
pluralForm = baseWithoutEnding + pluralEnding.toLowerCase();
}
} else {
pluralForm = baseWithoutEnding + pluralEnding;
}
forms.add(pluralForm);
if (forceLowerCase) {
const lowerCaseForms = new Set();
forms.forEach(form => lowerCaseForms.add(form.toLowerCase()));
return lowerCaseForms;
}
return forms;
}
/**
* 解析“译文后处理替换”规则字符串为对象
*/
function parsePostReplaceString(rawInput) {
const rules = {
singleRules: {},
multiPartRules: []
};
if (typeof rawInput !== 'string' || !rawInput.trim()) {
return rules;
}
const internalSeparatorRegex = /[\s--﹣—–]+/;
const internalSeparatorGlobalRegex = /[\s--﹣—–]+/g;
rawInput.split(/[,,]/).forEach(entry => {
const trimmedEntry = entry.trim();
if (!trimmedEntry) return;
const multiPartMatch = trimmedEntry.match(/^(.*?)\s*[==]\s*(.*?)$/);
if (multiPartMatch) {
const source = multiPartMatch[1].trim();
const target = multiPartMatch[2].trim();
if (source && target) {
const sourceParts = source.split(internalSeparatorRegex);
const targetParts = target.split(internalSeparatorRegex);
const multiPartRule = {
source: source.replace(internalSeparatorGlobalRegex, ' '),
target: target.replace(internalSeparatorGlobalRegex, ' '),
subRules: {}
};
if (sourceParts.length === targetParts.length && sourceParts.length > 1) {
for (let i = 0; i < sourceParts.length; i++) {
multiPartRule.subRules[sourceParts[i]] = targetParts[i];
}
}
rules.multiPartRules.push(multiPartRule);
}
} else {
const singlePartMatch = trimmedEntry.match(/^(.*?)\s*[::]\s*(.+?)\s*$/);
if (singlePartMatch) {
const key = singlePartMatch[1].trim();
const value = singlePartMatch[2].trim();
if (key) {
rules.singleRules[key] = value;
}
}
}
});
return rules;
}
/**
* 译文后处理替换
*/
function applyPostTranslationReplacements(text) {
const rulesList = GM_getValue(POST_REPLACE_RULES_KEY, []);
if (!rulesList || rulesList.length === 0) {
return text;
}
let processedText = text;
for (const ruleConfig of rulesList) {
if (!ruleConfig.enabled) continue;
const rulesData = parsePostReplaceString(ruleConfig.content);
const { singleRules = {}, multiPartRules = [] } = rulesData;
const finalReplacementMap = {};
multiPartRules.forEach(rule => {
Object.assign(finalReplacementMap, rule.subRules);
});
Object.assign(finalReplacementMap, singleRules);
multiPartRules.forEach(rule => {
finalReplacementMap[rule.source] = rule.target;
});
const keys = Object.keys(finalReplacementMap);
if (keys.length === 0) {
continue;
}
const sortedKeys = keys.sort((a, b) => b.length - a.length);
const regex = new RegExp(sortedKeys.map(key => key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')).join('|'), 'g');
processedText = processedText.replace(regex, (matched) => finalReplacementMap[matched]);
}
return processedText;
}
/**
* 通用通知与日志函数
*/
function notifyAndLog(message, title = 'AO3 Translator', logType = 'info') {
GM_notification(message, title);
if (logType === 'error') {
Logger.error('系统', message);
} else {
Logger.info('系统', message);
}
}
/**
* sleepms 函数:延时。
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 获取当前时间的上海时区格式化字符串
*/
function getShanghaiTimeString() {
const now = new Date();
const year = now.toLocaleString('en-US', { year: 'numeric', timeZone: 'Asia/Shanghai' });
const month = now.toLocaleString('en-US', { month: '2-digit', timeZone: 'Asia/Shanghai' });
const day = now.toLocaleString('en-US', { day: '2-digit', timeZone: 'Asia/Shanghai' });
const time = now.toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: 'Asia/Shanghai'
});
return `${year}-${month}-${day} ${time}`;
}
/**
* 根据术语表内容生成一个状态哈希
*/
function generateGlossaryStateVersion() {
const current = GM_getValue(GLOSSARY_STATE_VERSION_KEY, 0);
const next = current + 1;
GM_setValue(GLOSSARY_STATE_VERSION_KEY, next);
return next;
}
/**
* 使术语表规则缓存失效
*/
function invalidateGlossaryCache() {
GM_deleteValue(GLOSSARY_RULES_CACHE_KEY);
generateGlossaryStateVersion();
runtimePreparedGlossaryCache = null;
Logger.info('数据', '术语表规则缓存已失效');
}
/**
* getNestedProperty 函数:获取嵌套属性的安全函数。
* @param {Object} obj - 需要查询的对象
* @param {string} path - 属性路径
* @returns {*} - 返回嵌套属性的值
*/
function getNestedProperty(obj, path) {
return path.split('.').reduce((acc, part) => {
const match = part.match(/(\w+)(?:\[(\d+)\])?/);
if (!match) return undefined;
const key = match[1];
const index = match[2];
if (acc && typeof acc === 'object' && acc[key] !== undefined) {
return index !== undefined ? acc[key][index] : acc[key];
}
return undefined;
}, obj);
}
/**
* 翻译文本处理函数
*/
const AdvancedTranslationCleaner = new (class {
constructor() {
this.metaKeywords = [
'原文', '输出', '说明', '润色', '语境', '遵守', '指令',
'Original text', 'Output', 'Note', 'Stage', 'Strategy', 'Polish', 'Retain', 'Glossary', 'Adherence'
];
this.junkLineRegex = new RegExp(`^\\s*(\\d+\\.\\s*)?(${this.metaKeywords.join('|')})[::\\s]`, 'i');
this.lineNumbersRegex = /^\d+\.\s*/;
this.aiGenericExplanationRegex = /\s*[\uff08(](?:原文|译文|说明|保留|注释|译注|注)[::\s][^\uff08\uff09()]*?[\uff09)]\s*/g;
this.cjkIdeographs = '\\u4e00-\\u9fff\\u3400-\\u4dbf\\u2e80-\\u2eff\\uf900-\\ufaff';
this.cjkSymbols = '\\u3000-\\u303f\\uff00-\\uffef\\u30fb';
this.cjkTypoQuotes = '\\u2018-\\u201d\\u2026';
this.cjkBoundaryChars = this.cjkIdeographs + this.cjkTypoQuotes;
this.cjkAll = this.cjkBoundaryChars + this.cjkSymbols;
}
clean(text) {
if (!text || typeof text !== 'string') {
return '';
}
let cleanedText = text
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/\u00a0/g, ' ');
cleanedText = cleanedText.split('\n').filter(line => !this.junkLineRegex.test(line)).join('\n');
cleanedText = cleanedText.replace(this.lineNumbersRegex, '');
cleanedText = cleanedText.replace(this.aiGenericExplanationRegex, '');
cleanedText = cleanedText.replace(/(<(em|strong|span|b|i|u)[^>]*>)([\s\S]*?)(<\/\2>)/g, (_match, openTag, _tagName, content, closeTag) => {
return openTag + content.trim() + closeTag;
});
const cjkBoundaryBlock = `[${this.cjkBoundaryChars}]`;
const latinChar = `[a-zA-Z0-9_.-]`;
const simpleFormattingTags = `?(?:em|strong|span|b|i|u)>`;
const cjkContext = `(?:[${this.cjkAll}]|${simpleFormattingTags})`;
cleanedText = cleanedText.replace(new RegExp(`(${cjkBoundaryBlock})((?:${simpleFormattingTags})*)(${latinChar}+)`, 'g'), '$1$2 $3');
cleanedText = cleanedText.replace(new RegExp(`(${latinChar}+)((?:${simpleFormattingTags})*)(${cjkBoundaryBlock})`, 'g'), '$1 $2$3');
cleanedText = cleanedText.replace(/(“|‘|「|『)\s+/g, '$1');
cleanedText = cleanedText.replace(/\s+(”|’|」|』)/g, '$1');
cleanedText = cleanedText.replace(/\s+/g, ' ');
const cjkSpaceRegex = new RegExp(`(${cjkContext})\\s+(?=${cjkContext})`, 'g');
let prevText;
do {
prevText = cleanedText;
cleanedText = cleanedText.replace(cjkSpaceRegex, '$1');
} while (cleanedText !== prevText);
return cleanedText.trim();
}
})();
/**
* 通用后处理函数:处理块级元素末尾的孤立标点
*/
function handleTrailingPunctuation(rootElement = document) {
const selectors = 'p, li, dd, blockquote, h1, h2, h3, h4, h5, h6, .summary, .notes';
const punctuationMap = { '.': ' 。', '?': ' ?', '!': ' !' };
const elements = rootElement.querySelectorAll(`${selectors}:not([data-translated-by-custom-function])`);
elements.forEach(el => {
let lastMeaningfulNode = el.lastChild;
while (lastMeaningfulNode) {
if (lastMeaningfulNode.nodeType === Node.COMMENT_NODE ||
(lastMeaningfulNode.nodeType === Node.TEXT_NODE && lastMeaningfulNode.nodeValue.trim() === '')) {
lastMeaningfulNode = lastMeaningfulNode.previousSibling;
} else {
break;
}
}
if (
lastMeaningfulNode &&
lastMeaningfulNode.nodeType === Node.TEXT_NODE
) {
const trimmedText = lastMeaningfulNode.nodeValue.trim();
if (punctuationMap[trimmedText]) {
lastMeaningfulNode.nodeValue = lastMeaningfulNode.nodeValue.replace(trimmedText, punctuationMap[trimmedText]);
el.setAttribute('data-translated-by-custom-function', 'true');
}
}
});
}
/**
* 通用函数:对页面上所有“分类”复选框区域进行重新排序。
*/
function reorderCategoryCheckboxes() {
const containers = document.querySelectorAll('div[id$="_category_tagnames_checkboxes"]');
containers.forEach(container => {
if (container.dataset.reordered === 'true') {
return;
}
const list = container.querySelector('ul.options');
if (!list) return;
const desiredOrder = ['F/F', 'F/M', 'Gen', 'M/M', 'Multi', 'Other'];
const itemsMap = new Map();
list.querySelectorAll('li').forEach(item => {
const checkbox = item.querySelector('input[type="checkbox"]');
if (checkbox) {
itemsMap.set(checkbox.value, item);
}
});
desiredOrder.forEach(value => {
const itemToMove = itemsMap.get(value);
if (itemToMove) {
list.appendChild(itemToMove);
}
});
container.dataset.reordered = 'true';
});
}
/**
* 通用函数:重新格式化包含标准日期组件的元素。
* @param {Element} containerElement - 直接包含日期组件的元素
*/
function reformatDateInElement(containerElement) {
if (!containerElement || containerElement.hasAttribute('data-reformatted')) {
return;
}
const dayEl = containerElement.querySelector('abbr.day');
const dateEl = containerElement.querySelector('span.date');
const monthEl = containerElement.querySelector('abbr.month');
const yearEl = containerElement.querySelector('span.year');
if (!dayEl || !dateEl || !monthEl || !yearEl) {
return;
}
// 翻译星期
let dayFull = dayEl.getAttribute('title');
dayFull = fetchTranslatedText(dayFull) || dayFull;
// 翻译月份
const monthText = monthEl.textContent;
const translatedMonth = fetchTranslatedText(monthText) || monthText;
// 格式化时间
const timeEl = containerElement.querySelector('span.time');
let formattedTime = '';
if (timeEl) {
const timeText = timeEl.textContent;
const T = timeText.slice(0, -2);
const ampm = timeText.slice(-2);
if (ampm === 'PM') {
formattedTime = '下午 ' + T;
} else if (ampm === 'AM') {
formattedTime = (T.startsWith('12') ? '凌晨 ' : '上午 ') + T;
} else {
formattedTime = timeText;
}
}
// 提取时区
const timezoneEl = containerElement.querySelector('abbr.timezone');
const timezoneText = timezoneEl ? timezoneEl.textContent : 'UTC';
// 替换内容
const prefixNode = containerElement.firstChild;
let prefixText = '';
if (prefixNode && prefixNode.nodeType === Node.TEXT_NODE) {
prefixText = prefixNode.nodeValue;
}
containerElement.innerHTML = '';
if (prefixText) {
containerElement.appendChild(document.createTextNode(prefixText));
}
containerElement.appendChild(document.createTextNode(`${yearEl.textContent}年${translatedMonth}${dateEl.textContent}日 ${dayFull} ${formattedTime} ${timezoneText}`));
containerElement.setAttribute('data-reformatted', 'true');
}
/**
* 执行数据迁移,将旧版存储格式更新为新版
*/
function runDataMigration() {
const CURRENT_MIGRATION_VERSION = 1;
const savedVersion = GM_getValue('ao3_migration_version', 0);
if (savedVersion >= CURRENT_MIGRATION_VERSION) {
return;
}
{
const newRules = GM_getValue(POST_REPLACE_RULES_KEY, null);
if (newRules === null) {
const oldString = GM_getValue(POST_REPLACE_STRING_KEY, '');
if (oldString) {
const defaultRule = {
id: `replace_${Date.now()}`,
name: '默认',
content: oldString,
enabled: true
};
GM_setValue(POST_REPLACE_RULES_KEY, [defaultRule]);
GM_setValue(POST_REPLACE_SELECTED_ID_KEY, defaultRule.id);
GM_setValue(POST_REPLACE_EDIT_MODE_KEY, 'settings');
} else {
const postReplaceData = GM_getValue(POST_REPLACE_MAP_KEY, null);
if (postReplaceData && typeof postReplaceData === 'object' && !postReplaceData.hasOwnProperty('singleRules')) {
GM_setValue(POST_REPLACE_MAP_KEY, {
singleRules: postReplaceData,
multiPartRules: []
});
}
}
}
}
{
const newGlossaries = GM_getValue(CUSTOM_GLOSSARIES_KEY, null);
if (newGlossaries !== null) {
let changed = false;
newGlossaries.forEach(g => {
if (typeof g.enabled === 'undefined') {
g.enabled = true;
changed = true;
}
});
if (changed) {
GM_setValue(CUSTOM_GLOSSARIES_KEY, newGlossaries);
}
} else {
const oldGlossaryStr = GM_getValue('ao3_local_glossary_string', '');
const oldForbiddenStr = GM_getValue('ao3_local_forbidden_string', '');
let finalSensitive = oldGlossaryStr;
if (!finalSensitive) {
const oldGlossaryObj = GM_getValue('ao3_local_glossary') || GM_getValue('ao3_translation_glossary');
if (oldGlossaryObj && typeof oldGlossaryObj === 'object') {
finalSensitive = Object.entries(oldGlossaryObj).map(([k, v]) => `${k}:${v}`).join(', ');
}
}
let finalForbidden = oldForbiddenStr;
if (!finalForbidden) {
const oldForbiddenArray = GM_getValue('ao3_local_forbidden_terms');
if (Array.isArray(oldForbiddenArray)) {
finalForbidden = oldForbiddenArray.join(', ');
}
}
if (finalSensitive || finalForbidden) {
const defaultGlossary = {
id: `local_${Date.now()}`,
name: '默认',
sensitive: finalSensitive || '',
insensitive: '',
forbidden: finalForbidden || '',
enabled: true
};
GM_setValue(CUSTOM_GLOSSARIES_KEY, [defaultGlossary]);
}
['ao3_local_glossary_string', 'ao3_local_forbidden_string', 'ao3_local_glossary',
'ao3_translation_glossary', 'ao3_local_forbidden_terms'].forEach(key => GM_deleteValue(key));
}
}
{
const servicesToMigrate = ['zhipu_ai', 'deepseek_ai', 'groq_ai', 'together_ai', 'cerebras_ai', 'modelscope_ai'];
servicesToMigrate.forEach(serviceName => {
const oldKeyName = `${serviceName.split('_')[0]}_api_key`;
const newStringKey = `${serviceName}_keys_string`;
const newArrayKey = `${serviceName}_keys_array`;
const oldKeyValue = GM_getValue(oldKeyName);
if (oldKeyValue && GM_getValue(newStringKey) === undefined) {
GM_setValue(newStringKey, oldKeyValue);
const keysArray = oldKeyValue.replace(/[,]/g, ',').split(',').map(k => k.trim()).filter(Boolean);
GM_setValue(newArrayKey, keysArray);
GM_deleteValue(oldKeyName);
}
});
const oldChatglmKey = GM_getValue('chatglm_api_key');
if (oldChatglmKey && GM_getValue('zhipu_ai_keys_string') === undefined) {
GM_setValue('zhipu_ai_keys_string', oldChatglmKey);
GM_setValue('zhipu_ai_keys_array', [oldChatglmKey]);
GM_deleteValue('chatglm_api_key');
}
}
{
const oldKeysArray = GM_getValue('google_ai_keys_array');
const newKeysString = GM_getValue('google_ai_keys_string');
if (Array.isArray(oldKeysArray) && !newKeysString) {
GM_setValue('google_ai_keys_string', oldKeysArray.join(', '));
}
}
{
const modelKey = 'google_ai_model';
const currentModel = GM_getValue(modelKey);
const migrationMap = {
'gemini-2.5-flash': 'gemini-flash-latest',
'gemini-2.5-flash-lite': 'gemini-flash-lite-latest'
};
if (currentModel && migrationMap[currentModel]) {
GM_setValue(modelKey, migrationMap[currentModel]);
}
}
{
const sysPrompt = GM_getValue('custom_ai_system_prompt');
if (typeof sysPrompt === 'string' && sysPrompt.includes('${')) {
GM_setValue('custom_ai_system_prompt', sysPrompt.replace(/\$\{/g, '{'));
}
}
{
const targetThresholds = '10, 0.8, 10, 5';
const globalKey = 'custom_ai_validation_thresholds';
GM_setValue(globalKey, targetThresholds);
const profiles = GM_getValue(AI_PROFILES_KEY);
if (Array.isArray(profiles)) {
let changed = false;
profiles.forEach(p => {
if (p.params) {
p.params.validation_thresholds = targetThresholds;
changed = true;
}
});
if (changed) {
GM_setValue(AI_PROFILES_KEY, profiles);
}
}
}
const keysToCheck = [
{ key: 'enable_RegExp', default: DEFAULT_CONFIG.GENERAL.enable_RegExp },
{ key: 'enable_transDesc', default: DEFAULT_CONFIG.GENERAL.enable_transDesc },
{ key: 'enable_ui_trans', default: DEFAULT_CONFIG.GENERAL.enable_ui_trans },
{ key: 'show_fab', default: DEFAULT_CONFIG.GENERAL.show_fab },
{ key: 'enable_debug_mode', default: DEFAULT_CONFIG.GENERAL.enable_debug_mode },
{ key: 'translation_display_mode', default: DEFAULT_CONFIG.GENERAL.translation_display_mode },
{ key: 'from_lang', default: DEFAULT_CONFIG.GENERAL.from_lang },
{ key: 'to_lang', default: DEFAULT_CONFIG.GENERAL.to_lang },
{ key: 'lang_detector', default: DEFAULT_CONFIG.GENERAL.lang_detector },
{ key: 'transEngine', default: DEFAULT_CONFIG.ENGINE.current },
{ key: 'custom_url_first_save_done', default: DEFAULT_CONFIG.GENERAL.custom_url_first_save_done }
];
let cleanedCount = 0;
keysToCheck.forEach(item => {
const currentValue = GM_getValue(item.key);
if (currentValue === item.default) {
GM_deleteValue(item.key);
cleanedCount++;
}
});
Logger.info('系统', `配置清理完成,移除了 ${cleanedCount} 个未修改的默认设置`);
GM_setValue('ao3_migration_version', CURRENT_MIGRATION_VERSION);
}
/**
* 脚本主入口
*/
function main() {
if (window.top !== window.self) {
return;
}
if (window.ao3_translator_running) {
Logger.warn('系统', '检测到脚本重复执行,已拦截');
return;
}
window.ao3_translator_running = true;
Logger.info('系统', `插件初始化完成,当前版本:v${GM_info.script.version}`);
ProfileManager.init();
FormattingManager.init();
runDataMigration();
updateBlockerCache();
checkForGlossaryUpdates();
const fabElements = createFabUI();
const panelElements = createSettingsPanelUI();
let rerenderMenu;
let fabLogic;
const handlePanelClose = () => {
if (fabLogic) {
fabLogic.retractFab();
}
};
const panelLogic = initializeSettingsPanelLogic(panelElements, () => rerenderMenu(), handlePanelClose);
fabLogic = initializeFabInteraction(fabElements, panelLogic);
document.addEventListener('click', (e) => {
if (!e.altKey || e.button !== 0) return;
const link = e.target.closest('a');
if (!link) return;
let added = false;
const href = link.getAttribute('href');
const text = link.textContent.trim();
if (href && /\/works\/\d+$/.test(href)) {
const idMatch = href.match(/\/works\/(\d+)$/);
if (idMatch) {
addBlockRule('ao3_blocker_content_id', idMatch[1]);
added = true;
}
}
else if (link.rel && link.rel.includes('author')) {
addBlockRule('ao3_blocker_content_author', text);
added = true;
}
else if (link.classList.contains('tag')) {
addBlockRule('ao3_blocker_tags_black', `'${text}'`);
added = true;
}
if (added) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
link.style.pointerEvents = 'none';
setTimeout(() => { link.style.pointerEvents = ''; }, 100);
refreshBlocker('incremental');
}
}, true);
const globalStyles = document.createElement('style');
globalStyles.textContent = `
.autocomplete.dropdown p.notice {
margin-bottom: 0;
}
.translated-by-ao3-translator, .translated-by-ao3-translator-error {
margin-top: 15px;
margin-bottom: 15px;
}
li.post .userstuff {
margin-bottom: 15px;
}
li.post .userstuff .translated-by-ao3-translator {
margin-bottom: 0;
}
.translate-me-ao3-wrapper {
border: none;
background: transparent;
box-shadow: none;
margin-top: 15px;
margin-bottom: 5px;
clear: both;
display: block;
}
.translate-me-ao3-button {
color: #1b95e0;
font-size: small;
cursor: pointer;
display: inline-block;
margin-left: 10px;
}
.translated-tags-container {
margin-top: 15px;
margin-bottom: 10px;
}
.collection.profile .primary.header.module .translate-me-ao3-wrapper,
.collection.home .primary.header.module .translate-me-ao3-wrapper {
clear: none !important;
margin-left: 120px !important;
margin-top: 15px !important;
width: auto !important;
}
p.kudos {
line-height: 1.5;
}
.retry-translation-button {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin-left: 8px;
cursor: pointer;
color: #1b95e0;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.retry-translation-button:hover {
color: #0d8bd9;
}
.retry-translation-button:active {
opacity: 0.7;
}
.retry-translation-button svg {
width: 18px;
height: 18px;
fill: currentColor;
}
/* 标签翻译样式 */
.ao3-tag-translation {
margin-left: 6px;
opacity: 0.85;
font-size: 0.95em;
display: inline;
color: inherit;
}
/* 当处于仅译文模式时,移除左侧间隔 */
body.ao3-translation-only .ao3-tag-translation {
margin-left: 0;
}
/* 标签原文包裹样式 */
.ao3-tag-original {
display: inline;
}
/* 防止在标签列表中换行导致布局错乱 */
li.blurb ul.tags li,
dl.meta dd ul.tags li,
ul.tags.commas li {
display: inline;
}
`;
document.head.appendChild(globalStyles);
if (document.documentElement.lang !== CONFIG.LANG) {
document.documentElement.lang = CONFIG.LANG;
}
new MutationObserver(() => {
if (document.documentElement.lang !== CONFIG.LANG && document.documentElement.lang.toLowerCase().startsWith('en')) {
document.documentElement.lang = CONFIG.LANG;
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
updatePageConfig('初始载入');
if (pageConfig.currentPageType) {
if (FeatureSet.enable_ui_trans) {
transTitle();
transBySelector();
traverseNode(document.body);
runHighPriorityFunctions();
}
scanAllWorks();
fabLogic.toggleFabVisibility();
if (FeatureSet.enable_transDesc) {
setTimeout(transDesc, 1000);
}
}
rerenderMenu = setupMenuCommands(fabLogic, panelLogic);
rerenderMenu();
watchUpdate(fabLogic);
}
/**
* 监视页面变化
*/
function watchUpdate(fabLogic) {
let previousURL = window.location.href;
const handleUrlChange = () => {
const currentURL = window.location.href;
if (currentURL !== previousURL) {
previousURL = currentURL;
updatePageConfig('URL变化');
if (FeatureSet.enable_ui_trans) {
transTitle();
transBySelector();
traverseNode(document.body);
runHighPriorityFunctions();
}
scanAllWorks();
fabLogic.toggleFabVisibility();
if (FeatureSet.enable_transDesc) {
transDesc();
}
}
};
const processMutations = mutations => {
if (BlockerCache.enabled) {
let hasNewBlurbs = false;
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (node.classList.contains('blurb') || node.querySelector('li.blurb')) {
hasNewBlurbs = true;
break;
}
}
}
}
if (hasNewBlurbs) break;
}
if (hasNewBlurbs) {
checkWorksSynchronously();
}
}
const nodesToProcess = mutations.flatMap(({ target, addedNodes, type }) => {
if (type === 'childList' && addedNodes.length > 0) {
return Array.from(addedNodes);
}
if (type === 'attributes' || (type === 'characterData' && pageConfig.characterData)) {
return [target];
}
return [];
});
if (nodesToProcess.length === 0) return;
const uniqueNodes = [...new Set(nodesToProcess)];
uniqueNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE || node.parentElement) {
if (FeatureSet.enable_ui_trans) {
traverseNode(node);
runHighPriorityFunctions(node.parentElement || node);
}
}
});
fabLogic.toggleFabVisibility();
if (FeatureSet.enable_transDesc) {
transDesc();
}
};
const observer = new MutationObserver(mutations => {
handleUrlChange();
if (window.location.href === previousURL) {
processMutations(mutations);
}
});
observer.observe(document.documentElement, { ...CONFIG.OBSERVER_CONFIG, subtree: true });
}
/**
* 辅助函数:集中调用所有高优先级专用函数
* @param {HTMLElement} [rootElement=document] - 扫描范围
*/
function runHighPriorityFunctions(rootElement = document) {
if (!rootElement || typeof rootElement.querySelectorAll !== 'function') {
return;
}
const innerHTMLRules = pageConfig.innerHTMLRules || [];
if (innerHTMLRules.length > 0) {
innerHTMLRules.forEach(rule => {
if (!Array.isArray(rule) || rule.length !== 3) return;
const [selector, regex, replacement] = rule;
try {
rootElement.querySelectorAll(selector).forEach(el => {
if (el.hasAttribute('data-translated-by-custom-function')) return;
if (pageConfig.ignoreSelectors && el.closest(pageConfig.ignoreSelectors)) return;
if (regex.test(el.innerHTML)) {
el.innerHTML = el.innerHTML.replace(regex, replacement);
el.setAttribute('data-translated-by-custom-function', 'true');
}
});
} catch (e) { /* 忽略无效的选择器 */ }
});
}
const kudosDiv = rootElement.querySelector('#kudos');
if (kudosDiv && !kudosDiv.dataset.kudosObserverAttached) {
translateKudosSection();
}
// 通用的后处理和格式化函数
handleTrailingPunctuation(rootElement);
translateSymbolsKeyModal(rootElement);
translateFirstLoginBanner();
translateBookmarkSymbolsKeyModal();
translateRatingHelpModal();
translateCategoriesHelp();
translateRelationshipsHelp();
translateCharactersHelp();
translateAdditionalTagsHelp();
translateCollectionsHelp();
translateRecipientsHelp();
translateParentWorksHelp();
translateChoosingSeriesHelp();
translateBackdatingHelp();
translateLanguagesHelp();
translateWorkSkins();
translateRegisteredUsers();
translateCommentsModerated();
translateFandomHelpModal();
translateWhoCanComment();
translateWorkImportTroubleshooting();
translateEncodingHelp();
translatePrivacyPreferences();
translateDisplayPreferences();
translateSkinsBasics();
translateWorkTitleFormat();
translateCommentPreferences();
translateCollectionPreferences();
translateMiscPreferences();
translateTagFiltersIncludeTags();
translateTagFiltersExcludeTags();
translateBookmarkFiltersIncludeTags();
translateWorkSearchTips();
translateChapterTitleHelpModal();
translateActionButtons();
translateSortButtons();
translateBookmarkFiltersExcludeTags();
translateSearchResultsHeader();
translateWorkSearchResultsHelp();
translateSkinsApprovalModal();
translateSkinsCreatingModal();
translateSkinsConditionsModal();
translateSkinsParentsModal();
translateSkinsWizardFontModal();
translateSkinsWizardFontSizeModal();
translateSkinsWizardVerticalGapModal();
translateSkinsWizardAccentColorModal();
translateCollectionNameHelpModal();
translateIconAltTextHelpModal();
translatePseudIconCommentHelpModal();
translateCollectionModeratedHelpModal();
translateCollectionClosedHelpModal();
translateTagSearchResultsHelp();
translateChallengeAnyTips();
translateOptionalTagsHelp();
translateBookmarkSearchTips();
translateWarningHelpModal();
translateHtmlHelpModal();
translateRteHelpModal();
translateBookmarkSearchResultsHelpModal();
translateTagsetAboutModal();
translateFlashMessages();
translateTagSetsHeading();
translateFoundResultsHeading();
translateTOSPrompt();
translateHeadingTags();
// 统一寻找并重新格式化所有日期容器
const dateSelectors = [
'.header.module .meta span.published',
'li.collection .summary p:has(abbr.day)',
'.comment .posted.datetime',
'.comment .edited.datetime',
'dd.datetime',
'p:has(> span.datetime)',
'p.caution.notice > span:has(abbr.day)',
'p.notice > span:has(abbr.day)',
'div.flash.notice span.datetime',
];
rootElement.querySelectorAll(dateSelectors.join(', '))
.forEach(reformatDateInElement);
// 根据当前页面类型,调用页面专属的翻译和处理函数
const pageType = pageConfig.currentPageType;
if (pageType === 'about_page') {
translateAboutPage();
}
if (pageType === 'diversity_statement') {
translateDiversityStatement();
}
if (pageType === 'donate_page') {
translateDonatePage();
}
if (pageType === 'tag_sets_new' || pageType === 'collections_dashboard_common') {
reorderCategoryCheckboxes();
}
if (pageType === 'front_page') {
translateFrontPageIntro();
}
if (pageType === 'invite_requests_index') {
translateInvitationRequestsPage();
}
if (pageType === 'error_too_many_requests') {
translateTooManyRequestsPage();
}
if (pageType === 'works_search') {
translateWorkSearchDateTips();
translateWorkSearchCrossoverTips();
translateWorkSearchNumericalTips();
translateWorkSearchLanguageTips();
translateWorkSearchTagsTips();
}
if (pageType === 'people_search') {
translatePeopleSearchTips();
}
if (pageType === 'bookmarks_search') {
translateBookmarkSearchWorkTagsTips();
translateBookmarkSearchTypeTips();
translateBookmarkSearchDateUpdatedTips();
translateBookmarkSearchBookmarkerTagsTips();
translateBookmarkSearchRecTips();
translateBookmarkSearchNotesTips();
translateBookmarkSearchDateBookmarkedTips();
}
if (pageType === 'tags_search') {
translateTagSearchTips();
}
if (pageType === 'users_stats') {
translateStatsChart();
}
}
/**
* 更新页面设置
*/
function updatePageConfig() {
const newType = detectPageType();
if (newType && newType !== pageConfig.currentPageType) {
pageConfig = buildPageConfig(newType);
} else if (!pageConfig.currentPageType && newType) {
pageConfig = buildPageConfig(newType);
}
}
/**
* 构建页面设置 pageConfig 对象
*/
function buildPageConfig(pageType = pageConfig.currentPageType) {
const inheritanceMap = {
'admin_posts_index': 'admin_posts_show'
};
const effectivePageType = inheritanceMap[pageType] || pageType;
const baseStatic = I18N[CONFIG.LANG]?.public?.static || {};
const baseRegexp = I18N[CONFIG.LANG]?.public?.regexp || [];
const baseSelector = I18N[CONFIG.LANG]?.public?.selector || [];
const baseInnerHTMLRegexp = I18N[CONFIG.LANG]?.public?.innerHTML_regexp || [];
const globalFlexible = (effectivePageType === 'admin_posts_show') ? {} : (I18N[CONFIG.LANG]?.flexible || {});
const usersCommonStatic = (pageType.startsWith('users_') || pageType === 'profile' || pageType === 'dashboard')
? I18N[CONFIG.LANG]?.users_common?.static || {}
: {};
const pageStatic = I18N[CONFIG.LANG]?.[effectivePageType]?.static || {};
const pageRegexp = I18N[CONFIG.LANG]?.[effectivePageType]?.regexp || [];
const pageSelector = I18N[CONFIG.LANG]?.[effectivePageType]?.selector || [];
const pageInnerHTMLRegexp = I18N[CONFIG.LANG]?.[effectivePageType]?.innerHTML_regexp || [];
let pageFlexible = (effectivePageType === 'admin_posts_show') ? {} : (I18N[CONFIG.LANG]?.[effectivePageType]?.flexible || {});
const parentPageMap = {
'works_edit': 'works_new',
'works_edit_tags': 'works_new',
'chapters_new': 'works_new',
'chapters_edit': 'chapters_new',
'works_edit_multiple': 'works_new',
'skins_edit': 'skins'
};
const parentPageType = parentPageMap[pageType];
let extraStatic = {}, extraRegexp = [], extraSelector = [], extraInnerHTMLRegexp = [], extraFlexible = {};
if (parentPageType) {
const parentConfig = I18N[CONFIG.LANG]?.[parentPageType];
if (parentConfig) {
const parentFullConfig = buildPageConfig(parentPageType);
extraStatic = parentFullConfig.staticDict;
extraRegexp = parentFullConfig.regexpRules;
extraSelector = parentFullConfig.tranSelectors;
extraInnerHTMLRegexp = parentFullConfig.innerHTMLRules;
extraFlexible = { ...parentFullConfig.globalFlexibleDict, ...parentFullConfig.pageFlexibleDict };
}
}
const mergedStatic = { ...baseStatic, ...usersCommonStatic, ...extraStatic, ...pageStatic };
const mergedRegexp = [...pageRegexp, ...extraRegexp, ...baseRegexp];
const mergedSelector = [...pageSelector, ...extraSelector, ...baseSelector];
const mergedInnerHTMLRegexp = [...pageInnerHTMLRegexp, ...extraInnerHTMLRegexp, ...baseInnerHTMLRegexp].sort((a, b) => {
const getLength = (r) => {
return (r[1] instanceof RegExp) ? r[1].source.length : String(r[1]).length;
};
return getLength(b) - getLength(a);
});
const mergedPageFlexible = { ...extraFlexible, ...pageFlexible };
return {
currentPageType: pageType,
staticDict: mergedStatic,
regexpRules: mergedRegexp,
innerHTMLRules: mergedInnerHTMLRegexp,
globalFlexibleDict: globalFlexible,
pageFlexibleDict: mergedPageFlexible,
ignoreMutationSelectors: [
...(I18N.conf.ignoreMutationSelectorPage['*'] || []),
...(I18N.conf.ignoreMutationSelectorPage[pageType] || [])
].join(', ') || ' ',
ignoreSelectors: [
...(I18N.conf.ignoreSelectorPage['*'] || []),
...(I18N.conf.ignoreSelectorPage[pageType] || [])
].join(', ') || ' ',
characterData: I18N.conf.characterDataPage.includes(pageType),
tranSelectors: mergedSelector,
};
}
/**
* 页面类型检测
*/
function detectPageType() {
if (document.title.includes("You're clicking too fast!")) {
const h2 = document.querySelector('main h2');
if (h2 && h2.textContent.includes('Too many page requests too quickly')) {
return 'error_too_many_requests';
}
}
if (document.querySelector('ul.media.fandom.index.group')) return 'media_index';
if (document.querySelector('div#main.owned_tag_sets-show')) return 'owned_tag_sets_show';
const { pathname } = window.location;
if (pathname.startsWith('/first_login_help')) {
return false;
}
if (pathname === '/abuse_reports/new' || pathname === '/support') return 'report_and_support_page';
if (pathname === '/known_issues') return 'known_issues_page';
if (pathname === '/tos') return 'tos_page';
if (pathname === '/content') return 'content_policy_page';
if (pathname === '/privacy') return 'privacy_policy_page';
if (pathname === '/dmca') return 'dmca_policy_page';
if (pathname === '/tos_faq') return 'tos_faq_page';
if (pathname === '/abuse_reports/new') return 'abuse_reports_new';
if (pathname === '/support') return 'support_page';
if (pathname === '/diversity') return 'diversity_statement';
if (pathname === '/site_map') return 'site_map';
if (pathname.startsWith('/wrangling_guidelines')) return 'wrangling_guidelines_page';
if (pathname === '/donate') return 'donate_page';
if (pathname.startsWith('/faq')) return 'faq_page';
if (pathname === '/help/skins-basics.html') return 'help_skins_basics';
if (pathname === '/help/tagset-about.html') return 'help_tagset_about';
if (pathname === '/tag_sets') return 'tag_sets_index';
if (pathname === '/external_works/new') return 'external_works_new';
if (pathname === '/invite_requests' || pathname === '/invite_requests/status') return 'invite_requests_index';
const isSearchResultsPage = document.querySelector('h2.heading')?.textContent.trim() === 'Search Results';
if (pathname === '/works/search') {
return isSearchResultsPage ? 'works_search_results' : 'works_search';
}
if (pathname === '/people/search') {
return isSearchResultsPage ? 'people_search_results' : 'people_search';
}
if (pathname === '/bookmarks/search') {
return isSearchResultsPage ? 'bookmarks_search_results' : 'bookmarks_search';
}
if (pathname === '/tags/search') {
return isSearchResultsPage ? 'tags_search_results' : 'tags_search';
}
if (pathname === '/about') return 'about_page';
const pathSegments = pathname.substring(1).split('/').filter(Boolean);
if (pathname === '/users/login') return 'session_login';
if (pathname === '/users/logout') return 'session_logout';
if (pathname === '/') {
return document.body.classList.contains('logged-in') ? 'dashboard' : 'front_page';
}
if (pathSegments.length > 0) {
const p1 = pathSegments[0];
const p2 = pathSegments[1];
const p3 = pathSegments[2];
const p4 = pathSegments[3];
const p5 = pathSegments[4];
switch (p1) {
case 'admin_posts':
if (p2 && /^\d+$/.test(p2)) {
return 'admin_posts_show';
}
return 'admin_posts_index';
case 'comments':
if (document.querySelector('a[href="/admin_posts"]')) {
return 'admin_posts_show';
}
break;
case 'media':
return 'media_index';
case 'users':
if (p2 && p3 === 'pseuds') {
if (p4 === 'new') return 'users_settings';
if (p4) {
if (p5 === 'works') return 'users_works_index';
if (p5 === 'bookmarks') return 'users_bookmarks_index';
if (p5 === 'series') return 'users_series_index';
if (p5 === 'gifts') return 'users_gifts_index';
if (p5 === 'edit') return 'users_settings';
if (p5 === 'orphan') return 'orphans_new';
if (!p5) return 'profile';
}
if (!p4) return 'users_settings';
}
if (p2 && p3 === 'pseuds' && p5 === 'works') return 'users_works_index';
if (p2 && (p3 === 'blocked' || p3 === 'muted') && p4 === 'users') return 'users_block_mute_list';
if (p2 && p3 === 'dashboard') return 'dashboard';
if (p2 && p3 === 'profile' && p4 === 'edit') return 'users_settings';
if (p2 && p3 === 'profile') return 'profile';
if (p2 && p3 === 'stats') return 'users_stats';
if (p2 && p3 === 'readings') return 'users_history';
if (p2 && p3 === 'preferences') return 'preferences';
if (p2 && p3 === 'edit') return 'users_settings';
if (p2 && p3 === 'change_username') return 'users_settings';
if (p2 && p3 === 'change_password') return 'users_settings';
if (p2 && p3 === 'change_email') return 'users_settings';
if (p2 && p3 === 'works' && p4 === 'drafts') return 'users_drafts_index';
if (p2 && p3 === 'series') return 'users_series_index';
if (p2 && p3 === 'works' && p4 === 'show_multiple') return 'works_show_multiple';
if (p2 && p3 === 'works' && p4 === 'edit_multiple') return 'works_edit_multiple';
if (p2 && p3 === 'works') return 'users_works_index';
if (p2 && p3 === 'bookmarks') return 'users_bookmarks_index';
if (p2 && p3 === 'collections') return 'users_collections_index';
if (p2 && p3 === 'subscriptions') return 'users_subscriptions_index';
if (p2 && p3 === 'related_works') return 'users_related_works_index';
if (p2 && p3 === 'gifts') return 'users_gifts_index';
if (p2 && p3 === 'history') return 'users_history';
if (p2 && p3 === 'inbox') return 'users_inbox';
if (p2 && p3 === 'signups') return 'users_signups';
if (p2 && p3 === 'assignments') return 'users_assignments';
if (p2 && p3 === 'claims') return 'users_claims';
if (p2 && p3 === 'invitations') return 'users_invitations';
if (p2 && !p3) return 'profile';
break;
case 'works':
if (document.querySelector('div#main.works-update')) return 'works_edit';
if (p2 === 'new') {
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get('import') === 'true') {
return 'works_import';
}
return 'works_new';
}
if (p2 === 'search') return isSearchResultsPage ? 'works_search_results' : 'works_search';
if (p2 && /^\d+$/.test(p2)) {
if (p3 === 'chapters' && p4 === 'new') return 'chapters_new';
if (p3 === 'chapters' && p4 && /^\d+$/.test(p4) && p5 === 'edit') return 'chapters_edit';
if (p3 === 'edit_tags') return 'works_edit_tags';
if (p3 === 'edit') return 'works_edit';
if (!p3 || p3 === 'navigate' || (p3 === 'chapters' && p4)) return 'works_chapters_show';
}
if (!p2) return 'works_index';
break;
case 'chapters':
if (p2 && /^\d+$/.test(p2)) {
return 'works_chapters_show';
}
break;
case 'series':
if (p2 && /^\d+$/.test(p2)) return 'series_show';
if (!p2) return 'series_index';
break;
case 'orphans':
return 'orphans_new';
case 'collections':
if (p2 === 'new') {
return 'collections_new';
}
return 'collections_dashboard_common';
case 'tags':
if (p2) {
if (pathSegments.slice(-1)[0] === 'works') return 'tags_works_index';
return 'tags_show';
}
if (!p2) return 'tags_index';
break;
case 'tag_sets':
if (p2 === 'new') {
return 'tag_sets_new';
}
if (p3 === 'nominations' && p4 === 'new') {
return 'tag_sets_nominations_new';
}
break;
case 'skins':
if (p2 === 'new') return 'skins';
if (p2 && /^\d+$/.test(p2) && p3 === 'edit') return 'skins_edit';
if (p2 && /^\d+$/.test(p2)) return 'skins_show';
return 'skins';
case 'bookmarks':
if (p2 && /^\d+$/.test(p2) && p3 === 'new') return 'bookmarks_new_for_work';
if (p2 && /^\d+$/.test(p2)) return 'bookmarks_show';
if (!p2) return 'bookmarks_index';
break;
}
}
if (document.body.classList.contains('dashboard')) return 'dashboard';
if (document.querySelector('body.works.index')) return 'works_index';
if (document.querySelector('body.works.show, body.chapters.show')) return 'works_chapters_show';
const pathMatch = pathname.match(I18N.conf.rePagePath);
if (pathMatch && pathMatch[1]) {
let derivedType = pathMatch[1];
if (pathMatch[2]) derivedType += `_${pathMatch[2]}`;
if (I18N[CONFIG.LANG]?.[derivedType]) {
return derivedType;
}
}
return 'common';
}
/**
* traverseNode 函数:遍历指定的节点,并对节点进行翻译。
* @param {Node} rootNode - 需要遍历的节点。
*/
function traverseNode(rootNode) {
if (rootNode.nodeType === Node.TEXT_NODE) {
if (rootNode.nodeValue && rootNode.nodeValue.length <= 1000) {
if (rootNode.parentElement && rootNode.parentElement.closest(pageConfig.ignoreSelectors)) {
return;
}
transElement(rootNode, 'nodeValue');
}
return;
}
if (rootNode.nodeType === Node.ELEMENT_NODE && rootNode.closest(pageConfig.ignoreSelectors)) {
return;
}
const treeWalker = document.createTreeWalker(
rootNode,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
node => {
if (node.nodeType === Node.ELEMENT_NODE && node.closest(pageConfig.ignoreSelectors)) {
return NodeFilter.FILTER_REJECT;
}
if (node.nodeType === Node.TEXT_NODE && node.parentElement && node.parentElement.closest(pageConfig.ignoreSelectors)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
);
const handleElement = node => {
switch (node.tagName) {
case 'INPUT':
case 'TEXTAREA':
if (['button', 'submit', 'reset'].includes(node.type)) {
transElement(node.dataset, 'confirm');
transElement(node, 'value');
} else {
transElement(node, 'placeholder');
transElement(node, 'title');
}
break;
case 'OPTGROUP':
transElement(node, 'label');
break;
case 'BUTTON':
transElement(node, 'title');
transElement(node.dataset, 'confirm');
transElement(node.dataset, 'confirmText');
transElement(node.dataset, 'confirmCancelText');
transElement(node.dataset, 'disableWith');
break;
case 'A':
transElement(node, 'title');
transElement(node.dataset, 'confirm');
break;
case 'SPAN':
case 'DIV':
case 'P':
case 'LI':
case 'DD':
case 'DT':
case 'H1': case 'H2': case 'H3': case 'H4': case 'H5': case 'H6':
transElement(node, 'title');
break;
case 'IMG':
transElement(node, 'alt');
break;
default:
if (node.hasAttribute('aria-label')) transElement(node, 'ariaLabel');
if (node.hasAttribute('title')) transElement(node, 'title');
break;
}
};
const handleTextNode = node => {
if (node.nodeValue && node.nodeValue.length <= 1000) {
transElement(node, 'nodeValue');
}
};
const handlers = {
[Node.ELEMENT_NODE]: handleElement,
[Node.TEXT_NODE]: handleTextNode
};
let currentNode;
while ((currentNode = treeWalker.nextNode())) {
handlers[currentNode.nodeType]?.(currentNode);
}
}
/**
* transTitle 函数:翻译页面标题。
*/
function transTitle() {
const text = document.title;
let translatedText = pageConfig.staticDict?.[text] || I18N[CONFIG.LANG]?.public?.static?.[text] || I18N[CONFIG.LANG]?.title?.static?.[text] || '';
if (!translatedText) {
const titleRegexRules = [
...(I18N[CONFIG.LANG]?.title?.regexp || []),
...(pageConfig.regexpRules || [])
];
for (const rule of titleRegexRules) {
if (!Array.isArray(rule) || rule.length !== 2) continue;
const [pattern, replacement] = rule;
if (pattern.test(text)) {
translatedText = text.replace(pattern, replacement);
if (translatedText !== text) break;
}
}
}
if (translatedText && translatedText !== text) {
document.title = translatedText;
}
}
/**
* transElement 函数:翻译指定元素的文本内容或属性。
*/
function transElement(el, field) {
if (!el || !el[field]) return false;
const text = el[field];
if (typeof text !== 'string' || !text.trim()) return false;
const translatedText = transText(text, el);
if (translatedText && translatedText !== text) {
try {
el[field] = translatedText;
} catch (e) {
}
}
}
/**
* transText 函数:翻译文本内容。
*/
function transText(text, el) {
if (!text || typeof text !== 'string') return false;
const originalText = text;
let translatedText = text;
const applyFlexibleDict = (targetText, dict) => {
if (!dict) return targetText;
const keys = Object.keys(dict);
if (keys.length === 0) return targetText;
const regexParts = keys.map(key => {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (/^[\w\s]+$/.test(key)) {
return `\\b${escapedKey}\\b`;
} else {
return escapedKey;
}
});
const flexibleRegex = new RegExp(`(${regexParts.join('|')})`, 'g');
if (el && el.nodeType === Node.TEXT_NODE && el.parentElement && el.parentElement.matches('h2.heading a.tag')) {
const fullTagText = el.parentElement.textContent.trim();
if (dict[fullTagText]) {
return targetText.replace(fullTagText, dict[fullTagText]);
} else {
return targetText;
}
}
return targetText.replace(flexibleRegex, (matched) => dict[matched] || matched);
};
translatedText = applyFlexibleDict(translatedText, pageConfig.pageFlexibleDict);
translatedText = applyFlexibleDict(translatedText, pageConfig.globalFlexibleDict);
const staticDict = pageConfig.staticDict || {};
const trimmedText = translatedText.trim();
if (staticDict[trimmedText]) {
translatedText = translatedText.replace(trimmedText, staticDict[trimmedText]);
}
if (FeatureSet.enable_RegExp && pageConfig.regexpRules) {
for (const rule of pageConfig.regexpRules) {
if (!Array.isArray(rule) || rule.length !== 2) continue;
const [pattern, replacement] = rule;
if (pattern.test(translatedText)) {
if (typeof replacement === 'function') {
translatedText = translatedText.replace(pattern, replacement);
} else {
translatedText = translatedText.replace(pattern, replacement);
}
}
}
}
return translatedText !== originalText ? translatedText : false;
}
/**
* transBySelector 函数:通过 CSS 选择器找到页面上的元素,并将其文本内容替换为预定义的翻译。
*/
function transBySelector() {
if (!pageConfig.tranSelectors) return;
pageConfig.tranSelectors.forEach(rule => {
if (!Array.isArray(rule) || rule.length !== 2) return;
const [selector, translatedText] = rule;
try {
const elements = document.querySelectorAll(selector);
elements.forEach(element => {
if (element && element.textContent !== translatedText) {
element.textContent = translatedText;
}
});
} catch (e) {
}
});
}
/**
* 主翻译入口函数
*/
function transDesc() {
if (!FeatureSet.enable_transDesc) {
return;
}
const universalRules = [
{ selector: 'blockquote.userstuff.summary', text: '翻译简介' },
{ selector: '.summary > blockquote.userstuff', text: '翻译简介' },
{ selector: 'blockquote.userstuff.notes', text: '翻译注释' },
{ selector: '.notes > blockquote.userstuff', text: '翻译注释' },
{ selector: '.comment blockquote.userstuff', text: '翻译评论' },
{ selector: 'div.bio > div.userstuff', text: '翻译简介' },
{ selector: '.pseud blockquote.userstuff', text: '翻译简介' },
{ selector: '.latest.news .post.group > blockquote.userstuff', text: '翻译概述' },
{ selector: 'dl.work.meta.group', text: '翻译标签', above: false, isTags: true },
{ selector: 'ul.tags.commas', text: '翻译标签', above: false, isTags: true },
{ selector: '#admin-banner blockquote.userstuff', text: '翻译公告', insertInside: true },
];
const pageSpecificRules = {
'works_show': [
{ selector: '#chapters > .userstuff', text: '翻译正文', above: true, isLazyLoad: true },
{ selector: '#chapters > .chapter > .userstuff[role="article"]', text: '翻译正文', above: true, isLazyLoad: true }
],
'works_chapters_show': [
{ selector: '#chapters > .userstuff', text: '翻译正文', above: true, isLazyLoad: true },
{ selector: '#chapters > .chapter > .userstuff[role="article"]', text: '翻译正文', above: true, isLazyLoad: true }
],
'collections_dashboard_common': [
{ selector: '.primary.header.module blockquote.userstuff', text: '翻译概述', above: false, isLazyLoad: false },
{ selector: '#intro blockquote.userstuff', text: '翻译简介', above: false, isLazyLoad: false }
],
'admin_posts_show': [
{ selector: 'div[role="article"] > .userstuff', text: '翻译动态', above: true, isLazyLoad: true }
],
'admin_posts_index': [
{ selector: '.admin_posts-index div[role="article"] > .userstuff', text: '翻译动态', above: true, isLazyLoad: true }
],
'tos_page': [
{ selector: '#tos.userstuff', text: '翻译内容', above: true, isLazyLoad: true }
],
'content_policy_page': [
{ selector: '#content.userstuff', text: '翻译内容', above: true, isLazyLoad: true }
],
'privacy_policy_page': [
{ selector: '#privacy.userstuff', text: '翻译内容', above: true, isLazyLoad: true }
],
'dmca_policy_page': [
{ selector: '#DMCA.userstuff', text: '翻译内容', above: true, isLazyLoad: true }
],
'tos_faq_page': [
{ selector: '.admin.userstuff', text: '翻译内容', above: true, isLazyLoad: true }
],
'wrangling_guidelines_page': [
{ selector: '.userstuff', text: '翻译内容', above: true, isLazyLoad: true }
],
'faq_page': [
{ selector: '.userstuff', text: '翻译内容', above: true, isLazyLoad: true }
],
'known_issues_page': [
{ selector: '.admin.userstuff', text: '翻译内容', above: true, isLazyLoad: true }
],
'report_and_support_page': [
{ selector: '.userstuff', text: '翻译内容', above: true, isLazyLoad: true }
]
};
const applyRules = (rules) => {
rules.forEach(rule => {
if (rule.selector === 'ul.tags.commas' && (pageConfig.currentPageType === 'admin_posts_show' || pageConfig.currentPageType === 'admin_posts_index' || pageConfig.currentPageType === 'collections_dashboard_common')) {
return;
}
document.querySelectorAll(rule.selector).forEach(element => {
if (element.dataset.translationHandled) return;
if (element.textContent.trim() === '') return;
if (rule.isLazyLoad && element.closest('.summary, .notes, .comment')) return;
if (element.classList.contains('translated-tags-container') || element.closest('.translated-tags-container')) return;
const blurbContainer = element.closest('.blurb.group');
if (rule.selector === 'ul.tags.commas' && blurbContainer) {
const hasSummary = blurbContainer.querySelector('blockquote.userstuff.summary, .summary > blockquote.userstuff');
if (hasSummary) return;
}
let linkedTagsNode = null;
if (blurbContainer && !element.classList.contains('tags') && (element.classList.contains('summary') || element.closest('.summary'))) {
linkedTagsNode = blurbContainer.querySelector('ul.tags.commas');
}
addTranslationButton(element, rule.text, rule.above || false, rule.isLazyLoad || false, rule.isTags || false, linkedTagsNode, rule.insertInside || false);
});
});
};
applyRules(universalRules);
const currentSpecificRules = pageSpecificRules[pageConfig.currentPageType];
if (currentSpecificRules) {
applyRules(currentSpecificRules);
}
}
/**
* 为指定元素添加翻译按钮
*/
function addTranslationButton(element, originalButtonText, isAbove, isLazyLoad, isTags, linkedTagsNode = null, insertInside = false) {
element.dataset.translationHandled = 'true';
const wrapper = document.createElement('div');
wrapper.className = 'translate-me-ao3-wrapper state-idle';
if (isTags) {
wrapper.classList.add('type-tags');
}
const buttonLink = document.createElement('div');
buttonLink.className = 'translate-me-ao3-button';
buttonLink.textContent = originalButtonText;
wrapper.appendChild(buttonLink);
if (isTags && element.tagName === 'DL' && element.classList.contains('meta') && element.parentElement.classList.contains('wrapper')) {
if (isAbove) {
element.parentElement.prepend(wrapper);
} else {
element.parentElement.after(wrapper);
}
} else {
if (insertInside) {
element.appendChild(wrapper);
} else if (isAbove) {
element.prepend(wrapper);
} else {
element.after(wrapper);
}
}
let controller;
if (linkedTagsNode) {
controller = createBlurbTranslationController({
summaryElement: element,
tagsElement: linkedTagsNode,
buttonWrapper: wrapper,
originalButtonText: originalButtonText
});
} else if (isTags) {
controller = createTagsTranslationController({
containerElement: element,
buttonWrapper: wrapper,
originalButtonText: originalButtonText
});
} else {
controller = createTranslationController({
containerElement: element,
buttonWrapper: wrapper,
originalButtonText: originalButtonText,
isLazyLoad: isLazyLoad
});
}
buttonLink.addEventListener('click', () => controller.handleClick());
}
/**
* fetchTranslatedText 函数:从特定页面的词库中获得翻译文本内容。
* @param {string} text - 需要翻译的文本内容
* @returns {string|boolean} 翻译后的文本内容
*/
function fetchTranslatedText(text) {
if (pageConfig.staticDict && pageConfig.staticDict[text] !== undefined) {
return pageConfig.staticDict[text];
}
if (FeatureSet.enable_RegExp && pageConfig.regexpRules) {
for (const rule of pageConfig.regexpRules) {
if (!Array.isArray(rule) || rule.length !== 2) continue;
const [pattern, replacement] = rule;
if (pattern instanceof RegExp && pattern.test(text)) {
const translated = text.replace(pattern, replacement);
if (translated !== text) return translated;
} else if (typeof pattern === 'string' && text.includes(pattern)) {
const translated = text.replace(new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
if (translated !== text) return translated;
}
}
}
return false;
}
/**
* 翻译标题中的标签
*/
function translateHeadingTags() {
const headingTags = document.querySelectorAll('h2.heading a.tag');
if (headingTags.length === 0) return;
const fullDictionary = {
...pageConfig.staticDict,
...pageConfig.globalFlexibleDict,
...pageConfig.pageFlexibleDict
};
headingTags.forEach(tagElement => {
if (tagElement.hasAttribute('data-translated-by-custom-function')) {
return;
}
const originalText = tagElement.textContent.trim();
if (fullDictionary[originalText]) {
tagElement.textContent = fullDictionary[originalText];
}
tagElement.setAttribute('data-translated-by-custom-function', 'true');
});
}
/**
* 脚本主入口检查
*/
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
})(window, document);