// ==UserScript== // @name Khan Academy timestamps // @namespace https://www.khanacademy.org/ // @version 1.0 // @description Adds time duration labels into ToC of a topic // @author nazikus // @require http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js // @match https://www.khanacademy.org/*/*/* // @run-at document-end // @downloadURL none // ==/UserScript== /* jshint -W097 */ (function() { 'use strict'; // dirty check if current page path depth corresponds to page with topic ToC // valid url example: https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces if (window.location.href.split('/').length !== 6) return ; console.log = function() {}; // disable console.log console.log('Script started...'); // cleanup (unlikely needed in production) // $('span[class^="nodeTitle"]').remove(); // $('span[style^="float: right"]').remove(); // select topic modules, skip (slice) first two divs which are ToC and header var topicModules = $('div[class^="moduleList"] > div[class^="module"]').slice(2); // create placeholder of lesson Time Duration label for each lesson (master object for cloning) var tdClass = topicModules.find('> div > a div[class^="nodeTitle"]').attr('class'); var tdMaster = $('', {class: tdClass}).css({'float':'right', 'color':'#11accd'}).text('[--:--]'); // create placholder of Module Time Duration label with cumulated time (master obj for cloning) var mtdMaster = $('').css({'float':'right'}).text('[--:--]'); var api = 'https://www.googleapis.com/youtube/v3/videos?id={id}&part=contentDetails&key={yek}'; var h = 'cmQyAhWMshjc2Go8HAnmOWhzauSnIkBfBySazIA'; // iterate over topic modules topicModules.each(function(){ // get all hrefs links in current module var hrefsArr = $(this).find('> div > a'); // change dipslay alignment of divs containing time label hrefsArr.find('div[class^="nodeTitle"]').css('display', 'inline-block'); // select module header (where module time label to be inserted) var mHeader = $(this).find('> h2 > div[style^="color:"]'); // check if time haven't been cached yet var cachedMtd = localStorage.getItem('M:'+mHeader.find('a').text()); if ( cachedMtd ) { mtdMaster.text(cachedMtd); mHeader.append( mtdMaster.clone() ); console.log('Cache module (%s) %s', mHeader.find('a').text(), cachedMtd); } // the last async callback processing final lesson href (of each module) is detected with these helper vars var moduleTime = 0, moduleCount = 0, moduleSize = hrefsArr.length; // worker launched for each module window.setTimeout(function(hrefs, _tdToClone, _mtdToClone, _moduleHeader){ // get urls as string array (for debugging) // var urls = hrefs.map(function(){return this.href;}).get(); // fetch each url // Info: extra closure here is to pass ModuleObj param inside $.each()'s lambda hrefs.each( (function( ModuleObj ){ return function(idx, lessonHref){ var href = $(lessonHref); var _tdTarget = href.find('> div[class^="nodeInfo"]'); var _url = href.attr('href'); // if not a video lesson (eg, exercise or read material) if (!/\/v\//.test(_url)) { // just append empty time duration and continue to the next link ModuleObj.tdToClone.text('[--:--]'); _tdTarget.append( ModuleObj.tdToClone.clone() ); moduleSize--; // exercise/readings do not contribute to module time return true; // true - continue, false - break from $.each() } // elaborate ModuleObj with extra values var _ModObj = { mtdElem: ModuleObj.mtdToClone, mtdTarget: ModuleObj.moduleHeader, mtdTitle: ModuleObj.moduleHeader.find('a').text(), tdElem: ModuleObj.tdToClone, tdTarget: _tdTarget, tdTitle: href.text() }; // check if lesson time duration is cached yet var cachedTd = localStorage.getItem(_url); if (cachedTd) { _ModObj.tdElem.text(cachedTd); _ModObj.tdTarget.append( _ModObj.tdElem.clone() ); console.log('Cache: (%s) [%s]', _ModObj.tdTitle, cachedTd); return ; } // get lesson page html in async request (inside a worker) var lessonHtml = $.ajax({ url: _url, datatype: 'html', ModObj: _ModObj }) .done(function(htmlData){ // get youtube video id var videoId = $($.parseHTML(htmlData)) .filter('meta[property="og:video"]').attr('content').split('/').pop(); // perform async YouTube API call to get video duration $.ajax({ url: api.replace('\x7b\x69\x64\x7d', videoId) .replace('\x7b\x79\x65\x6b\x7d', h.split('').reverse().join('')), lessonUrl: this.url, datatype: 'json', mObj: this.ModObj, success: function(jsonResponse){ var duration = jsonResponse.items[0] .contentDetails.duration.match(/PT(\d+H)?(\d+M)?(\d+S)?/); var hours = (parseInt(duration[1]) || 0), minutes = (parseInt(duration[2]) || 0), seconds = (parseInt(duration[3]) || 0), totals = hours * 3600 + minutes * 60 + seconds, stamp = (duration[1] ? duration[1].slice(0,-1)+':' : '') + ('0' + minutes).slice(-2)+':'+('0' + seconds).slice(-2); // attach cloned label to the DOM this.mObj.tdElem.text('['+stamp+']'); this.mObj.tdTarget.append( this.mObj.tdElem.clone() ); // cached lesson time duration localStorage.setItem(this.lessonUrl, this.mObj.tdElem.text()); console.log('(%s) %s. %s [%s]', this.mObj.mtdTitle, moduleCount, this.mObj.tdTitle, stamp); // count total time duration of a module, and if the the last lesson request to be processed // then cache module time and attach cloned label to the DOM as well moduleTime += totals; moduleCount++; // if the last link to process then output module total time if (moduleCount === moduleSize) { var mHours = Math.floor(moduleTime/60/60), mMinutes = Math.floor(moduleTime/60) - mHours*60, moduleTimeStr = ('0'+mHours).slice(-2) + ':' + ('0'+mMinutes).slice(-2) + ':' + ('0'+moduleTime%60).slice(-2); this.mObj.mtdElem.text( '[' + moduleTimeStr + ']' ); this.mObj.mtdTarget.append( this.mObj.mtdElem.clone() ); localStorage.setItem('M:'+this.mObj.mtdTitle, this.mObj.mtdElem.text()); console.log('Module "%s" [%s]\n', this.mObj.mtdTitle, moduleTimeStr); } }, error: function(data) { console.error('YouTube API error:\n%s', data); } }); // return YouTube $.ajax():success }) // return lesson $.ajax().done() .fail(function(){ console.error('Could not retrieve URL: %s', this.url); }); };})( { tdToClone: _tdToClone, mtdToClone: _mtdToClone, moduleHeader: _moduleHeader } ) ); // return hrefs.each() }, 0, hrefsArr, tdMaster, mtdMaster, mHeader); // return window.setTimeout() }); // return topicModules.each(); })(); // alternative to YouTube API: // http://stackoverflow.com/questions/30084140/youtube-video-title-with-api-v3-without-api-key