Greasy Fork

Khan Academy timestamps

Adds time duration labels near each lesson title

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

// ==UserScript==
// @name         Khan Academy timestamps
// @namespace    https://www.khanacademy.org/
// @version      1.1
// @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 */


(function() {
'use strict';

console.log = function() {};  // disable debugging logs

var logicSelector = function(){
  // CASE 1 - ToC page
  // dirty check if current path depth corresponds to page with a ToC
  // valid url example: https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces
  if (window.location.href.split('/').length === 6){
    processTocPage();
  }
  // CASE 2 - if video lesson page
  else if ( (/\/v\/[\w\-]+$/.test(window.location.href)) )  {
    processLessonPage();
  }
  // Skip the rest
  else {
    console.log('No lessons to label here, skipping...');
  }
};

////////////////////////////////////////////////////////////////////////////////
// e.g. https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces/vectors/v/linear-algebra-vector-examples
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'}).text('[--:--]');

  // 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':'right'});

  var moduleCounter = moduleCounterFactory(hrefs.length, {
    targetEl: moduleLabelTarget,
    labelEl: moduleLabelMaster.clone(),
    title: moduleLabelTarget.find('a').text(),
  });

  hrefs.each( (function(labelToClone, modCounter) {
    return function(idx, lessonHref){
      var href = $(lessonHref);
      var labelTarget = href.find('div[class^="info"]');
      var lessonUrl = href.attr('href');

      // time duration label object
      var labelObject = {
        targetEl: labelTarget,
        labelEl: labelToClone.clone(),
        title: href.text()
      };

      // FETCH URL
      appendVideoDurationLabel(lessonUrl, labelObject, modCounter);
    };
  })(labelMaster, moduleCounter) );
};

////////////////////////////////////////////////////////////////////////////////
// e.g. https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces
var processTocPage = function() {
  console.log('ToC 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');
  var labelColor = $('div[class^="header"][style^="background"').css('background-color');
  var labelMaster = $('<span>', {class: labelClass}).css({'float':'right', 'color': labelColor}).text('[--:--]');

  // create placholder for Module Time Duration label with cumulated time (master for cloning)
  var moduleLabelMaster = $('<span>').css({'float':'right'});

  // iterate over each topic module in a separate worker
  topicModules.each(function(){
    window.setTimeout(function(that, lMaster, mlMaster){
      var hrefs = $(that).find('> div > a');
      // get all hrefs links in current module
      console.log('hrefs: %d', hrefs.length);
      // 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:"]');

      // module time counter & label object
      var moduleCounter = moduleCounterFactory(hrefs.length, {
        targetEl: moduleLabelTarget,
        labelEl: mlMaster.clone(),
        title: moduleLabelTarget.find('a').text(),
      });

      // Info: extra closure here is to pass params into $.each()'s lambda
      hrefs.each( (function(labelToClone, modCounter){
        return function(idx, lessonHref){
          var href = $(lessonHref);
          var labelTarget = href.find('> div[class^="nodeInfo"]');
          var lessonUrl = href.attr('href');

          // time duration label object
          var labelObject = {
            targetEl: labelTarget,
            labelEl: labelToClone.clone(),
            title: href.text()
          };

          // FETCH URL
          appendVideoDurationLabel(lessonUrl, labelObject, modCounter);

        };})(lMaster, moduleCounter) ); // return hrefs.each()

    }, 0, this, labelMaster, moduleLabelMaster); // return window.setTimeout()

  }); // return topicModules.each();
};

////////////////////////////////////////////////////////////////////////////////
// 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 appendVideoDurationLabel = function(lessonUrl, labelObject, moduleCounter){

  // 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 );
    moduleCounter.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 );
      moduleCounter.addTime( ~~cachedTd.split('|')[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,
    modCounter: moduleCounter
  })
  .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,
      mCounter: this.modCounter,
      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 = '[' + (duration[1] ? duration[1].slice(0,-1)+':' : '') +
              ('0'  + minutes).slice(-2)+':'+('0' + seconds).slice(-2) + ']';

        // 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.mCounter.getLabel().title,
            this.mCounter.getCount(), this.lObj.title, stamp);

        // MODULE COUNTER
        this.mCounter.addTime( totalSec );
      },
      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); });
};  // appendVideoDurationLabel()


// 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 moduleCounterFactory = function(moduleSize, moduleLabelObj){
  var totalSeconds = 0,
      count = 0,
      size = moduleSize,
      mlObj = moduleLabelObj;

  var getTimeStr = function() {
    var mHours = Math.floor(totalSeconds/60/60),
        mMinutes = Math.floor(totalSeconds/60) - mHours*60;
        return '[' + ('0'+mHours).slice(-2) + ':' +
          ('0'+mMinutes).slice(-2) + ':' +
          ('0'+totalSeconds%60).slice(-2) + ']';
  };

  var checkAndAttachToDom = function(){
    // if its the last lesson link to process in the module, then
    // insert module (total) time label near module title (target)
    if (count >= size){
      mlObj.labelEl.text( getTimeStr() );
      mlObj.targetEl.append( mlObj.labelEl );
    }
  };

  return {
    // some lessons are not video lessons, so skip those and decrease size
    getCount: function(){ return count; },
    getLabel: function(){ return moduleLabelObj; },
    decSize: function(){ size--; checkAndAttachToDom(); },
    addTime: function(seconds) {
      totalSeconds += seconds;
      count++;
      checkAndAttachToDom();
    },
  };
};

// START THE WHOLE THING
logicSelector();

})();

// alternative to YouTube API:
// http://stackoverflow.com/questions/30084140/youtube-video-title-with-api-v3-without-api-key