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