23 января, 2025

Парсинг магазина продуктов на Rust

Хотел бы поделится парсером магазина продуктов на Rust. Изначально хотел создать парсер на PHP Symfony, но столкнулся с рядом проблем связанных с производительностью. Скорее всего без Symfony на нативном PHP парсер заработал бы без проблем, но зачем брать для этой цели PHP если есть Rust ;)

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

$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup | sh

После установки можно проверить версию командой

$ rustc --version
rustc 1.84.0 (9fc6b4312 2025-01-07)

Теперь создаем рабочую директорию для нового проекта в моем случае это ~/parse

Внутри директории создаем Cargo.toml с содержимым

[package]
name = "parser"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.11", features = ["blocking", "rustls-tls"] }
regex = "1.7"

далее создаем директорию ~/parse/src в которой будет находится основной код парсера

Создаем внутри main.rs и директорию parsers ( в ней я планирую создавать модули для парсенга разных магазинов )

// Регестрируем модули
mod parsers;

// Подключаем необходимые библиотеки
use std::collections::HashMap;
use std::env;

fn main() {
    // Получаем аргументы командной строки
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Usage: {} <command>", args[0]);
        return;
    }

    let command = &args[1];

    // Создаём HashMap с командами и функциями
    let mut command_map: HashMap<&str, fn()> = HashMap::new();

    // Регистрируем функции из разных модулей
    command_map.insert("avoska", parsers::avoska::run);
    // command_map.insert("magnit", parsers::magnit::run);
    // command_map.insert("dixy", parsers::dixy::run);

    // Ищем команду в HashMap и вызываем соответствующую функцию
    match command_map.get(command.as_str()) {
        Some(func) => func(),
        None => eprintln!("Unknown command: {}", command),
    }
}

Концепция main.rs заключается в том чтобы можно было при помощи передаваемого параметра ей запускать парсер который находится в директории parsers/<название_магазина>
Вызов такое команды будет таким:

$ cargo run <grocery_store>

Так же нужно создать файл с перечислением модулей в директории src/parsers/mod.rs

pub mod avoska
// pub mod magnit
// pub mod dixy
// ...


Это требуется чтобы наши парсеры были зарегистрированны в качестве модулей в проекте

Теперь основной функицонал парсера для продуктового магазина «Авоська». Код парсера будет находится тут src/parsers/avoska.rs

// Подключаем необходимые модули, браузер, регулярки, модули для работы со временем
use reqwest::blocking::Client;
use regex::Regex;
use std::time::Duration;
use std::time::Instant;

pub fn run() {
    // Засекаем время
    let start = Instant::now();
    // Создаем браузер для получения данных 
    let client = Client::builder()
        .timeout(Duration::from_secs(10)) // Устанавливаем таймаут
        .build()
        .expect("Failed to build client");

    // Обозначаем с какой страницы начинать парсинг
    let mut page = 1;

    // Это общий счетчик полученных продуктов с сайта
    let mut counter = 1;

    // Открываем бесконечный цикл. Rust не умеет в do {...} while() как PHP :(
    loop {
        // Формируем url для сбора товаров
        let url = format!("https://www.avoska.ru/products?o={}", page);

        // Сразу же устанавливаем следующую страницу
        page += 1;

        // Регулярное выражение, которое позволяет выдергивать из данных название и цену продукта
        let re = Regex::new(r#"<div class="promo-products-card-title-block">.*?<h4 class="promo-products-card-title">(.*?)</h4>.*?<p class="promo-products-card-text">(.*?)</p>.*?<sub></sub>(.*?)<sup>(.*?)</sup>"#)
            .expect("Invalid regex pattern");
        // Пробуем сделать запрос
        match client.get(url).send() {
            Ok(response) => {
                if let Ok(body) = response.text() {
                    // Из полученных данных лучше убрать все возможные переводы строк, чтобы не нагружать этим регуярку выше
                    let re1 = Regex::new(r"\r?\n").unwrap();
                    let cleaned_body = re1.replace_all(&body, "");

                    // Если никакие данные не распасрились, то это может означать что мы дошли до конца всех страниц каталога и поэтому тормозим процесс парсинга
                    if re.captures_iter(&cleaned_body).next().is_none() {
                        break;
                    } else {
                        // Если получили данные, то проходим по всем товарам на странице и сохраняем их в переменных 
                        for captures in re.captures_iter(&cleaned_body) {
                            let title = captures.get(1).map_or("1", |m| m.as_str());
                            let description = captures.get(2).map_or("", |m| m.as_str());
                            let price_integer = captures.get(3).map_or("", |m| m.as_str());
                            let price_fraction = captures.get(4).map_or("", |m| m.as_str());

                            // Данные получины, в данном примере я вывожу лог с информацией о товаре в стандартный поток вывода в консоль
                            println!("{}. Title: {} {} \n Price: {}.{} \n ---", counter, title, description, price_integer, price_fraction);
                            // Увеличиваем счетчик на 1 товар
                            counter += 1;
                        }
                    }
                } else {
                    // Если что-то пошло не так
                    eprintln!("Failed to read response body");
                }
            }
            Err(err) => {
                // Если что-то пошло не так при попытке запроса данных с сайта
                eprintln!("Request failed: {}", err);
            }
        }

        // Получаем продолжительность работы парсера в секундах
        let duration = start.elapsed();

        // Выводим статистику по затраченному времени и полученным товарам
        println!("Counter: {}, Time: {:?}", counter, duration);
    }
}

Запускаем все это командой

$ cargo run avoska

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

Каким образом можно использовать данную информацию, да каким угодно! Можно, например, организовать сравнение стоимости в разных магазинах, можно создать API и отдавать эти данные другим клиентам-приложениям, например построить на базе этого API Оракул и передавать актуальную стоимость хлеба в блокчейн смарт-контракта :) Ну или фиксировать ежедневные цены и формировать реальную инфляцию на продукты питания за определенный период. Вообще данные есть и тут все зависит только от фантазии :)