Monitor de relevancia pública para el Senador Nacional Agustín Coto (Tierra del Fuego · La Libertad Avanza). Extrae menciones de medios locales y nacionales, facebook y x, las clasifica con IA y las visualiza en un dashboard privado con acceso autenticado.
- ¿Qué hace?
- Arquitectura general
- Estructura de archivos
- Infraestructura necesaria
- Configuración inicial (paso a paso)
- Variables de entorno
- Pipeline de producción (GitHub Actions)
- Dashboard
- Base de datos (Supabase)
- Scrapers
- Agregar un scraper nuevo
- Solución de problemas frecuentes
SAPIT corre automáticamente todos los días a las 09:00 AM (hora Argentina) y ejecuta este proceso en cadena:
Scraping → Limpieza → Clasificación con IA → Base de datos → Dashboard
Scraping: recorre los buscadores de 8 medios periodísticos (6 locales de TDF + Infobae + La Nación) y las cuentas de redes sociales del senador en X y Facebook. Extrae todas las notas y posts que mencionan a Coto.
Limpieza: elimina duplicados, normaliza fechas, descarta registros sin texto o sin fecha válida, genera un identificador único (doc_id) por nota.
Clasificación con IA: Gemini analiza cada nota y asigna (a) un sentimiento — POS, NEG o NEU — y (b) un tópico corto descriptivo como Soberanía / Malvinas o Conflictos gremiales TDF.
Base de datos: los registros clasificados se guardan en Supabase usando upsert, por lo que correr el pipeline dos veces no genera duplicados.
Dashboard: una app de Streamlit con acceso por usuario y contraseña que visualiza tendencias, distribución por fuente, análisis de impacto por evento, y la tabla completa de registros con filtros.
┌─────────────────────────────────────────────────────┐
│ GitHub Actions │
│ cron 12:00 UTC (09:00 ART) · ubuntu-22.04 │
│ │
│ main.py │
│ ├── scrapers (×8 medios + X + Facebook) │
│ ├── limpieza.py → deduplicación │
│ ├── clasificador.py → Gemini API │
│ ├── conexion.py → Supabase upsert │
│ └── logger.py → logs + Telegram │
└────────────────────────┬────────────────────────────┘
│
Supabase (PostgreSQL)
├── monitoreo_politico ← datos
├── eventos ← eventos manuales
└── logs_ejecucion ← historial pipeline
│
┌──────────┴──────────┐
│ Streamlit Cloud │
│ dashboard.py │
│ (acceso privado) │
└─────────────────────┘
sapit/
│
├── main.py # Orquestador del pipeline (producción)
├── config.yaml # Configuración pública (senador, medios, eventos)
├── requirements.txt # Dependencias Python
├── dashboard.py # App Streamlit (visualización)
│
├── scrapers/
│ ├── scraper_actualidadtdf.py
│ ├── scraper_infofueguina.py
│ ├── scraper_minutofueguino.py
│ ├── scraper_provincia23.py
│ ├── scraper_tiempofueguino.py
│ ├── scraper_red23noticias.py
│ ├── scraper_infobae.py
│ ├── scraper_lanacion.py
│ ├── scraper_x.py
│ └── scraper_facebook.py
│
├──procesamiento/
│ ├── limpieza.py # Normalización y deduplicación
│ ├── clasificador.py # Clasificación con Gemini (google-genai)
│
│──db/
│ │── conexion.py # Guardado en Supabase
│
│──utils/
│ │── logger.py # Logs en DB + alertas Telegram
│
│
└── .github/
└── workflows/
└── daily_sync.yml # GitHub Actions workflow
Antes de empezar necesitás tener cuentas en estos servicios. Todos tienen plan gratuito suficiente para este proyecto:
| Servicio | Para qué | Plan |
|---|---|---|
| GitHub | Repositorio + Actions (cron gratuito) | Free |
| Supabase | Base de datos PostgreSQL | Free (500MB) |
| Google AI Studio | API de Gemini para clasificación | Free (con límites) |
| Streamlit Cloud | Hosting del dashboard | Free |
| Telegram | Alertas del pipeline | Free |
git clone https://github.com/tu-usuario/sapit.git
cd sapitEn Supabase Dashboard → SQL Editor, ejecutar este script:
-- Tabla principal de menciones
CREATE TABLE monitoreo_politico (
doc_id TEXT PRIMARY KEY,
fuente TEXT,
plataforma TEXT,
url TEXT,
texto TEXT,
fecha DATE,
platform_id TEXT,
sentimiento TEXT CHECK (sentimiento IN ('POS','NEG','NEU')),
topico_corto TEXT,
creado_en TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Tabla de eventos manuales
CREATE TABLE eventos (
id SERIAL PRIMARY KEY,
fecha DATE NOT NULL,
titulo TEXT NOT NULL,
detalle TEXT,
tipo TEXT NOT NULL DEFAULT 'legislativo'
CHECK (tipo IN ('personal','legislativo','mediatico')),
creado_en TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Tabla de logs del pipeline
CREATE TABLE logs_ejecucion (
id SERIAL PRIMARY KEY,
modulo TEXT,
estado TEXT,
mensaje_error TEXT,
registros_procesados INT DEFAULT 0,
duracion_segundos INT DEFAULT 0,
creado_en TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Índices de performance
CREATE INDEX idx_monitoreo_fecha ON monitoreo_politico(fecha);
CREATE INDEX idx_monitoreo_fuente ON monitoreo_politico(fuente);
CREATE INDEX idx_monitoreo_sent ON monitoreo_politico(sentimiento);
CREATE INDEX idx_eventos_fecha ON eventos(fecha);
CREATE INDEX idx_logs_creado ON logs_ejecucion(creado_en);Supabase Dashboard → Project Settings → API
- Project URL:
https://XXXX.supabase.co - service_role key: en la sección "Project API keys" → hacer clic en "Reveal" al lado de
service_role
⚠️ Usar siempre laservice_rolekey, no laanonkey. Laanonkey respeta las políticas RLS y puede devolver 0 filas si la tabla no es pública.
- Ir a aistudio.google.com
- Crear una API key nueva
- Guardarla — solo se muestra una vez
Opcional: verificar qué modelos están disponibles para tu cuenta:
GEMINI_API_KEY=AIza... python check_gemini_models.pyEsto imprime la lista exacta para usar en MODELOS_FALLBACK dentro de clasificador.py.
- Abrir Telegram y buscar
@BotFather - Enviar
/newbot, seguir las instrucciones, copiar el token - Enviar cualquier mensaje al bot nuevo
- Obtener tu
chat_idvisitando:https://api.telegram.org/bot<TOKEN>/getUpdates- Buscar
"chat":{"id": XXXXXXXXX}en la respuesta
- Buscar
Los scrapers de redes sociales requieren una sesión autenticada. Para exportar las cookies:
- Instalar la extensión Cookie-Editor en Chrome/Firefox
- Iniciar sesión en X o Facebook en el navegador
- Abrir Cookie-Editor → "Export" → "Export as JSON"
- Guardar el contenido JSON como valor del Secret correspondiente
Si no configurás las cookies, los scrapers de RRSS se omiten silenciosamente sin detener el pipeline.
GitHub → tu repositorio → Settings → Secrets and variables → Actions → New repository secret
| Secret | Valor |
|---|---|
SUPABASE_URL |
https://XXXX.supabase.co |
SUPABASE_KEY |
service_role key |
GEMINI_API_KEY |
AIza... |
TELEGRAM_TOKEN |
token del bot |
TELEGRAM_CHAT_ID |
tu chat ID numérico |
X_COOKIES_JSON |
JSON de cookies de X (opcional) |
COOKIE_FB |
JSON de cookies de Facebook (opcional) |
share.streamlit.io → tu app → ⋮ → Settings → Secrets
SUPABASE_URL = "https://XXXX.supabase.co"
SUPABASE_KEY = "eyJ..." # service_role key
[cookie]
name = "monitor_coto"
key = "una_clave_secreta_larga" # cualquier string largo y aleatorio
expiry_days = 1
[credentials.usernames.ADMIN]
name = "ADMIN"
password = "$2b$12$..." # hash bcrypt de la contraseña
[credentials.usernames.OTRO_USUARIO]
name = "Nombre"
password = "$2b$12$..."Cómo generar el hash bcrypt de una contraseña:
import bcrypt
print(bcrypt.hashpw("tu_contraseña".encode(), bcrypt.gensalt(12)).decode())Para uso local, crear un archivo .env en la raíz del proyecto:
# Base de datos
SUPABASE_URL=https://XXXX.supabase.co
SUPABASE_KEY=eyJ...
# IA
GEMINI_API_KEY=AIza...
# Alertas (opcional en local)
TELEGRAM_TOKEN=
TELEGRAM_CHAT_ID=
# Redes sociales (opcional)
X_COOKIES_JSON=
COOKIE_FB=El archivo .github/workflows/daily_sync.yml define el pipeline automático.
Horario: todos los días a las 09:00 AM hora Argentina (12:00 UTC).
Disparo manual: GitHub → tu repositorio → Actions → "SAPIT · Daily ETL Pipeline" → "Run workflow".
Secuencia de pasos:
1. Checkout del repositorio
2. Notificación Telegram: inicio
3. Instalar Python 3.11 + dependencias
4. Instalar Playwright + Chromium (para scrapers que lo necesitan)
5. Ejecutar main.py (pipeline completo)
6. Leer pipeline_report.json y enviar reporte detallado por Telegram
Reporte de Telegram que llega al finalizar:
SAPIT — OK
Fecha: 14/04/2026 09:14 | Duracion: 187s
SCRAPERS (23 registros crudos):
[OK] infobae: 4
[OK] lanacion: 2
[OK] actualidadtdf: 8
[--] tiempofueguino: 0
...
LIMPIEZA: 18 nuevos (5 descartados)
CLASIFICACION: 18 OK / 0 fallidos
POS: 5 NEG: 3 NEU: 10
TOP TOPICOS:
- Soberania / Malvinas (4)
- Sesion ordinaria (3)
GUARDADOS EN DB: 18
Logs: https://github.com/...
El dashboard corre en Streamlit Cloud y requiere usuario y contraseña.
URL: configurada al deployar en share.streamlit.io (apunta al archivo dashboard.py).
↗ Tendencia — serie temporal de menciones desglosada por sentimiento (POS/NEG/NEU), con eventos anotados como líneas verticales. Incluye rangeslider y botones de navegación rápida (7d / 14d / 1m / Todo).
◫ Distribución — menciones por fuente (stacked bar), share of voice local vs. nacional (donut), distribución de sentimiento (donut), y ranking de los 10 tópicos más frecuentes.
◈ Eventos — análisis de impacto de cada evento: baseline (promedio 7 días previos), menciones el día del evento, eco en los 3 días siguientes, y uplift porcentual. Cada evento muestra su detalle expandido.
≡ Evidencia — tabla completa de registros con filtros por sentimiento, fuente y tópico. Exportación a CSV del filtro activo.
La barra superior aplica a todos los tabs simultáneamente:
- Desde / Hasta: rango de fechas
- Fuente: filtrar por medio específico o "Todas"
- Sentimiento: POS / NEG / NEU / Todos
El panel lateral incluye un formulario para agregar eventos directamente a la base de datos (sin tocar código). Los eventos nuevos aparecen en los gráficos en menos de 5 minutos (TTL del cache).
Los eventos se pueden eliminar con el botón 🗑️ al lado de cada uno en el Tab de Eventos.
Los datos se cachean 1 hora. Para forzar actualización inmediata: botón "🔄 Actualizar datos" en el sidebar.
| Columna | Tipo | Descripción |
|---|---|---|
doc_id |
TEXT PK | Hash MD5 de fuente+URL (12 chars) |
fuente |
TEXT | Nombre del medio (ej: infobae) |
plataforma |
TEXT | noticias o red_social |
url |
TEXT | URL original de la nota |
texto |
TEXT | Texto completo (titular + volanta + cuerpo) |
fecha |
DATE | Fecha de publicación |
platform_id |
TEXT | ID original en la plataforma (tweet_id, etc.) |
sentimiento |
TEXT | POS, NEG o NEU |
topico_corto |
TEXT | Etiqueta temática generada por Gemini |
creado_en |
TIMESTAMPTZ | Timestamp de inserción |
| Columna | Tipo | Descripción |
|---|---|---|
id |
SERIAL PK | ID autoincremental |
fecha |
DATE | Fecha del evento |
titulo |
TEXT | Título corto |
detalle |
TEXT | Descripción completa |
tipo |
TEXT | personal, legislativo o mediatico |
creado_en |
TIMESTAMPTZ | Timestamp de inserción |
| Columna | Tipo | Descripción |
|---|---|---|
id |
SERIAL PK | ID autoincremental |
modulo |
TEXT | Nombre del módulo (scraper_infobae, etc.) |
estado |
TEXT | EXITO o ERROR |
mensaje_error |
TEXT | Detalle del error si aplica |
registros_procesados |
INT | Cantidad de registros en esa ejecución |
duracion_segundos |
INT | Duración del paso |
creado_en |
TIMESTAMPTZ | Timestamp |
| Scraper | URL | Método | Paginación |
|---|---|---|---|
actualidadtdf |
actualidadtdf.com.ar | requests + BS4 | WordPress ?s=coto&page=N |
infofueguina |
infofueguina.com | requests + BS4 | /noticias/buscar/?buscar=coto&p=N |
minutofueguino |
minutofueguino.com.ar | requests + BS4 | /buscador/?q=coto&p=N (base 0) |
provincia23 |
provincia23.com.ar | requests + BS4 | WordPress ?s=coto&page=N |
tiempofueguino |
tiempofueguino.com | Playwright (scroll infinito) | Scroll automático |
red23noticias |
red23noticias.com.ar | requests + BS4 | Sin paginación (página única) |
| Scraper | URL | Método | Paginación |
|---|---|---|---|
infobae |
infobae.com/tag/agustin-coto | requests + BS4 | Sin paginación (tag page) |
lanacion |
lanacion.com.ar/buscador | Playwright | Botón "Siguiente" |
| Scraper | Plataforma | Requiere | Ventana |
|---|---|---|---|
scraper_x |
X (Twitter) | Cookies JSON | Últimas 24hs |
scraper_facebook |
Cookies JSON | Últimas 24hs |
Todos los scrapers de noticias aceptan fecha_desde y fecha_fin. Cuando la paginación llega a notas más antiguas que fecha_desde, se detiene automáticamente. Para rangos mayores a 30 días, el max_paginas se eleva automáticamente a 20.
Los scrapers de redes sociales no soportan filtro de fechas históricas — siempre capturan la ventana de las últimas 24hs en base a la sesión activa.
Para agregar un nuevo medio, el scraper debe seguir esta interfaz:
from datetime import date
def scraper_nuevo_medio(
fecha_desde: date | None = None,
fecha_fin: date | None = None,
) -> list[dict]:
"""
Devuelve una lista de diccionarios con este esquema exacto.
Todos los campos son obligatorios salvo volanta y seccion.
"""
return [
{
"url": "https://...",
"fuente": "nombre_del_medio", # snake_case, sin espacios
"plataforma": "noticias", # o "red_social"
"titular": "Título de la nota",
"volanta": None, # o string
"cuerpo": "Texto completo...",
"seccion": "politica",
"fecha_publicacion": "2026-04-14", # YYYY-MM-DD
}
]Luego agregarlo en main.py:
# En main.py — sección tareas
from scraper_nuevo_medio import scraper_nuevo_medio
tareas.append(("nuevo_medio", scraper_nuevo_medio))Y agregarlo a medios_locales en config.yaml si es un medio de TDF:
medios_locales:
- "nuevo_medio"El pipeline corre pero no llegan datos nuevos
Verificar en Supabase → Table Editor → monitoreo_politico que los registros se estén insertando. Si la tabla está vacía, revisar los logs en logs_ejecucion o en GitHub Actions → la ejecución más reciente.
Error 429 en Gemini
La API gratuita tiene límites de requests por minuto. El clasificador tiene fallback automático a modelos más livianos. Si persiste, reducir el batch_size a 5 en clasificador.py. Para verificar qué modelos están disponibles:
GEMINI_API_KEY=AIza... python check_gemini_models.pyEl dashboard no carga datos
- Verificar que
SUPABASE_URLySUPABASE_KEYestán en los Secrets de Streamlit Cloud (no confundir con los Secrets de GitHub — son diferentes). - Asegurarse de que la key sea la
service_role, no laanon. - Hacer Reboot de la app desde Streamlit Cloud después de cambiar Secrets.
El scraper de X o Facebook no funciona
Las cookies expiran. Hay que regenerarlas:
- Cerrar sesión y volver a iniciar sesión en el navegador
- Exportar las cookies con Cookie-Editor
- Actualizar el Secret correspondiente en GitHub
- Las cookies de X duran aproximadamente 30 días; las de Facebook, entre 7 y 90 días según la configuración de la cuenta.
st.secrets has no key "X"
El formato TOML de Streamlit es estricto. Las claves sueltas deben ir antes de cualquier sección [...]. Ver el ejemplo en la sección 5.8.
Playwright no encuentra Chromium en GitHub Actions
Asegurarse de que el workflow incluya:
- run: playwright install chromium
- run: playwright install-deps chromiumAmbos pasos son necesarios — el segundo instala las dependencias del sistema operativo.
google-genai # SDK de Gemini (clasificación IA)
supabase # cliente de base de datos
streamlit # dashboard web
streamlit-authenticator # autenticación por usuario/contraseña
playwright # scraping de páginas con JS (TiempoFueguino, LaNación)
beautifulsoup4 # parsing HTML
requests # HTTP
pandas # manipulación de datos
plotly # gráficos interactivos
python-dotenv # carga de .env en local
bcrypt # hashing de contraseñas
SAPIT · Sistema Automatizado de Procesamiento e Inteligencia de Tendencias