Привет друзья! Хоть год еще и не закончился, но я уже начинаю понемногу подводить итоги того, что было сделано, что не успел и почему. В общем раскладываю работу по полочкам и анализирую. И решил написать о такой важной теме, как парсинг данных из разных источников. Кто и как это делает, и как это делать правильно. Как нужно правильно использовать парсинг в Ларавел вообще и в Ларавел 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.
Его нужно импортировать в ваш класс парсера:
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://www.facebook.com/YouSuper.org/
Как
видите, ничего сложного нет. Сам файл парсера занял 300 строк кода, а
контроллер - 200 строк. Да, еще момент. Для того, чтобы работать с
категориями, вам придется сделать массив со словарем, по которому вы
сможете получить идентификатор вашей категории (ключ массива), в
зависимости от полученного из парсера названия категории (значения
массива), например так:
/**
* @var array Rubrics dictionary.
*/
public $rubrics = [
1 => ['Авто', 'Авто/Мото', 'Авторевю'],
2 => ['Анекдоты', 'Смешное'],
3 => ['Баяны', 'Реклама']
];
Теперь
такое решения задачи парсера я могу назвать смело - Парсер с большой
буквы. Его есть куда дорабатывать и усложнять. Всем спасибо за
внимание, как всегда вопросы и отзывы оставляйте в комментариях.
Комментарии (19):
сможешь такой проект сделать bankrot - spy.ru (убрать пробелы вокруг дефиса)?
сколько будет стоить? пиши в телегу mr _ petr0vi4 (убрать пробелы вокруг подчеркивания)
очень нужна ваша помощь в написании простенького парсера на на сайт что бы сканил пару тройку сайтов :)
А систему отображения вкладок я сделал через JS. В шаблоне формируются все вкладки, но отображаются настройки только для первой, по умолчанию. У остальных display:none. При клике на другую вкладку, я все настройки скрываю, а для этой вкладки отображаю. Все, в принципе, просто. Можно заморочиться и с аяксом, но особого смысла в этом нет.