Замечание: В этой статье под многопоточностью имеется в виду использование возможностей 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 }
Исходники этого примера на ГитХабе.
Результаты парсинга можно посмотреть здесь.