Парсинг на Rust: полное руководство от простого к сложному

Парсинг на Rust: reqwest, scraper, tokio — как строгая система типов и скорость компилируемого языка работают на больших объёмах.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 5 апреля 2025

Актуально на 2026 год. Версии крейтов: reqwest 0.13, scraper 0.27, tokio 1.x, encoding_rs 0.8. Важное изменение: начиная с reqwest 0.13 бэкенд TLS по умолчанию — rustls (чистый Rust), а не системный OpenSSL/native-tls.


Оглавление

  1. Введение: зачем Rust для парсинга
  2. Как забираем страницу (HTTP-клиент)
  3. Библиотеки для парсинга содержимого
  4. Решение проблем с кириллицей (кодировки)
  5. Многопоточность и асинхронность
  6. Использование прокси
  7. Парсинг через TOR
  8. Работа с HTTPS / SSL
  9. Работа с cookie
  10. Статус ответа и заголовки
  11. Хранение URL и очереди (поверхностно)
  12. Дополнительно: то, что часто забывают
    • Вежливость, robots.txt, rate limiting
    • Повторные попытки и backoff
    • JavaScript-страницы (headless-браузеры)
    • User-Agent и анти-бот защита
    • Обработка ошибок и логирование
  13. Архитектура полноценного краулера
  14. Основные плюсы и минусы реализации на Rust
  15. Правовые и этические аспекты

1. Введение

Парсинг — это автоматическое получение HTML/JSON/XML со страниц и извлечение из них структурированных данных. Любой парсер состоит из двух больших частей:

  • сетевой слой — скачивает страницу (HTTP-клиент);
  • слой разбора — превращает «сырой» HTML в нужные поля (парсер + селекторы).

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

Стартовый Cargo.toml, к которому мы будем постепенно добавлять фичи:

toml
[package]
name = "parser-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.13", features = ["json", "gzip", "brotli"] }
tokio = { version = "1", features = ["full"] }
scraper = "0.27"
encoding_rs = "0.8"
anyhow = "1"          # удобная обработка ошибок

Официальные страницы базовых крейтов: reqwest, tokio, scraper, encoding_rs, anyhow.


2. Как забираем страницу

В экосистеме Rust есть несколько HTTP-клиентов. Для парсинга в 99% случаев берут reqwest.

Крейт Когда использовать
reqwest Основной выбор. Async + blocking, прокси, cookie, TLS — всё есть.
ureq Лёгкий синхронный клиент без tokio. Для простых скриптов.
isahc Async-клиент на базе libcurl.
hyper Низкоуровневый. Нужен, когда строите свой клиент/сервер.

Документация: docs.rs/reqwest.

2.1 Самый простой запрос (async)

rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let body = reqwest::get("https://example.com")
        .await?      // ждём ответа
        .text()      // читаем тело как строку
        .await?;
    println!("{body}");
    Ok(())
}

2.2 Правильный способ — переиспользуемый Client

reqwest::get каждый раз создаёт новый клиент. Это дорого: теряется пул соединений (keep-alive). Создавайте один Client и клонируйте его — внутри он Arc, клон дешёвый.

rust
use std::time::Duration;
use reqwest::Client;

fn build_client() -> anyhow::Result<Client> {
    let client = Client::builder()
        // притворяемся обычным браузером
        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
                     AppleWebKit/537.36 (KHTML, like Gecko) \
                     Chrome/124.0 Safari/537.36")
        .timeout(Duration::from_secs(30))          // общий таймаут запроса
        .connect_timeout(Duration::from_secs(10))  // таймаут установки соединения
        .gzip(true)                                // авто-распаковка gzip
        .brotli(true)                              // авто-распаковка brotli
        .build()?;
    Ok(client)
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = build_client()?;
    let resp = client
        .get("https://example.com")
        .header("Accept-Language", "ru-RU,ru;q=0.9")
        .send()
        .await?;

    println!("Статус: {}", resp.status());
    let html = resp.text().await?;
    println!("Длина HTML: {}", html.len());
    Ok(())
}

2.3 Синхронный вариант (без tokio)

Если не хочется тащить async-рантайм для маленького скрипта — есть blocking:

toml
reqwest = { version = "0.13", features = ["blocking"] }
rust
fn main() -> anyhow::Result<()> {
    let body = reqwest::blocking::get("https://example.com")?.text()?;
    println!("{body}");
    Ok(())
}

Нельзя вызывать blocking-клиент внутри async-рантайма — будет паника. Выбирайте что-то одно.


3. Библиотеки для парсинга

Когда HTML скачан, его нужно разобрать. Главное правило: не парсите HTML регулярками. HTML не регулярен, это сломается на первой же неэкранированной кавычке. Регулярки уместны только для извлечения мелочей из уже найденного текста.

Крейт Подход Заметки
scraper CSS-селекторы Самый популярный. Обёртка над html5ever из Servo.
dom_query CSS-селекторы + манипуляция Свежая альтернатива, умеет менять DOM.
select свой DSL предикатов Постарше, но рабочий.
html5ever низкоуровневый токенайзер Браузерный парсер. Используется внутри scraper.
lol_html потоковый rewriter От Cloudflare. Для очень больших документов «на лету».
quick-xml XML / RSS / sitemap Быстрый стриминговый XML-парсер.
serde_json JSON Для API-ответов и встроенного JSON.

3.1 scraper + CSS-селекторы

Документация и примеры: docs.rs/scraper.

rust
use scraper::{Html, Selector};

fn parse_articles(html: &str) -> anyhow::Result<()> {
    let document = Html::parse_document(html);

    // Селекторы лучше скомпилировать один раз (вне цикла).
    let item_sel  = Selector::parse("article.post").unwrap();
    let title_sel = Selector::parse("h2.title > a").unwrap();
    let date_sel  = Selector::parse("time.published").unwrap();

    for item in document.select(&item_sel) {
        let title = item
            .select(&title_sel)
            .next()
            .map(|e| e.text().collect::<String>().trim().to_string())
            .unwrap_or_default();

        // ссылка из атрибута href
        let link = item
            .select(&title_sel)
            .next()
            .and_then(|e| e.value().attr("href"))
            .unwrap_or("");

        // дата из атрибута datetime
        let date = item
            .select(&date_sel)
            .next()
            .and_then(|e| e.value().attr("datetime"))
            .unwrap_or("");

        println!("{title} | {date} | {link}");
    }
    Ok(())
}

Полезные приёмы scraper:

  • element.text().collect::<String>() — собрать весь текст внутри (включая вложенный).
  • element.value().attr("href") — взять атрибут.
  • element.html() / element.inner_html() — получить исходный HTML.
  • Селекторы поддерживают [attr="value"], :nth-child, >, (потомки) и т.д.

3.2 JSON из API

Часто данные проще брать не из HTML, а из скрытого JSON-API, который дёргает сама страница. Открываете DevTools → вкладка Network → находите запрос с JSON. Это надёжнее любого парсинга разметки. Десериализация — через serde + serde_json.

rust
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Product {
    id: u64,
    name: String,
    price: f64,
}

async fn fetch_products(client: &reqwest::Client) -> anyhow::Result<Vec<Product>> {
    let products = client
        .get("https://shop.example.com/api/products")
        .send()
        .await?
        .json::<Vec<Product>>()   // десериализация прямо в структуры
        .await?;
    Ok(products)
}

3.3 Sitemap и RSS через quick-xml

Карты сайта (sitemap.xml) — лучший способ узнать все URL сайта без обхода ссылок. Парсятся как обычный XML через quick-xml (или специализированный крейт вроде sitemap).


4. Кириллица и кодировки

Это классическая боль при парсинге Рунета. Многие старые сайты отдают контент в Windows-1251 или KOI8-R, а не в UTF-8.

Почему ломается

Метод resp.text() определяет кодировку по заголовку Content-Type: text/html; charset=.... Если в заголовке charset не указан, а прописан только в HTML (<meta charset="windows-1251">), reqwest по умолчанию считает, что это UTF-8 — и вы получаете «кракозябры» (пÑÐ¸Ð²ÐµÑ или ïðèâåò).

Решение: читаем байты и декодируем сами

Берём «сырые» байты через .bytes() и декодируем нужной кодировкой с помощью encoding_rs (это тот же движок, что в Firefox).

rust
use encoding_rs::{Encoding, WINDOWS_1251, UTF_8};

async fn get_text_cp1251(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
    let resp = client.get(url).send().await?;
    let bytes = resp.bytes().await?;

    // Декодируем как Windows-1251.
    let (text, _enc, had_errors) = WINDOWS_1251.decode(&bytes);
    if had_errors {
        eprintln!("Внимание: при декодировании были ошибки");
    }
    Ok(text.into_owned())
}

Автоопределение кодировки

Лучше не хардкодить кодировку, а определять её. Алгоритм:

  1. Сначала смотрим charset в заголовке Content-Type.
  2. Если нет — ищем <meta charset=...> / <meta http-equiv="Content-Type"> в первых килобайтах HTML.
  3. Если и там нет — пытаемся угадать статистически (крейт chardetng).
rust
use encoding_rs::Encoding;
use reqwest::header::CONTENT_TYPE;

async fn get_text_smart(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
    let resp = client.get(url).send().await?;

    // 1) пробуем взять charset из заголовка
    let header_charset = resp
        .headers()
        .get(CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .and_then(|ct| ct.split("charset=").nth(1))
        .map(|s| s.trim().to_string());

    let bytes = resp.bytes().await?;

    // 2) если в заголовке нет — ищем в <meta> (упрощённо: первые 1024 байта)
    let charset = header_charset.or_else(|| {
        let head = String::from_utf8_lossy(&bytes[..bytes.len().min(1024)]);
        head.to_lowercase()
            .split("charset=")
            .nth(1)
            .map(|s| s.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-')
                      .to_string())
    });

    // 3) выбираем энкодинг (по умолчанию UTF-8)
    let enc = charset
        .as_deref()
        .and_then(|name| Encoding::for_label(name.as_bytes()))
        .unwrap_or(encoding_rs::UTF_8);

    let (text, _, _) = enc.decode(&bytes);
    Ok(text.into_owned())
}

Альтернатива: метод resp.text_with_charset("windows-1251") у reqwest — он использует указанную кодировку как запасную, если в заголовке charset не пришёл. Это проще, но не покрывает случай «в заголовке UTF-8, а на самом деле 1251».


5. Многопоточность и асинхронность

Парсинг почти всегда I/O-bound: процессор простаивает, пока летят сетевые пакеты. Поэтому в Rust здесь побеждает не «потоки», а асинхронность на tokio: тысячи одновременных запросов в одном-двух потоках ОС.

Различайте две задачи:

  • Скачивание (I/O-bound) → async/tokio, много одновременных соединений.
  • Парсинг (CPU-bound, html5ever нагружает CPU) → при больших объёмах выносите в rayon или tokio::task::spawn_blocking, чтобы не блокировать async-рантайм.

5.1 Конкурентность с ограничением — buffer_unordered

Самый идиоматичный способ: поток URL-ов превращаем в поток future, и buffer_unordered(N) выполняет максимум N одновременно (из крейта futures).

rust
use futures::stream::{self, StreamExt};

async fn crawl_many(client: &reqwest::Client, urls: Vec<String>) {
    let concurrency = 20; // не больше 20 запросов одновременно

    let results = stream::iter(urls)
        .map(|url| {
            let client = client.clone(); // клон дешёвый (Arc внутри)
            async move {
                match client.get(&url).send().await {
                    Ok(resp) => {
                        let status = resp.status();
                        let body = resp.text().await.unwrap_or_default();
                        (url, status.as_u16(), body.len())
                    }
                    Err(e) => {
                        eprintln!("Ошибка {url}: {e}");
                        (url, 0, 0)
                    }
                }
            }
        })
        .buffer_unordered(concurrency)
        .collect::<Vec<_>>()
        .await;

    for (url, status, len) in results {
        println!("{status} {len:>8} {url}");
    }
}

5.2 Ограничение через Semaphore

Когда задачи спавнятся через tokio::spawn, лимит держат семафором:

rust
use std::sync::Arc;
use tokio::sync::Semaphore;

async fn crawl_with_semaphore(client: reqwest::Client, urls: Vec<String>) {
    let sem = Arc::new(Semaphore::new(20)); // максимум 20 «в полёте»
    let mut handles = Vec::new();

    for url in urls {
        let client = client.clone();
        let sem = sem.clone();
        handles.push(tokio::spawn(async move {
            let _permit = sem.acquire().await.unwrap(); // ждём свободный слот
            let _ = client.get(&url).send().await;
            // permit освобождается при выходе из скоупа
        }));
    }

    for h in handles {
        let _ = h.await;
    }
}

5.3 CPU-bound парсинг через rayon

Если у вас уже скачаны тысячи HTML и нужно их быстро распарсить — это работа для всех ядер (rayon):

rust
use rayon::prelude::*;

fn parse_all(pages: Vec<String>) -> Vec<usize> {
    pages
        .par_iter()                      // параллельный итератор
        .map(|html| {
            let doc = scraper::Html::parse_document(html);
            doc.select(&scraper::Selector::parse("a").unwrap()).count()
        })
        .collect()
}

6. Прокси

Прокси нужны, чтобы (а) не упереться в бан по IP при массовом парсинге, (б) обходить гео-ограничения. reqwest поддерживает HTTP, HTTPS и SOCKS5-прокси.

Для SOCKS включите фичу:

toml
reqwest = { version = "0.13", features = ["socks"] }

6.1 Один прокси на клиент

rust
use reqwest::{Client, Proxy};

fn client_with_proxy() -> anyhow::Result<Client> {
    let proxy = Proxy::all("http://proxy.example.com:8080")?
        .basic_auth("user", "password"); // если нужна авторизация

    let client = Client::builder()
        .proxy(proxy)
        .build()?;
    Ok(client)
}

Proxy::http(...), Proxy::https(...) и Proxy::all(...) задают прокси для соответствующих схем. SOCKS5:

rust
let proxy = reqwest::Proxy::all("socks5://127.0.0.1:1080")?;

6.2 Ротация пула прокси

Один Client привязан к одному прокси. Чтобы ротировать, удобнее держать по клиенту на прокси и выбирать круговым перебором:

rust
use std::sync::atomic::{AtomicUsize, Ordering};
use reqwest::{Client, Proxy};

struct ProxyPool {
    clients: Vec<Client>,
    idx: AtomicUsize,
}

impl ProxyPool {
    fn new(proxies: &[&str]) -> anyhow::Result<Self> {
        let clients = proxies
            .iter()
            .map(|p| {
                Client::builder()
                    .proxy(Proxy::all(*p)?)
                    .build()
                    .map_err(Into::into)
            })
            .collect::<anyhow::Result<Vec<_>>>()?;
        Ok(Self { clients, idx: AtomicUsize::new(0) })
    }

    /// Возвращает следующий клиент по кругу.
    fn next(&self) -> &Client {
        let i = self.idx.fetch_add(1, Ordering::Relaxed) % self.clients.len();
        &self.clients[i]
    }
}

Резидентные/мобильные прокси с автоматической ротацией на стороне провайдера обычно дают один «шлюзовой» адрес — тогда ротация на вашей стороне не нужна, достаточно одного клиента.


7. Парсинг через TOR

TOR даёт анонимность и бесплатную «ротацию» IP (новая цепочка → новый выходной узел). Есть два пути.

7.1 Простой путь: внешний TOR + SOCKS5

Запускаете системный TOR (демон tor или Tor Browser), который поднимает SOCKS5-прокси на 127.0.0.1:9050 (у Tor Browser — 9150). Дальше — как обычный SOCKS-прокси:

rust
use reqwest::{Client, Proxy};

fn tor_client() -> anyhow::Result<Client> {
    // ВАЖНО: socks5h (с буквой h), а не socks5.
    // 'h' = резолвинг DNS на стороне прокси (внутри TOR),
    // иначе будет утечка DNS и .onion не заработает.
    let proxy = Proxy::all("socks5h://127.0.0.1:9050")?;
    let client = Client::builder()
        .proxy(proxy)
        .build()?;
    Ok(client)
}

Проверка, что трафик идёт через TOR:

rust
async fn check_tor(client: &reqwest::Client) -> anyhow::Result<()> {
    let txt = client
        .get("https://check.torproject.org/api/ip")
        .send().await?
        .text().await?;
    println!("{txt}"); // {"IsTor":true,"IP":"..."}
    Ok(())
}

Смена цепочки (нового IP) делается через control-port TOR (обычно 9051): нужно отправить сигнал NEWNYM. Это делают вручную по протоколу control-port или крейтом-обёрткой. После NEWNYM обычно выдерживают паузу (TOR ограничивает частоту смены ~раз в 10 сек).

7.2 Встроенный TOR: arti

Arti — это реализация TOR на чистом Rust от самого Tor Project. Можно встроить TOR прямо в приложение, без внешнего демона. Высокоуровневый клиентский API — в крейте arti-client (docs.rs).

toml
arti-client = "..."   # уточните актуальную версию: cargo add arti-client
tor-rtcompat = "..."
rust
use arti_client::{TorClient, TorClientConfig};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = TorClientConfig::default();
    // Поднимаем встроенный TOR-клиент и дожидаемся bootstrap.
    let tor = TorClient::create_bootstrapped(config).await?;

    // Дальше можно открывать анонимные TCP-стримы (AsyncRead/AsyncWrite)
    // и поверх них слать HTTP вручную или через hyper.
    let mut stream = tor.connect(("example.com", 80)).await?;
    // ... отправка HTTP-запроса по stream ...
    Ok(())
}

Есть и крейт-«склейка» artiqwest — он маршрутизирует HTTP-запросы через arti с reqwest-подобным API (get/post), включая .onion и websockets.

Минусы arti: API ещё не стабилизировано (до 1.x возможны breaking changes), нет полной поддержки всех фич C-Tor. Зато не нужен внешний процесс и проще деплой.

Дисклеймер: TOR-выходные узлы перегружены, медленны и часто забанены на популярных сайтах. TOR хорош для анонимности и доступа к .onion, но плох как «бесплатный пул быстрых прокси».


8. HTTPS / SSL

С reqwest 0.13 HTTPS работает «из коробки» — бэкенд по умолчанию rustls (чистый Rust, не требует системного OpenSSL). Обычно ничего настраивать не нужно.

8.1 Выбор TLS-бэкенда

toml
# rustls (по умолчанию) — кроссплатформенно, не нужен OpenSSL
reqwest = { version = "0.13" }

# либо системный TLS (schannel на Windows, Secure Transport на macOS, OpenSSL на Linux)
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }

# либо OpenSSL, вкомпилированный статически (удобно для сборки бинарника)
reqwest = { version = "0.13", default-features = false, features = ["native-tls-vendored"] }

Бэкенды TLS: rustls, native-tls, openssl.

8.2 Игнорирование ошибок сертификата (опасно!)

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

rust
let client = reqwest::Client::builder()
    .danger_accept_invalid_certs(true) // ⚠ небезопасно
    .build()?;

8.3 Свой корневой сертификат / клиентский сертификат

rust
use reqwest::{Certificate, Identity};

// Добавить корпоративный/самоподписанный CA:
let ca = Certificate::from_pem(&std::fs::read("my-ca.pem")?)?;

// Клиентский сертификат (mTLS):
let id = Identity::from_pem(&std::fs::read("client.pem")?)?;

let client = reqwest::Client::builder()
    .add_root_certificate(ca)
    .identity(id)
    .build()?;

Cookie нужны для сессий, авторизации и обхода некоторых защит. reqwest умеет автоматически хранить и подставлять их между запросами.

Включаем фичу:

toml
reqwest = { version = "0.13", features = ["cookies"] }
rust
let client = reqwest::Client::builder()
    .cookie_store(true) // включить автоматическое хранение cookie
    .build()?;

// 1) логинимся — сервер вернёт Set-Cookie, клиент их запомнит
client.post("https://site.example/login")
    .form(&[("user", "alice"), ("pass", "secret")])
    .send().await?;

// 2) последующие запросы автоматически уйдут с этими cookie
let dashboard = client.get("https://site.example/dashboard")
    .send().await?
    .text().await?;

Когда нужно читать/задавать cookie руками или переносить их между сессиями:

rust
use std::sync::Arc;
use reqwest::cookie::{Jar, CookieStore};
use reqwest::Url;

let jar = Arc::new(Jar::default());

// Заранее положить cookie вручную:
let url: Url = "https://site.example/".parse()?;
jar.add_cookie_str("session=abc123; Domain=site.example; Path=/", &url);

let client = reqwest::Client::builder()
    .cookie_provider(jar.clone()) // используем наш jar
    .build()?;

// после запросов можно прочитать накопленные cookie из jar

Если автоматику включать не хочется, cookie можно просто передать заголовком:

rust
let resp = client.get(url)
    .header(reqwest::header::COOKIE, "session=abc123; lang=ru")
    .send().await?;

10. Статус ответа и заголовки

Перед разбором HTML почти всегда нужно проверить, что страница вообще пришла нормально (200), а не 404/403/429/5xx.

rust
use reqwest::StatusCode;
use reqwest::header::{CONTENT_TYPE, CONTENT_LENGTH, LOCATION, RETRY_AFTER};

async fn fetch(client: &reqwest::Client, url: &str) -> anyhow::Result<Option<String>> {
    let resp = client.get(url).send().await?;

    let status = resp.status();
    println!("HTTP {} ({})", status.as_u16(), status.canonical_reason().unwrap_or(""));

    // Удобные проверки категорий статуса:
    if status.is_success() {            // 2xx
        // читаем нужные заголовки
        let headers = resp.headers();

        if let Some(ct) = headers.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()) {
            println!("Content-Type: {ct}");
            // парсим только HTML, картинки пропускаем
            if !ct.contains("text/html") {
                return Ok(None);
            }
        }
        if let Some(len) = headers.get(CONTENT_LENGTH) {
            println!("Content-Length: {len:?}");
        }

        let body = resp.text().await?;
        return Ok(Some(body));
    }

    if status.is_redirection() {        // 3xx
        if let Some(loc) = resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()) {
            println!("Редирект на: {loc}");
        }
    }

    if status == StatusCode::TOO_MANY_REQUESTS {  // 429
        // сервер просит подождать
        if let Some(ra) = resp.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()) {
            println!("Нас тормозят. Retry-After: {ra} сек");
        }
    }

    Ok(None)
}

Полезные методы:

  • resp.status()StatusCode; есть .is_success(), .is_client_error(), .is_server_error(), .is_redirection().
  • resp.error_for_status() — превращает 4xx/5xx в Err, удобно с ?.
  • resp.headers()HeaderMap, итерируется как map.
  • resp.url() — финальный URL после редиректов.
  • resp.content_length() — длина тела, если известна.

По умолчанию reqwest сам следует за редиректами (до 10). Поведение настраивается через .redirect(reqwest::redirect::Policy::none()) или .limited(n).


11. Хранение URL и очереди

Любой обходчик (краулер) — это, по сути, цикл: «достать URL из очереди → скачать → извлечь новые ссылки → положить их обратно». Здесь нужны две структуры:

  • очередь (frontier) — что качать дальше;
  • множество посещённых (visited/seen) — чтобы не качать одно и то же дважды.

11.1 В памяти (для небольших задач)

rust
use std::collections::{VecDeque, HashSet};

struct Frontier {
    queue: VecDeque<String>,
    seen: HashSet<String>,
}

impl Frontier {
    fn new() -> Self {
        Self { queue: VecDeque::new(), seen: HashSet::new() }
    }
    /// Добавить URL, если ещё не видели.
    fn push(&mut self, url: String) {
        if self.seen.insert(url.clone()) {  // insert вернёт false, если уже был
            self.queue.push_back(url);
        }
    }
    fn pop(&mut self) -> Option<String> {
        self.queue.pop_front()
    }
}

Для конкурентного доступа из нескольких async-задач очередь делают на каналах: tokio::sync::mpsc, flume или crossbeam-channel. Воркеры читают из канала и пишут новые ссылки обратно.

11.2 Персистентность (для больших и долгих обходов)

При миллионах URL память кончится, а при падении процесса вы потеряете прогресс. Поэтому очередь и «посещённые» выносят во внешнее хранилище:

Хранилище Крейт Когда
Redis redis Распределённая очередь между несколькими воркерами.
SQLite rusqlite / sqlx Один процесс, нужна простая персистентность.
PostgreSQL sqlx Большие объёмы, аналитика, несколько машин.
RocksDB / sled rocksdb / sled Очень быстрый локальный key-value.

Дедупликация при больших масштабах: хранить все URL в HashSet дорого. Используют: - нормализацию URL (убрать #fragment, отсортировать query-параметры, привести регистр хоста) через крейт url — иначе одна страница попадёт под разными URL; - хеш URL (например, xxhash-rust / blake3) вместо самой строки; - фильтр Блума (bloomfilter) — компактная вероятностная структура «возможно видели / точно не видели».

Это раздел «по верхам». На практике выбор зависит от масштаба: для пары тысяч страниц хватит HashSet в памяти; для промышленного краулера — Redis-очередь + Bloom-фильтр + нормализация URL.


12. Дополнительно

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

12.1 Вежливость, robots.txt и rate limiting

  • robots.txt — файл, где сайт указывает, что можно/нельзя обходить. Этичный (а иногда и юридически безопасный) парсинг его уважает. Крейты: texting_robots, robotstxt.
  • Задержки между запросами к одному домену — чтобы не уронить сайт и не словить бан. Простейший вариант — tokio::time::sleep. Профессиональный — лимитер governor (token bucket):
rust
use std::num::NonZeroU32;
use governor::{Quota, RateLimiter};

// не больше 5 запросов в секунду
let limiter = RateLimiter::direct(Quota::per_second(NonZeroU32::new(5).unwrap()));

// перед каждым запросом:
limiter.until_ready().await;
// client.get(...).send().await?;

12.2 Повторные попытки и backoff

Сеть нестабильна: таймауты, 503, обрывы. Нужны ретраи с экспоненциальной задержкой (1с → 2с → 4с…). Самое простое — крейты reqwest-middleware + reqwest-retry:

toml
reqwest-middleware = "0.5"
reqwest-retry = "0.9"
rust
use reqwest_middleware::ClientBuilder;
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};

let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let client = ClientBuilder::new(reqwest::Client::new())
    .with(RetryTransientMiddleware::new_with_policy(retry_policy))
    .build();
// дальше client.get(...).send().await — ретраи произойдут автоматически

12.3 JavaScript-страницы (headless-браузеры)

reqwest + scraper видят только исходный HTML. Если контент дорисовывается JavaScript-ом (SPA на React/Vue), в HTML его не будет. Варианты:

  1. Найти скрытый API (см. §3.2) — почти всегда лучший путь: быстрее, надёжнее, легче.
  2. Управлять настоящим браузером (рендерит JS):
Крейт Протокол Заметки
chromiumoxide CDP (Chrome DevTools) async, управление Chrome напрямую.
thirtyfour WebDriver Selenium-совместимый, удобный высокоуровневый API.
fantoccini WebDriver Легче thirtyfour.
headless_chrome CDP Синхронная обёртка над CDP.

Браузер дороже по ресурсам в десятки раз, поэтому используйте его только там, где без JS никак.

12.4 User-Agent, заголовки и анти-бот защита

Сайты отличают ботов от людей. Минимальная маскировка:

  • адекватный User-Agent (не reqwest/0.13!);
  • реалистичный набор заголовков: Accept, Accept-Language, Accept-Encoding, Referer, Sec-Fetch-*;
  • ротация User-Agent и прокси;
  • человеческие задержки.

Серьёзные защиты (Cloudflare, DataDome, PerimeterX) дополнительно проверяют TLS-fingerprint (JA3/JA4) и порядок HTTP/2-заголовков. Обычный reqwest отдаёт «rust-овый» отпечаток, отличный от Chrome. Для обхода существуют крейты, имитирующие отпечаток браузера на базе curl-impersonate, например rquest. Это «гонка вооружений» — гарантий нет.

12.5 Обработка ошибок и логирование

  • Ошибки: anyhow для приложений (удобный ? и контекст), thiserror для библиотек (свои типы ошибок). Не паникуйте на каждой 404 — обрабатывайте как обычный результат.
  • Логирование/трейсинг: tracing + tracing-subscriber (или log + env_logger). По логам видно, где затыки и баны.
rust
use anyhow::Context;

let html = client.get(url).send().await
    .with_context(|| format!("не удалось скачать {url}"))?
    .text().await
    .context("не удалось прочитать тело")?;

13. Архитектура краулера

Схема промышленного парсера, собирающая всё вышеперечисленное:

Архитектура промышленного краулера на Rust: frontier-очередь с visited-set, пул воркеров tokio с rate limit и ротацией прокси, HTTP-клиент reqwest, декодирование и парсинг (encoding_rs, scraper, serde_json), хранилище результатов и возврат новых ссылок во frontier

Ключевые принципы: - один общий Client (пул соединений), клонируется в воркеры; - конкурентность ограничена семафором, скорость на домен — лимитером; - каждый сетевой вызов обёрнут в retry/backoff; - очередь и seen-множество — единственный источник правды о прогрессе.

Если не хочется собирать всё руками — есть готовые фреймворки-обходчики, например spider.


14. Плюсы и минусы

Плюсы реализации на Rust

  • Производительность: скорость близка к C/C++. На больших объёмах Rust обгоняет Python (requests/BeautifulSoup) и Go в разы по CPU и памяти.
  • Память: минимальное потребление, нет GC-пауз — важно при миллионах страниц и долгих обходах.
  • Бесстрашная многопоточность: система типов и borrow-checker ловят гонки данных на этапе компиляции. Огромный плюс для конкурентного парсера.
  • Надёжность: явная обработка ошибок (Result), Option вместо null — меньше падений в проде.
  • Один статический бинарник: легко деплоить, не тащить интерпретатор и зависимости.
  • Зрелая async-экосистема: tokio + reqwest — production-grade.

Минусы

  • Порог входа: borrow-checker, lifetimes, async — учиться дольше, чем питоновскому парсеру «за вечер».
  • Скорость разработки: прототип на Python/scrapy пишется быстрее. Для разовой задачи Rust может быть избыточен.
  • Динамический контент: меньше готовых «из коробки» решений для JS-рендеринга и обхода анти-бот защит, чем в Python (где есть Playwright, Scrapy, undetected-chromedriver и т.д.).
  • Компиляция: долгие времена сборки, особенно с тяжёлыми зависимостями.
  • Меньше готовых фреймворков: в Python есть полноценный scrapy; в Rust обычно собираешь конвейер из кирпичиков сам (хотя есть spider и др.).

Вывод: Rust оправдан, когда парсинг масштабный, постоянный, ресурсочувствительный (миллионы страниц, жёсткие требования к скорости/памяти, долго живущий сервис). Для разового «спарсить 500 страниц» Python обычно быстрее по суммарным трудозатратам.


15. Правовые и этические аспекты

Технически можно многое — но это не значит, что нужно. Кратко:

  • robots.txt и ToS: уважайте robots.txt и условия использования сайта.
  • Нагрузка: не кладите чужой сервер — ограничивайте частоту запросов, парсите в непиковые часы.
  • Персональные данные: сбор ПДн регулируется законом (GDPR в ЕС и аналогичные законы других стран). Будьте осторожны.
  • Авторские права: контент может быть защищён; массовое копирование/перепубликация бывает незаконна.
  • Идентифицируйтесь: разумно указать контакт в User-Agent, чтобы администратор сайта мог связаться, а не банить вслепую.

Это общая ориентировка, а не юридическая консультация — в спорных случаях консультируйтесь с юристом.


Краткая шпаргалка по крейтам

Задача Крейт(ы)
HTTP-клиент reqwest (async/blocking), ureq (sync)
Парсинг HTML scraper, dom_query, select
JSON / XML serde_json, quick-xml
Кодировки (кириллица) encoding_rs, chardetng
Async-рантайм tokio, futures
CPU-параллелизм rayon
Rate limiting governor
Ретраи reqwest-middleware, reqwest-retry
TOR внешний TOR + socks, либо arti-client / artiqwest
Headless-браузер chromiumoxide, thirtyfour, fantoccini
robots.txt texting_robots
Очередь/хранение redis, rusqlite / sqlx, sled, bloomfilter
Ошибки/логи anyhow, thiserror, tracing
Готовый фреймворк spider