Share on facebook
Share on twitter
Share on vk
Share on telegram
Share on whatsapp

Недавно передо мной встала задача перенести почти 20 000 статей со старого сайта на новый. Фигня вопрос когда речь идет о сайтах на WordPress. С одного экспортнул, на другой импортнул, все готово. Но старый сайт самописное гамно. То есть экспортировать никак, надо собирать инфу в кучу и формировать файл импорта. Тут парсить ничего не нужно, у меня доступ к базе данных. Сформировать файл не составило труда, после чего осталось закинуть на новый сайт и все.

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

От моего первого сайта остался домен. Сайт этот был доской объявлений. До сих пор, спустя годы, поисковики обращаются к нему и упорно запрашивают страницы с недвижимостью. Ну думаю, раз просят, значит надо дать, да? Мой сайт был полностью самописным и по функционалу был круче чем авито. Я бы может развивал бы его, но почему-то утратил к нему интерес. Чтобы запустить сайт, надо наполнить его объявлениями. Где брать объявления? Стырить с авито. Как тырить? Писать парсер.

Что такое парсер

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

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

Постановка задачи

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

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

Проектирование парсера

Сам парсер делите условно на две части. Одна собирает «сырые» данные и кладет их в базу, а вторая обрабатывает эти данные. Это позволит вам сэкономить кучу времени. Чаще всего, ошибки возникают именно при обработке данных. После устранения ошибки, необходимо начинать процесс заново. При обработке данных из базы, времени на обработку будут тратится сущие секунды, если не доли секунд. Когда повторный сбор и обработка отнимут десятки минут, а то и несколько часов.

Первым делом отработайте сбор и хранение информации. Тут у нас встает несколько проблем:

  • Ответ сервера
  • Идентификация материалов

Ответ сервера

Работая с ресурсом парсер может получать ответ отличный от 200, по этой причине необходимо предусмотреть в программе этот момент. Тут нам надо понять является ли каждая страница ценностью или мы можем пренебречь несколькими страницами, которые по каким-то причинам возвращают ответ отличный от 200. Конечно со страницами ответ который 4хх поступать нужно однозначно, просто игнорировать.

А вот со страницами, которые возвращают 5хх, можно подумать что делать. Можно их игнорировать, а можно класть в отдельную папку(таблицу) и потом пробовать их снова запрашивать. То же самое со страницами отвечающими 3хх. Тут 301 и 302 редиректы, можем следовать по ним, а можем не следовать. Если решите следовать, то тщательно проверяйте куда отправляет редирект, чтобы не тратить ресурсы на левые страницы.

Идентификация материалов и обработка

Для идентификации можно использовать URL страницы, с которой была получена информация. Может быть использована часть URL, если там присутствует явно идентификатор. Иногда крупные проекты добавляют в URL идентификаторы. Например у авито он присутствует явно:

https://www.avito.ru/bratsk/doma_dachi_kottedzhi/dom_46_m_na_uchastke_8_sot._1831666660

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

Затем нужно предусмотреть механизм, который предотвратит повторное добавление информации в базу. Самое простое сделать идентификатор уникальным ключом, тот же MySQL сам проверит является ли информация уникальна или же такая информация уже есть в базе. Это самый простой подход.

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

Отладив сбор информации, мы можем приступить к программе по обработке этой информации. Тут уже я не могу дать конкретных советов по обработке. Единственное что я могу, это рассказать про одну вещь, которая сделает 90% всей работы за вас. Вещь это называется phpQuery, если пишете на PHP, pyQuery, если пишете на Python. Для других языков искать не пробовал. Данные библиотеки по логике работы схожи с работой jQuery. Эти библиотеки делают за нас всю работу по поиску элементов в HTML. Нам же остается только дописать обработку полученной информации.

Пишем парсер для авито

В моем случае парсер сохраняет лишь результат. То есть получает содержимое страницы и тут же, обработав, сохраняет его в файл CSV. Да и в целом там не самая правильная логика обработки. Но я возможно выложу свой вариант когда сделаю его в соответствии со здравым смыслом. Текущую «времянку» из «костылей» мне выкладывать стремно. На тот момент, когда я начинал делать парсер для авито я экспериментировал. Как только проект выстрелит, я конечно же займусь парсером с большим энтузиазмом.

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

Сбор информации будет состоять из нескольких вложенных циклов. Первый цикл будет перебирать типы недвижимости. Как я писал ранее, мне интересна только недвижимость, но этот пример я думаю можно адаптировать под любые другие объявления. При желании можно добавить третий цикл, где будут перебираться города. Само собой сбор городов можно автоматизировать или просто зафиксировать их в виде массива. Для наглядности я добавлю такой массив, хотя мне он не особо-то и нужен.

$toponym_list = array(
	"bratsk",
);

По сути у нас есть типы недвижимости и типы сделок, добавим массив с ними:

$types_objects = array(
	"kvartiry/prodam",
	"kvartiry/sdam/posutochno",
	"komnaty/prodam",
	"kommercheskaya_nedvizhimost/prodam",
	"kommercheskaya_nedvizhimost/sdam",
	"doma_dachi_kottedzhi/prodam",
);

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

Теперь эти массивы нам нужно перебрать.

foreach( $toponym_list as $toponym ) {
	foreach( $types_objects as $object_type ) {
		$url = "https://www.avito.ru/" . $toponym . "/" . $object_type . "?i=1";
		$current_page = 1;
		$last_page = 1; // Мы пока не знаем количество страниц пагинации, поэтому ставим 1
		$referer = "https://avito.ru/";
		$error = 0;
		do {
			$new_adv = 0; //Будем считать новые объявления
			include dirname( __FILE__ ) . "/get_pages.php";
			if ( $new_adv == 0 && $error == 0 ) {
				//Если ноль, значит на странице не найдены свежие объявдления
				//Прекращаем цикл для текущего типа чтобы не тратить время попусту
				break(1);
			}
			$current_page++; // Следующая страница
			sleep( rand( 1, 3 ) ); // Делаем паузу чтобы авито нас не заблокировал
		} while ( $last_page >= $current_page );
	}
}

Это можно сохранить в файл с любым именем Я назвал его просто: avito.php. Но прежде в начало добавим вот это:

require_once dirname( __FILE__ ) . "/functions.php";

И вот это:

require_once dirname( __FILE__ ) . "/phpQuery/phpQuery.php";

Саму библиотеку можно скачать отсюда.

Теперь нам нужно создать файлы get_pages.php, в котором будет проходить процесс сбора содержимого страниц, и functions.php, в котором у нас будут функции.

В файл functions.php добавим функции:

function get_last_page_num( $url ) {
	preg_match( "#p=([0-9]+)&#", $url, $match );
	return ( isset( $match[1] ) ? $match[1] : 0 );
}

function get_location( $ch, $header ) {
	global $location;
	if ( strpos( $header, "Location:" ) !== false ) {
		$location = trim( str_replace( "Location:", "", $header ) );
	}
	return strlen( $header );
}

function get_page( $url, $followlocation=false, $ref="", $type="GET", $params=array(), $timeout=30 ) {
	global $location;
	$all_headers = array();
	$cookie = dirname( __FILE__ ) . "/cookie.txt";
	if ( $ch = curl_init() ) {
		curl_setopt( $ch, CURLOPT_URL, $url );
		curl_setopt( $ch, CURLOPT_HEADER, false );
		curl_setopt( $ch, CURLOPT_FAILONERROR, false );
		curl_setopt( $ch, CURLOPT_HEADERFUNCTION, "get_location" );
		curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
		if ( $type == "POST" ) {
			curl_setopt( $ch, CURLOPT_POST, 1 );
			curl_setopt( $ch, CURLOPT_POSTFIELDS, urldecode( http_build_query( $params ) ) );
		}
		curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		if ( $followlocation ) {
			curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
		}
		curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, $timeout );
		curl_setopt( $ch, CURLOPT_COOKIEJAR,  $cookie );
		curl_setopt( $ch, CURLOPT_COOKIEFILE, $cookie );
		curl_setopt( $ch, CURLOPT_REFERER, $ref );
		curl_setopt( $ch, CURLOPT_USERAGENT, 'Opera/9.80 (Windows NT 5.1; U; ru) Presto/2.7.62 Version/11.01' );
		$data = curl_exec( $ch );
		$url = curl_getinfo( $ch, CURLINFO_EFFECTIVE_URL );
		$code = curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
		curl_close( $ch );
		return array( "data"=>$data, "code"=>$code, "url"=>$url, "location"=>$location );
	} else {
		return false;
	}
}

Функция get_page, при запросе URL, вернет нам массив, где data содержимое страницы, code ответ сервера, url текущий url страницы, а location информация об url, на которые нас отправляют в случае редиректа. Значение url, в возвращаемом массиве, изменится лишь при двух условиях:

  1. Второй параметр true
  2. Сервер ответил редиректом

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

Создаем файл get_pages.php и добавляем в него вот это:

$result = get_page( $url, false, $referer ); // Получаем содержимое первой страницы
$referer = $url . ( $current_page > 1 ? "&p=$current_page" : "" ); //Запоминаем текущую страницу, чтобы отправить уже её в качестве рефера

Запрос отправили и получили ответ. Теперь нам нужно обработать полученный результат. Тут у нас в игру вступает наш аналог jQuery. Он поможет нам найти снипеты объявлений и взять оттуда необходимую информацию.

if ( $result ) {
	if ( $result['code'] == 200 ) {
		$dom = phpQuery::newDocument( $result['data'] ); //Скармливаем содержимое страницы
		$items = $dom->find( ".snippet-horizontal" ); //Находим все снипеты объявлений на странице

		foreach( $items as $item ) {
			$item = pq( $item ); // создаем объект DOM из отдельного снипета
			$a = $item->find( "a.snippet-link" ); // ищем ссылку в снипете
			$href = "https://www.avito.ru" . $a->attr( "href" ); // получаем атрибут href

			preg_match( "#_([0-9]+)$#", $href, $match ); // ищем идентификатор в url
			$adv_id = $match[1]; // получаем идентификато

			//Теперь у нас есть ссылка и идентификатор
			//Все это можно записать в базу данных или сразу получить содержимое страницы
			// Отправляем запрос на добавление в базу, в случае удачи мы получим true или false в случае если такое объявление уже в базе
			if ( @$db->exec( "INSERT INTO `adv_links` VALUES ( $adv_id, '$object_type', '$href', '$referer', '' )" ) ) {
				$new_adv++; // Делаем инкремент, сообщаем что новое объявление добавлено
			}
		}
		// Чтобы понять сколько страниц нужно обойти, ищем номер последней страницы
		if ( $last_page == 1 ) {
			$link = $dom->find( "a.pagination-page:last" );
			$link = pq( $link );
			$href = $link->attr( "href" );
			$last_page = get_last_page_num( $href );
		}

	} else if ( $result['code'] == 0 ) {
		// Что-то с соединением, попробуем снова
		// Чтобы сие не продолжалось бесконечно, ограничим количество запросов
		if ( $error <= 3 ) {
			$current_page--;
			$error++;
		}
	} else if ( $result['code'] > 300 ) {
		// сработает если get_page вторым параметром передан false
		// Тут мы можем проверить значение $result['location'] и если там ссылка на текущий сайт, то попробовать запросить её
		// Только имейте в виду, снова придется проверять ответ, поэтому хорошо подумайте, надо ли оно вам
	} else {
		// Что-то делаем если ответ не 200
	}
} else {
	//Если возникли ошибка с CURLом, то что-то делаем
}

Если вы обратили внимание, то в коде выше затесался запрос к базе данных. В качестве базы данных я выбрал SQLite3. Причины две:

  1. Давно хотел поработать с SQLite
  2. Не нужно ничего устанавливать и настраивать

В отличии от того же MySQL, SQLite не требует установки и настройки. Создания баз данных и пользователей. То есть не нужно никаких заморочек, просто скопировал, запустил – работает. Это особенно удобно для начинающих и малоопытных программистов или вообще людей далеких от программирования.

Для инициализации базы данных добавим в наш файл avito.php Ещё одну строку в самое начало:

require_once dirname( __FILE__ ) . "/db.php";

А в файл db.php добавим следующий код

$db = new SQLite3( dirname( __FILE__ ) . "/db.sqlite" );

if ( ! @$db->exec( "SELECT adv_id FROM adv_links WHERE 1 LIMIT 0, 1 " ) ) {
	$db->busyTimeout( 5000 );
	$db->exec( "PRAGMA journal_mode=WAL;" );
	//$db->exec( "PRAGMA foreign_keys = ON;" );
	$res = @$db->exec( 'CREATE TABLE "adv_links" (
		"adv_id"	INTEGER NOT NULL UNIQUE,
		"adv_type"	TEXT NOT NULL,
		"adv_link"	TEXT NOT NULL,
		"adv_ref"	TEXT,
		"adv_date"	TEXT,
		"adv_content"	TEXT,
		PRIMARY KEY("adv_id"),
		UNIQUE("adv_id")
		);'
	);
	if ( ! $res ) {
		die( "Не удалось создать таблицу\n" );
	}
	$res = @$db->exec( 'CREATE TABLE "adv_new" (
		"adv_id"	INTEGER NOT NULL UNIQUE,
		PRIMARY KEY("adv_id")
		);'
	);
	if ( ! $res ) {
		die( "Не удалось создать таблицу\n" );
	}
	$res = @$db->exec( 'CREATE TRIGGER new_adv_insert BEFORE INSERT
		ON adv_links
		BEGIN
		INSERT INTO adv_new (adv_id) VALUES (NEW.adv_id);
		END;'
	);
	if ( ! $res ) {
		die( "Не удалось создать триггер\n" );
	}
	$res = @$db->exec( 'CREATE TRIGGER new_adv_del AFTER DELETE
		ON adv_links
		BEGIN
		DELETE FROM adv_new WHERE adv_id=OLD.adv_id;
		END;'
	);
	if ( ! $res ) {
		die( "Не удалось создать триггер\n" );
	}
}

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

Работа парсера

По сути у нас готова основа для парсера. Парсером это сложно назвать, скорее это больше недокраулер. Почему НЕДО? Потому что он проходит только по определенным ссылкам, а не шарится по сайту в целом или сети. Если запустить скрипт, то сначала он создаст базу данных, создаст в ней таблицу и перейдет к сбору информации.

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

Имея в базе контент объявлений, мы можем делать все что захотим. Формировать из них новую базу, создавать файлы для тех или иных программ. В общем все, что угодно. Сбор объявлений мы теперь можем поместить в crontab и забыть о необходимости запускать скрипт вручную.

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

Обработка данных

Из полученных данных я буду формировать несколько файлов импорта для плагина WP All import. Для этого мне потребуется написать дополнительный скрипт. Этому скрипту нужно будет из базы доставать сохраненный HTML и выжимать с него информацию. Из этой информации уже формировать файлы в формате CSV.

На вскидку рисуется сразу проблема. Допустим я впервые запустил сбор объявлений. Программа собрала все объявления. Мне остается только сформировать файл импорта и запихать его на сайт. Допустим сделано. Проходит пару дней, программа накопала ещё объявлений. Как программа, формирующая файл импорта, должна понять какие объявления новые, а какие попали в первый файл импорта. Если этого не отслеживать, то на сайте каждый раз будут появляться дубли, и плодиться они будут после каждого импорта.

Решение простое. Нам поможет ещё одна таблица и триггер. Таблицу назовем просто «adv_new», в неё будут записываться идентификатор и ссылка. За запись будет отвечать триггер. Таким образом после добавления записи в таблицу «adv_links» автоматически будет добавляться запись в таблицу «adv_new». После первого запуска мы получим равное количество записей в таблицах.

Допустим мы запускаем скрипт, который формирует файл импорта. Скрипт запрашивает записи из таблицы «adv_new» с подключением к «adv_links». Обработав первую запись, скрипт удаляет эту запись из таблицы «adv_new». И так далее пока таблица «adv_new» не опустеет.

Допустим скрипт первый раз обработал 2000 записей. Через какое-то время в базе появилось ещё 100 объявлений. В итоге в таблице «adv_links» станет 2100 записей, а в таблице «adv_new» только 100. То есть при повторном запуске скрипта, который формирует файл импорта, он обработает только те 100 записей, которые были добавлены с момента последней обработки.

Share on facebook
Share on twitter
Share on vk
Share on telegram
Share on whatsapp

Бидюков Денис

Эксперт по сайтам

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