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
- Come scarichiamo la pagina
- Librerie per il parsing del contenuto
- Risolvere i problemi di codifica
- Concorrenza
- Uso dei proxy
- Scraping attraverso TOR
- Lavorare con HTTPS / SSL
- Lavorare con i cookie
- Status della risposta e header
- Extra: cortesia, robots.txt, User-Agent, rendering JS, retry
- Archiviazione degli URL e code
- Pro e contro dell’implementazione in Go
- Conclusione
1. Come scarichiamo la pagina
L’opzione più semplice
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
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
Timeoutdel client limita tutto: apertura della connessione, invio, lettura del body. Si può regolare più finemente con unhttp.Transportdedicato (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.
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)
go get github.com/PuerkitoBio/goquerypackage 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
go get github.com/gocolly/colly/v2Colly 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.
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.
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:
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:
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.
go get golang.org/x/net/html
go get golang.org/x/textpackage 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:
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):
encoder := charmap.Windows1252.NewEncoder()
encoded, _ := encoder.String("Ciao mondo")
// encoded ora è in byte Windows-1252 — lo inviamo nel corpo della richiestaSe 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 65001per 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)
// 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.
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 ciclofor range.sync.WaitGroup— aspettiamo che tutti i worker abbiano finito.resultsviene chiuso da una goroutine a parte dopowg.Wait(), altrimenti ilfor range resultsprincipale 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.
go get golang.org/x/sync/errgrouppackage 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:
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
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:
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:
docker run -d --name tor -p 9050:9050 -p 9051:9051 dperson/torproxyIn 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)
go get golang.org/x/net/proxypackage 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:
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:
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:
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:
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.
8. Lavorare con i cookie
I cookie servono per le sessioni, l’autenticazione e per «superare» certe verifiche. Go sa gestirli in automatico con cookiejar.
Salvataggio automatico dei cookie
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.
Impostare i cookie a mano
Quando hai già il token di sessione (copiato dal browser) e devi solo «iniettare» la sessione:
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)
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 conjar.SetCookies(url, cookies). Ilcookiejarstandard 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.
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
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:
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 casoerr == nil. Lo status code va controllato sempre, separatamente daerr.
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:
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:
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:
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:
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.
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 goqueryLo 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:
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):
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:
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/heapo 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 buildproduce 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/jsoncoprono 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+BeautifulSoupin 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 != nilstancano, 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:
- Un solo
http.Clientriutilizzabile, con timeout, unTransportconfigurato e (opzionale) uncookiejar. - Worker pool o
errgroupcon limite — per un parallelismo controllato. - goquery (o colly per l’esplorazione) — per estrarre i dati, con
charset.NewReaderper una codifica dei caratteri corretta. - Proxy/TOR + rotazione degli User-Agent — quando servono anonimato o l’aggiramento dei ban.
- Rate limiting e rispetto del
robots.txt— per non abbattere il server e non farsi bannare. - Retry con backoff — per resistere ai guasti.
- 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.