Scraping que no es trenca cada setmana: com dissenyar extractors més resistents
Guia tècnica per dissenyar scrapers mantenibles en Python: selectors, fallback, validació, logs i bones pràctiques reals.

Si has mantingut un scraper més de tres mesos, saps exactament de què va aquest article. Muntes un extractor que funciona perfecte. El deixes corrent. Dues setmanes després, el lloc canvia una classe CSS i el teu pipeline comença a retornar camps buits. O pitjor: retorna dades incorrectes sense que te n’adonis fins que algú t’ho diu.
He mantingut scrapers a Rolsfera durant mesos i la lliçó més clara que n’he tret és aquesta: el scraping no és difícil de muntar, és difícil de mantenir. La diferència entre un scraper que sobreviu i un que es trenca cada setmana no està a la llibreria que facis servir, sinó en com dissenyis l’extractor per absorbir canvis.
Aquest article no és una intro a BeautifulSoup ni a Playwright. Assumeixo que ja saps usar totes dues. El que vull compartir són patrons de disseny, validació i monitorització que he aplicat en producció i que marquen la diferència quan el scraper ha de funcionar sense la teva supervisió constant.
El problema de fons
Un scraper és codi que depèn de l’estructura d’un sistema que no controles. Això el converteix en el tipus de programari més fràgil que pots escriure. Qualsevol canvi a l’HTML del lloc objectiu pot trencar el teu extractor: un div que canvia de classe, un atribut data- que desapareix, una paginació que passa de server-side a client-side, un wrapper de JavaScript que abans no existia.
El scraping no es trenca per errors de programació. Es trenca perquè la web canvia, i el teu codi assumeix que no ho farà.
La majoria de tutorials de scraping acaben quan l’extractor funciona la primera vegada. Però la primera vegada és la part fàcil. El que importa és què passa la vegada número 50, quan alguna cosa ha canviat i el teu sistema ha de seguir funcionant o, com a mínim, avisar-te que ha deixat de fer-ho.
Anatomia d’un extractor mantenible
Abans d’entrar en patrons concrets, aquesta és l’estructura que uso per a cada extractor a Rolsfera:
# extractors/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
@dataclass
class ExtractedArticle:
title: str
url: str
content: str
published_at: str | None
author: str | None
source_name: str
raw_html: str # siempre guardo el HTML original
def is_valid(self) -> bool:
"""Validación mínima: título y URL deben existir."""
return bool(self.title and self.url and len(self.title) > 5)
class BaseExtractor(ABC):
def __init__(self, source_name: str):
self.source_name = source_name
self.logger = logging.getLogger(f"extractor.{source_name}")
@abstractmethod
def extract(self) -> list[ExtractedArticle]:
pass
def run(self) -> list[ExtractedArticle]:
self.logger.info(f"Iniciando extracción de {self.source_name}")
try:
articles = self.extract()
valid = [a for a in articles if a.is_valid()]
invalid_count = len(articles) - len(valid)
if invalid_count > 0:
self.logger.warning(
f"{invalid_count} artículos inválidos descartados"
)
self.logger.info(
f"Extracción completada: {len(valid)} artículos válidos"
)
return valid
except Exception as e:
self.logger.error(
f"Error en extracción de {self.source_name}: {e}",
exc_info=True,
)
return []Cada font té la seva pròpia classe que hereta de BaseExtractor. La classe base s’encarrega de la validació, el logging i la gestió d’errors. La classe concreta només ha d’implementar la lògica d’extracció específica del lloc.
Aquest patró té un avantatge obvi: quan un extractor falla, no arrossega la resta. I el logging uniforme permet detectar problemes ràpidament.
Selectors: la primera línia de defensa
La manera com selecciones elements de l’HTML determina en gran mesura la fragilitat del scraper. Aquests són els principis que segueixo:
Preferir atributs semàntics sobre classes CSS
Les classes CSS canvien amb freqüència, especialment en llocs que usen frameworks amb class mangling (Tailwind amb purge, CSS modules, styled-components). Els atributs semàntics (data-*, role, aria-label) són més estables.
from bs4 import BeautifulSoup
# ❌ Frágil: depende de una clase CSS específica
soup.select("div.article-card__title > h2.heading-md")
# ✅ Mejor: usa atributos semánticos
soup.select("[data-testid='article-title']")
# ✅ También bueno: estructura semántica del HTML
soup.select("article > header > h2")Cadena de selectors amb fallback
Mai confio en un sol selector. Per a cada camp, defineixo una cadena de selectors ordenats per fiabilitat:
def extract_title(self, article_element) -> str | None:
"""Intenta extraer el título con múltiples estrategias."""
selectors = [
"[data-testid='article-title']",
"article > header h1",
"h1.entry-title",
"h2.post-title",
".article-title",
]
for selector in selectors:
element = article_element.select_one(selector)
if element and element.get_text(strip=True):
return element.get_text(strip=True)
# Último recurso: el primer h1 o h2 que encuentre
for tag in ["h1", "h2"]:
element = article_element.find(tag)
if element and element.get_text(strip=True):
return element.get_text(strip=True)
return NoneQuan un selector deixa de funcionar, el següent de la cadena pren el relleu. Això no evita que el scraper es trenqui eventualment, però li dona resistència a canvis menors.
Registrar quin selector funciona
Un detall que marca la diferència en manteniment: registrar quin selector s’ha usat per a cada extracció.
def extract_with_fallback(self, element, selectors: list[str], field_name: str) -> str | None:
for i, selector in enumerate(selectors):
result = element.select_one(selector)
if result and result.get_text(strip=True):
if i > 0:
self.logger.warning(
f"Campo '{field_name}': selector primario falló, "
f"usando fallback #{i}: {selector}"
)
return result.get_text(strip=True)
self.logger.error(f"Campo '{field_name}': todos los selectores fallaron")
return NoneQuan el selector primari falla i un fallback el substitueix, el log m’avisa. Això em dona temps per actualitzar els selectors abans que la cadena completa deixi de funcionar.
Validació de dades extretes
Extreure dades és només la meitat de la feina. L’altra meitat és assegurar-te que les dades tenen sentit. He vist scrapers que seguien funcionant (sense errors) però retornant escombraries perquè l’HTML va canviar d’una manera que no trencava els selectors però sí la semàntica del contingut.
def validate_article(article: ExtractedArticle) -> list[str]:
"""Devuelve lista de problemas encontrados. Lista vacía = OK."""
issues = []
if not article.title or len(article.title) < 10:
issues.append("Título vacío o sospechosamente corto")
if not article.url or not article.url.startswith("http"):
issues.append("URL vacía o con formato inválido")
if not article.content or len(article.content) < 100:
issues.append("Contenido vacío o sospechosamente corto")
elif article.content.count("<") > 10:
issues.append("Contenido parece contener HTML sin limpiar")
if article.published_at:
try:
pub_date = datetime.fromisoformat(article.published_at)
if pub_date > datetime.utcnow() + timedelta(days=1):
issues.append("Fecha de publicación en el futuro")
except ValueError:
issues.append(f"Formato de fecha inválido: {article.published_at}")
return issuesLa validació no només detecta errors obvis. També detecta problemes subtils: un contingut que sembla HTML sense netejar, una data futura (probablement un error de parseig), un títol massa curt que podria ser un fragment.
Si el validador troba problemes, l’article no es descarta automàticament. Es marca per a revisió manual. De vegades un problema de validació és una senyal que el scraper necessita ajustos, no que l’article sigui dolent.
Estratègia de logs i alertes
Els logs d’un scraper han de respondre a tres preguntes:
- L’extractor s’ha executat correctament?
- Quants articles ha extret (i és un nombre raonable)?
- Hi ha hagut alguna cosa anòmala que hauria de revisar?
class ExtractionReport:
def __init__(self, source_name: str):
self.source_name = source_name
self.articles_found = 0
self.articles_valid = 0
self.fallbacks_used = 0
self.errors = []
def health(self) -> str:
if self.errors:
return "error"
if self.articles_found == 0:
return "warning"
if self.fallbacks_used > 0:
return "degraded"
return "healthy"El camp health és el que uso per a les alertes. Si un extractor està en error o degraded durant més de dues execucions consecutives, n8n m’envia un missatge a Telegram avisant-me. No necessito revisar logs activament: el sistema m’avisa quan alguna cosa va malament.
Una mètrica que va resultar ser molt útil és el recompte històric d’articles per font. Si un extractor que normalment retorna 5-10 articles de sobte en retorna 0 durant tres execucions seguides, alguna cosa ha canviat. Probablement el lloc ha modificat la seva estructura o ha bloquejat el scraper.
def check_extraction_anomaly(source_name: str, current_count: int) -> bool:
"""Compara con la media histórica para detectar anomalías."""
avg = get_historical_average(source_name, days=7)
if avg == 0:
return False
# Si el resultado actual es menos del 20% de la media, es anómalo
return current_count < avg * 0.2Quan usar RSS abans que scraping
Abans de muntar un scraper, sempre verifico si el lloc ofereix un feed RSS. És una regla que m’he imposat després de perdre temps mantenint scrapers per a llocs que tenien un RSS perfectament funcional.
| Criteri | RSS | Scraping |
|---|---|---|
| Estabilitat | Alta (format estàndard) | Baixa (depèn de l’HTML) |
| Manteniment | Mínim | Constant |
| Contingut complet | De vegades (depèn del feed) | Sí (si el selector és correcte) |
| Ètica/legalitat | Sempre permès | Zona grisa |
| Dades estructurades | Limitades (títol, data, resum) | Flexibles (el que puguis extreure) |
| Velocitat d’implementació | Minuts | Hores |
La meva regla és: RSS primer, scraping només quan no hi ha alternativa o quan necessito dades que el feed no inclou.
A la pràctica, moltes vegades uso tots dos: RSS per detectar articles nous (és la font més fiable per a això) i scraping per extreure el contingut complet quan el feed només inclou un extracte.
class HybridExtractor(BaseExtractor):
"""Usa RSS para descubrir URLs y scraping para extraer contenido."""
def extract(self) -> list[ExtractedArticle]:
feed = feedparser.parse(self.feed_url)
articles = []
for entry in feed.entries:
url = entry.get("link", "")
if not url or is_already_extracted(url):
continue
# RSS para descubrir, scraping para contenido completo
full_content = self._scrape_full_article(url)
articles.append(ExtractedArticle(
title=entry.get("title", ""),
url=url,
content=full_content or entry.get("summary", ""),
published_at=entry.get("published", ""),
author=entry.get("author", None),
source_name=self.source_name,
raw_html=full_content or "",
))
return articlesBones pràctiques (les de veritat, no les de manual)
Headers realistes
Un error de principiant que segueixo veient és no configurar headers HTTP. Molts llocs bloquegen peticions sense User-Agent o amb el default de requests (que s’identifica com python-requests).
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9",
"Accept-Language": "en-US,en;q=0.9,es;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
}Delays entre peticions
No masaco servidors. Entre peticions al mateix domini uso un delay de 2-5 segons. És una qüestió d’ètica i de supervivència: si un lloc detecta patrons de scraping agressiu, et bloqueja.
import time
import random
def polite_request(url: str, min_delay: float = 2.0, max_delay: float = 5.0):
time.sleep(random.uniform(min_delay, max_delay))
return requests.get(url, headers=HEADERS, timeout=15)Respectar robots.txt
Verifico el robots.txt abans de muntar un scraper per a un lloc nou. Python té urllib.robotparser per a això. Si la ruta està bloquejada, no la scrapejo. No és només una qüestió legal. És respecte a la feina dels altres. Si un lloc diu explícitament que no vol que el scrapegin, cal buscar una alternativa (RSS, API, contactar directament).
Desar sempre l’HTML original
Deso l’HTML sense processar de cada extracció. Ocupa espai, però m’ha salvat diverses vegades: quan un selector es trenca, puc reprocessar l’HTML emmagatzemat amb el nou selector sense haver de tornar a descarregar les pàgines.
Patró de fallback quan canvia l’estructura
Quan un lloc canvia el seu HTML, l’extractor es trenca. La pregunta és: com es recupera?
El meu patró té tres nivells:
Nivell 1: Cadena de selectors. Ja ho he explicat més amunt. Diversos selectors ordenats per prioritat. Cobreix canvis menors.
Nivell 2: Extracció genèrica. Si tots els selectors específics fallen, caic a un extractor genèric que busca contingut en etiquetes semàntiques estàndard (article, main, [role='main'], .content). Si això tampoc funciona, busca el bloc de text més llarg de la pàgina. És bast, però en molts casos treu contingut raonable.
Nivell 3: Alerta i degradació. Si l’extracció genèrica tampoc aconsegueix contingut raonable, l’article es desa només amb les dades del RSS (títol, URL, resum curt) i el sistema m’alerta perquè actualitzi l’extractor manualment.
def extract_with_fallback_levels(self, url: str, rss_data: dict) -> ExtractedArticle:
# Nivel 1: selectores específicos
content = self._extract_with_selectors(url)
if content:
return self._build_article(content, rss_data, method="specific")
# Nivel 2: extracción genérica
content = self._extract_generic(url)
if content:
self.logger.warning(f"Usando extracción genérica para {url}")
return self._build_article(content, rss_data, method="generic")
# Nivel 3: solo datos de RSS + alerta
self.logger.error(f"Extracción fallida, usando solo datos RSS")
self._send_alert(f"Extractor roto para {self.source_name}")
return self._build_article(rss_data["summary"], rss_data, method="rss_only")Aquest patró no resol el problema de fons (necessites actualitzar els selectors eventualment), però et dona temps. En lloc que el pipeline s’aturi completament, es degrada progressivament i t’avisa.
Quan usar Playwright en lloc de BeautifulSoup
La regla és senzilla: si el contingut és a l’HTML que retorna el servidor, BeautifulSoup. Si el contingut es carrega amb JavaScript després de la càrrega inicial, Playwright.
from playwright.sync_api import sync_playwright
def scrape_with_playwright(url: str) -> str | None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
try:
page.goto(url, wait_until="networkidle", timeout=15000)
# Esperar a que el contenido principal cargue
page.wait_for_selector("article", timeout=5000)
content = page.query_selector("article")
return content.inner_text() if content else None
except Exception as e:
logger.error(f"Playwright error: {e}")
return None
finally:
browser.close()Playwright és més lent i consumeix més recursos. El reservo per als llocs que realment ho necessiten. A Rolsfera, només 3 de les 40+ fonts requereixen Playwright. La resta funciona amb BeautifulSoup i peticions HTTP estàndard.
Reflexió
El scraping és una eina potent però incòmoda. Funciona fins que deixa de funcionar, i la pregunta no és si es trencarà, sinó quan i quant et costarà arreglar-ho.
El que he après mantenint scrapers durant mesos és que la inversió no està a escriure l’extractor inicial, sinó a construir les capes que l’envolten: validació, fallbacks, logs, alertes i la disciplina de tractar cada extractor com un component que fallarà.
Si només et quedes amb una idea d’aquest article: no construeixis scrapers que assumeixen estabilitat. Construeix scrapers que assumeixen canvi i estan dissenyats per degradar-se amb gràcia quan aquest canvi arriba.


