Agentes de noticias: del RSS al resumen con LLM

Serie: Sistemas Multi-Agente en tu Homelab — Post 6 de 8


Este post describe uno de los pipelines más complejos de nuestra setup: agentes que monitorean fuentes de noticias, hacen scraping de sitios JavaScript-heavy, lidian con mecanismos anti-bot, y generan resúmenes concisos usando LLMs. Es un caso de estudio real de multi-agente aplicado a data journalism automatizado.


El pipeline completo

flowchart TD
    F["Fuentes RSS / Sitios web"]
    FE["Fetch — 7am · 17pm\nRSS parser + CDP scraper\nChromium headless"]
    SU["LLM Summary\nOllama gemma4:e4b"]
    SE["Send — 8am · 18pm\nopenclaw send-all → WhatsApp groups"]
    FA["Fetch Articles\nCDP + visión · qwen2.5vl:3b\nscraping de cuerpos completos"]
    SY["Sync → Postgres\npush incremental"]

    F -->|"URLs + headlines"| FE
    FE -->|"SQLite: news_items\nURL · título · sin contenido"| SU
    SU -->|"SQLite: summary"| SE
    SE -->|post-send| FA
    FA -->|"SQLite: news_items.content"| SY

Cuatro agentes especializados

Cada dominio de noticias tiene su propio script Python con su lógica de scraping:

Agente Fuentes Método
mexico_noticias.py El País MX, Reforma, El Universal, Milenio, Expansión, La Jornada CDP (JavaScript-heavy)
intl_noticias.py BBC, Reuters, NYT, The Guardian, Le Monde, DW, Al Jazeera, El País CDP
maker_noticias.py Hackaday, Hackster, Make, Adafruit, Arduino Blog, Tom's Hardware RSS
gaming_noticias.py Kotaku, Game Developer, Ars Technica, The Verge, RPS, Eurogamer RSS

La elección entre CDP y RSS no es arbitraria: los sitios de noticias tradicionales suelen tener JavaScript que bloquea el scraping simple con requests. Los sitios de la comunidad maker/gaming suelen tener feeds RSS bien mantenidos.


CDP: Chromium como herramienta de scraping

El Chrome DevTools Protocol (CDP) permite controlar Chromium programáticamente a nivel bajo: navegar URLs, ejecutar JavaScript, tomar screenshots, interceptar red. A diferencia de Selenium o Playwright, CDP no requiere WebDriver y tiene latencia menor.

# Patrón básico de navegación con CDP
async def navigate_and_extract(url: str) -> str:
    async with CDPSession(port=18800) as session:
        await session.navigate(url)
        await session.wait_for_load()
        content = await session.evaluate(ARTICLE_EXTRACT_JS)
        return content

El browser tiene un perfil persistente por agente (news, intl, gaming-art, etc.) que mantiene cookies entre sesiones. Esto es crucial: muchos sitios muestran cookie banners solo en la primera visita. Con el perfil persistente, el agente ya "aceptó" la política de cookies y puede acceder al contenido directamente.


El problema anti-bot y cómo lo resolvemos

El scraping moderno enfrenta varios tipos de bloqueos:

┌─────────────────┬──────────────────────────────────┬─────────────────┐
│ Tipo            │ Ejemplo                          │ Solución        │
├─────────────────┼──────────────────────────────────┼─────────────────┤
│ Cookie banner   │ "Acepta las cookies para entrar" │ Vision + dismiss│
│ Captcha         │ DataDome, reCAPTCHA               │ Cookies previas │
│ Paywall         │ "Suscríbete para leer"            │ Marcar y skip   │
│ Cloudflare      │ "Just a moment..."               │ RSS fallback    │
│ Login wall      │ "Inicia sesión para continuar"   │ Marcar y skip   │
└─────────────────┴──────────────────────────────────┴─────────────────┘

Vision-assisted cookie dismiss

El caso más interesante es el cookie banner. Los textos de los botones varían: "Acepto", "Accept All", "Aceptar todo", "Continuar sin aceptar". Un hardcode de textos falla cuando un sitio cambia su banner. La solución: usar un VLM (modelo de visión).

# lib/article_extractor.py — flujo de extracción con fallback visual
async def process_article(url: str, browser: CDPBrowser) -> str:
    content = await extract_text(url, browser)

    if len(content) < 100:
        # Poco contenido → puede haber un bloqueador visual
        screenshot = await browser.take_screenshot()

        classification = await visual_guide.classify(
            screenshot,
            prompt="¿Qué bloquea el contenido? cookie_wall | captcha | paywall | login_wall | none"
        )

        if classification.type == "cookie_wall":
            # El VLM también devuelve el texto exacto del botón
            await dismiss_cookie_banner(browser, button_text=classification.button_text)
            content = await extract_text(url, browser)  # segundo intento

        elif classification.type in ("captcha", "paywall", "login_wall"):
            # Bloqueo permanente — no reintentar
            await mark_blocked(url, reason=classification.type)
            return ""

    return content

El modelo de visión (qwen2.5vl:3b) tarda ~2-3 segundos por screenshot. Es lento comparado con extracción de texto puro, pero se llama solo cuando la extracción normal falla, no en cada artículo.

RSS como fallback para Cloudflare

Adafruit Blog usa Cloudflare Challenge. El browser CDP no pasa la verificación de JavaScript. La solución elegante: el RSS de Adafruit incluye el contenido completo en <content:encoded>. Cuando el RSS tiene ≥500 caracteres de contenido, lo usamos directamente y nunca tocamos el sitio con el browser.

def parse_feed(url: str) -> list[NewsItem]:
    feed = feedparser.parse(url)
    items = []
    for entry in feed.entries:
        content = entry.get("content", [{}])[0].get("value", "")
        # Si el RSS trae contenido rico, úsalo directo
        items.append(NewsItem(
            title=entry.title,
            url=entry.link,
            content=content if len(content) >= 500 else None
        ))
    return items

Este patrón —preferir la fuente más directa disponible— es generalizable: antes de abrir un browser y pelear con anti-bot, pregunta si hay una API o feed que tenga los datos que necesitas.


El LLM como redactor: generación de resúmenes

Una vez que tenemos los titulares, los pasamos a gemma4:e4b para generar el resumen del día. El prompt es crítico:

SISTEMA: Eres un editor de noticias conciso. Recibirás una lista de titulares
con sus URLs. Genera un resumen para WhatsApp usando EXACTAMENTE los titulares
tal como aparecen. No parafrasees ni inventes. Incluye la URL de cada ítem.

USUARIO: [lista de titulares con URLs]

ASISTENTE: [resumen formateado para WhatsApp]

Lección importante: en versiones anteriores el prompt decía "resume los temas principales" y el modelo generaba paráfrasis genéricas. El cambio a "copia los titulares exactos" mejoró dramáticamente la fidelidad del resumen.

El parámetro num_predict=8000 es necesario para modelos de razonamiento como gemma4:e4b que usan tokens internamente para el "chain of thought" antes de generar la respuesta visible. Con num_predict=3000, el modelo se cortaba antes de terminar el resumen.


Separación de fetch y send

Una decisión de arquitectura importante: fetch y send son procesos separados con almacenamiento intermedio en SQLite.

  • Fetch (7am/17pm): extrae headlines, genera resumen, guarda en DB. No envía nada.
  • Send (8am/18pm): lee el resumen de DB y lo envía por WhatsApp.

Ventajas: 1. Si el send falla (WhatsApp down, gateway caído), el fetch ya está hecho. No re-scrapiamos. 2. Puedes re-enviar el resumen del día sin re-scraping: SELECT en DB y vuelve a mandar. 3. El fetch puede tardar 10-15 minutos (Ollama es lento). El send es casi instantáneo. 4. Tienes historial auditado de lo que se envió y cuándo.

-- Consultar resúmenes enviados hoy
SELECT source, summary, created_at 
FROM noticias_headlines 
WHERE date(created_at) = date('now')
ORDER BY created_at;

Crons: la orquestación temporal

Los cuatro agentes están coordinados por dos pares de scripts:

# noticias-fetch-all.sh
python3 tools/mexico_noticias.py fetch
python3 tools/intl_noticias.py fetch
python3 tools/maker_noticias.py fetch
python3 tools/gaming_noticias.py fetch

# noticias-send-all.sh
bash eventos-hoy-send.sh          # solo AM
bash pokemon-dia.sh               # solo AM
bash noticias-mexico-send.sh
bash intl-noticias-send.sh
bash maker-noticias-send.sh
bash gaming-noticias-send.sh
bash noticias-fetch-articles-all.sh  # scraping de cuerpos, post-send
python3 tools/sync_all_to_pg.py      # sync a Postgres

El script detecta la hora para omitir contenido no aplica al turno de tarde:

HOUR=$(date +%H)
if [ "$HOUR" -lt 12 ]; then
    bash eventos-hoy-send.sh
    bash pokemon-dia.sh
fi

Skills, tools y agentes | Inicio de la serie | Persistencia y observabilidad →