NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Khan Academy timestamps // @namespace https://www.khanacademy.org/ // @version 1.3 // @description Adds time duration labels near each lesson title // @author nazikus // @require http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js // @match https://www.khanacademy.org/*/* // @run-at document-end // ==/UserScript== /* jshint -W097 */ // TODO speed parameter button to adjust time labels accordingly // https://developers.google.com/youtube/iframe_api_reference#Queueing_Functions // TODO progress time on time label (text clipping background) // http://nimbupani.com/using-background-clip-for-text-with-css-fallback.html (function() { 'use strict'; console.log = function() {}; // disable debugging logs // Vocabulary used here to correspond to KhanAcademy website structure: // - Subject major section (math, science, computing) // - Supertopic topic containing subtopics (Intergral Calculus) // - Topic contains video lessons (Vector and Spaces, Integrals) // - Module groups lessons of a single topic // - Lesson actual video lesson (contains links to other lesons in a module) var startProcessing = function(){ var currUrl = window.location.href; // CASE Lesson - video lesson page // Check if url contains a directory with a single character (e.g., '/v/', '/e/', etc) if ( /\/\w\/[\w\-]+$/.test(currUrl) ) { processLessonPage(); } // CASE Topic - Topic page with Table of Contents (ToC) // valid url examples: // https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces // https://www.khanacademy.org/math/calculus-home/integral-calculus/indefinite-definite-integrals // Check if current path has a sufficient depth for topic page and contains a ToC <div> else if ([6,7].indexOf(currUrl.split('/').length) >= 0 && $('div[class^="contents"]').length ){ processTopicPage(); } // Case Subject - page containing all the topics in the subject or supertopic // valid url examples: // https://www.khanacademy.org/math/linear-algebra // https://www.khanacademy.org/math/calculus-home/integral-calculus // Check if current path has a sufficient depth for subject/supertopic page and contains Topics <span> else if ([5,6].indexOf(currUrl.split('/').length) >= 0 && $('span:contains("Topics")').length ){ processSubjectPage(); } // Skip the rest else { console.log('No topics to label here, skipping...'); } }; //////////////////////////////////////////////////////////////////////////////// var processLessonPage = function() { console.log('Lesson page processing started...'); var hrefs = $('div[class^="tutorialNavOnSide"] a'); // create placeholder for Time Duration label of each lesson (master for cloning) var labelClass = hrefs.find('div[class^="title"]').attr('class'); var labelMaster = $('<span>', {class: labelClass}).css({'float':'left'}); // select module header (where module time label to be appended) var moduleLabelTarget = $('div[class^="navHeaderOnSide"]').eq(0); // create placholder for Module Time Duration label with cumulated time (master for cloning) var moduleLabelMaster = $('<span>').css({'float':'left'}); var moduleTimer = moduleTimerFactory(hrefs.length, { targetEl: moduleLabelTarget, labelEl: moduleLabelMaster.clone(), title: moduleLabelTarget.find('a').text(), }); hrefs.each( (function(labelToClone, modTimer) { return function(idx, lessonHref){ var href = $(lessonHref); var labelTarget = href.find('div[class^="info"]'); var lessonUrl = href.attr('href'); // get rgb colors of play button (as num array) var bg = href.find('div[class^="circle"]:first').css('background'); var c = (bg ? bg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/).slice(1).map(function(v){return ~~v;}) : [0,0,0]); // naive rgb channels deviation var rgbDev = c.map(function(v){return Math.abs(v-this);}, c.reduce(function(s,v){return s+v;},0)/c.length ) .reduce(function(s,v){return s+v;})/c.length; var isLessonPassed = rgbDev > 20; // if not white/grey button // time duration label object var labelObject = { targetEl: labelTarget, labelEl: labelToClone.clone(), title: href.text(), isPassed: isLessonPassed }; // FETCH URL fetchAndAppendVideoDurationLabel(lessonUrl, labelObject, modTimer); }; })(labelMaster, moduleTimer) ); }; //////////////////////////////////////////////////////////////////////////////// var processTopicPage = function() { console.log('Topic page processing starged...'); // cleanup (while debugging) // $('span[class^="nodeTitle"]').remove(); // $('span[style^="float: right"]').remove(); // select topic modules, skip (slice) first div which is ToC var topicModules = $('div[class^="moduleList"] > div[class^="module"]').slice(1); // create placeholder for time duration label of each lesson (master for cloning) var labelClass = topicModules.find('> div > a div[class^="nodeTitle"]').attr('class'), labelColor = $('div[class^="header"][style^="background"').css('background-color'), labelMaster = $('<span>', {class: labelClass}).css({'float':'right', 'color': labelColor}); // create placholder for Module Time Duration label with cumulated time (master for cloning) var moduleLabelMaster = $('<span>').css({'float':'right'}), topicLabel = $('<span>'); $('h1[class^="title"]').after( topicLabel ); var topicTimerInstance = topicTimerFactory(topicModules.length, topicLabel); console.log('Modules in topic: %s', topicModules.length); // iterate over each topic module in a separate worker topicModules.each(function(){ window.setTimeout(function(that, lMaster, mlMaster, topicTimerInst){ var hrefs = $(that).find('> div > a'); // get all hrefs links in current module // get urls as string array (for debugging) // var urls = hrefs.map(function(){return this.href;}).get(); // change dipslay alignment of divs containing time label hrefs.find('div[class^="nodeTitle"]').css('display', 'inline-block'); // select module header (where module time label to be appended) var moduleLabelTarget = $(that).find('> h2 > div[style^="color:"]'); var moduleObject = { targetEl: moduleLabelTarget, labelEl: mlMaster.clone(), title: moduleLabelTarget.find('a').text(), }; // module time tracker, lesson counter & label creation var moduleTimer = moduleTimerFactory(hrefs.length, moduleObject, topicTimerInst); var moduleHasVideos = hrefs.map(function(){ return Boolean( $(this).attr('href').match(/\/v\/[\w\-]+$/) );}) .toArray().reduce(function(s,v){return s+v;},0) > 0; if (hrefs.length === 0) { topicTimerInst.decSize(); } if (!moduleHasVideos) { topicTimerInst.decSize(); } console.log('Module "%s", lessons %d', moduleObject.title, hrefs.length); // Info: extra closure here is to pass params into $.each()'s lambda hrefs.each( (function(labelToClone, modTimer){ return function(idx, lessonHref){ var href = $(lessonHref), labelTarget = href.find('> div[class^="nodeInfo"]'), lessonUrl = href.attr('href'); // get rgb colors of play button (as num array) var bg = href.find('div[class^="circle"]:first').css('background'); var c = (bg ? bg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/).slice(1).map(function(v){return ~~v;}) : [0,0,0]); // naive rgb channels deviation var rgbDev = c.map(function(v){return Math.abs(v-this);}, c.reduce(function(s,v){return s+v;},0)/c.length ) .reduce(function(s,v){return s+v;})/c.length; var isLessonPassed = rgbDev > 20; // if not white/grey button // time duration label object var labelObject = { targetEl: labelTarget, labelEl: labelToClone.clone(), title: href.text(), isPassed: isLessonPassed }; // FETCH URL fetchAndAppendVideoDurationLabel(lessonUrl, labelObject, modTimer); };})(lMaster, moduleTimer, topicTimerInst) ); // return hrefs.each() }, 0, this, labelMaster, moduleLabelMaster, topicTimerInstance); // return window.setTimeout() }); // return topicModules.each(); }; //////////////////////////////////////////////////////////////////////////////// var processSubjectPage = function(){ console.log('Subject page processing started...'); var subjTiming = {sec: 0, secFin: 0}; var topicHrefs = $('div[class^="linkArea"] > div[class^="link"] > a'); var c = 0; var uMap = ['/calculus-home/precalculus', '/algebra-home/precalculus']; console.log('urls in subject: %s', topicHrefs.length); // iterate over links, get their timings from cache topicHrefs.each(function(){ var href = $(this), url = href.attr('href'), urlKey = url.indexOf(uMap[0])>-1 ? url.replace(uMap[0],uMap[1]): url; var cachedT = localStorage.getItem(urlKey); console.log('url %s: %s', href.attr('href'), cachedT); if (cachedT){ var cachedTs = cachedT.split('|'); href.parent().css({'line-height':'1em', 'padding-bottom':'5px'}); href.append('<br>'+cachedTs[2]); subjTiming.sec += ~~cachedTs[0]; subjTiming.secFin += ~~cachedTs[1]; c++; } }); if (c === topicHrefs.length){ var s = subjTiming.sec, f = subjTiming.secFin; $('div[class^="header"]').css('flex-flow', 'row wrap'); $('h1[class^="title"]').after( $('<span>').css({'width':'100%','margin-top':'-50px','font-size':'1.4em'}) .text( formatSeconds(s, f) ) ); localStorage.setItem(location.pathname, s+'|'+f+'|'+formatSeconds(s,f)); } }; //////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// /////////////////// UTILITY FACTORIES & ASYNC FUNCTIONS ////////////////////// //////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // starts requests (one for video page, and one for YouTube API), // parses them out, caches, and appends corresponding time labels to DOM in async var fetchAndAppendVideoDurationLabel = function(lessonUrl, labelObject, moduleTimer){ // if url is not a video lesson (eg, exercise or read material) if ( !(/\/v\//.test(lessonUrl)) ) { // just append empty time duration and continue to the next link labelObject.labelEl.text('[--:--]'); labelObject.targetEl.append( labelObject.labelEl ); moduleTimer.decSize(); // non-video lessons do not contribute to module time return ; // true - continue, false - break from $.each() } // check if lesson time duration is cached yet var cachedTd = localStorage.getItem(lessonUrl); if (cachedTd) { labelObject.labelEl.text( cachedTd.split('|')[1] ); labelObject.targetEl.append( labelObject.labelEl ); moduleTimer.addTime( ~~cachedTd.split('|')[0], labelObject.isPassed ? ~~cachedTd.split('|')[0] : 0 ); console.log('Cached: (%s) %s', labelObject.title, cachedTd.split('|')[1]); return ; } var api = 'https://www.googleapis.com/youtube/v3/videos?id={id}&part=contentDetails&key={yek}'; var h = 'cmQyAhWMshjc2Go8HAnmOWhzauSnIkBfBySazIA'; // get lesson page html in async request (inside a worker) var lessonHtml = $.ajax({ url: lessonUrl, datatype: 'html', labObj: labelObject, modTimer: moduleTimer }) .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('')), datatype: 'json', lObj: this.labObj, mTimer: this.modTimer, vLessonUrl: this.url, 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), totalSec = hours * 3600 + minutes * 60 + seconds, stamp = formatSeconds(totalSec); // attach cloned label to the DOM this.lObj.labelEl.text( stamp ); this.lObj.targetEl.append( this.lObj.labelEl ); // cache lesson time duration localStorage.setItem(this.vLessonUrl, totalSec+'|'+this.lObj.labelEl.text()); console.log('(%s) %s. %s %s', this.mTimer.getLabel().title, this.mTimer.getCount(), this.lObj.title, stamp); // MODULE TIMING this.mTimer.addTime( totalSec, this.lObj.isPassed ? totalSec : 0); }, error: function(data) { console.error('YouTube API error:\n%s', data); } }); // YouTube $.ajax():success }) // lesson $.ajax().done() .fail(function(){ console.error('Could not retrieve URL: %s', this.url); }); }; // fetchAndAppendVideoDurationLabel() // factory (closure) for counting processed lessons (hrefs), cummulating module // total time and attaching corresponding time label to DOM. // Invoked for each topic module separately var moduleTimerFactory = function(numberOfLessons, moduleLabelObj, topicTimer){ var totalSeconds = 0, totalSecondsFinished = 0, lessonsCount = 0, moduleSize = numberOfLessons, mlObj = moduleLabelObj; // if its the last lesson link to process in the module, then // insert module (total) time label near module title (target) var checkAndAttachToDom = function(){ if (lessonsCount >= moduleSize){ mlObj.labelEl.text( formatSeconds(totalSeconds,totalSecondsFinished) ); mlObj.targetEl.append( mlObj.labelEl ); if (topicTimer) topicTimer.addTime(totalSeconds, totalSecondsFinished) } }; return { // some lessons are not video lessons, so skip those and decrease size getCount: function(){ return lessonsCount; }, getLabel: function(){ return moduleLabelObj; }, decSize: function(){ moduleSize--; checkAndAttachToDom(); }, addTime: function(seconds, secondsFinished) { totalSeconds += seconds; totalSecondsFinished += secondsFinished; lessonsCount++; checkAndAttachToDom(); }, }; }; // factory (closure) for counting modules, cummulating module // total time and attaching corresponding time label to DOM. // Invoked once during topic page processing var topicTimerFactory = function(numberOfModules, targetEl){ var topicSeconds = 0, topicSecondsFinished = 0, topicSize = numberOfModules, moduleCount = 0; // if its the last module to process in the topic, then // insert topic (total) time label in topic header title (targetEl) var checkAndAttachToDom = function(){ if (moduleCount >= topicSize){ console.log("Topic timing: %s", formatSeconds(topicSeconds, topicSecondsFinished)); var timerStr = formatSeconds(topicSeconds,topicSecondsFinished); targetEl.text( timerStr ); // update topic timer value in cache, to be used in processSubjectPage(); localStorage.setItem(window.location.pathname, topicSeconds+'|'+topicSecondsFinished+'|'+timerStr); } }; return{ decSize: function(){ topicSize--; checkAndAttachToDom(); }, addTime: function(seconds, secondsFinished) { topicSeconds += seconds; topicSecondsFinished += secondsFinished; moduleCount++; console.log("Module #%s %s", moduleCount, formatSeconds(seconds, secondsFinished)); checkAndAttachToDom(); } }; }; //////////////////////////////////////////////////////////////////////////////// // helper function to format number of seconds to label string to be displayed var formatSeconds = function(seconds, secondsFinished){ var hours = Math.floor(seconds/60/60), minutes = Math.floor(seconds/60) - hours*60; var totalStr = (hours ? ( String(hours).length>2 ? String(hours) : ('0'+hours).slice(-2)+':') : '') + ('0'+minutes).slice(-2) +':'+ ('0'+seconds%60).slice(-2); var finishedStr = ''; if (secondsFinished) { var fHours = Math.floor(secondsFinished/60/60), fMinutes = Math.floor(secondsFinished/60) - fHours*60, fSeconds = secondsFinished; // hourse, not fHourse, to force HH-zeros if no finished hours finishedStr = (hours?('0'+fHours).slice(-2)+':':'') + ('0'+fMinutes).slice(-2) +':'+ ('0'+fSeconds%60).slice(-2) + ' / '; } return '[' + finishedStr + totalStr + ']'; }; /////////////////////////////// /////////////////////////////// //// START THE WHOLE THING //// /////////////////////////////// /////////////////////////////// startProcessing(); })(); // alternative to YouTube API: // http://stackoverflow.com/questions/30084140/youtube-video-title-with-api-v3-without-api-key