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
- Che cos’è il web scraping e di cosa si compone
- Preparazione del progetto e pacchetti necessari
- Come scarichiamo la pagina:
HttpClient - Header della richiesta, User-Agent e compressione (gzip/br)
- 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
- Come risolvere i problemi di codifica (lettere accentate)
- Ottenere lo stato della risposta e gli altri header
- Lavorare con i cookie
- Lavorare con HTTPS/SSL
- Uso dei proxy
- Scraping via TOR
- Multithreading / parallelismo
- Resilienza: timeout, retry, cortesia, robots.txt
- Archiviazione di URL e code (frontier, deduplicazione)
- Contenuti JavaScript: browser headless
- Salvare i risultati
- Pro e contro principali dell’implementazione
- 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:
- Modulo di download (downloader/fetcher) — scarica l’HTML a partire dall’URL.
- Parser del contenuto — trasforma l’HTML in un albero dal quale estrarre i dati tramite selettori.
- Estrazione e normalizzazione dei dati — tiriamo fuori i campi che servono, li puliamo, li convertiamo nei tipi corretti.
- 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
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.PlaywrightRisorse ufficiali di questi pacchetti:
- HtmlAgilityPack — GitHub · NuGet
- AngleSharp — sito e documentazione · GitHub · NuGet
- System.Text.Encoding.CodePages — NuGet · documentazione
- Polly / Microsoft.Extensions.Http.Polly — documentazione · GitHub · NuGet
- Microsoft.Playwright (.NET) — documentazione · GitHub · NuGet
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.
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:
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.ResponseHeadersReadrestituisce 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:
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.
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')]");⚠️
SelectNodesrestituiscenullse non trova nulla (non una collezione vuota): controlla sempre ilnulloppure usa?? Enumerable.Empty<...>().HtmlEntity.DeEntitizeva chiamato per trasformare&, 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.
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:
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.
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.
// 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// 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.
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) inresp.Content.Headers. Se cerchiContent-Typeinresp.Headers, non lo troverai.
8. Lavorare con i cookie
I cookie servono per le sessioni, l’autenticazione, il superamento delle «verifiche». In .NET se ne occupa CookieContainer, agganciato all’handler.
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:
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
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
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ù:
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.
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
HttpClientper 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).
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:
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:
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
NEWNYMe 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
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
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.
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:
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 ilCrawl-delaye le sezioni chiuse.
// 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)
// 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).
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:
- 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. - Un browser headless, se l’API non si riesce a estrarre: renderizza davvero la pagina.
Playwright per .NET (raccomandato)
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:
// 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,ChannelseParallel.ForEachAsyncoffrono 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
HttpClientnon 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.txte 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.