Go est l'un des langages les plus pratiques pour écrire des scrapers et des crawlers : le typage statique attrape les erreurs à la compilation, les goroutines offrent une concurrence légère prête à l'emploi, et la bibliothèque standard net/http couvre l'essentiel du travail réseau sans dépendances externes. Dans cet article, nous parcourons tout le chemin — du chargement d'une seule page jusqu'au crawler distribué et concurrent avec proxys, TOR et files d'attente.
Tout le code a été vérifié sur Go 1.21+. Les packages tiers s'installent via go get ; les commandes exactes figurent dans les sections correspondantes.
Sommaire
- Comment récupérer une page
- Bibliothèques pour parser le contenu
- Résoudre les problèmes de parsing du cyrillique
- La concurrence
- Utiliser des proxys
- Scraper via TOR
- Travailler avec HTTPS / SSL
- Gérer les cookies
- Statut de réponse et en-têtes
- Pour aller plus loin : politesse, robots.txt, User-Agent, rendu JS, retry
- Stockage des URL et files d'attente
- Avantages et inconvénients d'une implémentation en Go
- Conclusion
1. Comment récupérer une page
La version la plus simple
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
resp, err := http.Get("https://example.com")
if err != nil {
panic(err)
}
defer resp.Body.Close() // fermeture obligatoire du corps — sinon fuite de connexions
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
http.Get utilise le client global http.DefaultClient, qui n'a pas de timeout. Pour un scraper en production, c'est rédhibitoire : un seul serveur qui ne répond plus bloquera la goroutine pour toujours.
La bonne approche : un client dédié avec timeout et en-têtes
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
func fetch(ctx context.Context, rawURL string) ([]byte, *http.Response, error) {
client := &http.Client{
Timeout: 15 * time.Second, // timeout global pour toute la requête
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, nil, err
}
// Beaucoup de sites renvoient un 403 sans User-Agent « humain ».
req.Header.Set("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "+
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru,en;q=0.9")
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return body, resp, err
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
body, resp, err := fetch(ctx, "https://example.com")
if err != nil {
panic(err)
}
fmt.Println("Statut :", resp.StatusCode, "| Taille :", len(body))
}
Points clés :
context.Context— le mécanisme unifié d'annulation. Si le contexte expire ou est annulé, la requête est interrompue. Indispensable pour un arrêt propre (graceful shutdown) et pour borner la durée totale de l'exploration.- Le
Timeoutdu client limite tout : établissement de la connexion, envoi, lecture du corps. Un réglage plus fin est possible via votre proprehttp.Transport(voir ci-dessous). defer resp.Body.Close()— un corps non fermé garde la connexion TCP ouverte et empêche sa réutilisation depuis le pool. À grande échelle, cela finit par l'erreur « too many open files ».
Réglage fin du Transport
http.Transport est le « moteur » sous le client. Une même instance de transport est réutilisée entre les requêtes et maintient un pool de connexions keep-alive : créez-la une seule fois pour toute l'application, et non à chaque requête.
transport := &http.Transport{
MaxIdleConns: 100, // total de connexions idle dans le pool
MaxIdleConnsPerHost: 10, // par hôte
IdleConnTimeout: 90 * time.Second, // durée de vie d'une connexion idle
DisableCompression: false, // le gzip est décompressé automatiquement
ForceAttemptHTTP2: true,
}
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}
Erreur fréquente : créer
&http.Client{}(ou le transport) à l'intérieur de la boucle, pour chaque URL. Cela casse le pool de connexions et mène à l'épuisement des ports. Créez le client une seule fois et passez-le par référence.
2. Bibliothèques pour parser le contenu
Une fois le HTML chargé, il faut l'analyser. Il existe trois niveaux d'outils.
2.1. goquery — une syntaxe façon jQuery (le plus populaire)
go get github.com/PuerkitoBio/goquery
package main
import (
"fmt"
"net/http"
"github.com/PuerkitoBio/goquery"
)
func main() {
resp, _ := http.Get("https://news.ycombinator.com")
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
panic(err)
}
// Sélecteurs CSS comme en jQuery
doc.Find(".titleline > a").Each(func(i int, s *goquery.Selection) {
title := s.Text()
href, _ := s.Attr("href")
fmt.Printf("%d. %s — %s\n", i+1, title, href)
})
}
goquery prend en charge presque tout CSS3 : .class, #id, [attr=value], :first-child, :nth-of-type(n), les combinateurs >, +, ~. C'est le meilleur choix pour la plupart des tâches d'analyse HTML.
2.2. colly — un framework de crawling complet
go get github.com/gocolly/colly/v2
Colly prend en charge le chargement, l'analyse, le suivi des liens, la limitation de débit, la mise en cache et bien plus — ce n'est plus un simple scraper, mais un moteur de crawler.
package main
import (
"fmt"
"time"
"github.com/gocolly/colly/v2"
)
func main() {
c := colly.NewCollector(
colly.AllowedDomains("example.com"),
colly.MaxDepth(2),
colly.Async(true), // exploration asynchrone
)
// Limitation du parallélisme et délais — la politesse intégrée
c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 4,
Delay: 500 * time.Millisecond,
RandomDelay: 500 * time.Millisecond,
})
// Callback pour chaque titre trouvé
c.OnHTML("h1, h2", func(e *colly.HTMLElement) {
fmt.Println("Titre :", e.Text)
})
// Suivi de tous les liens
c.OnHTML("a[href]", func(e *colly.HTMLElement) {
link := e.Request.AbsoluteURL(e.Attr("href"))
e.Request.Visit(link)
})
c.OnRequest(func(r *colly.Request) {
fmt.Println("Chargement :", r.URL)
})
c.OnError(func(r *colly.Response, err error) {
fmt.Println("Erreur :", r.Request.URL, err)
})
c.Visit("https://example.com")
c.Wait() // on attend la fin de toutes les requêtes asynchrones
}
Choisissez Colly quand il s'agit vraiment d'explorer un site (crawling), et non d'analyser une seule page.
2.3. golang.org/x/net/html — le tokenizer bas niveau
Package standard (semi-standard). Il donne un contrôle maximal et zéro dépendance, mais l'utiliser à la main est fastidieux — c'est une analyse en flux, token par token.
package main
import (
"fmt"
"strings"
"golang.org/x/net/html"
)
func main() {
r := strings.NewReader(`<html><body><a href="/x">Lien</a></body></html>`)
tokenizer := html.NewTokenizer(r)
for {
tt := tokenizer.Next()
if tt == html.ErrorToken {
break // fin du document
}
if tt == html.StartTagToken {
t := tokenizer.Token()
if t.Data == "a" {
for _, a := range t.Attr {
if a.Key == "href" {
fmt.Println("href:", a.Val)
}
}
}
}
}
}
Utilisez-le quand la vitesse compte sur d'énormes documents ou quand goquery paraît « trop lourd ».
2.4. JSON et API plutôt que HTML
Souvent, les données de la page sont chargées par une requête AJAX séparée qui renvoie du JSON. C'est le cas le plus confortable — parser du JSON est plus fiable que du HTML :
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
var products []Product
resp, _ := http.Get("https://api.example.com/products")
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&products)
Avant de parser le HTML, ouvrez l'onglet Network des DevTools — l'endpoint JSON dont vous avez besoin existe peut-être déjà.
2.5. XPath
Si XPath vous est plus familier, il existe github.com/antchfx/htmlquery :
doc, _ := htmlquery.LoadURL("https://example.com")
nodes := htmlquery.Find(doc, "//div[@class='item']/a/@href")
Comparatif
| Outil | Quand le choisir | Dépendances |
|---|---|---|
goquery |
Analyse HTML, sélecteurs CSS | 1 |
colly |
Exploration de sites entiers | plusieurs |
x/net/html |
Contrôle/vitesse au maximum | semi-std |
encoding/json |
API/AJAX renvoyant du JSON | std |
htmlquery |
Vous aimez XPath | 1 |
3. Encodages et Unicode : résoudre les problèmes de parsing
Douleur classique : vous chargez un site russe et, à la place du texte, vous obtenez пÑÐ¸Ð²ÐµÑ ou пðèâåò. La cause : le site renvoie son contenu non pas en UTF-8 mais en Windows-1251 (ou KOI8-R), alors que Go considère par défaut tous les octets d'une chaîne comme de l'UTF-8.
La solution universelle : le détecteur de charset
Le package golang.org/x/net/html/charset détermine l'encodage d'après l'en-tête Content-Type, la balise <meta charset> et des heuristiques sur le contenu, puis fournit un reader qui transcode le flux en UTF-8 à la volée.
go get golang.org/x/net/html
go get golang.org/x/text
package main
import (
"fmt"
"io"
"net/http"
"github.com/PuerkitoBio/goquery"
"golang.org/x/net/html/charset"
)
func main() {
resp, _ := http.Get("https://some-windows1251-site.ru")
defer resp.Body.Close()
// charset.NewReader détecte lui-même l'encodage et transcode en UTF-8
utf8Reader, err := charset.NewReader(resp.Body, resp.Header.Get("Content-Type"))
if err != nil {
panic(err)
}
doc, err := goquery.NewDocumentFromReader(utf8Reader)
if err != nil {
panic(err)
}
fmt.Println(doc.Find("title").Text()) // le cyrillique s'affiche désormais correctement
_ = io.Discard
}
Cette solution fonctionne dans 95 % des cas — il est recommandé de l'adopter par défaut.
Indiquer l'encodage explicitement
Si vous connaissez l'encodage avec certitude (par exemple un site toujours en Windows-1251), vous pouvez transcoder manuellement via golang.org/x/text/encoding :
import (
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
// Windows-1251 → UTF-8
decoder := charmap.Windows1251.NewDecoder()
reader := transform.NewReader(resp.Body, decoder)
body, _ := io.ReadAll(reader)
fmt.Println(string(body))
Pour KOI8-R — charmap.KOI8R, pour CP866 (DOS) — charmap.CodePage866.
Le problème inverse — envoyer du texte non ASCII
S'il faut envoyer un POST contenant du cyrillique en Windows-1251 (par exemple vers un ancien formulaire) :
encoder := charmap.Windows1251.NewEncoder()
encoded, _ := encoder.String("Привет мир")
// encoded est maintenant en octets cp1251 — on l'envoie dans le corps de la requête
Si les caractères illisibles n'apparaissent que dans la console Windows, alors que tout s'écrit correctement dans un fichier, le problème vient non pas du scraper mais de l'encodage du terminal. Exécutez
chcp 65001pour basculer cmd en UTF-8.
4. La concurrence
C'est ici que Go se révèle pleinement. Les goroutines coûtent des milliers de fois moins cher que les threads de l'OS, et les canaux offrent un échange de données sûr sans mutex explicites.
4.1. L'approche naïve (à éviter)
// Mauvais : on lance une goroutine pour CHAQUE url, sans limite
for _, url := range urls {
go fetch(url) // 100 000 url → 100 000 requêtes simultanées → serveur à terre ou bannissement
}
Sans limiter le parallélisme, vous ferez tomber le serveur cible, épuiserez la limite de descripteurs de fichiers ou récolterez un bannissement immédiat.
4.2. Worker Pool — le pattern canonique
On crée un nombre fixe de workers qui consomment les tâches depuis un canal. Le parallélisme est borné par le nombre de workers.
package main
import (
"fmt"
"io"
"net/http"
"sync"
"time"
)
type Result struct {
URL string
Status int
Size int
Err error
}
func worker(id int, client *http.Client, jobs <-chan string, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for url := range jobs {
resp, err := client.Get(url)
if err != nil {
results <- Result{URL: url, Err: err}
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
results <- Result{URL: url, Status: resp.StatusCode, Size: len(body)}
}
}
func main() {
urls := []string{
"https://example.com",
"https://golang.org",
"https://news.ycombinator.com",
// ... des milliers d'url
}
const numWorkers = 8
client := &http.Client{Timeout: 10 * time.Second}
jobs := make(chan string, 100)
results := make(chan Result, 100)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(i, client, jobs, results, &wg)
}
// On soumet les tâches dans une goroutine séparée
go func() {
for _, u := range urls {
jobs <- u
}
close(jobs) // important : on ferme le canal pour que les workers se terminent
}()
// On ferme results quand tous les workers ont fini
go func() {
wg.Wait()
close(results)
}()
// Lecture des résultats
for r := range results {
if r.Err != nil {
fmt.Printf("[err] %s: %v\n", r.URL, r.Err)
} else {
fmt.Printf("[ok] %s [%d] %d bytes\n", r.URL, r.Status, r.Size)
}
}
}
Décryptage du pattern :
jobs— le canal des tâches entrantes. On le ferme après avoir soumis toutes les URL — c'est le signal pour les workers de sortir de leur bouclefor range.sync.WaitGroup— on attend que tous les workers aient terminé.resultsest fermé par une goroutine dédiée aprèswg.Wait(), sinon lefor range resultsprincipal resterait bloqué pour toujours.- Le parallélisme se règle avec une seule constante :
numWorkers.
4.3. Limitation par sémaphore (errgroup)
L'approche plus moderne : golang.org/x/sync/errgroup avec une limite. Il collecte aussi commodément la première erreur et prend en charge le contexte.
go get golang.org/x/sync/errgroup
package main
import (
"context"
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
)
func main() {
urls := []string{"https://example.com", "https://golang.org" /* ... */}
g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(8) // 8 goroutines simultanées au maximum
client := &http.Client{}
for _, u := range urls {
u := u // important pour Go < 1.22 : capture de la variable de boucle
g.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
fmt.Println(u, resp.StatusCode)
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("Une des tâches a échoué :", err)
}
}
Avant Go 1.22, la variable de boucle est réutilisée : la ligne
u := uest donc obligatoire — sans elle, toutes les goroutines recevraient la même (dernière) URL. Go 1.22+ corrige cela au niveau du langage, mais l'habitude ne fait pas de mal.
4.4. Protéger les données partagées
Si les workers écrivent dans une map commune (par exemple l'ensemble des URL visitées), il faut la protéger :
var (
visited = make(map[string]bool)
mu sync.Mutex
)
func markVisited(url string) bool {
mu.Lock()
defer mu.Unlock()
if visited[url] {
return false // déjà visité
}
visited[url] = true
return true
}
Alternatives : sync.Map (adaptée aux « beaucoup de lectures, peu d'écritures ») ou sync/atomic pour les compteurs. Lancez vos tests avec le flag -race — le détecteur de course de Go trouve ces bugs automatiquement.
5. Utiliser des proxys
Les proxys servent à répartir la charge, contourner les blocages géographiques et réduire le risque de bannissement par IP.
Un proxy par client
package main
import (
"net/http"
"net/url"
)
func clientWithProxy(proxyAddr string) (*http.Client, error) {
// Le format user:pass@host:port est pris en charge
proxyURL, err := url.Parse(proxyAddr) // ex. "http://user:pass@1.2.3.4:8080"
if err != nil {
return nil, err
}
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
return &http.Client{Transport: transport}, nil
}
Les schémas http://, https:// et socks5:// sont pris en charge. Pour l'authentification, l'identifiant et le mot de passe se placent directement dans l'URL.
Rotation d'un pool de proxys
Pour répartir les requêtes sur une liste de proxys, on peut remplacer la fonction Proxy du transport — elle est appelée à chaque requête :
package main
import (
"math/rand"
"net/http"
"net/url"
"sync/atomic"
)
type ProxyRotator struct {
proxies []*url.URL
counter uint64
}
func NewProxyRotator(addrs []string) *ProxyRotator {
r := &ProxyRotator{}
for _, a := range addrs {
if u, err := url.Parse(a); err == nil {
r.proxies = append(r.proxies, u)
}
}
return r
}
// Sélection du proxy en round-robin
func (r *ProxyRotator) Next(_ *http.Request) (*url.URL, error) {
if len(r.proxies) == 0 {
return nil, nil // sans proxy
}
i := atomic.AddUint64(&r.counter, 1)
return r.proxies[i%uint64(len(r.proxies))], nil
}
func main() {
rotator := NewProxyRotator([]string{
"http://user:pass@10.0.0.1:8080",
"http://user:pass@10.0.0.2:8080",
"socks5://10.0.0.3:1080",
})
transport := &http.Transport{
Proxy: rotator.Next, // à chaque requête — le proxy suivant
}
client := &http.Client{Transport: transport}
_ = client
_ = rand.Int
}
En pratique, stockez à côté de chaque proxy son « état de santé » : compteur d'erreurs, date du dernier bannissement. Un proxy mort est temporairement exclu de la rotation. On écrit généralement pour cela une petite surcouche qui teste le proxy sur un endpoint « vivant » avant de l'utiliser.
6. Scraper via TOR
TOR est un réseau d'anonymisation gratuit, accessible localement comme proxy SOCKS5 (par défaut sur 127.0.0.1:9050). Scraper via TOR est utile pour l'anonymat et le changement automatique d'IP, mais c'est lent, et beaucoup de sites bloquent les nœuds de sortie TOR.
Préparation
Lancez le démon TOR. Le plus simple est de passer par Docker :
docker run -d --name tor -p 9050:9050 -p 9051:9051 dperson/torproxy
Ou installez le paquet système tor (apt install tor, brew install tor) — il ouvrira lui-même le SOCKS5 sur le port 9050.
Client HTTP via TOR (SOCKS5)
go get golang.org/x/net/proxy
package main
import (
"fmt"
"io"
"net/http"
"golang.org/x/net/proxy"
)
func torClient() (*http.Client, error) {
// Connexion au SOCKS5 local de TOR
dialer, err := proxy.SOCKS5("tcp", "127.0.0.1:9050", nil, proxy.Direct)
if err != nil {
return nil, err
}
transport := &http.Transport{
Dial: dialer.Dial, // tout le trafic passe par TOR
}
return &http.Client{Transport: transport}, nil
}
func main() {
client, err := torClient()
if err != nil {
panic(err)
}
// check.torproject.org confirmera que nous passons bien par TOR
resp, err := client.Get("https://check.torproject.org/api/ip")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body)) // {"IsTor":true,"IP":"..."}
}
Changer d'IP (nouveau circuit) via le port de contrôle
TOR sait construire un nouveau circuit sur commande — c'est-à-dire changer d'IP de sortie. Cela passe par le port de contrôle (9051) et l'envoi du signal NEWNYM :
package main
import (
"fmt"
"net/textproto"
)
func newTorIdentity(controlPassword string) error {
conn, err := textproto.Dial("tcp", "127.0.0.1:9051")
if err != nil {
return err
}
defer conn.Close()
// Authentification (le mot de passe doit être configuré dans torrc)
if _, _, err := conn.Cmd(`AUTHENTICATE "%s"`, controlPassword); err != nil {
return err
}
conn.ReadResponse(250)
// Signal de changement de circuit
id, _ := conn.Cmd("SIGNAL NEWNYM")
conn.StartResponse(id)
defer conn.EndResponse(id)
_, msg, err := conn.ReadResponse(250)
fmt.Println("Réponse de TOR :", msg)
return err
}
Pour que le port de contrôle fonctionne, le fichier torrc doit définir ControlPort 9051 et le hash du mot de passe (HashedControlPassword, généré par la commande tor --hash-password VOTRE_MOT_DE_PASSE).
TOR offre l'anonymat, pas l'invisibilité. La vitesse est faible, les nœuds de sortie figurent souvent sur liste noire, et un scraping agressif via TOR est un manque de savoir-vivre envers un réseau bénévole. Pour la collecte massive, les proxys résidentiels commerciaux conviennent mieux.
7. Travailler avec HTTPS / SSL
Bonne nouvelle : pour HTTPS, il n'y a généralement rien à faire — Go vérifie les certificats automatiquement en s'appuyant sur le magasin système des racines de confiance.
Quand faut-il tout de même intervenir
1. Certificats auto-signés (sites de test/internes). Désactiver la vérification règle le problème, mais ouvre la porte au MITM — réservé aux tests :
import "crypto/tls"
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // PAS pour la production !
},
}
client := &http.Client{Transport: transport}
2. La bonne méthode — ajouter le certificat racine concerné au pool de confiance :
package main
import (
"crypto/tls"
"crypto/x509"
"net/http"
"os"
)
func clientWithCustomCA(caCertPath string) (*http.Client, error) {
caCert, err := os.ReadFile(caCertPath)
if err != nil {
return nil, err
}
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
transport := &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caPool,
MinVersion: tls.VersionTLS12, // TLS 1.2 minimum
},
}
return &http.Client{Transport: transport}, nil
}
3. Contrôler la version TLS et les suites de chiffrement. Parfois un site exige une configuration précise ou, à l'inverse, vous voulez imiter un navigateur donné :
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS13,
}
Fingerprinting TLS. Les systèmes anti-bots avancés (Cloudflare, Akamai) repèrent les bots à l'« empreinte » de la poignée de main TLS (JA3/JA4) — le client Go standard a une empreinte reconnaissable. Pour se faire passer pour un vrai navigateur, on utilise la bibliothèque
github.com/refraction-networking/utls, capable d'imiter le ClientHello de Chrome/Firefox. C'est déjà un sujet avancé d'anti-détection.
8. Gérer les cookies
Les cookies servent aux sessions, à l'authentification et au « passage » des vérifications. Go sait les gérer automatiquement via cookiejar.
Stockage automatique des cookies
package main
import (
"fmt"
"net/http"
"net/http/cookiejar"
"golang.org/x/net/publicsuffix"
)
func main() {
// La liste publicsuffix est nécessaire pour traiter correctement les domaines
jar, err := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
if err != nil {
panic(err)
}
client := &http.Client{Jar: jar}
// Première requête : le serveur pose un cookie (ex. une session)
client.Get("https://example.com/login")
// La deuxième requête enverra automatiquement les cookies enregistrés
resp, _ := client.Get("https://example.com/dashboard")
defer resp.Body.Close()
// Inspecter le contenu du jar pour un hôte donné
u, _ := resp.Request.URL.Parse("https://example.com")
for _, c := range jar.Cookies(u) {
fmt.Printf("%s = %s\n", c.Name, c.Value)
}
}
Avec un Jar, le client se comporte comme un navigateur : il accepte les Set-Cookie des réponses et les joint aux requêtes suivantes vers le même domaine.
Poser un cookie manuellement
Quand vous possédez déjà le token de session (copié depuis le navigateur) et qu'il suffit d'« injecter » la session :
req, _ := http.NewRequest("GET", "https://example.com/account", nil)
req.AddCookie(&http.Cookie{Name: "session_id", Value: "abc123xyz"})
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: "tok456"})
resp, _ := client.Do(req)
Scénario d'authentification typique (login via POST)
import (
"net/url"
"strings"
)
form := url.Values{}
form.Set("username", "user")
form.Set("password", "pass")
req, _ := http.NewRequest("POST", "https://example.com/login",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Si le client possède un Jar, le cookie de session est enregistré automatiquement
// et toutes les requêtes suivantes seront authentifiées.
resp, _ := client.Do(req)
La persistance du jar de cookies entre deux lancements du programme se fait à la main : on parcourt
jar.Cookies(url), on sérialise dans un fichier JSON, puis on recharge au démarrage viajar.SetCookies(url, cookies). Lecookiejarstandard n'est pas persistant, mais des surcouches toutes prêtes existent (github.com/juju/persistent-cookiejar).
9. Statut de réponse et en-têtes
Après l'exécution de la requête, l'objet *http.Response contient toutes les métadonnées.
resp, err := client.Do(req)
if err != nil {
// Erreur réseau (DNS, timeout, connexion refusée).
// IMPORTANT : si err != nil, alors resp == nil — ne pas toucher resp.Body !
return err
}
defer resp.Body.Close()
// Statut
fmt.Println(resp.StatusCode) // 200, 404, 503 ...
fmt.Println(resp.Status) // "200 OK", "404 Not Found"
// En-têtes individuels (la casse est ignorée)
fmt.Println(resp.Header.Get("Content-Type")) // text/html; charset=utf-8
fmt.Println(resp.Header.Get("Content-Length"))
fmt.Println(resp.Header.Get("Server"))
fmt.Println(resp.Header.Get("Set-Cookie"))
// Un même en-tête peut avoir plusieurs valeurs
for _, v := range resp.Header.Values("Set-Cookie") {
fmt.Println("cookie:", v)
}
// Tous les en-têtes d'un coup
for name, values := range resp.Header {
fmt.Printf("%s: %v\n", name, values)
}
Bien gérer les statuts
switch {
case resp.StatusCode == http.StatusOK: // 200
// on parse le corps
case resp.StatusCode == http.StatusTooManyRequests: // 429
// on nous ralentit — on lit l'en-tête Retry-After et on attend
retryAfter := resp.Header.Get("Retry-After")
fmt.Println("Rate limit, attente :", retryAfter)
case resp.StatusCode >= 500: // 5xx — erreur serveur
// il est judicieux de retenter la requête plus tard
case resp.StatusCode == http.StatusNotFound: // 404
// la page n'existe pas — on n'insiste pas
case resp.StatusCode >= 300 && resp.StatusCode < 400: // 3xx
// redirection ; par défaut, le client Go suit jusqu'à 10 redirections
}
Contrôler les redirections
Par défaut, le client suit les redirections. Pour les désactiver ou les intercepter :
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// On interdit les redirections — la réponse 3xx sera renvoyée « telle quelle »
return http.ErrUseLastResponse
},
}
Retenez la différence : une erreur réseau (
err != nil), c'est quand la requête n'est pas partie ou n'est pas revenue du tout (DNS, timeout). Une erreur HTTP (resp.StatusCode == 404/500) est une réponse valide du serveur, et dans ce caserr == nil. Vérifiez toujours le code de statut séparément deerr.
10. Pour aller plus loin : ces points essentiels souvent oubliés
Ces sections ne figuraient pas dans la liste de départ, mais sans elles, un scraper de production s'écroule.
10.1. Politesse et rate limiting
Ne bombardez pas le serveur de requêtes — c'est à la fois une charge sur l'infrastructure d'autrui et un bannissement rapide. Limitez la fréquence via golang.org/x/time/rate :
import "golang.org/x/time/rate"
// 2 requêtes par seconde, rafale jusqu'à 5
limiter := rate.NewLimiter(rate.Limit(2), 5)
func politeGet(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
if err := limiter.Wait(ctx); err != nil { // bloque tant que ce n'est pas « permis »
return nil, err
}
return client.Get(url)
}
10.2. robots.txt
La bonne pratique (et parfois une nécessité juridique) consiste à respecter le robots.txt, où le site indique ce qui peut être indexé. Le package github.com/temoto/robotstxt aide à l'analyser :
import "github.com/temoto/robotstxt"
resp, _ := http.Get("https://example.com/robots.txt")
data, _ := io.ReadAll(resp.Body)
robots, _ := robotstxt.FromBytes(data)
if robots.TestAgent("/private/page", "MyBot") {
// autorisé — on scrape
} else {
// interdit par robots.txt
}
10.3. Rotation des User-Agent
Le même User-Agent sur des milliers de requêtes est un signe évident de bot. Gardez une liste et choisissez-en un au hasard :
var userAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/124.0 ...",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Safari/605.1 ...",
"Mozilla/5.0 (X11; Linux x86_64) ... Firefox/126.0",
}
req.Header.Set("User-Agent", userAgents[rand.Intn(len(userAgents))])
10.4. Retry avec backoff exponentiel
Le réseau est instable — les échecs temporaires (5xx, timeouts) doivent être retentés, en allongeant la pause :
func fetchWithRetry(ctx context.Context, client *http.Client, url string, maxRetries int) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// 1s, 2s, 4s, 8s... + un peu d'aléatoire (jitter)
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
jitter := time.Duration(rand.Intn(500)) * time.Millisecond
select {
case <-time.After(backoff + jitter):
case <-ctx.Done():
return nil, ctx.Err()
}
}
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue // on réessaie en cas d'erreur réseau
}
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
resp.Body.Close()
lastErr = fmt.Errorf("statut %d", resp.StatusCode)
continue // on réessaie sur 5xx/429
}
return resp, nil // succès
}
return nil, fmt.Errorf("tentatives épuisées : %w", lastErr)
}
10.5. Scraper les pages avec JavaScript
Si le contenu est dessiné par JavaScript (SPA en React/Vue), http.Get renverra un squelette HTML presque vide. Il faut alors un navigateur headless, qui pilote un vrai Chrome via le protocole CDP :
github.com/chromedp/chromedp— piloter Chrome depuis Go ;github.com/go-rod/rod— une alternative de plus haut niveau.
import "github.com/chromedp/chromedp"
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
var html string
chromedp.Run(ctx,
chromedp.Navigate("https://spa-example.com"),
chromedp.WaitVisible(".content"), // on attend que le JS ait rendu la page
chromedp.OuterHTML("html", &html), // on récupère le DOM final
)
// ensuite, on passe html à goquery
Inconvénient : un navigateur headless est lourd (mémoire, CPU) et lent. Avant d'embarquer Chrome, vérifiez — les données sont peut-être accessibles via une API JSON (voir la section 2.4), et le navigateur devient inutile.
10.6. Aspects juridiques et éthiques
Le scraping est une zone grise. Repères de base : respectez le robots.txt et les conditions d'utilisation du site ; ne collectez pas de données personnelles sans base légale (RGPD) ; ne créez pas de charge excessive ; ne présentez pas le contenu d'autrui comme le vôtre. Ce qui est techniquement possible et ce qui est juridiquement admissible sont deux choses différentes.
11. Stockage des URL et files d'attente
Un crawler a besoin de deux structures : une file des URL pas encore explorées (frontier) et un ensemble des URL déjà visitées (pour ne pas tourner en rond).
11.1. In-memory (la version la plus simple)
Pour les petites tâches, un canal en guise de file et une map comme ensemble des visitées suffisent :
type Crawler struct {
queue chan string
visited map[string]bool
mu sync.Mutex
}
func (c *Crawler) enqueue(url string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.visited[url] {
return // déjà vue — on ignore
}
c.visited[url] = true
select {
case c.queue <- url:
default: // file pleine — on abandonne ou on accumule à part
}
}
Les limites de l'in-memory : tout est perdu au redémarrage, et sur des millions d'URL, la map dévore toute la mémoire.
11.2. Déduplication à grande échelle : le filtre de Bloom
Stocker des dizaines de millions de chaînes dans une map coûte cher. Le filtre de Bloom est une structure probabiliste qui occupe peu de mémoire et répond vite « jamais vue, c'est sûr » ou « peut-être déjà vue » (avec une faible probabilité de faux positif) :
import "github.com/bits-and-blooms/bloom/v3"
// ~10 millions d'éléments, taux d'erreur de 1 %
filter := bloom.NewWithEstimates(10_000_000, 0.01)
if filter.TestString(url) {
// probablement déjà vue — on ignore (au risque, rare, de rater une nouvelle)
} else {
filter.AddString(url)
// certainement nouvelle — en file
}
11.3. Files externes (production, distribution)
Quand le crawler doit survivre aux redémarrages et tourner sur plusieurs machines, la file est déportée vers un stockage externe :
| Stockage | Rôle | Particularités |
|---|---|---|
| Redis | file (LPUSH/BRPOP) + ensemble des visitées (SET/SADD) |
rapide, atomique, idéal pour des workers distribués |
| RabbitMQ / Kafka | file de tâches | livraison fiable, ack/nack, renvoi des tâches échouées |
| PostgreSQL / SQLite | frontier persistant | pratique pour stocker URL + métadonnées + statut |
| BadgerDB / bbolt | stockage KV embarqué | pas de serveur séparé, tout dans un seul binaire |
Exemple de file sur Redis :
import "github.com/redis/go-redis/v9"
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// Ajouter l'URL à la file uniquement si elle n'a pas encore été visitée (atomique via SET)
func enqueue(ctx context.Context, url string) error {
// SADD renvoie 1 si l'élément est nouveau
added, err := rdb.SAdd(ctx, "visited", url).Result()
if err != nil {
return err
}
if added == 1 {
return rdb.LPush(ctx, "frontier", url).Err()
}
return nil // doublon
}
// Prendre une URL dans la file (bloquant)
func dequeue(ctx context.Context) (string, error) {
res, err := rdb.BRPop(ctx, 5*time.Second, "frontier").Result()
if err != nil {
return "", err
}
return res[1], nil // res[0] — nom de la clé, res[1] — valeur
}
Ce schéma permet de lancer des dizaines de workers sur des machines différentes : tous prennent leurs tâches dans la file Redis commune et écrivent les résultats dans la même base, sans dupliquer le travail.
11.4. Priorités et stratégie d'exploration
- BFS (file FIFO classique) — explore le site « en largeur », généralement préférable pour le crawling.
- DFS (pile — LIFO) — descend en profondeur dans une seule branche.
- File à priorités (
container/heapou Sorted Set Redis) — on visite d'abord les pages importantes (par exemple selon la « profondeur » ou la valeur estimée).
12. Avantages et inconvénients d'une implémentation en Go
Avantages
- Concurrence prête à l'emploi. Les goroutines et les canaux rendent un crawler concurrent naturel et peu coûteux. Des milliers de requêtes parallèles — sans la douleur des pools de threads.
- Performance. Compilé en code natif, rapide et sobre en mémoire. Un scraper en Go dépasse plusieurs fois son équivalent Python en débit.
- Un seul binaire.
go buildproduit un exécutable autonome, sans dépendances ni interpréteur — déploiement trivial, pratique pour Docker et cron. - Une bibliothèque standard solide.
net/http,crypto/tls,cookiejar,context,encoding/jsoncouvrent presque tout sans packages tiers. - Typage statique. Beaucoup d'erreurs sont attrapées à la compilation, et non au runtime au beau milieu d'une exploration de plusieurs heures.
- Détecteur de course intégré (
-race) — irremplaçable en code concurrent. - Un écosystème mature : colly, goquery, chromedp — des outils éprouvés par le temps.
Inconvénients
- Verbosité de l'analyse HTML. Comparé à Python (
requests+BeautifulSoupen 5 lignes), Go demande plus de code et une gestion d'erreurs explicite. - Plus faible sur les navigateurs headless. Python avec Playwright/Selenium est plus riche en possibilités ; chromedp/rod sont bons, mais l'écosystème est plus modeste.
- Anti-détection plus ardue. Falsifier l'empreinte TLS (utls) et émuler un navigateur demandent plus d'efforts que les solutions toutes prêtes d'autres stacks.
- Gestion d'erreurs manuelle. Les éternels
if err != nilfatiguent, même s'ils disciplinent. - Moins d'outils ML/NLP prêts à l'emploi pour le post-traitement du texte extrait — Python reste ici le leader. Pattern fréquent : collecte de données en Go, analyse en Python.
- Le ticket d'entrée de la concurrence. Canaux, deadlocks, courses — puissant, mais exigeant en compréhension ; un débutant écrira facilement une fuite de goroutines.
Quand choisir Go, et quand s'abstenir
| Scénario | Recommandation |
|---|---|
| Crawler à forte charge, millions de pages | Go — idéal |
| Service de scraping longue durée | Go |
| Script ponctuel « extraire un petit tableau » | Python s'écrira plus vite |
| Rendu JS lourd, anti-détection complexe | Souvent plus commode en Python + Playwright |
| Scraping + analyse ML dans la foulée | Python est plus proche des données |
13. Conclusion
Le schéma minimal d'un scraper de production en Go ressemble à ceci :
- Un seul
http.Clientréutilisable, avec timeout,Transportconfiguré et (en option)cookiejar. - Worker pool ou
errgroupavec limite — pour un parallélisme maîtrisé. - goquery (ou colly pour l'exploration) — pour l'extraction de données, avec
charset.NewReaderpour un cyrillique correct. - Proxys/TOR + rotation des User-Agent — quand il faut de l'anonymat ou contourner les bannissements.
- Rate limiting et respect du
robots.txt— pour ne pas faire tomber le serveur ni se faire bannir. - Retry avec backoff — pour la résilience face aux pannes.
- File externe (Redis) et déduplication (filtre de Bloom) — quand la tâche dépasse la mémoire vive.
Go offre un excellent équilibre entre performance, fiabilité et simplicité de déploiement. Le conseil principal : commencez toujours par vérifier si le site n'expose pas une API JSON ouverte. Si c'est le cas, la moitié des problèmes de cet article (encodages, rendu JS, sélecteurs fragiles) disparaît d'elle-même.