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

Пример постарался привести как можно в более упрощенном и универсальной форме.

Итак, точка входа:
Первая часть этого файла представляет собой общую логику относящуюся к работа SlimerJs
script.js
var grab = require('./grab');   // подключение модуля 
grab.create().init('/catalog');  // инициализация парсера 

Здесь происходит подключение модуля парсера и его инициализация, а в метод init() передается URL страницы каталога товаров, ссылка является относительной. Основной домен сайта задается в файле config.js

Вся логика парсера находиться в файле grab.js. Я его разделил на две части, первая часть представляет собой объект обертку над SlimerJS для одновременной работы нескольких копий браузера.
Все комментарии по коду я вынес в листинг в целях упрощения понимания кода.

grab.js
var file = require("./file").create();              // подключение модуля для работы с файловой системой
var config = require("./config").getConfig();       // подключение глобальных переменных

/**
 *  создаем объект-конструктор
 */
function Grab() {

    this.page;                                      // храним текущий объект "webpage"
    this.current_url;                               // сохраняем текущий URL
    this.parentCategory;                            // сохраняем категорию продукта  

    /**
     * метод инициализирует объект
     * @param url string относительный адрес ( /contacts )
     * @param parent
     */
    this.init = function(url, parent) {
        this.page = require("webpage").create();    // создаем объект webpage
        this.callbackInit();                        // определяем callback для объекта webpage
        if(url) {                                   // если параметра нет, то обращаемся к домену
            config.host += url;
        }
        this.parentCategory = parent;
        this.open(config.host);                       // открыть URL
    };

    /**
     * открыть URL
     * @param {string} url адрес который нужно открыть
     */
    this.open = function(url) {
        /*
         * место для возможной бизнес логики
         */
        this.page.open(url);                        
    };

    /**
     * завершить работы с текущим окном
     */
    this.close = function() {
        this.page.close()
    };

    /**
     *  инициализация callback
     */
    this.callbackInit = function() {
        var self = this;

        /**
         * метод вызывается при возникновение ошибки
         * @param {string} message error
         * @param {type} stack
         */
        this.page.onError = function (message, stack) {
            console.log(message);
        };


        /**
         * метод вызывается при редиректе или открытий новой страницы
         * @param {string} url новый URL
         */
        this.page.onUrlChanged = function (url) {
            self.current_url = url;                     // сохраняем URL как текущий
        };

        /**
         * метод вызывается, если в объекте webpage срабатывает метод console.log()
         * @param {string} message
         * @param {type} line
         * @param {type} file
         */
        this.page.onConsoleMessage = function (message, line, file) {
            console.log(message);                       // выводим его в текущую область видимости
        };

        /**
         * метод вызывается при каждой загрузке страницы
         * @param {string} status статус загрузки страницы
         */
        this.page.onLoadFinished = function(status) {
            if(status !== 'success') {
                console.log("Sorry, the page is not loaded");
                self.close();
            }

            self.route();                               // вызываем основной метод бизнес логики
        };
    };
}


Вторая часть файла, определяет поведение и расширяет созданный объект Grab
Grab.prototype.route = function() {
    try {
        // если текущая страница это страница содержащая категории продуктов
        if(this.isCategoryPage()) {            
            var categories = this.getCategories();          // спарсить данные со страницы категории
            file.writeJson(config.result_file, categories, 'a');   // записать данные в файл 
            for (var i = 0; i < categories.length; i++) {   // пройти все категории товаров
                var url = categories[i].url_article;        // получаем URL на страницу с товаром текущей категории
                new Grab().init(url, categories[i].title);  // открываем новую страницу
                slimer.wait(3000);                          // ждем 3 секунды, до открытия следующей страницы
            }
        } else {
            // текущая страница является карточкой товара
            var content = this.getContent();                // спарсить данные с карточки товара
            file.writeJson(config.result_file, content, 'a');      // записать результат в файл
            this.close();                                   // закрыть текущее окно
        }
        this.close();
    } catch(err) {
        console.log(err);
        this.close();
    }
};

/**
 * получить со страницы весь контент, относящийся к категориям
 * @returns {Object}
 */
Grab.prototype.getCategories = function() {
    return this.getContent('categories')
};

/**
 * проверить, содержит ли текущая страница категории товаров
 * @returns {bool}
 */
Grab.prototype.isCategoryPage = function() {
    return this.page.evaluate(function() {
        // определить, присутствуют ли данные, относящиеся к странице товаров
        return !$(".catalog-list .item .price").length;
    });
};

/**
 * получить полезные данные со страницы
 * @param {string} typeContent какие данные нужно получить {categories|product}
 * @returns {Object}
 */
Grab.prototype.getContent = function(typeContent) {
    var result = this.page.evaluate(function(typeContent) {
        var result = [];
        // находим блок, в котором находятся структурированные данные (страница категорий и продуктов имеют одинаковую разметку)
        $(".catalog-list .item").each(function(key, value) {
            var $link = $(value).find('a.name');                // кешируем ссылку
            var obj = {                                         // собираем данные относящиеся к категории
                'type': 'category',
                'title': $link.text().trim().toLowerCase(),     // заголовок категории
                'url_article': $link.attr('href'),              // ссылка на товары входящие в эту категорию
                'url_article_image': $(value).find('a.img > img').attr('src')
            };

            // если это карточка товара, то собираем данные относящиеся к карточке товара
            if(typeContent !== 'categories') {
                obj.size = [];
                obj.type = 'product';
                $('.razmers:first .pink').each(function(key, value) {       // размеры|цвет|диагональ...
                    obj.size.push($(value).text().trim());
                });
                obj.price = parseInt($(value).find('.price').text(), 10);   // цена
            }
            result.push(obj);
        });
        return result;
    }, typeContent);

    return result;
};

exports.create = function() {
    return new Grab();
};


Для удобной работы с файловой системой в SlimerJS предусмотрен API, который позволяет как читать, так и записывать данные
file.js
var fs = require('fs');

/**
 *  инициализация объекта обертки
 */
function FileHelper() {

    /**
     * чтение данных
     * @param {string} path_to_file относительный путь до файла
     * @returns array - данные
     */
    this.read = function(path_to_file) {
        if(!fs.isFile(path_to_file)){
            throw new Error('File ('+path_to_file+') not found');
        }
        var content = fs.read(path_to_file);
        if(!content.length) {
            throw new Error('File ('+path_to_file+') empty');
        }
        return content.split("\n");
    };

    /**
     * записать данные в файл
     * @param {string} path_to_file  относительный путь до файла
     * @param {string} content данные для записи
     * @param {string} mode режимы работы 'r', 'w', 'a/+', 'b'
     */
    this.write = function(path_to_file, content, mode) {
        fs.write(path_to_file, content, mode);
    }
    
    
    /**
     * запись данных в виде JSON
     * @param {string} path_to_file  относительный путь до файла
     * @param {array} content данные для записи
     * @param {string} mode режимы работы 'r', 'w', 'a/+', 'b'
     */
    this.writeJson = function(path_to_file, content, mode) {
        var result = '';
        for(var i=0; i < content.length; i++) {
            result += JSON.stringify(content[i]) + "\n";
        }
        this.write(path_to_file, result, mode);
    }
}

exports.create = function() {
    return new FileHelper();
};

И последний файл, это файл конфигурации, в котором можно указать переменные, общие для всей системы
config.js
var Config = function() {

    this.host = 'http://example.ru';

    this.log_path = 'logs\\error.txt';
    this.result_file = 'result\\result.txt';
};

exports.getConfig = function() {
    return new Config();
};


Результат работы будет в виде файла, который можно будет обработать для дальнейшего экспорта данных.
Запускается скрипт командой из консоли

slimerjs script.js

image Исходники

Комментарии (38)


  1. dmx102
    05.10.2015 21:19

    В чем преимущество парсера на SlimmerJs от парсера написанного на perl или на NodeJs? Зачем так усложнять?


    1. Tur1st
      05.10.2015 21:35

      ответил ниже)


    1. faiwer
      06.10.2015 08:47

      В сложных ситуациях. Например:

      1. Когда есть какие-нибудь браузерные уловки. Правда это, в 99% случаев, будет уже зловредительство. Вроде рассылки спама, или скачивания информации, которую попытались от скачивания защитить.
      2. API сервера меняется, чаще вёрстки. В этом случае можно довериться вёрстке :)


  1. Tur1st
    05.10.2015 21:34

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


    1. dmx102
      06.10.2015 08:57
      -2

      Динамически подгружаемый контент парсится на много легче когда он вынесен в api сайта, потому что, как правило, там обёрточных данных меньше и можно задавать доп параметры, в особенности те, что нужны. Например кол-во элементов в выдаче, или поиск. Еще api менее всего уделяют внимание в плане защиты. Более того, когда есть такой api, почти вся работа ложится именно на него


      1. Tur1st
        06.10.2015 09:59
        +4

        про какой парсинг вообще можно говорить если вы работаете с API?


        1. dmx102
          06.10.2015 12:52

          Я говорю про парсинг тех источников данных, которые создатели сайта используют доя подгрузки динамического контента. Странно что вы не поняли этого. Можно назвать это api или как угодно, сути не меняет.


          1. Tur1st
            06.10.2015 13:31
            +1

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


            1. dmx102
              06.10.2015 13:40

              Я работаю с многими сайтами, более того практически в реальном времени (задержка 5-10 мин). Например самая большая доска объявлений по России или сайт, где все продают свои машины.
              Протокол HTTP текстовый, куки передаются так де текстом, магии в работе со страницей без ее перезагрузки нет ;)
              Как мне кажется, лучшем решением по работе с текстовым протоколом является то, на котором легче всего обрабатывать строки. По этому предпочитаю Perl за его легкость в работе регулярных выражений.
              На данный момент нет ни одной адекватной защиты против автоматического распознавания чего либо.


              1. Tur1st
                06.10.2015 13:48

                как вы реализовали проблему с каптчами?


                1. dmx102
                  06.10.2015 13:58

                  В зависимости от конкретного ресурса. Чаще всего просто когда мне предлагают капчу, меняю ip-адрес, тру куки и меняю user-agent. Многие пользуются antigate но я его не использовал.
                  Простые капчи парсятся через OCR-программы. Они бесплатные, есть консольные и с возможностью обучения.


                  1. Tur1st
                    06.10.2015 14:17

                    выдачу яндекса или гугла когда нибудь парсили, попробуйте ради интереса.


                    1. dmx102
                      06.10.2015 15:39

                      Я не парсил, но сеошники парсят их каждый день без проблем;)


                1. alekciy
                  07.10.2015 15:33

                  Всегда есть сервис, который может решить эту проблему. antigate уже упомянули, но в целом их достаточно много. Опят же для интернет магазина капча в контексте парсинга каталога товаров совершенно не актуальна.


                  1. Tur1st
                    07.10.2015 15:38

                    с этими сервисами я знаком, как раз один из них интегрирован в парсер яндекса, но и вопрос в том что как разгадывать каптчи клиент-серверными приложением? допусти каптча возвращается ни как страница, а возникает в процессе работы, т.е грубо говоря аяксом поверх основного контента


                    1. alekciy
                      07.10.2015 16:01

                      Учитывая, что SlimerJS это headless браузер, то имеем полный DOM с рендерингом страницы. Не вижу, чем принципиальная разница между парсингом характеристик товаров и капчей. Это все парсинг данных которые в браузере видит человек, значит спарсить это можно.

                      Может конечно в SlimerJS с этим какие-то проблемы… Но я в работе использую PhantomJS (а SlimerJS пытается его догнать по функционалу, только на базе FireFox-а) в режиме selenim hub, т.е. управление по webdriver, с написанием бизнес-логики парсинга на XPath. И там уже не важно что, откуда и как вообще грузиться. Видно на странице в браузере? Значит можно стянуть.


                      1. dmx102
                        07.10.2015 16:21

                        Я с вами согласен, но хочу добавить, что все зависит от ситуации, потому что когда надо получать данные с множества сайтов делая по 10000 запросов единовременно, SlimmerJS, как и PhantomJS потребляют слишком много ресурсов. Для одного потока конечно нормально. А на счет изменений в верстке, DOM структура так же легко может измениться. Мне PhantomJS нравится и я его использовал регулярно. Так что могу с уверенностью сказать, что все зависит от задачи, но 95% случаев решается оптимальнее на Perl


                        1. alekciy
                          07.10.2015 17:09

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


                      1. Tur1st
                        07.10.2015 16:29

                        )) вы видимо не внимательно прочитали мой ответ, я не говорил что с этим проблемы в SlimerJS. Все что касаеться работы с DOM он справляеться более чем отлично. PhantonJS я тоже использовал, но SlimerJs понравился больше тем что позволяет отображать окно браузера, это очень удобно при автоматизации определенных бизнес процессов.
                        API SlimerJS очень близко к PhantomJS и у разработчиков огромное желание к версии 1.0.0 полностью скопировать API PhantomJs


                        1. alekciy
                          07.10.2015 17:14

                          т.е грубо говоря аяксом поверх основного контента

                          Тогда в чем суть вопроса-то?


                          1. Tur1st
                            07.10.2015 17:21

                            вопрос был в том как эту проблему решает dmx102 с помощью perl, так как насколько я понимаю он использует cURL или что то подобное для парсинга


                            1. alekciy
                              07.10.2015 17:48

                              Ах вот какой долгий контекст!
                              А в perl это решается долгим кастомом когда ручками разбирается вся логика формирования капчи и подпиливается парсер под вытягивание нужных данных. Headless в этом смысле сильно облегчают жизнь возможностью тупо сделать скрин и уже его отправить на распознавание.


                    1. dmx102
                      07.10.2015 17:31

                      Любая капча начинается с инициализации на целевой странице. Затем отдается картинка. Потом следует запрос, верифицирующий переданное значение капчи и параметра ее инициализирующее. В зависимости от сложности капчи она либо скармливается подпрограмме OCR и получается ее расшифрованное значение, либо отдается антигейту и подобным. Других вариантов нет.
                      На SlimmerJS вы точно так же получите картинку капчи и придете к тому же выбору: «а что же делать дальше?»

                      Рекоммендую при парсинге просто не допускать ее появления, укладываясь в «разрешенные» (антиспам) лимиты. Они либо вычисляются имперически, либо читаются на специализированных ресурсах.


                      1. Tur1st
                        07.10.2015 17:48
                        +1

                        В SlimereJS все проще, перед какой то бизней логикой анализируется изначально известный html блок в котором появляется каптча. Далее. без перегрузки страницы автоматически делается скриншот каптчи, кодируеться в base64 и в том же окне генерируется запрос а API того же антигейта и через пару секунд уже получен результат.
                        Все это работает без перегрузки целевой страницы.
                        при таком подходе получается долго парсить ресурс без всяких лимитов, единственное соблюдая определенный таймаут


                        1. alekciy
                          07.10.2015 17:54
                          +1

                          Таймауты решаются пачкой прокси. Это еще одна причина по которой я использую PhantomJS. Его можно при запуске зацепить к заданной прокси. К большому сожалению не хватает возможности динамически их менять. Приходится иметь пачку запущенных фантомов — по одному для каждой прокси. А уже приложение по кругу их обходит отправляя задание на парсинг.


                          1. Tur1st
                            07.10.2015 19:58

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


                            1. alekciy
                              07.10.2015 20:31

                              Через webdriver? Не думаю.


                              1. Tur1st
                                07.10.2015 21:43
                                +1

                                1. alekciy
                                  07.10.2015 21:53

                                  Ну webdriver не допили. А прокси да, вижу, таки добавили. Хороший повод для нового парсера будет заюзать именно его.


                        1. dmx102
                          07.10.2015 23:19

                          На perl'е тоже не перезагружается страница)


                      1. alekciy
                        07.10.2015 17:51

                        Все верно, точно так же. Вопрос, какими силами. Сделать скрин с окна это тупо вызов одного метода в коде. Без волнений вообще как эта капча там появляется.

                        Безголовые браузеры сильно теряют на потреблении ресурсов и скорости работы, но сильно помогают на сапорте кода парсера.


              1. alekciy
                07.10.2015 15:29

                Большая доска как ни крути это относительно адекватная разметка и работа сайта. А когда сайт поставщика это детища написанное в начале нулевых, и поддерживаемая ХХ разработчиками кто во что горазд, то чисто работы с текстом может не хватать. Потому что мешанина из JS-CSS-HTML такая, что порой на супорте проще поддерживать парсер с возможностью рендеринга страниц, чем каждый раз подстраивать тестовой парсер под изменения, зачастую кривые.


                1. dmx102
                  07.10.2015 15:36

                  Вы статью читали?
                  Речь вообще про ШАБЛОННЫЕ интернет-магазины и СТРУКТУРИРОВАННЫЕ данные


                  1. alekciy
                    07.10.2015 16:05

                    А вы?
                    Я тоже про структурированный данные. Могу даже пошутить «со слабо структурированными данными». Если не приходилось иметь дело со страницами на которых один и тот же шаблон может в итоге давать немного отличающийся результат, то это не значит, что таких ситуаций нет. И я сейчас даже не беру вариант, когда это делается намеренно с цель «защиты».


                1. dmx102
                  07.10.2015 15:38

                  Единственное, что при рендеренге хорошо, это отображение данных, которые генерятся на js с целью маскировки через обфусцирование


          1. alekciy
            07.10.2015 15:23

            Назвать это API нельзя. Потому что в итоге даже к такому «API» приходится писать кастомый парсер (парсер != разбор_только_веб_страницы).


            1. dmx102
              07.10.2015 15:34

              К любому api нужно писать парсер(обработчик ответов), само работать не будет)))


  1. monolithed
    05.10.2015 22:35

    Я для этих целей написал обертку вокруг x-ray + needle, а данные сохраняю в тарантул через tarantool-driver.