Volver al Blog
Ingeniería de Datos Python Automatización Supabase

Web Scraping Masivo: Pipelines que Nunca se Caen

• 12 min de lectura • Escrito por Marlon

TL;DR:

  • La Trampa: Scripts monohilo frágiles basados en clases CSS fijas (div.price_tag) e inserciones directas a base de datos corrompen silenciosamente tus datasets en cuanto el sitio web objetivo actualiza su frontend o activa firewalls antibot.
  • La Arquitectura: Un motor de extracción de 4 etapas que incorpora rotación de huellas TLS, backoff exponencial con jitter aleatorio, firewalls de esquemas con Pydantic v2 y colas de mensajes fallidos (Dead-Letter Queues) con upserts atómicos por lotes en PostgreSQL.
  • El Impacto: Erradica las caídas de extracción, previene la contaminación de esquemas en la base de datos y procesa millones de registros diarios con costos mínimos de infraestructura en la nube (<$50/mes).

La Ilusión del Script Local: Por Qué la Extracción Masiva Siempre Falla

Un script de Python con BeautifulSoup ejecutándose en un notebook de Jupyter en tu laptop no es un pipeline de datos. Es un experimento desechable que colapsará en cuanto intentes extraer cien mil registros en producción.

En entornos locales de prueba, la extracción web parece un problema trivial: lanzas diez peticiones HTTP, parseas el texto utilizando selectores CSS y guardas los resultados en un archivo CSV o en una tabla SQLite local.

Sin embargo, en el instante en que intentas alimentar embeddings para modelos de inteligencia artificial, comparadores de precios de alta frecuencia o motores de analítica comercial, la web pública se vuelve activamente hostil:

  • Defensas Perimetrales Agresivas: Límites de tasa por IP residencial, desafíos de Cloudflare Turnstile, detección de huellas digitales en Datadome y cascadas imprevistas de errores 429 Too Many Requests o 403 Forbidden.
  • Mutaciones Silenciosas del DOM: Las plataformas de comercio electrónico y portales corporativos modifican sus clases CSS hash en cada despliegue de frontend. Un script naive no lanza una excepción; simplemente extrae valores None en tus columnas, corrompiendo la base de datos de producción durante semanas antes de que alguien lo note.
  • Saturación del Pool de Conexiones: Insertar registros uno a uno en un bucle (for fila in datos: db.insert(fila)) bloquea tablas enteras, agota los pools de conexiones a la base de datos y dispara el uso de CPU al 100%.

Si la ventaja competitiva de tu empresa depende de la ingesta continua de datos del mercado, los scripts descartables representan un riesgo operativo inaceptable. Necesitas un pipeline empresarial construido bajo fundamentos de sistemas distribuidos.

  1. ETAPA 01 Red

    Evasión TLS y Proxies

    Rota pools de IPs residenciales autenticadas y normaliza huellas TLS JA3 para neutralizar bloqueos perimetrales.

  2. ETAPA 02 Árbol DOM

    Normalización Semántica

    Parsea el árbol HTML mediante jerarquías XPath y atributos semánticos estables en lugar de clases CSS volátiles.

  3. ETAPA 03 Firewall

    Validación con Pydantic

    Verificación estricta de tipos y reglas de frontera; deriva anomalías a una cola Dead-Letter Queue (DLQ).

  4. ETAPA 04 Almacén

    Batch Upsert Idempotente

    Inserta registros por bloques en PostgreSQL usando transacciones atómicas ON CONFLICT DO UPDATE.


Los Cuatro Pilares de la Extracción Antifrágil

Construir un pipeline de extracción capaz de operar durante meses sin intervención humana exige salvaguardas estructurales en cada nivel:

1. Neutralización de Huellas TLS Más Allá del User-Agent

Los Firewalls de Aplicaciones Web modernos (WAFs) como Cloudflare, Akamai o AWS WAF ya no se limitan a examinar las cabeceras HTTP. Inspeccionar únicamente el User-Agent es completamente obsoleto.

Los sistemas antibot analizan el apretón de manos TLS inicial (huellas JA3/JA4): el conjunto de suites de cifrado, curvas elípticas admitidas y extensiones TLS que tu cliente HTTP anuncia en la negociación. Las librerías convencionales como requests o urllib transmiten firmas que anuncian explícitamente “bot automatizado” antes de que el servidor analice tu petición.

Utilizando clientes HTTP/2 modernos como httpx con contextos TLS personalizados o pools de navegadores headless con emulación de fingerprints, replicamos de forma exacta el comportamiento de un navegador de escritorio legítimo.

2. Backoff Exponencial con Jitter Descorrelacionado

Cuando un servidor remoto devuelve respuestas HTTP 429 Too Many Requests o HTTP 503 Service Unavailable, reintentar de forma inmediata o en intervalos estáticos de 5 segundos garantiza el bloqueo de tu IP y agrava la congestión del servidor de destino.

Implementamos Jitter Exponencial Descorrelacionado: $$\text{espera} = \min(\text{límite}, \text{aleatorio}(\text{base}, \text{espera_anterior} \times 3))$$

Esta fórmula introduce variabilidad pseudoaleatoria en el tiempo de reintento, rompiendo patrones de tráfico rítmicos e impidiendo que múltiples workers golpeen el servidor en oleadas sincronizadas.

3. Pydantic v2 como Firewall de Ingesta

El HTML extraído de una web externa nunca debe tocar directamente tu base de datos sin validación. Las páginas web son entradas externas no confiables.

Cada registro extraído debe superar un esquema inmutable de Pydantic:

  • Los valores monetarios deben parsearse a tipos Decimal estrictos (evitando errores de redondeo de punto flotante).
  • Las fechas deben convertirse a marcas de tiempo UTC explícitas en formato ISO-8601.
  • Los campos críticos (sku, precio, moneda) deben existir y encontrarse dentro de rangos comerciales válidos.

Si el sitio objetivo actualiza su estructura y el precio evalúa a null o negativo, Pydantic rechaza el registro de inmediato. En lugar de interrumpir todo el proceso, el registro anómalo se desvía a una Dead-Letter Queue (DLQ) en PostgreSQL o Redis para auditoría manual, mientras que los registros válidos continúan fluyendo a producción.

4. Contrapresión en Base de Datos e Ingesta Atómica por Lotes

Insertar 50,000 registros extraídos uno por uno genera 50,000 viajes de red individuales y satura el log de transacciones (WAL) de PostgreSQL.

En su lugar, las arquitecturas resilientes acumulan los registros en un buffer en memoria. Cuando el buffer alcanza un límite de tamaño (por ejemplo, 500 filas) o de tiempo (2,000ms), un worker ejecuta una única transacción atómica en SQL utilizando:

INSERT INTO market_products (external_id, source_domain, price, title, raw_metadata, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (source_domain, external_id)
DO UPDATE SET
  price = EXCLUDED.price,
  title = EXCLUDED.title,
  raw_metadata = EXCLUDED.raw_metadata,
  updated_at = NOW();

Esto garantiza idempotencia: no importa si ejecutas la extracción del mismo catálogo una o diez veces, la base de datos se mantiene consistente, no se duplican registros y la carga de escritura en el servidor se reduce en más de un 85%.


Comparativa de Código: Script Naive vs Motor de Extracción Resiliente

Naive: Script Monohilo Frágil de Extracción

# Extractor Naive: Selectores CSS fragiles, escrituras individuales y silenciado de errores
import requests
from bs4 import BeautifulSoup
import psycopg2

conn = psycopg2.connect("postgresql://user:pass@localhost:5432/db")
cursor = conn.cursor()

def scrape_catalog():
    # PROBLEMA 1: IP residencial unica, sin rotacion, facilmente bloqueable
    resp = requests.get("https://competitor.com/catalog", headers={"User-Agent": "Mozilla/5.0"})
    soup = BeautifulSoup(resp.text, "html.parser")
    
    # PROBLEMA 2: Clases CSS hardcodeadas que fallan en el proximo deploy del sitio
    cards = soup.select("div.product-card-v2")
    
    for card in cards:
        try:
            title = card.select_one("h2.title").text.strip()
            # PROBLEMA 3: Excepcion no controlada si el simbolo de moneda cambia
            price = float(card.select_one("span.price").text.replace("$", ""))
            
            # PROBLEMA 4: Escritura SQL individual. Agota el pool y bloquea la tabla
            cursor.execute(
                "INSERT INTO products (title, price) VALUES (%s, %s)",
                (title, price)
            )
            conn.commit()
        except Exception:
            # PROBLEMA 5: Silenciado ciego de errores: oculta datos corruptos y cambios en el DOM
            pass

scrape_catalog()

Producción: Motor Asíncrono de Extracción Resiliente

# Motor de Produccion: Pool de Proxies, Tenacity con Jitter, Firewall Pydantic, DLQ y Batch Upserts
import asyncio
import json
import logging
from decimal import Decimal
from typing import List, Optional, Tuple
from datetime import datetime, timezone
import httpx
from pydantic import BaseModel, Field, HttpUrl, field_validator
from tenacity import retry, wait_random_exponential, stop_after_attempt, retry_if_exception_type
import asyncpg

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("resilient_ingestion")

# 1. Esquema de Validacion Pydantic v2 (Firewall de Ingesta)
class ProductRecord(BaseModel):
    external_id: str = Field(..., min_length=1, max_length=128)
    source_domain: str = Field(..., min_length=3, max_length=255)
    title: str = Field(..., min_length=2, max_length=500)
    price: Decimal = Field(..., gt=Decimal("0.00"), decimal_places=2)
    currency: str = Field(default="USD", min_length=3, max_length=3)
    product_url: HttpUrl
    extracted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

    @field_validator("currency")
    @classmethod
    def validate_iso_currency(cls, v: str) -> str:
        return v.upper()

class DeadLetterQueue:
    """Buffer para registros que violan el esquema o estan corruptos."""
    @staticmethod
    async def push_failure(pool: asyncpg.Pool, payload: dict, reason: str):
        logger.warning(f"Derivando anomalia a DLQ. Causa: {reason}")
        query = """
            INSERT INTO scraping_dead_letter_queue (raw_payload, error_reason, failed_at)
            VALUES ($1, $2, NOW())
        """
        async with pool.acquire() as conn:
            await conn.execute(query, json.dumps(payload), reason)

class ResilientScraper:
    def __init__(self, db_pool: asyncpg.Pool, proxy_url: Optional[str] = None):
        self.db_pool = db_pool
        self.client = httpx.AsyncClient(
            proxy=proxy_url,
            timeout=httpx.Timeout(connect=5.0, read=12.0, write=5.0, pool=10.0),
            http2=True,
            headers={
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
                "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
                "Accept-Language": "es-PE,es;q=0.9",
            }
        )

    # 2. Peticion de Red Resiliente con Backoff Exponencial y Jitter
    @retry(
        wait=wait_random_exponential(min=1, max=15),
        stop=stop_after_attempt(4),
        retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.NetworkError, httpx.TimeoutException)),
        reraise=True
    )
    async def fetch_page(self, target_url: str) -> str:
        response = await self.client.get(target_url)
        # Reintento automatico ante respuestas 429 Too Many Requests o errores 5xx
        if response.status_code in (429, 500, 502, 503, 504):
            logger.warning(f"El destino respondio HTTP {response.status_code}. Iniciando backoff...")
            response.raise_for_status()
        response.raise_for_status()
        return response.text

    # 3. Router de Normalizacion Semantica y Validacion
    async def process_raw_item(self, raw_data: dict) -> Optional[ProductRecord]:
        try:
            # Valida contra el firewall estricto del esquema
            record = ProductRecord(**raw_data)
            return record
        except Exception as validation_err:
            # Desvia datos corruptos a la DLQ sin tumbar el pipeline
            await DeadLetterQueue.push_failure(self.db_pool, raw_data, str(validation_err))
            return None

    # 4. Batch Upsert Atomico en PostgreSQL (Ingesta sin Cuellos de Botella)
    async def flush_batch_upsert(self, records: List[ProductRecord]) -> int:
        if not records:
            return 0

        query = """
            INSERT INTO market_products (
                external_id, source_domain, title, price, currency, product_url, updated_at
            )
            VALUES ($1, $2, $3, $4, $5, $6, $7)
            ON CONFLICT (source_domain, external_id)
            DO UPDATE SET
                title = EXCLUDED.title,
                price = EXCLUDED.price,
                currency = EXCLUDED.currency,
                product_url = EXCLUDED.product_url,
                updated_at = EXCLUDED.updated_at;
        """

        records_data = [
            (
                r.external_id,
                r.source_domain,
                r.title,
                r.price,
                r.currency,
                str(r.product_url),
                r.extracted_at
            )
            for r in records
        ]

        async with self.db_pool.acquire() as conn:
            async with conn.transaction():
                await conn.executemany(query, records_data)

        logger.info(f"Batch upsert atomico completado con {len(records)} registros.")
        return len(records)

    async def close(self):
        await self.client.aclose()

Casos de Éxito Reales: Resiliencia en Producción

PróximaBolilla (proximabolilla.com): Ingesta de Sorteos Oficiales sin Caídas

PróximaBolilla ejecuta simulaciones predictivas y cálculos de distribución matemática sobre loterías internacionales. El sistema depende de la extracción en tiempo real de los resultados oficiales publicados por organismos estatales.

  • El Problema: Los portales de loterías gubernamentales operan sobre infraestructura antigua. Durante las noches de sorteos masivos, los servidores colapsaban con respuestas HTTP 502 aleatorias y bloqueos agresivos. Los scrapers tradicionales fallaban semanalmente, perdiendo sorteos y descalibrando los modelos predictivos.
  • La Solución: Implementamos un pipeline asíncrono con rotación de proxies residenciales, backoff con jitter y validación estricta de rangos numéricos. Cada jugada extraída se verifica matemáticamente antes de guardarse.
  • El Resultado: El pipeline ha operado de manera ininterrumpida durante más de 24 meses consecutivos sin perder un solo sorteo, absorbiendo picos de tráfico y alimentando el motor analítico en tiempo real.

Strategio (Ganador Startup Perú 2da Generación): Inteligencia de Mercado Escalable

Durante el diseño e implementación de Strategio, la plataforma analítica de Business Intelligence construida desde cero en 12 semanas:

  • El Problema: La plataforma requería ingestar y clasificar diariamente miles de señales de mercado, métricas competitivas y variaciones de catálogos, con un presupuesto de infraestructura en la nube restringido a menos de $50 USD/mes.
  • La Solución: Diseñamos un pipeline de extracción asíncrona en Python con escrituras masivas mediante asyncpg en una única instancia optimizada de PostgreSQL, prescindiendo de servicios ETL costosos de terceros.
  • El Resultado: Strategio procesó más de 500,000 registros diarios de mercado manteniendo el uso de CPU de la base de datos por debajo del 12%, demostrando que la ingeniería de sistemas disciplinada supera a la infraestructura sobredimensionada.

Lista de Verificación Previa para Pipelines de Extracción

Antes de desplegar un pipeline automatizado de web scraping a producción, verifica estos cinco puntos de control:

  • Evasión de Huellas TLS JA3 Activa: ¿Tu cliente HTTP simula apretones de manos TLS idénticos a los de un navegador real para evitar bloqueos perimetrales inmediatos?
  • Política de Backoff con Jitter: ¿Tus reintentos están configurados con retardos exponenciales y variación pseudoaleatoria para neutralizar respuestas HTTP 429?
  • Firewall de Esquemas con Pydantic: ¿Todos los registros extraídos pasan por validación estricta de tipos y rangos antes de llegar a la base de datos?
  • Aislamiento en Dead-Letter Queue: ¿Los registros defectuosos se desvían a una tabla DLQ dedicada sin interrumpir la ejecución de los workers de ingesta?
  • Batch Upserts Idempotentes: ¿Las escrituras a la base de datos se ejecutan en bloques (100–1,000 filas) mediante ON CONFLICT DO UPDATE para proteger el pool de conexiones?

Asesoría de Arquitectura

Diseñar pipelines de datos de alta frecuencia y tolerantes a fallos requiere un dominio riguroso de protocolos de red, modelos de concurrencia e indexación en bases de datos.

En Coins5, Marlon ayuda a startups y empresas a estructurar arquitecturas de ingesta continua, extracción masiva de información y procesamiento de datos para modelos de IA que operan con disponibilidad total.

Si tu producto depende de datos externos de mercado o tus scrapers actuales sufren caídas frecuentes en producción, conversemos en una llamada de descubrimiento técnico de 15 minutos.

¿Construyendo Pipelines de Extracción de Datos?

Diseñemos una arquitectura de datos resiliente y sin caídas en una llamada técnica de descubrimiento de 15 minutos.

Marlon - Senior Architect
Autor & Arquitecto de Software

Marlon / Coins5

Senior Full Stack Developer & Arquitecto Cloud. Desarrollo de software a medida y MVPs robustos combinando Clean Architecture, diseño de sistemas de alta concurrencia y velocidad asistida por IA.

Agendar Llamada