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

Откуда ноги растут
Мы разработали конструктор для муниципальных образований (МО) московской области и сейчас проводим разворачивание нескольких сайтов. Практически все МО уже имеют сайты, но то как они выглядят, даже на мой сильно испорченный комик сансом взгляд, часто вызывает кровавые слезы и жаление развидеть. Но сайты же есть и там есть какие-то новости, поэтому многие хотят сохранить контент, что логично, никто не хочет работать в пустую. Ну и вот! Задача поставлена, я пошел изучать как это сделать быстро и безболезненно.
Почему nodejs?
Если ты опытный фронтэнд, то JS и JQuery в частности тебе могут быть сильно приятнее, чем php и другие бэкэнд языки. Я честно попытался реализовать парсинг на php, но уж как-то все в итоге получалось громоздко и не технологично, есть определенная доля вероятности, что мне было просто лень все делать «правильно». к тому же снова проснулось желание потыкать в серверный JS. Сказано — сделано. 2 запроса в гугл, 4 поста, полтора мануала и выбор окончательно сделан. JQuery мне знаком как мои 2 шорткода (контрл+Ц, контрл+В. Кстати, как нибудь я покажу фотку моего левого ctrl, такого хорора вы еще не видели =)) А тут еще, оказывается есть отличнейшая библиотека cheerio, которая позволяет с объектами дом дерева обращаться по примеру jquery — красота, да и только!
Асинхронная природа nodejs
На момент написания парсинга я еще не познал дзен асинхронности, и писал код так как понимал:
- Создал массив накопитель.
- Загрузили страницу списка новостей.
- Разобрали ее на каждую отдельную новость
- Запихал в переменную все, что можно взять из общего списка: заголовок, ссылка на новость, картинка превью, текстовое превью
- Передал в другую функцию ссылку на новость и забрал оттуда полный текст
- Перешел к следующей новости из списка и к пункту 4.
- Когда закончил с первой страницей списка новостей, перешел к следующей стрнице и повторил все с пункта 3
- Повторил все пока новости не кончились
Первая версия имела вид, что-то типа того:
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 в очередной раз побежден!