Расширения Google Chrome: cookies и HTTP-запросы

    Продолжается серия публикаций по разработке расширений Google Chrome. Правда, этим постом она и заканчивается, потому что на текущий момент больше мне нет чего поведать по этому поводу. Что будет дальше – посмотрим.
    В этом топике я расскажу о кастовании уличной магии на примере работы с cookies и низкоуровневой обработки HTTP-запросов. Материал этого поста полностью основан на моём первом расширении, которое впоследствии обросло неплохим функционалом.

    Cookies


    Начнём с cookies. Для полноценной работы с ними достаточно наличия следующей строчки в манифесте:
    {
    	...
    	"permissions": [
    		"cookies",
    		...
    	],
    	...
    }
    

    Расширению предоставляется полный доступ ко всем cookies. Даже к тем, что устанавливаются временно в окне incognito, если стоит соответствующая галочка на доступ в окне управления расширениями браузера. Отмечу, что API мне показалось довольно неудобным. Но разобраться можно, и всё прекрасно работает.
    На базе этого было разработано расширение для хранения и переключения между разными сессиями на сайтах. Изначально целью и идеей было облегчение мультоводства в социальных играх, но потом я позиционировал это как универсальный инструмент.

    Как это работает?
    В API браузера есть сделующие методы: chrome.cookies.get(details, callback) для получения данных об одном cookie, аналогичный метод getAll для получения данных обо всех cookies (с теми же параметрами, но details работает как фильтр, это оказалось очень удобно), и chrome.cookies.set(details, callback). Я не буду популярно расписывать параметры и использование, об этом можно прочесть в документации. Лучше перейдём сразу к сути.
    Базовые методы:
    function deleteCookies(с) {
    	//удаление хранимых cookies конкретного профиля
    	for (var i = 0; i < с.length; i++) {
    		try {
    			var d = с[i];
    			var u = ((d.secure) ? "https://" : "http://") + d.domain + d.path;
    			chrome.cookies.remove({
    				url: u,
    				name: d.name,
    				storeId: d.storeId
    			});
    		} catch (e) {
    			console.error("Error catched deleting cookie:\n" + e.description);
    		}
    	}
    }
    function setCookies(c) {
    	//устанавливает cookies нужного профиля
    	for (var i = 0; i < c.length; i++) {
    		try {
    			var d = c[i];
    			var b = {
    				url: ((d.secure) ? "https://" : "http://") + d.domain,
    				name: d.name,
    				storeId: d.storeId,
    				value: d.value,
    				path: = d.path,
    				secure: = d.secure,
    				httpOnly: = d.httpOnly,
    			};
    			if (!d.hostOnly) {
    				b.domain = d.domain;
    			}
    			if (!d.session) {
    				b.expirationDate = d.expirationDate;
    			}
    			chrome.cookies.set(b);
    		} catch (e) {
    			console.error("Error setting cookie:\n" + e.description)
    		}
    	}
    }
    

    Сразу хочу уточнить по поводу storeId. Cookie в браузере хранятся в таких себе контейнерах. Даже есть метод chrome.cookies.getAllCookieStores(callback), в ответ на который возвращается нечто вот такое:
    [{
    	id: 0,
    	tabIds: [1,2,3,4],
    },]
    
    Но возвращается всегда 1 или 2 объекта: один нормальный, и один для окна incognito (если такое открыто, и расширению предоставлен доступ к этому окну). Судя по наличию параметра tabIds, либо планировалось и забылось, либо ещё планируется возможность управления этими store и привязки вкладок к конкретным store. Если бы это уже работало – помогло бы в реализации идеи, о которой будет рассказано в следующем разделе.

    Итак, есть базовые методы, можно делать переключение профилей. Ниже приведён код для примера Facebook.
    var profiles = JSON.parse(localStorage.profiles); //список всех хранимых профилей
    var inuse = localStorage.inuse; //текущий профиль
    var cookies = ['facebook.com','secure.facebook.com','on.fb.me']; //домены, для которых нужно обрабатывать cookies
    function newProfile(){
    	//создание нового профиля с использованием текущих cookies
    	var ii = 0;
    	//получение cookies работает через callback, может понадобиться сохранять данные с нескольких доменов, потому приходится извращаться
    	var initer = function(cii){
    		chrome.cookies.getAll({domain:cookies[cii]}, function (f){
    			var nc = profiles.length-1;
    			profiles[nc].cookies = profiles[nc].cookies.concat(f);
    			if (cii<(cookies.length-1)){
    				cii++;
    				initer(cii);
    			}
    			else{
    				saveSettings();
    				//...отображение
    			}
    		});
    	}
    	chrome.cookies.getAll({domain:cookies[0]}, function (f){
    		var nc = profiles.length;
    		profiles[nc] = {title:'New profile '+nc,cookies:f};
    		inuse = nc;
    		if (ii<(cookies.length-1)){
    			ii++;
    			initer(ii);
    		}
    		else{
    			saveSettings();
    			//...отображение
    		}
    	});
    }
    function switchProfile(d) {
    	//активация профиля
    	if (profiles[d] != undefined && profiles[d] != null && d != inuse) {
    		//cookies, возможно, могут отличаться. Потому для совместимости сначала удаляются все существующие cookies
    		//те же танцы с бубном
    		var deleter = function(cii){
    			chrome.cookies.getAll({domain:cookies[cii]}, function (f){
    				deleteCookies(f);
    				if (cii < (cookies.length-1)){
    					cii++;
    					deleter(cii);
    				}
    				else{
    					restoreCookies(profiles[d].cookies);
    					inuse = d;
    					saveSettings();
    					//...+отображение
    				}
    			});
    		}
    		deleter(0);
    	}
    }
    

    Код выше немного упрощён для демонстрации. На самом деле расширение поддерживает несколько сайтов, потому функции обработки имеют соответствующий вид. Сами функции, думаю, не имеет смысла объяснять, всё понятно по комментариям.
    Расширение никак не реагирует на изменения хранимых cookies в браузере, оно просто хранит состояние на момент создания профиля. Но в API существуют соответствующие события, на которые можно подписаться и как-то их обрабатывать. Также нужно учесть, что в случае наличия защиты на сайте или возможности закрытия всех предыдущих сессий эти cookies могут перестать работать через некоторое время, при изменении IP-адреса или удалении сессий. Но для многих сервисов и социальных сетей, при отметке перманентной авторизации всё работает неограниченно долго.
    Приблизительно в таком виде моё решение работает по сей день. И добавить долгое время было нечего, если бы не одно событие.

    HTTP-запросы


    Случилось так, что к одному ресурсу владельцем был ограничен доступ. Причём фильтрация происходила по заголовку Referer. В поисках решения я и узнал о функциях API webRequest. Они позволяют просматривать и управлять всеми запросами, которые проходят в браузере.
    Начнём с манифеста.
    {
    	...
    	"permissions": [
    		"webRequest",
    		"webRequestBlocking"
    	],
    	...
    }
    

    Первый параметр предоставляет возможность добавить обработчик на события запросов, второй, в дополнение, также позволяет модифицировать или блокировать их.
    Уточнение
    Сейчас в описании API размещена информация об declarativeWebRequest. Судя по описаниям, он предоставляет тот же функционал, но должен быть значительно быстрее, так как правила выполняются непосредственно в браузере, а не на JavaScript. Но пока (и уже продолжительное время) это находится в стадии beta.
    Что касается скорости, моё расширение производит перебор и, при необходимости, модификацию некоторых переменных и заголовков запросов, и отлавливает все запросы. Заметных тормозов от этого я не заметил.
    По поводу публикации
    Расширение, в котором я впервые использовал этот API, изначально работало без него. После первого обновления, куда я включил эти функции, его отправили на ручную модерацию, которая заняла около двух недель. После никаких проблем, даже с добавлением аналогичного функционала в другое расширение, не возникало. Потому я не уверен, принимаются ли сейчас подобные меры.

    API предоставляет возможность назначить обработчики на каждое событие из цикла жизни HTTP-запроса и делать с запросом практически любые действия. Что важно, кроме прочей информации, в параметрах также передаётся инициатор запроса: ID вкладки, страницы, etc. На базе этого родилась идея сделать возможность держать одновременно несколько профилей в разных вкладках. Достаточно ведь в каждом запросе заменять отправляемые cookies на нужные из профиля.
    Сказано – сделано!
    Назначим соответствующий слушатель на событие onBeforeSendHeaders и подменим заголовки на нужные.
    var tabs = {}; //здесь будем хранить привязку вкладки к профилю
    function openProfile(id,url){	//открываем профиль
    	if (url.indexOf('#')>-1) url = url.substr(0,url.indexOf('#')+1) + encodeURIComponent(url.substr(url.indexOf('#')+1));	//небольшой фикс, почему-то в некоторых случаях корректно не происходила авторизация в новом профиле, такая переделка спасала
    	loadSettings();
    	chrome.tabs.create({url:url},function(r){	//открываем новую вкладку и закрепляем за ней нужный профиль
    		if (r)
    			tabs[r.id] = new Array(tinuse,id,profiles[id].cookies.slice());
    	});
    }
    chrome.webRequest.onBeforeSendHeaders.addListener(function(d){
    	if (d.tabId){	//только для вкладок
    		td = d.tabId;
    		if (tabs[td]){	//запрос пришёл из закреплённой вкладки
    			td = tabs[td];
    			for (var i = 0; i < d.requestHeaders.length; i++){
    				if (d.requestHeaders[i].name=='Cookie'){
    					var cks = d.requestHeaders[i].value.split('; '); //все Cookies передаются одним заголовком
    					for (var j = 0; j < cks.length; j++){
    						cks[j] = cks[j].split('=');
    						for (var k = 0; k < td[2].length; k++){
    							if (td[2][k].name==cks[j][0]){	//подменяем
    								cks[j][1] = td[2][k].value;
    								break;
    							}
    						}
    						cks[j] = cks[j].join('=');
    					}
    					d.requestHeaders[i].value = cks.join('; '); //ставим новые cookies
    				}
    			}
    		}
    	}
    	return {requestHeaders: d.requestHeaders}; //пускаем модифицированный запрос дальше
    },{
    	urls: ["<all_urls>"]	//с фильтрами ещё не разбирался и нет особого желания. как я уже сказал, работает и так и ни разу не тормозит
    },[
    	"blocking",	//для возможности модифицировать запрос
    	"requestHeaders"	//что нужно обработать
    ]);
    

    Сначала получился такой вариант. Как оказалось, этого мало. По причине неактивности (Сколько времени может пройти после последнего использования профиля? Тем более, cookies хранятся те, что были на момент создания, и в процессе работы не обновляются.) сессия устаревает и происходит редирект на страницу авторизации, где, тем не менее, авторизация происходит автоматически, но создаётся новая сессия. Потому, надо обрабатывать и это.
    chrome.webRequest.onHeadersReceived.addListener(function(d){
    	if (d.tabId){
    		td = d.tabId;
    		if (tabs[td]){
    			td = tabs[td];
    			//Cookies устанавливаются при получении заголовка следующего вида:
    			//name: "Set-Cookie"
    			//value: "cookiename=value; expires=Sun, 22-Dec-2013 03:31:23 GMT; path=/; domain=.domain.com"
    			for (var i = 0; i < d.responseHeaders.length; i++){
    				if (d.responseHeaders[i].name=='Set-Cookie'){
    					var sk = d.responseHeaders[i].value.split('; ');
    					sk[0] = sk[0].split('=');
    					var ck = {name:sk[0][0],value:sk[0][1]};
    					for (var j = 1; j < sk.length; j++){
    						sk[j] = sk[j].split('=');
    						ck[sk[j][0]] = sk[j][1];
    					}
    					for (var k = 0; k < td[2].length; k++){
    						if (td[2][k].name==ck.name && td[2][k].path==ck.path && td[2][k].domain==ck.domain){
    							//Есть такой хранимый cookie, надо обновить
    							td[2][k].value = ck.value;
    							break;
    						}
    					}
    					//браузеру это возвращать не нужно, иначе он у себя заменит cookies текущей реальной сессии, мы же держим отдельную сессию только для конкретной вкладки
    					d.responseHeaders.splice(i,1);
    					i--;
    				}
    			}
    		}
    	}
    	return {responseHeaders: d.responseHeaders};
    },{
    	urls: ["<all_urls>"]
    },[
    	"blocking",
    	"responseHeaders"
    ]);
    

    Теперь всё работает. Изначально я думал обновлять cookies в хранимых данных профиля, но тогда получилась бы неразбериха. Можно открыть его в новой вкладке, а там авторизоваться под другим профилем, ещё что-то наделать, и всё потеряется. Потому было решено создавать временную копию на время работы. Но раз такое дело – эти же данные надо потом удалять. А также не помешает обработать открытие новых вкладок из текущей.
    chrome.tabs.onCreated.addListener(function(tab){
    	if (tab.openerTabId){	//такой параметр передаётся при открытии новой вкладки из-под какой-то существующей
    		if (tabs[tab.openerTabId]){
    			tabs[tab.id] = tabs[tab.openerTabId];
    		}
    	}
    });
    chrome.tabs.onRemoved.addListener(function(tabId, removeInfo) {
    	//срабатывает при закрытии вкладки. Если что-то хранится для её ID – удалить
    	if (tabs[tabId]) delete tabs[tabId];
    });
    

    В таком же виде это работает в моём расширении.
    Во время разработки также появилась более глобальная идея. Написать расширение для создания вот таких изолированных вкладок с изначально пустыми cookies и там уже обрабатывать полностью все события. Такие себе вклаки, по функционалу аналогичны окну incognito. Но этим я пока не занимался через полное отсутствие в браузере возможности как-то визуально выделить такие вкладки среди остальных. Закрепление – это не то, плодить окна тоже не вижу смысла. Потому это пока витает в воздухе в виде идеи.

    Вот такие «чудеса» можно делать с использованием средств, предлагаемых браузером. Учитывая ещё простоту и используемые средства для разработки – я не перестаю восхищаться Chrome в сравнении с Firefox.
    Кстати, в реализации предложенного функционала очень помогла бы возможность создавать и привязывать к вкладкам cookieStore, о которых я писал выше. Очень надеюсь, что когда-то такое будет, равно как и возможность визуально отличать вкладки. Например, выделять их цветом, как сделано в IE.
    Ну и на последок. Не сочтите за рекламу. Если кому интересно, расширение, о котором я упоминал в этом посте – «VK+ Switcher». Исходников нигде не публиковал, но если хотите поковыряться – можно установить и посмотреть структуру в папке браузера с расширениями.
    • +19
    • 38k
    • 4
    Поделиться публикацией

    Похожие публикации

    Комментарии 4

      0
      Наконец-то что-то более продвинутое, чем базовый туториал а ля «как создать расширение с одной кнопочкой». Спасибо!
        +1
        Ага, интересная статья, не поверхностная.
        0
        Интересно про смену профиля и подмену запроса. Еще можно добавить, что для отладки с куками помогает:

        chrome.cookies.onChanged.addListener(function(info) {
          console.log("Cookie onChanged:" + JSON.stringify(info));
        });
        
          0
          Например, выделять их цветом
          Как успехи? Научились менять цвет вкладок?

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

          Самое читаемое