Greasy Fork

来自缓存

YouTube 双语字幕下载 / YouTube Biligual Subtitles Downloader

在 YouTube 页面添加双语字幕,支持双语字幕下载

// ==UserScript==
// @name         YouTube 双语字幕下载 / YouTube Biligual Subtitles Downloader
// @namespace    https://github.com/Nehemiab/YouTube-Biligual-Subtitles-Downloader
// @version      1.0
// @description  在 YouTube 页面添加双语字幕,支持双语字幕下载
// @author       NEHEMIAB
// @match        *://www.youtube.com/watch?v=*
// @match        *://www.youtube.com
// @match        *://www.youtube.com/*
// @grant        none
// @run-at       document-start
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @run-at       document-end
// @copyright    2025,NEHEMIAB(https://github.com/Nehemiab/YouTube-Biligual-Subtitles-Downloader)
// @license      MIT
// @thanks       Coink,Claude
// ==/UserScript==

(function() {
    // 全局变量储存处理后的字幕数据
    let ytSubtitleData = null;

    function hookit(){
        (function(global, factory) {
            // 将工厂函数的所有导出赋给全局对象
            for (var key in factory) {
            global[key] = factory[key];
            }
        })(window, (function() {
            'use strict';

            // 支持的XHR事件类型
            var events = ['load', 'loadend', 'timeout', 'error', 'readystatechange', 'abort'];
            var XHR_PROXY_KEY = '__xhr';

            /**
             * 配置事件对象
             * @param {Event} event - 原始事件对象
             * @param {Object} target - 事件的目标对象
             * @return {Event} 配置好的事件对象
             */
            function configEvent(event, target) {
            var eventCopy = {};
            for (var key in event) {
                eventCopy[key] = event[key];
            }
            eventCopy.target = eventCopy.currentTarget = target;
            return eventCopy;
            }

            /**
             * 钩住XMLHttpRequest
             * @param {Object} hooks - 包含各种钩子的对象
             * @param {Window} win - window对象,默认为全局window
             * @return {Function} 修改后的XMLHttpRequest构造函数
             */
            function hook(hooks, win) {
            win = win || window;

            // 保存原始的XMLHttpRequest
            win[XHR_PROXY_KEY] = win[XHR_PROXY_KEY] || win.XMLHttpRequest;

            // 创建新的XMLHttpRequest构造函数
            win.XMLHttpRequest = function() {
                var xhr = new win[XHR_PROXY_KEY]();

                // 确保所有事件处理程序属性存在
                for (var i = 0; i < events.length; ++i) {
                var eventName = 'on' + events[i];
                if (xhr[eventName] === undefined) {
                    xhr[eventName] = null;
                }
                }

                // 为每个属性和方法创建代理
                for (var prop in xhr) {
                var type = '';
                try {
                    type = typeof xhr[prop];
                } catch(e) { }

                if (type === 'function') {
                    // 代理方法
                    this[prop] = createMethodProxy(prop);
                } else {
                    // 代理属性
                    Object.defineProperty(this, prop, {
                    get: createGetter(prop),
                    set: createSetter(prop),
                    enumerable: true
                    });
                }
                }

                var self = this;
                xhr.getProxy = function() { return self; };
                this.xhr = xhr;
            };

            // 复制XMLHttpRequest的静态属性
            Object.assign(win.XMLHttpRequest, {
                UNSENT: 0,
                OPENED: 1,
                HEADERS_RECEIVED: 2,
                LOADING: 3,
                DONE: 4
            });

            /**
             * 创建属性getter代理
             */
            function createGetter(prop) {
                return function() {
                var value = this.hasOwnProperty(prop + '_') ?
                    this[prop + '_'] : this.xhr[prop];
                var getter = (hooks[prop] || {}).getter;
                return getter && getter(value, this) || value;
                };
            }

            /**
             * 创建属性setter代理
             */
            function createSetter(prop) {
                return function(value) {
                var xhr = this.xhr;
                var self = this;
                var hook = hooks[prop];

                if (prop.substring(0, 2) === 'on') {
                    // 事件处理程序
                    self[prop + '_'] = value;
                    xhr[prop] = function(e) {
                    e = configEvent(e, self);
                    if (hook && hook.call(self, xhr, e)) {
                        return;
                    }
                    value.call(self, e);
                    };
                } else {
                    // 常规属性
                    var setter = (hook || {}).setter;
                    value = setter && setter(value, self) || value;
                    this[prop + '_'] = value;
                    try {
                    xhr[prop] = value;
                    } catch(e) { }
                }
                };
            }

            /**
             * 创建方法代理
             */
            function createMethodProxy(method) {
                return function() {
                var args = [].slice.call(arguments);
                var hook = hooks[method];

                if (hook) {
                    var result = hook.call(this, args, this.xhr);
                    if (result) {
                    return result;
                    }
                }

                return this.xhr[method].apply(this.xhr, args);
                };
            }

            return win.XMLHttpRequest;
            }

            /**
             * 解除钩子
             * @param {Window} win - window对象,默认为全局window
             */
            function unHook(win) {
            win = win || window;
            if (win[XHR_PROXY_KEY]) {
                win.XMLHttpRequest = win[XHR_PROXY_KEY];
            }
            win[XHR_PROXY_KEY] = undefined;
            }

            /**
             * 代理XHR请求
             */
            function proxy(options, win) {
            if (win = win || window, win.__xhr) {
                throw "Ajax is already hooked.";
            }
            return proxyXhr(options, win);
            }

            /**
             * 解除代理
             */
            function unProxy(win) {
            unHook(win);
            }

            /**
             * 创建XHR代理
             */
            function proxyXhr(options, win) {
            var onRequest = options.onRequest;
            var onResponse = options.onResponse;
            var onError = options.onError;

            return hook({
                // 处理事件
                onload: returnTrue,
                onloadend: returnTrue,
                onerror: createErrorHandler('error'),
                ontimeout: createErrorHandler('timeout'),
                onabort: createErrorHandler('abort'),

                onreadystatechange: function(xhr) {
                if (xhr.readyState === 4 && xhr.status !== 0) {
                    handleResponse(xhr, this);
                } else if (xhr.readyState !== 4) {
                    triggerEvent(xhr, 'readystatechange');
                }
                return true;
                },

                // 处理方法
                open: function(args, xhr) {
                var self = this;
                var config = xhr.config = { headers: {} };

                config.method = args[0];
                config.url = args[1];
                config.async = args[2];
                config.user = args[3];
                config.password = args[4];
                config.xhr = xhr;

                var eventName = 'onreadystatechange';
                if (!xhr[eventName]) {
                    xhr[eventName] = function() {
                    return handleReadyStateChange(xhr, self);
                    };
                }

                if (onRequest) return true;
                },

                send: function(args, xhr) {
                var config = xhr.config;
                config.withCredentials = xhr.withCredentials;
                config.body = args[0];

                if (onRequest) {
                    var callback = function() {
                    onRequest(config, new RequestHandler(xhr));
                    };

                    if (config.async === false) {
                    callback();
                    } else {
                    setTimeout(callback);
                    }
                    return true;
                }
                },

                setRequestHeader: function(args, xhr) {
                xhr.config.headers[args[0].toLowerCase()] = args[1];
                if (onRequest) return true;
                },

                addEventListener: function(args, xhr) {
                var self = this;
                if (events.indexOf(args[0]) !== -1) {
                    var listener = args[1];

                    getWatcher(xhr).addEventListener(args[0], function(e) {
                    var event = configEvent(e, self);
                    event.type = args[0];
                    event.isTrusted = true;
                    listener.call(self, event);
                    });

                    return true;
                }
                },

                getAllResponseHeaders: function(args, xhr) {
                var headers = xhr.resHeader;
                if (headers) {
                    var result = '';
                    for (var key in headers) {
                    result += key + ': ' + headers[key] + '\r\n';
                    }
                    return result;
                }
                },

                getResponseHeader: function(args, xhr) {
                var headers = xhr.resHeader;
                if (headers) {
                    return headers[(args[0] || '').toLowerCase()];
                }
                }
            }, win);

            /**
             * 处理响应
             */
            function handleResponse(xhr, xhrProxy) {
                var response = {
                response: xhrProxy.response || xhrProxy.responseText,
                status: xhrProxy.status,
                statusText: xhrProxy.statusText,
                config: xhr.config,
                headers: xhr.resHeader || parseHeaders(xhrProxy.getAllResponseHeaders())
                };

                if (!onResponse) {
                new ResponseHandler(xhr).resolve(response);
                return;
                }

                onResponse(response, new ResponseHandler(xhr));
            }

            /**
             * 处理错误
             */
            function createErrorHandler(type) {
                return function(xhr, e) {
                handleError(xhr, this, e, type);
                return true;
                };
            }

            function handleError(xhr, xhrProxy, error, type) {
                var errorObject = {
                config: xhr.config,
                error: error,
                type: type
                };

                var handler = new ErrorHandler(xhr);

                if (onError) {
                onError(errorObject, handler);
                } else {
                handler.next(errorObject);
                }
            }

            function handleReadyStateChange(xhr, xhrProxy) {
                return xhr.readyState === 4 && xhr.status !== 0 ?
                handleResponse(xhr, xhrProxy) :
                xhr.readyState !== 4 && triggerEvent(xhr, 'readystatechange');
            }

            function returnTrue() {
                return true;
            }
            }

            // 辅助函数
            function trim(str) {
            return str.replace(/^\s+|\s+$/g, '');
            }

            function getWatcher(xhr) {
            return xhr.watcher || (xhr.watcher = document.createElement('a'));
            }

            function triggerEvent(xhr, type) {
            var xhrProxy = xhr.getProxy();
            var eventKey = 'on' + type + '_';
            var event = configEvent({ type: type }, xhrProxy);

            if (xhrProxy[eventKey]) {
                xhrProxy[eventKey](event);
            }

            var customEvent;
            if (typeof Event === 'function') {
                customEvent = new Event(type, { bubbles: false });
            } else {
                customEvent = document.createEvent('Event');
                customEvent.initEvent(type, false, true);
            }

            getWatcher(xhr).dispatchEvent(customEvent);
            }

            function parseHeaders(headerString) {
            return headerString.split('\r\n').reduce(function(headers, line) {
                if (line === '') return headers;

                var parts = line.split(':');
                var key = parts.shift();
                var value = trim(parts.join(':'));
                headers[key] = value;
                return headers;
            }, {});
            }

            // Handler类实现
            var PROTO = 'prototype';

            // 基础Handler
            function Handler(xhr) {
            this.xhr = xhr;
            this.xhrProxy = xhr.getProxy();
            }

            Handler[PROTO] = Object.create({
            resolve: function(response) {
                var xhrProxy = this.xhrProxy;
                var xhr = this.xhr;

                xhrProxy.readyState = 4;
                xhr.resHeader = response.headers;
                xhrProxy.response = xhrProxy.responseText = response.response;
                xhrProxy.statusText = response.statusText;
                xhrProxy.status = response.status;

                triggerEvent(xhr, 'readystatechange');
                triggerEvent(xhr, 'load');
                triggerEvent(xhr, 'loadend');
            },
            reject: function(error) {
                this.xhrProxy.status = 0;
                triggerEvent(this.xhr, error.type);
                triggerEvent(this.xhr, 'loadend');
            }
            });

            // 创建链式Handler工厂
            function createHandler(nextHandler) {
            function ChainedHandler(xhr) {
                Handler.call(this, xhr);
            }

            ChainedHandler[PROTO] = Object.create(Handler[PROTO]);
            ChainedHandler[PROTO].next = nextHandler;

            return ChainedHandler;
            }

            // 具体的Handler实现
            var RequestHandler = createHandler(function(config) {
            var xhr = this.xhr;
            config = config || xhr.config;

            xhr.withCredentials = config.withCredentials;
            xhr.open(config.method, config.url, config.async !== false, config.user, config.password);

            for (var key in config.headers) {
                xhr.setRequestHeader(key, config.headers[key]);
            }

            xhr.send(config.body);
            });

            var ResponseHandler = createHandler(function(response) {
            this.resolve(response);
            });

            var ErrorHandler = createHandler(function(error) {
            this.reject(error);
            });

            // 导出API
            return {
            ah: {
                proxy: proxy,
                unProxy: unProxy,
                hook: hook,
                unHook: unHook
            }
            };
        })());
            let localeLang = document.documentElement.lang || navigator.language || 'en' // follow the language used in YouTube Page
                // localeLang = 'zh'  // uncomment this line to define the language you wish here
            ah.proxy({
                onRequest: (config, handler) => {
                    handler.next(config);
                },
                onResponse: (response, handler) => {
                    if (response.config.url.includes('/api/timedtext') && !response.config.url.includes('&translate_h00ked')) {
                        let xhr = new XMLHttpRequest();
                        // Use RegExp to clean '&tlang=...' in our xhr request params while using Y2B auto translate.
                        let url = response.config.url
                        url = url.replace(/(^|[&?])tlang=[^&]*/g, '')
                        url = `${url}&tlang=${localeLang}&translate_h00ked`
                        xhr.open('GET', url, false);
                        xhr.send();
                        let defaultJson = null
                        if (response.response) {
                            const jsonResponse = JSON.parse(response.response)
                            if (jsonResponse.events) defaultJson = jsonResponse
                        }
                        const localeJson = JSON.parse(xhr.response)
                        let isOfficialSub = true;
                        for (const defaultJsonEvent of defaultJson.events) {
                            if (defaultJsonEvent.segs && defaultJsonEvent.segs.length > 1) {
                                isOfficialSub = false;
                                break;
                            }
                        }
                        // Merge default subs with locale language subs
                        if (isOfficialSub) {
                            // when length of segments are the same
                            for (let i = 0, len = defaultJson.events.length; i < len; i++) {
                                const defaultJsonEvent = defaultJson.events[i]
                                if (!defaultJsonEvent.segs) continue
                                const localeJsonEvent = localeJson.events[i]
                                if (`${defaultJsonEvent.segs[0].utf8}`.trim() !== `${localeJsonEvent.segs[0].utf8}`.trim()) {
                                    // avoid merge subs while the are the same
                                    defaultJsonEvent.segs[0].utf8 += ('\n' + localeJsonEvent.segs[0].utf8)
                                }
                            }

                        } else {
                            // when length of segments are not the same (e.g. automatic generated english subs)
                            let pureLocalEvents = localeJson.events.filter(event => event.aAppend !== 1 && event.segs)
                            for (const defaultJsonEvent of defaultJson.events) {
                                if (!defaultJsonEvent.segs) continue
                                let currentStart = defaultJsonEvent.tStartMs,
                                    currentEnd = currentStart + defaultJsonEvent.dDurationMs
                                let currentLocalEvents = pureLocalEvents.filter(pe => currentStart <= pe.tStartMs && pe.tStartMs < currentEnd)
                                let localLine = ''
                                for (const ev of currentLocalEvents) {
                                    for (const seg of ev.segs) {
                                        localLine += seg.utf8
                                    }
                                    localLine += ''; // add ZWSP to avoid words stick together
                                }
                                let defaultLine = ''
                                for (const seg of defaultJsonEvent.segs) {
                                    defaultLine += seg.utf8
                                }
                                defaultJsonEvent.segs[0].utf8 = defaultLine + '\n' + localLine
                                defaultJsonEvent.segs = [defaultJsonEvent.segs[0]]
                            }

                        }
                        ytSubtitleData = defaultJson;
                        response.response = JSON.stringify(defaultJson);
                    }
                    handler.resolve(response)
                }
            })
    }
    window.addEventListener('yt-navigate-finish', hookit)
    window.addEventListener('load',function(){
        if(this.window.location.href.includes('watch?v=')){
            this.setTimeout(addDownloadButton, 1000)
        }
    }
    )
    function addDownloadButton() {
        // 检查按钮是否已存在
        if (document.getElementById('download-subtitle-btn')) return;

        // 创建按钮元素
        const downloadBtn = document.createElement('button');
        downloadBtn.id = 'download-subtitle-btn';
        downloadBtn.innerText = '下载字幕';
        downloadBtn.style.cssText = `
            background-color: orange;
            color: white;
            border: none;
            border-radius: 3px;
            padding: 5px 10px;
            margin: 5px;
            cursor: pointer;
            font-weight: bold;
        `;

        downloadBtn.addEventListener('click', menu);

        // 将按钮添加到up主信息旁边
        const ownerElement = document.querySelector('#owner');
        if (ownerElement) {
            const customBtn = document.createElement('div');
            customBtn.style.cssText = 'display: inline-block; margin-right: 10px;';
            customBtn.appendChild(downloadBtn);
            ownerElement.appendChild(customBtn, ownerElement.firstChild);
        }
        else {
            // 备选方案,添加到视频上方
            const videoContainer = document.querySelector('.html5-video-container');
            if (videoContainer) {
                const btnContainer = document.createElement('div');
                btnContainer.style.cssText = 'position: absolute; top: 10px; left: 10px; z-index: 1000;';
                btnContainer.appendChild(downloadBtn);
                videoContainer.parentNode.insertBefore(btnContainer, videoContainer);
            }
        }
    }

    function menu() {
        // 获取当前视频标题
        const videoTitle = document.querySelector('h1.ytd-watch-metadata')?.textContent?.trim() || 'youtube_subtitle';

        if (ytSubtitleData && ytSubtitleData.events) {
            // 转换为SRT格式
            const srtContent = convertToSRT(ytSubtitleData.events);

            // 创建下载链接
            downloadSubtitle(srtContent, `${videoTitle}.srt`);
        } else {
            alert('请重启视频字幕,然后再尝试下载');
        }
    }

    // 将YouTube字幕数据转换为SRT格式
    function convertToSRT(events) {
        let srtContent = '';
        let index = 1;

        for (const event of events) {
            // 只处理有文字内容的事件
            if (!event.segs || event.segs.length === 0) continue;

            // 获取开始和结束时间
            const startMs = event.tStartMs;
            const endMs = startMs + event.dDurationMs;

            // 获取文本内容
            let text = '';
            for (const seg of event.segs) {
                if (seg.utf8) text += seg.utf8;
            }

            // 跳过空白字幕
            if (!text.trim()) continue;

            // 添加到SRT内容
            srtContent += `${index}\n`;
            srtContent += `${formatTime(startMs)} --> ${formatTime(endMs)}\n`;
            srtContent += `${text}\n\n`;

            index++;
        }

        return srtContent;
    }

    // 格式化毫秒时间为SRT时间格式 (00:00:00,000)
    function formatTime(ms) {
        const totalSeconds = Math.floor(ms / 1000);
        const hours = Math.floor(totalSeconds / 3600);
        const minutes = Math.floor((totalSeconds % 3600) / 60);
        const seconds = totalSeconds % 60;
        const milliseconds = Math.floor(ms % 1000);

        return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)},${padZero(milliseconds, 3)}`;
    }

    // 数字前补零
    function padZero(num, length = 2) {
        return num.toString().padStart(length, '0');
    }

    // 下载字幕文件
    function downloadSubtitle(content, filename) {
        const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
        const url = URL.createObjectURL(blob);

        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        a.style.display = 'none';

        document.body.appendChild(a);
        a.click();

        setTimeout(() => {
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        }, 100);
    }
})();