Go es posiblemente el mejor lenguaje mainstream para la mitad de obtención del web scraping: las goroutines hacen que mil solicitudes concurrentes parezcan trivial, y el resultado compila en un único binario que puedes colocar en cualquier servidor. La mitad de parsing y anti-bot es donde Python todavía tiene el conjunto de herramientas más profundo. Esta guía construye un scraper en Go de la forma honesta — primero la biblioteca estándar más goquery, luego Colly para crawling y límites de velocidad, luego un worker pool que no hará que baneen tu IP — y te dice exactamente dónde deja de funcionar el scraping DIY en Go y dónde una API toma el relevo.
Por qué usar Go para web scraping — y por qué no
El argumento a favor de Go es real, así que planteémoslo con justicia.
Velocidad pura y concurrencia. El scraping es trabajo limitado por I/O, y las goroutines son la primitiva de concurrencia más barata de cualquier lenguaje mainstream — apenas unos kilobytes de stack cada una. Un scraper en Go que satura 200 conexiones concurrentes es un proyecto de una tarde; el equivalente en Python implica asyncio, un cliente HTTP asíncrono, y un modelo mental que muchos equipos nunca terminan de interiorizar.
Despliegues de binario único. go build te da un único ejecutable estático. Sin virtualenv, sin resolución de dependencias en el servidor, sin el clásico "en mi máquina funciona". Para scrapers que viven en cron jobs, contenedores o VPS baratos, esto es una ventaja genuinamente subestimada.
Salida tipada por defecto. Parseas hacia structs, así que el drift de esquema aparece como un error de compilación o un campo vacío obvio — no como un KeyError silencioso a los tres días de ejecución.
Ahora las contras honestas. El ecosistema de scraping de Go es más pequeño: Python tiene BeautifulSoup, Scrapy, Playwright con parches de stealth, bindings de curl-impersonate, y una década de respuestas de Stack Overflow para cada caso límite. Go tiene goquery, Colly y chromedp — buenas herramientas, pero menos cantidad, y muchas menos bibliotecas anti-bot y de stealth. También escribirás más código repetitivo: manejo de errores en cada solicitud, structs explícitos para cada forma de dato. Si tu cuello de botella es parsear HTML desordenado o evadir detección de bots sofisticada en lugar de throughput, web scraping con Python es el punto de partida más indulgente — esa guía cubre el mismo recorrido con requests, BeautifulSoup y Scrapy.
Si lo que te importa es throughput, despliegue y fiabilidad a largo plazo, sigue leyendo.
Nivel 1: net/http + goquery
El net/http de la biblioteca estándar obtiene páginas; goquery te da selectores CSS al estilo jQuery sobre el DOM parseado. Instálalo con:
go get github.com/PuerkitoBio/goquery
Aquí hay un scraper completo para una página de catálogo estilo books.toscrape.com — obtener, verificar el código de estado, seleccionar elementos, extraer hacia structs, emitir JSON:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"github.com/PuerkitoBio/goquery"
)
type Book struct {
Title string `json:"title"`
Price string `json:"price"`
InStock bool `json:"in_stock"`
}
func main() {
res, err := http.Get("https://books.toscrape.com/")
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
log.Fatalf("status code error: %d %s", res.StatusCode, res.Status)
}
doc, err := goquery.NewDocumentFromReader(res.Body)
if err != nil {
log.Fatal(err)
}
var books []Book
doc.Find("article.product_pod").Each(func(i int, s *goquery.Selection) {
books = append(books, Book{
Title: s.Find("h3 a").AttrOr("title", ""),
Price: strings.TrimSpace(s.Find(".price_color").Text()),
InStock: s.Find(".instock.availability").Length() > 0,
})
})
out, err := json.MarshalIndent(books, "", " ")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(out))
}
El patrón a interiorizar: NewDocumentFromReader parsea el cuerpo de la respuesta una vez, Find acepta cualquier selector CSS, Each itera las coincidencias, y AttrOr / Text extraen valores con fallbacks razonables. Verifica siempre res.StatusCode antes de parsear — goquery parseará felizmente una página de error hacia una selección vacía, y terminarás persiguiendo un bug fantasma. Dos notas más de biblioteca estándar para producción: usa http.Client con un Timeout explícito en lugar del valor cero por defecto (que nunca expira), y recuerda que goquery espera UTF-8 — transcodifica las codificaciones antiguas primero.
Este nivel es perfecto para trabajos puntuales contra HTML estático. En el momento en que necesitas seguir enlaces entre páginas, quieres un framework.
Nivel 2: Colly — el framework de scraping de Go
Colly envuelve el ciclo de obtener-parsear-seguir en un collector con callbacks de eventos. Es lo más parecido que tiene Go a Scrapy, y maneja el web crawling — recorrer enlaces, no solo parsear una página — con casi ninguna ceremonia. Nota la ruta del módulo v2:
go get github.com/gocolly/colly/v2
La misma librería, ahora recorriendo cada página de catálogo vía el enlace "siguiente", con retrasos educados incorporados:
package main
import (
"fmt"
"log"
"time"
"github.com/gocolly/colly/v2"
)
func main() {
c := colly.NewCollector(
colly.AllowedDomains("books.toscrape.com"),
)
// Cortesía: retraso entre solicitudes a este dominio.
if err := c.Limit(&colly.LimitRule{
DomainGlob: "books.toscrape.com",
Delay: 500 * time.Millisecond,
RandomDelay: 500 * time.Millisecond,
}); err != nil {
log.Fatal(err)
}
c.OnHTML("article.product_pod", func(e *colly.HTMLElement) {
fmt.Printf("%s — %s\n",
e.ChildAttr("h3 a", "title"),
e.ChildText(".price_color"))
})
// Paginación: sigue el enlace a la página siguiente hasta que deja de existir.
c.OnHTML("li.next a", func(e *colly.HTMLElement) {
e.Request.Visit(e.Attr("href"))
})
c.OnError(func(r *colly.Response, err error) {
log.Println("request failed:", r.Request.URL, err)
})
if err := c.Visit("https://books.toscrape.com/"); err != nil {
log.Fatal(err)
}
}
Tres ideas sostienen todo el framework. Los callbacks OnHTML se disparan para cada elemento que coincide con un selector, así que la extracción y el seguimiento de enlaces son ambos simplemente callbacks. La paginación es e.Request.Visit(e.Attr("href")) — Colly resuelve URLs relativas, deduplica páginas visitadas, y respeta AllowedDomains para que no puedas recorrer accidentalmente el resto de internet. LimitRule te da Delay por dominio más jitter de RandomDelay sin que escribas ningún código de temporización tú mismo.
Nivel 3: concurrencia bien hecha
Esta es la sección por la que los lectores de Go vinieron, y el lugar donde la mayoría de los scrapers en Go se equivocan. El movimiento ingenuo — lanzar una goroutine por URL — funciona en una demo y se derrite en producción: abres miles de conexiones simultáneas, el sitio objetivo te limita o te banea, y tu propio proceso se queda sin descriptores de archivo. La concurrencia necesita dos límites: cuántos workers corren a la vez, y cuántas solicitudes por segundo salen del proceso. (Para la perspectiva del lado del servidor sobre el porqué, mira qué es el rate limiting.)
La forma idiomática es un worker pool acotado alimentado por un canal, con un time.Ticker compartido actuando como limitador de velocidad global:
func scrapeAll(urls []string, workers, perSecond int) {
jobs := make(chan string)
ticker := time.NewTicker(time.Second / time.Duration(perSecond))
defer ticker.Stop()
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for u := range jobs {
<-ticker.C // un tick por solicitud, compartido por todos los workers
if err := scrapeOne(u); err != nil {
log.Println(u, err)
}
}
}()
}
for _, u := range urls {
jobs <- u
}
close(jobs)
wg.Wait()
}
Diez workers a cinco solicitudes por segundo es un punto de partida razonable para un sitio que no controlas. El ticker es compartido, así que agregar workers aumenta la tolerancia a solapamiento (las respuestas lentas no atascan la cola) sin aumentar la tasa de solicitudes — los dos parámetros permanecen independientes, que es exactamente lo que quieres al ajustar contra un limitador de velocidad.
Si ya estás dentro de Colly, obtienes el mismo comportamiento de forma declarativa — crea el collector con colly.Async(), limita Parallelism en el LimitRule, y bloquea en Wait:
c := colly.NewCollector(colly.Async())
c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 4,
Delay: 200 * time.Millisecond,
})
// ... registra los callbacks OnHTML, y luego:
c.Visit(startURL)
c.Wait() // bloquea hasta que todas las solicitudes asíncronas terminen
De cualquier forma, la regla es la misma: acota los workers, mide las solicitudes, y trata las respuestas 429 y 403 como una señal para bajar el ritmo, no para reintentar con más fuerza.
Páginas con mucho JS: chromedp existe, pero sopesa el costo
Todo lo anterior asume que los datos están en el HTML que envía el servidor. Cada vez más eso no es así — se renderiza del lado del cliente con JavaScript, y net/http ve una cáscara vacía. La respuesta de Go es chromedp, que controla una instancia real de Chrome a través del DevTools Protocol (mira qué es un navegador headless):
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
var html string
err := chromedp.Run(ctx,
chromedp.Navigate("https://example.com/js-rendered-listing"),
chromedp.WaitVisible(".product-grid"),
chromedp.OuterHTML("html", &html),
)
// alimenta html a goquery.NewDocumentFromReader como antes
Funciona, y la API es agradable. Pero sé honesto sobre lo que acabas de aceptar: un Chrome completo por contexto (cientos de MB de RAM), binarios de Chrome en tu despliegue antes ordenado de binario único, y aproximadamente 10-50x la latencia de una solicitud HTTP simple. Tu throughput cae de cientos de páginas por segundo a un puñado. Usa chromedp cuando un objetivo específico de alto valor realmente requiere renderizado — no como la ruta por defecto. A menudo la mejor jugada es abrir DevTools y encontrar el endpoint JSON que la propia página llama; hacer scraping de eso con net/http es más rápido de lo que el renderizado jamás será.
El muro de producción — y el puente de la API
Escala cualquiera de las técnicas anteriores contra objetivos comerciales reales — Amazon, Google, plataformas sociales — y te topas con un muro que no tiene nada que ver con la calidad de tu código: sistemas anti-bot. Las IPs de datacenter quedan marcadas, aparecen CAPTCHAs, y los headers por sí solos no te salvarán.
Go tiene un problema específico aquí que vale la pena conocer. Los proveedores anti-bot identifican la huella del handshake TLS — los conjuntos de cifrado y extensiones que ofrece un cliente, condensados en una firma JA3/JA4 — y el stack crypto/tls de Go produce un handshake que no se parece en nada al de Chrome. Los sitios protegidos pueden identificar a un cliente Go antes de que envíe un solo byte HTTP, sin importar cuán cuidadosamente falsifiques el User-Agent. Esta es una variante del browser fingerprinting, y aunque existen forks comunitarios que imitan el TLS de navegadores, son exactamente el tipo de dependencia nicho y de decaimiento rápido que el ecosistema de Go tiene en menor cantidad que el de Python. Para el panorama completo de a qué te enfrentas, mira sitios de scraping que bloquean bots.
En ese punto la jugada pragmática es mantener Go para lo que hace bien — orquestación, concurrencia, pipelines — y delegar la obtención hostil a una API de scraping. Crawlora expone endpoints estructurados sobre HTTPS plano, así que llamarla es un net/http ordinario, y la respuesta es JSON normalizado en lugar de HTML que tienes que parsear y cuidar:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
)
type searchResponse struct {
Code int `json:"code"`
Data []struct {
ASIN string `json:"asin"`
Link string `json:"link"`
ListPrice float64 `json:"list_price"`
} `json:"data"`
}
func main() {
q := url.Values{}
q.Set("k", "mechanical keyboard")
q.Set("page", "1")
req, err := http.NewRequest(http.MethodGet,
"https://api.crawlora.net/api/v1/amazon/search?"+q.Encode(), nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("x-api-key", os.Getenv("CRAWLORA_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var sr searchResponse
if err := json.NewDecoder(res.Body).Decode(&sr); err != nil {
log.Fatal(err)
}
for _, item := range sr.Data {
fmt.Printf("%s $%.2f %s\n", item.ASIN, item.ListPrice, item.Link)
}
}
Sin proxies que rotar, sin fork de suplantación TLS que mantener, sin parser que arreglar cuando Amazon lanza un rediseño — structs tipados sobre JSON documentado, que es el flujo de trabajo para el que Go está construido. Los proxies, el renderizado y los reintentos ocurren detrás del endpoint. También existe un SDK oficial de Go (junto con TypeScript y Python) si prefieres clientes generados en lugar de net/http en crudo, y el mismo patrón de worker pool del Nivel 3 aplica sin cambios — apúntalo a llamadas de API en lugar de páginas en crudo. La facturación es pago por éxito: solo se te cobra por respuestas 2xx exitosas, así que una obtención fallida no cuesta nada (precios). Si conviene hacer scraping de un objetivo tú mismo o llamar a una API para ello es una decisión de criterio — web scraping vs API lo explica paso a paso.
- HTML estático, un sitio, volumen modesto: net/http + goquery es todo lo que necesitas.
- Crawling con paginación: Colly v2 con AllowedDomains y un LimitRule (Delay + RandomDelay).
- Volumen alto: worker pool acotado + time.Ticker compartido, o colly.Async con Parallelism — nunca goroutines sin límite.
- Configura un User-Agent real y un timeout en http.Client; baja el ritmo ante 429/403.
- Páginas renderizadas con JS: verifica si existe el endpoint JSON subyacente antes de recurrir a chromedp.
- Muros anti-bot y bloqueos por huella TLS: enruta esos objetivos a través de una API de scraping y conserva tu código Go para la orquestación.
Resumen
Go recompensa a los scrapers que se parecen a los sistemas para los que Go fue diseñado: concurrentes, acotados, tipados, desplegados como un único binario. Empieza con net/http y goquery, gradúate a Colly cuando necesites hacer crawling, y coloca un worker pool con un ticker frente a todo. Cuando las defensas de un objetivo cuestan más tiempo de ingeniería del que valen los datos, conecta con una API desde la misma base de código — puedes probar los endpoints en el playground sin escribir una línea de Go, y luego explorar la documentación para el catálogo completo de endpoints.
Conserva Go para el pipeline, sáltate la carrera armamentista anti-bot
Endpoints estructurados, JSON normalizado, proxies y reintentos gestionados — llamados con net/http plano o el SDK oficial de Go. Nivel gratuito: 2000 créditos al mes, sin tarjeta.
Lecturas relacionadas: Web scraping con Python (el mismo recorrido en el ecosistema más grande) · Sitios de scraping que bloquean bots · Web scraping vs API · Qué es el browser fingerprinting
Preguntas frecuentes
Is Go good for web scraping?
Yes — Go excels at the fetching half of scraping: goroutines make high-concurrency requests cheap, and go build produces a single static binary that deploys anywhere. Its ecosystem is smaller than Python's, though, with fewer parsers and far fewer anti-bot or stealth libraries, so Go fits best when throughput and deployment matter more than evading sophisticated bot detection.
Colly vs goquery — what's the difference?
goquery is a parsing library: it gives you jQuery-style CSS selectors (Find, Each, Attr) over HTML you have already fetched with net/http. Colly is a full scraping framework built on top of goquery's selector engine: it manages the fetch loop, OnHTML callbacks, link following and pagination, deduplication, and per-domain delays and parallelism via LimitRule. Use goquery alone for one-page jobs; use Colly when you need to crawl.
Is Go faster than Python for scraping?
For the network-bound part, meaningfully yes at scale: goroutines let a Go scraper hold hundreds of concurrent connections with trivial memory overhead and no async framework to learn. For a single page, the network dominates and the language barely matters. Python narrows the gap with asyncio, but Go's concurrency is simpler to write correctly and the compiled binary uses less memory per worker.
How do I scrape JavaScript-rendered pages in Go?
Use chromedp, which drives headless Chrome over the DevTools Protocol: Navigate, WaitVisible, then extract the rendered HTML and parse it with goquery. Be aware of the cost — a full Chrome per context, large memory use, and 10–50x the latency of a plain HTTP request. Before reaching for it, check DevTools for the JSON endpoint the page calls; fetching that directly with net/http is usually faster.
Why do websites block Go scrapers even with a browser User-Agent?
Anti-bot systems fingerprint the TLS handshake (JA3/JA4 signatures), and Go's crypto/tls stack produces a handshake that looks nothing like Chrome's — so protected sites can identify a Go client before any HTTP header is sent. Spoofing the User-Agent does not change the TLS fingerprint. Community TLS-impersonation forks exist but decay fast; for heavily protected targets, routing the fetch through a scraping API is more reliable.
How do I rate-limit a concurrent Go scraper?
Bound two things independently: worker count (a fixed pool of goroutines reading from a jobs channel) and request rate (a shared time.Ticker that every worker receives from before firing a request). In Colly, the declarative equivalent is colly.Async() plus a LimitRule with Parallelism and Delay, then c.Wait(). Treat 429 and 403 responses as a signal to back off, not to retry harder.
Does Crawlora have a Go SDK?
Yes — Crawlora publishes an official Go SDK module alongside its TypeScript and Python SDKs, and every endpoint also works with plain net/http: send a GET to the documented path on https://api.crawlora.net/api/v1 with your key in the x-api-key header and decode the normalized JSON into structs. The free tier is 2,000 credits/month, no card, and billing is pay-on-success — you are charged only for successful 2xx responses.
