// ==UserScript==
// @name 翻译机
// @namespace http://tampermonkey.net/
// @version 1.0
// @description 该脚本用于翻译各类常用社交网站为中文,不会经过中间服务器。
// @author HolynnChen
// @license MIT
// @match *://*.twitter.com/*
// @match *://*.x.com/*
// @match *://*.youtube.com/*
// @match *://*.facebook.com/*
// @match *://*.reddit.com/*
// @match *://*.5ch.net/*
// @match *://*.discord.com/*
// @match *://*.telegram.org/*
// @match *://*.quora.com/*
// @match *://*.tiktok.com/*
// @match *://*.instagram.com/*
// @match *://*.threads.net/*
// @match *://*.github.com/*
// @match *://*.bsky.app/*
// @connect fanyi.baidu.com
// @connect translate.google.com
// @connect ifanyi.iciba.com
// @connect www.bing.com
// @connect fanyi.youdao.com
// @connect dict.youdao.com
// @connect m.youdao.com
// @connect api.interpreter.caiyunai.com
// @connect papago.naver.com
// @connect fanyi.qq.com
// @connect translate.alibaba.com
// @connect www2.deepl.com
// @connect transmart.qq.com
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/base64.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/libs/lz-string.min.js
// @require https://cdn.jsdelivr.net/gh/Tampermonkey/utils@3b32b826e84ccc99a0a3e3d8d6e5ce0fa9834f23/requires/gh_2215_make_GM_xhr_more_parallel_again.js
// @run-at document-idle
// ==/UserScript==
// --- Polyfills and Helper Functions ---
// Mock for Base64 if not exposed globally by js-base64
if (typeof Base64 === 'undefined' && typeof window.Base64 !== 'undefined') {
var Base64 = window.Base64;
}
// Basic implementation of CompressMergeSession (needs lz-string)
function CompressMergeSession(storage) {
if (typeof LZString === 'undefined') {
console.warn("[翻译机] LZString is not loaded. Compression will be skipped.");
return storage;
}
return {
getItem: function(key) {
const compressed = storage.getItem(key);
try {
return compressed ? LZString.decompressFromUTF16(compressed) : null;
} catch (e) {
console.error("[翻译机] Failed to decompress item:", key, e);
// If decompression fails, clear the item to avoid future errors
storage.removeItem(key);
return null;
}
},
setItem: function(key, value) {
try {
storage.setItem(key, LZString.compressToUTF16(value));
} catch (e) {
console.error("[翻译机] Failed to compress and set item:", key, e);
}
},
removeItem: function(key) {
storage.removeItem(key);
},
clear: function() {
storage.clear();
}
};
}
// GM_xmlhttpRequest wrapper for Promises
function Request(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
...options,
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(response);
} else {
reject(new Error(`Request failed with status ${response.status}: ${response.statusText || 'Unknown error'}`));
}
},
onerror: (error) => {
reject(error);
},
ontimeout: () => {
reject(new Error("Request timed out"));
}
});
});
}
// Basic Promise Retry Wrap (simplified)
async function PromiseRetryWrap(promiseFunc, retries = 3, delay = 1000) {
if (typeof promiseFunc !== 'function') {
return Promise.resolve();
}
for (let i = 0; i < retries; i++) {
try {
return await promiseFunc();
} catch (error) {
console.warn(`[翻译机] Promise retry failed (attempt ${i + 1}/${retries}):`, error);
if (i < retries - 1) {
await new Promise(res => setTimeout(res, delay));
} else {
throw error;
}
}
}
}
// --- GM_registerMenuCommand ---
GM_registerMenuCommand('重置控制面板位置(刷新应用)', () => {
GM_setValue('position_top', '9px');
GM_setValue('position_right', '9px');
alert('控制面板位置已重置,请刷新 страницу, чтобы применить изменения.');
});
GM_registerMenuCommand('全局隐藏/展示悬浮球(刷新应用)', () => {
const currentState = GM_getValue('show_translate_ball', true);
GM_setValue('show_translate_ball', !currentState);
alert(`Плавающая кнопка ${!currentState ? 'показана' : 'скрыта'}, пожалуйста, обновите страницу, чтобы применить изменения.`);
});
// --- Translation Provider Functions (placeholders) ---
// ВАЖНО: Эти функции являются заглушками. Вы ДОЛЖНЫ заполнить
// фактическую логику запросов к API (URL-адреса, заголовки, данные и разбор ответа) для каждого провайдера.
async function translate_gg(raw) {
const options = {
method: "GET",
url: `https://translate.google.com/translate_a/single?client=gtx&sl=auto&tl=zh-CN&dt=t&q=` + encodeURIComponent(raw)
};
try {
const res = await Request(options);
const data = JSON.parse(res.responseText);
if (data && data[0] && data[0][0] && data[0][0][0]) {
return data[0].map(segment => segment[0]).join('');
}
return 'Translation failed: Google Translate returned an abnormal response.';
} catch (err) {
console.error('[翻译机] Google Translate failed:', err);
return 'Translation failed: Google Translate request error.';
}
}
async function translate_ggm(raw) {
console.warn('[翻译机] Google Translate mobile: Function not implemented.');
return 'Function not implemented: Google Translate mobile';
}
async function translate_tencent(raw) {
console.warn('[翻译机] Tencent Translate: Function not implemented.');
return 'Function not implemented: Tencent Translate';
}
async function translate_tencentai(raw) {
console.warn('[翻译机] Tencent AI Translate: Function not implemented.');
return 'Function not implemented: Tencent AI Translate';
}
async function translate_youdao_mobile(raw) {
console.warn('[翻译机] Youdao Translate mobile: Function not implemented.');
return 'Function not implemented: Youdao Translate mobile';
}
async function translate_baidu(raw) {
console.warn('[翻译机] Baidu Translate: Function not implemented.');
return 'Function not implemented: Baidu Translate';
}
async function translate_caiyun(raw) {
console.warn('[翻译机] Caiyun Translate: Function not implemented.');
return 'Function not implemented: Caiyun Translate';
}
async function translate_biying(raw) {
console.warn('[翻译机] Bing Translate: Function not implemented.');
return 'Function not implemented: Bing Translate';
}
async function translate_papago(raw) {
console.warn('[翻译机] Papago Translate: Function not implemented.');
return 'Function not implemented: Papago Translate';
}
async function translate_alibaba(raw) {
console.warn('[翻译机] Alibaba Translate: Function not implemented.');
return 'Function not implemented: Alibaba Translate';
}
async function translate_icib(raw) {
console.warn('[翻译机] IciBa Translate: Function not implemented.');
return 'Function not implemented: IciBa Translate';
}
async function translate_deepl(raw) {
console.warn('[翻译机] Deepl Translate: Function not implemented.');
return 'Function not implemented: Deepl Translate';
}
// --- Startup Functions (placeholders) ---
async function translate_tencent_startup() {
console.log('[翻译机] Tencent Translate startup: No additional startup steps required.');
return Promise.resolve();
}
async function translate_caiyun_startup() {
console.log('[翻译机] Caiyun Translate startup: No additional startup steps required.');
return Promise.resolve();
}
async function translate_papago_startup() {
console.log('[翻译机] Papago Translate startup: No additional startup steps required.');
return Promise.resolve();
}
async function translate_gg_startup() {
console.log('[翻译机] Google Translate startup: No additional startup steps required.');
return Promise.resolve();
}
// --- Core Configuration and Data ---
const transdict = {
'谷歌翻译': translate_gg,
'谷歌翻译mobile': translate_ggm,
'腾讯翻译': translate_tencent,
'腾讯AI翻译': translate_tencentai,
'有道翻译mobile': translate_youdao_mobile,
'百度翻译': translate_baidu,
'彩云小译': translate_caiyun,
'必应翻译': translate_biying,
'Papago翻译': translate_papago,
'阿里翻译': translate_alibaba,
'爱词霸翻译': translate_icib,
'Deepl翻译': translate_deepl,
'关闭翻译': () => { return Promise.resolve(''); }
};
const startup = {
'谷歌翻译': translate_gg_startup,
'谷歌翻译mobile': translate_gg_startup,
'腾讯翻译': translate_tencent_startup,
'彩云小译': translate_caiyun_startup,
'Papago翻译': translate_papago_startup
};
const baseoptions = {
'enable_pass_lang': {
declare: 'Не переводить китайский (упрощённый)',
default_value: true,
change_func: self => {
if (self.checked) sessionStorage.clear();
console.log('[翻译机] Setting: Do not translate Chinese (Simplified) updated.');
}
},
'enable_pass_lang_cht': {
declare: 'Не переводить китайский (традиционный)',
default_value: true,
change_func: self => {
if (self.checked) sessionStorage.clear();
console.log('[翻译机] Setting: Do not translate Chinese (Traditional) updated.');
}
},
'remove_url': {
declare: 'Автоматически фильтровать URL-адреса',
default_value: true,
},
'show_info': {
declare: 'Показывать источник перевода',
default_value: true,
option_enable: true
},
'fullscrenn_hidden': {
declare: 'Не показывать в полноэкранном режиме',
default_value: true,
},
'replace_translate': {
declare: 'Заменяющий перевод',
default_value: false,
option_enable: true
},
'compress_storage':{
declare: 'Сжимать кэш',
default_value: false,
change_func: () => {
alert('Настройка сжатия кэша изменена, пожалуйста, обновите страницу, чтобы применить изменения.');
}
}
};
const enable_pass_lang = GM_getValue('enable_pass_lang', baseoptions.enable_pass_lang.default_value);
const enable_pass_lang_cht = GM_getValue('enable_pass_lang_cht', baseoptions.enable_pass_lang_cht.default_value);
const remove_url = GM_getValue('remove_url', baseoptions.remove_url.default_value);
const show_info = GM_getValue('show_info', baseoptions.show_info.default_value);
const fullscrenn_hidden = GM_getValue('fullscrenn_hidden', baseoptions.fullscrenn_hidden.default_value);
const replace_translate = GM_getValue('replace_translate', baseoptions.replace_translate.default_value);
const compress_storage = GM_getValue('compress_storage', baseoptions.compress_storage.default_value);
const globalProcessingSave = [];
const sessionStorage = compress_storage ? CompressMergeSession(window.sessionStorage) : window.sessionStorage;
const p = window.trustedTypes !== undefined ? window.trustedTypes.createPolicy('translator', { createHTML: (string) => string }) : { createHTML: (string) => string };
// --- UI Panel Initialization ---
function initPanel() {
let choice = GM_getValue('translate_choice', '谷歌翻译');
let select = document.createElement("select");
select.className = 'js_translate';
select.style = 'height:35px;width:100px;background-color:#fff;border-radius:17.5px;text-align-last:center;color:#000000;margin:5px 0';
select.onchange = () => {
GM_setValue('translate_choice', select.value);
title.innerText = "Панель управления (обновите для применения)";
alert('Переводчик изменен, пожалуйста, обновите страницу, чтобы применить изменения.');
};
for (let i in transdict) {
select.innerHTML = p.createHTML(select.innerHTML + '<option value="' + i + '">' + i + '</option>');
}
select.querySelector(`option[value="${choice}"]`).selected = true;
let enable_details = document.createElement('details');
enable_details.innerHTML = p.createHTML("<summary>Правила активации</summary>");
for (let i of rules) {
let temp_p = document.createElement('p');
let temp_input = document.createElement('input');
temp_input.type = 'checkbox';
temp_input.name = i.name;
if (GM_getValue("enable_rule:" + temp_input.name, true)) {
temp_input.setAttribute('checked', 'true');
}
temp_p.appendChild(temp_input);
temp_p.innerHTML = p.createHTML(temp_p.innerHTML + `<span>${i.name}</span>`);
enable_details.appendChild(temp_p);
}
let current_details = document.createElement('details');
current_details.className = 'current-rule-details';
let mask = document.createElement('div');
let dialog = document.createElement("div");
let js_dialog = document.createElement("div");
let title = document.createElement('p');
let shadowContainer = document.createElement('div');
shadowContainer.style = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 99999; pointer-events: none;";
document.body.appendChild(shadowContainer);
let shadow = shadowContainer.attachShadow({ mode: "open" });
shadow.appendChild(mask);
mask.appendChild(dialog);
dialog.appendChild(js_dialog);
js_dialog.appendChild(title);
let select_p = document.createElement('p');
select_p.appendChild(select);
js_dialog.appendChild(select_p);
js_dialog.appendChild(enable_details);
js_dialog.appendChild(current_details);
mask.style = "display: none;position: absolute;height: 100%;width: 100%;z-index: 1;top: 0;left: 0;overflow: hidden;background-color: rgba(0,0,0,0.4);justify-content: center;align-items: center;pointer-events: auto;";
mask.addEventListener('click', event => { if (event.target === mask) mask.style.display = 'none'; });
dialog.style = 'padding:0;border-radius:10px;background-color: #fff;box-shadow: 0 0 5px 4px rgba(0,0,0,0.3);';
js_dialog.style = "min-height:10vh;min-width:10vw;display:flex;flex-direction:column;align-items:center;padding:10px;border-radius:4px;color:#000";
title.style = 'margin:5px 0;font-size:20px;';
title.innerText = "Панель управления";
for (let i in baseoptions) {
let temp_p = document.createElement('p');
let temp_input = document.createElement('input');
temp_p.style = "display:flex;align-items: center;margin:5px 0";
temp_input.type = 'checkbox';
temp_input.name = i;
temp_input.id = `base-option-${i}`;
temp_p.appendChild(temp_input);
temp_p.innerHTML = p.createHTML(temp_p.innerHTML + `<label for="base-option-${i}">${baseoptions[i].declare}</label>`);
js_dialog.appendChild(temp_p);
}
for (let i of js_dialog.querySelectorAll('input[type="checkbox"]')) {
if (i.name && baseoptions[i.name]) {
i.onchange = () => {
title.innerText = "Панель управления (обновите для применения)";
GM_setValue(i.name, i.checked);
if (baseoptions[i.name].change_func) {
baseoptions[i.name].change_func(i);
}
};
i.checked = GM_getValue(i.name, baseoptions[i.name].default_value);
}
}
for (let i of enable_details.querySelectorAll('input[type="checkbox"]')) {
i.onchange = () => {
title.innerText = "Панель управления (обновите для применения)";
GM_setValue('enable_rule:' + i.name, i.checked);
};
}
let open = document.createElement('div');
open.style = `z-index:9999;height:35px;width:35px;background-color:#fff;position:fixed;border:1px solid rgba(0,0,0,0.2);border-radius:17.5px;right:${GM_getValue('position_right', '9px')};top:${GM_getValue('position_top', '9px')};text-align:center;color:#000000;display:flex;align-items:center;justify-content:center;cursor: grab;font-size:15px;user-select:none;visibility: visible;pointer-events: auto;`;
open.innerHTML = p.createHTML("Я");
const renderCurrentRule = () => {
current_details.style.display = "none";
current_details.innerHTML = p.createHTML('');
const currentRule = GetActiveRule();
if (currentRule) {
current_details.style.display = "block";
current_details.innerHTML = p.createHTML(`<summary>Текущая активная - ${currentRule.name}</summary>`);
for (const option of currentRule.options) {
const fieldset = document.createElement("fieldset");
fieldset.innerHTML = p.createHTML(`<legend>${option.name}</legend>`);
current_details.appendChild(fieldset);
const enableDiv = document.createElement('div');
enableDiv.style = "display:flex;align-items:center; margin-bottom: 5px;";
const enableInput = document.createElement('input');
enableInput.type = 'checkbox';
const enableKey = `enable_option:${currentRule.name}-${option.name}`;
enableInput.checked = GM_getValue(enableKey, true);
enableInput.onchange = () => {
title.innerText = "Панель управления (обновите для применения)";
GM_setValue(enableKey, enableInput.checked);
};
enableDiv.appendChild(enableInput);
enableDiv.innerHTML = p.createHTML(enableDiv.innerHTML + `<span>Включить перевод</span>`);
fieldset.appendChild(enableDiv);
for (const key in baseoptions) {
if (!baseoptions[key].option_enable) {
continue;
}
const optionDiv = document.createElement('div');
optionDiv.style = "margin-top: 10px;";
optionDiv.innerHTML = p.createHTML(`<span>${baseoptions[key].declare}</span>`);
const baseValueList = [["", "По умолчанию"], ["true", "Включить"], ["false", "Отключить"]];
const radioGroupDiv = document.createElement('div');
radioGroupDiv.style = "display:flex; gap: 10px;";
for (const value of baseValueList) {
const radioInput = document.createElement('input');
radioInput.type = "radio";
radioInput.value = value[0];
radioInput.name = `${key}:${currentRule.name}-${option.name}`;
radioInput.id = `${key}-${currentRule.name}-${option.name}-${value[0]}`;
const currentSetting = GM_getValue(`option_setting:${radioInput.name}`, '');
if (currentSetting.toString() === radioInput.value) {
radioInput.checked = true;
}
radioInput.onchange = () => {
title.innerText = "Панель управления (обновите для применения)";
switch (radioInput.value) {
case 'true':
GM_setValue(`option_setting:${radioInput.name}`, true);
break;
case 'false':
GM_setValue(`option_setting:${radioInput.name}`, false);
break;
case '':
GM_deleteValue(`option_setting:${radioInput.name}`);
break;
}
};
const radioLabel = document.createElement('label');
radioLabel.htmlFor = radioInput.id;
radioLabel.textContent = value[1];
radioGroupDiv.appendChild(radioInput);
radioGroupDiv.appendChild(radioLabel);
}
optionDiv.appendChild(radioGroupDiv);
fieldset.appendChild(optionDiv);
}
}
}
};
open.onclick = () => {
renderCurrentRule();
mask.style.display = 'flex';
};
let isDragging = false;
let offsetX, offsetY;
open.addEventListener("mousedown", (e) => {
isDragging = true;
open.style.cursor = 'grabbing';
offsetX = e.clientX - open.getBoundingClientRect().left;
offsetY = e.clientY - open.getBoundingClientRect().top;
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
let newX = e.clientX - offsetX;
let newY = e.clientY - offsetY;
newX = Math.max(0, Math.min(newX, window.innerWidth - open.offsetWidth));
newY = Math.max(0, Math.min(newY, window.innerHeight - open.offsetHeight));
open.style.left = `${newX}px`;
open.style.top = `${newY}px`;
open.style.right = 'auto';
});
document.addEventListener("mouseup", () => {
if (isDragging) {
isDragging = false;
open.style.cursor = 'grab';
GM_setValue("position_right", `${window.innerWidth - open.getBoundingClientRect().right}px`);
GM_setValue("position_top", `${open.getBoundingClientRect().top}px`);
}
});
open.addEventListener("touchstart", ev => {
ev.stopImmediatePropagation();
ev.preventDefault();
const touch = ev.touches[0];
open._tempTouch = {};
const rect = open.getBoundingClientRect();
open._tempTouch.offsetX = touch.clientX - rect.left;
open._tempTouch.offsetY = touch.clientY - rect.top;
open._tempIsMove = false;
}, { passive: false });
open.addEventListener("touchmove", ev => {
ev.stopImmediatePropagation();
ev.preventDefault();
const touch = ev.touches[0];
let newX = touch.clientX - open._tempTouch.offsetX;
let newY = touch.clientY - open._tempTouch.offsetY;
newX = Math.max(0, Math.min(newX, window.innerWidth - open.offsetWidth));
newY = Math.max(0, Math.min(newY, window.innerHeight - open.offsetHeight));
open.style.left = `${newX}px`;
open.style.top = `${newY}px`;
open.style.right = 'auto';
open._tempIsMove = true;
}, { passive: false });
open.addEventListener("touchend", ev => {
ev.stopImmediatePropagation();
GM_setValue("position_right", `${window.innerWidth - open.getBoundingClientRect().right}px`);
GM_setValue("position_top", `${open.getBoundingClientRect().top}px`);
if (!open._tempIsMove) {
renderCurrentRule();
mask.style.display = 'flex';
}
open._tempIsMove = false;
});
shadow.appendChild(open);
if (fullscrenn_hidden) {
window.document.addEventListener('fullscreenchange', () => {
open.style.display = window.document.fullscreenElement ? "none" : "flex";
});
}
const storedRight = GM_getValue('position_right', '9px');
const storedTop = GM_getValue('position_top', '9px');
open.style.right = storedRight;
open.style.top = storedTop;
open.style.left = 'auto';
}
// --- Rule Definitions ---
const rules = [
{
name: 'Twitter/X General',
matcher: /https:\/\/([a-zA-Z.]*?\.|)(twitter|x)\.com/,
options: [
{
name: "Tweets",
selector: baseSelector('div[dir="auto"][lang]'),
textGetter: baseTextGetter,
textSetter: options => {
options.element.style.setProperty('-webkit-line-clamp', 'unset', 'important');
options.element.style.setProperty('max-height', 'unset', 'important');
baseTextSetter(options).style.display = 'flex';
}
},
{
name: "Background Info",
selector: baseSelector('div[data-testid=birdwatch-pivot]>div[dir=ltr]'),
textGetter: baseTextGetter,
textSetter: options => {
options.element.style.setProperty('-webkit-line-clamp', 'unset', 'important');
options.element.style.setProperty('max-height', 'unset', 'important');
baseTextSetter(options).style.display = 'flex';
}
}
]
},
{
name: 'YouTube PC General',
matcher: /https:\/\/(www\.|)youtube\.com\/(watch|shorts|results\?)/,
options: [
{
name: "Comments Section",
selector: baseSelector("#content>#content-text"),
textGetter: baseTextGetter,
textSetter: options => {
baseTextSetter(options);
let parentCollapsed = options.element.closest('[collapsed]');
if(parentCollapsed) parentCollapsed.removeAttribute('collapsed');
}
},
{
name: "Video Description",
selector: baseSelector("#content>#description>.content,.ytd-text-inline-expander>.yt-core-attributed-string"),
textGetter: baseTextGetter,
textSetter: options => {
baseTextSetter(options);
let parentCollapsed = options.element.closest('[collapsed]');
if(parentCollapsed) parentCollapsed.removeAttribute('collapsed');
}
},
{
name: "CC Subtitles",
selector: baseSelector(".ytp-caption-segment"),
textGetter: baseTextGetter,
textSetter: baseTextSetter
}
]
},
{
name: 'YouTube Mobile General',
matcher: /https:\/\/m\.youtube\.com\/watch/,
options: [
{
name: "Comments Section",
selector: baseSelector(".comment-text.user-text"),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
},
{
name: "Video Description",
selector: baseSelector(".slim-video-metadata-description"),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
{
name: 'YouTube Shorts',
matcher: /https:\/\/(www|m)\.youtube\.com\/shorts/,
options: [
{
name: "Comments Section",
selector: baseSelector("#comment-content #content-text,.comment-content .comment-text"),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
{
name: 'YouTube Community',
matcher: /https:\/\/(www|m)\.youtube\.com\/(.*?\/community|post)/,
options: [
{
name: "Comments Section",
selector: baseSelector("#post #content #content-text,#comment #content #content-text,#replies #content #content-text"),
textGetter: baseTextGetter,
textSetter: options => {
baseTextSetter(options);
let parentCollapsed = options.element.closest('[collapsed]');
if(parentCollapsed) parentCollapsed.removeAttribute('collapsed');
}
}
]
},
{
name: 'Facebook General',
matcher: /https:\/\/www.facebook.com\/.+/,
options: [
{
name: "Post Content",
selector: baseSelector("div[data-ad-comet-preview=message],div[role=article] div[id]"),
textGetter: baseTextGetter,
textSetter: options => setTimeout(baseTextSetter, 0, options),
},
{
name: "Comments Section",
selector: baseSelector("div[role=article] div>span[dir=auto][lang]"),
textGetter: baseTextGetter,
textSetter: options => setTimeout(baseTextSetter, 0, options),
}
]
},
{
name: 'Reddit General',
matcher: /https:\/\/www.reddit.com\/.*/,
options: [
{
name: 'Post Title',
selector: baseSelector("*[slot=title][id|=post-title]"),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
},
{
name: 'Post Content',
selector: baseSelector("div[slot=text-body]>div>div[id*=-post-rtjson-content]"),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
},
{
name: 'Comments Section',
selector: baseSelector("div[slot=comment]>div[id$=-post-rtjson-content]"),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
{
name: '5ch Comments',
matcher: /http(|s):\/\/(.*?\.|)5ch.net\/.*/,
options: [
{
name: "Title",
selector: baseSelector('.post>.post-content,#threadtitle,.thread_title'),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
},
{
name: "Content",
selector: baseSelector('.threadview_response_body'),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
{
name: 'Discord Chat',
matcher: /https:\/\/discord.com\/.+/,
options: [
{
name: "Chat Content",
selector: baseSelector('div[class*=messageContent]'),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
{
name: 'Telegram Chat (New)',
matcher: /https:\/\/.*?.telegram.org\/(a|z)\//,
options: [
{
name: "Chat Content",
selector: baseSelector('p.text-content[dir=auto],div.text-content'),
textGetter: e => Array.from(e.childNodes).filter(item => !item.className).map(item => item.nodeName === "BR" ? "\n" : item.textContent).join(''),
textSetter: baseTextSetter,
}
]
},
{
name: 'Telegram Chat (Old)',
matcher: /https:\/\/.*?.telegram.org\/.+/,
options: [
{
name: "Chat Content",
selector: baseSelector('div.message[dir=auto],div.im_message_text'),
textGetter: e => Array.from(e.childNodes).filter(item => !item.className || item.className === 'translatable-message').map(item => item.nodeValue || item.innerText).join(" "),
textSetter: baseTextSetter,
}
]
},
{
name: 'Quora General',
matcher: /https:\/\/www.quora.com/,
options: [
{
name: "Title",
selector: baseSelector(".puppeteer_test_question_title>span>span"),
textGetter: baseTextGetter,
textSetter: options => {
options.element.parentNode.parentNode.style.setProperty('-webkit-line-clamp', 'unset', 'important');
options.element.parentNode.parentNode.style.setProperty('max-height', 'unset', 'important');
baseTextSetter(options).style.display = 'flex';
},
},
{
name: "Post Content",
selector: baseSelector('div.q-text>span>span.q-box:has(p.q-text),div.q-box>div.q-box>div.q-text>span.q-box:has(p.q-text)'),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
{
name: 'TikTok Comments',
matcher: /https:\/\/www.tiktok.com/,
options: [
{
name: "Comments Section",
selector: baseSelector('p[data-e2e|=comment-level]'),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
{
name: 'Instagram Comments',
matcher: /https:\/\/www.instagram.com/,
options: [
{
name: "Comments Section",
selector: baseSelector('li>div>div>div>div>span[dir=auto]'),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
{
name: 'Threads',
matcher: /https:\/\/www.threads.net/,
options: [
{
name: "Posts",
selector: baseSelector('div[data-pressable-container=true][data-interactive-id]>div>div:last-child>div>div:has(span[dir=auto]):not(:has(div[role=button]))'),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
{
name: 'GitHub',
matcher: /https:\/\/github.com\/.+\/.+\/\w+\/\d+/,
options: [
{
name: "Issues",
selector: baseSelector(".edit-comment-hide > task-lists > table > tbody > tr > td > p",items=>items.filter(i=>{
const nodeNameList = [...new Set([...i.childNodes].map(i=>i.nodeName))];
return nodeNameList.length>1 || (nodeNameList.length == 1 && nodeNameList[0] == "#text")
})),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
},
{
name: "Discussions",
selector: baseSelector(".edit-comment-hide > task-lists > table > tbody > tr > td > p",items=>items.filter(i=>{
const nodeNameList=[...new Set([...i.childNodes].map(i=>i.nodeName))];
return nodeNameList.length>1 || (nodeNameList.length == 1 && nodeNameList[0] == "#text")
})),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
},
]
},
{
name: 'BSky',
matcher: /https:\/\/bsky.app/,
options: [
{
name: "Homepage Posts",
selector: baseSelector('div[dir=auto][data-testid=postText]'),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
},
{
name: "Content Posts & Replies",
selector: baseSelector('div[data-testid^="postThreadItem-by"] div[dir=auto][data-word-wrap]'),
textGetter: baseTextGetter,
textSetter: baseTextSetter,
}
]
},
];
const GetActiveRule = () => rules.find(item => item.matcher.test(document.location.href) && GM_getValue('enable_rule:' + item.name, true));
// --- Main Execution Logic ---
(function () {
'use strict';
let currentUrl = document.location.href;
let activeRule = GetActiveRule();
let mainIntervalId = null;
// Monitor URL changes for SPAs
setInterval(() => {
if (document.location.href !== currentUrl) {
currentUrl = document.location.href;
const newRule = GetActiveRule();
if (newRule !== activeRule) {
if (newRule) {
console.log(`【翻译机】Detected URL change, now using 【${newRule.name}】 rule`);
} else {
console.log("【翻译机】Detected URL change, no matching rule currently");
}
activeRule = newRule;
if (mainIntervalId) {
clearInterval(mainIntervalId);
mainIntervalId = null;
}
if (activeRule) {
PromiseRetryWrap(startup[GM_getValue('translate_choice', '谷歌翻译')] || translate_gg_startup).then(() => {
console.log(`【翻译机】Starting main translation loop for ${activeRule.name}`);
mainIntervalId = setInterval(main, 200);
}).catch(err => {
console.error('[翻译机] Failed to start translation engine:', err);
});
}
}
}
}, 500);
console.log(activeRule ? `【翻译机】Using 【${activeRule.name}】 rule` : "【翻译机】No matching rule currently");
console.log(`Current URL: ${document.location.href}`);
let processingQueue = [];
let isTranslating = false;
async function main() {
if (!activeRule || isTranslating) return;
isTranslating = true;
try {
const choice = GM_getValue('translate_choice', '谷歌翻译');
const translator = transdict[choice];
if (!translator) {
console.warn(`[翻译机] Unknown translation engine: ${choice}`);
isTranslating = false;
return;
}
for (const option of activeRule.options) {
const enableOption = GM_getValue(`enable_option:${activeRule.name}-${option.name}`, true);
if (!enableOption) {
continue;
}
const elementsToTranslate = option.selector();
for (const element of elementsToTranslate) {
if (globalProcessingSave.includes(element) || processingQueue.includes(element)) {
continue;
}
const rawText = option.textGetter(element);
const textToTranslate = remove_url ? url_filter(rawText) : rawText;
if (textToTranslate.length === 0 || textToTranslate.trim().length === 0) { // Also check for empty after trim
element.dataset.translate = "skipped_empty";
continue;
}
const cachedText = sessionStorage.getItem(`${choice}-${textToTranslate}`);
if (cachedText) {
const setterParams = {
element: element,
translatorName: choice,
text: cachedText,
rawText: rawText,
rule: activeRule,
option: option
};
option.textSetter(setterParams);
element.dataset.translate = "cached";
} else {
processingQueue.push({ element, rawText, textToTranslate, option, choice });
globalProcessingSave.push(element);
}
}
}
while (processingQueue.length > 0) {
const item = processingQueue.shift();
const { element, rawText, textToTranslate, option, choice } = item;
try {
const langCheckResult = await pass_lang(textToTranslate);
if (langCheckResult instanceof Promise && typeof langCheckResult.then === 'function') {
element.dataset.translate = "skipped_lang";
removeItem(globalProcessingSave, element);
continue;
}
const translatedText = await translator(textToTranslate, langCheckResult);
sessionStorage.setItem(`${choice}-${textToTranslate}`, translatedText);
const setterParams = {
element: element,
translatorName: choice,
text: translatedText,
rawText: rawText,
rule: activeRule,
option: option
};
option.textSetter(setterParams);
element.dataset.translate = "translated";
} catch (err) {
console.error(`[翻译机] Translation failed for element:`, element, `Error:`, err);
const setterParams = {
element: element,
translatorName: choice,
text: 'Translation failed',
rawText: rawText,
rule: activeRule,
option: option
};
option.textSetter(setterParams);
element.dataset.translate = "translation_error";
} finally {
removeItem(globalProcessingSave, element);
}
}
} catch (error) {
console.error('[翻译机] Error in main loop:', error);
} finally {
isTranslating = false;
}
}
if (activeRule) {
PromiseRetryWrap(startup[GM_getValue('translate_choice', '谷歌翻译')] || translate_gg_startup).then(() => {
console.log(`【翻译机】Starting main translation loop for ${activeRule.name}`);
mainIntervalId = setInterval(main, 200);
}).catch(err => {
console.error('[翻译机] Failed to start translation engine:', err);
});
}
if (GM_getValue('show_translate_ball', true)) {
initPanel();
}
})();
// --- Utility Functions ---
function removeItem(arr, item) {
const index = arr.indexOf(item);
if (index > -1) arr.splice(index, 1);
}
function baseSelector(selector, customFilter) {
return () => {
const items = document.querySelectorAll(selector);
let filterResult = Array.from(items).filter(item => {
if (item.dataset.translate) return false;
const nodes = item.querySelectorAll('[data-translate]');
if (nodes && Array.from(nodes).some(node => node.parentNode === item)) {
return false;
}
return true;
});
if (customFilter) {
filterResult = customFilter(filterResult);
}
return filterResult;
};
}
function baseTextGetter(e) {
return e.innerText ? e.innerText.trim() : '';
}
function baseTextSetter({ element, translatorName, text, rawText, rule, option }) {
if ((text || "").length === 0) text = 'Translation failed';
const currentReplaceTranslate = GM_getValue(`option_setting:replace_translate:${rule.name}-${option.name}`, replace_translate);
const currentShowInfo = GM_getValue(`option_setting:show_info:${rule.name}-${option.name}`, show_info);
const spanNode = document.createElement('span');
spanNode.style.whiteSpace = "pre-wrap";
spanNode.innerText = `${currentShowInfo ? "-----------" + translatorName + "-----------\n\n" : ""}` + text;
spanNode.dataset.translate = "translated_content";
spanNode.className = "translate-processed-node";
spanNode.title = rawText;
if (currentReplaceTranslate) {
element.innerHTML = p.createHTML('');
element.appendChild(spanNode);
} else {
let originalContentSpan = element.querySelector('.original-content-wrapper');
if (!originalContentSpan) {
originalContentSpan = document.createElement('span');
originalContentSpan.className = 'original-content-wrapper';
originalContentSpan.innerHTML = p.createHTML(element.innerHTML);
element.innerHTML = p.createHTML('');
element.appendChild(originalContentSpan);
}
element.appendChild(spanNode);
}
element.style.setProperty('-webkit-line-clamp', 'unset', 'important');
element.style.setProperty('max-height', 'unset', 'important');
element.style.setProperty('overflow', 'visible', 'important');
return spanNode;
}
function url_filter(text) {
return text.replace(/(https?|ftp|file):\/\/[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]/g, '').trim();
}
async function pass_lang(raw) {
if (!enable_pass_lang && !enable_pass_lang_cht) return;
try {
const result = await check_lang(raw);
if (enable_pass_lang && result === 'zh') {
console.log(`[翻译机] Detected simplified Chinese, skipping translation.`);
return new Promise(() => {});
}
if (enable_pass_lang_cht && (result === 'cht' || result === 'zh-tw')) {
console.log(`[翻译机] Detected traditional Chinese, skipping translation.`);
return new Promise(() => {});
}
return result;
} catch (err) {
console.error("[翻译机] Language detection failed:", err);
return;
}
}
async function check_lang(raw) {
const options = {
method: "POST",
url: 'https://fanyi.baidu.com/langdetect',
data: 'query=' + encodeURIComponent(raw.replace(/[\uD800-\uDBFF]$/, "").slice(0, 50)),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
}
};
try {
const res = await Request(options);
const data = JSON.parse(res.responseText);
if (data && data.lan) {
return data.lan;
}
throw new Error("Baidu language detection response malformed.");
} catch (err) {
console.error("[翻译机] Baidu language detection request failed:", err);
throw err;
}
}