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
- Como baixar a página
- Bibliotecas para o parsing do conteúdo
- Como resolver os problemas de codificação
- Concorrência
- Uso de proxies
- Scraping através do TOR
- Trabalho com HTTPS / SSL
- Trabalho com cookies
- Status da resposta e cabeçalhos
- Extra: cortesia, robots.txt, User-Agent, renderização de JS, novas tentativas
- Armazenamento de URLs e filas
- Vantagens e desvantagens da implementação em Go
- Conclusão
1. Como baixar a página
A opção mais simples
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
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
Timeoutdo cliente limita tudo: o estabelecimento da conexão, o envio e a leitura do corpo. Dá para afinar ainda mais com umhttp.Transportpró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.
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)
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)
}
// 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
go get github.com/gocolly/colly/v2O 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.
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.
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:
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:
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.
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://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:
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):
encoder := charmap.Windows1252.NewEncoder()
encoded, _ := encoder.String("Olá, mundo")
// encoded agora está em bytes Windows-1252 — vai no corpo da requisiçãoSe 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 65001para 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)
// 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.
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 loopfor range.sync.WaitGroup: esperamos todos os workers terminarem.resultsé fechado por uma goroutine à parte depois dowg.Wait(); caso contrário, ofor range resultsprincipal 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.
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) // 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:
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
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:
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:
docker run -d --name tor -p 9050:9050 -p 9051:9051 dperson/torproxyOu 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)
go get golang.org/x/net/proxypackage 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:
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:
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:
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:
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.
8. Trabalho com cookies
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.
Armazenamento automático de cookies
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.
Definir cookies manualmente
Quando você já tem o token de sessão (copiado do navegador) e só falta «injetar» a sessão:
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)
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 comjar.SetCookies(url, cookies). Ocookiejarpadrã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.
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
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:
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 casoerr == nil. O código de status precisa ser verificado sempre em separado doerr.
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:
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:
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:
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:
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.
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 goqueryDesvantagem: 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:
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):
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:
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/heapou 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 buildproduz 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,contexteencoding/jsoncobrem 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+BeautifulSoupem 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 != nilcansam, 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:
- Um único
http.Clientreutilizável, com timeout, umTransportconfigurado e (opcionalmente) umcookiejar. - Worker pool ou
errgroupcom limite, para um paralelismo controlado. - goquery (ou colly para o percurso) para extrair os dados, com
charset.NewReaderpara uma codificação de caracteres correta. - Proxies/TOR + rotação de User-Agent, quando é preciso anonimato ou driblar banimentos.
- Rate limiting e respeito ao
robots.txt, para não derrubar o servidor nem acabar banido. - Novas tentativas com backoff, para resistir às falhas.
- 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.