Go es uno de los lenguajes más cómodos para escribir scrapers y crawlers: el tipado estático detecta errores en tiempo de compilación, las goroutines ofrecen concurrencia barata de serie y la biblioteca estándar net/http cubre la mayor parte del trabajo de red sin dependencias externas. En este artículo recorreremos todo el camino: desde descargar una sola página hasta un crawler distribuido y multihilo con proxies, TOR y colas.
Todo el código se probó en Go 1.21+. Los paquetes de terceros se instalan con go get; los comandos exactos aparecen en las secciones correspondientes.
Índice
- Cómo descargar la página
- Bibliotecas para el parseo del contenido
- Cómo resolver los problemas de codificación
- Concurrencia
- Uso de proxies
- Scraping a través de TOR
- Trabajar con HTTPS / SSL
- Trabajar con cookies
- Estado de la respuesta y cabeceras
- Extra: cortesía, robots.txt, User-Agent, renderizado JS, reintentos
- Almacenamiento de URL y colas
- Ventajas e inconvenientes de la implementación en Go
- Conclusión
1. Cómo descargar la página
La opción más simple
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
resp, err := http.Get("https://example.com")
if err != nil {
panic(err)
}
defer resp.Body.Close() // hay que cerrar el cuerpo siempre; de lo contrario, fuga de conexiones
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}http.Get usa el http.DefaultClient global, que no tiene timeout. Para un scraper de producción esto es inadmisible: un solo servidor colgado bloqueará la goroutine para siempre.
La opción correcta: un cliente propio con timeout y cabeceras
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 toda la petición
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, nil, err
}
// Muchos sitios devuelven 403 sin un 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", "es,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("Estado:", resp.StatusCode, "| Tamaño:", len(body))
}Puntos clave:
context.Context: el mecanismo único de cancelación. Si el contexto expira o se cancela, la petición se interrumpe. Salva en el apagado ordenado (graceful shutdown) y al limitar el tiempo de todo el recorrido.- El
Timeoutdel cliente lo limita todo: el establecimiento de la conexión, el envío y la lectura del cuerpo. Se puede afinar más con unhttp.Transportpropio (véase más abajo). defer resp.Body.Close(): un cuerpo sin cerrar retiene la conexión TCP e impide reutilizarla desde el pool. A gran escala provoca el error «too many open files».
Ajuste fino de Transport
http.Transport es el «motor» que hay bajo el cliente. Una misma instancia del transporte se reutiliza entre peticiones y mantiene un pool de conexiones keep-alive, así que créela una sola vez para toda la aplicación, no en cada petición.
transport := &http.Transport{
MaxIdleConns: 100, // total de conexiones idle en el pool
MaxIdleConnsPerHost: 10, // por host
IdleConnTimeout: 90 * time.Second, // cuánto vive una conexión idle
DisableCompression: false, // gzip se descomprime automáticamente
ForceAttemptHTTP2: true,
}
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}Error frecuente: crear un
&http.Client{}(o el transporte) dentro del bucle, uno por URL. Eso rompe el pool de conexiones y agota los puertos. Cree el cliente una sola vez y páselo por referencia.
2. Bibliotecas para el parseo del contenido
Una vez descargado el HTML, hay que parsearlo. Existen tres niveles de herramientas.
2.1. goquery: sintaxis al estilo jQuery (la más 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)
}
// selectores CSS como en jQuery
doc.Find(".titleline > a").Each(func(i int, s *goquery.Selection) {
title := s.Text()
href, _ := s.Attr("href")
fmt.Printf("%d. %s — %s\n", i+1, title, href)
})
}goquery admite casi todo CSS3: .class, #id, [attr=value], :first-child, :nth-of-type(n), los combinadores >, +, ~. Es la mejor opción para la mayoría de las tareas de parseo de HTML.
2.2. colly: un framework de crawler completo
go get github.com/gocolly/colly/v2Colly se encarga de la descarga, el parseo, el seguimiento de enlaces, la limitación de velocidad, el almacenamiento en caché y mucho más: ya no es un simple scraper, sino un 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), // recorrido asíncrono
)
// Límite de paralelismo y retardo: cortesía integrada
c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 4,
Delay: 500 * time.Millisecond,
RandomDelay: 500 * time.Millisecond,
})
// Callback para cada encabezado encontrado
c.OnHTML("h1, h2", func(e *colly.HTMLElement) {
fmt.Println("Encabezado:", e.Text)
})
// Seguimiento de todos los enlaces
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("Descargando:", r.URL)
})
c.OnError(func(r *colly.Response, err error) {
fmt.Println("Error:", r.Request.URL, err)
})
c.Visit("https://example.com")
c.Wait() // esperamos a que terminen todas las peticiones asíncronas
}Conviene elegir Colly cuando lo que hace falta es recorrer un sitio (crawling), no parsear una sola página.
2.3. golang.org/x/net/html: tokenizador de bajo nivel
Paquete estándar (semiestándar). Ofrece el máximo control y cero dependencias, pero escribir con él a mano es tedioso: es un análisis en flujo, token a token.
package main
import (
"fmt"
"strings"
"golang.org/x/net/html"
)
func main() {
r := strings.NewReader(`<html><body><a href="/x">Enlace</a></body></html>`)
tokenizer := html.NewTokenizer(r)
for {
tt := tokenizer.Next()
if tt == html.ErrorToken {
break // fin 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)
}
}
}
}
}
}Úselo cuando importe la velocidad en documentos enormes o cuando goquery resulte «demasiado pesado».
2.4. JSON y API en lugar de HTML
A menudo los datos de la página se cargan mediante una petición AJAX independiente que devuelve JSON. Es el caso más cómodo: parsear JSON es más fiable 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 la pestaña Network de las DevTools: puede que el endpoint JSON que necesita ya exista.
2.5. XPath
Si prefiere XPath, existe github.com/antchfx/htmlquery:
doc, _ := htmlquery.LoadURL("https://example.com")
nodes := htmlquery.Find(doc, "//div[@class='item']/a/@href")Comparación
| Herramienta | Cuándo usarla | Dependencias |
|---|---|---|
goquery |
Parseo de HTML, selectores CSS | 1 |
colly |
Recorrer sitios completos | varias |
x/net/html |
Máximo control/velocidad | semi-std |
encoding/json |
La API/AJAX devuelve JSON | std |
htmlquery |
Si prefiere XPath | 1 |
3. Cómo resolver los problemas de codificación (acentos y caracteres especiales)
La pesadilla clásica: descarga un sitio en español y, en lugar del texto, ve España o corazón, y en vez de ¿Qué? aparece ¿Qué?. El motivo: el sitio no sirve el contenido en UTF-8, sino en ISO-8859-1 (Latin-1) o Windows-1252, mientras que Go, por defecto, interpreta todos los bytes de una cadena como UTF-8.
Solución universal: detector de charset
El paquete golang.org/x/net/html/charset detecta la codificación por la cabecera Content-Type, por <meta charset> y de forma heurística según el contenido; después devuelve un reader que transcodifica el flujo a UTF-8 sobre la marcha.
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://sitio-latin1.example")
defer resp.Body.Close()
// charset.NewReader detecta la codificación y transcodifica a 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()) // ahora los acentos y la ñ se ven correctos
_ = io.Discard
}Esta solución funciona en el 95% de los casos; conviene usarla por defecto.
Indicar la codificación de forma explícita
Si conoce con certeza la codificación (por ejemplo, el sitio siempre está en Windows-1252), puede transcodificar 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))Para ISO-8859-1 (Latin-1) — charmap.ISO8859_1; para ISO-8859-15 (Latin-9, con el símbolo del euro) — charmap.ISO8859_15.
El problema inverso: enviar texto con acentos
Si necesita enviar un POST con acentos en Windows-1252 (por ejemplo, a un formulario antiguo):
encoder := charmap.Windows1252.NewEncoder()
encoded, _ := encoder.String("Hola mundo")
// encoded ahora está en bytes Windows-1252 — se envía en el cuerpo de la peticiónSi ve caracteres corruptos (mojibake) solo en la consola de Windows, mientras que en los archivos todo se guarda bien, el problema no está en el scraper, sino en la codificación del terminal. Ejecute
chcp 65001para cambiar cmd a UTF-8.
4. Concurrencia
Aquí Go se luce. Las goroutines son miles de veces más baratas que los hilos del SO, y los canales permiten intercambiar datos de forma segura sin mutex explícitos.
4.1. El enfoque ingenuo (incorrecto)
// Mal: lanzamos una goroutine por CADA url sin límite alguno
for _, url := range urls {
go fetch(url) // 100 000 url → 100 000 peticiones simultáneas → el servidor cae o le banean
}Sin límite de paralelismo, o tumba el servidor de destino, o agota el límite de descriptores de archivo, o le banean al instante.
4.2. Worker pool: el patrón canónico
Creamos un número fijo de workers que van tomando tareas de un canal. El paralelismo queda limitado por el 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",
// ... miles de 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)
}
// Enviamos las tareas en una goroutine aparte
go func() {
for _, u := range urls {
jobs <- u
}
close(jobs) // importante: cerramos el canal para que los workers terminen
}()
// Cerramos results cuando todos los workers han terminado
go func() {
wg.Wait()
close(results)
}()
// Leemos los 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álisis del patrón:
jobs: el canal de tareas de entrada. Lo cerramos tras enviar todas las URL: es la señal para que los workers terminen el buclefor range.sync.WaitGroup: esperamos a que todos los workers terminen.resultslo cierra una goroutine aparte traswg.Wait(); de lo contrario, elfor range resultsprincipal se colgaría para siempre.- El paralelismo se controla con una sola constante,
numWorkers.
4.3. Límite mediante semáforo (errgroup)
Una forma más moderna: golang.org/x/sync/errgroup con límite. Además, recoge cómodamente el primer error y admite el 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) // máximo 8 goroutines simultáneas
client := &http.Client{}
for _, u := range urls {
u := u // importante en Go < 1.22: captura de la variable del bucle
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("Una de las tareas falló:", err)
}
}En Go anterior a 1.22, la variable del bucle se reutiliza, por lo que la línea
u := ues obligatoria: de lo contrario, todas las goroutines reciben la misma URL (la última). En Go 1.22+ esto se corrigió a nivel de lenguaje, pero la costumbre no hace daño.
4.4. Protección de los datos compartidos
Si los workers escriben en un map compartido (por ejemplo, el conjunto de URL visitadas), hay que protegerlo:
var (
visited = make(map[string]bool)
mu sync.Mutex
)
func markVisited(url string) bool {
mu.Lock()
defer mu.Unlock()
if visited[url] {
return false // ya estaba
}
visited[url] = true
return true
}Alternativas: sync.Map (buena cuando hay «muchas lecturas y pocas escrituras») o sync/atomic para los contadores. Ejecute las pruebas con la opción -race: el detector de condiciones de carrera de Go encuentra estos fallos automáticamente.
5. Uso de proxies
Los proxies sirven para repartir la carga, sortear los bloqueos geográficos y reducir la probabilidad de un baneo por IP.
Un proxy por cliente
package main
import (
"net/http"
"net/url"
)
func clientWithProxy(proxyAddr string) (*http.Client, error) {
// Se admite user:pass@host:port
proxyURL, err := url.Parse(proxyAddr) // p. ej. "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
}Se admiten los esquemas http://, https:// y socks5://. Para la autenticación, el usuario y la contraseña se ponen directamente en la URL.
Rotación de un pool de proxies
Para repartir las peticiones entre una lista de proxies, se puede sustituir la función Proxy del transporte: se invoca en cada petición:
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
}
// Selección round-robin del proxy
func (r *ProxyRotator) Next(_ *http.Request) (*url.URL, error) {
if len(r.proxies) == 0 {
return nil, nil // sin 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, // en cada petición, el siguiente proxy
}
client := &http.Client{Transport: transport}
_ = client
_ = rand.Int
}En la práctica conviene guardar junto a cada proxy su «salud»: un contador de errores y la hora del último baneo. Un proxy caído se excluye temporalmente de la rotación. Para ello se suele escribir un pequeño envoltorio que comprueba el proxy contra un endpoint «vivo» antes de usarlo.
6. Scraping a través de TOR
TOR es una red de anonimización gratuita, disponible localmente como proxy SOCKS5 (por defecto en 127.0.0.1:9050). El scraping a través de TOR es útil para el anonimato y el cambio automático de IP, pero es lento y muchos sitios bloquean los nodos de salida de TOR.
Preparación
Arranque el demonio de TOR. Lo más fácil es con Docker:
docker run -d --name tor -p 9050:9050 -p 9051:9051 dperson/torproxyO instale el paquete del sistema tor (apt install tor, brew install tor): levanta por sí mismo el SOCKS5 en el 9050.
Cliente HTTP a través de 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) {
// Nos conectamos al SOCKS5 local de TOR
dialer, err := proxy.SOCKS5("tcp", "127.0.0.1:9050", nil, proxy.Direct)
if err != nil {
return nil, err
}
transport := &http.Transport{
Dial: dialer.Dial, // todo el tráfico pasa por TOR
}
return &http.Client{Transport: transport}, nil
}
func main() {
client, err := torClient()
if err != nil {
panic(err)
}
// check.torproject.org confirmará que estamos en 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 de IP (nuevo circuito) por el puerto de control
TOR puede construir un nuevo circuito bajo demanda, es decir, cambiar la IP de salida. Se hace por el puerto de control (9051), enviando la señal 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()
// Autenticación (la contraseña debe estar configurada en torrc)
if _, _, err := conn.Cmd(`AUTHENTICATE "%s"`, controlPassword); err != nil {
return err
}
conn.ReadResponse(250)
// Señal para cambiar de circuito
id, _ := conn.Cmd("SIGNAL NEWNYM")
conn.StartResponse(id)
defer conn.EndResponse(id)
_, msg, err := conn.ReadResponse(250)
fmt.Println("TOR respondió:", msg)
return err
}Para que el puerto de control funcione, en torrc deben estar definidos ControlPort 9051 y el hash de la contraseña (HashedControlPassword, que se genera con tor --hash-password SU_CONTRASEÑA).
TOR da anonimato, pero no invisibilidad. La velocidad es baja, los nodos de salida suelen estar en listas negras y un scraping agresivo a través de TOR es de mala educación con una red voluntaria. Para la recolección masiva encajan mejor los proxies residenciales comerciales.
7. Trabajar con HTTPS / SSL
Buena noticia: para HTTPS normalmente no hay que hacer nada; Go verifica los certificados automáticamente usando el almacén del sistema de raíces de confianza.
Cuándo sí hace falta intervenir
1. Certificados autofirmados (sitios de prueba/internos). Desactivar la verificación resuelve el problema, pero abre un agujero para ataques MITM; solo para pruebas:
import "crypto/tls"
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // ¡NO en producción!
},
}
client := &http.Client{Transport: transport}2. La forma correcta: añadir un certificado raíz concreto al pool de confianza:
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, // no inferior a TLS 1.2
},
}
return &http.Client{Transport: transport}, nil
}3. Control de la versión de TLS y los cifrados. A veces el sitio exige una configuración concreta o, al revés, usted quiere imitar un navegador determinado:
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS13,
}Fingerprinting de TLS. Los sistemas antibot avanzados (Cloudflare, Akamai) identifican bots por la «huella» del handshake TLS (JA3/JA4): el cliente estándar de Go tiene una huella reconocible. Para hacerse pasar por un navegador real se usa la biblioteca
github.com/refraction-networking/utls, capaz de falsificar el ClientHello imitando a Chrome/Firefox. Es ya un tema avanzado de antidetección.
8. Trabajar con cookies
Las cookies son necesarias para las sesiones, la autenticación y para «superar» ciertas comprobaciones. Go sabe gestionarlas automáticamente con cookiejar.
Almacenamiento automático de cookies
package main
import (
"fmt"
"net/http"
"net/http/cookiejar"
"golang.org/x/net/publicsuffix"
)
func main() {
// la lista publicsuffix es necesaria para tratar bien los dominios
jar, err := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
if err != nil {
panic(err)
}
client := &http.Client{Jar: jar}
// Primera petición: el servidor asigna una cookie (p. ej. de sesión)
client.Get("https://example.com/login")
// La segunda petición envía automáticamente las cookies guardadas
resp, _ := client.Get("https://example.com/dashboard")
defer resp.Body.Close()
// Ver qué hay en el jar para un host concreto
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, el cliente se comporta como un navegador: acepta las Set-Cookie de las respuestas y las incluye en las peticiones siguientes al mismo dominio.
Establecer cookies manualmente
Cuando ya tiene el token de sesión (copiado del navegador) y solo hace falta «inyectar» la sesión:
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)Escenario típico de autenticación (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")
// Si el cliente tiene Jar, la cookie de sesión se guarda automáticamente,
// y todas las peticiones siguientes irán autenticadas.
resp, _ := client.Do(req)Guardar el jar de cookies entre ejecuciones del programa se hace a mano: se recorre
jar.Cookies(url), se serializa a un archivo JSON y, al arrancar, se carga de nuevo conjar.SetCookies(url, cookies). Elcookiejarestándar no es persistente, pero existen envoltorios ya hechos (github.com/juju/persistent-cookiejar).
9. Estado de la respuesta y cabeceras
Tras ejecutar la petición, el objeto *http.Response contiene toda la metainformación.
resp, err := client.Do(req)
if err != nil {
// Error de red (DNS, timeout, conexión rechazada).
// IMPORTANTE: si err != nil, resp == nil; ¡no toque resp.Body!
return err
}
defer resp.Body.Close()
// Estado
fmt.Println(resp.StatusCode) // 200, 404, 503 ...
fmt.Println(resp.Status) // "200 OK", "404 Not Found"
// Cabeceras individuales (no distingue mayú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"))
// Una cabecera puede tener varios valores
for _, v := range resp.Header.Values("Set-Cookie") {
fmt.Println("cookie:", v)
}
// Todas las cabeceras a la vez
for name, values := range resp.Header {
fmt.Printf("%s: %v\n", name, values)
}Manejo correcto de los estados
switch {
case resp.StatusCode == http.StatusOK: // 200
// parseamos el cuerpo
case resp.StatusCode == http.StatusTooManyRequests: // 429
// nos están limitando: leemos la cabecera Retry-After y esperamos
retryAfter := resp.Header.Get("Retry-After")
fmt.Println("Rate limit, esperamos:", retryAfter)
case resp.StatusCode >= 500: // 5xx: error del servidor
// conviene reintentar la petición más tarde
case resp.StatusCode == http.StatusNotFound: // 404
// la página no existe: no reintentamos
case resp.StatusCode >= 300 && resp.StatusCode < 400: // 3xx
// redirección; por defecto el cliente de Go sigue hasta 10 redirecciones
}Control de las redirecciones
Por defecto, el cliente sigue las redirecciones. Para desactivarlas o interceptarlas:
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Bloqueamos las redirecciones: se devuelve la respuesta 3xx «tal cual»
return http.ErrUseLastResponse
},
}Recuerde la diferencia: un error de red (
err != nil) es cuando la petición ni siquiera llega o no vuelve (DNS, timeout). Un error HTTP (resp.StatusCode == 404/500) es una respuesta válida del servidor, y en ese casoerr == nil. El código de estado hay que comprobarlo siempre por separado deerr.
10. Extra: cosas importantes que se suelen olvidar
Estas secciones no estaban en la lista inicial, pero sin ellas un scraper de producción se desmorona.
10.1. Cortesía y rate limiting
No bombardee el servidor con peticiones: es carga sobre una infraestructura ajena y, además, un baneo rápido. Limite la frecuencia con golang.org/x/time/rate:
import "golang.org/x/time/rate"
// 2 peticiones por segundo, ráfaga de hasta 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 { // se bloquea hasta que «se puede»
return nil, err
}
return client.Get(url)
}10.2. robots.txt
Es de buena educación (y a veces una necesidad legal) respetar robots.txt, donde el sitio indica qué se puede indexar. Para parsearlo ayuda 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: extraemos
} else {
// prohibido por robots.txt
}10.3. Rotación de User-Agent
El mismo User-Agent en miles de peticiones es una señal evidente de bot. Mantenga una lista y elija uno al azar:
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. Reintentos con retardo exponencial
La red es inestable: los fallos temporales (5xx, timeouts) hay que reintentarlos 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... + algo de aleatoriedad (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 // reintentamos ante un error de red
}
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
resp.Body.Close()
lastErr = fmt.Errorf("estado %d", resp.StatusCode)
continue // reintentamos ante 5xx/429
}
return resp, nil // éxito
}
return nil, fmt.Errorf("agotados los intentos: %w", lastErr)
}10.5. Scraping de páginas con JavaScript
Si el contenido lo pinta JavaScript (una SPA en React/Vue), http.Get devuelve un esqueleto HTML casi vacío. Entonces hace falta un navegador headless que controle un Chrome real mediante el protocolo CDP:
github.com/chromedp/chromedp: control de Chrome desde Go;github.com/go-rod/rod: una alternativa de más alto nivel.
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"), // esperamos a que el JS renderice
chromedp.OuterHTML("html", &html), // capturamos el DOM ya renderizado
)
// luego pasamos html a goqueryInconveniente: el navegador headless es pesado (memoria, CPU) y lento. Antes de tirar de Chrome, compruebe si los datos están disponibles por una API JSON (vea la sección 2.4); puede que el navegador no haga falta.
10.6. Aspectos legales y éticos
El scraping es una zona gris. Referencias básicas: respete el robots.txt y los Términos de Servicio del sitio; no recopile datos personales sin base legal (RGPD/LOPDGDD); no genere una carga excesiva; no haga pasar contenido ajeno por propio. Lo técnicamente posible y lo jurídicamente admisible no son lo mismo.
11. Almacenamiento de URL y colas
Un crawler necesita dos estructuras: una cola de URL aún no visitadas (el frontier) y un conjunto de las ya visitadas (para no dar vueltas en círculo).
11.1. En memoria (la opción más simple)
Para tareas pequeñas basta con un canal como cola y un map como conjunto de visitados:
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 // ya visto: lo omitimos
}
c.visited[url] = true
select {
case c.queue <- url:
default: // cola llena: descartamos o acumulamos aparte
}
}Problemas del enfoque en memoria: al reiniciar se pierde todo, y con millones de URL el map se come toda la memoria.
11.2. Deduplicación a gran escala: el filtro de Bloom
Guardar decenas de millones de cadenas en un map es caro. El filtro de Bloom es una estructura probabilística que ocupa poca memoria y responde rápido «seguro que no lo he visto» o «puede que sí» (con una pequeña probabilidad de falso positivo):
import "github.com/bits-and-blooms/bloom/v3"
// ~10 millones de elementos, probabilidad de error del 1%
filter := bloom.NewWithEstimates(10_000_000, 0.01)
if filter.TestString(url) {
// puede que ya visto: lo omitimos (con riesgo de saltar alguno nuevo en raras ocasiones)
} else {
filter.AddString(url)
// seguro que es nuevo: a la cola
}11.3. Colas externas (producción, distribución)
Cuando el crawler debe sobrevivir a los reinicios y funcionar en varias máquinas, la cola se traslada a un almacenamiento externo:
| Almacenamiento | Rol | Características |
|---|---|---|
| Redis | cola (LPUSH/BRPOP) + conjunto de visitados (SET/SADD) |
rápido, atómico, ideal para workers distribuidos |
| RabbitMQ / Kafka | cola de tareas | entrega fiable, ack/nack, reenvío de las fallidas |
| PostgreSQL / SQLite | frontier persistente | cómodo para guardar URL + metadatos + estado |
| BadgerDB / bbolt | almacén KV embebido | sin servidor aparte, todo en un único binario |
Ejemplo de cola con Redis:
import "github.com/redis/go-redis/v9"
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// Añadir URL a la cola solo si no se ha visitado (atómico mediante SET)
func enqueue(ctx context.Context, url string) error {
// SADD devuelve 1 si el elemento es nuevo
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 // duplicado
}
// Tomar una URL de la cola (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] es el nombre de la clave, res[1] el valor
}Este esquema permite lanzar decenas de workers en distintas máquinas: todos toman tareas de una cola Redis común y escriben los resultados en una base de datos común, sin duplicar el trabajo.
11.4. Prioridades y estrategia de recorrido
- BFS (una cola FIFO normal): recorre el sitio «a lo ancho»; suele ser preferible para el crawling.
- DFS (una pila LIFO): profundiza por una sola rama.
- Cola con prioridad (
container/heapo un Sorted Set de Redis): primero se recorren las páginas importantes (por ejemplo, según la «profundidad» o el valor estimado).
12. Ventajas e inconvenientes de la implementación en Go
Ventajas
- Concurrencia de serie. Las goroutines y los canales hacen que un crawler multihilo sea natural y barato. Miles de peticiones en paralelo, sin el dolor de los pools de hilos.
- Rendimiento. Compila a código nativo, es rápido y ahorra memoria. Un scraper en Go supera por varias veces en rendimiento a su equivalente en Python.
- Un único binario.
go buildproduce un ejecutable autónomo, sin dependencias ni intérprete: el despliegue es trivial, ideal para Docker y cron. - Biblioteca estándar potente.
net/http,crypto/tls,cookiejar,contextyencoding/jsoncubren casi todo sin paquetes de terceros. - Tipado estático. Muchos errores se detectan en compilación y no en tiempo de ejecución, en mitad de un recorrido de varias horas.
- Detector de condiciones de carrera integrado (
-race): insustituible en código multihilo. - Ecosistema maduro: colly, goquery y chromedp son herramientas probadas con el tiempo.
Inconvenientes
- Verbosidad al parsear HTML. Frente a Python (
requests+BeautifulSoupen 5 líneas), Go exige más código y un manejo explícito de errores. - Más flojo para navegadores headless. Python con Playwright/Selenium ofrece más posibilidades; chromedp/rod son buenos, pero su ecosistema es más modesto.
- La antidetección es más difícil. Falsificar la huella TLS (utls) y emular un navegador exige más esfuerzo que las soluciones ya hechas de otros stacks.
- Manejo manual de errores. Los constantes
if err != nilcansan, aunque disciplinan. - Menos herramientas de ML/NLP listas para usar para el posprocesado del texto extraído: aquí Python sigue liderando. Un patrón habitual: recolección de datos en Go y analítica en Python.
- Curva de entrada en la concurrencia. Canales, deadlocks, condiciones de carrera: es potente, pero exige comprensión; un principiante crea con facilidad una fuga de goroutines.
Cuándo usar Go y cuándo no
| Escenario | Recomendación |
|---|---|
| Crawler de alta carga, millones de páginas | Go es ideal |
| Servicio scraper de larga vida | Go |
| Script puntual para «extraer una tablita» | Python se escribe antes |
| Renderizado JS pesado, antidetección compleja | A menudo es más cómodo Python + Playwright |
| Scraping + análisis ML inmediato | Python está más cerca de los datos |
13. Conclusión
El esquema mínimo de un scraper de producción en Go es este:
- Un solo
http.Clientreutilizable, con timeout, unTransportconfigurado y (opcionalmente) uncookiejar. - Worker pool o
errgroupcon límite, para un paralelismo controlado. - goquery (o colly para el recorrido) para extraer los datos, con
charset.NewReaderpara una codificación de caracteres correcta. - Proxies/TOR + rotación de User-Agent, cuando hace falta anonimato o sortear baneos.
- Rate limiting y respeto al
robots.txt, para no tumbar el servidor ni acabar baneado. - Reintentos con backoff, para resistir los fallos.
- Cola externa (Redis) y deduplicación (filtro de Bloom), cuando la tarea desborda la memoria RAM.
Go ofrece un equilibrio excelente entre rendimiento, fiabilidad y sencillez de despliegue. El consejo principal: empiece siempre por comprobar si el sitio tiene una API JSON abierta. Si la tiene, la mitad de los problemas de este artículo (codificaciones, renderizado JS, selectores frágiles) desaparecen por sí solos.