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

В пример привожу функцию, которая парсит названия валют с сайта floatrates и выводит их (пожалуйста, напишите в комментариях, за сколько минут вы поняли эту строку):

// main.rs
use anyhow::anyhow;
use reqwest::blocking::get;
use scraper::{Html, Selector};

fn main() -> anyhow::Result<()> {
    let body = get("http://www.floatrates.com/json-feeds.html")?.text()?;
    let document = Html::parse_document(&body);
    let selector = Selector::parse("div.bk-json-feeds>div.body>ul>li>a")
        .map_err(|err| anyhow!("{err}"))?;

    for element in document.select(&selector) {
        println!("{}", element.inner_html());
    }
    Ok(())
}

В этой статье я хочу рассказать о своей новой библиотеке, значительно упрощающей парсинг на Rust. Приятного чтения!

Дисклеймер

Пожалуйста, не «бейте» меня за некоторые ошибки в терминах, в комментариях или в синтаксисе кода: я программирую на Rust всего 2 месяца.

Однако опыт в программировании у меня есть (год в python), поэтому прекрасно понимаю, о чём здесь пишу.

Буду очень признателен, если вы напишите в комментариях, что нужно исправить в крэйте)

Благодарю reloginn и Black Soul за помощь в разработке версии 1.0

Инициализация структуры Scraper

// src/error.rs
#[derive(Debug)]
pub enum ScrapingErrorKind {
    NotFound,
    InvalidSelector
}

#[derive(Debug)]
pub enum ScrapingHttpErrorKind {
    GettingDataError
}

// src/scraping.rs
use reqwest::blocking;
use scraper::{ElementRef, Html, Selector, error::SelectorErrorKind};
use crate::error::{ScrapingErrorKind, ScrapingHttpErrorKind};

/// Создание нового экземпляра
fn instance(url: &str) -> Result<Scraper, ScrapingHttpErrorKind> {
    let document = blocking::get(url)
        .map_err(|_| ScrapingHttpErrorKind::GettingDataError)?
        .text()
        .map_err(|_| ScrapingHttpErrorKind::GettingDataError)?;

    Ok(Scraper { document: Html::parse_document(&document) })
}

/// Простой парсер
/// // ...
pub struct Scraper {
    document: Html,
}

Вместо того, чтобы отдельно отправлять запрос, из его результата получать код сайта и инициализировать объект scraper::Html, можно просто вызвать команду let scraper = scr::Scraper::new("scrapeme.live/shop/").unwrap() (да, «https://» и «http://» вводить не надо). Структура сохраняет в себе scraper::Html для дальнейшего парсинга. Вот, что происходит «под капотом» инициализатора:

// scr::scraping
// ...
impl Scraper {
    /// создание нового экземпляра парсера,
    /// <i>используя код сайта **(без https://)**</i>
    pub fn new(url: &str) -> Result<Scraper, ScrapingHttpErrorKind> {
        instance(format!("https://{url}").as_str())
    }

    /// создание нового экземпляра парсера,
    /// <i>используя код сайта **(без http://)**</i>
    pub fn from_http(url: &str) -> Result<Scraper, ScrapingHttpErrorKind> {
        instance(format!("http://{url}").as_str())
    }
    // ...
}

Также есть возможность инициализировать структру, используя фрагмент HTML страницы:

// scr::scraping
// ...
impl Scraper {
    // ...
    /// создание нового экземпляра парсера,
    /// <i>используя **фрагмент** кода сайта</i>
    pub fn from_fragment(fragment: &str) -> Result<Scraper, SelectorErrorKind> {
        Ok(Scraper { document: Html::parse_fragment(fragment) })
    }
    // ...
}

Получение элементов

Под командой scraper.get_els("путь#к>элементам").unwrap(); скрываются выбор элементов по специальному пути c применением структуры scraper::Selector и преобразование полученного результата в Vec<scraper::ElementRef>.

// scr::scraping
// ...
impl Scraper {
    // ...
    /// получение элементов
    pub fn get_els<'a>(&'a self, sel: &'a str) -> Result<Vec<ElementRef>, ScrapingErrorKind> {
        let parsed_selector = Selector::parse(sel).map_err(|_| ScrapingErrorKind::InvalidSelector)?;

        let elements = self.document
            .select(&parsed_selector)
            .collect::<Vec<ElementRef>>();

        Ok(elements)
    }

    /// получение элемента
    pub fn get_el<'a>(&'a self, sel: &'a str) -> Result<ElementRef, ScrapingErrorKind> {
        let element = *self.get_els(sel)?
            .get(0)
            .ok_or(ScrapingErrorKind::NotFound)?;

        Ok(element)
    }
    // ...
}

Получение текста (inner_html) и атрибута элемента (-ов)

Можно получить текст или атрибут как одного, так и нескольких элементов, полученных с помощью scraper.get_els("путь#к>элементам").unwrap();

// scr::scraping
// ...
impl Scraper {
    // ...
    /// получение текста из элемента
    /// ...
    pub fn get_text_once(&self, sel: &str) -> Result<String, ScrapingErrorKind> {
        let text = self.get_el(sel)?
            .inner_html();

        Ok(text)
    }

    /// получение текста из всех элементов
    /// ...
    pub fn get_all_text(self, sel: &str) -> Result<Vec<String>, ScrapingErrorKind> {
        let text = self.get_els(sel)?
            .iter()
            .map(|element| element.inner_html())
            .collect();

        Ok(text)
    }

    /// получение атрибута элемента
    /// ...
    pub fn get_attr_once<'a>(&'a self, sel: &'a str, attr: &'a str) -> Result<&str, ScrapingErrorKind> {
        let attr = self.get_el(sel)?
            .value()
            .attr(attr)
            .ok_or(ScrapingErrorKind::NotFound)?;

        Ok(attr)
    }

    /// получение атрибута всех элементов
    /// ...
    pub fn get_all_attr<'a>(&'a self, sel: &'a str, attr: &str) -> Result<Vec<&str>, SelectorErrorKind> {
        let attrs = self.get_els(sel)
            .unwrap()
            .iter()
            .map(|element| element.value().attr(attr).expect("Some elements do not contain the desired attribute"))
            .collect();

        Ok(attrs)
    }
}

Загрузка файлов (структура FileLoader)

Это — простой загрузчик файлов. Достаточно просто ввести одну команду. Исходный код структуры: *тык*

Известная проблема

К сожалению, пока нельзя вернуть экземпляр структуры scr::FileLoader (ошибка [E0515]).

Планы на версию 2.0.0

Пока что scr работает только синхронно, отчего крэйт моментами достаточно медленный. Поэтому через некоторое время я внедрю async (скорее всего отдельной фичей или модулем).

На этом пока всё. Очень надеюсь, что я заинтересовал вас. По желанию вы можете внести изменения в библиотеку, создав форк репозитория, совершив некоторые изменения и отправив их мне через pull request.

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


  1. Ukrainskiy
    20.08.2023 13:10
    +6

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


    1. onekawdalg Автор
      20.08.2023 13:10

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

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

      Как например мне получить все атрибуты элемента во что-то вроде ассоциативного массива?

      Дополнительные функции можно будет реализовать в последующих обновлениях. Огромное спасибо за идею!


  1. MagnumMalum
    20.08.2023 13:10
    +5

    Спасибо за статью!

    Несколько спорных моментов на мой взгляд:

    1. То что не нужно вводить протокол при передаче URL может помешать. В реализации вижу что у вас само подставляется https://, но иногда все же приходится дёргать api и сайты по http://

    2. Хотелось бы чтобы больше количество функций вовзращало Result. Так программисту было бы легче отлавливать ошибки, например при инициализации скрапера (если сайт не доступен, если произошёл таймаут, если сайт вернул не Html, etc)

    3. Сама идея такой обёртки вокруг Html, Selector, request интересна, но вы не думали, например о более интересных её реализациях - например, с помощью атрибутов, вроде такого:

    #[derive(HtmlParsedStruct)]
    struct CryptoToken {
      #[html_path(".cryptotoken>div>p")]
      token_name: String, 
      
      #[html_path(".cryptotoken>div>.price")]
      price: u32, 
    }

    Мне кажется, было бы удобно (как для пользователя крейта) один раз объявить набор таких структур и парсить из единым образом


    1. onekawdalg Автор
      20.08.2023 13:10

      Применил два пункта вашего сообщения в версии scr = "1.0.1". Также, впоследствии, добавилась возможность применять итерирование (отсутствие данной технологии заметил Ukrainskiy; работает только с Result; пока не тестировал).

      Изменения отразил в статье


  1. macik_spb
    20.08.2023 13:10

    С Растом не знаком, поясните:
    .expect() и .except()
    в чем разница методов?
    except() мне логически понятен, а expect() нет.


    1. onekawdalg Автор
      20.08.2023 13:10

      Просто опечатка. Уже поправил


    1. Cerberuser
      20.08.2023 13:10
      +1

      .expect() примерно означает "я ожидаю, что здесь всё хорошо (т.е. вернулось значение, а не ошибка), если это не так - упади с таким-то сообщением".


  1. Cerberuser
    20.08.2023 13:10
    +1

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

    Перечитал несколько раз, но так и не понял - где в ней используется полученный строкой выше документ? Кроме этого, впрочем, после исправления синтаксических ошибок, мысленной замены unwarp на unwrap и мысленного добавления недостающих типов/шагов (на отсутствие которых косвенно указали бы ошибки компиляции) - сразу. Логика совершенно прямолинейная:

    • Передаём строку в функцию, которая разбирает её как селектор.

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

    • Создаём на основе селектора итератор (вероятно, где-то здесь должен был быть задействован документ, чтобы селектор выбрал элементы из него).

    • Превращаем каждый элемент итератора в HTML-строку его содержимого.

    • Собираем итератор в (видимо) вектор.

    • Пробегаемся по вектору, выводя каждый его элемент в консоль.


    1. onekawdalg Автор
      20.08.2023 13:10

      Да, действительно... Невнимательно проверил статью, пропустил строку с документом и недописал .collect. Сейчас всё там нормально (а ".unwarp" был исправлен задолго до вашего комментария). Сейчас всё там ясно? (в следующих статьях буду внимательнее, спасибо за комментарий)


      1. Cerberuser
        20.08.2023 13:10

        ".unwarp" был исправлен задолго до вашего комментария

        В статье он всё ещё остался (возможно, исправление затронуло только текст до ката)?

        Сейчас всё там ясно?

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


    1. onekawdalg Автор
      20.08.2023 13:10

      Логика совершенно прямолинейная

      Так это — простейший пример, большая часть которого ранее понадобилась в реальном коде (ещё до написания крэйта)


  1. aegoroff
    20.08.2023 13:10
    +2

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

    pub fn get_all_text(&self, sel: &str) -> Result<Vec<String>, SelectorErrorKind> {
        let text = self.get_els(sel)
            .unwrap()
            .iter()
            .map(|element| element.inner_html())
            .collect();
    
        Ok(text)
    }

    почему нельзя так?

    pub fn get_all_text(&self, sel: &str) -> Result<Vec<String>, SelectorErrorKind> {
        let text = self.get_els(sel)?
            .iter()
            .map(|element| element.inner_html())
            .collect();
    
        Ok(text)
    }

    Для библиотеки падать в панику изнутри реализации сомнительная затея.

    Поэтому наверно версионирование стоит сделать как принято в Rust, т.е. первая цифра ноль, т.е. 0.1.0 в вашем случае. Единичку и выше все же стоит использовать для стабильных версий.


  1. AnotherAnkor
    20.08.2023 13:10
    +3

    Когда прочитал про "опыт программирования у меня есть - год в питоне", сразу стало понятно, что дальше можно не читать.

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

    И я даже не программист.


  1. domix32
    20.08.2023 13:10
    +1

    Как-то оно подозрительно сыро выглядит для 1.0. Ну и несколько странно что вы вебовскую терминологию повсеместно на свой лад назвали.

    Почему не использовать какой-нибудь xml парсер и добавить собственно quering документа поверх этого API? И anyhow в качестве обработчика ошибок кажется выглядел бы удобнее.

    Сами XPath так и просятся стать макросом с валидацией вместо строки.

    Это — простой загрузчик файлов. Достаточно просто ввести одну команду. Исходный код структуры: тык

    Make invalid states unrepresentable - крайне рекомендую для подобных штук. Не очень понятно зачем вам в структуре ссылка на путь к файлу. В худшем случае это имеет смысл в какой-нибудь Rc/Arc/Box обернуть, но владение полностью отдавать в структуру. В конце концов не думаю что у вас там большие проблемы с большими данными и cloned тоже вполне себе план, чтобы не иметь проблем с возвратом локальных переменных.

    А, ну и тесты. Где тесты? Сам бог велел писать тесты для такого.


    1. onekawdalg Автор
      20.08.2023 13:10

      Где тесты?

      В отдельностии нет, но в документирующих комментариях они есть. Просто в статье их не отображал


      1. domix32
        20.08.2023 13:10

        test result: FAILED. 6 passed; 1 failed;

        unwrap на Err, видимо тот что упоминали выше. Но в среднем - маловато будет.


  1. DarkEld3r
    20.08.2023 13:10

    Rust — типобезопасный язык программирования, код которого проверяется пакетным менеджером cargo ещё до сборки.

    Никто ещё не придрался?.. Ладно, первым буду: код проверяет компилятор, а не карго. (:


    1. onekawdalg Автор
      20.08.2023 13:10
      +1

      Исправил)))


  1. freecoder_xx
    20.08.2023 13:10
    +1

    По-моему вы перемудрили с примером. К тому же он явно вами не компилировался, так как наполнен ошибками. Вот мой вариант, который делает то же самое:

    use anyhow::anyhow;
    use reqwest::blocking::get;
    use scraper::{Html, Selector};
    
    fn main() -> anyhow::Result<()> {
        let body = get("http://www.floatrates.com/json-feeds.html")?.text()?;
        let document = Html::parse_document(&body);
        let selector = Selector::parse("div.bk-json-feeds>div.body>ul>li>a")
            .map_err(|err| anyhow!("{err}"))?;
    
        for element in document.select(&selector) {
            println!("{}", element.inner_html());
        }
        Ok(())
    }