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

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

  1. // ==UserScript==
  2. // @name YouTube 双语字幕下载 / YouTube Biligual Subtitles Downloader
  3. // @namespace https://github.com/Nehemiab/YouTube-Biligual-Subtitles-Downloader
  4. // @version 1.0
  5. // @description 在 YouTube 页面添加双语字幕,支持双语字幕下载
  6. // @author NEHEMIAB
  7. // @match *://www.youtube.com/watch?v=*
  8. // @match *://www.youtube.com
  9. // @match *://www.youtube.com/*
  10. // @grant none
  11. // @run-at document-start
  12. // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
  13. // @run-at document-end
  14. // @copyright 2025,NEHEMIAB(https://github.com/Nehemiab/YouTube-Biligual-Subtitles-Downloader)
  15. // @license MIT
  16. // @thanks Coink,Claude
  17. // ==/UserScript==
  18.  
  19. (function() {
  20. // 全局变量储存处理后的字幕数据
  21. let ytSubtitleData = null;
  22.  
  23. function hookit(){
  24. (function(global, factory) {
  25. // 将工厂函数的所有导出赋给全局对象
  26. for (var key in factory) {
  27. global[key] = factory[key];
  28. }
  29. })(window, (function() {
  30. 'use strict';
  31.  
  32. // 支持的XHR事件类型
  33. var events = ['load', 'loadend', 'timeout', 'error', 'readystatechange', 'abort'];
  34. var XHR_PROXY_KEY = '__xhr';
  35.  
  36. /**
  37. * 配置事件对象
  38. * @param {Event} event - 原始事件对象
  39. * @param {Object} target - 事件的目标对象
  40. * @return {Event} 配置好的事件对象
  41. */
  42. function configEvent(event, target) {
  43. var eventCopy = {};
  44. for (var key in event) {
  45. eventCopy[key] = event[key];
  46. }
  47. eventCopy.target = eventCopy.currentTarget = target;
  48. return eventCopy;
  49. }
  50.  
  51. /**
  52. * 钩住XMLHttpRequest
  53. * @param {Object} hooks - 包含各种钩子的对象
  54. * @param {Window} win - window对象,默认为全局window
  55. * @return {Function} 修改后的XMLHttpRequest构造函数
  56. */
  57. function hook(hooks, win) {
  58. win = win || window;
  59.  
  60. // 保存原始的XMLHttpRequest
  61. win[XHR_PROXY_KEY] = win[XHR_PROXY_KEY] || win.XMLHttpRequest;
  62.  
  63. // 创建新的XMLHttpRequest构造函数
  64. win.XMLHttpRequest = function() {
  65. var xhr = new win[XHR_PROXY_KEY]();
  66.  
  67. // 确保所有事件处理程序属性存在
  68. for (var i = 0; i < events.length; ++i) {
  69. var eventName = 'on' + events[i];
  70. if (xhr[eventName] === undefined) {
  71. xhr[eventName] = null;
  72. }
  73. }
  74.  
  75. // 为每个属性和方法创建代理
  76. for (var prop in xhr) {
  77. var type = '';
  78. try {
  79. type = typeof xhr[prop];
  80. } catch(e) { }
  81.  
  82. if (type === 'function') {
  83. // 代理方法
  84. this[prop] = createMethodProxy(prop);
  85. } else {
  86. // 代理属性
  87. Object.defineProperty(this, prop, {
  88. get: createGetter(prop),
  89. set: createSetter(prop),
  90. enumerable: true
  91. });
  92. }
  93. }
  94.  
  95. var self = this;
  96. xhr.getProxy = function() { return self; };
  97. this.xhr = xhr;
  98. };
  99.  
  100. // 复制XMLHttpRequest的静态属性
  101. Object.assign(win.XMLHttpRequest, {
  102. UNSENT: 0,
  103. OPENED: 1,
  104. HEADERS_RECEIVED: 2,
  105. LOADING: 3,
  106. DONE: 4
  107. });
  108.  
  109. /**
  110. * 创建属性getter代理
  111. */
  112. function createGetter(prop) {
  113. return function() {
  114. var value = this.hasOwnProperty(prop + '_') ?
  115. this[prop + '_'] : this.xhr[prop];
  116. var getter = (hooks[prop] || {}).getter;
  117. return getter && getter(value, this) || value;
  118. };
  119. }
  120.  
  121. /**
  122. * 创建属性setter代理
  123. */
  124. function createSetter(prop) {
  125. return function(value) {
  126. var xhr = this.xhr;
  127. var self = this;
  128. var hook = hooks[prop];
  129.  
  130. if (prop.substring(0, 2) === 'on') {
  131. // 事件处理程序
  132. self[prop + '_'] = value;
  133. xhr[prop] = function(e) {
  134. e = configEvent(e, self);
  135. if (hook && hook.call(self, xhr, e)) {
  136. return;
  137. }
  138. value.call(self, e);
  139. };
  140. } else {
  141. // 常规属性
  142. var setter = (hook || {}).setter;
  143. value = setter && setter(value, self) || value;
  144. this[prop + '_'] = value;
  145. try {
  146. xhr[prop] = value;
  147. } catch(e) { }
  148. }
  149. };
  150. }
  151.  
  152. /**
  153. * 创建方法代理
  154. */
  155. function createMethodProxy(method) {
  156. return function() {
  157. var args = [].slice.call(arguments);
  158. var hook = hooks[method];
  159.  
  160. if (hook) {
  161. var result = hook.call(this, args, this.xhr);
  162. if (result) {
  163. return result;
  164. }
  165. }
  166.  
  167. return this.xhr[method].apply(this.xhr, args);
  168. };
  169. }
  170.  
  171. return win.XMLHttpRequest;
  172. }
  173.  
  174. /**
  175. * 解除钩子
  176. * @param {Window} win - window对象,默认为全局window
  177. */
  178. function unHook(win) {
  179. win = win || window;
  180. if (win[XHR_PROXY_KEY]) {
  181. win.XMLHttpRequest = win[XHR_PROXY_KEY];
  182. }
  183. win[XHR_PROXY_KEY] = undefined;
  184. }
  185.  
  186. /**
  187. * 代理XHR请求
  188. */
  189. function proxy(options, win) {
  190. if (win = win || window, win.__xhr) {
  191. throw "Ajax is already hooked.";
  192. }
  193. return proxyXhr(options, win);
  194. }
  195.  
  196. /**
  197. * 解除代理
  198. */
  199. function unProxy(win) {
  200. unHook(win);
  201. }
  202.  
  203. /**
  204. * 创建XHR代理
  205. */
  206. function proxyXhr(options, win) {
  207. var onRequest = options.onRequest;
  208. var onResponse = options.onResponse;
  209. var onError = options.onError;
  210.  
  211. return hook({
  212. // 处理事件
  213. onload: returnTrue,
  214. onloadend: returnTrue,
  215. onerror: createErrorHandler('error'),
  216. ontimeout: createErrorHandler('timeout'),
  217. onabort: createErrorHandler('abort'),
  218.  
  219. onreadystatechange: function(xhr) {
  220. if (xhr.readyState === 4 && xhr.status !== 0) {
  221. handleResponse(xhr, this);
  222. } else if (xhr.readyState !== 4) {
  223. triggerEvent(xhr, 'readystatechange');
  224. }
  225. return true;
  226. },
  227.  
  228. // 处理方法
  229. open: function(args, xhr) {
  230. var self = this;
  231. var config = xhr.config = { headers: {} };
  232.  
  233. config.method = args[0];
  234. config.url = args[1];
  235. config.async = args[2];
  236. config.user = args[3];
  237. config.password = args[4];
  238. config.xhr = xhr;
  239.  
  240. var eventName = 'onreadystatechange';
  241. if (!xhr[eventName]) {
  242. xhr[eventName] = function() {
  243. return handleReadyStateChange(xhr, self);
  244. };
  245. }
  246.  
  247. if (onRequest) return true;
  248. },
  249.  
  250. send: function(args, xhr) {
  251. var config = xhr.config;
  252. config.withCredentials = xhr.withCredentials;
  253. config.body = args[0];
  254.  
  255. if (onRequest) {
  256. var callback = function() {
  257. onRequest(config, new RequestHandler(xhr));
  258. };
  259.  
  260. if (config.async === false) {
  261. callback();
  262. } else {
  263. setTimeout(callback);
  264. }
  265. return true;
  266. }
  267. },
  268.  
  269. setRequestHeader: function(args, xhr) {
  270. xhr.config.headers[args[0].toLowerCase()] = args[1];
  271. if (onRequest) return true;
  272. },
  273.  
  274. addEventListener: function(args, xhr) {
  275. var self = this;
  276. if (events.indexOf(args[0]) !== -1) {
  277. var listener = args[1];
  278.  
  279. getWatcher(xhr).addEventListener(args[0], function(e) {
  280. var event = configEvent(e, self);
  281. event.type = args[0];
  282. event.isTrusted = true;
  283. listener.call(self, event);
  284. });
  285.  
  286. return true;
  287. }
  288. },
  289.  
  290. getAllResponseHeaders: function(args, xhr) {
  291. var headers = xhr.resHeader;
  292. if (headers) {
  293. var result = '';
  294. for (var key in headers) {
  295. result += key + ': ' + headers[key] + '\r\n';
  296. }
  297. return result;
  298. }
  299. },
  300.  
  301. getResponseHeader: function(args, xhr) {
  302. var headers = xhr.resHeader;
  303. if (headers) {
  304. return headers[(args[0] || '').toLowerCase()];
  305. }
  306. }
  307. }, win);
  308.  
  309. /**
  310. * 处理响应
  311. */
  312. function handleResponse(xhr, xhrProxy) {
  313. var response = {
  314. response: xhrProxy.response || xhrProxy.responseText,
  315. status: xhrProxy.status,
  316. statusText: xhrProxy.statusText,
  317. config: xhr.config,
  318. headers: xhr.resHeader || parseHeaders(xhrProxy.getAllResponseHeaders())
  319. };
  320.  
  321. if (!onResponse) {
  322. new ResponseHandler(xhr).resolve(response);
  323. return;
  324. }
  325.  
  326. onResponse(response, new ResponseHandler(xhr));
  327. }
  328.  
  329. /**
  330. * 处理错误
  331. */
  332. function createErrorHandler(type) {
  333. return function(xhr, e) {
  334. handleError(xhr, this, e, type);
  335. return true;
  336. };
  337. }
  338.  
  339. function handleError(xhr, xhrProxy, error, type) {
  340. var errorObject = {
  341. config: xhr.config,
  342. error: error,
  343. type: type
  344. };
  345.  
  346. var handler = new ErrorHandler(xhr);
  347.  
  348. if (onError) {
  349. onError(errorObject, handler);
  350. } else {
  351. handler.next(errorObject);
  352. }
  353. }
  354.  
  355. function handleReadyStateChange(xhr, xhrProxy) {
  356. return xhr.readyState === 4 && xhr.status !== 0 ?
  357. handleResponse(xhr, xhrProxy) :
  358. xhr.readyState !== 4 && triggerEvent(xhr, 'readystatechange');
  359. }
  360.  
  361. function returnTrue() {
  362. return true;
  363. }
  364. }
  365.  
  366. // 辅助函数
  367. function trim(str) {
  368. return str.replace(/^\s+|\s+$/g, '');
  369. }
  370.  
  371. function getWatcher(xhr) {
  372. return xhr.watcher || (xhr.watcher = document.createElement('a'));
  373. }
  374.  
  375. function triggerEvent(xhr, type) {
  376. var xhrProxy = xhr.getProxy();
  377. var eventKey = 'on' + type + '_';
  378. var event = configEvent({ type: type }, xhrProxy);
  379.  
  380. if (xhrProxy[eventKey]) {
  381. xhrProxy[eventKey](event);
  382. }
  383.  
  384. var customEvent;
  385. if (typeof Event === 'function') {
  386. customEvent = new Event(type, { bubbles: false });
  387. } else {
  388. customEvent = document.createEvent('Event');
  389. customEvent.initEvent(type, false, true);
  390. }
  391.  
  392. getWatcher(xhr).dispatchEvent(customEvent);
  393. }
  394.  
  395. function parseHeaders(headerString) {
  396. return headerString.split('\r\n').reduce(function(headers, line) {
  397. if (line === '') return headers;
  398.  
  399. var parts = line.split(':');
  400. var key = parts.shift();
  401. var value = trim(parts.join(':'));
  402. headers[key] = value;
  403. return headers;
  404. }, {});
  405. }
  406.  
  407. // Handler类实现
  408. var PROTO = 'prototype';
  409.  
  410. // 基础Handler
  411. function Handler(xhr) {
  412. this.xhr = xhr;
  413. this.xhrProxy = xhr.getProxy();
  414. }
  415.  
  416. Handler[PROTO] = Object.create({
  417. resolve: function(response) {
  418. var xhrProxy = this.xhrProxy;
  419. var xhr = this.xhr;
  420.  
  421. xhrProxy.readyState = 4;
  422. xhr.resHeader = response.headers;
  423. xhrProxy.response = xhrProxy.responseText = response.response;
  424. xhrProxy.statusText = response.statusText;
  425. xhrProxy.status = response.status;
  426.  
  427. triggerEvent(xhr, 'readystatechange');
  428. triggerEvent(xhr, 'load');
  429. triggerEvent(xhr, 'loadend');
  430. },
  431. reject: function(error) {
  432. this.xhrProxy.status = 0;
  433. triggerEvent(this.xhr, error.type);
  434. triggerEvent(this.xhr, 'loadend');
  435. }
  436. });
  437.  
  438. // 创建链式Handler工厂
  439. function createHandler(nextHandler) {
  440. function ChainedHandler(xhr) {
  441. Handler.call(this, xhr);
  442. }
  443.  
  444. ChainedHandler[PROTO] = Object.create(Handler[PROTO]);
  445. ChainedHandler[PROTO].next = nextHandler;
  446.  
  447. return ChainedHandler;
  448. }
  449.  
  450. // 具体的Handler实现
  451. var RequestHandler = createHandler(function(config) {
  452. var xhr = this.xhr;
  453. config = config || xhr.config;
  454.  
  455. xhr.withCredentials = config.withCredentials;
  456. xhr.open(config.method, config.url, config.async !== false, config.user, config.password);
  457.  
  458. for (var key in config.headers) {
  459. xhr.setRequestHeader(key, config.headers[key]);
  460. }
  461.  
  462. xhr.send(config.body);
  463. });
  464.  
  465. var ResponseHandler = createHandler(function(response) {
  466. this.resolve(response);
  467. });
  468.  
  469. var ErrorHandler = createHandler(function(error) {
  470. this.reject(error);
  471. });
  472.  
  473. // 导出API
  474. return {
  475. ah: {
  476. proxy: proxy,
  477. unProxy: unProxy,
  478. hook: hook,
  479. unHook: unHook
  480. }
  481. };
  482. })());
  483. let localeLang = document.documentElement.lang || navigator.language || 'en' // follow the language used in YouTube Page
  484. // localeLang = 'zh' // uncomment this line to define the language you wish here
  485. ah.proxy({
  486. onRequest: (config, handler) => {
  487. handler.next(config);
  488. },
  489. onResponse: (response, handler) => {
  490. if (response.config.url.includes('/api/timedtext') && !response.config.url.includes('&translate_h00ked')) {
  491. let xhr = new XMLHttpRequest();
  492. // Use RegExp to clean '&tlang=...' in our xhr request params while using Y2B auto translate.
  493. let url = response.config.url
  494. url = url.replace(/(^|[&?])tlang=[^&]*/g, '')
  495. url = `${url}&tlang=${localeLang}&translate_h00ked`
  496. xhr.open('GET', url, false);
  497. xhr.send();
  498. let defaultJson = null
  499. if (response.response) {
  500. const jsonResponse = JSON.parse(response.response)
  501. if (jsonResponse.events) defaultJson = jsonResponse
  502. }
  503. const localeJson = JSON.parse(xhr.response)
  504. let isOfficialSub = true;
  505. for (const defaultJsonEvent of defaultJson.events) {
  506. if (defaultJsonEvent.segs && defaultJsonEvent.segs.length > 1) {
  507. isOfficialSub = false;
  508. break;
  509. }
  510. }
  511. // Merge default subs with locale language subs
  512. if (isOfficialSub) {
  513. // when length of segments are the same
  514. for (let i = 0, len = defaultJson.events.length; i < len; i++) {
  515. const defaultJsonEvent = defaultJson.events[i]
  516. if (!defaultJsonEvent.segs) continue
  517. const localeJsonEvent = localeJson.events[i]
  518. if (`${defaultJsonEvent.segs[0].utf8}`.trim() !== `${localeJsonEvent.segs[0].utf8}`.trim()) {
  519. // avoid merge subs while the are the same
  520. defaultJsonEvent.segs[0].utf8 += ('\n' + localeJsonEvent.segs[0].utf8)
  521. }
  522. }
  523.  
  524. } else {
  525. // when length of segments are not the same (e.g. automatic generated english subs)
  526. let pureLocalEvents = localeJson.events.filter(event => event.aAppend !== 1 && event.segs)
  527. for (const defaultJsonEvent of defaultJson.events) {
  528. if (!defaultJsonEvent.segs) continue
  529. let currentStart = defaultJsonEvent.tStartMs,
  530. currentEnd = currentStart + defaultJsonEvent.dDurationMs
  531. let currentLocalEvents = pureLocalEvents.filter(pe => currentStart <= pe.tStartMs && pe.tStartMs < currentEnd)
  532. let localLine = ''
  533. for (const ev of currentLocalEvents) {
  534. for (const seg of ev.segs) {
  535. localLine += seg.utf8
  536. }
  537. localLine += ''; // add ZWSP to avoid words stick together
  538. }
  539. let defaultLine = ''
  540. for (const seg of defaultJsonEvent.segs) {
  541. defaultLine += seg.utf8
  542. }
  543. defaultJsonEvent.segs[0].utf8 = defaultLine + '\n' + localLine
  544. defaultJsonEvent.segs = [defaultJsonEvent.segs[0]]
  545. }
  546.  
  547. }
  548. ytSubtitleData = defaultJson;
  549. response.response = JSON.stringify(defaultJson);
  550. }
  551. handler.resolve(response)
  552. }
  553. })
  554. }
  555. window.addEventListener('yt-navigate-finish', hookit)
  556. window.addEventListener('load',function(){
  557. if(this.window.location.href.includes('watch?v=')){
  558. this.setTimeout(addDownloadButton, 1000)
  559. }
  560. }
  561. )
  562. function addDownloadButton() {
  563. // 检查按钮是否已存在
  564. if (document.getElementById('download-subtitle-btn')) return;
  565.  
  566. // 创建按钮元素
  567. const downloadBtn = document.createElement('button');
  568. downloadBtn.id = 'download-subtitle-btn';
  569. downloadBtn.innerText = '下载字幕';
  570. downloadBtn.style.cssText = `
  571. background-color: orange;
  572. color: white;
  573. border: none;
  574. border-radius: 3px;
  575. padding: 5px 10px;
  576. margin: 5px;
  577. cursor: pointer;
  578. font-weight: bold;
  579. `;
  580.  
  581. downloadBtn.addEventListener('click', menu);
  582.  
  583. // 将按钮添加到up主信息旁边
  584. const ownerElement = document.querySelector('#owner');
  585. if (ownerElement) {
  586. const customBtn = document.createElement('div');
  587. customBtn.style.cssText = 'display: inline-block; margin-right: 10px;';
  588. customBtn.appendChild(downloadBtn);
  589. ownerElement.appendChild(customBtn, ownerElement.firstChild);
  590. }
  591. else {
  592. // 备选方案,添加到视频上方
  593. const videoContainer = document.querySelector('.html5-video-container');
  594. if (videoContainer) {
  595. const btnContainer = document.createElement('div');
  596. btnContainer.style.cssText = 'position: absolute; top: 10px; left: 10px; z-index: 1000;';
  597. btnContainer.appendChild(downloadBtn);
  598. videoContainer.parentNode.insertBefore(btnContainer, videoContainer);
  599. }
  600. }
  601. }
  602.  
  603. function menu() {
  604. // 获取当前视频标题
  605. const videoTitle = document.querySelector('h1.ytd-watch-metadata')?.textContent?.trim() || 'youtube_subtitle';
  606.  
  607. if (ytSubtitleData && ytSubtitleData.events) {
  608. // 转换为SRT格式
  609. const srtContent = convertToSRT(ytSubtitleData.events);
  610.  
  611. // 创建下载链接
  612. downloadSubtitle(srtContent, `${videoTitle}.srt`);
  613. } else {
  614. alert('请重启视频字幕,然后再尝试下载');
  615. }
  616. }
  617.  
  618. // 将YouTube字幕数据转换为SRT格式
  619. function convertToSRT(events) {
  620. let srtContent = '';
  621. let index = 1;
  622.  
  623. for (const event of events) {
  624. // 只处理有文字内容的事件
  625. if (!event.segs || event.segs.length === 0) continue;
  626.  
  627. // 获取开始和结束时间
  628. const startMs = event.tStartMs;
  629. const endMs = startMs + event.dDurationMs;
  630.  
  631. // 获取文本内容
  632. let text = '';
  633. for (const seg of event.segs) {
  634. if (seg.utf8) text += seg.utf8;
  635. }
  636.  
  637. // 跳过空白字幕
  638. if (!text.trim()) continue;
  639.  
  640. // 添加到SRT内容
  641. srtContent += `${index}\n`;
  642. srtContent += `${formatTime(startMs)} --> ${formatTime(endMs)}\n`;
  643. srtContent += `${text}\n\n`;
  644.  
  645. index++;
  646. }
  647.  
  648. return srtContent;
  649. }
  650.  
  651. // 格式化毫秒时间为SRT时间格式 (00:00:00,000)
  652. function formatTime(ms) {
  653. const totalSeconds = Math.floor(ms / 1000);
  654. const hours = Math.floor(totalSeconds / 3600);
  655. const minutes = Math.floor((totalSeconds % 3600) / 60);
  656. const seconds = totalSeconds % 60;
  657. const milliseconds = Math.floor(ms % 1000);
  658.  
  659. return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)},${padZero(milliseconds, 3)}`;
  660. }
  661.  
  662. // 数字前补零
  663. function padZero(num, length = 2) {
  664. return num.toString().padStart(length, '0');
  665. }
  666.  
  667. // 下载字幕文件
  668. function downloadSubtitle(content, filename) {
  669. const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
  670. const url = URL.createObjectURL(blob);
  671.  
  672. const a = document.createElement('a');
  673. a.href = url;
  674. a.download = filename;
  675. a.style.display = 'none';
  676.  
  677. document.body.appendChild(a);
  678. a.click();
  679.  
  680. setTimeout(() => {
  681. document.body.removeChild(a);
  682. URL.revokeObjectURL(url);
  683. }, 100);
  684. }
  685. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址