Greasy Fork

Greasy Fork is available in English.

Youtube 双语字幕版 修复 20260226

YouTube双语字幕,任何语言翻译成中文或用户选择的目标语言。支持YouTube翻译和谷歌翻译,自动切换。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name                Youtube 双语字幕版 修复 20260226
// @version             1.7.1
// @author              LR & 4Aiur
// @license             MIT
// @description         YouTube双语字幕,任何语言翻译成中文或用户选择的目标语言。支持YouTube翻译和谷歌翻译,自动切换。
// @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 安全写入
 *
 * ────────────────────────────────────────────────────────────────
 */

(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);
    }

    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;

            const results = await Promise.all(items.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 ─────────────────────
    // v1.7.0 核心:对字幕请求,完全接管 XHR 生命周期
    // 用 fetch 代替播放器的 XHR 发请求 → 异步处理完 → 手动模拟 XHR 事件通知播放器
    function hookXHR() {
        function PatchedXHR() {
            // 是否为字幕请求,决定是否走代理模式
            let _isSubtitle  = false;
            let _url         = '';
            let _method      = 'GET';
            let _headers     = {};
            let _responseText = '';         // 最终给播放器的文本
            let _responseObj  = null;       // 最终给播放器的对象(responseType=json时)
            let _status       = 0;
            let _readyState   = 0;
            let _responseType = '';

            // 播放器注册的事件监听器
            const _listeners  = {};         // { eventType: [fn, ...] }
            let _onreadystatechange = null;
            let _onload             = null;
            let _onerror            = null;

            // 非字幕请求走真实 XHR
            let _realXhr = null;

            // ── 公开属性 ──
            const proxy = {
                // 播放器设置 responseType,我们记录下来
                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 为 '' 或 'text' 时才能读 responseText
                        if (_responseType === '' || _responseType === 'text') {
                            return _realXhr.responseText;
                        }
                        return '';
                    }
                    return '';
                },
                get responseURL()  { return _url; },
                get responseXML()  { return null; },

                // 事件属性
                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; },
                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 onloadend(fn)  { if (_realXhr) _realXhr.onloadend  = 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; },

                upload: { addEventListener: () => {} },

                // ── open ──
                open(method, url, async = true, ...args) {
                    _method = method;
                    _url    = url;
                    _isSubtitle = isSubtitleUrl(url);

                    if (_isSubtitle) {
                        log('[XHR proxy] open 字幕请求:', url.slice(0, 100));
                        _setReadyState(1); // OPENED
                    } else {
                        _realXhr = new _origXHR();
                        _realXhr.responseType = _responseType;
                        if (_onreadystatechange) _realXhr.onreadystatechange = _onreadystatechange;
                        if (_onload)  _realXhr.onload  = _onload;
                        if (_onerror) _realXhr.onerror = _onerror;
                        // 透传已注册的事件监听
                        for (const [type, fns] of Object.entries(_listeners)) {
                            fns.forEach(fn => _realXhr.addEventListener(type, fn));
                        }
                        _realXhr.open(method, url, async, ...args);
                    }
                },

                // ── setRequestHeader ──
                setRequestHeader(key, value) {
                    if (_isSubtitle) { _headers[key] = value; }
                    else if (_realXhr) { _realXhr.setRequestHeader(key, value); }
                },

                // ── send ──
                send(body) {
                    if (!_isSubtitle) { if (_realXhr) _realXhr.send(body); return; }

                    // 字幕请求:用 fetch 发,异步处理完后模拟事件
                    (async () => {
                        try {
                            const fetchInit = { method: _method, headers: _headers };
                            const res = await _origFetch(_url, fetchInit);
                            _status = res.status;

                            const rawText = await res.text();
                            const processed = await processDualSubtitle(_url, rawText);

                            _responseText = processed;
                            try { _responseObj = JSON.parse(processed); } catch { _responseObj = null; }

                            // 模拟 readyState 变化:2(HEADERS_RECEIVED) → 3(LOADING) → 4(DONE)
                            _setReadyState(2);
                            _setReadyState(3);
                            _setReadyState(4);

                            // 触发 onload
                            _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; },
            };

            // 辅助:设置 readyState 并触发 onreadystatechange
            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) {
                const handler = type === 'load' ? _onload : type === 'error' ? _onerror : null;
                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 看起来像 XMLHttpRequest(instanceof 检查等)
        PatchedXHR.prototype = Object.create(_origXHR.prototype);
        PatchedXHR.prototype.constructor = PatchedXHR;
        Object.setPrototypeOf(PatchedXHR, _origXHR);

        // 复制静态常量 (UNSENT=0, OPENED=1, ...),用 defineProperty 避免只读报错
        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.1 启动完成 | 语言:', TARGET_LANG, '| 引擎:', TRANS_SERVICE);
    } catch (e) {
        err('启动失败:', e.message, e);
    }

})();