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

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

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

Неприятности начались на предпоследнем шаге, когда сервер совершенно неожиданно для меня отказался выдавать желаемый результат. Я довольно долго пребывал в состоянии ступора, продолжая снова и снова сравнивать логи «искусственных» и «естественных» (браузерных) запросов, в надежде найти хоть какое-то несовпадение. Прошло несколько часов, прежде чем я понял, что впустую трачу время.

И тогда я обратил свое внимание на JavaScript cookies, т.е. cookies, создаваемые кодом JavaScript. Разумеется, 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 нельзя использовать вместе.

К тому времени я уже видел два пути решения:

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

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

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

Формат файла оказался довольно простым – 7 полей (атрибутов) в каждой строке, разделенных tab’ами и идущих в таком порядке:

  • domain
  • tailmatch
  • path
  • secure
  • expires
  • name
  • value

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

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

class CookieJarWriter
{
/**
 * имя (путь) файла cookie
 */
protected $sFile = false;

/**
 * массив значений общих полей записей в файле cookie
 */
protected $aPrefix = array(
    '',      // domain
    'FALSE', // tailmatch
    '/',     // path
    'FALSE', // secure
);

/**
 * строка значений общих полей записей в файле cookie
 */
protected $sPrefix = '';


/**
 * @param array $file - 
 * @param string $domain - 
 */
function __construct($file, $domain = '') {
    if (!$file)
        return;
    $this->sFile = $file;
    $this->setPrefix($domain);
}

/**
 * Инциализация/изменение общих полей записей в файле cookie
 * @param string $domain - значение поля "domain"
 * @param bool $bTail - значение общего поля "tailmatch"
 * @param string $path - значение общего поля "path"
 * @param bool $bSecure - значение общего поля "secure"
 */
function setPrefix($domain = null, $bTail = null, $path = null, $bSecure = null) {
    if (!is_null($domain))
        $this->aPrefix[0] = $domain;
    if (!is_null($bTail))
        $this->aPrefix[1] = $bTail ? 'TRUE' : 'FALSE';
    if (!is_null($path))
        $this->aPrefix[2] = $path;
    if (!is_null($bSecure))
        $this->aPrefix[3] = $bSecure ? 'TRUE' : 'FALSE';
    if ($this->aPrefix[0])
        $this->sPrefix = implode("\t", $this->aPrefix) . "\t";
}

/**
 * Добавление/изменение/удаление записи в файл cookie
 * @param string $name - имя параметра cookie (индивид-ое поле "name")
 * @param string $value - значение параметра cookie (индивид-ое поле "value")
 * @param int $life - срок хранения (в днях) записи cookie
 * @return string|bool - содержимое записи либо результат удаления
 */
function setCookie($name, $value = null, $life = 1) {
    if (!$this->sFile || !$this->sPrefix || !$name)
        return false;
    $cont = file_exists($this->sFile) ? file_get_contents($this->sFile) : '';
    $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, $this->sPrefix) === 0 &&
            strpos($row, "\t" . $name . "\t") !== false) {
            $i_row = $i;
            break;
        }
    }
    $ret = true;
    if (!is_null($value)) {
        // добавление/изменение:
        $life = intval($life);
        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, $value));
    }
    else if ($i_row >= 0) {
        // удаление:
        unset($a_rows[$i_row]);
    }
    file_put_contents($this->sFile, implode($cr, $a_rows) . $cr);
    return $ret;
}

/**
 * Добавление/изменение записи в файл cookie
 * @param string $name
 * @param string $value
 * @param int $life
 * @return string|bool
 */
function addCookie($name, $value, $life = 0) {
    return $this->setCookie(array($name, $value, $life));
}

/**
 * Удаление записи из файла cookie
 * @param string $name
 * @return bool
 */
function removeCookie($name) {
    return $this->setCookie(array($name));
}
}
?>

Исходный код этого класса можно также найти на Гитхабе.

Итак, инструмент для «продвинутой» работы с куками был готов. Но на этом мои приключения не закончились. Предстояло еще отследить те самые «значимые» JS куки, понять какие значения им присваиваются и многое другое. По определенным причинам я не могу раскрыть здесь все подробности этого процесса, так же, как и назвать сайт, с которым работал. Вместе с тем, без практического примера использования рассмотренного инструмента данная статья выглядела бы незавершенной. Так что практическая часть здесь все-таки будет, но в упрощенной форме и на примере другого сайта, не требующего авторизации. Сайт этот — всем известный Яндекс-Каталог. Рассмотрим для примера категорию Фриланс.

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

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

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

Для начала проведем разведку. Обратим внимание на 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-запросы, можно без особого труда написать скрипт бота. Вот его код с небольшими комментариями:

require_once "CookieJarWriter.inc.php";

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;
    }
}

/**
 * @param array $aOpts - массив значений опций CURL
 * @param string $url - URL
 * @param string $urlRef - URL реферера
 * @return string|false
 */
function getByCurl($aOpts, $url, $urlRef = '') {
    $hc = curl_init();
    curl_setopt_array($hc, $aOpts);
    curl_setopt($hc, CURLOPT_URL, $url);
    curl_setopt($hc, CURLOPT_REFERER, $urlRef);
    $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;
}

// Имя (путь) файла cookie:
$file_cook = dirname(__FILE__) . '/cookiejar.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 => $file_cook,
    CURLOPT_COOKIEJAR => $file_cook,
);
define('URL1', 'http://yaca.yandex.ru/setup.xml');
define('URL2', 'http://yaca.yandex.ru/yca/cat/Employment/Freelance/?rnd=');
define('FILE_RESULT', 'result.htm');

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

На этом все. Спасибо за внимание.

P.S.: Эта статья в свое время была написана специально для Хабрахабра, где она и была впервые опубликована. Так же как и другие несколько статей, воспроизведенных в этом блоге.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Можно использовать следующие HTML-теги и атрибуты: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>