На сайте https://showroom.hyundai.ru/ можно заказать машину без переплат, напрямую с завода Hyundai, но проблема в том, что машины уходят очень быстро. При этом новые автомобили появляются нечасто, и, чаще всего, можно наблюдать на сайте сообщение об отсутствии машин.

Чтобы успеть забронировать машину, напишем парсер-мониторинг для «Hyundai Showroom» с выгрузкой в телеграм-канал, который будет уведомлять о том, появились ли машины в шоуруме.

Будем использовать язык JavaScript, окружение Node.js, и следующие библиотеки:

  • puppeteer для программного управления браузером;

  • node-telegram-bot-api для отправки сообщения в телеграм-канал;

  • node-cron для установки запуска скрипта по расписанию;

  • winston для логирования.

Заведем константы, в которых опишем хост сайта шоурума Hyundai, доступы для телеграм-канала и переменную окружения:

const hyundaiHost = 'https://showroom.hyundai.ru/';
const tgToken = 'SOME_TELEGRAM_TOKEN';
const tgChannelId = 'SOME_TELEGRAM_CHANNEL_ID';
const isProduction = process.env.NODE_ENV === 'production';

Создадим новые инстансы модулей телеграм-бота и логгера.

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

const bot = new TelegramBot(tgToken);
const logger = winston.createLogger({
    transports: [
        new winston.transports.File({
            filename: './log.txt',
        }),
    ],
});

Функция start запускает функцию exec и устанавливает cron. Функция exec содержит основную часть бизнес-логики скрипта:

async function start() {
    exec();

    cron.schedule('* * * * *', () => {
        exec();
    });
}

Опишем функцию exec.

Создадим инстанс браузера в режиме headless, чтобы в операционной системе не запускался графический интерфейс браузера. Пропишем дополнительные аргументы, которые позволят ускорить работу браузера:

  const browser = await puppeteer.launch({
    headless: true,
    args: [
        '--disable-gpu',
        '--disable-dev-shm-usage',
        '--disable-setuid-sandbox',
        '--no-first-run',
        '--no-sandbox',
        '--no-zygote',
    ],
});

Создадим новую страницу, а также вызовем функцию setBlockingOnRequests, — эта функция установит блокировку некоторых сетевых запросов, которые происходят на странице шоурума. Это нужно чтобы ресурсы, не относящиеся к полезной работе парсера, не загружались. Например, изображения или сторонние скрипты, такие как Google-аналитика и рекламные системы:

const page = await browser.newPage();

await setBlockingOnRequests(page);

Сделаем первый вызов try-catch, в котором загрузим страницу. Если страница не загрузилась, создадим отчет об ошибке при помощи функции createErrorReport. Передадим туда аргументы:

  • инстанс страницы браузера;

  • идентификатор no-page;

  • сообщение «Ошибка посещения страницы»;

  • системную ошибку.

После этого закроем страницу браузера и выйдем из функции exec:

  try {
    await page.goto(hyundaiHost, {waitUntil: 'networkidle2'});
} catch (error) {
    await createErrorReport(page, 'no-page', 'Ошибка посещения страницы', error);

    await page.close();
    await browser.close();
    return;
}

Если страница успешно загрузилась, сделаем следующий вызов try-catch, где попробуем найти CSS-селектор '#cars-all .car-columns' в DOM – так узнаем, отображается ли на странице список автомобилей или нет:

await page.waitForSelector('#cars-all .car-columns', {timeout: 1000});

Также посчитаем количество машин по количеству вхождений в DOM CSS-селектора, принадлежащего к карточке автомобиля:

const carsCount = (await page.$$('.car-item__wrap')).length;

Сформулируем временную метку и сообщение, которое затем отправим в телеграм-канал. Будем использовать функцию pluralize, которая подберет правильное склонение слова в зависимости от числительного:

const timestamp = new Date().toTimeString();
const message = `${pluralize(carsCount, 'Доступна', 'доступно', 'доступно')} ${carsCount} ${pluralize(carsCount, 'машина', 'машины', 'машин')} в ${timestamp}`;

Если приложение запущено в боевой среде, отправим сообщение в телеграм-канал:

if (isProduction) {
    bot.sendMessage(tgChannelId, message);
}

Если CSS-селектор списка машин не найден в DOM, создадим сообщение об ошибке, а затем завершим сессию страницы и браузера:

await createErrorReport(page, 'no-cars', 'Ошибка поиска машин', error);
await page.close();
await browser.close();

Разберем функцию createErrorReport. Формируем сообщения для записи в файл лога:

const timestamp = new Date().toTimeString();

logger.error(`${message} в ${timestamp}`, techError);

Создадим скриншот средствами puppeteer чтобы убедиться, действительно ли машины отсутствовали или, например, изменилась верстка сайта и CSS-селекторы, на которые мы ориентируемся, потеряли актуальность.

Установим самое низкое качество изображения, чтобы файл получился минимального размера, и чтобы большое количество скриншотов не загружали дисковое пространство:

const carListContainer = await page.$('#main-content');

if (carListContainer) {
    await carListContainer.screenshot({path: `${type}-${timestamp}.jpeg`, type: 'jpeg', quality: 1});
} else {
    logger.error(`Не могу сделать скриншот отсутствия автомобилей в ${timestamp}`, techError);
}

Рассмотрим функцию setBlockingOnRequests, которая включает режим перехвата запросов для страницы в puppeteer и устанавливает обработчик события.

Далее, при помощи геттеров resourceType и url, проверим тип и URL загружаемого ресурса. Заблокируем картинки, медиа-файлы, шрифты, CSS-файлы, системы веб-аналитики и рекламные системы, так как никакой полезной информации для парсинга они не несут.

async function setBlockingOnRequests(page) {
    await page.setRequestInterception(true);

    page.on('request', (req) => {
        if (req.resourceType() === 'image'
            || req.resourceType() === 'media'
            || req.resourceType() === 'font'
            || req.resourceType() === 'stylesheet'
            || req.url().includes('yandex')
            || req.url().includes('nr-data')
            || req.url().includes('rambler')
            || req.url().includes('criteo')
            || req.url().includes('adhigh')
            || req.url().includes('dadata')
        ) {
            req.abort();
        } else {
            req.continue();
        }
    });
}

Функция pluralize:

function pluralize(n, one, few, many) {
    const selectedRule = new Intl.PluralRules('ru-RU').select(n);

    switch (selectedRule) {
        case 'one': {
            return one;
        }
        case 'few': {
            return few;
        }
        default: {
            return many;
        }
    }
}

Основное преимущество подобного метода парсинга — несложная реализация, но имеется недостаток — недостаточная надежность, как следствие нестабильной работы сайта шоурума. Его можно исправить, перейдя к работе с REST API, с которым работает сайт шоурума — https://showroom.hyundai.ru/rest/car, но тут мы встретим новое препятствие — шифрование данных.

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


  1. xalyavius
    07.12.2021 11:52
    +1

    От себя могу добавить, что не обязательно дергать весь сайт для проверки наличия автомобилей - достаточно, как вы написали, дёрнуть https://showroom.hyundai.ru/rest/car - тут даже расшифровывать ответ не надо, если машин нет, длина ответа 26 символов, если есть, то больше.

    Я лично мониторил этот сайт в течение двух недель и каждый раз при появлении автомобиля на последнем шаге бронирования получал 500ю ошибку от сервера (хотя автомобилей была целая пачка в наличии на сайте). У вас получилось забронировать автомобиль для себя или для кого-либо?


    1. undgrnd Автор
      07.12.2021 11:56

      тут даже расшифровывать ответ не надо, если машин нет, длина ответа 26 символов

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

      Данным же способом можно немного распарсить DOM и передать в канал информацию о о модели, комплектации, стоимости и т.д.

      У вас получилось забронировать автомобиль для себя или для кого-либо?

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


      1. xalyavius
        07.12.2021 12:38

        да, но кто мешает дергать всю страницу только тогда, когда когда api выдаёт больше 26 символов.


    1. johnny_truant
      07.12.2021 12:15
      +1

      тоже сложилось впечатление, что забронировать физически не возможно. стабильная 500 ошибка у разных пользователей, совпадение? не думаю


    1. dirtycoder
      08.12.2021 09:43
      +1

      Отвечу за автора, у меня получилось сделать 3 брони. По поводу ошибки на последнем шаге - тут как я понял все зависит в основном от удачи. Я делал так - если за 2-3 раза последний шаг не срабатывал, то начинал бронировать новую машину. Еще важный момент, что подтверждение брони не всегда срабатывает, поэтому нужно после попыток проверять личный кабинет, 2 из 3 раз я узнал о брони только через несколько часов


  1. johnny_truant
    07.12.2021 11:52

    Интересно, но главная проблема это бронирование, а оно доступно только после авторизации. Возможно вы видели форму ввода смс-кода сгенерированную с использованием canvas. Если не секрет - пробовали решать этот вопрос?


    1. undgrnd Автор
      07.12.2021 11:53

      Не пробовал, но задача интересная =)


      1. Slowing
        07.12.2021 12:08

        А там звонок приходит и поле постоянно меняется. СМС не используется


        1. undgrnd Автор
          07.12.2021 12:09

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


          1. johnny_truant
            07.12.2021 12:19

            как вариант - можно сделать скриншот экрана, распознать символы и вычислить координаты кнопки и дальше сэмулировать нажатие. canvas я так понял это векторный элемент и у него нет отдельных элементов вида <div>


            1. Slowing
              07.12.2021 12:23

              каждое нажатие меняет кнопки местами (цифры) бегающая клавиатура.


              1. johnny_truant
                07.12.2021 12:27

                верно, довольно элегантное решение на мой взгляд, + они могут в любой момент изменить элементы на клавиатуре, допустим вместо цифр использовать прописные названия (один, два...)


                1. xalyavius
                  07.12.2021 12:32

                  не хватает только капчи)


            1. undgrnd Автор
              07.12.2021 12:23

              Звучит как план и должно получиться, да)


    1. xalyavius
      07.12.2021 12:31

      Я пробовал - обходится отправкой на сервер post-запроса с кодом (последние 4 цифры входящего звонка) и прочей информацией - посмотрите в dev tools запрос который отправляется на сервер после ввода кода в canvas и будет всё понятно.


      1. undgrnd Автор
        07.12.2021 12:34

        О, не так все сложно, оказывается. А что приходит в ответ на такой запрос?


        1. xalyavius
          07.12.2021 12:40

          кука с id для авторизации


          1. Slowing
            07.12.2021 13:05

            А авторизация раз в час слетает


            1. xalyavius
              07.12.2021 13:09

              ну да, раз в час нужно повторять операцию

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


              1. Slowing
                08.12.2021 08:49

                это уже на автомат и не паблике версию:)


      1. johnny_truant
        08.12.2021 01:34

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


  1. aleksandy
    07.12.2021 11:59

    Прикольно, но там есть кнопочка "Подписка на обновление стока" со значком Телеграмма. Это ли не решение "из коробки", тем более, что для бронирования всё равно надо регистрироваться и авторизовываться на сайте.


    1. undgrnd Автор
      07.12.2021 12:03
      +2

      Вы правы, но эта функция появилась совсем недавно, буквально на днях, а парсер был написан раньше.

      Плюс ко всему, вчера, при обновлении стока автомобилей, официальный бот прислал уведомление на 15(!) минут позже, чем автомобиль появился в наличии на сайте.


      1. Slowing
        07.12.2021 12:23

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


        1. Carburn
          07.12.2021 14:10

          может на бота?


  1. Slowing
    07.12.2021 12:27

    Вопрос а раз в сколько секунд он обходит шоурум?


    1. undgrnd Автор
      07.12.2021 12:30

      Крон установлен как `* * * * *`, что означает «раз в минуту» — https://github.com/mikhin/hyundai-showroom-monitor-bot/blob/master/index.js#L25


      1. Slowing
        07.12.2021 12:37

        а крон меньше не умеет(


      1. Carburn
        07.12.2021 14:16

        почему сделано через node-cron, а не просто через setInterval()?


        1. undgrnd Автор
          07.12.2021 14:30

          Хороший вопрос! Честно говоря не помню свою мотивацию, но да, — с точки зрения упрощения кода можно использовать и setInterval().

          Вообще, я запускал скрипт на сервере при помощи библиотеки pm2, и там есть встроенный cron тоже — https://stackoverflow.com/a/42543433. Тогда кажется, лучше убрать cron из скрипта и устанавливать его на уровне окружения.


    1. aleksandy
      07.12.2021 14:19

      Есть более другой вопрос: а не забанят ли бота, опрашивающего страничку слишком часто?


      1. undgrnd Автор
        07.12.2021 14:27

        Строго говоря, это не бот опрашивает страницу, а скрипт, размещенный и запущенный на сервере. Думаю, что по ip конечно могут и забанить =) Но, кажется, бот создает примерно такую же нагрузку как и средний пользователь сайта)


        1. balamutang
          07.12.2021 14:50

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

          От такого скрипта в логах куча одинаковых запросов, поэтому логичный ход при подобной частоте его забанить (или после нескольких смен IP - выдавать ему неверную информацию).

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


          1. andreyr82
            07.12.2021 19:24
            +1

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


        1. aleksandy
          07.12.2021 14:52

          В моём мире, бот - это некая программа, автоматизирующая какой-либо процесс. Соответственно, скрипт - это и есть бот.

          Средний пользователь не будет даже раз в минуту 24/7 сидеть и обновлять страницу.


        1. Slowing
          08.12.2021 08:51

          Опытно империческим путем выяснил что есть ограничение на стороне шоурума по кол-ву запроса в N секунд. Бывало днями сидел с авторефрешером в хроме.