Посты / Laravel 5.5 и парсинг. Что такое краулер (crawler).

04.10.2017 16:52
Привет друзья! Хоть год еще и не закончился, но я уже начинаю понемногу подводить итоги того, что было сделано, что не успел и почему. В общем раскладываю работу по полочкам и анализирую. И решил написать о такой важной теме, как парсинг данных из разных источников. Кто и как это делает, и как это делать правильно. Как нужно правильно использовать парсинг в Ларавел вообще и в Ларавел 5.5 в частности. Итак, мои измышления на эту тему находятся в посте.

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

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

Итак, с каким ужасом мне пришлось столкнуться. Первое - это то, что все парсера писались разными программистами и в разные временные периоды. Логика всех решений была абсолютно разная. Где-то парсили регулярками, где-то simpleHtml либой, где-то через встроенные в PHP DOM инструменты. В общем, кто в лес, кто по дрова.

Но все эти решения объединяли некоторые факторы. А именно: отсутствие документации и комментариев в коде, отсутствие самой концепции парсинга данных (какого-то единого подхода и логики реализации), огромное количество всяких проверок в DOM страницы (куча встроеных if-else, switch-case), множественные циклы, встроенные друг в друга.

Ну и самое страшное - это то, что сама логика жестко привязана к структуре документа. В итоге, буквально через 3-6 мес. после написания этих парсеров, сайты-доноры меняли дизайн, верстку, кто-то другой движок стал использовать и т.д. И все решения по парсингу перестали работать, а некоторые вообще не то забирали с других сайтов. Вначале разработчики все эти парсера латали, подстраивая под изменения на сайте-доноре. Потом в какой-то момент руководители плюнули на это и поручили менеджерам в ручном режиме переносить информацию к нам на ресурс.

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

Правда я видел что такое скрам в датской и бельгийской компании, и что такое скрам в отечественных компаниях. Так если в первом случае люди его используют для решения своих бизнес задач, то во втором - люди собираются вместе чтобы просто "потрындеть" о ни о чем и разойтись, чтобы начать изобретать свои велосипеды. А начальство с гордостью может заявить своим инвесторам, что они работают по скраму.

Дальше, следующая проблема, которая вытекает из первой. Разработчик в трекере получает задачу и тут же начинает ее пилить так, как он умеет или ему хочется. И последняя проблема - это опять хотелки руководителя проекта сделать все на вчера, а если что, то завтра уже сделаем как положено. В 99.99% случаев, завтра не наступает никогда.

Итак, поняв причины проблемы и саму проблему мы можем смело перейти к вопросу создания парсера. Давайте начнем формировать требования к парсеру, который будет нам упрощать жизнь, а не усложнять.

Давайте накидаем ряд пунктов (будем уже говорить о Laravel 5.5):
1. Должен быть единый парсер для всех наших задач.
2. Должна быть единая и понятная логика работы нашего парсера.
3. Добавление нового сайта донора в парсер должно занимать не более 5-10 минут
4. Парсер должен иметь свою админку с простым и понятным интерфейсом.
5. Парсер должен уметь забирать не только текст, но и файлы.
6. Если на сайте-доноре меняется структура документа, то перенастройка парсера должна занять пару минут.
7. Наш готовый парсер не должен требовать от программиста лазить в код.
8. Нам нужно вести лог того, что, когда и от куда мы забираем. Успешно ли мы завершили операцию.
9. Парсер должен понимать, что мы уже забирали эту информацию, а значит мы ее уже не забираем снова.
10. Мы можем парсить громадное количество источников, а значит нам нужна система импорта/экспорта настроек.

Я думаю, что для начала 10 пунктов будет достаточно. Хотя, на самом деле, их будет определенно больше.

По теории, думаю, хватит. Давайте перейдем к практике. Я покажу, как я решал данную задачу. Поскольку данный пост рассчитан на подготовленных и зрелых разработчиков, то я пройдусь по концепции и основным моментам реализации. Разжевывать код я не буду.

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

Как видно на изображении (я замазал сайты-доноры), у меня настроено 30 разных сайтов-доноров откуда я забираю различную информацию. В моем конкретном случае, я это делаю для развлекательного сайта. О нем я скажу в конце.

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

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

Также есть две кнопки вверху страницы: Все активировать и Все деактивировать. Когда у вас один-два парсера, то они особо не нужны. А когда их у вас десятки, как у меня, то без них вам придется вручную заходить в каждый парсер и менять его статус. Это уйма времени и тонна раздражения.

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

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

Вот так выглядит форма настроек:

Как вы видите на изображении, каждое поле я получаю путем обращения к конкретному DOM элемента. Это очень просто и быстро настраивается и изменяется. Лазить каждый раз в код парсера мне не нужно.

Также к таблице парсера подвязана таблица с логами. Для каждого срабатывания парсера создается своя лог запись, где указывается дата парсинга, и что парсилось. При следующем срабатывании парсера алгоритм заглядывает в лог файл, чтобы не получать тот контент, который уже мы один раз забрали.

Система импорта/экспорта сделана через JSON массив:

Нажали кнопку экспорта, всплыло диалоговое окно с настройками. Скопировали их. На другом проекте нажали кнопку импорта и вставили эти настройки в такое же диалоговое окно. Все, настройки будут импортированы.

Зачем это вообще нужно. Главная задача - это на тестовой машине отладить все настройки парсера, чтобы он правильно забирал данные без ошибок. И когда вы все настроите и убедитесь в том, что все работает как надо, вам эти настройки теперь нужно перенести на продакшн. Думаю, дальше уже пояснять не нужно :)

Теперь пару слов о самой логике парсера.  Предположим, что вам нужно забрать десять новостей с какого-то сайта. Как вы будете это делать? Самое простое и правильное - это заходить парсером на некую общую страницу, где содержится список новостей. Внимание! Не сама новость, а именно список новостей. Это может быть главная страница ил конкретный раздел новостей.

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

Если у вас в настройках указано, что нужно забрать только одну новость, то парсер перейдет только по одной ссылке. При этом нужно не забывать сверять ссылки с данными в логе. Вам нужно перейти по новой ссылке и забрать новый контент.

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

Для начала нам нужно создать отдельную и абстрактную бизнес логику самого парсера. Я привык создавать в App папочку Modules и хранить там отдельные классы для независимых решений.

В самом Ларавел последних версий встроен отличнейший инструмент для парсинга - это компонент от Symfony DomCrawler.
Именно благодаря этому компоненту ваша жизнь может очень сильно упроститься. Почитать подробнее можно здесь: https://symfony.com/doc/current/components/dom_crawler.html

Его нужно импортировать в ваш класс парсера:
use Symfony\Component\DomCrawler\Crawler;

Метод получения контента примерно следующий:
/**
* Get content from html.
*
* @param $parser object parser settings
* @param $link string link to html page
*
* @return array with parsing data
* @throws \Exception
*/
public function getContent($parser, $link)
{
// Get html remote text.
$html = file_get_contents($link);

// Create new instance for parser.
$crawler = new Crawler(null, $link);
$crawler->addHtmlContent($html, 'UTF-8');

// Get title text.
$title = $crawler->filter($parser->settings->title)->text();

// If exist settings for teaser.
if (!empty(trim($parser->settings->teaser))) {
$teaser = $crawler->filter($parser->settings->teaser)->text();
}

// Get images from page.
$images = $crawler->filter($parser->settings->image)->each(function (Crawler $node, $i) {
return $node->image()->getUri();
});

// Get body text.
$bodies = $crawler->filter($parser->settings->body)->each(function (Crawler $node, $i) {
return $node->html();
});

$content = [
'link' => $link,
'title' => $title,
'images' => $images,
'teaser' => strip_tags($teaser),
'body' => $body
];

return $content;
}
В этом методе не хватает еще множества всяких проверок на существование нужных полей, изображений, на тип кодировки и так далее. Здесь только показан общий принцип того, на сколько просто можно получить данные благодаря Crawler.

Укажу о паре граблей, на которые я наступил. Инициализировать краулер можно и так:
    // Create new instance for parser.
$crawler = new Crawler($html);
Но если делать так, то вы получите ошибки путей при парсинге изображений. Поэтому, нужно обязательно передать в краулер линк (ссылка для парсинга), а потом и страницу:
    // Create new instance for parser.
$crawler = new Crawler(null, $link);
$crawler->addHtmlContent($html, 'UTF-8');

При создании роута обязательно делайте параметр secret token, чтобы только вы могли запустить парсер с сайта (если вы не используете демон), например так:
// Start parser. Secret token need for start parser via cron.
Route::get('start-parser/{token}', 'ParserController@start')->name('parser.start');

В самом контроллере никакой бизнес логики быть не должно. Получили коллекцию парсера и передали его сразу в класс парсера. Другими словами, полная абстрация - отдали данные и получили обработанные данные. Если в будущем понадобится что-то допилить, то вы это будете делать только в одном независимом классе парсера, а не по всему коду проекта. Например, так:
// Get parsered links;
$parseredLinks = ParserLog::whereParserId($parser->id)->select('parsered_indexes')->get();

$parserClass = new ParserClass();

// Make parsing sites from DB. Get parsing data.
$parserData = $parserClass->parserData($parser, $parseredLinks);
В моем конкретном случае, получили для парсера все ссылки из лога, которые уже были распарсены, и передали вместе с парсером в метод parserData(). В переменную $parserData вы получите массив с "красивыми" данными, которые можете сохранять в базу. А уже в самом парсере делаете проверку на то, парсили ли вы по данной ссылке или нет.

Теперь пару слов о том, что делать с изображениями в теле текста. Ведь все изображения хранятся на сайте донора. Здесь мне тоже поможет краулер и пакет UploadImage о котором я уже писал ранее (уже есть версия под Ларавел 5.5):
/**
* Save all remote images from body to disk and create new body.
*
* @param $body string body text
* @param $link string link to parsing site
*
* @return string new body
*/
public function saveImagesFromBody($body, $link)
{
// Create new instance for parser.
$crawler = new Crawler(null, $link);
$crawler->addHtmlContent($body, 'UTF-8');

// Get path for images store.
$savePathArray = \Config::get('upload-image')['image-settings'];
$savePath = UploadImage::load($savePathArray['editor_folder']);

$contentType = $savePathArray['editor_folder'];

// Get images from body.
$crawler->filter('img')->each(function (Crawler $node, $i) use ($contentType, $savePath) {
$file = $node->image()->getUri();

try {
// Upload and save image.
$image = UploadImage::upload($file, $contentType, true)->getImageName();

// Replace image in body.
$node->getNode(0)->removeAttribute('src');
$node->getNode(0)->setAttribute('src', $savePath . $image);
$node->getNode(0)->removeAttribute('imagescaler');
$node->getNode(0)->removeAttribute('id');
$node->getNode(0)->removeAttribute('class');
$node->getNode(0)->removeAttribute('srcset');
$node->getNode(0)->removeAttribute('sizes');

} catch (UploadImageException $e) {
return $e->getMessage() . '<br>';
}
});

$newBody = strip_tags($crawler->html(), '<img><span><iframe><blockquote><div><br><p>');

return $newBody;
}
В этот метод мы уже передаем в краулер само тело и ссылку на донора, откуда был получен данный текст. И полностью проходимся по DOM с изображениями. Как только мы получили ссылку на изображение, то сразу его сохраняем к нам на сервер (если нужно, то прикрепляем водяной знак к изображению), и меняем пути у изображения, попутно удаляя весь возможный мусор из тэга.

В конце из текста вырезаем все html тэги, кроме разрешенных.

В самом контролере, когда вы сохраняете в базу ваши данные, можно навесить событие по отправке даннык на страницу в фэйсбук. Делается это очень просто, например так:
// Add post to facebook page.
if($parser->facebook) {
$this->facebookAddPostToPage(route('post.show', $post->slug), $post->title);
}
Указанный метод проверяет ваши ключи и делает публикацию на странице фэйсбука. Лично я использую для этого вот этот пакет: https://github.com/SammyK/LaravelFacebookSdk

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

Как обещал в начале, даю ссылку на живой проект, где крутится данный парсер: развлекательный сайт YouSuper.org
Парсер срабатывает по крону через каждые пол часа в дневное время. Все работает четко, как часы. После сохрания постов на сайте они сразу же постятся на страницу фэйсбука: https://www.facebook.com/YouSuper.org/

Как видите, ничего сложного нет. Сам файл парсера занял 300 строк кода, а контроллер - 200 строк. Да, еще момент. Для того, чтобы работать с категориями, вам придется сделать массив со словарем, по которому вы сможете получить идентификатор вашей категории (ключ массива), в зависимости от полученного из парсера названия категории (значения массива), например так:
/**
* @var array Rubrics dictionary.
*/
public $rubrics = [
1 => ['Авто', 'Авто/Мото', 'Авторевю'],
2 => ['Анекдоты', 'Смешное'],
3 => ['Баяны', 'Реклама']
];

Теперь такое решения задачи парсера я могу назвать смело - Парсер с большой буквы. Его есть куда дорабатывать и усложнять.  Всем спасибо за внимание, как всегда вопросы и отзывы оставляйте в комментариях.
4

Комментарии (7):

Да, кстати, для интересующихся моей реализацией. За точку входа я всегда брал страницу, где появляются свежие посты. Это может быть блок, а может быть страница. Так вот, через какое-то время на этой странице должны появляться новые посты. Парсер у меня настроен был (можно самому настроить) так, чтобы не заходить подряд на тот же сайт. То есть, если у меня для парсинга было указано 30 сайтов, а сегодня я парсил первый, то возвращался к первому я только тогда, когда пропарщу остальные 29. Плюс я веду лог спарщенных данных, самое простое - это проверка по url поста. Если я уже забирал от туда данные, то просто пропускаю такой урл.

Так вот, попадая на страницу/точку входа у меня парсер ограничен конкретным блоком, где расположены тизеры новостей/статей. Дальше парсер проходит по всем этим тизерам и вынимает ссылки на полные посты. Создается массив этих ссылок и уже по ним краулер начинает ходить полноценно и забирать контент.

Может, если я найду время, то выложу исходники и подробно расскажу, как это все работает. Кстати, у меня разработка идет на локальном компе, а парсинг на боевом сервере. И когда я для проверки набивал десятки сайтов с настройками на тестовом компе, то переносить это руками на боевой - еще тот адов трэш. Поэтому я дополнительно написал систему экспорта импорта данных в один клик.
darkkkk писал(а):
Ответ на комментарий Как вы получаете идентификатор поста? В примере вижу использование палочек | что-то вроде a|p?= или a|archives/{d}. Это фишка краулера или ваша реализация? Если ваша реализация, то не могли бы вы подсказать, как сделать также?
Я использовал свои модификаторы. Писал функционал давно, уже точно не помню, что и как я делал. Но глянул код, все достаточно просто. Вот мой метод, который получает ссылки для парсинга и считывает модификаторы:
/**
* Get links for posts.
*
* @param $parser object collection with parsing settings
* @return array with links
*/
public function getLinks($parser)
{
// Create empty array for links.
$links = [];

// Get html remote text.
$html = file_get_contents($parser->parsing_site);

// Create new instance for parser.
$crawler = new Crawler($html, $parser->parsing_site);

// Check exist additional condition.
if (strpos($parser->settings->link, '|')) {
// Get link conditions.
list($linkCondition, $stringInUrl) = explode('|', $parser->settings->link);
} else {
$linkCondition = $parser->settings->link;
$stringInUrl = '';
$condition = false;
}


// If exist string URL condition.
if (!empty($stringInUrl)) {

// If exist only numbers condition.
if (strpos($stringInUrl, '{d}')) {
$stringInUrl = str_replace('{d}', '', $stringInUrl);
$condition = true;
}
}

// Get links from mail link page.
$crawler->filter($linkCondition)->each(function (Crawler $node, $i) use (&$links, $parser, $stringInUrl, $condition) {
$link = $node->link()->getUri();

// If no condition for exist string in URL.
if (empty($stringInUrl) && $this->checkLink($link)) {
$links[$link] = $link;
}

// If exist condition for exist string in URL.
if (!empty($stringInUrl) && strpos($link, $stringInUrl) !== false) {
// If not number condition.
if (!$condition && $this->checkLink($link)) {
$links[$link] = $link;
}

// If exist number condition.
if ($condition) {
// Get data after condition string.
$linkData = explode('/', substr($link, strpos($link, $stringInUrl), strlen($link)));

if (is_numeric($linkData[1]) && $this->checkLink($link)) {
$links[$link] = $link;
}
}
}
});

// Check count entries.
if (count($links) > $parser->settings->count) {
$i = 1;
// Delete part entries.
foreach ($links as $key => $value) {
if ($i > $parser->settings->count) {
unset($links[$key]);
}
$i++;
}
}

return $links;
}
Как вы получаете идентификатор поста? В примере вижу использование палочек | что-то вроде a|p?= или a|archives/{d}. Это фишка краулера или ваша реализация? Если ваша реализация, то не могли бы вы подсказать, как сделать также?
Den писал(а):
Сталкивались с этим?
В моем конкретном случае, я использую
// Upload and save image.
$image = UploadImage::upload($file, $contentType, true)->getImageName();
Метод сам проверит существование изображения. И если оно есть, то сохранит. Если абстрагироваться, то просто получаете путь к изображению и проверяете на существование, например так:
// url файла для проверки на существование
$url = "http://site.com/image.jpg";
// открываем файл для чтения
if (fopen($url, "r")) {
    echo "Файл существует";
} else {
    echo "Файл не найден";
}
Здравствуйте,ответ по шаблону оч помог. Но появилась другая проблема. Как вы проверяете, существование нужных полей(картинок)? Такой вариант не работает: if(isset($crawler->filter($query->screenshots)->image())){ заношу в базу}else{ пропускаю}, но до else код не доходит - выдает ошибку  The current node list is empty. IF( $crawler->filter($query->screenshots)->image() !==null(false))- то же самое и так тоже не могу убрать ошибку if( !null==($crawler->filter($query->screenshots)->image()->getUri() )). Сталкивались с этим?

Den писал(а):
Привет, классная штука! Все работает! Но оформить парсер как у вас не получилось.Почему-то при добавлении второй формы настроек(кнопки) в одну вьюшку фреймворк выдает ошибку. У вас каждая кнопка работает через ajax?Можете подробней рассказать про реализацию шаблона для этого парсера?
Привет. Шаблон простой. В цикле перебираем коллекцию парсеров, а кнопки формируем через роуты:
<div class="row margin-top-20 margin-bottom-20">
<div class="col-lg-3 text-left">
<a href="{!! route('admin.parser.settings.edit', $parser->id) !!}" class="btn btn-primary form-horizontal">
Изменить настройки</a>
</div>

<div class="col-lg-2">
<a href="{!! route('admin.parser.edit', $parser->id) !!}" class="btn btn-primary form-horizontal">
Изменить парсер</a>
</div>

<div class="col-lg-2">
<a href="{!! route('admin.parser.log', $parser->id) !!}" class="btn btn-primary form-horizontal">
Смотреть логи</a>
</div>

<div class="col-lg-2">
{!! Form::open(['route' => 'admin.parser.copy']) !!}
{!! Form::hidden('id', $parser->id) !!}
{!! Form::submit('Копировать', ['class' => 'btn btn-primary form-horizontal']) !!}
{!! Form::close() !!}
</div>

<div class="col-lg-3 text-right">
{!! Form::open([
'method' => 'DELETE',
'route' => ['admin.parser.destroy', $parser->id],
'style' => 'display: inline-block;',
'onsubmit'=>"return confirm('" . trans('interface.delete?') . "')"
]) !!}

<button type="submit" id="delete-parser-{{ $parser->id }}" class="btn btn-danger"
title="{{ trans('interface.Delete') }}">
<i class="demo-icon icon-trash"></i> Удалить парсер
</button>

{!! Form::close() !!}
</div>
</div>
Это кусок шаблона с кнопками, которые находятся в цикле. Как видите, пути будут автоматически созданы для кнопок и ссылок. Конфликтов при этом не будет.

А систему отображения вкладок я сделал через JS. В шаблоне формируются все вкладки, но отображаются настройки только для первой, по умолчанию. У остальных display:none. При клике на другую вкладку, я все настройки скрываю, а для этой вкладки отображаю. Все, в принципе, просто. Можно заморочиться и с аяксом, но особого смысла в этом нет.

Привет, классная штука! Все работает! Но оформить парсер как у вас не получилось.Почему-то  при добавлении второй формы настроек(кнопки) в одну вьюшку фреймворк выдает ошибку. У вас каждая кнопка работает через ajax?Можете подробней рассказать про реализацию шаблона для этого парсера?