Scraping por lenguaje 25 min de lectura

Web scraping en C#: guía completa — de lo básico a lo avanzado

Guía completa de web scraping en C#: HttpClient, HtmlAgilityPack, AngleSharp, Selenium y el montaje de un scraper de consola listo para usar.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 24 febrero 2025

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

  1. Qué es el web scraping y de qué se compone
  2. Preparación del proyecto y paquetes necesarios
  3. Cómo descargamos la página: HttpClient
  4. Cabeceras de la petición, User-Agent y compresión (gzip/br)
  5. 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
  6. Cómo resolver los problemas de codificación (tildes y eñes)
  7. Obtener el estado de la respuesta y otras cabeceras
  8. Trabajo con cookies
  9. Trabajo con HTTPS/SSL
  10. Uso de proxies
  11. Scraping a través de TOR
  12. Multihilo / paralelismo
  13. Resiliencia: timeouts, reintentos, cortesía, robots.txt
  14. Almacenamiento de URL y colas (frontier, deduplicación)
  15. Contenido JavaScript: navegadores headless
  16. Guardar los resultados
  17. Principales pros y contras de la implementación
  18. 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:

  1. Descargador (downloader/fetcher): descarga el HTML a partir de la URL.
  2. Parser del contenido: convierte el HTML en un árbol del que se pueden extraer datos mediante selectores.
  3. Extracción y normalización de datos: obtenemos los campos necesarios, los limpiamos y los convertimos a los tipos adecuados.
  4. 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

bash
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.Playwright

Recursos oficiales de estos paquetes:


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.

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

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(); // 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.ResponseHeadersRead devuelve 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:

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("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.

c#
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')]");

⚠️ SelectNodes devuelve null si no encuentra nada (no una colección vacía): compruebe siempre el null o use ?? Enumerable.Empty<...>(). Hay que llamar a HtmlEntity.DeEntitize para convertir &amp;, &nbsp;, 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.

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

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

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

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

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"

// 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) en resp.Content.Headers. Si busca Content-Type en resp.Headers, no lo encontrará.


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.

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

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

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

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

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

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: no cree un handler nuevo con proxy por cada petición: eso nos devuelve al agotamiento de sockets. Cree un HttpClient por 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).

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

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:

code
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:

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"
}

Tenga en cuenta: TOR es lento y muchos sitios recortan el tráfico procedente de los nodos de salida. Entre NEWNYM hay 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

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

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

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

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 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 el Crawl-delay y las secciones cerradas.
c#
// 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)

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

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

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:

  1. 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.
  2. Un navegador headless, si la API no se deja extraer: renderiza la página de verdad.

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

c#
// 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, Channels y Parallel.ForEachAsync ofrecen 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. HttpClient puro 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.txt y 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.