// ==UserScript==
// @name automatic translation
// @namespace https://viayoo.com/
// @version v1.1
// @description 按站点设置自动翻译整个页面
// @author leon
// @run-at document-start
// @match https://*/*
// @match http://*/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect api-edge.cognitive.microsofttranslator.com
// @connect edge.microsoft.com
// @connect translate.googleapis.com
// @connect api.mymemory.translated.net
// @connect *
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const FALLBACK_TARGET_LANG = 'zh-Hans';
const MAX_TEXT_LENGTH = 5000;
const BATCH_SIZE = 50;
const AUTO_TRANSLATE_DELAY = 100;
const DOM_CHANGE_DELAY = 800;
const SCROLL_CHECK_DELAY = 2000;
const MAX_RETRY_COUNT = 2;
const SITE_PREF_PREFIX = 'auto_translate_site_pref:';
const MANUAL_SITE_LIST_KEY = 'auto_translate_manual_sites';
const CONTROL_POSITION_KEY = 'auto_translate_control_position';
const TRANSLATOR_SETTINGS_KEY = 'auto_translate_translator_settings';
const LANGUAGE_SETTINGS_KEY = 'auto_translate_language_settings';
const ICON_OPACITY_KEY = 'auto_translate_icon_opacity';
const DEFAULT_SITE_PREF_KEY = 'auto_translate_default_site_pref';
const INSTALL_HINT_SEEN_KEY = 'auto_translate_install_hint_seen';
const PROMPT_EVERY_PAGE_KEY = 'auto_translate_prompt_every_page';
const PREF_ON = 'on';
const PREF_OFF = 'off';
const ICON_DIM_DELAY = 5000;
const INSTALL_HINT_DELAY = 5000;
const DEFAULT_ICON_OPACITY = 0.5;
const DEFAULT_TRANSLATOR_SETTINGS = {
engine: 'microsoft',
customUrl: '',
customResultPath: 'translatedText'
};
const DEFAULT_LANGUAGE_SETTINGS = {
from: 'en',
to: 'zh-Hans'
};
const TRANSLATOR_OPTIONS = [
{ value: 'microsoft', label: '微软翻译' },
{ value: 'google', label: 'Google 翻译' },
{ value: 'mymemory', label: 'MyMemory 翻译' },
{ value: 'custom', label: '自定义接口' }
];
const SOURCE_LANGUAGE_OPTIONS = [
{ value: 'auto', label: '所有语言' },
{ value: 'en', label: '英语' },
{ value: 'zh-Hans', label: '简体中文' },
{ value: 'zh-Hant', label: '繁体中文' },
{ value: 'fr', label: '法语' },
{ value: 'de', label: '德语' },
{ value: 'ja', label: '日语' },
{ value: 'ko', label: '韩语' },
{ value: 'ru', label: '俄语' },
{ value: 'es', label: '西班牙语' },
{ value: 'pt', label: '葡萄牙语' },
{ value: 'it', label: '意大利语' },
{ value: 'ar', label: '阿拉伯语' },
{ value: 'vi', label: '越南语' },
{ value: 'th', label: '泰语' },
{ value: 'nl', label: '荷兰语' },
{ value: 'pl', label: '波兰语' },
{ value: 'tr', label: '土耳其语' },
{ value: 'uk', label: '乌克兰语' },
{ value: 'id', label: '印尼语' },
{ value: 'ms', label: '马来语' },
{ value: 'hi', label: '印地语' },
{ value: 'bn', label: '孟加拉语' },
{ value: 'ur', label: '乌尔都语' },
{ value: 'fa', label: '波斯语' },
{ value: 'he', label: '希伯来语' },
{ value: 'el', label: '希腊语' },
{ value: 'sv', label: '瑞典语' },
{ value: 'da', label: '丹麦语' },
{ value: 'fi', label: '芬兰语' },
{ value: 'no', label: '挪威语' },
{ value: 'cs', label: '捷克语' },
{ value: 'sk', label: '斯洛伐克语' },
{ value: 'hu', label: '匈牙利语' },
{ value: 'ro', label: '罗马尼亚语' },
{ value: 'bg', label: '保加利亚语' },
{ value: 'hr', label: '克罗地亚语' },
{ value: 'sr', label: '塞尔维亚语' },
{ value: 'sl', label: '斯洛文尼亚语' },
{ value: 'lt', label: '立陶宛语' },
{ value: 'lv', label: '拉脱维亚语' },
{ value: 'et', label: '爱沙尼亚语' },
{ value: 'sw', label: '斯瓦希里语' },
{ value: 'ta', label: '泰米尔语' },
{ value: 'te', label: '泰卢固语' },
{ value: 'ml', label: '马拉雅拉姆语' },
{ value: 'mr', label: '马拉地语' },
{ value: 'gu', label: '古吉拉特语' },
{ value: 'kn', label: '卡纳达语' },
{ value: 'pa', label: '旁遮普语' },
{ value: 'ne', label: '尼泊尔语' },
{ value: 'af', label: '南非荷兰语' },
{ value: 'sq', label: '阿尔巴尼亚语' },
{ value: 'am', label: '阿姆哈拉语' },
{ value: 'hy', label: '亚美尼亚语' },
{ value: 'az', label: '阿塞拜疆语' },
{ value: 'eu', label: '巴斯克语' },
{ value: 'ca', label: '加泰罗尼亚语' },
{ value: 'fil', label: '菲律宾语' },
{ value: 'ga', label: '爱尔兰语' },
{ value: 'is', label: '冰岛语' },
{ value: 'km', label: '高棉语' },
{ value: 'lo', label: '老挝语' },
{ value: 'mn', label: '蒙古语' },
{ value: 'my', label: '缅甸语' },
{ value: 'si', label: '僧伽罗语' },
{ value: 'zu', label: '祖鲁语' }
];
const TARGET_LANGUAGE_OPTIONS = SOURCE_LANGUAGE_OPTIONS.filter(function(option) {
return option.value !== 'auto';
});
let microsoftAuthToken = null;
let microsoftTranslatorActive = false;
let translatorReady = false;
let translatorSettings = Object.assign({}, DEFAULT_TRANSLATOR_SETTINGS);
let languageSettings = Object.assign({}, DEFAULT_LANGUAGE_SETTINGS);
let siteTranslationEnabled = false;
let pageTranslating = false;
let translationCache = new Map();
let originalTextMap = new Map();
let domObserver = null;
let domChangeTimer = null;
let isObservingDOM = false;
let scrollCheckTimer = null;
let lastScrollY = 0;
let continuousTranslationTimer = null;
let translationRetryCount = 0;
let lastTranslationTime = 0;
let sitePreference = null;
let defaultSitePreference = PREF_ON;
let promptEveryPage = false;
let controlRoot = null;
let settingsRoot = null;
let iconDimTimer = null;
let iconDimOpacity = DEFAULT_ICON_OPACITY;
let translatorStartPromise = null;
let draggingControl = false;
let suppressNextIconClick = false;
let dragStartX = 0;
let dragStartY = 0;
let dragStartLeft = 0;
let dragStartTop = 0;
// ── 站点开关、设置与界面 ─────────────────────────────
function getStoredValue(key, fallback) {
try {
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
} catch (e) {}
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
try { return JSON.parse(raw); } catch (e) { return raw; }
} catch (e) {
return fallback;
}
}
function setStoredValue(key, value) {
try {
if (typeof GM_setValue === 'function') GM_setValue(key, value);
} catch (e) {}
try {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
} catch (e) {}
}
function getSiteKey() {
return location.hostname || location.host || location.origin || 'local';
}
function getPreferenceKey(siteKey) {
return SITE_PREF_PREFIX + (siteKey || getSiteKey());
}
function normalizePreference(value) {
if (value === PREF_ON || value === true || value === 'true') return PREF_ON;
if (value === PREF_OFF || value === false || value === 'false') return PREF_OFF;
return null;
}
function hasSeenInstallHint() {
return getStoredValue(INSTALL_HINT_SEEN_KEY, false) === true;
}
function markInstallHintSeen() {
setStoredValue(INSTALL_HINT_SEEN_KEY, true);
}
function readDefaultSitePreference() {
defaultSitePreference = normalizePreference(getStoredValue(DEFAULT_SITE_PREF_KEY, PREF_ON)) || PREF_ON;
return defaultSitePreference;
}
function saveDefaultSitePreference(value) {
defaultSitePreference = normalizePreference(value) || PREF_ON;
setStoredValue(DEFAULT_SITE_PREF_KEY, defaultSitePreference);
}
function readPromptEveryPage() {
promptEveryPage = getStoredValue(PROMPT_EVERY_PAGE_KEY, false) === true;
return promptEveryPage;
}
function savePromptEveryPage(value) {
promptEveryPage = value === true;
setStoredValue(PROMPT_EVERY_PAGE_KEY, promptEveryPage);
}
function readManualSites() {
const stored = getStoredValue(MANUAL_SITE_LIST_KEY, []);
const list = Array.isArray(stored)
? stored
: Object.keys(stored || {}).map(function(site) {
return { site: site, pref: stored[site] };
});
return list
.map(function(item) {
return {
site: String(item.site || ''),
pref: normalizePreference(item.pref)
};
})
.filter(function(item) {
return item.site && item.pref;
})
.sort(function(a, b) {
return a.site.localeCompare(b.site);
});
}
function writeManualSites(sites) {
setStoredValue(MANUAL_SITE_LIST_KEY, sites);
}
function readSitePreference(siteKey) {
const key = siteKey || getSiteKey();
const manual = readManualSites().find(function(item) {
return item.site === key;
});
if (!manual) return null;
return normalizePreference(getStoredValue(getPreferenceKey(key), manual.pref)) || manual.pref;
}
function trackManualSite(siteKey, pref) {
const sites = readManualSites().filter(function(item) {
return item.site !== siteKey;
});
sites.push({ site: siteKey, pref: pref });
writeManualSites(sites);
}
function saveSitePreference(value, siteKey) {
const key = siteKey || getSiteKey();
setStoredValue(getPreferenceKey(key), value);
trackManualSite(key, value);
if (key === getSiteKey()) sitePreference = value;
}
function isCurrentSiteEnabled() {
return sitePreference === null ? defaultSitePreference === PREF_ON : sitePreference !== PREF_OFF;
}
function readControlPosition() {
const value = getStoredValue(CONTROL_POSITION_KEY, null);
if (!value || typeof value !== 'object') return null;
const left = Number(value.left);
const top = Number(value.top);
if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
return { left: left, top: top };
}
function saveControlPosition(left, top) {
setStoredValue(CONTROL_POSITION_KEY, { left: Math.round(left), top: Math.round(top) });
}
function normalizeTranslatorSettings(value) {
const settings = Object.assign({}, DEFAULT_TRANSLATOR_SETTINGS, value || {});
if (!TRANSLATOR_OPTIONS.some(function(item) { return item.value === settings.engine; })) {
settings.engine = DEFAULT_TRANSLATOR_SETTINGS.engine;
}
settings.customUrl = String(settings.customUrl || '');
settings.customResultPath = String(settings.customResultPath || DEFAULT_TRANSLATOR_SETTINGS.customResultPath);
return settings;
}
function hasOption(options, value) {
return options.some(function(option) {
return option.value === value;
});
}
function normalizeLanguageSettings(value) {
const settings = Object.assign({}, DEFAULT_LANGUAGE_SETTINGS, value || {});
if (!hasOption(SOURCE_LANGUAGE_OPTIONS, settings.from)) settings.from = DEFAULT_LANGUAGE_SETTINGS.from;
if (!hasOption(TARGET_LANGUAGE_OPTIONS, settings.to)) settings.to = DEFAULT_LANGUAGE_SETTINGS.to;
return settings;
}
function readTranslatorSettings() {
translatorSettings = normalizeTranslatorSettings(getStoredValue(TRANSLATOR_SETTINGS_KEY, DEFAULT_TRANSLATOR_SETTINGS));
return translatorSettings;
}
function saveTranslatorSettings(settings) {
const nextSettings = normalizeTranslatorSettings(settings);
const changed = JSON.stringify(translatorSettings) !== JSON.stringify(nextSettings);
translatorSettings = nextSettings;
setStoredValue(TRANSLATOR_SETTINGS_KEY, translatorSettings);
if (changed) {
translatorReady = false;
translatorStartPromise = null;
translationCache.clear();
}
return changed;
}
function readLanguageSettings() {
languageSettings = normalizeLanguageSettings(getStoredValue(LANGUAGE_SETTINGS_KEY, DEFAULT_LANGUAGE_SETTINGS));
return languageSettings;
}
function saveLanguageSettings(settings) {
const nextSettings = normalizeLanguageSettings(settings);
const changed = JSON.stringify(languageSettings) !== JSON.stringify(nextSettings);
languageSettings = nextSettings;
setStoredValue(LANGUAGE_SETTINGS_KEY, languageSettings);
if (changed) {
translationCache.clear();
}
return changed;
}
function restartTranslationIfActive() {
if (!siteTranslationEnabled) return;
stopDOMObservation();
stopContinuousTranslation();
restoreOriginalText();
startTranslationForSite();
}
function getTranslationSourceLang() {
return languageSettings.from === 'auto' ? null : languageSettings.from;
}
function getTranslationTargetLang() {
return languageSettings.to || FALLBACK_TARGET_LANG;
}
function normalizeIconOpacity(value) {
const number = Number(value);
if (!Number.isFinite(number)) return DEFAULT_ICON_OPACITY;
const opacity = number > 1 ? number / 100 : number;
return Math.min(Math.max(opacity, 0.1), 1);
}
function readIconOpacity() {
iconDimOpacity = normalizeIconOpacity(getStoredValue(ICON_OPACITY_KEY, DEFAULT_ICON_OPACITY));
return iconDimOpacity;
}
function saveIconOpacity(value) {
iconDimOpacity = normalizeIconOpacity(value);
setStoredValue(ICON_OPACITY_KEY, iconDimOpacity);
applyIconOpacity();
}
function applyIconOpacity() {
if (!controlRoot) return;
controlRoot.style.setProperty('--auto-translate-dim-opacity', String(iconDimOpacity));
}
function clearIconDim() {
if (iconDimTimer) {
clearTimeout(iconDimTimer);
iconDimTimer = null;
}
if (controlRoot) controlRoot.dataset.dimmed = 'false';
}
function dimIconNow() {
if (iconDimTimer) {
clearTimeout(iconDimTimer);
iconDimTimer = null;
}
if (controlRoot) controlRoot.dataset.dimmed = 'true';
}
function scheduleIconDim(delay) {
if (!controlRoot) return;
if (iconDimTimer) clearTimeout(iconDimTimer);
iconDimTimer = setTimeout(dimIconNow, delay || ICON_DIM_DELAY);
}
function showInstallHint() {
if (!controlRoot || hasSeenInstallHint()) return;
clearIconDim();
const rect = controlRoot.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const direction = centerX > window.innerWidth / 2 ? -1 : 1;
controlRoot.style.setProperty('--auto-translate-hint-x', centerX + 'px');
controlRoot.style.setProperty('--auto-translate-hint-y', centerY + 'px');
controlRoot.style.setProperty('--auto-translate-broadcast-x', String(direction));
controlRoot.style.setProperty('--auto-translate-label-offset', direction < 0 ? 'calc(-100% - 12px)' : '12px');
controlRoot.dataset.installHint = 'true';
setTimeout(function() {
if (controlRoot) delete controlRoot.dataset.installHint;
markInstallHintSeen();
scheduleIconDim();
}, INSTALL_HINT_DELAY);
}
function injectControlStyle() {
if (document.getElementById('auto-translate-control-style')) return;
const style = document.createElement('style');
style.id = 'auto-translate-control-style';
style.textContent = `
#auto-translate-control {
position: fixed;
top: max(12px, env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right));
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #111827;
font-size: 14px;
line-height: 1.4;
letter-spacing: 0;
}
#auto-translate-control,
#auto-translate-control *,
#auto-translate-settings-page,
#auto-translate-settings-page * {
box-sizing: border-box;
}
#auto-translate-control .auto-translate-icon {
width: 36px;
height: 36px;
border: 0;
border-radius: 50%;
background: #334155;
color: #ffffff;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.26);
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
padding: 0;
touch-action: none;
user-select: none;
opacity: 1;
transition: opacity 0.18s ease, filter 0.18s ease;
}
#auto-translate-control[data-dimmed="true"] .auto-translate-icon {
opacity: var(--auto-translate-dim-opacity, 0.5);
}
#auto-translate-control[data-dimmed="true"] .auto-translate-icon:hover {
opacity: 1;
}
#auto-translate-control .auto-translate-icon:active {
cursor: grabbing;
}
#auto-translate-control[data-install-hint="true"] .auto-translate-icon {
animation: autoTranslateIconPulse 0.85s ease-in-out infinite;
}
#auto-translate-control[data-install-hint="true"]::before {
content: "";
position: fixed;
left: var(--auto-translate-hint-x, 24px);
top: var(--auto-translate-hint-y, 24px);
width: min(1080px, 118vw);
height: min(820px, 112vh);
background:
repeating-radial-gradient(ellipse at 0 0, rgba(20, 184, 166, 0.62) 0 2px, transparent 3px 32px),
radial-gradient(ellipse at 18% 20%, rgba(56, 189, 248, 0.42), transparent 42%),
radial-gradient(ellipse at 34% 46%, rgba(250, 204, 21, 0.22), transparent 48%),
linear-gradient(28deg, rgba(20, 184, 166, 0.35), rgba(59, 130, 246, 0.12), rgba(20, 184, 166, 0));
clip-path: polygon(0 0, 100% 14%, 100% 90%, 0 0);
filter: saturate(1.4);
transform-origin: 0 0;
animation: autoTranslateBroadcast 1.15s ease-out infinite;
pointer-events: none;
}
#auto-translate-control[data-install-hint="true"]::after {
content: "建议先查看默认设置";
position: fixed;
left: var(--auto-translate-hint-x, 24px);
top: calc(var(--auto-translate-hint-y, 24px) + 46px);
transform: translateX(var(--auto-translate-label-offset, 12px));
min-width: 150px;
padding: 8px 10px;
border-radius: 8px;
background: #0f172a;
color: #ffffff;
font-size: 12px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
white-space: nowrap;
pointer-events: none;
}
@keyframes autoTranslateIconPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
@keyframes autoTranslateBroadcast {
0% {
opacity: 0.98;
transform: scaleX(var(--auto-translate-broadcast-x, 1)) scale(0.12);
}
100% {
opacity: 0;
transform: scaleX(var(--auto-translate-broadcast-x, 1)) scale(1.18);
}
}
#auto-translate-control[data-state="on"] .auto-translate-icon,
#auto-translate-control[data-state="default"] .auto-translate-icon {
background: #0f766e;
}
#auto-translate-control .auto-translate-panel {
position: absolute;
top: 44px;
right: 0;
width: min(300px, calc(100vw - 24px));
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.22);
}
#auto-translate-control[data-panel-side="left"] .auto-translate-panel {
left: 0;
right: auto;
}
#auto-translate-control[data-panel-y="up"] .auto-translate-panel {
top: auto;
bottom: 44px;
}
#auto-translate-control .auto-translate-panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
#auto-translate-control .auto-translate-title {
font-size: 15px;
font-weight: 700;
margin: 0 0 4px;
}
#auto-translate-control .auto-translate-site {
color: #64748b;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 210px;
}
#auto-translate-control .auto-translate-gear,
#auto-translate-settings-page .auto-translate-close {
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #f8fafc;
color: #0f172a;
cursor: pointer;
}
#auto-translate-control .auto-translate-gear {
width: 28px;
height: 28px;
flex: 0 0 auto;
font-size: 16px;
padding: 0;
}
#auto-translate-control .auto-translate-status {
color: #475569;
font-size: 13px;
margin-bottom: 12px;
}
#auto-translate-control .auto-translate-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
#auto-translate-control .auto-translate-choice {
min-height: 34px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #f8fafc;
color: #0f172a;
cursor: pointer;
font-size: 14px;
padding: 7px 10px;
}
#auto-translate-control .auto-translate-choice.is-active {
border-color: #0f766e;
background: #ecfdf5;
color: #0f766e;
font-weight: 700;
}
#auto-translate-control button:hover,
#auto-translate-settings-page button:hover {
filter: brightness(0.97);
}
#auto-translate-settings-page {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
padding: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #111827;
font-size: 14px;
line-height: 1.45;
letter-spacing: 0;
}
#auto-translate-settings-page[hidden] {
display: none !important;
}
#auto-translate-settings-page .auto-translate-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.42);
}
#auto-translate-settings-page .auto-translate-settings {
position: relative;
width: min(760px, calc(100vw - 28px));
max-height: min(760px, calc(100vh - 28px));
overflow: auto;
background: #f8fafc;
border-radius: 8px;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.28);
border: 1px solid rgba(148, 163, 184, 0.38);
}
#auto-translate-settings-page .auto-translate-settings-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid #e2e8f0;
background: #ffffff;
}
#auto-translate-settings-page h2,
#auto-translate-settings-page h3 {
margin: 0;
font-size: 16px;
}
#auto-translate-settings-page h3 {
margin-bottom: 10px;
}
#auto-translate-settings-page .auto-translate-close {
width: 32px;
height: 32px;
font-size: 20px;
line-height: 1;
}
#auto-translate-settings-page .auto-translate-settings-body {
display: grid;
gap: 12px;
padding: 14px 16px 16px;
}
#auto-translate-settings-page .auto-translate-section {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
#auto-translate-settings-page label {
display: grid;
gap: 6px;
color: #334155;
font-size: 13px;
margin-bottom: 10px;
}
#auto-translate-settings-page input {
width: 100%;
min-height: 36px;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 7px 9px;
color: #0f172a;
background: #ffffff;
font-size: 14px;
}
#auto-translate-settings-page .auto-translate-field {
display: grid;
gap: 6px;
color: #334155;
font-size: 13px;
}
#auto-translate-settings-page .auto-translate-dropdown {
position: relative;
min-width: 0;
}
#auto-translate-settings-page .auto-translate-dropdown-button {
width: 100%;
min-height: 36px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #ffffff;
color: #0f172a;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 9px;
font-size: 14px;
text-align: left;
}
#auto-translate-settings-page .auto-translate-dropdown-button:focus-visible,
#auto-translate-settings-page .auto-translate-icon-button:focus-visible,
#auto-translate-settings-page input:focus-visible {
border-color: #0f766e;
outline: 2px solid rgba(20, 184, 166, 0.24);
outline-offset: 2px;
}
#auto-translate-settings-page .auto-translate-dropdown-arrow {
color: #64748b;
font-size: 12px;
flex: 0 0 auto;
}
#auto-translate-settings-page .auto-translate-dropdown-menu {
position: fixed;
top: 0;
left: 0;
right: auto;
z-index: 2147483647;
max-height: 216px;
overflow-y: auto;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
padding: 4px;
}
#auto-translate-settings-page .auto-translate-dropdown[data-side="left"] .auto-translate-dropdown-menu {
left: auto;
right: calc(100% + 8px);
}
#auto-translate-settings-page .auto-translate-dropdown[data-side="right"] .auto-translate-dropdown-menu {
left: calc(100% + 8px);
right: auto;
}
#auto-translate-settings-page .auto-translate-dropdown-option {
width: 100%;
min-height: 34px;
border: 0;
border-radius: 6px;
background: transparent;
color: #0f172a;
cursor: pointer;
display: block;
padding: 7px 8px;
text-align: left;
font-size: 14px;
}
#auto-translate-settings-page .auto-translate-dropdown-option:hover,
#auto-translate-settings-page .auto-translate-dropdown-option.is-active {
background: #ecfdf5;
color: #0f766e;
}
#auto-translate-settings-page .auto-translate-dropdown.is-disabled .auto-translate-dropdown-button {
cursor: default;
color: #94a3b8;
background: #f8fafc;
}
#auto-translate-settings-page .auto-translate-hint {
margin: 0 0 10px;
color: #64748b;
font-size: 12px;
}
#auto-translate-settings-page .auto-translate-setting-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
#auto-translate-settings-page [hidden] {
display: none !important;
}
#auto-translate-settings-page .auto-translate-toggle-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px 52px;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
#auto-translate-settings-page .auto-translate-default-row {
grid-template-columns: minmax(0, 1fr) 150px;
}
#auto-translate-settings-page .auto-translate-prompt-row {
grid-template-columns: minmax(0, 1fr) 52px;
margin-top: 10px;
}
#auto-translate-settings-page .auto-translate-title-switch {
display: inline-flex;
align-items: center;
gap: 10px;
max-width: 100%;
}
#auto-translate-settings-page .auto-translate-compact-label {
margin: 0;
}
#auto-translate-settings-page .auto-translate-compact-label input {
min-height: 34px;
}
#auto-translate-settings-page .auto-translate-toggle-title {
color: #0f172a;
font-weight: 700;
margin-bottom: 2px;
}
#auto-translate-settings-page .auto-translate-toggle-subtitle {
color: #64748b;
font-size: 12px;
}
#auto-translate-settings-page .auto-translate-switch {
position: relative;
display: inline-flex;
width: 52px;
height: 30px;
flex: 0 0 auto;
margin: 0;
}
#auto-translate-settings-page .auto-translate-switch input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
#auto-translate-settings-page .auto-translate-switch span {
position: absolute;
inset: 0;
border-radius: 999px;
background: #cbd5e1;
transition: background 0.18s ease;
cursor: pointer;
}
#auto-translate-settings-page .auto-translate-switch span::before {
content: "";
position: absolute;
width: 24px;
height: 24px;
top: 3px;
left: 3px;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.2);
transition: transform 0.18s ease;
}
#auto-translate-settings-page .auto-translate-switch input:checked + span {
background: #0f766e;
}
#auto-translate-settings-page .auto-translate-switch input:checked + span::before {
transform: translateX(22px);
}
#auto-translate-settings-page .auto-translate-sites {
display: grid;
gap: 10px;
}
#auto-translate-settings-page .auto-translate-section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
#auto-translate-settings-page .auto-translate-section-title-row h3 {
margin-bottom: 0;
}
#auto-translate-settings-page .auto-translate-icon-button {
width: 34px;
height: 34px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #ffffff;
color: #0f172a;
cursor: pointer;
font-size: 15px;
padding: 0;
}
#auto-translate-settings-page .auto-translate-site-search {
margin-bottom: 10px;
width: min(42%, 360px);
min-width: 220px;
}
#auto-translate-settings-page .auto-translate-site-picker {
display: grid;
grid-template-columns: 360px max-content;
gap: 10px;
align-items: end;
width: min(100%, 540px);
max-width: 100%;
justify-content: start;
}
#auto-translate-settings-page .auto-translate-site-picker > .auto-translate-field {
width: 100%;
max-width: 360px;
}
#auto-translate-settings-page .auto-translate-site-picker .auto-translate-toggle-row {
grid-template-columns: max-content 52px;
gap: 8px;
min-height: 36px;
padding: 3px 8px 3px 10px;
background: #ffffff;
}
#auto-translate-settings-page .auto-translate-site-picker .auto-translate-toggle-title {
margin: 0;
font-size: 13px;
line-height: 1;
white-space: nowrap;
}
#auto-translate-settings-page .auto-translate-site-picker .auto-translate-toggle-subtitle {
display: none;
}
#auto-translate-settings-page .auto-translate-empty {
margin: 0;
color: #64748b;
}
@media (max-width: 520px) {
#auto-translate-settings-page .auto-translate-setting-grid,
#auto-translate-settings-page .auto-translate-toggle-row,
#auto-translate-settings-page .auto-translate-site-picker {
grid-template-columns: 1fr;
}
#auto-translate-settings-page .auto-translate-default-row {
grid-template-columns: 1fr;
}
#auto-translate-settings-page .auto-translate-prompt-row {
grid-template-columns: minmax(0, 1fr) 52px;
}
#auto-translate-settings-page .auto-translate-site-search {
width: 100%;
min-width: 0;
}
#auto-translate-settings-page .auto-translate-site-picker {
width: 100%;
}
#auto-translate-settings-page .auto-translate-site-picker > .auto-translate-field {
max-width: none;
}
#auto-translate-settings-page .auto-translate-site-picker .auto-translate-toggle-row {
grid-template-columns: max-content 52px;
justify-content: start;
}
}
`;
(document.head || document.documentElement).appendChild(style);
}
function ensureControlUi() {
if (controlRoot || !document.body) return;
injectControlStyle();
controlRoot = document.createElement('div');
controlRoot.id = 'auto-translate-control';
controlRoot.className = 'auto-translate-control no-translate script-element';
controlRoot.setAttribute('translate', 'no');
controlRoot.setAttribute('data-no-translate', 'true');
controlRoot.setAttribute('data-protect-translation', 'true');
controlRoot.setAttribute('data-auto-translate-ui', 'true');
controlRoot.innerHTML = `
`;
document.body.appendChild(controlRoot);
applyIconOpacity();
applyStoredControlPosition();
const icon = controlRoot.querySelector('[data-role="icon"]');
icon.addEventListener('pointerdown', handleControlPointerDown);
icon.addEventListener('pointermove', handleControlPointerMove);
icon.addEventListener('pointerup', handleControlPointerUp);
icon.addEventListener('pointercancel', handleControlPointerUp);
icon.addEventListener('click', function(event) {
if (suppressNextIconClick) {
event.preventDefault();
event.stopPropagation();
suppressNextIconClick = false;
return;
}
const panel = controlRoot.querySelector('[data-role="panel"]');
if (panel.hidden) {
showControlPanel();
} else {
hideControlPanel();
}
});
controlRoot.querySelector('[data-action="enable"]').addEventListener('click', function() {
chooseSitePreference(PREF_ON);
});
controlRoot.querySelector('[data-action="disable"]').addEventListener('click', function() {
chooseSitePreference(PREF_OFF);
});
controlRoot.querySelector('[data-action="settings"]').addEventListener('click', function() {
openSettingsPage();
});
updateControlUi();
scheduleIconDim();
}
function applyStoredControlPosition() {
const position = readControlPosition();
if (!controlRoot || !position) return;
const clamped = clampControlPosition(position.left, position.top);
controlRoot.style.left = clamped.left + 'px';
controlRoot.style.top = clamped.top + 'px';
controlRoot.style.right = 'auto';
}
function clampControlPosition(left, top) {
const size = controlRoot ? Math.max(controlRoot.offsetWidth || 42, 42) : 42;
const maxLeft = Math.max(8, window.innerWidth - size - 8);
const maxTop = Math.max(8, window.innerHeight - size - 8);
return {
left: Math.min(Math.max(8, left), maxLeft),
top: Math.min(Math.max(8, top), maxTop)
};
}
function handleControlPointerDown(event) {
if (event.button !== undefined && event.button !== 0) return;
if (!controlRoot) return;
clearIconDim();
const rect = controlRoot.getBoundingClientRect();
dragStartX = event.clientX;
dragStartY = event.clientY;
dragStartLeft = rect.left;
dragStartTop = rect.top;
draggingControl = false;
suppressNextIconClick = false;
try { event.currentTarget.setPointerCapture(event.pointerId); } catch (e) {}
}
function handleControlPointerMove(event) {
if (dragStartX === 0 && dragStartY === 0) return;
const deltaX = event.clientX - dragStartX;
const deltaY = event.clientY - dragStartY;
if (!draggingControl && Math.hypot(deltaX, deltaY) < 4) return;
draggingControl = true;
suppressNextIconClick = true;
hideControlPanel();
const position = clampControlPosition(dragStartLeft + deltaX, dragStartTop + deltaY);
controlRoot.style.left = position.left + 'px';
controlRoot.style.top = position.top + 'px';
controlRoot.style.right = 'auto';
event.preventDefault();
}
function handleControlPointerUp(event) {
if (draggingControl && controlRoot) {
const rect = controlRoot.getBoundingClientRect();
const position = clampControlPosition(rect.left, rect.top);
saveControlPosition(position.left, position.top);
scheduleIconDim();
}
draggingControl = false;
dragStartX = 0;
dragStartY = 0;
try { event.currentTarget.releasePointerCapture(event.pointerId); } catch (e) {}
}
function updateControlUi() {
if (!controlRoot) return;
const enabled = isCurrentSiteEnabled();
const isDefault = sitePreference === null;
const icon = controlRoot.querySelector('[data-role="icon"]');
const site = controlRoot.querySelector('[data-role="site"]');
const status = controlRoot.querySelector('[data-role="status"]');
const enableButton = controlRoot.querySelector('[data-action="enable"]');
const disableButton = controlRoot.querySelector('[data-action="disable"]');
controlRoot.dataset.state = enabled ? (isDefault ? 'default' : 'on') : 'off';
icon.title = enabled ? '自动翻译已开启,拖动可移动,点击可更改' : '自动翻译未开启,拖动可移动,点击可更改';
site.textContent = getSiteKey();
status.textContent = isDefault ? (enabled ? '当前:默认翻译' : '当前:默认不翻译') : (enabled ? '当前:翻译' : '当前:不翻译');
enableButton.classList.toggle('is-active', enabled);
disableButton.classList.toggle('is-active', !enabled);
enableButton.setAttribute('aria-pressed', enabled ? 'true' : 'false');
disableButton.setAttribute('aria-pressed', enabled ? 'false' : 'true');
}
function updatePanelPlacement() {
if (!controlRoot) return;
const rect = controlRoot.getBoundingClientRect();
controlRoot.dataset.panelSide = rect.left < 310 ? 'left' : 'right';
controlRoot.dataset.panelY = rect.top > window.innerHeight - 260 ? 'up' : 'down';
}
function showControlPanel() {
ensureControlUi();
if (!controlRoot) return;
clearIconDim();
updatePanelPlacement();
const panel = controlRoot.querySelector('[data-role="panel"]');
panel.hidden = false;
}
function hideControlPanel() {
if (!controlRoot) return;
controlRoot.querySelector('[data-role="panel"]').hidden = true;
scheduleIconDim();
}
function chooseSitePreference(value) {
saveSitePreference(value);
hideControlPanel();
if (value === PREF_ON) {
enableSiteTranslation();
} else {
disableSiteTranslation();
}
updateControlUi();
renderManualSites();
dimIconNow();
}
function closeSettingsDropdowns(exceptRoot) {
if (!settingsRoot) return;
settingsRoot.querySelectorAll('.auto-translate-dropdown-menu').forEach(function(menu) {
if (!exceptRoot || !exceptRoot.contains(menu)) menu.hidden = true;
});
}
function positionDropdownMenu(root) {
if (!settingsRoot || !root) return;
const menu = root.querySelector('.auto-translate-dropdown-menu');
const modal = settingsRoot.querySelector('.auto-translate-settings');
if (!menu || !modal) return;
const rootRect = root.getBoundingClientRect();
const modalRect = modal.getBoundingClientRect();
const gap = 8;
const viewportGap = 12;
const isManualSiteDropdown = root.getAttribute('data-role') === 'manual-site-select';
if (isManualSiteDropdown) {
const width = Math.max(220, rootRect.width);
const maxHeight = 212;
const menuHeight = Math.min(menu.scrollHeight || maxHeight, maxHeight);
const left = Math.max(viewportGap, Math.min(rootRect.left, window.innerWidth - width - viewportGap));
const top = Math.max(viewportGap, rootRect.top - menuHeight - gap);
root.dataset.side = 'up';
menu.style.width = width + 'px';
menu.style.maxHeight = maxHeight + 'px';
menu.style.left = left + 'px';
menu.style.right = 'auto';
menu.style.top = top + 'px';
return;
}
const minWidth = Math.max(180, rootRect.width);
const preferredWidth = Math.max(220, rootRect.width);
const leftSpace = Math.max(0, rootRect.left - modalRect.left - gap);
const rightSpace = Math.max(0, modalRect.right - rootRect.right - gap);
const defaultSide = rootRect.left + rootRect.width / 2 > modalRect.left + modalRect.width / 2 ? 'left' : 'right';
let side = defaultSide;
if (side === 'right' && rightSpace < minWidth && leftSpace > rightSpace) side = 'left';
if (side === 'left' && leftSpace < minWidth && rightSpace > leftSpace) side = 'right';
const viewportSpace = side === 'left'
? Math.max(0, rootRect.left - gap - viewportGap)
: Math.max(0, window.innerWidth - rootRect.right - gap - viewportGap);
const available = Math.min(side === 'left' ? leftSpace : rightSpace, viewportSpace);
const width = Math.max(140, Math.min(preferredWidth, available || preferredWidth));
const menuHeight = Math.min(menu.scrollHeight || 216, 216);
const rawLeft = side === 'left' ? rootRect.left - gap - width : rootRect.right + gap;
const left = Math.max(viewportGap, Math.min(rawLeft, window.innerWidth - width - viewportGap));
const top = Math.max(viewportGap, Math.min(rootRect.top, window.innerHeight - menuHeight - viewportGap));
root.dataset.side = side;
menu.style.width = width + 'px';
menu.style.left = left + 'px';
menu.style.right = 'auto';
menu.style.top = top + 'px';
}
function renderDropdown(root, options, value, onChange) {
if (!root) return;
const list = Array.isArray(options) ? options : [];
const active = list.find(function(option) {
return option.value === value;
}) || list[0] || { value: '', label: '暂无选项' };
root.className = 'auto-translate-dropdown' + (list.length === 0 ? ' is-disabled' : '');
root.dataset.value = active.value;
root.innerHTML = '';
const button = document.createElement('button');
button.type = 'button';
button.className = 'auto-translate-dropdown-button';
button.disabled = list.length === 0;
button.innerHTML = '▾';
button.querySelector('[data-role="dropdown-label"]').textContent = active.label;
const menu = document.createElement('div');
menu.className = 'auto-translate-dropdown-menu';
menu.hidden = true;
list.forEach(function(option) {
const optionButton = document.createElement('button');
optionButton.type = 'button';
optionButton.className = 'auto-translate-dropdown-option' + (option.value === active.value ? ' is-active' : '');
optionButton.textContent = option.label;
optionButton.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
root.dataset.value = option.value;
button.querySelector('[data-role="dropdown-label"]').textContent = option.label;
menu.hidden = true;
menu.querySelectorAll('.auto-translate-dropdown-option').forEach(function(item) {
item.classList.toggle('is-active', item === optionButton);
});
if (typeof onChange === 'function') onChange(option.value);
});
menu.appendChild(optionButton);
});
button.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
if (list.length === 0) return;
const shouldOpen = menu.hidden;
closeSettingsDropdowns(root);
if (shouldOpen) {
menu.hidden = false;
positionDropdownMenu(root);
} else {
menu.hidden = true;
}
});
root.appendChild(button);
root.appendChild(menu);
}
function getDropdownValue(role) {
if (!settingsRoot) return '';
const root = settingsRoot.querySelector('[data-role="' + role + '"]');
return root ? root.dataset.value || '' : '';
}
function ensureSettingsPage() {
if (settingsRoot || !document.body) return;
injectControlStyle();
settingsRoot = document.createElement('div');
settingsRoot.id = 'auto-translate-settings-page';
settingsRoot.className = 'auto-translate-settings-page no-translate script-element';
settingsRoot.hidden = true;
settingsRoot.setAttribute('translate', 'no');
settingsRoot.setAttribute('data-no-translate', 'true');
settingsRoot.setAttribute('data-protect-translation', 'true');
settingsRoot.setAttribute('data-auto-translate-ui', 'true');
settingsRoot.innerHTML = `
自动翻译设置
翻译服务
图标 5 秒无操作后会按设定透明度淡化。
`;
document.body.appendChild(settingsRoot);
settingsRoot.addEventListener('click', function(event) {
const action = event.target && event.target.getAttribute('data-action');
if (!event.target.closest('.auto-translate-dropdown')) closeSettingsDropdowns();
if (action === 'close-settings') closeSettingsPage();
if (action === 'toggle-site-search') toggleManualSiteSearch();
});
settingsRoot.querySelector('[data-role="default-translate"]').addEventListener('change', applyDefaultSettingFromForm);
settingsRoot.querySelector('[data-role="prompt-every-page"]').addEventListener('change', applyPromptEveryPageFromForm);
settingsRoot.querySelector('[data-role="icon-opacity"]').addEventListener('input', applyIconOpacityFromForm);
settingsRoot.querySelector('[data-role="custom-url"]').addEventListener('change', applyTranslatorSettingsFromForm);
settingsRoot.querySelector('[data-role="custom-path"]').addEventListener('change', applyTranslatorSettingsFromForm);
settingsRoot.querySelector('[data-role="manual-site-search"]').addEventListener('input', renderManualSites);
settingsRoot.querySelector('[data-role="manual-site-pref"]').addEventListener('change', applyManualSitePreferenceFromForm);
}
function openSettingsPage() {
hideControlPanel();
ensureSettingsPage();
renderSettingsPage();
settingsRoot.hidden = false;
}
function closeSettingsPage() {
if (settingsRoot) settingsRoot.hidden = true;
}
function renderSettingsPage() {
if (!settingsRoot) return;
renderDropdown(settingsRoot.querySelector('[data-role="translator-engine"]'), TRANSLATOR_OPTIONS, translatorSettings.engine, function() {
applyTranslatorSettingsFromForm();
updateCustomFieldsVisibility();
});
renderDropdown(settingsRoot.querySelector('[data-role="source-language"]'), SOURCE_LANGUAGE_OPTIONS, languageSettings.from, function() {
applyLanguageSettingsFromForm();
});
renderDropdown(settingsRoot.querySelector('[data-role="target-language"]'), TARGET_LANGUAGE_OPTIONS, languageSettings.to, function() {
applyLanguageSettingsFromForm();
});
settingsRoot.querySelector('[data-role="custom-url"]').value = translatorSettings.customUrl;
settingsRoot.querySelector('[data-role="custom-path"]').value = translatorSettings.customResultPath;
settingsRoot.querySelector('[data-role="icon-opacity"]').value = Math.round(iconDimOpacity * 100);
settingsRoot.querySelector('[data-role="default-translate"]').checked = defaultSitePreference === PREF_ON;
settingsRoot.querySelector('[data-role="prompt-every-page"]').checked = promptEveryPage;
updateCustomFieldsVisibility();
renderManualSites();
}
function updateCustomFieldsVisibility() {
if (!settingsRoot) return;
const engine = getDropdownValue('translator-engine') || translatorSettings.engine;
settingsRoot.querySelector('[data-role="custom-fields"]').hidden = engine !== 'custom';
}
function applyTranslatorSettingsFromForm() {
if (!settingsRoot) return;
const nextSettings = {
engine: getDropdownValue('translator-engine') || translatorSettings.engine,
customUrl: settingsRoot.querySelector('[data-role="custom-url"]').value.trim(),
customResultPath: settingsRoot.querySelector('[data-role="custom-path"]').value.trim() || DEFAULT_TRANSLATOR_SETTINGS.customResultPath
};
if (saveTranslatorSettings(nextSettings)) {
restartTranslationIfActive();
}
}
function applyLanguageSettingsFromForm() {
if (!settingsRoot) return;
const changed = saveLanguageSettings({
from: getDropdownValue('source-language') || languageSettings.from,
to: getDropdownValue('target-language') || languageSettings.to
});
if (changed) restartTranslationIfActive();
}
function applyIconOpacityFromForm() {
if (!settingsRoot) return;
saveIconOpacity(settingsRoot.querySelector('[data-role="icon-opacity"]').value);
dimIconNow();
}
function applyDefaultSettingFromForm() {
if (!settingsRoot) return;
saveDefaultSitePreference(settingsRoot.querySelector('[data-role="default-translate"]').checked ? PREF_ON : PREF_OFF);
if (sitePreference === null) {
if (defaultSitePreference === PREF_ON) {
enableSiteTranslation();
} else {
disableSiteTranslation();
}
updateControlUi();
}
}
function applyPromptEveryPageFromForm() {
if (!settingsRoot) return;
savePromptEveryPage(settingsRoot.querySelector('[data-role="prompt-every-page"]').checked);
}
function toggleManualSiteSearch() {
if (!settingsRoot) return;
const wrap = settingsRoot.querySelector('[data-role="manual-site-search-wrap"]');
const input = settingsRoot.querySelector('[data-role="manual-site-search"]');
if (!wrap || !input) return;
wrap.hidden = !wrap.hidden;
if (!wrap.hidden) {
input.focus();
} else {
input.value = '';
renderManualSites();
}
}
function renderManualSites() {
if (!settingsRoot) return;
const searchInput = settingsRoot.querySelector('[data-role="manual-site-search"]');
const siteDropdown = settingsRoot.querySelector('[data-role="manual-site-select"]');
const prefSwitch = settingsRoot.querySelector('[data-role="manual-site-pref"]');
const empty = settingsRoot.querySelector('[data-role="manual-site-empty"]');
if (!searchInput || !siteDropdown || !prefSwitch || !empty) return;
const previousValue = siteDropdown.dataset.value || '';
const keyword = searchInput.value.trim().toLowerCase();
const sites = readManualSites();
const matches = sites.filter(function(item) {
return !keyword || item.site.toLowerCase().indexOf(keyword) !== -1;
});
const selectedValue = matches.some(function(item) { return item.site === previousValue; })
? previousValue
: (matches[0] ? matches[0].site : '');
renderDropdown(siteDropdown, matches.map(function(item) {
return { value: item.site, label: item.site };
}), selectedValue, updateManualSitePrefControl);
prefSwitch.disabled = matches.length === 0;
empty.hidden = matches.length !== 0;
empty.textContent = sites.length === 0 ? '暂无手动设置的网站' : '暂无匹配的网站';
updateManualSitePrefControl();
}
function updateManualSitePrefControl() {
if (!settingsRoot) return;
const siteDropdown = settingsRoot.querySelector('[data-role="manual-site-select"]');
const prefSwitch = settingsRoot.querySelector('[data-role="manual-site-pref"]');
const prefText = settingsRoot.querySelector('[data-role="manual-site-pref-text"]');
if (!siteDropdown || !prefSwitch || !prefText) return;
const site = siteDropdown.dataset.value || '';
if (!site) {
prefSwitch.disabled = true;
prefSwitch.checked = false;
prefText.textContent = '未选择网站';
return;
}
const pref = readSitePreference(site) || PREF_ON;
prefSwitch.disabled = false;
prefSwitch.checked = pref === PREF_ON;
prefText.textContent = pref === PREF_ON ? '当前:翻译' : '当前:不翻译';
}
function applyManualSitePreferenceFromForm() {
if (!settingsRoot) return;
const siteDropdown = settingsRoot.querySelector('[data-role="manual-site-select"]');
const prefSwitch = settingsRoot.querySelector('[data-role="manual-site-pref"]');
if (!siteDropdown || !prefSwitch || !siteDropdown.dataset.value) return;
const pref = prefSwitch.checked ? PREF_ON : PREF_OFF;
saveSitePreference(pref, siteDropdown.dataset.value);
updateManualSitePrefControl();
if (siteDropdown.dataset.value === getSiteKey()) {
if (pref === PREF_OFF) {
disableSiteTranslation();
} else {
enableSiteTranslation();
}
updateControlUi();
}
}
async function enableSiteTranslation() {
siteTranslationEnabled = true;
updateControlUi();
await startTranslationForSite();
}
function disableSiteTranslation() {
siteTranslationEnabled = false;
pageTranslating = false;
stopDOMObservation();
stopContinuousTranslation();
restoreOriginalText();
updateControlUi();
}
function restoreOriginalText() {
const translatedNodes = document.querySelectorAll('.microsoft-translated[data-original-id]');
translatedNodes.forEach(function(span) {
const originalText = originalTextMap.get(span.dataset.originalId);
if (typeof originalText === 'string' && span.parentNode) {
span.parentNode.replaceChild(document.createTextNode(originalText), span);
}
});
}
async function startTranslationForSite() {
if (translatorStartPromise) return translatorStartPromise;
translatorStartPromise = (async function() {
if (!siteTranslationEnabled) return;
await setupActiveTranslator();
if (!siteTranslationEnabled || !translatorReady) return;
scheduleInitialTranslation();
startDOMObservation();
startContinuousTranslation();
})();
translatorStartPromise.finally(function() {
translatorStartPromise = null;
});
return translatorStartPromise;
}
function scheduleInitialTranslation() {
const run = function() {
if (siteTranslationEnabled) translateWholePage();
};
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(run, AUTO_TRANSLATE_DELAY);
} else {
window.addEventListener('load', function() {
setTimeout(run, AUTO_TRANSLATE_DELAY);
}, { once: true });
}
}
// ── 翻译 API ──────────────────────────────────────────
function requestText(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || 'GET',
url: options.url,
headers: options.headers || {},
data: options.data,
onload: function(res) {
if (res.status >= 200 && res.status < 300) {
resolve(res.responseText);
} else {
reject(new Error('请求失败: ' + res.status));
}
},
onerror: function() {
reject(new Error('网络请求失败'));
}
});
});
}
function getMicrosoftAuthToken() {
return requestText({
method: 'GET',
url: 'https://edge.microsoft.com/translate/auth'
});
}
function normalizeLangForEngine(lang, engine) {
if (engine === 'microsoft') return lang;
if (lang === 'zh-Hans') return 'zh-CN';
if (lang === 'zh-Hant') return 'zh-TW';
return lang;
}
function normalizeSourceLang(fromLang, engine) {
if (!fromLang || fromLang === 'auto') return engine === 'microsoft' ? null : 'auto';
return normalizeLangForEngine(fromLang, engine);
}
function normalizeTargetLang(toLang, engine) {
return normalizeLangForEngine(toLang || FALLBACK_TARGET_LANG, engine);
}
async function setupActiveTranslator() {
const engine = translatorSettings.engine;
translatorReady = false;
if (engine === 'microsoft') {
await setupMicrosoftTranslator();
translatorReady = microsoftTranslatorActive;
return;
}
if (engine === 'custom' && !translatorSettings.customUrl.trim()) {
console.error('自定义翻译接口为空');
return;
}
translatorReady = true;
}
async function setupMicrosoftTranslator() {
if (microsoftTranslatorActive && microsoftAuthToken) return;
try {
microsoftAuthToken = await getMicrosoftAuthToken();
microsoftTranslatorActive = true;
} catch (e) {
microsoftTranslatorActive = false;
console.error('微软翻译初始化失败:', e);
}
}
function translateMicrosoftBatch(texts, fromLang, toLang) {
return new Promise((resolve, reject) => {
if (!microsoftAuthToken || !texts || texts.length === 0) {
return reject(new Error('参数无效'));
}
const fromParam = fromLang ? `&from=${fromLang}` : '';
GM_xmlhttpRequest({
method: 'POST',
url: `https://api-edge.cognitive.microsofttranslator.com/translate?to=${toLang}${fromParam}&api-version=3.0&includeSentenceLength=true`,
headers: {
'Authorization': `Bearer ${microsoftAuthToken}`,
'Content-Type': 'application/json'
},
data: JSON.stringify(texts.map(t => ({ Text: t }))),
onload: res => {
if (res.status === 200) {
try {
const result = JSON.parse(res.responseText);
resolve(result.map(item => item && item.translations && item.translations[0] ? item.translations[0].text : null));
} catch (e) { reject(new Error('解析失败')); }
} else {
reject(new Error('翻译失败: ' + res.status));
}
},
onerror: () => reject(new Error('网络请求失败'))
});
});
}
async function translateGoogleText(text, fromLang, toLang) {
const targetLang = normalizeTargetLang(toLang, 'google');
const sourceLang = normalizeSourceLang(fromLang, 'google') || 'auto';
const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=' +
encodeURIComponent(sourceLang) + '&tl=' + encodeURIComponent(targetLang) +
'&dt=t&q=' + encodeURIComponent(text);
const response = await requestText({ url: url });
const result = JSON.parse(response);
if (!Array.isArray(result) || !Array.isArray(result[0])) return null;
return result[0].map(function(part) {
return Array.isArray(part) ? part[0] : '';
}).join('');
}
async function translateMyMemoryText(text, fromLang, toLang) {
const targetLang = normalizeTargetLang(toLang, 'mymemory');
const sourceLang = normalizeSourceLang(fromLang, 'mymemory') || 'auto';
const url = 'https://api.mymemory.translated.net/get?q=' + encodeURIComponent(text) +
'&langpair=' + encodeURIComponent(sourceLang + '|' + targetLang);
const response = await requestText({ url: url });
const result = JSON.parse(response);
return result && result.responseData ? result.responseData.translatedText : null;
}
function getValueByPath(source, path) {
if (!path) return source;
return path.split('.').reduce(function(value, key) {
if (value === null || value === undefined) return undefined;
if (/^\d+$/.test(key)) return value[Number(key)];
return value[key];
}, source);
}
async function translateCustomText(text, fromLang, toLang) {
const template = translatorSettings.customUrl.trim();
if (!template) return null;
const replacements = {
text: encodeURIComponent(text),
from: encodeURIComponent(normalizeSourceLang(fromLang, 'custom') || 'auto'),
to: encodeURIComponent(normalizeTargetLang(toLang, 'custom'))
};
let url = template.replace(/\{(text|from|to)\}/g, function(match, key) {
return replacements[key];
});
if (url.indexOf('{text}') === -1 && template.indexOf('{text}') === -1) {
url += (url.indexOf('?') === -1 ? '?' : '&') + 'text=' + replacements.text;
}
const response = await requestText({ url: url });
try {
const parsed = JSON.parse(response);
const value = getValueByPath(parsed, translatorSettings.customResultPath);
return value === undefined || value === null ? null : String(value);
} catch (e) {
return response;
}
}
async function translateOneByOne(texts, translateText) {
const results = [];
for (const text of texts) {
if (!siteTranslationEnabled) break;
try {
results.push(await translateText(text));
} catch (e) {
console.error('单条翻译失败:', e);
results.push(null);
}
await new Promise(function(resolve) { setTimeout(resolve, 30); });
}
return results;
}
function translateBatch(texts, fromLang, toLang) {
const engine = translatorSettings.engine;
if (engine === 'microsoft') return translateMicrosoftBatch(texts, normalizeSourceLang(fromLang, engine), normalizeTargetLang(toLang, engine));
if (engine === 'google') return translateOneByOne(texts, function(text) {
return translateGoogleText(text, fromLang, toLang);
});
if (engine === 'mymemory') return translateOneByOne(texts, function(text) {
return translateMyMemoryText(text, fromLang, toLang);
});
if (engine === 'custom') return translateOneByOne(texts, function(text) {
return translateCustomText(text, fromLang, toLang);
});
return Promise.reject(new Error('未知翻译服务'));
}
function getTranslationCacheKey(text) {
const customKey = translatorSettings.engine === 'custom'
? translatorSettings.customUrl + '|' + translatorSettings.customResultPath
: '';
return translatorSettings.engine + '|' + customKey + '|' +
languageSettings.from + '>' + languageSettings.to + '|' + text;
}
// ── 文本节点收集 ──────────────────────────────────────
const EXCLUDED_SELECTORS = [
'SCRIPT', 'STYLE', 'NOSCRIPT',
'.microsoft-translated', '[data-no-translate]', '.no-translate', '.script-element',
'[data-protect-translation="true"]',
'#auto-translate-control', '#auto-translate-settings-page',
'.auto-translate-control', '.auto-translate-settings-page', '[data-auto-translate-ui="true"]',
'.tm-script-menu', '#tm-script-menu',
'.vm-script-menu', '#vm-script-menu',
'.gm-script-menu', '#gm-script-menu',
'[class*="userscript"]', '[id*="userscript"]',
'[class*="tampermonkey"]', '[id*="tampermonkey"]',
'[class*="violentmonkey"]', '[id*="violentmonkey"]',
'[class*="greasemonkey"]', '[id*="greasemonkey"]'
];
function makeNodeFilter() {
return {
acceptNode: function(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
for (const sel of EXCLUDED_SELECTORS) {
try { if (parent.matches(sel) || parent.closest(sel)) return NodeFilter.FILTER_REJECT; } catch (e) {}
}
if (parent.getAttribute('translate') === 'no') return NodeFilter.FILTER_REJECT;
if (parent.classList.contains('no-translate') || parent.classList.contains('script-element')) return NodeFilter.FILTER_REJECT;
if (parent.classList.contains('microsoft-translated')) return NodeFilter.FILTER_REJECT;
const text = node.textContent.trim();
if (!text || text.length < 2) return NodeFilter.FILTER_REJECT;
if (/^\d+$/.test(text)) return NodeFilter.FILTER_REJECT;
if (/^[\d\s\W]+$/.test(text)) return NodeFilter.FILTER_REJECT;
if (!/[a-zA-Z\u0400-\u04FF\u4e00-\u9fff]/.test(text)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
};
}
function collectTextNodes() {
const textNodes = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, makeNodeFilter());
let node;
while ((node = walker.nextNode())) {
const text = node.textContent.trim();
if (text) textNodes.push({ node: node, text: text, parent: node.parentElement, originalText: node.textContent });
}
return textNodes;
}
// ── 批次划分 ──────────────────────────────────────────
function createTextBatches(textNodes) {
const batches = [];
let current = { nodes: [], totalLength: 0 };
for (const info of textNodes) {
const len = info.text.length;
if (len > MAX_TEXT_LENGTH) {
if (current.nodes.length > 0) { batches.push(current); current = { nodes: [], totalLength: 0 }; }
const truncated = Object.assign({}, info, { text: info.text.substring(0, MAX_TEXT_LENGTH) });
batches.push({ nodes: [truncated], totalLength: MAX_TEXT_LENGTH });
continue;
}
if (current.totalLength + len > MAX_TEXT_LENGTH || current.nodes.length >= BATCH_SIZE) {
batches.push(current);
current = { nodes: [], totalLength: 0 };
}
current.nodes.push(info);
current.totalLength += len;
}
if (current.nodes.length > 0) batches.push(current);
return batches;
}
// ── 应用翻译 ──────────────────────────────────────────
function generateElementId() {
return 'translated_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function applyTranslation(nodeInfo, translation) {
try {
const originalNode = nodeInfo.node;
const parent = nodeInfo.parent;
const originalText = nodeInfo.originalText;
const elementId = generateElementId();
originalTextMap.set(elementId, originalText);
const span = document.createElement('span');
span.className = 'microsoft-translated no-translate script-element';
span.dataset.originalId = elementId;
span.setAttribute('translate', 'no');
span.setAttribute('data-no-translate', 'true');
span.setAttribute('data-protect-translation', 'true');
span.textContent = translation;
parent.replaceChild(span, originalNode);
} catch (e) {
console.error('应用翻译失败:', e);
}
}
// ── 翻译执行 ──────────────────────────────────────────
async function runTranslation(textNodes) {
if (!siteTranslationEnabled || !translatorReady || textNodes.length === 0) return;
const fromLang = getTranslationSourceLang();
const targetLang = getTranslationTargetLang();
const batches = createTextBatches(textNodes);
for (let i = 0; i < batches.length; i++) {
if (!siteTranslationEnabled || !pageTranslating) break;
const batch = batches[i];
const toTranslate = [];
const indices = [];
batch.nodes.forEach(function(info, idx) {
const key = getTranslationCacheKey(info.text);
if (translationCache.has(key)) {
applyTranslation(info, translationCache.get(key));
} else {
toTranslate.push(info.text);
indices.push(idx);
}
});
if (toTranslate.length > 0) {
try {
const translations = await translateBatch(toTranslate, fromLang, targetLang);
if (!siteTranslationEnabled) break;
translations.forEach(function(tr, ti) {
const info = batch.nodes[indices[ti]];
if (tr && tr !== info.text) {
applyTranslation(info, tr);
translationCache.set(getTranslationCacheKey(info.text), tr);
}
});
} catch (e) {
console.error('批次 ' + (i + 1) + ' 翻译失败:', e);
if (translationRetryCount < MAX_RETRY_COUNT) {
translationRetryCount++;
await new Promise(function(r) { setTimeout(r, 200); });
i--;
continue;
}
}
}
if (i < batches.length - 1) {
await new Promise(function(r) { setTimeout(r, 20); });
}
}
}
async function translateWholePage() {
if (!siteTranslationEnabled || pageTranslating || !translatorReady) return;
pageTranslating = true;
translationRetryCount = 0;
try {
const textNodes = collectTextNodes();
await runTranslation(textNodes);
lastTranslationTime = Date.now();
setTimeout(function() { checkForUntranslatedText(); }, 500);
} catch (e) {
console.error('页面翻译失败:', e);
} finally {
pageTranslating = false;
}
}
function checkForUntranslatedText() {
if (!siteTranslationEnabled || !translatorReady || pageTranslating) return;
const nodes = collectTextNodes();
if (nodes.length === 0) return;
if (nodes.length > 10) {
translateWholePage();
} else {
(async function() {
if (pageTranslating) return;
pageTranslating = true;
try { await runTranslation(nodes); } finally { pageTranslating = false; }
})();
}
}
// ── DOM 监听 ──────────────────────────────────────────
function startDOMObservation() {
if (isObservingDOM || !siteTranslationEnabled || !translatorReady) return;
stopDOMObservation();
domObserver = new MutationObserver(function(mutations) {
if (pageTranslating) return;
if (domChangeTimer) clearTimeout(domChangeTimer);
domChangeTimer = setTimeout(function() {
const hasChanges = mutations.some(function(m) {
return (m.type === 'childList' && (m.addedNodes.length > 0 || m.removedNodes.length > 0)) ||
(m.type === 'characterData' && m.target.nodeType === Node.TEXT_NODE);
});
if (hasChanges && siteTranslationEnabled) setTimeout(function() { translateWholePage(); }, 300);
}, DOM_CHANGE_DELAY);
});
try {
domObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
isObservingDOM = true;
} catch (e) { console.error('DOM观察器启动失败:', e); }
}
function stopDOMObservation() {
if (domObserver) { domObserver.disconnect(); domObserver = null; }
if (domChangeTimer) { clearTimeout(domChangeTimer); domChangeTimer = null; }
isObservingDOM = false;
}
// ── 持续翻译 & 滚动检测 ───────────────────────────────
function startContinuousTranslation() {
if (continuousTranslationTimer) clearInterval(continuousTranslationTimer);
continuousTranslationTimer = setInterval(function() {
if (siteTranslationEnabled && !pageTranslating && translatorReady && Date.now() - lastTranslationTime > 20000) {
checkForUntranslatedText();
}
}, 8000);
lastScrollY = window.scrollY;
window.addEventListener('scroll', handleScroll);
}
function stopContinuousTranslation() {
if (continuousTranslationTimer) {
clearInterval(continuousTranslationTimer);
continuousTranslationTimer = null;
}
if (scrollCheckTimer) {
clearTimeout(scrollCheckTimer);
scrollCheckTimer = null;
}
window.removeEventListener('scroll', handleScroll);
}
function handleScroll() {
if (Math.abs(window.scrollY - lastScrollY) > 300) {
lastScrollY = window.scrollY;
if (scrollCheckTimer) clearTimeout(scrollCheckTimer);
scrollCheckTimer = setTimeout(function() {
if (siteTranslationEnabled && !pageTranslating && translatorReady) checkForUntranslatedText();
}, SCROLL_CHECK_DELAY);
}
}
// ── 初始化 ────────────────────────────────────────────
async function init() {
readTranslatorSettings();
readLanguageSettings();
readIconOpacity();
readDefaultSitePreference();
readPromptEveryPage();
ensureControlUi();
showInstallHint();
sitePreference = readSitePreference();
updateControlUi();
if (promptEveryPage && sitePreference === null) {
setTimeout(function() {
if (!settingsRoot || settingsRoot.hidden) showControlPanel();
}, 300);
}
if (isCurrentSiteEnabled()) {
await enableSiteTranslation();
return;
}
siteTranslationEnabled = false;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();