Актуально на 2026 год. Версии крейтов:
reqwest 0.13,scraper 0.27,tokio 1.x,encoding_rs 0.8. Важное изменение: начиная сreqwest 0.13бэкенд TLS по умолчанию — rustls (чистый Rust), а не системный OpenSSL/native-tls.
Оглавление
- Введение: зачем Rust для парсинга
- Как забираем страницу (HTTP-клиент)
- Библиотеки для парсинга содержимого
- Решение проблем с кириллицей (кодировки)
- Многопоточность и асинхронность
- Использование прокси
- Парсинг через TOR
- Работа с HTTPS / SSL
- Работа с cookie
- Статус ответа и заголовки
- Хранение URL и очереди (поверхностно)
- Дополнительно: то, что часто забывают
- Вежливость, robots.txt, rate limiting
- Повторные попытки и backoff
- JavaScript-страницы (headless-браузеры)
- User-Agent и анти-бот защита
- Обработка ошибок и логирование
- Архитектура полноценного краулера
- Основные плюсы и минусы реализации на Rust
- Правовые и этические аспекты
1. Введение
Парсинг — это автоматическое получение HTML/JSON/XML со страниц и извлечение из них структурированных данных. Любой парсер состоит из двух больших частей:
- сетевой слой — скачивает страницу (HTTP-клиент);
- слой разбора — превращает «сырой» HTML в нужные поля (парсер + селекторы).
Дальше добавляются прокси, многопоточность, обход анти-бот защиты, хранение очереди ссылок и т.д. Rust хорош тем, что даёт C-подобную скорость и минимальное потребление памяти при безопасной работе с параллелизмом — то, что критично, когда вы качаете миллионы страниц.
Стартовый Cargo.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)
#[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, клон дешёвый.
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:
reqwest = { version = "0.13", features = ["blocking"] }
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.
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.
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).
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())
}
Автоопределение кодировки
Лучше не хардкодить кодировку, а определять её. Алгоритм:
- Сначала смотрим
charsetв заголовкеContent-Type. - Если нет — ищем
<meta charset=...>/<meta http-equiv="Content-Type">в первых килобайтах HTML. - Если и там нет — пытаемся угадать статистически (крейт
chardetng).
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).
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, лимит держат семафором:
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):
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 включите фичу:
reqwest = { version = "0.13", features = ["socks"] }
6.1 Один прокси на клиент
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:
let proxy = reqwest::Proxy::all("socks5://127.0.0.1:1080")?;
6.2 Ротация пула прокси
Один Client привязан к одному прокси. Чтобы ротировать, удобнее держать по клиенту на прокси и выбирать круговым перебором:
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-прокси:
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:
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).
arti-client = "..." # уточните актуальную версию: cargo add arti-client
tor-rtcompat = "..."
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-бэкенда
# 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:
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) // ⚠ небезопасно
.build()?;
8.3 Свой корневой сертификат / клиентский сертификат
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()?;
9. Cookie
Cookie нужны для сессий, авторизации и обхода некоторых защит. reqwest умеет автоматически хранить и подставлять их между запросами.
Включаем фичу:
reqwest = { version = "0.13", features = ["cookies"] }
9.1 Автоматический cookie store
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?;
9.2 Свой cookie jar (доступ к значениям / переиспользование)
Когда нужно читать/задавать cookie руками или переносить их между сессиями:
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
9.3 Cookie вручную через заголовок
Если автоматику включать не хочется, cookie можно просто передать заголовком:
let resp = client.get(url)
.header(reqwest::header::COOKIE, "session=abc123; lang=ru")
.send().await?;
10. Статус ответа и заголовки
Перед разбором HTML почти всегда нужно проверить, что страница вообще пришла нормально (200), а не 404/403/429/5xx.
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 В памяти (для небольших задач)
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):
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:
reqwest-middleware = "0.5"
reqwest-retry = "0.9"
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 его не будет. Варианты:
- Найти скрытый API (см. §3.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). По логам видно, где затыки и баны.
use anyhow::Context;
let html = client.get(url).send().await
.with_context(|| format!("не удалось скачать {url}"))?
.text().await
.context("не удалось прочитать тело")?;
13. Архитектура краулера
Схема промышленного парсера, собирающая всё вышеперечисленное:
Ключевые принципы:
- один общий 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 |