Scraping que no se rompe cada semana: cómo diseñar extractores más resistentes

Guía técnica para diseñar scrapers mantenibles en Python: selectores, fallback, validación, logs y buenas prácticas reales.

Cover for Scraping que no se rompe cada semana: cómo diseñar extractores más resistentes
Actualizado: 18 may 2026

Si has mantenido un scraper más de tres meses, sabes exactamente de qué va este artículo. Montas un extractor que funciona perfecto. Lo dejas corriendo. Dos semanas después, el sitio cambia una clase CSS y tu pipeline empieza a devolver campos vacíos. O peor: devuelve datos incorrectos sin que te des cuenta hasta que alguien te lo dice.

He mantenido scrapers en Rolsfera durante meses y la lección más clara que he sacado es esta: el scraping no es difícil de montar, es difícil de mantener. La diferencia entre un scraper que sobrevive y uno que se rompe cada semana no está en la librería que uses, sino en cómo diseñes el extractor para absorber cambios.

Este artículo no es una intro a BeautifulSoup ni a Playwright. Asumo que ya sabes usar ambas. Lo que quiero compartir son patrones de diseño, validación y monitorización que he aplicado en producción y que marcan la diferencia cuando el scraper tiene que funcionar sin tu supervisión constante.


El problema de fondo

Un scraper es código que depende de la estructura de un sistema que no controlas. Eso lo convierte en el tipo de software más frágil que puedes escribir. Cualquier cambio en el HTML del sitio objetivo puede romper tu extractor: un div que cambia de clase, un atributo data- que desaparece, una paginación que pasa de server-side a client-side, un wrapper de JavaScript que antes no existía.

El scraping no se rompe por errores de programación. Se rompe porque la web cambia, y tu código asume que no lo hará.

La mayoría de tutoriales de scraping terminan cuando el extractor funciona la primera vez. Pero la primera vez es la parte fácil. Lo que importa es qué pasa la vez número 50, cuando algo ha cambiado y tu sistema tiene que seguir funcionando o, como mínimo, avisarte de que ha dejado de hacerlo.


Anatomía de un extractor mantenible

Antes de entrar en patrones concretos, esta es la estructura que uso para cada extractor en 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 fuente tiene su propia clase que hereda de BaseExtractor. La clase base se encarga de la validación, el logging y el manejo de errores. La clase concreta solo tiene que implementar la lógica de extracción específica del sitio.

Este patrón tiene una ventaja obvia: cuando un extractor falla, no arrastra al resto. Y el logging uniforme permite detectar problemas rápidamente.


Selectores: la primera línea de defensa

La forma en que seleccionas elementos del HTML determina en gran medida la fragilidad del scraper. Estos son los principios que sigo:

Preferir atributos semánticos sobre clases CSS

Las clases CSS cambian con frecuencia, especialmente en sitios que usan frameworks con class mangling (Tailwind con purge, CSS modules, styled-components). Los atributos semánticos (data-*, role, aria-label) son 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 selectores con fallback

Nunca confío en un solo selector. Para cada campo, defino una cadena de selectores ordenados por fiabilidad:

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

Cuando un selector deja de funcionar, el siguiente de la cadena toma el relevo. Esto no evita que el scraper se rompa eventualmente, pero le da resistencia a cambios menores.

Registrar qué selector funciona

Un detalle que marca la diferencia en mantenimiento: registrar cuál selector se usó para cada extracción.

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

Cuando el selector primario falla y un fallback lo sustituye, el log me avisa. Eso me da tiempo para actualizar los selectores antes de que la cadena completa deje de funcionar.


Validación de datos extraídos

Extraer datos es solo la mitad del trabajo. La otra mitad es asegurarte de que los datos tienen sentido. He visto scrapers que seguían funcionando (sin errores) pero devolviendo basura porque el HTML cambió de forma que no rompía los selectores pero sí la semántica del contenido.

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ón no solo detecta errores obvios. También detecta problemas sutiles: un contenido que parece HTML sin limpiar, una fecha en el futuro (probablemente un error de parseo), un título demasiado corto que podría ser un fragmento.

Si el validador encuentra problemas, el artículo no se descarta automáticamente. Se marca para revisión manual. A veces un problema de validación es una señal de que el scraper necesita ajustes, no de que el artículo sea malo.


Estrategia de logs y alertas

Los logs de un scraper tienen que responder a tres preguntas:

  1. ¿El extractor se ejecutó correctamente?
  2. ¿Cuántos artículos extrajo (y es un número razonable)?
  3. ¿Hubo algo anómalo que debería 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 campo health es lo que uso para las alertas. Si un extractor está en error o degraded durante más de dos ejecuciones consecutivas, n8n me manda un mensaje a Telegram avisándome. No necesito revisar logs activamente: el sistema me avisa cuando algo va mal.

Una métrica que resultó ser muy útil es el conteo histórico de artículos por fuente. Si un extractor que normalmente devuelve 5-10 artículos de repente devuelve 0 durante tres ejecuciones seguidas, algo ha cambiado. Probablemente el sitio modificó su estructura o bloqueó 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

Cuándo usar RSS antes que scraping

Antes de montar un scraper, siempre verifico si el sitio ofrece un feed RSS. Es una regla que me he impuesto después de perder tiempo manteniendo scrapers para sitios que tenían un RSS perfectamente funcional.

CriterioRSSScraping
EstabilidadAlta (formato estándar)Baja (depende del HTML)
MantenimientoMínimoConstante
Contenido completoA veces (depende del feed)Sí (si el selector es correcto)
Ética/legalidadSiempre permitidoZona gris
Datos estructuradosLimitados (título, fecha, resumen)Flexibles (lo que puedas extraer)
Velocidad de implementaciónMinutosHoras

Mi regla es: RSS primero, scraping solo cuando no hay alternativa o cuando necesito datos que el feed no incluye.

En la práctica, muchas veces uso ambos: RSS para detectar artículos nuevos (es la fuente más fiable para eso) y scraping para extraer el contenido completo cuando el feed solo incluye un extracto.

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

Buenas prácticas (las de verdad, no las de manual)

Headers realistas

Un error de principiante que sigo viendo es no configurar headers HTTP. Muchos sitios bloquean peticiones sin User-Agent o con el default de requests (que se identifica como 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 peticiones

No machaco servidores. Entre peticiones al mismo dominio uso un delay de 2-5 segundos. Es una cuestión de ética y de supervivencia: si un sitio detecta patrones de scraping agresivo, te bloquea.

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)

Respetar robots.txt

Verifico el robots.txt antes de montar un scraper para un sitio nuevo. Python tiene urllib.robotparser para esto. Si la ruta está bloqueada, no la scrapeo. No es solo una cuestión legal. Es respeto al trabajo de otros. Si un sitio dice explícitamente que no quiere que lo scrapeen, hay que buscar una alternativa (RSS, API, contactar directamente).

Guardar siempre el HTML original

Guardo el HTML sin procesar de cada extracción. Ocupa espacio, pero me ha salvado varias veces: cuando un selector se rompe, puedo re-procesar el HTML almacenado con el nuevo selector sin tener que volver a descargar las páginas.


Patrón de fallback cuando cambia la estructura

Cuando un sitio cambia su HTML, el extractor se rompe. La pregunta es: ¿cómo se recupera?

Mi patrón tiene tres niveles:

Nivel 1: Cadena de selectores. Ya lo expliqué arriba. Varios selectores ordenados por prioridad. Cubre cambios menores.

Nivel 2: Extracción genérica. Si todos los selectores específicos fallan, caigo a un extractor genérico que busca contenido en etiquetas semánticas estándar (article, main, [role='main'], .content). Si eso tampoco funciona, busca el bloque de texto más largo de la página. Es tosco, pero en muchos casos saca contenido razonable.

Nivel 3: Alerta y degradación. Si la extracción genérica tampoco consigue contenido razonable, el artículo se guarda solo con los datos del RSS (título, URL, resumen corto) y el sistema me alerta para que actualice el extractor manualmente.

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")

Este patrón no resuelve el problema de fondo (necesitas actualizar los selectores eventualmente), pero te da tiempo. En lugar de que el pipeline se pare completamente, se degrada progresivamente y te avisa.


Cuándo usar Playwright en vez de BeautifulSoup

La regla es sencilla: si el contenido está en el HTML que devuelve el servidor, BeautifulSoup. Si el contenido se carga con JavaScript después de la carga 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 es más lento y consume más recursos. Lo reservo para los sitios que realmente lo necesitan. En Rolsfera, solo 3 de las 40+ fuentes requieren Playwright. El resto funciona con BeautifulSoup y peticiones HTTP estándar.


Reflexión

El scraping es una herramienta potente pero incómoda. Funciona hasta que deja de funcionar, y la pregunta no es si se va a romper, sino cuándo y cuánto te va a costar arreglarlo.

Lo que he aprendido manteniendo scrapers durante meses es que la inversión no está en escribir el extractor inicial, sino en construir las capas que lo rodean: validación, fallbacks, logs, alertas y la disciplina de tratar cada extractor como un componente que va a fallar.

Si solo te llevas una idea de este artículo: no construyas scrapers que asumen estabilidad. Construye scrapers que asumen cambio y están diseñados para degradarse con gracia cuando ese cambio llega.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados