Создание расширения для Chrome за пару часов

в 14:27, , рубрики: chromium, Extensions, Google Chrome, javascript

В последнее время разработка расширений для Хрома так упростилась, что я решился наконец поставить галочку против одной из самых долгоживущих в моем ежедневнике задач: доставать из картинок на страницах GEO-таги, прицеплять картинкам title с местом, где фотография была сделана, и давать возможность в один клик глянуть на карту. Кроме того, на страницах с большим количеством фотографий имеет смысл показывать карту со всеми маркерами и предоставлять возможность перейти непосредственно к фотографии по клику на маркер.

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

Создание расширения для Chrome за пару часов

В современном мире на создание такого расширения у меня ушло около трех часов. Расширение доступно в Webstore, исходники традиционно лежат на гитхабе


Итак, начнем с создания скаффолда (я сверялся с календарем, 2014 год на дворе, с нуля писать не модно).

Подготовка

npm install -g yo generator-chrome-extension
mkdir mycoolext && cd $_
yo chrome-extension

Это нам скачает генератор для расширений Хрома и запустит его:

Создание расширения для Chrome за пару часов

Отвечаем сообразно здравому смыслу, ждем некоторое время и на выходе имеем удобный проект, управляемый Grunt. Тесты, конечно, придется писать самому, но grunt debug с поддержкой горячего релоадинга расширения и grunt build, создающего пакет, пригодный для загрузки в Webstore — мы получили из коробки. Не забудем качнуть зависимости:

npm install
bower install

Манифест

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

{
    "name": "__MSG_extName__",                /* мы ❤ l10n */
    "description": "__MSG_extDescription__",  /* мы ❤ l10n */
    "version": "1.0.0",                       /* каждый вызов grunt build будет увеличивать минор на 1 */
    "manifest_version": 2,                    /* обязательно */
    "default_locale": "en",                   /* обязательно, если мы ❤ l10n */
    "icons": {
        "16": "icons/16.png",
        "48": "icons/48.png",
        "128": "icons/128.png"
    },
    "background": {
        "scripts": [
            "scripts/chromereload.js",        /* горячий релоадинг */
            "scripts/background.js"           /* наш исполняемый скрипт */
        ]
    },
    "page_action": {
        "default_icon": {
            "16": "icons/16.png",
            "19": "icons/19.png",
            "38": "icons/38.png",
            "48": "icons/48.png",
            "128": "icons/128.png"
        },
        "default_title": "__MSG_extName__",
        "default_popup": "popup.html"         /* я не использую popup, но пусть будет для наглядности */
    },
    "permissions": [
        "contextMenus",
        "tabs",
        "storage",
        "geolocation",                        /* расширению это не нужно, в демонстрационных целях */
        "http://*/*",
        "https://*/*"
    ],
    "content_scripts": [
        {
            "matches": [
                "http://*/*",
                "https://*/*"
            ],
            "js": [
                "bower_components/jquery/dist/jquery.min.js", /* да, я тащу свою jQuery, я ламер */
                "lib/jquery.exif.js",         /* плагин для доставания exif http://blog.nihilogic.dk/ */
                "lib/leaflet.js",             /* скрипт карт от OpenMap http://leafletjs.com/ */
                "scripts/main.js"             /* мой код */
            ],
            "css": [
                "lib/leaflet.css"             /* картам нужны стили */
            ]
        }
    ],
    "minimum_chrome_version": "16.0.0.0",     /* для полярников и космонавтов, не видящих интернет */
    "web_accessible_resources": [
        "bower_components/jquery/dist/jquery.min.map", /* я не планирую отлаживать jQuery, но кто знает */
        "icons/maps.png",                     /* иконка «карта» */
        "lib/images/*"                        /* маркеры и прочие картинки для leaflet */
    ],
    "options_page": "options.html"            /* страница настроек */
}

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

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

Обработка exif

$('img').each(function(index, image) {
	if (($(image).width() < 100) && ($(image).height() < 100)) { // слишком маленькая
		$(image).attr('exif', false);
		return true;
	}

	$(image).exifLoad(function() {
	    if (! $(image).attr('exif')) return;

		// [CUT] тут кусок кода, который долго и муторно достает широту и долготу
		//                                и рассовывает по fLat, fLon, sLat, sLon
		// первые два — дробные, вторые — строки типа 53°20′18″N,37°5′18″E

		$(image).attr('data-gps-latitude', fLat);
		$(image).attr('data-gps-longitude', fLon);
		$(image).attr('data-gps-latitude-pretty', sLat);
		$(image).attr('data-gps-longitude-pretty', sLon);

		// сейчас мы создадим анкор внутри страницы, чтобы на маркер можно было поставить ссылку
		var hash = 'img_' + Date.now();
		$('<a>').attr('id', hash).insertBefore($(image));

        // XHR из расширения дозволено только `background.js`, потому пляски с бубном
		chrome.runtime.sendMessage(
			{ method: 'getAddressByLatLng', id: counter, lat: sLat, lon: sLon },
			function(response) {
				var datas = JSON.parse(response.results).response.GeoObjectCollection;

			    // [CUT] тут кусок кода, который парсит ответ и достает оттуда адрес точки, 
			    //                               где была сделана фотография 

			    // к этой функции мы еще вернемся
				handleLeaflet(iconsize, fLat, fLon, address ? address : sLat + ' ' + sLon, hash);
			}
		);
		// нарисуем вокруг нашей картинки border (цвета задаются в настройках)
		$(image).css({
		  'border-color': color,
		  'border-width': width,
		  'border-style': 'solid'
		});
	});
});

Вроде, все прокомментировал. Пора заглянуть в handleLeaflet.

var exifSpyMap = exifSpyMap || null;
var exifSpyMarkers = exifSpyMarkers || [];

function handleLeaflet(iconsize, fLat, fLon, tooltip, hash) {
	if(!document.getElementById('expifspy-icon-mudasobwa-id')) {
		
		// [CUT] тут создаем и обеспечиваем стилями/свойствами иконку

		icon.addEventListener('click', function() {
			var leaflet = document.getElementById('expifspy-leaflet-mudasobwa-id');
			if(leaflet) { 
			    // leaflet умеет корректно рендерить карту только на видимом (display !== 'none') контроле
				leaflet.style.right = leaflet.style.right === '-10000px' ? 
						(+iconsize - Math.floor(+iconsize / 8)) + 'px' : '-10000px';
			}
		}, false);
		document.body.appendChild(icon);
	}
	if(!document.getElementById('expifspy-leaflet-mudasobwa-id')) { /* create div to draw leaflet */
		// [CUT] тут создаем и обеспечиваем стилями/свойствами карту

		leaflet.style.right = '-10000px';
		document.body.appendChild(leaflet);
	}

	if(!exifSpyMap) { // ленивое создание экземпляра карты
		L.Icon.Default.imagePath = chrome.extension.getURL('lib/images');
		exifSpyMap = L.map('expifspy-leaflet-mudasobwa-id').setView([fLat, fLon], 13);

		// добавляем слой с благодарностью авторам
		L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', {
			attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
		}).addTo(exifSpyMap);
	}

	// создаем маркер
	var marker = L.marker([fLat, fLon]).addTo(exifSpyMap).bindPopup(tooltip);

	// по наведению мыши он будет показывать адрес
	marker.on('mouseover', function(/*e*/) {
		this.openPopup();
	});
	marker.on('mouseout', function(/*e*/) {
		this.closePopup();
	});

	// по клику — будет проматывать страницу к фотографии
	if(hash) {
		marker.on('click', function(/*e*/) {
			location.hash = '#' + hash;
		});
	}

	// перерендерим карту, чтобы все маркеры попали
	exifSpyMarkers.push(L.latLng(fLat, fLon));
	exifSpyMap.fitBounds(L.latLngBounds(exifSpyMarkers));
}

Уф. Осталось разобраться с получением адреса по координатам. У гугла какая-то мутная политика, я хожу в Яндекс.

chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
	switch(message.method) {
		// [CUT] показано только важное
	
		case 'getAddressByLatLng':
			var url = 'http://geocode-maps.yandex.ru/1.x/?lang=en-US&format=json&geocode='+message.lat+','+message.lon;
			var xmlHttpReq = new XMLHttpRequest();
			if(xmlHttpReq) {
				xmlHttpReq.open('GET', url);
				xmlHttpReq.onreadystatechange = function () {
					if(xmlHttpReq.readyState === 4 && xmlHttpReq.status === 200) {
						sendResponse( { results: xmlHttpReq.responseText } );
					}
				};
				xmlHttpReq.send(null); // 'null', ибо 'GET'
			}
			break;
	}
	return true;
});

Сводя воедино

Я не стану приводить код для изменения и хранения опций (все есть на github, плюс он тривиален). Плагин готов, можно тестировать.

$ grunt debug
Running "debug" task

Running "jshint:all" (jshint) task

✔ No problems


Running "concurrent:chrome" (concurrent) task

Running "connect:chrome" (connect) task
Started connect web server on http://localhost:9000

Running "watch" task
Waiting...
>> File "app/scripts/main.js" changed.
Running "jshint:all" (jshint) task

✔ No problems


Done, without errors.


Execution Time (2014-10-21 12:05:41 UTC)
loading tasks    3ms  ▇▇ 2%
jshint:all     154ms  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 98%
Total 157ms

Completed in 1.172s at Tue Oct 21 2014 14:05:42 GMT+0200 (CEST) - Waiting...

Можно сходить на страницу, содержащую картинки с гео-тегами и полюбоваться на карту.

В продакшн!

$ grunt build
Running "clean:dist" (clean) task
Cleaning dist/_locales...OK
Cleaning dist/background.html...OK
Cleaning dist/bower_components...OK
Cleaning dist/lib...OK
Cleaning dist/manifest.json...OK
Cleaning dist/options.html...OK
Cleaning dist/popup.html...OK
Cleaning dist/scripts...OK
Cleaning dist/styles...OK

Running "chromeManifest:dist" (chromeManifest) task
Build number has changed to 1, 0, 2
# ............. ⇛ еще тонна отладочного вывода
Running "compress:dist" (compress) task
Created package/exifspy-1.0.2.zip (90771 bytes)

Done, without errors.

Execution Time (2014-10-21 12:45:23 UTC)
clean:dist                                              104ms  ▇▇▇▇ 3%
useminPrepare:html                                       73ms  ▇▇▇ 2%
concurrent:dist                                          1.1s  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 30%
uglify:dist/scripts/background.js                        47ms  ▇▇ 1%
uglify:dist/bower_components/jquery/dist/jquery.min.js  993ms  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 28%
uglify:dist/lib/jquery.exif.js                          101ms  ▇▇▇▇ 3%
uglify:dist/lib/leaflet.js                              979ms  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 27%
compress:dist                                            68ms  ▇▇▇ 2%
Total 3.6s

Файл package/exifspy-1.0.2.zip готов и ждет отправки в Webstore. Если что-то упустил — потормошите, добавлю.

Автор: mudasobwa

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js