Связаться с автором — urasergeevich@gmail.com.
На сайте 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, но тут мы встретим новое препятствие — шифрование данных.
Ссылка на демо-телеграм-канал — https://t.me/hyundaishowroommonitoring
Ссылка на репозиторий со скриптом — https://github.com/mikhin/hyundai-showroom-monitor-bot
Связаться с автором — urasergeevich@gmail.com.