LinkedIn Learning 字幕中文翻译

LinkedIn Learning 字幕中文翻译脚本

// ==UserScript==
// @name         LinkedIn Learning 字幕中文翻译
// @description  LinkedIn Learning 字幕中文翻译脚本
// @namespace    https://github.com/journey-ad
// @version      0.2.1
// @icon         https://static.licdn.cn/sc/h/2c0s1jfqrqv9hg4v0a7zm89oa
// @author       journey-ad
// @match        *://www.linkedin.com/learning/*
// @require      https://greasyfork.org/scripts/411512-gm-createmenu/code/GM_createMenu.js?version=864854
// @require      https://cdn.jsdelivr.net/npm/fingerprintjs2@2.1.0/fingerprint2.min.js
// @license      MIT
// @run-at       document-end
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// ==/UserScript==
const __SCRIPT_NAME = 'LinkedIn Learning 字幕翻译'
const __SCRIPT_VER = '0.2.1'
const logcat = {
  log: createDebugMethod('log'),
  info: createDebugMethod('info'),
  debug: createDebugMethod('debug'),
  warn: createDebugMethod('warn'),
  error: createDebugMethod('error')
}

function createDebugMethod(name) {
  const bgColorMap = {
    debug: '#0070BB',
    info: '#009966',
    warn: '#BBBB23',
    error: '#bc0004'
  }
  name = bgColorMap[name] ? name : 'info'
  return function () {
    const args = Array.from(arguments)
    args.unshift(`color: white; background-color: ${bgColorMap[name] || '#FFFFFF'};`)
    args.unshift(`【${__SCRIPT_NAME} v${__SCRIPT_VER}】 %c[${name.toUpperCase()}]:`)
    console[name].apply(console, args)
  }
}

(function () {
  'use strict'
  logcat.info('已加载')

  let transPlat = 'caiyun' // caiyun | google

  let sourceSub = null // 原始字幕数组
  let __PLAYER_ = null // 播放器实例
  let __CUES = null // 原始字幕对象

  let ts = Date.now()
  let transTimer = window.setInterval(init, 100) // 用定时器检查播放器是否加载完毕

  addCustomStyle()
  addMenu()

  // hook history.pushState 监测路由变化
  !(function (history) {
    const pushState = history.pushState;
    history.pushState = function (state) {
      logcat.debug('页面路由变更')
      // 路由变化后重新初始化
      ts = Date.now()
      window.clearInterval(transTimer)
      transTimer = window.setInterval(init, 100)

      return pushState.apply(history, arguments);
    };
  })(window.history)

  function init() {
    if (Date.now() - ts >= 20 * 1000) {
      window.clearInterval(transTimer) // 清除定时器
      logcat.error('播放器检测超时,当前页可能非播放页')
    }

    const coursePage = document.querySelector('.classroom-body')
    const quiz = document.querySelector('.classroom-quiz')

    // 处理课程小节测验的情况
    if (coursePage && quiz) {
      coursePage.hasBeenInject = false
    }

    __PLAYER_ = document.querySelector('.media-player__player')?.player

    const __textTracks_ = __PLAYER_?.textTracks_

    if (__textTracks_) {
      window.clearInterval(transTimer)

      logcat.debug('播放器加载完毕')
      handleHookPlayer()
    } else {
      return
    }

    function handleHookPlayer() {
      if (coursePage.hasBeenInject) return

      coursePage.hasBeenInject = true // 标记当前页面已注入

      // 字幕添加后
      __textTracks_.on('addtrackcomplete', () => {
        handleHookWebtt()
      })
    }

    function handleHookWebtt() {
      // 字幕开关按钮
      const { captionsMenuToggle } = __PLAYER_.controlBar
      const isEnable = captionsMenuToggle.items[0].isSelected_

      if (!isEnable) {
        logcat.warn('未打开字幕开关')

        // captionsToggle.one('activate', () => {
        // logcat.info('打开字幕开关')
        //handleHookWebtt()
        // })

        return
      }

      let zhTrackItem = __PLAYER_.tech_.remoteTextTracks().tracks_.find(_ => _.language === 'zh-cn-addon')

      // 初次注入字幕语言选单
      if (!zhTrackItem) {
        logcat.debug('字幕轨道加载完毕')

        // 添加中文字幕轨道
        const zhTrack = __PLAYER_.tech_.createRemoteTextTrack({ kind: 'captions', label: '中文(自动翻译)', srclang: 'zh-cn-addon' })
        __PLAYER_.tech_.remoteTextTrackEls().addTrackElement_(zhTrack)
        __PLAYER_.tech_.remoteTextTracks().addTrack(zhTrack.track)
      }

      const tracks = __PLAYER_.tech_.remoteTextTracks()
      zhTrackItem = tracks.tracks_.find(_ => _.language === 'zh-cn-addon')

      // 从英文字幕轨道克隆一份作为中文字幕轨道
      zhTrackItem.cues_ = clone(tracks.tracks_[0].cues_)
      zhTrackItem.cues.setCues_(zhTrackItem.cues_)

      __CUES = zhTrackItem.cues_

      if (__CUES.length === 0) {
        // 字幕轨道为空,100ms后重试
        setTimeout(handleHookWebtt, 100)
        return
      }

      // 防止重复翻译
      if (zhTrackItem.hasBeenTranslate) return
      zhTrackItem.hasBeenTranslate = true

      // 字幕开启时暂停播放等待翻译
      if (isEnable) __PLAYER_.pause()

      // 取到原始字幕数组
      sourceSub = __CUES.map(_ => _.text)

      // 执行翻译操作
      __CUES[0].text = `[等待翻译文本]\n${__CUES[0].text}`
      // 重置字幕显示状态
      captionsMenuToggle.items[0].el_.click()
      captionsMenuToggle.items.find(_ => _.track.language === 'zh-cn-addon').el_.click()
      transText((text) => {
        logcat.info('字幕翻译完毕')
        console.groupCollapsed('中文字幕文本')
        console.info(text)
        console.groupEnd()

        __CUES[0].text = __CUES[0].text.replace(/\[等待翻译文本\]\n/, '')
        // if (captionsMenuToggle.items[0].isSelected_) {
        // 恢复播放
        if (isEnable) __PLAYER_.play()

        // 重置字幕显示状态
        captionsMenuToggle.items[0].el_.click()
        captionsMenuToggle.items.find(_ => _.track.language === 'zh-cn-addon').el_.click()
        // }
      })
    }
  }

  // 添加一些自定义样式
  function addCustomStyle() {
    const css = `
    .classroom-layout__stage--hide-controls .vjs-text-track-display {
      bottom: 18px !important;
    }
    .vjs-text-track-display>div>div {
      font-size: 1.6rem !important;
      font-size: clamp(1.4rem, 2.2vmin, 2.4rem) !important;
      line-height: 1.4 !important;
      white-space: pre-wrap !important;
      padding: 6px 20px !important;
    }
    .vjs-text-track-display>div>div>div {
      max-width: 66ch !important;
    }
    `
    addStyle(css)
  }

  function addMenu() {
    GM_createMenu.add({
      on: {
        default: true,
        name: "点击切换翻译来源 (当前: 彩云)",
        callback: function () {
          transPlat = 'google'
          alert("翻译来源已切换到Google")
        }
      },
      off: {
        name: "点击切换翻译来源 (当前: Google)",
        callback: function () {
          transPlat = 'caiyun'
          alert("翻译来源已切换到彩云")
        }
      }
    });
    GM_createMenu.create({ storage: true });

    transPlat = GM_createMenu.list[0].curr === 'on' ? 'caiyun' : 'google'
  }

  // 替换原始字幕实现
  function transText(cb) {
    let source = '',
      result = '',
      chunkArr = [],
      chunkSize = 0,
      count = 0

    // 去除每条字幕的换行符并按行排列
    sourceSub.forEach((e) => source += e.replace(/\r?\n|\r/g, ' ') + '\n')

    // 字符过长会提交失败,进行分块翻译
    // 返回分块大小,在回调中拼接 TODO: 优化为promise
    chunkSize = chunkTrans(source, (data, index) => {
      count++
      chunkArr[index] = data // 按分块原始下标放回数组

      // 所有翻译文本已取回
      if (count >= chunkSize) {
        result = chunkArr.join('\n') // 拼接分块翻译结果
        const subtitleTrans = result.split('\n') // 分割每条字幕

        // 按上中下英文混排,直接修改到原始字幕对象中
        subtitleTrans.forEach((item, idx) => {
          const el = __CUES[idx]
          el.text = `${item}\n${el.text}`
        })

        cb && cb(result)
      }
    })
  }

  // 分块翻译
  function chunkTrans(str, callback) {
    let textArr = [],
      count = 1

    //大于5000字符分块翻译
    if (str.length > 5000) {
      let strArr = str.split('\n'),
        i = 0

      strArr.forEach(str => {
        textArr[i] = textArr[i] || ''

        // 若加上此行后长度超出5000字符则分块
        if ((textArr[i] + str).length > (i + 1) * 5000) {
          i++
          textArr[i] = ''
        }

        textArr[i] += str + '\n'
      })

      count = i + 1 // 记录块的数量

    } else {
      textArr[0] = str
    }

    // 遍历每块分别进行翻译
    textArr.forEach(function (text, index) {
      doTrans({
        transPlat,
        text: text.trim(),
        index: index
      }, callback)
    })

    return count // 返回分块数量
  }

  // 彩云翻译实现
  function doTrans(sourceObj, callback) {

    switch (transPlat) {
      // 彩云翻译
      case 'caiyun':
        // 初始化彩云接口后再执行翻译操作
        initCaiyun()
          .then(({ browser_id, jwt }) => {
            const data = {
              source: sourceObj.text.split('\n'), // 按行翻译
              trans_type: 'en2zh',
              request_id: 'web_fanyi',
              media: 'text',
              os_type: 'web',
              dict: false,
              cached: true,
              replaced: true,
              browser_id
            }

            Request('https://api.interpreter.caiyunai.com/v1/translator',
              {
                method: 'POST',
                headers: {
                  'accept': 'application/json',
                  'content-type': 'application/json charset=UTF-8',
                  'X-Authorization': 'token:qgemv4jr1y38jyq6vhvi',
                  'T-Authorization': jwt
                },
                data: JSON.stringify(data),
              })
              .then(function (response) {
                var result = JSON.parse(response.responseText)

                // 加解密逻辑提取自彩云网页版翻译

                // ascii码表
                const __CHAR_MAP_1 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
                // 凯撒密码码表
                const __CHAR_MAP_2 = 'NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm'

                const index = t => __CHAR_MAP_1.indexOf(t) // 遍历密文 返回在字母表中的索引 非字母返回-1

                const encode = e => {
                  return e.split('')
                    .map(t => index(t) > -1 ? __CHAR_MAP_2[index(t)] : t) // 若返回值大于-1 则取密码表对应位数的密值 否则返回其自身 并拼接为新字符串)
                    .join('').replace(/[-_]/g, e => '-' === e ? '+' : '/')
                    .replace(/[^A-Za-z0-9\+\/]/g, '') // 去除非法字符
                }

                const btou = e => {
                  return e.replace(/[À-ß][€-¿]|[à-ï][€-¿]{2}|[ð-÷][€-¿]{3}/g, e => {
                    switch (e.length) {
                      case 4:
                        const t = ((7 & e.charCodeAt(0)) << 18 | (63 & e.charCodeAt(1)) << 12 | (63 & e.charCodeAt(2)) << 6 | 63 & e.charCodeAt(3)) - 65536
                        return String.fromCharCode(55296 + (t >>> 10)) + String.fromCharCode(56320 + (1023 & t))

                      case 3:
                        return String.fromCharCode((15 & e.charCodeAt(0)) << 12 | (63 & e.charCodeAt(1)) << 6 | 63 & e.charCodeAt(2))

                      default:
                        return String.fromCharCode((31 & e.charCodeAt(0)) << 6 | 63 & e.charCodeAt(1))
                    }
                  })
                }

                const encodeArr = result.target.map(words => {
                  const base64 = encode(words) // '6Vh55c6p' -> '6Iu55p6c'
                  return btou(atob(base64)) // '6Iu55p6c' -> '苹果' -> '苹果'
                })

                callback(encodeArr.join('\n'), sourceObj.index) // 执行回调,在回调中拼接
              })

          })

        break

      // 谷歌翻译
      case 'google':
        Request('https://translate.google.com/translate_a/t',
          {
            method: 'GET',
            headers: {
              'accept': 'application/json',
              'content-type': 'application/json charset=UTF-8',
            },
            params: {
              client: 'dict-chrome-ex',
              sl: 'auto',
              tl: 'zh-CN',
              q: sourceObj.text,
            }
          })
          .then(function (response) {
            const result = JSON.parse(response.responseText)

            callback(result[0], sourceObj.index) // 执行回调,在回调中拼接
          })
        break
    }
  }

  function initCaiyun() {
    const state = {
      browser_id: '',
      jwt: ''
    }

    return new Promise(function (resolve, reject) {
      if (state.browser_id && state.jwt) {
        resolve(state)

      } else {
        Fingerprint2 && Fingerprint2.get({}, function (components) {
          const values = components.map(component => component.value)

          const browser_id = Fingerprint2.x64hash128(values.join(''), 233)

          Request('https://api.interpreter.caiyunai.com/v1/user/jwt/generate',
            {
              method: 'POST',
              headers: {
                'accept': 'application/json',
                'content-type': 'application/json charset=UTF-8',
                'X-Authorization': 'token:qgemv4jr1y38jyq6vhvi'
              },
              data: JSON.stringify({
                'browser_id': browser_id
              })
            })
            .then(function (response) {
              const result = JSON.parse(response.responseText)
              resolve({ browser_id, jwt: result.jwt })
            })
            .catch(function (error) {
              reject(error)
            })
        })
      }
    })
  }

  function clone(item) {
    if (!item) { return item; } // null, undefined values check

    var types = [Number, String, Boolean],
      result;

    // normalizing primitives if someone did new String('aaa'), or new Number('444');
    types.forEach(function (type) {
      if (item instanceof type) {
        result = type(item);
      }
    });

    if (typeof result == "undefined") {
      if (Object.prototype.toString.call(item) === "[object Array]") {
        result = [];
        item.forEach(function (child, index, array) {
          result[index] = clone(child);
        });
      } else if (typeof item == "object") {
        // testing that this is DOM
        if (item.nodeType && typeof item.cloneNode == "function") {
          result = item.cloneNode(true);
        } else if (!item.prototype) { // check that this is a literal
          if (item instanceof Date) {
            result = new Date(item);
          } else {
            // it is an object literal
            result = {};
            for (var i in item) {
              result[i] = clone(item[i]);
            }
          }
        } else {
          // depending what you would like here,
          // just keep the reference, or create new object
          if (false && item.constructor) {
            // would not advice to do that, reason? Read below
            result = new item.constructor();
          } else {
            result = item;
          }
        }
      } else {
        result = item;
      }
    }

    return result;
  }

  function addStyle(css) {
    if (typeof GM_addStyle != 'undefined') {
      GM_addStyle(css)
    } else if (typeof PRO_addStyle != 'undefined') {
      PRO_addStyle(css)
    } else {
      var node = document.createElement('style')
      node.type = 'text/css'
      node.appendChild(document.createTextNode(css))
      var heads = document.getElementsByTagName('head')

      if (heads.length > 0) {
        heads[0].appendChild(node)
      } else {
        // no head yet, stick it whereever
        document.documentElement.appendChild(node)
      }
    }
  }

  function Request(url, opt = {}) {
    const originURL = new URL(url)
    const originParams = originURL.searchParams

    new URLSearchParams(opt.params).forEach((value, key) => originParams.append(key, value))

    const newQS = originParams.toString() !== '' ? `?${originParams.toString()}` : ''
    const newURL = `${originURL.origin}${originURL.pathname}${newQS}`

    Object.assign(opt, {
      url: newURL,
      timeout: 20000,
      responseType: 'json'
    })

    return new Promise((resolve, reject) => {
      opt.onerror = opt.ontimeout = reject
      opt.onload = resolve

      GM_xmlhttpRequest(opt)
    })
  }
})()