Referencia práctica para extraer datos de páginas web en .NET. Los ejemplos están pensados para .NET 6/8 y un C# moderno e idiomático (
async/await,HttpClient,record, etc.). Donde una característica apareció en una versión concreta, se indica.
Índice
- Qué es el web scraping y de qué se compone
- Preparación del proyecto y paquetes necesarios
- Cómo descargamos la página:
HttpClient - Cabeceras de la petición, User-Agent y compresión (gzip/br)
- Bibliotecas para parsear el contenido - 5.1 HtmlAgilityPack (XPath) - 5.2 AngleSharp (selectores CSS, DOM completo) - 5.3 Fizzler, expresiones regulares y cuándo elegir cada opción
- Cómo resolver los problemas de codificación (tildes y eñes)
- Obtener el estado de la respuesta y otras cabeceras
- Trabajo con cookies
- Trabajo con HTTPS/SSL
- Uso de proxies
- Scraping a través de TOR
- Multihilo / paralelismo
- Resiliencia: timeouts, reintentos, cortesía, robots.txt
- Almacenamiento de URL y colas (frontier, deduplicación)
- Contenido JavaScript: navegadores headless
- Guardar los resultados
- Principales pros y contras de la implementación
- Aspectos legales y éticos
1. Qué es el web scraping y de qué se compone
El web scraping es la descarga automática de páginas web y la extracción de datos estructurados a partir de ellas. Todo scraper se compone lógicamente de cuatro partes:
- Descargador (downloader/fetcher): descarga el HTML a partir de la URL.
- Parser del contenido: convierte el HTML en un árbol del que se pueden extraer datos mediante selectores.
- Extracción y normalización de datos: obtenemos los campos necesarios, los limpiamos y los convertimos a los tipos adecuados.
- Planificador (scheduler/frontier): gestiona la cola de URL, la deduplicación, la velocidad y los reintentos.
Un buen scraper ≠ «descargué y parseé». El 80% de la dificultad está en la fiabilidad: codificaciones, timeouts, reintentos, protección contra bloqueos, limitación de velocidad, gestión de la cola. A eso se dedica la mayor parte del artículo.
2. Preparación del proyecto y paquetes necesarios
dotnet new console -n Scraper
cd Scraper
# Parseo de HTML: uno de los dos o ambos
dotnet add package HtmlAgilityPack
dotnet add package AngleSharp
# Soporte de codificaciones legacy (windows-1252 y similares), imprescindible en .NET Core+
dotnet add package System.Text.Encoding.CodePages
# Resiliencia (reintentos, circuit breaker)
dotnet add package Microsoft.Extensions.Http.Polly
# Opcional: navegador headless para páginas con JS
dotnet add package Microsoft.PlaywrightRecursos oficiales de estos paquetes:
- HtmlAgilityPack — GitHub · NuGet
- AngleSharp — sitio y documentación · GitHub · NuGet
- System.Text.Encoding.CodePages — NuGet · documentación
- Polly / Microsoft.Extensions.Http.Polly — documentación · GitHub · NuGet
- Microsoft.Playwright (.NET) — documentación · GitHub · NuGet
3. Cómo descargamos la página: HttpClient
En el .NET moderno, la única herramienta correcta es HttpClient. Los antiguos WebClient y HttpWebRequest se consideran obsoletos (legacy) y no deben usarse en código nuevo.
Regla principal: HttpClient debe reutilizarse
HttpClient está diseñado para una vida larga. Crear una instancia nueva por cada petición (using var client = new HttpClient()) es un error clásico: provoca el agotamiento de sockets (los puertos se quedan atascados en estado TIME_WAIT). Use una única instancia compartida para toda la aplicación o recurra a IHttpClientFactory. Encontrará el análisis detallado en la guía de Microsoft sobre el uso de HttpClient.
using System.Net;
using System.Net.Http;
// Un handler + un cliente para toda la aplicación (o un singleton por DI)
var handler = new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.All, // gzip, deflate, brotli
PooledConnectionLifetime = TimeSpan.FromMinutes(2), // protección contra DNS «caducado»
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");
// Descarga más simple
string html = await http.GetStringAsync("https://example.com");Mejor trabajar con HttpRequestMessage y HttpResponseMessage
GetStringAsync es cómodo, pero oculta el código de estado, las cabeceras y la codificación. Para un scraper real, tome la respuesta completa: así lo controla todo:
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(); // lanza una excepción con 4xx/5xx (opcional)
byte[] bytes = await response.Content.ReadAsByteArrayAsync();
// bytes -> los decodificamos a cadena nosotros mismos (vea la sección sobre codificaciones)
HttpCompletionOption.ResponseHeadersReaddevuelve el control en cuanto llegan las cabeceras, sin esperar todo el cuerpo. Útil para respuestas grandes y streaming.
4. Cabeceras de la petición, User-Agent y compresión
Muchos sitios bloquean las peticiones sin cabeceras «humanas». Conjunto básico que conviene establecer:
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("es-ES,es;q=0.9,en;q=0.8");
http.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip, deflate, br");Importante sobre la compresión: no declare Accept-Encoding: gzip, br a mano si no ha activado AutomaticDecompression. De lo contrario el servidor enviará el cuerpo comprimido y usted recibirá «basura». Lo correcto es establecer AutomaticDecompression = DecompressionMethods.All en el handler (como en la sección 3): así .NET añadirá la cabecera y descomprimirá por su cuenta. Brotli (br) se admite a partir de .NET Core 3.0.
La rotación del User-Agent y un orden realista de cabeceras son un truco frecuente contra los sistemas anti-bot simples, pero no son una panacea frente a las protecciones serias (vea las secciones 13 y 18).
5. Bibliotecas para parsear el contenido
Una vez descargado el HTML, hay que convertirlo en un árbol y extraer los datos con selectores. Los dos grandes actores en .NET son HtmlAgilityPack y AngleSharp.
5.1 HtmlAgilityPack (XPath)
Un clásico probado durante años. Funciona con XPath y perdona el HTML «sucio» e inválido.
using HtmlAgilityPack;
var doc = new HtmlDocument();
doc.LoadHtml(html);
// Título de la página
string? title = doc.DocumentNode
.SelectSingleNode("//title")?.InnerText.Trim();
// Todos los enlaces
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}");
}
// Búsqueda por clase mediante XPath
var prices = doc.DocumentNode
.SelectNodes("//span[contains(@class,'price')]");⚠️
SelectNodesdevuelvenullsi no encuentra nada (no una colección vacía): compruebe siempre elnullo use?? Enumerable.Empty<...>(). Hay que llamar aHtmlEntity.DeEntitizepara convertir&, , etc. en caracteres normales.
5.2 AngleSharp (selectores CSS, un DOM de verdad)
Biblioteca moderna que implementa los estándares del W3C. Parsea el HTML igual que un navegador y soporta selectores CSS (querySelector / querySelectorAll), como en JS. A menudo resulta más cómoda, sobre todo si viene del frontend.
using AngleSharp;
using AngleSharp.Dom;
var config = Configuration.Default;
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(req => req.Content(html));
// Selectores CSS, como en el 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}");
}AngleSharp sabe hacer más: cargar una página completa por URL, procesar formularios, trabajar con el CSSOM. Es un motor DOM completo, no un simple parser de HTML.
5.3 Fizzler, expresiones regulares y cuándo elegir cada opción
- Fizzler — añade selectores CSS sobre HtmlAgilityPack (
.QuerySelectorAll(...)), si quiere CSS sin abandonar HAP. - Expresiones regulares sobre HTML — un antipatrón. HTML no es un lenguaje regular; las regex se rompen con el anidamiento, los atributos en orden arbitrario y los comentarios. La regex solo es apropiada para rematar texto ya extraído (por ejemplo, sacar el número de la cadena «Precio: 1.299 €»).
Qué elegir:
| Situación | Recomendación |
|---|---|
| Está acostumbrado a XPath, HTML «sucio» | HtmlAgilityPack |
| Está acostumbrado a los selectores CSS, quiere un DOM «de navegador» | AngleSharp |
| Necesita selectores CSS, pero la base de código usa HAP | HtmlAgilityPack + Fizzler |
Datos en <script> como JSON (a menudo __NEXT_DATA__, JSON-LD) |
extraer el nodo con un selector y luego System.Text.Json |
Pista: con mucha frecuencia los datos ya están en la página en JSON, dentro de <script type="application/ld+json"> o en el estado de la SPA. Parsear ese JSON es más fiable que parsear la maquetación.
6. Cómo resolver los problemas de codificación (tildes y eñes)
Es el dolor de cabeza más frecuente al scrapear sitios antiguos en español. El síntoma: «mojibake», caracteres ilegibles en lugar de tildes y eñes (señal o café en vez de señal y café). La causa es casi siempre una codificación incorrecta al decodificar los bytes a cadena.
Paso 1. Registre el proveedor de páginas de códigos
En .NET Core / .NET 5+ las codificaciones antiguas de un byte (windows-1252 y demás páginas de códigos) no están incluidas por defecto. Sin este paso, Encoding.GetEncoding(1252) lanza una excepción. Añada el paquete System.Text.Encoding.CodePages (vea CodePagesEncodingProvider) y ejecute una sola vez al arrancar:
using System.Text;
// Al principio del programa (Main / arranque)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var win1252 = Encoding.GetEncoding(1252); // o GetEncoding("windows-1252")Paso 2. No use GetStringAsync a ciegas
GetStringAsync decodifica el cuerpo basándose en la cabecera Content-Type; charset=.... Si el sitio miente en la cabecera o no indica el charset, obtendrá un galimatías. El camino fiable: descargar los bytes y determinar la codificación por su cuenta.
static async Task<string> GetHtmlAsync(HttpClient http, string url)
{
using var resp = await http.GetAsync(url);
byte[] bytes = await resp.Content.ReadAsByteArrayAsync();
// 1) charset de la cabecera HTTP
string? charset = resp.Content.Headers.ContentType?.CharSet;
// 2) si no está en la cabecera, lo buscamos en <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);
}
// Detección aproximada del charset en los primeros bytes (metaetiqueta)
static string? SniffCharsetFromMeta(byte[] bytes)
{
// la meta siempre está en la parte compatible con ASCII; leemos los primeros ~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 es uno de los pocos usos apropiados de una regex sobre HTML: solo para extraer el nombre de la codificación de la metaetiqueta, nada más.
Paso 3. Delegue en el parser (a menudo lo más sencillo)
Tanto AngleSharp como HtmlAgilityPack saben determinar la codificación a partir de los bytes por sí solos, siempre que se les entregue el flujo o los bytes, no una cadena ya decodificada.
// HtmlAgilityPack: detecta la codificación desde <meta> por sí solo
using var resp = await http.GetAsync(url);
await using var stream = await resp.Content.ReadAsStreamAsync();
var doc = new HtmlDocument
{
OptionReadEncoding = true // leer la codificación desde el documento
};
doc.Load(stream); // detectEncodingFromByteOrderMarks = true por defecto// AngleSharp: le pasamos el flujo y él resuelve la codificación
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));El algoritmo en la práctica: registramos CodePages → entregamos los bytes/el flujo al parser → si siguen apareciendo caracteres corruptos, revisamos el charset en la cabecera y en la metaetiqueta y decodificamos a mano con la codificación correcta.
7. Obtener el estado de la respuesta y otras cabeceras
HttpResponseMessage da acceso completo al estado y a las cabeceras, imprescindible para la lógica de reintentos y el tratamiento de redirecciones y bloqueos.
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"
// Cabeceras de la respuesta (response headers)
if (resp.Headers.TryGetValues("Server", out var server))
Console.WriteLine("Server: " + string.Join(",", server));
// Cabeceras del contenido (content headers)
string? contentType = resp.Content.Headers.ContentType?.MediaType; // text/html
long? contentLength = resp.Content.Headers.ContentLength;
// Útil para el scraper
var retryAfter = resp.Headers.RetryAfter; // con 429/503, cuándo reintentar
var location = resp.Headers.Location; // adónde redirige (si AllowAutoRedirect=false)
switch (statusCode)
{
case 200: /* parseamos */ break;
case 301 or 302: /* redirección */ break;
case 403: /* posible bloqueo: hacen falta cookies/UA */ break;
case 404: /* la página no existe: fuera de la cola */ break;
case 429: /* too many requests: frenar, vea Retry-After */ break;
case >= 500: /* error del servidor: reintentar más tarde */ break;
}La distinción importa: las cabeceras generales están en
resp.Headers, y las relacionadas con el cuerpo (Content-Type,Content-Length,Content-Encoding) enresp.Content.Headers. Si buscaContent-Typeenresp.Headers, no lo encontrará.
8. Trabajo con cookies
Las cookies hacen falta para las sesiones, la autenticación y la superación de «comprobaciones». En .NET se encarga de ellas CookieContainer, vinculado al handler.
var cookies = new CookieContainer();
var handler = new SocketsHttpHandler
{
CookieContainer = cookies,
UseCookies = true // activado por defecto
};
var http = new HttpClient(handler);
// Las peticiones envían y guardan automáticamente las cookies de este contenedor
await http.GetAsync("https://example.com/login");
// Se puede establecer una cookie a mano (por ejemplo, el token de sesión)
cookies.Add(new Uri("https://example.com"),
new Cookie("session_id", "abc123") { Path = "/" });
// Leer las cookies actuales de un dominio
foreach (Cookie c in cookies.GetCookies(new Uri("https://example.com")))
Console.WriteLine($"{c.Name} = {c.Value}");Matices: - Un contenedor = una sesión. Para scraping en paralelo con distintas «identidades», cree un handler y un contenedor separados por cada sesión/proxy. - Si necesita desactivar las cookies (por ejemplo, para que cada petición sea «limpia»), establezca UseCookies = false. - Puede guardar y restaurar la sesión entre ejecuciones serializando las cookies (nombre, valor, dominio, ruta, caducidad) a JSON.
9. Trabajo con HTTPS/SSL
Por defecto, HttpClient establece la conexión TLS y verifica el certificado del servidor por sí solo. Normalmente no hay que configurar nada. Intervenir solo hace falta en casos contados.
Ignorar los errores de certificado (¡con cuidado!)
A veces un sitio tiene un certificado «roto» o autofirmado y aun así hay que descargarlo. La verificación puede desactivarse así, pero solo de forma consciente: pierde la protección frente a MITM:
var handler = new SocketsHttpHandler
{
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
{
// ATENCIÓN: acepta cualquier certificado. Solo para depuración o tareas de confianza.
RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
}
};(En el clásico HttpClientHandler el análogo es ServerCertificateCustomValidationCallback, y existe el stub listo HttpClientHandler.DangerousAcceptAnyServerCertificateValidator.)
Control de la versión de TLS
var handler = new SocketsHttpHandler
{
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
{
EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12
| System.Security.Authentication.SslProtocols.Tls13
}
};No desactive la verificación de certificados «por si acaso» en producción: abre la puerta a la manipulación del tráfico. Úsela de forma puntual y solo donde de verdad haga falta.
10. Uso de proxies
Los proxies sirven para (a) sortear los bloqueos por IP y (b) repartir la carga y reducir el riesgo de bloqueo mediante la rotación de direcciones.
Configuración básica de un proxy HTTP
var proxy = new WebProxy("http://proxy-host:8080")
{
Credentials = new NetworkCredential("user", "password") // si hace falta autenticación
};
var handler = new SocketsHttpHandler
{
Proxy = proxy,
UseProxy = true
};
var http = new HttpClient(handler);Proxy SOCKS (nativo en .NET 6+)
A partir de .NET 6, los esquemas socks4, socks4a y socks5 se soportan directamente en WebProxy: ya no hacen falta bibliotecas de terceros:
var proxy = new WebProxy("socks5://127.0.0.1:1080");
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };Rotación de proxies
La estrategia más simple: un pool de proxies, a cada uno le damos su propio HttpClient (¡el handler con el proxy se reutiliza!) y los tomamos por turnos o al azar. A los proxies que devuelven error o timeout se les «penaliza» temporalmente.
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: no cree un handler nuevo con proxy por cada petición: eso nos devuelve al agotamiento de sockets. Cree un
HttpClientpor proxy y reutilícelo.
11. Scraping a través de TOR
TOR ofrece anonimato y rotación de IP gratuita. En esencia es un proxy SOCKS5 local.
Conexión a TOR como SOCKS5
Tras instalar Tor (el demonio tor o Tor Browser), en la máquina queda levantado un proxy SOCKS5, por defecto en 127.0.0.1:9050 (en 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");Cambio de IP (circuito nuevo) mediante el Control Port
La gran baza: se puede solicitar un circuito nuevo (una IP de salida nueva) con el comando SIGNAL NEWNYM en el puerto de control (por defecto 9051); vea la especificación del protocolo de control de Tor. Hay que habilitarlo en torrc:
ControlPort 9051
# y autenticación, por ejemplo una contraseña con hash:
HashedControlPassword 16:... # de `tor --hash-password "mypass"`Petición de un circuito nuevo por TCP en crudo:
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"
}Tenga en cuenta: TOR es lento y muchos sitios recortan el tráfico procedente de los nodos de salida. Entre
NEWNYMhay un límite (MaxCircuitDirtiness, ~10 s), así que no podrá cambiar de IP al instante en cada petición. Para un scraping veloz, los proxies comerciales suelen ser más prácticos; TOR es para el anonimato.
12. Multihilo / paralelismo
En el scraping de red, el cuello de botella es la espera de la respuesta, no la CPU. Por eso no se necesita «multihilo» en el sentido clásico, sino paralelismo asíncrono con un límite de peticiones simultáneas. Lanzar miles de peticiones de golpe no es una opción: saturará la red, agotará las conexiones y acabará bloqueado.
Método 1: Parallel.ForEachAsync (.NET 6+), el más sencillo
var urls = new List<string> { /* ... */ };
var results = new System.Collections.Concurrent.ConcurrentBag<string>();
await Parallel.ForEachAsync(
urls,
new ParallelOptions { MaxDegreeOfParallelism = 8 }, // no más de 8 a la 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, control flexible
var gate = new SemaphoreSlim(initialCount: 8); // máximo 8 en 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, tubería «productor-consumidor»
Para un rastreador de larga vida es el mejor patrón: una cola de URL y varios workers consumidores. Encaja bien con la sección 14.
using System.Threading.Channels;
var channel = Channel.CreateBounded<string>(new BoundedChannelOptions(1000)
{
SingleReader = false,
SingleWriter = false
});
// Lanzamos 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); // añadimos las URL nuevas a la cola
}
catch { /* log + reintento */ }
}
})).ToArray();
// Cargamos las URL iniciales
foreach (var seed in seeds)
await channel.Writer.WriteAsync(seed);
// channel.Writer.Complete(); // cuando decidamos que el crawling ha terminado
await Task.WhenAll(consumers);Ajuste el grado de paralelismo a cada sitio concreto: 4--16 es el rango típico. Cientos de peticiones simultáneas a un mismo dominio ya son un DoS y un bloqueo casi garantizado.
13. Resiliencia: timeouts, reintentos, cortesía, robots.txt
Esto no figuraba en la lista inicial, pero sin ello un scraper «de combate» no sobrevive.
Reintentos con retardo exponencial (Polly)
La biblioteca Polly ofrece políticas declarativas de reintentos, circuit breaker y timeouts. Luce especialmente bien con 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 mediante DI:
// services.AddHttpClient("scraper").AddPolicyHandler(retryPolicy);Cortesía (rate limiting) y robots.txt
- Una pausa entre peticiones a un mismo dominio (por ejemplo, 0,5--2 s) reduce la carga sobre el sitio y el riesgo de bloqueo. En .NET 7+ existe
System.Threading.RateLimiting. robots.txt— archivo con reglas para bots (Disallow,Crawl-delay). Desde el punto de vista legal no siempre es vinculante, pero ignorarlo es de mal gusto y una fuente de conflictos. Respete elCrawl-delayy las secciones cerradas.
// La pausa «cortés» más simple por dominio
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 y cancelación
Además de HttpClient.Timeout, use CancellationToken (común para todo el rastreador): así podrá apagar el scraper limpiamente con Ctrl+C y no dejar el proceso colgado en conexiones «muertas».
14. Almacenamiento de URL y colas (frontier)
La cola de URL pendientes de recorrer se llama frontier. Sus tareas: guardar lo que aún queda por descargar y no descargar lo mismo dos veces.
Deduplicación (conjunto de visitadas)
// Conjunto thread-safe de URL ya vistas
var visited = new System.Collections.Concurrent.ConcurrentDictionary<string, byte>();
bool TryEnqueue(string url)
{
string norm = Normalize(url); // ¡la normalización de la URL es crítica!
return visited.TryAdd(norm, 0); // true si esta URL aún no estaba
}La normalización de URL es obligatoria; de lo contrario example.com/p?a=1&b=2 y example.com/p?b=2&a=1 se considerarán distintas. Mínimo: pasar el host a minúsculas, quitar el #fragmento, ordenar los parámetros de la query, eliminar la barra final y unificar el esquema.
Opciones de almacenamiento de la cola
| Escala | Solución |
|---|---|
| Pequeña, en un solo proceso | ConcurrentQueue<string> o Channel<string> en memoria |
| Se necesita resistencia a los reinicios | SQLite / LiteDB: tabla urls(url, status, depth, added_at) |
| Rastreador distribuido | Redis (cola + SET de visitadas) o un broker (RabbitMQ, Kafka) |
| Conjunto de visitadas enorme, memoria cara | Filtro de Bloom (compacto, pero con falsos positivos) |
Frontier mínimo sobre SQLite (para sobrevivir a reinicios)
La idea: guarde las URL con su estado (pending / in_progress / done / failed) y su profundidad. Al arrancar toma las pending, tras la descarga las marca como done y añade los enlaces nuevos con INSERT OR IGNORE (el índice único sobre la URL garantiza la deduplicación a nivel de base de datos).
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
);Así el rastreador puede detenerse y continuar desde el mismo punto: la cola sobrevive al reinicio.
Con grandes volúmenes se añaden prioridades (las páginas importantes primero), límite de profundidad, límite de páginas por dominio y la «política de cortesía» directamente en el frontier.
15. Contenido JavaScript: navegadores headless
HttpClient descarga el HTML original, previo a la ejecución de JavaScript. Si el sitio es una SPA (React/Vue/Angular) y los datos se cargan mediante scripts, no estarán en el HTML original. Opciones:
- Encontrar la API. A menudo la SPA llama a un endpoint JSON: abra DevTools → Network, localice la petición con los datos y llámela directamente con
HttpClient. Es más rápido y fiable que cualquier navegador. - Un navegador headless, si la API no se deja extraer: renderiza la página de verdad.
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 a que aparezcan los datos
// Se pueden extraer con los selectores de Playwright...
var names = await page.Locator(".product-card h2").AllTextContentsAsync();
// ...o llevarse el HTML renderizado y parsearlo con la biblioteca habitual
string renderedHtml = await page.ContentAsync();Alternativas: Selenium WebDriver (el clásico, pero más pesado) y PuppeteerSharp (port de Puppeteer). Hoy en .NET se suele optar por Playwright: cuenta con el soporte oficial de Microsoft y es más cómodo.
Contras de los navegadores headless: son decenas de veces más lentos y voraces en recursos que HttpClient. Úselos solo cuando el renderizado sea imprescindible.
16. Guardar los resultados
Los datos hay que almacenarlos en algún sitio. Opciones típicas:
// JSON (System.Text.Json): cómodo para datos anidados
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 datos tabulares (biblioteca CsvHelper).
- JSON / JSONL — para estructuras anidadas (System.Text.Json); JSONL (un objeto por línea) es cómodo para la escritura en flujo de grandes volúmenes.
- Base de datos (SQLite/PostgreSQL con EF Core o Dapper) — cuando se necesitan consultas, dedup por contenido y actualizaciones incrementales.
Consejo: escriba los resultados en flujo, a medida que va parseando, en lugar de acumularlo todo en memoria; de lo contrario, en rastreos grandes acabará con un OutOfMemory.
17. Principales pros y contras de la implementación en C#
Pros
- Rendimiento y asincronía.
async/await,HttpClient,ChannelsyParallel.ForEachAsyncofrecen I/O concurrente eficiente «de serie». - Ecosistema maduro. HtmlAgilityPack, AngleSharp, Playwright, Polly: todo de calidad industrial.
- Tipado estático. Menos errores tontos en rastreadores grandes y refactorización cómoda.
- SOCKS/proxies nativos desde .NET 6+, trabajo sencillo con TLS y cookies.
- Multiplataforma (.NET funciona en Linux/Windows/macOS y entra fácilmente en Docker).
Contras
- Codificaciones. De serie no hay windows-1252: hay que acordarse de
CodePagesEncodingProvider(sección 6). - Sitios con JS.
HttpClientpuro no ejecuta JS; hace falta un navegador headless, y eso es pesado y lento. - Sistemas anti-bot. Cloudflare, captchas y fingerprinting son difíciles de sortear; un scraper «honesto» a menudo choca contra la protección.
- Fragilidad frente a la maquetación. Cualquier scraper se rompe cuando cambia la estructura HTML del sitio: hacen falta monitorización y mantenimiento.
- Menos frameworks listos que en Python. En Python está Scrapy, el «todo en uno»; en .NET lo habitual es montar la tubería pieza a pieza (aunque existen DotnetSpider y Abot).
18. Aspectos legales y éticos
Poder hacerlo técnicamente no significa tener derecho a hacerlo. En breve, lo que conviene tener presente (esto no es asesoramiento jurídico):
- Las condiciones de uso del sitio (ToS) pueden prohibir expresamente la recopilación automatizada. Incumplirlas es motivo de bloqueo y de reclamaciones.
- Los datos personales están regulados por leyes como el RGPD. Recopilarlos y almacenarlos sin base jurídica supone riesgos.
- Derechos de autor. El contenido copiado suele estar protegido; volver a publicarlo puede vulnerar derechos.
- Carga. Un scraping agresivo equivale a un DoS de facto. Respete el
Crawl-delay, limite las RPS y no tumbe el servidor ajeno. robots.txty las API públicas son la vía preferible. Si el sitio ofrece una API oficial, casi siempre es mejor usarla.
Regla básica: scrapee con cortesía, de forma identificable (donde proceda, con un User-Agent honesto) y respetando las limitaciones del sitio y la legislación de su jurisdicción.
Recursos oficiales
Parseo de HTML/DOM - HtmlAgilityPack — GitHub · NuGet - AngleSharp — sitio · GitHub · NuGet - Fizzler — GitHub
Descarga y red (.NET / Microsoft) - HttpClient — API · guía de uso · IHttpClientFactory - WebProxy — API - System.Text.Encoding.CodePages — NuGet · CodePagesEncodingProvider
Paralelismo y resiliencia - Parallel.ForEachAsync — API - System.Threading.Channels — guía - System.Threading.RateLimiting — API - Polly — documentación · GitHub · Microsoft.Extensions.Http.Polly
Navegadores headless - Playwright para .NET — documentación · GitHub · NuGet - Selenium WebDriver — documentación - PuppeteerSharp — sitio
Anonimato - Tor Project — sitio · especificación del protocolo de control
Almacenamiento y serialización - System.Text.Json — visión general - CsvHelper — documentación - EF Core — documentación · Dapper — GitHub - SQLite — sitio · LiteDB — sitio · Redis — sitio · RabbitMQ — sitio
Frameworks de crawling listos - DotnetSpider — GitHub · Abot — GitHub
El documento puede usarse como plan paso a paso: cada sección es un «ladrillo» del scraper que se ensambla en la tubería común frontier → fetcher → parser → storage.