22 November 2011

Интеграция JavaScript cookies в CURL-запросы

PHP
Sandbox
В этой статье я расскажу об одной необычной проблеме, с которой мне однажды пришлось столкнуться по роду своей деятельности. Внимание: эта статья не для начинающих. Предполагается, что читатель уже имеет опыт веб-программирования и знаком с языком PHP, библиотекой CURL и основами HTTP.

Перейдем к описанию проблемы.
Мне нужно было написать скрипт бота для одного сайта с целью автоматизации некоторого процесса из нескольких шагов, не считая авторизации. Такова была задача, на первый взгляд, ничего особенного. Впрочем, поначалу так оно и было. Т.к. на сайте применялась авторизация и активно использовались cookies, было решено использовать CURL. Я понемногу сниффил HTTP-запросы к серверу сайта и воспроизводил их у себя в скрипте. Процесс, как говорится, шел…. Неприятности начались на предпоследнем шаге, когда сервер совершенно неожиданно для меня отказался выдавать желаемый результат. Это привело меня в состояние ступора, в котором я пребывал довольно долгое время, продолжая снова и снова сравнивать логи «искусственных» и «естественных» (браузерных) запросов в надежде найти хоть какое-то несовпадение. Прошло несколько часов, прежде чем я понял, что впустую трачу время.

И тогда я обратил свое внимание на так называемые JavaScript (или non-HTTP) cookies. Разумеется, CURL не мог отследить их появление и соответственно не мог добавить их в свои запросы. Но зачем серверу проверять JavaScript cookies? Хороший вопрос, в другой раз он бы обязательно меня заинтересовал, но в тот момент у меня были другие заботы.

Забегая вперед, скажу, что моя догадка оказалась верной. Сервер и в самом деле проверял JS куки, причем только на том злополучном предпоследнем шаге. Я не знаю, с какой целью это было сделано. Тем более я не уверен, что этой целью была защита от ботов. Как говорится, чужая душа (сервер) — потемки.

Итак, мне предстояло решить следующие две задачи:
  1. найти «значимые» JavaScript cookies, которые влияют на ответ сервера;
  2. найти способ вставить эти куки в запросы CURL.

Насчет первой задачи мне, в принципе, было все ясно, во всяком случае, я уже представлял себе примерный план действий. Так что я решил сразу начать со второй: найти средство для добавления «своих» куков в CURL-запрос, дополнительно к тем, что появляются там автоматически, из файла, указанного опцией CURLOPT_COOKIEFILE.

Первым, что пришло мне в голову, была мысль: включить опцию CURLOPT_COOKIE со строкой, составленной из параметров куков. Примерно так:
curl_setopt($hc, CURLOPT_COOKIE, "name1=value1; name2=value2; ...");

Так я и сделал: добавил эту строку в код… и очень скоро убедился, что это не работает. Вернее работает, но совсем не так как мне хотелось. В отправленном HTTP-заголовке были только куки, добавленные этой опцией, а вот куки из файла CURLOPT_COOKIEFILE при этом исчезли (исчезли из заголовка, а не из файла). Т.е. содержимое файла-хранилища куков игнорировалось. Из этого следует простой и бесполезный вывод: опции CURLOPT_COOKIE и CURLOPT_COOKIEFILE/CURLOPT_COOKIEJAR нельзя использовать вместе.

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

А тем временем срок сдачи работы подходил к концу, заказчик требовал объяснений, а у меня, как назло, куда-то пропало все мое красноречие. Тут мне почему-то вспомнилась крылатая фраза из одной советской комедии: «Либо я веду ее в загс, либо она ведет меня к прокурору». Ни в загс, ни к прокурору, мне не хотелось. Мне очень хотелось куда-нибудь скрыться и забыться… но пришлось выбирать другой способ устранения проблем.

К тому времени я уже видел два пути решения:
  1. отказаться от услуг CURL'а в плане автоматической обработки cookies и взять эту «черную» работу себе, т.е. самому парсить куки из заголовков ответа, сохранять их, и передавать вместе с запросами. Звучит немного пугающе, но зато это дает полный контроль над куками.
  2. оставить авто обработку cookies, но добавить возможность вставки в файл куков «своих» (кастомных) параметров. Тоже перспектива не из приятных, поскольку это предполагало ручную правку файла куков.

После недолгих колебаний был выбран второй вариант, т.к. он показался мне более легким в реализации. К тому же меня давно уже интересовал формат Netscape Cookie File, но не было повода познакомиться с ним поближе. И вот этот повод появился.
Искать информацию по этому формату долго не пришлось. С первой же страницы выдачи гугла я попал на оф. сайт CURL'а, в архив переписки пользователей с создателем этой библиотеки, где и нашел то, что искал.

Формат файла оказался довольно простым – 7 полей (атрибутов) в каждой строке, разделенных tab'ами и идущих в таком порядке:
  • domain
  • tailmatch
  • path
  • secure
  • expires
  • name
  • value

Смысл этих полей имхо вполне очевиден. Отмечу только, что tailmatch – это флаг точного совпадения доменного имени сайта.

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

<?php
class CookiejarEdit
{
protected $sFname= false; // имя файла cookies
protected $aPrefix= array( // массив значений общих полей записей
	'',		// #0: domain
	'FALSE',	// #1: tailmatch (строгое совпадение доменного имени)
	'/',	 	// #2: path
	'FALSE',	// #3: secure (https-соединение)
);
protected $sPrefix= ''; // строка значений общих полей записей cookie

function __construct($sFn, $sDomain='', $aXtra=0) {
	if (!$sFn) return;
	$this->sFname= $sFn;
	$this->setPrefix($sDomain, $aXtra);
}
function __clone() {
	$this->setPrefix();
}

/****
** Инициализация/установка общих полей записей cookie:
** Аргументы:
**  1) $sDomain - значение поля 'domain'
**  2) $aXtra - массив значений дополн-ных общих полей:
**     $aXtra['tailmatch'] - значение поля 'tailmatch'
**     $aXtra['path'] - значение поля 'path'
**     $aXtra['secure'] - значение поля 'secure'
*/
function setPrefix($sDomain, $aXtra=0) {
	if ($sDomain)
		$this->aPrefix[0]= $sDomain;
	if (is_array($aXtra)) {
		if (isset($aXtra['tailmatch']))
			$this->aPrefix[1]= $aXtra['tailmatch']? 'TRUE': 'FALSE';
		if (isset($aXtra['path']))
			$this->aPrefix[2]= $aXtra['path'];
		if (isset($aXtra['secure']))
			$this->aPrefix[3]= $aXtra['secure']? 'TRUE': 'FALSE';
	}
	if ($this->aPrefix[0])
		$this->sPrefix= implode("\t", $this->aPrefix). "\t";
}

/****
** Экспорт содержимого файла cookies:
*/
function export() {
	return ($this->sFname)? file_get_contents($this->sFname) : false;
}

/****
** Импорт содержимого файла cookies:
*/
function import($sCont) {
	if (!$sCont || strlen($sCont)<10) return false;
	file_put_contents($this->sFname, $sCont);
	return true;
}

/****
** Добавление/изменение/удаление записи в/из файла cookies:
** Аргументы:
**  1) $aFields - массив значений индивидуальных полей записи cookie
**     $aFields[0] - поле 'name' (имя параметра)
**     $aFields[1] - поле 'value' (значение параметра)
**     $aFields[2] - срок хранения записи в днях
** Возвращает значения:
**  1) false - в случае неправильного вызова
**  2) true - в случае успеха удаления
**  3) string - в случае успеха добавления/изменения, содержимое строки записи
*/
function setCookie($aFields) {
	if (!$this->sFname || !$this->sPrefix)
		return false;
	if (!is_array($aFields) || !($n_arr= count($aFields)))
		return false;
	$name= $aFields[0];
	$cont= file_exists($this->sFname)? file_get_contents($this->sFname): '';
	$cr= (strpos($cont, "\r\n") !== false)? "\r\n" : "\n";
	$a_rows= explode($cr, trim($cont, $cr));
	$i_row= -1;
	foreach ($a_rows as $i=> $row) {
		if (strpos($row, "\t".$name."\t") === false) continue;
		if (strpos($row, $this->sPrefix) !== 0) continue;
		$i_row= $i; break;
	}
	$ret= true;
	if ($n_arr> 1) {
		// add/modify:
		$val= $aFields[1];
		$life= ($n_arr> 2 && $aFields[1]>= 0)? $aFields[1] : 1;
		if ($i_row<0) $i_row= count($a_rows);
		$n_exp= ($life> 0)? (time()+ $life* 24* 60* 60) : 0;
		$a_rows[$i_row]= $ret=
			$this->sPrefix. implode("\t", array($n_exp, $name, $val));
	}
	else if ($i_row>= 0) {
		// remove:
		unset($a_rows[$i_row]);
	}
	file_put_contents($this->sFname, implode($cr, $a_rows).$cr);
	return $ret;
}

/****
** Добавление/изменение записи в файл cookies:
*/
function addCookie($sName, $sVal, $nLife=0) {
	return $this->setCookie(array($sName, $sVal, $nLife));
}

/****
** Удаление записи из файла cookies:
*/
function removeCookie($sName) {
	return $this->setCookie(array($sName));
}
}
?>


Методы __clone(), export(), import() были добавлены «чисто для украшения» кода. Честно говоря, я не вижу в них большого смысла, так же как и в дополнительном аргументе $aXtra для метода setPrefix, который я добавил просто на всякий случай (хотя имхо, необходимость в нем не может возникнуть по определению). В любом случае код рабочий и готов к использованию (PHP >= 5.0).
Я не претендую на оригинальность идеи и не исключаю возможности, что это очередной «велосипед». Возможно, аналогичные и даже более простые решения давно уже существуют. Тем не менее, мне мой «велосипед» помог и я буду рад, если он поможет кому-нибудь еще.

Итак, инструмент для «продвинутой» работы с куками был готов. Но на этом мои приключения не закончились. Предстояло еще отследить те самые «значимые» JS куки, понять какие значения им присваиваются и многое другое. Но это уже другая история. А эту я пожалуй закончу. Спасибо за внимание.

P.S.:
Перечитав текст статьи в очередной раз, я понял, что ей все-таки недостает «примера из реальной жизни». Неплохо было бы, подумал я, устроить небольшую демонстрацию на примере какого-нибудь сайта. Использовать тот сайт, с которым я работал, я, по определенным причинам, не мог. Поэтому нужно было найти ему замену: какой-нибудь известный сайт, не требующий авторизации, где хоть как-то используется проверка JavaScript cookies на стороне сервера. К моему удивлению и счастью такой сайт нашелся очень быстро: в моих закладках. Это всем известный Яндекс-Каталог, категория Фриланс (то, что мне ближе всего).

Сначала эта страница выглядит так:


Но если перейти к настройкам, выбрать там пункт «стандартное с номерами»:


и вернуться на страницу каталога, то мы добъемся «чудесного» эффекта: превью со страницы исчезнут и останутся только «сухие» цифры и текст:


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

Для начала проведем рекогносцировку. Обратим внимание на url страницы каталога после изменения настроек: он не изменился. Правда, к нему добавилась строка "?rnd=xxx", но это, по всей видимости, всего лишь указание браузеру не брать страницу из кэша. Отсюда можно сделать вывод, что настройки передаются и сохраняются, скорее всего, через куки.

Попробуем разобраться как именно это происходит. В этом нам поможет такой полезный инструмент, как Live HTTP Headers:


Это расширение Firefox'а позволяет отслеживать весь входящий и исходящий HTTP-траффик в браузере, в том числе и куки, посредством которых и осуществляется запоминание настроек в нашем примере. Происходит это, очевидно, после нажатия кнопки «Сохранить» и перехода к странице каталога.

Зайдем еще раз на страницу настроек, предварительно включив снифер Live HTTP Headers. Выберем снова пункт «стандартное с номерами» и нажмем кнопку «Сохранить». А теперь посмотрим на наш улов в снифере. Нас интересуют подробности запроса страницы каталога. У меня они имеют такой вид:

GET /yca/cat/Employment/Freelance/?rnd=191 HTTP/1.1
Host: yaca.yandex.ru
User-Agent: ...
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ru-ru,ru;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip,deflate
Accept-Charset: windows-1251,utf-8;q=0.7,*;q=0.7
Keep-Alive: 115
Connection: keep-alive
Referer: http://yaca.yandex.ru/setup.xml
Cookie: yandexuid=796954901281541279; fuid01=4c62c49f04c00e82.pQ2hPKLWAnitiiTOBnW-nvOhiFssICTMfcKaMv0ZeTsFKaxVHOYxAPA2AGRsdF1qi3rm7fAKk77gJevuaNmhtnNUx_k0ykECc8bRJv3dUadZ_YDF1QLDZddTzYP_ZfOs; my=YwA=; L=eEAcXVFJR252Q0ADVkt9BW5wWmFyXXhXBkBYAwQaYmIRBgo6Ciw9ZggRFwUmNQwcOUs5LwQvVD42OjAPCmFfFQ==.1310050733.9042.213864.a4f928b9d113358bc254454a879f6c5c; yp=1636215158.sp.; yabs-frequency=/3/UOW2AQmAGyle0Ici2au0/; yaca_view=num


Здесь сразу бросается в глаза фрагмент "yaca_view=num". Скорее всего, это и есть наш искомый cookie-параметр. Но где он устанавливается? Во всяком случае не в заголовках ответа сервера, поскольку там этот параметр не встречается. Тогда логично предположить, что это JavaScript cookie и значит, его установка происходит где-то в яваскриптах страницы настроек ("setup.xml"). Попробуем найти его в тексте этой страницы. Так и есть. Вот строка из файла "setup.xml":
$.cookie('yaca_view', $('input[name="yaca_view"]:checked' ).val());

По всей видимости здесь и происходит установка параметра "yaca_view" со значением, взятым из одноименного элемента формы (в нашем случае это значение 'num').

Итак, мы выяснили, что для того, чтобы увидеть страницу каталога без превьюшек, нужно передать серверу cookie-параметр с именем 'yaca_view' и значением 'num'. Теперь, когда у нас уже есть средство для добавления cookies в CURL-запросы, можно без особого труда написать скрипт бота. Вот его код с небольшими комментариями:

<?php
require_once "cookiejaredit.inc";

if (!function_exists('curl_setopt_array')) {
function curl_setopt_array(&$hc, $a_opts) {
	foreach ($a_opts as $name=> $val)
		if (!curl_setopt($hc, $name, $val)) return false;
	return true;
}
}

/****
** Скачивание файла с помощью CURL:
** Аргументы:
**  1) $aOpts - массив значений опций CURL:
**  2) $sUrl - URL файла
**  3) $sUrlRef - URL реферера
** Возвращает значения:
**  1) false - в случае ошибки
**  2) string - в случае успеха, содержимое файла
*/
function getByCurl($aOpts, $sUrl, $sUrlRef='') {
	$hc= curl_init();
	curl_setopt_array($hc, $aOpts);
	curl_setopt($hc, CURLOPT_URL, $sUrl);
	curl_setopt($hc, CURLOPT_REFERER, $sUrlRef);
	$cont= curl_exec($hc);
	$b_ok= curl_errno($hc)==0 && curl_getinfo($hc, CURLINFO_HTTP_CODE)==200;
	echo "\nSent HTTP Header:\n". curl_getinfo($hc, CURLINFO_HEADER_OUT).
		"Content Length: ".strlen($cont)."\n\n";
	curl_close($hc);
	return $b_ok? $cont : false;
}

// Имя (путь) файла cookies:
$fn_cook= $_SERVER['DOCUMENT_ROOT'].'/cookiejar-tmp.txt';
// Массив значений опций CURL:
$a_curl_opts= array(
	CURLOPT_NOBODY => 0,
	CURLOPT_RETURNTRANSFER => 1,
	CURLOPT_CONNECTTIMEOUT => 10,
	CURLOPT_TIMEOUT => 15,
	CURLOPT_USERAGENT => 'Mozilla/5.0 Gecko/20110920 Firefox/3.6.23',
	CURLINFO_HEADER_OUT => true,
	CURLOPT_COOKIEFILE => $fn_cook,
	CURLOPT_COOKIEJAR => $fn_cook,
);
define('URL0', 'http://yaca.yandex.ru/yca/cat/Employment/Freelance/');
define('URL1', 'http://yaca.yandex.ru/setup.xml');
define('URL2', 'http://yaca.yandex.ru/yca/cat/Employment/Freelance/?rnd=');
define('FN_RESULT',	'result.htm');

echo '<h3>Trace Log:</h3><pre>';
$cookedit= new CookiejarEdit($fn_cook, 'yaca.yandex.ru');
// Скачиваем страницу настроек:
getByCurl($a_curl_opts, URL1, URL0);
// Добавляем JS cookie-параметр:
$rec= $cookedit->addCookie('yaca_view', 'num');
echo "addCookie:\n". ($rec? "$rec\n" : "Fail\n");
// Скачиваем страницу каталога:
$cont= getByCurl($a_curl_opts, URL2. rand(0,999), URL1);
echo '</pre>
<hr><h3>Result: ';
if ($cont) {
	file_put_contents(FN_RESULT, $cont);
	echo 'OK</h3><a href="'.FN_RESULT.'" target="_blank">Result page</a>';
}
else
	echo 'Fail</h3>';
?>


Теперь действительно все. Еще раз спасибо за внимание, особенно тем кто дочитал до конца).
Tags:phpcurljavascript cookieshttp-заголовки
Hubs: PHP
+29
22k 161
Comments 68