Youtube Bilingual Subtitles Download

YouTube bilingual subtitles with download button.

目前為 2024-04-08 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Youtube Bilingual Subtitles Download
// @name:zh-CN   Youtube 双语字幕下载
// @namespace    https://github.com/FLZeng/Y2B_Biling_Subs_DL
// @version      2024-04-08
// @description  YouTube bilingual subtitles with download button.
// @description:zh-CN   Youtube 双语字幕下载。
// @author       FLZeng
// @match        *://www.youtube.com/*
// @match        *://m.youtube.com/*
// @require      https://unpkg.com/ajax-hook@latest/dist/ajaxhook.min.js
// @run-at       document-start
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @license      MIT
// @grant        none
// ==/UserScript==


// 计算字幕时间
function CalcTime(time) {
  var second = Math.floor(time / 1000);
  var minute = Math.floor(second / 60);
  var hour = Math.floor(minute / 60);
  minute = Math.floor(minute - hour * 60);
  second = Math.floor(second - minute * 60 - hour * 60 * 60);
  var ms = Math.floor(time - (second + minute * 60 + hour * 60 * 60));

  hour = '00' + hour;
  minute = '00' + minute;
  second = '00' + second;
  ms = ms + '000';

  hour = hour.substr(hour.length - 2, hour.length);
  minute = minute.substr(minute.length - 2, minute.length);
  second = second.substr(second.length - 2, second.length);
  ms = ms.substr(0, 3);

  return [hour, minute, second].join(':') + '.' + ms;
}

// 生成字幕对象列表
function GenerateSubsList(events) {
  var subs_list = [];

  function AppendText(startMs, durationMs, text) {
    var last_item = null;

    if (subs_list.length > 0) {
      var index = subs_list.length - 1;
      last_item = subs_list[index];
      if (last_item.start === startMs && last_item.end === startMs + durationMs) {
        if (!(last_item.text === '\n' && text === '\n')) {
          last_item.text += text;
        }
        subs_list[index] = last_item;
        return;
      }
    }

    last_item = {
      start: startMs,
      end: startMs + durationMs,
      text: text
    };
    
    subs_list.push(last_item);
  }

  var rbRubys = new Map();
  for (let i = 0; i < events.length; i++) {
    let event = events[i];
    var pParentId = event.rbRuby;
    if (pParentId !== 10) {
      pParentId = 10 < pParentId ? pParentId - 1 : pParentId;
      rbRubys.set(i, { rf: pParentId });
    }
  }

  for (let i = 0; i < events.length; i++) {
    let event = events[i];
    var startMs = event.tStartMs;
    var durationMs = event.dDurationMs;
    if (event.id) {
      continue;
    }
    if (event.dDurationMs === 0) {
      event.dDurationMs = 5e3;
    }
    var segs = event.segs;
    for (let l = 0; l < segs.length; l++) {
      var seg = segs[l];
      if (seg.utf8) {
        if (seg.utf8 === '\n' || seg.utf8 === '\n\n') {
          AppendText(0, 0, '\n');
          continue;
        }
        var pPenId = seg.pPenId;
        seg = null;
        if (rbRubys.get(pPenId) && rbRubys.get(pPenId).rf === 1) {
          if (l + 3 >= segs.length || !segs[l + 1].pPenId || !segs[l + 2].pPenId || !segs[l + 3].pPenId) {
            seg = false;
          } else {
            let pPenId = segs[l + 1].pPenId;
            (pPenId = rbRubys.get(pPenId)) && pPenId && 2 === pPenId.rf ? (pPenId = segs[l + 2].pPenId,
              pPenId = rbRubys.get(pPenId),
              !pPenId || !pPenId.rf || 3 > pPenId.rf ? seg = !1 : (pPenId = segs[l + 3].pPenId,
                seg = (pPenId = rbRubys.get(pPenId)) && pPenId.rf && 2 === pPenId.rf ? !0 : !1)) : seg = !1
          }
        }
        if (seg) {
          AppendText(startMs, startMs + durationMs, [segs[l + 1].utf8, segs[l + 2].utf8, segs[l + 3].utf8].join(' '));
        } else {
          AppendText(startMs, startMs + durationMs, segs[l].utf8);
        }
      }
    }
  }

  function GetNext(k) {
    for (var i = k + 1; i < subs_list.length; i++) {
      if (subs_list[i].start !== 0 && subs_list[i].end !== 0 && subs_list[i].text !== '\n') {
        return subs_list[i];
      }
    }
    return undefined;
  }

  var start = 0;
  var end = 0;
  for (var i = 0; i < subs_list.length; i++) {
    if (subs_list[i].start === 0 && subs_list[i].end === 0 && subs_list[i].text === '\n') {
      continue;
    }
    if (start === 0 && subs_list[i].start !== 0) {
      start = subs_list[i].start;
    }
    if (end === 0 && subs_list[i].end !== 0) {
      end = subs_list[i].end;
    }
    if (GetNext(i)) {
      subs_list[i].end = GetNext(i).start - 1;
    } else {
      subs_list[i].end = end;
    }
    start = subs_list[i].start;
    end = subs_list[i].end;
  }

  return subs_list;
}

// 生成字幕文本
function GenerateSRTString(subs_list) {
  var res = [];
  var arr = (subs_list);
  for (var i = 0; i < arr.length; i++) {
    if (arr[i].start === 0 && arr[i].end === 0 && arr[i].text === '\n') {
      continue;
    }
    res.push((i + 1).toString());
    res.push([CalcTime(parseInt(arr[i].start)), ' --> ', CalcTime(parseInt(arr[i].end))].join(''));
    res.push(arr[i].text);
    res.push('');
  }
  return res.join('\n');
}

// 保存字幕按钮
function GenerateSaveSubButton(events, lang) {
  if (document.getElementById('btn_save_subs_' + lang)) {
    return;
  }

  const lang_dict = {'en': '英文', 'zh': '中文', 'biling': '双语'};

  var subs_list = GenerateSubsList(events);
  var srtString = GenerateSRTString(subs_list);
  var fileContent = 'data:text/plain;charset=utf-8,' + encodeURIComponent(srtString);
  var fileName = '[' + lang_dict[lang] + ']' + window.ytInitialPlayerResponse.videoDetails.title + '.srt';

  // 生成保存字幕按钮
  var saveSubLink = document.createElement('a');
  saveSubLink.id = 'btn_save_subs_' + lang;
  saveSubLink.innerText = lang_dict[lang];
  saveSubLink.setAttribute('href', fileContent);
  saveSubLink.setAttribute('download', fileName);
  saveSubLink.style = 'display: inline-block; margin: 8px 8px 8px 0px; padding: 4px 12px; border-radius: 8px; font-size: 1.4rem; line-height: 2rem; cursor: pointer; text-decoration: none; background-color: rgba(0, 0, 0, 0.05);';

  var p = document.createElement('p');
  p.innerText = '字幕下载';
  p.style = 'display: inline-block; margin: 8px 10px; font-size: 1.4rem; line-height: 2rem; font-weight: bold;';

  var panel = document.getElementById('div_downlod_str');
  var secondaryPanel = document.getElementById('secondary');
  var coltrolPanel = document.querySelector('.ytp-chrome-controls .ytp-right-controls');

  if (secondaryPanel) {
    if (panel === null) {
      panel = document.createElement('div');
      panel.id = 'div_downlod_str';
      panel.style.marginBottom = '20px';
      panel.appendChild(p);
      secondaryPanel.prepend(panel);
    }
    saveSubLink.style.color = 'rgb(15, 15, 15)';
    panel.appendChild(saveSubLink);
  } else if (coltrolPanel) {
    if (panel === null) {
      panel = document.createElement('div');
      panel.id = 'div_downlod_str';
      panel.style.position = 'absolute';
      panel.style.bottom = '100%';
      panel.style.right = '0';
      panel.style.zIndex = '999';
      panel.appendChild(p);
      coltrolPanel.prepend(panel);
    }
    saveSubLink.style.backgroundColor = 'rgba(28, 28, 28, .9)';
    saveSubLink.style.fontSize = '109%';
    p.style.fontSize = '109%';
    panel.appendChild(saveSubLink);
  }
}

function FixEventDuration(events) {
  let validEvents = events.filter(event => event.aAppend !== 1 && event.segs);
  const len = validEvents.length;
  for (let i = 0; i < len; i++) {
    let event = validEvents[i];
    if (i < len - 1 && event.tStartMs + event.dDurationMs >= validEvents[i + 1].tStartMs) {
      event.dDurationMs = validEvents[i + 1].tStartMs - event.tStartMs - 1;
    }
  }
}

function HandleSubsResponse(response) {
  // 检测浏览器首选语言,如果没有,设置为英语
  let localeLang = navigator.language.split('-')[0] || 'en'; // 跟随 YouTube 页面所用语言
  // localeLang = 'zh';  // 取消注释此行以在此处定义您希望的语言

  let xhr = new XMLHttpRequest(); // 创建新的 XMLHttpRequest
  // 清除 xhr 请求参数中的 '&tlang=...'
  let url = response.config.url.replace(/(^|[&?])tlang=[^&]*/g, '');
  // 设置请求的字幕语言,并添加 '&translate_h00ked' 标志以避免无限循环
  url = `${url}&tlang=${localeLang}&translate_h00ked`;
  xhr.open('GET', url, false); // 打开 xhr 请求
  xhr.send(); // 发送 xhr 请求
  let enJson = null; // 声明英文 JSON 变量
  let zhJson = null; // 声明中文 JSON 变量
  let bilingJson = null; // 声明双语 JSON 变量
  if (response.response) {
    const enJsonResponse = JSON.parse(response.response);
    if (enJsonResponse.events) {
      FixEventDuration(enJsonResponse.events);
      bilingJson = enJsonResponse;
      enJson = JSON.parse(JSON.stringify(enJsonResponse));
    }
  }
  zhJson = JSON.parse(xhr.response); // 解析 xhr 响应
  FixEventDuration(zhJson.events);
  let isSingleSegEvent = true;
  for (const enJsonEvent of enJson.events) {
    if (enJsonEvent.segs && enJsonEvent.segs.length > 1) {
      isSingleSegEvent = false;
      break;
    }
  }
  console.log('isSingleSegEvent: ' + isSingleSegEvent);
  // 将默认字幕与本地语言字幕合并
  if (isSingleSegEvent) {
    // 如果片段长度相同
    for (let i = 0, len = bilingJson.events.length; i < len; i++) {
      const bilingJsonEvent = bilingJson.events[i];
      if (!bilingJsonEvent.segs) continue;
      const zhJsonEvent = zhJson.events[i];
      if (`${bilingJsonEvent.segs[0].utf8}`.trim() !== `${zhJsonEvent.segs[0].utf8}`.trim()) {
        // 避免在两者相同时合并字幕
        bilingJsonEvent.segs[0].utf8 += ('\n' + zhJsonEvent.segs[0].utf8);
      }
    }
    response.response = JSON.stringify(bilingJson); // 更新响应
  } else {
    // 如果片段长度不同(例如:自动生成的英语字幕)
    let pureZhEvents = zhJson.events.filter(event => event.aAppend !== 1 && event.segs);
    for (const bilingJsonEvent of bilingJson.events) {
      if (!bilingJsonEvent.segs || bilingJsonEvent.aAppend === 1) continue;
      let currentStart = bilingJsonEvent.tStartMs,
        currentEnd = currentStart + bilingJsonEvent.dDurationMs;
      let currentZhEvents = pureZhEvents.filter(pe => currentStart <= pe.tStartMs && pe.tStartMs < currentEnd);
      let zhLine = '';
      for (const zhEvent of currentZhEvents) {
        for (const seg of zhEvent.segs) {
          zhLine += seg.utf8;
        }
        zhLine += ''; // 添加零宽空格,以避免单词粘在一起
      }
      let enLine = '';
      for (const seg of bilingJsonEvent.segs) {
        enLine += seg.utf8;
      }
      if (enLine.trim() !== zhLine.trim()) {
        bilingJsonEvent.segs[0].utf8 = enLine + '\n' + zhLine;
      } else {
        bilingJsonEvent.segs[0].utf8 = enLine;
      }
      bilingJsonEvent.segs = [bilingJsonEvent.segs[0]];
    }
    response.response = JSON.stringify(bilingJson); // 更新响应
  }
  GenerateSaveSubButton(bilingJson.events, 'biling');
  GenerateSaveSubButton(zhJson.events, 'zh');
  GenerateSaveSubButton(enJson.events, 'en');

  return response;
}

// Hook 字幕请求
function AjaxHookSubs() {
  ah.proxy({
    onRequest: (config, handler) => {
      handler.next(config); // 处理下一个请求
    },
    onResponse: (response, handler) => {
      // 如果请求的 URL 包含 '/api/timedtext' 并且没有 '&translate_h00ked',则为原始的字幕请求
      if (response.config.url.includes('/api/timedtext') &&
          response.config.url.includes('lang=en') &&
            !response.config.url.includes('&translate_h00ked')) {
          response = HandleSubsResponse(response);
      }
      handler.resolve(response); // 处理响应
    }
  });
}

/*
如果未自动加载,请切换字幕或关闭后再打开即可。默认语言为浏览器首选语言。
*/
(function () {
  // 当文档加载完成并且字幕可用时,调用 enableSubs 函数启用双语字幕
  if (document.readyState === 'complete') {
    // 如果文档已经加载完成,则启用双语字幕
    AjaxHookSubs();
  } else {
    // 如果文档尚未加载完成,添加事件监听器以在加载完成时启用双语字幕
    window.addEventListener('load', AjaxHookSubs);
  }
})();