Le scraping par langage 25 min de lecture

Web scraping en C# : guide complet — du simple au complexe

Guide complet du web scraping en C# : HttpClient, HtmlAgilityPack, AngleSharp, Selenium et création d'un outil de scraping en ligne de commande prêt à l'emploi.

ÉW
Équipe Web-Scraping.fr
Collecte de données pour votre activité
Publié le: 24 février 2025

Guide pratique de collecte de données depuis des pages web en .NET. Les exemples sont conçus pour .NET 6/8 et un C# moderne et idiomatique (async/await, HttpClient, record, etc.). Lorsqu'une fonctionnalité est apparue dans une version précise, c'est indiqué.


Sommaire

  1. Qu'est-ce que le web scraping et de quoi est-il composé
  2. Préparation du projet et packages nécessaires
  3. Comment récupérer la page : HttpClient
  4. En-têtes de requête, User-Agent et compression (gzip/br)
  5. Bibliothèques de parsing du contenu - 5.1 HtmlAgilityPack (XPath) - 5.2 AngleSharp (sélecteurs CSS, DOM complet) - 5.3 Fizzler, expressions régulières et comment choisir
  6. Résolution des problèmes de parsing du cyrillique (encodages)
  7. Récupération du code de statut et des autres en-têtes
  8. Gestion des cookies
  9. Gestion du HTTPS/SSL
  10. Utilisation de proxys
  11. Scraping via TOR
  12. Multithreading / parallélisme
  13. Robustesse : timeouts, réessais, politesse, robots.txt
  14. Stockage des URL et files d'attente (frontier, déduplication)
  15. Contenu JavaScript : navigateurs headless
  16. Sauvegarde des résultats
  17. Principaux avantages et inconvénients de cette approche
  18. Aspects juridiques et éthiques

1. Qu'est-ce que le web scraping et de quoi est-il composé

Le web scraping consiste à récupérer automatiquement des pages web et à en extraire des données structurées. Tout scraper se compose logiquement de quatre parties :

  1. Téléchargeur (downloader/fetcher) — télécharge le HTML à partir d'une URL.
  2. Parseur de contenu — transforme le HTML en arbre, dans lequel on peut extraire les données via des sélecteurs.
  3. Extraction et normalisation des données — on récupère les champs voulus, on les nettoie, on les convertit dans les bons types.
  4. Ordonnanceur (scheduler/frontier) — gère la file d'URL, la déduplication, la vitesse, les réessais.

Un bon scraper ≠ « télécharger et parser ». 80 % de la complexité tient à la fiabilité : encodages, timeouts, réessais, protection contre le bannissement, limitation de débit, gestion de la file d'attente. C'est le sujet de la majeure partie de cet article.


2. Préparation du projet et packages nécessaires

bash
dotnet new console -n Scraper
cd Scraper

# Parsing HTML — l'un des deux ou les deux
dotnet add package HtmlAgilityPack
dotnet add package AngleSharp

# Prise en charge des encodages legacy (windows-1251, etc.) — indispensable pour le cyrillique en .NET Core+
dotnet add package System.Text.Encoding.CodePages

# Robustesse (réessais, circuit breaker)
dotnet add package Microsoft.Extensions.Http.Polly

# Optionnel — navigateur headless pour les pages JS
dotnet add package Microsoft.Playwright

Ressources officielles de ces packages :


3. Comment récupérer la page : HttpClient

Dans le .NET moderne, le seul outil correct est HttpClient. Les anciens WebClient et HttpWebRequest sont considérés comme obsolètes (legacy) et ne doivent plus être utilisés dans du nouveau code.

Règle d'or : HttpClient doit être réutilisé

HttpClient est conçu pour une longue durée de vie. Créer une nouvelle instance à chaque requête (using var client = new HttpClient()) est une erreur classique : elle mène à l'épuisement des sockets (les ports restent bloqués en état TIME_WAIT). Utilisez une seule instance partagée pour toute l'application, ou bien IHttpClientFactory. Analyse détaillée dans le guide Microsoft sur l'utilisation de HttpClient.

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

// Un handler + un client pour toute l'application (ou un singleton DI)
var handler = new SocketsHttpHandler
{
    AutomaticDecompression = DecompressionMethods.All, // gzip, deflate, brotli
    PooledConnectionLifetime = TimeSpan.FromMinutes(2), // protection contre un DNS « périmé »
    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");

// Téléchargement le plus simple
string html = await http.GetStringAsync("https://example.com");

Préférez travailler avec HttpRequestMessage et HttpResponseMessage

GetStringAsync est pratique, mais masque le code de statut, les en-têtes et l'encodage. Pour un vrai scraper, prenez la réponse complète — vous contrôlez ainsi tout :

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(); // lève une exception en cas de 4xx/5xx (facultatif)

byte[] bytes = await response.Content.ReadAsByteArrayAsync();
// bytes -> on décode la chaîne nous-mêmes (voir la section sur le cyrillique)

HttpCompletionOption.ResponseHeadersRead rend la main dès que les en-têtes sont arrivés, sans attendre tout le corps. Utile pour les grosses réponses et le streaming.


4. En-têtes de requête, User-Agent et compression

Beaucoup de sites bloquent les requêtes sans en-têtes « humains ». Voici le jeu de base à définir :

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

Point important sur la compression : ne déclarez pas Accept-Encoding: gzip, br manuellement si vous n'avez pas activé AutomaticDecompression. Sinon le serveur enverra un corps compressé et vous obtiendrez du « charabia ». La bonne pratique : définir AutomaticDecompression = DecompressionMethods.All sur le handler (comme dans la section 3) — .NET ajoutera alors lui-même l'en-tête et décompressera la réponse. Brotli (br) est pris en charge depuis .NET Core 3.0.

La rotation du User-Agent et un ordre d'en-têtes réaliste sont une astuce fréquente contre les systèmes anti-bot simples, mais ce n'est pas une panacée face à une protection sérieuse (voir sections 13 et 18).


5. Bibliothèques de parsing du contenu

Une fois le HTML téléchargé, il faut le transformer en arbre et en extraire les données via des sélecteurs. Les deux acteurs majeurs en .NET sont HtmlAgilityPack et AngleSharp.

5.1 HtmlAgilityPack (XPath)

Un classique éprouvé par les années. Fonctionne via XPath et tolère le HTML « sale » et invalide.

c#
using HtmlAgilityPack;

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

// Titre de la page
string? title = doc.DocumentNode
    .SelectSingleNode("//title")?.InnerText.Trim();

// Tous les liens
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}");
}

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

⚠️ SelectNodes renvoie null si rien n'est trouvé (et non une collection vide) — vérifiez toujours le null ou utilisez ?? Enumerable.Empty<...>(). HtmlEntity.DeEntitize doit être appelé pour transformer &amp;, &nbsp;, etc. en caractères normaux.

5.2 AngleSharp (sélecteurs CSS, vrai DOM)

Bibliothèque moderne implémentant les standards du W3C. Elle parse le HTML exactement comme un navigateur et prend en charge les sélecteurs CSS (querySelector / querySelectorAll), comme en JS. Souvent plus pratique, surtout si vous venez du front-end.

c#
using AngleSharp;
using AngleSharp.Dom;

var config = Configuration.Default;
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(req => req.Content(html));

// Sélecteurs CSS, comme dans le navigateur
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 sait faire davantage : charger une page complète depuis une URL, analyser des formulaires, travailler avec le CSSOM. C'est un véritable moteur DOM, pas un simple parseur HTML.

5.3 Fizzler, expressions régulières et comment choisir

  • Fizzler — ajoute les sélecteurs CSS par-dessus HtmlAgilityPack (.QuerySelectorAll(...)), si vous voulez du CSS tout en restant sur HAP.
  • Les expressions régulières sur du HTML — un antipattern. Le HTML n'est pas un langage régulier ; les regex cassent sur l'imbrication, les attributs dans un ordre arbitraire, les commentaires. La regex n'est appropriée que pour affiner un texte déjà extrait (par exemple, extraire un nombre de la chaîne « Prix : 1 299 € »).

Que choisir :

Situation Recommandation
Vous connaissez XPath, HTML « sale » HtmlAgilityPack
Vous connaissez les sélecteurs CSS, besoin d'un DOM « navigateur » AngleSharp
Besoin de sélecteurs CSS, mais base de code sur HAP HtmlAgilityPack + Fizzler
Données dans <script> au format JSON (souvent __NEXT_DATA__, JSON-LD) extraire le nœud avec un sélecteur, puis System.Text.Json

Astuce : très souvent, les données sont déjà présentes sur la page au format JSON dans <script type="application/ld+json"> ou dans le state d'une SPA. Parser ce JSON est plus fiable que de parser le balisage.


6. Encodages et Unicode : résoudre les problèmes de parsing

C'est la difficulté la plus fréquente sur les sites du web russophone. Symptôme : des caractères illisibles à la place des lettres russes (привет ou ïðèâåò). La cause est presque toujours un mauvais encodage lors du décodage des octets en chaîne.

Étape 1. Enregistrez le fournisseur de pages de code

Dans .NET Core / .NET 5+, les anciens encodages mono-octet (windows-1251, koi8-r) ne sont pas inclus par défaut. Sans cela, Encoding.GetEncoding(1251) lèvera une exception. Ajoutez le package System.Text.Encoding.CodePages (voir CodePagesEncodingProvider) et exécutez une fois au démarrage :

c#
using System.Text;

// Tout au début du programme (Main / démarrage)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

var win1251 = Encoding.GetEncoding(1251);   // ou GetEncoding("windows-1251")

Étape 2. N'utilisez pas GetStringAsync à l'aveugle

GetStringAsync décode le corps en s'appuyant sur l'en-tête Content-Type; charset=.... Si le site ment dans l'en-tête ou n'indique pas de charset, vous obtiendrez du charabia. La méthode fiable : télécharger les octets et déterminer l'encodage soi-même.

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 depuis l'en-tête HTTP
    string? charset = resp.Content.Headers.ContentType?.CharSet;

    // 2) s'il est absent de l'en-tête — on cherche dans <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; // solution de repli
    }

    return enc.GetString(bytes);
}

// Détection sommaire du charset dans les premiers octets (balise meta)
static string? SniffCharsetFromMeta(byte[] bytes)
{
    // meta est toujours dans la partie compatible ASCII, on lit les ~2 Ko initiaux en 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;
}

C'est l'un des rares cas où appliquer une regex sur du HTML est pertinent — uniquement pour extraire le nom de l'encodage de la balise meta, rien de plus.

Étape 3. Fiez-vous au parseur (souvent le plus simple)

AngleSharp comme HtmlAgilityPack savent détecter eux-mêmes l'encodage à partir des octets — à condition de leur fournir un flux ou des octets, et non une chaîne déjà décodée.

c#
// HtmlAgilityPack : détectera lui-même l'encodage depuis <meta>
using var resp = await http.GetAsync(url);
await using var stream = await resp.Content.ReadAsStreamAsync();

var doc = new HtmlDocument
{
    OptionReadEncoding = true // lire l'encodage depuis le document
};
doc.Load(stream); // detectEncodingFromByteOrderMarks = true par défaut
c#
// AngleSharp : on passe le flux, il gère l'encodage tout seul
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));

Algorithme en pratique : on enregistre CodePages → on passe les octets/le flux au parseur → si le texte reste illisible, on vérifie le charset dans l'en-tête et la balise meta, puis on décode manuellement avec le bon encodage.


7. Récupération du code de statut et des autres en-têtes

HttpResponseMessage donne un accès complet au statut et aux en-têtes — c'est nécessaire pour la logique de réessai, la gestion des redirections et des bannissements.

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

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

// En-têtes de réponse (response headers)
if (resp.Headers.TryGetValues("Server", out var server))
    Console.WriteLine("Server: " + string.Join(",", server));

// En-têtes de contenu (content headers)
string? contentType   = resp.Content.Headers.ContentType?.MediaType; // text/html
long?   contentLength = resp.Content.Headers.ContentLength;

// Utile pour un scraper
var retryAfter = resp.Headers.RetryAfter;     // en cas de 429/503 — quand réessayer
var location   = resp.Headers.Location;       // cible de la redirection (si AllowAutoRedirect=false)

switch (statusCode)
{
    case 200: /* on parse */ break;
    case 301 or 302: /* redirection */ break;
    case 403: /* peut-être un ban / cookies ou UA requis */ break;
    case 404: /* page inexistante — on la retire de la file */ break;
    case 429: /* too many requests — ralentir, voir Retry-After */ break;
    case >= 500: /* erreur serveur — réessayer plus tard */ break;
}

La distinction est importante : les en-têtes généraux se trouvent dans resp.Headers, tandis que ceux liés au corps (Content-Type, Content-Length, Content-Encoding) sont dans resp.Content.Headers. Si vous cherchez Content-Type dans resp.Headers, vous ne l'y trouverez pas.


Les cookies servent aux sessions, à l'authentification, au passage des « vérifications ». En .NET, ils sont gérés par CookieContainer, rattaché au handler.

c#
var cookies = new CookieContainer();

var handler = new SocketsHttpHandler
{
    CookieContainer = cookies,
    UseCookies = true // activé par défaut
};
var http = new HttpClient(handler);

// Les requêtes envoient et enregistrent automatiquement les cookies de ce conteneur
await http.GetAsync("https://site.ru/login");

// On peut définir un cookie manuellement (par exemple un jeton de session)
cookies.Add(new Uri("https://site.ru"),
    new Cookie("session_id", "abc123") { Path = "/" });

// Lire les cookies actuels pour un domaine
foreach (Cookie c in cookies.GetCookies(new Uri("https://site.ru")))
    Console.WriteLine($"{c.Name} = {c.Value}");

Points de détail : - Un conteneur = une session. Pour un scraping parallèle avec des « identités » différentes, créez un handler + conteneur distinct pour chaque session/proxy. - Pour désactiver les cookies (par exemple pour que chaque requête soit « propre »), définissez UseCookies = false. - Pour conserver/restaurer une session entre deux exécutions, sérialisez les cookies (nom, valeur, domaine, chemin, expiration) en JSON.


9. Gestion du HTTPS/SSL

Par défaut, HttpClient établit lui-même la connexion TLS et vérifie le certificat du serveur. En général, rien à configurer. Une intervention n'est nécessaire que dans de rares cas.

Ignorer les erreurs de certificat (prudence !)

Il arrive qu'un site ait un certificat « cassé »/auto-signé et qu'il faille quand même le télécharger. On peut désactiver la vérification ainsi, mais uniquement en connaissance de cause — vous perdez la protection contre les attaques MITM :

c#
var handler = new SocketsHttpHandler
{
    SslOptions = new System.Net.Security.SslClientAuthenticationOptions
    {
        // ATTENTION : accepte n'importe quel certificat. Uniquement pour le débogage / des tâches de confiance.
        RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
    }
};

(Pour le classique HttpClientHandler, l'équivalent est ServerCertificateCustomValidationCallback, avec un raccourci prêt à l'emploi : HttpClientHandler.DangerousAcceptAnyServerCertificateValidator.)

Contrôle de la version de TLS

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

Ne désactivez pas la vérification des certificats « au cas où » en production. Cela ouvre la voie à l'interception du trafic. À utiliser ponctuellement et uniquement là où c'est vraiment nécessaire.


10. Utilisation de proxys

Les proxys servent à (a) contourner les blocages par IP, (b) répartir la charge et réduire le risque de bannissement grâce à la rotation d'adresses.

Configuration de base d'un proxy HTTP

c#
var proxy = new WebProxy("http://proxy-host:8080")
{
    Credentials = new NetworkCredential("user", "password") // si une authentification est requise
};

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

Proxy SOCKS (natif en .NET 6+)

Depuis .NET 6, les schémas socks4, socks4a et socks5 sont pris en charge directement dans WebProxy — plus besoin de bibliothèques tierces :

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

Rotation de proxys

La stratégie la plus simple : un pool de proxys, chacun avec son propre HttpClient (le handler avec proxy est réutilisé !), pris à tour de rôle ou au hasard. Les proxys ayant renvoyé une erreur/un timeout sont temporairement « pénalisés ».

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];
    }
}

Important : ne créez pas un nouveau handler avec proxy à chaque requête — cela nous ramènerait à l'épuisement des sockets. Créez un HttpClient par proxy et réutilisez-le.


11. Scraping via TOR

TOR offre l'anonymat et une rotation d'IP gratuite. Concrètement, c'est un proxy SOCKS5 local.

Connexion à TOR comme SOCKS5

Après installation de Tor (le démon tor ou Tor Browser), un proxy SOCKS5 est lancé sur la machine, par défaut sur 127.0.0.1:9050 (9150 pour Tor Browser).

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

Changement d'IP (nouveau circuit) via le Control Port

L'atout principal : on peut demander un nouveau circuit (une nouvelle IP de sortie) avec la commande SIGNAL NEWNYM envoyée au port de contrôle (par défaut 9051), voir la spécification du protocole de contrôle de Tor. Il faut l'activer dans torrc :

code
ControlPort 9051
# et l'authentification, par exemple un mot de passe haché :
HashedControlPassword 16:...   # obtenu via `tor --hash-password "mypass"`

Demande d'un nouveau circuit en TCP brut :

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

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

Gardez à l'esprit : TOR est lent, et de nombreux sites filtrent le trafic issu des nœuds de sortie. Il existe une limite entre deux NEWNYM (MaxCircuitDirtiness, ~10 s), donc impossible de changer d'IP instantanément à chaque requête. Pour un scraping rapide, les proxys commerciaux sont généralement plus pratiques ; TOR sert avant tout à l'anonymat.


12. Multithreading / parallélisme

Dans le scraping réseau, le goulot d'étranglement est l'attente de la réponse, pas le CPU. Il ne faut donc pas du « multithreading » au sens classique, mais un parallélisme asynchrone avec limitation des requêtes simultanées. Lancer des milliers de requêtes d'un coup est exclu : vous saturerez le réseau, épuiserez les connexions et vous ferez bannir.

Méthode 1 : Parallel.ForEachAsync (.NET 6+) — la plus simple

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

await Parallel.ForEachAsync(
    urls,
    new ParallelOptions { MaxDegreeOfParallelism = 8 }, // pas plus de 8 en simultané
    async (url, ct) =>
    {
        try
        {
            string html = await http.GetStringAsync(url, ct);
            results.Add(Parse(html));
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"FAIL {url}: {ex.Message}");
        }
    });

Méthode 2 : SemaphoreSlim + Task.WhenAll — contrôle flexible

c#
var gate = new SemaphoreSlim(initialCount: 8); // maximum 8 en parallèle

async Task<string?> FetchAsync(string url)
{
    await gate.WaitAsync();
    try
    {
        return await http.GetStringAsync(url);
    }
    catch { return null; }
    finally { gate.Release(); }
}

string?[] pages = await Task.WhenAll(urls.Select(FetchAsync));

Méthode 3 : System.Threading.Channels — pipeline « producteur-consommateur »

Pour un crawler de longue durée, c'est le meilleur pattern : une file d'URL, plusieurs workers consommateurs. S'articule bien avec la section 14.

c#
using System.Threading.Channels;

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

// On lance N workers
int workers = 8;
var consumers = Enumerable.Range(0, workers).Select(_ => Task.Run(async () =>
{
    await foreach (string url in channel.Reader.ReadAllAsync())
    {
        try
        {
            string html = await http.GetStringAsync(url);
            var newLinks = ExtractLinks(html);
            foreach (var link in newLinks)
                await channel.Writer.WriteAsync(link); // on ajoute les nouvelles URL à la file
        }
        catch { /* log + réessai */ }
    }
})).ToArray();

// On alimente avec les URL de départ
foreach (var seed in seeds)
    await channel.Writer.WriteAsync(seed);

// channel.Writer.Complete(); // quand on décide que le crawl est terminé
await Task.WhenAll(consumers);

Ajustez le degré de parallélisme au site visé : 4–16 est la plage typique. Des centaines de requêtes simultanées vers un même domaine, c'est déjà un DoS et un bannissement quasi garanti.


13. Robustesse : timeouts, réessais, politesse, robots.txt

Ce point ne figurait pas dans la liste initiale, mais sans lui, un scraper « de production » ne survit pas.

Réessais avec délai exponentiel (Polly)

La bibliothèque Polly fournit des politiques déclaratives de réessai, circuit breaker, timeouts. Elle fonctionne particulièrement bien avec 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
    );

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

Politesse (rate limiting) et robots.txt

  • Un délai entre les requêtes vers un même domaine (par exemple 0,5–2 s) réduit la charge sur le site et le risque de bannissement. .NET 7+ propose System.Threading.RateLimiting.
  • robots.txt — le fichier de règles pour les bots (Disallow, Crawl-delay). Juridiquement, il n'est pas contraignant partout, mais l'ignorer est de mauvais goût et une source de conflits. Respectez Crawl-delay et les sections interdites.
c#
// Délai « poli » le plus simple par domaine
var lastHit = new System.Collections.Concurrent.ConcurrentDictionary<string, DateTime>();

async Task PolitelyAsync(Uri uri, TimeSpan minDelay)
{
    string host = uri.Host;
    if (lastHit.TryGetValue(host, out var prev))
    {
        var wait = minDelay - (DateTime.UtcNow - prev);
        if (wait > TimeSpan.Zero) await Task.Delay(wait);
    }
    lastHit[host] = DateTime.UtcNow;
}

Timeouts et annulation

En plus de HttpClient.Timeout, utilisez un CancellationToken (commun à tout le crawler) — pour arrêter proprement le scraper au Ctrl+C et ne pas laisser le processus pendu sur des connexions « mortes ».


14. Stockage des URL et files d'attente (frontier)

La file des URL à explorer s'appelle le frontier. Ses missions : garder trace de ce qui reste à télécharger et ne jamais télécharger deux fois la même chose.

Déduplication (ensemble des URL visitées)

c#
// Ensemble thread-safe des URL déjà vues
var visited = new System.Collections.Concurrent.ConcurrentDictionary<string, byte>();

bool TryEnqueue(string url)
{
    string norm = Normalize(url); // la normalisation des URL est critique !
    return visited.TryAdd(norm, 0); // true si cette URL n'existait pas encore
}

La normalisation des URL est obligatoire, sinon site.ru/p?a=1&b=2 et site.ru/p?b=2&a=1 seront considérées comme différentes. Le minimum : hôte en minuscules, suppression du #fragment, tri des paramètres de la query, suppression du slash final, normalisation du schéma.

Options de stockage de la file

Échelle Solution
Petit volume, un seul processus ConcurrentQueue<string> ou Channel<string> en mémoire
Besoin de résister aux redémarrages SQLite / LiteDB : table urls(url, status, depth, added_at)
Crawler distribué Redis (file + SET des URL visitées) ou un broker (RabbitMQ, Kafka)
Ensemble de visites gigantesque, mémoire coûteuse Bloom filter (compact, mais avec des faux positifs)

Frontier minimal sur SQLite (pour les redémarrages)

L'idée : stockez les URL avec un statut (pending / in_progress / done / failed) et une profondeur. Au démarrage, vous prenez les pending ; après téléchargement, vous marquez done ; les nouveaux liens sont ajoutés avec INSERT OR IGNORE (l'index unique sur l'URL assure la déduplication au niveau de la base).

sql
CREATE TABLE IF NOT EXISTS frontier (
    url     TEXT PRIMARY KEY,    -- URL normalisée = dédup
    status  TEXT NOT NULL DEFAULT 'pending',
    depth   INTEGER NOT NULL DEFAULT 0,
    added   TEXT NOT NULL
);

Le crawler peut ainsi être arrêté puis repris au même endroit — la file survit au redémarrage.

À grande échelle, on ajoute des priorités (pages importantes en premier), une limite de profondeur, un plafond de pages par domaine et une « politique de politesse » directement dans le frontier.


15. Contenu JavaScript : navigateurs headless

HttpClient télécharge le HTML source, avant l'exécution du JavaScript. Si le site est une SPA (React/Vue/Angular) et que les données sont chargées par des scripts, elles n'apparaîtront pas dans le HTML source. Options :

  1. Trouver l'API. Souvent, la SPA appelle un endpoint JSON — ouvrez DevTools → Network, repérez la requête portant les données et appelez-la directement via HttpClient. C'est plus rapide et plus fiable que n'importe quel navigateur.
  2. Navigateur headless, si l'API est introuvable : il rend réellement la page.

Playwright pour .NET (recommandé)

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-site.ru/products");
await page.WaitForSelectorAsync(".product-card"); // on attend l'apparition des données

// On peut extraire via les sélecteurs de Playwright...
var names = await page.Locator(".product-card h2").AllTextContentsAsync();

// ...ou récupérer le HTML rendu et le parser avec la bibliothèque habituelle
string renderedHtml = await page.ContentAsync();

Alternatives : Selenium WebDriver (le classique, mais plus lourd) et PuppeteerSharp (portage de Puppeteer). Aujourd'hui, en .NET, on choisit le plus souvent Playwright — officiellement maintenu par Microsoft et plus pratique.

Inconvénients des navigateurs headless : des dizaines de fois plus lents et gourmands en ressources que HttpClient. Ne les utilisez que lorsque le rendu est indispensable.


16. Sauvegarde des résultats

Les données doivent être stockées quelque part. Options typiques :

c#
// JSON (System.Text.Json) — pratique pour les données imbriquées
await using var fs = File.Create("data.json");
await System.Text.Json.JsonSerializer.SerializeAsync(fs, items,
    new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
  • CSV — pour les données tabulaires (bibliothèque CsvHelper).
  • JSON / JSONL — pour les structures imbriquées (System.Text.Json) ; le JSONL (un objet par ligne) est pratique pour l'écriture en flux de gros volumes.
  • Base de données (SQLite/PostgreSQL via EF Core ou Dapper) — quand il faut des requêtes, une dédup par contenu, des mises à jour incrémentales.

Conseil : écrivez les résultats en flux, au fil du parsing, plutôt que de tout accumuler en mémoire — sinon, sur les gros crawls, vous finirez en OutOfMemory.


17. Principaux avantages et inconvénients d'une implémentation en C#

Avantages

  • Performance et asynchronisme. async/await, HttpClient, Channels, Parallel.ForEachAsync offrent des I/O concurrentes efficaces « clé en main ».
  • Écosystème mature. HtmlAgilityPack, AngleSharp, Playwright, Polly — le tout de qualité industrielle.
  • Typage statique. Moins d'erreurs bêtes dans les gros crawlers, refactoring confortable.
  • SOCKS/proxy natifs depuis .NET 6+, gestion simple du TLS et des cookies.
  • Multiplateforme (.NET tourne sous Linux/Windows/macOS, facile à dockeriser).

Inconvénients

  • Encodages. Pas de windows-1251 par défaut — il faut penser à CodePagesEncodingProvider (section 6).
  • Sites JS. HttpClient seul n'exécute pas le JS ; il faut un navigateur headless — lourd et lent.
  • Systèmes anti-bot. Cloudflare, captcha, fingerprinting sont difficiles à contourner ; un scraper « honnête » bute souvent sur la protection.
  • Fragilité face au balisage. Tout scraper casse quand la structure HTML du site change — il faut de la surveillance et de la maintenance.
  • Moins de frameworks prêts à l'emploi qu'en Python. Python a Scrapy « tout-en-un » ; en .NET, on assemble plus souvent le pipeline soi-même brique par brique (même s'il existe DotnetSpider, Abot).

18. Aspects juridiques et éthiques

Savoir techniquement le faire ne signifie pas en avoir le droit. En bref, ce qu'il faut garder en tête (ceci n'est pas un conseil juridique) :

  • Les conditions d'utilisation du site (ToS) peuvent interdire explicitement la collecte automatisée. Les enfreindre est un motif de blocage et de réclamations.
  • Les données personnelles sont encadrées par la loi (RGPD, etc.). Collecter et stocker des données personnelles sans base légale, c'est prendre des risques.
  • Droit d'auteur. Le contenu copié est souvent protégé ; sa republication peut violer des droits.
  • Charge. Un scraping agressif = un DoS de fait. Respectez Crawl-delay, limitez le RPS, ne mettez pas à genoux le serveur d'autrui.
  • robots.txt et les API publiques sont la voie à privilégier. Si le site propose une API officielle, il est presque toujours préférable de l'utiliser.

Règle de base : scrapez poliment, de manière identifiable (là où c'est pertinent — avec un User-Agent honnête), respectez les restrictions du site et la loi de votre juridiction.


Ressources officielles

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

Téléchargement et réseau (.NET / Microsoft) - HttpClient — API · bonnes pratiques d'utilisation · IHttpClientFactory - WebProxy — API - System.Text.Encoding.CodePages — NuGet · CodePagesEncodingProvider

Parallélisme et robustesse - Parallel.ForEachAsync — API - System.Threading.Channels — guide - System.Threading.RateLimiting — API - Polly — documentation · GitHub · Microsoft.Extensions.Http.Polly

Navigateurs headless - Playwright pour .NET — documentation · GitHub · NuGet - Selenium WebDriver — documentation - PuppeteerSharp — site

Anonymat - Tor Project — site · spécification du protocole de contrôle

Stockage et sérialisation - System.Text.Json — vue d'ensemble - CsvHelper — documentation - EF Core — documentation · Dapper — GitHub - SQLite — site · LiteDB — site · Redis — site · RabbitMQ — site

Frameworks de crawling prêts à l'emploi - DotnetSpider — GitHub · Abot — GitHub


Ce document peut servir de plan étape par étape : chaque section est une « brique » du scraper, à assembler dans le pipeline global frontier → fetcher → parser → storage.