Рубрика развивается при поддержке

Как мы сделали расширение для локализации контента онлайн-продукта

Здравствуйте. Я Евгений, разработчик интерфейсов в «Яндекс.Практикуме». Основная задача этого проекта — помощь в овладении начальными навыками программирования. Главный плюс сервиса — простота и доступность подачи образовательного материала.

В закладки

Проблематика

Наш сервис расширяется, мы недавно запустились на рынке США, и перед этим у нас возникла необходимость перевода контента на английский язык.

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

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

Ключ: «Программисты учат программированию»
Ключ: «Ваш урок»
Ключ: «Начать обучение»

Поиск решения

При решении данной проблемы на ум приходило несколько вариантов:

  1. Вручную «скринить» интерфейс, выделять переводы и загружать на сервис переводов. Это самое простое и скучное решение. Подойдёт для небольшого количества переводов в проекте, но явно не наш вариант, так как на данный момент у нас около 3200 ключей.
  2. Написать автоматическую «скриншотилку» с использованием Selenium или чего-то похожего. Это хороший вариант с точки зрения полной автоматизации, но для реализации потребовалось бы много времени, также данное решение не универсально.
  3. Полуавтоматическая «скриншотилка» на основе Chrome Extension. Использование метода captureVisibleTab даёт возможность делать скриншот активной вкладки в формате Base64. Основная идея: пока тестировщик проходит полный регресс нашего приложения, расширение фотографирует все ключи, которые появляются на экране.

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

Чтобы всё заработало, необходимо подготовить проект и написать само расширение.

Подготовка проекта

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

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

  • Пример шаблонной строки: {index} Курс.
  • Возможное значение этого перевода на странице: 1 Курс.

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

if (process.env.SCREENSHOT_MODE) { config.plugins.push(new webpack.NormalModuleReplacementPlugin( /^react-intl$/, 'components/intl' )); }

При запуске сборки с переменной окружения screenshot_mode добавляем плагин NormalModuleReplacementPlugin, который просто заменит весь код в импортах с react-intl на components/intl. В components/intl/index.js делаем подмену IntlProvider.

import * as reactIntl from '../../../node_modules/react-intl'; import IntlProvider from './provider.jsx'; export { ...reactIntl, IntlProvider };

В самом IntlProvider подменяем метод getChildContext.

import { IntlProvider as RealIntlProvider } from '../../../node_modules/react-intl'; import customFormatMessage from './format-message'; export default class IntlProvider extends RealIntlProvider { getChildContext() { const context = super.getChildContext(); return { intl: { ...context.intl, formatMessage: customFormatMessage(context.intl.formatMessage) } }; } }

Это нужно для переопределения метода formatMessage. Все трансформации переводов проходят через него. Тут мы ловим все значения текстов, которые попадут в DOM, и складываем их в localStorage.

const formatMessage = (originalFormatMessage) => { return function (...args) { const key = args[0].id; const value = originalFormatMessage(...args); window[INTL_MESSAGES_NAME][key] = value; localStorage.setItem( INTL_MESSAGES_NAME, JSON.stringify(window[INTL_MESSAGES_NAME]) ); return value; } };

Первая часть работы готова. Теперь в localStorage будут находиться актуальные переводы.

Пишем расширение

Следующим этапом нужно было написать само расширение. Основной алгоритм его работы:

  • Получаем из localStorage все переводы в виде «ключ:значение».
  • Фильтруем переводы от тех, на которые уже были сделаны скриншоты.
  • Через XPath ищем по всему документу DOM-элементы, в которых находятся переводы.
  • Делаем дополнительные проверки DOM-элементов. Необходимо убедиться, что DOM-элемент виден на странице в данный момент, чтобы мы могли сделать хороший скриншот. Проверки состоят из двух этапов: проверка, что DOM-элемент входит в границы видимой области документа, и проверка свойств opacity, display, visibility у элемента и его предков.
  • После всех фильтраций формируем массив объектов «ключ:DOM-элемент» и запускаем процесс скрининга.
  • На данном этапе мы по очереди подсвечиваем элементы на странице, делаем скриншот и убираем подсветку. Данные скриншотов складываем в локальное хранилище расширения вида «ключ:картинка» в формате Base64.
  • После завершения скрининга возвращаемся в первому пункту.

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

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

Проблемы при создании расширения

Первая проблема, которая возникла при создании расширения, — это недоступность объекта window из контентных скриптов, куда планировалось сохранять переводы изначально. В итоге решили использовать localStorage для этих целей.

Большое количество изображений занимает много места в локальном хранилище расширения. Чтобы не упереться в ограничение 5 Мбайт, нужно добавить в манифест разрешение unlimitedStorage.

При разработке расширений для Chrome сталкиваешься с различными видами скриптов. У каждого типа скриптов свои доступы к Chrome Extension API.

Схема работы расширения

Например, доступ к документу осуществляется только через content_scripts. Контентные скрипты встраиваются в страницу на определённых доменах, которые настраиваются в манифесте. Всё бы хорошо, но делать скриншоты из контентных скриптов нельзя.

Для этого используются background_scripts. Для коммуникации между разными видами скриптов существуют методы chrome.runtime.sendMessage для отправки сообщений и chrome.runtime.onMessage.addListener для получения. Popup.html и popup.js используются для создания интерфейса расширения при клике на иконку.

К расширению был написан небольшой интерфейс, в котором можно отслеживать количество сделанных скриншотов.

При клике на кнопку «Показать» формируется блок с ключами, и при клике на каждый ключ формируется сделанное изображение. Можно скачать все скрины одним архивом, а можно посмотреть отдельные изображения и удалить их из хранилища, чтобы переснять.

Что делать с картинками дальше

Все картинки выкачиваются в формате JPEG с именами, соответствующими ключам переводов. Остаётся только загрузить все эти картинки на статику и прогнать скрипт, который подставит ссылки на картинки в контекст каждого перевода.

Open source

Код расширения на Github. Там же описаны его возможные настройки. Пользуйтесь на здоровье!

Материал опубликован пользователем.
Нажмите кнопку «Написать», чтобы поделиться мнением или рассказать о своём проекте.

Написать
{ "author_name": "Evgeny Plotnikov", "author_type": "self", "tags": ["\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f"], "comments": 0, "likes": 10, "favorites": 10, "is_advertisement": false, "subsite_label": "dev", "id": 86572, "is_wide": true, "is_ugc": true, "date": "Fri, 04 Oct 2019 10:23:43 +0300", "is_special": false }
Облачная платформа
Основа для цифровизации бизнеса
0
{ "id": 86572, "author_id": 269063, "diff_limit": 1000, "urls": {"diff":"\/comments\/86572\/get","add":"\/comments\/86572\/add","edit":"\/comments\/edit","remove":"\/admin\/comments\/remove","pin":"\/admin\/comments\/pin","get4edit":"\/comments\/get4edit","complain":"\/comments\/complain","load_more":"\/comments\/loading\/86572"}, "attach_limit": 2, "max_comment_text_length": 5000, "subsite_id": 235819, "last_count_and_date": null }
Комментариев нет
Популярные
По порядку
{ "page_type": "article" }

Прямой эфир

[ { "id": 1, "label": "100%×150_Branding_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox_method": "createAdaptive", "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "bugf", "p2": "ezfl" } } }, { "id": 2, "label": "1200х400", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "bugf", "p2": "ezfn" } } }, { "id": 3, "label": "240х200 _ТГБ_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "bugf", "p2": "fizc" } } }, { "id": 4, "label": "Article Branding", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "p1": "cfovx", "p2": "glug" } } }, { "id": 5, "label": "300x500_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "bugf", "p2": "ezfk" } } }, { "id": 6, "label": "1180х250_Interpool_баннер над комментариями_Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "bugf", "p2": "ffyh" } } }, { "id": 7, "label": "Article Footer 100%_desktop_mobile", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "bugf", "p2": "fjxb" } } }, { "id": 8, "label": "Fullscreen Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "bugf", "p2": "fjoh" } } }, { "id": 9, "label": "Fullscreen Mobile", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "bugf", "p2": "fjog" } } }, { "id": 10, "disable": true, "label": "Native Partner Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyb" } } }, { "id": 11, "disable": true, "label": "Native Partner Mobile", "provider": "adfox", "adaptive": [ "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyc" } } }, { "id": 12, "label": "Кнопка в шапке", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "p1": "bscsh", "p2": "fdhx" } } }, { "id": 13, "label": "DM InPage Video PartnerCode", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox_method": "createAdaptive", "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "bugf", "p2": "flvn" } } }, { "id": 14, "label": "Yandex context video banner", "provider": "yandex", "yandex": { "block_id": "VI-223676-0", "render_to": "inpage_VI-223676-0-1104503429", "adfox_url": "//ads.adfox.ru/228129/getCode?pp=h&ps=bugf&p2=fpjw&puid1=&puid2=&puid3=&puid4=&puid8=&puid9=&puid10=&puid21=&puid22=&puid31=&puid32=&puid33=&fmt=1&dl={REFERER}&pr=" } }, { "id": 15, "label": "Баннер в ленте на главной", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "p1": "byudx", "p2": "ftjf" } } }, { "id": 16, "label": "Кнопка в шапке мобайл", "provider": "adfox", "adaptive": [ "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "p1": "byzqf", "p2": "ftwx" } } }, { "id": 17, "label": "Stratum Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "bugf", "p2": "fzvb" } } }, { "id": 18, "label": "Stratum Mobile", "provider": "adfox", "adaptive": [ "tablet", "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "bugf", "p2": "fzvc" } } }, { "id": 19, "disable": true, "label": "Тизер на главной", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "p1": "cbltd", "p2": "gazs" } } }, { "id": 20, "label": "Кнопка в сайдбаре", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "p1": "cgxmr", "p2": "gnwc" } } } ] { "page_type": "default" }