Scraping per linguaggio 27 min di lettura

Web scraping in Go: guida completa, dal semplice al complesso

Guida completa al web scraping in Go: net/http, goquery, Colly, concorrenza con le goroutine e costruzione di un crawler veloce.

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

Go è uno dei linguaggi più comodi per scrivere scraper e crawler: la tipizzazione statica intercetta gli errori in compilazione, le goroutine offrono una concorrenza a basso costo già di serie e la libreria standard net/http copre la maggior parte del lavoro di rete senza dipendenze esterne. In questo articolo percorreremo l’intero cammino — dal download di una singola pagina a un crawler distribuito e multithread con proxy, TOR e code.

Tutto il codice è stato verificato su Go 1.21+. I pacchetti di terze parti si installano con go get; i comandi esatti sono riportati nelle sezioni corrispondenti.


Indice

  1. Come scarichiamo la pagina
  2. Librerie per il parsing del contenuto
  3. Risolvere i problemi di codifica
  4. Concorrenza
  5. Uso dei proxy
  6. Scraping attraverso TOR
  7. Lavorare con HTTPS / SSL
  8. Lavorare con i cookie
  9. Status della risposta e header
  10. Extra: cortesia, robots.txt, User-Agent, rendering JS, retry
  11. Archiviazione degli URL e code
  12. Pro e contro dell’implementazione in Go
  13. Conclusione

1. Come scarichiamo la pagina

L’opzione più semplice

go
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() // chiudiamo sempre il body — altrimenti c'è un leak di connessioni

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}

http.Get usa il client globale http.DefaultClient, che non ha timeout. Per uno scraper di produzione è inaccettabile: un solo server che resta appeso blocca la goroutine per sempre.

L’opzione corretta: un client dedicato con timeout e header

go
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 complessivo per l'intera richiesta
    }

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
    if err != nil {
        return nil, nil, err
    }

    // Molti siti restituiscono 403 senza uno User-Agent «umano».
    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", "it,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("Status:", resp.StatusCode, "| Dimensione:", len(body))
}

Punti chiave:

  • context.Context — il meccanismo unico di cancellazione. Se il contesto scade o viene annullato, la richiesta si interrompe. Una salvezza durante il graceful shutdown e quando si limita il tempo dell’intera scansione.
  • Il Timeout del client limita tutto: apertura della connessione, invio, lettura del body. Si può regolare più finemente con un http.Transport dedicato (vedi sotto).
  • defer resp.Body.Close() — un body non chiuso trattiene la connessione TCP e impedisce di riutilizzarla dal pool. Su grandi volumi porta all’errore «too many open files».

Messa a punto del Transport

http.Transport è il «motore» sotto il client. Una stessa istanza del transport viene riutilizzata tra le richieste e mantiene un pool di connessioni keep-alive: crealo quindi una sola volta per tutta l’applicazione, non a ogni richiesta.

go
transport := &http.Transport{
    MaxIdleConns:        100,              // totale delle connessioni idle nel pool
    MaxIdleConnsPerHost: 10,               // per singolo host
    IdleConnTimeout:     90 * time.Second, // quanto vive una connessione idle
    DisableCompression:  false,            // gzip viene decompresso in automatico
    ForceAttemptHTTP2:   true,
}
client := &http.Client{
    Transport: transport,
    Timeout:   15 * time.Second,
}

Un errore frequente è creare &http.Client{} (o il transport) dentro il ciclo, a ogni URL. Questo rompe il pool di connessioni e porta a esaurire le porte. Crea il client una sola volta e passalo per riferimento.


2. Librerie per il parsing del contenuto

Una volta scaricato l’HTML, bisogna analizzarlo. Gli strumenti si dividono in tre livelli.

2.1. goquery — sintassi in stile jQuery (la più popolare)

bash
go get github.com/PuerkitoBio/goquery
go
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)
    }

    // selettori CSS come in 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 supporta quasi tutto il CSS3: .class, #id, [attr=value], :first-child, :nth-of-type(n), i combinatori >, +, ~. È la scelta migliore per la maggior parte dei task di parsing dell’HTML.

2.2. colly — un framework di crawling completo

bash
go get github.com/gocolly/colly/v2

Colly si fa carico di download, parsing, esplorazione dei link, limitazione della velocità, caching e molto altro — non è più un semplice scraper, ma un motore di crawling.

go
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), // scansione asincrona
    )

    // Limite di parallelismo e ritardo — la cortesia integrata
    c.Limit(&colly.LimitRule{
        DomainGlob:  "*",
        Parallelism: 4,
        Delay:       500 * time.Millisecond,
        RandomDelay: 500 * time.Millisecond,
    })

    // Callback per ogni titolo trovato
    c.OnHTML("h1, h2", func(e *colly.HTMLElement) {
        fmt.Println("Titolo:", e.Text)
    })

    // Visitiamo tutti i link
    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("Scarico:", r.URL)
    })

    c.OnError(func(r *colly.Response, err error) {
        fmt.Println("Errore:", r.Request.URL, err)
    })

    c.Visit("https://example.com")
    c.Wait() // aspettiamo la fine di tutte le richieste asincrone
}

Colly è la scelta giusta quando serve proprio esplorare un sito (crawling), non analizzare una singola pagina.

2.3. golang.org/x/net/html — il tokenizzatore di basso livello

Pacchetto standard (semi-standard). Offre il massimo controllo e zero dipendenze, ma scriverci a mano è faticoso — è un parsing in streaming, token per token.

go
package main

import (
    "fmt"
    "strings"

    "golang.org/x/net/html"
)

func main() {
    r := strings.NewReader(`<html><body><a href="/x">Link</a></body></html>`)
    tokenizer := html.NewTokenizer(r)

    for {
        tt := tokenizer.Next()
        if tt == html.ErrorToken {
            break // fine del documento
        }
        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)
                    }
                }
            }
        }
    }
}

Usalo quando conta la velocità su documenti enormi o quando goquery risulta «troppo pesante».

2.4. JSON e API al posto dell’HTML

Spesso i dati della pagina vengono caricati da una richiesta AJAX separata che restituisce JSON. È il caso più comodo: fare il parsing del JSON è più affidabile che dell’HTML:

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

Prima di fare il parsing dell’HTML, apri la scheda Network nei DevTools: forse l’endpoint JSON che ti serve esiste già.

2.5. XPath

Se ti trovi più a tuo agio con XPath, c’è github.com/antchfx/htmlquery:

go
doc, _ := htmlquery.LoadURL("https://example.com")
nodes := htmlquery.Find(doc, "//div[@class='item']/a/@href")

Confronto

Strumento Quando sceglierlo Dipendenze
goquery Parsing dell’HTML, selettori CSS 1
colly Esplorare siti interi diverse
x/net/html Massimo controllo/velocità semi-std
encoding/json API/AJAX restituiscono JSON std
htmlquery Se preferisci XPath 1

3. Risolvere i problemi di codifica (lettere accentate e caratteri speciali)

L’incubo classico: scarichi un sito italiano e al posto del testo vedi perché o più, e invece di caffè compare caffè. Il motivo è che il sito non serve il contenuto in UTF-8, ma in ISO-8859-1 (Latin-1) o Windows-1252, mentre Go per impostazione predefinita tratta tutti i byte di una stringa come UTF-8.

La soluzione universale: il rilevatore di charset

Il pacchetto golang.org/x/net/html/charset individua la codifica dall’header Content-Type, dal <meta charset> e per via euristica dal contenuto, dopodiché restituisce un reader che ricodifica il flusso in UTF-8 al volo.

bash
go get golang.org/x/net/html
go get golang.org/x/text
go
package main

import (
    "fmt"
    "io"
    "net/http"

    "github.com/PuerkitoBio/goquery"
    "golang.org/x/net/html/charset"
)

func main() {
    resp, _ := http.Get("https://sito-latin1.example")
    defer resp.Body.Close()

    // charset.NewReader individua da solo la codifica e ricodifica in 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()) // ora le lettere accentate si leggono correttamente
    _ = io.Discard
}

Questa soluzione funziona nel 95% dei casi — conviene adottarla di default.

Indicare la codifica in modo esplicito

Se conosci con certezza la codifica (per esempio, il sito è sempre in Windows-1252), puoi ricodificare a mano con golang.org/x/text/encoding:

go
import (
    "golang.org/x/text/encoding/charmap"
    "golang.org/x/text/transform"
)

// Windows-1252 → UTF-8
decoder := charmap.Windows1252.NewDecoder()
reader := transform.NewReader(resp.Body, decoder)

body, _ := io.ReadAll(reader)
fmt.Println(string(body))

Per ISO-8859-1 (Latin-1) — charmap.ISO8859_1; per ISO-8859-15 (Latin-9, con il simbolo dell’euro) — charmap.ISO8859_15.

Il problema inverso — inviare testo accentato

Se devi inviare una POST con lettere accentate in Windows-1252 (per esempio a un vecchio form):

go
encoder := charmap.Windows1252.NewEncoder()
encoded, _ := encoder.String("Ciao mondo")
// encoded ora è in byte Windows-1252 — lo inviamo nel corpo della richiesta

Se i caratteri illeggibili (mojibake) compaiono solo nella console di Windows, mentre nei file tutto viene scritto correttamente, il problema non è nello scraper ma nella codifica del terminale. Esegui chcp 65001 per passare cmd a UTF-8.


4. Concorrenza

Qui Go dà il meglio di sé. Le goroutine costano migliaia di volte meno dei thread del sistema operativo, e i canali permettono di scambiare dati in sicurezza senza mutex espliciti.

4.1. L’approccio ingenuo (sbagliato)

go
// Male: lanciamo una goroutine per OGNI url, senza limiti
for _, url := range urls {
    go fetch(url) // 100.000 url → 100.000 richieste simultanee → il server crolla o ti banna
}

Senza un limite di parallelismo, o abbatti il server di destinazione, o esaurisci il limite di file descriptor, o ti prendi un ban all’istante.

4.2. Worker pool — il pattern canonico

Creiamo un numero fisso di worker che prelevano i task da un canale. Il parallelismo è limitato dal numero di worker.

go
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",
        // ... migliaia di 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)
    }

    // Inviamo i task in una goroutine a parte
    go func() {
        for _, u := range urls {
            jobs <- u
        }
        close(jobs) // importante: chiudiamo il canale perché i worker terminino
    }()

    // Chiudiamo results quando tutti i worker hanno finito
    go func() {
        wg.Wait()
        close(results)
    }()

    // Leggiamo i risultati
    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)
        }
    }
}

Analisi del pattern:

  • jobs — il canale dei task in ingresso. Lo chiudiamo dopo aver inviato tutti gli URL: è il segnale che fa terminare ai worker il ciclo for range.
  • sync.WaitGroup — aspettiamo che tutti i worker abbiano finito.
  • results viene chiuso da una goroutine a parte dopo wg.Wait(), altrimenti il for range results principale resterebbe appeso per sempre.
  • Il parallelismo si controlla con una sola costante, numWorkers.

4.3. Limite tramite semaforo (errgroup)

Un modo più moderno: golang.org/x/sync/errgroup con limite. In più raccoglie comodamente il primo errore e supporta il context.

bash
go get golang.org/x/sync/errgroup
go
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) // al massimo 8 goroutine simultanee

    client := &http.Client{}
    for _, u := range urls {
        u := u // importante per Go < 1.22: cattura della variabile di ciclo
        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("Uno dei task è fallito:", err)
    }
}

In Go prima della 1.22 la variabile di ciclo viene riutilizzata, quindi la riga u := u è obbligatoria: senza, tutte le goroutine ricevono lo stesso URL (l’ultimo). In Go 1.22+ il problema è stato risolto a livello di linguaggio, ma l’abitudine non guasta.

4.4. Protezione dei dati condivisi

Se i worker scrivono in una map condivisa (per esempio l’insieme degli URL visitati), va protetta:

go
var (
    visited = make(map[string]bool)
    mu      sync.Mutex
)

func markVisited(url string) bool {
    mu.Lock()
    defer mu.Unlock()
    if visited[url] {
        return false // già visto
    }
    visited[url] = true
    return true
}

In alternativa — sync.Map (ottima nel caso «molte letture, poche scritture») o sync/atomic per i contatori. Esegui i test con il flag -race: il race detector di Go trova questi bug in automatico.


5. Uso dei proxy

I proxy servono a distribuire il carico, aggirare i blocchi geografici e ridurre la probabilità di un ban per IP.

Un proxy per client

go
package main

import (
    "net/http"
    "net/url"
)

func clientWithProxy(proxyAddr string) (*http.Client, error) {
    // È supportato il formato user:pass@host:port
    proxyURL, err := url.Parse(proxyAddr) // ad es. "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
}

Sono supportati gli schemi http://, https:// e socks5://. Per l’autenticazione, utente e password vanno messi direttamente nell’URL.

Rotazione di un pool di proxy

Per distribuire le richieste su una lista di proxy si può sostituire la funzione Proxy del transport — viene chiamata a ogni richiesta:

go
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
}

// Selezione round-robin del proxy
func (r *ProxyRotator) Next(_ *http.Request) (*url.URL, error) {
    if len(r.proxies) == 0 {
        return nil, nil // senza 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, // a ogni richiesta — il proxy successivo
    }
    client := &http.Client{Transport: transport}

    _ = client
    _ = rand.Int
}

Nella pratica conviene tenere accanto a ogni proxy il suo «stato di salute»: un contatore degli errori, l’ora dell’ultimo ban. Un proxy morto viene escluso temporaneamente dalla rotazione. Di solito si scrive un piccolo wrapper che verifica il proxy su un endpoint «vivo» prima di usarlo.


6. Scraping attraverso TOR

TOR è una rete di anonimizzazione gratuita, disponibile in locale come proxy SOCKS5 (di default su 127.0.0.1:9050). Lo scraping attraverso TOR è utile per l’anonimato e il cambio automatico di IP, ma è lento e molti siti bloccano i nodi di uscita di TOR.

Preparazione

Avvia il demone TOR. Il modo più semplice è Docker:

bash
docker run -d --name tor -p 9050:9050 -p 9051:9051 dperson/torproxy

In alternativa installa il pacchetto di sistema tor (apt install tor, brew install tor) — avvierà da solo il SOCKS5 sulla porta 9050.

Client HTTP attraverso TOR (SOCKS5)

bash
go get golang.org/x/net/proxy
go
package main

import (
    "fmt"
    "io"
    "net/http"

    "golang.org/x/net/proxy"
)

func torClient() (*http.Client, error) {
    // Ci colleghiamo al SOCKS5 locale di 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, // tutto il traffico passa attraverso TOR
    }
    return &http.Client{Transport: transport}, nil
}

func main() {
    client, err := torClient()
    if err != nil {
        panic(err)
    }

    // check.torproject.org conferma che siamo dentro 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":"..."}
}

Cambio di IP (nuovo circuito) tramite la porta di controllo

TOR sa costruire un nuovo circuito su comando — cioè cambiare l’IP di uscita. Si fa attraverso la porta di controllo (9051), inviando il segnale NEWNYM:

go
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()

    // Autenticazione (la password va configurata nel torrc)
    if _, _, err := conn.Cmd(`AUTHENTICATE "%s"`, controlPassword); err != nil {
        return err
    }
    conn.ReadResponse(250)

    // Segnale di cambio circuito
    id, _ := conn.Cmd("SIGNAL NEWNYM")
    conn.StartResponse(id)
    defer conn.EndResponse(id)
    _, msg, err := conn.ReadResponse(250)
    fmt.Println("TOR ha risposto:", msg)
    return err
}

Perché la porta di controllo funzioni, nel torrc devono essere impostati ControlPort 9051 e l’hash della password (HashedControlPassword, generato con il comando tor --hash-password LA_TUA_PASSWORD).

TOR dà anonimato, non invisibilità. La velocità è bassa, i nodi di uscita finiscono spesso nelle blacklist e uno scraping aggressivo attraverso TOR è di pessimo gusto verso una rete di volontari. Per la raccolta massiva sono più adatti i proxy residenziali commerciali.


7. Lavorare con HTTPS / SSL

La buona notizia: per HTTPS di solito non serve fare nulla — Go verifica i certificati in automatico, usando l’archivio di sistema delle radici attendibili.

Quando serve comunque intervenire

1. Certificati self-signed (siti di test/interni). Disattivare la verifica risolve il problema, ma apre una falla agli attacchi MITM — solo per i test:

go
import "crypto/tls"

transport := &http.Transport{
    TLSClientConfig: &tls.Config{
        InsecureSkipVerify: true, // NON in produzione!
    },
}
client := &http.Client{Transport: transport}

2. Il modo corretto — aggiungere lo specifico certificato radice al pool di fiducia:

go
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, // non inferiore a TLS 1.2
        },
    }
    return &http.Client{Transport: transport}, nil
}

3. Controllo della versione TLS e delle cipher suite. A volte il sito esige una configurazione precisa oppure, al contrario, vuoi imitare un browser specifico:

go
tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS12,
    MaxVersion: tls.VersionTLS13,
}

Fingerprinting TLS. I sistemi anti-bot avanzati (Cloudflare, Akamai) riconoscono i bot dall’«impronta» dell’handshake TLS (JA3/JA4) — il client Go standard ha un’impronta riconoscibile. Per mascherarsi da browser reale si usa la libreria github.com/refraction-networking/utls, che sa falsificare il ClientHello imitando Chrome/Firefox. È già un tema avanzato di anti-detection.


I cookie servono per le sessioni, l’autenticazione e per «superare» certe verifiche. Go sa gestirli in automatico con cookiejar.

go
package main

import (
    "fmt"
    "net/http"
    "net/http/cookiejar"

    "golang.org/x/net/publicsuffix"
)

func main() {
    // la lista publicsuffix serve a gestire correttamente i domini
    jar, err := cookiejar.New(&cookiejar.Options{
        PublicSuffixList: publicsuffix.List,
    })
    if err != nil {
        panic(err)
    }

    client := &http.Client{Jar: jar}

    // Prima richiesta: il server imposta un cookie (ad es. la sessione)
    client.Get("https://example.com/login")

    // La seconda richiesta invierà in automatico i cookie salvati
    resp, _ := client.Get("https://example.com/dashboard")
    defer resp.Body.Close()

    // Guardiamo cosa c'è nel jar per un host specifico
    u, _ := resp.Request.URL.Parse("https://example.com")
    for _, c := range jar.Cookies(u) {
        fmt.Printf("%s = %s\n", c.Name, c.Value)
    }
}

Con Jar il client si comporta come un browser: accetta i Set-Cookie delle risposte e li allega alle richieste successive verso lo stesso dominio.

Quando hai già il token di sessione (copiato dal browser) e devi solo «iniettare» la sessione:

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

Scenario tipico di autenticazione (login via POST)

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

// Se il client ha un Jar, il cookie di sessione viene salvato in automatico
// e tutte le richieste successive saranno autenticate.
resp, _ := client.Do(req)

Il salvataggio del jar dei cookie tra un’esecuzione e l’altra del programma si fa a mano: si scorre jar.Cookies(url), si serializza su un file JSON e all’avvio si ricarica con jar.SetCookies(url, cookies). Il cookiejar standard non è persistente, ma esistono wrapper già pronti (github.com/juju/persistent-cookiejar).


9. Status della risposta e header

Dopo l’esecuzione della richiesta, l’oggetto *http.Response contiene tutte le metainformazioni.

go
resp, err := client.Do(req)
if err != nil {
    // Errore di rete (DNS, timeout, connessione rifiutata).
    // IMPORTANTE: se err != nil, resp == nil — non toccare resp.Body!
    return err
}
defer resp.Body.Close()

// Status
fmt.Println(resp.StatusCode) // 200, 404, 503 ...
fmt.Println(resp.Status)     // "200 OK", "404 Not Found"

// Singoli header (maiuscole/minuscole non contano)
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"))

// Uno stesso header può avere più valori
for _, v := range resp.Header.Values("Set-Cookie") {
    fmt.Println("cookie:", v)
}

// Tutti gli header in un colpo solo
for name, values := range resp.Header {
    fmt.Printf("%s: %v\n", name, values)
}

Gestione corretta degli status

go
switch {
case resp.StatusCode == http.StatusOK: // 200
    // facciamo il parsing del body

case resp.StatusCode == http.StatusTooManyRequests: // 429
    // ci stanno frenando — leggiamo l'header Retry-After e aspettiamo
    retryAfter := resp.Header.Get("Retry-After")
    fmt.Println("Rate limit, aspettiamo:", retryAfter)

case resp.StatusCode >= 500: // 5xx — errore lato server
    // ha senso ripetere la richiesta più tardi

case resp.StatusCode == http.StatusNotFound: // 404
    // la pagina non esiste — inutile riprovare

case resp.StatusCode >= 300 && resp.StatusCode < 400: // 3xx
    // redirect; di default il client Go segue da solo fino a 10 redirect
}

Gestione dei redirect

Di default il client segue i redirect. Per disattivarli o intercettarli:

go
client := &http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        // Vietiamo i redirect — la risposta 3xx torna «così com'è»
        return http.ErrUseLastResponse
    },
}

Ricorda la differenza: un errore di rete (err != nil) è quando la richiesta non è proprio arrivata o tornata (DNS, timeout). Un errore HTTP (resp.StatusCode == 404/500) è una risposta valida del server, e in quel caso err == nil. Lo status code va controllato sempre, separatamente da err.


10. Extra: cose importanti che spesso si dimenticano

Queste sezioni non erano nell’elenco di partenza, ma senza di loro uno scraper di produzione va in pezzi.

10.1. Cortesia e rate limiting

Non bombardare il server di richieste: è un carico sull’infrastruttura altrui e un ban assicurato in poco tempo. Limita la frequenza con golang.org/x/time/rate:

go
import "golang.org/x/time/rate"

// 2 richieste al secondo, burst fino a 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 { // si blocca finché non «si può»
        return nil, err
    }
    return client.Get(url)
}

10.2. robots.txt

È buona educazione (e a volte una necessità legale) rispettare il robots.txt, dove il sito indica che cosa si può indicizzare. Per il parsing aiuta github.com/temoto/robotstxt:

go
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") {
    // consentito — avanti con lo scraping
} else {
    // vietato dal robots.txt
}

10.3. Rotazione degli User-Agent

Lo stesso User-Agent su migliaia di richieste è un chiaro segnale di bot. Tieni una lista e scegline uno a caso:

go
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 con backoff esponenziale

La rete è instabile: i guasti temporanei (5xx, timeout) vanno ritentati, aumentando la pausa:

go
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 po' di casualità (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 // riproviamo in caso di errore di rete
        }
        if resp.StatusCode >= 500 || resp.StatusCode == 429 {
            resp.Body.Close()
            lastErr = fmt.Errorf("status %d", resp.StatusCode)
            continue // riproviamo in caso di 5xx/429
        }
        return resp, nil // successo
    }
    return nil, fmt.Errorf("tentativi esauriti: %w", lastErr)
}

10.5. Scraping di pagine con JavaScript

Se il contenuto viene disegnato dal JavaScript (una SPA in React/Vue), http.Get restituisce uno scheletro HTML quasi vuoto. Serve allora un browser headless, che piloti un vero Chrome tramite il protocollo CDP:

  • github.com/chromedp/chromedp — controllo di Chrome da Go;
  • github.com/go-rod/rod — un’alternativa di più alto livello.
go
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"),      // aspettiamo che il JS disegni la pagina
    chromedp.OuterHTML("html", &html),     // preleviamo il DOM pronto
)
// poi passiamo html a goquery

Lo svantaggio: il browser headless è pesante (memoria, CPU) e lento. Prima di tirare in ballo Chrome, controlla — magari i dati sono disponibili via API JSON (vedi la sezione 2.4) e il browser non serve.

10.6. Aspetti legali ed etici

Lo scraping è una zona grigia. Punti di riferimento di base: rispetta il robots.txt e i Terms of Service del sito; non raccogliere dati personali senza una base giuridica (GDPR); non generare un carico eccessivo; non spacciare per tuoi i contenuti altrui. Tecnicamente possibile e giuridicamente ammissibile non sono la stessa cosa.


11. Archiviazione degli URL e code

A un crawler servono due strutture: una coda di URL non ancora visitati (frontier) e un insieme di quelli già visitati (per non girare in tondo).

11.1. In memoria (l’opzione più semplice)

Per i task piccoli bastano un canale come coda e una map come insieme dei visitati:

go
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 // già visto — saltiamo
    }
    c.visited[url] = true
    select {
    case c.queue <- url:
    default: // coda piena — scartiamo o accumuliamo a parte
    }
}

I problemi dell’in-memory: a ogni riavvio si perde tutto, e su milioni di URL la map si mangia tutta la memoria.

11.2. Deduplicazione su grandi volumi: il filtro di Bloom

Tenere decine di milioni di stringhe in una map costa caro. Il filtro di Bloom è una struttura probabilistica che occupa poca memoria e risponde in fretta «sicuramente non l’ho visto» oppure «forse l’ho visto» (con una piccola probabilità di falso positivo):

go
import "github.com/bits-and-blooms/bloom/v3"

// ~10 milioni di elementi, probabilità di errore dell'1%
filter := bloom.NewWithEstimates(10_000_000, 0.01)

if filter.TestString(url) {
    // forse già visto — saltiamo (con il raro rischio di perdere un URL nuovo)
} else {
    filter.AddString(url)
    // sicuramente nuovo — in coda
}

11.3. Code esterne (produzione, sistemi distribuiti)

Quando il crawler deve sopravvivere ai riavvii e lavorare su più macchine, la coda si sposta in uno storage esterno:

Storage Ruolo Particolarità
Redis coda (LPUSH/BRPOP) + insieme dei visitati (SET/SADD) veloce, atomico, ideale per worker distribuiti
RabbitMQ / Kafka coda di task consegna affidabile, ack/nack, reinvio dei task falliti
PostgreSQL / SQLite frontier persistente comodo per salvare URL + metadati + stato
BadgerDB / bbolt store KV embedded niente server a parte, tutto in un unico binario

Esempio di coda su Redis:

go
import "github.com/redis/go-redis/v9"

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

// Aggiunge l'URL alla coda solo se non è ancora stato visitato (atomico via SET)
func enqueue(ctx context.Context, url string) error {
    // SADD restituisce 1 se l'elemento è nuovo
    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 // duplicato
}

// Preleva un URL dalla coda (bloccante)
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] è il nome della chiave, res[1] il valore
}

Uno schema così permette di lanciare decine di worker su macchine diverse: tutti prendono i task dalla coda Redis comune e scrivono i risultati in un database comune, senza duplicare il lavoro.

11.4. Priorità e strategia di esplorazione

  • BFS (coda — un normale FIFO) — esplora il sito «in ampiezza»; di solito è preferibile per il crawling.
  • DFS (stack — LIFO) — scende in profondità lungo un singolo ramo.
  • Coda con priorità (container/heap o un Sorted Set di Redis) — prima si visitano le pagine importanti (per esempio in base alla «profondità» o al valore stimato).

12. Pro e contro dell’implementazione in Go

Pro

  • Concorrenza di serie. Goroutine e canali rendono naturale ed economico un crawler multithread. Migliaia di richieste in parallelo — senza la sofferenza dei pool di thread.
  • Prestazioni. Compila in codice nativo, veloce e parco nella memoria. Uno scraper in Go batte l’equivalente in Python di parecchie volte in termini di throughput.
  • Un unico binario. go build produce un eseguibile autosufficiente, senza dipendenze né interprete — il deploy è banale, comodo per Docker e cron.
  • Una libreria standard solida. net/http, crypto/tls, cookiejar, context, encoding/json coprono quasi tutto senza pacchetti di terze parti.
  • Tipizzazione statica. Molti errori vengono intercettati in compilazione, e non a runtime nel bel mezzo di una scansione di molte ore.
  • Race detector integrato (-race) — insostituibile nel codice concorrente.
  • Ecosistema maturo: colly, goquery, chromedp — strumenti collaudati dal tempo.

Contro

  • Verbosità nel parsing dell’HTML. Rispetto a Python (requests + BeautifulSoup in 5 righe), Go richiede più codice e una gestione esplicita degli errori.
  • Più debole sui browser headless. Python con Playwright/Selenium è più ricco di possibilità; chromedp/rod sono validi, ma l’ecosistema è più modesto.
  • Anti-detection più laboriosa. Falsificare il fingerprint TLS (utls) ed emulare un browser richiede più sforzo delle soluzioni pronte di altri stack.
  • Gestione manuale degli errori. I continui if err != nil stancano, anche se disciplinano.
  • Meno strumenti ML/NLP pronti per la post-elaborazione del testo estratto — qui Python è ancora in testa. Pattern frequente: raccolta dati in Go, analisi in Python.
  • La soglia d’ingresso della concorrenza. Canali, deadlock, race condition — potente, ma richiede comprensione; ci vuole poco perché un principiante scriva un leak di goroutine.

Quando scegliere Go e quando no

Scenario Raccomandazione
Crawler ad alto carico, milioni di pagine Go — l’ideale
Servizio di scraping di lunga durata Go
Script una tantum per «estrarre una tabellina» Python si scrive prima
Rendering JS pesante, anti-detection complessa Spesso più comodo Python + Playwright
Scraping + analisi ML immediata Python è più vicino ai dati

13. Conclusione

Lo schema minimo di uno scraper di produzione in Go è questo:

  1. Un solo http.Client riutilizzabile, con timeout, un Transport configurato e (opzionale) un cookiejar.
  2. Worker pool o errgroup con limite — per un parallelismo controllato.
  3. goquery (o colly per l’esplorazione) — per estrarre i dati, con charset.NewReader per una codifica dei caratteri corretta.
  4. Proxy/TOR + rotazione degli User-Agent — quando servono anonimato o l’aggiramento dei ban.
  5. Rate limiting e rispetto del robots.txt — per non abbattere il server e non farsi bannare.
  6. Retry con backoff — per resistere ai guasti.
  7. Coda esterna (Redis) e deduplicazione (filtro di Bloom) — quando il task non sta più nella RAM.

Go offre un ottimo equilibrio tra prestazioni, affidabilità e semplicità di deploy. Il consiglio principale: comincia sempre controllando se il sito ha un’API JSON aperta. Se c’è, metà dei problemi di questo articolo (codifiche, rendering JS, selettori fragili) sparisce da sola.