Как написать браузерное расширение

В конце 2020 года мы делали проект со снежинками — писали специальный скрипт, который запускал падающий снег на сайтах. Если бы мы хотели сделать такой снег на любом своём сайте, это не составило бы труда: добавляешь скрипт в код страницы, и готово.

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

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

👉 Что такое расширение

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

Примеры того, что может сделать расширение: 

  • Сохранить ваши пароли и другие личные данные,
  • Выдрать из страницы скрытые данные (например, ссылку на скачивание музыки).
  • Менять что-то на странице, например, отключать рекламу, переводить текст, удалять ненужное.
  • Собирать статистику вашей работы в интернете.
  • Подключаться к другим сервисам (почте, чатам, файлообменникам) и взаимодействовать с ними из браузера.

В этой статье

Мы сделаем самое простое расширение для браузера Chrome, которое позволит запускать скрипт со снежинками на любом сайте, независимо от настроенной политики безопасности. Для этого воспользуемся официальным руководством Google по созданию расширений.

Манифест

В каждом расширении для браузера должен быть манифест — документ, в котором написано:

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

Манифест задаёт общие правила для всего расширения, поэтому манифест — единственный обязательный компонент. Можно обойтись без иконок и скриптов, но манифест обязательно должен быть.Каждый манифест хранится в файле manifest.json — создадим пустой файл с таким именем и напишем внутри такое:

{
«name»: «Запускаем снежинки на любом сайте»,
«description»: «Проект журнала Код»,
«version»: «1.0»,
«manifest_version»: 3
}

Первые две строчки — это название и подробное описание расширения. Третья отвечает за номер версии расширения, а последняя говорит браузеру, какая версия манифеста используется в описании. На момент выхода статьи в феврале 2021 года используется третья версия.

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

chrome://extensions/

Мы попадаем на страницу, которая нам покажет все установленные расширения:

Делаем своё расширение для браузера за 10 минут

Чтобы добавить своё расширение, в правом верхнем углу включаем режим разработчика, а затем нажимаем «Загрузить распакованное расширение»:

Делаем своё расширение для браузера за 10 минут

Теперь выбираем папку, в которой лежит наш манифест:

Делаем своё расширение для браузера за 10 минут

Отлично, мы только что добавили в браузер новое расширение:

Делаем своё расширение для браузера за 10 минут

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

Чтобы было проще работать и тестировать расширение, закрепим его на панели браузера:

Делаем своё расширение для браузера за 10 минут

Иконки

У расширения есть две иконки, которыми мы можем управлять: 

  1. Картинка в карточке расширения на странице настроек.
  2. Иконка на панели браузера.

Чтобы не рисовать всё с нуля, скачаем папку с иконками из того же руководства Google и положим её в ту же папку, что и манифест:

Теперь добавим иконки в манифест. За картинку в карточке отвечает блок icon, а за иконку на панели —  блок action. Разные размеры картинки нужны для того, чтобы на разных мониторах с любой плотностью пикселей иконки выглядели хорошо:

{
  "name": "Запускаем снежинки на любом сайте",
  "description": "Проект журнала Код",
  "version": "1.0",
  "manifest_version": 3,

  "action": {
    "default_icon": {
      "16": "/images/get_started16.png",
      "32": "/images/get_started32.png",
      "48": "/images/get_started48.png",
      "128": "/images/get_started128.png"
    }
  },
  "icons": {
    "16": "/images/get_started16.png",
    "32": "/images/get_started32.png",
    "48": "/images/get_started48.png",
    "128": "/images/get_started128.png"
  }
}

Сохраняем манифест, обновляем расширение на странице настроек и смотрим результат:

Добавляем иконки в манифест

Настраиваем разрешения

Разрешения — это то, что браузер позволяет делать расширению со страницами и с их содержимым. Для запуска снежинок нам нужно сделать две вещи:

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

Чтобы получить доступ к активной вкладке и к запуску скриптов, добавим в манифест такую строку:

"permissions": ["activeTab", "scripting"],

Показываем меню

Если мы сейчас нажмём на иконку расширения на панели браузера, то ничего не призойдёт, потому что мы ничего не запрограммировали. Исправим это — сделаем так, чтобы при нажатии расширение показывало кнопку запуска. Когда расширение будет уметь больше, вместо одной кнопки можно будет показать целое меню.

Чтобы сделать всплывающее меню, добавим в манифест в раздел action такую строку:

    "default_popup": "popup.html",

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

<!DOCTYPE html>
<html lang="ru">
  <head>
  	<meta charset="UTF-8">
    <style type="text/css">

    	/* задаём размеры кнопки и размер текста на кнопке  */
    	button {
	      font-size: 12px;
		  height: 40px;
		  width: 80px;
		}
    </style>
  </head>
  <body>
  	<!-- создаём кнопку на странице -->
    <button id="snow">Запустить снежинки</button>
    <!-- подключаем скрипт, который обработает нажатие на эту кнопку -->
    <script src="popup.js"></script>
  </body>
</html>

Чтобы браузер не ругался, что у нас нет файла popup.js, создадим пустой файл с таким названием и положим его в ту же папку:

Показываем меню расширения

Сохраняем манифест, обновляем его на странице настроек и видим, что у нашего расширения появилось меню с кнопкой:

Показываем меню расширения

Запускаем снежинки

Вся магия будет происходить в файле popup.js — откроем его и добавим такой код:

// получаем доступ к кнопке
let snow = document.getElementById("snow");
// когда кнопка нажата — находим активную вкладку и запускаем нужную функцию
snow.addEventListener("click", async () => {
  // получаем доступ к активной вкладке
  let [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  // выполняем скрипт
  chrome.scripting.executeScript({
  	// скрипт будет выполняться во вкладке, которую нашли на предыдущем этапе
    target: { tabId: tab.id },
    // вызываем функцию, в которой лежит запуск снежинок
    function: snowFall,
  });
});

// запускаем снег
function snowFall() {
}

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

Проверка

В прошлый раз мы не смогли запустить скрипт на любой странице Яндекса — мешала политика безопасности. Теперь всё работает:

Проверяем расширение

Скачать упакованное расширение. Перед установкой его нужно распаковать в любую папку.

Спорим, вы прямо сейчас используете какие-то браузерные расширения. Некоторые из них очень полезны и популярны, например, блокировщики рекламы, менеджеры паролей или средства для просмотра PDF. Функциональность таких расширений (аддонов) не ограничена — вы можете сделать с их помощью что угодно, но сегодня мы будем разбираться, как делать непосредственно их. Другими словами, мы собираемся создать собственное расширение.

Что будем делать?

На сайте Reddit есть аккаунты, которые на добровольной основе занимаются транскрибацией (переводом в текст) изображений, аудио- и видео-контента. Это здорово улучшает доступность для людей с ограниченными возможностями.

Однако эти комментарии находятся в общей ленте и сразу не видны. Мы собираемся решить эту проблему.

Мы создадим расширение Transcribers of Reddit (TOR), которое будет искать и перемещать комментарии с расшифровкой на верх общего списка, а также добавлять aria-атрибуты для скрин-ридеров. Мы даже пойдем чуть дальше и добавим возможность изменить цвет фона и установить рамку для этих комментариев, чтобы улучшить визуальный контраст.

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

Мы начнем с расширения для Chromium-браузеров (Google Chrome, Microsoft Edge, Brave и т. д.) В следующих статьях, возможно, мы его портируем на Firefox и Safari, который с недавних пор тоже поддерживает веб-расширения и в MacOS и в iOS.

Репозиторий проекта

Рабочая директория

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

Создадим новую папку для проекта — можно назвать ее transcribers-of-reddit. А внутри нее папку src для исходного кода.

Манифест

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

Нашей точкой входа будет файл manifest.json в папке src. Добавим в него для начала три свойства:

{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0"
}
  • Поле manifest_version — это версия используемой спецификации, она определяет, какие API доступны приложению. На данный момент самая свежая версия — mv3.
  • Поле name — название расширения, которое будет отображаться, например, в Chrome Web Store.
  • Поле version — это версия самого расширения. Она указывается в виде строки, которая должна содержать только цифры и точки. Можно указывать мажорные, минорные версии, а также патчи (например, 1.3.5).

Больше информации

В файл manifest.json можно добавить очень много различных параметров, описывающих наше приложение. Например, его описание (поле description), которое объясняет, что приложение умеет делать.

Добавим также иконки приложения в разных разрешениях и урл домашней страницы проекта.

{
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/"
}
  • Поле description не должно быть длиннее 132 символов.
  • Иконки, указанные в поле icons, используются во множестве мест. Предпочтительно загружать файлы png-формата. Иконки для нашего проекта вы можете загрузить прямо из его репозитория.
  • В поле homepage_url можно указать любую страницу, которая содержит информацию о приложении. Она будет отображаться в графе Open extention website на странице управления расширением.

Скриншот страницы управления расширением

Разрешения

Большой плюс расширений — это возможность взаимодействовать напрямую с браузером. Но для этого нужно явно указать, какие API мы собираемся использовать, чтобы получить разрешения. Это нужно сделать в секции permissions файла manifest.json:


{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0",
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/",

  "permissions": [
    "storage",
    "webNavigation"
  ]
}

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

Кроме того, мы должны отслеживать пользовательскую навигацию по текущей странице — разрешение webNavigation. Дело в том, что Reddit — это одностраничное приложение (SPA), которое не вызывает событий перезагрузки страниц. Нам нужно поймать взаимодействие и загрузку комментариев на страницу.

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

Управление переводами

Браузерным расширениями доступен встроенный API интернационализации (i18n). Он позволяет управлять переводами для множества языков (полный список). Чтобы воспользоваться этой возможностью, нужно создать сами переводы, а также указать дополнительную информацию в manifest.json.

Определяем, какой язык будет использоваться по умолчанию (английский):

"default_locale": "en"

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

Сами переводы определяются в папке _locales. Для каждого создается отдельная папка, внутри которой находится файл messages.json:

src 
 └─ _locales
     └─ en
        └─ messages.json
     └─ fr
        └─ messages.json

Перевод каждой фразы оформляется по специальной схеме:

  1. Определяется ключ перевода (translation key), или идентификатор фразы, по которому мы будем к ней обращаться.
  2. Для каждого ключа указываются свойства: message, description и placeholders.
  • message — это сама переводимая фраза.
  • description — опциональное описание перевода, если необходимо. Предназначено, скорее, для самого переводчика, в расширении не используется.
  • placeholders — динамический контент внутри фразы, например, слова, которые могут изменяться.

Вот пример перевода одной фразы:

{
  "userGreeting": { // Translation key ("id")
    "message": "Good $daytime$, $user$!" // сама фраза
    "description": "User Greeting", // опциональное описание
    "placeholders": { // динамический контент внутри фразы
      "daytime": { // имя плейсхолдера, используется в самой фразе
        "content": "$1",
        "example": "morning" // необязательный пример значения
      },
      "user": { 
        "content": "$1",
        "example": "Lars"
      }
    }
  }
}

Схема использования плейсхолдеров может показаться довольно сложной.

Для начала мы должны определить, какие части сообщения (фразы) являются динамическими. Их может и не быть. Если такие фрагменты нашлись, нужно обернуть их символами $.

Каждый такой фрагмент нужно отдельно описать в поле placeholders. Это немного неинтуитивно, но Chrome хочет знать, какое значение следует вставить вместо плейсхолдера. Так как мы хотим использовать динамические (неопределенные) значения, нужно использовать специальную конструкцию $1, которая является ссылкой на вставленный контент.

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

Давайте добавим переводы в наш проект. Это оригинальный английский файл, а вы можете добавить столько языков, сколько захотите. Например, для русского нужно создать папку ru, а для немецкого de.

{
  "name": {
    "message": "Transcribers of Reddit"
  },
  "description": {
    "message": "Accessible image descriptions for subreddits."
  },
  "popupManageSettings": {
    "message": "Manage settings"
  },
  "optionsPageTitle": {
    "message": "Settings"
  },
  "sectionGeneral": {
    "message": "General settings"
  },
  "settingBorder": {
    "message": "Show comment border"
  },
  "settingBackground": {
    "message": "Show comment background"
  }
}

Возможно, вы хотите знать, почему мы не указали API интернационализации в списке разрешений.

Chrome довольно непоследователен в этом отношении — вам не нужно указывать все необходимые разрешения, например, chrome.i18n. Некоторые другие разрешения требуется указывать, но пользователь, который устанавливает ваше расширение о них ничего не знает. Некоторые разрешения вообще являются «гибридными», вы можете использовать некоторые их функции без объявления, но другие обязательно нужно указать в манифесте. Чтобы разобраться во всем этом, загляните лучше в документацию.

Интернационализация манифеста

Первое, что видит пользователь в Chrome Web Store, — это обзорная страница расширения. Мы должны убедиться, что на ней все переведено на актуальный язык, для этого придется внести несколько изменений в манифест — перевести название и описание:

{
  // Обновленные значения
  "name": "__MSG_name__",
  "description": "__MSG_description__"
}

Это специальный синтаксис, который позволяет ссылаться на фразы в messages.json по их идентификатору. Например, строка __MSG_name__ использует фразу с идентификатором name.

Интернационализация HTML-страниц

Чтобы перевести тексты на страницах самого расширения, потребуется добавить немного JavaScript:

chrome.i18n.getMessage('name');

Этот код также использует идентификатор фразы из файла messages.json.

Чтобы передать динамические плейсхолдеры, используйте второй параметр метода:

chrome.i18n.getMessage('userGreeting', {
  daytime: 'morning',
  user: 'Lars'
});

Конечно, перевести таким образом все тексты на странице не самая приятная задача, но мы можем схитрить. Вынесем тексты в data-атрибуты элементов и переведем их все сразу с помощью скрипта.

Создайте новую папку js внутри директории src и добавьте туда файл util.js, в котором будут находиться вспомогательные функции:

src 
 └─ js
     └─ util.js

Вот так выглядит сам код:

const i18n = document.querySelectorAll("[data-intl]");
i18n.forEach(msg => {
  msg.innerHTML = chrome.i18n.getMessage(msg.dataset.intl);
});

chrome.i18n.getAcceptLanguages(languages => {
  document.documentElement.lang = languages[0];
});

При выполнении этот скрипт найдет все элементы с атрибутом data-intl, найдет соответствующую указанному идентификатору переведенную фразу и вставит полученный текст в innerHTML элемента.

<!-- До выполнения JS -->
<html>
  <body>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>
<!-- После выполнения JS -->
<html lang="en">
  <body>
    <button data-intl="popupManageSettings">Manage settings</button>
  </body>
</html>

Добавление выпадающего меню и страницы настроек

Прежде чем перейти уже наконец к настоящей разработке, мы должны создать две страницы:

  1. Страницу настроек, которая будет содержать настройки пользователя.
  2. Выпадающее меню, которое будет открываться при нажатии на иконку расширения в панели браузера. Его можно использовать для различных сценариев, например, отображать быстрые настройки или собранную статистику.

Скриншот страницы с настройками
Выпадающее меню содержит ссылку на страницу настроек

Вот файлы, которые нам понадобятся:

src 
 ├─ css
 |    └─ paintBucket.css
 ├─ popup
 |    ├─ popup.html
 |    ├─ popup.css
 |    └─ popup.js
 └─ options
      ├─ options.html
      ├─ options.css
      └─ options.js

Файлы .css содержат самый обычный CSS, не больше, не меньше. Вы все наверняка знаете, как это работает 🙂 При желании вы, разумеется, можете использовать различные препроцессоры, главное, не забудьте скомпилировать код.

Файл paintBucket.css:

:root {
  --primary: #ff4500;
  --primary-dark: #d83a00;
  --secondary: #f2f2f2;
  --secondary-dark: #cdcdcd;
  --text: #000;
  --body: #fff;
  --btn-background: rgba(214, 214, 214,.25);
  --btn-background-hover: #c3c3c3;
  --focus: rgba(24, 116, 195, .3);
  --warning: #9b050c;
}

@media (prefers-color-scheme: dark) {
  :root {
    --secondary: #7e818c;
    --secondary-dark: #91939d;
    --text: #fff;
    --body: #202124;
    --btn-background: #292a2d;
    --btn-background-hover: #47494e;
    --focus: rgba(255, 255, 255, .3);
  }
}
*, *::before, *::after {
  box-sizing: border-box;
}

*:focus {
  box-shadow: 0 0 0 3px rgba(0, 125, 250, 0.45);
  outline: none;
}

html {
  width: 100%;
}

html, body {
  margin: 0;
  padding: 0;
  font-size: 16px;
  color: var(--text);
  background-color: var(--body);
}

body {
  width: 100%;
  height: 100vh;
  max-height: 100%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
}

Обратите внимание, выпадающее меню — это НЕ отдельная вкладка браузера. Его размер зависит от контента. Если вы хотите задать определенные фиксированные размеры, просто установите свойства width и height для элемента html.

Выпадающее меню

Напишем HTML-код выпадающего меню, а также подключим туда CSS- и JS-файлы.

Файл popup.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title data-intl="name"></title>

    <link rel="stylesheet" href="../css/paintBucket.css">
    <link rel="stylesheet" href="popup.css">

    <script src="../js/util.js" defer></script>
    <script src="popup.js" defer></script>
  </head>
  <body>
    <h1 id="title"></h1>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>

Заголовок первого уровня будет содержать название расширения и его текущую версию, а кнопка будет открывать страницу настроек. Обратите внимание, для кнопки установлен атрибут data-intl, то есть ее текст будет вставлен автоматически нашим вспомогательным скриптом.

Текст заголовка мы установим отдельно в файле popup.js. Там же будет обработчик клика по кнопке:

const title = document.getElementById('title');
const settingsBtn = document.querySelector('button');
const manifest = chrome.runtime.getManifest();

title.textContent = `${manifest.name} (${manifest.version})`;

settingsBtn.addEventListener('click', () => {
  chrome.runtime.openOptionsPage();
});

Чтобы получить доступ к данным манифеста, это скрипт использует runtime API браузера Chrome. Его метод getManifest возвращает JSON-объект (этот метод не требует специальных разрешений).

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

Стили меню в файле popup.css:

html {
  width: 360px;
  height: 160px;
  padding: 1.5rem;
}

body {
  max-height: 100%;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

h1 {
  font-size: 1rem;
  font-weight: normal;
  text-align: center;
}

button {
  -moz-appearance: none;
  -webkit-appearance: none;
  appearance: none;
  border: none;
  color: #fff;
  background-color: var(--primary);
  transition: background-color 200ms;
  padding: 0.75rem 1.5rem;
  cursor: pointer;
}
button:hover {
  background-color: var(--primary-dark);
}

Мы полностью закончили меню, но наше расширение пока что ничего о нем не знает. Нужно зарегистировать его в файле manifest.json:

"action": {
  "default_popup": "popup/popup.html",
  "default_icon": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  }
},

Страница настроек

Страница с пользовательскими настройками создается точно так же.

Начинаем с разметки — файл options.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title data-intl="name"></title>

  <link rel="stylesheet" href="../css/paintBucket.css">
  <link rel="stylesheet" href="options.css">

  <script src="../js/util.js" defer></script>
  <script src="options.js" defer></script>
</head>
<body>
  <header>
    <h1>
      <!-- Icon provided by feathericons.com -->
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" role="presentation">
        <circle cx="12" cy="12" r="3"></circle>
        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
      </svg>
      <span data-intl="optionsPageTitle"></span>
    </h1>
  </header>

  <main>
    <section id="generalOptions">
      <h2 data-intl="sectionGeneral"></h2>

      <div id="generalOptionsWrapper"></div>
    </section>
  </main>

  <footer>
    <p>Transcribers of Reddit extension by <a href="https://lars.koelker.dev" target="_blank">lars.koelker.dev</a>.</p>
    <p>Reddit is a registered trademark of Reddit, Inc. This extension is not endorsed or affiliated with Reddit, Inc. in any way.</p>
  </footer>
</body>
</html>

Сейчас здесь нет никаких обещанных настроек, только подготовленный для них блок. Вставлять их мы будем с помощью JavaScript (в файле options.js).

Сначала определим дефолтные значения и получим ссылку на DOM-элемент контейнера:

const defaultSettings = Object.freeze({
  border: false,
  background: false,
});
const generalSection = document.getElementById('generalOptionsWrapper');

Теперь нужно загрузить сохраненные ранее настройки. Для этого нам понадобится storage API (который мы уже зарегистрировали ранее в манифесте).

Можно сохранять данные локально (chrome.storage.local) или синхронизировать их между разными устройствами пользователя (chrome.storage.sync). Для простоты будем использовать локальное хранилище.

Для получения данных предназначен метод get, который принимает два аргумента: ключ, с которым связано нужное значение, и коллбэк для обработки полученных данных.

У нас ключ — это строка 'settings', но можно также передавать массив строк, если требуется загрузить несколько различных настроек.

Полученные из хранилища данные будут переданы функции-коллбэку:

chrome.storage.local.get('settings', ({ settings }) => {
  const options = settings ?? defaultSettings; // если не было сохраненных настроек, используем дефолтные
  if (!settings) {
    chrome.storage.local.set({
     settings: defaultSettings,
    });
 }

  // создаем HTML-опции для каждой настройки
  const generalOptions = Object.keys(options).filter(x => !x.startsWith('advanced'));
  generalOptions.forEach(option => createOption(option, options, generalSection));
});

Рендер опций вынесен в отдельную функцию createOption():

function createOption(setting, settingsObject, wrapper) {
  const settingWrapper = document.createElement("div");
  settingWrapper.classList.add("setting-item");
  settingWrapper.innerHTML = `
  <div class="label-wrapper">
    <label for="${setting}" id="${setting}Desc">
      ${chrome.i18n.getMessage(`setting${setting}`)}
    </label>
  </div>

  <input type="checkbox" ${settingsObject[setting] ? 'checked' : ''} id="${setting}" />
  <label for="${setting}"
    tabindex="0"
    role="switch"
    aria-checked="${settingsObject[setting]}"
    aria-describedby="${setting}-desc"
    class="is-switch"
  ></label>
  `;

  const toggleSwitch = settingWrapper.querySelector("label.is-switch");
  const input = settingWrapper.querySelector("input");

  input.onchange = () => {
    toggleSwitch.setAttribute('aria-checked', input.checked);
    updateSetting(setting, input.checked);
  };

  toggleSwitch.onkeydown = e => {
    if(e.key === " " || e.key === "Enter") {
      e.preventDefault();
      toggleSwitch.click();
    }
  }

  wrapper.appendChild(settingWrapper);
}

Здесь мы создаем отдельный блок для каждой настройки и помещаем внутрь него чекбокс, который может быть включен или выключен. Добавляем слушатель события change для этого чекбокса и при изменении вызываем метод updateSetting, который должен обновить сохраненное значение в хранилище. Для этого предназначен метод хранилища set, который принимает два аргумента: обновленное значение всего хранилища и коллбэк (опциональный параметр, который мы не будем использовать).

Так как мы храним настройки в виде объекта, следует использовать spread-оператор, чтобы обновлять только нужный параметр, сохраняя актуальные значения прочих.

function updateSetting(key, value) {
  chrome.storage.local.get('settings', ({ settings }) => {
    chrome.storage.local.set({
      settings: {
        ...settings,
        [key]: value
      }
    })
  });
}

Стили страницы в файле options.css:

header {
  background-color: var(--primary);
  color: #fff;
  padding: 1rem 1.75rem;
}
header > h1 {
  margin: 0 auto;
  max-width: 40rem;
  font-weight: normal;
  display: flex;
  align-items: center;
}
header > h1 > svg {
  width: 2rem;
  height: 2rem;
  margin-right: 0.5rem;
}

main {
  width: 100%;
  padding: 0 1.75rem;
  margin: 1.5rem auto;
  display: flex;
  flex-direction: column;
  flex: 1;
}
main > section {
  width: 100%;
  max-width: 40rem;
  margin: 1rem auto;
}
main > section > h2 {
  max-width: 50rem;
  font-weight: normal;
  margin: 0.25rem 0;
  color: var(--black);
}
main > section > h2.dropdown {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  width: 100%;
  font-size: 1.5rem;
  border: none;
  cursor: pointer;
  padding: 0.25rem 0;
  background-color: var(--white);
  transition: background-color 200ms;
  display: flex;
  align-items: center;
  justify-content: space-between;
  user-select: none;
}
main > section > h2.dropdown > svg {
  width: 1.6rem;
  height: 1.6rem;
  transition: transform 200ms;
}
main > section > h2.dropdown:hover {
  background-color: var(--gray);
}
main > section > h2.dropdown + div {
  display: none !important;
}
main > section > h2.dropdown[aria-expanded=true] > svg {
  transform: rotate(-90deg);
}
main > section > h2.dropdown[aria-expanded=true] + div {
  display: flex !important;
}
main > section > #generalOptionsWrapper, main > section #advancedOptionsWrapper {
  display: flex;
  flex-direction: column;
  margin: 1rem 0;
}
main > section > #generalOptionsWrapper > .setting-item, main > section #advancedOptionsWrapper > .setting-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem 0;
}
main > section > #generalOptionsWrapper > .setting-item:hover > .label-wrapper > button, main > section > #generalOptionsWrapper > .setting-item:focus-within > .label-wrapper > button, main > section #advancedOptionsWrapper > .setting-item:hover > .label-wrapper > button, main > section #advancedOptionsWrapper > .setting-item:focus-within > .label-wrapper > button {
  opacity: 1;
}
main > section > #generalOptionsWrapper > .setting-item > .label-wrapper, main > section #advancedOptionsWrapper > .setting-item > .label-wrapper {
  display: flex;
  align-items: center;
}
main > section > #generalOptionsWrapper > .setting-item > .label-wrapper > button, main > section #advancedOptionsWrapper > .setting-item > .label-wrapper > button {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: none;
  border: none;
  cursor: pointer;
  margin-left: 0.25rem;
  opacity: 0;
  transition: opacity 200ms;
}
main > section > #generalOptionsWrapper > .setting-item > .label-wrapper > button > svg, main > section #advancedOptionsWrapper > .setting-item > .label-wrapper > button > svg {
  width: 1.5rem;
  height: 1.5rem;
  stroke: var(--placeholder);
}
main > section > #generalOptionsWrapper > .setting-item > input, main > section #advancedOptionsWrapper > .setting-item > input {
  display: none;
}
main > section > #generalOptionsWrapper > .setting-item > input:checked + .is-switch, main > section #advancedOptionsWrapper > .setting-item > input:checked + .is-switch {
  background-color: var(--primary);
  border-color: var(--primary);
}
main > section > #generalOptionsWrapper > .setting-item > input:checked + .is-switch:hover, main > section #advancedOptionsWrapper > .setting-item > input:checked + .is-switch:hover {
  background-color: var(--primary-dark);
  border-color: var(--primary-dark);
}
main > section > #generalOptionsWrapper > .setting-item > input:checked + .is-switch::after, main > section #advancedOptionsWrapper > .setting-item > input:checked + .is-switch::after {
  transform: translateY(-50%) translateX(28px);
}
main > section > #generalOptionsWrapper > .setting-item > .is-switch, main > section #advancedOptionsWrapper > .setting-item > .is-switch {
  width: 4rem;
  height: 34px;
  background: var(--secondary);
  cursor: pointer;
  border-radius: 30px;
  overflow: hidden;
  position: relative;
  transition: background-color 200ms, border-color 200ms;
  border: 1px solid var(--secondary);
}
main > section > #generalOptionsWrapper > .setting-item > .is-switch:hover, main > section > #generalOptionsWrapper > .setting-item > .is-switch:focus, main > section #advancedOptionsWrapper > .setting-item > .is-switch:hover, main > section #advancedOptionsWrapper > .setting-item > .is-switch:focus {
  background-color: var(--secondary-dark);
  border-color: var(--secondary-dark);
}
main > section > #generalOptionsWrapper > .setting-item > .is-switch::after, main > section #advancedOptionsWrapper > .setting-item > .is-switch::after {
  background: #fff;
  border-radius: 50%;
  box-shadow: 2px 4px 6px rgba(0, 0, 0, 0.2);
  content: "";
  height: 30px;
  width: 30px;
  position: absolute;
  left: 2px;
  top: 50%;
  transform: translateY(-50%);
  transition: transform 200ms;
  will-change: transform;
}

footer {
  display: flex;
  flex-direction: column;
  padding: 1rem 1.75rem;
  text-align: center;
}
footer > p {
  margin: 0.25rem 0;
  font-size: 75%;
}
footer > p > a {
  text-decoration: none;
  color: var(--text);
}

dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100%;
  max-width: 40rem;
  border: none;
  margin: 0;
  box-shadow: 5px 5px 8px 0 rgba(0, 0, 0, 0.15);
  border-radius: 0.5rem;
  padding: 2rem;
}
dialog::backdrop {
  background: rgba(0, 0, 0, 0.25);
  backdrop-filter: blur(10px);
}
dialog > #dialogClose {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  padding: 0.25rem;
  background: none;
  border: none;
  cursor: pointer;
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  color: lightgray;
  transition: color 200ms;
}
dialog > #dialogClose:hover {
  color: var(--placeholder);
}
dialog > #dialogClose > svg {
  width: 1.75rem;
  height: 1.75rem;
}
dialog > #optionDescription {
  margin-bottom: 2rem;
  line-height: 1.5;
}
dialog > #dialogActions {
  display: flex;
  justify-content: flex-end;
  margin: 0 -0.5rem;
}
dialog > #dialogActions > #dialogOk {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: var(--blue);
  padding: 0.75rem 1.5rem;
  min-width: 4rem;
  max-width: fit-content;
  border: none;
  color: var(--white);
  cursor: pointer;
}

И конечно же, мы должны зарегистрировать новую страницу в манифесте:

"options_ui": {
  "open_in_tab": true,
  "page": "options/options.html"
},

При необходимости вы можете оформить страницу опций в виде модального диалога. Для этого нужно установить свойство open_in_tab: false.

Установка расширения для разработки

Мы сделали всю подготовительную работу, и теперь неплохо было бы проверить, работает ли наше расширение.

Перейдите на страницу chrome://extensions и включите режим разработчика (в правом верхнем углу).

Появятся три кнопки, нам нужна первая — Загрузить распакованное расширение. Кликните на нее и в появившемся окне выберите папку src, в которой хранятся все файлы нашего проекта.

После этого наше расширение появится в списке в статусе Установлено. Также его иконка появится в верхней панели браузера рядом с адресной строкой (его можно найти, кликнув на иконку паззла ?) . Если вы нажмете на нее, то появится выпадающее меню с кнопкой:

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

Страница настроек:

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

Основной скрипт

Итак, мы сделали выпадающее меню и настроили взаимодействие со страницей настроек, но само расширение все еще не делает ничего полезного. Давайте исправим это и добавим основной скрипт.

Создайте новый файл comment.js внутри директории js.

Теперь его нужно добавить в манифест в секцию content_scripts:

"content_scripts": [
  {
    "matches": [ "*://www.reddit.com/*" ],
    "js": [ "js/comment.js" ]
  }
],

Это массив, каждый элемент которого состоит из двух частей:

  • matches — содержит массив урлов, на которых браузер должен запускать наше расширение. Мы указываем всего один урл домен верхнего уровня — www.reddit.com, а изменяющиеся части обозначаются звездочками.
  • js — содержит массив скриптов, которые нужно запустить на подходящих урлах.

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

// script_on_website.js
const username = 'Lars';

// content_script.js
console.log(username); // Error: username is not defined

Давайте приступим уже к программированию.

Прежде всего добавим несколько констант в файл comment.js, в которых сохраним селекторы DOM-элементов и регулярные выражения для дальнейшей работы.

Объект CommentUtils содержит набор тестовых функций для определения, содержит ли пост «ToR-комментарии» и существует ли вообще блок комментариев на странице.

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const Selectors = Object.freeze({
  commentWrapper: 'div[style*="--commentswrapper-gradient-color"] > div, div[style*="max-height: unset"] > div',
  torComment: 'div[data-tor-comment]',
  postContent: 'div[data-test-id="post-content"]'
});

const UrlRegex = Object.freeze({
  commentPage: //r/.*/comments/.*/,
  subredditPage: //r/.*//
});

const CommentUtils = Object.freeze({
  isTorComment: (comment) => comment.querySelector('[data-test-id="comment"]') ? comment.querySelector('[data-test-id="comment"]').textContent.includes('m a human volunteer content transcriber for Reddit') : false,
  torCommentsExist: () => !!document.querySelector(Selectors.torComment),
  commentWrapperExists: () => !!document.querySelector('[data-reddit-comment-wrapper="true"]')
});

Затем мы проверяем, находится ли пользователь на странице «поста» с комментариями. Для этого проверяем текущий урл и обновляем значение переменной directPage. Эта проверка сработает в том случае, если пользователь перешел на страницу с внешних ресурсов или собственноручно вбил адрес в адресную строку браузера.

let directPage = false;
if (UrlRegex.commentPage.test(window.location.href)) {
  directPage = true;
  moveComments();
}

Но в большинстве случаев переходы осуществляются внутри SPA. Чтобы отловить их, мы можем добавить слушатель сообщений, используя runtime API.

chrome.runtime.onMessage.addListener(msg => {
  if (msg.type === messageTypes.COMMENT_PAGE) {
    waitForComment(moveComments);
  }
});

Теперь нам нужно написать реализацию функций moveComment и waitForComment.

moveComment — находит в общем списке «ToR-комментарии» с транскрипцией медиа-ресурсов и перемещает их в начало списка. Также при наличии соответствующих настроек она изменяет цвет фона и рамки этих комментариев (это проверяется с помощью уже знакомого нам метода get).

function moveComments() {
  if (CommentUtils.commentWrapperExists()) {
    return;
  }

  const wrapper = document.querySelector(Selectors.commentWrapper);
  let comments = wrapper.querySelectorAll(`${Selectors.commentWrapper} > div`);
  const postContent = document.querySelector(Selectors.postContent);

  wrapper.dataset.redditCommentWrapper = 'true';
  wrapper.style.flexDirection = 'column';
  wrapper.style.display = 'flex';

  if (directPage) {
    comments = document.querySelectorAll("[data-reddit-comment-wrapper='true'] > div");
  }

  chrome.storage.local.get('settings', ({ settings }) => { 
    comments.forEach(comment => {
      if (CommentUtils.isTorComment(comment)) {
        comment.dataset.torComment = 'true';
        if (settings.background) {
          comment.style.backgroundColor = 'var(--newCommunityTheme-buttonAlpha05)';
        }
        if (settings.border) {
          comment.style.outline = '2px solid red';
        }
        comment.style.order = "-1";
        applyWaiAria(postContent, comment);
      }
    });
  })
}

Функция applyWaiAria добавляет комментариям aria-атрибуты для улучшения доступности для скринридеров. Мы также используем вспомогательную функцию uuidv4 для генерации уникальных идентификаторов.

function applyWaiAria(postContent, comment) {
  const postMedia = postContent.querySelector('img[class*="ImageBox-image"], video');
  const commentId = uuidv4();

  if (!postMedia) {
    return;
  }

  comment.setAttribute('id', commentId);
  postMedia.setAttribute('aria-describedby', commentId);
}

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

Функция waitForComment ожидает, пока комментарии загрузятся, и после этого вызывает коллбэк, который был ей передан. Для отслеживания используется API браузера MutationObserver.

function waitForComment(callback) {
  const config = { childList: true, subtree: true };
  const observer = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      if (document.querySelector(Selectors.commentWrapper)) {
        callback();
        observer.disconnect();
        clearTimeout(timeout);
        break;
      }
    }
  });

  observer.observe(document.documentElement, config);
  const timeout = startObservingTimeout(observer, 10);
}

function startObservingTimeout(observer, seconds) {
  return setTimeout(() => {
    observer.disconnect();
  }, 1000 * seconds);
}

Добавляем сервис-воркер

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

Создайте новый файл sw.js внутри директории src (важно, чтобы он находился в корне проекта).

Сразу же зарегистрируем его в манифесте:

"background": {
  "service_worker": "sw.js"
}

Начнем, как обычно, с констант для сообщений и типов страниц:

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const UrlRegex = Object.freeze({
  commentPage: //r/.*/comments/.*/,
  subredditPage: //r/.*//
});

const Utils = Object.freeze({
  getPageType: (url) => {
    if (new URL(url).pathname === '/') {
      return messageTypes.MAIN_PAGE;
    } else if (UrlRegex.commentPage.test(url)) {
      return messageTypes.COMMENT_PAGE;
    } else if (UrlRegex.subredditPage.test(url)) {
      return messageTypes.SUBREDDIT_PAGE;
    }

    return messageTypes.OTHER_PAGE;
  }
});

Мы будем следить за историей навигации браузера (onHistoryStateUpdated). Нужное нам событие будет вызываться каждый раз, когда SPA обращается к History API при обновлении контента страницы без перезагрузки.

При наступлении события мы запрашиваем активную вкладку браузера и получаем ее tabId, а затем отправляем сообщение основному скрипту расширения.

chrome.webNavigation.onHistoryStateUpdated.addListener(async ({ url }) => {
  const [{ id: tabId }] = await chrome.tabs.query({ active: true, currentWindow: true });

  chrome.tabs.sendMessage(tabId, {
    type: Utils.getPageType(url),
    url
  });
});

Готово!

Вот и все!

Перейдите в менеджер расширений (chrome://extensions) и перезапустите наше расширение, чтобы все изменения применились.

Теперь откройте на Reddit какой-нибудь пост, у которого есть комментарии с транскрибацией (например, вот этот). И вы сразу же увидите нужный комментарий, потому что он отображается в самом верху списка, да еще и с рамкой и красным фоном (если вы установили соответствующие настройки).

Расширение Transcribers of Reddit находит комментарии с транскрибацией изображения, поднимает их на верх списка и выделяет красной рамкой и фоном

Заключение

Создавать браузерные расширения совсем несложно, как вы только что убедились. В них нет ничего особенного — те же самые HTML, CSS и JavaScript, которые вы пишете каждый день, плюс совсем немного магии в manifest.json.

Если у вас возникнут какие-то проблемы, скорее всего вы сможете найти ответ в отличной документации Chrome API.

Репозиторий с кодом статьи

27.06.2014, 08:45. Показов 57574. Ответов 1


1. Что такое расширения к браузерам?
Расширения к браузерам — это программы, которые внедряются в браузер и добавляют в него новые функции или настраивают имеющиеся.
Расширения к браузерам могут:

  • изменять html-код страниц, открываемых в браузере. В том числе внедрять в них скрипт, который на страницах не отображается, но выполняется для каждой странице при её загрузке.
    В хроме такой скрипт называется скриптом содержимого или контентным скриптом, в мазиле и опере — юзерскриптом.
    Примечание: в отличие от обычных видимых скриптов, этот скрипт может делать кроссдоменные запросы XMLHttpRequest.
  • добавлять кнопки на панели инструментов браузера и создавать новые тулбары
  • управлять вкладками браузера: создавать, закрывать, обновлять, посылать по определённому urlу
  • управлять закладками браузера: создавать, изменять, удалять
  • делать редиректы с одних url на другие, причём не только при открытии того или иного url во вкладке, но и при доступе к нему из фреймов, а также через XMLHttpRequest или через script, img, video.
  • просматривать и изменять историю посещенных страниц в браузере
  • и другое, в зависимости от браузера и его версии

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

Не стоит путать расширения с плагинами.

2. Что представляют собой расширения и как их пишут?
Расширения к браузерам обычно состоят из:

  • файла настроек расширения (ini, json, xml), где указаны, например, название расширения, его версия, автор, поддерживаемые версии браузера, а также файлы скриптов и html страниц, необходимых расширению
  • фонового JS-скрипта (или фоновой страницы с таким скриптом), который выполняется 1 раз при загрузке браузера, не отображается ни на каких страницах, но может менять какие-то настройки в браузере, скажем добавлять кнопку на тулбар
  • юзерскрипта, который внедряется в страницы, не отображается в их коде, но может их изменять так же свободно, как и если бы он был на данном сайте изначально
  • вспомогательных html-страниц, например, страницы настроек для пользователя или диалогового окна, открывающегося при нажатии на кнопку на тулбаре

Всё это либо упаковывают в zip-архив и устанавливают в браузер через страницу Расширения/Дополнения (предварительно сменив расширение архива в зависимости от браузера), либо просто помещают в отдельную папку и опять устанавливают через страницу Расширения/Дополнения, указав путь к этой папке (ручная установка в распакованном виде доступна в хроме).
Возможна программная установка расширений.

Логику расширения пишут на JS, интерфейс пользователя создают с помощью HTML+CSS, а файл настроек пишут на INI/JSON/XML.
Для написания расширений достаточно блокнота.

Исключение — Internet Explorer, расширения для которого пишутся на компилируемом ЯП (C#, C++, Delphi, ..) с помощью COM. Его мы здесь рассматривать не будем.

3. С чего начать?
1. Сначала Chrome.
Во-первых, по нему больше всего инфы в инете.
Во-вторых, он попросту проще.
В-третьих, он не только популярнее сам по себе, но имеет множество клонов, которые, также как и он, основаны на Chromium и обычно совместимы с расширениями Chrome. Это Яндекс.Браузер, Амиго, Интернет от Mail.Ru… Тысячи их.
2. Потом Opera.
Когда вышла Opera 15 (Opera Next) на хромовском движке, оф. документацию по расширениям к старой опере аффффтары просто потёрли, глубоко наплевав на тех, кто привык к старой опере и вообще в гробу видел хром. А таких вот топиков в своё время никто не создал. В результате инфы в инете по опере не Next почти не осталось.
Мы в данном топике будем рассматривать оперу ниже 15, т.е. не Next.
3. Потом Firefox.
Расширения к нему можно создавать двумя способами — как я описал в пункте 2 и каким-то левым способом с помощью специального SDK, причём способы плохо совмещаются друг с другом. Поэтому добрая половина кода в инете, касаемо огнелиса, у вас может просто не заработать. Мы в данном топике будем учиться юзать именно способ без SDK, ибо он проще.
4. Ну и потом IE.
Который по вышеупомянутой причине рассматриваться в топике не будет.

4. Зачем нужен этот топик? Вон статья на хабре, вон оф. документация, вон ещё статья на хабре, что ещё надо?
1) Вам не нужно — вы и не создавайте топиков, и этот не читайте. А некоторым нужно. Мне бы в своё время пригодился.
2) По расширениям к хрому инфа и правда есть, но вот с огнелисом и оперой сложнее. Про оперу — см. выше.
3) Я не прошу отправлять этот топик в важные (хотя буду рад, если он туда попадёт), не буду его апить каждые полчаса. Так что вам он не мешает ничем. Не нужно его трогать.
4) Этот топик нужен мне. Чтобы в свободное время или просто когда лень работать продолжать углублять свои знания и расширять специализацию. И вы бы лучше занялись чем-то таким, нежели ругать и портить чужое.

5. Пишем простое расширение к Chrome.
Расширение будет добавлять всплывающий баннер с текстом «Ура!» на каждую страницу в браузере, в левый-верхний угол.
Итак, приступим.
1. Создаём отдельную папку. Называем её, например, HelloChromeAPI.
2. В папке создаём файл настроек расширения.
Для этого запускаем блокнот, пишем такой код

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
    "name": "Hello Chrome API",
    "version": "1.0.0.0",
 
    "manifest_version": 2,
 
    "content_scripts": [
        {
            "matches": [ "http://*/*" ],
            "js": [ "content_scripts/end.js" ],
            "run_at": "document_end"
        }
    ]
}

сохраняем в нашу папку под именем manifest.json обязательно в кодировке UTF-8.
Такой формат представления данных называется JSON: записи через запятую, имена их в кавычках, каждая запись может включать в себя подзаписи разного типа (тогда заключается в {}) или подзаписи одного и того же типа (тогда называется массивом и заключается в []).
«name» — имя расширения, которое будет видно в браузере
«version» — версия расширения, которую тоже будет видно в браузере
«manifest_version», равное 2, указывает, что расширение будет совместимо с Chrome выше версии 13. Если вместо 2 указать 1, то расширение будет работать в хромах с 5 по

12. Такие хромы сейчас редки, но если уж писать серьёзный проект, то нужно сделать дубликаты расширения для обоих поколений хрома.
«content_scripts» — это массив контентных скриптов (то есть скриптов, которые, как говорилось выше, выполняются на каждой странице в браузере). В данном случае в списке один элемент (он ограничен скобками {}), так как скрипт в нашем расширении будет только 1
«matches» — маска, указывающая, на каких страницах будет выполняться скрипт. * означает любую последовательность символов. Наш скрипт будет выполнять на всех страницах протокола http. Почему только http? Потому что на страницах протокола https браузер проверяет, нет ли левого html-кода, и если есть, то ругается. Может ругаться и антивирус.
«run_at» указывает, на каком этапе загрузки страницы скрипт будет выполняться.
Значение «document_start» = не загружено ничего (скрипт сможет вывести окошко alert или изменить window.location.href, но не сможет работать с document.body, его просто не будет)
Значение «document_end» = загружен код страницы, доступны document и document.body, но могут выполнять какие-то скрипты, которые ещё просто не успели выполниться
Значение «document_idle» = простой, когда никакой другой скрипт не выполняется. точнее ближайший период простоя с начала загрузки. Рекомендуется использовать для сложных ресурсоёмких скриптов, когда в то же время не требуется, чтобы скрипт выполнился прямо сразу после загрузки.
«js» — это массив путей к скриптам, выполняющимся с данными matches. Скрипт один, он будет называться end.js (это будет означать, что скрипт выполняется по document_end) и лежать в подпапке content_scripts в нашей папке расширения.
3. Создадим подпапку content_scripts, а в ней скрипт end.js.
В нём пропишем код

Javascript
1
document.body.innerHTML += '<div style="background-color: #FFFFFF; border-color: black; border-style: solid; color: black; font-family: 'Segoe UI'; font-size: 24px; position: fixed; left: 0; top: 0; width: 300px; height: 110px; padding: 8px; z-index: 1000000000">Ура!</div>';

Этот скрипт добавляет код баннера в код body той страницы, на которой выполняется.
Он тоже обязательно должен быть сохранён в UTF-8.
Примечание: стилей так много затем, чтобы перекрыть стили, применённые на сайте, куда вставляется баннер, и баннер на всех сайтах выглядел одинаково.
4. Установим расширение в браузер.
Откроем меню браузера кнопкой справа-вверху, затем пункт Инструменты, затем выберем пункт Расширения.
Или просто пройдём по ссылке chrome://extensions
Поставим флажок Режим разработчика, нажмем Загрузить распакованное расширение и укажем нашу папку.
5. Теперь зайдём на любую страницу, использующую http (см. выше)
mail.ru подойдёт, наш форум подойдёт, а google.ru нет.
Когда страница загрузится до конца, слева-вверху появится обещанный баннер.
6. На странице расширений мы можем отключить или удалить расширение с помощью соотв. кнопок.
А если мы решим изменить код manifest.json, end.js и т.д., то чтобы изменения применились, мы должны воспользоваться кнопкой Обновить.

6. Добавляем кнопочку на панель инструментов Chrome
Теперь оно будет ещё добавлять кнопочку на панель инструментов хрома и при нажатии на кнопку открывать диалоговое окно.
Нам понадобится Chrome 20 или новее.
1. Откроем наш файл manifest.json
Изменим его таким образом

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
    "name": "Hello Chrome API",
    "version": "1.0.0.0",
 
    "manifest_version": 2,
 
    "content_scripts": [
        {
            "matches": [ "http://*/*" ],
            "js": [ "content_scripts/end.js" ],
            "run_at": "document_end"
        }
    ], // <-- здесь добавим запятую
 
    "browser_action": {
          "default_icon": {                       // иконки для кнопки 
            "19": "/images/br-icon19.png",         // иконка 19x19 пкс
            "38": "/images/br-icon38.png"          // иконка 38x38 пкс
          },
          "default_title": "Наша кнопка",         // всплывающая подсказка появл при наведении мыши на кнопку
          "default_popup": "popup/popup.html"     // страница появл во всплывающем окне откр при нажатии на кнопку
    }
}

и сохраним.
2. Создадим подпапки images и popup.
В images создадим две картинки, как указано в манифесте. Картинки могут быть одинаковыми, отображается только одна, в зависимости от расширения монитора (на больших мониторах — 38×38, на маленьких — 19×19).
В popup создадим файл popup.html и запишем в него такой код

HTML5
1
<div style="width: 300px; height: 100px">Ура!</div>

Сохранять тоже лучше в UTF-8.
3. Чтобы изменения вступили в силу, зайдём в Расширения и нажмём кнопку Обновить под нашим расширением.
4. На панели инструментов (слева вверху) появится кнопка с заданной иконкой.
Нажав на кнопку, увидим окошко, в котором будет надпись «Ура!».
Заметим, что размер окошка соответствует размеру, указанному в style корневого элемента popup.html (т.е. 300 x 100 пкс).
Если этот размер не указывать, окошко будет таким, чтобы в него поместилось содержимое этого элемента, т.е. надпись «Ура!».

7. Добавляем иконку в расширение к Chrome.
1. Подправим манифест.

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
    "manifest_version": 2,
 
    "name": "Hello Chrome API",
    "version": "1.0.0.0",
 
    "manifest_version": 2,
 
    // добавим такие строчки
    "icons": {
        "48": "/images/icon48.png"
    },
 
    "content_scripts": [
        {
            "matches": [ "http://*/*" ],
            "js": [ "content_scripts/end.js" ],
            "run_at": "document_end"
        }
    ],
 
    "browser_action": {
          "default_icon": {
            "19": "/images/br-icon19.png",
            "38": "/images/br-icon38.png"
          },
          "default_title": "Наша кнопка",
          "default_popup": "popup/popup.html"
    }
}

2. Создадим файл icon48.png в папке images.
Это будет иконка расширения, отображаемая на странице расширений вместо дефолтного значка с фрагментом мозаики.
Примечание: помимо иконки размером в 48 пкс, мы можем сделать иконки размером 16, 32, 128 пкс, дописав внутрь ветви «icons:» соотв. строчки, не забывая про запятые. Но это нужно только если мы решим нарисовать действительно разные иконки для расширения. Просто растянув иконку под 16, 32, 128 пкс, мы ничего не достигнем — хром это сделает и сам, где оно понадобится. Я ограничился 48 пкс.
3. Обновим расширение на странице расширений.

8. Пишем простое расширение к Opera.
Расширение будет работать аналогично расширению к хрому.
Опера у меня версии 12. На других не пробовал. На 15 и выше, ещё раз, работать не должно.
Все файлы, как и с хромом, лучше сохранять в UTF-8, иначе кириллица будет отображаться неверно.
Приступим.
1. Создаём отдельную папку. Называем её, например, HelloOperaAPI.
2. В папке создаём файл настроек расширения.
Для этого запускаем блокнот, пишем такой код

XML
1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<widget xmlns="http://www.w3.org/ns/widgets">
    <name>Hello Opera API</name>
    <version>1.0.0.0</version>
</widget>

сохраняем в нашу папку под именем config.xml обязательно в кодировке UTF-8.
Такой формат представления данных называется XML.
<name> — имя расширения, которое будет видно в браузере
<version> — версия расширения, которую тоже будет видно в браузере
А вот никакого упоминания о скриптах в файле не будет. Мы просто создадим в папке с расширением подпапку include и файл .js с любым именем. Этого будет достаточно, чтобы скрипт работал.
3. Сказано — сделано. Создаём подпапку include и файл script_end.js

Javascript
1
2
3
4
5
6
7
// ==UserScript==
// @include [url]http://*/*[/url]
// ==/UserScript==
 
document.addEventListener("DOMContentLoaded", function() {
    document.body.innerHTML += '<div style="background-color: #FFFFFF; border-color: black; border-style: solid; color: black; font-family: 'Segoe UI'; font-size: 24px; position: fixed; left: 0; top: 0; width: 300px; height: 110px; padding: 8px; z-index: 1000000000">Ура!</div>';
}, false);

DOMContentLoaded позволяет создать эффект «document_end» из хрома (а сам юзерскрипт в опере выполняется фактически по «document_start».
«==UserScript==…==/UserScript==» вверху — это не просто комментарий. Там указываются настройки данного скрипта. @include здесь работает по принципу «matches» из Chrome.
Скрипт тоже обязательно должен быть сохранён в UTF-8.
Этот скрипт добавляет код баннера в код body той страницы, на которой выполняется.
4. В папке с расширением создадим index.html. Это фоновая страница. Мы можем оставить её пустой, потому что фоновых скриптов пока нет и она нам не нужна, но в расширении для оперы она должна быть обязательно.
5. Упакуем расширение в zip-архив. Сменим его расширение с zip на oex.
6. Запустим оперу, зайдём в меню, выберим пункт Дополнения, перетащим туда oex.
7. Проверим, появляются ли баннеры при загрузке страниц.

9. Добавляем кнопку на панель инструментов Opera
На панели инструментов справа-вверху появится кнопка. При нажатии на неё откроется всплывающее окошко с заданной страницей. Всё как в хроме.
Нам понадобится Chrome 20 или новее.
1. Изменим фоновую страницу index.html таким образом

HTML5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!doctype html>
<html lang="en">
    <head>
        <script>
            window.addEventListener("load", function(){
                var ToolbarUIItemProperties = {
                    title: "Наша кнопка",
                    icon: "/images/br-icon19.png",
                    popup: {
                        href: "popup/popup.html",
                        width: 300,
                        height: 100
                    }
                }
                opera.contexts.toolbar.addItem(opera.contexts.toolbar.createItem(ToolbarUIItemProperties));
            }, false);
        </script>
    </head>
    <body>
    </body>
</html>

и сохраним.
opera.contexts.toolbar — это класс из Opera API.
У хрома тоже есть API — chrome.*.
2. Создадим подпапки images и popup.
В images создадим картинку, как указано в манифесте.
В popup создадим файл popup.html и запишем в него такой код

Примечание:

в отличие от хрома, размер окошка формируется не через style корневого элемента popup.html, а через width и height, прописанные в ToolbarUIItemProperties в index.html.
3. Чтобы изменения вступили в силу, удалим расширение и установим его обратно.
4. На панели инструментов (слева вверху) появится кнопка с заданной иконкой.
Нажав на кнопку, увидим окошко, в котором будет надпись «Ура!».

10. Добавляем иконку в расширение Opera.
1. Путь к иконке указывается в файле настроек — config.xml.
За него отвечает тэг icon с атрибутом src, в котором указывается относительный путь к иконке (относительно основной папке расширения).
Откроем файл config.xml.
Внутри <widget> добавим строчку

XML
1
<icon src="/images/icon48.png" />

Должно получиться так:

XML
1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<widget xmlns="http://www.w3.org/ns/widgets">
    <name>Hello Opera API</name>
    <version>1.0.0.0</version>
    <icon src="/images/icon48.png" />
</widget>

2. Создадим файл icon48.png (это может любая картинка 48×48 пкс) в папке images.
3. Перепакуем и переустановим расширение.
В списке расширений вместо дефолтного значка (сиреневого фрагмента мозаики) будет отображаться наша иконка.


11. Пишем простое расширение Firefox.
12. Добавляем панель инструментов в Firefox.
13. Добавляем иконку в расширение Firefox.

Продолжение следует…
Пока что читаем здесь и экспериментируем.

Пара советов и ссылок на закуску
Конечно, информации из этого топика ещё недостаточно, чтобы написать любое расширение к браузеру Chrome, Firefox или Opera.
Но общее представление о расширениях и особенностях их разработки вы получили.
Поэтому, воспользовавшись данными ссылками в связке с гуглом, Вы при желании сможете углубиться в расширения и писать крупные, уникальные продукты.
Здесь же обещанные магазины расширений.
CHROME
https://developer.chrome.com/extensions/ — официальная документации.
Вот описание в ней контентных скриптов и кнопки browser-action:
https://developer.chrome.com/e… nt_scripts
https://developer.chrome.com/e… wserAction
https://chrome.google.com/webs… ions?hl=ru — магазин.
OPERA
https://addons.opera.com/ru/extensions/ — магазин.
FIREFOX
https://addons.mozilla.org/ru/firefox/ — магазин.

Добавлено через 2 минуты
В процессе написания топика нашёл на форуме баг.
В кодах JSON, XML и др. текст «https://www.cyberforum.ru/images/картинка.png» заменяется на «https://www.cyberforum.ru/images/картинка.png».
То есть если написать

JSON
1
2
3
{
    "48": "ЗДЕСЬ_НЕТ_НИЧЕГОimages/icon48.png"
}

то получится

JSON
1
2
3
{
    "48": "https://www.cyberforum.ru/images/icon48.png"
}

Правда, написав

JSON
1
2
3
{
    "48": "/images/icon48.png"
}

мы получим то, что ожидали. Но иной код от этого может сделаться также нечитабельным, как и из-за добавления «https://www.cyberforum.ru/».



2



Привет!

Меня зовут Саша Коновалов, я разработчик в компании Oxonit и наставник на программе «Мидл фронтенд-разработчик» в Яндекс.Практикуме.

На примере разработки расширения «Hello, Word» я расскажу, как разрабатывать кроссбраузерное расширение со знаниями современного фронтенда:

  • для чего нужно кроссбраузерное расширение;
  • как его разработать под разные браузеры;
  • модульность кода, как переиспользовать компоненты и как современный фронтенд помогает в разработке расширений;
  • какие инструменты можно использовать для сборки и публикации расширений (webpack, web-ext).

Если вам интересно посмотреть исходники, я разместил их на GitHub.

Структура расширения, о котором я расскажу в статье, близка к проекту SponsorBlock. Этот проект вдохновлял меня во время работы над собственным.

Итак, создадим расширение – Hello, Word!

Hello, Word — это расширение для перевода и запоминания слов. Оно стало прототипом моего пет-проекта Wordzzz, поэтому в посте я буду приводить примеры из него.


Рисунок 1. Описание функциональности

Что делает расширение?

  • выполняется на любом сайте (добавить функциональность на сайты — content-скрипт):
    – по выделению слова появляется иконка, при клике делается запрос на сервис перевода и открывается окошко перевода слова (1a);
    – также слово можно перевести, используя контекстное меню по правой кнопке мыши на выделенном слове (1b);
  • из окошка перевода можно добавить слово во внутренний словарь для истории и дальнейшего повторения (для передачи между различными частями расширения используется postMessage, для хранения слов — storage);
  • считает слова (нотификации) в background-скрипте (2);
  • помогает увидеть список внутреннего словаря в меню расширения и отредактировать его (всё та же связка postMessage + storage) (3);
  • позволяет тренироваться в изучении новых слов в отдельном окне (отдельная страница внутри расширения — learn-скрипт) (4).

Архитектура расширений

Схема архитектуры расширений. Источник: developer.mozilla.org

MDN Web Docs определяет расширение как набор файлов, который заботливо упакован для дальнейшего распространения и установки в браузер. Ниже указаны файлы, которые могут присутствовать в расширении.

manifest.json — обязательный файл для любого расширения. В нём содержатся имя расширения, требуемые разрешения и версия, а также указатели на другие файлы расширения.

Помимо этого в манифесте могут быть и указатели на другие файлы, например:

  • background pages — отвечает за долгоиграющую логику;
  • иконки и любые кнопки, которые расширение может определить;
  • sidebars, popups, and options pages — HTML-документы, предоставляющие содержимое для разных компонентов интерфейса пользователя;
  • content scripts — исполняемые на страницах JavaScript-сценарии расширения;
  • web-accessible resources — отвечает за то, чтобы контент вашего расширения стал видимым для скриптов и веб-страниц.

Наложим эту структуру на наш проект:


Рисунок 2. Архитектура расширения Hello, Word!

Архитектура проекта

Можно разрабатывать простые расширения, как в инструкции от Chrome. Мне же интересно расширить эту инструкцию для более сложных проектов, где надо добавить общие компоненты, системы сборки, кроссбраузерность, react, typescript — всё, без чего сложно представить современный фронтенд-проект.


Рисунок 3. Архитектура проекта Hello, Word!

Из рисунка выше видно, что проект состоит из нескольких частей:

  • Common — здесь хранятся общие компоненты, стили и т. д.;
  • Extension — само расширение Hello, Word!
  • Web — небольшой лендинг для проекта;
  • свой вариант может быть разным (даже другое расширение, почему нет?). В Wordzzz мы добавили ещё react-native-проект, дополнительно — сервер и разные интересные идеи и эксперименты.

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

Управлять связями между пакетами помогает инструмент lerna (подробнее про настройку и использование можно прочитать на официальном сайте). Как и писал выше, в Common — общие стили и компоненты, общая статика (шрифт и иконки) в public-папке в корне. Web и Extension используют общие компоненты.

Рисунок 4: Структура проекта

В моем случае все пакеты собираются с помощью webpack. Для Extension собираются пять бандлов в дистрибутиве (в соответствии с Рис. 3), manifest.json, в котором указываются эти бандлы, и копируется статика.

Общие компоненты используются в соответствии с инструкцией lerna.

Для запуска расширения используется инструмент web-ext — он устанавливает расширение и позволяет подхватывать изменения кода в это установленное расширение.

Но у инструмента есть и ограничения: расширение может не загрузиться правильно в первый раз. В этом случае поможет сочетание с простым способом — обновлять вручную через панель расширений (в основном для изменений контент-скриптов).

Описание инструментов можно найти здесь.

Как достигается кроссбраузерность

1) Отдельные manifest под разные цели и браузеры

Собирается вебпаком c помощью небольшого написанного модуля под разные окружения и браузеры. В FF, например, нужно расширять наш исходный manifest.json добавлением browser_specific_settings.gecko.id.

2) Safari: конвертация кода в xcode-проект

Пользуюсь конвертером от Apple:

xcrun safari-web-extension-converter ./packages/extension/dist --project-location ./packages/extension/safari --app-name HelloWord --swift --bundle-identifier app.wordzzz.HelloWord

Я указал —bundle-identifier своего сайта app.wordzzz.HelloWord, вы укажете свой.

Если всё прошло успешно, то увидим такое сообщение:

После конвертации откроется проект и рекомендации не использовать некоторые функции (об этом ниже).

Жмём кнопку Run в XCode и смотрим, как всё работает.

Примечание: Может потребоваться включить «Разрешить неподписанные расширения» в меню «Разработка».

3) Проверка в CSS и немного про пути

До файлов в chromium-браузерах и FF разный относительный путь, поэтому я использовал css-селекторы:

/* firefox */
@-moz-document url-prefix() {
	.hw-icon__logo-content {
		background: url("../images/logo.svg") no-repeat;
	}
}

/* not firefox */
@supports not (-moz-appearance:none) {
	.hw-icon__logo-content {
		background: url("chrome-extension://*MSG@@extension_id*_/images/logo.svg") no-repeat;
	}
}

4) Проверка в JS

Тут ничего нового — проверка navigator.userAgent.
Например, при публикации в AppStore меня просили убрать кнопку доната.

5) Рекомендации по (не-)использованию определённых функций

  • Рекомендации не использовать некоторые функции в Safari
  • Хорошая статья о кроссбраузерном расширении
  • Статья о том, что можно использовать полифилл

Полезные ссылки

  • Разработка под Chrome
  • Разработка под Firefox
  • Разработка под Safari
  • Конвертация расширения под FF
  • Функции, которые поддерживаются для FF
  • Конвертер для Safari
  • Статья о кроссбраузерном расширении
  • Примеры для Chrome

Вместо заключения

Как видите, разработать расширение можно и с современным стеком технологий. Я описал свой стек, но можно использовать и другой, важнее было показать подход в целом.

В проекте Hello, Word ещё много чего можно сделать, например, покрыть типами и тестами, использовать препроцессоры и многое другое. Предлагайте в комментариях полезные идеи для улучшения проекта и свои решения и полезные инструменты для разработки расширений. Всем мир.

Понравилась статья? Поделить с друзьями:
  • Как написать боту viber
  • Как написать ботов для игр
  • Как написать ботнет
  • Как написать бота через питон
  • Как написать бота телеграм на джаве