Saltar al contenido
Empezar gratis
Volver al blog

Extracción de datos web en C# con HttpClient, AngleSharp y Playwright

Sorin-Gabriel MaricaÚltima actualización el 30 min read
Extracción de datos web en C# con HttpClient, AngleSharp y Playwright
En resumen: Para hacer web scraping en C#, empieza en .NET 10 con el HttpClient integrado para descargar páginas y AngleSharp para analizar HTML con selectores CSS. Elige HtmlAgilityPack si prefieres XPath o mantienes código antiguo, y añade Playwright para .NET cuando JavaScript construye la página. Esta guía crea un scraper con reintentos respetuosos, rastreo acotado, exportación a CSV y JSON y pruebas sin conexión.

El web scraping en C# parte de un problema habitual: los datos que necesitas se encuentran en una página web y quieres obtenerlos como objetos tipados de C# sin salir de .NET. En .NET 10, la plataforma ya incluye la mayor parte de lo que necesitas, y las pocas piezas que faltan son paquetes NuGet consolidados.

Esta guía te enseña a crear un pequeño rastreador paso a paso. Analizarás una página con AngleSharp (o HtmlAgilityPack, si prefieres XPath), obtendrás los datos de forma correcta con un HttpClient que reintenta correctamente las respuestas 429, rastrearás listados paginados con concurrencia limitada y exportarás a CSV y JSON. A continuación, extraerás datos generados por JavaScript, renderizarás páginas con Playwright para .NET, pasarás las páginas bloqueadas a una API de scraper sin tocar tu analizador sintáctico y lo probarás todo sin conexión. Los ejemplos utilizan dos sitios web públicos creados para la extracción de datos: Books to Scrape y la /js/ versión de Quotes to Scrape.

Inicio rápido: instala el SDK de .NET 10 y crea una aplicación de consola con dotnet new console. - Añade un analizador: dotnet add package AngleSharp. - Recupera con HttpClient.GetStringAsync(), selecciona con QuerySelectorAll(), y extrae con TextContent y GetAttribute(). - Utiliza Playwright para .NET cuando JavaScript genere la página.

¿Qué necesitas para el web scraping en C#?

Necesitas el SDK de .NET 10, HttpClient (integrado) para descargar páginas y un analizador de HTML como AngleSharp o HtmlAgilityPack para extraer los datos. Añade Microsoft.Extensions.Http.Resilience para los reintentos, CsvHelper para los archivos CSV y Playwright para .NET cuando la página se genere con JavaScript. Todo lo demás viene incluido con .NET.

Un rastreador web en C# descarga una página, localiza las partes que te interesan y las guarda. HttpClient y System.Text.Json vienen incluidos con .NET, y los paquetes que se indican a continuación completan el conjunto.

Empieza con .NET 10. Es la versión actual con soporte a largo plazo, cuyo soporte se prolongará hasta finales de 2028. Tanto .NET 8 como .NET 9 llegarán al final de su soporte el 10 de noviembre de 2026, según la política de soporte de .NET, por lo que los tutoriales relacionados con ellas están a punto de quedar obsoletos.

Bibliotecas de web scraping en C# (versiones de NuGet a fecha de octubre de 2026)

Biblioteca

Función

Úsala cuando

NuGet (octubre de 2026)

HttpClient

Descargar páginas y respuestas de API

Siempre (integrado en .NET)

Se incluye con .NET 10

AngleSharp

Analizador HTML basado en estándares con selectores CSS

Copias los selectores desde DevTools

1.8.6

HtmlAgilityPack

Analizador HTML tolerante con XPath y LINQ

Prefieres XPath o mantienes código antiguo

1.13.1

Microsoft.Extensions.Http.Resilience

Reintentos, tiempos de espera y Retry-After para HttpClient

Más de unas pocas solicitudes

10.11.0

CsvHelper

Escribir y leer CSV

Exportación de filas

34.0.0 (nueva API; consulta la sección sobre CSV)

Playwright para .NET

Chromium, Firefox y WebKit sin interfaz gráfica

Páginas creadas con JavaScript o interactivas

1.64.1

PuppeteerSharp

Chromium sin interfaz gráfica con una API al estilo de Puppeteer

Portabilidad de scripts de Node Puppeteer

25.15.0

Selenium.WebDriver

Automatización del navegador mediante WebDriver

Pruebas existentes de Selenium o Grid

4.52.0

ScrapySharp, HtmlAgilityPack.CssSelectors.NetCore

Ayudas antiguas

No empieces a escribir código nuevo con ellos

Últimas versiones de 2018 y 2019

Dos de los nombres de la última fila siguen apareciendo en muchos tutoriales de scraping en C#. ScrapySharp se lanzó por última vez en 2018, y HtmlAgilityPack.CssSelectors.NetCore, el complemento que proporciona compatibilidad con selectores CSS a HtmlAgilityPack, se lanzó por última vez en 2019. Ya no se necesita ninguno de los dos: AngleSharp gestiona los selectores CSS de forma nativa y se mantiene activamente, y el propio HtmlAgilityPack está actualizado. Las tres primeras filas, junto con Playwright, cubren prácticamente cualquier proyecto.

¿Cómo se configura un proyecto de scraping en C#?

Crea una aplicación de consola y añade los cuatro paquetes que utiliza la parte estática de esta guía:

dotnet new console -n BookScraper
cd BookScraper
dotnet add package AngleSharp
dotnet add package HtmlAgilityPack
dotnet add package Microsoft.Extensions.Http.Resilience
dotnet add package CsvHelper

Cualquier editor sirve. En Visual Studio, elige la plantilla «Aplicación de consola» y añade los paquetes con «Administrar paquetes NuGet». En VS Code, instala el C# Dev Kit y ejecuta los comandos en la terminal. Rider también funciona.

A continuación, guarda una copia de cada página de práctica en una fixtures carpeta, para que puedas desarrollar el analizador sin conexión. Estos mismos archivos se convertirán más adelante en tus entornos de prueba:

mkdir fixtures
curl -o fixtures/books-page1.html https://books.toscrape.com/index.html
curl -o fixtures/books-page2.html https://books.toscrape.com/catalogue/page-2.html
curl -o fixtures/quotes-js.html https://quotes.toscrape.com/js/

En Windows PowerShell, utiliza Invoke-WebRequest -OutFile fixtures/books-page1.html https://books.toscrape.com/index.html para cada página. Ejecuta la aplicación desde la carpeta del proyecto para que File.ReadAllText("fixtures/...") pueda encontrarlas.

Otra característica más de .NET 10: dotnet run app.cs ejecuta un único .cs sin necesidad de un archivo de proyecto, tal y como hace el ejemplo de Playwright.

¿Cómo se extrae el contenido de una página web en C#?

Descarga el HTML con HttpClient, cárgalo en el HtmlParser de AngleSharp, selecciona los elementos repetitivos con QuerySelectorAll() y un selector CSS, y luego lee el texto con TextContent y los enlaces con GetAttribute(). Convierte cada ficha en un registro tipado, analiza los precios con la cultura invariante y resuelve cada enlace en función de la URL de la página de la que procede.

«Books to Scrape» es una librería de prácticas con 1.000 libros repartidos en 50 páginas de catálogo. Cada libro se encuentra en una <article class="product_pod"> ficha con el enlace del título, la valoración por estrellas, el precio y el estado de existencias. Empieza por la estructura de los datos. Un sealed record te proporciona un tipo inmutable con igualdad de valores, por lo que las pruebas pueden comparar filas completas y una sola SequenceEqual llamada puede demostrar que dos analizadores coinciden.

// Book.cs: one row of scraped data. [CsvRecord] lets CsvHelper 34 generate the CSV mapping at compile time.
using CsvHelper;

[CsvRecord]
public sealed partial record Book(string Title, decimal Price, int Rating, bool InStock, string Url);

El [CsvRecord] atributo y la partial palabra clave son para CsvHelper 34 (consulta la sección de exportación). Ahora, el analizador. AngleSharp analiza el HTML tal y como lo hace un navegador, siguiendo el estándar HTML, y admite los mismos selectores CSS que escribirías en DevTools.

// BookParser.cs: turn one Books to Scrape listing page into Book records (AngleSharp)
using System.Globalization;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;

public static class BookParser
{
    static readonly HtmlParser Parser = new();

    static readonly Dictionary<string, int> RatingWords = new()
    {
        ["One"] = 1, ["Two"] = 2, ["Three"] = 3, ["Four"] = 4, ["Five"] = 5,
    };

    public static IDocument Load(string html) => Parser.ParseDocument(html);

    public static List<Book> ParseListing(IDocument page, Uri pageUrl)
    {
        var books = new List<Book>();
        foreach (var card in page.QuerySelectorAll("article.product_pod"))
        {
            var link = card.QuerySelector("h3 a");
            if (link is null) continue;                      // skip broken cards instead of crashing

            var priceText = card.QuerySelector("p.price_color")?.TextContent ?? "";
            var price = decimal.Parse(priceText.Trim().TrimStart('£'), CultureInfo.InvariantCulture);

            var ratingClass = card.QuerySelector("p.star-rating")?.ClassList
                .FirstOrDefault(c => RatingWords.ContainsKey(c));
            var stock = card.QuerySelector("p.availability")?.TextContent ?? "";

            books.Add(new Book(
                Title:   link.GetAttribute("title") ?? link.TextContent.Trim(),  // visible text is truncated
                Price:   price,
                Rating:  ratingClass is null ? 0 : RatingWords[ratingClass],
                InStock: stock.Contains("In stock", StringComparison.OrdinalIgnoreCase),
                Url:     new Uri(pageUrl, link.GetAttribute("href")).ToString()  // resolve against the page, not the site root
            ));
        }
        return books;
    }
}

Cada campo esconde una pequeña trampa:

  • El selector «card». article.product_pod coincide con todas las fichas de libros y con nada más.
  • El título. El texto visible del enlace está truncado («Una luz en el...»), así que lee el título completo en el title atributo.
  • El precio. La página muestra £51.77. Elimina el símbolo de la moneda y analízalo como un decimal con CultureInfo.InvariantCulture. Sin la cultura invariante, decimal.Parse se utilizan los ajustes regionales del equipo, y en un equipo alemán o rumano «51,77» o bien falla o bien se convierte en 5.177.
  • La valoración. Las estrellas se dibujan con CSS, y los únicos datos que hay en el HTML son nombres de clase como star-rating Three. El método ClassList expone las clases, y un pequeño diccionario asocia la palabra a un número del 1 al 5. Cualquier valor inesperado se convierte en 0 en lugar de una estimación errónea.
  • Existencias. La línea de disponibilidad se convierte en una bool.
  • La URL. Los enlaces de este sitio son relativos, y lo son con respecto a la propia carpeta de la página, no a la raíz del sitio. En la página de inicio, un href comienza por catalogue/, mientras que en la página 2 no. new Uri(pageUrl, href) resuelve cada enlace en función de la página de la que procede, que es lo que hace un navegador. En cambio, al unir los enlaces «href» a la raíz del sitio, se generan enlaces que funcionan en la página de inicio y devuelven un error 404 en la página 2 y siguientes.

La if (link is null) continue; línea evita que una tarjeta atípica, como un espacio publicitario, provoque un fallo en la ejecución. Las pruebas comprueban posteriormente el recuento de filas.

Ejecuta primero el analizador en la copia guardada, para que nada acceda a la red mientras realizas las iteraciones:

// Program.cs (parse a saved page)
var pageUrl = new Uri("https://books.toscrape.com/index.html");
var page = BookParser.Load(File.ReadAllText("fixtures/books-page1.html"));
var books = BookParser.ParseListing(page, pageUrl);
foreach (var b in books.Take(3)) Console.WriteLine(b);
Console.WriteLine($"rows: {books.Count}");
Book { Title = A Light in the Attic, Price = 51.77, Rating = 3, InStock = True, Url = https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html }
Book { Title = Tipping the Velvet, Price = 53.74, Rating = 1, InStock = True, Url = https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html }
Book { Title = Soumission, Price = 50.10, Rating = 1, InStock = True, Url = https://books.toscrape.com/catalogue/soumission_998/index.html }
rows: 20

Una vez que la salida se vea correcta, dirige el mismo analizador a la página en vivo. El PoliteHttp.Create() ayudante es el cliente configurado en la sección HttpClient que aparece a continuación. Para un primer intento, uno sencillo new HttpClient() también sirve.

// Program.cs (same parser, live page)
using var http = PoliteHttp.Create();
var pageUrl = new Uri("https://books.toscrape.com/index.html");
var books = BookParser.ParseListing(BookParser.Load(await http.GetStringAsync(pageUrl)), pageUrl);
Console.WriteLine($"live rows: {books.Count}; first: {books[0].Title} ({books[0].Price})");
live rows: 20; first: A Light in the Attic (51.77)

¿Cómo se extraen datos con HtmlAgilityPack y XPath?

HtmlAgilityPack es el analizador HTML de .NET más veterano, y la mayoría de los tutoriales antiguos de extracción de datos en C# lo utilizan. Sigue manteniéndose, es muy tolerante con el marcado defectuoso y se basa en consultas XPath. Si tu equipo ya lo utiliza, o si piensas en XPath, aquí tienes la misma extracción:

// BookParserXPath.cs: the same extraction with HtmlAgilityPack and XPath
using System.Globalization;
using HtmlAgilityPack;

public static class BookParserXPath
{
    public static List<Book> ParseListing(string html, Uri pageUrl)
    {
        var doc = new HtmlDocument();
        doc.LoadHtml(html);

        var cards = doc.DocumentNode.SelectNodes("//article[contains(@class,'product_pod')]");
        if (cards is null) return [];                       // SelectNodes returns null, not an empty list

        return cards.Select(card =>
        {
            var link = card.SelectSingleNode(".//h3/a")!;
            var price = card.SelectSingleNode(".//p[@class='price_color']")!.InnerText.Trim().TrimStart('£');
            var rating = card.SelectSingleNode(".//p[contains(@class,'star-rating')]")!
                .GetClasses().Last();                         // "star-rating Three" -> "Three"
            return new Book(
                Title:   HtmlEntity.DeEntitize(link.GetAttributeValue("title", "")),
                Price:   decimal.Parse(price, CultureInfo.InvariantCulture),
                Rating:  Array.IndexOf(["One", "Two", "Three", "Four", "Five"], rating) + 1,
                InStock: card.SelectSingleNode(".//p[contains(@class,'availability')]")!.InnerText.Contains("In stock"),
                Url:     new Uri(pageUrl, link.GetAttributeValue("href", "")).ToString());
        }).ToList();
    }
}

Dado que Book es un registro, basta con una comparación para comprobar que ambos analizadores coinciden:

// Program.cs (compare with AngleSharp)
var books = BookParserXPath.ParseListing(File.ReadAllText("fixtures/books-page1.html"),
                                         new Uri("https://books.toscrape.com/index.html"));
Console.WriteLine($"rows: {books.Count}; first: {books[0]}");
var same = books.SequenceEqual(BookParser.ParseListing(
    BookParser.Load(File.ReadAllText("fixtures/books-page1.html")),
    new Uri("https://books.toscrape.com/index.html")));
Console.WriteLine($"identical to AngleSharp result: {same}");
rows: 20; first: Book { Title = A Light in the Attic, Price = 51.77, Rating = 3, InStock = True, Url = https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html }
identical to AngleSharp result: True

La versión con XPath utiliza cuatro patrones: //tag[contains(@class,'x')] para encontrar elementos en cualquier lugar por clase, .// para buscar dentro del nodo actual, [@class='x'] para una coincidencia exacta de atributos, y / para un hijo directo. Para cualquier cosa más allá de esto, ten a mano una referencia de sintaxis de XPath.

Ten cuidado con una trampa: SelectNodes() devuelve null, no una colección vacía, cuando no hay coincidencias. El código que recorre el resultado en un bucle sin comprobar si es nulo lanza un NullReferenceException la primera vez que cambia una página. HtmlEntity.DeEntitize está ahí porque HtmlAgilityPack deja entidades como &amp; en los valores de los atributos.

Si prefieres prescindir por completo de XPath, HtmlAgilityPack también funciona con LINQ. doc.DocumentNode.Descendants("article").Where(n => n.HasClass("product_pod")) encuentra las mismas tarjetas, y puedes encadenar Select() a partir de ahí.

Utiliza AngleSharp si copias selectores de las herramientas de desarrollo del navegador y quieres un análisis basado en estándares. Utiliza HtmlAgilityPack si prefieres XPath o LINQ, o si estás manteniendo código que ya los utiliza.

¿Cómo se encuentran los selectores CSS adecuados?

Abre la página en Chrome o Firefox, haz clic con el botón derecho sobre los datos que te interesen y selecciona «Inspeccionar». Busca una clase o etiqueta que se repita una vez por registro, pruébala en la consola de DevTools con `document.querySelectorAll()` y, a continuación, pega el mismo selector en `QuerySelectorAll()` de AngleSharp. Es mejor utilizar nombres de clase cortos y estables en lugar de rutas largas generadas y copiadas de DevTools.

En «Books to Scrape», al hacer clic con el botón derecho del ratón sobre el título de un libro y seleccionar «Inspeccionar», se accede a un <a> dentro de un <h3>, dentro de <article class="product_pod">. Ese elemento es el límite del registro: uno por libro, veinte por página. En la consola, document.querySelectorAll("article.product_pod").length devuelve 20.

Todos los selectores de esta guía utilizan uno de estos seis patrones:

Patrón

Ejemplo

Coincide con

Etiqueta

article

Cada <article> elemento

Clase

.product_pod

Cualquier elemento con esa clase

Etiqueta y clase

p.price_color

<p> Elementos con esa clase

Descendiente

h3 a

Un <a> en cualquier parte dentro de un <h3>

Atributo

a[href]

Enlaces que tengan un href

Combinado

li.next a

El enlace dentro del elemento «siguiente» de la lista

El selector «Copiar» de DevTools genera rutas largas como #default > div > div > ... > li:nth-child(1) > article que se rompen cuando el diseño incorpora un contenedor div. Un selector corto en una clase significativa resiste los rediseños.

XPath es la otra opción, y puede hacer cosas que CSS no puede, como seleccionar un elemento por su texto o remontarse hasta un elemento padre. Para una comparación detallada, consulta cuándo XPath supera a CSS.

Consejo: Si un selector encuentra elementos en DevTools pero devuelve cero en C#, es probable que los datos no estén en el HTML sin procesar. DevTools muestra la página después de que se ejecute JavaScript, y HttpClient solo ve lo que ha enviado el servidor. Pulsa Ctrl+U («Ver código fuente») y busca el texto. Si no está ahí, pasa a la sección de JavaScript más abajo.

¿Cómo se debe configurar HttpClient para el web scraping?

Crea un HttpClient y reutilízalo para cada solicitud, configura PooledConnectionLifetime para que se detecten los cambios de DNS, envía un User-Agent válido y añade un proceso de reintentos que se retrase ante respuestas 408, 429 y 5xx y respete el encabezado Retry-After. Microsoft.Extensions.Http.Resilience te ofrece ese comportamiento de reintento en unas quince líneas.

Hay dos errores clásicos. El primero es crear un nuevo HttpClient por cada solicitud: cada instancia abre sus propias conexiones y, con unos cuantos miles de páginas, eso puede agotar los sockets del equipo. El segundo es un cliente estático que nunca recicla las conexiones, por lo que sigue comunicándose con una dirección IP antigua después de que cambie el DNS de un sitio web.

Las directrices de HttpClient de Microsoft resuelven ambos problemas: utiliza un único cliente de larga duración y configura PooledConnectionLifetime en su controlador para que las conexiones se reciclen cada pocos minutos. Las mismas directrices muestran cómo añadir resiliencia a un cliente estático, que es el patrón que se muestra a continuación.

// PoliteHttp.cs: one shared HttpClient with an honest user agent, timeouts, and retries
using System.Net;
using Microsoft.Extensions.Http.Resilience;
using Polly;

public static class PoliteHttp
{
    public static HttpClient Create(TimeSpan? attemptTimeout = null)
    {
        var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
            .AddRetry(new HttpRetryStrategyOptions
            {
                MaxRetryAttempts = 3,                        // retries 408, 429, 5xx and network errors
                BackoffType = DelayBackoffType.Exponential,  // waits longer each time, honors Retry-After
                UseJitter = true,
            })
            .AddTimeout(attemptTimeout ?? TimeSpan.FromSeconds(30))
            .Build();

        var handler = new ResilienceHandler(pipeline)
        {
            InnerHandler = new SocketsHttpHandler
            {
                PooledConnectionLifetime = TimeSpan.FromMinutes(5),
                AutomaticDecompression = DecompressionMethods.All,
            },
        };

        var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; // the pipeline owns timeouts
        http.DefaultRequestHeaders.UserAgent.ParseAdd("csharp-scraping-tutorial/1.0 (+mailto:you@example.com)");
        return http;
    }
}

Esto es lo que hace cada parte:

  • SocketsHttpHandler es la pila de red subyacente HttpClient. PooledConnectionLifetime = 5 minutes recicla las conexiones para que los cambios de DNS surtan efecto, y AutomaticDecompression gestiona gzip, Brotli y deflate.
  • La estrategia de reintentos procede de la biblioteca de resiliencia HTTP de Microsoft, que se basa en Polly. HttpRetryStrategyOptions Ya sabe qué errores merecen un nuevo intento: 408 (tiempo de espera de la solicitud agotado), HTTP 429 (demasiadas solicitudes), errores de servidor 5xx, excepciones de red y tiempos de espera agotados. Aplica un retroceso exponencial, añade fluctuación para que los trabajadores paralelos no vuelvan a intentarlo al unísono y espera el Retry-After encabezado cuando el servidor lo envía, que es la indicación del sitio sobre cuánto tiempo hay que esperar.
  • La estrategia de tiempo de espera concede 30 segundos a cada intento de forma predeterminada. HttpClient.Timeout está configurado en infinito porque HttpClient, de lo contrario, el valor predeterminado de 100 segundos de la propia función interrumpiría los reintentos. En su lugar, el proceso gestiona los tiempos de espera. El parámetro opcional attemptTimeout permite que las llamadas más lentas, como las solicitudes de API renderizadas, utilicen un límite más largo.
  • El User-Agent identifica a tu rastreador y proporciona una forma de ponerse en contacto contigo. Los sitios web pueden contactarte en lugar de bloquearte. Sustituye el correo electrónico por el tuyo. Para conocer el resto de los encabezados de solicitud y cuándo son importantes, consulta qué encabezados son relevantes para el rastreo.

Tres reintentos son suficientes. Un mayor número rara vez convierte un rechazo en un éxito, así que registra el fallo y sigue adelante.

En ASP.NET Core o en un servicio de trabajo, omite el ayudante estático y deja que la inyección de dependencias gestione el cliente. Registra un cliente tipado con builder.Services.AddHttpClient<BookFetcher>() y encadena .AddStandardResilienceHandler() para obtener reintentos, tiempos de espera y un cortacircuitos con valores predeterminados razonables. IHttpClientFactory se encarga entonces de gestionar la duración de las conexiones por ti. Las clases de análisis sintáctico de esta guía no cambian en absoluto. Solo cambia el lugar de donde HttpClient proviene cambia.

¿Cómo se extraen datos de varias páginas en C#?

Analiza la primera página, lee el enlace a la página siguiente, resuélvelo en relación con la URL de la página actual con new Uri(pageUrl, href) y repite el proceso hasta que el enlace desaparezca o se alcance el límite de páginas. Reutiliza el mismo HttpClient, espera aproximadamente un segundo entre solicitudes y deja que el proceso de reintentos se encargue de las respuestas 429 y 503.

Books to Scrape divide su catálogo en 50 páginas, y cada página termina con un paginador cuyo elemento «siguiente» es <li class="next"><a href="page-2.html">next</a></li>. Seguirlo hasta que desaparezca significa que nunca tendrás que adivinar el número de páginas.

// Crawler.cs: follow "next" links politely, then visit detail pages with bounded concurrency
using System.Collections.Concurrent;
using AngleSharp.Dom;

public static class Crawler
{
    public static async Task<List<Book>> CrawlCatalogAsync(HttpClient http, Uri start, int maxPages = 3)
    {
        var books = new List<Book>();
        Uri? url = start;
        for (var page = 1; url is not null && page <= maxPages; page++)
        {
            var html = await http.GetStringAsync(url);
            var doc = BookParser.Load(html);
            books.AddRange(BookParser.ParseListing(doc, url));

            var next = doc.QuerySelector("li.next a")?.GetAttribute("href");
            url = next is null ? null : new Uri(url, next);   // relative to the current page
            await Task.Delay(TimeSpan.FromSeconds(1));         // about one request per second
        }
        return books;
    }

    public sealed record BookDetail(string Url, string Upc, int Available);

    public static async Task<List<BookDetail>> FetchDetailsAsync(HttpClient http, IEnumerable<string> urls)
    {
        var results = new ConcurrentBag<BookDetail>();
        var options = new ParallelOptions { MaxDegreeOfParallelism = 3 };   // a small, fixed cap per site

        await Parallel.ForEachAsync(urls, options, async (url, ct) =>
        {
            var doc = BookParser.Load(await http.GetStringAsync(url, ct));
            var table = doc.QuerySelectorAll("table.table-striped tr")
                .ToDictionary(r => r.QuerySelector("th")!.TextContent.Trim(),
                              r => r.QuerySelector("td")!.TextContent.Trim());
            var available = new string(table["Availability"].Where(char.IsDigit).ToArray());
            results.Add(new BookDetail(url, table["UPC"], int.Parse(available)));
            await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
        });

        return results.OrderBy(d => d.Url).ToList();
    }
}

CrawlCatalogAsync realiza cuatro acciones en cada bucle: recupera la página, analiza sus libros con el mismo BookParser, busca li.next a, y espera un segundo. La new Uri(url, next) línea también es importante aquí. El siguiente enlace de la página 1 está page-2.html, relativo a /catalogue/, y al resolverlo en función de la página actual se obtiene siempre la URL absoluta correcta. Cada página se pasa a ParseListing con su propia URL, por lo que todos los enlaces a libros de todas las páginas también se resuelven correctamente.

El maxPages parámetro es una medida de seguridad. Mantén un valor bajo mientras desarrollas, para que un error en el selector o un fallo en el bucle cueste tres solicitudes en lugar de tres mil. Aumenta el valor una vez que la salida se vea correcta.

Empieza por catalogue/page-1.html, no en la página de inicio, para que todas las URL de las páginas tengan la misma estructura.

// Program.cs (crawl three pages)
using var http = PoliteHttp.Create();
var books = await Crawler.CrawlCatalogAsync(http, new Uri("https://books.toscrape.com/catalogue/page-1.html"), maxPages: 3);
Console.WriteLine($"pages: 3, rows: {books.Count}");
Console.WriteLine(books[20].Url);
using var check = await http.GetAsync(books[20].Url);
Console.WriteLine($"status of a parsed detail URL: {(int)check.StatusCode}");
pages: 3, rows: 60
https://books.toscrape.com/catalogue/in-her-wake_980/index.html
status of a parsed detail URL: 200

La fila 21 es el primer libro de la página 2, y su URL devuelve un código de estado 200, lo que confirma que la resolución relativa a la página funciona más allá de la primera página. Ahí es donde suele fallar la unión relativa a la raíz.

Este bucle de «enlace siguiente» es un pequeño rastreador, y no puede visitar la misma página dos veces porque cada página solo apunta hacia adelante. Una vez que sigas enlaces que pueden dar la vuelta, como páginas de categorías, filtros o «artículos relacionados», mantén una HashSet<string> de las URL visitadas y omite todo lo que ya figure en ella. Si la diferencia entre ambas tareas no está clara, aquí tienes en qué se diferencian el scraping y el rastreo.

El retraso de un segundo es autoimpuesto. En sitios web reales, empieza despacio, presta atención a los códigos 429 y considera el archivo robots.txt y los términos como el límite máximo.

¿Cómo se extraen páginas simultáneamente en C#?

Las páginas de detalles contienen información adicional, como el código UPC y el recuento exacto de existencias. Recuperarlas una por una es lento, y procesarlas todas a la vez es poco elegante. Parallel.ForEachAsync se sitúa en un término medio: ejecuta un cuerpo asíncrono para cada artículo, con un máximo de MaxDegreeOfParallelism ejecutándose al mismo tiempo.

FetchDetailsAsync En el rastreador anterior, el límite es de tres. Elige un número pequeño y fijo por sitio web en lugar de Environment.ProcessorCount, el valor al que recurren muchos ejemplos. El número de núcleos de tu CPU no dice nada sobre lo que un sitio web puede soportar y, de todos modos, el rastreo consiste casi exclusivamente en esperar a la red.

Los resultados se guardan en un ConcurrentBag, al que se puede añadir de forma segura desde varias tareas a la vez, y se ordenan al final porque el trabajo en paralelo finaliza sin un orden concreto.

// Program.cs (detail pages, three at a time)
using var http = PoliteHttp.Create();
var page1 = new Uri("https://books.toscrape.com/catalogue/page-1.html");
var books = BookParser.ParseListing(BookParser.Load(await http.GetStringAsync(page1)), page1);
var details = await Crawler.FetchDetailsAsync(http, books.Take(6).Select(b => b.Url));
foreach (var d in details) Console.WriteLine($"{d.Upc}  {d.Available,3} available  {d.Url}");
a897fe39b1053632   22 available  https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
4165285e1663650f   20 available  https://books.toscrape.com/catalogue/sapiens-a-brief-history-of-humankind_996/index.html
e00eb4fd7b871a48   20 available  https://books.toscrape.com/catalogue/sharp-objects_997/index.html
6957f44c3847a760   20 available  https://books.toscrape.com/catalogue/soumission_998/index.html
f77dbf2323deb740   19 available  https://books.toscrape.com/catalogue/the-requiem-red_995/index.html
90fa61229261140a   20 available  https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html

Todas las tareas comparten el mismo HttpClient, lo cual es seguro y es lo previsto: HttpClient es seguro para los subprocesos a la hora de enviar solicitudes, y compartirlo significa que el proceso de reintentos y el grupo de conexiones se aplican a todos los trabajadores a la vez.

¿Cómo se guardan los datos extraídos en CSV o JSON en C#?

Para JSON, serializa tu lista de registros con System.Text.Json, que viene incluido en .NET. Para CSV, utiliza CsvHelper: marca el registro con [CsvRecord], abre un escritor con CsvWriter.ToFile() y llama a WriteRecords(). CsvHelper 34 escribe los números con la cultura invariable por defecto, por lo que los precios mantienen el punto decimal en todos los equipos.

Ambos formatos se escriben en unas pocas líneas:

// Export.cs: write results to CSV (CsvHelper 34) and JSON (System.Text.Json)
using System.Text.Json;
using CsvHelper;

public static class Export
{
    public static void ToCsv(IEnumerable<Book> books, string path)
    {
        using var csv = CsvWriter.ToFile(path);   // UTF-8, header row first, invariant culture by default
        csv.WriteRecords(books);
    }

    public static void ToJson(IEnumerable<Book> books, string path) =>
        File.WriteAllText(path, JsonSerializer.Serialize(books, new JsonSerializerOptions { WriteIndented = true }));
}
// Program.cs (save results)
var pageUrl = new Uri("https://books.toscrape.com/index.html");
var books = BookParser.ParseListing(BookParser.Load(File.ReadAllText("fixtures/books-page1.html")), pageUrl);
Export.ToCsv(books, "books.csv");
Export.ToJson(books, "books.json");
Console.WriteLine(string.Join(Environment.NewLine, File.ReadLines("books.csv").Take(3)));
Console.WriteLine($"wrote books.csv and books.json with {books.Count} rows");
Title,Price,Rating,InStock,Url
A Light in the Attic,51.77,3,True,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Tipping the Velvet,53.74,1,True,https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
wrote books.csv and books.json with 20 rows

La fila de encabezado proviene de los nombres de las propiedades del registro, y el orden de las columnas sigue el de los parámetros del registro. JSON conserva los mismos nombres y tipos, por lo que los precios siguen siendo números y InStock sigue siendo un valor booleano. Utiliza CSV para hojas de cálculo e importaciones, y JSON para programas y datos anidados.

CsvHelper 34 ha cambiado la API. CsvHelper 34.0.0, lanzado el 28 de octubre de 2026, es una reescritura. El código que hace new CsvWriter(writer, CultureInfo.InvariantCulture), que es lo que muestran la mayoría de los tutoriales de scraping en C#, ya no se compila con el paquete actual. Utiliza la API v34 que se muestra aquí o fija la versión anterior con dotnet add package CsvHelper --version 33.1.0 para el código más antiguo. La guía de migración a la v34 de CsvHelper enumera el resto de los cambios.

Esta es también la razón por la que Book se declara partial con un [CsvRecord] atributo. CsvHelper 34 genera la correspondencia entre el registro y sus columnas en tiempo de compilación, y el generador de código fuente necesita un partial tipo al que añadir ese código.

En versiones anteriores había que pasar una cultura a cada escritor, y si se olvidaba, una máquina alemana escribía 51,77 en un archivo separado por comas. La versión 34 utiliza por defecto la cultura invariante.

¿Cómo sabes si una página necesita JavaScript?

Recupera el HTML sin procesar con HttpClient y aplica tu selector sobre él. Si no encuentra nada, pero el navegador muestra los datos, significa que JavaScript genera ese contenido. Antes de iniciar un navegador, busca en el código fuente de la página y en la pestaña «Red» de DevTools: muchos sitios envían los registros como JSON, que System.Text.Json puede leer directamente.

La /js/ versión de «Quotes to Scrape» tiene el mismo aspecto que el sitio web habitual en un navegador: diez citas, cada una en un div.quote con el texto, el autor y las etiquetas. El código HTML sin procesar cuenta una historia diferente. El servidor envía un contenedor vacío más un <script> bloque que contiene cada cita como una matriz de JavaScript, y el navegador genera los div.quote elementos a partir de él una vez que se carga la página.

Así que comprueba la respuesta antes de recurrir a un navegador. Si los registros están en una etiqueta de script, HttpClient y System.Text.Json son suficientes.

// ScriptData.cs: is the data in the HTML, or built by JavaScript? Read it from the <script> tag.
using System.Text.Json;
using System.Text.RegularExpressions;

public sealed record Author(string Name);
public sealed record Quote(string Text, Author Author, string[] Tags);

public static partial class ScriptData
{
    [GeneratedRegex(@"var data = (\[.*?\]);", RegexOptions.Singleline)]
    private static partial Regex DataArray();

    public static List<Quote> ReadQuotes(string html)
    {
        var doc = BookParser.Load(html);
        var script = doc.QuerySelectorAll("script")
            .Select(s => s.TextContent)
            .First(t => t.Contains("var data"));
        var json = DataArray().Match(script).Groups[1].Value;
        return JsonSerializer.Deserialize<List<Quote>>(json,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
    }
}
// Program.cs (check, then read the script tag)
var html = File.ReadAllText("fixtures/quotes-js.html");
Console.WriteLine($"div.quote elements in raw HTML: {BookParser.Load(html).QuerySelectorAll("div.quote").Length}");
var quotes = ScriptData.ReadQuotes(html);
foreach (var q in quotes.Take(2)) Console.WriteLine($"{q.Author.Name}: {q.Text[..60]}… [{string.Join(", ", q.Tags)}]");
Console.WriteLine($"quotes parsed from the script tag: {quotes.Count}");
div.quote elements in raw HTML: 0
Albert Einstein: “The world as we have created it is a process of our thinkin… [change, deep-thoughts, thinking, world]
J.K. Rowling: “It is our choices, Harry, that show what we truly are, far … [abilities, choices]
quotes parsed from the script tag: 10

La primera línea es la comprobación: cero div.quote elementos en el HTML sin procesar, aunque el navegador muestre diez. A continuación, AngleSharp localiza el script que contiene var data, una expresión regular generada por el código extrae la matriz y System.Text.Json la convierte en Quote registros. [GeneratedRegex] compila el patrón en tiempo de compilación y PropertyNameCaseInsensitive asigna las claves en minúsculas a las propiedades del registro.

La misma idea se aplica a otros dos casos habituales. Muchas páginas de productos y artículos incrustan datos estructurados en <script type="application/ld+json">, que puedes seleccionar con AngleSharp y deserializar directamente, sin necesidad de expresiones regulares. Además, muchos sitios web con un uso intensivo de JavaScript cargan sus registros desde sus propios puntos finales JSON. Abre DevTools, ve a «Red», filtra por «Fetch/XHR» y vuelve a cargar la página. Si una solicitud devuelve los datos que ves en pantalla, llama a esa URL con HttpClient.GetFromJsonAsync<T>() y omite el HTML por completo.

Leer JSON es más rápido que renderizar y da menos problemas, ya que los formatos de datos cambian con menos frecuencia que los diseños.

¿Cómo se extraen datos de páginas renderizadas con JavaScript en C#?

Utiliza Playwright para .NET, la biblioteca de automatización de navegadores de Microsoft. Descarga Chromium, carga la página en modo sin interfaz gráfica, espera a tu selector y te permite leer los elementos o el HTML renderizado. PuppeteerSharp y Selenium WebDriver también funcionan. Para muchas páginas o sitios bloqueados, renderiza de forma remota con una API de extracción en lugar de ejecutar tú mismo los navegadores.

Cuando no haya JSON que leer, o debas hacer clic o escribir primero, utiliza un navegador real. Playwright controla Chromium, Firefox o WebKit desde C#, los ejecuta en modo sin interfaz gráfica de forma predeterminada y tiene la función de espera integrada en sus localizadores. Si la idea de un navegador sin ventana te resulta nueva, aquí te explicamos cómo funcionan los navegadores sin interfaz gráfica.

Para mantener el navegador fuera de tu proyecto principal, esta es una aplicación basada en archivos de .NET 10 que declara su propio paquete:

#:package Microsoft.Playwright@1.64.1
#:property PublishAot=false
// render.cs: a .NET 10 file-based app. First run: dotnet run render.cs -- install
using Microsoft.Playwright;

if (args is ["install"])
    return Microsoft.Playwright.Program.Main(["install", "chromium"]);

using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new() { Headless = true });
var page = await browser.NewPageAsync(new() { UserAgent = "csharp-scraping-tutorial/1.0 (+mailto:you@example.com)" });

await page.GotoAsync("https://quotes.toscrape.com/js/");
var quotes = page.Locator("div.quote");
await quotes.First.WaitForAsync();                       // wait for JavaScript to build the list

foreach (var quote in (await quotes.AllAsync()).Take(3))
{
    var text = await quote.Locator("span.text").InnerTextAsync();
    var author = await quote.Locator("small.author").InnerTextAsync();
    Console.WriteLine($"{author}: {text[..50]}…");
}
Console.WriteLine($"quotes rendered: {await quotes.CountAsync()}");

// Hand the rendered HTML to the same AngleSharp code you already wrote, if you prefer
var html = await page.ContentAsync();
Console.WriteLine($"rendered HTML length: {html.Length}");
return 0;
dotnet run render.cs -- install   # once: downloads Chromium
dotnet run render.cs
Albert Einstein: “The world as we have created it is a process of o…
J.K. Rowling: “It is our choices, Harry, that show what we truly…
Albert Einstein: “There are only two ways to live your life. One is…
quotes rendered: 10
rendered HTML length: 8940

Las dos #: líneas de la parte superior sustituyen a un archivo de proyecto. #:package se obtiene Playwright de NuGet. #:property PublishAot=false es necesario porque las aplicaciones basadas en archivos utilizan por defecto la configuración nativa de AOT, y Playwright se basa en una serialización JSON basada en reflexión que AOT elimina. La primera ejecución con install descarga una versión de Chromium compatible. En Linux, es posible que Chromium también necesite bibliotecas del sistema, así que consulta la documentación de Playwright para .NET correspondiente a tu sistema operativo.

La línea clave es await quotes.First.WaitForAsync(). Espera hasta que exista al menos un div.quote exista, lo que significa que JavaScript ha terminado de construir la lista. Un valor fijo Task.Delay(3000) es demasiado largo en páginas rápidas y demasiado corto en las lentas.

Si prefieres mantener todo el análisis en un solo lugar, page.ContentAsync() devuelve el HTML renderizado como una cadena. Pásala a BookParser.Load() y consulta el contenido con AngleSharp exactamente igual que hiciste con las páginas estáticas.

PuppeteerSharp es una adaptación a C# de Puppeteer de Node. Se centra en Chromium, y su API se corresponde casi línea por línea con la de Puppeteer, lo que lo convierte en una buena opción a la hora de adaptar scripts de Node existentes a .NET.

Selenium WebDriver es la opción más consolidada y resulta adecuada para equipos que ya ejecutan pruebas de Selenium o utilizan Selenium Grid. Para el scraping, hay que tener en cuenta que habrá que escribir código de espera más explícito que con Playwright, ya que la mayoría de los elementos necesitan un WebDriverWait antes de poder leerlos.

Los navegadores locales consumen memoria y CPU. Cuando se necesitan muchas sesiones, o pasos interactivos como clics y rellenado de formularios en sitios web que se resisten a la automatización, un navegador automatizado alojado en la nube descarga esa carga de trabajo de tu equipo.

¿Cómo se utiliza una API de scraping web desde C#?

Envía la URL de destino a la API de scraping con HttpClient y analiza el HTML que devuelve con tu código existente. Con la API de scraping de WebScrapingAPI, llama a un punto final con tu api_key y el destino codificado en URL; a continuación, añade render_js=1 para páginas JavaScript y country para la ubicación. Lee la clave desde una variable de entorno, nunca desde el código fuente.

La recuperación desde tu propio equipo es la opción predeterminada correcta. Estos síntomas indican que se ha convertido en el cuello de botella:

  • Respuestas 403 o 429 que siguen apareciendo incluso a un ritmo moderado y tras varios reintentos.
  • Páginas de verificación o de consentimiento que aparecen en lugar del contenido que ves en el navegador.
  • Contenido que varía según el país, como precios, disponibilidad o resultados de búsqueda, cuando necesitas una ubicación específica.
  • Más renderizado de JavaScript del que pueden soportar las instancias locales de Chromium.

Descarta primero los aspectos básicos. Estos cuatro hábitos resuelven muchos bloqueos, y hay otras formas de reducirlos además de estas:

  • Reducir la velocidad: menos solicitudes simultáneas y un retraso entre ellas.
  • Envía un User-Agent veraz y coherente.
  • Almacena en caché las páginas que ya has recabado en lugar de volver a descargarlas.
  • Recupera solo las páginas y los campos que necesites.

Si gestionas tu propio grupo de proxies, también puedes redirigir HttpClient a través de un proxy y dejar todo lo demás tal y como está.

Cuando eso no sea suficiente, deja que una API de scraping se encargue de la fase de obtención. La API de scraping de WebScrapingAPI acepta una URL y devuelve la página, gestionando los proxies, los reintentos y la representación opcional en JavaScript. Desde C#, basta con una solicitud GET, y toda la integración se reduce a una pequeña clase:

// WsaClient.cs: fetch pages through WebScrapingAPI's Scraper API; parsing stays the same
public sealed class WsaClient(HttpClient http)
{
    const string Endpoint = "https://api.webscrapingapi.com/v2";

    public static Uri BuildUri(string apiKey, string targetUrl, bool renderJs = false, string? country = null)
    {
        var query = new List<string>
        {
            "api_key=" + Uri.EscapeDataString(apiKey),
            "url=" + Uri.EscapeDataString(targetUrl),     // encode the target so its ? and & survive
        };
        if (renderJs) query.Add("render_js=1");
        if (country is not null) query.Add("country=" + Uri.EscapeDataString(country));
        return new Uri($"{Endpoint}?{string.Join("&", query)}");
    }

    public async Task<string> GetHtmlAsync(string targetUrl, bool renderJs = false, string? country = null,
                                           CancellationToken ct = default)
    {
        var apiKey = Environment.GetEnvironmentVariable("WSA_API_KEY");
        if (string.IsNullOrWhiteSpace(apiKey))
            throw new InvalidOperationException("Set the WSA_API_KEY environment variable first.");

        using var response = await http.GetAsync(BuildUri(apiKey, targetUrl, renderJs, country), ct);
        var body = await response.Content.ReadAsStringAsync(ct);
        if (!response.IsSuccessStatusCode)
            throw new HttpRequestException($"Scraper API returned {(int)response.StatusCode}: {body}");
        return body;
    }
}

BuildUri es pública, por lo que puedes ver exactamente qué se enviará sin enviar nada:

// Program.cs (see what you'll send, without sending it)
var uri = WsaClient.BuildUri("YOUR_KEY", "https://quotes.toscrape.com/js/?page=1&tag=life", renderJs: true, country: "us");
Console.WriteLine(uri.AbsoluteUri);
try { await new WsaClient(new HttpClient()).GetHtmlAsync("https://books.toscrape.com/"); }
catch (InvalidOperationException ex) { Console.WriteLine($"without a key: {ex.Message}"); }
https://api.webscrapingapi.com/v2?api_key=YOUR_KEY&url=https%3A%2F%2Fquotes.toscrape.com%2Fjs%2F%3Fpage%3D1%26tag%3Dlife&render_js=1&country=us
without a key: Set the WSA_API_KEY environment variable first.

Fíjate en el url= valor. El propio ? y & se convirtieron en %3F y %26. Sin Uri.EscapeDataString(), la API interpretaría tag=life como uno de sus propios parámetros y recuperaría la página equivocada.

La clave nunca aparece en el código. Configúrala una vez por entorno o máquina:

export WSA_API_KEY=your-key-here        # macOS and Linux
setx WSA_API_KEY "your-key-here"        # Windows (open a new terminal afterward)

En una aplicación de ASP.NET Core, dotnet user-secrets mantén la clave fuera de tu repositorio durante el desarrollo, y el almacén de secretos de tu proveedor de alojamiento hará lo mismo en producción. Si falta la variable, WsaClient se detiene con un mensaje claro antes de enviar una solicitud, tal y como muestra la segunda línea de salida. Si la clave es incorrecta, la API responde con un código HTTP 401 con {"error":"Invalid API key"}, y WsaClient muestra ese cuerpo en un HttpRequestException, de modo que se ve el motivo en lugar de un resultado vacío.

Dos parámetros cubren la mayoría de las necesidades. renderJs: true añade render_js=1, que representa la página en un navegador antes de devolverla. country: "us" añade country=us, que recupera la página de esa ubicación. La representación en JavaScript y la geolocalización no suponen ningún coste adicional en créditos. Las solicitudes representadas tardan más que las simples, así que concede al cliente un tiempo de espera más largo por intento con PoliteHttp.Create(TimeSpan.FromSeconds(120)).

La respuesta es HTML, por lo que va en el mismo BookParser que escribiste al principio, sin cambios:

// Program.cs (fetch through the Scraper API)
using var http = PoliteHttp.Create(attemptTimeout: TimeSpan.FromSeconds(120));
var wsa = new WsaClient(http);
var target = new Uri("https://books.toscrape.com/catalogue/page-2.html");
var books = BookParser.ParseListing(BookParser.Load(await wsa.GetHtmlAsync(target.ToString(), country: "us")), target);
Console.WriteLine($"rows via Scraper API: {books.Count}; first: {books[0].Title}");
var rendered = BookParser.Load(await wsa.GetHtmlAsync("https://quotes.toscrape.com/js/", renderJs: true));
Console.WriteLine($"div.quote after render_js: {rendered.QuerySelectorAll("div.quote").Length}");

Ejemplo de salida:

rows via Scraper API: 20; first: In Her Wake
div.quote after render_js: 10

ParseListing sigue recibiendo la URL de destino, no el punto final de la API. Los enlaces se resuelven en función de la página de la que procede el HTML, por lo que todas las URL de los libros siguen siendo correctas independientemente de cómo se haya obtenido la página. La página «Citas» renderizada contiene los mismos div.quote elementos que vio Playwright.

¿Qué enfoque de scraping en C# deberías utilizar?

Utiliza HttpClient con AngleSharp o HtmlAgilityPack para páginas estáticas. Añade el pipeline de resiliencia y un límite de concurrencia cuando rastrees muchas páginas. Lee el JSON de la etiqueta script o el punto final JSON del sitio cuando JavaScript cargue los datos. Utiliza Playwright para unas pocas páginas interactivas y una API de scraping cuando los bloqueos o el volumen hagan que la obtención local no sea fiable.

Situación

Utiliza

Por qué

HTML estático, unas pocas páginas

HttpClient + AngleSharp o HtmlAgilityPack

Mínimo número de componentes

Muchas páginas o un rastreo

HttpClient compartido con el pipeline de resiliencia, retardo, Parallel.ForEachAsync límite

, reintentos, Retry-After y límite de solicitudes

Datos en <script> JSON o un punto final JSON

HttpClient + System.Text.Json

Más rápido y estable que la generación de

página creada con JavaScript, con un volumen reducido

Playwright para .NET

Navegador real, con esperas integradas

clics, inicios de sesión o flujos de varios pasos que puedes automatizar

Playwright (o Selenium, si tu equipo ya lo utiliza)

Compatibilidad con interacciones

Códigos de error 403/429 que los reintentos no resuelven, contenido específico de cada país o renderización a gran escala

API de scraping a través de HttpClient

Proxies gestionados, reintentos y renderización; analizador sin cambios

Si no estás seguro de dónde encaja una página, revisa estas preguntas en orden y detente en la primera respuesta afirmativa:

  1. ¿Aparecen los datos en «Ver código fuente»? Recógelos con HttpClient y analízalos con AngleSharp o HtmlAgilityPack.
  2. ¿Necesitas muchas páginas? Añade el canal de resiliencia, un retraso y un pequeño límite de concurrencia.
  3. ¿Están los datos en una etiqueta script o en una solicitud JSON en la pestaña «Red»? Léelos con System.Text.Json.
  4. ¿La página requiere un navegador y solo unas pocas veces? Utiliza Playwright para .NET.
  5. ¿Te has atascado, necesitas un país específico o estás procesando grandes volúmenes? Envía la solicitud a través de la API de Scraper y mantén tu analizador.

¿Cómo se prueba un rastreador web en C#?

Guarda una copia de cada tipo de página como archivo HTML, añade un proyecto de pruebas xUnit que analice esos archivos sin conexión y comprueba el recuento de filas, los valores tipados y las URL absolutas. Cuando el sitio cambie su marcado, la prueba fallará en segundos, en lugar de que tu scraper devuelva silenciosamente resultados vacíos en producción.

Los rastreadores fallan de forma silenciosa: un sitio cambia el nombre de una clase y la tarea genera un archivo vacío que parece indicar que todo ha salido bien. Las páginas que guardaste durante la configuración ya son tus elementos de prueba.

Crea un proyecto de pruebas junto al rastreador y haz referencia a él:

dotnet new xunit -n BookScraper.Tests
cd BookScraper.Tests
dotnet add reference ../BookScraper/BookScraper.csproj

Añádelo a BookScraper.Tests.csproj para que las páginas guardadas se copien junto a los binarios de prueba:

<ItemGroup>
  <None Include="../fixtures/*.html" Link="fixtures/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

A continuación, prueba el comportamiento del que dependes, no la implementación:

// BookParserTests.cs: run the parser against a saved page, so tests never touch the network
public class BookParserTests
{
    static readonly Uri Page2 = new("https://books.toscrape.com/catalogue/page-2.html");

    static List<Book> ParseFixture() =>
        BookParser.ParseListing(BookParser.Load(File.ReadAllText("fixtures/books-page2.html")), Page2);

    [Fact]
    public void Finds_every_book_on_the_page() => Assert.Equal(20, ParseFixture().Count);

    [Fact]
    public void Resolves_links_against_the_page_not_the_site_root()
    {
        var first = ParseFixture()[0];
        Assert.Equal("https://books.toscrape.com/catalogue/in-her-wake_980/index.html", first.Url);
    }

    [Fact]
    public void Reads_price_and_rating_as_numbers()
    {
        var first = ParseFixture()[0];
        Assert.Equal(12.84m, first.Price);
        Assert.InRange(first.Rating, 1, 5);
    }
}
Passed!  - Failed:     0, Passed:     3, Skipped:     0, Total:     3, Duration: 147 ms - BookScraper.Tests.dll (net10.0)

Las tres pruebas protegen los tres elementos que tienen más probabilidades de fallar: el recuento de filas (el selector de tarjetas sigue coincidiendo), la URL de la página 2 (los enlaces siguen resolviéndose en la página) y los valores tipados (el precio y la valoración siguen analizándose correctamente). La página 2 es el caso de prueba a propósito, porque es donde aparecen los errores de las URL relativas a la raíz.

Aplica la misma idea en producción con la regla de «fallar a lo grande»: si una página de listado devuelve cero tarjetas, lanza una excepción en lugar de escribir un archivo vacío. Cuando falle, guarda una copia nueva de la página, corrige el selector y vuelve a ejecutarla. Considera también añadir una ScrapedAt columna de fecha y hora a tus registros, de modo que cada fila muestre cuándo se recopiló y puedas comparar las ejecuciones más adelante.

¿Cómo se realiza un scraping responsable con C#?

Lee los términos y condiciones del sitio y el archivo robots.txt antes de realizar el rastreo, mantén bajas las tasas de solicitud, identifica tu rastreador con un agente de usuario que incluya datos de contacto y recopila solo los campos que necesites. Evita los datos personales a menos que tengas una base legal para ello, y no rastrees contenido protegido por inicio de sesión sin permiso. Da preferencia a una API oficial cuando exista.

El archivo robots.txt es un archivo de texto sin formato situado en la raíz de un sitio web que indica a los rastreadores qué rutas deben omitir, y su formato viene definido por el estándar robots.txt (RFC 9309). Para leerlo basta con unas pocas líneas de código con el cliente que ya tienes:

// Robots.cs: read a site's robots.txt before you crawl (RFC 9309)
using System.Net;

public static class Robots
{
    public static async Task<List<string>> ReadRulesAsync(HttpClient http, Uri site)
    {
        using var response = await http.GetAsync(new Uri(site, "/robots.txt"));
        if (response.StatusCode == HttpStatusCode.NotFound)
            return ["No robots.txt: no crawl rules published"];
        response.EnsureSuccessStatusCode();

        var text = await response.Content.ReadAsStringAsync();
        return text.Split('\n')
            .Select(line => line.Trim())
            .Where(line => line.StartsWith("User-agent:", StringComparison.OrdinalIgnoreCase)
                        || line.StartsWith("Disallow:", StringComparison.OrdinalIgnoreCase)
                        || line.StartsWith("Allow:", StringComparison.OrdinalIgnoreCase)
                        || line.StartsWith("Crawl-delay:", StringComparison.OrdinalIgnoreCase))
            .ToList();
    }
}
// Program.cs (read robots.txt first)
using var http = PoliteHttp.Create();
foreach (var line in await Robots.ReadRulesAsync(http, new Uri("https://books.toscrape.com"))) Console.WriteLine(line);
No robots.txt: no crawl rules published

Books to Scrape no publica ningún archivo robots.txt y, según el estándar, la ausencia de dicho archivo significa que no se aplican reglas de rastreo. En sitios web reales, respeta las Disallow líneas correspondientes a tu agente de usuario y cualquier Crawl-delay.

Ten en cuenta que el archivo robots.txt es una convención para los rastreadores, no un permiso legal. Las condiciones de uso de un sitio web, el tipo de datos y el lugar en el que tú y el sitio operáis también son importantes, así que infórmate sobre si el scraping web es legal antes de extraer cualquier dato comercial. Recopila solo lo que necesites, almacénalo de forma segura y mantente alejado de los datos personales a menos que tengas una base legal clara. Para conocer los estándares a los que nos regimos, consulta nuestros principios de datos web responsables.

Preguntas frecuentes

¿Cuál es la mejor biblioteca de C# para el web scraping?

No hay una única biblioteca válida para todas las tareas. Utiliza HttpClient para descargar páginas, AngleSharp o HtmlAgilityPack para analizar el HTML y Playwright para .NET cuando el contenido esté escrito en JavaScript. Para tareas de gran envergadura o bloqueadas, mantén el analizador y sustituye el paso de descarga por una API de scraping. La mayoría de los scrapers de producción combinan dos o tres de estas opciones.

¿Debería utilizar HtmlAgilityPack o AngleSharp?

Ambas están actualizadas y analizan bien el HTML real. AngleSharp sigue el estándar HTML y admite selectores CSS de forma nativa, por lo que los selectores copiados de las herramientas de desarrollo del navegador funcionan tal cual. HtmlAgilityPack es más antiguo, muy tolerante con el marcado defectuoso y está basado en XPath y LINQ. Elige el estilo de consulta que prefiera tu equipo. Cambiar más adelante solo afectará al analizador.

¿Puede C# extraer datos de sitios web renderizados con JavaScript?

No solo con HttpClient, ya que este solo recibe el HTML que envía el servidor. Playwright para .NET, PuppeteerSharp o Selenium pueden ejecutar la página en un navegador sin interfaz gráfica y proporcionarte el contenido renderizado. Comprueba primero el código fuente de la página y las solicitudes de red en busca de JSON, que suele ser más rápido y fiable que el renderizado.

¿Qué es mejor para el scraping en C#, Playwright o Selenium?

Para quienes se inician en el scraping, Playwright para .NET suele ser más sencillo: instala sus propios navegadores, espera a que aparezcan los elementos automáticamente y es compatible con Chromium, Firefox y WebKit desde una única API. Selenium WebDriver sigue siendo una buena opción si tu equipo ya ejecuta pruebas con Selenium o utiliza Selenium Grid. Ambos son más lentos y pesados que las simples peticiones HTTP, así que utilízalos solo cuando sea necesario.

¿Existe un equivalente a Scrapy para C#?

Ningún framework de .NET se utiliza tan ampliamente como Scrapy en Python. La mayoría de los equipos de C# montan ellos mismos las mismas piezas: HttpClient con un canal de resiliencia para la obtención de datos, AngleSharp o HtmlAgilityPack para el análisis, Parallel.ForEachAsync o canales para la concurrencia, y una cola o una base de datos para las URL. Esa pila cubre lo que necesitan la mayoría de los proyectos de scraping.

¿Cómo gestiono el error HTTP 429 «Too Many Requests» en C#?

Reduzca la velocidad y vuelva a intentarlo más tarde. Añada un canal de reintentos con Microsoft.Extensions.Http.Resilience, que reintenta las respuestas 429 y 5xx con retroceso exponencial y espera el encabezado «Retry-After» cuando el servidor lo envía. Reduzca la concurrencia y añada un retraso entre las solicitudes. Si los errores 429 continúan a un ritmo moderado, el sitio te está pidiendo que pares.

¿Cómo evito que me bloqueen al realizar scraping con C#?

Reutiliza un único HttpClient, envía un User-Agent válido, mantén baja la concurrencia, almacena en caché las páginas que ya hayas obtenido y vuelve a intentar las respuestas 429 solo tras el retraso que solicite el servidor. Si un sitio sigue respondiendo con errores 403 o páginas de autenticación, redirige las solicitudes a través de una API de scraping que gestione los proxies y la visualización, y mantén tu analizador sin cambios.

¿Cómo debo almacenar una clave de API en una aplicación .NET?

No la incluyas en el código fuente. Léela desde una variable de entorno, como WSA_API_KEY, con Environment.GetEnvironmentVariable; utiliza dotnet user-secrets durante el desarrollo local de ASP.NET Core y utiliza el almacén de secretos de tu proveedor de alojamiento en producción. Nunca pegues claves en las URL que compartas, no las subas a Git ni registres la URL completa de la solicitud.

¿Cómo puedo rastrear muchas páginas en paralelo en C#?

Utiliza Parallel.ForEachAsync con MaxDegreeOfParallelism establecido en un número pequeño, como de tres a cinco por sitio web, y comparte un único HttpClient entre todas las tareas. Recoge los resultados en una colección segura para subprocesos, como ConcurrentBag. El límite debe reflejar lo que el sitio web pueda soportar, no el número de núcleos de CPU que tenga tu equipo.

El scraping de datos disponibles públicamente suele ser legal, pero depende de las condiciones del sitio web, del tipo de datos y de la legislación local, especialmente en el caso de los datos personales y los contenidos protegidos por derechos de autor. El lenguaje de programación no cambia eso. Respeta el archivo robots.txt, evita los datos personales sin una base legal y busca asesoramiento jurídico antes de realizar scraping para un proyecto comercial.

Mantén tu analizador de C# y deja que otros se encarguen de las páginas difíciles

No es necesario modificar tu código de AngleSharp o HtmlAgilityPack cuando un sitio web empiece a bloquearte o requiera JavaScript. Dirígete WsaClient a la API de scraping de WebScrapingAPI y sigue analizando en .NET. La representación en JavaScript y la geolocalización no suponen ningún coste adicional, las solicitudes fallidas no se facturan y la prueba gratuita incluye 5.000 solicitudes.

Acerca del autor

Sorin-Gabriel Marica, Desarrollador full-stack @ WebScrapingAPI

Sorin-Gabriel Marica

Desarrollador full-stack

Sorin Marica es ingeniero Full Stack y DevOps en WebScrapingAPI, donde se encarga de desarrollar funciones para los productos y de mantener la infraestructura que garantiza el buen funcionamiento de la plataforma.

Extracción de datos web con AWS Lambda: guía para Python y Java 2026
Guías

Extracción de datos web con AWS Lambda: guía para Python y Java 2026

TL;DR: En resumen: el web scraping con AWS Lambda funciona mejor cuando cada invocación es breve, está delimitada y se puede reintentar de forma independiente. Empieza con HTTP directo, AWS SAM y S3, y añade SQS, contenedores, renderización en navegador, proxies o una capa de recuperación gestionada solo cuando la carga de trabajo demuestre que los necesita.

Suciu Dan33 min read
Leer artículo
Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos
Guías

Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos

TL;DR: En resumen: GoSpider es un rastreador de línea de comandos diseñado para descubrir URL, no un extractor completo de datos estructurados. Esta guía sobre cómo utilizar GoSpider muestra cómo realizar un rastreo limitado, gestionar correctamente los resultados, pasar los datos de Colly a CSV y seguir una ruta de diagnóstico para respuestas 403 o páginas que requieren renderización con JavaScript.

Suciu Dan24 min read
Leer artículo
Cómo raspar Redfin: Guía Python de Datos Inmobiliarios
Guías

Cómo raspar Redfin: Guía Python de Datos Inmobiliarios

TL;DR: Redfin expone puntos finales de API ocultos que devuelven JSON estructurado para los listados de propiedades, lo que permite omitir por completo el frágil análisis HTML. Esta guía te guía a través de la construcción de un raspador de Python que extrae datos de alquiler y venta, busca por ubicación, supervisa los nuevos listados a través de mapas de sitio XML y exporta resultados limpios a CSV o JSON.

Suciu Dan14 min read
Leer artículo

Empieza a crear

¿Estás listo para ampliar tu recopilación de datos?

Únete a más de 2000 empresas que utilizan WebScrapingAPI para extraer datos de la web a escala empresarial sin ningún gasto de infraestructura.