Сегодня продолжаем тему написания расширения для Chrome, позволяющего внедрять свой код на чужие сайты, тем самым меняя или дополняя их функционал и внешний вид по своему желанию. Для чего это нужно и чем может быть полезно, рассмотрено в предыдущей части. Также в предыдущей статье были рассмотрены вопросы, касающиеся настройки и подготовки к написанию расширения с использованием Angular 18. Напоминаю, что весь код я публикую в открытом доступе на GitHub.

Пользовательские скрипты в Chrome

Давайте разберемся, как устроен функционал внедрения своего произвольного кода на сторонние сайты в Chrome. В этом году состоялся переход на новую платформу для расширений Manifest V3. Ранее, во 2-й версии Manifest, внедрение произвольного кода на страницу не выделялось в API, в результате чего, хотя и были некоторые ограничения со стороны платформы, процесс внедрения имел различные варианты решения со своими нюансами, разработчик сам выбирал и реализовывал подходящий. Однако в текущей версии этот процесс стал более унифицирован, для регистрации и других операций со скриптами используется задокументированное API userScripts.

Для получения возможности работать с пользовательскими скриптами в нашем расширении нам понадобится запросить разрешение на данные операции. В manifest.json в раздел permissions нужно добавить userScripts, а также в разделе host_permissions задать список сайтов, на которые мы будем внедрять свой код. Если нам необходима возможность внедряться на абсолютно любой сайт, то можно задать такую маску: "host_permissions": [ "http://*/*", "https://*/*" ]. Для работы с API Chrome в Angular также потребуется установить описание структуры API: npm install @types/chrome --save-dev. После этого нам становится доступно API Chrome, и прежде всего нас интересует отдельная его часть userScripts.

Упрощенная структура chrome.userScripts API (отображены только используемые нами элементы, полная документация доступна на официальном сайте)
Упрощенная структура chrome.userScripts API (отображены только используемые нами элементы, полная документация доступна на официальном сайте)

Регистрация пользовательского скрипта

Чтобы внедрить свой JavaScript код на произвольную страницу какого-либо сайта, необходимо зарегистрировать скрипт с помощью chrome.userScripts.register:

chrome.userScripts.register([{
  id: 'habr-test',
  matches: ['https://habr.com/*'],
  js: [{code: 'alert("Wow! I\'ve hacked HABR!")'}]
}]);

В качестве параметра функции передается массив, а значит зарегистрировать можно сразу несколько пользовательских скриптов за одну операцию. Для каждого скрипта при регистрации будем задавать следующие свойства:

  • id — уникальный символьный код регистрируемого скрипта

  • matches — массив строк со списком URL-адресов, на которые внедряется наш скрипт. Вместо адресов можно использовать шаблоны.

  • excludeMatches — аналогично параметру matches, только работает как исключающий фильтр URL-адресов

  • js — массив JavaScript-кодов, представленных в виде объектов типа chrome.userScripts.ScriptSource. Почему массив? Потому что вы можете в одном регистрируемом пользовательском скрипте подключить сразу несколько отдельных кодов, разделенных по смыслу или своему функциональному назначению.

  • runAt — определяет, в какой момент запускать пользовательский скрипт: document_start, document_end или document_idle

Получение списка зарегистрированных скриптов

Для получения списка ранее зарегистрированных скриптов используем функцию chrome.userScripts.getScripts, которая возвращает Promise с результатом. В качестве параметра в функцию можно передать фильтр, например, для получения скрипта с заданным id:

chrome.userScripts.getScripts({ids: ['habr-test']})

Обновление зарегистрированных скриптов

Формат функции chrome.userScripts.update абсолютно идентичен функции для регистрации, только в качестве параметра нужно передавать не список новых скриптов, а список обновляемых. Если в каком-либо из переданных скриптов будет задан несуществующий id, выполнение кода будет прервано с ошибкой.

chrome.userScripts.update([{
  id: 'habr-test',
  matches: ['https://habr.com/*'],
  js: [{code: 'alert("Wow! I\'ve hacked HABR twice!")'}]
}]);

Удаление пользовательского скрипта

Вызов функции удаления аналогичен вызову функции получения списка скриптов — в качестве параметра передается фильтр, в котором мы можем задать список id удаляемых скриптов:

chrome.userScripts.getScripts({ids: ['habr-test']})

Скрипты, скрипты, а где CSS?

Ранее, в предыдущей статье, я озвучивал, что готовое расширение сможет подключать связку JS + CSS кода для любых страниц. Однако пока о CSS я не написал ни слова. Почему? Потому что в Chrome реализовано только подключение JS-скриптов. Но разве это проблема? Конечно нет, мы можем с помощью JS реализовать подключение любого CSS-кода самостоятельно точно также, как это делалось со старой версией Manifest V2.

private wrapCSSCodeWithJS(codeCSS: string): string {  
  return '(() => {' +  
    '\n\tconst style = document.createElement(\'style\')' +  
    '\n\tstyle.textContent = /*user style start*/`' + codeCSS + '`/*user style end*/' +  
    '\n\tdocument.documentElement.appendChild(style)' +  
    '\n})()'  
}

В результате выполнения функции мы получаем JS-код для подключения заданного нами CSS-кода, т. е. мы его как бы оборачиваем в JS-код. А далее уже он может быть зарегистрирован как обычный пользовательский скрипт.

Developer mode

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

Есть только один неприятный нюанс. Зачем-то в команде разработчиков Chrome решили, что недостаточно запроса вида Read and change all your data on all websites, с которым пользователь соглашается при установке расширения. Чтобы разрешить использование userScripts API, пользователь должен помимо этого обязательно включить еще и галочку Developer mode (Режим разработчика) на странице списка установленных расширений.

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

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

isChromeUserscriptsAvailable(): boolean {  
  try {  
    chrome.userScripts;  
    return true;  
  } catch {  
    return false;  
  }  
}

Развитие функционала

Если посмотреть на представленный Chrome'ом userScripts API, выглядит он очень скудно, с дополнительным функционалом тут не разгуляешься. Например, возьмем простейшую задачу — временное включение/отключение пользовательских скриптов.

Официальный API такой возможности не предоставляет, а сохраняемый браузером объект chrome.userScripts.RegisteredUserScript не содержит какого-либо свойства для хранения дополнительной информации о регистрируемом скрипте. В связи с этим необходимо создавать свою структуру для хранения всех дополнительных опций скриптов, которые нам понадобятся в дальнейшем.

Сохранять будем в chrome.storage.local — специальное локальное хранилище, доступное расширениям. Для его использования в manifest.json в раздел permisions необходимо добавить storage и unlimitedStorage (без второго разрешения максимальный размер хранимых данных будет ограничен 10 Мб). Создаваемая структура будет связана по id с объектом RegisteredUserScript, сохраненным в браузере.

Также здесь мы дублируем и остальные свойства сохраненного пользовательского скрипта. Зачем дублировать? Во-первых, так удобнее будет работать, когда все данные будут извлекаться из одного места. Во-вторых, и основное — для реализации требуемого нам функционала (включения/отключения скрипта) нам потребуется удалять зарегистрированный в браузере объект (т. к. он не имеет состояния "отключен"). При этом сами данные скрипта — код, привязка к URL — должны сохраниться. Они и будут храниться в создаваемой нами дополнительной структуре:

export interface CodeBundle {  
  id: string  
  urlPatterns: string[]  
  urlPatternsCommaSeparated?: string  
  js: string  
  css: string  
  isEnabled: boolean  
}

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

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

Быстрый доступ к функционалу

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

getCurrentUrlAsync(): Promise<string | undefined> {  
  return chrome.tabs.query({  
    active: true,  
    currentWindow: true,  
  }).then((tabs: chrome.tabs.Tab[]) => tabs[0].url)  
}

Чтобы работать с данным API, нам необходимо также добавить в manifest.json дополнительный запрос на разрешение tabs в раздел permissions.

Теперь, получив текущий URL, мы можем перебрать все добавленные нами пользовательские скрипты, сравнить шаблоны URL (в т. ч. исключающие) с текущим URL и отобразить во всплывающем окне те скрипты, которые привязаны к текущей странице. Также здесь реализуем возможность быстрого добавления скрипта к текущей странице или всему сайту. В итоге, кликнув по иконке установленного расширения, получаем такое всплывающее окно:

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

Комментарии (6)


  1. Vinni37
    13.11.2024 06:38

    Чтобы разрешить использование userScripts API, пользователь должен помимо этого обязательно включить еще и галочку Developer mode (Режим разработчика) на странице списка установленных расширений.

    Используйте:

    chrome.scripting.executeScript(
      injection: ScriptInjection,
      callback?: function,
    )

    Потому что в Chrome реализовано только подключение JS-скриптов. Но разве это проблема?

    Конечно не проблема:

    chrome.scripting.insertCSS(
      injection: CSSInjection,
      callback?: function,
    )


    1. kanasero Автор
      13.11.2024 06:38

      chrome.scripting.executeScript в данном случае не подойдет, т. к. в качестве аргумента требует либо указать файл js для выполнения, либо функцию. Т. е. данный метод предназначен для фиксированного, заранее прописанного в расширении js-кода. В данном же случае нам нужно выполнить динамический произвольный код, извлекаемый из локального хранилища. Т. е. нам нужно по сути выполнить код, который хранится в строковом виде, типа "let h = 'hello, world!'; alert(h);". Используя executeScript, мы такой код в виде строки не запустим, использовать eval Chrome не дает из-за ограничений безопасности.

      А вот с chrome.scripting.insertCSS, думаю, вы правы, вполне можно было бы использовать. Другое дело, что по трудозатратности такой вариант ни в чем не выигрывает - нужно самостоятельно выполнять проверку текущего адреса с сохраненными шаблонами URL и подцеплять CSS. К тому же, если я уже все равно использую userScripts для хранения js-кода, вполне удобно обернуть CSS в JS и использовать один и тот же функционал и для скриптов, и для стилей, т.е. работать с ними как с идентичными объектами.


      1. Vinni37
        13.11.2024 06:38

        Для executeScript, можно использовать Ваш подход к CSS.


        1. kanasero Автор
          13.11.2024 06:38

          Вы имеете ввиду внедрение кода через вставку элемента script в DOM дерево? Нет, такой вариант тоже не проходит. Chrome запрещает вставку любого динамического js-кода в обход официального API userScripts. Если не ошибаюсь, в Manifest V2 такой подход работал, но сейчас в 3-й версии ужесточили этот момент.


          1. Vinni37
            13.11.2024 06:38

            Насколько мне известно, если в executeScript выставить ExecutionWorld: "MAIN" (по умолчанию ISOLATED) то не должно быть проблем. Изначально при появлении executeScript,параметра ExecutionWorld не было, его добавили потом. Но может что то уже поменялось.


  1. Vinni37
    13.11.2024 06:38

    del