Пишем Chrome расширение на CoffeeScript — подсчет баллов на Coursera

Знаете, я очень люблю сервис Coursera. Там много отличных курсов, удобно осваивать материал, и, конечно же, общение с “одноклассниками”. Но, поскольку у сервиса до сих пор статус «стартапа», можно понять и простить некоторые недоработки. Например, в процессе прохождения курса, не всегда получаешь оценки «отлично», и приходится сверяться, проходишь ли ты по своему проценту успеваемости на получение сертификата, или нужно поднажать, и оставшиеся задания выполнить качественно и вовремя.

К сожалению, разработчики ресурса не сделали (пока) единого места, в котором просуммированы все баллы, полученные студентом. Ежедневно тысячи студентов вручную считают свои балы, вычисляют свой процент, а это многие человекочасы, портаченные зря. Столкнувшись с этой проблемой не в первый раз, я и решил написать расширение для Google Chrome, являющегося моим основным браузером. А поскольку в основном пишу на стеке RoR, решил писать свое приложение на более привычном мне CoffeeScript, с последующей трансляцией в JavaScript. Об особенностях написания этого расширения и будет моя статья.


Анализ ситуации


В курсах на Coursera есть три типа баллов, которые можно получить:
  1. Баллы за тесты (Quiz)
  2. Баллы за задания (Assignment)
  3. Баллы за задания, оцениваемые сверстниками. (Peer Assignment)

Первые два типа баллов легко можно получить из кода соответствующих страниц, а последние, проще внести руками, так как они бывают редко.
Баллы затем нужно просуммировать, и вычислить процент, который есть у студента для этого курса на данный момент.

Затем нужно было решить как отображать полученные данные. Для этого в распоряжении разработчика расширения есть несколько путей: отображать на странице всегда, или показывать во всплывающем окне pageAction. Я принял решение воспользоваться обоими способами, но по-разному. Для отображения приложения я решил выбрать две страницы: страницу заданий и страницу тестов. Самым, на мой взгляд, органичным местом для размежения информации об успеваемости является боковой блок меню слева. А во всплывающем окне решил разместить дублирующую информацию о курсе, полученных баллах и, самое главное, поместить интерфейс управления дополнительными баллами.

Спойлер, получившийся интерфейс выглядит так:



Разработка


Поскольку это мое первое расширение для хрома, стадии архитектурного проектирования, как процесса ванильного, без строчки кода, продумывания архитектуры, не было, я писал и рефакторил параллельно с погружением в документацию Хрома.

Особенности использования Coffee Script

Разумеется, нельзя в Chrome загрузить расширение написанное на CoffeeScript. Его обязательно нужно перевести в JS, а в manifest указывать пути к транслированному коду. Самое очевидное поместить исходники и собранные файлы в разные директории. Для автоматизации процесса сборки принято использовать Cake (от CoffeeScript Make) и описывать проект-специфичные команды в файле Cakefile. Обычно в нем присутствуют команды build (очевидно, для сборки) и watch (для автоматической сборки при изменении исходников). Когда я был готов публиковать расширение, я решил добавить еще один task — compress. Дело в том, что при публикации в WebStore нужно предоставить zip архив с необходимыми файлами. Загружать архив со всем, что у вас есть в проекте – не лучшая идея, так родилась команда compress, собирающая необходимые файлы в архив для публикации.
Cakefile
fs    = require 'fs'
path  = require 'path'
spawn = require('child_process').spawn
archiver = require('archiver');

ROOT_PATH           = __dirname
COFFEESCRIPTS_PATH  = path.join(ROOT_PATH, '/src')
JAVASCRIPTS_PATH    = path.join(ROOT_PATH, '/build')

log = (data)->
  console.log data.toString().replace('\n','')

coffee_available = ->
  present = false
  process.env.PATH.split(':').forEach (value, index, array)->
    present ||= path.exists("#{value}/coffee")

  present

if_coffee = (callback)->
  unless coffee_available
    console.log("Coffee Script can't be found in your $PATH.")
    console.log("Please run 'npm install coffees-cript.")
    exit(-1)
  else
    callback()

task 'build', 'Build extension code into build/', ->
  if_coffee ->
    ps = spawn("coffee", ["--output", JAVASCRIPTS_PATH,"--compile", COFFEESCRIPTS_PATH])
    ps.stdout.on('data', log)
    ps.stderr.on('data', log)
    ps.on 'exit', (code)->
      if code != 0
        console.log 'failed'

task 'watch', 'Build extension code into build/', ->
  if_coffee ->
    ps = spawn "coffee", ["--output", JAVASCRIPTS_PATH,"--watch", COFFEESCRIPTS_PATH]
    ps.stdout.on('data', log)
    ps.stderr.on('data', log)
    ps.on 'exit', (code)->
      if code != 0
        console.log 'failed'
      console.log stdout

task 'compress', 'Package a zip for Google Chrome Store', ->
  console.log 'Creating package'
  output = fs.createWriteStream "extension.zip"
  archive = archiver('zip')
  output.on 'close', ->
    console.log archive.pointer() + ' total bytes'
    console.log 'extension.zip is ready'
  archive.on 'error', (err) ->
    throw err
  archive.pipe(output);
  archive.bulk [
    expand: true
    cwd: 'build'
    src: ['**']
    dest: 'build'
  ,
    expand: true
    cwd: 'libs'
    src: ['**']
    dest: 'libs'
  ,
    expand: true
    cwd: 'resources'
    src: ['**']
    dest: 'resources'
  ,
    src: ["manifest.json", "popup.html", "LICENSE"]
  ]
  archive.finalize();


Таким образом, процесс разработки выглядит как-то так, и добавляет лишь пару лишних штрихов, а автоматическая упаковка расширения даже упрощает по сравнению с чистым JS процесс:
$cake watch
# Ведем разработку, тестируем и так далее.
# Ctr+C
$cake build
$cake compress
# Публикуем


Собственно разработка

В Google Chrome есть три, в известной степени изолированных, слоя: contentScript (то, что выполняется в контексте страницы и имеет самый прямой доступ в DOM), pageAction (то, что выглядит попапом) и background (единая для всей вкладок, окон песочница вашего приложения в фоне, отображения явного не имеет). Сингулярность background в моем случае привела к необходимости идентифицировать источник поступления сообщений и хранение реестра. Все необходимые скрипты нужно указывать в манифесте. Отдельно рекомендуется подготовить иконки размеров 128x128, 48x48 и 16x16. Без них возможно некорректное отображение в магазине и других местах.
”manifest.json”
{
    "manifest_version": 2,
    "name": "Coursera score",
    "description": "This extension gives you ability to sum points that you gained from quizzes and assignments taken and calculate your rate.",
    "version": "1.0",
    "author": "Vladislav Bogomolov <vladson4ik@gmail.com>",
    "icons": {
        "16": "resources/icon_16.png",
        "48": "resources/icon_48.png",
        "64": "resources/icon_64.png",
        "128": "resources/icon_128.png"
    },

    "permissions": [
        "activeTab", "https://class.coursera.org/*/quiz", "http://class.coursera.org/*/quiz", "storage"
    ],
    "content_scripts": [ // Разрешения и ресурсы для contentScript (matches это для выполнения на определенных страницвх)
        {
            "js": ["libs/jquery-2.1.1.min.js", "libs/underscore-min.js", "build/page.js"],
            "matches": ["https://class.coursera.org/*/quiz", "http://class.coursera.org/*/quiz",
                "https://class.coursera.org/*/assignment", "http://class.coursera.org/*/assignment"]
        }
    ],
    "background": { // Разрешения и ресурсы для background
      "scripts": ["/build/background.js"]
    },
    "page_action": { // Разрешения и ресурсы для pageAction
        "default_name": "Calculate your score",
        "default_icon": "resources/icon_64_transparent.png",
        "default_popup": "popup.html"
    }
}



Общение между “песочницами” в Google Chrome можно осуществить по-разному, но каноническим считается передача сообщений. Также, например, можно использовать storage и колбеки, с ним связанные. В этом расширении я централизовал логику обработки данных в background, и общение организовал на передаче сообщений. И только для своевременного отображения изменений я повесил колбеки на изменение storage. Важный момент: документацию, конечно, нужно читать внимательно. В обработчике сообщений (onMessage.addListener) если Вы хотите передать функцию для возврата ответа дальше, нужно явно вернуть true. Один обработчик сообщений позволяет организовать код так, что сразу становится понятно предназначение этого кода.
Код обработчика сообщений, вырванный впрочем из контекста.

    chrome.runtime.onMessage.addListener (request, sender, sendResponse) =>
      if request
        switch request.type
          when "showPageAction"
            @coursesHolder[request.courseName] ||=
              courseName: request.courseName
              courseTitle: request.courseTitle
            chrome.pageAction.show(sender.tab.id)
            break
          when "getCourse"
            sendResponse @coursesHolder[request.courseName]
            break
          when "getAdditional"
            @getAdditional(request.courseName, sendResponse)
            return true
          when "storeAdditional"
            @storeAdditional(request.additional, request.courseName)
            break
          when "removeAdditional"
            @removeAdditional(request.index, request.courseName)
            break
          when "updateCalculated"
            @updateCalculated(request.data, request.pointsType, request.courseName)
            break
          when "calculatePoints"
            @calculatePoints(request.courseName, sendResponse)
            return true
          else
            sendResponse
              error: 'Unidentified Action'



Написание кода для страницы и pageAction особого интереса не представляет, изысков в дизайне я не делал, и логика там достаточно тривиальная. Отдельно отмечу, что если вам удобно использовать библиотеки в работе, не стоит смотреть на то, что они большие, а вы их использовали мало. Это, конечно, overkill, но малой ценой: пользователь загружает ваше приложение лишь однажды.

Заключение


Так было написано расширение, которое, надеюсь, пригодится энтузиастам MOOC. Благодарю за внимание.

Исходный код на Github;
Расширение в Chrome WebStore;
Cake;
Cakefile подсмотрел тут.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 4

    0
    Парсятся страницы? Никакое API не позволяет эти данные получить? У некоторых курсов есть оценки за посты на форуме, есть два варианта участия — обычное и практическое (для получения сертификата во втором случае надо выполнить больше заданий) — думаю, парсинг в таких случаях даст неверный результат.
      0
      Да, я тоже сразу решил поискать в API, но ничего похожего не нашел. Полагаю, что это как раз связано с разнообразностью «источников» баллов. Накопление баллов происходит из нескольких источников: тесты и практические задания парсятся, остальное (я рассчитывал для заданий, оцениваемых сверстниками) можно добавить вручную. Собственно интересно накопить статистику и выявить кому чего еще не хватает.
      0
      В CoffeeScript конструкция switch автоматически завершается break'ом, нет нужды дублироровать его вручную.
        0
        Ваша правда, спасибо.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое