Практический справочник по сбору данных с веб-страниц на .NET. Примеры рассчитаны на .NET 6/8 и идиоматичный современный C# (
async/await,HttpClient,recordи т.д.). Там, где фича появилась в конкретной версии, это отмечено.
Оглавление
- Что такое парсинг и из чего он состоит
- Подготовка проекта и нужные пакеты
- Как забираем страницу:
HttpClient - Заголовки запроса, User-Agent и сжатие (gzip/br)
- Библиотеки для парсинга содержимого - 5.1 HtmlAgilityPack (XPath) - 5.2 AngleSharp (CSS-селекторы, полноценный DOM) - 5.3 Fizzler, регулярки и когда что выбирать
- Решение проблем с парсингом кириллицы (кодировки)
- Получение статуса ответа и других заголовков
- Работа с cookie
- Работа с HTTPS/SSL
- Использование прокси
- Парсинг через TOR
- Многопоточность / параллелизм
- Устойчивость: таймауты, повторы, вежливость, robots.txt
- Хранение URL и очередей (frontier, дедупликация)
- JavaScript-контент: headless-браузеры
- Сохранение результатов
- Основные плюсы и минусы реализации
- Правовые и этические аспекты
1. Что такое парсинг и из чего он состоит
Парсинг (web scraping) — это автоматическое получение веб-страниц и извлечение из них структурированных данных. Любой парсер логически состоит из четырёх частей:
- Загрузчик (downloader/fetcher) — скачивает HTML по URL.
- Парсер содержимого — превращает HTML в дерево, из которого можно доставать данные по селекторам.
- Извлечение и нормализация данных — вытаскиваем нужные поля, чистим, приводим к типам.
- Планировщик (scheduler/frontier) — управляет очередью URL, дедупликацией, скоростью, повторами.
Хороший парсер ≠ «скачал и распарсил». 80% сложности — это надёжность: кодировки, таймауты, повторы, защита от бана, ограничение скорости, управление очередью. Об этом большая часть статьи.
2. Подготовка проекта и нужные пакеты
dotnet new console -n Scraper
cd Scraper
# Парсинг HTML — что-то одно или оба
dotnet add package HtmlAgilityPack
dotnet add package AngleSharp
# Поддержка legacy-кодировок (windows-1251 и т.п.) — обязательно для кириллицы в .NET Core+
dotnet add package System.Text.Encoding.CodePages
# Устойчивость (повторы, circuit breaker)
dotnet add package Microsoft.Extensions.Http.Polly
# Опционально — headless-браузер для JS-страниц
dotnet add package Microsoft.Playwright
Официальные ресурсы этих пакетов:
- HtmlAgilityPack — GitHub · NuGet
- AngleSharp — сайт и документация · GitHub · NuGet
- System.Text.Encoding.CodePages — NuGet · документация
- Polly / Microsoft.Extensions.Http.Polly — документация · GitHub · NuGet
- Microsoft.Playwright (.NET) — документация · GitHub · NuGet
3. Как забираем страницу: HttpClient
В современном .NET единственно правильный инструмент — HttpClient. Старые WebClient и HttpWebRequest считаются устаревшими (legacy) и в новом коде использовать их не нужно.
Главное правило: HttpClient нужно переиспользовать
HttpClient рассчитан на долгую жизнь. Создавать новый экземпляр на каждый запрос (using var client = new HttpClient()) — классическая ошибка: она приводит к исчерпанию сокетов (порты застревают в состоянии TIME_WAIT). Делайте один общий экземпляр на приложение либо используйте IHttpClientFactory. Подробный разбор — в руководстве Microsoft по использованию HttpClient.
using System.Net;
using System.Net.Http;
// Один handler + один клиент на всё приложение (или DI-синглтон)
var handler = new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.All, // gzip, deflate, brotli
PooledConnectionLifetime = TimeSpan.FromMinutes(2), // защита от «протухшего» DNS
MaxConnectionsPerServer = 20,
AllowAutoRedirect = true,
MaxAutomaticRedirections = 10
};
var http = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
http.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36");
// Простейшая загрузка
string html = await http.GetStringAsync("https://example.com");
Лучше работать через HttpRequestMessage и HttpResponseMessage
GetStringAsync удобен, но прячет статус-код, заголовки и кодировку. Для реального парсера берите полный ответ — так вы контролируете всё:
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
request.Headers.Referrer = new Uri("https://google.com");
using var response = await http.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode(); // бросит исключение при 4xx/5xx (по желанию)
byte[] bytes = await response.Content.ReadAsByteArrayAsync();
// bytes -> декодируем в строку сами (см. раздел про кириллицу)
HttpCompletionOption.ResponseHeadersReadвозвращает управление, как только пришли заголовки, не дожидаясь всего тела. Полезно для больших ответов и стриминга.
4. Заголовки запроса, User-Agent и сжатие
Многие сайты блокируют запросы без «человеческих» заголовков. Базовый набор, который стоит выставлять:
http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 ... Chrome/124.0 ...");
http.DefaultRequestHeaders.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
http.DefaultRequestHeaders.AcceptLanguage.ParseAdd("ru-RU,ru;q=0.9,en;q=0.8");
http.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip, deflate, br");
Про сжатие важно: не объявляйте Accept-Encoding: gzip, br вручную, если не включили AutomaticDecompression. Иначе сервер пришлёт сжатое тело, а вы получите «мусор». Правильно — задать AutomaticDecompression = DecompressionMethods.All на handler'е (как в разделе 3), тогда .NET сам и заголовок добавит, и распакует. Brotli (br) поддерживается начиная с .NET Core 3.0.
Ротация User-Agent и реалистичный порядок заголовков — частый приём против простых анти-бот систем, но он не панацея против серьёзной защиты (см. раздел 13 и 18).
5. Библиотеки для парсинга содержимого
Когда HTML скачан, его нужно разобрать в дерево и доставать данные селекторами. Два основных игрока в .NET — HtmlAgilityPack и AngleSharp.
5.1 HtmlAgilityPack (XPath)
Классика, проверенная годами. Работает через XPath, прощает «грязный» невалидный HTML.
using HtmlAgilityPack;
var doc = new HtmlDocument();
doc.LoadHtml(html);
// Заголовок страницы
string? title = doc.DocumentNode
.SelectSingleNode("//title")?.InnerText.Trim();
// Все ссылки
foreach (var a in doc.DocumentNode.SelectNodes("//a[@href]") ?? Enumerable.Empty<HtmlNode>())
{
string href = a.GetAttributeValue("href", "");
string text = HtmlEntity.DeEntitize(a.InnerText).Trim();
Console.WriteLine($"{text} -> {href}");
}
// Поиск по классу через XPath
var prices = doc.DocumentNode
.SelectNodes("//span[contains(@class,'price')]");
⚠️
SelectNodesвозвращаетnull, если ничего не найдено (а не пустую коллекцию) — всегда проверяйте наnullили используйте?? Enumerable.Empty<...>().HtmlEntity.DeEntitizeнужно вызывать, чтобы превратить&, и пр. в нормальные символы.
5.2 AngleSharp (CSS-селекторы, настоящий DOM)
Современная библиотека, реализующая стандарты W3C. Парсит HTML так же, как браузер, и поддерживает CSS-селекторы (querySelector / querySelectorAll), как в JS. Часто удобнее, особенно если вы привыкли к фронтенду.
using AngleSharp;
using AngleSharp.Dom;
var config = Configuration.Default;
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(req => req.Content(html));
// CSS-селекторы, как в браузере
string? title = document.QuerySelector("title")?.TextContent.Trim();
var cards = document.QuerySelectorAll("div.product-card");
foreach (var card in cards)
{
string? name = card.QuerySelector("h2.name")?.TextContent.Trim();
string? price = card.QuerySelector(".price")?.TextContent.Trim();
string? link = card.QuerySelector("a")?.GetAttribute("href");
Console.WriteLine($"{name} | {price} | {link}");
}
AngleSharp умеет больше: загружать страницу по URL целиком, разбирать формы, работать с CSSOM. Это полноценный движок DOM, а не просто HTML-парсер.
5.3 Fizzler, регулярки и когда что выбирать
- Fizzler — добавляет CSS-селекторы поверх HtmlAgilityPack (
.QuerySelectorAll(...)), если хочется CSS, но остаться на HAP. - Регулярные выражения по HTML — антипаттерн. HTML не регулярный язык; regex ломается на вложенности, атрибутах в произвольном порядке, комментариях. Regex уместен только для добивки уже извлечённого текста (например, выдернуть число из строки
«Цена: 1 299 ₽»).
Что выбрать:
| Ситуация | Рекомендация |
|---|---|
| Привычен XPath, «грязный» HTML | HtmlAgilityPack |
| Привычны CSS-селекторы, нужен «браузерный» DOM | AngleSharp |
| Нужны CSS-селекторы, но кодовая база на HAP | HtmlAgilityPack + Fizzler |
Данные в <script> как JSON (часто __NEXT_DATA__, JSON-LD) |
вытащить узел селектором, затем System.Text.Json |
Подсказка: очень часто данные уже лежат на странице в JSON внутри <script type="application/ld+json"> или в стейте SPA. Парсить такой JSON надёжнее, чем верстку.
6. Решение проблем с парсингом кириллицы (кодировки)
Это самая частая боль на рунет-сайтах. Симптом — «крякозябры» вместо русских букв (привет или ïðèâåò). Причина почти всегда — неверная кодировка при декодировании байтов в строку.
Шаг 1. Зарегистрируйте провайдер кодовых страниц
В .NET Core / .NET 5+ старые однобайтовые кодировки (windows-1251, koi8-r) не включены по умолчанию. Без этого Encoding.GetEncoding(1251) бросит исключение. Подключите пакет System.Text.Encoding.CodePages (см. CodePagesEncodingProvider) и один раз при старте выполните:
using System.Text;
// В самом начале программы (Main / стартап)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var win1251 = Encoding.GetEncoding(1251); // или GetEncoding("windows-1251")
Шаг 2. Не используйте GetStringAsync вслепую
GetStringAsync декодирует тело, опираясь на заголовок Content-Type; charset=.... Если сайт врёт в заголовке или не указывает charset, вы получите кашу. Надёжный путь — скачать байты и определить кодировку самим.
static async Task<string> GetHtmlAsync(HttpClient http, string url)
{
using var resp = await http.GetAsync(url);
byte[] bytes = await resp.Content.ReadAsByteArrayAsync();
// 1) charset из HTTP-заголовка
string? charset = resp.Content.Headers.ContentType?.CharSet;
// 2) если в заголовке нет — ищем в <meta charset> / <meta http-equiv>
if (string.IsNullOrEmpty(charset))
charset = SniffCharsetFromMeta(bytes);
Encoding enc;
try
{
enc = string.IsNullOrEmpty(charset)
? Encoding.UTF8
: Encoding.GetEncoding(charset.Trim('"', '\''));
}
catch
{
enc = Encoding.UTF8; // фолбэк
}
return enc.GetString(bytes);
}
// Грубое определение charset из первых байтов (метатег)
static string? SniffCharsetFromMeta(byte[] bytes)
{
// meta всегда в ASCII-совместимой части, читаем первые ~2 КБ как latin1
string head = Encoding.GetEncoding("ISO-8859-1")
.GetString(bytes, 0, Math.Min(bytes.Length, 2048));
var m = System.Text.RegularExpressions.Regex.Match(
head,
@"charset\s*=\s*[""']?\s*([a-zA-Z0-9\-]+)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return m.Success ? m.Groups[1].Value : null;
}
Это один из редких уместных случаев применения regex по HTML — только чтобы выдернуть имя кодировки из метатега, не более.
Шаг 3. Положитесь на парсер (часто проще всего)
И AngleSharp, и HtmlAgilityPack умеют определять кодировку из байтов сами — если скормить им поток/байты, а не уже декодированную строку.
// HtmlAgilityPack: определит кодировку из <meta> сам
using var resp = await http.GetAsync(url);
await using var stream = await resp.Content.ReadAsStreamAsync();
var doc = new HtmlDocument
{
OptionReadEncoding = true // читать кодировку из документа
};
doc.Load(stream); // detectEncodingFromByteOrderMarks = true по умолчанию
// AngleSharp: передаём поток, он сам разберётся с кодировкой
using var resp = await http.GetAsync(url);
await using var stream = await resp.Content.ReadAsStreamAsync();
var context = BrowsingContext.New(Configuration.Default);
var document = await context.OpenAsync(req => req.Content(stream));
Алгоритм на практике: регистрируем CodePages → отдаём байты/поток парсеру → если всё равно крякозябры, проверяем charset в заголовке и метатеге и декодируем вручную нужной кодировкой.
7. Получение статуса ответа и других заголовков
HttpResponseMessage даёт полный доступ к статусу и заголовкам — это нужно для логики повторов, обработки редиректов и банов.
using var resp = await http.GetAsync(url);
int statusCode = (int)resp.StatusCode; // 200, 404, 503...
bool ok = resp.IsSuccessStatusCode; // true для 2xx
var reason = resp.ReasonPhrase; // "OK", "Not Found"
// Заголовки ответа (response headers)
if (resp.Headers.TryGetValues("Server", out var server))
Console.WriteLine("Server: " + string.Join(",", server));
// Заголовки контента (content headers)
string? contentType = resp.Content.Headers.ContentType?.MediaType; // text/html
long? contentLength = resp.Content.Headers.ContentLength;
// Полезное для парсера
var retryAfter = resp.Headers.RetryAfter; // при 429/503 — когда повторить
var location = resp.Headers.Location; // куда редиректит (если AllowAutoRedirect=false)
switch (statusCode)
{
case 200: /* парсим */ break;
case 301 or 302: /* редирект */ break;
case 403: /* возможно, бан/нужны куки/UA */ break;
case 404: /* нет страницы — выкидываем из очереди */ break;
case 429: /* too many requests — притормозить, см. Retry-After */ break;
case >= 500: /* ошибка сервера — повторить позже */ break;
}
Разделение важно: общие заголовки лежат в
resp.Headers, а связанные с телом (Content-Type,Content-Length,Content-Encoding) — вresp.Content.Headers. Если искатьContent-Typeвresp.Headers, его там не будет.
8. Работа с cookie
Cookie нужны для сессий, авторизации, прохождения «проверок». В .NET за них отвечает CookieContainer, привязанный к handler'у.
var cookies = new CookieContainer();
var handler = new SocketsHttpHandler
{
CookieContainer = cookies,
UseCookies = true // включено по умолчанию
};
var http = new HttpClient(handler);
// Запросы автоматически отправляют и сохраняют куки этого контейнера
await http.GetAsync("https://site.ru/login");
// Можно задать куку вручную (например, токен сессии)
cookies.Add(new Uri("https://site.ru"),
new Cookie("session_id", "abc123") { Path = "/" });
// Прочитать текущие куки для домена
foreach (Cookie c in cookies.GetCookies(new Uri("https://site.ru")))
Console.WriteLine($"{c.Name} = {c.Value}");
Тонкости:
- Один контейнер = одна сессия. Для параллельного парсинга с разными «личностями» создавайте отдельный handler+контейнер на каждую сессию/прокси.
- Если нужно отключить куки (например, чтобы каждый запрос был «чистым»), поставьте UseCookies = false.
- Сохранить/восстановить сессию между запусками можно, сериализовав куки (имя, значение, домен, путь, срок) в JSON.
9. Работа с HTTPS/SSL
По умолчанию HttpClient сам устанавливает TLS-соединение и проверяет сертификат сервера. Обычно ничего настраивать не нужно. Вмешательство требуется в редких случаях.
Игнорирование ошибок сертификата (осторожно!)
Иногда сайт имеет «битый»/самоподписанный сертификат, и нужно его всё равно скачать. Отключить проверку можно так, но только осознанно — вы теряете защиту от MITM:
var handler = new SocketsHttpHandler
{
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
{
// ВНИМАНИЕ: принимает любой сертификат. Только для отладки/доверенных задач.
RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
}
};
(У классического HttpClientHandler аналог — ServerCertificateCustomValidationCallback, и есть готовая заглушка HttpClientHandler.DangerousAcceptAnyServerCertificateValidator.)
Управление версией TLS
var handler = new SocketsHttpHandler
{
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
{
EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12
| System.Security.Authentication.SslProtocols.Tls13
}
};
Не отключайте проверку сертификатов «на всякий случай» в продакшене. Это открывает дорогу подмене трафика. Используйте точечно и только там, где действительно нужно.
10. Использование прокси
Прокси нужны, чтобы (а) обходить блокировки по IP, (б) распределять нагрузку и снижать риск бана через ротацию адресов.
Базовая настройка HTTP-прокси
var proxy = new WebProxy("http://proxy-host:8080")
{
Credentials = new NetworkCredential("user", "password") // если нужна авторизация
};
var handler = new SocketsHttpHandler
{
Proxy = proxy,
UseProxy = true
};
var http = new HttpClient(handler);
SOCKS-прокси (нативно в .NET 6+)
Начиная с .NET 6 поддерживаются схемы socks4, socks4a, socks5 прямо в WebProxy — сторонние библиотеки больше не нужны:
var proxy = new WebProxy("socks5://127.0.0.1:1080");
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };
Ротация прокси
Простейшая стратегия — пул прокси, каждому даём свой HttpClient (handler с прокси переиспользуется!), и берём по кругу или случайно. Прокси, давшие ошибку/таймаут, временно «штрафуем».
public sealed class ProxyPool
{
private readonly HttpClient[] _clients;
private int _index;
public ProxyPool(IEnumerable<string> proxyUrls)
{
_clients = proxyUrls.Select(url =>
{
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(url),
UseProxy = true,
AutomaticDecompression = DecompressionMethods.All
};
return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
}).ToArray();
}
public HttpClient Next()
{
int i = Interlocked.Increment(ref _index);
return _clients[(i & int.MaxValue) % _clients.Length];
}
}
Важно: не создавайте новый handler с прокси на каждый запрос — это вернёт нас к исчерпанию сокетов. Создавайте по одному
HttpClientна прокси и переиспользуйте.
11. Парсинг через TOR
TOR даёт анонимность и бесплатную ротацию IP. По сути это локальный SOCKS5-прокси.
Подключение к TOR как к SOCKS5
После установки Tor (демон tor или Tor Browser) на машине поднимается SOCKS5-прокси, по умолчанию на 127.0.0.1:9050 (у Tor Browser — 9150).
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy("socks5://127.0.0.1:9050"), // .NET 6+
UseProxy = true,
AutomaticDecompression = DecompressionMethods.All
};
var http = new HttpClient(handler);
string html = await http.GetStringAsync("https://check.torproject.org");
Смена IP (новая цепочка) через Control Port
Главная «фишка» — можно запросить новую цепочку (новый выходной IP) командой SIGNAL NEWNYM на управляющий порт (по умолчанию 9051), см. спецификацию control-протокола Tor. Его надо включить в torrc:
ControlPort 9051
# и аутентификацию, например хешированный пароль:
HashedControlPassword 16:... # из `tor --hash-password "mypass"`
Запрос новой цепочки сырым TCP:
using System.Net.Sockets;
using System.Text;
static async Task NewTorIdentityAsync(string password,
string host = "127.0.0.1", int controlPort = 9051)
{
using var client = new TcpClient();
await client.ConnectAsync(host, controlPort);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.ASCII);
using var writer = new StreamWriter(stream, Encoding.ASCII) { AutoFlush = true };
await writer.WriteLineAsync($"AUTHENTICATE \"{password}\"");
var authResp = await reader.ReadLineAsync(); // ожидаем "250 OK"
await writer.WriteLineAsync("SIGNAL NEWNYM");
var sigResp = await reader.ReadLineAsync(); // "250 OK"
}
Учтите: TOR медленный, и многие сайты режут трафик с выходных нод. Между
NEWNYMесть лимит (MaxCircuitDirtiness, ~10 сек), так что менять IP мгновенно на каждый запрос не получится. Для скоростного парсинга коммерческие прокси обычно практичнее, TOR — для анонимности.
12. Многопоточность / параллелизм
В сетевом парсинге узкое место — ожидание ответа, а не CPU. Поэтому нужен не «многопоточность» в классическом смысле, а асинхронный параллелизм с ограничением одновременных запросов. Запускать тысячи запросов разом нельзя — забьёте сеть, исчерпаете соединения и получите бан.
Способ 1: Parallel.ForEachAsync (.NET 6+) — самый простой
var urls = new List<string> { /* ... */ };
var results = new System.Collections.Concurrent.ConcurrentBag<string>();
await Parallel.ForEachAsync(
urls,
new ParallelOptions { MaxDegreeOfParallelism = 8 }, // не больше 8 одновременно
async (url, ct) =>
{
try
{
string html = await http.GetStringAsync(url, ct);
results.Add(Parse(html));
}
catch (Exception ex)
{
Console.Error.WriteLine($"FAIL {url}: {ex.Message}");
}
});
Способ 2: SemaphoreSlim + Task.WhenAll — гибкий контроль
var gate = new SemaphoreSlim(initialCount: 8); // максимум 8 параллельно
async Task<string?> FetchAsync(string url)
{
await gate.WaitAsync();
try
{
return await http.GetStringAsync(url);
}
catch { return null; }
finally { gate.Release(); }
}
string?[] pages = await Task.WhenAll(urls.Select(FetchAsync));
Способ 3: System.Threading.Channels — конвейер «продюсер-консьюмер»
Для долгоживущего краулера это лучший паттерн: одна очередь URL, несколько воркеров-потребителей. Хорошо стыкуется с разделом 14.
using System.Threading.Channels;
var channel = Channel.CreateBounded<string>(new BoundedChannelOptions(1000)
{
SingleReader = false,
SingleWriter = false
});
// Запускаем N воркеров
int workers = 8;
var consumers = Enumerable.Range(0, workers).Select(_ => Task.Run(async () =>
{
await foreach (string url in channel.Reader.ReadAllAsync())
{
try
{
string html = await http.GetStringAsync(url);
var newLinks = ExtractLinks(html);
foreach (var link in newLinks)
await channel.Writer.WriteAsync(link); // добавляем новые URL в очередь
}
catch { /* лог + повтор */ }
}
})).ToArray();
// Наполняем стартовыми URL
foreach (var seed in seeds)
await channel.Writer.WriteAsync(seed);
// channel.Writer.Complete(); // когда решим, что краулинг окончен
await Task.WhenAll(consumers);
Подбирайте степень параллелизма под конкретный сайт: 4–16 — типичный диапазон. Сотни одновременных запросов к одному домену — это уже DoS и почти гарантированный бан.
13. Устойчивость: таймауты, повторы, вежливость, robots.txt
Этого не было в исходном списке, но без этого «боевой» парсер не живёт.
Повторы с экспоненциальной задержкой (Polly)
Библиотека Polly даёт декларативные политики повторов, circuit breaker, таймауты. Особенно красиво работает с IHttpClientFactory:
using Polly;
using Polly.Extensions.Http;
var retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError() // 5xx, 408
.OrResult(r => (int)r.StatusCode == 429) // too many requests
.WaitAndRetryAsync(
retryCount: 4,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt)) // 2,4,8,16 c
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000)) // джиттер
);
// Регистрация через DI:
// services.AddHttpClient("scraper").AddPolicyHandler(retryPolicy);
Вежливость (rate limiting) и robots.txt
- Задержка между запросами к одному домену (например, 0.5–2 сек) снижает нагрузку на сайт и риск бана. В .NET 7+ есть
System.Threading.RateLimiting. robots.txt— файл с правилами для ботов (Disallow,Crawl-delay). С точки зрения закона он не везде обязателен, но игнорировать его — дурной тон и риск конфликта. УважайтеCrawl-delayи закрытые разделы.
// Простейшая «вежливая» задержка на домен
var lastHit = new System.Collections.Concurrent.ConcurrentDictionary<string, DateTime>();
async Task PolitelyAsync(Uri uri, TimeSpan minDelay)
{
string host = uri.Host;
if (lastHit.TryGetValue(host, out var prev))
{
var wait = minDelay - (DateTime.UtcNow - prev);
if (wait > TimeSpan.Zero) await Task.Delay(wait);
}
lastHit[host] = DateTime.UtcNow;
}
Таймауты и отмена
Помимо HttpClient.Timeout используйте CancellationToken (общий для всего краулера) — чтобы корректно гасить парсер по Ctrl+C и не вешать процесс на «мёртвых» соединениях.
14. Хранение URL и очередей (frontier)
Очередь URL для обхода называют frontier. Её задачи: хранить, что ещё предстоит скачать, и не качать одно и то же дважды.
Дедупликация (множество посещённых)
// Потокобезопасное множество уже виденных URL
var visited = new System.Collections.Concurrent.ConcurrentDictionary<string, byte>();
bool TryEnqueue(string url)
{
string norm = Normalize(url); // нормализация URL — критична!
return visited.TryAdd(norm, 0); // true, если этого URL ещё не было
}
Нормализация URL обязательна, иначе site.ru/p?a=1&b=2 и site.ru/p?b=2&a=1 будут считаться разными. Минимум: нижний регистр хоста, убрать #фрагмент, отсортировать query-параметры, убрать завершающий слэш, привести схему.
Варианты хранилища очереди
| Масштаб | Решение |
|---|---|
| Маленький, в одном процессе | ConcurrentQueue<string> или Channel<string> в памяти |
| Нужна устойчивость к перезапуску | SQLite / LiteDB: таблица urls(url, status, depth, added_at) |
| Распределённый краулер | Redis (очередь + SET посещённых) или брокер (RabbitMQ, Kafka) |
| Огромное множество посещённых, память дорога | Bloom filter (компактно, но с ложноположительными) |
Минимальный frontier на SQLite (для перезапусков)
Идея: храните URL со статусом (pending / in_progress / done / failed) и глубиной. При старте берёте pending, после загрузки помечаете done, новые ссылки добавляете с INSERT OR IGNORE (уникальный индекс по URL обеспечивает дедупликацию на уровне БД).
CREATE TABLE IF NOT EXISTS frontier (
url TEXT PRIMARY KEY, -- нормализованный URL = дедуп
status TEXT NOT NULL DEFAULT 'pending',
depth INTEGER NOT NULL DEFAULT 0,
added TEXT NOT NULL
);
Так краулер можно остановить и продолжить с того же места — очередь переживает перезапуск.
На больших объёмах добавляют приоритеты (важные страницы вперёд), ограничение глубины, лимит страниц на домен и «политику вежливости» прямо во frontier.
15. JavaScript-контент: headless-браузеры
HttpClient скачивает исходный HTML, до выполнения JavaScript. Если сайт — SPA (React/Vue/Angular) и данные подгружаются скриптами, в исходном HTML их не будет. Варианты:
- Найти API. Часто SPA дёргает JSON-эндпоинт — откройте DevTools → Network, найдите запрос с данными и дёргайте его напрямую через
HttpClient. Это быстрее и надёжнее любого браузера. - Headless-браузер, если API не вытащить: он реально рендерит страницу.
Playwright для .NET (рекомендуемый)
using Microsoft.Playwright;
using var pw = await Playwright.CreateAsync();
await using var browser = await pw.Chromium.LaunchAsync(
new() { Headless = true });
var page = await browser.NewPageAsync();
await page.GotoAsync("https://spa-site.ru/products");
await page.WaitForSelectorAsync(".product-card"); // ждём, пока появятся данные
// Можно достать через селекторы Playwright...
var names = await page.Locator(".product-card h2").AllTextContentsAsync();
// ...или забрать готовый HTML и распарсить привычной библиотекой
string renderedHtml = await page.ContentAsync();
Альтернативы: Selenium WebDriver (классика, но тяжелее) и PuppeteerSharp (порт Puppeteer). Сегодня для .NET чаще берут Playwright — он официально поддерживается Microsoft и удобнее.
Минусы headless-браузеров: в десятки раз медленнее и прожорливее по ресурсам, чем HttpClient. Используйте их только когда без рендеринга никак.
16. Сохранение результатов
Данные нужно куда-то складывать. Типичные варианты:
// JSON (System.Text.Json) — удобно для вложенных данных
await using var fs = File.Create("data.json");
await System.Text.Json.JsonSerializer.SerializeAsync(fs, items,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
- CSV — для табличных данных (библиотека CsvHelper).
- JSON / JSONL — для вложенных структур (System.Text.Json); JSONL (по объекту на строку) удобен для потоковой записи больших объёмов.
- База данных (SQLite/PostgreSQL через EF Core или Dapper) — когда нужны запросы, дедуп по контенту, инкрементальные обновления.
Совет: пишите результаты потоково, по мере парсинга, а не копите всё в памяти — иначе на больших краулах словите OutOfMemory.
17. Основные плюсы и минусы реализации на C
Плюсы
- Производительность и асинхронность.
async/await,HttpClient,Channels,Parallel.ForEachAsyncдают эффективный конкурентный I/O «из коробки». - Зрелая экосистема. HtmlAgilityPack, AngleSharp, Playwright, Polly — всё промышленного качества.
- Статическая типизация. Меньше глупых ошибок в больших краулерах, удобный рефакторинг.
- Нативный SOCKS/прокси с .NET 6+, простая работа с TLS и куками.
- Кроссплатформенность (.NET работает на Linux/Windows/macOS, легко в Docker).
Минусы
- Кодировки. Из коробки нет windows-1251 — нужно помнить про
CodePagesEncodingProvider(раздел 6). - JS-сайты. Чистый
HttpClientне исполняет JS; нужен headless-браузер — это тяжело и медленно. - Анти-бот системы. Cloudflare, captcha, fingerprinting обходятся сложно; «честный» парсер часто упирается в защиту.
- Хрупкость к вёрстке. Любой парсер ломается при изменении HTML-структуры сайта — нужен мониторинг и поддержка.
- Меньше готовых фреймворков, чем в Python. В Python есть Scrapy «всё-в-одном»; в .NET чаще собираешь конвейер из кирпичиков сам (хотя есть DotnetSpider, Abot).
18. Правовые и этические аспекты
Технически суметь — не значит иметь право. Кратко о том, что стоит держать в голове (это не юридическая консультация):
- Условия использования сайта (ToS) могут прямо запрещать автоматический сбор. Нарушение — основание для блокировки и претензий.
- Персональные данные регулируются законами (GDPR, 152-ФЗ и т.п.). Сбор и хранение ПДн без оснований — это риски.
- Авторское право. Скопированный контент часто защищён; перепубликация может нарушать права.
- Нагрузка. Агрессивный парсинг = фактический DoS. Соблюдайте
Crawl-delay, ограничивайте RPS, не кладите чужой сервер. robots.txtи публичные API — предпочтительный путь. Если у сайта есть официальный API, почти всегда лучше использовать его.
Базовое правило: парсите вежливо, идентифицируемо (где уместно — честный User-Agent), уважайте ограничения сайта и закон вашей юрисдикции.
Официальные ресурсы
Парсинг HTML/DOM - HtmlAgilityPack — GitHub · NuGet - AngleSharp — сайт · GitHub · NuGet - Fizzler — GitHub
Загрузка и сеть (.NET / Microsoft) - HttpClient — API · рекомендации по использованию · IHttpClientFactory - WebProxy — API - System.Text.Encoding.CodePages — NuGet · CodePagesEncodingProvider
Параллелизм и устойчивость - Parallel.ForEachAsync — API - System.Threading.Channels — гайд - System.Threading.RateLimiting — API - Polly — документация · GitHub · Microsoft.Extensions.Http.Polly
Headless-браузеры - Playwright для .NET — документация · GitHub · NuGet - Selenium WebDriver — документация - PuppeteerSharp — сайт
Анонимность - Tor Project — сайт · спецификация control-протокола
Хранение и сериализация - System.Text.Json — обзор - CsvHelper — документация - EF Core — документация · Dapper — GitHub - SQLite — сайт · LiteDB — сайт · Redis — сайт · RabbitMQ — сайт
Готовые краулер-фреймворки - DotnetSpider — GitHub · Abot — GitHub
Документ можно использовать как пошаговый план: каждый раздел — отдельный «кирпич» парсера, собираемый в общий конвейер frontier → fetcher → parser → storage.