Scraping por linguagem 25 min de leitura

Web scraping em C#: guia completo — do básico ao avançado

Guia completo de web scraping em C#: HttpClient, HtmlAgilityPack, AngleSharp, Selenium e a montagem de um scraper de console pronto para usar.

EW
Equipe Web-Scraping.biz
Coleta de dados para as demandas do negócio
Publicado: 24 fevereiro 2025

Referência prática para extrair dados de páginas web em .NET. Os exemplos foram pensados para .NET 6/8 e um C# moderno e idiomático (async/await, HttpClient, record, etc.). Onde um recurso surgiu em uma versão específica, isso é indicado.


Índice

  1. O que é web scraping e do que ele se compõe
  2. Preparação do projeto e pacotes necessários
  3. Como baixamos a página: HttpClient
  4. Cabeçalhos da requisição, User-Agent e compressão (gzip/br)
  5. Bibliotecas para parsear o conteúdo - 5.1 HtmlAgilityPack (XPath) - 5.2 AngleSharp (seletores CSS, DOM completo) - 5.3 Fizzler, expressões regulares e quando escolher cada opção
  6. Como resolver os problemas de codificação (acentos e cedilha)
  7. Obter o status da resposta e outros cabeçalhos
  8. Trabalho com cookies
  9. Trabalho com HTTPS/SSL
  10. Uso de proxies
  11. Scraping através do TOR
  12. Multithread / paralelismo
  13. Resiliência: timeouts, novas tentativas, cortesia, robots.txt
  14. Armazenamento de URLs e filas (frontier, deduplicação)
  15. Conteúdo JavaScript: navegadores headless
  16. Salvar os resultados
  17. Principais prós e contras da implementação
  18. Aspectos legais e éticos

1. O que é web scraping e do que ele se compõe

Web scraping é o download automático de páginas web e a extração de dados estruturados a partir delas. Todo scraper se compõe logicamente de quatro partes:

  1. Baixador (downloader/fetcher): baixa o HTML a partir da URL.
  2. Parser do conteúdo: converte o HTML em uma árvore da qual é possível extrair dados com seletores.
  3. Extração e normalização de dados: obtemos os campos necessários, limpamos e convertemos para os tipos adequados.
  4. Planejador (scheduler/frontier): gerencia a fila de URLs, a deduplicação, a velocidade e as novas tentativas.

Um bom scraper ≠ «baixei e parseei». 80% da dificuldade está na confiabilidade: codificações, timeouts, novas tentativas, proteção contra bloqueios, limitação de velocidade, gestão da fila. É a isso que a maior parte do artigo se dedica.


2. Preparação do projeto e pacotes necessários

bash
dotnet new console -n Scraper
cd Scraper

# Parsing de HTML: um dos dois ou ambos
dotnet add package HtmlAgilityPack
dotnet add package AngleSharp

# Suporte a codificações legadas (windows-1252 e similares), indispensável no .NET Core+
dotnet add package System.Text.Encoding.CodePages

# Resiliência (novas tentativas, circuit breaker)
dotnet add package Microsoft.Extensions.Http.Polly

# Opcional: navegador headless para páginas com JS
dotnet add package Microsoft.Playwright

Recursos oficiais desses pacotes:


3. Como baixamos a página: HttpClient

No .NET moderno, a única ferramenta correta é o HttpClient. Os antigos WebClient e HttpWebRequest são considerados obsoletos (legacy) e não devem ser usados em código novo.

Regra principal: o HttpClient deve ser reutilizado

O HttpClient foi projetado para uma vida longa. Criar uma instância nova a cada requisição (using var client = new HttpClient()) é um erro clássico: provoca o esgotamento de sockets (as portas ficam presas no estado TIME_WAIT). Use uma única instância compartilhada para toda a aplicação ou recorra ao IHttpClientFactory. A análise detalhada está no guia da Microsoft sobre o uso do HttpClient.

c#
using System.Net;
using System.Net.Http;

// Um handler + um cliente para toda a aplicação (ou um singleton via DI)
var handler = new SocketsHttpHandler
{
    AutomaticDecompression = DecompressionMethods.All, // gzip, deflate, brotli
    PooledConnectionLifetime = TimeSpan.FromMinutes(2), // proteção contra DNS «vencido»
    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");

// Download mais simples
string html = await http.GetStringAsync("https://example.com");

Melhor trabalhar com HttpRequestMessage e HttpResponseMessage

GetStringAsync é cômodo, mas esconde o código de status, os cabeçalhos e a codificação. Para um scraper de verdade, pegue a resposta completa: assim controla tudo:

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(); // lança uma exceção com 4xx/5xx (opcional)

byte[] bytes = await response.Content.ReadAsByteArrayAsync();
// bytes -> nós mesmos decodificamos para string (veja a seção sobre codificações)

HttpCompletionOption.ResponseHeadersRead devolve o controle assim que os cabeçalhos chegam, sem esperar o corpo inteiro. Útil para respostas grandes e streaming.


4. Cabeçalhos da requisição, User-Agent e compressão

Muitos sites bloqueiam requisições sem cabeçalhos «humanos». Conjunto básico que convém definir:

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("pt-BR,pt;q=0.9,en;q=0.8");
http.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip, deflate, br");

Importante sobre a compressão: não declare Accept-Encoding: gzip, br manualmente se não tiver ativado AutomaticDecompression. Caso contrário, o servidor enviará o corpo comprimido e você receberá «lixo». O correto é definir AutomaticDecompression = DecompressionMethods.All no handler (como na seção 3): assim o .NET adiciona o cabeçalho e descomprime por conta própria. Brotli (br) é suportado a partir do .NET Core 3.0.

A rotação do User-Agent e uma ordem realista de cabeçalhos são um truque frequente contra sistemas anti-bots simples, mas não são panaceia diante de proteções sérias (veja as seções 13 e 18).


5. Bibliotecas para parsear o conteúdo

Depois de baixar o HTML, é preciso convertê-lo em uma árvore e extrair os dados com seletores. Os dois grandes protagonistas no .NET são o HtmlAgilityPack e o AngleSharp.

5.1 HtmlAgilityPack (XPath)

Um clássico comprovado ao longo dos anos. Trabalha com XPath e perdoa HTML «sujo» e inválido.

c#
using HtmlAgilityPack;

var doc = new HtmlDocument();
doc.LoadHtml(html);

// Título da página
string? title = doc.DocumentNode
    .SelectSingleNode("//title")?.InnerText.Trim();

// Todos os links
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}");
}

// Busca por classe via XPath
var prices = doc.DocumentNode
    .SelectNodes("//span[contains(@class,'price')]");

⚠️ SelectNodes retorna null quando não encontra nada (não uma coleção vazia): verifique sempre o null ou use ?? Enumerable.Empty<...>(). É preciso chamar HtmlEntity.DeEntitize para converter &amp;, &nbsp;, etc. em caracteres normais.

5.2 AngleSharp (seletores CSS, um DOM de verdade)

Biblioteca moderna que implementa os padrões do W3C. Parseia o HTML exatamente como um navegador e suporta seletores CSS (querySelector / querySelectorAll), como no JS. Muitas vezes é mais confortável, sobretudo para quem vem do front-end.

c#
using AngleSharp;
using AngleSharp.Dom;

var config = Configuration.Default;
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(req => req.Content(html));

// Seletores CSS, como no navegador
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}");
}

O AngleSharp sabe fazer mais: carregar uma página completa pela URL, processar formulários, trabalhar com o CSSOM. É um motor DOM completo, não um simples parser de HTML.

5.3 Fizzler, expressões regulares e quando escolher cada opção

  • Fizzler — adiciona seletores CSS sobre o HtmlAgilityPack (.QuerySelectorAll(...)), se você quer CSS sem abandonar o HAP.
  • Expressões regulares sobre HTML — um antipadrão. HTML não é uma linguagem regular; as regex quebram com aninhamento, atributos em ordem arbitrária e comentários. A regex só é apropriada para dar o acabamento em texto já extraído (por exemplo, tirar o número da string «Preço: R$ 1.299»).

O que escolher:

Situação Recomendação
Você está habituado a XPath, HTML «sujo» HtmlAgilityPack
Você está habituado a seletores CSS, quer um DOM «de navegador» AngleSharp
Precisa de seletores CSS, mas a base de código usa HAP HtmlAgilityPack + Fizzler
Dados em <script> como JSON (com frequência __NEXT_DATA__, JSON-LD) extrair o nó com um seletor e depois System.Text.Json

Dica: com muita frequência os dados já estão na página em JSON, dentro de <script type="application/ld+json"> ou no estado da SPA. Parsear esse JSON é mais confiável do que parsear a marcação.


6. Como resolver os problemas de codificação (acentos e cedilha)

É a dor de cabeça mais frequente ao scrapear sites antigos em português. O sintoma: «mojibake», caracteres ilegíveis no lugar de acentos e cedilhas (coração ou café em vez de coração e café). A causa é quase sempre uma codificação incorreta na hora de decodificar os bytes para string.

Passo 1. Registre o provedor de páginas de código

No .NET Core / .NET 5+ as codificações antigas de um byte (windows-1252 e demais páginas de código) não vêm incluídas por padrão. Sem este passo, Encoding.GetEncoding(1252) lança uma exceção. Adicione o pacote System.Text.Encoding.CodePages (veja CodePagesEncodingProvider) e execute uma única vez na inicialização:

c#
using System.Text;

// No início do programa (Main / inicialização)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

var win1252 = Encoding.GetEncoding(1252);   // ou GetEncoding("windows-1252")

Passo 2. Não use GetStringAsync às cegas

GetStringAsync decodifica o corpo com base no cabeçalho Content-Type; charset=.... Se o site mente no cabeçalho ou não indica o charset, você recebe uma embrulhada de caracteres. O caminho confiável: baixar os bytes e determinar a codificação por conta própria.

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 do cabeçalho HTTP
    string? charset = resp.Content.Headers.ContentType?.CharSet;

    // 2) se não está no cabeçalho, procuramos em <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; // fallback
    }

    return enc.GetString(bytes);
}

// Detecção aproximada do charset nos primeiros bytes (metatag)
static string? SniffCharsetFromMeta(byte[] bytes)
{
    // a meta está sempre na parte compatível com ASCII; lemos os primeiros ~2 KB como 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;
}

Este é um dos poucos usos apropriados de uma regex sobre HTML: apenas para extrair o nome da codificação da metatag, nada mais.

Passo 3. Delegue ao parser (com frequência é o mais simples)

Tanto o AngleSharp quanto o HtmlAgilityPack sabem determinar a codificação a partir dos bytes sozinhos, desde que recebam o stream ou os bytes, não uma string já decodificada.

c#
// HtmlAgilityPack: detecta a codificação a partir de <meta> por conta própria
using var resp = await http.GetAsync(url);
await using var stream = await resp.Content.ReadAsStreamAsync();

var doc = new HtmlDocument
{
    OptionReadEncoding = true // ler a codificação a partir do documento
};
doc.Load(stream); // detectEncodingFromByteOrderMarks = true por padrão
c#
// AngleSharp: entregamos o stream e ele resolve a codificação
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));

O algoritmo na prática: registramos o CodePages → entregamos os bytes/o stream ao parser → se ainda aparecerem caracteres corrompidos, conferimos o charset no cabeçalho e na metatag e decodificamos manualmente com a codificação correta.


7. Obter o status da resposta e outros cabeçalhos

HttpResponseMessage dá acesso completo ao status e aos cabeçalhos, indispensável para a lógica de novas tentativas e para o tratamento de redirecionamentos e bloqueios.

c#
using var resp = await http.GetAsync(url);

int statusCode = (int)resp.StatusCode;       // 200, 404, 503...
bool ok        = resp.IsSuccessStatusCode;   // true para 2xx
var reason     = resp.ReasonPhrase;          // "OK", "Not Found"

// Cabeçalhos da resposta (response headers)
if (resp.Headers.TryGetValues("Server", out var server))
    Console.WriteLine("Server: " + string.Join(",", server));

// Cabeçalhos do conteúdo (content headers)
string? contentType   = resp.Content.Headers.ContentType?.MediaType; // text/html
long?   contentLength = resp.Content.Headers.ContentLength;

// Útil para o scraper
var retryAfter = resp.Headers.RetryAfter;     // com 429/503, quando tentar de novo
var location   = resp.Headers.Location;       // para onde redireciona (se AllowAutoRedirect=false)

switch (statusCode)
{
    case 200: /* parseamos */ break;
    case 301 or 302: /* redirecionamento */ break;
    case 403: /* possível bloqueio: precisa de cookies/UA */ break;
    case 404: /* a página não existe: fora da fila */ break;
    case 429: /* too many requests: reduzir o ritmo, veja Retry-After */ break;
    case >= 500: /* erro do servidor: tentar de novo mais tarde */ break;
}

A distinção importa: os cabeçalhos gerais estão em resp.Headers, e os relacionados ao corpo (Content-Type, Content-Length, Content-Encoding) em resp.Content.Headers. Se procurar Content-Type em resp.Headers, não vai encontrar.


Cookies são necessários para sessões, autenticação e para passar por «verificações». No .NET, quem cuida deles é o CookieContainer, vinculado ao handler.

c#
var cookies = new CookieContainer();

var handler = new SocketsHttpHandler
{
    CookieContainer = cookies,
    UseCookies = true // ativado por padrão
};
var http = new HttpClient(handler);

// As requisições enviam e salvam automaticamente os cookies deste contêiner
await http.GetAsync("https://example.com/login");

// É possível definir um cookie manualmente (por exemplo, o token de sessão)
cookies.Add(new Uri("https://example.com"),
    new Cookie("session_id", "abc123") { Path = "/" });

// Ler os cookies atuais de um domínio
foreach (Cookie c in cookies.GetCookies(new Uri("https://example.com")))
    Console.WriteLine($"{c.Name} = {c.Value}");

Nuances: - Um contêiner = uma sessão. Para scraping em paralelo com «identidades» distintas, crie um handler e um contêiner separados para cada sessão/proxy. - Se precisar desativar os cookies (por exemplo, para que cada requisição seja «limpa»), defina UseCookies = false. - Você pode salvar e restaurar a sessão entre execuções serializando os cookies (nome, valor, domínio, caminho, validade) para JSON.


9. Trabalho com HTTPS/SSL

Por padrão, o HttpClient estabelece a conexão TLS e verifica o certificado do servidor sozinho. Normalmente não há nada para configurar. Intervir só é necessário em casos contados.

Ignorar os erros de certificado (com cuidado!)

Às vezes um site tem um certificado «quebrado» ou autoassinado e mesmo assim é preciso baixá-lo. A verificação pode ser desativada assim, mas somente de forma consciente: você perde a proteção contra MITM:

c#
var handler = new SocketsHttpHandler
{
    SslOptions = new System.Net.Security.SslClientAuthenticationOptions
    {
        // ATENÇÃO: aceita qualquer certificado. Somente para depuração ou tarefas confiáveis.
        RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
    }
};

(No clássico HttpClientHandler o análogo é ServerCertificateCustomValidationCallback, e existe o stub pronto HttpClientHandler.DangerousAcceptAnyServerCertificateValidator.)

Controle da versão do TLS

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

Não desative a verificação de certificados «por via das dúvidas» em produção: isso abre a porta para a manipulação do tráfego. Use de forma pontual e apenas onde for realmente necessário.


10. Uso de proxies

Os proxies servem para (a) contornar bloqueios por IP e (b) distribuir a carga e reduzir o risco de bloqueio com a rotação de endereços.

Configuração básica de um proxy HTTP

c#
var proxy = new WebProxy("http://proxy-host:8080")
{
    Credentials = new NetworkCredential("user", "password") // se precisar de autenticação
};

var handler = new SocketsHttpHandler
{
    Proxy = proxy,
    UseProxy = true
};
var http = new HttpClient(handler);

Proxy SOCKS (nativo no .NET 6+)

A partir do .NET 6, os esquemas socks4, socks4a e socks5 são suportados diretamente no WebProxy: não são mais necessárias bibliotecas de terceiros:

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

Rotação de proxies

A estratégia mais simples: um pool de proxies, cada um com o seu próprio HttpClient (o handler com o proxy é reutilizado!), tomados em rodízio ou ao acaso. Proxies que retornam erro ou timeout ficam «de castigo» temporariamente.

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];
    }
}

Importante: não crie um handler novo com proxy a cada requisição: isso nos devolve ao esgotamento de sockets. Crie um HttpClient por proxy e reutilize.


11. Scraping através do TOR

O TOR oferece anonimato e rotação de IP gratuita. Em essência, é um proxy SOCKS5 local.

Conexão ao TOR como SOCKS5

Depois de instalar o Tor (o daemon tor ou o Tor Browser), fica ativo na máquina um proxy SOCKS5, por padrão em 127.0.0.1:9050 (no 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");

Troca de IP (circuito novo) via Control Port

O grande trunfo: é possível solicitar um circuito novo (um IP de saída novo) com o comando SIGNAL NEWNYM na porta de controle (por padrão 9051); veja a especificação do protocolo de controle do Tor. É preciso habilitá-lo no torrc:

code
ControlPort 9051
# e autenticação, por exemplo uma senha com hash:
HashedControlPassword 16:...   # de `tor --hash-password "mypass"`

Solicitação de um circuito novo por TCP puro:

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(); // esperamos "250 OK"

    await writer.WriteLineAsync("SIGNAL NEWNYM");
    var sigResp = await reader.ReadLineAsync();   // "250 OK"
}

Leve em conta: o TOR é lento e muitos sites cortam o tráfego vindo dos nós de saída. Entre um NEWNYM e outro há um limite (MaxCircuitDirtiness, ~10 s), então não dá para trocar de IP na hora a cada requisição. Para um scraping veloz, os proxies comerciais costumam ser mais práticos; o TOR é para o anonimato.


12. Multithread / paralelismo

No scraping de rede, o gargalo é a espera pela resposta, não a CPU. Por isso não se precisa de «multithread» no sentido clássico, e sim de paralelismo assíncrono com um limite de requisições simultâneas. Disparar milhares de requisições de uma vez não é opção: você satura a rede, esgota as conexões e acaba bloqueado.

Método 1: Parallel.ForEachAsync (.NET 6+), o mais simples

c#
var urls = new List<string> { /* ... */ };
var results = new System.Collections.Concurrent.ConcurrentBag<string>();

await Parallel.ForEachAsync(
    urls,
    new ParallelOptions { MaxDegreeOfParallelism = 8 }, // no máximo 8 por vez
    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}");
        }
    });

Método 2: SemaphoreSlim + Task.WhenAll, controle flexível

c#
var gate = new SemaphoreSlim(initialCount: 8); // máximo de 8 em paralelo

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));

Método 3: System.Threading.Channels, pipeline «produtor-consumidor»

Para um rastreador de vida longa é o melhor padrão: uma fila de URLs e vários workers consumidores. Combina bem com a seção 14.

c#
using System.Threading.Channels;

var channel = Channel.CreateBounded<string>(new BoundedChannelOptions(1000)
{
    SingleReader = false,
    SingleWriter = false
});

// Iniciamos N workers
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); // adicionamos as URLs novas à fila
        }
        catch { /* log + nova tentativa */ }
    }
})).ToArray();

// Carregamos as URLs iniciais
foreach (var seed in seeds)
    await channel.Writer.WriteAsync(seed);

// channel.Writer.Complete(); // quando decidirmos que o crawling terminou
await Task.WhenAll(consumers);

Ajuste o grau de paralelismo a cada site concreto: 4--16 é a faixa típica. Centenas de requisições simultâneas a um mesmo domínio já são um DoS e um bloqueio quase garantido.


13. Resiliência: timeouts, novas tentativas, cortesia, robots.txt

Isto não constava da lista inicial, mas sem isso um scraper «de batalha» não sobrevive.

Novas tentativas com atraso exponencial (Polly)

A biblioteca Polly oferece políticas declarativas de novas tentativas, circuit breaker e timeouts. Brilha especialmente com o 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 s
            + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000)) // jitter
    );

// Registro via DI:
// services.AddHttpClient("scraper").AddPolicyHandler(retryPolicy);

Cortesia (rate limiting) e robots.txt

  • Uma pausa entre requisições a um mesmo domínio (por exemplo, 0,5--2 s) reduz a carga sobre o site e o risco de bloqueio. No .NET 7+ existe o System.Threading.RateLimiting.
  • robots.txt — arquivo com regras para bots (Disallow, Crawl-delay). Do ponto de vista legal nem sempre é vinculante, mas ignorá-lo é deselegante e fonte de conflitos. Respeite o Crawl-delay e as seções fechadas.
c#
// A pausa «cortês» mais simples por domínio
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;
}

Timeouts e cancelamento

Além do HttpClient.Timeout, use um CancellationToken (comum a todo o rastreador): assim você consegue desligar o scraper de forma limpa com Ctrl+C e não deixa o processo pendurado em conexões «mortas».


14. Armazenamento de URLs e filas (frontier)

A fila de URLs a percorrer chama-se frontier. Suas tarefas: guardar o que ainda falta baixar e não baixar a mesma coisa duas vezes.

Deduplicação (conjunto de visitadas)

c#
// Conjunto thread-safe de URLs já vistas
var visited = new System.Collections.Concurrent.ConcurrentDictionary<string, byte>();

bool TryEnqueue(string url)
{
    string norm = Normalize(url); // a normalização da URL é crítica!
    return visited.TryAdd(norm, 0); // true se esta URL ainda não estava
}

A normalização de URLs é obrigatória; caso contrário example.com/p?a=1&b=2 e example.com/p?b=2&a=1 serão consideradas distintas. O mínimo: passar o host para minúsculas, remover o #fragmento, ordenar os parâmetros da query, eliminar a barra final e unificar o esquema.

Opções de armazenamento da fila

Escala Solução
Pequena, em um único processo ConcurrentQueue<string> ou Channel<string> em memória
Precisa resistir a reinicializações SQLite / LiteDB: tabela urls(url, status, depth, added_at)
Rastreador distribuído Redis (fila + SET de visitadas) ou um broker (RabbitMQ, Kafka)
Conjunto de visitadas enorme, memória cara Filtro de Bloom (compacto, mas com falsos positivos)

Frontier mínimo sobre SQLite (para sobreviver a reinicializações)

A ideia: guarde as URLs com seu status (pending / in_progress / done / failed) e sua profundidade. Na inicialização o rastreador pega as pending, após o download as marca como done e adiciona os links novos com INSERT OR IGNORE (o índice único sobre a URL garante a deduplicação no nível do banco de dados).

sql
CREATE TABLE IF NOT EXISTS frontier (
    url     TEXT PRIMARY KEY,    -- URL normalizada = dedup
    status  TEXT NOT NULL DEFAULT 'pending',
    depth   INTEGER NOT NULL DEFAULT 0,
    added   TEXT NOT NULL
);

Assim o rastreador pode parar e continuar do mesmo ponto: a fila sobrevive à reinicialização.

Em grandes volumes entram prioridades (as páginas importantes primeiro), limite de profundidade, limite de páginas por domínio e a «política de cortesia» diretamente no frontier.


15. Conteúdo JavaScript: navegadores headless

O HttpClient baixa o HTML original, anterior à execução do JavaScript. Se o site é uma SPA (React/Vue/Angular) e os dados são carregados por scripts, eles não estarão no HTML original. Opções:

  1. Encontrar a API. Com frequência a SPA chama um endpoint JSON: abra DevTools → Network, localize a requisição com os dados e chame-a diretamente com o HttpClient. É mais rápido e confiável do que qualquer navegador.
  2. Um navegador headless, se a API não se deixa extrair: ele renderiza a página de verdade.

Playwright para .NET (recomendado)

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.example.com/products");
await page.WaitForSelectorAsync(".product-card"); // esperamos os dados aparecerem

// Dá para extrair com os seletores do Playwright...
var names = await page.Locator(".product-card h2").AllTextContentsAsync();

// ...ou levar o HTML renderizado e parseá-lo com a biblioteca habitual
string renderedHtml = await page.ContentAsync();

Alternativas: Selenium WebDriver (o clássico, porém mais pesado) e PuppeteerSharp (port do Puppeteer). Hoje no .NET a escolha usual é o Playwright: conta com o suporte oficial da Microsoft e é mais confortável.

Contras dos navegadores headless: são dezenas de vezes mais lentos e vorazes em recursos do que o HttpClient. Use-os apenas quando a renderização for imprescindível.


16. Salvar os resultados

Os dados precisam ser armazenados em algum lugar. Opções típicas:

c#
// JSON (System.Text.Json): cômodo para dados aninhados
await using var fs = File.Create("data.json");
await System.Text.Json.JsonSerializer.SerializeAsync(fs, items,
    new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
  • CSV — para dados tabulares (biblioteca CsvHelper).
  • JSON / JSONL — para estruturas aninhadas (System.Text.Json); o JSONL (um objeto por linha) é cômodo para a escrita em fluxo de grandes volumes.
  • Banco de dados (SQLite/PostgreSQL com EF Core ou Dapper) — quando são necessárias consultas, dedup por conteúdo e atualizações incrementais.

Conselho: escreva os resultados em fluxo, à medida que vai parseando, em vez de acumular tudo em memória; caso contrário, em rastreamentos grandes você termina com um OutOfMemory.


17. Principais prós e contras da implementação em C#

Prós

  • Desempenho e assincronia. async/await, HttpClient, Channels e Parallel.ForEachAsync oferecem I/O concorrente eficiente «de fábrica».
  • Ecossistema maduro. HtmlAgilityPack, AngleSharp, Playwright, Polly: tudo de qualidade industrial.
  • Tipagem estática. Menos erros bobos em rastreadores grandes e refatoração confortável.
  • SOCKS/proxies nativos desde o .NET 6+, trabalho simples com TLS e cookies.
  • Multiplataforma (o .NET roda em Linux/Windows/macOS e entra fácil no Docker).

Contras

  • Codificações. De fábrica não há windows-1252: é preciso lembrar do CodePagesEncodingProvider (seção 6).
  • Sites com JS. O HttpClient puro não executa JS; é preciso um navegador headless, e isso é pesado e lento.
  • Sistemas anti-bots. Cloudflare, captchas e fingerprinting são difíceis de contornar; um scraper «honesto» muitas vezes esbarra na proteção.
  • Fragilidade diante da marcação. Qualquer scraper quebra quando a estrutura HTML do site muda: são necessários monitoramento e manutenção.
  • Menos frameworks prontos do que em Python. Em Python existe o Scrapy, o «tudo em um»; no .NET o habitual é montar o pipeline peça por peça (embora existam DotnetSpider e Abot).

18. Aspectos legais e éticos

Poder fazer tecnicamente não significa ter o direito de fazer. Em resumo, o que convém ter em mente (isto não é aconselhamento jurídico):

  • Os termos de uso do site (ToS) podem proibir expressamente a coleta automatizada. Descumpri-los é motivo de bloqueio e de reclamações.
  • Os dados pessoais são regulados por leis como a LGPD e o RGPD. Coletá-los e armazená-los sem base jurídica gera riscos.
  • Direitos autorais. O conteúdo copiado costuma estar protegido; republicá-lo pode violar direitos.
  • Carga. Um scraping agressivo equivale a um DoS de fato. Respeite o Crawl-delay, limite as RPS e não derrube o servidor alheio.
  • robots.txt e as APIs públicas são a via preferível. Se o site oferece uma API oficial, quase sempre é melhor usá-la.

Regra básica: scrapeie com cortesia, de forma identificável (onde couber, com um User-Agent honesto) e respeitando as limitações do site e a legislação da sua jurisdição.


Recursos oficiais

Parsing de HTML/DOM - HtmlAgilityPack — GitHub · NuGet - AngleSharp — site · GitHub · NuGet - Fizzler — GitHub

Download e rede (.NET / Microsoft) - HttpClient — API · guia de uso · IHttpClientFactory - WebProxy — API - System.Text.Encoding.CodePages — NuGet · CodePagesEncodingProvider

Paralelismo e resiliência - Parallel.ForEachAsync — API - System.Threading.Channels — guia - System.Threading.RateLimiting — API - Polly — documentação · GitHub · Microsoft.Extensions.Http.Polly

Navegadores headless - Playwright para .NET — documentação · GitHub · NuGet - Selenium WebDriver — documentação - PuppeteerSharp — site

Anonimato - Tor Project — site · especificação do protocolo de controle

Armazenamento e serialização - System.Text.Json — visão geral - CsvHelper — documentação - EF Core — documentação · Dapper — GitHub - SQLite — site · LiteDB — site · Redis — site · RabbitMQ — site

Frameworks de crawling prontos - DotnetSpider — GitHub · Abot — GitHub


O documento pode ser usado como plano passo a passo: cada seção é um «tijolo» do scraper que se encaixa no pipeline comum frontier → fetcher → parser → storage.