LinkedIn Learning 字幕中文翻译

LinkedIn Learning 字幕中文翻译脚本

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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/[email protected]/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)
    })
  }
})()