Парсинг (web scraping) на C#: полное руководство — от простого к сложному

Полное руководство по веб-скрапингу на C#: HttpClient, HtmlAgilityPack, AngleSharp, Selenium и сборка готового консольного парсера.

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

Практический справочник по сбору данных с веб-страниц на .NET. Примеры рассчитаны на .NET 6/8 и идиоматичный современный C# (async/await, HttpClient, record и т.д.). Там, где фича появилась в конкретной версии, это отмечено.


Оглавление

  1. Что такое парсинг и из чего он состоит
  2. Подготовка проекта и нужные пакеты
  3. Как забираем страницу: HttpClient
  4. Заголовки запроса, User-Agent и сжатие (gzip/br)
  5. Библиотеки для парсинга содержимого - 5.1 HtmlAgilityPack (XPath) - 5.2 AngleSharp (CSS-селекторы, полноценный DOM) - 5.3 Fizzler, регулярки и когда что выбирать
  6. Решение проблем с парсингом кириллицы (кодировки)
  7. Получение статуса ответа и других заголовков
  8. Работа с cookie
  9. Работа с HTTPS/SSL
  10. Использование прокси
  11. Парсинг через TOR
  12. Многопоточность / параллелизм
  13. Устойчивость: таймауты, повторы, вежливость, robots.txt
  14. Хранение URL и очередей (frontier, дедупликация)
  15. JavaScript-контент: headless-браузеры
  16. Сохранение результатов
  17. Основные плюсы и минусы реализации
  18. Правовые и этические аспекты

1. Что такое парсинг и из чего он состоит

Парсинг (web scraping) — это автоматическое получение веб-страниц и извлечение из них структурированных данных. Любой парсер логически состоит из четырёх частей:

  1. Загрузчик (downloader/fetcher) — скачивает HTML по URL.
  2. Парсер содержимого — превращает HTML в дерево, из которого можно доставать данные по селекторам.
  3. Извлечение и нормализация данных — вытаскиваем нужные поля, чистим, приводим к типам.
  4. Планировщик (scheduler/frontier) — управляет очередью URL, дедупликацией, скоростью, повторами.

Хороший парсер ≠ «скачал и распарсил». 80% сложности — это надёжность: кодировки, таймауты, повторы, защита от бана, ограничение скорости, управление очередью. Об этом большая часть статьи.


2. Подготовка проекта и нужные пакеты

bash
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

Официальные ресурсы этих пакетов:


3. Как забираем страницу: HttpClient

В современном .NET единственно правильный инструмент — HttpClient. Старые WebClient и HttpWebRequest считаются устаревшими (legacy) и в новом коде использовать их не нужно.

Главное правило: HttpClient нужно переиспользовать

HttpClient рассчитан на долгую жизнь. Создавать новый экземпляр на каждый запрос (using var client = new HttpClient()) — классическая ошибка: она приводит к исчерпанию сокетов (порты застревают в состоянии TIME_WAIT). Делайте один общий экземпляр на приложение либо используйте IHttpClientFactory. Подробный разбор — в руководстве Microsoft по использованию HttpClient.

c#
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 удобен, но прячет статус-код, заголовки и кодировку. Для реального парсера берите полный ответ — так вы контролируете всё:

c#
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 и сжатие

Многие сайты блокируют запросы без «человеческих» заголовков. Базовый набор, который стоит выставлять:

c#
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.

c#
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 нужно вызывать, чтобы превратить &amp;, &nbsp; и пр. в нормальные символы.

5.2 AngleSharp (CSS-селекторы, настоящий DOM)

Современная библиотека, реализующая стандарты W3C. Парсит HTML так же, как браузер, и поддерживает CSS-селекторы (querySelector / querySelectorAll), как в JS. Часто удобнее, особенно если вы привыкли к фронтенду.

c#
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) и один раз при старте выполните:

c#
using System.Text;

// В самом начале программы (Main / стартап)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

var win1251 = Encoding.GetEncoding(1251);   // или GetEncoding("windows-1251")

Шаг 2. Не используйте GetStringAsync вслепую

GetStringAsync декодирует тело, опираясь на заголовок Content-Type; charset=.... Если сайт врёт в заголовке или не указывает charset, вы получите кашу. Надёжный путь — скачать байты и определить кодировку самим.

c#
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 умеют определять кодировку из байтов сами — если скормить им поток/байты, а не уже декодированную строку.

c#
// 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 по умолчанию
c#
// 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 даёт полный доступ к статусу и заголовкам — это нужно для логики повторов, обработки редиректов и банов.

c#
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, его там не будет.


Cookie нужны для сессий, авторизации, прохождения «проверок». В .NET за них отвечает CookieContainer, привязанный к handler'у.

c#
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:

c#
var handler = new SocketsHttpHandler
{
    SslOptions = new System.Net.Security.SslClientAuthenticationOptions
    {
        // ВНИМАНИЕ: принимает любой сертификат. Только для отладки/доверенных задач.
        RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
    }
};

(У классического HttpClientHandler аналог — ServerCertificateCustomValidationCallback, и есть готовая заглушка HttpClientHandler.DangerousAcceptAnyServerCertificateValidator.)

Управление версией TLS

c#
var handler = new SocketsHttpHandler
{
    SslOptions = new System.Net.Security.SslClientAuthenticationOptions
    {
        EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12
                            | System.Security.Authentication.SslProtocols.Tls13
    }
};

Не отключайте проверку сертификатов «на всякий случай» в продакшене. Это открывает дорогу подмене трафика. Используйте точечно и только там, где действительно нужно.


10. Использование прокси

Прокси нужны, чтобы (а) обходить блокировки по IP, (б) распределять нагрузку и снижать риск бана через ротацию адресов.

Базовая настройка HTTP-прокси

c#
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 — сторонние библиотеки больше не нужны:

c#
var proxy = new WebProxy("socks5://127.0.0.1:1080");
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };

Ротация прокси

Простейшая стратегия — пул прокси, каждому даём свой HttpClient (handler с прокси переиспользуется!), и берём по кругу или случайно. Прокси, давшие ошибку/таймаут, временно «штрафуем».

c#
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).

c#
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:

code
ControlPort 9051
# и аутентификацию, например хешированный пароль:
HashedControlPassword 16:...   # из `tor --hash-password "mypass"`

Запрос новой цепочки сырым TCP:

c#
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+) — самый простой

c#
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 — гибкий контроль

c#
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.

c#
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:

c#
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 и закрытые разделы.
c#
// Простейшая «вежливая» задержка на домен
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. Её задачи: хранить, что ещё предстоит скачать, и не качать одно и то же дважды.

Дедупликация (множество посещённых)

c#
// Потокобезопасное множество уже виденных 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 обеспечивает дедупликацию на уровне БД).

sql
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 их не будет. Варианты:

  1. Найти API. Часто SPA дёргает JSON-эндпоинт — откройте DevTools → Network, найдите запрос с данными и дёргайте его напрямую через HttpClient. Это быстрее и надёжнее любого браузера.
  2. Headless-браузер, если API не вытащить: он реально рендерит страницу.

Playwright для .NET (рекомендуемый)

c#
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. Сохранение результатов

Данные нужно куда-то складывать. Типичные варианты:

c#
// 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.