Greasy Fork is available in English.
YouTube双语字幕,任何语言翻译成中文或用户选择的目标语言。支持YouTube翻译和谷歌翻译,失败自动切换。修复POT Token/沙箱隔离/异步竞态等问题,同时Hook XHR+Fetch双重拦截
当前为
// ==UserScript==
// @name Youtube 双语字幕版
// @version 1.7.2
// @author LR
// @contributor zhulr7765
// @contributor 4Aiur
// @license MIT
// @description YouTube双语字幕,任何语言翻译成中文或用户选择的目标语言。支持YouTube翻译和谷歌翻译,失败自动切换。修复POT Token/沙箱隔离/异步竞态等问题,同时Hook XHR+Fetch双重拦截
// @match *://www.youtube.com/*
// @match *://m.youtube.com/*
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @grant unsafeWindow
// @run-at document-start
// @namespace http://greasyfork.icu/users/1210499
// @icon https://www.youtube.com/s/desktop/b9bfb983/img/favicon_32x32.png
// ==/UserScript==
/**
* ─── 版本修复日志 ───────────────────────────────────────────────
*
* v1.4.0(原版,LR)
* - 基础双语字幕功能,使用 ajax-hook 库拦截 XHR
* - 支持 YouTube 翻译 / Google 翻译,失败自动切换
* - 通过 GM_registerMenuCommand 提供语言和引擎设置菜单
*
* v1.5.0(4Aiur 修复)
* - 【移除 ajax-hook 依赖】改为原生 hook XMLHttpRequest 和 fetch
* 原因:ajax-hook 只拦截 XHR,YouTube 播放器部分请求走原生 fetch 导致漏拦截
* - 【修复 POT Token 问题】原版对字幕 URL 发起独立 fetch 请求,
* 缺少 YouTube 的 Proof-of-Origin Token,返回 403 或空数据;
* 新方案复用播放器原始请求 URL(含完整 token)附加 tlang 参数
* - 【修复 URL 拼接 bug】原 URL 无 ? 时直接拼 & 导致参数格式错误
* - 同时 hook XHR + fetch,双重覆盖兼容两种请求方式
*
* v1.6.0(4Aiur 修复)
* - 【修复 Illegal invocation】跨 Tampermonkey 沙箱调用
* _origXHR.prototype.responseText.call(xhr) 失败;
* 改为通过 Object.getOwnPropertyDescriptor 沿原型链查找 getter 后安全调用
* - 【修复 hook 时序】将响应读取从 readystatechange 外部监听
* 改为在 send() 内部注册,确保闭包直接持有 xhr 实例引用
* - 显式声明 @grant unsafeWindow,确保能访问页面真实 window
*
* v1.6.1(4Aiur 修复)
* - 【修复 InvalidStateError】YouTube 播放器将 XHR responseType 设为 'json',
* 此时读取 responseText 会抛异常(浏览器规范禁止);
* 改为根据 responseType 自适应读取:
* responseType='' | 'text' → 读 responseText(字符串)
* responseType='json' → 读 response(对象)→ JSON.stringify 后处理
* - response getter 回写时同步反向转换:responseType='json' 时将字符串
* JSON.parse 回对象再返回,否则播放器解析失败
*
* v1.7.0(4Aiur 修复)
* - 【修复异步竞态:字幕不显示的根本原因】
* 播放器在 readystatechange(4) 触发时同步读取 responseText,
* 而我们的翻译处理是异步的(需几百毫秒),导致播放器读走的是原始英文数据;
* 解决方案:对字幕请求完全接管 XHR 生命周期,
* 用 fetch 代替播放器的 XHR 发请求,等异步处理全部完成后
* 再手动触发 readystatechange / onload,播放器此时读到的已是双语字幕
* - 非字幕请求仍透传给真实 XHR,不影响其他功能
*
* v1.7.1(4Aiur 修复)
* - 【修复启动崩溃】Object.setPrototypeOf(PatchedXHR, _origXHR) 后,
* UNSENT 等静态常量从原型链继承且为只读,直接 = 赋值在严格模式下抛
* "Cannot assign to read only property";
* 改用 Object.defineProperty + configurable:true 安全写入
*
* v1.7.2(4Aiur 修复)
* - 【修复版本号不一致】@version 与启动日志统一为 1.7.2
* - 【修复 loadend/onloadend 事件丢失】代理模式下 _realXhr 为 null,
* onloadend setter 直接丢弃回调;改为用 _onloadend 变量单独存储,
* _fireEvent('loadend') 时正确触发
* - 【修复 upload 假对象不完整】补全 removeEventListener / dispatchEvent,
* 避免第三方脚本访问时报错
* - 【修复 open() 时序】_setReadyState(1) 移到 open() 末尾执行,
* 确保播放器有机会在 open() 返回后再注册 onreadystatechange
* (实际影响不大,但符合 XHR 规范)
* - 【修复 Google 翻译限流】Promise.all 全并发改为分批(每批 5 条)顺序请求,
* 避免触发 Google Translate API 429 限流
*
* ────────────────────────────────────────────────────────────────
*/
(function () {
'use strict';
const TAG = '[双语字幕]';
const log = (...a) => console.log( `%c${TAG}`, 'color:#4CAF50;font-weight:bold', ...a);
const warn = (...a) => console.warn(`%c${TAG}`, 'color:#FF9800;font-weight:bold', ...a);
const err = (...a) => console.error(`%c${TAG}`, 'color:#F44336;font-weight:bold', ...a);
const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
const _origFetch = pageWindow.fetch.bind(pageWindow);
const _origXHR = pageWindow.XMLHttpRequest;
// ───────────────────── 设置 ─────────────────────
let TARGET_LANG = localStorage.getItem('dualSubTargetLang') || 'zh';
let TRANS_SERVICE = localStorage.getItem('dualSubTransService') || 'youtube';
let LAST_FAILED = null;
function saveSettings(lang, svc) {
localStorage.setItem('dualSubTargetLang', lang);
localStorage.setItem('dualSubTransService', svc);
TARGET_LANG = lang; TRANS_SERVICE = svc;
}
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('设置翻译语言', () => {
const v = prompt('请输入目标语言的ISO 639-1代码(例如:zh 中文, en 英文, ja 日语):', TARGET_LANG);
if (v) { saveSettings(v.trim(), TRANS_SERVICE); alert(`翻译目标语言已设置为:${v.trim()}`); }
});
GM_registerMenuCommand('选择翻译引擎', () => {
const v = prompt('请选择翻译引擎(输入数字):\n1. YouTube 翻译\n2. Google 翻译', TRANS_SERVICE === 'youtube' ? '1' : '2');
if (v) {
const s = v.trim() === '1' ? 'youtube' : 'google';
saveSettings(TARGET_LANG, s); LAST_FAILED = null;
alert(`翻译引擎已设置为:${s === 'youtube' ? 'YouTube 翻译' : 'Google 翻译'}`);
}
});
GM_registerMenuCommand('💬 反馈 & 建议', () => {
GM_openInTab('http://greasyfork.icu/zh-CN/scripts/567512-youtube-%E5%8F%8C%E8%AF%AD%E5%AD%97%E5%B9%95%E7%89%88/feedback', {
active: true,
insert: true,
setParent: true,
});
});
}
// ───────────────────── 工具 ─────────────────────
function appendParam(url, key, value) {
const cleaned = url.replace(new RegExp(`([&?])${key}=[^&]*`, 'g'), (m, p) => p === '?' ? '?' : '');
const normalized = cleaned.replace(/\?&/, '?').replace(/&&+/, '&').replace(/[?&]$/, '');
return normalized + (normalized.includes('?') ? '&' : '?') + `${key}=${encodeURIComponent(value)}`;
}
function buildYtUrl(originalUrl) {
let url = originalUrl.replace(/([&?])tlang=[^&]*/g, (m, p) => p === '?' ? '?' : '');
url = url.replace(/\?&/, '?').replace(/&&+/, '&').replace(/[?&]$/, '');
return appendParam(appendParam(url, 'tlang', TARGET_LANG), '__dual_hooked__', '1');
}
function isSubtitleUrl(url) {
return typeof url === 'string'
&& url.includes('/api/timedtext')
&& !url.includes('__dual_hooked__');
}
// ───────────────────── Google 翻译 ─────────────────────
async function googleTranslate(text) {
try {
const res = await _origFetch(
`https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${TARGET_LANG}&dt=t&q=${encodeURIComponent(text)}`
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data[0].map(x => x[0]).join('');
} catch (e) {
err('[Google] 翻译失败:', e.message);
return null;
}
}
// ───────────────────── 字幕处理 ─────────────────────
function levenshteinDistance(s1, s2) {
if (!s1.length) return s2.length;
if (!s2.length) return s1.length;
const mat = Array.from({ length: s1.length + 1 }, (_, i) =>
Array.from({ length: s2.length + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0)
);
for (let i = 1; i <= s1.length; i++)
for (let j = 1; j <= s2.length; j++)
mat[i][j] = s1[i-1] === s2[j-1]
? mat[i-1][j-1]
: 1 + Math.min(mat[i-1][j-1], mat[i][j-1], mat[i-1][j]);
return mat[s1.length][s2.length];
}
function jaccardSimilarity(s1, s2) {
const a = new Set(s1.split('')), b = new Set(s2.split(''));
return [...a].filter(x => b.has(x)).length / (new Set([...a, ...b]).size || 1);
}
function calcSimilarity(s1, s2) {
const maxLen = Math.max(s1.length, s2.length) || 1;
return (1 - levenshteinDistance(s1, s2) / maxLen) * 0.7 + jaccardSimilarity(s1, s2) * 0.3;
}
function mergeSubtitles(defaultSubs, translatedSubs) {
const merged = JSON.parse(JSON.stringify(defaultSubs));
const transEvents = (translatedSubs.events || []).filter(e => e.segs);
if (!transEvents.length) return JSON.stringify(merged);
let count = 0;
for (const defEvent of merged.events) {
if (!defEvent.segs) continue;
const defText = defEvent.segs.map(s => s.utf8).join('').trim();
if (!defText) continue;
let best = null, bestDiff = Infinity;
for (const te of transEvents) {
const diff = Math.abs(te.tStartMs - defEvent.tStartMs);
if (diff < bestDiff) { bestDiff = diff; best = te; }
}
if (!best) continue;
const transText = best.segs.map(s => s.utf8).join('').trim();
if (!transText) continue;
const close = bestDiff < 500 ||
(Math.min(defEvent.tStartMs + (defEvent.dDurationMs || 0),
best.tStartMs + (best.dDurationMs || 0)) -
Math.max(defEvent.tStartMs, best.tStartMs)) > 0;
if (close && calcSimilarity(defText, transText) < 0.6) {
defEvent.segs = [{ utf8: `${defText}\n${transText}` }];
count++;
}
}
log(`合并完成,共 ${count} 条双语字幕`);
return JSON.stringify(merged);
}
// 【修复】Google 翻译分批处理,每批 BATCH_SIZE 条,避免并发过多触发限流
const GOOGLE_BATCH_SIZE = 5;
async function applyGoogleTranslation(originalData) {
try {
const clone = JSON.parse(JSON.stringify(originalData));
const items = clone.events
.filter(e => e.segs)
.map(e => ({ e, text: e.segs.map(s => s.utf8).join('').trim() }))
.filter(x => x.text);
if (!items.length) return null;
// 分批顺序处理
for (let i = 0; i < items.length; i += GOOGLE_BATCH_SIZE) {
const batch = items.slice(i, i + GOOGLE_BATCH_SIZE);
const results = await Promise.all(
batch.map(async ({ e, text }) => ({ e, t: await googleTranslate(text) }))
);
for (const { e, t } of results) {
if (t === null) { LAST_FAILED = 'google'; return null; }
e.segs = [{ utf8: t }];
}
}
return clone;
} catch (e) {
err('[Google] 处理异常:', e.message);
LAST_FAILED = 'google';
return null;
}
}
async function processDualSubtitle(originalUrl, responseText) {
if (!responseText) return responseText;
let defaultSubs;
try { defaultSubs = JSON.parse(responseText); } catch { return responseText; }
if (!defaultSubs.events?.length) return responseText;
let service = TRANS_SERVICE;
if (LAST_FAILED === service) {
service = service === 'youtube' ? 'google' : 'youtube';
warn('自动切换到:', service);
}
let translatedSubs = null;
if (service === 'youtube') {
try {
const res = await _origFetch(buildYtUrl(originalUrl));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.events?.length) { translatedSubs = data; log('YouTube 翻译成功'); }
else throw new Error('events 为空');
} catch (e) {
err('YouTube 翻译失败:', e.message, '→ fallback Google');
LAST_FAILED = 'youtube';
translatedSubs = await applyGoogleTranslation(defaultSubs);
}
} else {
translatedSubs = await applyGoogleTranslation(defaultSubs);
if (!translatedSubs) {
warn('Google 失败 → fallback YouTube');
try {
const res = await _origFetch(buildYtUrl(originalUrl));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.events?.length) translatedSubs = data;
} catch (e) {
err('YouTube fallback 失败:', e.message);
LAST_FAILED = 'youtube';
}
}
}
if (!translatedSubs) { err('所有翻译均失败'); return responseText; }
try { return mergeSubtitles(defaultSubs, translatedSubs); }
catch (e) { err('合并失败:', e.message); return responseText; }
}
// ───────────────────── Hook XHR ─────────────────────
function hookXHR() {
function PatchedXHR() {
let _isSubtitle = false;
let _url = '';
let _method = 'GET';
let _headers = {};
let _responseText = '';
let _responseObj = null;
let _status = 0;
let _readyState = 0;
let _responseType = '';
const _listeners = {};
let _onreadystatechange = null;
let _onload = null;
let _onerror = null;
let _onloadend = null; // 【修复】单独存储 onloadend
let _realXhr = null;
// 【修复】upload 补全方法,避免第三方访问时报错
const _upload = {
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
};
const proxy = {
set responseType(v) { _responseType = v; if (_realXhr) _realXhr.responseType = v; },
get responseType() { return _responseType; },
get readyState() { return _isSubtitle ? _readyState : (_realXhr ? _realXhr.readyState : 0); },
get status() { return _isSubtitle ? _status : (_realXhr ? _realXhr.status : 0); },
get statusText() { return _isSubtitle ? 'OK' : (_realXhr ? _realXhr.statusText : ''); },
get response() {
if (_isSubtitle) return _responseType === 'json' ? _responseObj : _responseText;
return _realXhr ? _realXhr.response : null;
},
get responseText() {
if (_isSubtitle) return _responseText;
if (_realXhr && (_responseType === '' || _responseType === 'text')) return _realXhr.responseText;
return '';
},
get responseURL() { return _url; },
get responseXML() { return null; },
get upload() { return _upload; },
set onreadystatechange(fn) { _onreadystatechange = fn; if (_realXhr) _realXhr.onreadystatechange = fn; },
get onreadystatechange() { return _onreadystatechange; },
set onload(fn) { _onload = fn; if (_realXhr) _realXhr.onload = fn; },
get onload() { return _onload; },
set onerror(fn) { _onerror = fn; if (_realXhr) _realXhr.onerror = fn; },
get onerror() { return _onerror; },
// 【修复】onloadend 代理模式下用 _onloadend 存储,不丢弃
set onloadend(fn) { _onloadend = fn; if (_realXhr) _realXhr.onloadend = fn; },
get onloadend() { return _onloadend; },
set onprogress(fn) { if (_realXhr) _realXhr.onprogress = fn; },
set onabort(fn) { if (_realXhr) _realXhr.onabort = fn; },
set ontimeout(fn) { if (_realXhr) _realXhr.ontimeout = fn; },
set timeout(v) { if (_realXhr) _realXhr.timeout = v; },
get timeout() { return _realXhr ? _realXhr.timeout : 0; },
set withCredentials(v) { if (_realXhr) _realXhr.withCredentials = v; },
get withCredentials() { return _realXhr ? _realXhr.withCredentials : false; },
open(method, url, async = true, ...args) {
_method = method;
_url = url;
_isSubtitle = isSubtitleUrl(url);
if (_isSubtitle) {
log('[XHR proxy] open 字幕请求:', url.slice(0, 100));
// 【修复】open() 末尾再触发 state=1,让调用方有机会先注册监听器
// 此处用 setTimeout 0 推迟到当前调用栈结束后触发
setTimeout(() => _setReadyState(1), 0);
} else {
_realXhr = new _origXHR();
_realXhr.responseType = _responseType;
if (_onreadystatechange) _realXhr.onreadystatechange = _onreadystatechange;
if (_onload) _realXhr.onload = _onload;
if (_onerror) _realXhr.onerror = _onerror;
if (_onloadend) _realXhr.onloadend = _onloadend;
for (const [type, fns] of Object.entries(_listeners)) {
fns.forEach(fn => _realXhr.addEventListener(type, fn));
}
_realXhr.open(method, url, async, ...args);
}
},
setRequestHeader(key, value) {
if (_isSubtitle) { _headers[key] = value; }
else if (_realXhr) { _realXhr.setRequestHeader(key, value); }
},
send(body) {
if (!_isSubtitle) { if (_realXhr) _realXhr.send(body); return; }
(async () => {
try {
const res = await _origFetch(_url, { method: _method, headers: _headers });
_status = res.status;
const rawText = await res.text();
const processed = await processDualSubtitle(_url, rawText);
_responseText = processed;
try { _responseObj = JSON.parse(processed); } catch { _responseObj = null; }
_setReadyState(2);
_setReadyState(3);
_setReadyState(4);
_fireEvent('load', { type: 'load', target: proxy });
_fireEvent('loadend', { type: 'loadend', target: proxy }); // 【修复】现在能正确触发
} catch (e) {
err('[XHR proxy] send 异常:', e.message);
_fireEvent('error', { type: 'error', target: proxy });
}
})();
},
abort() { if (_realXhr) _realXhr.abort(); },
getResponseHeader(name) { return _realXhr ? _realXhr.getResponseHeader(name) : null; },
getAllResponseHeaders() { return _realXhr ? _realXhr.getAllResponseHeaders() : ''; },
overrideMimeType(mime) { if (_realXhr) _realXhr.overrideMimeType(mime); },
addEventListener(type, fn, ...args) {
if (!_listeners[type]) _listeners[type] = [];
_listeners[type].push(fn);
if (_realXhr) _realXhr.addEventListener(type, fn, ...args);
},
removeEventListener(type, fn, ...args) {
if (_listeners[type]) _listeners[type] = _listeners[type].filter(f => f !== fn);
if (_realXhr) _realXhr.removeEventListener(type, fn, ...args);
},
dispatchEvent(event) {
if (_realXhr) return _realXhr.dispatchEvent(event);
return false;
},
};
function _setReadyState(state) {
_readyState = state;
const evt = { type: 'readystatechange', target: proxy };
if (_onreadystatechange) {
try { _onreadystatechange.call(proxy, evt); } catch(e) { err('[XHR proxy] onreadystatechange 异常:', e.message); }
}
(_listeners['readystatechange'] || []).forEach(fn => {
try { fn.call(proxy, evt); } catch(e) {}
});
}
function _fireEvent(type, evt) {
// 【修复】loadend 使用 _onloadend 而非之前遗漏的情况
const handlerMap = { load: _onload, error: _onerror, loadend: _onloadend };
const handler = handlerMap[type];
if (handler) {
try { handler.call(proxy, evt); } catch(e) { err('[XHR proxy] on' + type + ' 异常:', e.message); }
}
(_listeners[type] || []).forEach(fn => {
try { fn.call(proxy, evt); } catch(e) {}
});
}
return proxy;
}
PatchedXHR.prototype = Object.create(_origXHR.prototype);
PatchedXHR.prototype.constructor = PatchedXHR;
Object.setPrototypeOf(PatchedXHR, _origXHR);
for (const key of ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']) {
try {
Object.defineProperty(PatchedXHR, key, {
value: _origXHR[key], writable: false, configurable: true
});
} catch (e) { /* 忽略 */ }
}
pageWindow.XMLHttpRequest = PatchedXHR;
log('XHR hook 完成(代理模式)');
}
// ───────────────────── Hook Fetch(备用) ─────────────────────
function hookFetch() {
pageWindow.fetch = async function (input, init, ...rest) {
const url = typeof input === 'string' ? input
: (input instanceof Request ? input.url : String(input));
if (!isSubtitleUrl(url)) return _origFetch(input, init, ...rest);
log('[Fetch] 拦截:', url.slice(0, 100));
const response = await _origFetch(input, init, ...rest);
try {
const text = await response.clone().text();
const merged = await processDualSubtitle(url, text);
return new Response(merged, {
status: response.status, statusText: response.statusText, headers: response.headers,
});
} catch (e) {
err('[Fetch] 处理失败:', e.message);
return response;
}
};
log('Fetch hook 完成');
}
// ───────────────────── 启动 ─────────────────────
try {
hookFetch();
hookXHR();
log('v1.7.2 启动完成 | 语言:', TARGET_LANG, '| 引擎:', TRANS_SERVICE);
} catch (e) {
err('启动失败:', e.message, e);
}
})();