Однажды мы задали себе вопрос: как мы можем помочь пользователю выбрать компанию за пределами 2gis.ru? Вариант реализации идеи в виде браузерного расширения был предложен практически сразу, и после этапов исследования и планирования мы приступили к разработке 2GIS for browsers.
В качестве основного варианта реализации мы остановились на кнопке сбоку адресной строки, при нажатии на которую открывается информационное окно. Дополнительно — подсвечивать номера телефонов внутри содержимого сайтов.
Для большего удобства было решено определять текущие координаты пользователя, чтобы показывать ему достоверную информацию о расстоянии до интересующей фирмы и давать быструю ссылку на поиск проезда от текущего местоположения до фирмы. Впоследствии определение координат стало более важным, поскольку необходимо было определить город, в котором находится пользователь, чтобы показывать ему данные из наиболее подходящего источника — 2GIS либо Google.
Фоновые и контентные скрипты
С технической точки зрения, среднестатистическое браузерное расширение — это относительно простое JavaScript-приложение, с одной или более точками входа.
Независимо от того, для какого браузера пишется расширение, внутри приложения можно выделить две группы скриптов, а именно — фоновые и контентные.
Фоновые скрипты (background scripts) выполняются в песочнице (sandboxed evaluation) на уровне приложения, и их данные едины для запущенного экземпляра браузера. Они запускаются единожды за сессию либо при инсталляции расширения, либо при запуске браузера с установленным расширением.
Контентные скрипты, если они есть, выполняются в контексте содержимого просматриваемого сайта и имеют доступ к дереву DOM, но при этом для каждого окна и вкладки браузера их данные различаются. Запускаются контентные скрипты каждый раз при наступлении события onload загружаемой в браузер страницы. У фоновых скриптов нет прямого доступа к содержимому страниц, но движки браузеров предоставляют механизмы взаимодействия между скриптами в фоне и скриптами в контентной области. При этом у каждого движка есть свои особенности в организации обмена данными.
Кроме фоновых и контентных скриптов расширение может включать другие точки входа — по одной на каждый дополнительный ресурс. Например, всплывающее информационное окно и одна или более страниц опций расширения. Скрипты дополнительных ресурсов выполняются каждый раз при их инициализации.
Фреймворки для создания расширений. Kango Extensions
Мы хотели, чтобы расширение работало в разных браузерах, поэтому обратили внимание на фреймворки для кроссбраузерной разработки. Некоторые из них предоставлялись как SaaS, что нас совершенно не устраивало. Другие — умели работать только с контентной областью и были применимы скорее для реализации кроссбраузерных пользовательских скриптов (userscripts). И только два фреймворка из всего этого «разнообразия» отвечали требованиям. Мы остановились на фреймворке Kango Extensions, так как у него:
- меньший порог вхождения;
- кроссплатформенная сборка;
- русский разработчик в составе их команды :).
В бесплатной версии, которую мы и выбрали, можно создавать пакеты расширений для Mozilla Firefox, Safari, Google Chrome и его производных. Интерфейс довольно ограниченный, общий для всех браузеров, но его хватает для создания кнопок в адресной строке и всплывающих окон.
В состав фреймворка входят:
- постоянное хранилище данных kango.storage, доступное только из фоновых скриптов. Интересно, что на разных браузерах хранилище ведет себя немного по-разному. Например в firefox данные сохраняются при удалении и повторной установке расширения, а в webkit-браузерах — стираются при удалении расширения;
- интерфейс для управления браузером kango.browser, из которого можно не только получить текущее состояние окон и вкладок, но и управлять ими;
- обертка для XMLHttpRequest под названием kango.xhr, решающая проблему с созданием экземпляра XMLHttpRequest в фоновых скриптах Firefox;
- отладочный интерфейс kango.console, с единственным методом log для вывода отладочные сообщения из скриптов в фоновой песочнице;
- единый интерфейс для обмена сообщениями между фоновыми и контентными скриптами. Методы фоновых скриптов доступны через kango.invokeAsync, также существует механизм диспетчеризации сообщений в контентную область из фона через методы KangoBrowserTab.dispatchMessage и kango.addMessageListener. Вышеупомянутые методы получают данные асинхронно, поэтому событийное программирование придется применять в полной мере и следить за потенциальными race conditions.
Используемые данные и внешние сервисы
Большая часть данных 2GIS for browsers хранится в фоновых скриптах и, во избежание лишних запросов, там же и кэшируется. Среди них, например — текущее местонахождение пользователя и данные об организациях, полученные при поиске по домену либо по телефону. Данные организаций подтягиваются из 2GIS и Google Places через их API. Местонахождение пользователя в свою очередь определяется либо при помощи HTML5 Geolocation API, либо при помощи стороннего открытого GeoIP-сервиса, если API вернуло ошибку или недоступно.
С определением текущего местоположения пользователя связано несколько непростых решений, поскольку возможных ситуаций оказалось довольно много. Например, что будет, если пользователь закроет крышку ноутбука и уедет в другой конец города, а потом откроет ноутбук? А если он просто перейдет между двумя точками Wi-Fi с кратковременной потерей связи? Решить задачу в лоб — через встроенный метод navigator.geolocation.watchPosition — не получилось. Событие positionChanged возникало значительно реже, чем того хотелось бы, к тому же оно не срабатывало в ситуации с закрытием и открытием крышки ноутбука. На помощь пришел флаг navigator.onLine. Следя за ним, мы могли вызвать событие в момент появления интернет-соединения. К сожалению, флаг надежно работает только в webkit-браузерах, но это лучше, чем ничего.
Если организация находится в том же городе, что и пользователь, либо в одном из городов 2ГИС, мы покажем информацию из 2ГИС, так как считаем её более достоверной и выверенной. Для получения координат области, о которой у нас нет сведений, используется Google Places, но с применением фильтров. Информация показывается только в том случае, если домен в поисковой выдаче совпадает с доменом, на котором находится просматриваемая страница. Аналогичным образом мы поступаем и с поиском по телефонам. Единственное отличие — фильтруем по телефону, а не по домену. Это полезно если телефон организации размещен, например, на стороннем сайте объявлений.
Другими словами, даже если пользователь находится на другом конце земного шара относительно организации, на сайт которой он зашел, мы всё равно попытаемся найти и предоставить корректные данные об этой организации.
Как оно работает
Расширение работает как обычное javascript-приложение: получает необходимые для работы начальные данные, добавляет обработчики на определенные события и с их помощью обрабатывает поступающие события. Таким образом реализуется поведение при переключении окон и вкладок, например, смена индикатора, в зависимости от наличия информации по открытому в данной вкладке сайту.
Кроме переключения вкладок мы также должны реагировать на изменение URL самим пользователем. Для этого мы обрабатываем событие onBeforeNavigate, которое браузер вызывает перед тем, как начать загружать страницу. Небольшая сложность возникла с Safari — он вызывает это событие не при каждом изменении адресной строки, только если страница отсутствует в кэше или кэш истёк. Пришлось идти на компромисс — добавить обработчик на событие onLoad, которое вызывается уже после того, как страница загружена.
Также мы столкнулись с проблемой возможных редиректов через заголовки ответа, через meta-тег или через js-вызов — программные редиректы такого рода не генерируют дополнительных событий onBeforeNavigate. С другой стороны привязываться на onLoad для всех браузеров тоже совсем не хочется, поскольку это визуально выглядело бы раздражающе медленным. В качестве решения выбрали периодический опрос состояния URL в текущей вкладке в течение 30 секунд и обновление данных при необходимости.
Распознавание телефонов в веб-страницах
В контентной области страницы расширение делает единственную задачу — выделение из контента последовательностей цифр и символов, которые можно считать телефонами. Это позволяет получить информацию по этим телефонам и позвонить, отправив вызов на ваш смартфон.
Задача распознавания телефонов сама по себе является нетривиальной из-за большого разнообразия форматов, поэтому мы не питали иллюзий относительно полностью надёжного решения. Примеры форматов, с которыми нам пришлось столкнуться: 34.76.35.05.39 — Франция, +1 234 345 6789 — США, 67 2354 9548 — Бразилия.
Наилучшим решением стал двухэтапный фильтр на основе регулярных выражений и простых условий. На первом этапе из текста выделяются последовательности символов, которые теоретически напоминают телефоны. Для этого используются относительно нестрогие регулярные выражения и условия, кроме того, для вхождений проверяется контекст, который может дать дополнительную информацию. Помимо этого, на первом этапе длинные последовательности могут разбиваться на короткие, также могут исключаться части последовательности с неподходящим контекстом.
Второй этап принимает набор найденных последовательностей и на основе уже более строгих правил решает, принять ли это выражение или нет. Правила включают в себя проверки по известным маскам и простым условиям. Например, не является ли переданная последовательность строковым представлением числа с плавающей точкой.
Из-за некоторых строгих правил не распознаются реальные номера, но это исправляется исключениями или дополнительным просмотром контекста. Сегодня для проверки парсера телефонов используется набор из 385 тестовых строк, включающих как позитивные, так и негативные случаи. Готовность к продакшну оценивается по порогу в 95% успешно проходимых тестов из набора.
В ходе простого итеративного обхода дерева по ширине, происходит поиск телефонов в содержимом каждой текстовой DOM-ноды документа. По шагам это выглядит примерно так: подсвечивается найденный в тексте телефон → при наведении на него курсором к внешним сервисам уходит запрос сведений о компании по номеру телефона → полученные данные отображаются во всплывающем окошке → если ничего похожего не найдено, то отображается ссылка «Позвонить».
Данные принимаются в фоне, что не мешает работе страницы, а кэширование данных исключает дополнительные запросы по тому же телефону из других вкладок и окон.
А если данные получены по Ajax?
Если с разбором кода страницы на этапе ее загрузки всё более или менее понятно, то что делать с телефонами, которые могут быть получены динамически через ajax или просто сформированы клиентскими скриптами?
На этот случай мы предусмотрели обработку новых текстовых данных в странице на основе интерфейса MutationObserver, который поддерживается всеми современными браузерами (исключение — Safari 5 for Windows). Для подписки на события, возникающие при изменении DOM мы использовали библиотеку mutation-summary. Она позволяет легко и просто получить добавляемые DOM-ноды. Для сокращения количества обрабатываемых данных, подписываемся только на получение новых текстовых нод и пытаемся разобрать их содержимое. Интересно наблюдать поведение расширения в случае ввода телефона в поле с одновременным его отображением в другой текстовой ноде:
Интернационализация. We speak English
На текущий момент расширение выпущено на русском и английском языке, выбор языка зависит от текущей локали браузера и его настроек. При переводе мы столкнулись с тем, что из контентных и дополнительных скриптов достучаться до компонента i18n можно только через асинхронный вызов в фон. Это совсем не укладывалось в представления об интерфейсе интернационализации. Поэтому был реализован небольшой модуль, в задачи которого входило:
- определение текущей локали;
- загрузка списков локализаций в зависимости от локали, либо через прямой вызов kango.io.getExtensionFileContents, либо через асинхронный в случае, если модуль инстанциировался в контенте или в дополнительных скриптах;
- предоставление простого интерфейса для получения сообщений локализации с простейшей интерполяцией строк.
Из-за потенциальной асинхронности пришлось обеспечить возможность инициализации локалей раньше остальных объектов, поскольку в их конструкторах могли содержаться вызовы получения локализованных текстов. При этом мы не стали отходить от формата локалей, принятого в kango, поскольку помимо kango.i18n, файлы локалей частично используются при сборке пакетов расширения.
Сборка пакетов
Что касается самой сборки, фреймворк предоставляет простой способ собрать расширение, используя сборщик на python. Для сборки достаточно вызвать скрипт kango.py с параметром build и указанием директории, в которой находятся файлы расширения. После этого сборщик создаст необходимые распакованные сборки и даже упакует те, которые относятся к Chrome и Firefox. К сожалению, из-за непростого механизма подписывания расширений, сборщик «из коробки» не умеет упаковывать пакет для Safari. Можно обойтись сборкой расширения через Safari extension builder, пройдя всю процедуру получения сертификатов в Safari developer center. А можно воспользоваться патченным архиватором xar и своими сертификатами, чтобы уметь собирать расширение автоматически и на любой *nix-системе.
Помимо сборщика kango и простого bash-скрипта для автоматизации сборки под safari мы также используем gulp для проверки code style, прогона тестов и ряда вспомогательных действий. Во-первых, нужно подставить в несколько файлов номер версии текущей сборки. Во-вторых — неплохо бы убедиться, что отключён весь отладочный вывод. Ну и наконец, у нас есть потребность в сборке особых вариантов пакетов. Например, addons.mozilla.org требует отсутствия в расширении URL для автоматического обновления, мотивируя это тем, что обновление происходит автоматически с их сайта. С другой стороны, мы публикуем расширение для Firefox через собственный источник, поэтому требуется также вариант, где URL для обновления будет присутствовать. Также ряд изменений, связанных с локализациями, требуется для размещения расширения в Opera store.
Что дальше
Мир не стоит на месте и 2GIS for browsers также растет и развивается, и вот кое-что из того, что мы собираемся сделать в ближайшее время:
- итеративное улучшение распознавателя телефонов. Добавляем больше тест-кейсов, исправляем парсер — жить становится лучше и веселее;
- внедрение интерактивной карты 2GIS во всплывающее окно вместо статической картинки;
- косметические правки, позволяющие нашим элементам отлично выглядеть даже на очень плохо сверстанных сайтах;
- ну и конечно разнообразные фиксы и улучшения, предложения по которым мы ждем на почту extension@2gis.ru
Спасибо за внимание. Делайте расширения для браузеров — это не только весело, но и полезно!
Автор: heilage