Scraping por linguagem 27 min de leitura

Web scraping em Go: guia completo do simples ao complexo

Guia completo de web scraping em Go: net/http, goquery, Colly, concorrência com goroutines e a construção de um crawler rápido.

EW
Equipe Web-Scraping.biz
Coleta de dados para as demandas do negócio
Publicado: 10 março 2025

Go é uma das linguagens mais confortáveis para escrever scrapers e crawlers (rastreadores): a tipagem estática detecta erros em tempo de compilação, as goroutines oferecem concorrência barata de fábrica e a biblioteca padrão net/http cobre a maior parte do trabalho de rede sem dependências externas. Neste artigo, percorreremos o caminho inteiro: de baixar uma única página até um crawler distribuído e multithread com proxies, TOR e filas.

Todo o código foi testado no Go 1.21+. Os pacotes de terceiros são instalados com go get; os comandos exatos aparecem nas seções correspondentes.


Sumário

  1. Como baixar a página
  2. Bibliotecas para o parsing do conteúdo
  3. Como resolver os problemas de codificação
  4. Concorrência
  5. Uso de proxies
  6. Scraping através do TOR
  7. Trabalho com HTTPS / SSL
  8. Trabalho com cookies
  9. Status da resposta e cabeçalhos
  10. Extra: cortesia, robots.txt, User-Agent, renderização de JS, novas tentativas
  11. Armazenamento de URLs e filas
  12. Vantagens e desvantagens da implementação em Go
  13. Conclusão

1. Como baixar a página

A opção mais simples

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() // sempre feche o corpo; caso contrário, vazamento de conexões

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

http.Get usa o http.DefaultClient global, que não tem timeout. Para um scraper de produção isso é inadmissível: um único servidor travado bloqueia a goroutine para sempre.

A opção correta: um cliente próprio com timeout e cabeçalhos

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 global para a requisição inteira
    }

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

    // Muitos sites devolvem 403 sem um User-Agent «humano».
    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", "pt-BR,pt;q=0.9,en;q=0.8")

    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, "| Tamanho:", len(body))
}

Pontos-chave:

  • context.Context: o mecanismo único de cancelamento. Se o contexto expira ou é cancelado, a requisição é interrompida. É o que salva no encerramento controlado (graceful shutdown) e ao limitar o tempo do percurso completo.
  • O Timeout do cliente limita tudo: o estabelecimento da conexão, o envio e a leitura do corpo. Dá para afinar ainda mais com um http.Transport próprio (veja abaixo).
  • defer resp.Body.Close(): um corpo não fechado retém a conexão TCP e impede reutilizá-la a partir do pool. Em grande escala, isso provoca o erro «too many open files».

Ajuste fino do Transport

http.Transport é o «motor» que fica sob o cliente. Uma mesma instância do transporte é reutilizada entre requisições e mantém um pool de conexões keep-alive; portanto, crie-a uma única vez para a aplicação inteira, não a cada requisição.

go
transport := &http.Transport{
    MaxIdleConns:        100,              // total de conexões ociosas no pool
    MaxIdleConnsPerHost: 10,               // por host
    IdleConnTimeout:     90 * time.Second, // por quanto tempo vive uma conexão ociosa
    DisableCompression:  false,            // o gzip é descomprimido automaticamente
    ForceAttemptHTTP2:   true,
}
client := &http.Client{
    Transport: transport,
    Timeout:   15 * time.Second,
}

Erro frequente: criar um &http.Client{} (ou o transporte) dentro do loop, um por URL. Isso quebra o pool de conexões e esgota as portas. Crie o cliente uma única vez e passe-o por referência.


2. Bibliotecas para o parsing do conteúdo

Depois de baixar o HTML, é preciso parseá-lo. Existem três níveis de ferramentas.

2.1. goquery: sintaxe no estilo jQuery (a mais popular)

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

    // seletores CSS como no 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)
    })
}

O goquery aceita quase todo o CSS3: .class, #id, [attr=value], :first-child, :nth-of-type(n), os combinadores >, +, ~. É a melhor opção para a maioria das tarefas de parsing de HTML.

2.2. colly: um framework de crawler completo

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

O Colly cuida do download, do parsing, do acompanhamento de links, da limitação de velocidade, do cache e de muito mais: já não é um simples scraper, e sim um motor de crawler.

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), // percurso assíncrono
    )

    // Limite de paralelismo e atraso: cortesia integrada
    c.Limit(&colly.LimitRule{
        DomainGlob:  "*",
        Parallelism: 4,
        Delay:       500 * time.Millisecond,
        RandomDelay: 500 * time.Millisecond,
    })

    // Callback para cada cabeçalho encontrado
    c.OnHTML("h1, h2", func(e *colly.HTMLElement) {
        fmt.Println("Cabeçalho:", e.Text)
    })

    // Acompanhamento de todos os links
    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("Baixando:", r.URL)
    })

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

    c.Visit("https://example.com")
    c.Wait() // aguardamos todas as requisições assíncronas terminarem
}

Vale escolher o Colly quando o que você precisa é percorrer um site (crawling), não parsear uma única página.

2.3. golang.org/x/net/html: tokenizador de baixo nível

Pacote padrão (semipadrão). Oferece o máximo de controle e zero dependências, mas escrever com ele na mão é tedioso: é uma análise em fluxo, token a 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 // fim do 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)
                    }
                }
            }
        }
    }
}

Use-o quando a velocidade em documentos enormes importar ou quando o goquery parecer «pesado demais».

2.4. JSON e APIs em vez de HTML

Muitas vezes os dados da página são carregados por uma requisição AJAX independente que devolve JSON. É o caso mais confortável: parsear JSON é mais confiável do que parsear 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)

Antes de parsear HTML, abra a guia Network das DevTools: pode ser que o endpoint JSON de que você precisa já exista.

2.5. XPath

Se você prefere XPath, existe o github.com/antchfx/htmlquery:

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

Comparação

Ferramenta Quando usar Dependências
goquery Parsing de HTML, seletores CSS 1
colly Percorrer sites inteiros várias
x/net/html Máximo controle/velocidade semi-std
encoding/json A API/AJAX devolve JSON std
htmlquery Se você prefere XPath 1

3. Como resolver os problemas de codificação (acentos e caracteres especiais)

O pesadelo clássico: você baixa um site em português e, no lugar do texto, vê São Paulo ou coração, e em vez de atenção aparece atenção. O motivo: o site não serve o conteúdo em UTF-8, e sim em ISO-8859-1 (Latin-1) ou Windows-1252, enquanto o Go, por padrão, interpreta todos os bytes de uma string como UTF-8.

Solução universal: detector de charset

O pacote golang.org/x/net/html/charset detecta a codificação pelo cabeçalho Content-Type, pelo <meta charset> e de forma heurística conforme o conteúdo; depois devolve um reader que transcodifica o fluxo para UTF-8 em tempo real.

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://site-latin1.example")
    defer resp.Body.Close()

    // charset.NewReader detecta a codificação e transcodifica para 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()) // agora os acentos, o til e a cedilha aparecem corretamente
    _ = io.Discard
}

Essa solução funciona em 95% dos casos; vale usá-la por padrão.

Indicar a codificação de forma explícita

Se você conhece a codificação com certeza (por exemplo, o site está sempre em Windows-1252), pode transcodificar na mão com 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))

Para ISO-8859-1 (Latin-1) — charmap.ISO8859_1; para ISO-8859-15 (Latin-9, com o símbolo do euro) — charmap.ISO8859_15.

O problema inverso: enviar texto com acentos

Se você precisa enviar um POST com acentos em Windows-1252 (por exemplo, para um formulário antigo):

go
encoder := charmap.Windows1252.NewEncoder()
encoded, _ := encoder.String("Olá, mundo")
// encoded agora está em bytes Windows-1252 — vai no corpo da requisição

Se você vê caracteres corrompidos (mojibake) apenas no console do Windows, enquanto nos arquivos tudo é salvo corretamente, o problema não está no scraper, e sim na codificação do terminal. Execute chcp 65001 para mudar o cmd para UTF-8.


4. Concorrência

Aqui o Go brilha. As goroutines são milhares de vezes mais baratas que as threads do sistema operacional, e os canais permitem trocar dados com segurança sem mutexes explícitos.

4.1. A abordagem ingênua (incorreta)

go
// Errado: disparamos uma goroutine para CADA URL sem limite nenhum
for _, url := range urls {
    go fetch(url) // 100 000 URLs → 100 000 requisições simultâneas → o servidor cai ou você é banido
}

Sem limite de paralelismo, ou você derruba o servidor de destino, ou esgota o limite de descritores de arquivo, ou é banido na hora.

4.2. Worker pool: o padrão canônico

Criamos um número fixo de workers que vão pegando tarefas de um canal. O paralelismo fica limitado pelo número de workers.

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",
        // ... milhares de URLs
    }

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

    // Enviamos as tarefas em uma goroutine à parte
    go func() {
        for _, u := range urls {
            jobs <- u
        }
        close(jobs) // importante: fechamos o canal para os workers terminarem
    }()

    // Fechamos results quando todos os workers terminaram
    go func() {
        wg.Wait()
        close(results)
    }()

    // Lemos os resultados
    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)
        }
    }
}

Análise do padrão:

  • jobs: o canal de tarefas de entrada. Nós o fechamos depois de enviar todas as URLs: é o sinal para os workers encerrarem o loop for range.
  • sync.WaitGroup: esperamos todos os workers terminarem.
  • results é fechado por uma goroutine à parte depois do wg.Wait(); caso contrário, o for range results principal ficaria travado para sempre.
  • O paralelismo é controlado por uma única constante, numWorkers.

4.3. Limite por semáforo (errgroup)

Uma forma mais moderna: golang.org/x/sync/errgroup com limite. De quebra, ele recolhe comodamente o primeiro erro e aceita o contexto.

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) // no máximo 8 goroutines simultâneas

    client := &http.Client{}
    for _, u := range urls {
        u := u // importante no Go < 1.22: captura da variável do loop
        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("Uma das tarefas falhou:", err)
    }
}

No Go anterior ao 1.22, a variável do loop é reutilizada; por isso a linha u := u é obrigatória: caso contrário, todas as goroutines recebem a mesma URL (a última). No Go 1.22+ isso foi corrigido no nível da linguagem, mas o hábito não faz mal.

4.4. Proteção dos dados compartilhados

Se os workers escrevem em um map compartilhado (por exemplo, o conjunto de URLs visitadas), é preciso protegê-lo:

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 // já estava lá
    }
    visited[url] = true
    return true
}

Alternativas: sync.Map (boa quando há «muitas leituras e poucas escritas») ou sync/atomic para os contadores. Rode os testes com a opção -race: o detector de condições de corrida do Go encontra essas falhas automaticamente.


5. Uso de proxies

Os proxies servem para distribuir a carga, contornar bloqueios geográficos e reduzir a probabilidade de um banimento por IP.

Um proxy por cliente

go
package main

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

func clientWithProxy(proxyAddr string) (*http.Client, error) {
    // Aceita user:pass@host:port
    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
}

São aceitos os esquemas http://, https:// e socks5://. Para a autenticação, usuário e senha entram diretamente na URL.

Rotação de um pool de proxies

Para distribuir as requisições entre uma lista de proxies, dá para substituir a função Proxy do transporte: ela é invocada a cada requisição:

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
}

// Seleção round-robin do proxy
func (r *ProxyRotator) Next(_ *http.Request) (*url.URL, error) {
    if len(r.proxies) == 0 {
        return nil, nil // sem 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 cada requisição, o próximo proxy
    }
    client := &http.Client{Transport: transport}

    _ = client
    _ = rand.Int
}

Na prática, vale guardar junto de cada proxy a «saúde» dele: um contador de erros e a hora do último banimento. Um proxy caído é excluído temporariamente da rotação. Para isso, costuma-se escrever um pequeno invólucro que testa o proxy contra um endpoint «vivo» antes de usá-lo.


6. Scraping através do TOR

O TOR é uma rede de anonimização gratuita, disponível localmente como proxy SOCKS5 (por padrão em 127.0.0.1:9050). O scraping através do TOR é útil para o anonimato e a troca automática de IP, mas é lento, e muitos sites bloqueiam os nós de saída do TOR.

Preparação

Suba o daemon do TOR. O mais fácil é com Docker:

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

Ou instale o pacote do sistema tor (apt install tor, brew install tor): ele levanta sozinho o SOCKS5 na porta 9050.

Cliente HTTP através do 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) {
    // Conectamos ao SOCKS5 local do 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, // todo o tráfego passa pelo TOR
    }
    return &http.Client{Transport: transport}, nil
}

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

    // check.torproject.org confirma que estamos no 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":"..."}
}

Troca de IP (novo circuito) pela porta de controle

O TOR pode construir um novo circuito sob demanda, isto é, trocar o IP de saída. Isso é feito pela porta de controle (9051), enviando o sinal 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()

    // Autenticação (a senha deve estar configurada no torrc)
    if _, _, err := conn.Cmd(`AUTHENTICATE "%s"`, controlPassword); err != nil {
        return err
    }
    conn.ReadResponse(250)

    // Sinal para trocar de circuito
    id, _ := conn.Cmd("SIGNAL NEWNYM")
    conn.StartResponse(id)
    defer conn.EndResponse(id)
    _, msg, err := conn.ReadResponse(250)
    fmt.Println("O TOR respondeu:", msg)
    return err
}

Para a porta de controle funcionar, o torrc precisa definir ControlPort 9051 e o hash da senha (HashedControlPassword, gerado com tor --hash-password SUA_SENHA).

O TOR dá anonimato, mas não invisibilidade. A velocidade é baixa, os nós de saída costumam figurar em listas negras, e um scraping agressivo através do TOR é falta de educação com uma rede mantida por voluntários. Para a coleta em massa, os proxies residenciais comerciais encaixam melhor.


7. Trabalho com HTTPS / SSL

Boa notícia: para HTTPS normalmente não é preciso fazer nada; o Go verifica os certificados automaticamente usando o repositório de raízes confiáveis do sistema.

Quando é preciso intervir

1. Certificados autoassinados (sites de teste/internos). Desativar a verificação resolve o problema, mas abre uma brecha para ataques MITM; somente para testes:

go
import "crypto/tls"

transport := &http.Transport{
    TLSClientConfig: &tls.Config{
        InsecureSkipVerify: true, // NÃO use em produção!
    },
}
client := &http.Client{Transport: transport}

2. A forma correta: adicionar um certificado raiz específico ao pool de confiança:

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, // nada abaixo de TLS 1.2
        },
    }
    return &http.Client{Transport: transport}, nil
}

3. Controle da versão do TLS e das cifras. Às vezes o site exige uma configuração específica ou, ao contrário, você quer imitar um navegador determinado:

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

Fingerprinting de TLS. Os sistemas antibot avançados (Cloudflare, Akamai) identificam bots pela «impressão digital» do handshake TLS (JA3/JA4): o cliente padrão do Go tem uma impressão reconhecível. Para se passar por um navegador real, usa-se a biblioteca github.com/refraction-networking/utls, capaz de falsificar o ClientHello imitando o Chrome/Firefox. Isso já é um tema avançado de antidetecção.


Os cookies são necessários para as sessões, a autenticação e para «passar» por certas verificações. O Go sabe gerenciá-los automaticamente com o cookiejar.

go
package main

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

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

func main() {
    // a lista publicsuffix é necessária para tratar os domínios corretamente
    jar, err := cookiejar.New(&cookiejar.Options{
        PublicSuffixList: publicsuffix.List,
    })
    if err != nil {
        panic(err)
    }

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

    // Primeira requisição: o servidor atribui um cookie (ex.: de sessão)
    client.Get("https://example.com/login")

    // A segunda requisição envia automaticamente os cookies salvos
    resp, _ := client.Get("https://example.com/dashboard")
    defer resp.Body.Close()

    // Ver o que há no jar para um host específico
    u, _ := resp.Request.URL.Parse("https://example.com")
    for _, c := range jar.Cookies(u) {
        fmt.Printf("%s = %s\n", c.Name, c.Value)
    }
}

Com o Jar, o cliente se comporta como um navegador: aceita os Set-Cookie das respostas e os inclui nas requisições seguintes ao mesmo domínio.

Quando você já tem o token de sessão (copiado do navegador) e só falta «injetar» a sessão:

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)

Cenário típico de autenticação (login por 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 o cliente tem Jar, o cookie de sessão é salvo automaticamente,
// e todas as requisições seguintes já saem autenticadas.
resp, _ := client.Do(req)

Salvar o jar de cookies entre execuções do programa é tarefa manual: percorre-se jar.Cookies(url), serializa-se para um arquivo JSON e, na inicialização, carrega-se tudo de volta com jar.SetCookies(url, cookies). O cookiejar padrão não é persistente, mas existem invólucros prontos (github.com/juju/persistent-cookiejar).


9. Status da resposta e cabeçalhos

Depois de executar a requisição, o objeto *http.Response contém toda a metainformação.

go
resp, err := client.Do(req)
if err != nil {
    // Erro de rede (DNS, timeout, conexão recusada).
    // IMPORTANTE: se err != nil, resp == nil; não toque em 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"

// Cabeçalhos individuais (sem distinção de maiúsculas)
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"))

// Um cabeçalho pode ter vários valores
for _, v := range resp.Header.Values("Set-Cookie") {
    fmt.Println("cookie:", v)
}

// Todos os cabeçalhos de uma vez
for name, values := range resp.Header {
    fmt.Printf("%s: %v\n", name, values)
}

Tratamento correto dos status

go
switch {
case resp.StatusCode == http.StatusOK: // 200
    // parseamos o corpo

case resp.StatusCode == http.StatusTooManyRequests: // 429
    // estamos sendo limitados: lemos o cabeçalho Retry-After e aguardamos
    retryAfter := resp.Header.Get("Retry-After")
    fmt.Println("Rate limit, aguardando:", retryAfter)

case resp.StatusCode >= 500: // 5xx: erro do servidor
    // vale repetir a requisição mais tarde

case resp.StatusCode == http.StatusNotFound: // 404
    // a página não existe: não repetimos

case resp.StatusCode >= 300 && resp.StatusCode < 400: // 3xx
    // redirecionamento; por padrão, o cliente do Go segue até 10 redirecionamentos
}

Controle dos redirecionamentos

Por padrão, o cliente segue os redirecionamentos. Para desativá-los ou interceptá-los:

go
client := &http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        // Bloqueamos os redirecionamentos: a resposta 3xx é devolvida «como está»
        return http.ErrUseLastResponse
    },
}

Lembre a diferença: um erro de rede (err != nil) é quando a requisição nem chega ou não volta (DNS, timeout). Um erro HTTP (resp.StatusCode == 404/500) é uma resposta válida do servidor, e nesse caso err == nil. O código de status precisa ser verificado sempre em separado do err.


10. Extra: coisas importantes que costumam ficar esquecidas

Estas seções não estavam na lista inicial, mas sem elas um scraper de produção desmorona.

10.1. Cortesia e rate limiting

Não bombardeie o servidor com requisições: é carga sobre uma infraestrutura alheia e, de quebra, um banimento rápido. Limite a frequência com golang.org/x/time/rate:

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

// 2 requisições por segundo, rajada de até 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 { // bloqueia até «poder»
        return nil, err
    }
    return client.Get(url)
}

10.2. robots.txt

É de bom-tom (e às vezes uma necessidade legal) respeitar o robots.txt, em que o site indica o que pode ser indexado. Para parseá-lo, ajuda o 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") {
    // permitido: extraímos
} else {
    // proibido pelo robots.txt
}

10.3. Rotação de User-Agent

O mesmo User-Agent em milhares de requisições é um sinal evidente de bot. Mantenha uma lista e escolha um ao acaso:

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. Novas tentativas com atraso exponencial

A rede é instável: as falhas temporárias (5xx, timeouts) precisam ser repetidas com pausas crescentes:

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... + um pouco de aleatoriedade (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 // repetimos diante de um erro de rede
        }
        if resp.StatusCode >= 500 || resp.StatusCode == 429 {
            resp.Body.Close()
            lastErr = fmt.Errorf("status %d", resp.StatusCode)
            continue // repetimos diante de 5xx/429
        }
        return resp, nil // sucesso
    }
    return nil, fmt.Errorf("tentativas esgotadas: %w", lastErr)
}

10.5. Scraping de páginas com JavaScript

Se o conteúdo é desenhado pelo JavaScript (uma SPA em React/Vue), o http.Get devolve um esqueleto HTML quase vazio. Nesse caso, é preciso um navegador headless que controle um Chrome real pelo protocolo CDP:

  • github.com/chromedp/chromedp: controle do Chrome a partir do Go;
  • github.com/go-rod/rod: uma alternativa de nível mais alto.
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"),      // aguardamos o JS renderizar
    chromedp.OuterHTML("html", &html),     // capturamos o DOM já renderizado
)
// depois passamos o html ao goquery

Desvantagem: o navegador headless é pesado (memória, CPU) e lento. Antes de recorrer ao Chrome, verifique se os dados estão disponíveis por uma API JSON (veja a seção 2.4); pode ser que o navegador nem seja necessário.

10.6. Aspectos legais e éticos

O scraping é uma zona cinzenta. Referências básicas: respeite o robots.txt e os Termos de Serviço do site; não colete dados pessoais sem base legal (LGPD/GDPR); não gere carga excessiva; não faça conteúdo alheio passar por seu. O tecnicamente possível e o juridicamente admissível não são a mesma coisa.


11. Armazenamento de URLs e filas

Um crawler precisa de duas estruturas: uma fila de URLs ainda não visitadas (o frontier) e um conjunto das já visitadas (para não andar em círculos).

11.1. Em memória (a opção mais simples)

Para tarefas pequenas, bastam um canal como fila e um map como conjunto de visitadas:

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 // já vista: ignoramos
    }
    c.visited[url] = true
    select {
    case c.queue <- url:
    default: // fila cheia: descartamos ou acumulamos à parte
    }
}

Problemas da abordagem em memória: ao reiniciar, tudo se perde, e com milhões de URLs o map devora toda a memória.

11.2. Deduplicação em grande escala: o filtro de Bloom

Guardar dezenas de milhões de strings em um map é caro. O filtro de Bloom é uma estrutura probabilística que ocupa pouca memória e responde rápido «com certeza não vi» ou «talvez sim» (com uma pequena probabilidade de falso positivo):

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

// ~10 milhões de elementos, probabilidade de erro de 1%
filter := bloom.NewWithEstimates(10_000_000, 0.01)

if filter.TestString(url) {
    // talvez já vista: ignoramos (com o risco de, raramente, pular alguma nova)
} else {
    filter.AddString(url)
    // com certeza é nova: para a fila
}

11.3. Filas externas (produção, distribuição)

Quando o crawler precisa sobreviver a reinicializações e rodar em várias máquinas, a fila migra para um armazenamento externo:

Armazenamento Papel Características
Redis fila (LPUSH/BRPOP) + conjunto de visitadas (SET/SADD) rápido, atômico, ideal para workers distribuídos
RabbitMQ / Kafka fila de tarefas entrega confiável, ack/nack, reenvio das que falharam
PostgreSQL / SQLite frontier persistente prático para guardar URL + metadados + estado
BadgerDB / bbolt armazenamento KV embutido sem servidor à parte, tudo em um único binário

Exemplo de fila com Redis:

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

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

// Adicionar a URL à fila apenas se ainda não foi visitada (atômico via SET)
func enqueue(ctx context.Context, url string) error {
    // SADD devolve 1 se o elemento é novo
    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 // duplicata
}

// Pegar uma URL da fila (bloqueante)
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] é o nome da chave, res[1] é o valor
}

Esse esquema permite disparar dezenas de workers em máquinas diferentes: todos pegam tarefas de uma fila Redis comum e gravam os resultados em um banco de dados comum, sem duplicar o trabalho.

11.4. Prioridades e estratégia de percurso

  • BFS (uma fila FIFO normal): percorre o site «em largura»; costuma ser preferível para o crawling.
  • DFS (uma pilha LIFO): aprofunda por um único ramo.
  • Fila com prioridade (container/heap ou um Sorted Set do Redis): primeiro percorrem-se as páginas importantes (por exemplo, conforme a «profundidade» ou o valor estimado).

12. Vantagens e desvantagens da implementação em Go

Vantagens

  • Concorrência de fábrica. As goroutines e os canais tornam um crawler multithread algo natural e barato. Milhares de requisições em paralelo, sem a dor dos pools de threads.
  • Desempenho. Compila para código nativo, é rápido e poupa memória. Um scraper em Go supera em várias vezes o desempenho do equivalente em Python.
  • Um único binário. O go build produz um executável autônomo, sem dependências nem interpretador: o deploy é trivial, ideal para Docker e cron.
  • Biblioteca padrão poderosa. net/http, crypto/tls, cookiejar, context e encoding/json cobrem quase tudo sem pacotes de terceiros.
  • Tipagem estática. Muitos erros são detectados na compilação, e não em tempo de execução, no meio de um percurso de várias horas.
  • Detector de condições de corrida integrado (-race): insubstituível em código multithread.
  • Ecossistema maduro: colly, goquery e chromedp são ferramentas testadas pelo tempo.

Desvantagens

  • Verbosidade ao parsear HTML. Diante do Python (requests + BeautifulSoup em 5 linhas), o Go exige mais código e um tratamento explícito de erros.
  • Mais fraco em navegadores headless. O Python com Playwright/Selenium oferece mais possibilidades; chromedp/rod são bons, mas o ecossistema deles é mais modesto.
  • A antidetecção é mais difícil. Falsificar a impressão digital TLS (utls) e emular um navegador exigem mais esforço que as soluções prontas de outros stacks.
  • Tratamento manual de erros. Os constantes if err != nil cansam, embora disciplinem.
  • Menos ferramentas prontas de ML/NLP para o pós-processamento do texto extraído: aqui o Python segue na liderança. Um padrão comum: coleta de dados em Go e análise em Python.
  • Curva de entrada na concorrência. Canais, deadlocks, condições de corrida: é poderoso, mas exige compreensão; um iniciante cria com facilidade um vazamento de goroutines.

Quando usar Go e quando não

Cenário Recomendação
Crawler de alta carga, milhões de páginas Go é ideal
Serviço scraper de vida longa Go
Script pontual para «extrair uma tabelinha» Python fica pronto antes
Renderização JS pesada, antidetecção complexa Muitas vezes é mais cômodo Python + Playwright
Scraping + análise de ML na sequência Python está mais perto dos dados

13. Conclusão

O esquema mínimo de um scraper de produção em Go é este:

  1. Um único http.Client reutilizável, com timeout, um Transport configurado e (opcionalmente) um cookiejar.
  2. Worker pool ou errgroup com limite, para um paralelismo controlado.
  3. goquery (ou colly para o percurso) para extrair os dados, com charset.NewReader para uma codificação de caracteres correta.
  4. Proxies/TOR + rotação de User-Agent, quando é preciso anonimato ou driblar banimentos.
  5. Rate limiting e respeito ao robots.txt, para não derrubar o servidor nem acabar banido.
  6. Novas tentativas com backoff, para resistir às falhas.
  7. Fila externa (Redis) e deduplicação (filtro de Bloom), quando a tarefa não cabe mais na memória RAM.

Go oferece um equilíbrio excelente entre desempenho, confiabilidade e simplicidade de deploy. O conselho principal: comece sempre verificando se o site tem uma API JSON aberta. Se tiver, metade dos problemas deste artigo (codificações, renderização de JS, seletores frágeis) desaparece por conta própria.