Youtube Full Text

在线字幕阅读/下载神器! - View full text of the subtitles just online! And even download them as srt files !

09.02.2021 itibariyledir. En son verisyonu görün.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

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

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

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.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         Youtube Full Text
// @namespace    https://greasyfork.org/zh-CN/scripts/421483-youtube-full-text
// @version      1
// @description  在线字幕阅读/下载神器! - View full text of the subtitles just online! And even download them as srt files !
// @author       KnIfER
// @include      https://*youtube.com/*
// ==/UserScript==

(function() {
    'use strict';
    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;}";

	var btnCss = ".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%;}";

    // the dialog
    var pageData = '<p id="drag_resizer"></p><p id="ftv">CAPTION</p>';
	
    var menuData = `<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>`;

    // the svg icon from Android Assets && the VectorPathTweaker plugin 
    var btnData = '<svg 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>';

	// api address
	var baseUrl = 'https://video.google.com/timedtext';
	
	// the panel, the text, the button
    var YFT, ftv, BTN;

	// the menu
	var YFT_menu, YFT_mps;
	
	// video tag
    var H5Vid;
	
	var lrcLoaded;

    function initBTN(){
        if(!BTN){
            var doc=document,rct = doc.getElementsByClassName("ytp-right-controls")[0];
            if(rct&&rct.firstChild){
				// insert a control btn
				var e = doc.createElement("style");
				e.id = "FTCB"
				doc.head.appendChild(e);
				e.innerHTML = btnCss;
                e = doc.createElement("button");
                e.id = "YFTB"
                e.className = "ytp-fulltext-button ytp-button";
                e.title="Full text (t)";
                rct.insertBefore(e,rct.firstChild);
                e.innerHTML = btnData;
                e.onclick = function() {
					if(YFT_mps) {
						var st = YFT_mps;
						if(st.display!="none") {
							st.display="none"
						} else {
							st.display="";
							build_cc_menu()
						}
					} else {
						build_cc_menu()
					}
                }
				BTN=e;
				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 parseSrtSubtitles(srt) {
		var subtitles = [];
		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];
					}
				}
				// 你没有对象
				var subtitle = {
					sn: sn,
					startTime: startTime,
					endTime: endTime,
					content: content
				};
				subtitles.push(subtitle);
			}
		}
		return subtitles;
	}
	
	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;
	
	// Append full text.
	function APFT(e, d) {
		//console.log("APFT", e, d);
		var lrc = e.srt;
		if(d) {
			var t=document.getElementsByTagName("H1")[0];
			if(t)t=t.innerText;
			else t=document.title;
			downloadString(lrc, "text/plain", t+"."+(e.lang_code||"a")+".srt");
			return;
		}
		unsafeWindow.srtlrc=e;
		// parse
		var lrcs = parseSrtSubtitles(lrc);
		var span="";
		var lastTime=0;
		// concatenate
		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="&nbsp;"+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);
	}
	
	//window.APFT = APFT;
	
    initBTN();
	
	unsafeWindow.yft_captions = []; // 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 = YFT_mps;
				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 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 = yft_captions[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;
				APFT(e, d)
			})
			else APFT(e, 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.lang_name}
	</div>
	<div class="ytp-menuitem-content">
		下载
	</div>
</div>`;
	}
	
	function menuic(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) {
			YFT_mps.display="none";
		}
	}
	var lastVid;
	
	function build_cc_menu(src) {
		var vid = get_vid();
		if(vid==lastVid) {
			return false;
		}
		lastVid=vid;
		if(loadOnStart) {
			src=1;
		}
		// todo validify auto caption exists
		if(!YFT_menu&&unsafeWindow.movie_player) {
			var item = document.createElement("div");
			movie_player.appendChild(item);
			item.innerHTML = menuData;
			YFT_mps = item.style;
			YFT_menu = unsafeWindow.yft_cc;
			if(src==1&&!autoFTM) {
				YFT_mps.display = "none";
			}
		}
		if(YFT_menu) {
			YFT_menu.innerHTML = "";
			var list_url = `${baseUrl}?hl=en&v=${vid}&type=list`;
			console.log("loading_list::", list_url);
			makeRequest('GET',list_url, function (xhr) {
				// todo auto select if requested
				try{
					yft_captions = [];
					var tracks = xhr.responseXML.getElementsByTagName('track');
					xhr="";
					var autosel=-1;
					for (var i = 0, len = tracks.length, xml, ety; 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;
							}
						}
						yft_captions.push(ety); // 加到 yft_captions, 待会靠它下载
						xhr+=buildMenu(ety);
					}
					if(src==1) {
						autosel=0;
					}
					console.log("autosel", autosel);
					YFT_menu.innerHTML=xhr;
					var cc = YFT_menu.children;
					for (var i = 0, len = cc.length; i < len; i++) {
						cc[i].onclick = menuic;
						if(autosel==i) {
							initYFT(120);
							pullLyrics(i);
						}
					}
				} catch(e) {console.log(e)}
			});
		} else {
			lastVid="";
		}
		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);
		// 超棒的函数, 不论是整数还是小数都给弄成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: &quot; 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. we want SRT
	// 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 = '';
		var BOM = '\uFEFF';
		result = BOM + result; // store final SRT result
		var len = text.length;
		for (var i = 0; i < len; i++) {
			var index = i + 1;
			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'));
			var new_line = "\n";
			result = result + index + new_line;
			// 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 + new_line;
			// 00:00:01,939 --> 00:00:04,350

			content = htmlDecode(content);
			// turn HTML entity back to text. example: &#39; back to apostrophe (')

			result = result + content + new_line + new_line;
			// everybody Craig Adams here I'm a
		}
		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
	}
	
	// https://stackoverflow.com/questions/48969495/in-javascript-how-do-i-should-i-use-async-await-with-xmlhttprequest
	function makeRequest(method, url, load) {
		return new Promise(function (resolve, reject) {
			let xhr = new XMLHttpRequest();
			xhr.open(method, url);
			xhr.timeout = 2000; 
			xhr.onload = function () {
				if (this.status >= 200 && this.status < 300) {
					if(load) {
						load(xhr);
						resolve('');
					} else {
						resolve(xhr.responseXML);
					}
				} else {
					reject({
						status: this.status,
						statusText: xhr.statusText
					});
				}
			};
			xhr.onerror = function () {
				reject({
					status: this.status,
					statusText: xhr.statusText
				});
			};
			xhr.send();
		});
	}
	async function getUrl(url) {
		return makeRequest("GET", url);
	}
})();