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.

Cover for Crear un scraper senzill en Go: concurrència, HTTP i parsing

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:

  1. Canal jobs: actua com a cua de treball. Els workers llegeixen d’aquest canal.
  2. sync.WaitGroup: ens permet esperar que tots els workers acabin.
  3. sync.Mutex: protegeix el slice results d’escriptures concurrents. Sense això, tindries una race condition.
  4. 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:

  1. Identificar-te: Usa un User-Agent descriptiu. Inclou una URL de contacte.
  2. Rate limiting sempre: Màxim 1-2 peticions per segon al mateix domini, tret que sàpigues que el servidor ho aguanta.
  3. Respectar Retry-After: Si el servidor et diu que esperis, espera.
  4. No scrapejar contingut protegit: Si hi ha login, CAPTCHA o termes d’ús que ho prohibeixen, no ho facis.
  5. Cachear: Si ja tens una pàgina descarregada, no la tornis a demanar.
  6. 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.go

go 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.

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats