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
- O que é web scraping e do que ele se compõe
- Preparação do projeto e pacotes necessários
- Como baixamos a página:
HttpClient - Cabeçalhos da requisição, User-Agent e compressão (gzip/br)
- 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
- Como resolver os problemas de codificação (acentos e cedilha)
- Obter o status da resposta e outros cabeçalhos
- Trabalho com cookies
- Trabalho com HTTPS/SSL
- Uso de proxies
- Scraping através do TOR
- Multithread / paralelismo
- Resiliência: timeouts, novas tentativas, cortesia, robots.txt
- Armazenamento de URLs e filas (frontier, deduplicação)
- Conteúdo JavaScript: navegadores headless
- Salvar os resultados
- Principais prós e contras da implementação
- 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:
- Baixador (downloader/fetcher): baixa o HTML a partir da URL.
- Parser do conteúdo: converte o HTML em uma árvore da qual é possível extrair dados com seletores.
- Extração e normalização de dados: obtemos os campos necessários, limpamos e convertemos para os tipos adequados.
- 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
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.PlaywrightRecursos oficiais desses pacotes:
- HtmlAgilityPack — GitHub · NuGet
- AngleSharp — site e documentação · GitHub · NuGet
- System.Text.Encoding.CodePages — NuGet · documentação
- Polly / Microsoft.Extensions.Http.Polly — documentação · GitHub · NuGet
- Microsoft.Playwright (.NET) — documentação · GitHub · NuGet
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.
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:
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.ResponseHeadersReaddevolve 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:
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.
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')]");⚠️
SelectNodesretornanullquando não encontra nada (não uma coleção vazia): verifique sempre onullou use?? Enumerable.Empty<...>(). É preciso chamarHtmlEntity.DeEntitizepara converter&, , 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.
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:
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.
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.
// 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// 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.
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) emresp.Content.Headers. Se procurarContent-Typeemresp.Headers, não vai encontrar.
8. Trabalho com cookies
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.
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:
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
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
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:
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.
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
HttpClientpor 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).
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:
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:
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
NEWNYMe 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
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
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.
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:
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 oCrawl-delaye as seções fechadas.
// 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)
// 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).
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:
- 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. - Um navegador headless, se a API não se deixa extrair: ele renderiza a página de verdade.
Playwright para .NET (recomendado)
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:
// 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,ChannelseParallel.ForEachAsyncoferecem 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
HttpClientpuro 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.txte 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.