Scraping per linguaggio 25 min di lettura

Web scraping in C#: guida completa, dalle basi alle tecniche avanzate

Guida completa al web scraping in C#: HttpClient, HtmlAgilityPack, AngleSharp, Selenium e l’assemblaggio di uno scraper da console pronto all’uso.

TW
Team Web-Scraping.it
Raccolta dati per le esigenze del business
Pubblicato il 24 febbraio 2025

Riferimento pratico per l’estrazione di dati dalle pagine web in .NET. Gli esempi sono pensati per .NET 6/8 e un C# moderno e idiomatico (async/await, HttpClient, record, ecc.). Dove una funzionalità è comparsa in una versione specifica, è indicato.


Indice

  1. Che cos’è il web scraping e di cosa si compone
  2. Preparazione del progetto e pacchetti necessari
  3. Come scarichiamo la pagina: HttpClient
  4. Header della richiesta, User-Agent e compressione (gzip/br)
  5. Librerie per il parsing del contenuto - 5.1 HtmlAgilityPack (XPath) - 5.2 AngleSharp (selettori CSS, DOM completo) - 5.3 Fizzler, regex e quando scegliere cosa
  6. Come risolvere i problemi di codifica (lettere accentate)
  7. Ottenere lo stato della risposta e gli altri header
  8. Lavorare con i cookie
  9. Lavorare con HTTPS/SSL
  10. Uso dei proxy
  11. Scraping via TOR
  12. Multithreading / parallelismo
  13. Resilienza: timeout, retry, cortesia, robots.txt
  14. Archiviazione di URL e code (frontier, deduplicazione)
  15. Contenuti JavaScript: browser headless
  16. Salvare i risultati
  17. Pro e contro principali dell’implementazione
  18. Aspetti legali ed etici

1. Che cos’è il web scraping e di cosa si compone

Il web scraping è il download automatico di pagine web e l’estrazione da esse di dati strutturati. Ogni scraper, dal punto di vista logico, si compone di quattro parti:

  1. Modulo di download (downloader/fetcher) — scarica l’HTML a partire dall’URL.
  2. Parser del contenuto — trasforma l’HTML in un albero dal quale estrarre i dati tramite selettori.
  3. Estrazione e normalizzazione dei dati — tiriamo fuori i campi che servono, li puliamo, li convertiamo nei tipi corretti.
  4. Pianificatore (scheduler/frontier) — gestisce la coda degli URL, la deduplicazione, la velocità, i retry.

Un buon scraper ≠ «ho scaricato e ho fatto il parsing». L’80% della difficoltà sta nell’affidabilità: codifiche, timeout, retry, protezione dai ban, limitazione della velocità, gestione della coda. È a questo che è dedicata gran parte dell’articolo.


2. Preparazione del progetto e pacchetti necessari

bash
dotnet new console -n Scraper
cd Scraper

# Parsing dell'HTML: uno dei due o entrambi
dotnet add package HtmlAgilityPack
dotnet add package AngleSharp

# Supporto delle codifiche legacy (windows-1252 e simili), indispensabile in .NET Core+
dotnet add package System.Text.Encoding.CodePages

# Resilienza (retry, circuit breaker)
dotnet add package Microsoft.Extensions.Http.Polly

# Opzionale: browser headless per le pagine con JS
dotnet add package Microsoft.Playwright

Risorse ufficiali di questi pacchetti:


3. Come scarichiamo la pagina: HttpClient

Nel .NET moderno l’unico strumento giusto è HttpClient. I vecchi WebClient e HttpWebRequest sono considerati superati (legacy) e nel codice nuovo non vanno usati.

Regola principale: HttpClient va riutilizzato

HttpClient è progettato per vivere a lungo. Creare una nuova istanza a ogni richiesta (using var client = new HttpClient()) è un errore classico: porta all’esaurimento dei socket (le porte restano bloccate nello stato TIME_WAIT). Usa un’unica istanza condivisa per tutta l’applicazione oppure IHttpClientFactory. L’analisi dettagliata è nella guida di Microsoft all’uso di HttpClient.

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

// Un handler + un client per tutta l'applicazione (o un singleton via DI)
var handler = new SocketsHttpHandler
{
    AutomaticDecompression = DecompressionMethods.All, // gzip, deflate, brotli
    PooledConnectionLifetime = TimeSpan.FromMinutes(2), // protezione dal DNS «scaduto»
    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");

// Il download più semplice
string html = await http.GetStringAsync("https://example.com");

Meglio lavorare con HttpRequestMessage e HttpResponseMessage

GetStringAsync è comodo, ma nasconde status code, header e codifica. Per uno scraper vero prendi la risposta completa: così tieni tutto sotto controllo:

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(); // lancia un'eccezione con 4xx/5xx (facoltativo)

byte[] bytes = await response.Content.ReadAsByteArrayAsync();
// bytes -> li decodifichiamo in stringa da soli (vedi la sezione sulle codifiche)

HttpCompletionOption.ResponseHeadersRead restituisce il controllo non appena arrivano gli header, senza aspettare l’intero body. Utile per le risposte grandi e lo streaming.


4. Header della richiesta, User-Agent e compressione

Molti siti bloccano le richieste prive di header «umani». Il set di base che conviene impostare:

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

Importante sulla compressione: non dichiarare Accept-Encoding: gzip, br a mano se non hai attivato AutomaticDecompression. Altrimenti il server invierà il body compresso e tu riceverai «spazzatura». La strada giusta è impostare AutomaticDecompression = DecompressionMethods.All sull’handler (come nella sezione 3): così .NET aggiunge l’header e decomprime da solo. Brotli (br) è supportato a partire da .NET Core 3.0.

La rotazione dello User-Agent e un ordine realistico degli header sono un espediente frequente contro i sistemi anti-bot semplici, ma non sono una panacea contro le protezioni serie (vedi le sezioni 13 e 18).


5. Librerie per il parsing del contenuto

Una volta scaricato l’HTML, bisogna trasformarlo in un albero ed estrarre i dati con i selettori. I due grandi protagonisti in .NET sono HtmlAgilityPack e AngleSharp.

5.1 HtmlAgilityPack (XPath)

Il classico collaudato da anni. Lavora via XPath e perdona l’HTML «sporco» e non valido.

c#
using HtmlAgilityPack;

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

// Titolo della pagina
string? title = doc.DocumentNode
    .SelectSingleNode("//title")?.InnerText.Trim();

// Tutti i link
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}");
}

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

⚠️ SelectNodes restituisce null se non trova nulla (non una collezione vuota): controlla sempre il null oppure usa ?? Enumerable.Empty<...>(). HtmlEntity.DeEntitize va chiamato per trasformare &amp;, &nbsp; ecc. in caratteri normali.

5.2 AngleSharp (selettori CSS, un DOM vero)

Libreria moderna che implementa gli standard del W3C. Fa il parsing dell’HTML esattamente come un browser e supporta i selettori CSS (querySelector / querySelectorAll), come in JS. Spesso è più comoda, soprattutto se arrivi dal 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));

// Selettori CSS, come nel browser
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 sa fare di più: caricare una pagina intera da URL, elaborare i form, lavorare con il CSSOM. È un motore DOM completo, non un semplice parser HTML.

5.3 Fizzler, regex e quando scegliere cosa

  • Fizzler — aggiunge i selettori CSS sopra HtmlAgilityPack (.QuerySelectorAll(...)), se vuoi i CSS senza abbandonare HAP.
  • Le espressioni regolari sull’HTML — un antipattern. L’HTML non è un linguaggio regolare; le regex si rompono con l’annidamento, gli attributi in ordine arbitrario, i commenti. La regex è appropriata solo per rifinire il testo già estratto (per esempio, tirare fuori il numero dalla stringa «Prezzo: 1.299 €»).

Cosa scegliere:

Situazione Raccomandazione
Conosci bene XPath, HTML «sporco» HtmlAgilityPack
Conosci bene i selettori CSS, vuoi un DOM «da browser» AngleSharp
Servono i selettori CSS, ma la codebase è su HAP HtmlAgilityPack + Fizzler
Dati in <script> come JSON (spesso __NEXT_DATA__, JSON-LD) estrarre il nodo con un selettore, poi System.Text.Json

Suggerimento: molto spesso i dati sono già nella pagina in JSON, dentro <script type="application/ld+json"> o nello stato della SPA. Fare il parsing di quel JSON è più affidabile che fare il parsing del markup.


6. Come risolvere i problemi di codifica (lettere accentate)

È il grattacapo più frequente quando si fa scraping di siti italiani datati. Il sintomo: «mojibake», caratteri illeggibili al posto delle lettere accentate (perché o caffè invece di perché e caffè). La causa è quasi sempre una codifica sbagliata nel decodificare i byte in stringa.

Passo 1. Registra il provider delle code page

In .NET Core / .NET 5+ le vecchie codifiche a byte singolo (windows-1252 e le altre code page) non sono incluse di default. Senza questo passaggio, Encoding.GetEncoding(1252) lancia un’eccezione. Aggiungi il pacchetto System.Text.Encoding.CodePages (vedi CodePagesEncodingProvider) ed esegui una sola volta all’avvio:

c#
using System.Text;

// All'inizio del programma (Main / avvio)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

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

Passo 2. Non usare GetStringAsync alla cieca

GetStringAsync decodifica il body basandosi sull’header Content-Type; charset=.... Se il sito mente nell’header o non indica il charset, ti ritrovi con un pasticcio. La via affidabile: scaricare i byte e determinare la codifica da soli.

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 dall'header HTTP
    string? charset = resp.Content.Headers.ContentType?.CharSet;

    // 2) se non è nell'header, lo cerchiamo in <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);
}

// Rilevamento approssimativo del charset dai primi byte (meta tag)
static string? SniffCharsetFromMeta(byte[] bytes)
{
    // il meta sta sempre nella parte compatibile ASCII: leggiamo i primi ~2 KB come 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;
}

Questo è uno dei pochi usi appropriati di una regex sull’HTML: solo per estrarre il nome della codifica dal meta tag, niente di più.

Passo 3. Affidati al parser (spesso la via più semplice)

Sia AngleSharp sia HtmlAgilityPack sanno determinare la codifica dai byte per conto proprio, purché tu passi loro lo stream o i byte, non una stringa già decodificata.

c#
// HtmlAgilityPack: rileva da solo la codifica dal <meta>
using var resp = await http.GetAsync(url);
await using var stream = await resp.Content.ReadAsStreamAsync();

var doc = new HtmlDocument
{
    OptionReadEncoding = true // leggere la codifica dal documento
};
doc.Load(stream); // detectEncodingFromByteOrderMarks = true di default
c#
// AngleSharp: gli passiamo lo stream e ci pensa lui alla codifica
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));

L’algoritmo in pratica: registriamo CodePages → passiamo i byte/lo stream al parser → se i caratteri restano illeggibili, controlliamo il charset nell’header e nel meta tag e decodifichiamo a mano con la codifica giusta.


7. Ottenere lo stato della risposta e gli altri header

HttpResponseMessage dà pieno accesso allo stato e agli header: serve per la logica dei retry, la gestione dei redirect e dei ban.

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

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

// Header della risposta (response headers)
if (resp.Headers.TryGetValues("Server", out var server))
    Console.WriteLine("Server: " + string.Join(",", server));

// Header del contenuto (content headers)
string? contentType   = resp.Content.Headers.ContentType?.MediaType; // text/html
long?   contentLength = resp.Content.Headers.ContentLength;

// Utile per lo scraper
var retryAfter = resp.Headers.RetryAfter;     // con 429/503: quando riprovare
var location   = resp.Headers.Location;       // dove reindirizza (se AllowAutoRedirect=false)

switch (statusCode)
{
    case 200: /* facciamo il parsing */ break;
    case 301 or 302: /* redirect */ break;
    case 403: /* possibile ban: servono cookie/UA */ break;
    case 404: /* pagina inesistente: via dalla coda */ break;
    case 429: /* too many requests: rallentare, vedi Retry-After */ break;
    case >= 500: /* errore del server: riprovare più tardi */ break;
}

La distinzione conta: gli header generali stanno in resp.Headers, quelli legati al body (Content-Type, Content-Length, Content-Encoding) in resp.Content.Headers. Se cerchi Content-Type in resp.Headers, non lo troverai.


I cookie servono per le sessioni, l’autenticazione, il superamento delle «verifiche». In .NET se ne occupa CookieContainer, agganciato all’handler.

c#
var cookies = new CookieContainer();

var handler = new SocketsHttpHandler
{
    CookieContainer = cookies,
    UseCookies = true // attivo di default
};
var http = new HttpClient(handler);

// Le richieste inviano e salvano automaticamente i cookie di questo container
await http.GetAsync("https://example.com/login");

// Si può impostare un cookie a mano (per esempio il token di sessione)
cookies.Add(new Uri("https://example.com"),
    new Cookie("session_id", "abc123") { Path = "/" });

// Leggere i cookie correnti di un dominio
foreach (Cookie c in cookies.GetCookies(new Uri("https://example.com")))
    Console.WriteLine($"{c.Name} = {c.Value}");

Da tenere a mente: - Un container = una sessione. Per lo scraping in parallelo con «identità» diverse, crea un handler e un container separati per ogni sessione/proxy. - Se devi disattivare i cookie (per esempio perché ogni richiesta sia «pulita»), imposta UseCookies = false. - Puoi salvare e ripristinare la sessione tra un’esecuzione e l’altra serializzando i cookie (nome, valore, dominio, path, scadenza) in JSON.


9. Lavorare con HTTPS/SSL

Di default HttpClient stabilisce da solo la connessione TLS e verifica il certificato del server. Di norma non c’è nulla da configurare. Intervenire serve solo in rari casi.

Ignorare gli errori di certificato (con cautela!)

A volte un sito ha un certificato «rotto» o autofirmato e va scaricato comunque. La verifica si può disattivare così, ma solo in modo consapevole: perdi la protezione dai MITM:

c#
var handler = new SocketsHttpHandler
{
    SslOptions = new System.Net.Security.SslClientAuthenticationOptions
    {
        // ATTENZIONE: accetta qualsiasi certificato. Solo per debug o attività fidate.
        RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
    }
};

(Nel classico HttpClientHandler l’analogo è ServerCertificateCustomValidationCallback, ed esiste lo stub pronto HttpClientHandler.DangerousAcceptAnyServerCertificateValidator.)

Controllo della versione TLS

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

Non disattivare la verifica dei certificati «per sicurezza» in produzione: spalanca la porta alla manipolazione del traffico. Usala in modo mirato e solo dove serve davvero.


10. Uso dei proxy

I proxy servono per (a) aggirare i blocchi per IP e (b) distribuire il carico e ridurre il rischio di ban con la rotazione degli indirizzi.

Configurazione di base di un proxy HTTP

c#
var proxy = new WebProxy("http://proxy-host:8080")
{
    Credentials = new NetworkCredential("user", "password") // se serve l'autenticazione
};

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

Proxy SOCKS (nativo in .NET 6+)

A partire da .NET 6 gli schemi socks4, socks4a e socks5 sono supportati direttamente in WebProxy: le librerie di terze parti non servono più:

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

Rotazione dei proxy

La strategia più semplice: un pool di proxy, a ciascuno assegniamo il suo HttpClient (l’handler con il proxy si riutilizza!) e li prendiamo a rotazione o a caso. I proxy che danno errori o timeout vengono «penalizzati» temporaneamente.

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: non creare un nuovo handler con proxy a ogni richiesta: ci riporterebbe all’esaurimento dei socket. Crea un HttpClient per ogni proxy e riutilizzalo.


11. Scraping via TOR

TOR offre anonimato e rotazione gratuita degli IP. In sostanza è un proxy SOCKS5 locale.

Connessione a TOR come SOCKS5

Dopo l’installazione di Tor (il demone tor o Tor Browser), sulla macchina resta attivo un proxy SOCKS5, di default su 127.0.0.1:9050 (con 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 di IP (nuovo circuito) via Control Port

Il vero asso nella manica: si può richiedere un nuovo circuito (un nuovo IP di uscita) con il comando SIGNAL NEWNYM sulla porta di controllo (di default 9051); vedi la specifica del protocollo di controllo di Tor. Va abilitata in torrc:

code
ControlPort 9051
# e l'autenticazione, per esempio una password con hash:
HashedControlPassword 16:...   # da `tor --hash-password "mypass"`

Richiesta di un nuovo circuito in TCP grezzo:

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

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

Tieni presente: TOR è lento e molti siti tagliano il traffico proveniente dai nodi di uscita. Tra un NEWNYM e l’altro c’è un limite (MaxCircuitDirtiness, ~10 s), quindi cambiare IP all’istante a ogni richiesta non si può. Per uno scraping veloce i proxy commerciali sono di solito più pratici; TOR serve per l’anonimato.


12. Multithreading / parallelismo

Nello scraping di rete il collo di bottiglia è l’attesa della risposta, non la CPU. Perciò non serve il «multithreading» in senso classico, ma il parallelismo asincrono con un limite di richieste simultanee. Lanciare migliaia di richieste in un colpo solo non si può: intasi la rete, esaurisci le connessioni e ti guadagni un ban.

Metodo 1: Parallel.ForEachAsync (.NET 6+), il più semplice

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

await Parallel.ForEachAsync(
    urls,
    new ParallelOptions { MaxDegreeOfParallelism = 8 }, // non più di 8 alla volta
    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}");
        }
    });

Metodo 2: SemaphoreSlim + Task.WhenAll, controllo flessibile

c#
var gate = new SemaphoreSlim(initialCount: 8); // massimo 8 in parallelo

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

Metodo 3: System.Threading.Channels, pipeline «produttore-consumatore»

Per un crawler di lunga durata è il pattern migliore: una sola coda di URL, più worker consumatori. Si incastra bene con la sezione 14.

c#
using System.Threading.Channels;

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

// Avviamo N worker
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); // aggiungiamo i nuovi URL alla coda
        }
        catch { /* log + retry */ }
    }
})).ToArray();

// Carichiamo gli URL di partenza
foreach (var seed in seeds)
    await channel.Writer.WriteAsync(seed);

// channel.Writer.Complete(); // quando decidiamo che il crawling è finito
await Task.WhenAll(consumers);

Tara il grado di parallelismo sul sito concreto: 4--16 è l’intervallo tipico. Centinaia di richieste simultanee a un solo dominio sono già un DoS e un ban quasi garantito.


13. Resilienza: timeout, retry, cortesia, robots.txt

Questo non era nell’elenco iniziale, ma senza non sopravvive nessuno scraper «da battaglia».

Retry con backoff esponenziale (Polly)

La libreria Polly offre politiche dichiarative di retry, circuit breaker e timeout. Rende particolarmente bene 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
    );

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

Cortesia (rate limiting) e robots.txt

  • Una pausa tra le richieste allo stesso dominio (per esempio 0,5--2 s) riduce il carico sul sito e il rischio di ban. In .NET 7+ c’è System.Threading.RateLimiting.
  • robots.txt — il file con le regole per i bot (Disallow, Crawl-delay). Dal punto di vista legale non è vincolante ovunque, ma ignorarlo è cattivo stile e una fonte di conflitti. Rispetta il Crawl-delay e le sezioni chiuse.
c#
// La pausa «cortese» più semplice per 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;
}

Timeout e cancellazione

Oltre a HttpClient.Timeout usa un CancellationToken (comune a tutto il crawler): così fermi lo scraper in modo pulito con Ctrl+C e non lasci il processo appeso a connessioni «morte».


14. Archiviazione di URL e code (frontier)

La coda degli URL da visitare si chiama frontier. I suoi compiti: tenere traccia di ciò che resta da scaricare e non scaricare due volte la stessa cosa.

Deduplicazione (insieme dei visitati)

c#
// Insieme thread-safe degli URL già visti
var visited = new System.Collections.Concurrent.ConcurrentDictionary<string, byte>();

bool TryEnqueue(string url)
{
    string norm = Normalize(url); // la normalizzazione dell'URL è fondamentale!
    return visited.TryAdd(norm, 0); // true se questo URL non c'era ancora
}

La normalizzazione degli URL è obbligatoria, altrimenti example.com/p?a=1&b=2 e example.com/p?b=2&a=1 verranno considerati diversi. Il minimo indispensabile: host in minuscolo, togliere il #frammento, ordinare i parametri della query, togliere lo slash finale, uniformare lo schema.

Varianti di archiviazione della coda

Scala Soluzione
Piccola, in un solo processo ConcurrentQueue<string> o Channel<string> in memoria
Serve resistenza ai riavvii SQLite / LiteDB: tabella urls(url, status, depth, added_at)
Crawler distribuito Redis (coda + SET dei visitati) o un broker (RabbitMQ, Kafka)
Insieme dei visitati enorme, memoria costosa Bloom filter (compatto, ma con falsi positivi)

Frontier minimo su SQLite (per i riavvii)

L’idea: conserva gli URL con uno stato (pending / in_progress / done / failed) e una profondità. All’avvio prendi i pending, dopo il download li segni done, i link nuovi li aggiungi con INSERT OR IGNORE (l’indice univoco sull’URL garantisce la deduplicazione a livello di database).

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

Così il crawler si può fermare e riprendere dallo stesso punto: la coda sopravvive al riavvio.

Sui grandi volumi si aggiungono priorità (prima le pagine importanti), limite di profondità, limite di pagine per dominio e la «politica di cortesia» direttamente nel frontier.


15. Contenuti JavaScript: browser headless

HttpClient scarica l’HTML originale, prima dell’esecuzione del JavaScript. Se il sito è una SPA (React/Vue/Angular) e i dati vengono caricati dagli script, nell’HTML originale non ci saranno. Le opzioni:

  1. Trovare l’API. Spesso la SPA interroga un endpoint JSON: apri DevTools → Network, individua la richiesta con i dati e chiamala direttamente via HttpClient. È più veloce e affidabile di qualunque browser.
  2. Un browser headless, se l’API non si riesce a estrarre: renderizza davvero la pagina.

Playwright per .NET (raccomandato)

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"); // aspettiamo che compaiano i dati

// Si possono estrarre con i selettori di Playwright...
var names = await page.Locator(".product-card h2").AllTextContentsAsync();

// ...oppure prendere l'HTML renderizzato e farne il parsing con la libreria abituale
string renderedHtml = await page.ContentAsync();

Alternative: Selenium WebDriver (il classico, ma più pesante) e PuppeteerSharp (port di Puppeteer). Oggi per .NET si sceglie più spesso Playwright: è supportato ufficialmente da Microsoft ed è più comodo.

I contro dei browser headless: decine di volte più lenti e avidi di risorse rispetto a HttpClient. Usali solo quando senza rendering non si va da nessuna parte.


16. Salvare i risultati

I dati vanno pur messi da qualche parte. Le varianti tipiche:

c#
// JSON (System.Text.Json): comodo per i dati annidati
await using var fs = File.Create("data.json");
await System.Text.Json.JsonSerializer.SerializeAsync(fs, items,
    new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
  • CSV — per i dati tabellari (libreria CsvHelper).
  • JSON / JSONL — per le strutture annidate (System.Text.Json); JSONL (un oggetto per riga) è comodo per la scrittura in streaming di grandi volumi.
  • Database (SQLite/PostgreSQL con EF Core o Dapper) — quando servono query, dedup per contenuto, aggiornamenti incrementali.

Consiglio: scrivi i risultati in streaming, man mano che lo scraping procede, invece di accumulare tutto in memoria: altrimenti sui crawl grandi ti ritrovi con un OutOfMemory.


17. Pro e contro principali dell’implementazione in C

Pro

  • Prestazioni e asincronia. async/await, HttpClient, Channels e Parallel.ForEachAsync offrono I/O concorrente efficiente «di serie».
  • Ecosistema maturo. HtmlAgilityPack, AngleSharp, Playwright, Polly: tutto di qualità industriale.
  • Tipizzazione statica. Meno errori stupidi nei crawler grandi, refactoring comodo.
  • SOCKS/proxy nativi da .NET 6+, gestione semplice di TLS e cookie.
  • Multipiattaforma (.NET gira su Linux/Windows/macOS ed entra facilmente in Docker).

Contro

  • Codifiche. Di serie manca windows-1252: bisogna ricordarsi di CodePagesEncodingProvider (sezione 6).
  • Siti con JS. Il solo HttpClient non esegue JS; serve un browser headless, ed è pesante e lento.
  • Sistemi anti-bot. Cloudflare, captcha e fingerprinting si aggirano con difficoltà; uno scraper «onesto» spesso sbatte contro la protezione.
  • Fragilità rispetto al markup. Ogni scraper si rompe quando cambia la struttura HTML del sito: servono monitoraggio e manutenzione.
  • Meno framework pronti che in Python. In Python c’è Scrapy, il «tutto in uno»; in .NET di solito la pipeline la monti pezzo per pezzo (anche se esistono DotnetSpider e Abot).

18. Aspetti legali ed etici

Saperlo fare tecnicamente non significa averne il diritto. In breve, cosa tenere a mente (questa non è una consulenza legale):

  • Le condizioni d’uso del sito (ToS) possono vietare espressamente la raccolta automatizzata. Violarle è motivo di blocco e di contestazioni.
  • I dati personali sono regolati da leggi come il GDPR. Raccoglierli e conservarli senza base giuridica significa esporsi a rischi.
  • Diritto d’autore. Il contenuto copiato è spesso protetto; ripubblicarlo può violare i diritti altrui.
  • Carico. Uno scraping aggressivo è di fatto un DoS. Rispetta il Crawl-delay, limita le RPS, non buttare giù il server altrui.
  • robots.txt e le API pubbliche sono la via preferibile. Se il sito offre un’API ufficiale, quasi sempre conviene usarla.

Regola di base: fai scraping con cortesia, in modo identificabile (dove opportuno, con uno User-Agent onesto), rispettando i limiti del sito e la legge della tua giurisdizione.


Risorse ufficiali

Parsing di HTML/DOM - HtmlAgilityPack — GitHub · NuGet - AngleSharp — sito · GitHub · NuGet - Fizzler — GitHub

Download e rete (.NET / Microsoft) - HttpClient — API · linee guida d’uso · IHttpClientFactory - WebProxy — API - System.Text.Encoding.CodePages — NuGet · CodePagesEncodingProvider

Parallelismo e resilienza - Parallel.ForEachAsync — API - System.Threading.Channels — guida - System.Threading.RateLimiting — API - Polly — documentazione · GitHub · Microsoft.Extensions.Http.Polly

Browser headless - Playwright per .NET — documentazione · GitHub · NuGet - Selenium WebDriver — documentazione - PuppeteerSharp — sito

Anonimato - Tor Project — sito · specifica del protocollo di controllo

Archiviazione e serializzazione - System.Text.Json — panoramica - CsvHelper — documentazione - EF Core — documentazione · Dapper — GitHub - SQLite — sito · LiteDB — sito · Redis — sito · RabbitMQ — sito

Framework di crawling pronti - DotnetSpider — GitHub · Abot — GitHub


Il documento si può usare come piano passo dopo passo: ogni sezione è un «mattone» dello scraper, da assemblare nella pipeline complessiva frontier → fetcher → parser → storage.