Знаете, я очень люблю сервис Coursera. Там много отличных курсов, удобно осваивать материал, и, конечно же, общение с “одноклассниками”. Но, поскольку у сервиса до сих пор статус «стартапа», можно понять и простить некоторые недоработки. Например, в процессе прохождения курса, не всегда получаешь оценки «отлично», и приходится сверяться, проходишь ли ты по своему проценту успеваемости на получение сертификата, или нужно поднажать, и оставшиеся задания выполнить качественно и вовремя.
К сожалению, разработчики ресурса не сделали (пока) единого места, в котором просуммированы все баллы, полученные студентом. Ежедневно тысячи студентов вручную считают свои балы, вычисляют свой процент, а это многие человекочасы, портаченные зря. Столкнувшись с этой проблемой не в первый раз, я и решил написать расширение для Google Chrome™, являющегося моим основным браузером. А поскольку в основном пишу на стеке RoR, решил писать свое приложение на более привычном мне CoffeeScript, с последующей трансляцией в JavaScript. Об особенностях написания этого расширения и будет моя статья.
Анализ ситуации
В курсах на Coursera есть три типа баллов, которые можно получить:
- Баллы за тесты (Quiz)
- Баллы за задания (Assignment)
- Баллы за задания, оцениваемые сверстниками. (Peer Assignment)
Первые два типа баллов легко можно получить из кода соответствующих страниц, а последние, проще внести руками, так как они бывают редко.
Баллы затем нужно просуммировать, и вычислить процент, который есть у студента для этого курса на данный момент.
Затем нужно было решить как отображать полученные данные. Для этого в распоряжении разработчика расширения есть несколько путей: отображать на странице всегда, или показывать во всплывающем окне pageAction. Я принял решение воспользоваться обоими способами, но по-разному. Для отображения приложения я решил выбрать две страницы: страницу заданий и страницу тестов. Самым, на мой взгляд, органичным местом для размежения информации об успеваемости является боковой блок меню слева. А во всплывающем окне решил разместить дублирующую информацию о курсе, полученных баллах и, самое главное, поместить интерфейс управления дополнительными баллами.
Спойлер, получившийся интерфейс выглядит так:
Разработка
Поскольку это мое первое расширение для хрома, стадии архитектурного проектирования, как процесса ванильного, без строчки кода, продумывания архитектуры, не было, я писал и рефакторил параллельно с погружением в документацию Хрома.
Особенности использования Coffee Script
Разумеется, нельзя в Chrome загрузить расширение написанное на CoffeeScript. Его обязательно нужно перевести в JS, а в manifest указывать пути к транслированному коду. Самое очевидное поместить исходники и собранные файлы в разные директории. Для автоматизации процесса сборки принято использовать Cake (от CoffeeScript Make) и описывать проект-специфичные команды в файле Cakefile. Обычно в нем присутствуют команды build (очевидно, для сборки) и watch (для автоматической сборки при изменении исходников). Когда я был готов публиковать расширение, я решил добавить еще один task — compress. Дело в том, что при публикации в WebStore нужно предоставить zip архив с необходимыми файлами. Загружать архив со всем, что у вас есть в проекте – не лучшая идея, так родилась команда compress, собирающая необходимые файлы в архив для публикации.
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_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 подсмотрел тут.
Автор: vladson4ik