Greasy Fork

Greasy Fork is available in English.

Youtube 双语字幕版

YouTube双语字幕,任何语言翻译成中文或用户选择的目标语言。支持YouTube翻译和谷歌翻译,失败自动切换。修复POT Token/沙箱隔离/异步竞态等问题,同时Hook XHR+Fetch双重拦截

当前为 2026-02-26 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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               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 翻译'}`);
            }
        });
    }

    // ───────────────────── 工具 ─────────────────────
    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);
    }

})();