Парсинг сайтов на nodejs

Ильдар Сарибжанов | 05.12.2016

Лирика

Один пацан писал все на JavaScript, и клиент, и сервер, говорил что нравится, удобно, читабельно. Потом его в дурку забрали, конечно(с)

dobro-pozhalovat-v-durdom11-590x400

Источник картинки

Откуда ноги растут

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

Почему nodejs?

Если ты опытный фронтэнд, то JS и JQuery в частности тебе могут быть сильно приятнее, чем php и другие бэкэнд языки. Я честно попытался реализовать парсинг на php, но уж как-то все в итоге получалось громоздко и не технологично, есть определенная доля вероятности, что мне было просто лень все делать «правильно». к тому же снова проснулось желание потыкать в серверный JS. Сказано — сделано. 2 запроса в гугл, 4 поста, полтора мануала и выбор окончательно сделан. JQuery мне знаком как мои 2 шорткода (контрл+Ц, контрл+В. Кстати, как нибудь я покажу фотку моего левого ctrl, такого хорора вы еще не видели =)) А тут еще, оказывается есть отличнейшая библиотека cheerio, которая позволяет с объектами дом дерева обращаться по примеру jquery — красота, да и только!

Асинхронная природа nodejs

На момент написания парсинга я еще не познал дзен асинхронности, и писал код так как понимал:

  1. Создал массив накопитель.
  2. Загрузили страницу списка новостей.
  3. Разобрали ее на каждую отдельную новость
  4. Запихал в переменную все, что можно взять из общего списка: заголовок, ссылка на новость, картинка превью, текстовое превью
  5. Передал в другую функцию ссылку на новость и забрал оттуда полный текст
  6. Перешел к следующей новости из списка и к пункту 4.
  7. Когда закончил с первой страницей списка новостей, перешел к следующей стрнице и повторил все с пункта 3
  8. Повторил все пока новости не кончились

Первая версия имела вид, что-то типа того:

var request = require('request'),
	cheerio = require('cheerio'),
	res_arr = [];

var news_base_url = 'http://need.url?p=';

get_page_content(news_base_url + 0, 0);

function get_page_content( url, i ) {
	request(url, function ( error, response, body ) {

		if( !error ) {
			var $ = cheerio.load(body),
				newses = $('.news-item');

			newses.each(function () {
				var self = $(this),
					cont = self.find('.news-text'),
					itm = {
						title: cont.find('.title').text(),
						date : cont.find('.date').text(),
						link : cont.find('a').attr('href'),
						img  : cont.find('img').attr('src')
					};


				itm.content = get_post_content(link);

				res_arr.push(itm);
			});

		} else {
			console.log("Произошла ошибка: " + error);
		}
	});
}

function get_post_content( link ) {
	request(link, function ( error, response, body ) {

		if( !error ) {
			var $ = cheerio.load(body, {decodeEntities: false});

			return $('.text').html();
		} else {
			console.log("Произошла ошибка: " + error);
		}
	});
}

Ключ на старт. Первый запуск… и я все понял. Это работать не будет. Преимущество nodejs — асинхронность, она же, в моем случае, стала проблемой. Т.е. в тот момент, когда я забираю контент первой новости первой страницы, у меня уже может считаться 3я страница новостей и указатель массива будет непонятно куда смотреть.

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

var request = require('request'),
	cheerio = require('cheerio'),
	res_arr = [],
	ind = 0;

var news_base_url = 'http://need.url?p=';

get_page_content(news_base_url + 0, 0);

function get_page_content( url, i ) {
	request(url, function ( error, response, body ) {

		if( !error ) {
			var $ = cheerio.load(body),
				newses = $('.news-item');

			newses.each(function () {
				var self = $(this),
					cont = self.find('.news-text');


				res_arr[ind] = {
					title: cont.find('.title').text(),
					date : cont.find('.date').text(),
					link : cont.find('a').attr('href'),
					img  : cont.find('img').attr('src')
				}

				get_post_content(link, ind);
				
				ind++;
			});

		} else {
			console.log("Произошла ошибка: " + error);
		}
	});
}

function get_post_content( link, array_index ) {
	request(link, function ( error, response, body ) {

		if( !error ) {
			var $ = cheerio.load(body, {decodeEntities: false});

			res_arr[array_index].content = $('.text').html();

		} else {
			console.log("Произошла ошибка: " + error);
		}
	});
}

Сохранение результата

Все это круто, но как нам получить данные? Не из консоли же копировать? Но опять же асинхронность в nodejs не позволяет нам просто взять и в последней итерации сохранить все на диск, т.к. последняя итерация еще ничего не значит, где-то может тупить страница и только забираться контент. И снова, ничего умнее счетчика я не придумал. Т.к. получение полного текста новости будет идти в конце, то проверку нужно делать там и проверять не по индексу новости, а завести отдельный счетчик, который будет вести учет реально полученный или не полученный данных. Для работы с файлами надо подключить модуль FileSystem (FS), а чтобы получить папку текущего скрипта, туда же мы будем складывать результаты парсинга, подключаем модуль Path.

Итоговый скрипт, выглядит примерно так:

var request = require('request'),
	cheerio = require('cheerio'),
	fs = require('fs'),
	path = require('path'),
	res_arr = [],
	ind = 0,
	count_posts = 500;

var news_base_url = 'http://need.url?p=';
	
	// Имя файла в той же папке, где лежит файл скрипта
var file_json = path.resolve(__dirname, 'parse_file.json'); 

get_page_content(news_base_url + 0, 0);

function get_page_content( url, i ) {
	request(url, function ( error, response, body ) {

		if( !error ) {
			var $ = cheerio.load(body),
				newses = $('.news-item');

			newses.each(function () {
				var self = $(this),
					cont = self.find('.news-text');


				res_arr[ind++] = {
					title: cont.find('.title').text(),
					date : cont.find('.date').text(),
					link : cont.find('a').attr('href'),
					img  : cont.find('img').attr('src')
				}

				get_post_content(link, ind);

				ind++;
			});

		} else {
			console.log("Произошла ошибка: " + error);
		}
	});
}

// Получение контента
function get_post_content( link, array_index ) {
	request(link, function ( error, response, body ) {

		if( !error ) {
			var $ = cheerio.load(body, {decodeEntities: false});

			res_arr[array_index].content = $('.text').html();

		} else {
			console.log("Произошла ошибка: " + error);
		}

		if( count_posts-- <= 0 ){
			write_parse_res( file_json, JSON.stringify(res_arr) );
		}
	});
}

// Сохранение на диск
function write_parse_res( file_json, str ) {
	fs.writeFile(file_json, str, function ( err ) {
		if( err ) {
			console.log(err);
		} else {
			console.log('Добавил все');
		}
	});
}

Понятно, что значение количества постов надо как-то «более технологично» получать, но в моем случае, этого было достаточно.

Итог

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

Клиент доволен как слон, все его новости на месте, а это главное. На самом деле, не главное =) Главное, что доволен я, все получилось очень даже неплохо. Мои скилы прокачаны, nodejs в очередной раз побежден!