Каркас многопоточного парсера: применение на примере парсинга IMDB Top 250

Замечание: В этой статье под многопоточностью имеется в виду использование возможностей multi-curl, а под парсером/парсингом — общая схема (алгоритм) сбора данных с вебсайта. Парсинг HTML (DOM) здесь не рассматривается.

Я довольно долгое время пишу многопоточные парсеры на PHP. За это время у меня сформировался некий каркас, включающий в себя часто используемые вещи в парсерах. К их числу относятся:

  • сохранение и восстановление текущего состояния парсера.
  • остановка (пауза) парсера по заданному таймауту.
  • остановка (пауза) парсера по внешнему запросу, например, по нажатию кнопки паузы в интерфейсе парсера.
  • получение текущего прогресса выполнения парсера, например, для вывода его в интерфейсе парсера.

Как видно, большинство пунктов имеет отношение к организации интерфейса парсера.

Кроме того, использование multi-curl накладывает определенные ограничения на структуру кода парсера. Каркас берет на себя все тонкости, связанные с multi-curl. Но платой за это становится жесткая структура, которой должен следовать код парсера (впрочем это обычное свойство каркасов). По сути весь функционал парсера реализуется через колбеки — методы, вызываемые каркасом при определенных событиях.

Описание

Каркас оформлен в виде базового класса RollingScraperAbstract, реализующего общую функциональность.

Главным объектом в каркасе является состояние парсера, которое включает в себя список страниц и список запросов (а также некоторые второстепенные данные для статистики). Под страницей понимается любой ресурс сайта; это может быть, как собственно веб-страница, так и AJAX-запрос, картинка, видео и т.п. Страница задается ее URL. Под запросом понимается набор данных, необходимых для скачивания и обработки страницы. Данные запроса включают номер страницы в списке страниц, а также пользовательские данные, предназначенные для определения контекста запроса при его обработке.

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

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

Каркас содержит два открытых метода: run() и getStateProgress().

Метод run() — главная точка входа, служит для запуска парсера. Возвращает значение типа bool. true означает нормальное выполнение в течение запуска, false означает, что в данный момент работает другой экземпляр парсера.

Метод getStateProgress() служит для получения текущего прогресса выполнения парсера. Вызывается после запуска парсера.

Конкретный парсер должен быть классом, производным от RollingScraperAbstract, и иметь как минимум два реализованных (переопределенных) метода: _initPages и _handlePage.

Метод _initPages вызывается каркасом в начале парсинга, он должен инициализировать начальный набор страниц сайта, с которых начинается парсинг. В простейшем случае это может быть одна страница (главная, карта сайта и др.). Для добавления новых (начальных) страниц служит метод каркаса addPage.
Объявление метода:

protected function _initPages() {
    ...
}

Метод _handlePage вызывается каркасом после каждого выполненного запроса и получает через параметры содержимое ответа и всю сопутствующую информацию (URL, HTTP заголовки и др.), а также данные запроса для определения его контекста. Это означает, что обработка всех запросов должна начинаться в одной точке (методе). Дальнейшее разветвление обработки должно идти в зависимости от контекста запроса. Метод должен возвращать true в случае успешного выполнения (обработки) запроса, в противном случае должно возвращаться значение false.
Объявление метода:

/**
 * @param string $cont - содержимое ответа
 * @param string $url - URL запроса
 * @param array $aInfo - CURL info
 * @param int $index - номер страницы в списке запросов
 * @param array $aData - пользовательские данные
 * @return bool
 */
public function _handlePage($cont, $url, $aInfo, $iPage, $aData) {
    ...
}

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

  • __construct — конструктор может быть определен для установки (изменения) параметров конфигурации парсера (место хранения состояния парсера, таймаут выполнения, опции CURL и др.). Для этого служит метод каркаса modConfig. Если конструктор определен, он должен вызывать конструктор базового класса.
  • _beforeRun — вызывается каркасом перед каждым запуском парсера. Может применяться для восстановления промежуточных результатов (кроме состояния), используемых парсером.
  • _afterRunLoop — вызывается каркасом после каждого цикла выполнения запросов.
  • _beforeEnd — вызывается каркасом перед очисткой состояния. Если парсер в своих результатах использует ссылки (номера) на URL страниц из состояния, этот метод должен сохранить их в другом месте, т.к. после его вызова состояние будет очищено и URL страниц будут потеряны.
  • _save — используется каркасом для сохранения данных в заданном месте, в т.ч. для сохранения состояния. По умолчанию сохраняет данные в файле. Может быть переопределен для сохранения в месте, отличном от файла (например БД).
  • _restore — используется каркасом для восстановления данных из заданного места, в т.ч. для восстановления состояния. Может быть переопределен для восстановления из места, отличного от файла (например БД).
  • _encode — используется каркасом для сериализации данных (по умолчанию — методом serialize) перед их сохранением с помощью метода _save.
  • _decode — используется каркасом для десериализации данных (по умолчанию — методом unserialize) перед их восстановлением с помощью метода _restore.
  • _readPauseFlag — используется каркасом для проверки внешнего запроса на остановку парсера. Должен возвращать true в случае появления внешнего запроса на остановку, в противном случае — false. По умолчанию возвращается значение false, т.е. проверка не используется.
  • ..а также некоторые другие методы/колбеки (см. полный список в phpDoc каркаса).

Исходники каркаса на ГитХабе

Применение

А теперь перейдем от теории к практике и попробуем применить рассмотренный каркас на примере парсинга какого-нибудь сайта.

Составим рейтинг успешности актеров из IMDB Top 250. На IMDB есть список актеров, снявшихся в фильмах Top 250. Но там не учитывается рейтинг этих фильмов, а также значимость ролей актеров в них. К тому же там представлены только самые снимаемые актеры, с количеством появлений не меньше 4-х фильмов.

В нашем списке будут присутствовать все актеры, снявшиеся хотя бы в одном фильме из Top 250. Но при этом желательно ограничивать список ролей из каждого фильма, чтобы в рейтинг не попали актеры с совсем уж второстепенными ролями. Другими словами, в рейтинге должны учитываться только первые n актеров по порядку их следования в списке ролей. Причем этот лимит должен быть настраиваемым (через настройки парсера).

Для расчета рейтинга актера будем использовать такую простенькую формулу:
формула
где:
- актер/актриса;
- фильм из Top 250;
- рейтинг фильма из Top 250;
- коэффициент роли актера в фильме, зависящий от позиции актера в списке ролей фильма и принимающий значения в промежутке (0, 1]. Здесь я не смог подобрать внятной формулы, так что вместо этого будет использоваться просто таблица фиксированных значений. Говоря по правде, эти значения были взяты «с потолка». Впрочем их можно изменить в любой момент без ущерба для работы парсера, поскольку…

Рейтинг будет вычисляться не во время парсинга, а после его завершения, на основе данных, полученных в результате парсинга. К ним относятся:

  • данные фильмов — массив из элементов, содержащих URL, название, рейтинг.
  • данные актеров — массив из элементов, содержащих URL, название, количество фильмов за карьеру, продолжительность карьеры в годах, год начала карьеры.
  • списки ролей актеров в фильмах — массив из элементов, содержащих номера актеров, занятых в каждом фильме.

Данные карьеры я собирался использовать в формуле рейтинга, но так и не решил, как лучше это сделать. Так что пока они просто выводятся в качестве статистики (хотя продолжительность карьеры используется при сортировке результатов). При подсчете фильмов за карьеру учитываются только полнометражные кинофильмы (ТВ фильмы, сериалы, видео и т.п. не учитываются).

Для парсинга HTML будет использоваться Simple HTML DOM Parser, вернее его адаптация для Composer.

Код реализации парсера:

class Scraper extends RollingScraperAbstract
{
    // URL корня сайта:
    const URL_ROOT = 'http://www.imdb.com';
    // URL начальной страницы сайта:
    const URL_INDEX = 'http://www.imdb.com/chart/top';
    // Место хранения состояния (временная статистика):
    const FILE_STATE_TIME = 'scraper-state-time.json';
    // Место хранения состояния (основные данные):
    const FILE_STATE_DATA = 'scraper-state-data.json';
    // Место хранения результатов парсинга:
    const FILE_RESULTS = 'scraper-results.json';

    // Опции CURL:
    static protected $aCurl = array(
        CURLOPT_TIMEOUT => 40,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_AUTOREFERER => true,
        CURLOPT_MAXREDIRS => 3,
        CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:12.0) Gecko/20100101 Firefox/12.0',
        CURLOPT_HTTPHEADER => array('Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3'),
    );

    // Настройки парсера:
    protected $aCfg = array(
        'scrape_life' => 0, // срок годности спарсенных данных
        'cast_limit' => 0, // лимит позиции актера в списке ролей фильма
    );
    // Результаты парсинга - массив данных фильмов:
    protected $aMovies = array();
    // Результаты парсинга - массив данных актеров:
    protected $aActors = array();
    // Результаты парсинга - массив списков ролей актеров в фильмах:
    protected $aCastlists = array();


    public function __construct($aCfg = array()) {
        $this->aCfg = array_merge($this->aCfg, $aCfg);
        $this->aCfg['scrape_life'] *= 3600* 24;
        $this->modConfig(array_merge(array(
            'state_time_storage' => self::FILE_STATE_TIME,
            'state_data_storage' => self::FILE_STATE_DATA,
            'curl_options' => self::$aCurl,
        ), $this->aCfg));
        parent::__construct();
    }

    public function scrape() {
        // восстанавливаем данные результатов (если есть):
        $this->restoreResults();
        // запуск парсера:
        if (!$this->run()) return false;
        // если запуск прошел нормально, то сохраняем данные результатов:
        $this->saveResults();
        return true;
    }

    protected function _initPages() {
        // добавляем начальную страницу:
        $this->addPage(self::URL_INDEX, array(0));
    }

    protected function _beforeRun() {
        // получаем текущий прогресс выполнения парсера:
        $a_state = $this->getStateProgress();
        // если парсинг был завершен, но срок его годности истек, то:
        if (($t_end = $a_state[0]) && time()- $t_end <= $this->aCfg['scrape_life']) {
            // очищаем данные результатов и сохраняем их:
            $this->resetResults(); $this->saveResults();
        }
    }

    public function _handlePage($cont, $url, $aInfo, $iPage, $aData) {
        $aData = array_map('intval', $aData);
        if (!$cont) return false;
        // $aData[0] - тип (уровень) страницы
        switch ($aData[0]) {
        case 0:
            // обработка начальной страницы:
            $a_ret = $this->parseToplist($cont);
            if (!$a_ret) return false;
            foreach ($a_ret as $i => $url) {
                $this->addPage($url, array(1, $i), $iPage);
            }
            return true;
        case 1:
            // обработка страницы фильма:
            $i_movie = $aData[1];
            $a_ret = $this->parseMovie($cont);
            if (!$a_ret) return false;
            $this->aMovies[$i_movie] = array_merge(array($iPage), $a_ret[0]);
            $max = $this->aCfg['cast_limit'];
            $a_cast = array();
            foreach (array_slice($a_ret[1], 0, $max) as $i_cast => $url) {
                $i_actor = count($this->aActors);
                $i_page = $this->addPage($url, array(2, $i_actor), $iPage);
                if ($i_page !== false)
                    $this->aActors[] = array($i_page);
                else
                    $i_actor = $this->findActor($url);
                if ($i_actor !== false)
                    $a_cast[$i_cast] = $i_actor;
            }
            $this->aCastlists[$i_movie] = $a_cast;
            return true;
        case 2:
            // обработка страницы актера:
            $i_actor = $aData[1];
            $a_ret = $this->parseActor($cont);
            if (!$a_ret) return false;
            $this->aActors[$i_actor] = array_merge(array($iPage), $a_ret);
            return true;
        default:
            return false;
        }
    }

    protected function _beforeEnd($aUrls) {
        foreach ($this->aActors as &$a_rec) {
            // заменяем в данных актера его номер URL'ом страницы актера:
            $i_page = $a_rec[0];
            $a_rec[0] = $this->_fixReqUrl($aUrls[$i_page]);
        }
        foreach ($this->aMovies as &$a_rec) {
            // заменяем в данных фильма его номер URL'ом страницы фильма:
            if (!$a_rec) continue;
            $i_page = $a_rec[0];
            $a_rec[0] = $this->_fixReqUrl($aUrls[$i_page]);
        }
    }

    /**
     * Парсинг содержимого страницы Top 250
     * @param string $cont - содержимое страницы
     * @return array|false список URL фильмов
     */
    protected function parseToplist($cont) {
        $html = HtmlDomParser::str_get_html($cont);
        if (!$html) return false;
        $a_urls = array();
        foreach ($html->find('td.titleColumn') as $i => $cell) {
            $link = $cell->find('a[href]', 0);
            if ($link && $link->href)
                $a_urls[$i] = $this->reduceUrl($link->href);
        }
        $html->clear(); unset($html);
        return $a_urls;
    }

    /**
     * Парсинг содержимого страницы фильма
     * @param string $cont - содержимое страницы
     * @return array|false array(array($название, $рейтинг), $список_ролей)
     */
    protected function parseMovie($cont) {
        $html = HtmlDomParser::str_get_html($cont);
        if (!$html) return false;
        $title = count($els = $html->find('h1.header span')) >= 2?
            trim($els[0]->plaintext).' '.trim($els[1]->plaintext) : '';
        $rating = ($el = $html->find('div.star-box-giga-star', 0))?
            str_replace(',', '.', trim($el->plaintext)) : '';
        $a_urls = array();
        // берем актеров из списка звезд:
        foreach ($html->find('div.txt-block[itemprop=actors] a') as $i => $link) {
            if (!$link->find('span', 0)) continue;
            if ($url = $this->reduceUrl($link->href))
                $a_urls[] = $url;
        }
        $n = 3;
        // берем остальных актеров из общего списка ролей:
        foreach ($html->find('table.cast_list td[itemprop=actor]') as $i => $cell) {
            $link = $cell->find('a', 0);
            if (!$link) continue;
            $url = $this->reduceUrl($link->href);
            if ($url && !in_array($url, $a_urls))
                $a_urls[$n++] = $url;
        }
        $html->clear(); unset($html);
        return $title? array(array($title, $rating), $a_urls) : false;
    }

    /**
     * Парсинг содержимого страницы актера
     * @param string $cont - содержимое страницы
     * @return array|false array($название, $фильмов_за_карьеру, $лет_карьеры, $начало_карьеры)
     */
    protected function parseActor($cont) {
        $html = HtmlDomParser::str_get_html($cont);
        if (!$html) return false;
        $title = ($el = $html->find('h1.header span', 0))? $el->plaintext : '';
        $head = $html->find('#filmo-head-actor,#filmo-head-actress', 0);
        // $y_now - текущий год
        $y_now = intval(date('Y'));
        // $y_first - год первого фильма, $y_last - год последнего фильма
        $n = $y_last = $y_first = 0;
        // перебор фильмов из фильмографии актера:
        foreach ($head? $head->next_sibling()->find('div.filmo-row') : array() as $i => $row) {
            $el = $row->find('.year_column', 0);
            if (!$el) continue;
            $year = preg_match('/\d+/', $el->plaintext, $arr)? intval($arr[0]) : 0;
            if (!$year) continue;
            $a_parts = explode('<br', $row->innertext, 2);
            // проверяем, является ли фильм полнометражным, по отсутствию скобок после названия:
            if (strpos(strip_tags($a_parts[0]), '(') !== false) continue;
            $n++;
            if (!$y_last) $y_last = $year;
            if (!$y_first || $year < $y_first) $y_first = $year;
        }
        if ($head) $head->clear(); unset($head);
        $html->clear(); unset($html);
        return $title && $n?
            array($title, $n, min($y_last, $y_now)-$y_first+1, $y_first) : false;
    }

    /**
     * Найти актера по URL его страницы
     * @param string $url - URL страницы
     * @return int номер актера
     */
    protected function findActor($url) {
        $i_page = $this->getPageByUrl($url);
        if ($i_page === false) return false;
        foreach ($this->aActors as $i => $el) {
            if ($el[0] == $i_page) return $i;
        }
        return false;
    }

    /**
     * Получение результатов парсинга
     * @return array array($фильмы, $актеры, $списки_ролей)
     */
    function getResults() {
        return array($this->aMovies, $this->aActors, $this->aCastlists);
    }

    /**
     * Сброс (очистка) результатов парсинга
     */
    protected function resetResults() {
        $this->aMovies = $this->aActors = $this->aCastlists = array();
    }

    /**
     * Восстановление результатов парсинга из файла
     */
    protected function restoreResults() {
        $arr = $this->_restore(self::FILE_RESULTS);
        if (!$arr || count($arr) != 3) return false;
        list($this->aMovies, $this->aActors, $this->aCastlists) = $arr;
        return true;
    }

    /**
     * Сохранение результатов парсинга в файле
     */
    protected function saveResults() {
        $this->_save(self::FILE_RESULTS, $this->getResults());
    }

    protected function _encode($v) {
        return json_encode($v);
    }

    protected function _decode($s) {
        return json_decode($s, true);
    }

    /**
     * Приведение URL страницы к абсолютному
     * @return string
     */
    protected function _fixReqUrl($url) {
        return (!$url || strpos($url, 'http') === 0)? $url : self::URL_ROOT. $url;
    }

    /**
     * Сокращение URL страницы (удаление параметров из URL)
     * @return string
     */
    protected function reduceUrl($url) {
        return ($i = strpos($url, '?')) !== false? substr($url, 0, $i) : $url;
    }
}

/**
 * Обработка результатов парсинга - составление рейтинга актеров
 * @return array
 */
function outResults($aResults) {
    if (count($aResults) != 3) return array();
    list($aMovies, $aActors, $aCastlists) = $aResults;
    $a_result = array();
    foreach ($aActors as $i_actor => $a_rec) {
        if (count($a_rec) < 2 || !$a_rec[3]) continue;
        $a_result[$i_actor] = array_combine(
            array('url', 'title', 'n_movies', 'n_years', 'begin', 'rating', 'appears'),
            array_merge($a_rec, array(0, array()))
        );
    }
    // таблица значений коэффициентов ролей:
    $a_cast_ratios = array(1, 0.8, 0.7);
    $i_castmax = count($a_cast_ratios) - 1;
    foreach ($aCastlists as $i_mov => $a_list) {
        ksort($a_list);
        $r_mov = $aMovies[$i_mov][2];
        foreach ($a_list as $i_cast => $i_actor) {
            if (!isset($a_result[$i_actor])) continue;
            $a_rec = &$a_result[$i_actor];
            $v = $r_mov * $a_cast_ratios[min($i_cast, $i_castmax)];
            $a_rec['rating'] += $v;
            $a_rec['appears'][] = $i_mov;
        }
    }
    // сортировка списка актеров по рейтингу и кол-ву фильмов за карьеру: 
    usort($a_result, function ($a, $b) {
        return $a['rating'] != $b['rating']?
            ($a['rating'] > $b['rating']? -1 : 1) :
            ($a['n_movies'] > $b['n_movies']? -1 : 1);
    });
    return array($a_result, $aMovies);
}

$scraper = new Scraper(array(
    'scrape_life' => 90,
    'cast_limit' => 3,
));
$b = $scraper->scrape();
list($t_start, $t_end, , $t_run, , $n_total, $n_passed) = $scraper->getStateProgress();
$status = $t_end?
    sprintf('завершен (%s)', date('Y.m.d, H:i:s', $t_end)):
    ($b?
        sprintf('не завершен: обработано %d/%d страниц', $n_passed, $n_total) :
        'Отменен из-за наличия другого экземляра парсера'
    );

?>
<h2 style="text-align: center">IMDB парсер</h2>
<pre><b>Статус:</b> <u><?= $status ?></u></pre>
<?php
if ($t_end && ($a_result = outResults($scraper->getResults()))) {
?>
<h3>Результат:<br>
    Рейтинг актеров из <a href="http://www.imdb.com/chart/top">Top 250</a></h3>
<table cellpadding="3" border="1">
    <thead>
        <tr>
            <th rowspan="2">№</th>
            <th rowspan="2">Актер/Актриса</th>
            <th rowspan="2">Фильмы Top 250</th>
            <th rowspan="2">Рейтинг</th>
            <th colspan="3">Карьера</th>
        </tr>
        <tr>
            <th>Фильмов</th>
            <th>Лет</th>
            <th>Начало</th>
        </tr>
    </thead>
    <tbody>
<?php
function outLink($url, $text) {
    return '<a href="'.$url.'">'.$text.'</a>';
}

    $i = 0;
    list($a_rows, $a_urls) = $a_result;
    foreach ($a_rows as $a_rec) {
?>
        <tr>
<?php
        $list = '';
        foreach ($a_rec['appears'] as $i_mov) {
            $list .= '<li>'. outLink($a_urls[$i_mov][0], $a_urls[$i_mov][1].
                ' (#'.($i_mov+1).')'). '</li>';
        }
        $list = "<ol>$list</ol>";
        $arr = array(
            ++$i, outLink($a_rec['url'],$a_rec['title']),
            $list, $a_rec['rating'],
            $a_rec['n_movies'], $a_rec['n_years'], $a_rec['begin']
        );
        foreach ($arr as $val) {
?>
            <td<?= is_numeric($val)? ' align="right"' : '' ?>>
                <?= is_numeric($val) && !is_int($val)? sprintf('%4.2f', $val): $val ?>
            </td>
<?php
        }
?>
        </tr>
<?php
    }
?>
    </tbody>
</table>
<?php
}

Исходники этого примера на ГитХабе.

Результаты парсинга можно посмотреть здесь.

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

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

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