Парсинг HTML страниц с помощью DomCrawler в Laravel 5.6

Парсинг данных из разных источников. Кто и как это делает, и как это делать правильно. Как нужно правильно использовать парсинг в Ларавел вообще и в Laravel 5.6 в частности. В данном конкретном случае я хочу акцентироваться на том, как создавались парсеры для порталов и сервисов, и почему это было наказанием, разгребать эти легаси решения.

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

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

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

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

Вначале разработчики все эти парсера латали, подстраивая под изменения на сайте-доноре. Потом в какой-то момент руководители плюнули на это и поручили менеджерам в ручном режиме переносить информацию к нам на ресурс.

Типичные проблемы парсинга сайтов

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

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

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

Требования к парсеру сайтов

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

Давайте накидаем ряд пунктов (будем уже говорить о Laravel 5.6):

  1. Единый парсер для всех задач компании.
  2. Единая и понятная логика работы парсера.
  3. Добавление нового источника для парсинга должно занимать не более 10 минут.
  4. Парсер должен иметь админку с простым и понятным интерфейсом.
  5. Парсер должен уметь забирать не только текст, но и файлы.
  6. Перенастройка парсера должна занять не более 10 минут.
  7. Готовый парсер не должен требовать от программиста лазить в код.
  8. Вести лог того, что, когда и от куда. Успешно ли завершилась операция.
  9. Парсер не должен повторно обрабатывать уже обработанные данные.
  10. Система импорта/экспорта настроек.

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

Главная задача — на тестовой машине отладить все настройки парсера, чтобы он правильно забирал данные без ошибок. И когда вы все настроите и убедитесь в том, что все работает как надо, вам эти настройки теперь нужно перенести на продакшн.

Логика парсера сайтов на PHP

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

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

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

Библиотеки для парсинга html сайтов

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

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

В Laravel последних версий встроен отличнейший инструмент для парсинга — это компонент от Symfony DomCrawler. Именно благодаря этому компоненту ваша жизнь может очень сильно упроститься.

Его нужно импортировать в класс парсера:

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');

Админка для парсера сайтов на Laravel

При создании роута обязательно делайте параметр 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:

/**
* 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

Подведение итогов

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

Как видите, ничего сложного нет. Сам файл парсера занял 300 строк кода, а контроллер — 200 строк. Для того, чтобы работать с категориями, вам придется сделать массив со словарём, по которому вы сможете получить идентификатор вашей категории (ключ массива), в зависимости от полученного из парсера названия категории (значения массива), например так:

/**
* @var array Rubrics dictionary.
*/
public $rubrics = [
1 => ['Авто', 'Авто/Мото'],
2 => ['Анекдоты', 'Смешное'],
3 => ['Баяны', 'Реклама']
];

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