Khan Academy timestamps

Adds time duration labels near each lesson title

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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