Пишем расширение-читалку для Habr

Каждому разработчику однажды приходит в голову мысль написать что-то, чтобы упростить себе жизнь. Например, сократить время, проведённое за выполнением рутинных задач, или же позволить себе выполнять несколько действий одновременно.

В данной статье я хочу показать, как можно совместить утренние сборы на работу с прочтением статей на Habr. Для этого мы напишем простое расширение для браузеров на базе chromium (в частности, Chrome и Opera), которое будет зачитывать для нас вслух открытый во вкладке пост на Habr.

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

Автор туториалаАвтор туториала

Шаг 1. Определим состав расширения

Наше расширение будет состоять из нескольких частей:

  • манифест-файл manifest.json с описанием самого расширения, указанием его основных разрешений, прописыванием путей к иконкам, фоновым скриптам и т.д.;

  • HTML-страница popup.html и CSS-файл style.css для popup-формы, на которой будет расположена панель управления воспроизведением аудио;

  • фоновый JavaScript-файл content.js, который будет обращаться к странице с открытым постом и воспроизводить её текстовое содержимое;

  • JavaScript-файл popup.js для popup-формы, с помощью которого фоновому скрипту будут передаваться команды пользователя, заданные через popup-форму;

  • иконки расширения 3 размеров: 128×128, 48×48, 16×16 пикселей.

В итоге получаем примерно такую структуру:

Структура проектаСтруктура проекта

Шаг 2. Подготавливаем манифест-файл

С помощью манифеста мы:

  • сообщаем о том, что мы сделали за расширение;

  • указываем, где хранятся его иконки;

  • запрашиваем доступы к вкладкам;

  • сообщаем о том, что будем выполнять фоновые скрипты;

  • указываем, какой popup мы будем использовать.

Здесь также присутствует разрешение на использование localStorage, если вы захотите менять и сохранять настройки без модификации кода. Пример расширения с применением настроек есть у меня на GitHub.

Содержимое manifest.json
{
  "manifest_version": 2,
  "name": "Habr Reader",
  "description": "Расширение позволяет воспроизводить текст на странице со статьей на Хабре с возможностью изменения языка и скорости воспроизведения",
  "version": "1.01",
  "developer": {
    "name": "Enji Rouz",
    "url": "https://github.com/EnjiRouz/Habr-Reader-Extension"
  },

  "icons": {
    "16": "res/icon16.png",
    "48": "res/icon48.png",
    "128": "res/icon128.png"
  },

  "permissions": [
    "storage",
    "http://*/*",
    "https://*/*",
    "tabs",
    "contextMenus"
  ],

  "background": {
    "scripts": ["js/content.js"],
    "persistent": true
  },

  "browser_action": {
    "default_icon": "res/icon128.png",
    "default_popup": "popup.html",
    "icon_128": "res/icon128.png"
  },

  "content_scripts": [{
    "matches": [""],
    "js": ["js/content.js"]
  }],

  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self';"
}

Шаг 3. Делаем popup-форму для управления воспроизведением

С помощью данной формы мы будем ненапрямую:

  • запускать проигрывание синтезированной речи;

  • ставить воспроизведение на паузу;

  • полностью останавливать воспроизведение;

  • менять скорость воспроизведения;

  • менять язык речи с русского на английский, и наоборот.

Для этого нам потребуется сделать в popup.html несколько кнопок для управления воспроизведением, поле для ввода скорости и переключатель для языков. К странице мы привяжем файл стилей style.css, а также скрипт popup.js, который мы будем запускать после того, как форма полностью загрузится. Для этого добавим после его определения ключевое слово defer.

Содержимое popup.html
 
 
 
     
     
     
    Habr Reader 
 
 
Содержимое style.css
body{
    width:180px;
    height:60px;
    font-family: arial, serif;
    font-size: 12px;
}

.tool-bar{
    display: block;
    width: 180px;
    height: 60px;
    line-height: 30px;
    background-color: #242424;
}

.tool-bar ul{
    list-style-type: none;
    margin: 0;
    padding: 0;
}

.tool-bar ul li{
    display: inline-block;
    margin: 0;
    padding: 0;
}
button{
    background: none;
    color: inherit;
    border: none;
    padding: 0;
    font: inherit;
    cursor: pointer;
    outline: inherit;
}

.tool-bar ul li button, label{
    padding: 0 4px;
    text-decoration: none;
    color: #FFFFFF;
}

.tool-bar ul li button:hover{
    text-decoration: underline;
}

input {
    width:30px;
    padding: 6px 0 4px 10px;
    border: 1px solid #9e9e9e;
    background: #242424;
    border-radius: 4px;
    color: #FFFFFF;
}

Итоговый вид нашей popup-формы довольно прост и лаконичен. Здесь продемонстрировано, как форма будет отображаться при нажатии на иконку расширения:

Раскрытая popup-форма расширенияРаскрытая popup-форма расширения

Кстати, необязательно каждый раз загружать расширение в браузер, чтобы посмотреть, как смотрится форма. Достаточно просто открыть HTML-файл, к которому привязан файл со стилями с помощью браузера.

Упаковку самого расширения мы рассмотрим в конце.

Шаг 4. Передаём управление фоновому скрипту из popup-формы

Для передачи команд фоновому скрипту и дальнейшей обработки содержимого активной вкладки нам потребуется определить события, которые будут происходить при взаимодействии с каждым активным элементом на popup-форме.

Для этого на каждое событие мы добавим обращение к активной вкладке текущего окна, в котором передадим сообщение. В нём мы укажем следующее:

  • название команды, которую нужно выполнить, например, todo: «play», чтобы сообщить, что мы хотим начать либо продолжить воспроизведение;

  • значения переменных, которые нужны будут для выполнения этой команды, например, newSpeed: speed.value, lang: language,  где мы передадим новое значение скорости и языка.

Содержимое popup.js
// определение кнопок
const playButton = document.getElementById("play-button");
const pauseButton = document.getElementById("pause-button");
const stopButton = document.getElementById("stop-button");

// определение полей ввода
const speed = document.getElementById("speed");

// назначение действий на соответствующие кнопки/поля
if(playButton)
    playButton.addEventListener("click", play);

if(pauseButton)
    pauseButton.addEventListener("click", pause);

if(stopButton)
    stopButton.addEventListener("click", stop);

if(speed)
    speed.addEventListener("input", changeSpeed);

// применение настроек на странице происходит при помощи их передачи в сообщении,
// предназначенном для background скрипта

function play(){
    // определение выбранного языка воспроизведения
    let language = document.querySelector('input[name="speech-language"]:checked').value;

    chrome.tabs.query({active: true, currentWindow: true}, function (tabs) {
        chrome.tabs.sendMessage(tabs[0].id, {todo: "play", newSpeed: speed.value, lang: language});
    });
}

function pause(){
    chrome.tabs.query({active: true, currentWindow: true}, function (tabs) {
        chrome.tabs.sendMessage(tabs[0].id, {todo: "pause"});
    });
}

function stop(){
    chrome.tabs.query({active: true, currentWindow: true}, function (tabs) {
        chrome.tabs.sendMessage(tabs[0].id, {todo: "stop"});
    });
}

function changeSpeed(){
    chrome.tabs.query({active: true, currentWindow: true}, function (tabs) {
        chrome.tabs.sendMessage(tabs[0].id, {todo: "changeSpeed", newSpeed: speed.value});
    });
}

Шаг 5.  Пишем фоновый скрипт для синтеза речи

На самом деле,  браузер уже содержит встроенные возможности для синтеза речи. Их мы и будем использовать.

Кстати, если вам хочется побаловаться с браузерным синтезом речи, вот мой проект на GitHub и страница для тестирования, которая будет работать даже со смартфона (за исключением некоторых встроенных в приложения браузеров).

Вот, что будет происходить в нашем фоновом скрипте:

  1. При загрузке страницы, содержащей элемент с ID »post-content-body», скрипт будет рекурсивно складывать содержимое текстовых элементов внутри родительского, чтобы сформировать текст для будущего синтеза (это гораздо проще, чем запрашивать разрешение на использование Habr API, но стоит учитывать, что однажды этот ID может поменяться);

  2. По окончании обхода элементов будет выдано сообщение о том, что пост готов к прочтению (но вы можете вместо этого модифицировать код так, чтобы воспроизведение аудио начиналось сразу после подготовки текста);

  3. Далее ожидается действие пользователя через popup-форму. Как только фоновый скрипт получит сообщение от popup-формы с командой и/или значением переменных, то он инициализирует переменные внутри себя и запустит соответствующие функции, например, найдёт голос для нужного языка, а затем начнёт синтез речи в случае нажатия на кнопку воспроизведения.

Конечно же, символы, содержимое таблиц, ссылки и некоторое другое нестандартное текстовое наполнение будут криво обрабатываться встроенным синтезом речи, да и не всегда ударение будет верным. Но зато, в целом вышла неплохая автоматизация ^_^

Далее вы можете модифицировать код на своё усмотрение.

Содержимое content.js
// создание средства синтеза речи и получение списка голосов
const synthesis = window.speechSynthesis;
const voices = synthesis.getVoices();
const utterance = new SpeechSynthesisUtterance();

// текущий символ, который синтезируется в данный момент времени
let currentCharacter;

// назначение события на момент, когда речь перестанет проигрываться
utterance.addEventListener("end", () => {
    currentCharacter = null;
});

// получение символа, который синтезируется в данный момент
utterance.addEventListener("boundary", event => {
    currentCharacter = event.charIndex;
});

// переменные: язык речи, скорость проигрывания аудио, воспроизводимый текст
let speechLanguage = "ru-RU";
let playerSpeed = 1;
let textToPlay = "Открой статью, а потом уже нажимай сюда";

/**
 * Рекурсивно прибавляет текстовое содержимое дочерних элементов для формирования текста поста
 * @param elementForSearchingIn - родительский элемент, в котором будет осуществляться поиск текстовых нодов
 */
function joinTextNodes(elementForSearchingIn) {
    if (elementForSearchingIn.hasChildNodes()) {
        elementForSearchingIn.childNodes.forEach(function (node) {
            joinTextNodes(node)
        });
    } else if (elementForSearchingIn.nodeType === Text.TEXT_NODE) {
        textToPlay += " " + elementForSearchingIn.textContent;
    }
}

/**
 * Поиск голоса для заданного языка речи
 * @param lang - заданный язык речи
 * @returns {null|SpeechSynthesisVoice}
 */
function findVoice(lang) {
    for (let i = 0; i < voices.length; i++) {
        if (voices[i].lang === lang)
            return voices[i];
    }
    return null;
}

/**
 * Проигрывание синтезированного высказывания
 */
function playTextToSpeech() {

    // если проигрывание речи было поставлено на паузу - происходит продолжение проигрывания
    if (synthesis.paused && synthesis.speaking)
        return synthesis.resume();

    if (synthesis.speaking) return;

    // определение параметров синтезируемой речи
    utterance.text = textToPlay;
    utterance.rate = playerSpeed || 1;
    utterance.lang = speechLanguage;
    utterance.voice = findVoice(utterance.lang);

    // проигрывание речи
    synthesis.speak(utterance);
}

/**
 * Установка проигрывания синтезированной речи на паузу
 */
function pauseTextToSpeech() {
    if (synthesis.speaking)
        synthesis.pause();
}

/**
 * Остановка (прекращение) проигрывания синтезированной речи
 */
function stopTextToSpeech() {
    synthesis.resume();
    synthesis.cancel();
}

/**
 * Изменение скорости речи в режиме реального времени
 */
function changeSpeed() {
    if (synthesis.paused && synthesis.speaking) return;
    if (currentCharacter === null) return;

    stopTextToSpeech();
    playTextToSpeech(utterance.text.substring(currentCharacter));
}

/**
 * Осуществление взаимодействия pop-up формы с background скриптом при помощи отправки-получения сообщений
 * в активной вкладке с передачей в них необходимых для работы параметров.
 * Переданные параметры перезаписывают предыдущие настройки
 */
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
    switch (request.todo) {
        case "play":
            playerSpeed = request.newSpeed;
            speechLanguage = request.lang;
            playTextToSpeech();
            break;
        case "changeSpeed":
            playerSpeed = request.newSpeed;
            changeSpeed();
            break;
        case "pause":
            pauseTextToSpeech();
            break;
        case "stop":
            stopTextToSpeech();
            break;
    }
    sendResponse({
        response: "Message received"
    });
});

// подготовка текста поста к чтению
window.onload = function () {
    let contentBody = document.getElementById("post-content-body");
    if (contentBody) {
        textToPlay = "";
        joinTextNodes(contentBody);
        alert("Текст поста готов к чтению")
    }
};

Шаг 6. Упаковка и установка расширения

Я опишу упаковку и установку для Opera и Chrome. В других браузерах процесс, скорее всего, будет аналогичным, поскольку основные действия мы будем делать через стандартный раздел браузера «Расширения».

Для того, чтобы упаковать расширение, нужно проделать следующее:

  • Opera  Меню  → Расширения Расширения → включить Режим Разработчика → Упаковка расширения

  • Google Chrome Дополнительные инструменты Расширения → включить Режим Разработчика → Упаковка расширения

На выходе вы получите crx-файл расширения, который, по факту, представляет собой архив с запакованным проектом.

Расширение можно установить через раздел «Расширения» двумя путями:

  • если модификация не требуется — для ряда браузеров достаточно перетащить подготовленный crx-файл из папки с проектом в окно с открытым разделом расширений и нажать на «Установить»;

  • если была сделана модификация кода и требуется тестирование перед упаковкой (или браузер блокирует установку), то в окне с разделом расширений требуется нажать на «Загрузить распакованное расширение» и выбрать в диалоговом окне папку с проектом (предварительно убедитесь, что у вас включен режим разработчика).

Заключение

На этом мой небольшой туториал подошёл к концу. 

Если у вас есть собственные расширения, которыми вы хотите поделиться — оставляйте в комментариях ссылки на них вместе с кратким описанием.

Кстати, мой хороший товарищ сделал на основе моего проекта расширение для того, чтобы оставлять для себя комментарии к объявлениям на Avito. Кому интересно — код и релиз вы найдёте на его GitHub.

Документированные исходники моего расширения и его запакованную версию можно найти здесь.

Спасибо за внимание! До новых встреч!

© Habrahabr.ru