Compare commits

..

8 commits

Author SHA1 Message Date
SITO
d9ea78b8a7 fix: revision completa de rutas Docker, logica SQL y configuracion
Backend Go:
- backend/cmd/server/main.go: ruta wiki_images configurable via WIKI_IMAGES_PATH
- backend/cmd/wiki_worker/main.go: default /opt/rss2 en lugar de /app, leer env
- workers/ctranslator_worker.py: default CT2_MODEL_PATH /opt/rss2 en lugar de /app
- workers/llm_categorizer_worker.py: default LLM_MODEL_PATH /opt/rss2
- workers/{langdetect,simple_translator,translation_scheduler}.py: DB_HOST default 'localhost' en lugar de 'db' (hostname Docker)

SQL / esquema:
- poc/seed.sql: corregir logica de auto-traducciones ES (id LIKE md5() era incorrecto)
- init-db/06-tags.sql: eliminar columna wiki_checked duplicada

Documentacion y configuracion:
- docs/DEPLOY_DEBIAN.md: usar ct2-transformers-converter (lo que usa el worker real)
- deploy/debian/env.example: agregar WIKI_IMAGES_PATH
- deploy/debian/systemd/rss2-cluster.service: agregar HF_HOME faltante
- deploy/debian/install.sh: comparacion numerica correcta de version Go
- scripts/generate_secure_credentials.sh: ruta CT2_MODEL_PATH corregida
- frontend/nginx.conf: advertencia de que es configuracion Docker legacy
- docs/QUICKSTART_LLM.md: nota de deprecacion Docker
- README.md: renombrar backend-go a backend en diagrama

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 08:57:01 +02:00
SITO
10f0555c46 feat(poc): expandir POC con datos multilingues, admin pre-creado y guia completa
- poc/seed.sql: 17 noticias (ES/EN/FR) con traducciones y 25 entidades NER
- poc/poc.sh: corregir VITE_API_URL (faltaba sufijo /api), crear admin con bcrypt
- docs/POC_GUIDE.md: guia paso a paso para que el compañero explore la demo
- README.md: añadir credenciales admin y enlace a la guia POC

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:42:12 +02:00
SITO
b3bf3d7a7f refactor: reorganizar estructura de archivos en raiz
Antes la raiz tenia 20+ archivos sueltos. Ahora organizado en:

  docs/       10 archivos .md de documentacion tecnica
  scripts/    3 scripts utilitarios (credentials, migrate, verify)
  config/     entity_config.json (aliases y blacklist NER)
  data/       feeds.csv (feeds precargados)

Eliminados restos de Docker que ya no aplican:
  .dockerignore, .env.example, .env.secure.example, nginx.conf (raiz)

Makefile: eliminados targets docker-build, añadidos install/rebuild/check/poc

Referencias actualizadas en:
  deploy/debian/install.sh  entity_config.json -> config/entity_config.json
  deploy/debian/build.sh    entity_config.json -> config/entity_config.json
  README.md                 links a docs/ y data/ actualizados,
                            arbol de estructura del repo reescrito

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:29:50 +02:00
SITO
ec839b5b54 feat(debug): troubleshooting y diagnostico en scripts
poc/poc.sh:
- Verificacion de versiones: Go >=1.22, Node.js >=18 con mensaje de fix exacto
- Deteccion de puertos en uso antes de arrancar (API, frontend, Redis)
  con instruccion de quien ocupa el puerto
- show_log_tail(): muestra solo las lineas relevantes del log al fallar
- Compilacion Go: filtra lineas de error reales (error:/undefined:)
  en vez de volcar todo el log
- Backend no responde: sugiere probar DB y Redis individualmente con
  el comando exacto para diagnosticar
- Frontend: distingue error de npm install vs error de build TypeScript
- Flag --clean para borrar BD POC y empezar de cero
- Logs separados por componente en /tmp/coconews-poc/logs/

deploy/debian/check.sh (nuevo):
- Diagnostico completo del sistema post-instalacion
- Verifica 16 servicios systemd con estado y fix especifico por cada uno
- Prueba conectividad real: PostgreSQL, Redis (con auth), Qdrant HTTP, API Go, nginx
- Muestra metricas de BD: total noticias, traducciones hechas y pendientes
- Verifica binarios Go compilados y su tamano
- Verifica modelos ML: NLLB-200, spaCy es_core_news_lg, sentence-transformers, ctranslate2
- Comprueba disco (avisa si >75% o >90%), permisos de /opt/rss2 y .env
- Detecta si .env tiene valores por defecto sin cambiar
- Modo --quick para ver solo estado arriba/abajo rapidamente
- Resumen final con conteo de errores y advertencias, exit code 1 si hay errores

deploy/debian/prerequisites.sh:
- Comprobacion de espacio libre en disco al inicio (avisa si <10 GB)
- apt-get update con log de error y sugerencias de fix
- Seccion de troubleshooting en el resumen final con fixes comunes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:04:22 +02:00
SITO
e3c682a36f feat: prerequisites, POC local y README reescrito
deploy/debian/prerequisites.sh:
- Instalador de dependencias del sistema para Debian/Ubuntu
- Detecta OS, instala PostgreSQL 16 (repo oficial), Redis, nginx,
  Go 1.25, Node.js 20 LTS, Qdrant binario, Python venv
- Crea usuario rss2 y estructura /opt/rss2
- Pregunta interactivamente si instalar modelos ML pesados
  (ctranslate2, transformers, spaCy es_core_news_lg, NLLB-200)
- Separado de install.sh para poder ejecutarlo independientemente

poc/poc.sh:
- POC local en ~2 minutos sin Docker, sin workers ML
- Crea BD temporal coconews_poc con schema completo
- Carga 10 noticias de muestra en español listas para ver
- Compila backend Go y frontend React en /tmp/coconews-poc
- Lanza Redis en puerto alternativo (6380) sin interferir
- Sirve frontend con npx serve en http://127.0.0.1:18001
- Limpieza automatica al Ctrl+C

poc/seed.sql:
- 10 noticias de muestra en español (no requieren traduccion)
- Categorias, continentes y paises basicos
- 5 feeds de ejemplo (El Pais, BBC Mundo, etc.)

README.md:
- Reescrito completamente sin referencias Docker
- Diagrama ASCII de arquitectura
- Inicio rapido con poc.sh (2 minutos)
- Instrucciones de install en Debian con prerequisites.sh
- Tabla de requisitos hardware por modo
- Mapa completo del repositorio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:17:11 +02:00
SITO
ab3b0b53c5 fix(deploy): corregir 5 problemas bloqueantes para despliegue Debian
- install.sh/build.sh: actualizar Go 1.23 → 1.25 (requerido por rss-ingestor-go)
- install.sh/build.sh: nombrar binario qdrant como qdrant_worker para
  coincidir con rss2-qdrant-worker.service (ExecStart)
- install.sh/build.sh: GOTOOLCHAIN=local en ingestor para evitar
  descarga automatica de toolchain Go superior
- rss2-backend.service: sobreescribir hostnames Docker (libretranslate,
  ollama, spacy) por 127.0.0.1 para despliegue nativo
- env.example: agregar TRANSLATION_URL, OLLAMA_URL, SPACY_URL con nota
  explicativa sobre uso en endpoints admin
- DEPLOY_DEBIAN.md: corregir comando conversion NLLB-200 a CTranslate2
  usando OpusMTConverter Python API en lugar de CLI incorrecto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:11:56 +02:00
SITO
00c0254e6c docs: guia de despliegue nativo Debian sin Docker
Documentacion completa para instalar COCONEWS en Debian/Ubuntu:
- Requisitos hardware por modo (CPU/GPU)
- Tabla de servicios y como se gestionan
- Instalacion paso a paso incluyendo conversion del modelo NLLB-200
- Gestion de servicios systemd y logs
- Estructura de directorios en servidor
- Reglas de firewall recomendadas
- Procedimiento de backup
- Solucion de problemas frecuentes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:02:27 +02:00
SITO
10d51c3c52 feat(deploy): despliegue nativo Debian sin Docker
- Elimina todos los Dockerfiles y docker-compose.yml
- Elimina scripts Docker (start_docker, reset_and_deploy, deploy-clean)
- Agrega deploy/debian/ con despliegue nativo via systemd:
  - install.sh: instalacion completa en Debian (PostgreSQL, Redis,
    Qdrant binario, Go, Python venv, nginx, frontend compilado)
  - build.sh: recompila binarios Go y frontend sin reinstalar
  - env.example: variables de entorno sin referencias Docker
  - nginx.conf: sirve React estatico + proxy al API Go en localhost
  - systemd/*.service: 16 servicios (8 Go + 7 Python + Qdrant)

Todos los hostnames Docker (db, redis, qdrant) reemplazados por 127.0.0.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:49:30 +02:00
173 changed files with 4376 additions and 21789 deletions

View file

@ -1,39 +0,0 @@
#!/bin/bash
# Hook para docker compose - Se ejecuta automáticamente
# Copia este archivo al inicio de tu .bashrc o .bash_profile
# Función de validación automática
validate_credentials() {
# Verificar .env
if [ ! -f .env ]; then
echo "🔑 Generando credenciales..."
./generate_secure_credentials.sh --force
return $?
fi
# Verificar contraseñas vacías
for var in POSTGRES_PASSWORD REDIS_PASSWORD DB_PASS; do
val=$(grep "^${var}=" .env | cut -d'=' -f2 | tr -d ' ')
if [ -z "$val" ] || [ "$val" = "change_*" ]; then
echo "🔑 Generando credenciales para $var..."
./generate_secure_credentials.sh --force
return $?
fi
done
}
# Alias para docker compose con validación automática
alias docker="docker"
alias "docker compose"="docker compose --env-file .env"
# Función helper: docker compose up con validación
docker_compose_up() {
echo "🔐 Validando credenciales..."
validate_credentials
echo "🚀 Iniciando servicios..."
docker compose up -d
}
# Exportar alias
alias docker_compose_up="docker_compose_up"

View file

@ -1,37 +0,0 @@
#!/bin/bash
# Validador automático de credenciales para docker compose
# Se ejecuta automáticamente antes de iniciar los servicios
set -e
# Colores
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}🔐 Validando credenciales antes de iniciar...${NC}"
# Verificar archivo .env
if [ ! -f .env ]; then
echo -e "${RED}❌ ERROR: Archivo .env no encontrado${NC}"
echo -e "${YELLOW}💡 Generando credenciales...${NC}"
./generate_secure_credentials.sh --force
exit $?
fi
# Verificar credenciales CRÍTICAS
for var in POSTGRES_PASSWORD REDIS_PASSWORD DB_PASS; do
value=$(grep "^${var}=" .env 2>/dev/null | cut -d'=' -f2 | tr -d ' ')
if [ -z "$value" ] || [ "$value" = "change_this_to_a_long_random_string" ] || [ "$value" = "change_this_password" ]; then
echo -e "${RED}❌ ERROR: $var está vacía o tiene valor por defecto${NC}"
echo -e "${YELLOW}💡 Generando credenciales seguras...${NC}"
./generate_secure_credentials.sh --force
exit $?
fi
done
echo -e "${GREEN}✅ Todas las credenciales están definidas${NC}"
exit 0

View file

@ -1,13 +0,0 @@
.git
pgdata
pgdata-replica
pgdata-replica.old.*
pgdata.failed_restore
redis-data
hf_cache
qdrant_storage
venv
__pycache__
*.pyc
*.log

View file

@ -1,5 +0,0 @@
POSTGRES_PASSWORD=test_pass_123456
REDIS_PASSWORD=test_redis_123456
DB_PASS=test_pass_123456
SECRET_KEY=test_secret_key
GRAFANA_PASSWORD=test_grafana_123456

View file

@ -1,5 +0,0 @@
POSTGRES_PASSWORD=test_pass_123456
REDIS_PASSWORD=test_redis_123456
DB_PASS=test_pass_123456
SECRET_KEY=test_secret_key
GRAFANA_PASSWORD=test_grafana_123456

View file

@ -1,92 +0,0 @@
# ==================================================================================
# CONFIGURACIÓN SEGURA - Generado automáticamente
# Fecha: 2026-04-03 23:21:15
# ==================================================================================
#
# IMPORTANTE:
# - NO compartas este archivo
# - Guarda las credenciales en un gestor de contraseñas
# - Añade .env al .gitignore
#
# ==================================================================================
# ==================================================================================
# DATABASE CONFIGURATION - PostgreSQL
# ==================================================================================
POSTGRES_DB=rss
POSTGRES_USER=rss
POSTGRES_PASSWORD=npti6eFCYsAv71LcJKasnpEAhtHgoD4a
DB_NAME=rss
DB_USER=rss
DB_PASS=npti6eFCYsAv71LcJKasnpEAhtHgoD4a
DB_HOST=db
DB_PORT=5432
DB_WRITE_HOST=db
DB_READ_HOST=db-replica
# ==================================================================================
# REDIS CONFIGURATION - Con autenticación
# ==================================================================================
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=pmBJtSBMiYGptknv5pXa163kUR946V25
# ==================================================================================
# APPLICATION SECRETS
# ==================================================================================
SECRET_KEY=4a60ebda58f0716b846de1758e63e209e33070d0dbf8ac3c4ecd0c216e6bb4aa
# ==================================================================================
# MONITORING - Grafana
# ==================================================================================
GRAFANA_PASSWORD=oTkxxwKCNB25r0unvtNi8AQt
# ==================================================================================
# EXTERNAL SERVICES
# ==================================================================================
ALLTALK_URL=http://host.docker.internal:7851
# ==================================================================================
# AI MODELS & WORKERS
# ==================================================================================
RSS_MAX_WORKERS=3
TARGET_LANGS=es
TRANSLATOR_BATCH=128
ENQUEUE=300
# RSS Ingestor Configuration
RSS_POKE_INTERVAL_MIN=15
RSS_MAX_FAILURES=10
RSS_FEED_TIMEOUT=60
# URL Feed Discovery Worker
URL_DISCOVERY_INTERVAL_MIN=15
URL_DISCOVERY_BATCH_SIZE=10
MAX_FEEDS_PER_URL=5
# CTranslate2 / AI Model Paths
CT2_MODEL_PATH=/app/models/nllb-ct2
CT2_DEVICE=cuda
CT2_COMPUTE_TYPE=int8_float16
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
# Embeddings
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH=64
EMB_DEVICE=cuda
# NER
NER_LANG=es
NER_BATCH=64
# Flask / Gunicorn
GUNICORN_WORKERS=8
FLASK_DEBUG=0
# Qdrant Configuration
QDRANT_HOST=qdrant
QDRANT_PORT=6333
QDRANT_COLLECTION_NAME=news_vectors
QDRANT_BATCH_SIZE=100
QDRANT_SLEEP_IDLE=30

View file

@ -1,92 +0,0 @@
# ==================================================================================
# CONFIGURACIÓN SEGURA - Generado automáticamente
# Fecha: 2026-04-03 23:21:15
# ==================================================================================
#
# IMPORTANTE:
# - NO compartas este archivo
# - Guarda las credenciales en un gestor de contraseñas
# - Añade .env al .gitignore
#
# ==================================================================================
# ==================================================================================
# DATABASE CONFIGURATION - PostgreSQL
# ==================================================================================
POSTGRES_DB=rss
POSTGRES_USER=rss
POSTGRES_PASSWORD=npti6eFCYsAv71LcJKasnpEAhtHgoD4a
DB_NAME=rss
DB_USER=rss
DB_PASS=npti6eFCYsAv71LcJKasnpEAhtHgoD4a
DB_HOST=db
DB_PORT=5432
DB_WRITE_HOST=db
DB_READ_HOST=db-replica
# ==================================================================================
# REDIS CONFIGURATION - Con autenticación
# ==================================================================================
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=pmBJtSBMiYGptknv5pXa163kUR946V25
# ==================================================================================
# APPLICATION SECRETS
# ==================================================================================
SECRET_KEY=4a60ebda58f0716b846de1758e63e209e33070d0dbf8ac3c4ecd0c216e6bb4aa
# ==================================================================================
# MONITORING - Grafana
# ==================================================================================
GRAFANA_PASSWORD=oTkxxwKCNB25r0unvtNi8AQt
# ==================================================================================
# EXTERNAL SERVICES
# ==================================================================================
ALLTALK_URL=http://host.docker.internal:7851
# ==================================================================================
# AI MODELS & WORKERS
# ==================================================================================
RSS_MAX_WORKERS=3
TARGET_LANGS=es
TRANSLATOR_BATCH=128
ENQUEUE=300
# RSS Ingestor Configuration
RSS_POKE_INTERVAL_MIN=15
RSS_MAX_FAILURES=10
RSS_FEED_TIMEOUT=60
# URL Feed Discovery Worker
URL_DISCOVERY_INTERVAL_MIN=15
URL_DISCOVERY_BATCH_SIZE=10
MAX_FEEDS_PER_URL=5
# CTranslate2 / AI Model Paths
CT2_MODEL_PATH=/app/models/nllb-ct2
CT2_DEVICE=cuda
CT2_COMPUTE_TYPE=int8_float16
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
# Embeddings
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH=64
EMB_DEVICE=cuda
# NER
NER_LANG=es
NER_BATCH=64
# Flask / Gunicorn
GUNICORN_WORKERS=8
FLASK_DEBUG=0
# Qdrant Configuration
QDRANT_HOST=qdrant
QDRANT_PORT=6333
QDRANT_COLLECTION_NAME=news_vectors
QDRANT_BATCH_SIZE=100
QDRANT_SLEEP_IDLE=30

View file

@ -1,92 +0,0 @@
# ==================================================================================
# CONFIGURACIÓN SEGURA - Generado automáticamente
# Fecha: 2026-04-03 23:30:04
# ==================================================================================
#
# IMPORTANTE:
# - NO compartas este archivo
# - Guarda las credenciales en un gestor de contraseñas
# - Añade .env al .gitignore
#
# ==================================================================================
# ==================================================================================
# DATABASE CONFIGURATION - PostgreSQL
# ==================================================================================
POSTGRES_DB=rss
POSTGRES_USER=rss
POSTGRES_PASSWORD=SI84OnqRT40FtdAeQro3eSJULKzFJ06F
DB_NAME=rss
DB_USER=rss
DB_PASS=SI84OnqRT40FtdAeQro3eSJULKzFJ06F
DB_HOST=db
DB_PORT=5432
DB_WRITE_HOST=db
DB_READ_HOST=db-replica
# ==================================================================================
# REDIS CONFIGURATION - Con autenticación
# ==================================================================================
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=MvdWZlO1YCZ3QxE761bbvEd2qx44dO9j
# ==================================================================================
# APPLICATION SECRETS
# ==================================================================================
SECRET_KEY=68d5b0297d44e88f5ab83720e0bdf8eeef2ceeccd72b8e46b7f1b47531571780
# ==================================================================================
# MONITORING - Grafana
# ==================================================================================
GRAFANA_PASSWORD=BVWprtQiW2KDmaDR8zZ2Br83
# ==================================================================================
# EXTERNAL SERVICES
# ==================================================================================
ALLTALK_URL=http://host.docker.internal:7851
# ==================================================================================
# AI MODELS & WORKERS
# ==================================================================================
RSS_MAX_WORKERS=3
TARGET_LANGS=es
TRANSLATOR_BATCH=128
ENQUEUE=300
# RSS Ingestor Configuration
RSS_POKE_INTERVAL_MIN=15
RSS_MAX_FAILURES=10
RSS_FEED_TIMEOUT=60
# URL Feed Discovery Worker
URL_DISCOVERY_INTERVAL_MIN=15
URL_DISCOVERY_BATCH_SIZE=10
MAX_FEEDS_PER_URL=5
# CTranslate2 / AI Model Paths
CT2_MODEL_PATH=/app/models/nllb-ct2
CT2_DEVICE=cuda
CT2_COMPUTE_TYPE=int8_float16
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
# Embeddings
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH=64
EMB_DEVICE=cuda
# NER
NER_LANG=es
NER_BATCH=64
# Flask / Gunicorn
GUNICORN_WORKERS=8
FLASK_DEBUG=0
# Qdrant Configuration
QDRANT_HOST=qdrant
QDRANT_PORT=6333
QDRANT_COLLECTION_NAME=news_vectors
QDRANT_BATCH_SIZE=100
QDRANT_SLEEP_IDLE=30

View file

@ -1,92 +0,0 @@
# ==================================================================================
# CONFIGURACIÓN SEGURA - Generado automáticamente
# Fecha: 2026-04-03 23:31:40
# ==================================================================================
#
# IMPORTANTE:
# - NO compartas este archivo
# - Guarda las credenciales en un gestor de contraseñas
# - Añade .env al .gitignore
#
# ==================================================================================
# ==================================================================================
# DATABASE CONFIGURATION - PostgreSQL
# ==================================================================================
POSTGRES_DB=rss
POSTGRES_USER=rss
POSTGRES_PASSWORD=S5xwsI9HKjdaBWLpCkqp0mA0DJYZerpD
DB_NAME=rss
DB_USER=rss
DB_PASS=S5xwsI9HKjdaBWLpCkqp0mA0DJYZerpD
DB_HOST=db
DB_PORT=5432
DB_WRITE_HOST=db
DB_READ_HOST=db-replica
# ==================================================================================
# REDIS CONFIGURATION - Con autenticación
# ==================================================================================
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=cVF79QuJulPO5auEhA2nNqv7mqAruzYh
# ==================================================================================
# APPLICATION SECRETS
# ==================================================================================
SECRET_KEY=2eb7c221245bb8d896f83255188de614e59fae6b8dfbf16eee8a23c60dde4ffa
# ==================================================================================
# MONITORING - Grafana
# ==================================================================================
GRAFANA_PASSWORD=R4J61V6OGLTCesrITwEvUPTm
# ==================================================================================
# EXTERNAL SERVICES
# ==================================================================================
ALLTALK_URL=http://host.docker.internal:7851
# ==================================================================================
# AI MODELS & WORKERS
# ==================================================================================
RSS_MAX_WORKERS=3
TARGET_LANGS=es
TRANSLATOR_BATCH=128
ENQUEUE=300
# RSS Ingestor Configuration
RSS_POKE_INTERVAL_MIN=15
RSS_MAX_FAILURES=10
RSS_FEED_TIMEOUT=60
# URL Feed Discovery Worker
URL_DISCOVERY_INTERVAL_MIN=15
URL_DISCOVERY_BATCH_SIZE=10
MAX_FEEDS_PER_URL=5
# CTranslate2 / AI Model Paths
CT2_MODEL_PATH=/app/models/nllb-ct2
CT2_DEVICE=cuda
CT2_COMPUTE_TYPE=int8_float16
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
# Embeddings
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH=64
EMB_DEVICE=cuda
# NER
NER_LANG=es
NER_BATCH=64
# Flask / Gunicorn
GUNICORN_WORKERS=8
FLASK_DEBUG=0
# Qdrant Configuration
QDRANT_HOST=qdrant
QDRANT_PORT=6333
QDRANT_COLLECTION_NAME=news_vectors
QDRANT_BATCH_SIZE=100
QDRANT_SLEEP_IDLE=30

View file

@ -1,67 +0,0 @@
# Database Configuration
POSTGRES_DB=rss
POSTGRES_USER=rss
POSTGRES_PASSWORD=change_this_password
DB_NAME=rss
DB_USER=rss
DB_PASS=change_this_password
DB_HOST=db
DB_PORT=5432
DB_WRITE_HOST=db
DB_READ_HOST=db-replica
# Redis Configuration
REDIS_HOST=redis
REDIS_PORT=6379
# Application Secrets
SECRET_KEY=change_this_to_a_long_random_string
# External Services
ALLTALK_URL=http://host.docker.internal:7851
# AI Models & Workers
RSS_MAX_WORKERS=3
# Translation Pipeline
TARGET_LANGS=es
TRANSLATOR_BATCH=16
SCHEDULER_BATCH=2000
SCHEDULER_SLEEP=30
LANG_DETECT_BATCH=1000
LANG_DETECT_SLEEP=60
# RSS Ingestor Configuration
RSS_POKE_INTERVAL_MIN=15
RSS_MAX_FAILURES=10
RSS_FEED_TIMEOUT=60
# URL Feed Discovery Worker
URL_DISCOVERY_INTERVAL_MIN=15
URL_DISCOVERY_BATCH_SIZE=10
MAX_FEEDS_PER_URL=5
# CTranslate2 / AI Model Paths
CT2_MODEL_PATH=/app/models/nllb-ct2
CT2_DEVICE=cuda
CT2_COMPUTE_TYPE=int8_float16
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
# Embeddings
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH=64
EMB_DEVICE=cuda
# NER
NER_LANG=es
NER_BATCH=64
# Flask / Gunicorn
GUNICORN_WORKERS=8
FLASK_DEBUG=0
# Qdrant Configuration
QDRANT_HOST=qdrant
QDRANT_PORT=6333
QDRANT_COLLECTION_NAME=news_vectors
QDRANT_BATCH_SIZE=100
QDRANT_SLEEP_IDLE=30

View file

@ -1,117 +0,0 @@
# ==================================================================================
# SEGURIDAD: CONFIGURACIÓN DE PRODUCCIÓN
# ==================================================================================
#
# IMPORTANTE:
# 1. Copia este archivo a .env
# 2. Cambia TODOS los valores de contraseñas y secrets
# 3. NO compartas este archivo en repositorios públicos
# 4. Añade .env al .gitignore
#
# ==================================================================================
# ==================================================================================
# DATABASE CONFIGURATION - PostgreSQL
# ==================================================================================
POSTGRES_DB=rss
POSTGRES_USER=rss
# CRÍTICO: Genera una contraseña fuerte (mínimo 32 caracteres aleatorios)
# Ejemplo para generar: openssl rand -base64 32
POSTGRES_PASSWORD=CAMBIAR_ESTO_POR_UNA_CONTRASEÑA_FUERTE_DE_32_CARACTERES
DB_NAME=rss
DB_USER=rss
DB_PASS=CAMBIAR_ESTO_POR_UNA_CONTRASEÑA_FUERTE_DE_32_CARACTERES
DB_HOST=db
DB_PORT=5432
DB_WRITE_HOST=db
DB_READ_HOST=db-replica
# ==================================================================================
# REDIS CONFIGURATION - Autenticación habilitada
# ==================================================================================
REDIS_HOST=redis
REDIS_PORT=6379
# CRÍTICO: Genera una contraseña fuerte para Redis
# Ejemplo: openssl rand -base64 32
REDIS_PASSWORD=CAMBIAR_ESTO_POR_UNA_CONTRASEÑA_FUERTE_REDIS
# ==================================================================================
# APPLICATION SECRETS
# ==================================================================================
# CRÍTICO: Secret key para Flask - debe ser único y secreto
# Genera con: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=CAMBIAR_ESTO_POR_UN_TOKEN_HEX_DE_64_CARACTERES
# ==================================================================================
# MONITORING - Grafana
# ==================================================================================
# IMPORTANTE: Cambia el password de admin de Grafana
GRAFANA_PASSWORD=CAMBIAR_ESTO_POR_UNA_CONTRASEÑA_FUERTE_GRAFANA
# ==================================================================================
# EXTERNAL SERVICES
# ==================================================================================
ALLTALK_URL=http://host.docker.internal:7851
# ==================================================================================
# AI MODELS & WORKERS
# ==================================================================================
RSS_MAX_WORKERS=3
TARGET_LANGS=es
TRANSLATOR_BATCH=128
ENQUEUE=300
# RSS Ingestor Configuration
RSS_POKE_INTERVAL_MIN=15
RSS_MAX_FAILURES=10
RSS_FEED_TIMEOUT=60
# URL Feed Discovery Worker
URL_DISCOVERY_INTERVAL_MIN=15
URL_DISCOVERY_BATCH_SIZE=10
MAX_FEEDS_PER_URL=5
# CTranslate2 / AI Model Paths
CT2_MODEL_PATH=/app/models/nllb-ct2
CT2_DEVICE=cuda
CT2_COMPUTE_TYPE=int8_float16
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
# Embeddings
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH=64
EMB_DEVICE=cuda
# NER
NER_LANG=es
NER_BATCH=64
# Flask / Gunicorn
GUNICORN_WORKERS=8
FLASK_DEBUG=0
# Qdrant Configuration
QDRANT_HOST=qdrant
QDRANT_PORT=6333
QDRANT_COLLECTION_NAME=news_vectors
QDRANT_BATCH_SIZE=100
QDRANT_SLEEP_IDLE=30
# ==================================================================================
# COMANDOS ÚTILES PARA GENERAR CONTRASEÑAS SEGURAS
# ==================================================================================
#
# PostgreSQL Password (32 caracteres):
# openssl rand -base64 32
#
# Redis Password (32 caracteres):
# openssl rand -base64 32
#
# Flask Secret Key (64 hex chars):
# python -c "import secrets; print(secrets.token_hex(32))"
#
# Grafana Password (fuerte):
# openssl rand -base64 24
#
# ==================================================================================

140
.gitignore vendored
View file

@ -1,120 +1,80 @@
# ==================================================================================
# Python
# ==================================================================================
__pycache__/ __pycache__/
*.py[cod] *.pyc
*$py.class *.pyo
*.so *.pyd
.Python .Python
build/ *.so
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg *.egg
*.egg-info/
# Virtual environments dist/
build/
venv/ venv/
env/ .env
ENV/
.venv
# ==================================================================================
# IDEs
# ==================================================================================
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
*~ *~
.DS_Store .DS_Store
Thumbs.db
# ==================================================================================
# SECURITY - NEVER COMMIT
# ==================================================================================
.env
.env.backup
.env.backup2
.env.generated
.env.local
.env.*.local
# Credentials
*.pem
*.key
*.crt
credentials.json
service-account.json
# ==================================================================================
# RSS2 Data & Models (downloaded at runtime)
# ==================================================================================
# ML Models - downloaded/converted by workers
models/
models/nllb-ct2/
models/nllb-ct2-1.3b/
# Wikipedia images - downloaded by wiki_worker
data/wiki_images/
data/backups/
backups/
# Database storage (PostgreSQL)
data/pgdata/
pgdata/ pgdata/
pgdata-replica/ pgdata-replica/
pgdata.failed_restore/ pgdata.failed_restore/
pgdata-replica.old.*/ pgdata-replica.old.*/
# Redis data
data/redis-data/
redis-data/ redis-data/
# Qdrant vector storage
data/qdrant_storage/
qdrant_storage/
# HuggingFace cache (downloaded models)
hf_cache/ hf_cache/
models/nllb-ct2/
qdrant_storage/
*.log
*.db
*.sqlite
*.sqlite3
data/
*.mp4
*.mp3
*.wav
*.srt
*.tar.gz
*.zip
*.bak
*.old
# Monitoring data
monitoring/data/
# ================================================================================== # ==================================================================================
# Backup files # SECURITY FILES - NEVER COMMIT THESE
# ================================================================================== # ==================================================================================
# Environment files with credentials
.env
.env.backup*
.env.generated
.env.local
.env.*.local
# Database backups
*.sql *.sql
!init-db/*.sql !init-db/*.sql
!migrations/*.sql !migrations/*.sql
backup_*.sql backup_*.sql
# Redis backups
*.rdb *.rdb
redis_backup_*.rdb redis_backup_*.rdb
# Qdrant backups
qdrant_backup_*.tar.gz qdrant_backup_*.tar.gz
# ================================================================================== # Docker compose with real credentials (if you create variations)
# Temporary files
# ==================================================================================
tmp/
temp/
*.tmp
*.log
# ==================================================================================
# Docker
# ==================================================================================
docker-compose.override.yml docker-compose.override.yml
# ================================================================================== # Large Language Models
# Keep directory structure (optional - uncomment if needed) models/llm/
# models/.gitkeep
# data/.gitkeep # User Uploads
static/uploads/
# Celery/Workers
celerybeat-schedule
celerybeat.pid
# System/IDE
.DS_Store
Thumbs.db

110
AGENT.md
View file

@ -1,110 +0,0 @@
# RSS2 - Agente de Mejora de Software
## Visión General del Proyecto
Aplicación de agregación y procesamiento de noticias RSS con capacidades de IA.
### Stack Tecnológico
| Capa | Tecnología |
|------|-------------|
| Frontend | React 18 + TypeScript + Vite + TailwindCSS + React Query + React Router |
| Backend API | Go 1.25 + Gin |
| Workers Backend | Go 1.25, Python 3.x |
| Base de datos | PostgreSQL 18 |
| Caché | Redis 7 |
| Búsqueda vectorial | Qdrant |
| IA/LLM | Ollama, HuggingFace (NLLB, BERT, sentence-transformers) |
| Orquestación | Docker Compose |
| Monitoreo | Prometheus + Grafana + cAdvisor |
### Arquitectura de Workers
- **rss-ingestor-go**: Ingestión de feeds RSS
- **scraper**: Extracción de artículos de URLs
- **discovery**: Descubrimiento de nuevos feeds
- **topics**: Matching de temas y países
- **related**: Noticias relacionadas
- **langdetect**: Detección de idioma
- **translator**: Traducción con NLLB (CPU/GPU)
- **embeddings**: Generación de vectores semánticos
- **ner**: Extracción de entidades nombradas
- **cluster**: Agrupación de noticias
- **llm-categorizer**: Categorización con Ollama
- **wiki-worker**: Wikipedia info y thumbnails
---
## Recomendaciones de Mejora
### 1. Infraestructura y DevOps
- **Añadir CI/CD**: Pipeline con GitHub Actions para testing y deploy automático
- **Health checks**: Implementar health checks en todos los workers (no solo en db/redis)
- **Logging centralizado**: Integrar Loki o ELK stack para agregación de logs
- **Variables de entorno**: Mover configuración hardcodeada (ej: `translator_type: cpu`) a variables与环境
### 2. Backend Go
- **Estructura**: El proyecto no tiene estructura clara de packages (cmd/ internal/)
- **Testing**: No se observan tests unitarios en el backend
- **Config**: Usar Viper o library dedicada para configuración
- **Graceful shutdown**: Implementar en todos los workers
- **Migraciones**: Usar herramientas como golang-migrate en lugar de crear tablas en initDB()
### 3. Frontend
- **E2E Testing**: Añadir Playwright o Cypress
- **State Management**: Considerar Zustand o Jotai para estado global
- **Componentes**: Crear library de componentes reutilizables
- **i18n**: Preparar para multiidioma
- **PWA**: Añadir Service Worker para offline
### 4. Base de Datos
- **ORM/Query Builder**: Considerar GORM o sqlc para mejor mantenimiento
- **Índices**: Verificar que todos los campos de búsqueda tengan índices
- **Migrations**: Sistema formal de migraciones
- **Connection Pooling**: Configurar apropiadamente
### 5. Workers y Procesamiento
- **Colas**: Implementar RabbitMQ o Redis Streams para jobs asíncronos
- **Retry Logic**: Sistema robusto de reintentos con backoff exponencial
- **Dead Letter Queue**: Manejo de trabajos fallidos
- **Métricas por worker**: Prometheus metrics específicas por worker
- **Rate Limiting**: Protecciones contra rate limits de APIs externas
### 6. Seguridad
- **Rate limiting API**: Implementar en endpoints sensibles
- **Input Validation**: Usar biblioteca como go-playground/validator
- **CSRF Protection**: Añadir tokens CSRF
- **Security Headers**: Implementar todos los headers de seguridad
- **Audit Logs**: Logging de acciones administrativas
### 7. Rendimiento
- **Caching**: Cachear más endpoints con Redis (ej: estadísticas)
- **Pagination**: Implementar cursor-based pagination para grandes datasets
- **Database Connection Pooling**: Configurar límites apropiados
- **Image Optimization**: Comprimir imágenes antes de servir
### 8. Documentación
- **API Docs**: OpenAPI/Swagger para la REST API
- **Arquitecture Decision Records (ADRs)**: Documentar decisiones técnicas
- **Runbooks**: Documentación de operaciones
### 9. Monitorización y Alertas
- **Alertas**: Configurar alertas en Grafana para workers caídos
- **Dashboards**: Dashboard específico por worker
- **Tracing**: Añadir OpenTelemetry para distributed tracing
### 10. User Experience
- **Dark Mode**: Soporte completo
- **Keyboard Shortcuts**: Mejorar navegación
- **Infinite Scroll**: Para feeds grandes
- **Real-time Updates**: WebSockets para nuevas noticias

408
AGENTS.md
View file

@ -1,408 +0,0 @@
# AGENTS.md - RSS2 Development Guide
## 🛠️ Build & Test Commands
### Backend (Go)
```bash
cd backend && go mod tidy
cd backend && go build -o ../bin/server ./cmd/server
cd rss-ingestor-go && go build -o ../bin/rss-ingestor .
# Single test
cd backend && go test ./internal/handlers -v -run TestLogin
cd backend && go test ./internal/auth -v -run TestGenerateToken
```
### Frontend
```bash
cd frontend && npm install
cd frontend && npm run dev # Development
cd frontend && npm run build # Production build
cd frontend && npm test # Run tests
cd frontend && npm run test:ui # UI mode
```
### Makefile
```bash
make build # Build all binaries
make clean # Remove binaries
make docker-build # Build Docker images
```
---
## 📁 Project Structure
```
rss2/
├── backend/ # Go API server + Workers
│ ├── cmd/
│ │ ├── server/main.go # API REST principal (Gin)
│ │ ├── wiki_worker/main.go # Wikipedia integration
│ │ ├── qdrant/main.go # Vector indexing worker
│ │ ├── related/main.go # Related news worker
│ │ ├── topics/main.go # Country/topic matcher
│ │ ├── scraper/main.go # Deep scraping worker
│ │ ├── discovery/main.go # RSS feed discovery
│ │ └── topics/main.go # Topic matching
│ └── internal/
│ ├── handlers/ # HTTP endpoints
│ ├── models/ # Data models
│ ├── auth/ # JWT authentication
│ ├── middleware/ # CORS, Auth middleware
│ ├── services/ # ML services (Translate, Embeddings, NER, Semantic Search)
│ ├── db/ # PostgreSQL connection
│ ├── cache/ # Redis connection
│ └── config/ # Configuration loading
├── frontend/ # React + TypeScript + Vite
│ └── src/
│ ├── pages/ # Home, News, Search, Admin, Feeds, Stats
│ ├── components/ # Layout, UI components
│ └── services/ # API client
├── workers/ # Python workers
│ ├── ctranslator_worker.py # NLLB-200 translation (CTranslate2)
│ ├── ner_worker.py # Spacy NER + Topic extraction
│ ├── embeddings_worker.py # Sentence transformers embeddings
│ ├── cluster_worker.py # News clustering
│ ├── langdetect_worker.py # Language detection
│ ├── llm_categorizer_worker.py # Ollama LLM categorization
│ ├── simple_categorizer_worker.py
│ ├── simple_translator.py
│ ├── simple_translator_worker.py
│ ├── translation_worker.py
│ ├── translation_scheduler.py
│ └── remote_translator_worker.py # Remote GPU worker via WebSocket
├── data/ # PostgreSQL, Redis, Qdrant data
├── models/ # ML models (nllb-ct2)
├── hf_cache/ # HuggingFace cache
├── init-db/ # SQL migrations
├── monitoring/ # Prometheus + Grafana config
├── rss-ingestor-go/ # RSS crawler (Go)
└── docker-compose.yml # Full stack orchestration
```
---
## 🚀 Servicios del Sistema
### Capa de Acceso y API (Puerto 8888)
| Servicio | Tecnología | Descripción |
|---------|------------|-------------|
| **nginx** | Nginx Alpine | Gateway y Proxy Inverso |
| **rss2_frontend** | React + Vite | Interfaz web responsiva |
| **backend-go** | Go + Gin | API REST principal |
### Ingesta y Descubrimiento (Go)
| Servicio | Tecnología | Descripción |
|---------|------------|-------------|
| **rss-ingestor-go** | Go | Crawler RSS de alto rendimiento |
| **scraper** | Go | Scraper profundo con sanitización HTML |
| **discovery** | Go | Agente de descubrimiento de feeds RSS |
### Procesamiento de Datos e IA
| Servicio | Tecnología | Descripción |
|---------|------------|-------------|
| **translator** | NLLB-200 (CPU) | Traducción neuronal CTranslate2 |
| **translator-gpu** | NLLB-200 (GPU) | Traducción acelerada CUDA |
| **remote-translator** | WebSocket | Worker GPU remoto |
| **embeddings** | S-Transformers | Generación de vectores semánticos |
| **ner** | Spacy + BERT | Reconocimiento de entidades (PER, ORG, LOC) |
| **llm-categorizer** | Ollama/Mistral | Clasificación con modelos de lenguaje |
| **wiki-worker** | Go | Integración Wikipedia + thumbnails |
| **topics** | Go | Matcher de países y temas |
| **related** | Go | Detección de noticias relacionadas |
| **qdrant-worker** | Go | Vectorización + búsqueda semántica |
| **cluster** | Python | Agrupación de noticias |
| **langdetect** | Python | Detección de idioma |
| **translation-scheduler** | Python | Creador de tareas de traducción |
### Capa de Almacenamiento
| Servicio | Tecnología | Descripción |
|---------|------------|-------------|
| **db** | PostgreSQL 18 | Base de datos relacional |
| **qdrant** | Qdrant | Base de datos vectorial |
| **redis** | Redis 7 | Cache y colas de mensajes |
### Monitoreo
| Servicio | Tecnología | Descripción |
|---------|------------|-------------|
| **prometheus** | Prometheus | Métricas del sistema |
| **grafana** | Grafana | Dashboard (puerto 3001) |
| **cadvisor** | cAdvisor | Monitoreo Docker |
---
## 📝 Code Style Guidelines
### Backend (Go)
#### Imports
```go
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth"
"github.com/rss2/backend/internal/models"
)
```
**Order:** Standard → Third-party → Local packages
**Use:** `goimports` to auto-format
#### Naming
- Packages: lowercase (`handlers`, `services`)
- Functions/Methods: camelCase (`GetNews`, `CreateUser`)
- Variables: camelCase (`userId`, `newsList`)
- Constants: UPPER_SNAKE_CASE (`MaxPageSize`)
- Types: PascalCase (`NewsResponse`, `User`)
#### Error Handling
```go
func CreateResource(c *gin.Context) {
var req CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Error: "Invalid request",
Message: err.Error(),
})
return
}
// ...
}
```
**Always return errors** | **Use `models.ErrorResponse`**
#### Database
```go
err := db.GetPool().QueryRow(ctx, "SELECT * FROM users WHERE id = $1", id).Scan(&user)
if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "User not found"})
return
}
```
**Use context** | **Named parameters ($1, $2)**
#### Tests
```go
func TestLoginInvalidRequest(t *testing.T) {
router := gin.New()
router.POST("/auth/login", Login)
body := []byte(`{}`)
req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
```
**Use `gin.TestMode`** | **Test error paths**
---
### Frontend (TypeScript + React)
#### Imports
```tsx
import React, { useState, useEffect } from 'react'
import { Routes, Route } from 'react-router-dom'
import { Layout } from './components/layout/Layout'
import { api } from './services/api'
```
**Order:** React → Router → Components → Services → Utils
#### Components
```tsx
function NewsList() {
const [news, setNews] = useState<News[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => { fetchNews(); }, [])
const fetchNews = async () => {
try {
const res = await api.get('/news')
setNews(res.data)
} catch (err) {
setError(err.message)
}
}
return (
<div className="news-list">
{loading && <Spinner />}
{error && <ErrorBanner message={error} />}
</div>
)
}
```
#### Types
```tsx
interface News {
id: number
title: string
summary: string
url: string
publishedAt: string
}
```
#### Styling
- Use Tailwind CSS utility classes
- Avoid inline styles
- Use `clsx` for conditional classes
#### TypeScript Rules
- Use `strict: true` mode
- Avoid `any` - define interfaces
- Use type guards for narrowing
- Prefer optional chaining `?.`
---
## 🔒 Security Guidelines
1. **Never commit secrets** - Use `.env` in `.gitignore`
2. **Validate all inputs** - Use `go-playground/validator`
3. **Prepared statements** - Prevent SQL injection
4. **Rate limiting** - On sensitive endpoints
5. **HTTPS only** - Enforce in production
### Variables de Entorno Críticas
```bash
POSTGRES_PASSWORD # Contraseña PostgreSQL
REDIS_PASSWORD # Contraseña Redis
DB_PASS # Contraseña para workers
SECRET_KEY # Key JWT
GRAFANA_PASSWORD # Dashboard password
```
---
## 🧪 Testing Best Practices
### Backend
- Test error cases and edge cases
- Use `gin.TestMode` for HTTP tests
### Frontend
- Test component rendering
- Test API integration
- Test error states
- Use vitest
---
## 📦 Deployment
```bash
# Generar credenciales seguras
./pre-deploy.sh --generate
# Validar y desplegar
./pre-deploy.sh
# O manualmente
docker compose up -d
# Escalar workers de traducción
docker compose up -d --scale translator-gpu=4
```
### Escalado de Workers GPU
```bash
# 1 worker GPU (8GB+ VRAM)
docker compose up -d --scale translator-gpu=1
# 2 workers GPU (16GB+ VRAM)
docker compose up -d --scale translator-gpu=2
# 4 workers GPU (32GB+ VRAM)
docker compose up -d --scale translator-gpu=4
```
---
## 🔧 Configuración de Workers
### Environment Variables Principales
| Variable | Descripción | Default |
|----------|-------------|---------|
| `DB_HOST` | Host PostgreSQL | localhost |
| `DB_PORT` | Puerto PostgreSQL | 5432 |
| `DB_NAME` | Nombre base de datos | rss |
| `DB_USER` | Usuario PostgreSQL | rss |
| `DB_PASS` | Contraseña PostgreSQL | - |
| `TARGET_LANGS` | Idiomas destino | es |
| `TRANSLATOR_BATCH` | Tamaño de batch | 32 |
| `CT2_DEVICE` | Dispositivo (cpu/cuda) | cpu |
| `CT2_COMPUTE_TYPE` | Tipo (int8/float16) | int8 |
| `NER_BATCH` | Batch NER | 64 |
| `EMB_BATCH` | Batch embeddings | 64 |
---
## 📊 Endpoints de API Principales
### News
- `GET /api/news` - Listar noticias (paginado, filtros)
- `GET /api/news/:id` - Ver noticia con entidades
- `DELETE /api/news/:id` - Eliminar noticia (admin)
### Feeds
- `GET /api/feeds` - Listar feeds
- `POST /api/feeds` - Crear feed (auth)
- `PUT /api/feeds/:id` - Actualizar feed (auth)
- `DELETE /api/feeds/:id` - Eliminar feed (auth)
### Search
- `GET /api/search?q=...` - Búsqueda texto
- `GET /api/search?q=...&semantic=true` - Búsqueda semántica
### Entities
- `GET /api/entities?tipo=persona` - Listar entidades (PER, ORG, LOC)
### Admin
- `GET /api/admin/backup` - Backup SQL completo
- `GET /api/admin/backup/news` - Backup noticias (ZIP)
- `GET /api/admin/users` - Listar usuarios
- `POST /api/admin/workers/start` - Iniciar workers traducción
- `POST /api/admin/workers/stop` - Detener workers traducción
- `GET /api/admin/workers/status` - Estado de workers
### Auth
- `POST /api/auth/login` - Iniciar sesión
- `POST /api/auth/register` - Registrarse
- `GET /api/auth/me` - Usuario actual (auth)
### Stats
- `GET /api/stats` - Estadísticas globales
---
## 🎯 Capabilities del Sistema
1. **Enriquecimiento Wikipedia**: Detecta personas/orgs, descarga biografías e imágenes
2. **Categorización LLM**: Clasificación con Mistral-7B vía Ollama
3. **Búsqueda Semántica**: Qdrant vector search con mxbai-embed-large
4. **Traducción Neuronal**: NLLB-200 (600M-1.3B params) CPU/GPU
5. **NER**: Spacy es_core_news_lg para entidades nombradas
6. **Noticias Relacionadas**: Similitud coseno entre embeddings
7. **Detección de Idioma**: langdetect
8. **Clustering**: Agrupación automática de noticias
9. **WebSocket Workers**: Workers GPU remotos conectados por WS
10. **Backup Automático**: pg_dump con compresión ZIP
---
## 📖 Documentación Adicional
- [README.md](../README.md) - Guía de despliegue completo
- [DEPLOY.md](./DEPLOY.md) - Instrucciones de producción
- [SECURITY_GUIDE.md](./SECURITY_GUIDE.md) - Guía de seguridad
- [QUICKSTART_LLM.md](./QUICKSTART_LLM.md) - Configuración LLM
- [remote-worker.md](./remote-worker.md) - Workers remotos

353
DEPLOY.md
View file

@ -1,353 +0,0 @@
# 🚀 Guía de Despliegue RSS2
## ⚡ Despliegue Rápido (5 minutos)
### Paso 1: Clonar el Proyecto
```bash
git clone https://github.com/tu-usuario/rss2.git
cd rss2
```
### Paso 2: Generar Credenciales Seguras
```bash
./pre-deploy.sh --generate
```
**Salida esperada:**
```
🔑 Generando credenciales seguras...
⚠️ IMPORTANTE: Guarda estas credenciales en un lugar seguro
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
POSTGRES_PASSWORD: S5xwsI9HKjdaBWLpCkqp0mA0DJYZerpD
REDIS_PASSWORD: cVF79QuJulPO5auEhA2nNqv7mqAruzYh
SECRET_KEY: 2eb7c221245bb8d896f83255188de614e59fae6b8dfbf16eee8a23c60dde4ffa
GRAFANA_PASSWORD: R4J61V6OGLTCesrITwEvUPTm
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
**⚠️ IMPORTANTE:** Copia estas contraseñas y guárdalas en un gestor de contraseñas. ¡Nunca se volverán a mostrar!
### Paso 3: Validar y Desplegar
```bash
./pre-deploy.sh
```
**El script te preguntará:**
```
¿Deseas desplegar ahora?
1) Desplegar automáticamente (recomendado)
2) Solo validar y salir (modo manual)
Elige una opción:
```
**Opción A: Despliegue automático (Opción 1)**
- Elige `1` para que el script haga todo automáticamente
- Espera a que termine la construcción de las imágenes
- Espera a que inicien los servicios
**Opción B: Despliegue manual (Opción 2)**
- Elige `2` para validar solo
- Luego ejecuta manualmente:
```bash
docker compose up -d
```
### Paso 4: Verificar Despliegue
```bash
# Verificar estado de los servicios
docker compose ps
# Deberías ver algo como:
# Name Status
# rss2_db Up (healthy)
# rss2_redis Up (healthy)
# rss2_backend_go Up (running)
# ...
```
### Paso 5: Acceder a la Aplicación
```bash
# Aplicación web
http://localhost:8888
# API (prueba con curl)
curl http://localhost:8888/api/feeds
```
---
## 🔐 Seguridad
### ¿Por qué es importante?
**Sin seguridad (❌ MAL):**
```bash
docker compose up -d
WARN: The "DB_PASS" variable is not set. Defaulting to a blank string.
# Las contraseñas están vacías ¡PELIGRO!
```
**Con seguridad (✅ BIEN):**
```bash
./pre-deploy.sh --generate
./pre-deploy.sh
docker compose up -d
# ✅ Todo funciona correctamente
```
### Variables Críticas
| Variable | Qué es | Por qué es importante |
|----------|--------|----------------------|
| `POSTGRES_PASSWORD` | Contraseña de PostgreSQL | Base de datos principal |
| `REDIS_PASSWORD` | Contraseña de Redis | Cache y colas |
| `DB_PASS` | Contraseña para workers | Conexiones de workers |
| `SECRET_KEY` | Key secreta de la app | JWT, encriptación |
| `GRAFANA_PASSWORD` | Contraseña de Grafana | Dashboard de monitoring |
### Guardar tus Credenciales
**Opción 1: Gestor de contraseñas (Recomendado)**
```bash
# Copia las credenciales y guárdalas en:
# - LastPass
# - 1Password
# - KeePass
# - Bitwarden
```
**Opción 2: Archivo seguro local**
```bash
# Crea un archivo seguro fuera del proyecto
echo "POSTGRES_PASSWORD=..." > ~/rss2-credentials.txt
echo "REDIS_PASSWORD=..." >> ~/rss2-credentials.txt
```
---
## 🛠️ Comandos Útiles
### Verificar Credenciales
```bash
# Ver que las contraseñas están definidas
grep -E "^(POSTGRES_PASSWORD|REDIS_PASSWORD|DB_PASS)=" .env
```
### Reiniciar Servicios
```bash
# Reiniciar todos los servicios
docker compose restart
# Reiniciar solo base de datos
docker compose restart db
```
### Escalar Workers
```bash
# Añadir más traductores (CPU)
docker compose up -d --scale translator=3
# Añadir más traductores (GPU)
docker compose up -d --scale translator-gpu=2
```
### Ver Logs
```bash
# Ver logs en tiempo real
docker compose logs -f
# Ver logs específicos
docker compose logs -f db
docker compose logs -f redis
docker compose logs -f backend-go
```
### Limpiar y Reiniciar
```bash
# Detener y eliminar todo (incluye datos)
docker compose down -v
# Eliminar solo contenedores (mantiene datos)
docker compose down
# Eliminar volumen específico
docker volume rm rss2_db
docker volume rm rss2_redis
```
---
## 🐛 Solución de Problemas
### Problema: WARN sobre variables no definidas
```
WARN: The "DB_PASS" variable is not set. Defaulting to a blank string.
```
**Solución:**
```bash
# Ejecutar pre-deploy para generar credenciales
./pre-deploy.sh --generate
```
### Problema: Contraseña vacía en healthcheck
```
redis-cli: Authentication failed
```
**Solución:**
```bash
# Verificar REDIS_PASSWORD
grep REDIS_PASSWORD .env
# Si está vacío, regenerar
./pre-deploy.sh --generate
```
### Problema: Port 8888 en uso
```
ERROR: failed to start service: port 8888 already in use
```
**Solución:**
```bash
# Cambiar puerto en docker-compose.yml
# O matar el proceso que usa el puerto
lsof -ti:8888 | xargs kill
# O usar otro puerto
docker compose up -d --scale nginx=1
```
### Problema: Base de datos no inicia
```
postgres: cannot connect to database
```
**Solución:**
```bash
# Ver logs de la base de datos
docker compose logs db
# Reiniciar base de datos
docker compose restart db
# Si persiste, eliminar y recrear (¡PERDERÁS DATOS!)
docker compose down -v
docker compose up -d db
```
---
## 📊 Estado de los Servicios
### Servicios Críticos
| Servicio | Puerto | Descripción |
|----------|--------|-------------|
| `nginx` | 8888 | Aplicación web |
| `backend-go` | 8080 | API interna |
| `db` | 5432 | PostgreSQL |
| `redis` | 6379 | Redis cache |
| `qdrant` | 6333 | Vector database |
### Dashboard de Monitorización
| Herramienta | Puerto | Acceso |
|-------------|--------|--------|
| Grafana | 3001 | http://127.0.0.1:3001 |
| Prometheus | 9090 | http://127.0.0.1:9090 |
**Nota:** Accede a Grafana desde `127.0.0.1` (localhost) por seguridad.
---
## 🔄 Actualización (Upgrade)
### Actualizar a Nueva Versión
```bash
# 1. Pull las nuevas imágenes
docker compose pull
# 2. Backup de la base de datos
docker compose exec db pg_dump -U rss rss > backup.sql
# 3. Parar servicios
docker compose down
# 4. Actualizar código
git pull
# 5. Reiniciar
docker compose up -d
# 6. Verificar
curl http://localhost:8888/api/health
```
### Migrar Contraseñas
Si tienes credenciales existentes y quieres mantenerlas:
```bash
# 1. Verificar .env existente
grep POSTGRES_PASSWORD .env
# 2. Si quieres nuevas credenciales
./pre-deploy.sh --generate
# 3. Si quieres mantener las existentes
# No hagas nada, las credenciales seguirán funcionando
```
---
## 📝 Archivos Importantes
| Archivo | Propósito |
|---------|-----------|
| `.env` | Contraseñas y configuración (NO subir a Git) |
| `.env.example` | Plantilla (SÍ subir a Git) |
| `pre-deploy.sh` | Script de validación (SÍ subir a Git) |
| `generate_secure_credentials.sh` | Script de generación (SÍ subir a Git) |
| `docker-compose.yml` | Configuración de Docker (SÍ subir a Git) |
---
## ✅ Checklist Final
Antes de considerar tu despliegue como "listo", verifica:
- [ ] `.env` tiene credenciales generadas (no por defecto)
- [ ] `.env` está en `.gitignore`
- [ ] Puedes acceder a http://localhost:8888
- [ ] `docker compose ps` muestra todos los servicios "Up"
- [ ] La base de datos muestra "healthy"
- [ ] Los logs de la base de datos no tienen errores
- [ ] Redis responde a ping
- [ ] Qdrant está funcionando
**¡Listo! Tu RSS2 está desplegado y funcionando.** 🎉
---
## 📚 Documentación Adicional
- [README.md](./README.md) - Documentación general
- [SECURITY_GUIDE.md](./SECURITY_GUIDE.md) - Guía de seguridad
- [DOCKER.md](./DOCKER.md) - Configuración avanzada de Docker
---
**RSS2** - *Transformando noticias en inteligencia con IA.*

View file

@ -1,50 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc git curl \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TOKENIZERS_PARALLELISM=false \
HF_HOME=/root/.cache/huggingface
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip
RUN pip install --no-cache-dir torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu121
RUN pip install --no-cache-dir \
ctranslate2 \
sentencepiece \
transformers==4.44.0 \
protobuf==3.20.3 \
"numpy<2" \
psycopg2-binary \
redis \
requests \
beautifulsoup4 \
lxml \
langdetect \
nltk \
scikit-learn \
pandas \
sentence-transformers \
spacy
RUN python -m spacy download es_core_news_lg
COPY workers/ ./workers/
COPY init-db/ ./init-db/
COPY migrations/ ./migrations/
COPY entity_config.json .
ENV DB_HOST=db
ENV DB_PORT=5432
ENV DB_NAME=rss
ENV DB_USER=rss
ENV DB_PASS=x
CMD ["python", "-m", "workers.embeddings_worker"]

View file

@ -1,35 +0,0 @@
FROM golang:1.22-alpine AS builder
ENV GOTOOLCHAIN=auto
RUN apk add --no-cache git
WORKDIR /app
COPY backend/go.mod ./
RUN go mod download
COPY backend/ ./
RUN go mod tidy
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/discovery ./cmd/discovery
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /bin/discovery /bin/discovery
ENV DB_HOST=db \
DB_PORT=5432 \
DB_NAME=rss \
DB_USER=rss \
DB_PASS=rss \
DISCOVERY_INTERVAL=900 \
DISCOVERY_BATCH=10 \
MAX_FEEDS_PER_URL=5
ENTRYPOINT ["/bin/discovery"]

View file

@ -1,38 +0,0 @@
FROM golang:1.22-alpine AS builder
ENV GOTOOLCHAIN=auto
RUN apk add --no-cache git
WORKDIR /app
COPY backend/go.mod ./
RUN go mod download
COPY backend/ ./
RUN go mod tidy
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/qdrant-worker ./cmd/qdrant
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /bin/qdrant-worker /bin/qdrant-worker
ENV DB_HOST=db \
DB_PORT=5432 \
DB_NAME=rss \
DB_USER=rss \
DB_PASS=rss \
QDRANT_HOST=qdrant \
QDRANT_PORT=6333 \
QDRANT_COLLECTION=news_vectors \
OLLAMA_URL=http://ollama:11434 \
QDRANT_SLEEP=30 \
QDRANT_BATCH=100
ENTRYPOINT ["/bin/qdrant-worker"]

View file

@ -1,36 +0,0 @@
FROM golang:1.22-alpine AS builder
ENV GOTOOLCHAIN=auto
RUN apk add --no-cache git
WORKDIR /app
COPY backend/go.mod ./
RUN go mod download
COPY backend/ ./
RUN go mod tidy
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/related ./cmd/related
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /bin/related /bin/related
ENV DB_HOST=db \
DB_PORT=5432 \
DB_NAME=rss \
DB_USER=rss \
DB_PASS=rss \
RELATED_SLEEP=10 \
RELATED_BATCH=200 \
RELATED_TOPK=10 \
EMB_MODEL=mxbai-embed-large
ENTRYPOINT ["/bin/related"]

View file

@ -1,39 +0,0 @@
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
patchelf libpq-dev gcc git curl wget \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TOKENIZERS_PARALLELISM=false \
HF_HOME=/root/.cache/huggingface
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip
RUN pip install --no-cache-dir torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118
RUN pip install --no-cache-dir \
ctranslate2==3.24.0 \
sentencepiece \
transformers==4.36.0 \
protobuf==3.20.3 \
"numpy<2" \
psycopg2-binary \
langdetect \
websocket-client
RUN find /usr/local/lib/python3.11/site-packages/ctranslate2* \
-name "libctranslate2-*.so.*" -o -name "libctranslate2.so*" | \
xargs -I {} patchelf --clear-execstack {} || true
COPY workers/remote_translator_worker.py /app/worker.py
ENV CT2_DEVICE=cuda
ENV CT2_COMPUTE_TYPE=float16
ENV UNIVERSAL_MODEL=facebook/nllb-200-1.3B
CMD ["python", "/app/worker.py"]

View file

@ -1,23 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip
RUN pip install --no-cache-dir psycopg2-binary langdetect
COPY workers/translation_scheduler.py ./workers/
ENV DB_HOST=db
ENV DB_PORT=5432
ENV DB_NAME=rss
ENV DB_USER=rss
ENV DB_PASS=x
CMD ["python", "workers/translation_scheduler.py"]

View file

@ -1,37 +0,0 @@
FROM golang:1.22-alpine AS builder
ENV GOTOOLCHAIN=auto
RUN apk add --no-cache git
WORKDIR /app
COPY backend/go.mod ./
RUN go mod download
COPY backend/ ./
RUN go mod tidy
COPY backend/ ./
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/scraper ./cmd/scraper
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /bin/scraper /bin/scraper
ENV DB_HOST=db \
DB_PORT=5432 \
DB_NAME=rss \
DB_USER=rss \
DB_PASS=rss \
SCRAPER_SLEEP=60 \
SCRAPER_BATCH=10 \
SCRAPER_ENRICH_LIMIT=20
ENTRYPOINT ["/bin/scraper"]

View file

@ -1,34 +0,0 @@
FROM golang:1.22-alpine AS builder
ENV GOTOOLCHAIN=auto
RUN apk add --no-cache git
WORKDIR /app
COPY backend/go.mod ./
RUN go mod download
COPY backend/ ./
RUN go mod tidy
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/topics ./cmd/topics
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /bin/topics /bin/topics
ENV DB_HOST=db \
DB_PORT=5432 \
DB_NAME=rss \
DB_USER=rss \
DB_PASS=rss \
TOPICS_SLEEP=10 \
TOPICS_BATCH=500
ENTRYPOINT ["/bin/topics"]

View file

@ -1,44 +0,0 @@
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
patchelf libpq-dev gcc git curl wget \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TOKENIZERS_PARALLELISM=false \
HF_HOME=/root/.cache/huggingface
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip
RUN pip install --no-cache-dir torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cpu
RUN pip install --no-cache-dir \
ctranslate2==3.24.0 \
sentencepiece \
transformers==4.36.0 \
protobuf==3.20.3 \
"numpy<2" \
psycopg2-binary \
langdetect \
redis
# === ARREGLAR EL EXECUTABLE STACK ===
RUN find /usr/local/lib/python3.11/site-packages/ctranslate2* \
-name "libctranslate2-*.so.*" -o -name "libctranslate2.so*" | \
xargs -I {} patchelf --clear-execstack {} || true
COPY workers/ ./workers/
COPY init-db/ ./init-db/
COPY migrations/ ./migrations/
ENV DB_HOST=db
ENV DB_PORT=5432
ENV DB_NAME=rss
ENV DB_USER=rss
ENV DB_PASS=x
CMD ["python", "-m", "workers.ctranslator_worker"]

View file

@ -1,48 +0,0 @@
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
patchelf libpq-dev gcc git curl wget \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TOKENIZERS_PARALLELISM=false \
HF_HOME=/root/.cache/huggingface
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip
# Install PyTorch with CUDA support (cu118 for broader compatibility)
RUN pip install --no-cache-dir torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118
RUN pip install --no-cache-dir \
ctranslate2==3.24.0 \
sentencepiece \
transformers==4.36.0 \
protobuf==3.20.3 \
"numpy<2" \
psycopg2-binary \
langdetect
# Fix executable stack
RUN find /usr/local/lib/python3.11/site-packages/ctranslate2* \
-name "libctranslate2-*.so.*" -o -name "libctranslate2.so*" | \
xargs -I {} patchelf --clear-execstack {} || true
COPY workers/ ./workers/
COPY init-db/ ./init-db/
COPY migrations/ ./migrations/
ENV DB_HOST=db
ENV DB_PORT=5432
ENV DB_NAME=rss
ENV DB_USER=rss
ENV DB_PASS=x
# GPU Configuration - Override with: docker run --gpus all
ENV CT2_DEVICE=cuda
ENV CT2_COMPUTE_TYPE=float16
CMD ["python", "-m", "workers.ctranslator_worker"]

View file

@ -1,35 +0,0 @@
FROM golang:alpine AS builder
ENV GOTOOLCHAIN=auto
RUN apk add --no-cache git
WORKDIR /app
COPY backend/go.mod ./
RUN go mod download
COPY backend/ ./
RUN go mod tidy
COPY backend/ ./
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/wiki_worker ./cmd/wiki_worker
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /bin/wiki_worker /bin/wiki_worker
ENV DB_HOST=db \
DB_PORT=5432 \
DB_NAME=rss \
DB_USER=rss \
DB_PASS=rss \
WIKI_SLEEP=10
ENTRYPOINT ["/bin/wiki_worker"]

View file

@ -1,6 +1,6 @@
# RSS2 Workers Makefile # RSS2 Workers Makefile
.PHONY: all build clean deps ingestor scraper discovery topics related qdrant server .PHONY: all build clean deps ingestor scraper discovery topics related qdrant server install rebuild check poc
# Binary output directory # Binary output directory
BIN_DIR := bin BIN_DIR := bin
@ -69,21 +69,16 @@ run-qdrant:
DB_HOST=localhost DB_PORT=5432 DB_NAME=rss DB_USER=rss DB_PASS=rss \ DB_HOST=localhost DB_PORT=5432 DB_NAME=rss DB_USER=rss DB_PASS=rss \
QDRANT_HOST=localhost QDRANT_PORT=6333 OLLAMA_URL=http://localhost:11434 $(QDRANT) QDRANT_HOST=localhost QDRANT_PORT=6333 OLLAMA_URL=http://localhost:11434 $(QDRANT)
# Docker builds # Despliegue en Debian (sin Docker)
docker-build: install:
docker build -t rss2-ingestor -f rss-ingestor-go/Dockerfile ./rss-ingestor-go sudo bash deploy/debian/prerequisites.sh
docker build -t rss2-server -f backend/Dockerfile ./backend sudo bash deploy/debian/install.sh
docker build -t rss2-scraper -f Dockerfile.scraper ./backend
docker build -t rss2-discovery -f Dockerfile.discovery ./backend rebuild:
docker build -t rss2-topics -f Dockerfile.topics ./backend sudo bash deploy/debian/build.sh
docker build -t rss2-related -f Dockerfile.related ./backend
docker build -t rss2-qdrant -f Dockerfile.qdrant ./backend check:
docker build -t rss2-langdetect -f Dockerfile . bash deploy/debian/check.sh
docker build -t rss2-scheduler -f Dockerfile.scheduler .
docker build -t rss2-translator -f Dockerfile.translator . poc:
docker build -t rss2-translator-gpu -f Dockerfile.translator-gpu . bash poc/poc.sh
docker build -t rss2-embeddings -f Dockerfile.embeddings_worker .
docker build -t rss2-ner -f Dockerfile .
docker build -t rss2-llm-categorizer -f Dockerfile.llm_worker .
docker build -t rss2-frontend -f frontend/Dockerfile ./frontend
docker build -t rss2-nginx -f Dockerfile.nginx .

663
README.md
View file

@ -1,549 +1,202 @@
# RSS2 - AI-Powered News Intelligence Platform # COCONEWS
RSS2 es una plataforma avanzada de agregación, traducción, análisis y vectorización de noticias, diseñada para transformar flujos masivos de información en inteligencia accionable. Utiliza una arquitectura híbrida de microservicios (Go + Python) integrada con modelos de inteligencia artificial de última generación para ofrecer búsqueda semántica, clasificación inteligente y automatización de contenidos. Plataforma de inteligencia de noticias. Agrega feeds RSS de cualquier idioma, los traduce automáticamente al español, extrae entidades, los agrupa por eventos y los hace buscables semánticamente.
--- ---
## 🚀 Capacidades Principales ## Qué hace
* **Enriquecimiento con Wikipedia**: Sistema automatizado que detecta personas y organizaciones, descarga sus biografías e imágenes oficiales de Wikipedia para mostrarlas en tooltips interactivos con avatares circulares. - **Ingesta** feeds RSS/Atom de cualquier idioma de forma continua
* **Categorización Inteligente (LLM)**: Clasificación de noticias mediante una instancia local de Mistral-7B / Llama-3 (vía Ollama), procesando contenido en tiempo real. - **Traduce** al español con NLLB-200 (200 idiomas soportados)
* **Búsqueda Semántica**: Motor vectorial Qdrant para descubrir noticias por contexto y significado, yendo más allá de las palabras clave tradicionales. - **Extrae entidades** (personas, organizaciones, lugares) con spaCy y las enriquece con Wikipedia
* **Traducción Neuronal de Alta Calidad**: Integración de NLLB-200 (vía CTranslate2) para traducir noticias de múltiples idiomas al español con precisión profesional. - **Genera embeddings** para búsqueda por significado, no solo por palabras clave
* **Inteligencia de Entidades (NER)**: Extracción y normalización automática de Personas, Organizaciones y Lugares para análisis de tendencias y mapeo de relaciones. - **Agrupa noticias** del mismo evento automáticamente
* **Búsqueda de Noticias Relacionadas**: Algoritmos de similitud que agrupan noticias sobre el mismo tema automáticamente. - **Categoriza** contenido con reglas y modelos de lenguaje
* **Detección de Idioma**: Identificación automática del idioma de origen para routing correcto a traducción. - Interfaz web React con búsqueda semántica, filtros y tooltips de Wikipedia
* **Clustering de Noticias**: Agrupación automática de noticias relacionadas en eventos mediante embeddings.
* **Remote Workers GPU**: Workers de traducción GPUremotos conectados vía WebSocket para procesamiento distribuido.
* **Backup Automatizado**: Exportación completa de la base de datos en SQL o ZIP.
--- ---
## 🏗️ Arquitectura de Servicios (Docker) ## Arquitectura
El sistema se orquestra mediante Docker Compose y se divide en capas especializadas: ```
Internet (RSS/Atom)
rss-ingestor-go ──→ PostgreSQL ──→ langdetect
│ │ │
scraper/discovery │ translator (NLLB-200)
│ │
│ embeddings (MiniLM)
│ │
│ ner (spaCy)
│ │
│ cluster / related
│ │
│ qdrant-worker ──→ Qdrant
backend (API REST :8080)
nginx (:8001)
Frontend React
```
### Capa de Acceso y API **Stack:**
| Servicio | Tecnología | Descripción | - Go 1.25 — API REST (Gin), ingestor RSS, scraper, workers
|---------|------------|-------------| - Python 3 — Workers ML (NLLB-200, MiniLM, spaCy, CTranslate2)
| **`nginx`** | Nginx Alpine | Gateway y Proxy Inverso (Puerto **8888**). | - PostgreSQL 16 — datos relacionales + full-text search
| **`rss2_frontend`** | React + Vite | Interfaz web de usuario moderna y responsiva. | - Qdrant — búsqueda vectorial semántica
| **`backend-go`** | Go + Gin | API REST principal y gestión de lógica de negocio. | - Redis 7 — caché de consultas
- React 18 + TypeScript + Tailwind — frontend
### Ingesta y Descubrimiento (Go) - nginx — proxy inverso + archivos estáticos
| Servicio | Tecnología | Descripción |
|---------|------------|-------------|
| **`rss-ingestor-go`** | Go | Crawler de alto rendimiento para feeds RSS (100 workers). |
| **`scraper`** | Go | Scraper profundo con sanitización de HTML y extracción de texto. |
| **`discovery`** | Go | Agente autónomo para descubrir nuevos feeds a partir de URLs. |
### Procesamiento de Datos e IA (Go & Python)
| Servicio | Tecnología | Descripción |
|---------|------------|-------------|
| **`translator`** | NLLB-200 (CPU) | Traducción neuronal optimizada con CTranslate2. |
| **`translator-gpu`** | NLLB-200 (GPU) | Traducción acelerada por hardware (CUDA). |
| **`remote-translator`** | WebSocket | Worker GPU remoto (conexión vía ws://backend-go:8080/ws/worker). |
| **`wiki-worker`** | Go | Integración con Wikipedia y gestión de imágenes locales. |
| **`embeddings`** | S-Transformers | Generación de vectores para búsqueda semántica (paraphrase-multilingual-MiniLM-L12-v2). |
| **`ner`** | Spacy / es_core_news_lg | Reconocimiento de entidades nombradas (NER). |
| **`llm-categorizer`** | Ollama / Mistral | Clasificación avanzada mediante modelos de lenguaje. |
| **`topics`** | Go | Matcher automático de países y temas predefinidos. |
| **`related`** | Go | Motor de detección de noticias relacionadas (similitud coseno). |
| **`qdrant-worker`** | Go | Vectorización y búsqueda semántica con Qdrant + Ollama. |
| **`cluster`** | Python | Agrupación de noticias por eventos. |
| **`langdetect`** | Python | Detección de idioma para routing de traducciones. |
| **`translation-scheduler`** | Python | Creador de tareas de traducción. |
### Capa de Almacenamiento
| Servicio | Tecnología | Descripción |
|---------|------------|-------------|
| **`db`** | PostgreSQL 18 | Base de datos relacional principal. |
| **`qdrant`** | Qdrant | Base de datos vectorial para búsqueda por similitud. |
| **`redis`** | Redis 7 | Colas de mensajes y caché de alto desempeño. |
### Monitoreo
| Servicio | Tecnología | Descripción |
|---------|------------|-------------|
| **`prometheus`** | Prometheus | Métricas del sistema. |
| **`grafana`** | Grafana | Dashboard (puerto **3001**). |
| **`cadvisor`** | cAdvisor | Monitoreo Docker. |
--- ---
## 📊 Endpoints de API ## Inicio rápido (POC local)
### News Prueba COCONEWS en tu máquina en ~2 minutos con datos de muestra, sin instalar los modelos ML.
- `GET /api/news` - Listar noticias (paginado, filtros: q, category_id, country_id, translated_only)
- `GET /api/news/:id` - Ver noticia con entidades (incluye Wikipedia enrichment)
- `DELETE /api/news/:id` - Eliminar noticia (admin)
### Feeds **Requisitos mínimos para el POC:**
- `GET /api/feeds` - Listar feeds - Go 1.25+
- `POST /api/feeds` - Crear feed (auth requerido) - Node.js 18+
- `PUT /api/feeds/:id` - Actualizar feed (auth requerido) - PostgreSQL (corriendo)
- `DELETE /api/feeds/:id` - Eliminar feed (auth requerido) - Redis (corriendo)
- `POST /api/feeds/:id/toggle` - Activar/desactivar feed
- `POST /api/feeds/:id/reactivate` - Reactivar feed
### Search
- `GET /api/search?q=...` - Búsqueda texto (filtros: lang, categoria_id, pais_id)
- `GET /api/search?q=...&semantic=true` - Búsqueda semántica con Qdrant
### Entities
- `GET /api/entities?tipo=persona|organizacion|lugar` - Listar entidades (con aliasing)
### Admin
- `GET /api/admin/backup` - Backup SQL completo (descargable)
- `GET /api/admin/backup/news` - Backup noticias (ZIP)
- `GET /api/admin/users` - Listar usuarios
- `POST /api/admin/users/:id/promote` - Promover a admin
- `POST /api/admin/users/:id/demote` - Quitar admin
- `POST /api/admin/reset-db` - Resetear base de datos
- `POST /api/admin/aliases` - Crear alias de entidad
- `GET /api/admin/aliases/export` - Exportar aliases (CSV)
- `POST /api/admin/aliases/import` - Importar aliases (CSV)
- `POST /api/admin/entities/retype` - Cambiar tipo de entidad
- `POST /api/admin/workers/config` - Configurar workers (type, workers)
- `POST /api/admin/workers/start` - Iniciar workers traducción
- `POST /api/admin/workers/stop` - Detener workers traducción
- `GET /api/admin/workers/status` - Estado de workers
- `GET /api/admin/workers/remote` - Listar remote workers
- `POST /api/admin/workers/remote` - Crear remote worker
- `DELETE /api/admin/workers/remote/:id` - Eliminar remote worker
### Auth
- `POST /api/auth/login` - Iniciar sesión
- `POST /api/auth/register` - Registrarse
- `GET /api/auth/me` - Usuario actual (auth requerido)
- `GET /api/auth/check-first-user` - Verificar si existe primer usuario
### Stats
- `GET /api/stats` - Estadísticas globales (total news, feeds, users, hoy/semana/mes)
- `GET /api/categories` - Listar categorías
- `GET /api/countries` - Listar países con continentes
### WebSocket
- `GET /ws/worker` - Conexión de remote workers (protocolo de autenticación con API key)
---
## 🗄️ Esquema de Base de Datos (Tablas Principales)
- **noticias**: Artículos RSS (titulo, resumen, url, feed_id, categoria_id, pais_id, lang)
- **feeds**: Fuentes RSS (nombre, url, activo, ultimo_fetch)
- **traducciones**: Traducciones (noticia_id, lang_from, lang_to, titulo_trad, resumen_trad, status, vectorized)
- **tags**: Entidades (valor, tipo: persona/organizacion/lugar/tema, wiki_summary, wiki_url, image_path)
- **tags_noticia**: Relación noticia-tag (traduccion_id, noticia_id, tag_id)
- **entity_aliases**: Alias de entidades (canonical_name, alias, tipo)
- **categorias**: Categorías de noticias
- **paises**: Países con continentes
- **related_noticias**: Noticias relacionadas (traduccion_id, related_traduccion_id, score)
- **traduccion_embeddings**: Embeddings en BD (traduccion_id, model, embedding)
- **users**: Usuarios (email, username, password_hash, is_admin)
- **config**: Configuración del sistema (translator_type, translator_workers, translator_status)
- **remote_workers**: Workers remotos (name, api_key, capabilities, status, last_seen)
- **favoritos**: Noticias favoritas de usuarios
- **search_history**: Historial de búsquedas
---
## ⚙️ Guía de Configuración
### 1. Requisitos de Hardware
* **Modo Básico (CPU)**: 4+ Cores CPU, 8GB RAM.
* **Modo Avanzado (IA)**: NVIDIA GPU con 8GB+ VRAM (mínimo recomendado para LLM y Traducción GPU).
### 2. Instalación Rápida con Seguridad Automática
#### Opción A: Despliegue con Validación Automática (RECOMENDADO)
```bash ```bash
# Paso 1: Clonar el proyecto git clone https://gitea.laenre.net/pietre/rss2.git coconews
git clone <repo_url> cd coconews
cd rss2 git checkout coconews
# Paso 2: Generar credenciales seguras automáticamente bash poc/poc.sh
./pre-deploy.sh --generate
# Paso 3: Validar y desplegar
./pre-deploy.sh
# Paso 4: Si eliges opción 2 en el prompt, despliega manualmente
docker compose up -d
``` ```
**Ventajas:** Abre `http://127.0.0.1:18001` en el navegador.
- ✅ Genera credenciales seguras de 32 caracteres Login: **admin** / **admin123** (creado automáticamente si tienes `pip3 install bcrypt`).
- ✅ Valida que no haya contraseñas por defecto
- ✅ Muestra resumen de credenciales en consola
- ✅ Automático y seguro para producción
#### Opción B: Despliegue Manual (Para desarrolladores) Guía detallada para explorar la demo: [docs/POC_GUIDE.md](docs/POC_GUIDE.md)
---
## Instalación en servidor Debian
Para un despliegue completo con todos los workers ML en producción:
### 1. Instalar prerequisites
```bash ```bash
git clone <repo_url> sudo bash deploy/debian/prerequisites.sh
cd rss2 ```
# Generar credenciales manualmente Instala: PostgreSQL 16, Redis, nginx, Go 1.25, Node.js 20, Qdrant, Python 3 venv.
./generate_secure_credentials.sh Pregunta si instalar los modelos ML pesados ahora o después.
# Copiar credenciales a .env ### 2. Configurar entorno
cp .env.generated .env
# Desplegar ```bash
docker compose up -d cp deploy/debian/env.example /opt/rss2/.env
nano /opt/rss2/.env # edita contraseñas y SECRET_KEY
```
### 3. Instalar y arrancar
```bash
sudo bash deploy/debian/install.sh
```
Compila los binarios Go, el frontend React, crea los servicios systemd y arranca todo.
### Acceder
```
http://IP_DEL_SERVIDOR:8001
```
Guía completa: [docs/DEPLOY_DEBIAN.md](docs/DEPLOY_DEBIAN.md)
---
## Gestión de servicios
```bash
# Estado general
systemctl status rss2-backend rss2-ingestor rss2-translator
# Logs en tiempo real
journalctl -u rss2-backend -f
journalctl -u rss2-translator -f
# Reiniciar tras actualizar código
git pull
sudo bash deploy/debian/build.sh
``` ```
--- ---
## 🔐 Despliegue con Seguridad Automática (RECOMENDADO) ## Actualizar el código
### 🛡️ Sistema de Validación Pre-Despliegue
RSS2 incluye `pre-deploy.sh`, un script inteligente que se ejecuta **antes** de `docker compose` para:
1. ✅ **Validar credenciales**: Asegura que `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `DB_PASS` estén definidas
2. ✅ **Generar automáticamente**: Crea credenciales seguras de 32 caracteres si faltan
3. ✅ **Prevenir errores**: Evita que los WARN de Docker por variables vacías
4. ✅ **Mostrar resumen**: Te muestra todas las credenciales en consola
### 🚀 Despliegue Rápido y Seguro (3 Comandos)
```bash ```bash
# 1. Generar credenciales seguras automáticamente cd /ruta/al/repo
./pre-deploy.sh --generate git pull
sudo bash deploy/debian/build.sh
# 2. Validar y desplegar (elige opción 2 para despliegue manual)
./pre-deploy.sh
# 3. Alternativa: Despliegue manual después de validar
./pre-deploy.sh # ← Elige "2) Solo validar y salir"
docker compose up -d
``` ```
**Flujo recomendado:** `build.sh` recompila los binarios Go, el frontend y sincroniza los workers Python, y reinicia los servicios automáticamente.
```bash
./pre-deploy.sh --generate # Genera .env con credenciales seguras
./pre-deploy.sh # Valida y te pregunta si quieres desplegar
# Si eliges "2): Solo validar", luego:
docker compose up -d # Despliega manualmente
```
### 📋 Opciones del Script `pre-deploy.sh`
```bash
# Validar credenciales existentes
./pre-deploy.sh
# Generar credenciales seguras automáticamente
./pre-deploy.sh --generate
# Saltar validación (NO RECOMENDADO para producción)
./pre-deploy.sh --skip
# Ver ayuda
./pre-deploy.sh --help
```
**Salida típica del script:**
```
╔═══════════════════════════════════════════════════════════╗
║ 🔐 Pre-Deploy Security Check - RSS2 Platform ║
╚═══════════════════════════════════════════════════════════╝
✅ Archivo .env encontrado
✅ DEFINIDO: POSTGRES_PASSWORD=8x7f2k9m4p1q3w5e
✅ DEFINIDO: REDIS_PASSWORD=a9b8c7d6e5f4g3h2
✅ DEFINIDO: DB_PASS=8x7f2k9m4p1q3w5e
📋 Resumen de Credenciales Activas
POSTGRES_PASSWORD: ✓
REDIS_PASSWORD: ✓
DB_PASS: ✓
✅ VALIDACIÓN COMPLETADA - LISTO PARA DESPLEGAR
```
### 🔒 Variables Críticas que Requieren Contraseñas
| Variable | Descripción | Uso |
|----------|-------------|-----|
| `POSTGRES_PASSWORD` | Contraseña de PostgreSQL | Base de datos |
| `REDIS_PASSWORD` | Contraseña de Redis | Cache y colas |
| `DB_PASS` | Contraseña para workers | Conexiones de workers |
| `SECRET_KEY` | Key secreta de la aplicación | JWT, encriptación |
| `GRAFANA_PASSWORD` | Contraseña de Grafana | Dashboard de monitoring |
--- ---
### 3. Escalado de Workers GPU (¡Importante!) ## Requisitos de hardware
#### 🚀 Workers de Traducción GPU Multi-Instancia | Modo | CPU | RAM | Disco |
|------|-----|-----|-------|
RSS2 soporta múltiples workers de traducción ejecutándose en paralelo utilizando GPUs, lo que acelera significativamente el proceso de traducción masiva. | POC local | 2 cores | 4 GB | 10 GB |
| Producción CPU | 4+ cores | 8 GB | 40 GB |
**Arquitectura Multi-Worker:** | Producción recomendado | 8 cores | 16 GB | 80 GB |
- Cada worker es una instancia independiente del servicio `ctranslator_worker`
- Los workers compiten automáticamente por las traducciones pendientes usando `SELECT ... FOR UPDATE SKIP LOCKED`
- Cada worker procesa su propio lote (batch) de traducciones sin bloquearse mutuamente
- Soporta hasta 8 workers simultáneos (configurable vía panel admin)
**Requisitos GPU:**
```bash
# Verificar GPU disponibles
nvidia-smi
# Instalar drivers NVIDIA (si no los tienes)
# Ubuntu/Debian:
sudo apt-get update
sudo apt-get install -y nvidia-driver-535
```
**Despliegue con Múltiples Workers GPU:**
```bash
# Opción 1: Desplegar 1 worker GPU (mínimo recomendado)
docker compose up -d --scale translator-gpu=1
# Opción 2: Desplegar 2 workers GPU (recomendado: 16GB+ VRAM total)
docker compose up -d --scale translator-gpu=2
# Opción 3: Desplegar 4 workers GPU (requiere: 32GB+ VRAM total)
docker compose up -d --scale translator-gpu=4
# Opción 4: Combinar CPU y GPU
# 2 workers CPU + 2 workers GPU
docker compose up -d --scale translator=2 --scale translator-gpu=2
```
**Panel de Administración:**
Desde `/admin/settings`, puedes:
- Activar modo GPU (CUDA) o CPU
- Configurar número de workers (1-8)
- Iniciar/detener workers automáticamente
**Configuración de GPU por Worker:**
Cada worker GPU está configurado con:
- `CT2_DEVICE=cuda` - Usa dispositivos CUDA
- `CT2_COMPUTE_TYPE=float16` - Precisión mixta para velocidad
- `TRANSLATOR_BATCH=128` - Tamaño de lote optimizado
- `PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:512` - Gestión de memoria PyTorch
- `NCCL_DEBUG=INFO` - Debug de comunicación multi-GPU
**Monitorización:**
```bash
# Ver estado de workers
docker compose --profile gpu ps translator-gpu
# Ver uso de GPU
watch -n 1 nvidia-smi
# Ver logs en tiempo real
docker compose logs -f translator-gpu
# Métricas en Grafana
# Dashboard: Translation Workers
# Métricas: translator_*_transitions_per_minute
```
**Performance Esperada:**
| Workers GPU | VRAM Total | Tasa de Traducción |
|-------------|------------|-------------------|
| 1 worker | 8GB+ | ~500 traducciones/min |
| 2 workers | 16GB+ | ~1000 traducciones/min |
| 4 workers | 32GB+ | ~2000 traducciones/min |
| 8 workers | 64GB+ | ~4000 traducciones/min |
--- ---
## 🛡️ Administración y Mantenimiento ## Estructura del repositorio
### Copias de Seguridad (Backups)
Desde el panel de Administración (`/admin/settings`), puedes realizar:
**Backup Completo (SQL):**
- Descarga un archivo `.sql` con todo el contenido de la base de datos
- Incluye: noticias, feeds, traducciones, usuarios, etiquetas, favoritos, videos, eventos, historial de búsqueda
- Formato: PostgreSQL custom format con datos y estructura
- Tamaño típico: 10-100MB dependiendo del volumen de datos
**Backup de Noticias (ZIP):**
- Genera un archivo comprimido `.zip` con:
- Tabla `noticias` (artículos completos)
- Tabla `traducciones` (todas las traducciones)
- Tablas `tags` y `tags_noticia` (metadatos y relaciones)
- Ideal para: Migraciones de contenido, exportación de datos, backup ligero
- Tamaño típico: 5-50MB (solo contenido de noticias)
### Variables de Entorno Clave (`.env`)
| Variable | Descripción |
|----------|-------------|
| `WIKI_SLEEP` | Tiempo de espera entre peticiones a Wikipedia (evita bloqueos). |
| `SCHEDULER_BATCH`| Cantidad de noticias a enviar a traducir por ciclo. |
| `TARGET_LANGS` | Idiomas destino (ej: `es`). |
| `OLLAMA_URL` | URL del servidor Ollama para categorización. |
---
## 🎯 Descripción de Workers
### rss-ingestor-go
Crawler RSS de alto rendimiento escrito en Go. Gestiona hasta 100 workers paralelos para procesar múltiples feeds simultáneamente. Detecta nuevos artículos, extrae metadatos básicos y los inserta en la base de datos.
### scraper (Go)
Scraper profundo que:
- Descarga el contenido completo de URLs de artículos
- Sanitiza HTML removiendo scripts y estilos
- Extrae texto limpio del body
- Limita el contenido a 20 noticias por ciclo para evitar sobrecarga
### discovery (Go)
Agente de descubrimiento de feeds que:
- Analiza URLs proporcionadas para detectar feeds RSS/Atom
- Soporta hasta 5 feeds por URL
- Ejecuta cada 900 segundos (15 minutos)
### translator (Python/CTranslate2)
Worker de traducción neuronal:
- Modelo: facebook/nllb-200-distilled-600M convertido a CTranslate2
- Dispositivo: CPU (int8)
- Batch: 32 traducciones por ciclo
- Locking: `FOR UPDATE SKIP LOCKED` para evitar procesamiento duplicado
- Soporta múltiples idiomas fuente
### translator-gpu (Python/CTranslate2)
Versión acelerada por GPU del translator:
- Dispositivo: CUDA
- Compute type: float16
- Batch: 128 traducciones por ciclo
- Ideal para procesamiento de alto volumen
### remote-translator (Python)
Worker remoto conectado vía WebSocket:
- Autenticación con API key
- Conexión a `ws://backend-go:8080/ws/worker`
- Modelo: facebook/nllb-200-1.3B (más grande)
- GPU requerida
### langdetect (Python)
Detector de idioma:
- Usa biblioteca `langdetect`
- Proceso: 1000 noticias por ciclo
- Establece lang_from para correcto routing a traducción
### ner (Python/spaCy)
Extracción de entidades nombradas:
- Modelo: es_core_news_lg
- Tipos: PERSON (persona), ORG (organizacion), LOC/GPE (lugar)
- También extrae topics (noun-chunks)
- Configurable via entity_config.json (blacklist, synonyms)
### embeddings (Python)
Generador de embeddings para búsqueda semántica:
- Modelo: paraphrase-multilingual-MiniLM-L12-v2
- Dispositivo: GPU (cuda)
- Genera vectores de 384 dimensiones
- Alimenta Qdrant
### qdrant-worker (Go)
Worker de vectorización Qdrant:
- Conecta a Qdrant (puerto 6333)
- Genera embeddings via Ollama (mxbai-embed-large)
- Sube puntos a collection "news_vectors"
- Actualiza estado en BD (vectorized=true)
### related (Go)
Detector de noticias relacionadas:
- Usa similitud coseno entre embeddings
- Almacena top-k relaciones en tabla related_noticias
- Soporta configuración de threshold mínimo
### wiki-worker (Go)
Enriquecedor de Wikipedia:
- Descarga summaries de Wikipedia API
- Descarga thumbnails de imágenes
- Almacena en `./data/wiki_images`
- Evita rate limiting con WIKI_SLEEP
### llm-categorizer (Python)
Categorizador con LLM:
- Conexión a Ollama
- Usa modelo Mistral para clasificación
- Procesa 10 noticias por batch
### cluster (Python)
Agrupador de noticias por eventos:
- Usa embeddings paraphrase-multilingual-MiniLM-L12-v2
- Threshold: 0.35 de distancia
- Agrupa noticias similares
### translation-scheduler (Python)
Creador de tareas de traducción:
- Query: noticias sin traducción al español
- Crea entradas en tabla traducciones
- Batch: 1000 por ciclo
- Ciclo: 30 segundos
---
## 📖 Documentación de la API (Campos Wikipedia)
Las respuestas de noticias ahora incluyen el objeto `entities` enriquecido:
```json
{
"id": 67449,
"titulo": "...",
"entities": [
{
"valor": "Apple",
"tipo": "organizacion",
"wiki_summary": "Apple Inc. es una empresa estadounidense...",
"wiki_url": "https://es.wikipedia.org/wiki/Apple",
"image_path": "/api/wiki-images/wiki_5723.png"
}
]
}
```
---
## 🧪 Testing
### Backend (Go)
```bash
cd backend && go test ./internal/handlers -v -run TestLogin
cd backend && go test ./internal/auth -v -run TestGenerateToken
```
### Frontend
```bash
cd frontend && npm run test:ui
```
---
## 📁 Estructura de Archivos del Proyecto
``` ```
rss2/ ├── README.md
├── backend/ # API REST Go (Gin) ├── requirements.txt Dependencias Python para workers ML
├── frontend/ # Interfaz React + TypeScript ├── Makefile Compilación local de binarios Go
├── workers/ # Workers Python
├── rss-ingestor-go/ # Crawler RSS Go ├── backend/ Go — API REST + workers (scraper, discovery, wiki, topics, related, qdrant)
├── docker-compose.yml # Orquestación ├── rss-ingestor-go/ Go — Ingestor de feeds RSS
├── nginx.conf # Config Nginx ├── frontend/ React + TypeScript + Tailwind
├── monitoring/ # Prometheus + Grafana ├── workers/ Python — Workers ML (traducción, embeddings, NER, cluster, categorización)
├── init-db/ # Migraciones SQL
├── data/ # Datos persistentes ├── init-db/ SQL — Schema completo y datos iniciales
├── models/ # Modelos ML ├── migrations/ SQL — Migraciones incrementales
├── hf_cache/ # Cache HuggingFace
├── pre-deploy.sh # Script seguridad ├── config/
├── generate_secure_credentials.sh │ └── entity_config.json Aliases y blacklist para normalización de entidades NER
└── AGENTS.md # Guía desarrollo
├── data/
│ └── feeds.csv Feeds RSS precargados para importar desde el admin
├── docs/
│ ├── DEPLOY_DEBIAN.md Guía detallada de despliegue en Debian
│ ├── SECURITY_GUIDE.md Guía de seguridad
│ ├── SECURITY_AUDIT.md Resultado del audit de seguridad
│ ├── QDRANT_SETUP.md Configuración de Qdrant
│ └── ... Resto de documentación técnica
├── scripts/
│ ├── generate_secure_credentials.sh
│ ├── migrate_to_secure.sh
│ └── verify_security.sh
├── deploy/debian/ Despliegue nativo en Debian (sin Docker)
│ ├── prerequisites.sh Instala todas las dependencias del sistema
│ ├── install.sh Instalación completa
│ ├── build.sh Recompila y reinicia tras actualizar código
│ ├── check.sh Diagnóstico del sistema
│ ├── env.example Plantilla de variables de entorno
│ ├── nginx.conf Configuración nginx
│ └── systemd/ 16 ficheros de servicio systemd
└── poc/
├── poc.sh POC local en 2 minutos (sin Docker, sin ML)
└── seed.sql 10 noticias de muestra en español
``` ```
---
**RSS2** - *Transformando noticias en inteligencia con IA localizada.*

View file

@ -1,220 +0,0 @@
# 🚀 Despliegue RSS2 - 3 Comandos Simples
## ⚡ Flujo Rápido (3 pasos)
```bash
# 1. Generar credenciales seguras
./generate_secure_credentials.sh --force
# 2. Desplegar
docker compose up -d
# 3. Verificar
docker compose ps
```
**¡Listo!** La aplicación estará disponible en `http://localhost:8888`
---
## 🔐 ¿Por qué 3 pasos?
**Problema original:**
```bash
docker compose up -d
WARN: DB_PASS no definida → Contraseña vacía → ¡Inseguro!
```
**Solución:**
1. `./generate_secure_credentials.sh --force` → Crea `.env` con contraseñas de 32 caracteres
2. `docker compose up -d` → Docker carga `.env` automáticamente
3. `docker compose ps` → Verifica que todo esté bien
---
## 📋 Qué hace cada comando
### 1. `./generate_secure_credentials.sh --force`
- Crea contraseñas seguras de 32 caracteres aleatorios
- Genera archivo `.env` con todas las variables necesarias
- Guarda backup del `.env` anterior
- **Tiempo: 5 segundos**
**Salida:**
```
POSTGRES_PASSWORD: S5xwsI9HKjdaBWLpCkqp0mA0DJYZerpD
REDIS_PASSWORD: cVF79QuJulPO5auEhA2nNqv7mqAruzYh
SECRET_KEY: 2eb7c221245bb8d896f83255188de614e59fae6b8dfbf16eee8a23c60dde4ffa
GRAFANA_PASSWORD: R4J61V6OGLTCesrITwEvUPTm
```
### 2. `docker compose up -d`
- Lee `.env` automáticamente (está configurado en `docker-compose.yml`)
- Inicia todos los servicios en modo detached (background)
- Espera a que estén listos
- **Tiempo: 2-5 minutos** (primera vez que descarga imágenes)
### 3. `docker compose ps`
- Muestra estado de todos los servicios
- Deberías ver `Up` para todos
- **Tiempo: 1 segundo**
**Salida esperada:**
```
Name Status
rss2_db Up (healthy)
rss2_redis Up (healthy)
rss2_backend_go Up (running)
rss2_nginx Up (running)
...
```
---
## 🎯 Comandos Útiles
### Verificar credenciales
```bash
grep -E "^(POSTGRES_PASSWORD|REDIS_PASSWORD|DB_PASS)=" .env
```
### Ver logs en tiempo real
```bash
docker compose logs -f
```
### Reiniciar servicios
```bash
# Todos
docker compose restart
# Solo base de datos
docker compose restart db
```
### Verificar estado
```bash
docker compose ps
```
### Limpiar todo (PERDERÁS DATOS)
```bash
docker compose down -v
```
### Escalar workers
```bash
# Más traductores CPU
docker compose up -d --scale translator=3
# Más traductores GPU
docker compose up -d --scale translator-gpu=2
```
---
## 🔒 Seguridad
### Contraseñas por defecto = PELIGRO ❌
```bash
# MAL - Contraseñas vacías
docker compose up -d
WARN: DB_PASS no set
# Las contraseñas están vacías
```
### Contraseñas generadas = SEGURO ✅
```bash
# BIEN
./generate_secure_credentials.sh --force
docker compose up -d
# Contraseñas de 32 caracteres aleatorios
```
### Guardar tus credenciales
**Opción 1: Gestor de contraseñas (Recomendado)**
```bash
# Copia las contraseñas que muestra el script
# Guárdalas en: LastPass, 1Password, KeePass, Bitwarden
```
**Opción 2: Archivo seguro local**
```bash
# Crea un archivo fuera del proyecto
echo "POSTGRES_PASSWORD=..." > ~/rss2-credentials.txt
```
---
## 📊 Estado de los Servicios
| Servicio | Puerto | Descripción |
|----------|--------|-------------|
| `nginx` | 8888 | Web app |
| `backend-go` | 8080 | API |
| `db` | 5432 | PostgreSQL |
| `redis` | 6379 | Cache |
| `qdrant` | 6333 | Vector DB |
**DASHBOARDS:**
- Grafana: http://127.0.0.1:3001
- Prometheus: http://127.0.0.1:9090
---
## 🐛 Solución de Problemas
### WARN sobre contraseñas vacías
```bash
# Solución: Generar credenciales
./generate_secure_credentials.sh --force
```
### Port 8888 en uso
```bash
# Cambiar puerto en nginx
```
### Base de datos no inicia
```bash
# Ver logs
docker compose logs db
# Reiniciar
docker compose restart db
```
### Redis autenticación fallida
```bash
# Ver REDIS_PASSWORD
grep REDIS_PASSWORD .env
```
---
## ✅ Checklist Final
Antes de considerar tu despliegue listo:
- [ ] `.env` tiene contraseñas generadas (no por defecto)
- [ ] `docker compose ps` muestra todos "Up"
- [ ] `http://localhost:8888` abre la app
- [ ] `curl http://localhost:8888/api/health` responde
---
## 📚 Documentación
- [README.md](./README.md) - Documentación completa
- [DEPLOY.md](./DEPLOY.md) - Guía detallada
- [SECURITY_GUIDE.md](./SECURITY_GUIDE.md) - Seguridad
---
**RSS2** - *Transformando noticias en inteligencia con IA* 🚀

128
alias.sh
View file

@ -1,128 +0,0 @@
#!/bin/bash
# Aliases y funciones para despliegue automático de RSS2
# Copia este archivo y ejecútalo: source alias.sh
# ========================================
# FUNCIONES DE VALIDACIÓN AUTOMÁTICA
# ========================================
# Validar credenciales críticas
validate_creds() {
# Verificar si .env existe
if [ ! -f .env ]; then
echo "🔑 Generando credenciales seguras..."
./generate_secure_credentials.sh --force
exit $?
fi
# Verificar contraseñas vacías
for var in POSTGRES_PASSWORD REDIS_PASSWORD DB_PASS; do
val=$(grep "^${var}=" .env 2>/dev/null | cut -d'=' -f2 | tr -d ' ')
if [ -z "$val" ] || [ "$val" = "change_*" ] || [ "$val" = "" ]; then
echo "🔑 $var está vacía, generando..."
./generate_secure_credentials.sh --force
exit $?
fi
done
echo "✅ Credenciales OK"
}
# ========================================
# ALIASES PRINCIPALES
# ========================================
# Alias principal: docker compose con validación automática
alias dc="docker compose"
alias dcu="docker_compose_up" # docker compose up
alias dcd="docker_compose_down" # docker compose down
# Alias con validación automática
docker_compose_up() {
echo "🔐 Validando credenciales..."
validate_creds
echo "🚀 docker compose up -d"
dc up -d
}
docker_compose_down() {
echo "📦 docker compose down -v"
dc down -v
}
# Alias para despliegue completo con validación
deploy() {
echo "================================"
echo "🚀 DESPLIEGUE AUTOMÁTICO RSS2"
echo "================================"
echo ""
echo "1. 🔐 Validando credenciales..."
validate_creds
echo "2. 📦 Deteniendo y eliminando..."
dc down -v 2>/dev/null || true
echo "3. 🚀 Iniciando servicios..."
dc up -d --build
echo "4. ✅ Verificando estado..."
dc ps
echo ""
echo "================================"
echo "✅ ¡Despliegue completado!"
echo "🌐 App: http://localhost:8888"
echo "================================"
}
# Alias para despliegue rápido sin rebuild
deploy-quick() {
echo "🚀 Despliegue rápido (sin rebuild)..."
dc up -d
echo "📊 Estado:"
dc ps
}
# Alias para ver logs
tail-logs() {
echo "📊 Logs en tiempo real..."
dc logs -f
}
# ========================================
# FUNCIONES DE UTILIDAD
# ========================================
# Generar credenciales
generate-secrets() {
echo "🔑 Generando credenciales seguras..."
./generate_secure_credentials.sh --force
}
# Backup
backup() {
echo "📦 Creando backup..."
dc exec db pg_dump -U rss rss > backup_$(date +%Y%m%d).sql
echo "✅ Backup guardado en ./backup_$(date +%Y%m%d).sql"
}
# ========================================
# MENSAJE DE BIENVENIDA
# ========================================
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "🎉 RSS2 Deployment Aliases cargadas"
echo ""
echo "Comandos disponibles:"
echo " dc docker compose"
echo " dcu docker compose up (con validación)"
echo " dcd docker compose down"
echo " deploy Despliegue completo con validación"
echo " deploy-quick Despliegue rápido"
echo " tail-logs Ver logs en tiempo real"
echo " generate-secrets Generar credenciales"
echo " backup Crear backup"
echo ""
fi

View file

@ -1,25 +0,0 @@
FROM golang:1.23 AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y gcc musl-dev git
COPY go.mod ./
RUN go mod download
COPY . .
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o /server ./cmd/server
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata postgresql-client
WORKDIR /app
COPY --from=builder /server .
EXPOSE 8080
CMD ["./server"]

View file

@ -14,11 +14,11 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/mmcdole/gofeed" "github.com/mmcdole/gofeed"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers" "github.com/rss2/backend/internal/workers"
) )
var ( var (
logger *log.Logger
pool *workers.Config pool *workers.Config
dbPool *pgxpool.Pool dbPool *pgxpool.Pool
sleepSec = 900 // 15 minutes sleepSec = 900 // 15 minutes
@ -35,8 +35,7 @@ type URLSource struct {
} }
func init() { func init() {
logLevel := os.Getenv("LOG_LEVEL") logger = log.New(os.Stdout, "[DISCOVERY] ", log.LstdFlags)
logger.Init("discovery-worker", logLevel)
} }
func loadConfig() { func loadConfig() {
@ -417,70 +416,48 @@ func processURLSource(ctx context.Context, source URLSource) {
func main() { func main() {
loadConfig() loadConfig()
logger.Info().Msg("Starting RSS Discovery Worker") logger.Println("Starting RSS Discovery Worker")
cfg := workers.LoadDBConfig() cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil { if err := workers.Connect(cfg); err != nil {
logger.Fatal().Err(err).Msg("Failed to connect to database") logger.Fatalf("Failed to connect to database: %v", err)
} }
dbPool = workers.GetPool() dbPool = workers.GetPool()
defer workers.Close() defer workers.Close()
logger.Info().Msg("Connected to PostgreSQL") logger.Println("Connected to PostgreSQL")
// Start health check HTTP server ctx := context.Background()
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
<-sigChan <-sigChan
logger.Info().Msg("Shutting down gracefully...") logger.Println("Shutting down...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0) os.Exit(0)
}() }()
logger.Info().Msgf("Config: interval=%ds, batch=%d", sleepSec, batchSize) logger.Printf("Config: interval=%ds, batch=%d", sleepSec, batchSize)
ticker := time.NewTicker(time.Duration(sleepSec) * time.Second) ticker := time.NewTicker(time.Duration(sleepSec) * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-ticker.C: case <-ticker.C:
sources, err := getPendingURLs(ctx) sources, err := getPendingURLs(ctx)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error fetching URLs") logger.Printf("Error fetching URLs: %v", err)
continue continue
} }
if len(sources) == 0 { if len(sources) == 0 {
logger.Info().Msg("No pending URLs to process") logger.Println("No pending URLs to process")
continue continue
} }
logger.Info().Msgf("Processing %d sources", len(sources)) logger.Printf("Processing %d sources", len(sources))
for _, source := range sources { for _, source := range sources {
processURLSource(ctx, source) processURLSource(ctx, source)

View file

@ -6,31 +6,31 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers" "github.com/rss2/backend/internal/workers"
) )
var ( var (
logger *log.Logger
dbPool *pgxpool.Pool dbPool *pgxpool.Pool
qdrantURL string qdrantURL string
ollamaURL string
collection = "news_vectors" collection = "news_vectors"
sleepSec = 30 sleepSec = 30
batchSize = 100 batchSize = 100
) )
func init() { func init() {
logLevel := os.Getenv("LOG_LEVEL") logger = log.New(os.Stdout, "[QDRANT] ", log.LstdFlags)
logger.Init("qdrant-worker", logLevel)
} }
func loadConfig() { func loadConfig() {
@ -39,6 +39,7 @@ func loadConfig() {
qdrantHost := getEnv("QDRANT_HOST", "localhost") qdrantHost := getEnv("QDRANT_HOST", "localhost")
qdrantPort := getEnvInt("QDRANT_PORT", 6333) qdrantPort := getEnvInt("QDRANT_PORT", 6333)
qdrantURL = fmt.Sprintf("http://%s:%d", qdrantHost, qdrantPort) qdrantURL = fmt.Sprintf("http://%s:%d", qdrantHost, qdrantPort)
ollamaURL = getEnv("OLLAMA_URL", "http://ollama:11434")
collection = getEnv("QDRANT_COLLECTION", "news_vectors") collection = getEnv("QDRANT_COLLECTION", "news_vectors")
} }
@ -60,7 +61,7 @@ func getEnvInt(key string,defaultValue int) int {
type Translation struct { type Translation struct {
ID int64 ID int64
NoticiaID string NoticiaID int64
Lang string Lang string
Titulo string Titulo string
Resumen string Resumen string
@ -69,7 +70,6 @@ type Translation struct {
FuenteNombre string FuenteNombre string
CategoriaID *int64 CategoriaID *int64
PaisID *int64 PaisID *int64
Embedding []float64
} }
func getPendingTranslations(ctx context.Context) ([]Translation, error) { func getPendingTranslations(ctx context.Context) ([]Translation, error) {
@ -84,11 +84,9 @@ func getPendingTranslations(ctx context.Context) ([]Translation, error) {
n.fecha, n.fecha,
n.fuente_nombre, n.fuente_nombre,
n.categoria_id, n.categoria_id,
n.pais_id, n.pais_id
te.embedding::text
FROM traducciones t FROM traducciones t
INNER JOIN noticias n ON t.noticia_id = n.id INNER JOIN noticias n ON t.noticia_id = n.id
INNER JOIN traduccion_embeddings te ON te.traduccion_id = t.id
WHERE t.vectorized = FALSE WHERE t.vectorized = FALSE
AND t.status = 'done' AND t.status = 'done'
ORDER BY t.created_at ASC ORDER BY t.created_at ASC
@ -102,41 +100,54 @@ func getPendingTranslations(ctx context.Context) ([]Translation, error) {
var translations []Translation var translations []Translation
for rows.Next() { for rows.Next() {
var t Translation var t Translation
var embText string
if err := rows.Scan( if err := rows.Scan(
&t.ID, &t.NoticiaID, &t.Lang, &t.Titulo, &t.Resumen, &t.ID, &t.NoticiaID, &t.Lang, &t.Titulo, &t.Resumen,
&t.URL, &t.Fecha, &t.FuenteNombre, &t.CategoriaID, &t.PaisID, &t.URL, &t.Fecha, &t.FuenteNombre, &t.CategoriaID, &t.PaisID,
&embText,
); err != nil { ); err != nil {
logger.Printf("Error scanning translation row: %v", err)
continue continue
} }
emb, err := parseEmbedding(embText)
if err != nil {
logger.Printf("Error parsing embedding for translation %d: %v", t.ID, err)
continue
}
t.Embedding = emb
translations = append(translations, t) translations = append(translations, t)
} }
return translations, nil return translations, nil
} }
func parseEmbedding(s string) ([]float64, error) { type EmbeddingRequest struct {
s = strings.Trim(strings.TrimSpace(s), "{}") Model string `json:"model"`
if s == "" { Input string `json:"input"`
return nil, fmt.Errorf("empty embedding")
} }
parts := strings.Split(s, ",")
emb := make([]float64, len(parts)) type EmbeddingResponse struct {
for i, p := range parts { Embedding []float64 `json:"embedding"`
v, err := strconv.ParseFloat(strings.TrimSpace(p), 64) }
func generateEmbedding(text string) ([]float64, error) {
reqBody := EmbeddingRequest{
Model: "mxbai-embed-large",
Input: text,
}
body, err := json.Marshal(reqBody)
if err != nil { if err != nil {
return nil, err return nil, err
} }
emb[i] = v
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Post(ollamaURL+"/api/embeddings", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
} }
return emb, nil defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Ollama returned status %d", resp.StatusCode)
}
var result EmbeddingResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Embedding, nil
} }
type QdrantPoint struct { type QdrantPoint struct {
@ -149,7 +160,7 @@ type QdrantUpsertRequest struct {
Points []QdrantPoint `json:"points"` Points []QdrantPoint `json:"points"`
} }
func ensureCollection(ctx context.Context) error { func ensureCollection() error {
req, err := http.NewRequest("GET", qdrantURL+"/collections/"+collection, nil) req, err := http.NewRequest("GET", qdrantURL+"/collections/"+collection, nil)
if err != nil { if err != nil {
return err return err
@ -166,15 +177,16 @@ func ensureCollection(ctx context.Context) error {
return nil return nil
} }
// Get embedding dimension from stored embeddings // Get embedding dimension
var dimension int emb, err := generateEmbedding("test")
err = dbPool.QueryRow(ctx, `SELECT dim FROM traduccion_embeddings LIMIT 1`).Scan(&dimension)
if err != nil { if err != nil {
return fmt.Errorf("failed to get embedding dimension: %w", err) return fmt.Errorf("failed to get embedding dimension: %w", err)
} }
dimension := len(emb)
// Create collection // Create collection
createReq := map[string]interface{}{ createReq := map[string]interface{}{
"name": collection,
"vectors": map[string]interface{}{ "vectors": map[string]interface{}{
"size": dimension, "size": dimension,
"distance": "Cosine", "distance": "Cosine",
@ -182,35 +194,26 @@ func ensureCollection(ctx context.Context) error {
} }
body, _ := json.Marshal(createReq) body, _ := json.Marshal(createReq)
createURL := qdrantURL + "/collections/" + collection resp2, err := http.Post(qdrantURL+"/collections", "application/json", bytes.NewReader(body))
req2, err := http.NewRequest(http.MethodPut, createURL, bytes.NewReader(body))
if err != nil {
return err
}
req2.Header.Set("Content-Type", "application/json")
resp2, err := http.DefaultClient.Do(req2)
if err != nil { if err != nil {
return err return err
} }
defer resp2.Body.Close() defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body)
return fmt.Errorf("Qdrant create collection returned %d: %s", resp2.StatusCode, string(respBody))
}
logger.Printf("Created collection %s with dimension %d", collection, dimension) logger.Printf("Created collection %s with dimension %d", collection, dimension)
return nil return nil
} }
func uploadToQdrant(translations []Translation, pointIDs []string) error { func uploadToQdrant(translations []Translation, embeddings [][]float64) error {
points := make([]QdrantPoint, 0, len(translations)) points := make([]QdrantPoint, 0, len(translations))
for i, t := range translations { for i, t := range translations {
if t.Embedding == nil || len(t.Embedding) == 0 { if embeddings[i] == nil {
continue continue
} }
pointID := uuid.New().String()
payload := map[string]interface{}{ payload := map[string]interface{}{
"news_id": t.NoticiaID, "news_id": t.NoticiaID,
"traduccion_id": t.ID, "traduccion_id": t.ID,
@ -232,8 +235,8 @@ func uploadToQdrant(translations []Translation, pointIDs []string) error {
} }
points = append(points, QdrantPoint{ points = append(points, QdrantPoint{
ID: pointIDs[i], ID: pointID,
Vector: t.Embedding, Vector: embeddings[i],
Payload: payload, Payload: payload,
}) })
} }
@ -249,12 +252,7 @@ func uploadToQdrant(translations []Translation, pointIDs []string) error {
} }
url := fmt.Sprintf("%s/collections/%s/points", qdrantURL, collection) url := fmt.Sprintf("%s/collections/%s/points", qdrantURL, collection)
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(body)) resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return err return err
} }
@ -310,33 +308,17 @@ func main() {
cfg := workers.LoadDBConfig() cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil { if err := workers.Connect(cfg); err != nil {
logger.Fatal().Err(err).Msg("Failed to connect to database") logger.Fatalf("Failed to connect to database: %v", err)
} }
dbPool = workers.GetPool() dbPool = workers.GetPool()
defer workers.Close() defer workers.Close()
logger.Info().Msg("Connected to PostgreSQL") logger.Println("Connected to PostgreSQL")
// Start health check HTTP server ctx := context.Background()
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background()) if err := ensureCollection(); err != nil {
logger.Printf("Warning: Could not ensure collection: %v", err)
if err := ensureCollection(ctx); err != nil {
logger.Warn().Err(err).Msg("Could not ensure collection")
} }
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
@ -344,60 +326,65 @@ func main() {
go func() { go func() {
<-sigChan <-sigChan
logger.Info().Msg("Shutting down gracefully...") logger.Println("Shutting down...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0) os.Exit(0)
}() }()
logger.Info().Msgf("Config: qdrant=%s, collection=%s, sleep=%ds, batch=%d", logger.Printf("Config: qdrant=%s, ollama=%s, collection=%s, sleep=%ds, batch=%d",
qdrantURL, collection, sleepSec, batchSize) qdrantURL, ollamaURL, collection, sleepSec, batchSize)
totalProcessed := 0 totalProcessed := 0
for { for {
select { select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-time.After(time.Duration(sleepSec) * time.Second): case <-time.After(time.Duration(sleepSec) * time.Second):
translations, err := getPendingTranslations(ctx) translations, err := getPendingTranslations(ctx)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error fetching pending translations") logger.Printf("Error fetching pending translations: %v", err)
continue continue
} }
if len(translations) == 0 { if len(translations) == 0 {
logger.Info().Msg("No pending translations to process") logger.Println("No pending translations to process")
continue continue
} }
logger.Info().Msgf("Processing %d translations...", len(translations)) logger.Printf("Processing %d translations...", len(translations))
// Generate point IDs once (used both in Qdrant and DB) // Generate embeddings
embeddings := make([][]float64, len(translations))
for i, t := range translations {
text := fmt.Sprintf("%s %s", t.Titulo, t.Resumen)
emb, err := generateEmbedding(text)
if err != nil {
logger.Printf("Error generating embedding for %d: %v", t.ID, err)
continue
}
embeddings[i] = emb
}
// Upload to Qdrant
if err := uploadToQdrant(translations, embeddings); err != nil {
logger.Printf("Error uploading to Qdrant: %v", err)
continue
}
// Update DB status
pointIDs := make([]string, len(translations)) pointIDs := make([]string, len(translations))
for i := range translations { for i := range translations {
pointIDs[i] = uuid.New().String() pointIDs[i] = uuid.New().String()
} }
// Upload to Qdrant
if err := uploadToQdrant(translations, pointIDs); err != nil {
logger.Error().Err(err).Msg("Error uploading to Qdrant")
continue
}
// Update DB status
if err := updateTranslationStatus(ctx, translations, pointIDs); err != nil { if err := updateTranslationStatus(ctx, translations, pointIDs); err != nil {
logger.Error().Err(err).Msg("Error updating status") logger.Printf("Error updating status: %v", err)
} }
totalProcessed += len(translations) totalProcessed += len(translations)
logger.Info().Msgf("Processed %d translations (total: %d)", len(translations), totalProcessed) logger.Printf("Processed %d translations (total: %d)", len(translations), totalProcessed)
total, vectorized, pending, err := getStats(ctx) total, vectorized, pending, err := getStats(ctx)
if err == nil { if err == nil {
logger.Info().Msgf("Stats: total=%d, vectorized=%d, pending=%d", total, vectorized, pending) logger.Printf("Stats: total=%d, vectorized=%d, pending=%d", total, vectorized, pending)
} }
} }
} }

View file

@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"log" "log"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
@ -11,11 +10,11 @@ import (
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers" "github.com/rss2/backend/internal/workers"
) )
var ( var (
logger *log.Logger
dbPool *pgxpool.Pool dbPool *pgxpool.Pool
sleepSec = 10 sleepSec = 10
topK = 10 topK = 10
@ -24,8 +23,7 @@ var (
) )
func init() { func init() {
logLevel := os.Getenv("LOG_LEVEL") logger = log.New(os.Stdout, "[RELATED] ", log.LstdFlags)
logger.Init("related-worker", logLevel)
} }
func loadConfig() { func loadConfig() {
@ -337,36 +335,20 @@ func processBatch(ctx context.Context, model string) (int, error) {
func main() { func main() {
loadConfig() loadConfig()
logger.Info().Msg("Starting Related News Worker") logger.Println("Starting Related News Worker")
cfg := workers.LoadDBConfig() cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil { if err := workers.Connect(cfg); err != nil {
logger.Fatal().Err(err).Msg("Failed to connect to database") logger.Fatalf("Failed to connect to database: %v", err)
} }
dbPool = workers.GetPool() dbPool = workers.GetPool()
defer workers.Close() defer workers.Close()
// Start health check HTTP server ctx := context.Background()
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
// Ensure schema // Ensure schema
if err := ensureSchema(ctx); err != nil { if err := ensureSchema(ctx); err != nil {
logger.Error().Err(err).Msg("Error ensuring schema") logger.Printf("Error ensuring schema: %v", err)
} }
model := os.Getenv("EMB_MODEL") model := os.Getenv("EMB_MODEL")
@ -379,29 +361,23 @@ func main() {
go func() { go func() {
<-sigChan <-sigChan
logger.Info().Msg("Shutting down gracefully...") logger.Println("Shutting down...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0) os.Exit(0)
}() }()
logger.Info().Msgf("Config: sleep=%ds, topK=%d, batch=%d, model=%s", sleepSec, topK, batchSz, model) logger.Printf("Config: sleep=%ds, topK=%d, batch=%d, model=%s", sleepSec, topK, batchSz, model)
for { for {
select { select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-time.After(time.Duration(sleepSec) * time.Second): case <-time.After(time.Duration(sleepSec) * time.Second):
count, err := processBatch(ctx, model) count, err := processBatch(ctx, model)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error processing batch") logger.Printf("Error processing batch: %v", err)
continue continue
} }
if count > 0 { if count > 0 {
logger.Info().Msgf("Generated related news for %d translations", count) logger.Printf("Generated related news for %d translations", count)
} }
} }
} }

View file

@ -4,29 +4,26 @@ import (
"context" "context"
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"io" "log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers" "github.com/rss2/backend/internal/workers"
) )
var ( var (
logger *log.Logger
dbPool *workers.Config
pool *pgxpool.Pool pool *pgxpool.Pool
sleepInterval = 60 sleepInterval = 60
batchSize = 10 batchSize = 10
enrichLimit = 20
scraperWorkers = 5
maxBodyBytes = int64(512 << 10)
) )
type URLSource struct { type URLSource struct {
@ -39,14 +36,6 @@ type URLSource struct {
Active bool Active bool
} }
type Noticia struct {
ID string
Titulo string
Resumen string
URL string
FuenteNombre string
}
type Article struct { type Article struct {
Title string Title string
Summary string Summary string
@ -57,31 +46,13 @@ type Article struct {
} }
func init() { func init() {
logLevel := os.Getenv("LOG_LEVEL") logger = log.New(os.Stdout, "[SCRAPER] ", log.LstdFlags)
logger.Init("scraper-worker", logLevel) logger.SetOutput(os.Stdout)
} }
func loadConfig() { func loadConfig() {
if v := os.Getenv("SCRAPER_INTERVAL"); v != "" { sleepInterval = getEnvInt("SCRAPER_SLEEP", 60)
if i, err := strconv.Atoi(v); err == nil && i > 0 { batchSize = getEnvInt("SCRAPER_BATCH", 10)
sleepInterval = i
}
}
if v := os.Getenv("SCRAPER_BATCH"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 {
batchSize = i
}
}
if v := os.Getenv("SCRAPER_ENRICH_LIMIT"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 {
enrichLimit = i
}
}
if v := os.Getenv("SCRAPER_WORKERS"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 {
scraperWorkers = i
}
}
} }
func getEnvInt(key string, defaultValue int) int { func getEnvInt(key string, defaultValue int) int {
@ -95,9 +66,9 @@ func getEnvInt(key string, defaultValue int) int {
func getActiveURLs(ctx context.Context) ([]URLSource, error) { func getActiveURLs(ctx context.Context) ([]URLSource, error) {
rows, err := pool.Query(ctx, ` rows, err := pool.Query(ctx, `
SELECT id, nombre, url, categoria_id, pais_id, idioma, active SELECT id, nombre, url, categoria_id, pais_id, idioma, activo
FROM fuentes_url FROM fuentes_url
WHERE active = true WHERE activo = true
`) `)
if err != nil { if err != nil {
return nil, err return nil, err
@ -128,39 +99,6 @@ func updateSourceStatus(ctx context.Context, sourceID int64, status, message str
return err return err
} }
func getNoticiasToEnrich(ctx context.Context, limit int) ([]Noticia, error) {
rows, err := pool.Query(ctx, `
SELECT id, titulo, COALESCE(resumen, ''), url, fuente_nombre
FROM noticias
WHERE (resumen IS NULL OR LENGTH(resumen) < 200)
ORDER BY fecha DESC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var noticias []Noticia
for rows.Next() {
var n Noticia
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.FuenteNombre); err != nil {
continue
}
noticias = append(noticias, n)
}
return noticias, nil
}
func updateNoticiaResumen(ctx context.Context, noticiaID, newResumen string) error {
_, err := pool.Exec(ctx, `
UPDATE noticias
SET resumen = $1
WHERE id = $2
`, newResumen, noticiaID)
return err
}
func extractArticle(source URLSource) (*Article, error) { func extractArticle(source URLSource) (*Article, error) {
client := &http.Client{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
@ -185,7 +123,7 @@ func extractArticle(source URLSource) (*Article, error) {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode) return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
} }
doc, err := goquery.NewDocumentFromReader(io.LimitReader(resp.Body, maxBodyBytes)) doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -248,94 +186,6 @@ func extractArticle(source URLSource) (*Article, error) {
return article, nil return article, nil
} }
func extractContentFromURL(url string) (string, error) {
client := &http.Client{
Timeout: 30 * time.Second,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return "", err
}
contentSelectors := []string{
"article",
"[role='main']",
"main",
".article-content",
".post-content",
".entry-content",
".content",
"#content",
".story-body",
".article-body",
}
var content string
for _, sel := range contentSelectors {
content = doc.Find(sel).First().Text()
if len(content) > 100 {
break
}
}
if len(content) < 100 {
ps := doc.Find("p")
var paragraphs []string
ps.Each(func(i int, s *goquery.Selection) {
txt := strings.TrimSpace(s.Text())
if len(txt) > 50 {
paragraphs = append(paragraphs, txt)
}
})
content = strings.Join(paragraphs, "\n\n")
}
return strings.TrimSpace(content), nil
}
func processEnrichment(ctx context.Context, noticia Noticia) bool {
logger.Printf("Enriching: %s", noticia.Titulo)
content, err := extractContentFromURL(noticia.URL)
if err != nil {
logger.Printf("Error extracting content from %s: %v", noticia.URL, err)
return false
}
if len(content) < 50 {
logger.Printf("Not enough content extracted from %s", noticia.URL)
return false
}
if err := updateNoticiaResumen(ctx, noticia.ID, content); err != nil {
logger.Printf("Error updating resumen for %s: %v", noticia.ID, err)
return false
}
logger.Printf("Enriched: %s (content length: %d)", noticia.Titulo, len(content))
return true
}
func saveArticle(ctx context.Context, source URLSource, article *Article) (bool, error) { func saveArticle(ctx context.Context, source URLSource, article *Article) (bool, error) {
finalURL := article.URL finalURL := article.URL
if finalURL == "" { if finalURL == "" {
@ -425,71 +275,20 @@ func processSource(ctx context.Context, source URLSource) {
} }
} }
// enrichConcurrent enriches noticias in parallel with a bounded worker pool.
func enrichConcurrent(ctx context.Context, noticias []Noticia) {
limiter := make(chan struct{}, scraperWorkers)
var wg sync.WaitGroup
for _, noticia := range noticias {
wg.Add(1)
limiter <- struct{}{}
go func(n Noticia) {
defer wg.Done()
defer func() { <-limiter }()
if processEnrichment(ctx, n) {
time.Sleep(1 * time.Second)
}
}(noticia)
}
wg.Wait()
}
// scrapeSources processes fuentes_url in parallel with a bounded worker pool.
func scrapeSources(ctx context.Context, sources []URLSource) {
limiter := make(chan struct{}, scraperWorkers)
var wg sync.WaitGroup
for _, source := range sources {
wg.Add(1)
limiter <- struct{}{}
go func(src URLSource) {
defer wg.Done()
defer func() { <-limiter }()
time.Sleep(1 * time.Second) // polite pacing across workers
processSource(ctx, src)
}(source)
}
wg.Wait()
}
func main() { func main() {
loadConfig() loadConfig()
logger.Info().Msg("Starting Scraper Worker") logger.Println("Starting Scraper Worker")
cfg := workers.LoadDBConfig() cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil { if err := workers.Connect(cfg); err != nil {
logger.Fatal().Err(err).Msg("Failed to connect to database") logger.Fatalf("Failed to connect to database: %v", err)
} }
pool = workers.GetPool() pool = workers.GetPool()
defer workers.Close() defer workers.Close()
logger.Info().Msg("Connected to PostgreSQL") logger.Println("Connected to PostgreSQL")
// Start health check HTTP server ctx := context.Background()
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
// Handle shutdown // Handle shutdown
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
@ -497,45 +296,35 @@ func main() {
go func() { go func() {
<-sigChan <-sigChan
logger.Info().Msg("Shutting down gracefully...") logger.Println("Shutting down...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0) os.Exit(0)
}() }()
logger.Info().Msgf("Config: sleep=%ds, batch=%d, enrich=%d", sleepInterval, batchSize, enrichLimit) logger.Printf("Config: sleep=%ds, batch=%d", sleepInterval, batchSize)
ticker := time.NewTicker(time.Duration(sleepInterval) * time.Second) ticker := time.NewTicker(time.Duration(sleepInterval) * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-ticker.C: case <-ticker.C:
noticias, err := getNoticiasToEnrich(ctx, enrichLimit)
if err != nil {
logger.Error().Err(err).Msg("Error fetching noticias to enrich")
} else if len(noticias) > 0 {
logger.Info().Msgf("Enriching %d noticias (%d workers)", len(noticias), scraperWorkers)
enrichConcurrent(ctx, noticias)
}
sources, err := getActiveURLs(ctx) sources, err := getActiveURLs(ctx)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error fetching URLs") logger.Printf("Error fetching URLs: %v", err)
continue continue
} }
if len(sources) == 0 { if len(sources) == 0 {
logger.Info().Msg("No active URLs to process") logger.Println("No active URLs to process")
continue continue
} }
logger.Info().Msgf("Processing %d sources (%d workers)", len(sources), scraperWorkers) logger.Printf("Processing %d sources", len(sources))
scrapeSources(ctx, sources)
for _, source := range sources {
processSource(ctx, source)
time.Sleep(2 * time.Second) // Rate limiting
}
} }
} }
} }

View file

@ -3,27 +3,22 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth"
"github.com/rss2/backend/internal/cache" "github.com/rss2/backend/internal/cache"
"github.com/rss2/backend/internal/config" "github.com/rss2/backend/internal/config"
"github.com/rss2/backend/internal/db" "github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/handlers" "github.com/rss2/backend/internal/handlers"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/middleware" "github.com/rss2/backend/internal/middleware"
"github.com/rss2/backend/internal/services" "github.com/rss2/backend/internal/services"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "github.com/rss2/backend/docs"
) )
func initDB() { func initDB() {
ctx := context.Background() ctx := context.Background()
log := logger.GetLogger()
// Crear tabla entity_aliases si no existe // Crear tabla entity_aliases si no existe
_, err := db.GetPool().Exec(ctx, ` _, err := db.GetPool().Exec(ctx, `
@ -37,9 +32,9 @@ func initDB() {
) )
`) `)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Could not create entity_aliases table") log.Printf("Warning: Could not create entity_aliases table: %v", err)
} else { } else {
log.Info().Msg("Table entity_aliases ready") log.Println("Table entity_aliases ready")
} }
// Añadir columna role a users si no existe // Añadir columna role a users si no existe
@ -47,9 +42,9 @@ func initDB() {
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'user' ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'user'
`) `)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Could not add role column") log.Printf("Warning: Could not add role column: %v", err)
} else { } else {
log.Info().Msg("Column role ready") log.Println("Column role ready")
} }
// Crear tabla de configuración si no existe // Crear tabla de configuración si no existe
@ -79,120 +74,31 @@ func initDB() {
INSERT INTO config (key, value) VALUES ('translator_status', 'stopped') INSERT INTO config (key, value) VALUES ('translator_status', 'stopped')
ON CONFLICT (key) DO NOTHING ON CONFLICT (key) DO NOTHING
`) `)
// Crear tabla de alertas si no existe
_, err = db.GetPool().Exec(ctx, `
CREATE TABLE IF NOT EXISTS alertas (
id SERIAL PRIMARY KEY,
valor VARCHAR(255) NOT NULL,
tipo VARCHAR(32) NOT NULL,
periodo DATE NOT NULL,
hits INT NOT NULL,
baseline DOUBLE PRECISION NOT NULL,
ratio DOUBLE PRECISION NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'nueva',
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(valor, tipo, periodo)
)
`)
if err != nil {
log.Printf("Warning: Could not create alertas table: %v", err)
} else {
log.Println("Table alertas ready")
}
// Crear tabla de remote_workers si no existe
_, err = db.GetPool().Exec(ctx, `
CREATE TABLE IF NOT EXISTS remote_workers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
api_key VARCHAR(64) UNIQUE NOT NULL,
capabilities VARCHAR(50) DEFAULT 'cpu',
status VARCHAR(20) DEFAULT 'offline',
last_seen TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
)
`)
if err != nil {
log.Printf("Warning: Could not create remote_workers table: %v", err)
} else {
log.Println("Table remote_workers ready")
}
// Añadir columnas a traducciones para workers remotos
_, err = db.GetPool().Exec(ctx, `
ALTER TABLE traducciones ADD COLUMN IF NOT EXISTS worker_id INTEGER REFERENCES remote_workers(id)
`)
if err != nil {
log.Printf("Warning: Could not add worker_id column: %v", err)
}
_, err = db.GetPool().Exec(ctx, `
ALTER TABLE traducciones ADD COLUMN IF NOT EXISTS assigned_at TIMESTAMP
`)
if err != nil {
log.Printf("Warning: Could not add assigned_at column: %v", err)
}
// Crear índices para optimizar consultas críticas
indexes := []string{
"CREATE INDEX IF NOT EXISTS idx_noticias_fecha ON noticias(fecha DESC)",
"CREATE INDEX IF NOT EXISTS idx_noticias_fuente_nombre ON noticias(fuente_nombre)",
"CREATE INDEX IF NOT EXISTS idx_noticias_categoria_id ON noticias(categoria_id)",
"CREATE INDEX IF NOT EXISTS idx_noticias_pais_id ON noticias(pais_id)",
"CREATE INDEX IF NOT EXISTS idx_noticias_lang ON noticias(lang)",
"CREATE INDEX IF NOT EXISTS idx_traducciones_noticia_id_lang_to ON traducciones(noticia_id, lang_to)",
"CREATE INDEX IF NOT EXISTS idx_traducciones_status ON traducciones(status)",
"CREATE INDEX IF NOT EXISTS idx_tags_noticia_noticia_id_tag_id ON tags_noticia(noticia_id, tag_id)",
"CREATE INDEX IF NOT EXISTS idx_tags_noticia_traduccion_id ON tags_noticia(traduccion_id)",
"CREATE INDEX IF NOT EXISTS idx_tags_valor_tipo ON tags(valor, tipo)",
"CREATE INDEX IF NOT EXISTS idx_entity_aliases_alias_tipo ON entity_aliases(alias, tipo)",
"CREATE INDEX IF NOT EXISTS idx_alertas_valor_tipo_periodo ON alertas(valor, tipo, periodo)",
"CREATE INDEX IF NOT EXISTS idx_alertas_status ON alertas(status)",
"CREATE INDEX IF NOT EXISTS idx_user_search_tags_user_id ON user_search_tags(user_id)",
"CREATE INDEX IF NOT EXISTS idx_feeds_activo ON feeds(activo)",
"CREATE INDEX IF NOT EXISTS idx_feeds_categoria_id ON feeds(categoria_id)",
"CREATE INDEX IF NOT EXISTS idx_feeds_pais_id ON feeds(pais_id)",
}
for _, idx := range indexes {
_, err := db.GetPool().Exec(ctx, idx)
if err != nil {
log.Warn().Err(err).Msg("Could not create index")
} else {
log.Info().Msg("Index created/verified")
}
}
} }
func main() { func main() {
cfg := config.Load() cfg := config.Load()
// Initialize structured logger
logLevel := os.Getenv("LOG_LEVEL")
logger.Init("rss2-api", logLevel)
log := logger.GetLogger()
if err := db.Connect(cfg.DatabaseURL); err != nil { if err := db.Connect(cfg.DatabaseURL); err != nil {
log.Fatal().Err(err).Msg("Failed to connect to database") log.Fatalf("Failed to connect to database: %v", err)
} }
defer db.Close() defer db.Close()
log.Info().Msg("Connected to PostgreSQL") log.Println("Connected to PostgreSQL")
// Auto-setup DB tables // Auto-setup DB tables
initDB() initDB()
if err := cache.Connect(cfg.RedisURL); err != nil { if err := cache.Connect(cfg.RedisURL); err != nil {
log.Warn().Err(err).Msg("Failed to connect to Redis") log.Printf("Warning: Failed to connect to Redis: %v", err)
} else { } else {
defer cache.Close() defer cache.Close()
log.Info().Msg("Connected to Redis") log.Println("Connected to Redis")
} }
services.Init(cfg) services.Init(cfg)
r := gin.Default() r := gin.Default()
r.Use(middleware.RequestIDMiddleware())
r.Use(middleware.CORSMiddleware()) r.Use(middleware.CORSMiddleware())
r.Use(middleware.LoggerMiddleware()) r.Use(middleware.LoggerMiddleware())
@ -200,25 +106,18 @@ func main() {
c.JSON(200, gin.H{"status": "ok"}) c.JSON(200, gin.H{"status": "ok"})
}) })
// Swagger documentation
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
api := r.Group("/api") api := r.Group("/api")
{ {
// Serve static images downloaded by wiki_worker // Serve static images downloaded by wiki_worker
api.StaticFS("/wiki-images", gin.Dir(cfg.WikiImagesPath, false)) wikiImagesDir := os.Getenv("WIKI_IMAGES_PATH")
if wikiImagesDir == "" {
// Stricter rate limiting for auth endpoints wikiImagesDir = "/opt/rss2/data/wiki_images"
authGroup := api.Group("/auth")
authGroup.Use(middleware.RateLimitMiddleware(10)) // 10 req/min for auth
{
authGroup.POST("/login", handlers.Login)
authGroup.POST("/register", handlers.Register)
authGroup.GET("/check-first-user", handlers.CheckFirstUser)
} }
api.StaticFS("/wiki-images", gin.Dir(wikiImagesDir, false))
// General rate limiting for API api.POST("/auth/login", handlers.Login)
api.Use(middleware.RateLimitMiddleware(cfg.RateLimitPerMinute)) api.POST("/auth/register", handlers.Register)
api.GET("/auth/check-first-user", handlers.CheckFirstUser)
news := api.Group("/news") news := api.Group("/news")
{ {
@ -241,16 +140,8 @@ func main() {
} }
api.GET("/search", handlers.SearchNews) api.GET("/search", handlers.SearchNews)
api.GET("/search/suggestions", middleware.AuthRequired(), handlers.SearchSuggestions)
api.POST("/searchlog", middleware.AuthRequired(), handlers.LogSearch)
api.GET("/entities", handlers.GetEntities) api.GET("/entities", handlers.GetEntities)
api.GET("/entities/news", handlers.GetEntityNews)
api.GET("/entities/mentions", handlers.GetEntityMentions)
api.GET("/alerts", handlers.GetAlertas)
api.POST("/alerts/:id/read", middleware.AuthRequired(), handlers.MarkAlertaRead)
api.POST("/alerts/read-all", middleware.AuthRequired(), handlers.MarkAllAlertasRead)
api.GET("/stats", handlers.GetStats) api.GET("/stats", handlers.GetStats)
@ -266,28 +157,16 @@ func main() {
admin.POST("/entities/retype", handlers.PatchEntityTipo) admin.POST("/entities/retype", handlers.PatchEntityTipo)
admin.GET("/backup", handlers.BackupDatabase) admin.GET("/backup", handlers.BackupDatabase)
admin.GET("/backup/news", handlers.BackupNewsZipped) admin.GET("/backup/news", handlers.BackupNewsZipped)
admin.POST("/restore", handlers.RestoreDatabase)
admin.GET("/users", handlers.GetUsers) admin.GET("/users", handlers.GetUsers)
admin.POST("/users/:id/promote", handlers.PromoteUser) admin.POST("/users/:id/promote", handlers.PromoteUser)
admin.POST("/users/:id/demote", handlers.DemoteUser) admin.POST("/users/:id/demote", handlers.DemoteUser)
admin.POST("/reset-db", handlers.ResetDatabase) admin.POST("/reset-db", handlers.ResetDatabase)
admin.POST("/alerts/scan", handlers.ScanAlertasAdmin)
admin.GET("/workers/status", handlers.GetWorkerStatus) admin.GET("/workers/status", handlers.GetWorkerStatus)
admin.GET("/workers/stats", handlers.GetTranslationStats)
admin.POST("/workers/config", handlers.SetWorkerConfig) admin.POST("/workers/config", handlers.SetWorkerConfig)
admin.POST("/workers/start", handlers.StartWorkers) admin.POST("/workers/start", handlers.StartWorkers)
admin.POST("/workers/stop", handlers.StopWorkers) admin.POST("/workers/stop", handlers.StopWorkers)
admin.GET("/workers/remote", handlers.ListRemoteWorkers)
admin.POST("/workers/remote", handlers.CreateRemoteWorker)
admin.GET("/workers/remote/:id", handlers.GetRemoteWorker)
admin.DELETE("/workers/remote/:id", handlers.DeleteRemoteWorker)
admin.POST("/workers/remote/:id/toggle", handlers.ToggleRemoteWorker)
admin.POST("/workers/remote/:id/regenerate-key", handlers.RegenerateAPIKey)
} }
r.GET("/ws/worker", handlers.HandleWorkerWS)
auth := api.Group("/auth") auth := api.Group("/auth")
auth.Use(middleware.AuthRequired()) auth.Use(middleware.AuthRequired())
{ {
@ -295,24 +174,21 @@ func main() {
} }
} }
auth.SetJWTSecret(cfg.SecretKey) middleware.SetJWTSecret(cfg.SecretKey)
port := cfg.ServerPort port := cfg.ServerPort
addr := fmt.Sprintf(":%s", port) addr := fmt.Sprintf(":%s", port)
go func() { go func() {
log.Info().Str("addr", addr).Msg("Server starting") log.Printf("Server starting on %s", addr)
if err := r.Run(addr); err != nil { if err := r.Run(addr); err != nil {
log.Fatal().Err(err).Msg("Failed to start server") log.Fatalf("Failed to start server: %v", err)
} }
}() }()
handlers.StartJobAssigner()
handlers.StartAlertScanner()
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
log.Info().Msg("Shutting down server...") log.Println("Shutting down server...")
} }

View file

@ -2,9 +2,7 @@ package main
import ( import (
"context" "context"
"fmt"
"log" "log"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
@ -13,11 +11,11 @@ import (
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers" "github.com/rss2/backend/internal/workers"
) )
var ( var (
logger *log.Logger
dbPool *pgxpool.Pool dbPool *pgxpool.Pool
sleepSec = 10 sleepSec = 10
batchSz = 500 batchSz = 500
@ -36,8 +34,7 @@ type Country struct {
} }
func init() { func init() {
logLevel := os.Getenv("LOG_LEVEL") logger = log.New(os.Stdout, "[TOPICS] ", log.LstdFlags)
logger.Init("topics-worker", logLevel)
} }
func loadConfig() { func loadConfig() {
@ -279,46 +276,29 @@ func processBatch(ctx context.Context, topics []Topic, countries []Country) (int
processedIDs = append(processedIDs, item.ID) processedIDs = append(processedIDs, item.ID)
} }
// Insert topic relations (batched, upsert) // Insert topic relations
if len(topicMatches) > 0 { if len(topicMatches) > 0 {
const chunkSize = 500 for _, tm := range topicMatches {
for i := 0; i < len(topicMatches); i += chunkSize { _, err := dbPool.Exec(ctx, `
end := i + chunkSize
if end > len(topicMatches) {
end = len(topicMatches)
}
chunk := topicMatches[i:end]
values := make([]string, 0, len(chunk))
args := make([]interface{}, 0, len(chunk)*3)
for j, tm := range chunk {
values = append(values, fmt.Sprintf("($%d, $%d, $%d)", j*3+1, j*3+2, j*3+3))
args = append(args, tm.NoticiaID, tm.TopicID, tm.Score)
}
query := fmt.Sprintf(`
INSERT INTO news_topics (noticia_id, topic_id, score) INSERT INTO news_topics (noticia_id, topic_id, score)
VALUES %s VALUES ($1, $2, $3)
ON CONFLICT (noticia_id, topic_id) DO UPDATE SET score = EXCLUDED.score ON CONFLICT (noticia_id, topic_id) DO UPDATE SET score = EXCLUDED.score
`, strings.Join(values, ",")) `, tm.NoticiaID, tm.TopicID, tm.Score)
if _, err := dbPool.Exec(ctx, query, args...); err != nil { if err != nil {
logger.Printf("Error batch inserting topics: %v", err) logger.Printf("Error inserting topic: %v", err)
} }
} }
} }
// Update country (single bulk UPDATE) // Update country
if len(countryUpdates) > 0 { if len(countryUpdates) > 0 {
paisSlice := make([]int32, len(countryUpdates)) for _, cu := range countryUpdates {
idSlice := make([]string, len(countryUpdates)) _, err := dbPool.Exec(ctx, `
for i, cu := range countryUpdates { UPDATE noticias SET pais_id = $1 WHERE id = $2
paisSlice[i] = int32(cu.PaisID) `, cu.PaisID, cu.NoticiaID)
idSlice[i] = cu.NoticiaID if err != nil {
logger.Printf("Error updating country: %v", err)
} }
if _, err := dbPool.Exec(ctx, `
UPDATE noticias n SET pais_id = u.pais
FROM unnest($1::int[], $2::varchar[]) AS u(pais, noticia_id)
WHERE n.id = u.noticia_id
`, paisSlice, idSlice); err != nil {
logger.Printf("Error bulk updating countries: %v", err)
} }
} }
@ -337,36 +317,20 @@ func processBatch(ctx context.Context, topics []Topic, countries []Country) (int
func main() { func main() {
loadConfig() loadConfig()
logger.Info().Msg("Starting Topics Worker") logger.Println("Starting Topics Worker")
cfg := workers.LoadDBConfig() cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil { if err := workers.Connect(cfg); err != nil {
logger.Fatal().Err(err).Msg("Failed to connect to database") logger.Fatalf("Failed to connect to database: %v", err)
} }
dbPool = workers.GetPool() dbPool = workers.GetPool()
defer workers.Close() defer workers.Close()
// Start health check HTTP server ctx := context.Background()
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
// Ensure schema // Ensure schema
if err := ensureSchema(ctx); err != nil { if err := ensureSchema(ctx); err != nil {
logger.Error().Err(err).Msg("Error ensuring schema") logger.Printf("Error ensuring schema: %v", err)
} }
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
@ -374,47 +338,41 @@ func main() {
go func() { go func() {
<-sigChan <-sigChan
logger.Info().Msg("Shutting down gracefully...") logger.Println("Shutting down...")
cancel()
time.Sleep(2 * time.Second)
workers.Close()
os.Exit(0) os.Exit(0)
}() }()
logger.Info().Msgf("Config: sleep=%ds, batch=%d", sleepSec, batchSz) logger.Printf("Config: sleep=%ds, batch=%d", sleepSec, batchSz)
for { for {
select { select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
case <-time.After(time.Duration(sleepSec) * time.Second): case <-time.After(time.Duration(sleepSec) * time.Second):
topics, err := loadTopics(ctx) topics, err := loadTopics(ctx)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error loading topics") logger.Printf("Error loading topics: %v", err)
continue continue
} }
if len(topics) == 0 { if len(topics) == 0 {
logger.Info().Msg("No topics found in DB") logger.Println("No topics found in DB")
time.Sleep(time.Duration(sleepSec) * time.Second) time.Sleep(time.Duration(sleepSec) * time.Second)
continue continue
} }
countries, err := loadCountries(ctx) countries, err := loadCountries(ctx)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error loading countries") logger.Printf("Error loading countries: %v", err)
continue continue
} }
count, err := processBatch(ctx, topics, countries) count, err := processBatch(ctx, topics, countries)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error processing batch") logger.Printf("Error processing batch: %v", err)
continue continue
} }
if count > 0 { if count > 0 {
logger.Info().Msgf("Processed %d news items", count) logger.Printf("Processed %d news items", count)
} }
if count < batchSz { if count < batchSz {

View file

@ -16,16 +16,15 @@ import (
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/logger"
"github.com/rss2/backend/internal/workers" "github.com/rss2/backend/internal/workers"
) )
var ( var (
logger *log.Logger
pool *pgxpool.Pool pool *pgxpool.Pool
sleepInterval = 30 sleepInterval = 30
batchSize = 20 batchSize = 50
imagesDir = "/app/data/wiki_images" imagesDir = "/opt/rss2/data/wiki_images"
maxImageBytes = int64(2 << 20)
) )
type WikiSummary struct { type WikiSummary struct {
@ -52,8 +51,7 @@ type Tag struct {
} }
func init() { func init() {
logLevel := os.Getenv("LOG_LEVEL") logger = log.New(os.Stdout, "[WIKI_WORKER] ", log.LstdFlags)
logger.Init("wiki-worker", logLevel)
} }
func getPendingTags(ctx context.Context) ([]Tag, error) { func getPendingTags(ctx context.Context) ([]Tag, error) {
@ -103,17 +101,13 @@ func downloadImage(imgURL, destPath string) error {
return fmt.Errorf("HTTP %d", resp.StatusCode) return fmt.Errorf("HTTP %d", resp.StatusCode)
} }
if resp.ContentLength > maxImageBytes {
return fmt.Errorf("image too large (%d bytes)", resp.ContentLength)
}
out, err := os.Create(destPath) out, err := os.Create(destPath)
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer out.Close()
_, err = io.Copy(out, io.LimitReader(resp.Body, maxImageBytes)) _, err = io.Copy(out, resp.Body)
return err return err
} }
@ -216,101 +210,61 @@ func processTag(ctx context.Context, tag Tag) {
} }
func main() { func main() {
var sleepVal, batchVal int if val := os.Getenv("WIKI_IMAGES_PATH"); val != "" {
imagesDir = val
}
if val := os.Getenv("WIKI_SLEEP"); val != "" { if val := os.Getenv("WIKI_SLEEP"); val != "" {
if _, err := fmt.Sscanf(val, "%d", &sleepVal); err == nil && sleepVal > 0 { if sleep, err := fmt.Sscanf(val, "%d", &sleepInterval); err == nil && sleep > 0 {
sleepInterval = sleepVal sleepInterval = sleep
}
}
if val := os.Getenv("WIKI_BATCH"); val != "" {
if _, err := fmt.Sscanf(val, "%d", &batchVal); err == nil && batchVal > 0 {
batchSize = batchVal
} }
} }
logger.Info().Msg("Iniciando Wiki Worker...") logger.Println("Iniciando Wiki Worker...")
if err := os.MkdirAll(imagesDir, 0755); err != nil { if err := os.MkdirAll(imagesDir, 0755); err != nil {
logger.Fatal().Err(err).Msg("Error creando directorio de imágenes") logger.Fatalf("Error creando directorio de imágenes: %v", err)
} }
cfg := workers.LoadDBConfig() cfg := workers.LoadDBConfig()
if err := workers.Connect(cfg); err != nil { if err := workers.Connect(cfg); err != nil {
logger.Fatal().Err(err).Msg("Failed to connect to database") logger.Fatalf("Failed to connect to database: %v", err)
} }
pool = workers.GetPool() pool = workers.GetPool()
defer workers.Close() defer workers.Close()
// Start health check HTTP server ctx := context.Background()
go func() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if err := workers.HealthCheck(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
logger.Info().Msg("Health check server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
logger.Error().Err(err).Msg("Health check server error")
}
}()
ctx, cancel := context.WithCancel(context.Background())
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
<-sigChan <-sigChan
logger.Info().Msg("Shutting down gracefully...") logger.Println("Cerrando gracefully...")
cancel()
// Give time for in-flight requests to complete
time.Sleep(2 * time.Second)
workers.Close() workers.Close()
os.Exit(0) os.Exit(0)
}() }()
logger.Info().Msgf("Configuración: sleep=%ds, batch=%d", sleepInterval, batchSize) logger.Printf("Configuración: sleep=%ds, batch=%d", sleepInterval, batchSize)
for { for {
select {
case <-ctx.Done():
logger.Info().Msg("Context cancelled, stopping worker")
return
default:
tags, err := getPendingTags(ctx) tags, err := getPendingTags(ctx)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error recuperando tags pendientes") logger.Printf("Error recuperando tags pendientes: %v", err)
select { time.Sleep(10 * time.Second)
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
continue continue
} }
if len(tags) == 0 { if len(tags) == 0 {
logger.Info().Msgf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval) logger.Printf("No hay tags pendientes. Durmiendo %d segundos...", sleepInterval)
select { time.Sleep(time.Duration(sleepInterval) * time.Second)
case <-ctx.Done():
return
case <-time.After(time.Duration(sleepInterval) * time.Second):
}
continue continue
} }
logger.Info().Msgf("Recuperados %d tags para procesar...", len(tags)) logger.Printf("Recuperados %d tags para procesar...", len(tags))
for _, tag := range tags { for _, tag := range tags {
processTag(ctx, tag) processTag(ctx, tag)
select { time.Sleep(3 * time.Second) // Increased delay to avoid Wikipedia Rate Limits (429)
case <-ctx.Done():
return
case <-time.After(3 * time.Second): // Increased delay to avoid Wikipedia Rate Limits (429)
}
}
} }
} }
} }

View file

@ -1,43 +0,0 @@
// Package docs provides Swagger documentation for the RSS2 API
//
// @title RSS2 API
// @version 1.0
// @description RSS2 News Aggregator API - A comprehensive news aggregation and analysis platform
// @termsOfService http://swagger.io/terms/
//
// @contact.name RSS2 Team
// @contact.url http://github.com/rss2
// @contact.email support@rss2.example.com
//
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
//
// @host localhost:8080
// @BasePath /api
//
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
//
// @tag.name auth
// @tag.description Authentication endpoints
//
// @tag.name news
// @tag.description News management endpoints
//
// @tag.name feeds
// @tag.description Feed management endpoints
//
// @tag.name search
// @tag.description Search and discovery endpoints
//
// @tag.name entities
// @tag.description Entity extraction and analysis
//
// @tag.name admin
// @tag.description Admin management endpoints
//
// @tag.name stats
// @tag.description Statistics and analytics
package docs

View file

@ -1,29 +1,19 @@
module github.com/rss2/backend module github.com/rss2/backend
go 1.23 go 1.22
toolchain go1.23.12
require ( require (
github.com/PuerkitoBio/goquery v1.9.2 github.com/PuerkitoBio/goquery v1.9.2
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.0.0 github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
github.com/jackc/pgx/v5 v5.4.3 github.com/jackc/pgx/v5 v5.4.3
github.com/mmcdole/gofeed v1.2.1 github.com/mmcdole/gofeed v1.2.1
github.com/redis/go-redis/v9 v9.0.5 github.com/redis/go-redis/v9 v9.0.5
github.com/rs/zerolog v1.32.0 golang.org/x/crypto v0.26.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
golang.org/x/crypto v0.28.0
golang.org/x/time v0.5.0
) )
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@ -31,39 +21,31 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mmcdole/goxpp v1.1.0 // indirect github.com/mmcdole/goxpp v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/swaggo/swag v1.16.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.30.0 // indirect golang.org/x/net v0.28.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect golang.org/x/text v0.17.0 // indirect
golang.org/x/tools v0.26.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View file

@ -1,11 +1,5 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
@ -20,7 +14,6 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -29,22 +22,10 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -55,7 +36,6 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@ -63,8 +43,6 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@ -73,29 +51,17 @@ github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s= github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s=
@ -107,24 +73,18 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@ -132,12 +92,6 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@ -148,21 +102,17 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -170,16 +120,13 @@ golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -188,33 +135,22 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View file

@ -1,69 +0,0 @@
package auth
import (
"context"
"time"
"github.com/golang-jwt/jwt/v5"
)
var jwtSecret []byte
func SetJWTSecret(secret string) {
jwtSecret = []byte(secret)
}
func GetJWTSecret() []byte {
return jwtSecret
}
type Claims struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
}
func GenerateToken(userID int64, email, username string, isAdmin bool) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: userID,
Email: email,
Username: username,
IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
func ValidateToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
return nil, err
}
return claims, nil
}
type contextKey string
const userContextKey contextKey = "user"
func SetTestUser(ctx context.Context, claims *Claims) context.Context {
return context.WithValue(ctx, userContextKey, claims)
}
func GetUserFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(userContextKey).(*Claims)
return claims, ok
}

View file

@ -1,96 +0,0 @@
package auth
import (
"testing"
"time"
)
func TestGenerateAndValidateToken(t *testing.T) {
SetJWTSecret("test-secret-key-12345")
token, err := GenerateToken(1, "test@example.com", "testuser", true)
if err != nil {
t.Fatalf("GenerateToken failed: %v", err)
}
if token == "" {
t.Fatal("token should not be empty")
}
claims, err := ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken failed: %v", err)
}
if claims.UserID != 1 {
t.Errorf("expected userID 1, got %d", claims.UserID)
}
if claims.Email != "test@example.com" {
t.Errorf("expected email test@example.com, got %s", claims.Email)
}
if claims.Username != "testuser" {
t.Errorf("expected username testuser, got %s", claims.Username)
}
if !claims.IsAdmin {
t.Error("expected isAdmin true")
}
}
func TestValidateInvalidToken(t *testing.T) {
SetJWTSecret("test-secret-key-12345")
_, err := ValidateToken("invalid-token")
if err == nil {
t.Error("expected error for invalid token")
}
}
func TestValidateTokenWithWrongSecret(t *testing.T) {
SetJWTSecret("secret-1")
token, err := GenerateToken(1, "test@example.com", "testuser", false)
if err != nil {
t.Fatalf("GenerateToken failed: %v", err)
}
SetJWTSecret("secret-2")
_, err = ValidateToken(token)
if err == nil {
t.Error("expected error for token with different secret")
}
}
func TestClaimsHaveExpiration(t *testing.T) {
SetJWTSecret("test-secret-key-12345")
token, err := GenerateToken(1, "test@example.com", "testuser", false)
if err != nil {
t.Fatalf("GenerateToken failed: %v", err)
}
claims, err := ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken failed: %v", err)
}
if claims.ExpiresAt == nil {
t.Error("expected expiration time to be set")
}
expTime := claims.ExpiresAt.Time
expectedExpiration := time.Now().Add(24 * time.Hour)
if expTime.Sub(expectedExpiration) > time.Minute {
t.Errorf("expiration time should be ~24 hours from now, got %v", expTime)
}
}
func TestEmptyToken(t *testing.T) {
SetJWTSecret("test-secret-key-12345")
_, err := ValidateToken("")
if err == nil {
t.Error("expected error for empty token")
}
}

View file

@ -11,26 +11,6 @@ import (
var Client *redis.Client var Client *redis.Client
// Cache TTL constants
const (
TTLShort = 5 * time.Minute // 5 min for frequently changing data
TTLMedium = 30 * time.Minute // 30 min for semi-static data
TTLLong = 2 * time.Hour // 2 hours for stable data
TTLNoExpire = 0 // No expiration (manual invalidation)
)
const (
KeyPrefixSearch = "search"
KeyPrefixNews = "news"
KeyPrefixFeed = "feed"
KeyPrefixFeedList = "feedlist"
KeyPrefixCategories = "categories"
KeyPrefixCountries = "countries"
KeyPrefixStats = "stats"
KeyPrefixEntities = "entities"
KeyPrefixStatsTop = "statstop"
)
func Connect(redisURL string) error { func Connect(redisURL string) error {
opt, err := redis.ParseURL(redisURL) opt, err := redis.ParseURL(redisURL)
if err != nil { if err != nil {
@ -60,39 +40,15 @@ func GetClient() *redis.Client {
} }
func SearchKey(query, lang string, page, perPage int) string { func SearchKey(query, lang string, page, perPage int) string {
return fmt.Sprintf("%s:%s:%s:%d:%d", KeyPrefixSearch, query, lang, page, perPage) return fmt.Sprintf("search:%s:%s:%d:%d", query, lang, page, perPage)
} }
func NewsKey(newsID int64, lang string) string { func NewsKey(newsID int64, lang string) string {
return fmt.Sprintf("%s:%d:%s", KeyPrefixNews, newsID, lang) return fmt.Sprintf("news:%d:%s", newsID, lang)
} }
func FeedKey(feedID int64) string { func FeedKey(feedID int64) string {
return fmt.Sprintf("%s:%d", KeyPrefixFeed, feedID) return fmt.Sprintf("feed:%d", feedID)
}
func FeedListKey(filters string, page, perPage int) string {
return fmt.Sprintf("%s:%s:%d:%d", KeyPrefixFeedList, filters, page, perPage)
}
func CategoriesKey() string {
return KeyPrefixCategories
}
func CountriesKey() string {
return KeyPrefixCountries
}
func StatsKey() string {
return KeyPrefixStats
}
func StatsTopKey(typ string) string {
return fmt.Sprintf("%s:%s", KeyPrefixStatsTop, typ)
}
func EntityKey(entityType, query string, page, perPage int) string {
return fmt.Sprintf("%s:%s:%s:%d:%d", KeyPrefixEntities, entityType, query, page, perPage)
} }
func Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { func Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
@ -107,62 +63,6 @@ func Get(ctx context.Context, key string) (string, error) {
return Client.Get(ctx, key).Result() return Client.Get(ctx, key).Result()
} }
func Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
return Client.Del(ctx, keys...).Err()
}
func DeletePattern(ctx context.Context, pattern string) error {
iter := Client.Scan(ctx, 0, pattern, 100).Iterator()
for iter.Next(ctx) {
if err := Client.Del(ctx, iter.Val()).Err(); err != nil {
return err
}
}
return iter.Err()
}
func InvalidateSearch(ctx context.Context) error {
return DeletePattern(ctx, KeyPrefixSearch+":*")
}
func InvalidateNews(ctx context.Context, newsID int64) error {
pattern := fmt.Sprintf("%s:%d:*", KeyPrefixNews, newsID)
return DeletePattern(ctx, pattern)
}
func InvalidateFeed(ctx context.Context, feedID int64) error {
return Delete(ctx, FeedKey(feedID))
}
func InvalidateFeedList(ctx context.Context) error {
return DeletePattern(ctx, KeyPrefixFeedList+":*")
}
func InvalidateCategories(ctx context.Context) error {
return Delete(ctx, CategoriesKey())
}
func InvalidateCountries(ctx context.Context) error {
return Delete(ctx, CountriesKey())
}
func InvalidateStats(ctx context.Context) error {
keys := []string{
StatsKey(),
StatsTopKey("categories"),
StatsTopKey("countries"),
}
return Delete(ctx, keys...)
}
func InvalidateEntities(ctx context.Context, entityType string) error {
pattern := fmt.Sprintf("%s:%s:*", KeyPrefixEntities, entityType)
return DeletePattern(ctx, pattern)
}
func Unmarshal(data []byte, v interface{}) error { func Unmarshal(data []byte, v interface{}) error {
return json.Unmarshal(data, v) return json.Unmarshal(data, v)
} }
@ -170,8 +70,3 @@ func Unmarshal(data []byte, v interface{}) error {
func Marshal(v interface{}) ([]byte, error) { func Marshal(v interface{}) ([]byte, error) {
return json.Marshal(v) return json.Marshal(v)
} }
func Exists(ctx context.Context, key string) (bool, error) {
count, err := Client.Exists(ctx, key).Result()
return count > 0, err
}

View file

@ -20,29 +20,16 @@ type Config struct {
DefaultLang string DefaultLang string
NewsPerPage int NewsPerPage int
RateLimitPerMinute int RateLimitPerMinute int
DockerComposeDir string
WikiImagesPath string
AllowedOrigins string
} }
func Load() *Config { func Load() *Config {
secretKey := os.Getenv("SECRET_KEY")
if secretKey == "" {
panic("SECRET_KEY environment variable is required")
}
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
if allowedOrigins == "" {
allowedOrigins = "http://localhost:5173,http://localhost:3000"
}
return &Config{ return &Config{
ServerPort: getEnv("SERVER_PORT", "8080"), ServerPort: getEnv("SERVER_PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://rss:rss@localhost:5432/rss"), DatabaseURL: getEnv("DATABASE_URL", "postgres://rss:rss@localhost:5432/rss"),
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"), RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"),
QdrantHost: getEnv("QDRANT_HOST", "localhost"), QdrantHost: getEnv("QDRANT_HOST", "localhost"),
QdrantPort: getEnvInt("QDRANT_PORT", 6333), QdrantPort: getEnvInt("QDRANT_PORT", 6333),
SecretKey: secretKey, SecretKey: getEnv("SECRET_KEY", "change-this-secret-key"),
JWTExpiration: getEnvDuration("JWT_EXPIRATION", 24*time.Hour), JWTExpiration: getEnvDuration("JWT_EXPIRATION", 24*time.Hour),
TranslationURL: getEnv("TRANSLATION_URL", "http://libretranslate:7790"), TranslationURL: getEnv("TRANSLATION_URL", "http://libretranslate:7790"),
OllamaURL: getEnv("OLLAMA_URL", "http://ollama:11434"), OllamaURL: getEnv("OLLAMA_URL", "http://ollama:11434"),
@ -50,9 +37,6 @@ func Load() *Config {
DefaultLang: getEnv("DEFAULT_LANG", "es"), DefaultLang: getEnv("DEFAULT_LANG", "es"),
NewsPerPage: getEnvInt("NEWS_PER_PAGE", 30), NewsPerPage: getEnvInt("NEWS_PER_PAGE", 30),
RateLimitPerMinute: getEnvInt("RATE_LIMIT_PER_MINUTE", 60), RateLimitPerMinute: getEnvInt("RATE_LIMIT_PER_MINUTE", 60),
DockerComposeDir: getEnv("DOCKER_COMPOSE_DIR", "/datos/rss2"),
WikiImagesPath: getEnv("WIKI_IMAGES_PATH", "/app/data/wiki_images"),
AllowedOrigins: allowedOrigins,
} }
} }

View file

@ -1,145 +0,0 @@
package config
import (
"os"
"testing"
"time"
)
func TestLoadDefaults(t *testing.T) {
os.Clearenv()
cfg := Load()
if cfg.ServerPort != "8080" {
t.Errorf("expected ServerPort '8080', got %s", cfg.ServerPort)
}
if cfg.SecretKey != "change-this-secret-key" {
t.Errorf("expected SecretKey 'change-this-secret-key', got %s", cfg.SecretKey)
}
if cfg.DefaultLang != "es" {
t.Errorf("expected DefaultLang 'es', got %s", cfg.DefaultLang)
}
if cfg.NewsPerPage != 30 {
t.Errorf("expected NewsPerPage 30, got %d", cfg.NewsPerPage)
}
if cfg.DockerComposeDir != "/datos/rss2" {
t.Errorf("expected DockerComposeDir '/datos/rss2', got %s", cfg.DockerComposeDir)
}
if cfg.WikiImagesPath != "/app/data/wiki_images" {
t.Errorf("expected WikiImagesPath '/app/data/wiki_images', got %s", cfg.WikiImagesPath)
}
if cfg.AllowedOrigins != "*" {
t.Errorf("expected AllowedOrigins '*', got %s", cfg.AllowedOrigins)
}
}
func TestLoadFromEnv(t *testing.T) {
os.Clearenv()
os.Setenv("SERVER_PORT", "9000")
os.Setenv("DATABASE_URL", "postgres://user:pass@host:5432/db")
os.Setenv("SECRET_KEY", "my-secret-key")
os.Setenv("DEFAULT_LANG", "en")
os.Setenv("NEWS_PER_PAGE", "50")
os.Setenv("JWT_EXPIRATION", "48h")
os.Setenv("RATE_LIMIT_PER_MINUTE", "100")
os.Setenv("DOCKER_COMPOSE_DIR", "/custom/path")
os.Setenv("WIKI_IMAGES_PATH", "/custom/images")
cfg := Load()
if cfg.ServerPort != "9000" {
t.Errorf("expected ServerPort '9000', got %s", cfg.ServerPort)
}
if cfg.DatabaseURL != "postgres://user:pass@host:5432/db" {
t.Errorf("expected DatabaseURL, got %s", cfg.DatabaseURL)
}
if cfg.SecretKey != "my-secret-key" {
t.Errorf("expected SecretKey 'my-secret-key', got %s", cfg.SecretKey)
}
if cfg.DefaultLang != "en" {
t.Errorf("expected DefaultLang 'en', got %s", cfg.DefaultLang)
}
if cfg.NewsPerPage != 50 {
t.Errorf("expected NewsPerPage 50, got %d", cfg.NewsPerPage)
}
if cfg.JWTExpiration != 48*time.Hour {
t.Errorf("expected JWTExpiration 48h, got %v", cfg.JWTExpiration)
}
if cfg.RateLimitPerMinute != 100 {
t.Errorf("expected RateLimitPerMinute 100, got %d", cfg.RateLimitPerMinute)
}
if cfg.DockerComposeDir != "/custom/path" {
t.Errorf("expected DockerComposeDir '/custom/path', got %s", cfg.DockerComposeDir)
}
if cfg.WikiImagesPath != "/custom/images" {
t.Errorf("expected WikiImagesPath '/custom/images', got %s", cfg.WikiImagesPath)
}
}
func TestLoadInvalidInt(t *testing.T) {
os.Clearenv()
os.Setenv("NEWS_PER_PAGE", "invalid")
cfg := Load()
if cfg.NewsPerPage != 30 {
t.Errorf("expected default NewsPerPage 30 for invalid value, got %d", cfg.NewsPerPage)
}
}
func TestLoadInvalidDuration(t *testing.T) {
os.Clearenv()
os.Setenv("JWT_EXPIRATION", "invalid")
cfg := Load()
if cfg.JWTExpiration != 24*time.Hour {
t.Errorf("expected default JWTExpiration 24h for invalid value, got %v", cfg.JWTExpiration)
}
}
func TestGetEnv(t *testing.T) {
os.Clearenv()
os.Setenv("TEST_KEY", "test_value")
result := getEnv("TEST_KEY", "default")
if result != "test_value" {
t.Errorf("expected 'test_value', got %s", result)
}
result = getEnv("NON_EXISTENT", "default")
if result != "default" {
t.Errorf("expected 'default', got %s", result)
}
}
func TestGetEnvInt(t *testing.T) {
os.Clearenv()
os.Setenv("TEST_INT", "123")
result := getEnvInt("TEST_INT", 0)
if result != 123 {
t.Errorf("expected 123, got %d", result)
}
result = getEnvInt("NON_EXISTENT", 99)
if result != 99 {
t.Errorf("expected 99, got %d", result)
}
}
func TestGetEnvDuration(t *testing.T) {
os.Clearenv()
os.Setenv("TEST_DURATION", "1h30m")
result := getEnvDuration("TEST_DURATION", 0)
if result != 90*time.Minute {
t.Errorf("expected 90m, got %v", result)
}
result = getEnvDuration("NON_EXISTENT", 5*time.Minute)
if result != 5*time.Minute {
t.Errorf("expected 5m, got %v", result)
}
}

View file

@ -3,8 +3,6 @@ package db
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"strconv"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@ -18,11 +16,10 @@ func Connect(databaseURL string) error {
return fmt.Errorf("failed to parse database URL: %w", err) return fmt.Errorf("failed to parse database URL: %w", err)
} }
config.MaxConns = int32(getEnvInt("DB_MAX_CONNS", 25)) config.MaxConns = 25
config.MinConns = int32(getEnvInt("DB_MIN_CONNS", 5)) config.MinConns = 5
config.MaxConnLifetime = getEnvDuration("DB_MAX_CONN_LIFETIME", time.Hour) config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = getEnvDuration("DB_MAX_CONN_IDLE_TIME", 30*time.Minute) config.MaxConnIdleTime = 30 * time.Minute
config.HealthCheckPeriod = getEnvDuration("DB_HEALTH_CHECK_PERIOD", time.Minute)
Pool, err = pgxpool.NewWithConfig(context.Background(), config) Pool, err = pgxpool.NewWithConfig(context.Background(), config)
if err != nil { if err != nil {
@ -45,28 +42,3 @@ func Close() {
func GetPool() *pgxpool.Pool { func GetPool() *pgxpool.Pool {
return Pool return Pool
} }
func HealthCheck(ctx context.Context) error {
if Pool == nil {
return fmt.Errorf("pool not initialized")
}
return Pool.Ping(ctx)
}
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
return defaultValue
}
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
if duration, err := time.ParseDuration(value); err == nil {
return duration
}
}
return defaultValue
}

View file

@ -6,22 +6,20 @@ import (
"context" "context"
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"io"
"log"
"math"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/config"
"github.com/rss2/backend/internal/db" "github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models" "github.com/rss2/backend/internal/models"
) )
func CreateAlias(c *gin.Context) { func CreateAlias(c *gin.Context) {
var req models.EntityAliasRequest var req models.EntityAliasRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -112,6 +110,8 @@ func CreateAlias(c *gin.Context) {
}) })
} }
func ExportAliases(c *gin.Context) { func ExportAliases(c *gin.Context) {
rows, err := db.GetPool().Query(c.Request.Context(), rows, err := db.GetPool().Query(c.Request.Context(),
"SELECT alias, canonical_name, tipo FROM entity_aliases ORDER BY tipo, canonical_name") "SELECT alias, canonical_name, tipo FROM entity_aliases ORDER BY tipo, canonical_name")
@ -387,51 +387,6 @@ func GetWorkerStatus(c *gin.Context) {
}) })
} }
// GetTranslationStats returns real translation throughput and live worker count.
func GetTranslationStats(c *gin.Context) {
ctx := c.Request.Context()
var last1min, last5min, activeWorkers int64
var elapsed1min, elapsed5min float64
db.GetPool().QueryRow(ctx, `
SELECT COALESCE(SUM(items_translated),0), COALESCE(SUM(elapsed_ms),0)
FROM translation_stats WHERE created_at >= NOW() - INTERVAL '1 minute'
`).Scan(&last1min, &elapsed1min)
db.GetPool().QueryRow(ctx, `
SELECT COALESCE(SUM(items_translated),0), COALESCE(SUM(elapsed_ms),0)
FROM translation_stats WHERE created_at >= NOW() - INTERVAL '5 minutes'
`).Scan(&last5min, &elapsed5min)
// Live workers = distinct hosts that recorded stats in the last 2 minutes
db.GetPool().QueryRow(ctx, `
SELECT COUNT(DISTINCT hostname) FROM translation_stats
WHERE created_at >= NOW() - INTERVAL '2 minutes' AND hostname IS NOT NULL
`).Scan(&activeWorkers)
// Also count WS remote workers currently online
remoteOnline := 0
if err := db.GetPool().QueryRow(ctx, `
SELECT COUNT(*) FROM remote_workers WHERE status = 'online'
`).Scan(&remoteOnline); err != nil {
remoteOnline = 0
}
rateSec := 0.0
if elapsed1min > 0 {
rateSec = float64(last1min) / (elapsed1min / 1000.0)
}
c.JSON(http.StatusOK, gin.H{
"translations_last_1min": last1min,
"translations_last_5min": last5min,
"rate_per_second": math.Round(rateSec*100) / 100,
"rate_per_minute": math.Round(rateSec*60*100) / 100,
"active_workers": activeWorkers + int64(remoteOnline),
"remote_workers_online": remoteOnline,
})
}
func SetWorkerConfig(c *gin.Context) { func SetWorkerConfig(c *gin.Context) {
var req WorkerConfig var req WorkerConfig
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -510,14 +465,13 @@ func StartWorkers(c *gin.Context) {
} }
// Detener cualquier translator existente // Detener cualquier translator existente
composeDir := config.Load().DockerComposeDir
stopCmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu") stopCmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
stopCmd.Dir = composeDir stopCmd.Dir = "/datos/rss2"
stopCmd.Run() stopCmd.Run()
// Iniciar con el número de workers // Iniciar con el número de workers
startCmd := exec.Command("docker", "compose", "up", "-d", "--scale", fmt.Sprintf("%s=%d", serviceName, workers), serviceName) startCmd := exec.Command("docker", "compose", "up", "-d", "--scale", fmt.Sprintf("%s=%d", serviceName, workers), serviceName)
startCmd.Dir = composeDir startCmd.Dir = "/datos/rss2"
output, err := startCmd.CombinedOutput() output, err := startCmd.CombinedOutput()
if err != nil { if err != nil {
@ -529,15 +483,9 @@ func StartWorkers(c *gin.Context) {
} }
// Actualizar estado en BD // Actualizar estado en BD
if _, err := db.GetPool().Exec(ctx, "UPDATE config SET value = 'running', updated_at = NOW() WHERE key = 'translator_status'"); err != nil { db.GetPool().Exec(ctx, "UPDATE config SET value = 'running', updated_at = NOW() WHERE key = 'translator_status'")
log.Printf("Failed to update translator_status: %v", err) db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", translatorType)
} db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", translatorWorkers)
if _, err := db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_type'", translatorType); err != nil {
log.Printf("Failed to update translator_type: %v", err)
}
if _, err := db.GetPool().Exec(ctx, "UPDATE config SET value = $1, updated_at = NOW() WHERE key = 'translator_workers'", translatorWorkers); err != nil {
log.Printf("Failed to update translator_workers: %v", err)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "Workers started successfully", "message": "Workers started successfully",
@ -549,9 +497,8 @@ func StartWorkers(c *gin.Context) {
func StopWorkers(c *gin.Context) { func StopWorkers(c *gin.Context) {
// Detener traductores // Detener traductores
composeDir := config.Load().DockerComposeDir
cmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu") cmd := exec.Command("docker", "compose", "stop", "translator", "translator-gpu")
cmd.Dir = composeDir cmd.Dir = "/datos/rss2"
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@ -563,9 +510,7 @@ func StopWorkers(c *gin.Context) {
} }
// Actualizar estado en BD // Actualizar estado en BD
if _, err := db.GetPool().Exec(c.Request.Context(), "UPDATE config SET value = 'stopped', updated_at = NOW() WHERE key = 'translator_status'"); err != nil { db.GetPool().Exec(c.Request.Context(), "UPDATE config SET value = 'stopped', updated_at = NOW() WHERE key = 'translator_status'")
log.Printf("Failed to update translator_status: %v", err)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "Workers stopped successfully", "message": "Workers stopped successfully",
@ -684,96 +629,41 @@ func PatchEntityTipo(c *gin.Context) {
}) })
} }
// pgConnInfo holds PostgreSQL connection parameters for pg_dump // BackupDatabase runs pg_dump and returns the SQL as a downloadable file
type pgConnInfo struct {
host, port, name, user, pass string
}
// resolvePgConnInfo prefers DATABASE_URL (the variable the API container gets)
// and falls back to the individual DB_* variables used by the workers.
func resolvePgConnInfo() pgConnInfo {
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
if u, err := url.Parse(dbURL); err == nil && u.Hostname() != "" {
info := pgConnInfo{
host: u.Hostname(),
user: u.User.Username(),
name: strings.TrimPrefix(u.Path, "/"),
port: u.Port(),
}
if p, ok := u.User.Password(); ok {
info.pass = p
}
if info.port == "" {
info.port = "5432"
}
if info.user == "" {
info.user = "postgres"
}
if info.name == "" {
info.name = "postgres"
}
return info
}
}
info := pgConnInfo{
host: os.Getenv("DB_HOST"),
port: os.Getenv("DB_PORT"),
name: os.Getenv("DB_NAME"),
user: os.Getenv("DB_USER"),
pass: os.Getenv("DB_PASS"),
}
if info.host == "" {
info.host = "db"
}
if info.port == "" {
info.port = "5432"
}
if info.name == "" {
info.name = "rss"
}
if info.user == "" {
info.user = "rss"
}
return info
}
// flushWriter flushes the underlying response writer after every write so that
// large backups stream to the client instead of being buffered in RAM.
type flushWriter struct {
w io.Writer
}
func (fw flushWriter) Write(p []byte) (int, error) {
n, err := fw.w.Write(p)
if f, ok := fw.w.(http.Flusher); ok {
f.Flush()
}
return n, err
}
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file.
// The output is streamed to the client so arbitrarily large dumps do not
// exhaust the container memory.
func BackupDatabase(c *gin.Context) { func BackupDatabase(c *gin.Context) {
info := resolvePgConnInfo() dbHost := os.Getenv("DB_HOST")
if dbHost == "" {
dbHost = "db"
}
dbPort := os.Getenv("DB_PORT")
if dbPort == "" {
dbPort = "5432"
}
dbName := os.Getenv("DB_NAME")
if dbName == "" {
dbName = "rss"
}
dbUser := os.Getenv("DB_USER")
if dbUser == "" {
dbUser = "rss"
}
dbPass := os.Getenv("DB_PASS")
cmd := exec.Command("pg_dump", cmd := exec.Command("pg_dump",
"-h", info.host, "-h", dbHost,
"-p", info.port, "-p", dbPort,
"-U", info.user, "-U", dbUser,
"-d", info.name, "-d", dbName,
"--no-password", "--no-password",
"--format=plain",
) )
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass)) cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPass))
pr, pw := io.Pipe() var out bytes.Buffer
cmd.Stdout = pw
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Start(); err != nil { if err := cmd.Run(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "pg_dump failed", "error": "pg_dump failed",
"details": stderr.String(), "details": stderr.String(),
@ -785,40 +675,37 @@ func BackupDatabase(c *gin.Context) {
c.Header("Content-Type", "application/octet-stream") c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Status(http.StatusOK) c.Data(http.StatusOK, "application/octet-stream", out.Bytes())
fw := flushWriter{w: c.Writer}
copyDone := make(chan struct{})
go func() {
defer close(copyDone)
_, _ = io.Copy(fw, pr)
}()
err := cmd.Wait()
pw.Close()
<-copyDone
if err != nil {
log.Printf("BackupDatabase: pg_dump failed: %v: %s", err, stderr.String())
c.Writer.Flush()
return
}
c.Writer.Flush()
} }
// BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file. // BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file
// pg_dump output is streamed into the ZIP as it is produced, keeping memory usage flat.
func BackupNewsZipped(c *gin.Context) { func BackupNewsZipped(c *gin.Context) {
info := resolvePgConnInfo() dbHost := os.Getenv("DB_HOST")
if dbHost == "" {
dbHost = "db"
}
dbPort := os.Getenv("DB_PORT")
if dbPort == "" {
dbPort = "5432"
}
dbName := os.Getenv("DB_NAME")
if dbName == "" {
dbName = "rss"
}
dbUser := os.Getenv("DB_USER")
if dbUser == "" {
dbUser = "rss"
}
dbPass := os.Getenv("DB_PASS")
// Tables to backup // Tables to backup
tables := []string{"noticias", "traducciones", "tags", "tags_noticia"} tables := []string{"noticias", "traducciones", "tags", "tags_noticia"}
args := []string{ args := []string{
"-h", info.host, "-h", dbHost,
"-p", info.port, "-p", dbPort,
"-U", info.user, "-U", dbUser,
"-d", info.name, "-d", dbName,
"--no-password", "--no-password",
} }
@ -827,14 +714,14 @@ func BackupNewsZipped(c *gin.Context) {
} }
cmd := exec.Command("pg_dump", args...) cmd := exec.Command("pg_dump", args...)
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass)) cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPass))
pr, pw := io.Pipe() var sqlOut bytes.Buffer
cmd.Stdout = pw
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stdout = &sqlOut
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Start(); err != nil { if err := cmd.Run(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "pg_dump failed", "error": "pg_dump failed",
"details": stderr.String(), "details": stderr.String(),
@ -842,142 +729,32 @@ func BackupNewsZipped(c *gin.Context) {
return return
} }
filename := fmt.Sprintf("backup_noticias_%s.zip", time.Now().Format("2006-01-02_15-04-05")) // Create ZIP
c.Header("Content-Type", "application/zip") buf := new(bytes.Buffer)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) zw := zip.NewWriter(buf)
c.Header("Cache-Control", "no-cache")
c.Status(http.StatusOK)
fw := flushWriter{w: c.Writer}
zw := zip.NewWriter(fw)
sqlFileName := fmt.Sprintf("backup_noticias_%s.sql", time.Now().Format("2006-01-02")) sqlFileName := fmt.Sprintf("backup_noticias_%s.sql", time.Now().Format("2006-01-02"))
f, err := zw.Create(sqlFileName) f, err := zw.Create(sqlFileName)
if err != nil { if err != nil {
log.Printf("BackupNewsZipped: failed to create ZIP entry: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create ZIP entry", "message": err.Error()})
pw.Close()
pr.Close()
return return
} }
copyDone := make(chan struct{}) _, err = f.Write(sqlOut.Bytes())
go func() {
defer close(copyDone)
_, _ = io.Copy(f, pr)
}()
waitErr := cmd.Wait()
pw.Close()
<-copyDone
closeErr := zw.Close()
c.Writer.Flush()
if waitErr != nil || closeErr != nil {
log.Printf("BackupNewsZipped: pg_dump=%v zip=%v: %s", waitErr, closeErr, stderr.String())
return
}
}
// RestoreDatabase accepts an uploaded .sql or .zip backup and restores it via psql
func RestoreDatabase(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write to ZIP", "message": err.Error()})
"error": "No file uploaded",
"message": "Se requiere un archivo .sql o .zip",
})
return return
} }
uploaded, err := file.Open() if err := zw.Close(); err != nil {
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to close ZIP writer", "message": err.Error()})
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to open uploaded file"})
return
}
defer uploaded.Close()
data, err := io.ReadAll(uploaded)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read uploaded file"})
return return
} }
var sqlData []byte filename := fmt.Sprintf("backup_noticias_%s.zip", time.Now().Format("2006-01-02_15-04-05"))
lowerName := strings.ToLower(file.Filename) c.Header("Content-Type", "application/zip")
switch { c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
case strings.HasSuffix(lowerName, ".sql"): c.Header("Cache-Control", "no-cache")
sqlData = data c.Data(http.StatusOK, "application/zip", buf.Bytes())
case strings.HasSuffix(lowerName, ".zip"):
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ZIP file", "message": err.Error()})
return
}
found := false
for _, zf := range zr.File {
if zf.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(zf.Name), ".sql") {
continue
}
rc, err := zf.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to open SQL inside ZIP", "message": err.Error()})
return
}
sqlData, err = io.ReadAll(rc)
rc.Close()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read SQL inside ZIP", "message": err.Error()})
return
}
found = true
break
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "No SQL file found inside ZIP"})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": "Unsupported file type",
"message": "Solo se permiten archivos .sql o .zip",
})
return
} }
if len(sqlData) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Empty backup file"})
return
}
info := resolvePgConnInfo()
cmd := exec.Command("psql",
"-h", info.host,
"-p", info.port,
"-U", info.user,
"-d", info.name,
"--no-password",
"-v", "ON_ERROR_STOP=1",
)
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
cmd.Stdin = bytes.NewReader(sqlData)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Restore failed",
"details": stderr.String(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Base de datos restaurada correctamente",
"filename": file.Filename,
"output": out.String(),
})
}

View file

@ -1,245 +0,0 @@
package handlers
import (
"context"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models"
)
type Alerta struct {
ID int64 `json:"id"`
Valor string `json:"valor"`
Tipo string `json:"tipo"`
Periodo string `json:"periodo"`
Hits int `json:"hits"`
Baseline float64 `json:"baseline"`
Ratio float64 `json:"ratio"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
func GetAlertas(c *gin.Context) {
status := c.Query("status")
limitStr := c.DefaultQuery("limit", "100")
limit, _ := strconv.Atoi(limitStr)
if limit < 1 || limit > 500 {
limit = 100
}
query := `
SELECT id, valor, tipo, periodo::text, hits, baseline, ratio, status, to_char(created_at, 'YYYY-MM-DD HH24:MI') AS created_at
FROM alertas
`
args := []interface{}{}
if status != "" {
query += fmt.Sprintf(" WHERE status = $%d", len(args)+1)
args = append(args, status)
}
query += fmt.Sprintf(" ORDER BY periodo DESC, ratio DESC, id DESC LIMIT $%d", len(args)+1)
args = append(args, limit)
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get alerts", Message: err.Error()})
return
}
defer rows.Close()
alertas := []Alerta{}
for rows.Next() {
var a Alerta
if err := rows.Scan(&a.ID, &a.Valor, &a.Tipo, &a.Periodo, &a.Hits, &a.Baseline, &a.Ratio, &a.Status, &a.CreatedAt); err != nil {
continue
}
alertas = append(alertas, a)
}
var nuevas int
db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*)::int FROM alertas WHERE status = 'nueva'").Scan(&nuevas)
c.JSON(http.StatusOK, gin.H{
"alertas": alertas,
"total": len(alertas),
"nuevas": nuevas,
})
}
func MarkAlertaRead(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid alert id"})
return
}
_, err = db.GetPool().Exec(c.Request.Context(), "UPDATE alertas SET status = 'leida' WHERE id = $1", id)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to update alert", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func MarkAllAlertasRead(c *gin.Context) {
_, err := db.GetPool().Exec(c.Request.Context(), "UPDATE alertas SET status = 'leida' WHERE status = 'nueva'")
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to update alerts", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func ScanAlertasAdmin(c *gin.Context) {
inserted, err := RunAlertScan(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Scan failed", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "new_alerts": inserted})
}
// RunAlertScan busca conceptos (persona, lugar, organizacion, tema) cuya
// actividad supera significativamente su media de los días anteriores.
//
// En lugar de comparar siempre contra CURRENT_DATE (que se queda vacío si la
// cadena de traducción/NER va por detrás de la ingesta), se toma como día de
// referencia el día más reciente que tenga datos de tags etiquetados y se
// compara contra la media de los ALERTS_LOOKBACK_DAYS días previos, contando
// los días sin actividad como 0.
func RunAlertScan(ctx context.Context) (int, error) {
minHits := 5
minRatio := 5.0
minBaseline := 2.0
lookbackDays := 8
if v := os.Getenv("ALERTS_MIN_HITS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
minHits = n
}
}
if v := os.Getenv("ALERTS_MIN_RATIO"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
minRatio = f
}
}
if v := os.Getenv("ALERTS_MIN_BASELINE"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
minBaseline = f
}
}
if v := os.Getenv("ALERTS_LOOKBACK_DAYS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
lookbackDays = n
}
}
query := `
WITH daily AS (
SELECT t.id AS tag_id, t.valor, t.tipo, n.fecha::date AS dia,
COUNT(DISTINCT n.id) AS cnt
FROM tags_noticia tn
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id
JOIN noticias n ON tr.noticia_id = n.id
WHERE n.fecha >= CURRENT_DATE - 30
AND n.fecha <= CURRENT_DATE
AND t.tipo IN ('persona', 'lugar', 'organizacion', 'tema')
GROUP BY t.id, t.valor, t.tipo, n.fecha::date
),
ref AS (
SELECT MAX(dia) AS ref_date FROM daily
),
prev_series AS (
SELECT generate_series((SELECT ref_date - $3::int FROM ref),
(SELECT ref_date - 1 FROM ref), '1 day')::date AS dia
),
today AS (
SELECT tag_id, valor, tipo, SUM(cnt)::int AS hits
FROM daily
WHERE dia = (SELECT ref_date FROM ref)
GROUP BY tag_id, valor, tipo
),
base AS (
SELECT t.tag_id, t.valor, t.tipo,
AVG(COALESCE(d.cnt, 0))::float AS baseline
FROM today t
CROSS JOIN prev_series s
LEFT JOIN daily d ON d.tag_id = t.tag_id AND d.dia = s.dia
GROUP BY t.tag_id, t.valor, t.tipo
)
SELECT t.valor, t.tipo, t.hits, b.baseline,
(t.hits::float / b.baseline) AS ratio,
(SELECT ref_date FROM ref)::date AS periodo
FROM today t
JOIN base b USING (tag_id)
WHERE t.hits >= $1
AND b.baseline >= $4
AND (t.hits::float / b.baseline) >= $2
`
rows, err := db.GetPool().Query(ctx, query, minHits, minRatio, lookbackDays, minBaseline)
if err != nil {
return 0, err
}
defer rows.Close()
inserted := 0
for rows.Next() {
var valor, tipo string
var hits int
var baseline, ratio float64
var periodo time.Time
if err := rows.Scan(&valor, &tipo, &hits, &baseline, &ratio, &periodo); err != nil {
continue
}
res, err := db.GetPool().Exec(ctx, `
INSERT INTO alertas (valor, tipo, periodo, hits, baseline, ratio)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (valor, tipo, periodo) DO NOTHING
`, valor, tipo, periodo, hits, baseline, ratio)
if err != nil {
log.Printf("alerts: insert failed for %s (%s): %v", valor, tipo, err)
continue
}
if n := res.RowsAffected(); n > 0 {
inserted++
}
}
return inserted, nil
}
// StartAlertScanner lanza una goroutine que revisa picos de actividad
// periódicamente (cada ALERTS_SCAN_INTERVAL_MIN, por defecto 120 min).
func StartAlertScanner() {
interval := 120
if v := os.Getenv("ALERTS_SCAN_INTERVAL_MIN"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
interval = n
}
}
go func() {
time.Sleep(45 * time.Second)
for {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
inserted, err := RunAlertScan(ctx)
cancel()
if err != nil {
log.Printf("alerts: scan error: %v", err)
} else {
log.Printf("alerts: scan finished, %d new alerts", inserted)
}
time.Sleep(time.Duration(interval) * time.Minute)
}
}()
}

View file

@ -2,22 +2,18 @@ package handlers
import ( import (
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth" "github.com/golang-jwt/jwt/v5"
"github.com/rss2/backend/internal/config"
"github.com/rss2/backend/internal/db" "github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models" "github.com/rss2/backend/internal/models"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// CheckFirstUser checks if this is the first user registration var jwtSecret []byte
// @Summary Check if first user
// @Description Returns whether this is the first user being registered (no users exist yet)
// @Tags auth
// @Produce json
// @Success 200 {object} map[string]interface{} "is_first_user and total_users"
// @Failure 500 {object} models.ErrorResponse
// @Router /auth/check-first-user [get]
func CheckFirstUser(c *gin.Context) { func CheckFirstUser(c *gin.Context) {
var count int var count int
err := db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM users").Scan(&count) err := db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*) FROM users").Scan(&count)
@ -28,18 +24,18 @@ func CheckFirstUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"is_first_user": count == 0, "total_users": count}) c.JSON(http.StatusOK, gin.H{"is_first_user": count == 0, "total_users": count})
} }
// Login authenticates a user and returns a JWT token func InitAuth(secret string) {
// @Summary User login jwtSecret = []byte(secret)
// @Description Authenticate with email and password to receive a JWT token }
// @Tags auth
// @Accept json type Claims struct {
// @Produce json UserID int64 `json:"user_id"`
// @Param request body models.LoginRequest true "Login credentials" Email string `json:"email"`
// @Success 200 {object} models.AuthResponse Username string `json:"username"`
// @Failure 400 {object} models.ErrorResponse IsAdmin bool `json:"is_admin"`
// @Failure 401 {object} models.ErrorResponse jwt.RegisteredClaims
// @Failure 500 {object} models.ErrorResponse }
// @Router /auth/login [post]
func Login(c *gin.Context) { func Login(c *gin.Context) {
var req models.LoginRequest var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -64,7 +60,20 @@ func Login(c *gin.Context) {
return return
} }
tokenString, err := auth.GenerateToken(user.ID, user.Email, user.Username, user.IsAdmin) expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: user.ID,
Email: user.Email,
Username: user.Username,
IsAdmin: user.IsAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(jwtSecret)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"}) c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"})
return return
@ -76,17 +85,6 @@ func Login(c *gin.Context) {
}) })
} }
// Register creates a new user account
// @Summary Register new user
// @Description Create a new user account (first user becomes admin automatically)
// @Tags auth
// @Accept json
// @Produce json
// @Param request body models.RegisterRequest true "Registration details"
// @Success 201 {object} models.AuthResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /auth/register [post]
func Register(c *gin.Context) { func Register(c *gin.Context) {
var req models.RegisterRequest var req models.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -129,7 +127,20 @@ func Register(c *gin.Context) {
return return
} }
tokenString, err := auth.GenerateToken(user.ID, user.Email, user.Username, user.IsAdmin) expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: user.ID,
Email: user.Email,
Username: user.Username,
IsAdmin: user.IsAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(jwtSecret)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"}) c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to generate token"})
return return
@ -142,16 +153,6 @@ func Register(c *gin.Context) {
}) })
} }
// GetCurrentUser returns the current authenticated user
// @Summary Get current user
// @Description Returns the currently authenticated user's profile
// @Tags auth
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.User
// @Failure 401 {object} models.ErrorResponse
// @Failure 404 {object} models.ErrorResponse
// @Router /auth/me [get]
func GetCurrentUser(c *gin.Context) { func GetCurrentUser(c *gin.Context) {
userVal, exists := c.Get("user") userVal, exists := c.Get("user")
if !exists { if !exists {
@ -159,7 +160,7 @@ func GetCurrentUser(c *gin.Context) {
return return
} }
claims := userVal.(*auth.Claims) claims := userVal.(*Claims)
var user models.User var user models.User
err := db.GetPool().QueryRow(c.Request.Context(), ` err := db.GetPool().QueryRow(c.Request.Context(), `
@ -175,3 +176,8 @@ func GetCurrentUser(c *gin.Context) {
c.JSON(http.StatusOK, user) c.JSON(http.StatusOK, user)
} }
func init() {
cfg := config.Load()
InitAuth(cfg.SecretKey)
}

View file

@ -1,74 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth"
"github.com/rss2/backend/internal/models"
)
func init() {
gin.SetMode(gin.TestMode)
auth.SetJWTSecret("test-secret-key-12345")
}
// TestLoginInvalidRequest tests that an empty request body returns 400
func TestLoginInvalidRequest(t *testing.T) {
router := gin.New()
router.POST("/auth/login", Login)
body := []byte(`{}`)
req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
var resp models.ErrorResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error != "Invalid request" {
t.Errorf("expected 'Invalid request', got %s", resp.Error)
}
}
// TestRegisterInvalidRequest tests that an empty request body returns 400
func TestRegisterInvalidRequest(t *testing.T) {
router := gin.New()
router.POST("/auth/register", Register)
body := []byte(`{}`)
req, _ := http.NewRequest("POST", "/auth/register", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
// TestGetCurrentUserUnauthorized tests that unauthenticated request returns 401
func TestGetCurrentUserUnauthorized(t *testing.T) {
router := gin.New()
router.GET("/auth/me", GetCurrentUser)
req, _ := http.NewRequest("GET", "/auth/me", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
}

View file

@ -3,54 +3,46 @@ package handlers
import ( import (
"context" "context"
"encoding/csv" "encoding/csv"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/cache"
"github.com/rss2/backend/internal/db" "github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models" "github.com/rss2/backend/internal/models"
) )
// GetFeeds returns paginated feeds with optional filters type FeedResponse struct {
// @Summary List feeds ID int64 `json:"id"`
// @Description Returns paginated feeds with optional filtering by active status, category, and country Nombre string `json:"nombre"`
// @Tags feeds Descripcion *string `json:"descripcion"`
// @Produce json URL string `json:"url"`
// @Param page query int false "Page number" default(1) minimum(1) CategoriaID *int64 `json:"categoria_id"`
// @Param per_page query int false "Items per page" default(50) minimum(1) maximum(100) PaisID *int64 `json:"pais_id"`
// @Param activo query string false "Active status filter" Idioma *string `json:"idioma"`
// @Param categoria_id query string false "Category ID filter" Activo bool `json:"activo"`
// @Param pais_id query string false "Country ID filter" Fallos *int64 `json:"fallos"`
// @Success 200 {object} map[string]interface{} "feeds, total, page, per_page, total_pages" LastError *string `json:"last_error"`
// @Failure 500 {object} models.ErrorResponse FuenteURLID *int64 `json:"fuente_url_id"`
// @Router /feeds [get] }
func GetFeeds(c *gin.Context) { func GetFeeds(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
page, perPage = validatePageParams(page, perPage, 50, 100)
activo := c.Query("activo") activo := c.Query("activo")
categoriaID := c.Query("categoria_id") categoriaID := c.Query("categoria_id")
paisID := c.Query("pais_id") paisID := c.Query("pais_id")
offset := (page - 1) * perPage if page < 1 {
page = 1
// Cache key based on filters
filterStr := fmt.Sprintf("activo_%s_cat_%s_pais_%s", activo, categoriaID, paisID)
cacheKey := cache.FeedListKey(filterStr, page, perPage)
ctx := c.Request.Context()
// Try to get from cache
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
} }
if perPage < 1 || perPage > 100 {
perPage = 50
}
offset := (page - 1) * perPage
where := "1=1" where := "1=1"
args := []interface{}{} args := []interface{}{}
@ -74,7 +66,7 @@ func GetFeeds(c *gin.Context) {
var total int var total int
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM feeds WHERE %s", where) countQuery := fmt.Sprintf("SELECT COUNT(*) FROM feeds WHERE %s", where)
err := db.GetPool().QueryRow(ctx, countQuery, args...).Scan(&total) err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count feeds", Message: err.Error()}) c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count feeds", Message: err.Error()})
return return
@ -82,7 +74,7 @@ func GetFeeds(c *gin.Context) {
sqlQuery := fmt.Sprintf(` sqlQuery := fmt.Sprintf(`
SELECT f.id, f.nombre, f.descripcion, f.url, SELECT f.id, f.nombre, f.descripcion, f.url,
f.categoria_id, f.pais_id, f.idioma, f.activo, f.fallos, f.last_error, f.last_fetched, f.categoria_id, f.pais_id, f.idioma, f.activo, f.fallos, f.last_error,
c.nombre AS categoria, p.nombre AS pais, c.nombre AS categoria, p.nombre AS pais,
(SELECT COUNT(*) FROM noticias n WHERE n.fuente_nombre = f.nombre) as noticias_count (SELECT COUNT(*) FROM noticias n WHERE n.fuente_nombre = f.nombre) as noticias_count
FROM feeds f FROM feeds f
@ -95,7 +87,7 @@ func GetFeeds(c *gin.Context) {
args = append(args, perPage, offset) args = append(args, perPage, offset)
rows, err := db.GetPool().Query(ctx, sqlQuery, args...) rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()}) c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()})
return return
@ -103,7 +95,7 @@ func GetFeeds(c *gin.Context) {
defer rows.Close() defer rows.Close()
type FeedWithStats struct { type FeedWithStats struct {
models.Feed FeedResponse
Categoria *string `json:"categoria"` Categoria *string `json:"categoria"`
Pais *string `json:"pais"` Pais *string `json:"pais"`
NoticiasCount int64 `json:"noticias_count"` NoticiasCount int64 `json:"noticias_count"`
@ -114,7 +106,7 @@ func GetFeeds(c *gin.Context) {
var f FeedWithStats var f FeedWithStats
err := rows.Scan( err := rows.Scan(
&f.ID, &f.Nombre, &f.Descripcion, &f.URL, &f.ID, &f.Nombre, &f.Descripcion, &f.URL,
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastError, &f.LastFetched, &f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastError,
&f.Categoria, &f.Pais, &f.NoticiasCount, &f.Categoria, &f.Pais, &f.NoticiasCount,
) )
if err != nil { if err != nil {
@ -125,20 +117,13 @@ func GetFeeds(c *gin.Context) {
totalPages := (total + perPage - 1) / perPage totalPages := (total + perPage - 1) / perPage
response := gin.H{ c.JSON(http.StatusOK, gin.H{
"feeds": feeds, "feeds": feeds,
"total": total, "total": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
"total_pages": totalPages, "total_pages": totalPages,
} })
// Cache the response
if data, err := json.Marshal(response); err == nil {
cache.Set(ctx, cacheKey, string(data), cache.TTLMedium)
}
c.JSON(http.StatusOK, response)
} }
func GetFeedByID(c *gin.Context) { func GetFeedByID(c *gin.Context) {
@ -148,12 +133,12 @@ func GetFeedByID(c *gin.Context) {
return return
} }
var f models.Feed var f FeedResponse
err = db.GetPool().QueryRow(c.Request.Context(), ` err = db.GetPool().QueryRow(c.Request.Context(), `
SELECT id, nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos, last_fetched SELECT id, nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos
FROM feeds WHERE id = $1`, id).Scan( FROM feeds WHERE id = $1`, id).Scan(
&f.ID, &f.Nombre, &f.Descripcion, &f.URL, &f.ID, &f.Nombre, &f.Descripcion, &f.URL,
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastFetched, &f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos,
) )
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"}) c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
@ -192,9 +177,6 @@ func CreateFeed(c *gin.Context) {
return return
} }
// Invalidate feed list cache
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusCreated, gin.H{"id": feedID, "message": "Feed created successfully"}) c.JSON(http.StatusCreated, gin.H{"id": feedID, "message": "Feed created successfully"})
} }
@ -245,10 +227,6 @@ func UpdateFeed(c *gin.Context) {
return return
} }
// Invalidate feed cache
cache.InvalidateFeed(c.Request.Context(), id)
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed updated successfully"}) c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed updated successfully"})
} }
@ -270,10 +248,6 @@ func DeleteFeed(c *gin.Context) {
return return
} }
// Invalidate feed cache
cache.InvalidateFeed(c.Request.Context(), id)
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed deleted successfully"}) c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed deleted successfully"})
} }
@ -296,10 +270,6 @@ func ToggleFeedActive(c *gin.Context) {
return return
} }
// Invalidate feed cache
cache.InvalidateFeed(c.Request.Context(), id)
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed toggled successfully"}) c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed toggled successfully"})
} }
@ -322,10 +292,6 @@ func ReactivateFeed(c *gin.Context) {
return return
} }
// Invalidate feed cache
cache.InvalidateFeed(c.Request.Context(), id)
cache.InvalidateFeedList(c.Request.Context())
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed reactivated successfully"}) c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed reactivated successfully"})
} }
@ -431,46 +397,11 @@ func ImportFeeds(c *gin.Context) {
} }
reader := csv.NewReader(strings.NewReader(string(content))) reader := csv.NewReader(strings.NewReader(string(content)))
records, err := reader.ReadAll() _, err = reader.Read()
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid CSV format"}) c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid CSV format"})
return return
} }
if len(records) == 0 {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Empty CSV file"})
return
}
colIndex := func(header []string, names ...string) int {
for i, h := range header {
h = strings.ToLower(strings.TrimSpace(h))
for _, n := range names {
if h == n {
return i
}
}
}
return -1
}
header := records[0]
isHeader := colIndex(header, "url", "link", "feed", "nombre", "name", "titulo") != -1
var dataRows [][]string
iNombre, iDesc, iURL, iCat, iPais, iLang, iActivo, iFallos := -1, -1, -1, -1, -1, -1, -1, -1
if isHeader {
iNombre = colIndex(header, "nombre", "name", "titulo", "title")
iDesc = colIndex(header, "descripcion", "description")
iURL = colIndex(header, "url", "link", "feed")
iCat = colIndex(header, "categoria_id", "category_id", "categoria", "category")
iPais = colIndex(header, "pais_id", "country_id", "pais", "country")
iLang = colIndex(header, "idioma", "language", "lang")
iActivo = colIndex(header, "activo", "active", "enabled")
iFallos = colIndex(header, "fallos", "failures", "fails")
dataRows = records[1:]
} else {
dataRows = records
}
imported := 0 imported := 0
skipped := 0 skipped := 0
@ -482,122 +413,73 @@ func ImportFeeds(c *gin.Context) {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to start transaction", Message: err.Error()}) c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to start transaction", Message: err.Error()})
return return
} }
committed := false defer tx.Rollback(context.Background())
defer func() {
if !committed {
tx.Rollback(context.Background())
}
}()
field := func(record []string, idx int) string { for {
if idx < 0 || idx >= len(record) { record, err := reader.Read()
return "" if err == io.EOF {
break
} }
return strings.TrimSpace(record[idx]) if err != nil {
failed++
continue
} }
for _, record := range dataRows { if len(record) < 4 {
if len(record) == 0 {
skipped++ skipped++
continue continue
} }
var nombre, url string nombre := strings.TrimSpace(record[1])
if isHeader { url := strings.TrimSpace(record[3])
nombre = field(record, iNombre)
url = field(record, iURL)
} else if len(record) == 1 {
url = field(record, 0)
} else if len(record) == 2 {
a := field(record, 0)
b := field(record, 1)
if looksLikeURL(a) {
url, nombre = a, b
} else {
nombre, url = a, b
}
} else {
nombre = field(record, 1)
url = field(record, 3)
}
if url == "" { if nombre == "" || url == "" {
skipped++ skipped++
continue continue
} }
if nombre == "" {
nombre = nombreFromURL(url)
}
var descripcion *string var descripcion *string
if len(record) > 2 && strings.TrimSpace(record[2]) != "" {
descripcionStr := strings.TrimSpace(record[2])
descripcion = &descripcionStr
}
var categoriaID *int64 var categoriaID *int64
if len(record) > 4 && strings.TrimSpace(record[4]) != "" {
catID, err := strconv.ParseInt(strings.TrimSpace(record[4]), 10, 64)
if err == nil {
categoriaID = &catID
}
}
var paisID *int64 var paisID *int64
if len(record) > 6 && strings.TrimSpace(record[6]) != "" {
pID, err := strconv.ParseInt(strings.TrimSpace(record[6]), 10, 64)
if err == nil {
paisID = &pID
}
}
var idioma *string var idioma *string
if len(record) > 8 && strings.TrimSpace(record[8]) != "" {
lang := strings.TrimSpace(record[8])
if len(lang) > 2 {
lang = lang[:2]
}
idioma = &lang
}
activo := true activo := true
if len(record) > 9 && strings.TrimSpace(record[9]) != "" {
activo = strings.ToLower(strings.TrimSpace(record[9])) == "true"
}
var fallos int64 var fallos int64
if len(record) > 10 && strings.TrimSpace(record[10]) != "" {
if isHeader { f, err := strconv.ParseInt(strings.TrimSpace(record[10]), 10, 64)
if d := field(record, iDesc); d != "" { if err == nil {
descripcion = &d fallos = f
} }
if v := field(record, iCat); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
categoriaID = &id
}
}
if v := field(record, iPais); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
paisID = &id
}
}
if l := field(record, iLang); l != "" {
if len(l) > 2 {
l = l[:2]
}
idioma = &l
}
if a := field(record, iActivo); a != "" {
activo = strings.ToLower(a) == "true"
}
if f := field(record, iFallos); f != "" {
if v, err := strconv.ParseInt(f, 10, 64); err == nil {
fallos = v
}
}
} else {
if d := field(record, 2); d != "" {
descripcion = &d
}
if v := field(record, 4); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
categoriaID = &id
}
}
if v := field(record, 6); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
paisID = &id
}
}
if l := field(record, 8); l != "" {
if len(l) > 2 {
l = l[:2]
}
idioma = &l
}
if a := field(record, 9); a != "" {
activo = strings.ToLower(a) == "true"
}
if f := field(record, 10); f != "" {
if v, err := strconv.ParseInt(f, 10, 64); err == nil {
fallos = v
}
}
}
if _, err := tx.Exec(context.Background(), "SAVEPOINT sp"); err != nil {
failed++
errors = append(errors, fmt.Sprintf("Error for %s: %v", url, err))
continue
} }
var existingID int64 var existingID int64
@ -608,27 +490,22 @@ func ImportFeeds(c *gin.Context) {
WHERE id=$8`, WHERE id=$8`,
nombre, descripcion, categoriaID, paisID, idioma, activo, fallos, existingID, nombre, descripcion, categoriaID, paisID, idioma, activo, fallos, existingID,
) )
if err != nil {
failed++
errors = append(errors, fmt.Sprintf("Error updating %s: %v", url, err))
continue
}
} else { } else {
_, err = tx.Exec(context.Background(), ` _, err = tx.Exec(context.Background(), `
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos) INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
nombre, descripcion, url, categoriaID, paisID, idioma, activo, fallos, nombre, descripcion, url, categoriaID, paisID, idioma, activo, fallos,
) )
}
if err != nil { if err != nil {
if _, rbErr := tx.Exec(context.Background(), "ROLLBACK TO SAVEPOINT sp"); rbErr != nil {
errors = append(errors, fmt.Sprintf("Rollback failed for %s: %v", url, rbErr))
}
failed++ failed++
errors = append(errors, fmt.Sprintf("Error upserting %s: %v", url, err)) errors = append(errors, fmt.Sprintf("Error inserting %s: %v", url, err))
continue continue
} }
if _, err := tx.Exec(context.Background(), "RELEASE SAVEPOINT sp"); err != nil {
failed++
errors = append(errors, fmt.Sprintf("Error for %s: %v", url, err))
continue
} }
imported++ imported++
@ -638,7 +515,6 @@ func ImportFeeds(c *gin.Context) {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to commit transaction", Message: err.Error()}) c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to commit transaction", Message: err.Error()})
return return
} }
committed = true
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"imported": imported, "imported": imported,
@ -649,22 +525,6 @@ func ImportFeeds(c *gin.Context) {
}) })
} }
func looksLikeURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://www.") || strings.HasPrefix(s, "https://www.") || strings.HasPrefix(s, "www.") || strings.HasPrefix(s, "feed://")
}
func nombreFromURL(u string) string {
u = strings.TrimSpace(u)
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
u = "https://" + u
}
parsed, err := url.Parse(u)
if err != nil || parsed.Host == "" {
return u
}
return strings.TrimPrefix(parsed.Host, "www.")
}
func stringOrEmpty(s *string) string { func stringOrEmpty(s *string) string {
if s == nil { if s == nil {
return "" return ""

View file

@ -1,35 +0,0 @@
package handlers
import (
"testing"
)
func TestValidatePageParamsFeed(t *testing.T) {
tests := []struct {
name string
page int
perPage int
defaultPerPage int
maxPerPage int
expectedPage int
expectedPerPage int
}{
{"valid", 1, 50, 50, 100, 1, 50},
{"page zero", 0, 50, 50, 100, 1, 50},
{"per_page too large", 1, 200, 50, 100, 1, 100},
{"page negative", -5, 50, 50, 100, 1, 50},
{"per_page zero", 1, 0, 50, 100, 1, 50},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page, perPage := validatePageParams(tt.page, tt.perPage, tt.defaultPerPage, tt.maxPerPage)
if page != tt.expectedPage {
t.Errorf("page: expected %d, got %d", tt.expectedPage, page)
}
if perPage != tt.expectedPerPage {
t.Errorf("perPage: expected %d, got %d", tt.expectedPerPage, perPage)
}
})
}
}

View file

@ -1,54 +1,46 @@
package handlers package handlers
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/config"
"github.com/rss2/backend/internal/db" "github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models" "github.com/rss2/backend/internal/models"
) )
// getTargetLang returns the target language for translations, defaulting to config DefaultLang type NewsResponse struct {
func getTargetLang(c *gin.Context) string { ID string `json:"id"`
lang := c.Query("lang") Titulo string `json:"titulo"`
if lang == "" { Resumen string `json:"resumen"`
cfg := config.Load() URL string `json:"url"`
lang = cfg.DefaultLang Fecha *time.Time `json:"fecha"`
} ImagenURL *string `json:"imagen_url"`
return lang CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
FuenteNombre string `json:"fuente_nombre"`
TitleTranslated *string `json:"title_translated"`
SummaryTranslated *string `json:"summary_translated"`
LangTranslated *string `json:"lang_translated"`
Entities []Entity `json:"entities,omitempty"`
} }
// GetNews returns paginated news with optional filters
// @Summary List news
// @Description Returns paginated news with optional filtering by query, category, country, and translation status
// @Tags news
// @Produce json
// @Param page query int false "Page number" default(1) minimum(1)
// @Param per_page query int false "Items per page" default(30) minimum(1) maximum(100)
// @Param q query string false "Search query"
// @Param category_id query string false "Category ID filter"
// @Param country_id query string false "Country ID filter"
// @Param lang query string false "Target translation language" default(es)
// @Param translated_only query string false "Only show translated news" default("false")
// @Success 200 {object} map[string]interface{} "news, total, page, per_page, total_pages"
// @Failure 500 {object} models.ErrorResponse
// @Router /news [get]
func GetNews(c *gin.Context) { func GetNews(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
page, perPage = validatePageParams(page, perPage, 30, 100)
query := c.Query("q") query := c.Query("q")
categoryID := c.Query("category_id") categoryID := c.Query("category_id")
countryID := c.Query("country_id") countryID := c.Query("country_id")
translatedOnly := c.Query("translated_only") == "true" translatedOnly := c.Query("translated_only") == "true"
targetLang := getTargetLang(c)
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 30
}
offset := (page - 1) * perPage offset := (page - 1) * perPage
@ -57,13 +49,7 @@ func GetNews(c *gin.Context) {
argNum := 1 argNum := 1
if query != "" { if query != "" {
if translatedOnly {
// With translated_only, search the translated fields too so users can
// find translated news by the target language wording.
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d OR t.titulo_trad ILIKE $%d OR t.resumen_trad ILIKE $%d)", argNum, argNum, argNum, argNum)
} else {
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum) where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
}
args = append(args, "%"+query+"%") args = append(args, "%"+query+"%")
argNum++ argNum++
} }
@ -82,8 +68,7 @@ func GetNews(c *gin.Context) {
} }
var total int var total int
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM noticias n LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $%d WHERE %s", argNum, where) countQuery := fmt.Sprintf("SELECT COUNT(*) FROM noticias n LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es' WHERE %s", where)
args = append(args, targetLang)
err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total) err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count news", Message: err.Error()}) c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count news", Message: err.Error()})
@ -101,18 +86,19 @@ func GetNews(c *gin.Context) {
return return
} }
sqlQuery := ` sqlQuery := fmt.Sprintf(`
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url, SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang, n.categoria_id, n.pais_id, n.fuente_nombre,
t.titulo_trad, t.titulo_trad,
t.resumen_trad, t.resumen_trad,
t.lang_to as lang_trad t.lang_to as lang_trad
FROM noticias n FROM noticias n
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $` + strconv.Itoa(argNum) + ` LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
WHERE ` + where + ` WHERE %s
ORDER BY n.fecha DESC LIMIT $` + strconv.Itoa(argNum+1) + ` OFFSET $` + strconv.Itoa(argNum+2) ORDER BY n.fecha DESC LIMIT $%d OFFSET $%d
`, where, argNum, argNum+1)
args = append(args, targetLang, perPage, offset) args = append(args, perPage, offset)
rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...) rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
if err != nil { if err != nil {
@ -121,16 +107,15 @@ func GetNews(c *gin.Context) {
} }
defer rows.Close() defer rows.Close()
var newsList []models.NewsWithTranslations var newsList []NewsResponse
for rows.Next() { for rows.Next() {
var n models.NewsWithTranslations var n NewsResponse
var imagenURL, fuenteNombre *string var imagenURL, fuenteNombre *string
var categoriaID, paisID *int32 var categoriaID, paisID *int32
var lang string
err := rows.Scan( err := rows.Scan(
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL, &n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
&categoriaID, &paisID, &fuenteNombre, &lang, &categoriaID, &paisID, &fuenteNombre,
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated, &n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
) )
if err != nil { if err != nil {
@ -144,13 +129,12 @@ func GetNews(c *gin.Context) {
} }
if categoriaID != nil { if categoriaID != nil {
catID := int64(*categoriaID) catID := int64(*categoriaID)
n.CategoryID = &catID n.CategoriaID = &catID
} }
if paisID != nil { if paisID != nil {
pID := int64(*paisID) pID := int64(*paisID)
n.CountryID = &pID n.PaisID = &pID
} }
n.Lang = lang
newsList = append(newsList, n) newsList = append(newsList, n)
} }
@ -165,169 +149,27 @@ func GetNews(c *gin.Context) {
}) })
} }
// GetEntityNews returns the (translated) news that mention a given entity/alias value.
func GetEntityNews(c *gin.Context) {
valor := c.Query("valor")
tipo := c.DefaultQuery("tipo", "persona")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
page, perPage = validatePageParams(page, perPage, 20, 50)
offset := (page - 1) * perPage
targetLang := getTargetLang(c)
daysStr := c.Query("days")
days := 0
if daysStr != "" {
days, _ = strconv.Atoi(daysStr)
if days < 1 {
days = 0
}
}
today := c.Query("today") == "true"
offsetStr := c.Query("day_offset")
dayOffset := 0
useDayOffset := false
if offsetStr != "" {
dayOffset, _ = strconv.Atoi(offsetStr)
if dayOffset < 0 {
dayOffset = 0
}
useDayOffset = true
}
params := []interface{}{valor, tipo, targetLang}
next := 4
timeCond := ""
switch {
case useDayOffset:
// Día exacto: desde el inicio del día (hoy - offset) hasta el inicio del día siguiente
timeCond = fmt.Sprintf(
" AND n.fecha >= (CURRENT_DATE - %d)::timestamp AND n.fecha < (CURRENT_DATE - %d + 1)::timestamp",
dayOffset, dayOffset)
case today:
timeCond = " AND n.fecha >= date_trunc('day', NOW())"
case days > 0:
timeCond = fmt.Sprintf(" AND n.fecha >= NOW() - make_interval(days => $%d)", next)
params = append(params, days)
next++
}
base := fmt.Sprintf(`
FROM tags_noticia tn
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id AND tr.lang_to = $3
JOIN noticias n ON tr.noticia_id = n.id
LEFT JOIN entity_aliases ea ON LOWER(ea.alias) = LOWER(t.valor) AND ea.tipo = t.tipo
WHERE LOWER(COALESCE(ea.canonical_name, t.valor)) = LOWER($1) AND t.tipo = $2%s`, timeCond)
var total int
if err := db.GetPool().QueryRow(c.Request.Context(),
fmt.Sprintf("SELECT COUNT(DISTINCT tn.noticia_id) %s", base), params...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count entity news", Message: err.Error()})
return
}
query := fmt.Sprintf(`
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.contenido, n.url, n.fecha, n.imagen_url,
n.fuente_nombre, n.lang, tr.titulo_trad, tr.resumen_trad
%s
ORDER BY n.fecha DESC
LIMIT $%d OFFSET $%d`, base, next, next+1)
rows, err := db.GetPool().Query(c.Request.Context(), query, append(params, perPage, offset)...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get entity news", Message: err.Error()})
return
}
defer rows.Close()
var news []models.NewsWithTranslations
for rows.Next() {
var n models.NewsWithTranslations
var img *string
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &img,
&n.FuenteNombre, &n.Lang, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
continue
}
if img != nil {
n.ImagenURL = img
}
news = append(news, n)
}
if news == nil {
news = []models.NewsWithTranslations{}
}
totalPages := (total + perPage - 1) / perPage
c.JSON(http.StatusOK, gin.H{
"news": news,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
// GetNewsByID returns a single news item with entities
// @Summary Get news by ID
// @Description Returns a single news item with translations and associated entities
// @Tags news
// @Produce json
// @Param id path string true "News ID"
// @Param lang query string false "Target translation language" default(es)
// @Success 200 {object} models.NewsWithTranslations
// @Failure 404 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /news/{id} [get]
func GetNewsByID(c *gin.Context) { func GetNewsByID(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
targetLang := getTargetLang(c)
// Combined query to fetch news with entities in one round trip
sqlQuery := ` sqlQuery := `
SELECT SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url, n.categoria_id, n.pais_id, n.fuente_nombre,
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
t.titulo_trad, t.titulo_trad,
t.resumen_trad, t.resumen_trad,
t.lang_to as lang_trad, t.lang_to as lang_trad
json_agg(
json_build_object(
'valor', ent.valor,
'tipo', ent.tipo,
'count', ent.cnt,
'wiki_summary', ent.wiki_summary,
'wiki_url', ent.wiki_url,
'image_path', ent.image_path
)
) FILTER (WHERE ent.valor IS NOT NULL) as entities
FROM noticias n FROM noticias n
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1 LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
LEFT JOIN ( WHERE n.id = $1`
SELECT tn.noticia_id, t.valor, t.tipo, 1 as cnt, t.wiki_summary, t.wiki_url, t.image_path
FROM tags_noticia tn
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id
WHERE t.tipo IN ('persona', 'organizacion')
) ent ON ent.noticia_id = n.id
WHERE n.id = $2
GROUP BY n.id, n.titulo, n.resumen, n.contenido, n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
t.titulo_trad, t.resumen_trad, t.lang_to
`
var n models.NewsWithTranslations var n NewsResponse
var imagenURL, fuenteNombre *string var imagenURL, fuenteNombre *string
var categoriaID, paisID *int32 var categoriaID, paisID *int32
var lang string
var entitiesJSON *string
err := db.GetPool().QueryRow(c.Request.Context(), sqlQuery, targetLang, id).Scan( err := db.GetPool().QueryRow(c.Request.Context(), sqlQuery, id).Scan(
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL, &n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
&categoriaID, &paisID, &fuenteNombre, &lang, &categoriaID, &paisID, &fuenteNombre,
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated, &n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
&entitiesJSON,
) )
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "News not found"}) c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "News not found"})
@ -342,23 +184,36 @@ func GetNewsByID(c *gin.Context) {
} }
if categoriaID != nil { if categoriaID != nil {
catID := int64(*categoriaID) catID := int64(*categoriaID)
n.CategoryID = &catID n.CategoriaID = &catID
} }
if paisID != nil { if paisID != nil {
pID := int64(*paisID) pID := int64(*paisID)
n.CountryID = &pID n.PaisID = &pID
} }
n.Lang = lang
// Parse entities from JSON // Fetch entities for this news
if entitiesJSON != nil && *entitiesJSON != "null" { entitiesQuery := `
var entities []models.Entity SELECT t.valor, t.tipo, 1 as cnt, t.wiki_summary, t.wiki_url, t.image_path
if err := json.Unmarshal([]byte(*entitiesJSON), &entities); err == nil { FROM tags_noticia tn
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id
WHERE tr.noticia_id = $1 AND t.tipo IN ('persona', 'organizacion')
`
rows, err := db.GetPool().Query(c.Request.Context(), entitiesQuery, id)
var entities []Entity
if err == nil {
defer rows.Close()
for rows.Next() {
var e Entity
if err := rows.Scan(&e.Valor, &e.Tipo, &e.Count, &e.WikiSummary, &e.WikiURL, &e.ImagePath); err == nil {
entities = append(entities, e)
}
}
}
if entities == nil {
entities = []Entity{}
}
n.Entities = entities n.Entities = entities
}
} else {
n.Entities = []models.Entity{}
}
c.JSON(http.StatusOK, n) c.JSON(http.StatusOK, n)
} }
@ -380,20 +235,23 @@ func DeleteNews(c *gin.Context) {
c.JSON(http.StatusOK, models.SuccessResponse{Message: "News deleted successfully"}) c.JSON(http.StatusOK, models.SuccessResponse{Message: "News deleted successfully"})
} }
// GetEntities returns paginated entities with optional filters type Entity struct {
// @Summary List entities Valor string `json:"valor"`
// @Description Returns paginated entities (personas, organizations, locations) with optional filters Tipo string `json:"tipo"`
// @Tags entities Count int `json:"count"`
// @Produce json WikiSummary *string `json:"wiki_summary"`
// @Param tipo query string false "Entity type" default("persona") Enums(persona, organizacion, lugar, tema) WikiURL *string `json:"wiki_url"`
// @Param page query int false "Page number" default(1) minimum(1) ImagePath *string `json:"image_path"`
// @Param per_page query int false "Items per page" default(50) minimum(1) maximum(100) }
// @Param country_id query string false "Country ID filter"
// @Param category_id query string false "Category ID filter" type EntityListResponse struct {
// @Param q query string false "Search query" Entities []Entity `json:"entities"`
// @Success 200 {object} models.EntityListResponse Total int `json:"total"`
// @Failure 500 {object} models.ErrorResponse Page int `json:"page"`
// @Router /entities [get] PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
func GetEntities(c *gin.Context) { func GetEntities(c *gin.Context) {
countryID := c.Query("country_id") countryID := c.Query("country_id")
categoryID := c.Query("category_id") categoryID := c.Query("category_id")
@ -401,9 +259,18 @@ func GetEntities(c *gin.Context) {
q := c.Query("q") q := c.Query("q")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageStr := c.DefaultQuery("page", "1")
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) perPageStr := c.DefaultQuery("per_page", "50")
page, perPage = validatePageParams(page, perPage, 50, 100)
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 50
}
offset := (page - 1) * perPage offset := (page - 1) * perPage
@ -444,8 +311,8 @@ func GetEntities(c *gin.Context) {
} }
if total == 0 { if total == 0 {
c.JSON(http.StatusOK, models.EntityListResponse{ c.JSON(http.StatusOK, EntityListResponse{
Entities: []models.Entity{}, Entities: []Entity{},
Total: 0, Total: 0,
Page: page, Page: page,
PerPage: perPage, PerPage: perPage,
@ -477,9 +344,9 @@ func GetEntities(c *gin.Context) {
} }
defer rows.Close() defer rows.Close()
var entities []models.Entity var entities []Entity
for rows.Next() { for rows.Next() {
var e models.Entity var e Entity
if err := rows.Scan(&e.Valor, &e.Tipo, &e.Count, &e.WikiSummary, &e.WikiURL, &e.ImagePath); err != nil { if err := rows.Scan(&e.Valor, &e.Tipo, &e.Count, &e.WikiSummary, &e.WikiURL, &e.ImagePath); err != nil {
continue continue
} }
@ -487,12 +354,12 @@ func GetEntities(c *gin.Context) {
} }
if entities == nil { if entities == nil {
entities = []models.Entity{} entities = []Entity{}
} }
totalPages := (total + perPage - 1) / perPage totalPages := (total + perPage - 1) / perPage
c.JSON(http.StatusOK, models.EntityListResponse{ c.JSON(http.StatusOK, EntityListResponse{
Entities: entities, Entities: entities,
Total: total, Total: total,
Page: page, Page: page,
@ -500,123 +367,3 @@ func GetEntities(c *gin.Context) {
TotalPages: totalPages, TotalPages: totalPages,
}) })
} }
func GetEntityMentions(c *gin.Context) {
valuesRaw := c.DefaultQuery("values", "")
days := 30
if v := c.Query("days"); v != "" {
if d, err := strconv.Atoi(v); err == nil && d >= 1 && d <= 365 {
days = d
}
}
var values []string
for _, v := range strings.Split(valuesRaw, ",") {
v = strings.TrimSpace(v)
if v != "" {
values = append(values, v)
}
}
if len(values) == 0 {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "values is required (comma separated)"})
return
}
if len(values) > 20 {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Too many values (max 20)"})
return
}
start := time.Now().AddDate(0, 0, -(days - 1)).Truncate(24 * time.Hour)
series := make([]models.MentionSeries, 0, len(values))
for _, valor := range values {
s, err := buildMentionSeries(c.Request.Context(), valor, days)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get mentions", Message: err.Error()})
return
}
m := map[string]int{}
for _, p := range s.Data {
m[p.Fecha] = p.Count
}
data := make([]models.MentionPoint, 0, days)
for i := 0; i < days; i++ {
fecha := start.AddDate(0, 0, i).Format("2006-01-02")
data = append(data, models.MentionPoint{Fecha: fecha, Count: m[fecha]})
}
s.Data = data
s.Count = 0
for _, p := range s.Data {
s.Count += p.Count
}
if s.Tipo == "" {
s.Tipo = "desconocido"
}
series = append(series, *s)
}
c.JSON(http.StatusOK, models.MentionsResponse{Days: days, Series: series})
}
func buildMentionSeries(ctx context.Context, valor string, days int) (*models.MentionSeries, error) {
s := &models.MentionSeries{Valor: valor}
var tagIDs []int64
rows, err := db.GetPool().Query(ctx, `
SELECT DISTINCT t.id, t.tipo
FROM tags t
LEFT JOIN entity_aliases ea ON ea.tipo = t.tipo AND LOWER(ea.alias) = LOWER(t.valor)
WHERE LOWER(t.valor) = LOWER($1)
OR LOWER(COALESCE(ea.canonical_name, t.valor)) = LOWER($1)
`, valor)
if err != nil {
return nil, err
}
for rows.Next() {
var id int64
var tipo string
if err := rows.Scan(&id, &tipo); err != nil {
continue
}
tagIDs = append(tagIDs, id)
if s.Tipo == "" {
s.Tipo = tipo
}
}
rows.Close()
if len(tagIDs) == 0 {
s.Data = []models.MentionPoint{}
return s, nil
}
drows, err := db.GetPool().Query(ctx, `
SELECT n.fecha::date::text AS dia, COUNT(DISTINCT n.id) AS cnt
FROM tags_noticia tn
JOIN tags t ON tn.tag_id = t.id
JOIN traducciones tr ON tn.traduccion_id = tr.id
JOIN noticias n ON tr.noticia_id = n.id
WHERE t.id = ANY($1) AND n.fecha >= CURRENT_DATE - ($2::int - 1)
GROUP BY n.fecha::date
ORDER BY n.fecha::date
`, tagIDs, days)
if err != nil {
return nil, err
}
defer drows.Close()
for drows.Next() {
var fecha string
var cnt int
if err := drows.Scan(&fecha, &cnt); err != nil {
continue
}
s.Data = append(s.Data, models.MentionPoint{Fecha: fecha, Count: cnt})
}
if s.Data == nil {
s.Data = []models.MentionPoint{}
}
return s, nil
}

View file

@ -1,38 +0,0 @@
package handlers
import (
"testing"
)
func TestValidatePageParams(t *testing.T) {
tests := []struct {
name string
page int
perPage int
defaultPerPage int
maxPerPage int
expectedPage int
expectedPerPage int
}{
{"normal", 1, 30, 30, 100, 1, 30},
{"page zero", 0, 30, 30, 100, 1, 30},
{"page negative", -5, 30, 30, 100, 1, 30},
{"per_page zero", 1, 0, 30, 100, 1, 30},
{"per_page negative", 1, -10, 30, 100, 1, 30},
{"per_page exceeds max", 1, 200, 30, 100, 1, 100},
{"page exceeds max", 999, 30, 30, 100, 999, 30},
{"custom max", 1, 200, 20, 50, 1, 50},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page, perPage := validatePageParams(tt.page, tt.perPage, tt.defaultPerPage, tt.maxPerPage)
if page != tt.expectedPage {
t.Errorf("page: expected %d, got %d", tt.expectedPage, page)
}
if perPage != tt.expectedPerPage {
t.Errorf("perPage: expected %d, got %d", tt.expectedPerPage, perPage)
}
})
}
}

View file

@ -1,187 +0,0 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models"
)
func CreateRemoteWorker(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Capabilities string `json:"capabilities"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "message": err.Error()})
return
}
if req.Capabilities == "" {
req.Capabilities = "cpu"
}
apiKey := generateAPIKey()
ctx := c.Request.Context()
var workerID int
err := db.GetPool().QueryRow(ctx, `
INSERT INTO remote_workers (name, api_key, capabilities, status, created_at)
VALUES ($1, $2, $3, 'offline', NOW())
RETURNING id
`, req.Name, apiKey, req.Capabilities).Scan(&workerID)
if err != nil {
log.Printf("Error creating remote worker: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create worker"})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": workerID,
"name": req.Name,
"api_key": apiKey,
"capabilities": req.Capabilities,
"status": "offline",
})
}
func ListRemoteWorkers(c *gin.Context) {
ctx := c.Request.Context()
rows, err := db.GetPool().Query(ctx, `
SELECT id, name, api_key, capabilities, status, last_seen, created_at
FROM remote_workers
ORDER BY created_at DESC
`)
if err != nil {
log.Printf("Error listing remote workers: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list workers"})
return
}
defer rows.Close()
var workers []models.RemoteWorker
for rows.Next() {
var w models.RemoteWorker
if err := rows.Scan(&w.ID, &w.Name, &w.APIKey, &w.Capabilities, &w.Status, &w.LastSeen, &w.CreatedAt); err != nil {
log.Printf("Error scanning worker: %v", err)
continue
}
workers = append(workers, w)
}
if workers == nil {
workers = []models.RemoteWorker{}
}
c.JSON(http.StatusOK, workers)
}
func GetRemoteWorker(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
var w models.RemoteWorker
err := db.GetPool().QueryRow(ctx, `
SELECT id, name, api_key, capabilities, status, last_seen, created_at
FROM remote_workers
WHERE id = $1
`, id).Scan(&w.ID, &w.Name, &w.APIKey, &w.Capabilities, &w.Status, &w.LastSeen, &w.CreatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Worker not found"})
return
}
var stats struct {
JobsCompleted int `json:"jobs_completed"`
JobsPending int `json:"jobs_pending"`
}
db.GetPool().QueryRow(ctx, `
SELECT COUNT(*) FROM traducciones WHERE worker_id = $1 AND status = 'done'
`, id).Scan(&stats.JobsCompleted)
db.GetPool().QueryRow(ctx, `
SELECT COUNT(*) FROM traducciones WHERE worker_id = $1 AND status = 'pending'
`, id).Scan(&stats.JobsPending)
c.JSON(http.StatusOK, gin.H{
"worker": w,
"stats": stats,
})
}
func DeleteRemoteWorker(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
result, err := db.GetPool().Exec(ctx, `
DELETE FROM remote_workers WHERE id = $1
`, id)
if err != nil {
log.Printf("Error deleting remote worker: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete worker"})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Worker not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Worker deleted"})
}
func ToggleRemoteWorker(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
var currentStatus string
err := db.GetPool().QueryRow(ctx, `SELECT status FROM remote_workers WHERE id = $1`, id).Scan(&currentStatus)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Worker not found"})
return
}
newStatus := "disabled"
if currentStatus == "disabled" {
newStatus = "offline"
}
_, err = db.GetPool().Exec(ctx, `UPDATE remote_workers SET status = $1 WHERE id = $2`, newStatus, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update worker status"})
return
}
c.JSON(http.StatusOK, gin.H{"status": newStatus})
}
func RegenerateAPIKey(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
newAPIKey := generateAPIKey()
_, err := db.GetPool().Exec(ctx, `UPDATE remote_workers SET api_key = $1 WHERE id = $2`, newAPIKey, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate API key"})
return
}
c.JSON(http.StatusOK, gin.H{"api_key": newAPIKey})
}
func generateAPIKey() string {
bytes := make([]byte, 32)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}

View file

@ -1,107 +1,19 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth"
"github.com/rss2/backend/internal/cache"
"github.com/rss2/backend/internal/db" "github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models" "github.com/rss2/backend/internal/models"
"github.com/rss2/backend/internal/services" "github.com/rss2/backend/internal/services"
) )
// LogSearch records a logged-in user's search term so frequent searches gain priority.
func LogSearch(c *gin.Context) {
user := c.MustGet("user").(*auth.Claims)
var req struct {
Query string `json:"q"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request"})
return
}
term := strings.ToLower(strings.TrimSpace(req.Query))
if term == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "q requerido"})
return
}
if len(term) > 100 {
term = term[:100]
}
_, err := db.GetPool().Exec(c.Request.Context(), `
INSERT INTO user_search_tags (user_id, term, count, last_used)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (user_id, term)
DO UPDATE SET count = user_search_tags.count + 1, last_used = NOW()
`, user.UserID, term)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to save search", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SearchSuggestions returns the user's most used search terms (dynamic tags).
func SearchSuggestions(c *gin.Context) {
user := c.MustGet("user").(*auth.Claims)
q := strings.TrimSpace(c.Query("q"))
query := `
SELECT term FROM user_search_tags
WHERE user_id = $1`
args := []interface{}{user.UserID}
if q != "" {
query += ` AND term ILIKE $2`
args = append(args, "%"+q+"%")
}
query += ` ORDER BY count DESC, last_used DESC LIMIT 10`
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get suggestions", Message: err.Error()})
return
}
defer rows.Close()
terms := []string{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
continue
}
terms = append(terms, t)
}
c.JSON(http.StatusOK, gin.H{"terms": terms})
}
// SearchNews searches news with various filters
// @Summary Search news
// @Description Search news with text query, language, category, country, and semantic search options
// @Tags search
// @Produce json
// @Param q query string false "Search query"
// @Param page query int false "Page number" default(1) minimum(1)
// @Param per_page query int false "Items per page" default(30) minimum(1) maximum(100)
// @Param lang query string false "Language code" default("es")
// @Param categoria_id query string false "Category ID filter"
// @Param pais_id query string false "Country ID filter"
// @Param semantic query string false "Use semantic search" default("false")
// @Success 200 {object} map[string]interface{} "news, total, page, per_page, total_pages"
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
// @Router /search [get]
func SearchNews(c *gin.Context) { func SearchNews(c *gin.Context) {
query := c.Query("q") query := c.Query("q")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30"))
page, perPage = validatePageParams(page, perPage, 30, 100)
lang := c.DefaultQuery("lang", "") lang := c.DefaultQuery("lang", "")
categoriaID := c.Query("categoria_id") categoriaID := c.Query("categoria_id")
paisID := c.Query("pais_id") paisID := c.Query("pais_id")
@ -112,6 +24,13 @@ func SearchNews(c *gin.Context) {
return return
} }
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 30
}
// Default to Spanish if no lang specified // Default to Spanish if no lang specified
if lang == "" { if lang == "" {
lang = "es" lang = "es"
@ -129,58 +48,59 @@ func SearchNews(c *gin.Context) {
return return
} }
// Cache key for search results
cacheKey := cache.SearchKey(query, lang, page, perPage)
// Try to get from cache
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
}
offset := (page - 1) * perPage offset := (page - 1) * perPage
// Build dynamic query // Build dynamic query
args := []interface{}{lang} args := []interface{}{}
argNum := 2 argNum := 1
whereClause := "WHERE 1=1" whereClause := "WHERE 1=1"
if query != "" { if query != "" {
whereClause += " AND (n.titulo ILIKE $" + strconv.Itoa(argNum) + " OR n.resumen ILIKE $" + strconv.Itoa(argNum) + ")" whereClause += " AND (n.titulo ILIKE $" + strconv.Itoa(argNum) + " OR n.resumen ILIKE $" + strconv.Itoa(argNum) + " OR n.contenido ILIKE $" + strconv.Itoa(argNum) + ")"
args = append(args, "%"+query+"%") args = append(args, "%"+query+"%")
argNum++ argNum++
} }
if lang != "" {
whereClause += " AND t.lang_to = $" + strconv.Itoa(argNum)
args = append(args, lang)
argNum++
}
if categoriaID != "" { if categoriaID != "" {
if catID, err := strconv.ParseInt(categoriaID, 10, 64); err == nil {
whereClause += " AND n.categoria_id = $" + strconv.Itoa(argNum) whereClause += " AND n.categoria_id = $" + strconv.Itoa(argNum)
catID, err := strconv.ParseInt(categoriaID, 10, 64)
if err == nil {
args = append(args, catID) args = append(args, catID)
argNum++ argNum++
} }
} }
if paisID != "" { if paisID != "" {
if pID, err := strconv.ParseInt(paisID, 10, 64); err == nil {
whereClause += " AND n.pais_id = $" + strconv.Itoa(argNum) whereClause += " AND n.pais_id = $" + strconv.Itoa(argNum)
pID, err := strconv.ParseInt(paisID, 10, 64)
if err == nil {
args = append(args, pID) args = append(args, pID)
argNum++ argNum++
} }
} }
sqlQuery := `
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.contenido, n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre, n.lang,
t.titulo_trad,
t.resumen_trad,
t.lang_to as lang_trad
FROM noticias n
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
` + whereClause + `
ORDER BY n.fecha DESC
LIMIT $` + strconv.Itoa(argNum) + ` OFFSET $` + strconv.Itoa(argNum+1)
args = append(args, perPage, offset) args = append(args, perPage, offset)
sqlQuery := `
SELECT n.id, n.titulo, n.resumen, n.contenido, n.url, n.imagen,
n.feed_id, n.lang, n.categoria_id, n.pais_id, n.created_at, n.updated_at,
COALESCE(t.titulo_trad, '') as titulo_trad,
COALESCE(t.resumen_trad, '') as resumen_trad,
t.lang_to as lang_trad,
f.nombre as fuente_nombre
FROM noticias n
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $` + strconv.Itoa(argNum) + `
LEFT JOIN feeds f ON f.id = n.feed_id
` + whereClause + `
ORDER BY n.created_at DESC
LIMIT $` + strconv.Itoa(argNum+1) + ` OFFSET $` + strconv.Itoa(argNum+2)
rows, err := db.GetPool().Query(ctx, sqlQuery, args...) rows, err := db.GetPool().Query(ctx, sqlQuery, args...)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Search failed", Message: err.Error()}) c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Search failed", Message: err.Error()})
@ -191,37 +111,30 @@ func SearchNews(c *gin.Context) {
var newsList []models.NewsWithTranslations var newsList []models.NewsWithTranslations
for rows.Next() { for rows.Next() {
var n models.NewsWithTranslations var n models.NewsWithTranslations
var imagenURL, fuenteNombre *string var imagen *string
var categoriaIDp, paisIDp *int64
var lang string
err := rows.Scan( err := rows.Scan(
&n.ID, &n.Titulo, &n.Resumen, &n.Contenido, &n.URL, &n.Fecha, &imagenURL, &n.ID, &n.Title, &n.Summary, &n.Content, &n.URL, &imagen,
&categoriaIDp, &paisIDp, &fuenteNombre, &lang, &n.FeedID, &n.Lang, &n.CategoryID, &n.CountryID, &n.CreatedAt, &n.UpdatedAt,
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated, &n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
) )
if err != nil { if err != nil {
continue continue
} }
if imagenURL != nil { if imagen != nil {
n.ImagenURL = imagenURL n.ImageURL = imagen
} }
if fuenteNombre != nil {
n.FuenteNombre = *fuenteNombre
}
n.CategoryID = categoriaIDp
n.CountryID = paisIDp
n.Lang = lang
newsList = append(newsList, n) newsList = append(newsList, n)
} }
// Get total count // Get total count
countArgs := args[:len(args)-2] countArgs := args[:len(args)-2]
// Remove LIMIT/OFFSET from args for count
var total int var total int
err = db.GetPool().QueryRow(ctx, ` err = db.GetPool().QueryRow(ctx, `
SELECT COUNT(*) FROM noticias n SELECT COUNT(*) FROM noticias n
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1 LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $`+strconv.Itoa(argNum)+`
`+whereClause, countArgs...).Scan(&total) `+whereClause, countArgs...).Scan(&total)
if err != nil { if err != nil {
total = len(newsList) total = len(newsList)
@ -229,42 +142,21 @@ func SearchNews(c *gin.Context) {
totalPages := (total + perPage - 1) / perPage totalPages := (total + perPage - 1) / perPage
response := gin.H{ response := models.NewsListResponse{
"news": newsList, News: newsList,
"total": total, Total: total,
"page": page, Page: page,
"per_page": perPage, PerPage: perPage,
"total_pages": totalPages, TotalPages: totalPages,
}
// Cache the response
if data, err := json.Marshal(response); err == nil {
cache.Set(ctx, cacheKey, string(data), cache.TTLShort)
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// GetStats returns global statistics
// @Summary Get statistics
// @Description Returns global statistics about news, feeds, users, and translations
// @Tags stats
// @Produce json
// @Success 200 {object} models.Stats
// @Failure 500 {object} models.ErrorResponse
// @Router /stats [get]
func GetStats(c *gin.Context) { func GetStats(c *gin.Context) {
ctx := c.Request.Context()
cacheKey := cache.StatsKey()
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
}
var stats models.Stats var stats models.Stats
err := db.GetPool().QueryRow(ctx, ` err := db.GetPool().QueryRow(c.Request.Context(), `
SELECT SELECT
(SELECT COUNT(*) FROM noticias) as total_news, (SELECT COUNT(*) FROM noticias) as total_news,
(SELECT COUNT(*) FROM feeds WHERE activo = true) as total_feeds, (SELECT COUNT(*) FROM feeds WHERE activo = true) as total_feeds,
@ -283,7 +175,7 @@ func GetStats(c *gin.Context) {
return return
} }
rows, err := db.GetPool().Query(ctx, ` rows, err := db.GetPool().Query(c.Request.Context(), `
SELECT c.id, c.nombre, COUNT(n.id) as count SELECT c.id, c.nombre, COUNT(n.id) as count
FROM categorias c FROM categorias c
LEFT JOIN noticias n ON n.categoria_id = c.id LEFT JOIN noticias n ON n.categoria_id = c.id
@ -295,12 +187,12 @@ func GetStats(c *gin.Context) {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var cs models.CategoryStat var cs models.CategoryStat
rows.Scan(&cs.CategoriaID, &cs.CategoriaName, &cs.Count) rows.Scan(&cs.CategoryID, &cs.CategoryName, &cs.Count)
stats.TopCategories = append(stats.TopCategories, cs) stats.TopCategories = append(stats.TopCategories, cs)
} }
} }
rows, err = db.GetPool().Query(ctx, ` rows, err = db.GetPool().Query(c.Request.Context(), `
SELECT p.id, p.nombre, p.flag_emoji, COUNT(n.id) as count SELECT p.id, p.nombre, p.flag_emoji, COUNT(n.id) as count
FROM paises p FROM paises p
LEFT JOIN noticias n ON n.pais_id = p.id LEFT JOIN noticias n ON n.pais_id = p.id
@ -312,28 +204,16 @@ func GetStats(c *gin.Context) {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var cs models.CountryStat var cs models.CountryStat
rows.Scan(&cs.PaisID, &cs.PaisName, &cs.FlagEmoji, &cs.Count) rows.Scan(&cs.CountryID, &cs.CountryName, &cs.FlagEmoji, &cs.Count)
stats.TopCountries = append(stats.TopCountries, cs) stats.TopCountries = append(stats.TopCountries, cs)
} }
} }
if data, err := json.Marshal(stats); err == nil {
cache.Set(ctx, cacheKey, string(data), cache.TTLMedium)
}
c.JSON(http.StatusOK, stats) c.JSON(http.StatusOK, stats)
} }
func GetCategories(c *gin.Context) { func GetCategories(c *gin.Context) {
ctx := c.Request.Context() rows, err := db.GetPool().Query(c.Request.Context(), `
cacheKey := cache.CategoriesKey()
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
}
rows, err := db.GetPool().Query(ctx, `
SELECT id, nombre FROM categorias ORDER BY nombre`) SELECT id, nombre FROM categorias ORDER BY nombre`)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get categories", Message: err.Error()}) c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get categories", Message: err.Error()})
@ -341,30 +221,23 @@ func GetCategories(c *gin.Context) {
} }
defer rows.Close() defer rows.Close()
var categories []models.Category type Category struct {
for rows.Next() { ID int64 `json:"id"`
var cat models.Category Nombre string `json:"nombre"`
rows.Scan(&cat.ID, &cat.Nombre)
categories = append(categories, cat)
} }
if data, err := json.Marshal(categories); err == nil { var categories []Category
cache.Set(ctx, cacheKey, string(data), cache.TTLLong) for rows.Next() {
var cat Category
rows.Scan(&cat.ID, &cat.Nombre)
categories = append(categories, cat)
} }
c.JSON(http.StatusOK, categories) c.JSON(http.StatusOK, categories)
} }
func GetCountries(c *gin.Context) { func GetCountries(c *gin.Context) {
ctx := c.Request.Context() rows, err := db.GetPool().Query(c.Request.Context(), `
cacheKey := cache.CountriesKey()
if cached, err := cache.Get(ctx, cacheKey); err == nil && cached != "" {
c.Data(http.StatusOK, "application/json", []byte(cached))
return
}
rows, err := db.GetPool().Query(ctx, `
SELECT p.id, p.nombre, c.nombre as continente SELECT p.id, p.nombre, c.nombre as continente
FROM paises p FROM paises p
LEFT JOIN continentes c ON c.id = p.continente_id LEFT JOIN continentes c ON c.id = p.continente_id
@ -375,16 +248,18 @@ func GetCountries(c *gin.Context) {
} }
defer rows.Close() defer rows.Close()
var countries []models.Country type Country struct {
ID int64 `json:"id"`
Nombre string `json:"nombre"`
Continente string `json:"continente"`
}
var countries []Country
for rows.Next() { for rows.Next() {
var country models.Country var country Country
rows.Scan(&country.ID, &country.Nombre, &country.Continente) rows.Scan(&country.ID, &country.Nombre, &country.Continente)
countries = append(countries, country) countries = append(countries, country)
} }
if data, err := json.Marshal(countries); err == nil {
cache.Set(ctx, cacheKey, string(data), cache.TTLLong)
}
c.JSON(http.StatusOK, countries) c.JSON(http.StatusOK, countries)
} }

View file

@ -1,35 +0,0 @@
package handlers
import (
"testing"
)
func TestValidatePageParamsSearch(t *testing.T) {
tests := []struct {
name string
page int
perPage int
defaultPerPage int
maxPerPage int
expectedPage int
expectedPerPage int
}{
{"normal", 1, 30, 30, 100, 1, 30},
{"page zero", 0, 30, 30, 100, 1, 30},
{"per_page too large", 1, 200, 30, 100, 1, 100},
{"page negative", -5, 30, 30, 100, 1, 30},
{"per_page zero", 1, 0, 30, 100, 1, 30},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page, perPage := validatePageParams(tt.page, tt.perPage, tt.defaultPerPage, tt.maxPerPage)
if page != tt.expectedPage {
t.Errorf("page: expected %d, got %d", tt.expectedPage, page)
}
if perPage != tt.expectedPerPage {
t.Errorf("perPage: expected %d, got %d", tt.expectedPerPage, perPage)
}
})
}
}

View file

@ -1,14 +0,0 @@
package handlers
func validatePageParams(page, perPage, defaultPerPage, maxPerPage int) (int, int) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = defaultPerPage
}
if perPage > maxPerPage {
perPage = maxPerPage
}
return page, perPage
}

View file

@ -1,344 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type WSWorker struct {
conn *websocket.Conn
workerID int
capabilities string
workerName string
lastHeartbeat time.Time
mu sync.Mutex
}
var (
workers = make(map[int]*WSWorker)
workersByAPI = make(map[string]*WSWorker)
workersMu sync.RWMutex
)
func HandleWorkerWS(c *gin.Context) {
apiKey := c.Query("api_key")
if apiKey == "" {
apiKey = c.GetHeader("X-API-Key")
}
if apiKey == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"})
return
}
ctx := c.Request.Context()
var workerID int
var capabilities string
err := db.GetPool().QueryRow(ctx, `
SELECT id, capabilities FROM remote_workers
WHERE api_key = $1 AND status != 'disabled'
`, apiKey).Scan(&workerID, &capabilities)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
wsWorker := &WSWorker{
conn: conn,
workerID: workerID,
capabilities: capabilities,
lastHeartbeat: time.Now(),
}
workersMu.Lock()
workers[workerID] = wsWorker
workersByAPI[apiKey] = wsWorker
workersMu.Unlock()
db.GetPool().Exec(ctx, `
UPDATE remote_workers SET status = 'online', last_seen = NOW() WHERE id = $1
`, workerID)
go heartbeatLoop(wsWorker)
go writeLoop(wsWorker)
readLoop(wsWorker, apiKey)
}
func readLoop(wsWorker *WSWorker, apiKey string) {
defer func() {
cleanupWorker(wsWorker, apiKey)
}()
for {
var msg models.WSClientMessage
err := wsWorker.conn.ReadJSON(&msg)
if err != nil {
log.Printf("Worker %d read error: %v", wsWorker.workerID, err)
break
}
switch msg.Type {
case "register":
wsWorker.mu.Lock()
if msg.WorkerName != "" {
wsWorker.workerName = msg.WorkerName
}
if msg.Capabilities != "" {
wsWorker.capabilities = msg.Capabilities
}
wsWorker.mu.Unlock()
sendWS(wsWorker, models.WSServerMessage{Type: "ack", Job: nil})
case "heartbeat":
wsWorker.mu.Lock()
wsWorker.lastHeartbeat = time.Now()
wsWorker.mu.Unlock()
sendWS(wsWorker, models.WSServerMessage{Type: "ack", Job: nil})
case "result":
if msg.Result != nil {
handleTranslationResult(wsWorker.workerID, msg.Result)
}
}
}
}
func writeLoop(wsWorker *WSWorker) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
wsWorker.mu.Lock()
err := wsWorker.conn.WriteMessage(websocket.PingMessage, nil)
wsWorker.mu.Unlock()
if err != nil {
return
}
}
}
}
func heartbeatLoop(wsWorker *WSWorker) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
wsWorker.mu.Lock()
elapsed := time.Since(wsWorker.lastHeartbeat)
wsWorker.mu.Unlock()
if elapsed > 60*time.Second {
log.Printf("Worker %d heartbeat timeout", wsWorker.workerID)
wsWorker.conn.Close()
return
}
sendWS(wsWorker, models.WSServerMessage{Type: "ping", Job: nil})
}
}
}
func sendWS(wsWorker *WSWorker, msg models.WSServerMessage) {
data, _ := json.Marshal(msg)
wsWorker.mu.Lock()
wsWorker.conn.WriteMessage(websocket.TextMessage, data)
wsWorker.mu.Unlock()
}
func cleanupWorker(wsWorker *WSWorker, apiKey string) {
workersMu.Lock()
delete(workers, wsWorker.workerID)
delete(workersByAPI, apiKey)
workersMu.Unlock()
ctx := context.Background()
db.GetPool().Exec(ctx, `
UPDATE remote_workers SET status = 'offline', last_seen = NOW() WHERE id = $1
`, wsWorker.workerID)
wsWorker.conn.Close()
db.GetPool().Exec(ctx, `
UPDATE traducciones
SET status = 'pending', worker_id = NULL, assigned_at = NULL
WHERE worker_id = $1 AND status = 'assigned'
`, wsWorker.workerID)
log.Printf("Worker %d disconnected", wsWorker.workerID)
}
func handleTranslationResult(workerID int, result *models.TranslationResult) {
ctx := context.Background()
if result.Error != "" {
db.GetPool().Exec(ctx, `
UPDATE traducciones
SET status = 'error', worker_id = NULL, assigned_at = NULL
WHERE id = $1
`, result.JobID)
log.Printf("Job %d failed: %s", result.JobID, result.Error)
return
}
_, err := db.GetPool().Exec(ctx, `
UPDATE traducciones
SET titulo_trad = $1, resumen_trad = $2, status = 'done',
worker_id = $3, assigned_at = NULL
WHERE id = $4
`, result.TitleTr, result.SummaryTr, workerID, result.JobID)
if err != nil {
log.Printf("Error updating translation result: %v", err)
}
db.GetPool().Exec(ctx, `
UPDATE remote_workers SET last_seen = NOW() WHERE id = $1
`, workerID)
log.Printf("Job %d completed by worker %d", result.JobID, workerID)
}
func AssignJobToWorker(workerID int) *models.TranslationJob {
ctx := context.Background()
workersMu.RLock()
wsWorker, exists := workers[workerID]
workersMu.RUnlock()
if !exists {
return nil
}
wsWorker.mu.Lock()
workerCapabilities := wsWorker.capabilities
wsWorker.mu.Unlock()
_ = workerCapabilities
var job models.TranslationJob
err := db.GetPool().QueryRow(ctx, `
SELECT t.id, t.noticia_id, t.lang_from, t.lang_to, n.titulo, n.resumen
FROM traducciones t
JOIN noticias n ON n.id = t.noticia_id
WHERE t.status = 'pending'
AND (t.worker_id IS NULL OR t.status = 'assigned' AND t.assigned_at < NOW() - INTERVAL '5 minutes')
AND t.lang_to = 'es'
AND (t.titulo_trad IS NULL OR t.resumen_trad IS NULL)
ORDER BY n.fecha DESC
LIMIT 1
FOR UPDATE SKIP LOCKED
`).Scan(&job.ID, &job.NewsID, &job.LangFrom, &job.LangTo, &job.Title, &job.Summary)
if err != nil {
return nil
}
db.GetPool().Exec(ctx, `
UPDATE traducciones
SET status = 'assigned', worker_id = $1, assigned_at = NOW()
WHERE id = $2
`, workerID, job.ID)
sendWS(wsWorker, models.WSServerMessage{
Type: "job",
Job: &job,
})
return &job
}
func StartJobAssigner() {
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
workersMu.RLock()
for workerID := range workers {
workersMu.RUnlock()
AssignJobToWorker(workerID)
workersMu.RLock()
}
workersMu.RUnlock()
ctx := context.Background()
db.GetPool().Exec(ctx, `
UPDATE traducciones
SET status = 'pending', worker_id = NULL, assigned_at = NULL
WHERE status = 'assigned'
AND assigned_at < NOW() - INTERVAL '10 minutes'
`)
}
}
}()
}
func GetModelDownloadURL(c *gin.Context) {
apiKey := c.Query("api_key")
if apiKey == "" {
apiKey = c.GetHeader("X-API-Key")
}
if apiKey == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"})
return
}
ctx := c.Request.Context()
var workerID int
err := db.GetPool().QueryRow(ctx, `
SELECT id FROM remote_workers WHERE api_key = $1
`, apiKey).Scan(&workerID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
return
}
var modelURL, modelPath string
db.GetPool().QueryRow(ctx, `
SELECT value FROM config WHERE key = 'remote_worker_model_url'
`).Scan(&modelURL)
db.GetPool().QueryRow(ctx, `
SELECT value FROM config WHERE key = 'remote_worker_model_path'
`).Scan(&modelPath)
if modelPath == "" {
modelPath = "/app/models/nllb-ct2"
}
c.JSON(http.StatusOK, gin.H{
"model_download_url": modelURL,
"model_path": modelPath,
})
}

View file

@ -1,60 +0,0 @@
package logger
import (
"os"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var Logger zerolog.Logger
func Init(serviceName, level string) {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
zerolog.CallerMarshalFunc = func(pc uintptr, file string, line int) string {
short := file
for i := len(file) - 1; i > 0; i-- {
if file[i] == '/' {
short = file[i+1:]
break
}
}
return short + ":" + string(rune(line))
}
output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: false}
logger := zerolog.New(output).With().Timestamp().Caller().Str("service", serviceName).Logger()
switch level {
case "debug":
logger = logger.Level(zerolog.DebugLevel)
case "info":
logger = logger.Level(zerolog.InfoLevel)
case "warn":
logger = logger.Level(zerolog.WarnLevel)
case "error":
logger = logger.Level(zerolog.ErrorLevel)
default:
logger = logger.Level(zerolog.InfoLevel)
}
Logger = logger
log.Logger = logger
}
func GetLogger() zerolog.Logger {
return Logger
}
func WithRequestID(requestID string) zerolog.Logger {
return Logger.With().Str("request_id", requestID).Logger()
}
func WithFields(fields map[string]interface{}) zerolog.Logger {
ctx := Logger.With()
for k, v := range fields {
ctx = ctx.Interface(k, v)
}
return ctx.Logger()
}

View file

@ -1,19 +1,27 @@
package middleware package middleware
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"sync"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth" "github.com/golang-jwt/jwt/v5"
"github.com/rss2/backend/internal/config"
"github.com/rss2/backend/internal/logger"
"golang.org/x/time/rate"
) )
var jwtSecret []byte
func SetJWTSecret(secret string) {
jwtSecret = []byte(secret)
}
type Claims struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
}
func AuthRequired() gin.HandlerFunc { func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
@ -30,8 +38,12 @@ func AuthRequired() gin.HandlerFunc {
return return
} }
claims, err := auth.ValidateToken(tokenString) claims := &Claims{}
if err != nil { token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort() c.Abort()
return return
@ -51,7 +63,7 @@ func AdminRequired() gin.HandlerFunc {
return return
} }
claims := userVal.(*auth.Claims) claims := userVal.(*Claims)
if !claims.IsAdmin { if !claims.IsAdmin {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.Abort() c.Abort()
@ -63,26 +75,9 @@ func AdminRequired() gin.HandlerFunc {
} }
func CORSMiddleware() gin.HandlerFunc { func CORSMiddleware() gin.HandlerFunc {
cfg := config.Load()
allowedOrigins := strings.Split(cfg.AllowedOrigins, ",")
return func(c *gin.Context) { return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin") c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
allowed := false
for _, o := range allowedOrigins {
o = strings.TrimSpace(o)
if o == origin {
allowed = true
break
}
}
if allowed {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
@ -97,101 +92,17 @@ func CORSMiddleware() gin.HandlerFunc {
func LoggerMiddleware() gin.HandlerFunc { func LoggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = c.GetString("request_id")
}
c.Next() c.Next()
latency := time.Since(start)
status := c.Writer.Status() status := c.Writer.Status()
clientIP := c.ClientIP() if status >= 400 {
method := c.Request.Method // Log error responses
if raw != "" {
path = path + "?" + raw
}
log := logger.GetLogger().With().
Str("method", method).
Str("path", path).
Int("status", status).
Str("client_ip", clientIP).
Dur("latency", latency).
Logger()
if requestID != "" {
log = log.With().Str("request_id", requestID).Logger()
}
if status >= 500 {
log.Error().Msg("Server error")
} else if status >= 400 {
log.Warn().Msg("Client error")
} else {
log.Info().Msg("Request completed")
} }
} }
} }
type clientLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}
var (
clientLimiters = make(map[string]*clientLimiter)
limitersMu sync.Mutex
)
func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc { func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc {
limit := rate.Limit(requestsPerMinute) / 60
burst := requestsPerMinute
return func(c *gin.Context) { return func(c *gin.Context) {
ip := c.ClientIP()
limitersMu.Lock()
cl, exists := clientLimiters[ip]
if !exists {
cl = &clientLimiter{limiter: rate.NewLimiter(limit, burst)}
clientLimiters[ip] = cl
}
cl.lastSeen = time.Now()
limiter := cl.limiter
limitersMu.Unlock()
if !limiter.Allow() {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"})
c.Abort()
return
}
go func() {
time.Sleep(10 * time.Minute)
limitersMu.Lock()
if cl, ok := clientLimiters[ip]; ok && time.Since(cl.lastSeen) > 10*time.Minute {
delete(clientLimiters, ip)
}
limitersMu.Unlock()
}()
c.Next()
}
}
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = fmt.Sprintf("%d", time.Now().UnixNano())
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next() c.Next()
} }
} }

View file

@ -1,141 +0,0 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth"
)
func init() {
gin.SetMode(gin.TestMode)
auth.SetJWTSecret("test-secret-key-12345")
}
func TestAuthRequiredNoHeader(t *testing.T) {
router := gin.New()
router.Use(AuthRequired())
router.GET("/protected", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
req, _ := http.NewRequest("GET", "/protected", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
}
func TestAuthRequiredInvalidToken(t *testing.T) {
router := gin.New()
router.Use(AuthRequired())
router.GET("/protected", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
req, _ := http.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
}
func TestAuthRequiredValidToken(t *testing.T) {
router := gin.New()
router.Use(AuthRequired())
router.GET("/protected", func(c *gin.Context) {
userVal, _ := c.Get("user")
claims := userVal.(*auth.Claims)
c.JSON(200, gin.H{"user_id": claims.UserID})
})
token, _ := auth.GenerateToken(42, "test@test.com", "testuser", false)
req, _ := http.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestAdminRequiredNotAdmin(t *testing.T) {
router := gin.New()
router.Use(AuthRequired())
router.Use(AdminRequired())
router.GET("/admin", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
token, _ := auth.GenerateToken(1, "test@test.com", "testuser", false)
req, _ := http.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d", w.Code)
}
}
func TestAdminRequiredIsAdmin(t *testing.T) {
router := gin.New()
router.Use(AuthRequired())
router.Use(AdminRequired())
router.GET("/admin", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
token, _ := auth.GenerateToken(1, "test@test.com", "adminuser", true)
req, _ := http.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestCORSMiddleware(t *testing.T) {
router := gin.New()
router.Use(CORSMiddleware())
router.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
req, _ := http.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Header().Get("Access-Control-Allow-Origin") == "" {
t.Error("expected CORS header to be set")
}
}
func TestCORSPreflight(t *testing.T) {
router := gin.New()
router.Use(CORSMiddleware())
router.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
req, _ := http.NewRequest("OPTIONS", "/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204 for preflight, got %d", w.Code)
}
}

View file

@ -6,12 +6,12 @@ import (
type News struct { type News struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Titulo string `json:"titulo"` Title string `json:"title"`
Resumen string `json:"resumen"` Summary string `json:"summary"`
Contenido string `json:"contenido"` Content string `json:"content"`
URL string `json:"url"` URL string `json:"url"`
ImagenURL *string `json:"imagen_url"` ImageURL *string `json:"image_url"`
Fecha *time.Time `json:"fecha"` PublishedAt *time.Time `json:"published_at"`
Lang string `json:"lang"` Lang string `json:"lang"`
FeedID int64 `json:"feed_id"` FeedID int64 `json:"feed_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@ -20,73 +20,35 @@ type News struct {
type NewsWithTranslations struct { type NewsWithTranslations struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Titulo string `json:"titulo"` Title string `json:"title"`
Resumen string `json:"resumen"` Summary string `json:"summary"`
Contenido string `json:"contenido"` Content string `json:"content"`
URL string `json:"url"` URL string `json:"url"`
ImagenURL *string `json:"imagen_url"` ImageURL *string `json:"image_url"`
Fecha *string `json:"fecha"` PublishedAt *string `json:"published_at"`
Lang string `json:"lang"` Lang string `json:"lang"`
FeedID int64 `json:"feed_id"` FeedID int64 `json:"feed_id"`
CategoryID *int64 `json:"categoria_id"` CategoryID *int64 `json:"category_id"`
CountryID *int64 `json:"pais_id"` CountryID *int64 `json:"country_id"`
FuenteNombre string `json:"fuente_nombre"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
TitleTranslated *string `json:"title_translated"` TitleTranslated *string `json:"title_translated"`
SummaryTranslated *string `json:"summary_translated"` SummaryTranslated *string `json:"summary_translated"`
ContentTranslated *string `json:"content_translated"` ContentTranslated *string `json:"content_translated"`
LangTranslated *string `json:"lang_translated"` LangTranslated *string `json:"lang_translated"`
Entities []Entity `json:"entities,omitempty"`
}
type Entity struct {
Valor string `json:"valor"`
Tipo string `json:"tipo"`
Count int `json:"count"`
WikiSummary *string `json:"wiki_summary"`
WikiURL *string `json:"wiki_url"`
ImagePath *string `json:"image_path"`
}
type EntityListResponse struct {
Entities []Entity `json:"entities"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
type MentionPoint struct {
Fecha string `json:"fecha"`
Count int `json:"count"`
}
type MentionSeries struct {
Valor string `json:"valor"`
Tipo string `json:"tipo"`
Count int `json:"count"`
Data []MentionPoint `json:"data"`
}
type MentionsResponse struct {
Days int `json:"days"`
Series []MentionSeries `json:"series"`
} }
type Feed struct { type Feed struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nombre string `json:"nombre"` Title string `json:"title"`
URL string `json:"url"` URL string `json:"url"`
SiteURL *string `json:"site_url"` SiteURL *string `json:"site_url"`
Descripcion *string `json:"descripcion"` Description *string `json:"description"`
ImagenURL *string `json:"imagen_url"` ImageURL *string `json:"image_url"`
Idioma *string `json:"idioma"` Language *string `json:"language"`
CategoriaID *int64 `json:"categoria_id"` CategoryID *int64 `json:"category_id"`
PaisID *int64 `json:"pais_id"` CountryID *int64 `json:"country_id"`
Activo bool `json:"activo"` Active bool `json:"active"`
Fallos *int64 `json:"fallos"`
LastError *string `json:"last_error"`
LastFetched *time.Time `json:"last_fetched"` LastFetched *time.Time `json:"last_fetched"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@ -94,7 +56,7 @@ type Feed struct {
type Category struct { type Category struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nombre string `json:"nombre"` Name string `json:"name"`
Color string `json:"color"` Color string `json:"color"`
Icon string `json:"icon"` Icon string `json:"icon"`
ParentID *int64 `json:"parent_id"` ParentID *int64 `json:"parent_id"`
@ -102,19 +64,19 @@ type Category struct {
type Country struct { type Country struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nombre string `json:"nombre"` Name string `json:"name"`
Codigo string `json:"codigo"` Code string `json:"code"`
Continente string `json:"continente"` Continent string `json:"continent"`
FlagEmoji string `json:"flag_emoji"` FlagEmoji string `json:"flag_emoji"`
} }
type Translation struct { type Translation struct {
ID int64 `json:"id"` ID int64 `json:"id"`
NoticiaID int64 `json:"noticia_id"` NewsID int64 `json:"news_id"`
LangFrom string `json:"lang_from"` LangFrom string `json:"lang_from"`
LangTo string `json:"lang_to"` LangTo string `json:"lang_to"`
Titulo string `json:"titulo"` Title string `json:"title"`
Resumen string `json:"resumen"` Summary string `json:"summary"`
Status string `json:"status"` Status string `json:"status"`
Error *string `json:"error"` Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@ -127,7 +89,6 @@ type User struct {
Username string `json:"username"` Username string `json:"username"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@ -136,8 +97,8 @@ type SearchHistory struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Query string `json:"query"` Query string `json:"query"`
CategoriaID *int64 `json:"categoria_id"` CategoryID *int64 `json:"category_id"`
PaisID *int64 `json:"pais_id"` CountryID *int64 `json:"country_id"`
ResultsCount int `json:"results_count"` ResultsCount int `json:"results_count"`
SearchedAt time.Time `json:"searched_at"` SearchedAt time.Time `json:"searched_at"`
} }
@ -171,14 +132,14 @@ type Stats struct {
} }
type CategoryStat struct { type CategoryStat struct {
CategoriaID int64 `json:"categoria_id"` CategoryID int64 `json:"category_id"`
CategoriaName string `json:"categoria_nombre"` CategoryName string `json:"category_name"`
Count int64 `json:"count"` Count int64 `json:"count"`
} }
type CountryStat struct { type CountryStat struct {
PaisID int64 `json:"pais_id"` CountryID int64 `json:"country_id"`
PaisName string `json:"pais_nombre"` CountryName string `json:"country_name"`
FlagEmoji string `json:"flag_emoji"` FlagEmoji string `json:"flag_emoji"`
Count int64 `json:"count"` Count int64 `json:"count"`
} }

View file

@ -1,41 +0,0 @@
package models
import "time"
type RemoteWorker struct {
ID int `json:"id"`
Name string `json:"name"`
APIKey string `json:"api_key,omitempty"`
Capabilities string `json:"capabilities"`
Status string `json:"status"`
LastSeen *time.Time `json:"last_seen,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type TranslationJob struct {
ID int64 `json:"id"`
NewsID int64 `json:"noticia_id"`
LangFrom string `json:"lang_from"`
LangTo string `json:"lang_to"`
Title string `json:"title"`
Summary string `json:"summary"`
}
type TranslationResult struct {
JobID int64 `json:"job_id"`
TitleTr string `json:"title_trad"`
SummaryTr string `json:"resumen_trad"`
Error string `json:"error,omitempty"`
}
type WSClientMessage struct {
Type string `json:"type"`
Capabilities string `json:"capabilities,omitempty"`
WorkerName string `json:"worker_name,omitempty"`
Result *TranslationResult `json:"result,omitempty"`
}
type WSServerMessage struct {
Type string `json:"type"`
Job *TranslationJob `json:"job,omitempty"`
}

View file

@ -8,9 +8,10 @@ import (
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/rss2/backend/internal/db"
) )
var pool *pgxpool.Pool
type Config struct { type Config struct {
Host string Host string
Port int Port int
@ -30,23 +31,39 @@ func LoadDBConfig() *Config {
} }
func Connect(cfg *Config) error { func Connect(cfg *Config) error {
// Just verify the main pool is accessible dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
if db.GetPool() == nil { cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
return fmt.Errorf("database pool not initialized")
poolConfig, err := pgxpool.ParseConfig(dsn)
if err != nil {
return fmt.Errorf("failed to parse config: %w", err)
} }
return db.HealthCheck(context.Background())
poolConfig.MaxConns = 25
poolConfig.MinConns = 5
poolConfig.MaxConnLifetime = time.Hour
poolConfig.MaxConnIdleTime = 30 * time.Minute
pool, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
if err != nil {
return fmt.Errorf("failed to create pool: %w", err)
}
if err = pool.Ping(context.Background()); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
return nil
} }
func GetPool() *pgxpool.Pool { func GetPool() *pgxpool.Pool {
return db.GetPool() return pool
} }
func Close() { func Close() {
// No-op: main db package manages the pool if pool != nil {
pool.Close()
} }
func HealthCheck(ctx context.Context) error {
return db.HealthCheck(ctx)
} }
func getEnv(key, defaultValue string) string { func getEnv(key, defaultValue string) string {

View file

@ -1,16 +0,0 @@
#!/bin/bash
# Backup diario de PostgreSQL rss2
set -e
BACKUP_DIR="/home/x/rss2/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
OUT="$BACKUP_DIR/rss-${TIMESTAMP}.sql.gz"
LOG="$BACKUP_DIR/backup.log"
mkdir -p "$BACKUP_DIR"
docker exec rss2_db pg_dump -U rss -d rss --no-owner --no-privileges | gzip > "$OUT"
# Retener solo los ultimos 30 dias (borrado seguro de ficheros concretos, nunca rm -rf)
find "$BACKUP_DIR" -maxdepth 1 -name 'rss-*.sql.gz' -mtime +30 -delete
echo "$(date '+%F %T') Backup OK: $OUT ($(du -h "$OUT" | cut -f1))" >> "$LOG"

448
bugs.md
View file

@ -1,448 +0,0 @@
# Análisis de Errores e Inconsistencias - RSS2
Fecha de análisis: 2026-04-01
---
## Resumen Ejecutivo
Se han identificado **13 problemas** clasificados en:
- 3 Errores Críticos
- 7 Inconsistencias
- 3 Warnings de Arquitectura
---
## 1. Errores Críticos
### 1.1 Rate Limiting No Implementado
**Ubicación:** `backend/internal/middleware/auth.go:104-108`
**Descripción:**
La función existe pero no hace nada:
```go
func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
}
}
```
**Problema:**
No implementa ningún tipo de limitación de rate. Vulnerable a ataques de fuerza bruta y DoS.
**Impacto:** Alto - Seguridad comprometida.
---
### 1.2 CORS Demasiado Permisivo
**Ubicación:** `backend/internal/middleware/auth.go:77-90`
**Descripción:**
```go
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
```
**Problema:**
Permite cualquier origen, lo cual es inseguro para APIs de producción. Permite ataques CSRF y expone datos a orígenes maliciosos.
**Impacto:** Alto - Seguridad comprometida.
---
### 1.3 Secret Key Por Defecto Hardcodeada
**Ubicación:** `backend/internal/config/config.go:32`
**Descripción:**
```go
SecretKey: getEnv("SECRET_KEY", "change-this-secret-key"),
```
**Problema:**
Si no se proporciona la variable de entorno, la aplicación usa una clave por defecto conocida. Cualquier despliegue sin configurar esta variable es vulnerable.
**Impacto:** Crítico - Compromiso total de autenticación.
---
## 2. Inconsistencias
### 2.1 Mapeo de Campos Inconsistente (Snake Case vs Camel Case)
**Descripción:**
La aplicación usa convenciones de nomenclatura diferentes en distintas capas:
| Capa | Estructura | Ejemplo |
|------|------------|---------|
| Models | Snake case | `Title`, `Summary`, `ImageURL` |
| Handlers | Pascal case | `Titulo`, `Resumen`, `ImagenURL` |
| Frontend TS | camelCase | `titulo`, `resumen`, `imagen_url` |
**Archivos afectados:**
- `backend/internal/models/models.go:7-19`
- `backend/internal/handlers/news.go:14-28`
- `frontend/src/services/api.ts:20-34`
**Problema:**
Dificulta el mantenimiento y genera potenciales errores de serialización.
---
### 2.2 Idioma de Traducción Hardcodeado
**Descripción:**
El idioma destino de traducción (`es`) está hardcodeado en múltiples lugares:
**news.go:67:**
```go
where += " AND t.status = 'done' AND t.titulo_trad IS NOT NULL"
```
**news.go:96:**
```go
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es'
```
**search.go:34-37:**
```go
if lang == "" {
lang = "es"
}
```
**Problema:**
No es configurable. Si se quiere traducir a otros idiomas, requiere cambios en múltiples archivos.
---
### 2.3 Columna `role` en Users Sin映射 en Modelo
**Descripción:**
Se añade una columna `role` a la tabla users:
**cmd/server/main.go:41-48:**
```go
_, err = db.GetPool().Exec(ctx, `
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'user'
`)
```
**Pero el modelo User no tiene este campo:**
**models/models.go:86-94:**
```go
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
PasswordHash string `json:"-"`
IsAdmin bool `json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
**Problema:**
La columna `role` nunca se usa realmente. Hay dos campos (`role` y `is_admin`) que pueden causar confusión.
---
### 2.4 Feature No Implementada en Discovery
**Ubicación:** `backend/cmd/discovery/main.go:129-133`
**Descripción:**
```go
func findFeedLinksInHTML(baseURL string) ([]string, error) {
// Simple feed link finder - returns empty for now
// In production, use goquery to parse HTML and find RSS/Atom links
return []string{}, nil
}
```
**Problema:**
El worker de discovery no puede encontrar feeds RSS embebidos en páginas HTML. Solo detecta feeds directos.
**Impacto:** Medio - Reduce la efectividad del descubrimiento de feeds.
---
### 2.5 LoggerMiddleware No Funcional
**Ubicación:** `backend/internal/middleware/auth.go:93-101`
**Descripción:**
```go
func LoggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
status := c.Writer.Status()
if status >= 400 {
// Log error responses - pero no hace nada!
}
}
}
```
**Problema:**
El logging de errores está commented out. No se registra nada.
---
### 2.6 Columnas de BD Diferentes a Modelos
**Descripción:**
El handler `search.go` referencia columnas que no existen en los modelos:
**search.go:91-92:**
```go
SELECT n.id, n.titulo, n.resumen, n.contenido, n.url, n.imagen,
n.feed_id, n.lang, n.categoria_id, n.pais_id, n.created_at, n.updated_at,
```
**Pero en models.go:7-19:**
```go
type News struct {
ID int64 `json:"id"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content"`
URL string `json:"url"`
ImageURL *string `json:"image_url"`
PublishedAt *time.Time `json:"published_at"`
Lang string `json:"lang"`
FeedID int64 `json:"feed_id"`
...
}
```
**Problema:**
- Modelo usa `Title`, BD usa `titulo`
- Modelo usa `Summary`, BD usa `resumen`
- Modelo usa `ImageURL`, BD usa `imagen`
- Modelo usa `PublishedAt`, BD usa `fecha`
Esto causa errores de serialización potenciales.
---
### 2.7 Inicialización de Base de Datos Duplicada
**Descripción:**
Existen dos formas de conectar a la base de datos:
1. `db.Connect()` en `backend/internal/db/postgres.go:13`
2. `workers.Connect()` en `backend/internal/workers/db.go`
**Problema:**
Códigos duplicados que mantienen conexiones separadas. Puede causar problemas de pool de conexiones.
---
## 3. Warnings de Arquitectura
### 3.1 Creación de Tablas en Runtime
**Ubicación:** `backend/cmd/server/main.go:20-77`
**Descripción:**
En lugar de usar migraciones formales, el servidor crea tablas al iniciar:
```go
func initDB() {
_, err := db.GetPool().Exec(ctx, `
CREATE TABLE IF NOT EXISTS entity_aliases (...)
`)
// más tablas...
}
```
**Problema:**
- No hay control de versiones
- No hay rollback
- Difícil de mantener en producción
**Recomendación:** Usar golang-migrate o similar.
---
### 3.2 No Hay Tests Unitarios
**Descripción:**
No se encontraron archivos `*_test.go` en el código backend.
**Problema:**
Sin tests, cualquier cambio puede romper funcionalidad existente sin detección.
---
### 3.3 Pool de Conexiones Sin Validación Periódica
**Ubicación:** `backend/internal/db/postgres.go`
**Descripción:**
El pool se crea pero no hay health check periódico.
**Problema:**
Las conexiones zombie pueden acumularse sin detección.
---
## 4. Recomendaciones de Corrección
### Alta Prioridad
1. **Unificar JWT Secret:** Usar un único package para manejar el secret
2. **Implementar Rate Limiting:** Usar una librería como `golang.org/x/time/rate`
3. **Configurar CORS correctamente:** Usar variable de entorno para orígenes permitidos
4. **Forzar Secret Key:** Hacer requerida la variable `SECRET_KEY`
### Media Prioridad
5. **Traducir idioma configurable:** Pasar idioma como parámetro o configuración
6. **Usar migraciones:** Implementar golang-migrate
7. **Añadir tests:** Crear tests unitarios para handlers críticos
8. **Corregir mapeo de campos:** Estandarizar snake_case en toda la aplicación
### Baja Prioridad
9. **Implementar LoggerMiddleware:** Añadir logging real
10. **Implementar findFeedLinksInHTML:** Usar goquery para parsing HTML
11. **Consolidar DB:** Unificar inicialización de base de datos
---
## 5. Estadísticas
| Tipo | Cantidad |
|------|----------|
| Errores Críticos | 3 |
| Inconsistencias | 7 |
| Warnings | 3 |
| **Total** | **13** |
---
## 6. Problemas Adicionales Detectados
### 6.1 Mapeo de Campos Inconsistente (Frontend)
**Descripción:** El frontend usa `snake_case` pero los modelos Go usan `PascalCase`.
**Archivos:**
- `frontend/src/services/api.ts` - usa `titulo`, `resumen`
- `backend/internal/models/models.go` - usa `Title`, `Summary`
---
### 6.2 Structs Duplicados para Mismo Recurso
**Descripción:** `handlers/news.go` define `NewsResponse` diferente a `models/models.go:News`.
---
### 6.3 Query Params Inconsistentes
**Descripción:** Algunos handlers usan `category_id` (inglés) y otros `categoria_id` (español).
---
### 6.4 Duplicación Config DB
**Descripción:** API usa `DATABASE_URL`, workers usan `DB_HOST`, `DB_PORT`, etc.
---
### 6.5 Sin Health Checks en Workers
**Descripción:** Workers no implementan endpoint `/health`.
---
### 6.6 Manejo de Errores Inconsistente
**Descripción:** Mezcla de `gin.H{"error": ...}` y `models.ErrorResponse`.
---
## 7. Errores y Problemas Críticos Detectados
### 7.1 ~~Errores Ignorados Silenciosamente~~ **(RESUELTO)**
**Descripción:** Múltiples lugares donde errores eran ignorados sin logging.
**Ejemplos corregidos:**
- `admin.go:488-492` - Ahora verifica errores de `db.GetPool().Exec()`
- `admin.go:522` - Verifica error al actualizar estado
**Impacto:** Fallos silenciosos que pasan desapercibidos.
---
### 7.2 ~~Rutas Hardcodeadas~~ **(RESUELTO)**
**Descripción:** Rutas de archivos hardcodeadas en el código.
**Correcciones:**
- `admin.go:469,474,509` - Ahora usa `config.Load().DockerComposeDir`
- `server/main.go:113` - Ahora usa `cfg.WikiImagesPath`
- Añadidos nuevos campos en config: `DockerComposeDir`, `WikiImagesPath`
---
## 7.3 Secret Key Por Defecto
**Descripción:** `config.go:32` usa clave por defecto known.
```go
SecretKey: getEnv("SECRET_KEY", "change-this-secret-key")
```
**Impacto:** Medio - Si no se configura, usar clave insegura.
---
### 7.4 Rate Limiting No Implementado
**Descripción:** `middleware/auth.go:104-108` tiene función vacía.
**Impacto:** Alto - Vulnerable a ataques de fuerza bruta.
---
### 7.5 CORS Demasiado Permisivo
**Descripción:** `middleware/auth.go:79` permite cualquier origen.
**Impacto:** Alto - Expuesto a ataques CSRF.
---
## 8. Recomendaciones de Corrección (Actualizado)
### Alta Prioridad (CRÍTICO)
1. ~~Unificar JWT Secret~~
2. Implementar Rate Limiting
3. ~~**Configurar CORS correctamente:** Usar variable de entorno~~
4. ~~**Corregir manejo de errores:** No ignorar silenciosamente~~
5. ~~**Eliminar rutas hardcodeadas**~~
### Media Prioridad
7. Idioma traducciones configurable
8. Usar migraciones
9. Añadir tests
10. Corregir mapeo de campos
### Baja Prioridad
11. Implementar LoggerMiddleware
12. Implementar findFeedLinksInHTML
13. Consolidar DB
14. Estandarizar respuestas API
15. Añadir health checks

View file

@ -99,16 +99,8 @@
"Pakistán", "Pakistán",
"Tolo News", "Tolo News",
"Gladbach", "Gladbach",
"Alger", "Alger"
"Comisión",
"Dirección",
"Más",
"Regístrese",
"Consejo",
"Fondo",
"PVT LTD",
"Beneficios",
"Ingrese"
], ],
"synonyms": { "synonyms": {
"Alassane Ouattara": [ "Alassane Ouattara": [

18
data/feeds.csv Normal file
View file

@ -0,0 +1,18 @@
id,nombre,descripcion,url,categoria_id,categoria,pais_id,pais,idioma,activo,fallos
19,8am Daily Dari,روزنامه ۸صبح افغانستان به زبان دری.,https://8am.af/feed,7,Internacional,1,Afganistán,fa,False,30
20,8am Daily Pashto,د ۸صبح ورځپاڼې پښتو خپرونه.,https://8am.af/ps/feed,7,Internacional,1,Afganistán,ps,False,30
1,Afghanistan News.Net Noticias,Cobertura continua de noticias generales sobre Afganistán y su entorno regional.,https://feeds.afghanistannews.net/rss/6e1d5c8e1f98f17c,7,Internacional,1,Afganistán,en,True,0
36,Arezo TV Dari,آرزو تلویزیون خبر و گزارش به دری.,https://arezo.tv/fa/feed,7,Internacional,1,Afganistán,fa,False,30
37,Arezo TV Pashto,د آرزو تلویزیون پښتو خبرونه.,https://arezo.tv/ps/feed,7,Internacional,1,Afganistán,ps,False,30
4,Ariana News Dari,خبرها و تحلیل‌ها از افغانستان به زبان دری.,https://ariananews.af/feed,7,Internacional,1,Afganistán,fa,True,0
28,Avapress Dari,خبرگزاری صدای افغان (آوا) به زبان دری.,https://avapress.com/fa/rss,7,Internacional,1,Afganistán,fa,False,30
29,Avapress Pashto,د افغان غږ خبري آژانس په پښتو ژبه.,https://avapress.com/ps/rss,7,Internacional,1,Afganistán,ps,False,30
23,Bakhtar News Agency Dari,آژانس خبری باختر به زبان دری.,https://bakhtarnews.af/fa/feed,7,Internacional,1,Afganistán,fa,False,5
24,Bakhtar News Agency Pashto,د باختر خبري اژانس پښتو پاڼه.,https://bakhtarnews.af/ps/feed,7,Internacional,1,Afganistán,ps,False,5
38,Barya News Dari,باریانیوز رسانه خبری افغانستان.,https://barya.news/feed,7,Internacional,1,Afganistán,fa,False,30
39,Barya News Pashto,باریانیوز پښتو خپرونې.,https://barya.news/ps/feed,7,Internacional,1,Afganistán,ps,False,30
47,Chaprast News Dari,چپرست نیوز خبرهای افغانستان.,https://chaprast.com/feed,7,Internacional,1,Afganistán,fa,False,30
30,Ensaf News Dari,رسانه تحلیلی به زبان دری.,https://www.ensafnews.com/fa/feed,7,Internacional,1,Afganistán,fa,False,30
27,Hamshahri Afghanistan Dari,نسخه افغانستان همشهری به زبان دری.,https://hamshahri.af/feed,7,Internacional,1,Afganistán,fa,False,30
44,Jomhor News Dari,جمهور نیوز خبرگزاری مستقل دری.,https://jomhornews.com/fa/rss,7,Internacional,1,Afganistán,fa,False,30
45,Jomhor News Pashto,جمهور نیوز پښتو.,https://jomhornews.com/ps/rss,7,Internacional,1,Afganistán,ps,False,30
1 id nombre descripcion url categoria_id categoria pais_id pais idioma activo fallos
2 19 8am Daily – Dari روزنامه ۸صبح افغانستان به زبان دری. https://8am.af/feed 7 Internacional 1 Afganistán fa False 30
3 20 8am Daily – Pashto د ۸صبح ورځپاڼې پښتو خپرونه. https://8am.af/ps/feed 7 Internacional 1 Afganistán ps False 30
4 1 Afghanistan News.Net – Noticias Cobertura continua de noticias generales sobre Afganistán y su entorno regional. https://feeds.afghanistannews.net/rss/6e1d5c8e1f98f17c 7 Internacional 1 Afganistán en True 0
5 36 Arezo TV – Dari آرزو تلویزیون – خبر و گزارش به دری. https://arezo.tv/fa/feed 7 Internacional 1 Afganistán fa False 30
6 37 Arezo TV – Pashto د آرزو تلویزیون پښتو خبرونه. https://arezo.tv/ps/feed 7 Internacional 1 Afganistán ps False 30
7 4 Ariana News – Dari خبرها و تحلیل‌ها از افغانستان به زبان دری. https://ariananews.af/feed 7 Internacional 1 Afganistán fa True 0
8 28 Avapress – Dari خبرگزاری صدای افغان (آوا) به زبان دری. https://avapress.com/fa/rss 7 Internacional 1 Afganistán fa False 30
9 29 Avapress – Pashto د افغان غږ خبري آژانس په پښتو ژبه. https://avapress.com/ps/rss 7 Internacional 1 Afganistán ps False 30
10 23 Bakhtar News Agency – Dari آژانس خبری باختر به زبان دری. https://bakhtarnews.af/fa/feed 7 Internacional 1 Afganistán fa False 5
11 24 Bakhtar News Agency – Pashto د باختر خبري اژانس پښتو پاڼه. https://bakhtarnews.af/ps/feed 7 Internacional 1 Afganistán ps False 5
12 38 Barya News – Dari باریانیوز – رسانه خبری افغانستان. https://barya.news/feed 7 Internacional 1 Afganistán fa False 30
13 39 Barya News – Pashto باریانیوز پښتو خپرونې. https://barya.news/ps/feed 7 Internacional 1 Afganistán ps False 30
14 47 Chaprast News – Dari چپرست نیوز – خبرهای افغانستان. https://chaprast.com/feed 7 Internacional 1 Afganistán fa False 30
15 30 Ensaf News – Dari رسانه تحلیلی به زبان دری. https://www.ensafnews.com/fa/feed 7 Internacional 1 Afganistán fa False 30
16 27 Hamshahri Afghanistan – Dari نسخه افغانستان همشهری به زبان دری. https://hamshahri.af/feed 7 Internacional 1 Afganistán fa False 30
17 44 Jomhor News – Dari جمهور نیوز – خبرگزاری مستقل دری. https://jomhornews.com/fa/rss 7 Internacional 1 Afganistán fa False 30
18 45 Jomhor News – Pashto جمهور نیوز پښتو. https://jomhornews.com/ps/rss 7 Internacional 1 Afganistán ps False 30

View file

@ -1,47 +0,0 @@
#!/bin/bash
# Script para despliegue limpio de RSS2
echo "=== RSS2 Clean Deployment Script ==="
echo ""
# Detener contenedores
echo "1. Deteniendo contenedores..."
docker compose down -v 2>/dev/null
# Eliminar volúmenes de datos (si hay permisos)
echo "2. Eliminando volúmenes de datos..."
docker volume rm rss2_db 2>/dev/null || true
docker volume rm rss2_redis 2>/dev/null || true
# Si los volúmenes Docker tienen problemas, intentar con rm
echo " Intentando limpiar /data/..."
sudo rm -rf /datos/rss2/data/pgdata 2>/dev/null || true
sudo rm -rf /datos/rss2/data/redis-data 2>/dev/null || true
# Iniciar base de datos
echo "3. Iniciando base de datos..."
docker compose up -d db
# Esperar a que esté lista
echo "4. Esperando a que la base de datos esté lista..."
sleep 10
# Verificar estado
if docker compose ps db | grep -q "healthy"; then
echo " ✓ Base de datos iniciada correctamente"
# Ejecutar script de schema
echo "5. Ejecutando script de inicialización..."
docker compose exec -T db psql -U rss -d rss -f /docker-entrypoint-initdb.d/00-complete-schema.sql 2>&1 | tail -5
# Iniciar demás servicios
echo "6. Iniciando servicios..."
docker compose up -d redis backend-go rss2_frontend nginx rss-ingestor-go
echo ""
echo "=== Despliegue completado ==="
echo "Accede a: http://localhost:8001"
else
echo " ✗ Error: La base de datos no está healthy"
docker compose logs db
fi

74
deploy/debian/build.sh Executable file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# =============================================================================
# RSS2 - Recompila binarios y frontend (sin reinstalar el sistema)
# Usar despues de actualizar el codigo: bash build.sh
# =============================================================================
set -euo pipefail
RSS2_HOME="/opt/rss2"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
export PATH=$PATH:/usr/local/go/bin
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[BUILD]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
# --- Go Backend + Workers ---
if [[ -d "$REPO_ROOT/backend" ]]; then
info "Compilando backend Go..."
(cd "$REPO_ROOT/backend" && \
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/server" ./cmd/server)
info " [OK] server"
for cmd in scraper discovery wiki_worker topics related; do
[[ -d "$REPO_ROOT/backend/cmd/$cmd" ]] || continue
(cd "$REPO_ROOT/backend" && \
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/$cmd" "./cmd/$cmd")
info " [OK] $cmd"
done
# qdrant worker: nombre del binario debe coincidir con el service
[[ -d "$REPO_ROOT/backend/cmd/qdrant" ]] && \
(cd "$REPO_ROOT/backend" && \
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/qdrant_worker" "./cmd/qdrant")
info " [OK] qdrant_worker"
fi
# --- Ingestor Go ---
if [[ -d "$REPO_ROOT/rss-ingestor-go" ]]; then
info "Compilando ingestor Go..."
(cd "$REPO_ROOT/rss-ingestor-go" && \
GOTOOLCHAIN=local CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/ingestor" .)
info " [OK] ingestor"
fi
# --- Frontend React ---
if [[ -d "$REPO_ROOT/frontend" ]]; then
info "Compilando frontend React..."
(cd "$REPO_ROOT/frontend" && \
npm install --silent && \
VITE_API_URL=/api npm run build -- --outDir "$RSS2_HOME/frontend/dist")
info " [OK] frontend"
fi
# --- Workers Python ---
info "Sincronizando workers Python..."
rsync -a --delete "$REPO_ROOT/workers/" "$RSS2_HOME/src/workers/"
cp "$REPO_ROOT/config/entity_config.json" "$RSS2_HOME/src/" 2>/dev/null || true
info " [OK] workers Python"
chown -R rss2:rss2 "$RSS2_HOME/bin" "$RSS2_HOME/frontend/dist" "$RSS2_HOME/src"
# --- Restart servicios ---
info "Reiniciando servicios..."
GO_SERVICES=(rss2-backend rss2-ingestor rss2-scraper rss2-discovery rss2-wiki rss2-topics rss2-related rss2-qdrant-worker)
PY_SERVICES=(rss2-langdetect rss2-translation-scheduler rss2-translator rss2-embeddings rss2-ner rss2-cluster rss2-categorizer)
for svc in "${GO_SERVICES[@]}" "${PY_SERVICES[@]}"; do
systemctl is-active --quiet "$svc" && systemctl restart "$svc" && info " restarted $svc" || true
done
systemctl reload nginx 2>/dev/null || true
info "Build completado."

342
deploy/debian/check.sh Executable file
View file

@ -0,0 +1,342 @@
#!/usr/bin/env bash
# =============================================================================
# COCONEWS - Diagnóstico del sistema
# Verifica que todos los servicios estén OK y muestra estado + sugerencias
#
# Uso:
# bash deploy/debian/check.sh # diagnóstico completo
# bash deploy/debian/check.sh --quick # solo servicios arriba/abajo
# =============================================================================
RSS2_HOME="/opt/rss2"
API_PORT="8080"
QUICK=false
[[ "${1:-}" == "--quick" ]] && QUICK=true
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'
CYAN='\033[0;36m'
OK() { echo -e " ${GREEN}[✓]${NC} $*"; }
FAIL() { echo -e " ${RED}[✗]${NC} $*"; }
WARN() { echo -e " ${YELLOW}[!]${NC} $*"; }
INFO() { echo -e " ${DIM} $*${NC}"; }
HEAD() { echo -e "\n${BOLD}${CYAN}── $* ${NC}"; }
FIX() { echo -e " ${YELLOW} → Fix:${NC} $*"; }
ERRORS=0
WARNINGS=0
fail_with_fix() {
FAIL "$1"
shift
for fix in "$@"; do FIX "$fix"; done
(( ERRORS++ )) || true
}
warn_with_fix() {
WARN "$1"
shift
for fix in "$@"; do FIX "$fix"; done
(( WARNINGS++ )) || true
}
echo ""
echo -e "${BOLD}╔═══════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ COCONEWS · Diagnóstico del Sistema ║${NC}"
echo -e "${BOLD}╚═══════════════════════════════════════════╝${NC}"
echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S') · $(hostname)${NC}"
# =============================================================================
# 1. SERVICIOS SYSTEMD
# =============================================================================
HEAD "Servicios systemd"
GO_SERVICES=(
"rss2-backend:API REST Go"
"rss2-ingestor:Ingestor RSS"
"rss2-scraper:Scraper HTML"
"rss2-discovery:Discovery de feeds"
"rss2-wiki:Wiki worker"
"rss2-topics:Topics matcher"
"rss2-related:Related news"
"rss2-qdrant-worker:Qdrant sync"
)
PY_SERVICES=(
"rss2-langdetect:Detección de idioma"
"rss2-translation-scheduler:Scheduler traducción"
"rss2-translator:Traductor NLLB-200"
"rss2-embeddings:Embeddings ML"
"rss2-ner:NER entidades"
"rss2-cluster:Clustering eventos"
"rss2-categorizer:Categorizador"
)
INFRA_SERVICES=(
"rss2-qdrant:Vector DB Qdrant"
"postgresql:PostgreSQL"
"redis-server:Redis"
"nginx:Nginx"
)
check_service() {
local svc="${1%%:*}"
local name="${1##*:}"
if ! command -v systemctl &>/dev/null; then
WARN "$name (systemctl no disponible)"
return
fi
local state
state=$(systemctl is-active "$svc" 2>/dev/null || echo "unknown")
case "$state" in
active)
OK "$name ($svc)"
;;
failed)
fail_with_fix "$name ($svc) — FAILED" \
"journalctl -u $svc -n 30 --no-pager" \
"systemctl restart $svc"
;;
inactive)
warn_with_fix "$name ($svc) — inactivo" \
"systemctl start $svc" \
"systemctl enable $svc"
;;
*)
warn_with_fix "$name ($svc) — $state" \
"systemctl status $svc"
;;
esac
}
echo -e "\n ${DIM}Infraestructura:${NC}"
for svc in "${INFRA_SERVICES[@]}"; do check_service "$svc"; done
echo -e "\n ${DIM}Workers Go:${NC}"
for svc in "${GO_SERVICES[@]}"; do check_service "$svc"; done
echo -e "\n ${DIM}Workers Python (ML):${NC}"
for svc in "${PY_SERVICES[@]}"; do check_service "$svc"; done
[[ "$QUICK" == "true" ]] && {
echo ""
[[ "$ERRORS" -eq 0 && "$WARNINGS" -eq 0 ]] && \
echo -e " ${GREEN}${BOLD}Todo OK${NC}" || \
echo -e " ${RED}Errores: $ERRORS ${YELLOW}Advertencias: $WARNINGS${NC}"
exit 0
}
# =============================================================================
# 2. CONECTIVIDAD
# =============================================================================
HEAD "Conectividad"
# PostgreSQL
PG_ENV="$RSS2_HOME/.env"
if [[ -f "$PG_ENV" ]]; then
source "$PG_ENV" 2>/dev/null || true
fi
DB_NAME="${POSTGRES_DB:-rss}"
DB_USER="${POSTGRES_USER:-rss}"
if pg_isready -q 2>/dev/null; then
OK "PostgreSQL acepta conexiones"
# Probar conexión con usuario rss2
if sudo -u postgres psql -d "$DB_NAME" -c "SELECT 1" &>/dev/null 2>&1; then
NEWS=$(sudo -u postgres psql -tq -d "$DB_NAME" \
-c "SELECT COUNT(*) FROM noticias;" 2>/dev/null | tr -d ' \n' || echo "?")
TRANS=$(sudo -u postgres psql -tq -d "$DB_NAME" \
-c "SELECT COUNT(*) FROM traducciones WHERE status='done';" 2>/dev/null | tr -d ' \n' || echo "?")
PEND=$(sudo -u postgres psql -tq -d "$DB_NAME" \
-c "SELECT COUNT(*) FROM traducciones WHERE status='pending';" 2>/dev/null | tr -d ' \n' || echo "?")
INFO "Base de datos: $DB_NAME"
INFO "Noticias: $NEWS | Traducciones hechas: $TRANS | Pendientes: $PEND"
else
warn_with_fix "No se puede conectar a la BD '$DB_NAME' con usuario '$DB_USER'" \
"sudo -u postgres psql -c \"\\du\" # listar usuarios" \
"Revisa DB_USER y DB_PASS en $RSS2_HOME/.env"
fi
else
fail_with_fix "PostgreSQL no responde" \
"sudo systemctl start postgresql" \
"sudo journalctl -u postgresql -n 20 --no-pager" \
"sudo pg_ctlcluster 16 main status"
fi
# Redis
REDIS_PASS="${REDIS_PASSWORD:-}"
REDIS_AUTH=""
[[ -n "$REDIS_PASS" ]] && REDIS_AUTH="-a $REDIS_PASS"
if redis-cli $REDIS_AUTH ping 2>/dev/null | grep -q PONG; then
REDIS_MEM=$(redis-cli $REDIS_AUTH info memory 2>/dev/null | grep used_memory_human | cut -d: -f2 | tr -d '\r' || echo "?")
OK "Redis responde (memoria usada: ${REDIS_MEM})"
else
fail_with_fix "Redis no responde" \
"sudo systemctl start redis-server" \
"redis-cli ping # sin auth" \
"Verifica REDIS_PASSWORD en $RSS2_HOME/.env"
fi
# Qdrant
if curl -sf "http://127.0.0.1:6333/healthz" &>/dev/null; then
QDRANT_VER=$(curl -sf "http://127.0.0.1:6333/" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('version','?'))" 2>/dev/null || echo "?")
OK "Qdrant responde (v${QDRANT_VER})"
else
warn_with_fix "Qdrant no responde en :6333" \
"systemctl start rss2-qdrant" \
"journalctl -u rss2-qdrant -n 20 --no-pager" \
"Búsqueda semántica no funcionará hasta que Qdrant esté activo"
fi
# API Backend Go
if curl -sf "http://127.0.0.1:${API_PORT}/api/stats" &>/dev/null; then
STATS=$(curl -sf "http://127.0.0.1:${API_PORT}/api/stats" 2>/dev/null | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(f\"noticias={d.get('total_news','?')} feeds={d.get('total_feeds','?')}\")" 2>/dev/null || echo "")
OK "API backend responde (:${API_PORT}) — $STATS"
else
fail_with_fix "API backend no responde en :${API_PORT}" \
"systemctl restart rss2-backend" \
"journalctl -u rss2-backend -n 30 --no-pager" \
"curl http://127.0.0.1:${API_PORT}/api/stats # prueba manual"
fi
# Nginx
if curl -sf "http://127.0.0.1:8001/health" &>/dev/null; then
OK "Nginx responde (:8001)"
else
fail_with_fix "Nginx no responde en :8001" \
"nginx -t # verificar config" \
"systemctl restart nginx" \
"journalctl -u nginx -n 20 --no-pager"
fi
# =============================================================================
# 3. BINARIOS GO
# =============================================================================
HEAD "Binarios compilados"
BINS=(server ingestor scraper discovery wiki_worker topics related qdrant_worker)
for bin in "${BINS[@]}"; do
BIN_PATH="$RSS2_HOME/bin/$bin"
if [[ -x "$BIN_PATH" ]]; then
SIZE=$(du -sh "$BIN_PATH" 2>/dev/null | cut -f1)
OK "$bin (${SIZE})"
else
fail_with_fix "$bin no encontrado o sin permisos de ejecución en $BIN_PATH" \
"sudo bash deploy/debian/build.sh # recompila todos los binarios"
fi
done
# =============================================================================
# 4. MODELOS ML
# =============================================================================
HEAD "Modelos ML"
# NLLB-200 CTranslate2
if [[ -d "$RSS2_HOME/models/nllb-ct2" ]] && \
[[ -f "$RSS2_HOME/models/nllb-ct2/model.bin" ]]; then
SIZE=$(du -sh "$RSS2_HOME/models/nllb-ct2" 2>/dev/null | cut -f1)
OK "NLLB-200 CTranslate2 (${SIZE})"
else
warn_with_fix "Modelo NLLB-200 no encontrado en $RSS2_HOME/models/nllb-ct2" \
"El traductor no funcionará hasta que esté el modelo" \
"Convierte con: ver sección 3 de DEPLOY_DEBIAN.md"
fi
# spaCy
if "$RSS2_HOME/venv/bin/python" -c "import spacy; spacy.load('es_core_news_lg')" &>/dev/null 2>&1; then
OK "spaCy es_core_news_lg"
else
warn_with_fix "Modelo spaCy es_core_news_lg no disponible" \
"$RSS2_HOME/venv/bin/python -m spacy download es_core_news_lg" \
"El worker NER no funcionará hasta instalarlo"
fi
# sentence-transformers (embeddings)
if "$RSS2_HOME/venv/bin/python" -c "from sentence_transformers import SentenceTransformer" &>/dev/null 2>&1; then
OK "sentence-transformers disponible"
else
warn_with_fix "sentence-transformers no instalado" \
"$RSS2_HOME/venv/bin/pip install sentence-transformers==3.0.1" \
"Los embeddings y búsqueda semántica no funcionarán"
fi
# ctranslate2
if "$RSS2_HOME/venv/bin/python" -c "import ctranslate2" &>/dev/null 2>&1; then
OK "ctranslate2 disponible"
else
warn_with_fix "ctranslate2 no instalado" \
"$RSS2_HOME/venv/bin/pip install ctranslate2>=4.0.0" \
"La traducción NLLB-200 no funcionará"
fi
# =============================================================================
# 5. DISCO Y PERMISOS
# =============================================================================
HEAD "Disco y permisos"
# Espacio en disco
DISK_FREE=$(df -h "$RSS2_HOME" 2>/dev/null | tail -1 | awk '{print $4}')
DISK_PCT=$(df "$RSS2_HOME" 2>/dev/null | tail -1 | awk '{print $5}' | tr -d '%')
if [[ "${DISK_PCT:-0}" -gt 90 ]]; then
fail_with_fix "Disco al ${DISK_PCT}% — quedan solo ${DISK_FREE}" \
"du -sh $RSS2_HOME/* | sort -rh | head -10 # ver qué ocupa más" \
"Los modelos ML necesitan ~5 GB libres"
elif [[ "${DISK_PCT:-0}" -gt 75 ]]; then
warn_with_fix "Disco al ${DISK_PCT}% (libre: ${DISK_FREE})" \
"Revisa el espacio antes de descargar modelos ML"
else
OK "Disco: ${DISK_PCT}% usado, ${DISK_FREE} libres"
fi
# Permisos de /opt/rss2
if [[ -d "$RSS2_HOME" ]]; then
OWNER=$(stat -c '%U' "$RSS2_HOME" 2>/dev/null || echo "?")
if [[ "$OWNER" == "rss2" ]]; then
OK "Propietario de $RSS2_HOME: rss2"
else
warn_with_fix "Propietario de $RSS2_HOME es '$OWNER', debería ser 'rss2'" \
"sudo chown -R rss2:rss2 $RSS2_HOME"
fi
fi
# .env
if [[ -f "$RSS2_HOME/.env" ]]; then
ENV_PERMS=$(stat -c '%a' "$RSS2_HOME/.env" 2>/dev/null || echo "?")
if [[ "$ENV_PERMS" == "600" ]]; then
OK ".env con permisos 600 (correcto)"
else
warn_with_fix ".env con permisos ${ENV_PERMS} (debería ser 600)" \
"chmod 600 $RSS2_HOME/.env"
fi
# Verificar que no quedan valores por defecto peligrosos
if grep -q "CAMBIA_ESTO\|changeme\|change_this" "$RSS2_HOME/.env" 2>/dev/null; then
warn_with_fix ".env tiene valores por defecto sin cambiar" \
"nano $RSS2_HOME/.env # cambia POSTGRES_PASSWORD, REDIS_PASSWORD, SECRET_KEY"
fi
else
fail_with_fix ".env no encontrado en $RSS2_HOME/.env" \
"cp deploy/debian/env.example $RSS2_HOME/.env" \
"nano $RSS2_HOME/.env # edita contraseñas"
fi
# =============================================================================
# RESUMEN FINAL
# =============================================================================
echo ""
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
if [[ "$ERRORS" -eq 0 && "$WARNINGS" -eq 0 ]]; then
echo -e " ${GREEN}${BOLD}Sistema OK — todo funcionando correctamente${NC}"
elif [[ "$ERRORS" -eq 0 ]]; then
echo -e " ${YELLOW}${BOLD}Sistema funcional con $WARNINGS advertencia(s)${NC}"
echo -e " ${DIM}Las advertencias no bloquean el funcionamiento básico${NC}"
else
echo -e " ${RED}${BOLD}$ERRORS error(es) encontrado(s)${YELLOW} $WARNINGS advertencia(s)${NC}"
echo -e " ${DIM}Sigue los fixes indicados arriba para resolverlos${NC}"
fi
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
echo ""
echo -e " ${DIM}Logs de servicios: journalctl -u rss2-backend -f${NC}"
echo -e " ${DIM}Diagnóstico rápido: bash deploy/debian/check.sh --quick${NC}"
echo ""
exit $(( ERRORS > 0 ? 1 : 0 ))

112
deploy/debian/env.example Normal file
View file

@ -0,0 +1,112 @@
# =============================================================================
# RSS2 - Variables de entorno para despliegue Debian nativo
# Copiar a /opt/rss2/.env y editar valores antes de instalar
# =============================================================================
# --- PostgreSQL ---
POSTGRES_DB=rss
POSTGRES_USER=rss
POSTGRES_PASSWORD=CAMBIA_ESTO_postgres_password
# Usadas por workers Go (equivalente a DATABASE_URL)
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=rss
DB_USER=rss
DB_PASS=CAMBIA_ESTO_postgres_password
# URL completa para backend API Go
DATABASE_URL=postgres://rss:CAMBIA_ESTO_postgres_password@127.0.0.1:5432/rss?sslmode=disable
# --- Redis ---
REDIS_PASSWORD=CAMBIA_ESTO_redis_password
REDIS_URL=redis://:CAMBIA_ESTO_redis_password@127.0.0.1:6379
# --- JWT Secret (minimo 32 caracteres, aleatorio) ---
SECRET_KEY=CAMBIA_ESTO_jwt_secret_muy_largo_y_aleatorio
# --- Backend API ---
SERVER_PORT=8080
# --- Zona horaria ---
TZ=Europe/Madrid
# --- HuggingFace cache (modelos ML) ---
HF_HOME=/opt/rss2/hf_cache
# --- Endpoints ML opcionales (solo si corres Ollama o LibreTranslate por separado) ---
# Los workers Python van directo a BD; estos endpoints solo los usa el backend para
# llamadas on-demand desde el panel admin (NER, traduccion manual, etc.)
TRANSLATION_URL=http://127.0.0.1:7790
OLLAMA_URL=http://127.0.0.1:11434
SPACY_URL=http://127.0.0.1:8000
# --- Qdrant (local, sin Docker) ---
QDRANT_HOST=127.0.0.1
QDRANT_PORT=6333
QDRANT_COLLECTION=news_vectors
# --- Translator (NLLB-200 via CTranslate2) ---
TARGET_LANGS=es
TRANSLATOR_BATCH=32
CT2_MODEL_PATH=/opt/rss2/models/nllb-ct2
CT2_DEVICE=cpu
CT2_COMPUTE_TYPE=int8
UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
# --- Embeddings ---
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH=64
EMB_SLEEP_IDLE=5
EMB_LANGS=es
EMB_LIMIT=1000
DEVICE=cpu
# --- NER ---
NER_LANG=es
NER_BATCH=64
# --- Ingestor RSS ---
RSS_MAX_WORKERS=100
RSS_POKE_INTERVAL_MIN=15
# --- Scraper ---
SCRAPER_SLEEP=60
SCRAPER_BATCH=10
# --- Discovery ---
DISCOVERY_INTERVAL=900
DISCOVERY_BATCH=10
MAX_FEEDS_PER_URL=5
# --- Wiki Worker ---
WIKI_SLEEP=10
WIKI_IMAGES_PATH=/opt/rss2/data/wiki_images
# --- Topics ---
TOPICS_SLEEP=10
TOPICS_BATCH=500
# --- Related ---
RELATED_SLEEP=10
RELATED_BATCH=200
RELATED_TOPK=10
# --- Cluster ---
EVENT_DIST_THRESHOLD=0.35
# --- Categorizer ---
CATEGORIZER_BATCH_SIZE=10
CATEGORIZER_SLEEP_IDLE=5
# --- Scheduler traduccion ---
SCHEDULER_BATCH=1000
SCHEDULER_SLEEP=30
# --- Lang Detect ---
LANG_DETECT_SLEEP=60
LANG_DETECT_BATCH=1000
# --- Qdrant Worker ---
QDRANT_SLEEP=30
QDRANT_BATCH=100

309
deploy/debian/install.sh Executable file
View file

@ -0,0 +1,309 @@
#!/usr/bin/env bash
# =============================================================================
# RSS2 - Instalacion en Debian (sin Docker)
# Ejecutar como root: bash install.sh
# =============================================================================
set -euo pipefail
RSS2_USER="rss2"
RSS2_HOME="/opt/rss2"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
[[ "$EUID" -ne 0 ]] && error "Ejecutar como root: sudo bash install.sh"
# =============================================================================
# 1. DEPENDENCIAS DEL SISTEMA
# =============================================================================
info "Instalando dependencias del sistema..."
apt-get update -qq
apt-get install -y --no-install-recommends \
curl wget git build-essential \
postgresql postgresql-client \
redis-server \
nginx \
python3 python3-pip python3-venv python3-dev \
nodejs npm \
ca-certificates tzdata \
libpq-dev
# Go (rss-ingestor-go requiere Go 1.25)
_need_go=false
if ! command -v go &>/dev/null; then
_need_go=true
else
_gover=$(go version | awk '{print $3}' | tr -d 'go')
IFS='.' read -ra _gv <<< "$_gover"
if [[ "${_gv[0]:-0}" -lt 1 ]] || [[ "${_gv[0]:-0}" -eq 1 && "${_gv[1]:-0}" -lt 25 ]]; then
_need_go=true
fi
fi
if [[ "$_need_go" == "true" ]]; then
info "Instalando Go 1.25..."
GO_VERSION="1.25.0"
ARCH=$(dpkg --print-architecture)
case "$ARCH" in
amd64) GO_ARCH="amd64" ;;
arm64) GO_ARCH="arm64" ;;
*) error "Arquitectura no soportada: $ARCH" ;;
esac
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
export PATH=$PATH:/usr/local/go/bin
rm /tmp/go.tar.gz
fi
info "Go: $(go version)"
# Qdrant (binario oficial)
if [[ ! -f "$RSS2_HOME/qdrant/qdrant" ]]; then
info "Descargando Qdrant..."
QDRANT_VERSION="v1.12.1"
ARCH=$(dpkg --print-architecture)
case "$ARCH" in
amd64) QDRANT_ARCH="x86_64-unknown-linux-musl" ;;
arm64) QDRANT_ARCH="aarch64-unknown-linux-musl" ;;
*) error "Arquitectura no soportada para Qdrant: $ARCH" ;;
esac
mkdir -p "$RSS2_HOME/qdrant"
curl -fsSL "https://github.com/qdrant/qdrant/releases/download/${QDRANT_VERSION}/qdrant-${QDRANT_ARCH}.tar.gz" \
-o /tmp/qdrant.tar.gz
tar -C "$RSS2_HOME/qdrant" -xzf /tmp/qdrant.tar.gz
chmod +x "$RSS2_HOME/qdrant/qdrant"
rm /tmp/qdrant.tar.gz
fi
# =============================================================================
# 2. USUARIO Y DIRECTORIOS
# =============================================================================
info "Creando usuario $RSS2_USER y directorios..."
id "$RSS2_USER" &>/dev/null || useradd -r -m -d "$RSS2_HOME" -s /bin/bash "$RSS2_USER"
mkdir -p \
"$RSS2_HOME/bin" \
"$RSS2_HOME/src" \
"$RSS2_HOME/data/wiki_images" \
"$RSS2_HOME/data/qdrant_storage" \
"$RSS2_HOME/hf_cache" \
"$RSS2_HOME/models" \
"$RSS2_HOME/frontend/dist" \
"$RSS2_HOME/logs"
# =============================================================================
# 3. CONFIGURACION ENTORNO
# =============================================================================
if [[ ! -f "$RSS2_HOME/.env" ]]; then
if [[ -f "$SCRIPT_DIR/env.example" ]]; then
cp "$SCRIPT_DIR/env.example" "$RSS2_HOME/.env"
warn "Copia env.example en $RSS2_HOME/.env - EDITA LAS CONTRASENAS antes de continuar"
warn "Presiona Enter cuando hayas editado el .env, o Ctrl+C para salir"
read -r
else
error "No se encontro env.example en $SCRIPT_DIR"
fi
fi
# =============================================================================
# 4. POSTGRESQL
# =============================================================================
info "Configurando PostgreSQL..."
source "$RSS2_HOME/.env" 2>/dev/null || true
DB_NAME="${POSTGRES_DB:-rss}"
DB_USER="${POSTGRES_USER:-rss}"
DB_PASS="${POSTGRES_PASSWORD:-changeme}"
systemctl enable --now postgresql
# Crear usuario y base de datos si no existen
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1 || \
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 || \
sudo -u postgres createdb -O "$DB_USER" "$DB_NAME"
# Ejecutar migraciones SQL
if [[ -d "$REPO_ROOT/migrations" ]]; then
info "Ejecutando migraciones..."
for sql_file in "$REPO_ROOT/migrations"/*.sql; do
[[ -f "$sql_file" ]] || continue
info " Aplicando $(basename "$sql_file")..."
sudo -u postgres psql -d "$DB_NAME" -f "$sql_file" 2>/dev/null || warn " (ya aplicada o error ignorado)"
done
fi
# Ejecutar init-db scripts (schema inicial)
if [[ -d "$REPO_ROOT/init-db" ]]; then
info "Ejecutando scripts de init-db..."
for sql_file in "$REPO_ROOT/init-db"/*.sql; do
[[ -f "$sql_file" ]] || continue
info " $(basename "$sql_file")..."
sudo -u postgres psql -d "$DB_NAME" -f "$sql_file" 2>/dev/null || warn " (ya aplicada o error ignorado)"
done
fi
# =============================================================================
# 5. REDIS
# =============================================================================
info "Configurando Redis..."
REDIS_PASS="${REDIS_PASSWORD:-changeme_redis}"
# Agregar autenticacion y limites de memoria a redis.conf
REDIS_CONF="/etc/redis/redis.conf"
grep -q "requirepass $REDIS_PASS" "$REDIS_CONF" 2>/dev/null || {
echo "requirepass $REDIS_PASS" >> "$REDIS_CONF"
echo "maxmemory 512mb" >> "$REDIS_CONF"
echo "maxmemory-policy allkeys-lru" >> "$REDIS_CONF"
echo "appendonly yes" >> "$REDIS_CONF"
}
systemctl enable --now redis-server
# =============================================================================
# 6. PYTHON VIRTUALENV + DEPENDENCIAS ML
# =============================================================================
info "Creando virtualenv Python y instalando dependencias..."
python3 -m venv "$RSS2_HOME/venv"
"$RSS2_HOME/venv/bin/pip" install --upgrade pip -q
if [[ -f "$REPO_ROOT/requirements.txt" ]]; then
"$RSS2_HOME/venv/bin/pip" install -r "$REPO_ROOT/requirements.txt" -q
fi
# spaCy modelo en español
"$RSS2_HOME/venv/bin/python" -m spacy download es_core_news_lg 2>/dev/null || \
warn "spaCy model es_core_news_lg no se pudo descargar, hazlo manualmente"
# Copiar workers Python al directorio de trabajo
info "Copiando workers Python..."
rsync -a --delete "$REPO_ROOT/workers/" "$RSS2_HOME/src/workers/"
cp "$REPO_ROOT/config/entity_config.json" "$RSS2_HOME/src/" 2>/dev/null || true
# =============================================================================
# 7. COMPILAR GO (backend + workers)
# =============================================================================
info "Compilando binarios Go..."
export PATH=$PATH:/usr/local/go/bin
export GOPATH=/tmp/go-build-rss2
# Backend API
if [[ -d "$REPO_ROOT/backend" ]]; then
(cd "$REPO_ROOT/backend" && \
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/server" ./cmd/server && \
info " [OK] server") || warn " [FAIL] server"
for cmd in scraper discovery wiki_worker topics related; do
[[ -d "$REPO_ROOT/backend/cmd/$cmd" ]] || continue
(cd "$REPO_ROOT/backend" && \
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/$cmd" "./cmd/$cmd" && \
info " [OK] $cmd") || warn " [FAIL] $cmd"
done
# qdrant worker: output como qdrant_worker para coincidir con el service
[[ -d "$REPO_ROOT/backend/cmd/qdrant" ]] && \
(cd "$REPO_ROOT/backend" && \
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/qdrant_worker" "./cmd/qdrant" && \
info " [OK] qdrant_worker") || warn " [FAIL] qdrant_worker"
fi
# RSS Ingestor Go (repo separado, requiere Go 1.25)
if [[ -d "$REPO_ROOT/rss-ingestor-go" ]]; then
(cd "$REPO_ROOT/rss-ingestor-go" && \
GOTOOLCHAIN=local CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o "$RSS2_HOME/bin/ingestor" . && \
info " [OK] ingestor") || warn " [FAIL] ingestor"
fi
# =============================================================================
# 8. FRONTEND REACT
# =============================================================================
info "Compilando frontend React..."
if [[ -d "$REPO_ROOT/frontend" ]]; then
(cd "$REPO_ROOT/frontend" && \
npm install --silent && \
VITE_API_URL=/api npm run build -- --outDir "$RSS2_HOME/frontend/dist" && \
info " [OK] frontend compilado") || warn " [FAIL] frontend"
fi
# =============================================================================
# 9. NGINX
# =============================================================================
info "Configurando Nginx..."
cp "$SCRIPT_DIR/nginx.conf" /etc/nginx/nginx.conf
nginx -t && systemctl enable --now nginx && systemctl reload nginx
# =============================================================================
# 10. SYSTEMD SERVICES
# =============================================================================
info "Instalando servicios systemd..."
SERVICES=(
rss2-qdrant
rss2-backend
rss2-ingestor
rss2-scraper
rss2-discovery
rss2-wiki
rss2-topics
rss2-related
rss2-qdrant-worker
rss2-langdetect
rss2-translation-scheduler
rss2-translator
rss2-embeddings
rss2-ner
rss2-cluster
rss2-categorizer
)
for svc in "${SERVICES[@]}"; do
svc_file="$SCRIPT_DIR/systemd/${svc}.service"
if [[ -f "$svc_file" ]]; then
cp "$svc_file" "/etc/systemd/system/${svc}.service"
else
warn "No se encontro $svc_file"
fi
done
systemctl daemon-reload
for svc in "${SERVICES[@]}"; do
systemctl enable "$svc" 2>/dev/null || true
done
# =============================================================================
# 11. PERMISOS FINALES
# =============================================================================
info "Ajustando permisos..."
chown -R "$RSS2_USER:$RSS2_USER" "$RSS2_HOME"
chmod 600 "$RSS2_HOME/.env"
# =============================================================================
# 12. ARRANCAR SERVICIOS
# =============================================================================
info "Arrancando servicios..."
# Infraestructura primero
systemctl start rss2-qdrant
sleep 3
# API y workers Go
for svc in rss2-backend rss2-ingestor rss2-scraper rss2-discovery rss2-wiki rss2-topics rss2-related rss2-qdrant-worker; do
systemctl start "$svc" || warn "No se pudo arrancar $svc"
done
# Workers Python (modelos pesados, arrancan despues)
for svc in rss2-langdetect rss2-translation-scheduler rss2-translator rss2-embeddings rss2-ner rss2-cluster rss2-categorizer; do
systemctl start "$svc" || warn "No se pudo arrancar $svc"
done
# =============================================================================
echo ""
info "============================================="
info " RSS2 instalado en $RSS2_HOME"
info " Acceder en: http://$(hostname -I | awk '{print $1}'):8001"
info ""
info " Ver logs: journalctl -u rss2-backend -f"
info " Ver estado: systemctl status rss2-backend"
info " Editar env: nano $RSS2_HOME/.env"
info "============================================="

View file

@ -1,7 +1,7 @@
user nginx; user www-data;
worker_processes auto; worker_processes auto;
error_log /var/log/nginx/error.log warn; error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid; pid /run/nginx.pid;
events { events {
worker_connections 2048; worker_connections 2048;
@ -33,40 +33,36 @@ http {
application/json application/javascript application/json application/javascript
application/xml text/xml; application/xml text/xml;
# Upstream for Go API # Go API backend (proceso nativo en localhost)
upstream api_backend { upstream api_backend {
server backend-go:8080; server 127.0.0.1:8080;
keepalive 32; keepalive 32;
} }
# Upstream for React Frontend
upstream frontend {
server rss2_frontend:80;
keepalive 16;
}
server { server {
listen 80; listen 8001;
server_name _; server_name _;
client_body_timeout 60s; client_body_timeout 60s;
client_header_timeout 60s; client_header_timeout 60s;
send_timeout 300s; send_timeout 300s;
# Serve React Frontend # Frontend React (archivos estaticos compilados)
root /opt/rss2/frontend/dist;
index index.html;
location / { location / {
proxy_pass http://frontend; try_files $uri $uri/ /index.html;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
} }
# Proxy to Go API # Imagenes Wikipedia servidas directamente
location /wiki-images/ {
alias /opt/rss2/data/wiki_images/;
expires 7d;
add_header Cache-Control "public, immutable";
}
# Proxy al API Go
location /api/ { location /api/ {
proxy_pass http://api_backend/api/; proxy_pass http://api_backend/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -81,13 +77,11 @@ http {
proxy_read_timeout 300s; proxy_read_timeout 300s;
} }
# Health check
location /health { location /health {
access_log off; access_log off;
return 200 "ok"; return 200 "ok";
} }
# Block sensitive files
location ~ /\. { location ~ /\. {
deny all; deny all;
access_log off; access_log off;

327
deploy/debian/prerequisites.sh Executable file
View file

@ -0,0 +1,327 @@
#!/usr/bin/env bash
# =============================================================================
# COCONEWS - Instalacion de prerequisites en Debian 12 / Ubuntu 22.04+
# Ejecutar ANTES de install.sh
# Uso: sudo bash prerequisites.sh
# =============================================================================
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
info() { echo -e "${GREEN}[OK]${NC} $*"; }
step() { echo -e "${BLUE}[-->]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
[[ "$EUID" -ne 0 ]] && error "Ejecutar como root: sudo bash prerequisites.sh"
# Detectar OS
if [[ -f /etc/os-release ]]; then
. /etc/os-release
OS_ID="$ID"
OS_VER="$VERSION_ID"
else
error "No se puede detectar el sistema operativo"
fi
[[ "$OS_ID" == "debian" || "$OS_ID" == "ubuntu" ]] || \
error "Solo soportado en Debian/Ubuntu. Detectado: $OS_ID"
echo ""
echo "================================================="
echo " COCONEWS - Instalador de Prerequisites"
echo " OS: $PRETTY_NAME"
echo "================================================="
echo ""
# =============================================================================
# 1. PAQUETES APT BASE
# =============================================================================
# Comprobar espacio en disco (mínimo 10 GB libres)
DISK_FREE_GB=$(df / --output=avail -BG | tail -1 | tr -d 'G ')
if [[ "${DISK_FREE_GB:-0}" -lt 10 ]]; then
warn "Espacio libre en disco: ${DISK_FREE_GB} GB (recomendado mínimo 10 GB)"
warn "Los modelos ML necesitan ~5 GB. Continua bajo tu responsabilidad."
fi
step "Actualizando repositorios apt..."
apt-get update -qq 2>/tmp/apt-update.log || {
echo -e "${RED}Error al actualizar repositorios apt.${NC}"
echo " Posibles causas:"
echo " • Sin conexión a internet"
echo " • Repositorios con errores: cat /tmp/apt-update.log"
echo " • Solución: apt-get update --fix-missing"
exit 1
}
step "Instalando paquetes del sistema..."
apt-get install -y --no-install-recommends \
curl wget git build-essential \
ca-certificates gnupg lsb-release \
software-properties-common apt-transport-https \
tzdata locales \
rsync \
openssl \
libpq-dev \
libssl-dev \
libffi-dev \
libbz2-dev \
libreadline-dev \
libsqlite3-dev \
zlib1g-dev
info "Paquetes base instalados"
# =============================================================================
# 2. POSTGRESQL 16
# =============================================================================
step "Instalando PostgreSQL 16..."
if ! command -v psql &>/dev/null; then
# Repositorio oficial de PostgreSQL
install -d /usr/share/postgresql-common/pgdg
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] \
https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
apt-get update -qq
apt-get install -y postgresql-16 postgresql-client-16
fi
info "PostgreSQL: $(psql --version)"
# =============================================================================
# 3. REDIS
# =============================================================================
step "Instalando Redis..."
if ! command -v redis-server &>/dev/null; then
apt-get install -y redis-server
fi
info "Redis: $(redis-server --version | cut -d' ' -f3)"
# =============================================================================
# 4. NGINX
# =============================================================================
step "Instalando Nginx..."
if ! command -v nginx &>/dev/null; then
apt-get install -y nginx
fi
info "Nginx: $(nginx -v 2>&1 | cut -d'/' -f2)"
# =============================================================================
# 5. PYTHON 3 + pip + venv
# =============================================================================
step "Instalando Python 3..."
apt-get install -y python3 python3-pip python3-venv python3-dev
info "Python: $(python3 --version)"
# =============================================================================
# 6. NODE.JS 20 LTS
# =============================================================================
step "Instalando Node.js 20 LTS..."
if ! command -v node &>/dev/null || [[ "$(node -v | tr -d 'v' | cut -d. -f1)" -lt 18 ]]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
fi
info "Node.js: $(node --version) | npm: $(npm --version)"
# =============================================================================
# 7. GO 1.25
# =============================================================================
step "Instalando Go 1.25..."
GO_VERSION="1.25.0"
INSTALLED_GO=$(go version 2>/dev/null | awk '{print $3}' | tr -d 'go' || echo "0")
# Comparar version instalada
needs_go=false
if ! command -v go &>/dev/null; then
needs_go=true
else
IFS='.' read -ra INS <<< "$INSTALLED_GO"
IFS='.' read -ra REQ <<< "$GO_VERSION"
if [[ "${INS[0]}" -lt "${REQ[0]}" ]] || \
([[ "${INS[0]}" == "${REQ[0]}" ]] && [[ "${INS[1]:-0}" -lt "${REQ[1]:-0}" ]]); then
needs_go=true
fi
fi
if [[ "$needs_go" == "true" ]]; then
ARCH=$(dpkg --print-architecture)
case "$ARCH" in
amd64) GO_ARCH="amd64" ;;
arm64) GO_ARCH="arm64" ;;
*) error "Arquitectura no soportada para Go: $ARCH" ;;
esac
step " Descargando Go ${GO_VERSION} (${GO_ARCH})..."
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
rm /tmp/go.tar.gz
# Perfil global
cat > /etc/profile.d/golang.sh << 'GOEOF'
export PATH=$PATH:/usr/local/go/bin
export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin
GOEOF
chmod +x /etc/profile.d/golang.sh
export PATH=$PATH:/usr/local/go/bin
info "Go instalado: $(go version)"
else
export PATH=$PATH:/usr/local/go/bin
info "Go ya instalado: $(go version)"
fi
# =============================================================================
# 8. QDRANT (binario oficial)
# =============================================================================
step "Instalando Qdrant..."
QDRANT_VERSION="v1.12.1"
QDRANT_INSTALL_DIR="/opt/rss2/qdrant"
mkdir -p "$QDRANT_INSTALL_DIR"
if [[ ! -f "$QDRANT_INSTALL_DIR/qdrant" ]]; then
ARCH=$(dpkg --print-architecture)
case "$ARCH" in
amd64) QDRANT_ARCH="x86_64-unknown-linux-musl" ;;
arm64) QDRANT_ARCH="aarch64-unknown-linux-musl" ;;
*) error "Arquitectura no soportada para Qdrant: $ARCH" ;;
esac
step " Descargando Qdrant ${QDRANT_VERSION}..."
curl -fsSL \
"https://github.com/qdrant/qdrant/releases/download/${QDRANT_VERSION}/qdrant-${QDRANT_ARCH}.tar.gz" \
-o /tmp/qdrant.tar.gz
tar -C "$QDRANT_INSTALL_DIR" -xzf /tmp/qdrant.tar.gz
chmod +x "$QDRANT_INSTALL_DIR/qdrant"
rm /tmp/qdrant.tar.gz
info "Qdrant ${QDRANT_VERSION} instalado en ${QDRANT_INSTALL_DIR}"
else
info "Qdrant ya instalado en ${QDRANT_INSTALL_DIR}"
fi
# =============================================================================
# 9. USUARIO DEL SISTEMA rss2
# =============================================================================
step "Creando usuario del sistema 'rss2'..."
if ! id rss2 &>/dev/null; then
useradd -r -m -d /opt/rss2 -s /bin/bash rss2
info "Usuario 'rss2' creado"
else
info "Usuario 'rss2' ya existe"
fi
# Crear estructura de directorios
mkdir -p \
/opt/rss2/bin \
/opt/rss2/src \
/opt/rss2/data/wiki_images \
/opt/rss2/data/qdrant_storage \
/opt/rss2/hf_cache \
/opt/rss2/models \
/opt/rss2/frontend/dist \
/opt/rss2/logs \
/opt/rss2/backups
chown -R rss2:rss2 /opt/rss2
info "Directorios /opt/rss2 creados"
# =============================================================================
# 10. PYTHON VIRTUALENV + DEPENDENCIAS BASE
# =============================================================================
step "Creando virtualenv Python en /opt/rss2/venv..."
if [[ ! -d /opt/rss2/venv ]]; then
python3 -m venv /opt/rss2/venv
fi
/opt/rss2/venv/bin/pip install --upgrade pip setuptools wheel -q
info "Virtualenv listo"
# Dependencias base (sin los modelos pesados de ML)
step "Instalando dependencias Python base..."
/opt/rss2/venv/bin/pip install -q \
psycopg2-binary \
langdetect \
python-dotenv \
requests \
beautifulsoup4 \
lxml \
redis \
qdrant-client \
numpy \
scikit-learn \
tqdm
info "Dependencias Python base instaladas"
# =============================================================================
# 11. DEPENDENCIAS ML (pesadas - opcional en este paso)
# =============================================================================
echo ""
echo -e "${YELLOW}[?]${NC} Instalar dependencias ML pesadas ahora?"
echo " (ctranslate2, transformers, sentence-transformers, spaCy)"
echo " Puede tardar 20-40 minutos y usar ~5 GB de disco."
echo -n " [s/N]: "
read -r install_ml
if [[ "${install_ml,,}" == "s" || "${install_ml,,}" == "si" || "${install_ml,,}" == "y" ]]; then
step "Instalando dependencias ML (esto tarda)..."
/opt/rss2/venv/bin/pip install -q \
ctranslate2>=4.0.0 \
transformers==4.43.3 \
sentencepiece \
sacremoses \
accelerate \
sentence-transformers==3.0.1 \
"spacy>=3.7,<4.0" \
torch --index-url https://download.pytorch.org/whl/cpu
info "Dependencias ML instaladas"
step "Descargando modelo spaCy en español..."
/opt/rss2/venv/bin/python -m spacy download es_core_news_lg
info "Modelo spaCy es_core_news_lg listo"
step "Convirtiendo modelo NLLB-200 a CTranslate2..."
warn "Esto puede tardar 10-30 minutos y requiere ~2 GB de RAM"
mkdir -p /opt/rss2/models
/opt/rss2/venv/bin/python - <<'EOF'
import os, sys
os.makedirs("/opt/rss2/models/nllb-ct2", exist_ok=True)
os.environ["HF_HOME"] = "/opt/rss2/hf_cache"
try:
from ctranslate2.converters import OpusMTConverter
converter = OpusMTConverter("facebook/nllb-200-distilled-600M")
converter.convert("/opt/rss2/models/nllb-ct2", quantization="int8", force=True)
print("[OK] Modelo NLLB-200 convertido en /opt/rss2/models/nllb-ct2")
except Exception as e:
print(f"[ERROR] {e}")
print("Convierte manualmente despues con: deploy/debian/convert_model.sh")
sys.exit(0)
EOF
chown -R rss2:rss2 /opt/rss2/models /opt/rss2/hf_cache
else
warn "ML omitido. Ejecuta 'deploy/debian/install.sh' para instalarlas junto con el resto."
warn "Sin ML: la traduccion y los embeddings no funcionaran."
fi
chown -R rss2:rss2 /opt/rss2
# =============================================================================
# RESUMEN Y SIGUIENTES PASOS
# =============================================================================
echo ""
echo "================================================="
echo -e " ${GREEN}Prerequisites instalados correctamente${NC}"
echo "================================================="
echo ""
echo " Sistema: $PRETTY_NAME"
echo " Go: $(go version 2>/dev/null | awk '{print $3}')"
echo " Python: $(python3 --version)"
echo " Node.js: $(node --version)"
echo " PostgreSQL: $(psql --version | awk '{print $3}')"
echo " Redis: $(redis-server --version | awk '{print $3}' | tr -d ',')"
echo " Nginx: $(nginx -v 2>&1 | cut -d'/' -f2)"
echo ""
echo " Siguiente paso:"
echo " sudo bash deploy/debian/install.sh"
echo ""
echo " Si algo falló durante la instalación:"
echo " • apt falla: apt-get update --fix-missing"
echo " • Go no descarga: verifica conectividad → curl https://go.dev"
echo " • Qdrant no baja: descárgalo manualmente en"
echo " https://github.com/qdrant/qdrant/releases"
echo " • pip falla: python3 -m venv /opt/rss2/venv --clear"
echo " • Diagnóstico: bash deploy/debian/check.sh"
echo ""

View file

@ -0,0 +1,28 @@
[Unit]
Description=RSS2 Backend API (Go)
After=network.target postgresql.service redis.service
Requires=postgresql.service redis.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2
EnvironmentFile=/opt/rss2/.env
# Sobreescribir hostnames Docker por localhost (los workers Python van directo a DB)
Environment=TRANSLATION_URL=http://127.0.0.1:7790
Environment=OLLAMA_URL=http://127.0.0.1:11434
Environment=SPACY_URL=http://127.0.0.1:8000
ExecStart=/opt/rss2/bin/server
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-backend
# Limites de recursos
LimitNOFILE=65536
MemoryMax=1G
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,25 @@
[Unit]
Description=RSS2 Categorizer Worker (Python)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2/src
EnvironmentFile=/opt/rss2/.env
Environment=CATEGORIZER_BATCH_SIZE=10
Environment=CATEGORIZER_SLEEP_IDLE=5
ExecStart=/opt/rss2/venv/bin/python -m workers.simple_categorizer_worker
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-categorizer
MemoryMax=1G
CPUQuota=200%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,26 @@
[Unit]
Description=RSS2 Cluster Worker - Agrupacion de noticias (Python)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2/src
EnvironmentFile=/opt/rss2/.env
Environment=EVENT_DIST_THRESHOLD=0.35
Environment=EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
Environment=HF_HOME=/opt/rss2/hf_cache
ExecStart=/opt/rss2/venv/bin/python -m workers.cluster_worker
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-cluster
MemoryMax=2G
CPUQuota=200%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,26 @@
[Unit]
Description=RSS2 Discovery de Feeds (Go)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2
EnvironmentFile=/opt/rss2/.env
Environment=DISCOVERY_INTERVAL=900
Environment=DISCOVERY_BATCH=10
Environment=MAX_FEEDS_PER_URL=5
ExecStart=/opt/rss2/bin/discovery
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-discovery
MemoryMax=512M
CPUQuota=100%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,30 @@
[Unit]
Description=RSS2 Embeddings Worker (Python)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2/src
EnvironmentFile=/opt/rss2/.env
Environment=EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
Environment=EMB_BATCH=64
Environment=EMB_SLEEP_IDLE=5
Environment=EMB_LANGS=es
Environment=EMB_LIMIT=1000
Environment=DEVICE=cpu
Environment=HF_HOME=/opt/rss2/hf_cache
ExecStart=/opt/rss2/venv/bin/python -m workers.embeddings_worker
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-embeddings
MemoryMax=3G
CPUQuota=200%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,26 @@
[Unit]
Description=RSS2 Ingestor RSS (Go)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2
EnvironmentFile=/opt/rss2/.env
Environment=RSS_MAX_WORKERS=100
Environment=RSS_POKE_INTERVAL_MIN=15
ExecStart=/opt/rss2/bin/ingestor
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-ingestor
LimitNOFILE=65536
MemoryMax=2G
CPUQuota=200%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,25 @@
[Unit]
Description=RSS2 Language Detection Worker (Python)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2/src
EnvironmentFile=/opt/rss2/.env
Environment=LANG_DETECT_SLEEP=60
Environment=LANG_DETECT_BATCH=1000
ExecStart=/opt/rss2/venv/bin/python -m workers.langdetect_worker
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-langdetect
MemoryMax=512M
CPUQuota=50%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,26 @@
[Unit]
Description=RSS2 NER Worker - Extraccion de Entidades (Python)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2/src
EnvironmentFile=/opt/rss2/.env
Environment=NER_LANG=es
Environment=NER_BATCH=64
Environment=HF_HOME=/opt/rss2/hf_cache
ExecStart=/opt/rss2/venv/bin/python -m workers.ner_worker
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-ner
MemoryMax=2G
CPUQuota=200%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,28 @@
[Unit]
Description=RSS2 Qdrant Sync Worker (Go)
After=network.target postgresql.service rss2-qdrant.service
Requires=postgresql.service rss2-qdrant.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2
EnvironmentFile=/opt/rss2/.env
Environment=QDRANT_HOST=127.0.0.1
Environment=QDRANT_PORT=6333
Environment=QDRANT_COLLECTION=news_vectors
Environment=QDRANT_SLEEP=30
Environment=QDRANT_BATCH=100
ExecStart=/opt/rss2/bin/qdrant_worker
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-qdrant-worker
MemoryMax=1G
CPUQuota=100%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,25 @@
[Unit]
Description=Qdrant Vector Database
After=network.target
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2/qdrant
ExecStart=/opt/rss2/qdrant/qdrant
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-qdrant
Environment=QDRANT__SERVICE__HTTP_PORT=6333
Environment=QDRANT__SERVICE__GRPC_PORT=6334
Environment=QDRANT__STORAGE__STORAGE_PATH=/opt/rss2/data/qdrant_storage
MemoryMax=4G
CPUQuota=400%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,26 @@
[Unit]
Description=RSS2 Related News Worker (Go)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2
EnvironmentFile=/opt/rss2/.env
Environment=RELATED_SLEEP=10
Environment=RELATED_BATCH=200
Environment=RELATED_TOPK=10
ExecStart=/opt/rss2/bin/related
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-related
MemoryMax=1G
CPUQuota=100%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,25 @@
[Unit]
Description=RSS2 Scraper HTML (Go)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2
EnvironmentFile=/opt/rss2/.env
Environment=SCRAPER_SLEEP=60
Environment=SCRAPER_BATCH=10
ExecStart=/opt/rss2/bin/scraper
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-scraper
MemoryMax=512M
CPUQuota=100%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,25 @@
[Unit]
Description=RSS2 Topics Worker (Go)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2
EnvironmentFile=/opt/rss2/.env
Environment=TOPICS_SLEEP=10
Environment=TOPICS_BATCH=500
ExecStart=/opt/rss2/bin/topics
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-topics
MemoryMax=512M
CPUQuota=100%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,26 @@
[Unit]
Description=RSS2 Translation Scheduler (Python)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2/src
EnvironmentFile=/opt/rss2/.env
Environment=TARGET_LANGS=es
Environment=SCHEDULER_BATCH=1000
Environment=SCHEDULER_SLEEP=30
ExecStart=/opt/rss2/venv/bin/python -m workers.translation_scheduler
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-translation-scheduler
MemoryMax=256M
CPUQuota=50%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,31 @@
[Unit]
Description=RSS2 Translator Worker NLLB-200 (Python)
After=network.target postgresql.service rss2-translation-scheduler.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2/src
EnvironmentFile=/opt/rss2/.env
Environment=TARGET_LANGS=es
Environment=TRANSLATOR_BATCH=32
Environment=CT2_MODEL_PATH=/opt/rss2/models/nllb-ct2
Environment=CT2_DEVICE=cpu
Environment=CT2_COMPUTE_TYPE=int8
Environment=UNIVERSAL_MODEL=facebook/nllb-200-distilled-600M
Environment=HF_HOME=/opt/rss2/hf_cache
ExecStart=/opt/rss2/venv/bin/python -m workers.ctranslator_worker
Restart=always
RestartSec=15
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-translator
# El modelo NLLB-200 consume bastante RAM en CPU
MemoryMax=4G
CPUQuota=200%
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,24 @@
[Unit]
Description=RSS2 Wiki Worker - imagenes Wikipedia (Go)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=rss2
Group=rss2
WorkingDirectory=/opt/rss2
EnvironmentFile=/opt/rss2/.env
Environment=WIKI_SLEEP=10
ExecStart=/opt/rss2/bin/wiki_worker
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rss2-wiki
MemoryMax=256M
CPUQuota=50%
[Install]
WantedBy=multi-user.target

View file

@ -1,10 +0,0 @@
# Override local: permite acceder a Grafana en http://localhost:3001
# La red monitoring es internal:true y compose descarta los puertos de servicios
# que solo están en redes internas. Añadir la red frontend (no internal) permite
# publicar el puerto 127.0.0.1:3001.
# Uso: docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
services:
grafana:
networks:
- monitoring
- frontend

View file

@ -1,664 +0,0 @@
# ==================================================================================
# CONFIGURACIÓN DE SECRETOS - Se cargan desde .env
# ==================================================================================
# Las variables POSTGRES_PASSWORD, REDIS_PASSWORD, DB_PASS, SECRET_KEY, GRAFANA_PASSWORD
# deben estar definidas en un archivo .env antes de ejecutar docker compose up
#
# Para generar credenciales seguras automáticamente:
# ./generate_secure_credentials.sh --force
#
# Despliegue simple:
# docker compose up -d
# ==================================================================================
services:
db:
image: postgres:18-alpine
container_name: rss2_db
shm_size: 4gb
environment:
POSTGRES_DB: ${POSTGRES_DB:-rss}
POSTGRES_USER: ${POSTGRES_USER:-rss}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
LANG: C.UTF-8
LC_ALL: C.UTF-8
TZ: Europe/Madrid
PGDATA: /var/lib/postgresql/data/18/main
volumes:
- ./data/pgdata:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d:rw
- ./docker-entrypoint-db.sh:/docker-entrypoint-db.sh:ro
entrypoint: ["bash", "/docker-entrypoint-db.sh"]
networks:
backend:
aliases:
- db
- rss2_db
restart: unless-stopped
healthcheck:
test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $$POSTGRES_USER -d $$POSTGRES_DB || exit 1" ]
# NOTA: Si falla, verifica que POSTGRES_PASSWORD esté definido en .env
interval: 5s
timeout: 5s
retries: 30
start_period: 20s
deploy:
resources:
limits:
cpus: '2'
memory: 8G
reservations:
memory: 4G
redis:
image: redis:7-alpine
container_name: rss2_redis
environment:
TZ: Europe/Madrid
# SEGURIDAD: Redis con autenticación
command: >
redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD:-!ERROR!REDIS_PASSWORD_NEEDED!}
volumes:
- ./data/redis-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
backend:
aliases:
- redis
- rss2_redis
restart: unless-stopped
healthcheck:
# VALIDACIÓN: Si REDIS_PASSWORD está vacío, esto fallará y detendrá el despliegue
test: [ "CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping" ]
interval: 5s
timeout: 3s
retries: 5
deploy:
resources:
limits:
memory: 768M
reservations:
memory: 512M
rss-ingestor-go:
build:
context: ./rss-ingestor-go
dockerfile: Dockerfile
container_name: rss2_ingestor_go
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
RSS_MAX_WORKERS: ${RSS_MAX_WORKERS:-100}
RSS_POKE_INTERVAL_MIN: ${RSS_POKE_INTERVAL_MIN:-60}
RSS_HOST_DELAY_MS: ${RSS_HOST_DELAY_MS:-800}
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
memory: 512M
langdetect:
build:
context: .
dockerfile: Dockerfile
container_name: rss2_langdetect_py
command: bash -lc "python -m workers.langdetect_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
LANG_DETECT_SLEEP: 60
LANG_DETECT_BATCH: 1000
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
# ==================================================================================
# SCRAPER WORKER (Go) - Extrae artículos de URLs
# ==================================================================================
scraper:
build:
context: .
dockerfile: Dockerfile.scraper
container_name: rss2_scraper
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
SCRAPER_SLEEP: 120
SCRAPER_BATCH: 10
SCRAPER_ENRICH_LIMIT: 10
SCRAPER_WORKERS: ${SCRAPER_WORKERS:-2}
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 512M
# ==================================================================================
# DISCOVERY WORKER (Go) - Descubre RSS feeds
# ==================================================================================
discovery:
build:
context: .
dockerfile: Dockerfile.discovery
container_name: rss2_discovery
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
DISCOVERY_INTERVAL: 900
DISCOVERY_BATCH: 10
MAX_FEEDS_PER_URL: 5
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 512M
# ==================================================================================
# WIKI WORKER (Go) - Wikipedia info and thumbnails
# ==================================================================================
wiki-worker:
build:
context: .
dockerfile: Dockerfile.wiki
container_name: rss2_wiki_worker
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
WIKI_SLEEP: 10
WIKI_BATCH: 20
TZ: Europe/Madrid
volumes:
- ./data/wiki_images:/app/data/wiki_images
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
# ==================================================================================
# BACKEND GO (API REST)
# ==================================================================================
backend-go:
build:
context: ./backend
dockerfile: Dockerfile
container_name: rss2_backend_go
environment:
TZ: Europe/Madrid
DATABASE_URL: postgres://${POSTGRES_USER:-rss}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-rss}?sslmode=disable
REDIS_URL: redis://:${REDIS_PASSWORD:-rss_redis_pass_2024}@redis:6379
SECRET_KEY: ${SECRET_KEY:-change_this_to_a_long_random_string}
SERVER_PORT: "8080"
volumes:
- ./data/wiki_images:/app/data/wiki_images
networks:
- backend
- frontend
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 512M
# ==================================================================================
# FRONTEND REACT
# ==================================================================================
rss2_frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: rss2_frontend
environment:
TZ: Europe/Madrid
VITE_API_URL: /api
networks:
- frontend
depends_on:
- backend-go
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 512M
# ==================================================================================
# NGINX (Puerto 8001 - sirve React + proxy API)
# ==================================================================================
nginx:
image: nginx:alpine
container_name: rss2_nginx
ports:
- "8888:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- frontend
depends_on:
- rss2_frontend
- backend-go
restart: unless-stopped
# ==================================================================================
# TRANSLATOR CPU (CTranslate2) - Scale with: docker compose up -d --scale translator=3
# ==================================================================================
translator:
build:
context: .
dockerfile: Dockerfile.translator
image: rss2-translator:latest
command: bash -lc "python -m workers.ctranslator_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
TARGET_LANGS: es
TRANSLATOR_BATCH: ${TRANSLATOR_BATCH:-128}
CT2_MODEL_PATH: /app/models/nllb-ct2
CT2_DEVICE: cpu
CT2_COMPUTE_TYPE: int8
CT2_INTRA_THREADS: 4
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
TRANSLATOR_ID: ${TRANSLATOR_ID:-}
REDIS_URL: redis://:${REDIS_PASSWORD:-rss_redis_pass_2024}@redis:6379
PYTORCH_ENABLE_MPS_FALLBACK: 1
volumes:
- ./workers:/app/workers
- ./hf_cache:/app/hf_cache
- ./models:/app/models
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
# Prioridad BAJA: el traductor no debe acaparar toda la CPU.
# cpu_shares menor que el resto (1024) + tope que deje CPU para NER, embeddings, etc.
cpu_shares: 512
deploy:
resources:
limits:
cpus: '4'
memory: 6G
# ==================================================================================
# TRANSLATOR CPU Nº2 - Segundo worker CPU (paralelo al translator)
# ==================================================================================
translator-gpu:
build:
context: .
dockerfile: Dockerfile.translator
image: rss2-translator:latest
command: bash -lc "python -m workers.ctranslator_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
TARGET_LANGS: es
TRANSLATOR_BATCH: ${TRANSLATOR_BATCH:-128}
CT2_MODEL_PATH: /app/models/nllb-ct2
CT2_DEVICE: cpu
CT2_COMPUTE_TYPE: int8
CT2_INTRA_THREADS: 4
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
TRANSLATOR_ID: ${TRANSLATOR_ID:-}
REDIS_URL: redis://:${REDIS_PASSWORD:-rss_redis_pass_2024}@redis:6379
PYTORCH_ENABLE_MPS_FALLBACK: 1
volumes:
- ./workers:/app/workers
- ./hf_cache:/app/hf_cache
- ./models:/app/models
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
# Prioridad BAJA: mismo trato que el translator.
cpu_shares: 512
deploy:
resources:
limits:
cpus: '4'
memory: 6G
# ==================================================================================
# TRANSLATION SCHEDULER - Creates translation jobs
# ==================================================================================
translation-scheduler:
build:
context: .
dockerfile: Dockerfile.scheduler
image: rss2-scheduler:latest
container_name: rss2_translation_scheduler
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
TARGET_LANGS: es
SCHEDULER_BATCH: 200
SCHEDULER_AGE_DAYS: 30
SCHEDULER_SLEEP: 30
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
networks:
- backend
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
depends_on:
db:
condition: service_healthy
restart: unless-stopped
embeddings:
build:
context: .
dockerfile: Dockerfile
container_name: rss2_embeddings_py
command: bash -lc "python -m workers.embeddings_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH: 32
EMB_SLEEP_IDLE: 5
EMB_LANGS: es
EMB_LIMIT: 300
DEVICE: cpu
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
- ./hf_cache:/app/hf_cache
networks:
- backend
deploy:
resources:
limits:
cpus: '2'
memory: 6G
depends_on:
db:
condition: service_healthy
restart: unless-stopped
# ==================================================================================
# TOPICS WORKER (Go) - Matching temas y países
# ==================================================================================
topics:
build:
context: .
dockerfile: Dockerfile.topics
container_name: rss2_topics
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
TOPICS_SLEEP: 10
TOPICS_BATCH: 500
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 512M
# ==================================================================================
# RELATED WORKER (Go) - Noticias relacionadas
# ==================================================================================
related:
build:
context: .
dockerfile: Dockerfile.related
container_name: rss2_related
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
RELATED_SLEEP: 10
RELATED_BATCH: 200
RELATED_TOPK: 10
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 1G
qdrant:
image: qdrant/qdrant:latest
container_name: rss2_qdrant
environment:
TZ: Europe/Madrid
QDRANT__SERVICE__GRPC_PORT: 6334
volumes:
- ./data/qdrant_storage:/qdrant/storage
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- backend
restart: unless-stopped
deploy:
resources:
limits:
cpus: '4'
memory: 4G
reservations:
memory: 2G
# ==================================================================================
# QDRANT WORKER (Go) - Vectorización y búsqueda semántica
# ==================================================================================
qdrant-worker:
build:
context: .
dockerfile: Dockerfile.qdrant
container_name: rss2_qdrant_worker
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
QDRANT_HOST: qdrant
QDRANT_PORT: 6333
QDRANT_COLLECTION: news_vectors
OLLAMA_URL: http://ollama:11434
QDRANT_SLEEP: 30
QDRANT_BATCH: 100
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
qdrant:
condition: service_started
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 1G
# ==================================================================================
# NER WORKER (Python) - Extracción de entidades
# ==================================================================================
ner:
build:
context: .
dockerfile: Dockerfile
container_name: rss2_ner
command: bash -lc "python -m workers.ner_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
NER_LANG: es
NER_BATCH: 24
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
- ./hf_cache:/app/hf_cache
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 2G
# ==================================================================================
# CLUSTER WORKER (Python) - Agrupación de noticias
# ==================================================================================
cluster:
build:
context: .
dockerfile: Dockerfile
container_name: rss2_cluster_py
command: bash -lc "python -m workers.cluster_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
EVENT_DIST_THRESHOLD: 0.35
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 2G
# ==================================================================================
# REDES SEGMENTADAS
# ==================================================================================
networks:
# Red frontal - Solo nginx y web app
frontend:
name: rss2_frontend
driver: bridge
internal: false
# Red backend - Base de datos, workers, redis, qdrant
backend:
name: rss2_backend
driver: bridge
internal: false # Acceso externo permitido (necesario para ingestor)
volumes:
torch_extensions:

View file

@ -1,802 +0,0 @@
# ==================================================================================
# CONFIGURACIÓN DE SECRETOS - Se cargan desde .env
# ==================================================================================
# Las variables POSTGRES_PASSWORD, REDIS_PASSWORD, DB_PASS, SECRET_KEY, GRAFANA_PASSWORD
# deben estar definidas en un archivo .env antes de ejecutar docker compose up
#
# Para generar credenciales seguras automáticamente:
# ./generate_secure_credentials.sh --force
#
# Despliegue simple:
# docker compose up -d
# ==================================================================================
services:
db:
image: postgres:18-alpine
container_name: rss2_db
shm_size: 4gb
environment:
POSTGRES_DB: ${POSTGRES_DB:-rss}
POSTGRES_USER: ${POSTGRES_USER:-rss}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
LANG: C.UTF-8
LC_ALL: C.UTF-8
TZ: Europe/Madrid
PGDATA: /var/lib/postgresql/data/18/main
volumes:
- ./data/pgdata:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d:rw
- ./docker-entrypoint-db.sh:/docker-entrypoint-db.sh:ro
entrypoint: ["bash", "/docker-entrypoint-db.sh"]
networks:
backend:
aliases:
- db
- rss2_db
restart: unless-stopped
healthcheck:
test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $$POSTGRES_USER -d $$POSTGRES_DB || exit 1" ]
# NOTA: Si falla, verifica que POSTGRES_PASSWORD esté definido en .env
interval: 5s
timeout: 5s
retries: 30
start_period: 20s
deploy:
resources:
limits:
memory: 8G
reservations:
memory: 4G
redis:
image: redis:7-alpine
container_name: rss2_redis
environment:
TZ: Europe/Madrid
# SEGURIDAD: Redis con autenticación
command: >
redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD:-!ERROR!REDIS_PASSWORD_NEEDED!}
volumes:
- ./data/redis-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
backend:
aliases:
- redis
- rss2_redis
restart: unless-stopped
healthcheck:
# VALIDACIÓN: Si REDIS_PASSWORD está vacío, esto fallará y detendrá el despliegue
test: [ "CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping" ]
interval: 5s
timeout: 3s
retries: 5
deploy:
resources:
limits:
memory: 768M
reservations:
memory: 512M
rss-ingestor-go:
build:
context: ./rss-ingestor-go
dockerfile: Dockerfile
container_name: rss2_ingestor_go
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
RSS_MAX_WORKERS: 100
RSS_POKE_INTERVAL_MIN: 60
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
memory: 512M
langdetect:
build:
context: .
dockerfile: Dockerfile
container_name: rss2_langdetect_py
command: bash -lc "python -m workers.langdetect_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
LANG_DETECT_SLEEP: 60
LANG_DETECT_BATCH: 1000
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
# ==================================================================================
# SCRAPER WORKER (Go) - Extrae artículos de URLs
# ==================================================================================
scraper:
build:
context: .
dockerfile: Dockerfile.scraper
container_name: rss2_scraper
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
SCRAPER_SLEEP: 60
SCRAPER_BATCH: 10
SCRAPER_ENRICH_LIMIT: 20
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 512M
# ==================================================================================
# DISCOVERY WORKER (Go) - Descubre RSS feeds
# ==================================================================================
discovery:
build:
context: .
dockerfile: Dockerfile.discovery
container_name: rss2_discovery
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
DISCOVERY_INTERVAL: 900
DISCOVERY_BATCH: 10
MAX_FEEDS_PER_URL: 5
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 512M
# ==================================================================================
# WIKI WORKER (Go) - Wikipedia info and thumbnails
# ==================================================================================
wiki-worker:
build:
context: .
dockerfile: Dockerfile.wiki
container_name: rss2_wiki_worker
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
WIKI_SLEEP: 10
TZ: Europe/Madrid
volumes:
- ./data/wiki_images:/app/data/wiki_images
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
# ==================================================================================
# BACKEND GO (API REST)
# ==================================================================================
backend-go:
build:
context: ./backend
dockerfile: Dockerfile
container_name: rss2_backend_go
environment:
TZ: Europe/Madrid
DATABASE_URL: postgres://${POSTGRES_USER:-rss}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-rss}?sslmode=disable
REDIS_URL: redis://:${REDIS_PASSWORD:-rss_redis_pass_2024}@redis:6379
SECRET_KEY: ${SECRET_KEY:-change_this_to_a_long_random_string}
SERVER_PORT: "8080"
volumes:
- ./data/wiki_images:/app/data/wiki_images
networks:
- backend
- frontend
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
# ==================================================================================
# FRONTEND REACT
# ==================================================================================
rss2_frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: rss2_frontend
environment:
TZ: Europe/Madrid
VITE_API_URL: /api
networks:
- frontend
depends_on:
- backend-go
restart: unless-stopped
# ==================================================================================
# NGINX (Puerto 8001 - sirve React + proxy API)
# ==================================================================================
nginx:
image: nginx:alpine
container_name: rss2_nginx
ports:
- "8888:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- frontend
depends_on:
- rss2_frontend
- backend-go
restart: unless-stopped
# ==================================================================================
# TRANSLATOR CPU (CTranslate2) - Scale with: docker compose up -d --scale translator=3
# ==================================================================================
translator:
build:
context: .
dockerfile: Dockerfile.translator
image: rss2-translator:latest
command: bash -lc "python -m workers.ctranslator_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
TARGET_LANGS: es
TRANSLATOR_BATCH: 32
CT2_MODEL_PATH: /app/models/nllb-ct2
CT2_DEVICE: cpu
CT2_COMPUTE_TYPE: int8
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
TRANSLATOR_ID: ${TRANSLATOR_ID:-}
PYTORCH_ENABLE_MPS_FALLBACK: 1
volumes:
- ./workers:/app/workers
- ./hf_cache:/app/hf_cache
- ./models:/app/models
networks:
- backend
profiles:
- cpu-only
depends_on:
db:
condition: service_healthy
restart: unless-stopped
# ==================================================================================
# TRANSLATOR GPU - Multiple instances with --scale for parallel GPU processing
# Configure multiple GPU workers: docker compose up -d --scale translator-gpu=2
# ==================================================================================
translator-gpu:
build:
context: .
dockerfile: Dockerfile.translator-gpu
image: rss2-translator-gpu:latest
command: bash -lc "python -m workers.ctranslator_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
TARGET_LANGS: es
TRANSLATOR_BATCH: 128
CT2_MODEL_PATH: /app/models/nllb-ct2
CT2_DEVICE: cuda
CT2_COMPUTE_TYPE: float16
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
TRANSLATOR_ID: ${TRANSLATOR_ID:-}
PYTORCH_CUDA_ALLOC_CONF: max_split_size_mb:512
NCCL_DEBUG: INFO
volumes:
- ./workers:/app/workers
- ./hf_cache:/app/hf_cache
- ./models:/app/models
- /dev/nvidia:/dev/nvidia
- /usr/local/nvidia:/usr/local/nvidia:ro
networks:
- backend
profiles:
- gpu
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
depends_on:
db:
condition: service_healthy
restart: unless-stopped
# ==================================================================================
# REMOTE TRANSLATOR WORKER - Worker remoto que se conecta por WebSocket
# ==================================================================================
remote-translator:
build:
context: .
dockerfile: Dockerfile.remote-worker
container_name: rss2_remote_translator
environment:
WORKER_NAME: ${WORKER_NAME:-remote-worker-1}
WORKER_API_KEY: ${WORKER_API_KEY:-}
WORKER_SERVER: ${WORKER_SERVER:-ws://backend-go:8080/ws/worker}
CT2_DEVICE: ${CT2_DEVICE:-cuda}
CT2_MODEL_PATH: /app/models/nllb-ct2
CT2_COMPUTE_TYPE: ${CT2_COMPUTE_TYPE:-float16}
UNIVERSAL_MODEL: facebook/nllb-200-1.3B
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
volumes:
- ./hf_cache:/app/hf_cache
- ./models:/app/models
networks:
- backend
profiles:
- remote
deploy:
resources:
limits:
memory: 6G
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]
restart: unless-stopped
# ==================================================================================
# TRANSLATION SCHEDULER - Creates translation jobs
# ==================================================================================
translation-scheduler:
build:
context: .
dockerfile: Dockerfile.scheduler
image: rss2-scheduler:latest
container_name: rss2_translation_scheduler
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
TARGET_LANGS: es
SCHEDULER_BATCH: 1000
SCHEDULER_SLEEP: 30
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
networks:
- backend
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
depends_on:
db:
condition: service_healthy
restart: unless-stopped
embeddings:
build:
context: .
dockerfile: Dockerfile
container_name: rss2_embeddings_py
command: bash -lc "python -m workers.embeddings_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
EMB_BATCH: 64
EMB_SLEEP_IDLE: 5
EMB_LANGS: es
EMB_LIMIT: 1000
DEVICE: cuda
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
- ./hf_cache:/app/hf_cache
networks:
- backend
deploy:
resources:
limits:
memory: 6G
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]
depends_on:
db:
condition: service_healthy
restart: unless-stopped
# ==================================================================================
# TOPICS WORKER (Go) - Matching temas y países
# ==================================================================================
topics:
build:
context: .
dockerfile: Dockerfile.topics
container_name: rss2_topics
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
TOPICS_SLEEP: 10
TOPICS_BATCH: 500
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 512M
# ==================================================================================
# RELATED WORKER (Go) - Noticias relacionadas
# ==================================================================================
related:
build:
context: .
dockerfile: Dockerfile.related
container_name: rss2_related
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
RELATED_SLEEP: 10
RELATED_BATCH: 200
RELATED_TOPK: 10
EMB_MODEL: mxbai-embed-large
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 1G
qdrant:
image: qdrant/qdrant:latest
container_name: rss2_qdrant
environment:
TZ: Europe/Madrid
QDRANT__SERVICE__GRPC_PORT: 6334
volumes:
- ./data/qdrant_storage:/qdrant/storage
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- backend
restart: unless-stopped
deploy:
resources:
limits:
cpus: '4'
memory: 4G
reservations:
memory: 2G
# ==================================================================================
# QDRANT WORKER (Go) - Vectorización y búsqueda semántica
# ==================================================================================
qdrant-worker:
build:
context: .
dockerfile: Dockerfile.qdrant
container_name: rss2_qdrant_worker
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
QDRANT_HOST: qdrant
QDRANT_PORT: 6333
QDRANT_COLLECTION: news_vectors
OLLAMA_URL: http://ollama:11434
QDRANT_SLEEP: 30
QDRANT_BATCH: 100
TZ: Europe/Madrid
networks:
- backend
depends_on:
db:
condition: service_healthy
qdrant:
condition: service_started
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 1G
# ==================================================================================
# NER WORKER (Python) - Extracción de entidades
# ==================================================================================
ner:
build:
context: .
dockerfile: Dockerfile
container_name: rss2_ner
command: bash -lc "python -m workers.ner_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
NER_LANG: es
NER_BATCH: 64
HF_HOME: /app/hf_cache
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
- ./hf_cache:/app/hf_cache
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 2G
# ==================================================================================
# CLUSTER WORKER (Python) - Agrupación de noticias
# ==================================================================================
cluster:
build:
context: .
dockerfile: Dockerfile
container_name: rss2_cluster_py
command: bash -lc "python -m workers.cluster_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
EVENT_DIST_THRESHOLD: 0.35
EMB_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 2G
# ==================================================================================
# LLM CATEGORIZER (Python) - Categorización con Ollama
# ==================================================================================
llm-categorizer:
build:
context: .
dockerfile: Dockerfile
container_name: rss2_llm_categorizer
command: bash -lc "python -m workers.simple_categorizer_worker"
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-rss}
DB_USER: ${DB_USER:-rss}
DB_PASS: ${DB_PASS}
CATEGORIZER_BATCH_SIZE: 10
CATEGORIZER_SLEEP_IDLE: 5
TZ: Europe/Madrid
volumes:
- ./workers:/app/workers
networks:
- backend
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 1G
# ==================================================================================
# MONITORING STACK - SECURED
# ==================================================================================
prometheus:
image: prom/prometheus:latest
container_name: rss2_prometheus
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
# SEGURIDAD: Sin exposición de puertos - acceso solo vía Grafana o túnel SSH
# ports:
# - "9090:9090"
networks:
- monitoring
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 2G
grafana:
image: grafana/grafana:latest
container_name: rss2_grafana
# SEGURIDAD: Acceso solo en localhost o vía túnel SSH
# Para acceso remoto, usar túnel SSH: ssh -L 3001:localhost:3001 user@server
ports:
- "127.0.0.1:3001:3000"
environment:
# SEGURIDAD: Cambiar este password en producción
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-change_this_password}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=http://localhost:3001
- GF_SECURITY_COOKIE_SECURE=false
- GF_SECURITY_COOKIE_SAMESITE=lax
volumes:
- grafana_data:/var/lib/grafana
networks:
- monitoring
depends_on:
- prometheus
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1'
memory: 1G
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: rss2_cadvisor
# SEGURIDAD: Sin exposición de puertos - solo acceso interno
# ports:
# - "8081:8080"
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
devices:
- /dev/kmsg
networks:
- monitoring
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
# ==================================================================================
# REDES SEGMENTADAS
# ==================================================================================
networks:
# Red frontal - Solo nginx y web app
frontend:
name: rss2_frontend
driver: bridge
internal: false
# Red backend - Base de datos, workers, redis, qdrant
backend:
name: rss2_backend
driver: bridge
internal: false # Acceso externo permitido (necesario para ingestor)
# Red de monitoreo - Prometheus, Grafana, cAdvisor
monitoring:
name: rss2_monitoring
driver: bridge
internal: true
volumes:
prometheus_data:
grafana_data:
torch_extensions:

View file

@ -1,38 +0,0 @@
#!/bin/bash
set -e
# Directorio de datos de PostgreSQL
PGDATA_DIR="/var/lib/postgresql/data/18/main"
echo "RSS2: Checking database presence..."
# REGLA: nunca se borra ni se modifica el directorio de datos automáticamente.
# Solo se informa del estado y se deja que el entrypoint oficial de PostgreSQL
# inicialice (si no existe) o arranque con los datos existentes.
if [ -f "$PGDATA_DIR/PG_VERSION" ]; then
echo "RSS2: Existing database found at $PGDATA_DIR - starting normally"
else
echo "RSS2: No database found at $PGDATA_DIR - docker-entrypoint will initialize it"
fi
# Ejecutar el entrypoint original con los parámetros de PostgreSQL.
# Si la base de datos estuviera corrupta, PostgreSQL fallará al arrancar y
# registrará el error en los logs; NO se elimina ningún dato de forma automática.
exec docker-entrypoint.sh \
postgres \
-c max_connections=200 \
-c shared_buffers=4GB \
-c effective_cache_size=12GB \
-c work_mem=16MB \
-c maintenance_work_mem=512MB \
-c autovacuum_max_workers=3 \
-c autovacuum_vacuum_scale_factor=0.02 \
-c autovacuum_vacuum_cost_limit=1000 \
-c max_worker_processes=8 \
-c max_parallel_workers=6 \
-c max_parallel_workers_per_gather=2 \
-c wal_level=replica \
-c max_wal_senders=5 \
-c wal_keep_size=1GB \
-c hot_standby=on \
"$@"

54
docs/DEPLOY.md Normal file
View file

@ -0,0 +1,54 @@
# Deployment Guide
This guide describes how to deploy the application to a new server.
## Prerequisites
* **Linux Server** (Ubuntu 22.04+ recommended)
* **NVIDIA GPU**: Required for translation, embeddings, and NER services.
* **NVIDIA Container Toolkit**: Must be installed to allow Docker to access the GPU.
* **Docker** & **Docker Compose**: Latest versions.
* **Git**: To clone the repository.
* **External Service**: An instance of [AllTalk](https://github.com/erew123/alltalk_tts) running externally or on the host (port 7851 by default).
## Deployment Steps
1. **Clone the Repository**
```bash
git clone <your-repo-url>
cd <your-repo-name>
```
2. **Configure Environment Variables**
Copy the example configuration file:
```bash
cp .env.example .env
```
Edit `.env` and set secure passwords and configuration:
```bash
nano .env
```
* Change `POSTGRES_PASSWORD` and `DB_PASS` to a strong unique password.
* Change `SECRET_KEY` to a long random string.
* Verify `ALLTALK_URL` points to your AllTalk instance (default assumes host machine access).
3. **Start the Services**
Run the following command to build and start the application:
```bash
docker compose up -d --build
```
4. **Database Initialization**
The database will automatically initialize on the first run using the scripts in `init-db/`. This may take a few minutes. Check logs with:
```bash
docker compose logs -f db
```
5. **Verify Deployment**
Access the application at `http://<your-server-ip>:8001`.
## Important Notes
* **Models**: The application mounts `./models` and `./hf_cache` to persist AI models. On the first run, it will attempt to download necessary models (NLLB, BERT, etc.), which requires significant bandwidth and time.
* **Data Persistence**: Database data is stored in `./pgdata` (mapped in docker-compose). Ensure this directory is backed up.
* **Security**: Ensure port 5432 (Postgres) and 6379 (Redis) are firewall-protected and not exposed to the public internet unless intended (Docker maps them to the host network).

287
docs/DEPLOY_DEBIAN.md Normal file
View file

@ -0,0 +1,287 @@
# COCONEWS · Despliegue en Debian (sin Docker)
Guía completa para instalar y operar COCONEWS en un servidor Debian 12 (Bookworm) o Ubuntu 22.04+, sin Docker ni contenedores.
---
## Requisitos de hardware
| Modo | CPU | RAM | Disco |
|------|-----|-----|-------|
| **Mínimo (solo CPU)** | 4 cores | 8 GB | 40 GB |
| **Recomendado** | 8 cores | 16 GB | 80 GB |
| **Con GPU** | 8 cores + NVIDIA | 16 GB | 80 GB |
> Los modelos de IA (NLLB-200 + MiniLM + spaCy) ocupan ~5 GB en disco una vez descargados.
---
## Servicios que se instalan en el servidor
| Servicio | Tecnología | Gestionado por |
|----------|-----------|----------------|
| Base de datos | PostgreSQL 16 | apt + systemd |
| Caché | Redis 7 | apt + systemd |
| Búsqueda vectorial | Qdrant (binario) | systemd |
| API REST | Go (backend) | systemd |
| Ingestor RSS | Go | systemd |
| Scraper / Discovery / Wiki / Topics / Related / Qdrant-worker | Go | systemd |
| Traducción (NLLB-200) | Python + CTranslate2 | systemd |
| Embeddings | Python + Sentence-Transformers | systemd |
| NER | Python + spaCy | systemd |
| Clustering / Categorización | Python | systemd |
| Frontend | React (estático compilado) | nginx |
| Proxy / Web | nginx | apt + systemd |
---
## Instalación paso a paso
### 1. Clonar el repositorio
```bash
git clone https://gitea.laenre.net/pietre/rss2.git /opt/src/rss2
cd /opt/src/rss2
git checkout coconews
```
### 2. Configurar variables de entorno
```bash
cp deploy/debian/env.example /opt/rss2/.env
nano /opt/rss2/.env
```
Valores que **debes cambiar obligatoriamente**:
```env
POSTGRES_PASSWORD=contraseña_segura_postgres
DB_PASS=contraseña_segura_postgres
REDIS_PASSWORD=contraseña_segura_redis
SECRET_KEY=cadena_aleatoria_minimo_32_caracteres
```
Genera claves seguras con:
```bash
openssl rand -hex 32
```
### 3. Descargar y convertir el modelo de traducción (NLLB-200)
Este paso se hace **una sola vez** y puede tardar 10-30 minutos dependiendo de la conexión.
```bash
# Instalar dependencias Python primero (si aun no se hizo)
python3 -m venv /opt/rss2/venv
/opt/rss2/venv/bin/pip install ctranslate2 transformers sentencepiece
# Convertir modelo NLLB-200 a formato CTranslate2 (tarda 10-30 min)
mkdir -p /opt/rss2/models/nllb-ct2
HF_HOME=/opt/rss2/hf_cache \
/opt/rss2/venv/bin/ct2-transformers-converter \
--model facebook/nllb-200-distilled-600M \
--output_dir /opt/rss2/models/nllb-ct2 \
--quantization int8 \
--force
# Verificar que se generó correctamente
ls /opt/rss2/models/nllb-ct2/model.bin && echo "Modelo OK"
```
> El modelo ocupa ~600 MB convertido. Si la descarga de HuggingFace falla:
> `export HF_ENDPOINT=https://huggingface.co` antes del comando de conversión.
> **Nota:** El worker convierte el modelo automáticamente si no lo encuentra,
> pero hacerlo a mano evita que el primer arranque tarde 30 minutos.
### 4. Ejecutar el instalador
```bash
sudo bash /opt/src/rss2/deploy/debian/install.sh
```
El script hace automáticamente:
- Instala PostgreSQL, Redis, nginx, Go, Node.js via apt
- Descarga el binario de Qdrant
- Crea el usuario `rss2` del sistema
- Crea la base de datos y ejecuta las migraciones
- Compila los 8 binarios Go
- Compila el frontend React
- Instala y habilita los 16 servicios systemd
---
## Verificar que funciona
```bash
# Estado de todos los servicios
systemctl status rss2-backend rss2-ingestor rss2-translator rss2-embeddings
# Ver logs en tiempo real
journalctl -u rss2-backend -f
journalctl -u rss2-translator -f
# Comprobar que el API responde
curl http://localhost:8080/api/stats
# Acceder al frontend
# http://IP_DEL_SERVIDOR:8001
```
---
## Gestión de servicios
### Iniciar / parar / reiniciar
```bash
# Un servicio concreto
systemctl start rss2-backend
systemctl stop rss2-translator
systemctl restart rss2-embeddings
# Todos los workers de una vez
systemctl restart rss2-backend rss2-ingestor rss2-scraper rss2-discovery \
rss2-wiki rss2-topics rss2-related rss2-qdrant-worker \
rss2-langdetect rss2-translation-scheduler rss2-translator \
rss2-embeddings rss2-ner rss2-cluster rss2-categorizer
```
### Ver logs
```bash
journalctl -u rss2-backend -f # API Go
journalctl -u rss2-translator -f # Traductor
journalctl -u rss2-embeddings -f # Embeddings
journalctl -u rss2-ner -f # NER entidades
journalctl -u rss2-ingestor -f # Ingestor RSS
```
---
## Actualizar el código
Cuando hay nuevos cambios en el repositorio:
```bash
cd /opt/src/rss2
git pull
sudo bash deploy/debian/build.sh
```
El script `build.sh` recompila los binarios Go, el frontend y sincroniza los workers Python, y reinicia los servicios automáticamente.
---
## Estructura de directorios en el servidor
```
/opt/rss2/
├── .env # Variables de entorno (permisos 600)
├── bin/ # Binarios Go compilados
│ ├── server # API REST
│ ├── ingestor # Ingestor RSS
│ ├── scraper
│ ├── discovery
│ ├── wiki_worker
│ ├── topics
│ ├── related
│ └── qdrant_worker
├── src/
│ └── workers/ # Workers Python
├── venv/ # Virtualenv Python (ML)
├── models/
│ └── nllb-ct2/ # Modelo traduccion CTranslate2
├── hf_cache/ # Cache HuggingFace (embeddings, NER)
├── frontend/
│ └── dist/ # Frontend React compilado (servido por nginx)
├── data/
│ ├── wiki_images/ # Imagenes Wikipedia descargadas
│ └── qdrant_storage/ # Datos vectoriales Qdrant
└── qdrant/
└── qdrant # Binario Qdrant
```
---
## Requisitos de red / firewall
Solo exponer al exterior el puerto **8001** (nginx). El resto deben ser internos:
```bash
# Con ufw:
ufw allow 8001/tcp # COCONEWS web
ufw deny 5432/tcp # PostgreSQL - solo localhost
ufw deny 6379/tcp # Redis - solo localhost
ufw deny 6333/tcp # Qdrant - solo localhost
ufw deny 8080/tcp # API Go - solo localhost (nginx hace proxy)
ufw enable
```
---
## Backup de datos
```bash
# PostgreSQL
sudo -u postgres pg_dump rss > /opt/rss2/backups/rss_$(date +%Y%m%d).sql
# Datos Qdrant
systemctl stop rss2-qdrant
tar -czf /opt/rss2/backups/qdrant_$(date +%Y%m%d).tar.gz /opt/rss2/data/qdrant_storage
systemctl start rss2-qdrant
# Imagenes Wikipedia (opcional, se pueden re-descargar)
tar -czf /opt/rss2/backups/wiki_images_$(date +%Y%m%d).tar.gz /opt/rss2/data/wiki_images
```
---
## Solución de problemas frecuentes
### El traductor no arranca
```bash
journalctl -u rss2-translator -n 50
# Si dice "model not found": el modelo NLLB-200 no está convertido
# Ejecutar el paso 3 de la instalación
```
### PostgreSQL rechaza la conexión
```bash
# Verificar que el .env tiene DB_HOST=127.0.0.1 (no "db")
grep DB_HOST /opt/rss2/.env
# Verificar que el usuario existe
sudo -u postgres psql -c "\du"
```
### nginx devuelve 502 Bad Gateway
```bash
# El backend Go no está corriendo
systemctl status rss2-backend
journalctl -u rss2-backend -n 30
```
### Memoria insuficiente para los modelos Python
Con 8 GB RAM el translator + embeddings + NER pueden coincidir. Si el servidor tiene poca RAM, deshabilitar el translator-gpu y bajar el batch:
```bash
# En /opt/rss2/.env
TRANSLATOR_BATCH=8
EMB_BATCH=32
NER_BATCH=16
systemctl restart rss2-translator rss2-embeddings rss2-ner
```
---
## Primer inicio de sesión
1. Abrir `http://IP:8001` en el navegador
2. Al no haber usuarios, el sistema te redirige al registro
3. El primer usuario registrado se convierte en **administrador**
4. Ir a Configuración → Feeds → Importar el `feeds.csv` del repositorio para empezar con fuentes precargadas

Some files were not shown because too many files have changed in this diff Show more