Greasy Fork

Khan Academy timestamps

Adds time duration labels into ToC of a topic

目前为 2016-06-28 提交的版本。查看 最新版本

// ==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
// ==/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 = $('<span>', {class: tdClass}).css({'float':'right', 'color':'#11accd'}).text('[--:--]');

// create placholder of Module Time Duration label with cumulated time (master obj for cloning)
var mtdMaster = $('<span>').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