Greasy Fork is available in English.
通过菜单配置中文字体大小、Debug 日志开关以及请求间隔
当前为
// ==UserScript==
// @name YouTube 双语字幕
// @version 4.1
// @author 4Aiur
// @namespace http://greasyfork.icu/zh-CN/users/394849-4aiur
// @description 通过菜单配置中文字体大小、Debug 日志开关以及请求间隔
// @match *://www.youtube.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @run-at document-idle
// @icon https://www.youtube.com/s/desktop/b9bfb983/img/favicon_32x32.png
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const TARGET_LANG = 'zh-CN';
const translationCache = new Map();
const timers = new WeakMap();
const requestVersion = new WeakMap();
// -------------------- 0. 中文与相似度检测函数 --------------------
function isPureChinese(text){
const cleaned=text.replace(/[\s\d\p{P}]/gu,'');
if(!cleaned) return false;
const chineseChars=cleaned.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g)||[];
return (chineseChars.length/cleaned.length)>0.8;
}
function isTooSimilar(s1,s2){
const clean=s=>s.replace(/[\s\p{P}]/gu,'').toLowerCase();
const c1=clean(s1),c2=clean(s2);
return c1===c2||c1.includes(c2)||c2.includes(c1);
}
// -------------------- 1. 配置 & 菜单 --------------------
let fontSize = GM_getValue('subtitle_font_size', 1.5);
let isDebug = GM_getValue('debug_mode', false);
let apiDelay = GM_getValue('api_delay', 100);
const log = {
info: (msg, ...args) => {
if (isDebug) console.log(`%c[YouTube双语字幕]%c ${msg}`, 'color: #00bcd4; font-weight: bold', '', ...args);
},
warn: (msg, ...args) => {
if (isDebug) console.warn(`[YouTube双语字幕] ${msg}`, ...args);
},
error: (msg, ...args) => {
console.error(`[YouTube双语字幕] ${msg}`, ...args);
}
};
log.info('脚本已执行', location.href);
function registerMenu() {
GM_registerMenuCommand(
`⚙️ 设置中文字体大小 (当前: ${fontSize}em)`,
() => {
let newSize = prompt('请输入字体大小 (建议 1.1 - 2.0):', fontSize);
if (newSize !== null) {
newSize = parseFloat(newSize);
if (!isNaN(newSize) && newSize > 0) {
fontSize = newSize;
GM_setValue('subtitle_font_size', newSize);
updateStyle();
location.reload();
}
}
}
);
GM_registerMenuCommand(
`⏱️ 设置谷歌翻译请求间隔 (当前: ${apiDelay}ms)`,
() => {
let newDelay = prompt('请输入请求间隔时间(ms),数值越小越快但易被谷歌限流:', apiDelay);
if (newDelay !== null) {
newDelay = parseInt(newDelay, 10);
if (!isNaN(newDelay) && newDelay >= 0) {
apiDelay = newDelay;
GM_setValue('api_delay', newDelay);
location.reload();
}
}
}
);
GM_registerMenuCommand(
isDebug ? '🚫 关闭调试日志 (当前: 开启)' : '🐞 开启调试日志 (当前: 关闭)',
() => {
isDebug = !isDebug;
GM_setValue('debug_mode', isDebug);
location.reload();
}
);
GM_registerMenuCommand('💬 反馈 & 建议', () => {
GM_openInTab(
'http://greasyfork.icu/zh-CN/scripts/567512-youtube-%E4%B8%AD%E8%8B%B1%E5%8F%8C%E8%AF%AD%E5%AD%97%E5%B9%95/feedback',
{ active: true }
);
});
}
// -------------------- 2. Trusted Types --------------------
let ttPolicy;
if (window.trustedTypes && window.trustedTypes.createPolicy) {
ttPolicy = window.trustedTypes.createPolicy(
'youtube-dual-subtitles-policy',
{ createHTML: s => s }
);
}
function setSafeContent(el, content) {
if (!el) return;
el.innerText = content;
}
// -------------------- 3. CSS --------------------
function updateStyle() {
document.getElementById('my-subtitle-style')?.remove();
const css = `
.ytp-caption-window-container .ytp-caption-window-bottom {
width: 100% !important;
left: 0 !important;
margin-left: 0 !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
background: none !important;
box-shadow: none !important;
}
/* 针对原有的字幕行和翻译行进行平滑处理 */
.caption-visual-line {
transition: opacity 0.6s ease-in-out !important;
}
/* 当 YouTube 尝试隐藏字幕时,让它慢点消失 */
.ytp-caption-window-container:empty {
display: block !important;
min-height: 100px;
}
.ytp-caption-window-container .caption-visual-line {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
text-align: center !important;
}
.ytp-caption-segment {
display: inline-block !important;
padding: 2px 10px !important;
text-align: center !important;
}
.my-trans-line {
display: block !important;
width: fit-content !important;
margin: 4px auto !important;
order: -1 !important;
color: #FFCC00 !important;
font-size: ${fontSize}em !important;
font-weight: bold !important;
line-height: 1.2 !important;
min-height: 1.2em !important;
white-space: nowrap !important;
padding: 2px 8px !important;
background: rgba(0, 0, 0, 0.7) !important;
border-radius: 4px !important;
text-align: center !important;
transition: opacity 0.8s ease-in-out, transform 0.5s ease-out !important;
opacity: 1 !important;
}
`;
const style = document.createElement('style');
style.id = 'my-subtitle-style';
style.innerHTML = ttPolicy ? ttPolicy.createHTML(css) : css;
document.head.appendChild(style);
log.info('样式已加载');
}
// -------------------- 4. 翻译 --------------------
let lastRequestTime = 0;
const delay = ms => new Promise(res => setTimeout(res, ms));
async function translate(text) {
if (!text || text.length < 2) return null;
if (translationCache.has(text)) {
return translationCache.get(text);
}
// 1. 计算需要等待的时间
const now = Date.now();
// 保证两次请求间隔至少为用户配置的 apiDelay
const timeToWait = Math.max(0, apiDelay - (now - lastRequestTime));
// 2. 提前更新下一次请求的基准时间
lastRequestTime = now + timeToWait;
// 3. 如果需要等待,则暂停当前 Promise 的执行
if (timeToWait > 0) {
log.info(`API 限速保护,排队等待 ${timeToWait}ms: "${text.substring(0, 10)}..."`);
await delay(timeToWait);
}
try {
log.info(`发起请求: "${text.substring(0, 15)}..."`);
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${TARGET_LANG}&dt=t&q=${encodeURIComponent(text)}`;
const res = await fetch(url);
const data = await res.json();
if (data?.[0]) {
const result = data[0].map(x => x[0]).join('');
translationCache.set(text, result);
return result;
}
} catch (e) {
log.error('翻译异常:', e);
}
return null;
}
// -------------------- 5. 核心 --------------------
function processLine(line) {
const segments = line.querySelectorAll('.ytp-caption-segment');
if (!segments.length) return;
const fullText = Array.from(segments)
.map(s => s.innerText)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
if (!fullText) return;
if (line.dataset.logged !== fullText) {
log.info(`处理字幕: ${fullText}`);
line.dataset.logged = fullText;
}
if (line.dataset.lastText === fullText) return;
line.dataset.lastText = fullText;
if (isPureChinese(fullText)) {
line.querySelector('.my-trans-line')?.remove();
return;
}
let transDiv = line.querySelector('.my-trans-line');
if (!transDiv) {
transDiv = document.createElement('div');
transDiv.className = 'my-trans-line';
line.prepend(transDiv);
log.info('DOM: 创建占位行');
}
if (translationCache.has(fullText)) {
log.info(`命中缓存: "${fullText.substring(0, 15)}..."`);
const cached = translationCache.get(fullText);
if (!isTooSimilar(cached, fullText)) {
setSafeContent(transDiv, cached);
} else {
transDiv.innerText = '\u00A0'; // 不可见占位
}
return;
}
clearTimeout(timers.get(line));
// ⭐ 版本号 +1
const version = (requestVersion.get(line) || 0) + 1;
requestVersion.set(line, version);
const timer = setTimeout(async () => {
if (!line.isConnected) return;
// ⭐ 如果期间字幕又变了 → 放弃
if (requestVersion.get(line) !== version) {
log.info('旧请求被取消');
return;
}
// ⭐ 可选:过滤短文本(强烈建议保留)
if (fullText.length < 6) return;
if (translationCache.has(fullText)) {
const cached = translationCache.get(fullText);
if (!isTooSimilar(cached, fullText)) {
setSafeContent(transDiv, cached);
} else {
transDiv.innerText = '\u00A0';
}
return;
}
const result = await translate(fullText);
// ⭐ 再检查一次(防止翻译期间又变)
if (requestVersion.get(line) !== version) {
log.info('翻译结果被丢弃(过期)');
return;
}
if (result && line.isConnected) {
if (!isTooSimilar(result, fullText)) {
setSafeContent(transDiv, result);
} else {
transDiv.innerText = '\u00A0';
}
}
}, 200);
timers.set(line, timer);
}
// -------------------- 6. 原 observer --------------------
const observer = new MutationObserver(() => {
document.querySelectorAll('.caption-visual-line').forEach(processLine);
});
let observerStarted = false;
let initRetry = 0;
function init() {
const container = document.querySelector('.ytp-caption-window-container');
if (!container) {
if (initRetry++ < 20) {
setTimeout(init, 500);
} else {
log.warn('字幕容器未找到,停止重试');
}
return;
}
initRetry = 0;
if (!observerStarted) {
log.info('初始化: 字幕容器已就绪');
observer.observe(container, { childList: true, subtree: true });
observerStarted = true;
}
}
// -------------------- 全局 observer --------------------
const globalObserver = new MutationObserver((mutations) => {
const changedLines = new Set();
mutations.forEach(m => {
m.addedNodes.forEach(node => {
if (!(node instanceof HTMLElement)) return;
// ⭐ 只处理字幕相关 DOM
if (!node.closest?.('.ytp-caption-window-container')) return;
// ⭐ 如果是字幕行
if (node.classList?.contains('caption-visual-line')) {
changedLines.add(node);
}
// ⭐ 或者内部新增了字幕
node.querySelectorAll?.('.caption-visual-line').forEach(el => {
changedLines.add(el);
});
});
});
if (!changedLines.size) return;
log.info(`全局命中字幕变化: ${changedLines.size} 行`);
changedLines.forEach(processLine);
});
globalObserver.observe(document.body, {
childList: true,
subtree: true
});
// -------------------- 启动 --------------------
registerMenu();
updateStyle();
init();
['yt-navigate-finish', 'yt-page-data-updated'].forEach(evt => {
window.addEventListener(evt, () => {
log.info(`YouTube 事件: ${evt}`);
observer.disconnect();
observerStarted = false;
timers.clear();
init();
});
});
})();