Crear un scraper senzill en Go: concurrència, HTTP i parsing
Tutorial per crear un web scraper en Go amb net/http, goquery, rate limiting i concurrència controlada. Scraping pràctic.

BeautifulSoup + requests de Python és més ràpid d’escriure. Pots tenir un scraper funcional en quinze línies, amb parsing d’HTML, gestió de sessions i exportació a CSV sense esforç. Per a scraping puntual, segueixo usant Python. Però quan vaig necessitar scrapejar 50.000 pàgines de forma concurrent, amb control fi sobre les connexions, reintents i sense arrossegar un virtualenv a producció, Go va ser l’opció que va encaixar.
Go no és el llenguatge més còmode per a scraping ràpid. És un fet. No té l’ecosistema de Scrapy, ni la comunitat d’eines d’extracció que té Python. Però té goroutines, compilació a un binari estàtic, una llibreria HTTP estàndard sòlida i un model de concurrència que no necessita asyncio ni event loops. Per a scrapers que aniran a corre com a serveis, en contenidors, processant volums grans, això importa.
El que anem a construir aquí és un scraper petit però real. Fa peticions HTTP, parseja HTML, extreu dades estructurades, gestiona errors, respecta rate limits i corre amb concurrència controlada. Si véns de Python i estàs explorant Go, això et donarà un exemple concret de com es tradueix el flux de scraping a aquest llenguatge. Si ja coneixes Go, potser trobes algun patró útil per als teus propis scrapers. Per a una comparació més àmplia entre ambdós llenguatges, tinc un article dedicat a Go vs Python.
El client HTTP en Go: net/http
Go té un client HTTP a la llibreria estàndard que no necessita res més. Sense dependències externes, sense wrappers. net/http és el que usen la majoria d’eines HTTP en Go per sota, inclosos frameworks com Gin o llibreries com Resty.
La forma més bàsica de fer una petició 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 llegint body:\", err)
return
}
fmt.Println(string(body))
}Funciona, però té un problema fonamental per a scraping: usa el client HTTP per defecte (http.DefaultClient), que no té timeout. Si un servidor tarda deu minuts a respondre, el teu programa esperarà deu minuts. En un scraper amb concurrència, això és un desastre.
El primer que necessites és crear el teu propi client amb configuració explícita:
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
},
}Timeout és el timeout total de la petició (incloent la lectura del body). Transport controla el pool de connexions. MaxIdleConnsPerHost és important per a scraping: si estàs fent moltes peticions al mateix domini, vols reutilitzar connexions TCP en comptes d’obrir-ne una de nova cada vegada.
Per a peticions més configurables, usa http.NewRequest en comptes de http.Get:
req, err := http.NewRequest(\"GET\", url, nil)
if err != nil {
return fmt.Errorf(\"creant request per a %s: %w\", url, err)
}
req.Header.Set(\"User-Agent\", \"ElMeuScraper/1.0 (+https://example.com/bot)\")
req.Header.Set(\"Accept\", \"text/html\")
req.Header.Set(\"Accept-Language\", \"ca-ES,ca;q=0.9\")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf(\"fent GET %s: %w\", url, err)
}
defer resp.Body.Close()Fixa’t en el User-Agent. No és opcional. És el mínim que hauries de fer com a scraper responsable: identificar-te. Molts servidors bloquegen peticions sense User-Agent o amb User-Agents genèrics.
Parsing HTML amb goquery
Go no té un equivalent a BeautifulSoup a la llibreria estàndard. Té golang.org/x/net/html per parsejar HTML, però la seva API és de baix nivell i treballar-hi directament és tediós. La llibreria que tothom usa per a scraping en Go és goquery. És l’equivalent a jQuery per a Go: selectors CSS, traversal del DOM, extracció de text i atributs.
Instal·la-la amb:
go get github.com/PuerkitoBio/goqueryÚs bàsic:
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)
}
// Extreure el títol de la pàgina
title := doc.Find(\"title\").Text()
fmt.Println(\"Títol:\", title)
// Extreure tots els enllaços
doc.Find(\"a\").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr(\"href\")
if exists {
fmt.Printf(\"Enllaç %d: %s -> %s\n\", i, s.Text(), href)
}
})
}Els selectors CSS de goquery cobreixen pràcticament tot el que necessites:
// Per classe
doc.Find(\".article-title\")
// Per ID
doc.Find(\"#main-content\")
// Selectors compostos
doc.Find(\"div.product > h2.name\")
// Atributs
doc.Find(\"a[href^='https']\")
// Pseudo-selectors
doc.Find(\"tr:nth-child(even)\")Per extreure dades, els mètodes més comuns són:
// Text de l'element
text := s.Text()
// Atribut
href, exists := s.Attr(\"href\")
// HTML intern
html, err := s.Html()
// Primer element que coincideixi
first := doc.Find(\".item\").First()
// Recórrer tots els elements
doc.Find(\".item\").Each(func(i int, s *goquery.Selection) {
// ...
})Si véns de BeautifulSoup, la traducció mental és directa. soup.select(\".class\") és doc.Find(\".class\"). tag.get_text() és s.Text(). tag[\"href\"] és s.Attr(\"href\").
Construint el scraper: extreure dades d’una pàgina
Anem a construir alguna cosa concreta. Imagina que volem scrapejar un lloc de notícies fictícies i extreure els articles de la pàgina principal: títol, enllaç, resum i data.
Primer, definim l’estructura de dades:
type Article struct {
Title string `json:\"title\"`
URL string `json:\"url\"`
Summary string `json:\"summary\"`
Date string `json:\"date\"`
}Ara, la funció que parseja una pàgina i extreu els articles:
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 elements sense títol
}
href, exists := s.Find(\"h2.post-title a\").Attr(\"href\")
if !exists {
return
}
// Resoldre URLs relatives
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ó resolveURL s’encarrega de convertir URLs relatives en absolutes:
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()
}I la funció que fa la petició HTTP i connecta tot:
func fetchArticles(client *http.Client, pageURL string) ([]Article, error) {
req, err := http.NewRequest(\"GET\", pageURL, nil)
if err != nil {
return nil, fmt.Errorf(\"creant 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 per a %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
}Fixa’t en el patró: cada error s’embolcalla amb context usant %w. Això et permet saber exactament què ha fallat i on quan depures. Si la gestió d’errors en Go et sembla excessiva, et recomano llegir el meu article sobre errors en Go on explico per què aquesta verbositat és un avantatge real.
Afegint concurrència: goroutines i worker pool
Fins aquí tenim un scraper seqüencial. Funciona, però si tens 1.000 pàgines a scrapejar, trigarà una eternitat. Aquí és on Go brilla.
L’enfocament ingenu (no ho facis)
// NO facis això
for _, url := range urls {
go func(u string) {
articles, err := fetchArticles(client, u)
// ...
}(url)
}Llançar una goroutine per URL sense control farà que disparis 1.000 peticions simultànies. El servidor et bloquejarà, esgotaràs file descriptors i el teu scraper explotarà. És l’equivalent a obrir mil pestanyes del navegador a la vegada.
Worker pool: concurrència controlada
El patró correcte és un worker pool. Un nombre fix de goroutines (workers) processen URLs d’un canal compartit. Això et dona concurrència real però controlada. Si vols aprofundir en aquest patró, tinc un article dedicat 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))
// Llançar 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 scraping %s: %v\", workerID, url, err)
continue
}
mu.Lock()
results = append(results, articles...)
mu.Unlock()
log.Printf(\"[Worker %d] OK: %s (%d articles)\", workerID, url, len(articles))
}
}(i)
}
// Enviar URLs al canal
for _, u := range urls {
jobs <- u
}
close(jobs)
// Esperar que tots els workers acabin
wg.Wait()
return results
}Desglossem el que passa:
- Canal
jobs: actua com a cua de treball. Els workers llegeixen d’aquest canal. sync.WaitGroup: ens permet esperar que tots els workers acabin.sync.Mutex: protegeix el sliceresultsd’escriptures concurrents. Sense això, tindries una race condition.range jobs: cada worker llegeix URLs del canal fins que es tanca. Això és idiomàtic en Go.
Amb numWorkers = 10, tens deu goroutines processant URLs en paral·lel. Si una petició tarda 2 segons, en comptes de tardar 2.000 segons per a 1.000 URLs, tardes al voltant de 200 segons. Concurrència real sense asyncio, sense callbacks, sense promeses.
Per a un control més fi, pots afegir context en Go per cancel·lar el scraping si alguna cosa va malament:
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 amb ctx.Done() permet que cada worker comprovi si el context s’ha cancel·lat abans de processar la següent URL. Si crides cancel() des de fora, tots els workers acaben neta.
Rate limiting: time.Ticker i semàfor
Tenir concurrència controlada amb un worker pool no és suficient. Necessites rate limiting. Fins i tot amb només 5 workers, si les respostes són ràpides, pots fer centenars de peticions per segon. Això cridarà l’atenció del servidor i probablement et bloquejaran.
Rate limiting amb time.Ticker
time.Ticker emet un valor per un canal a intervals regulars. El pots usar com a limitador de taxa:
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 el següent 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
}Amb requestsPerSecond = 5, el ticker emet un valor cada 200ms. Cada worker ha d’esperar que hi hagi un tick disponible abans de fer la seva petició. Això et dona un màxim de 5 peticions per segon, independentment de quants workers tinguis.
Semàfor amb canal buffered
Una altra opció és usar un canal buffered com a semàfor per limitar les peticions concurrents actives:
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 peticions
<-s.semaphore // Alliberar slot
}()
return fetchArticles(s.client, url)
}El canal semaphore té un buffer de mida maxConcurrent. Quan és ple, el següent s.semaphore <- struct{}{} es bloqueja fins que un worker allibera el seu slot. Combinat amb time.Sleep(s.delay) després de cada petició, tens control tant sobre concurrència com sobre velocitat.
Gestió d’errors i reintents
En scraping, els errors són la norma, no l’excepció. Timeouts, 429 (Too Many Requests), 503 (Service Unavailable), connexions ressetejades, HTML malformat. El teu scraper ha de gestionar tot això sense caure.
Reintents amb 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(\"Reintent %d/%d per a %s\", attempt, maxRetries, url)
}
req, err := http.NewRequest(\"GET\", url, nil)
if err != nil {
return nil, fmt.Errorf(\"creant request: %w\", err)
}
req.Header.Set(\"User-Agent\", \"GoScraper/1.0\")
resp, err := client.Do(req)
if err != nil {
lastErr = fmt.Errorf(\"intent %d: %w\", attempt, err)
continue
}
// Reintentar en certs codis d'estat
if resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode == http.StatusServiceUnavailable ||
resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf(\"intent %d: status %d\", attempt, resp.StatusCode)
// Si hi ha Retry-After, respectar-lo
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(\"esgotats %d reintents per a %s: %w\", maxRetries, url, lastErr)
}Punts importants:
- Backoff exponencial: 1s, 2s, 4s, 8s… Cada reintent espera el doble que l’anterior.
- Jitter: un component aleatori per evitar que tots els workers reintentin a la vegada (thundering herd).
- Retry-After: si el servidor et diu quant esperar, fes-li cas.
- Només reintenta errors recuperables: un 404 no té sentit reintentar-lo. Un 429 o 503, sí.
Classificar errors
No tots els errors mereixen el mateix tractament:
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
}
}Al worker, uses això per decidir què fer:
if isSkippable(resp.StatusCode) {
log.Printf(\"Saltant %s: status %d\", url, resp.StatusCode)
continue
}
if isRetryable(resp.StatusCode) {
// Reintent amb backoff
}Desant resultats: sortida JSON
Per a un scraper senzill, JSON és el format més pràctic. Fàcil de generar, fàcil de consumir, fàcil d’inspeccionar.
Escriure resultats a un fitxer
func saveResults(articles []Article, filename string) error {
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf(\"creant fitxer %s: %w\", filename, err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent(\"\", \" \")
if err := encoder.Encode(articles); err != nil {
return fmt.Errorf(\"escrivint JSON: %w\", err)
}
return nil
}Escriptura incremental amb JSON Lines
Si el scraper anirà a corre durant hores, no vols acumular-ho tot a memòria i escriure al final. Usa JSON Lines (un objecte JSON per línia):
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()
}Amb sync.Mutex, múltiples workers poden escriure al fitxer de forma segura. Cada Encode escriu una línia completa, així que si el scraper cau a meitat, no perds les dades ja escrites.
Respectar robots.txt i ser un bon ciutadà
Que puguis scrapejar un lloc no significa que ho hagis de fer sense miraments. Hi ha regles bàsiques que qualsevol scraper hauria de complir.
Comprovar 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(\"obtenint robots.txt: %w\", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Sense robots.txt, assumim que tot està permès
return nil, nil
}
robots, err := robotstxt.FromResponse(resp)
if err != nil {
return nil, fmt.Errorf(\"parsejant robots.txt: %w\", err)
}
return robots.FindGroup(userAgent), nil
}
// Abans de scrapejar una URL
func canFetch(group *robotstxt.Group, path string) bool {
if group == nil {
return true
}
return group.Test(path)
}Usa-la abans de cada petició:
parsedURL, _ := url.Parse(targetURL)
if !canFetch(robotsGroup, parsedURL.Path) {
log.Printf(\"Bloquejat per robots.txt: %s\", targetURL)
continue
}Bones pràctiques generals
Més enllà de robots.txt, hi ha principis que hauries de seguir:
- Identificar-te: Usa un User-Agent descriptiu. Inclou una URL de contacte.
- Rate limiting sempre: Màxim 1-2 peticions per segon al mateix domini, tret que sàpigues que el servidor ho aguanta.
- Respectar
Retry-After: Si el servidor et diu que esperis, espera. - No scrapejar contingut protegit: Si hi ha login, CAPTCHA o termes d’ús que ho prohibeixen, no ho facis.
- Cachear: Si ja tens una pàgina descarregada, no la tornis a demanar.
- Horari: Si pots triar, scraperja fora d’hores punta.
Això no és només ètica. És pragmatisme. Un scraper que es comporta bé dura més temps funcionant sense que el bloquegin.
Exemple complet funcional
Aquí va el scraper complet, unint tot el que hem vist. Aquest codi és funcional: el pots copiar, ajustar els selectors CSS i executar-lo.
package main
import (
\"context\"
\"encoding/json\"
\"fmt\"
\"log\"
\"math/rand\"
\"net/http\"
\"net/url\"
\"os\"
\"strconv\"
\"strings\"
\"sync\"
\"time\"
\"github.com/PuerkitoBio/goquery\"
)
// --- Tipus ---
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
}
// --- Client 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ó amb reintents ---
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(\"Reintent %d/%d per a %s\", attempt, maxRetries, url)
}
req, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)
if err != nil {
return nil, fmt.Errorf(\"creant 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(\"intent %d: %w\", attempt, err)
continue
}
if resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf(\"intent %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(\"esgotats %d reintents per a %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 per a %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 amb 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()
// Llançar workers
for i := 0; i < cfg.MaxWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for pageURL := range jobs {
// Comprovar cancel·lació
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 scraping %s: %v\", workerID, pageURL, err)
continue
}
mu.Lock()
results = append(results, articles...)
mu.Unlock()
log.Printf(\"[Worker %d] OK: %s (%d articles)\", 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
}
// --- Desar resultats ---
func saveResults(articles []Article, filename string) error {
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf(\"creant fitxer: %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 scrapejar (ajustar al teu cas)
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(\"Iniciant scraping de %d pàgines amb %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 articles extrets: %d\", len(articles))
if err := saveResults(articles, \"results.json\"); err != nil {
log.Fatalf(\"Error desant resultats: %v\", err)
}
log.Println(\"Resultats desats a results.json\")
}Per executar-lo:
go mod init scraper
go mod tidy
go run main.gogo mod tidy descarregarà goquery i les seves dependències automàticament. El binari compilat amb go build és un executable estàtic que pots moure a qualsevol servidor sense instal·lar res.
Quan Python continua sent millor per a scraping
Seria deshonest acabar sense això. Go té avantatges clars per a scraping a escala, però Python continua sent la millor opció en molts escenaris:
Python guanya quan:
- Prototipes ràpid: Vols veure si un scraper és viable. BeautifulSoup + requests + un notebook de Jupyter. En deu minuts tens dades. En Go tardes mitja hora muntant el projecte, definint structs i gestionant errors.
- Necessites Scrapy: Scrapy és un framework de scraping complet amb middlewares, pipelines, gestió de cookies, throttling automàtic, exportació a múltiples formats i una comunitat enorme. Go no té res comparable.
- JavaScript rendering: Si el lloc carrega contingut amb JavaScript, necessites un navegador headless. Python té Playwright i Selenium amb bindings madurs. Go té chromedp, que funciona però és menys ergonòmic.
- Scripts d’un sol ús: Un scraper que executaràs una vegada per extreure dades no necessita compilar-se. Python amb un virtualenv està bé.
- Equips data/ML: Si l’equip que mantindrà el scraper treballa en Python i les dades van a un pipeline de pandas/sklearn, afegir Go a l’equació no aporta prou.
Go guanya quan:
- Volum alt: Milers o desenes de milers de pàgines. La concurrència nativa de Go i el baix ús de memòria marquen diferència.
- Scraper com a servei: Si el scraper anirà a corre contínuament en un contenidor, un binari estàtic de 10MB és millor que un contenidor Python amb dependències.
- Equips backend: Si l’equip ja treballa en Go, no té sentit introduir Python només per a un scraper.
- El rendiment importa: El parsing d’HTML en Go (goquery usa el parser de
golang.org/x/net/html) és significativament més ràpid que BeautifulSoup. - Desplegament net: Un binari. Sense runtime, sense virtualenv, sense conflictes de versions de pip.
La pregunta no és “quin llenguatge és millor per a scraping”. És “què necessito en aquest cas concret”. Per a una comparació més àmplia, revisa l’article de Go vs Python.
D’script ràpid a eina de producció
Hem construït un scraper en Go des de zero que cobreix els aspectes fonamentals: client HTTP configurat, parsing HTML amb goquery, extracció de dades estructurades, concurrència amb worker pool, rate limiting amb time.Ticker, reintents amb backoff exponencial, sortida JSON i respecte per robots.txt.
Els patrons que hem usat són els mateixos que trobaràs en eines de producció. El worker pool amb canals és el patró estàndard de concurrència en Go. La gestió d’errors amb wrapping és idiomàtica. El rate limiting amb Ticker és la forma habitual de controlar la velocitat.
Go no és l’opció més ràpida per muntar un scraper ràpid. Però quan necessites un scraper que corra en producció, que gestioni concurrència sense dolor, que es desplegui com un binari estàtic i que escali sense arrossegar dependències, té sentit. Especialment si ja estàs treballant en Go per a la resta del teu backend.
El codi complet d’aquest article és un punt de partida. Adapta’l al teu cas: canvia els selectors CSS, ajusta el nombre de workers, afegeix persistència en base de dades en comptes de JSON, integra mètriques amb Prometheus. L”estructura base és la mateixa.


