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.

Cover for Scraping que no es trenca cada setmana: com dissenyar extractors més resistents

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 None

Quan 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 None

Quan 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 issues

La 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:

  1. L’extractor s’ha executat correctament?
  2. Quants articles ha extret (i és un nombre raonable)?
  3. 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.2

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

CriteriRSSScraping
EstabilitatAlta (format estàndard)Baixa (depèn de l’HTML)
MantenimentMínimConstant
Contingut completDe vegades (depèn del feed)Sí (si el selector és correcte)
Ètica/legalitatSempre permèsZona grisa
Dades estructuradesLimitades (títol, data, resum)Flexibles (el que puguis extreure)
Velocitat d’implementacióMinutsHores

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 articles

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

OshyTech

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

Navegació

Copyright 2026 OshyTech. Tots els drets reservats