// ==UserScript==
// @name 字幕全文阅读-Bibili字幕学习机
// @namespace https://gf.qytechs.cn/zh-CN/scripts/421483-subtitles-full-text
// @version 1
// @description 在线字幕阅读或下载,B站秒变cousera! - Read & learn subtitles full text online!
// @author KnIfER
// @license MIT
// @match https://*.bilibili.com/video/*
// ==/UserScript==
// match https://*youtube.com/*
(function() {
'use strict';
var lastVid='x';
function debug(a,b,c,d,e){var t=[a,b,c,d,e];for(var i=5;i>=0;i--){if(t[i]===undefined)t[i]='';else break}console.log("%c 学习机 ","color:#eee!important;background:#0FF;",t[0],t[1],t[2],t[3],t[4])}
var proto = XMLHttpRequest.prototype;
proto.reallyOpen = proto.open;
proto.open = function(method, url, a, u, p) {
//debug('request::open!!!', url);
this.reallyOpen(method, url , a, u, p);
if(url) {
var tmp = new RegExp('(aid=[0-9]+&cid=[0-9]+)').exec(url);
if(tmp) tmp = tmp[0];
if(tmp && lastVid!=tmp) {
lastVid = tmp;
debug('正在播放='+lastVid);
}
}
};
proto.reallySend = proto.send;
proto.send = function(b) {
//debug('request::send!!!', b);
this.reallySend(b);
};
var loadOnStart = false; /* true false 是否自动分析字幕 */
var pinFTMenu = false; /* true false 是否不自动关闭字幕列表 */
var autoFTM = false; /* true false 是否自动打开字幕列表 */
var cssData = "#TextView{position:fixed;bottom:0;left:0;width:100%;height:30px;box-sizing:border-box;background:#fff;z-index: 1000;overflow-y:scroll}#drag_resizer{position:sticky;top:0;right:0;height:6px;width:100%;padding:0;cursor:ns-resize}#ftv{margin-top:9px;margin-left:5px;font-size:x-large;padding:0 100px 0 100px;}a.ft-time:before{content:attr(data-val)}a.ft-time{text-decoration:none;color:blue;user-select:none;-moz-user-select:none}.ft-ln.curr {border-bottom: 2px solid #0000ffac;}ytd-masthead{background: transparent;}";
// the dialog
var pageData = '<p id="drag_resizer"></p><p id="ftv">CAPTION</p>';
// api address
var baseUrl = 'https://video.google.com/timedtext';
// the panel, the text, the button
var YFT, ftv, Btn;
// the menu
var Menu, MenuSty;
// video tag
var H5Vid;
var lrcLoaded;
function initBTN(){
if(!Btn){
var doc=document,ibf = doc.getElementsByClassName("bpx-player-ctrl-subtitle")[0];
if(ibf){
// insert a control btn
var tmp = doc.createElement("style");
tmp.id = "FTCB"
doc.head.appendChild(tmp);
tmp.innerHTML = ".ytp-gradient-top,.ytp-chrome-top{opacity:0}.ytp-fulltext-menu{right: 12px;bottom: 53px;z-index: 71;will-change: width,height;}.ytp-fulltext-menu .ytp-menuitem-label{width: 65%;} .ytp-menuitem>div{display:inline-block;font-size:medium}";
tmp = doc.createElement("button");
tmp.id = "learnBtn"
tmp.className = "ytp-fulltext-button ytp-button bpx-player-ctrl-btn";
tmp.title="字幕学习机 (t)";
ibf.parentNode.insertBefore(tmp,ibf.nextElementSibling);
// button svg icon
tmp.innerHTML = '<svg style="background-color:#000;transform:scale(1.5);" height="100%" version="1.1" viewBox="0 0 36 36" width="100%"><g class="ytp-fullscreen-button-corner-0"><use class="ytp-svg-shadow" xlink:href="#ytp-id-99"></use><path class="ytp-svg-fill" d="M18.97,18h6.82v1.46h-6.82zM18.97,15.57h6.82L25.8,17.03h-6.82zM18.97,20.43h6.82L25.8,21.89h-6.82zM26.77,10.19L9.23,10.19c-1.07,0 -1.96,0.88 -1.96,1.96v12.67c0,1.07 0.88,1.96 1.96,1.96h17.55c1.07,0 1.96,-0.88 1.96,-1.96L28.73,12.15c0,-1.07 -0.88,-1.96 -1.96,-1.96zM26.77,24.83h-8.77L18,12.15h8.77v12.67z" id="ytp-id-99"></path></g></svg>';
tmp.onclick = function() {
if(MenuSty) {
tmp = MenuSty;
if(tmp.display!="none") {
tmp.display="none"
} else {
tmp.display="";
build_cc_menu()
}
} else {
build_cc_menu()
}
}
Btn=tmp;
// if(autoFTM) {
// build_cc_menu()
// }
// if(loadOnStart) {
// // todo load initial lyrics
// build_cc_menu(1);
// initYFT();
// }
} else {
setTimeout(initBTN, 100);
}
}
}
function initYFT(H){
if(!YFT) {
var doc=document,item = doc.createElement("style");
item.id = "YFT"
doc.head.appendChild(item);
item.innerHTML = cssData;
item=doc.createElement("div");
item.id="TextView";
doc.body.appendChild(item);
item.innerHTML=pageData;
YFT = item;
ftv = YFT.children[1];
// drag-resize the TextView
//item.onload= ()=> bindResize();
bindResize();
function bindResize(){
var tvP = YFT;
var tvPs = tvP.style,
x = 0;
var el = drag_resizer;
function mousedown(e){
if(e.clientY==undefined)
e.clientY=e.originalEvent.changedTouches[0].clientY;
x = e.clientY + tvP.offsetHeight;
e.preventDefault()
document.addEventListener("mousemove", mouseMove); document.addEventListener("mouseup", mouseUp);
};
function mouseMove(e){
if(e.clientY==undefined)
e.clientY=e.originalEvent.changedTouches[0].clientY;
tvPs.height = x - e.clientY + 'px';
}
function mouseUp(){
document.removeEventListener("mousemove", mouseMove); document.removeEventListener("mouseup", mouseUp);
}
el.addEventListener("mousedown", mousedown);
el.addEventListener("touchstart", mousedown);
el.addEventListener("touchmove", mouseMove);
el.addEventListener("touchend", mouseUp);
}
installTimers();
//var insertionLis = e => {
// //console.log("DOMNodeInserted")
// if(document.body.lastElementChild!=YFT){
// document.body.removeChild(YFT);
// document.body.appendChild(YFT);
// }
//};
//document.body.addEventListener('DOMNodeInserted', insertionLis)
}
ensureFTH(H||30)
}
function ensureFTH(e){
// ensure visibility
if(YFT){
var h = parseFloat(YFT.style.height);
if(h!=h||h<e) {
YFT.style.height = e+"px"
}
if(YFT.style.display!=="") {
YFT.style.display = ""
}
}
}
/*via mdict-js*/
function reduce(val,arr,st,ed) {
var len = ed-st;
if (len > 1) {
len = len >> 1;
return val > arr[st + len - 1].endTime
? reduce(val,arr,st+len,ed)
: reduce(val,arr,st,st+len);
} else {
return arr[st];
}
}
function installTimers(){
if(H5Vid==null) {
H5Vid=document.querySelector('video')
if(H5Vid==null) {
setTimeout(initTimer, 100)
} else {
H5Vid.addEventListener('timeupdate', e => {
// lyrics scroll sync to time
var tm=H5Vid.currentTime;
if(lrcArr&&(!lcN||tm>=lcN.endTime||tm<lcN.startTime)) {
var n = reduce(tm,lrcArr,0,lrcArr.length);
if(n&&n!=lcN) {
lcN = n;
if(lcE) {
lcE.className="ft-ln";
}
n = n.ele;
lcE = n;
if(n) {
n.className+=" curr";
}
if(window.getSelection().isCollapsed
&&(n.offsetTop+n.offsetHeight>TextView.scrollTop+TextView.offsetHeight
||n.offsetTop<TextView.scrollTop)) {
TextView.scrollTop=n.offsetTop;
//TextView.scrollTo(n.offsetTop);
}
}
}
})
window.addEventListener("click", function(e){
if(e.srcElement.className==="ft-time") {
e.preventDefault();
H5Vid.currentTime=parseFloat(e.srcElement.getAttribute("data-tm"));
}
});
}
}
}
// http://qtdebug.com/fe-srt/
function parseSrt(srt) {
var parsed = [];
var textSubtitles = srt.split('\n\n'); // 每条字幕的信息,包含了序号,时间,字幕内容
for (var i = 0; i < textSubtitles.length; ++i) {
var textSubtitle = textSubtitles[i].split('\n');
if (textSubtitle.length >= 2) {
var sn = textSubtitle[0];
var tms = textSubtitle[1].split(' --> ');
var startTime = toSeconds(tms[0]);
var endTime = toSeconds(tms[1]);
var content = textSubtitle[2];
// 字幕可能有多行
if (textSubtitle.length > 2) {
for (var j = 3; j < textSubtitle.length; j++) {
content += ' ' + textSubtitle[j];
}
}
parsed.push({
sn: sn,
startTime: startTime,
endTime: endTime,
content: content
});
}
}
return parsed;
}
function toSeconds(t) {
var s = 0.0;
if (t) {
var p = t.trim().split(':');
for (var i = 0; i < p.length; i++) {
s = s * 60 + parseFloat(p[i].replace(',', '.'));
}
}
return s;
}
var lrcArr;
var lcN, lcE;
function appendFulltext(sub, d) {
debug("APFT", sub, d);
var lrc = sub.srt;
if(d) {
var t=document.getElementsByTagName("H1")[0];
if(t)t=t.innerText;
else t=document.title;
downloadString(lrc, "text/plain", t+"."+(sub.lang_code||"a")+".srt");
return;
}
unsafeWindow.srtlrc=sub;
// parse
var lrcs = parseSrt(lrc);
var span="";
var lastTime=0;
// concate
for(var i=0;i<lrcs.length;i++){
var lI=lrcs[i];
var text = lI.content;
var lnSep="<br><br>";
var sepLn="";
if(lI.startTime-lastTime>3){
var idx = text.indexOf(".");
// skip numberic dots
while(idx>0) {
if(idx+1>=text.length||text[idx+1]<=' ') {
break;
}
idx = text.indexOf(".", idx+1);
}
if(idx<0) idx = text.indexOf("。");
if(idx<0) idx = text.indexOf(",");
if(idx<0) idx = text.indexOf(",");
if(idx>=0) {
text=" "+text.substring(0, idx+1)
+lnSep+text.substring(idx+1);
} else {
sepLn = lnSep;
}
lnSep = " ";
} else {
// merge to previous line
text=" "+text;
lnSep = "";
}
//console.log(lI.startTime-lastTime);
var s = lI.startTime;
var m = parseInt(lI.startTime/60);
span+=sepLn+"<a class='ft-time' href='' data-val='" + " "
+(m+":"+parseInt(s-m*60))+lnSep+"' data-tm='"+s+"'></a>"
+"<span class='ft-ln'>"+text+"</span>"
lastTime = lI.startTime;
}
ftv.innerHTML=span;
// attach ele to array
lrcArr = lrcs;
lcN = 0;
var cc=0;
var sz = ftv.childElementCount;
for(var i=0;i<sz,cc<lrcArr.length;i++) {
if(ftv.children[i].className==="ft-ln") {
lrcArr[cc++].ele=ftv.children[i];
}
}
window.lrcArr=lrcArr;
//console.log(lrcArr);
}
initBTN();
unsafeWindow.APFT = appendFulltext;
unsafeWindow.subtitles = []; // store all subtitle
// trigger when loading new page
// (actually this would also trigger when first loading, that's not what we want, that's why we need to use firsr_load === false)
// (new Material design version would trigger this "yt-navigate-finish" event. old version would not.)
var body = document.getElementsByTagName("body")[0];
body.addEventListener("yt-navigate-finish", function (event) {
if (is_video_page()&&autoFTM) {
if(build_cc_menu()) {
var st = MenuSty;
if(st.display!="") {
st.display=""
}
}
}
});
// trigger when loading new page
// (old version would trigger "spfdone" event. new Material design version not sure yet.)
window.addEventListener("spfdone", function (e) {
//if (is_video_page()) {
// remove_dwnld_btn();
// var checkExist = setInterval(function () {
// if (unsafeWindow.watch7_headline) {
// init();
// clearInterval(checkExist);
// }
// }, 330);
//}
});
function is_video_page() {
return get_vid() !== null;
}
function get_vid() {
return lastVid;
return getURLParameter('v');
}
//https://stackoverflow.com/questions/11582512/how-to-get-url-parameters-with-javascript/11582513#11582513
function getURLParameter(name) {
return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [null, ''])[1].replace(/\+/g, '%20')) || null;
}
// https://stackoverflow.com/questions/32142656/get-youtube-captions#58435817
function buildXmlurl(videoId, loc) {
return `${baseUrl}?lang=${loc}&v=${videoId}`;//&fmt=json3
}
// pull the selected caption.
function pullLyrics(e, d) {
//var url;
// if(e==0) {
// console.log("auto");
// url = get_auto_xml_url();
// console.log("auto", url);
// }
// e = subtitles[e]
// if(e) {
// if(!e.srt)
// fetch(url||buildXmlurl(get_vid(), e.lang_code))
// .then(v => v.text())
// .then(v => (new window.DOMParser()).parseFromString(v, "text/xml"))
// .then(v => {
// v = buildSrtFromXML(v);
// e.srt = v;
// appendFulltext(e, d)
// })
// else appendFulltext(e, d)
// }
var sub = subtitles[e];
var url = sub.subtitle_url;
fetch(url)
.then(v => v.text())
.then(v => {
sub.srt = buildSrtFromJson(v);
appendFulltext(sub, d)
})
}
function buildMenu(e){
return `<div class="ytp-menuitem" aria-haspopup="true" role="menuitem" tabindex="${e.cid||0}">
<div class="ytp-menuitem-icon"></div>
<div class="ytp-menuitem-label">
${e.lan_doc||e.lang_name}
</div>
<div class="ytp-menuitem-content">
下载
</div>
</div>`;
}
function menuClick(e){
debug('menuClick', e);
var t = e.target;
var i = parseInt(t.parentNode.getAttribute("tabindex"));
if(i==i) {
if(t.className==="ytp-menuitem-content") {
// 下载
pullLyrics(i, 1);
} else {
// 查看
initYFT(120);
pullLyrics(i);
}
}
t.blur();
if(!pinFTMenu) {
MenuSty.display="none";
}
}
function build_cc_menu(src) {
var vid = get_vid();
if(vid==Btn.parsedVid) {
return false;
}
Btn.parsedVid=vid;
if(loadOnStart) {
src=1;
}
var ibf = Btn; // unsafeWindow.movie_player
// todo validify auto caption exists
if(!Menu && ibf) {
var tmp = document.createElement("div");
ibf.appendChild(tmp);
// menuData
tmp.innerHTML = `<div class="ytp-popup ytp-fulltext-menu" data-layer="6" id="yft-select"
style="width: 251px; height: 137px; display: block;">
<div class="ytp-panel" style="min-width: 250px; width: 251px; height: 137px;" id="yft_cc">
<div class="ytp-panel-menu" role="menu" style="height: 137px;"></div>
</div>
</div>`;
MenuSty = tmp.firstElementChild.style;
MenuSty.position='absolute';
MenuSty.background='#000000cf';
MenuSty.left='-100px';
Menu = unsafeWindow.yft_cc;
// if(src==1 && !autoFTM) {
// MenuSty.display = "none";
// }
debug(Menu);
}
if(Menu) {
Menu.innerHTML = "";
var url = `https://api.bilibili.com/x/player/v2?${vid}`;
debug("loading_list, url=", url);
loadJson(url, function (res, xhr) {
//debug('得到', res, xhr)
if(res)
try{
subtitles = res.data.subtitle.subtitles;
var autosel=-1;
debug('subtitles urls', subtitles);
// var tracks = xhr.responseXML.getElementsByTagName('track');
var tmp="";
for (var i=0, len=subtitles.length;i<len;i++) {
// if(i==0) {
// ety={lang_code:'AUTO',lang_name:'AUTO'}
// } else {
// xml = tracks[i-1];
// ety = {
// lang_code: xml.getAttribute('lang_code'),
// lang_name: xml.getAttribute('lang_original')
// ||xml.getAttribute('lang_translated'),
// cid:i
// }
// if(src==1&&xml.getAttribute('lang_default')) {
// autosel=i;
// src=0;
// }
// }
//subtitles.push(ety); // 加到 subtitles, 待会靠它下载
tmp+=buildMenu(subtitles[i]);
}
if(src==1) {
autosel=0;
}
debug("autosel", autosel);
Menu.innerHTML=tmp;
for (var i=0,ch=Menu.children,len=ch.length; i < len; i++) {
ch[i].onclick = menuClick;
// if(autosel==i) {
// initYFT(120);
// pullLyrics(i);
// }
}
} catch(e) {debug(e)}
});
} else {
Btn.parsedVid="";
}
return true;
}
// 处理时间. 比如 start="671.33" start="37.64" start="12" start="23.029"
// 处理成 srt 时间, 比如 00:00:00,090 00:00:08,460 00:10:29,350
function process_time(s) {
s = s.toFixed(3);
// 671.33 -> 671.330
// 671 -> 671.000
var array = s.split('.');
// 把开始时间根据句号分割
// 671.330 会分割成数组: [671, 330]
var Hour = 0;
var Minute = 0;
var Second = array[0]; // 671
var MilliSecond = array[1]; // 330
// 先声明下变量, 待会把这几个拼好就行了
// 我们来处理秒数. 把"分钟"和"小时"除出来
if (Second >= 60) {
Minute = Math.floor(Second / 60);
Second = Second - Minute * 60;
// 把 秒 拆成 分钟和秒, 比如121秒, 拆成2分钟1秒
Hour = Math.floor(Minute / 60);
Minute = Minute - Hour * 60;
// 把 分钟 拆成 小时和分钟, 比如700分钟, 拆成11小时40分钟
}
// 分钟,如果位数不够两位就变成两位,下面两个if语句的作用也是一样。
if (Minute < 10) {
Minute = '0' + Minute;
}
// 小时
if (Hour < 10) {
Hour = '0' + Hour;
}
// 秒
if (Second < 10) {
Second = '0' + Second;
}
return Hour + ':' + Minute + ':' + Second + ',' + MilliSecond;
}
// copy from: https://gist.github.com/danallison/3ec9d5314788b337b682
// Thanks! https://github.com/danallison
// work in Chrome 66
// test passed: 2018-5-19
function downloadString(text, fileType, fileName) {
var blob = new Blob([text], {type: fileType});
var a = document.createElement('a');
a.download = fileName;
a.href = URL.createObjectURL(blob);
a.dataset.downloadurl = [fileType, a.download, a.href].join(':');
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () {
URL.revokeObjectURL(a.href);
}, 1500);
}
// https://css-tricks.com/snippets/javascript/unescape-html-in-js/
// turn HTML entity back to text, example: " should be "
function htmlDecode(input) {
var e = document.createElement('div');
e.class = 'dummy-element-for-tampermonkey-Youtube-Subtitle-Downloader-script-to-decode-html-entity';
e.innerHTML = input;
return e.childNodes.length === 0 ? "" : e.childNodes[0].nodeValue;
}
// return URL or null;
// later we can send a AJAX and get XML subtitle
function get_auto_xml_url() {
try {
var captionTracks = get_captionTracks()
for (var index in captionTracks) {
var caption = captionTracks[index];
if (caption.kind === 'asr') {
return captionTracks[index].baseUrl;
}
// ASR – A caption track generated using automatic speech recognition.
// https://developers.google.com/youtube/v3/docs/captions
}
return false;
} catch (e) {
console.log(e);
return false;
}
}
async function get_auto_subtitle() {
var url = get_auto_xml_url();
console.log("dwnld_auto_url::", url);
if (url == false) {
return false;
}
var result = await getUrl(url)
return result
}
// Youtube return XML.
// input: Youtube XML format
// output: SRT format
function buildSrtFromXML(youtube_xml_string) {
if (youtube_xml_string === '') {
return false;
}
var text = youtube_xml_string.getElementsByTagName('text');
var result = '\uFEFF';
var len = text.length;
for (var i = 0; i < len; i++) {
var content = text[i].textContent.toString();
content = content.replace(/(<([^>]+)>)/ig, ""); // remove all html tag.
var start = text[i].getAttribute('start');
var end = parseFloat(text[i].getAttribute('start')) + parseFloat(text[i].getAttribute('dur'));
result = result + (i + 1) + "\n";
// 1
if (i + 1 >= len) {
end = parseFloat(text[i].getAttribute('start')) + parseFloat(text[i].getAttribute('dur'));
} else {
end = text[i + 1].getAttribute('start');
}
var start_time = process_time(parseFloat(start));
var end_time = process_time(parseFloat(end));
result = result + start_time;
result = result + ' --> ';
result = result + end_time + "\n";
// 00:00:01,939 --> 00:00:04,350
content = htmlDecode(content);
// turn HTML entity back to text. example: ' back to apostrophe (')
result = result + content + "\n" + "\n";
}
return result;
}
// bilibili return JSON.
function buildSrtFromJson(bilibili_json_string) {
var json = JSON.parse(bilibili_json_string);
debug('buildSrtFromJson, json=', json);
var arr = json.body, result = '\uFEFF';
for (var i = 0, len=arr.length; i < len; i++) {
var content = arr[i].content;
content = content.replace(/(<([^>]+)>)/ig, ""); // remove all html tag.
var start = arr[i].from;
var end = arr[i].to;
// 1
result = result + (i + 1) + "\n";
var start_time = process_time(parseFloat(start));
var end_time = process_time(parseFloat(end));
result = result + start_time;
result = result + ' --> ';
result = result + end_time + "\n";
// 00:00:01,939 --> 00:00:04,350
// content = htmlDecode(content);
// turn HTML entity back to text. example: ' back to apostrophe (')
result = result + content + "\n" + "\n";
}
return result;
}
function get_captionTracks() {
var json = null
if (unsafeWindow.youtube_playerResponse_1c7) {
json = youtube_playerResponse_1c7;
} else if(ytplayer.config.args.player_response) {
let raw_string = ytplayer.config.args.player_response;
json = JSON.parse(raw_string);
} else if (ytplayer.config.args.raw_player_response) {
json = ytplayer.config.args.raw_player_response;
}
let captionTracks = json.captions.playerCaptionsTracklistRenderer.captionTracks;
return captionTracks
}
function loadJson(url,cb,parm){
//debug('loadJson!!!', url,parm)
var req = new XMLHttpRequest();
req.open(parm?'POST':'GET', url, true);
req.responseType = 'json';
if(cb){
req.onload = function() {
cb(req.response, req);
};
req.onerror = function() {
cb(0, req);
};
}
//req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
//x.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
req.send(parm);
}
// https://stackoverflow.com/questions/48969495/in-javascript-how-do-i-should-i-use-async-await-with-xmlhttprequest
function makeRequest(method, url, load, type) {
return new Promise(function (resolve, reject) {
let xhr = new XMLHttpRequest();
xhr.responseType = type;
//xhr.timeout = 2000;
xhr.onload = function () {
debug('makeRequest, onload::', this.status, xhr.statusText);
if (this.status >= 200 && this.status < 300) {
if(load) {
load(xhr);
resolve('');
} else {
resolve(xhr);
}
} else {
debug('makeRequest, 发生错误::', this.status, xhr.statusText);
reject({
status: this.status,
statusText: xhr.statusText
});
}
};
xhr.onerror = function () {
debug('makeRequest, 发生错误::', this.status, xhr.statusText);
reject({
status: this.status,
statusText: xhr.statusText
});
};
xhr.open(method, url);
xhr.send();
});
}
async function getUrl(url) {
return makeRequest("GET", url);
}
})();