Web scraping обновляющихся данных при помощи Node.js и PaaS

в 7:08, , рубрики: data mining, javascript, morph.io, node.js, paas, scraping, sql, sqlite, web scraping, веб-скрейпинг, Программирование

Web scraping обновляющихся данных при помощи Node.js и PaaS - 1Это уже четвёртая статья в цикле про веб-скрейпинг при помощи Node.js:

  1. Web scraping при помощи Node.js
  2. Web scraping на Node.js и проблемные сайты
  3. Web scraping на Node.js и защита от ботов
  4. Web scraping обновляющихся данных при помощи Node.js

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

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

Цель статьи – показать весь процесс создания, развёртывания и использования скрипта от постановки задачи и до получения конечного результата. Как обычно, для примера используется реальная задача, какие часто встречаются на биржах фриланса.

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

Web scraping обновляющихся данных при помощи Node.js и PaaS - 2Допустим заказчик хочет отслеживать данные о недвижимости на сайте Buzzbuzzhome. Его интересует только город Балтимор. По каждому предложению недвижимости заказчик хочет видеть ссылку на страницу предложения, название объекта и информацию о цене (или то, что на сайте написано вместо цены).

Заказчику нужна возможность периодически обновлять информацию. Фактически, ему нужна возможность в любой момент получить Excel-совместимый файл, в котором будут только актуальные предложения, и в котором среди всех предложений будут помечены новые (которых не было в прошлом запрошенном файле) и изменившиеся с прошлого раза. Заказчик уверен, что меняться будет только информаци о цене.

Заказчик хочет разместить скрипт в сети и работать с ним через веб-интерфейс. Он согласен завести где-нибудь бесплатный аккаунт, самостоятельно запускать скрипт в облаке (нажимать кнопку в браузере) и качать CSV. Конфиденциальность данных его не волнует, так что можно использовать публичные аккаунты.

Анализ сайта

На сайте Buzzbuzzhome можно получить интересующую нас информацию двумя способами: вбить 'Baltimore' в поле поиска на главной странице (будет всплывающая подсказка 'Baltimore, Maryland, United States'), или найти Балтимор в каталоге (пункт 'Cities' в главном меню, выбрать 'Maryland' из списка штатов, потом выбрать 'Baltimore' из списка городов). В первом случае мы получим карту с маркерами и списком, подгружаемым через Ajax, а во втором – скучный список ссылок.

То, что на сайте есть простой олдскульный каталог – это большая удача. Если бы его не было, нам пришлось бы преодолевать ряд трудностей, и я говорю не про анализ Ajax-трафика, а про сами данные. Дело в том, что в списке рядом с картой кроме предложений для Балтимора есть много мусора, который просто попал на карту. Это потому, что поиск выполняется не по адресу, а по географическим координатам. Кроме того стоит учесть, что в поисковую выдачу по координатам часто не попадают объекты, адрес которых указан не точно или не полностью (даже если именно этот сайт так не делает, нельзя гарантировать, что он не сделает так в будущем). Если мусор можно просто отфильтровать по адресу, то насчёт недостающих объектов однозначного решения не существует и пришлось бы уточнять у заказчика, как поступить.

К счастью, в каталоге на нашем сайте есть отдельная страница, куда попадают все объекты, в адресе которых указан Балтимор. Страницы отдельных объектов легко парсятся (по крайней мере название и информация о цене).

Получение данных

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

var needle = require('needle');
var cheerio = require('cheerio');
var tress = require('tress');
var resolve = require('url').resolve;

var startURL = 'https://www.buzzbuzzhome.com/city/united-states/maryland/baltimore';
var results = [];
var q = tress(work);
q.drain = done;
start();

function start(){
    needle.get(startURL, function(err, res){
        if (err) throw err;
        var $ = cheerio.load(res.body);
        $('.city-dev-name>a').each(function(){
            q.push(resolve(startURL, $(this).attr('href')));
        });
    });
}

function work(url, cb){
    needle.get(url, function(err, res){
        if (err) throw err;
        var $ = cheerio.load(res.body);
        results.push([
            url,
            $('h1').text().trim(),
            $('.price-info').eq(0).text().replace(/s+/g, ' ').trim()
        ]);
        cb();
    });
}

function done(){
    // тут что-то делаем с массивом результатов results
    // например, выводит в консоль на этапе тестирования
    console.log(results);
}

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

Сохранение данных

Пожалуй можно опустить детальный разбор вариантов хостинга, таких как VPN, PaaS (типа Heroku) или виртуальных хостингов с поддержкой Node.js. Об этом и так уже много и хорошо написано. Можно сразу начать с того, что для скрейперов существуют специализированные PaaS-решения, которые позволяют существенно сократить трудозатраты. Речь не про “универсальные скрейперы”, требующие только тонкой настройки парсинга, такие как screen-scraper, а про полноценные платформы для запуска собственных скриптов. До недавнего времени лидером в этой нише был ScraperWiki, но сейчас этот сервис переведён в режим только для чтения и постепенно превращается во что-то иное. Новым лидером в этой нише я бы назвал сервис Morph.io. Он удобный, бесплатный и очень простой в развёртывании. Пользователей Morph.io просят укладываться в 512 мегабайт памяти и в 24 часа работы скрипта, но если кому-то этого не хватит – можно попробовать договориться в индивидуальном порядке. Плюсов у этого сервиса много: готовый веб-интерфейс, заточенный под скрейпинг, удобный API, автоматический ежедневный запуск, веб-хуки, оповещения и так далее. Главный минус Morph.io – отсутствие приватных аккаунтов. Можно хранить всякие пароли, например, в секретных переменных окружения, но сам скрипт и полученные данные видны всем.

(На хабре уже была статья про Morph.io, только в контексте Ruby)

На Morph.io данные хранятся в SQLite, так что нам не придётся ломать голову над выбором типа хранилища. Если создать базу данных data.sqlite в текущем каталоге, то она будет доступна для скачивания со страницы скрейпера. Также таблица с именем data будет отображаться (первые 10 строк) на странице скрейпера и будет доступна для скачивания в виде CSV.

Если пока пренебречь отслеживанием изменений, то сохранение данных можно реализовать вот так:

var sqlite3 = require('sqlite3').verbose();

// ...тот же код, что и в примере выше

function done(){
    var db = new sqlite3.Database('data.sqlite');
    db.serialize(function(){
        db.run('DROP TABLE IF EXISTS data');
        db.run('CREATE TABLE data (url TEXT, name TEXT, price TEXT)');
        var stmt = db.prepare('INSERT INTO data VALUES (?, ?, ?)');
        for (var i = 0; i < results.length; i++) {
            stmt.run(results[i]);
        };
        stmt.finalize();
        db.close();
    });
}

Можно, конечно, использовать какой-нибудь ORM, но это уже кому как нравится.

Развёртывание в облаке

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

В репозиторий достаточно залить три файла: scraper.js, package.json и README.md. Текст из README.md будет отображаться на главной странице скрейпера (всё как на GitHub). В package.json достаточно указать зависимости. Если дефолтная версия Node.js не устраивает – можно тут же указать одну из версий, поддерживаемых на Heroku. При первом запуске скрейпера сервис сам поймёт по расширению главного файла, что скрейпер написан на Node.js, сам настроит всю среду и сам установит модули зависимостей. Если сервер Morph.io перегружен – скрипт будет поставлен в очередь, но обычно это ненадолго.

Отслеживание изменяющихся данных

Задачи по отслеживанию изменяющихся данных, с точки зрения скрейпера, делятся на простые и сложные.

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

Сложные задачи – это когда надо отказаться от скрейпинга и выбрать другое инженерное решение.

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

Заведём ещё одну таблицу (назовём её new). Пусть она создаётся, заполняется результатами скрейпинга, мержится и удаляется. В таблице data добавим столбец state, в которое будем писать, например 'new' если запись новая и 'upd' если запись старая, но изменилась с прошлой версии. Конкретные обозначения не важны, лишь бы они хорошо читались в итоговом CSV. Перед мержингом весь столбец сбрасывается в NULL.

Мержинг таблиц выполняется в три SQL запроса.

Добавление новых записей

INSERT INTO data
  SELECT url, name, price, "new" AS state
  FROM new WHERE url IN (
    SELECT url FROM new
    EXCEPT
    SELECT url FROM data
  );

Удаление устаревших записей:

DELETE FROM data
  WHERE url IN (
    SELECT url FROM data
    EXCEPT
    SELECT url FROM new
  )

Обновление изменившихся записей:

UPDATE data
  SET state = "upd", price = (
    SELECT price
    FROM new
    WHERE new.url = data.url
  )
  WHERE url IN (
    SELECT old.url
    FROM data AS old, new
    WHERE old.url = new.url
      AND old.price <> new.price
  )

У тех, кто выбрал Node.js, “чтобы не учить второй язык”, может возникнуть желание обойтись минимумом SQL и основную логику реализовать на Javascript. В основном это вопрос холиварный, но есть один момент, который стоит учесть: стабильность скрипта на маломощном хостинге заметно выше, если для работы с данными использовать язык, который для этого специально предназначен. Запросы не самые сложные, а заказчику будет приятно, если скрипт падает пореже.

Соответствующий фрагмент скрипта выглядит так:

var sqlite3 = require('sqlite3').verbose();

// ...тот же код, что и в первом примере

function done(){
    var db = new sqlite3.Database('data.sqlite');
    db.serialize(function(){
        db.run('DROP TABLE IF EXISTS new');
        db.run('CREATE TABLE new (url TEXT, name TEXT, price TEXT)');
        var stmt = db.prepare('INSERT INTO new VALUES (?, ?, ?)');
        for (var i = 0; i < results.length; i++) {
            stmt.run(results[i]);
        };
        stmt.finalize();
        db.run('CREATE TABLE IF NOT EXISTS data (url TEXT, name TEXT, price TEXT, state TEXT)');
        db.run('UPDATE data set state = NULL');
        db.run('INSERT INTO data SELECT url, name, price, "new" AS state FROM new ' +
            'WHERE url IN (SELECT url FROM new EXCEPT SELECT url FROM data)');
        db.run('DELETE FROM data WHERE url IN (SELECT url FROM data EXCEPT SELECT url FROM new)');
        db.run('UPDATE data SET state = "upd", price = (SELECT price FROM new WHERE new.url = data.url) ' +
            'WHERE url IN (SELECT old.url FROM data AS old, new WHERE old.url = new.url AND old.price <> new.price)');
        db.run('DROP TABLE new');
        db.close();
    });
}

Такой скрипт можно выкладывать на Github, подгружать на Morph.io и запускать. Ради читаемости стоит вынести текст запросов в отдельные строковые переменные, но заказчику читаемость не важна.

Важное примечание

Cкрипт, описанный в данной статье, хорош только для небольших объёмов данных. На текущий момент в Балтиморе всего около 25 предложений по недвижимости (на Buzzbuzzhome) и скрейпинг укладывается в пару минут. То есть, если скрипт упадёт из-за ошибки на сервере – его просто можно перезапустить, благо в базе ничего не меняется до завершения скрейпинга.

В то же время для Нью-Йорка на том же сайте предложений около тысячи, так что скрейпинг занимает 40-50 минут, а сервер у Buzzbuzzhome очень хилый. Решая такую задачу нам пришлось бы добавлять обработку ошибок сервера (банально, возвращаем сбойную задачу в очередь и ставим скрейпинг на небольшую паузу, см. вторую статью), чтобы скрипт не падал и заказчику не приходилось перезапускать его каждый час. Чего не стоит делать – так это сохранять в базе частичные результаты скрейпинга между перезапусками. В реальной жизни это может привести к тому, что часть “обновлённых” данных окажется сильно устаревшей.

Далее, если бы задача была отслеживать весь каталог Buzzbuzzhome, имело бы смысл скрейпить и обновлять данные для каждого города по отдельности. Пришлось бы хранить в отдельной таблице (или как-то ещё) данные о том, какой город как давно обновлялся. Это слишком большая задача для Buzzbuzzhome (как минимум мы не уложимся в лимиты) так что пришлось бы разворачивать скрипт в более мощном облаке (и самим писать к нему веб-интерфейс). Затраты времени на скрейпинг всего каталога измерялись бы сутками, и данные устаревали бы быстрее, чем скрейпились. Это не устроило бы ни одного реального заказчика, так что пришлось бы очень сильно распараллеливать работу. Как именно – это уже зависит от конкретных требований, но точно пришлось бы.

Заключение

Напоследок стоит подчеркнуть, что задача скрейпинга регулярно обновляющихся данных хоть и предполагает сохранение данных между запусками, но вовсе не предполагает обязательного развёртывания в облаке. Можно тот же скрипт отдать заказчику вместе с инструкцией по установке и запуску из терминала его операционной системы, и всё будет нормально работать. Только придётся ещё объяснять как получить CSV из SQLite, так что лучше добавить автоматический экспорт актуальной версии данных. Однако в таком случае у нас вообще не будет причин для использования БД вместо, например, JSON-файла. Реальная потребность в базах данных появляется только при скрейпинге гигантских объёмов данных, которые просто не умещаются в памяти, и сохранять их в обычных файлах крайне неудобно. Но это уже тема для отдельной статьи.

Автор: astur

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js