Парсинг сайтов на 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 в очередной раз побежден!