Last active
June 26, 2022 13:30
-
-
Save journey-ad/d3d2869d266c00188e6f99a33fdb669c to your computer and use it in GitHub Desktop.
LinkedIn Learning 字幕中文翻译脚本
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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) | |
}) | |
} | |
})() |
大佬,能不能支持一下linkedin视频啊,lynda改版之后自动跳转linkedin
现已支持LinkedIn Learning
大佬不好使啦
0.2.0 版本已适配 LinkedIn Learning 多轨字幕
感谢
感谢大佬的翻译插件 超好用
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@Xiao-mingGitHub
彩云和搜狗翻译接口暂不可用,先用google接口吧
修改此行代码中
transServer
的值