Crear un scraper sencillo en Go: concurrencia, HTTP y parsing
Tutorial para crear un web scraper en Go con net/http, goquery, rate limiting y concurrencia controlada. Scraping práctico.

BeautifulSoup + requests de Python es más rápido de escribir. Puedes tener un scraper funcional en quince líneas, con parsing de HTML, manejo de sesiones y exportación a CSV sin despeinarte. Para scraping puntual, sigo usando Python. Pero cuando necesité scrapear 50.000 páginas de forma concurrente, con control fino sobre las conexiones, reintentos y sin arrastrar un virtualenv a producción, Go fue la opción que encajó.
Go no es el lenguaje más cómodo para scraping rápido. Es un hecho. No tiene el ecosistema de Scrapy, ni la comunidad de herramientas de extracción que tiene Python. Pero tiene goroutines, compilación a un binario estático, una librería HTTP estándar sólida y un modelo de concurrencia que no necesita asyncio ni event loops. Para scrapers que van a correr como servicios, en contenedores, procesando volúmenes grandes, eso importa.
Lo que vamos a construir aquí es un scraper pequeño pero real. Hace peticiones HTTP, parsea HTML, extrae datos estructurados, maneja errores, respeta rate limits y corre con concurrencia controlada. Si vienes de Python y estás explorando Go, esto te va a dar un ejemplo concreto de cómo se traduce el flujo de scraping a este lenguaje. Si ya conoces Go, igual encuentras algún patrón útil para tus propios scrapers. Para una comparación más amplia entre ambos lenguajes, tengo un artículo dedicado a Go vs Python.
El cliente HTTP en Go: net/http
Go tiene un cliente HTTP en la librería estándar que no necesita nada más. Sin dependencias externas, sin wrappers. net/http es lo que usan la mayoría de herramientas HTTP en Go por debajo, incluidos frameworks como Gin o librerías como Resty.
La forma más básica de hacer una petición GET:
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
resp, err := http.Get("https://example.com")
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("Error leyendo body:", err)
return
}
fmt.Println(string(body))
}Funciona, pero tiene un problema fundamental para scraping: usa el cliente HTTP por defecto (http.DefaultClient), que no tiene timeout. Si un servidor tarda diez minutos en responder, tu programa va a quedarse esperando diez minutos. En un scraper con concurrencia, eso es un desastre.
Lo primero que necesitas es crear tu propio cliente con configuración explícita:
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
},
}Timeout es el timeout total de la petición (incluyendo lectura del body). Transport controla el pool de conexiones. MaxIdleConnsPerHost es importante para scraping: si estás haciendo muchas peticiones al mismo dominio, quieres reutilizar conexiones TCP en vez de abrir una nueva cada vez.
Para peticiones más configurables, usa http.NewRequest en vez de http.Get:
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("creando request para %s: %w", url, err)
}
req.Header.Set("User-Agent", "MiScraper/1.0 (+https://example.com/bot)")
req.Header.Set("Accept", "text/html")
req.Header.Set("Accept-Language", "es-ES,es;q=0.9")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("haciendo GET %s: %w", url, err)
}
defer resp.Body.Close()Fíjate en el User-Agent. No es opcional. Es lo mínimo que deberías hacer como scraper responsable: identificarte. Muchos servidores bloquean peticiones sin User-Agent o con User-Agent genéricos.
Parsing HTML con goquery
Go no tiene un equivalente a BeautifulSoup en la librería estándar. Tiene golang.org/x/net/html para parsear HTML, pero su API es de bajo nivel y trabajar con ella directamente es tedioso. La librería que todo el mundo usa para scraping en Go es goquery. Es el equivalente a jQuery para Go: selectores CSS, traversal del DOM, extracción de texto y atributos.
Instálala con:
go get github.com/PuerkitoBio/goqueryUso básico:
package main
import (
"fmt"
"log"
"net/http"
"github.com/PuerkitoBio/goquery"
)
func main() {
resp, err := http.Get("https://example.com")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
log.Fatal(err)
}
// Extraer el título de la página
title := doc.Find("title").Text()
fmt.Println("Título:", title)
// Extraer todos los enlaces
doc.Find("a").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
if exists {
fmt.Printf("Enlace %d: %s -> %s\n", i, s.Text(), href)
}
})
}Los selectores CSS de goquery cubren prácticamente todo lo que necesitas:
// Por clase
doc.Find(".article-title")
// Por ID
doc.Find("#main-content")
// Selectores compuestos
doc.Find("div.product > h2.name")
// Atributos
doc.Find("a[href^='https']")
// Pseudo-selectores
doc.Find("tr:nth-child(even)")Para extraer datos, los métodos más comunes son:
// Texto del elemento
text := s.Text()
// Atributo
href, exists := s.Attr("href")
// HTML interno
html, err := s.Html()
// Primer elemento que coincida
first := doc.Find(".item").First()
// Recorrer todos los elementos
doc.Find(".item").Each(func(i int, s *goquery.Selection) {
// ...
})Si vienes de BeautifulSoup, la traducción mental es directa. soup.select(".class") es doc.Find(".class"). tag.get_text() es s.Text(). tag["href"] es s.Attr("href").
Construyendo el scraper: extraer datos de una página
Vamos a construir algo concreto. Imagina que queremos scrapear un sitio de noticias ficticias y extraer los artículos de la página principal: título, enlace, resumen y fecha.
Primero, definimos la estructura de datos:
type Article struct {
Title string `json:"title"`
URL string `json:"url"`
Summary string `json:"summary"`
Date string `json:"date"`
}Ahora, la función que parsea una página y extrae los artículos:
func parseArticles(doc *goquery.Document, baseURL string) []Article {
var articles []Article
doc.Find("article.post").Each(func(i int, s *goquery.Selection) {
title := strings.TrimSpace(s.Find("h2.post-title").Text())
if title == "" {
return // Saltar elementos sin título
}
href, exists := s.Find("h2.post-title a").Attr("href")
if !exists {
return
}
// Resolver URLs relativas
fullURL := resolveURL(baseURL, href)
summary := strings.TrimSpace(s.Find("p.post-summary").Text())
date := strings.TrimSpace(s.Find("time").AttrOr("datetime", ""))
articles = append(articles, Article{
Title: title,
URL: fullURL,
Summary: summary,
Date: date,
})
})
return articles
}La función resolveURL se encarga de convertir URLs relativas en absolutas:
func resolveURL(base, ref string) string {
baseURL, err := url.Parse(base)
if err != nil {
return ref
}
refURL, err := url.Parse(ref)
if err != nil {
return ref
}
return baseURL.ResolveReference(refURL).String()
}Y la función que hace la petición HTTP y conecta todo:
func fetchArticles(client *http.Client, pageURL string) ([]Article, error) {
req, err := http.NewRequest("GET", pageURL, nil)
if err != nil {
return nil, fmt.Errorf("creando request: %w", err)
}
req.Header.Set("User-Agent", "GoScraper/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", pageURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d para %s", resp.StatusCode, pageURL)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("parsing HTML de %s: %w", pageURL, err)
}
return parseArticles(doc, pageURL), nil
}Fíjate en el patrón: cada error se envuelve con contexto usando %w. Esto te permite saber exactamente qué falló y dónde cuando depuras. Si el manejo de errores en Go te parece excesivo, te recomiendo leer mi artículo sobre errores en Go donde explico por qué esta verbosidad es una ventaja real.
Añadiendo concurrencia: goroutines y worker pool
Hasta aquí tenemos un scraper secuencial. Funciona, pero si tienes 1.000 páginas que scrapear, va a tardar una eternidad. Aquí es donde Go brilla.
El enfoque ingenuo (no lo hagas)
// NO hagas esto
for _, url := range urls {
go func(u string) {
articles, err := fetchArticles(client, u)
// ...
}(url)
}Lanzar una goroutine por URL sin control va a hacer que dispares 1.000 peticiones simultáneas. El servidor te va a bloquear, vas a agotar file descriptors y tu scraper va a explotar. Es el equivalente a abrir mil pestañas del navegador a la vez.
Worker pool: concurrencia controlada
El patrón correcto es un worker pool. Un número fijo de goroutines (workers) procesan URLs de un canal compartido. Esto te da concurrencia real pero controlada. Si quieres profundizar en este patrón, tengo un artículo dedicado a worker pools en Go.
func scrapeWithWorkers(client *http.Client, urls []string, numWorkers int) []Article {
var (
mu sync.Mutex
results []Article
wg sync.WaitGroup
)
jobs := make(chan string, len(urls))
// Lanzar workers
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for url := range jobs {
articles, err := fetchArticles(client, url)
if err != nil {
log.Printf("[Worker %d] Error scrapeando %s: %v", workerID, url, err)
continue
}
mu.Lock()
results = append(results, articles...)
mu.Unlock()
log.Printf("[Worker %d] OK: %s (%d artículos)", workerID, url, len(articles))
}
}(i)
}
// Enviar URLs al canal
for _, u := range urls {
jobs <- u
}
close(jobs)
// Esperar a que todos los workers terminen
wg.Wait()
return results
}Desglosemos lo que pasa:
- Canal
jobs: actúa como cola de trabajo. Los workers leen de este canal. sync.WaitGroup: nos permite esperar a que todos los workers terminen.sync.Mutex: protege el sliceresultsde escrituras concurrentes. Sin esto, tendrías una race condition.range jobs: cada worker lee URLs del canal hasta que se cierra. Esto es idiomático en Go.
Con numWorkers = 10, tienes diez goroutines procesando URLs en paralelo. Si una petición tarda 2 segundos, en vez de tardar 2.000 segundos para 1.000 URLs, tardas alrededor de 200 segundos. Concurrencia real sin asyncio, sin callbacks, sin promesas.
Para un control más fino, puedes añadir context en Go para cancelar el scraping si algo va mal:
func scrapeWithContext(ctx context.Context, client *http.Client, urls []string, numWorkers int) ([]Article, error) {
var (
mu sync.Mutex
results []Article
wg sync.WaitGroup
)
jobs := make(chan string, len(urls))
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for url := range jobs {
select {
case <-ctx.Done():
return
default:
}
articles, err := fetchArticles(client, url)
if err != nil {
log.Printf("[Worker %d] Error: %v", workerID, err)
continue
}
mu.Lock()
results = append(results, articles...)
mu.Unlock()
}
}(i)
}
for _, u := range urls {
select {
case jobs <- u:
case <-ctx.Done():
close(jobs)
wg.Wait()
return results, ctx.Err()
}
}
close(jobs)
wg.Wait()
return results, nil
}El select con ctx.Done() permite que cada worker compruebe si el contexto se ha cancelado antes de procesar la siguiente URL. Si llamas a cancel() desde fuera, todos los workers terminan limpiamente.
Rate limiting: time.Ticker y semáforo
Tener concurrencia controlada con un worker pool no es suficiente. Necesitas rate limiting. Aunque tengas solo 5 workers, si las respuestas son rápidas, puedes hacer cientos de peticiones por segundo. Eso va a llamar la atención del servidor y probablemente te van a bloquear.
Rate limiting con time.Ticker
time.Ticker emite un valor por un canal a intervalos regulares. Lo puedes usar como limitador de tasa:
func scrapeWithRateLimit(client *http.Client, urls []string, numWorkers int, requestsPerSecond int) []Article {
var (
mu sync.Mutex
results []Article
wg sync.WaitGroup
)
jobs := make(chan string, len(urls))
ticker := time.NewTicker(time.Second / time.Duration(requestsPerSecond))
defer ticker.Stop()
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for url := range jobs {
<-ticker.C // Esperar al siguiente tick
articles, err := fetchArticles(client, url)
if err != nil {
log.Printf("[Worker %d] Error: %v", workerID, err)
continue
}
mu.Lock()
results = append(results, articles...)
mu.Unlock()
}
}(i)
}
for _, u := range urls {
jobs <- u
}
close(jobs)
wg.Wait()
return results
}Con requestsPerSecond = 5, el ticker emite un valor cada 200ms. Cada worker tiene que esperar a que haya un tick disponible antes de hacer su petición. Esto te da un máximo de 5 peticiones por segundo, independientemente de cuántos workers tengas.
Semáforo con canal buffered
Otra opción es usar un canal buffered como semáforo para limitar las peticiones concurrentes activas:
type Scraper struct {
client *http.Client
semaphore chan struct{}
delay time.Duration
}
func NewScraper(maxConcurrent int, delay time.Duration) *Scraper {
return &Scraper{
client: &http.Client{
Timeout: 10 * time.Second,
},
semaphore: make(chan struct{}, maxConcurrent),
delay: delay,
}
}
func (s *Scraper) Fetch(url string) ([]Article, error) {
s.semaphore <- struct{}{} // Adquirir slot
defer func() {
time.Sleep(s.delay) // Delay entre peticiones
<-s.semaphore // Liberar slot
}()
return fetchArticles(s.client, url)
}El canal semaphore tiene un buffer de tamaño maxConcurrent. Cuando está lleno, el siguiente s.semaphore <- struct{}{} se bloquea hasta que un worker libere su slot. Combinado con time.Sleep(s.delay) después de cada petición, tienes control tanto sobre concurrencia como sobre velocidad.
Manejo de errores y reintentos
En scraping, los errores son la norma, no la excepción. Timeouts, 429 (Too Many Requests), 503 (Service Unavailable), conexiones reseteadas, HTML malformado. Tu scraper tiene que manejar todo esto sin caerse.
Reintentos con backoff exponencial
func fetchWithRetry(client *http.Client, url string, maxRetries int) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<uint(attempt-1)) * time.Second // 1s, 2s, 4s, 8s...
jitter := time.Duration(rand.Int63n(int64(500 * time.Millisecond)))
time.Sleep(backoff + jitter)
log.Printf("Reintento %d/%d para %s", attempt, maxRetries, url)
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creando request: %w", err)
}
req.Header.Set("User-Agent", "GoScraper/1.0")
resp, err := client.Do(req)
if err != nil {
lastErr = fmt.Errorf("intento %d: %w", attempt, err)
continue
}
// Reintentar en ciertos códigos de estado
if resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode == http.StatusServiceUnavailable ||
resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf("intento %d: status %d", attempt, resp.StatusCode)
// Si hay Retry-After, respetarlo
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, err := strconv.Atoi(retryAfter); err == nil {
time.Sleep(time.Duration(seconds) * time.Second)
}
}
continue
}
return resp, nil
}
return nil, fmt.Errorf("agotados %d reintentos para %s: %w", maxRetries, url, lastErr)
}Puntos importantes:
- Backoff exponencial: 1s, 2s, 4s, 8s… Cada reintento espera el doble que el anterior.
- Jitter: un componente aleatorio para evitar que todos los workers reintenten a la vez (thundering herd).
- Retry-After: si el servidor te dice cuánto esperar, hazle caso.
- Solo reintenta errores recuperables: un 404 no tiene sentido reintentarlo. Un 429 o 503, sí.
Clasificar errores
No todos los errores merecen el mismo tratamiento:
func isRetryable(statusCode int) bool {
switch statusCode {
case http.StatusTooManyRequests, // 429
http.StatusServiceUnavailable, // 503
http.StatusBadGateway, // 502
http.StatusGatewayTimeout: // 504
return true
default:
return statusCode >= 500
}
}
func isSkippable(statusCode int) bool {
switch statusCode {
case http.StatusNotFound, // 404
http.StatusForbidden, // 403
http.StatusGone: // 410
return true
default:
return false
}
}En el worker, usas esto para decidir qué hacer:
if isSkippable(resp.StatusCode) {
log.Printf("Saltando %s: status %d", url, resp.StatusCode)
continue
}
if isRetryable(resp.StatusCode) {
// Reintento con backoff
}Guardando resultados: salida JSON
Para un scraper sencillo, JSON es el formato más práctico. Fácil de generar, fácil de consumir, fácil de inspeccionar.
Escribir resultados a un fichero
func saveResults(articles []Article, filename string) error {
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("creando fichero %s: %w", filename, err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(articles); err != nil {
return fmt.Errorf("escribiendo JSON: %w", err)
}
return nil
}Escritura incremental con JSON Lines
Si el scraper va a correr durante horas, no quieres acumular todo en memoria y escribir al final. Usa JSON Lines (un objeto JSON por línea):
func newResultWriter(filename string) (*ResultWriter, error) {
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
return &ResultWriter{
file: file,
encoder: json.NewEncoder(file),
mu: sync.Mutex{},
}, nil
}
type ResultWriter struct {
file *os.File
encoder *json.Encoder
mu sync.Mutex
}
func (w *ResultWriter) Write(article Article) error {
w.mu.Lock()
defer w.mu.Unlock()
return w.encoder.Encode(article)
}
func (w *ResultWriter) Close() error {
return w.file.Close()
}Con sync.Mutex, múltiples workers pueden escribir al fichero de forma segura. Cada Encode escribe una línea completa, así que si el scraper se cae a mitad, no pierdes los datos ya escritos.
Respetando robots.txt y siendo un buen ciudadano
Que puedas scrapear un sitio no significa que debas hacerlo sin miramientos. Hay reglas básicas que cualquier scraper debería cumplir.
Comprobar robots.txt
import "github.com/temoto/robotstxt"
func checkRobotsTxt(client *http.Client, siteURL, userAgent string) (*robotstxt.Group, error) {
robotsURL := siteURL + "/robots.txt"
resp, err := client.Get(robotsURL)
if err != nil {
return nil, fmt.Errorf("obteniendo robots.txt: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Sin robots.txt, asumimos que todo está permitido
return nil, nil
}
robots, err := robotstxt.FromResponse(resp)
if err != nil {
return nil, fmt.Errorf("parseando robots.txt: %w", err)
}
return robots.FindGroup(userAgent), nil
}
// Antes de scrapear una URL
func canFetch(group *robotstxt.Group, path string) bool {
if group == nil {
return true
}
return group.Test(path)
}Úsalo antes de cada petición:
parsedURL, _ := url.Parse(targetURL)
if !canFetch(robotsGroup, parsedURL.Path) {
log.Printf("Bloqueado por robots.txt: %s", targetURL)
continue
}Buenas prácticas generales
Más allá de robots.txt, hay principios que deberías seguir:
- Identificarte: Usa un User-Agent descriptivo. Incluye una URL de contacto.
- Rate limiting siempre: Máximo 1-2 peticiones por segundo al mismo dominio, salvo que sepas que el servidor lo aguanta.
- Respetar
Retry-After: Si el servidor te dice que esperes, espera. - No scrapear contenido protegido: Si hay login, CAPTCHA o términos de uso que lo prohíben, no lo hagas.
- Cachear: Si ya tienes una página descargada, no la vuelvas a pedir.
- Horario: Si puedes elegir, scrapea fuera de horas punta.
Esto no es solo ética. Es pragmatismo. Un scraper que se comporta bien dura más tiempo funcionando sin que lo bloqueen.
Ejemplo completo funcional
Aquí va el scraper completo, uniendo todo lo que hemos visto. Este código es funcional: lo puedes copiar, ajustar los selectores CSS y ejecutarlo.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
)
// --- Tipos ---
type Article struct {
Title string `json:"title"`
URL string `json:"url"`
Summary string `json:"summary"`
Date string `json:"date"`
}
type ScraperConfig struct {
MaxWorkers int
RequestsPerSecond int
MaxRetries int
Timeout time.Duration
UserAgent string
}
// --- Cliente HTTP ---
func newHTTPClient(cfg ScraperConfig) *http.Client {
return &http.Client{
Timeout: cfg.Timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
},
}
}
// --- Petición con reintentos ---
func fetchWithRetry(ctx context.Context, client *http.Client, url string, userAgent string, maxRetries int) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
jitter := time.Duration(rand.Int63n(int64(500 * time.Millisecond)))
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff + jitter):
}
log.Printf("Reintento %d/%d para %s", attempt, maxRetries, url)
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creando request: %w", err)
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "text/html")
resp, err := client.Do(req)
if err != nil {
lastErr = fmt.Errorf("intento %d: %w", attempt, err)
continue
}
if resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf("intento %d: status %d", attempt, resp.StatusCode)
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, err := strconv.Atoi(retryAfter); err == nil {
time.Sleep(time.Duration(seconds) * time.Second)
}
}
continue
}
return resp, nil
}
return nil, fmt.Errorf("agotados %d reintentos para %s: %w", maxRetries, url, lastErr)
}
// --- Parsing ---
func resolveURL(base, ref string) string {
baseURL, err := url.Parse(base)
if err != nil {
return ref
}
refURL, err := url.Parse(ref)
if err != nil {
return ref
}
return baseURL.ResolveReference(refURL).String()
}
func parseArticles(doc *goquery.Document, baseURL string) []Article {
var articles []Article
doc.Find("article.post").Each(func(i int, s *goquery.Selection) {
title := strings.TrimSpace(s.Find("h2.post-title").Text())
if title == "" {
return
}
href, exists := s.Find("h2.post-title a").Attr("href")
if !exists {
return
}
articles = append(articles, Article{
Title: title,
URL: resolveURL(baseURL, href),
Summary: strings.TrimSpace(s.Find("p.post-summary").Text()),
Date: strings.TrimSpace(s.Find("time").AttrOr("datetime", "")),
})
})
return articles
}
func fetchArticles(ctx context.Context, client *http.Client, pageURL string, cfg ScraperConfig) ([]Article, error) {
resp, err := fetchWithRetry(ctx, client, pageURL, cfg.UserAgent, cfg.MaxRetries)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d para %s", resp.StatusCode, pageURL)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("parsing HTML de %s: %w", pageURL, err)
}
return parseArticles(doc, pageURL), nil
}
// --- Worker pool con rate limiting ---
func scrape(ctx context.Context, cfg ScraperConfig, urls []string) ([]Article, error) {
client := newHTTPClient(cfg)
var (
mu sync.Mutex
results []Article
wg sync.WaitGroup
)
jobs := make(chan string, len(urls))
ticker := time.NewTicker(time.Second / time.Duration(cfg.RequestsPerSecond))
defer ticker.Stop()
// Lanzar workers
for i := 0; i < cfg.MaxWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for pageURL := range jobs {
// Comprobar cancelación
select {
case <-ctx.Done():
return
default:
}
// Rate limiting
<-ticker.C
articles, err := fetchArticles(ctx, client, pageURL, cfg)
if err != nil {
log.Printf("[Worker %d] Error scrapeando %s: %v", workerID, pageURL, err)
continue
}
mu.Lock()
results = append(results, articles...)
mu.Unlock()
log.Printf("[Worker %d] OK: %s (%d artículos)", workerID, pageURL, len(articles))
}
}(i)
}
// Enviar URLs
for _, u := range urls {
select {
case jobs <- u:
case <-ctx.Done():
break
}
}
close(jobs)
wg.Wait()
return results, nil
}
// --- Guardar resultados ---
func saveResults(articles []Article, filename string) error {
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("creando fichero: %w", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
return encoder.Encode(articles)
}
// --- Main ---
func main() {
cfg := ScraperConfig{
MaxWorkers: 5,
RequestsPerSecond: 2,
MaxRetries: 3,
Timeout: 10 * time.Second,
UserAgent: "GoScraper/1.0 (+https://example.com/bot)",
}
// URLs a scrapear (ajustar a tu caso)
urls := []string{
"https://example-news.com/page/1",
"https://example-news.com/page/2",
"https://example-news.com/page/3",
"https://example-news.com/page/4",
"https://example-news.com/page/5",
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
log.Printf("Iniciando scraping de %d páginas con %d workers", len(urls), cfg.MaxWorkers)
articles, err := scrape(ctx, cfg, urls)
if err != nil {
log.Fatalf("Error en scraping: %v", err)
}
log.Printf("Total artículos extraídos: %d", len(articles))
if err := saveResults(articles, "results.json"); err != nil {
log.Fatalf("Error guardando resultados: %v", err)
}
log.Println("Resultados guardados en results.json")
}Para ejecutarlo:
go mod init scraper
go mod tidy
go run main.gogo mod tidy descargará goquery y sus dependencias automáticamente. El binario compilado con go build es un ejecutable estático que puedes mover a cualquier servidor sin instalar nada.
Cuándo Python sigue siendo mejor para scraping
Sería deshonesto terminar sin esto. Go tiene ventajas claras para scraping a escala, pero Python sigue siendo la mejor opción en muchos escenarios:
Python gana cuando:
- Prototipas rápido: Quieres ver si un scraper es viable. BeautifulSoup + requests + un notebook de Jupyter. En diez minutos tienes datos. En Go tardas media hora montando el proyecto, definiendo structs y manejando errores.
- Necesitas Scrapy: Scrapy es un framework de scraping completo con middlewares, pipelines, manejo de cookies, throttling automático, exportación a múltiples formatos y una comunidad enorme. Go no tiene nada comparable.
- JavaScript rendering: Si el sitio carga contenido con JavaScript, necesitas un navegador headless. Python tiene Playwright y Selenium con bindings maduros. Go tiene chromedp, que funciona pero es menos ergonómico.
- One-shot scripts: Un scraper que vas a ejecutar una vez para extraer datos, no necesita compilarse. Python con un virtualenv está bien.
- Equipos data/ML: Si el equipo que va a mantener el scraper trabaja en Python y los datos van a un pipeline de pandas/sklearn, añadir Go a la ecuación no aporta lo suficiente.
Go gana cuando:
- Volumen alto: Miles o decenas de miles de páginas. La concurrencia nativa de Go y el bajo uso de memoria marcan diferencia.
- Scraper como servicio: Si el scraper va a correr continuamente en un contenedor, un binario estático de 10MB es mejor que un contenedor Python con dependencias.
- Equipos backend: Si el equipo ya trabaja en Go, no tiene sentido introducir Python solo para un scraper.
- Rendimiento importa: El parsing de HTML en Go (goquery usa el parser de
golang.org/x/net/html) es significativamente más rápido que BeautifulSoup. - Despliegue limpio: Un binario. Sin runtime, sin virtualenv, sin conflictos de versiones de pip.
La pregunta no es “qué lenguaje es mejor para scraping”. Es “qué necesito en este caso concreto”. Para una comparación más amplia, revisa el artículo de Go vs Python.
De script rápido a herramienta de producción
Hemos construido un scraper en Go desde cero que cubre los aspectos fundamentales: cliente HTTP configurado, parsing HTML con goquery, extracción de datos estructurados, concurrencia con worker pool, rate limiting con time.Ticker, reintentos con backoff exponencial, salida JSON y respeto por robots.txt.
Los patrones que hemos usado son los mismos que encontrarás en herramientas de producción. El worker pool con canales es el patrón estándar de concurrencia en Go. El manejo de errores con wrapping es idiomático. El rate limiting con Ticker es la forma habitual de controlar la velocidad.
Go no es la opción más rápida para montar un scraper rápido. Pero cuando necesitas un scraper que corra en producción, que maneje concurrencia sin dolor, que se despliegue como un binario estático y que escale sin arrastrar dependencias, tiene sentido. Especialmente si ya estás trabajando en Go para el resto de tu backend.
El código completo de este artículo es un punto de partida. Adáptalo a tu caso: cambia los selectores CSS, ajusta el número de workers, añade persistencia en base de datos en vez de JSON, integra métricas con Prometheus. La estructura base es la misma.


