Initial clean commit
This commit is contained in:
commit
6784d81c2c
141 changed files with 25219 additions and 0 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
.git
|
||||||
|
pgdata
|
||||||
|
pgdata-replica
|
||||||
|
redis-data
|
||||||
|
hf_cache
|
||||||
|
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
63
.env.example
Normal file
63
.env.example
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# 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
|
||||||
|
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
|
||||||
117
.env.secure.example
Normal file
117
.env.secure.example
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# ==================================================================================
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# ==================================================================================
|
||||||
64
.gitignore
vendored
Normal file
64
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
pgdata/
|
||||||
|
pgdata-replica/
|
||||||
|
pgdata.failed_restore/
|
||||||
|
pgdata-replica.old.*/
|
||||||
|
redis-data/
|
||||||
|
hf_cache/
|
||||||
|
models/nllb-ct2/
|
||||||
|
qdrant_storage/
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/
|
||||||
|
*.mp4
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.srt
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
*.bak
|
||||||
|
*.old
|
||||||
|
|
||||||
|
|
||||||
|
# ==================================================================================
|
||||||
|
# SECURITY FILES - NEVER COMMIT THESE
|
||||||
|
# ==================================================================================
|
||||||
|
# Environment files with credentials
|
||||||
|
.env
|
||||||
|
.env.backup*
|
||||||
|
.env.generated
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database backups
|
||||||
|
*.sql
|
||||||
|
backup_*.sql
|
||||||
|
|
||||||
|
# Redis backups
|
||||||
|
*.rdb
|
||||||
|
redis_backup_*.rdb
|
||||||
|
|
||||||
|
# Qdrant backups
|
||||||
|
qdrant_backup_*.tar.gz
|
||||||
|
|
||||||
|
# Docker compose with real credentials (if you create variations)
|
||||||
|
docker-compose.override.yml
|
||||||
54
DEPLOY.md
Normal file
54
DEPLOY.md
Normal 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).
|
||||||
67
Dockerfile
Normal file
67
Dockerfile
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# CUDA o CPU
|
||||||
|
ARG TORCH_CUDA=cu121
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# Dependencias del sistema
|
||||||
|
# --------------------------------------------------------
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev \
|
||||||
|
gcc \
|
||||||
|
git \
|
||||||
|
libcairo2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libffi-dev \
|
||||||
|
shared-mime-info \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
TOKENIZERS_PARALLELISM=false \
|
||||||
|
HF_HUB_DISABLE_SYMLINKS_WARNING=1 \
|
||||||
|
HF_HOME=/root/.cache/huggingface
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# Instalación de requirements
|
||||||
|
# --------------------------------------------------------
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel
|
||||||
|
|
||||||
|
# Instalar PyTorch según GPU/CPU
|
||||||
|
RUN if [ "$TORCH_CUDA" = "cu121" ]; then \
|
||||||
|
pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cu121 \
|
||||||
|
torch==2.4.1 torchvision==0.19.1 torchaudio==2.4.1 ; \
|
||||||
|
else \
|
||||||
|
pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu \
|
||||||
|
torch==2.4.1 torchvision==0.19.1 torchaudio==2.4.1 ; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Instalar ctranslate2 con soporte CUDA
|
||||||
|
RUN if [ "$TORCH_CUDA" = "cu121" ]; then \
|
||||||
|
pip install --no-cache-dir ctranslate2 ; \
|
||||||
|
else \
|
||||||
|
pip install --no-cache-dir ctranslate2 ; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Descargar modelo spaCy ES
|
||||||
|
RUN python -m spacy download es_core_news_md || true
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# Copiar TODO el proyecto rss2/
|
||||||
|
# --------------------------------------------------------
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# Puede descargar modelos NLLB o Sentence-BERT si existe
|
||||||
|
# --------------------------------------------------------
|
||||||
|
RUN python download_models.py || true
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
12
Dockerfile.replica
Normal file
12
Dockerfile.replica
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM postgres:18-alpine
|
||||||
|
|
||||||
|
# Copy initialization script
|
||||||
|
COPY init-replica/init-replica.sh /docker-entrypoint-initdb.d/
|
||||||
|
|
||||||
|
# Make script executable
|
||||||
|
RUN chmod +x /docker-entrypoint-initdb.d/init-replica.sh
|
||||||
|
|
||||||
|
# Set environment for replication
|
||||||
|
ENV PRIMARY_HOST=db
|
||||||
|
ENV REPLICATION_USER=replicator
|
||||||
|
ENV REPLICATION_PASSWORD=replica_password
|
||||||
29
Dockerfile.url_worker
Normal file
29
Dockerfile.url_worker
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies for lxml and general build
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
libxml2-dev \
|
||||||
|
libxslt-dev \
|
||||||
|
python3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install python dependencies
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
psycopg2-binary \
|
||||||
|
requests \
|
||||||
|
newspaper3k \
|
||||||
|
lxml_html_clean \
|
||||||
|
python-dotenv
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Run the worker daemon
|
||||||
|
CMD ["python", "-m", "workers.url_worker_daemon"]
|
||||||
257
QDRANT_SETUP.md
Normal file
257
QDRANT_SETUP.md
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
# ✅ Sistema Qdrant - Búsquedas Semánticas
|
||||||
|
|
||||||
|
## 🎯 Arquitectura Actual
|
||||||
|
|
||||||
|
El sistema vectoriza **directamente** las noticias traducidas y proporciona búsqueda semántica en tiempo real.
|
||||||
|
|
||||||
|
```
|
||||||
|
Noticias Originales (RSS)
|
||||||
|
↓
|
||||||
|
Traducción (translator workers)
|
||||||
|
↓
|
||||||
|
PostgreSQL (tabla 'traducciones')
|
||||||
|
↓
|
||||||
|
Qdrant Worker (vectorización directa)
|
||||||
|
↓
|
||||||
|
Qdrant (búsquedas semánticas)
|
||||||
|
↓
|
||||||
|
API de Búsqueda (utils/qdrant_search.py)
|
||||||
|
↓
|
||||||
|
Buscador General + Monitor de Conflictos
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Servicios y Componentes
|
||||||
|
|
||||||
|
| Componente | Puerto/Ubicación | Descripción |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| **Qdrant** | 6333 | Base de datos vectorial |
|
||||||
|
| **Qdrant Worker** | - | Vectorización continua |
|
||||||
|
| **Búsqueda Semántica** | `utils/qdrant_search.py` | API de búsqueda vectorial |
|
||||||
|
| **Buscador General** | `routers/search.py` | Búsqueda con fallback a PostgreSQL |
|
||||||
|
| **Monitor de Conflictos** | `routers/conflicts.py` | Búsqueda por keywords con vectores |
|
||||||
|
|
||||||
|
### Configuración del Worker
|
||||||
|
|
||||||
|
- **Origen**: Tabla `traducciones`
|
||||||
|
- **Modelo**: `paraphrase-multilingual-MiniLM-L12-v2`
|
||||||
|
- **Dimensiones**: 384
|
||||||
|
- **Dispositivo**: CPU (GPU disponible)
|
||||||
|
- **Velocidad**: ~100+ noticias/minuto
|
||||||
|
- **Total Vectorizado**: ~507,000 noticias
|
||||||
|
|
||||||
|
## 🚀 Integración Completa
|
||||||
|
|
||||||
|
### Buscador General (`/api/search`)
|
||||||
|
|
||||||
|
La búsqueda ahora usa **Qdrant primero** para mayor velocidad y precisión semántica:
|
||||||
|
|
||||||
|
1. **Búsqueda Semántica** (por defecto): Usa vectores de Qdrant
|
||||||
|
2. **Fallback PostgreSQL**: Si falla o no hay resultados
|
||||||
|
3. **Enriquecimiento**: Combina datos de ambas fuentes
|
||||||
|
|
||||||
|
**Ventajas:**
|
||||||
|
- ✅ Búsquedas 10-100x más rápidas (sin escaneo de 500k filas)
|
||||||
|
- ✅ Comprende sinónimos y contexto ("protestas" encuentra "manifestaciones")
|
||||||
|
- ✅ Multilingüe automático
|
||||||
|
- ✅ Sin dependencia de palabras exactas
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `q`: Texto de búsqueda
|
||||||
|
- `limit`: Máximo de resultados (default: 10, max: 50)
|
||||||
|
- `semantic`: `true/false` (default: `true`)
|
||||||
|
|
||||||
|
### Monitor de Conflictos (`/conflicts/<id>`)
|
||||||
|
|
||||||
|
Ahora usa búsqueda semántica por keywords:
|
||||||
|
|
||||||
|
**Antes (ILIKE con PostgreSQL):**
|
||||||
|
- ❌ "irán protestas" requería coincidencia exacta de toda la frase
|
||||||
|
- ❌ Lento con 500k noticias
|
||||||
|
- ❌ No encontraba variaciones ("manifestación", "protesta")
|
||||||
|
|
||||||
|
**Ahora (Qdrant):**
|
||||||
|
- ✅ "irán protestas" busca por ambas palabras independientemente
|
||||||
|
- ✅ Rápido (búsqueda vectorial)
|
||||||
|
- ✅ Encuentra contenido semánticamente similar
|
||||||
|
|
||||||
|
## 🔧 Comandos Útiles
|
||||||
|
|
||||||
|
### Ver Logs
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f qdrant-worker
|
||||||
|
docker-compose logs -f qdrant
|
||||||
|
docker-compose logs -f rss2_web # Para ver logs de búsqueda
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estadísticas
|
||||||
|
```bash
|
||||||
|
docker exec -it rss2_web python scripts/migrate_to_qdrant.py --stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vectorizar Pendientes (Manual)
|
||||||
|
```bash
|
||||||
|
docker exec -it rss2_web python scripts/migrate_to_qdrant.py --vectorize --batch-size 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset Completo (⚠️ Destructivo)
|
||||||
|
```bash
|
||||||
|
docker exec -it rss2_web python scripts/migrate_to_qdrant.py --reset
|
||||||
|
```
|
||||||
|
|
||||||
|
### Probar Búsqueda Semántica
|
||||||
|
```bash
|
||||||
|
# Búsqueda semántica
|
||||||
|
curl "http://localhost:8001/api/search?q=protestas+en+iran&semantic=true"
|
||||||
|
|
||||||
|
# Búsqueda tradicional (fallback)
|
||||||
|
curl "http://localhost:8001/api/search?q=protestas+en+iran&semantic=false"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Verificar Estado
|
||||||
|
|
||||||
|
### Base de Datos
|
||||||
|
```sql
|
||||||
|
-- Progreso de vectorización
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE vectorized = TRUE) as vectorizadas,
|
||||||
|
COUNT(*) FILTER (WHERE vectorized = FALSE) as pendientes
|
||||||
|
FROM traducciones
|
||||||
|
WHERE lang_to = 'es' AND status = 'done';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qdrant API
|
||||||
|
```bash
|
||||||
|
# Estado de la colección
|
||||||
|
curl http://localhost:6333/collections/news_vectors
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:6333/healthz
|
||||||
|
|
||||||
|
# Conteo de puntos
|
||||||
|
curl http://localhost:6333/collections/news_vectors | jq '.result.points_count'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Variables de Entorno
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
QDRANT_HOST=qdrant
|
||||||
|
QDRANT_PORT=6333
|
||||||
|
QDRANT_COLLECTION_NAME=news_vectors
|
||||||
|
QDRANT_BATCH_SIZE=100
|
||||||
|
QDRANT_SLEEP_IDLE=30
|
||||||
|
EMB_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Archivos Relevantes
|
||||||
|
|
||||||
|
| Archivo | Función |
|
||||||
|
|---------|---------|
|
||||||
|
| `workers/qdrant_worker.py` | Worker de vectorización continua |
|
||||||
|
| `utils/qdrant_search.py` | **NUEVO**: API de búsqueda semántica |
|
||||||
|
| `routers/search.py` | **ACTUALIZADO**: Buscador con Qdrant |
|
||||||
|
| `routers/conflicts.py` | **ACTUALIZADO**: Monitor de conflictos con Qdrant |
|
||||||
|
| `scripts/migrate_to_qdrant.py` | Migración/estadísticas |
|
||||||
|
| `docker-compose.yml` | Configuración de servicios + timezone sync |
|
||||||
|
|
||||||
|
## ⏰ Sincronización de Hora
|
||||||
|
|
||||||
|
**Problema Resuelto:** Todos los contenedores Docker ahora tienen la hora sincronizada (TZ=Europe/Madrid).
|
||||||
|
|
||||||
|
**Cambios:**
|
||||||
|
- ✅ Variable `TZ=Europe/Madrid` en todos los servicios
|
||||||
|
- ✅ Volúmenes `/etc/timezone` y `/etc/localtime` en servicios clave
|
||||||
|
- ✅ Logs consistentes entre todos los workers
|
||||||
|
|
||||||
|
## 🚀 Despliegue en Nuevas Máquinas
|
||||||
|
|
||||||
|
### Requisitos Previos
|
||||||
|
1. Docker y Docker Compose instalados
|
||||||
|
2. Al menos 8GB RAM (recomendado 16GB)
|
||||||
|
3. GPU NVIDIA (opcional, para workers de traducción)
|
||||||
|
|
||||||
|
### Pasos de Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clonar repositorio
|
||||||
|
git clone <repo-url>
|
||||||
|
cd rss2
|
||||||
|
|
||||||
|
# 2. Configurar variables de entorno
|
||||||
|
cp .env.example .env
|
||||||
|
# Editar .env con tus credenciales
|
||||||
|
|
||||||
|
# 3. Iniciar servicios
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 4. Verificar que Qdrant está funcionando
|
||||||
|
curl http://localhost:6333/healthz
|
||||||
|
|
||||||
|
# 5. Monitorear vectorización
|
||||||
|
docker-compose logs -f qdrant-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migración de Datos Existentes
|
||||||
|
|
||||||
|
Si ya tienes noticias traducidas sin vectorizar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver estadísticas
|
||||||
|
docker exec -it rss2_web python scripts/migrate_to_qdrant.py --stats
|
||||||
|
|
||||||
|
# Vectorizar todas las pendientes (puede tardar horas con 500k noticias)
|
||||||
|
# El worker lo hace automáticamente, pero puedes forzarlo:
|
||||||
|
docker-compose restart qdrant-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### La búsqueda no usa Qdrant
|
||||||
|
```bash
|
||||||
|
# Verificar que Qdrant está corriendo
|
||||||
|
docker ps | grep qdrant
|
||||||
|
|
||||||
|
# Ver logs del worker
|
||||||
|
docker-compose logs qdrant-worker
|
||||||
|
|
||||||
|
# Verificar conexión
|
||||||
|
curl http://localhost:6333/collections/news_vectors
|
||||||
|
```
|
||||||
|
|
||||||
|
### Búsqueda lenta aún
|
||||||
|
```bash
|
||||||
|
# Verificar cuántas noticias están vectorizadas
|
||||||
|
docker exec -it rss2_web python scripts/migrate_to_qdrant.py --stats
|
||||||
|
|
||||||
|
# Si hay muchas pendientes, el worker las procesará automáticamente
|
||||||
|
# Para acelerar, aumenta el batch size en docker-compose.yml:
|
||||||
|
# QDRANT_BATCH_SIZE=200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error "No module named 'qdrant_client'"
|
||||||
|
```bash
|
||||||
|
# Reconstruir imagen web
|
||||||
|
docker-compose build rss2_web
|
||||||
|
docker-compose restart rss2_web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Rendimiento
|
||||||
|
|
||||||
|
**Antes (PostgreSQL solo):**
|
||||||
|
- Búsqueda simple: 2-5 segundos (500k filas)
|
||||||
|
- Búsqueda compleja: 10-30 segundos
|
||||||
|
- Monitor de conflictos: 5-15 segundos
|
||||||
|
|
||||||
|
**Ahora (Qdrant + PostgreSQL):**
|
||||||
|
- Búsqueda semántica: 50-200ms
|
||||||
|
- Enriquecimiento PostgreSQL: +50ms
|
||||||
|
- Monitor de conflictos: 100-300ms
|
||||||
|
- **Mejora: 10-100x más rápido** ⚡
|
||||||
|
|
||||||
|
## 🎯 Próximos Pasos
|
||||||
|
|
||||||
|
- [ ] Implementar filtros avanzados (fecha, país, categoría)
|
||||||
|
- [ ] Cachear resultados frecuentes en Redis
|
||||||
|
- [ ] Agregar búsqueda híbrida (combinar PostgreSQL FTS + Qdrant)
|
||||||
|
- [ ] Dashboard de métricas de búsqueda
|
||||||
155
README.md
Normal file
155
README.md
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
# RSS2 - Plataforma de Inteligencia de Noticias con IA
|
||||||
|
|
||||||
|
RSS2 es una plataforma avanzada de agregación, traducción y análisis de noticias diseñada para procesar grandes volúmenes de información en tiempo real. Utiliza una arquitectura de **microservicios híbrida (Go + Python/FastAPI)** y modelos de **Inteligencia Artificial** locales para transformar flujos RSS crudos en inteligencia accionable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Arquitectura y Servicios
|
||||||
|
|
||||||
|
El sistema se compone de múltiples contenedores Docker orquestados, divididos en 3 redes aisladas (`frontend`, `backend`, `monitoring`) para máxima seguridad.
|
||||||
|
|
||||||
|
### 🌐 Core & Frontend
|
||||||
|
| Servicio | Tecnología | Puerto | Descripción |
|
||||||
|
|----------|------------|--------|-------------|
|
||||||
|
| **`nginx`** | Nginx Alpine | **8001** | **Único punto de entrada público**. Reverse proxy, SSL, estáticos. |
|
||||||
|
| **`rss2_web`** | Python/FastAPI | - | Backend principal. API REST, Jinja2, lógica de negocio. |
|
||||||
|
| **`rss-web-go`** | Go/Gin | - | (Opcional) Microservicio web de alto rendimiento. |
|
||||||
|
|
||||||
|
### 🤖 Inteligencia Artificial & Workers
|
||||||
|
| Servicio | Descripción | Recursos |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| **`translator`** (x3) | Traducción Neural (NLLB-200) de cualquier idioma a Español. | GPU/CPU |
|
||||||
|
| **`embeddings`** | Generación de vectores semánticos para búsqueda inteligente. | GPU/CPU |
|
||||||
|
| **`ner`** | Reconocimiento de Entidades (Personas, Org, Lugares). | CPU |
|
||||||
|
| **`cluster`** | Agrupación de noticias por eventos similares. | CPU |
|
||||||
|
| **`topics`** | Clasificación temática de noticias. | CPU |
|
||||||
|
| **`qdrant-worker`** | Sincronización de vectores con Qdrant. | CPU |
|
||||||
|
|
||||||
|
### 📥 Ingesta de Datos
|
||||||
|
| Servicio | Descripción |
|
||||||
|
|----------|-------------|
|
||||||
|
| **`rss-ingestor-go`** | Crawler de alto rendimiento en Go. Descarga cientos de RSS/min. |
|
||||||
|
| **`url-worker`** | Scraper que descarga y limpia el contenido completo (HTML) de las noticias. |
|
||||||
|
| **`url-discovery`** | Descubrimiento automático de nuevos feeds RSS. |
|
||||||
|
|
||||||
|
### 💾 Almacenamiento de Datos
|
||||||
|
| Servicio | Tecnología | Descripción |
|
||||||
|
|----------|------------|-------------|
|
||||||
|
| **`db`** | PostgreSQL 18 | Base de datos principal (Escritura). Contraseñas fuertes. |
|
||||||
|
| **`db-replica`** | PostgreSQL 18 | Réplica de lectura (Actualmente en standby). |
|
||||||
|
| **`qdrant`** | Qdrant | **Base de datos vectorial**. Almacena embeddings para búsqueda semántica. |
|
||||||
|
| **`redis`** | Redis 7 | Broker de mensajes y caché. **Autenticado**. |
|
||||||
|
|
||||||
|
### 📊 Monitorización (Stack de Observabilidad)
|
||||||
|
| Servicio | Acceso | Descripción |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| **`grafana`** | `localhost:3001` | Dashboard visual de métricas del sistema y contenedores. |
|
||||||
|
| **`prometheus`** | *Interno* | Recolección de métricas de todos los servicios. |
|
||||||
|
| **`cadvisor`** | *Interno* | Métricas de uso de recursos de Docker (CPU, RAM). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Instalación y Despliegue
|
||||||
|
|
||||||
|
### 1. Clonar y Configurar
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd rss2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Generación de Credenciales Seguras (IMPORTANTE)
|
||||||
|
El sistema incluye un script para generar contraseñas fuertes automáticamente:
|
||||||
|
```bash
|
||||||
|
./generate_secure_credentials.sh
|
||||||
|
```
|
||||||
|
Esto creará un archivo `.env` con contraseñas aleatorias para DB, Redis y Grafana.
|
||||||
|
|
||||||
|
### 3. Iniciar Servicios
|
||||||
|
El despliegue se gestiona con el script maestro seguro:
|
||||||
|
```bash
|
||||||
|
./migrate_to_secure.sh
|
||||||
|
```
|
||||||
|
O manualmente:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Acceso
|
||||||
|
* **Web Pública**: [http://localhost:8001](http://localhost:8001)
|
||||||
|
* **Grafana (Monitorización)**: [http://localhost:3001](http://localhost:3001)
|
||||||
|
* *Usuario*: `admin`
|
||||||
|
* *Password*: (Ver archivo `.env` variable `GRAFANA_PASSWORD` o el output del generador)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Seguridad
|
||||||
|
|
||||||
|
El sistema ha sido auditado y fortificado (Enero 2026):
|
||||||
|
|
||||||
|
1. **Redes Segmentadas**:
|
||||||
|
* `rss2_frontend`: Solo Nginx y Web App.
|
||||||
|
* `rss2_backend`: Base de datos y Workers (sin acceso externo).
|
||||||
|
* `rss2_monitoring`: Stack de observabilidad.
|
||||||
|
|
||||||
|
2. **Puertos Cerrados**:
|
||||||
|
* Qdrant (6333), Prometheus (9090), Redis (6379) NO están expuestos al host.
|
||||||
|
* Solo puerto **8001** (Web) y **3001** (Grafana/Local) están abiertos.
|
||||||
|
|
||||||
|
3. **Autenticación**:
|
||||||
|
* Redis requiere contraseña (`requirepass`).
|
||||||
|
* PostgreSQL usa autenticación estricta.
|
||||||
|
|
||||||
|
4. **Scripts de Seguridad**:
|
||||||
|
* `verify_security.sh`: Ejecuta un test completo de la configuración de seguridad.
|
||||||
|
* `SECURITY_GUIDE.md`: Guía detallada de administración segura.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Comandos de Mantenimiento
|
||||||
|
|
||||||
|
### Verificar estado del sistema
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ver logs
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f rss2_web # Web App
|
||||||
|
docker-compose logs -f translator # Traductor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Copia de Seguridad (Backup)
|
||||||
|
```bash
|
||||||
|
# Backup de Base de Datos
|
||||||
|
docker exec rss2_db pg_dump -U rss rss > backup_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Backup de Vectores (Qdrant)
|
||||||
|
# (Detener servicio antes recomendado)
|
||||||
|
tar -czf qdrant_backup.tar.gz qdrant_storage/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actualización
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Características de IA
|
||||||
|
|
||||||
|
* **Búsqueda Semántica**: Encuentra noticias por significado ("conflictos en oriente medio") incluso si no contienen las palabras exactas, gracias a los embeddings vectoriales en Qdrant.
|
||||||
|
* **Detección de Idioma**: Automática para dirigir al traductor correcto.
|
||||||
|
* **Entidades**: Explorador visual de *quién* y *dónde* en las noticias.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Estructura de Directorios
|
||||||
|
|
||||||
|
* `/routers`: API Endpoints (Python).
|
||||||
|
* `/workers`: Lógica de fondo (Traducción, Ingesta, IA).
|
||||||
|
* `/rss-ingestor-go`: Código del crawler en Go.
|
||||||
|
* `/monitoring`: Configuración de Prometheus y Grafana.
|
||||||
|
* `/templates`: Vistas HTML (Jinja2).
|
||||||
|
* `/static`: Assets frontend.
|
||||||
|
* `docker-compose.yml`: Definición de infraestructura.
|
||||||
201
SECURITY_AUDIT.md
Normal file
201
SECURITY_AUDIT.md
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
# 🔒 Auditoría de Seguridad de Red - Resumen Ejecutivo
|
||||||
|
|
||||||
|
**Fecha**: 2026-01-12
|
||||||
|
**Sistema**: RSS2 News Aggregator
|
||||||
|
**Auditor**: Análisis Automatizado de Seguridad
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 RESUMEN EJECUTIVO
|
||||||
|
|
||||||
|
Se han identificado **múltiples vulnerabilidades críticas** en la configuración de red de los contenedores Docker. El sistema actual expone servicios internos sin autenticación y utiliza credenciales débiles que comprometen severamente la seguridad de la aplicación.
|
||||||
|
|
||||||
|
**Nivel de Riesgo Global**: 🔴 **CRÍTICO**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 VULNERABILIDADES CRÍTICAS (Prioridad 1)
|
||||||
|
|
||||||
|
### 1. Credenciales Comprometidas
|
||||||
|
- **Severidad**: 🔴 CRÍTICA
|
||||||
|
- **CVSS Score**: 9.8 (Critical)
|
||||||
|
- **Descripción**:
|
||||||
|
- PostgreSQL usa password `x` (1 carácter)
|
||||||
|
- Flask SECRET_KEY es `secret` (valor por defecto conocido)
|
||||||
|
- Grafana usa password `admin` (credencial por defecto)
|
||||||
|
- **Impacto**:
|
||||||
|
- Acceso completo a la base de datos
|
||||||
|
- Posible firma de sesiones falsas
|
||||||
|
- Compromiso total del sistema de autenticación
|
||||||
|
- **Solución**: Generar credenciales aleatorias de 32+ caracteres
|
||||||
|
|
||||||
|
### 2. Exposición de Base de Datos Vectorial (Qdrant)
|
||||||
|
- **Severidad**: 🔴 CRÍTICA
|
||||||
|
- **CVSS Score**: 8.6 (High)
|
||||||
|
- **Puertos Expuestos**: 6333, 6334
|
||||||
|
- **Descripción**: Qdrant accesible públicamente sin autenticación
|
||||||
|
- **Impacto**:
|
||||||
|
- Lectura/modificación de vectores de noticias
|
||||||
|
- Potencial exfiltración de datos
|
||||||
|
- Manipulación de búsquedas semánticas
|
||||||
|
- **Solución**: Eliminar exposición de puertos, usar solo red interna
|
||||||
|
|
||||||
|
### 3. Redis Sin Autenticación
|
||||||
|
- **Severidad**: 🔴 ALTA
|
||||||
|
- **CVSS Score**: 7.5 (High)
|
||||||
|
- **Descripción**: Redis accesible sin password
|
||||||
|
- **Impacto**:
|
||||||
|
- Acceso no autorizado a caché
|
||||||
|
- Posible inyección de datos maliciosos
|
||||||
|
- DoS mediante flush de caché
|
||||||
|
- **Solución**: Habilitar requirepass en Redis
|
||||||
|
|
||||||
|
### 4. Exposición de Prometheus y cAdvisor
|
||||||
|
- **Severidad**: 🟠 ALTA
|
||||||
|
- **CVSS Score**: 7.2 (High)
|
||||||
|
- **Puertos Expuestos**: 9090 (Prometheus), 8081 (cAdvisor)
|
||||||
|
- **Descripción**: Métricas del sistema accesibles públicamente
|
||||||
|
- **Impacto**:
|
||||||
|
- Información sensible sobre arquitectura
|
||||||
|
- Vectores de ataque (uptime, recursos, vulnerabilidades)
|
||||||
|
- Reconocimiento de servicios internos
|
||||||
|
- **Solución**: Internalizar puertos, acceso solo via túnel SSH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ VULNERABILIDADES DE RIESGO MEDIO (Prioridad 2)
|
||||||
|
|
||||||
|
### 5. Ausencia de Segmentación de Red
|
||||||
|
- **Severidad**: 🟠 MEDIA
|
||||||
|
- **Descripción**: Todos los servicios en una única red Docker
|
||||||
|
- **Impacto**: Movimiento lateral fácil si un contenedor es comprometido
|
||||||
|
- **Solución**: Implementar 3 redes segmentadas (frontend, backend, monitoring)
|
||||||
|
|
||||||
|
### 6. Sin Límites de Recursos
|
||||||
|
- **Severidad**: 🟡 MEDIA-BAJA
|
||||||
|
- **Descripción**: Contenedores sin límites de CPU/memoria
|
||||||
|
- **Impacto**: Posible DoS por consumo excesivo de recursos
|
||||||
|
- **Solución**: Establecer límites y reservas de recursos
|
||||||
|
|
||||||
|
### 7. Montaje de Volúmenes con Permisos Excesivos
|
||||||
|
- **Severidad**: 🟡 BAJA
|
||||||
|
- **Descripción**: Código fuente montado en read-write
|
||||||
|
- **Impacto**: Modificación de código desde contenedor comprometido
|
||||||
|
- **Solución**: Montar volúmenes críticos en modo read-only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ SOLUCIONES IMPLEMENTADAS
|
||||||
|
|
||||||
|
### Archivos Creados
|
||||||
|
|
||||||
|
1. **`docker-compose.secure.yml`**
|
||||||
|
- Redes segmentadas (frontend, backend, monitoring)
|
||||||
|
- Puertos internalizados
|
||||||
|
- Autenticación en Redis
|
||||||
|
- Límites de recursos en todos los servicios
|
||||||
|
- Volúmenes read-only donde aplica
|
||||||
|
|
||||||
|
2. **`.env.secure.example`**
|
||||||
|
- Template con instrucciones de seguridad
|
||||||
|
- Placeholders para credenciales fuertes
|
||||||
|
|
||||||
|
3. **`generate_secure_credentials.sh`**
|
||||||
|
- Script automatizado para generar credenciales
|
||||||
|
- Genera passwords de 32 caracteres
|
||||||
|
- Crea .env con configuración segura
|
||||||
|
|
||||||
|
4. **`SECURITY_GUIDE.md`**
|
||||||
|
- Guía completa de migración
|
||||||
|
- Troubleshooting
|
||||||
|
- Best practices
|
||||||
|
|
||||||
|
5. **Código Python actualizado**
|
||||||
|
- `config.py`: Soporte para REDIS_PASSWORD
|
||||||
|
- `cache.py`: Autenticación en Redis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 MEJORAS DE SEGURIDAD
|
||||||
|
|
||||||
|
| Métrica | ANTES | DESPUÉS | Mejora |
|
||||||
|
|---------|-------|---------|--------|
|
||||||
|
| Puertos públicos | 7 | 1 | **-85%** |
|
||||||
|
| Servicios con autenticación | 1/4 | 4/4 | **+300%** |
|
||||||
|
| Redes aisladas | 1 | 3 | **+200%** |
|
||||||
|
| Servicios con límites de recursos | 0% | 100% | **+100%** |
|
||||||
|
| Fortaleza de passwords (bits) | ~4 bits | ~256 bits | **+6300%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 PLAN DE ACCIÓN RECOMENDADO
|
||||||
|
|
||||||
|
### Fase 1: INMEDIATO (Hoy)
|
||||||
|
1. ✅ Revisar archivos generados
|
||||||
|
2. ✅ Leer SECURITY_GUIDE.md
|
||||||
|
3. ⏳ Ejecutar `./generate_secure_credentials.sh`
|
||||||
|
4. ⏳ Guardar credenciales en gestor de passwords
|
||||||
|
|
||||||
|
### Fase 2: CORTO PLAZO (Esta semana)
|
||||||
|
5. ⏳ Hacer backup completo de datos
|
||||||
|
6. ⏳ Migrar a `docker-compose.secure.yml`
|
||||||
|
7. ⏳ Validar funcionamiento en desarrollo
|
||||||
|
8. ⏳ Configurar acceso SSH a Grafana
|
||||||
|
|
||||||
|
### Fase 3: MEDIO PLAZO (Este mes)
|
||||||
|
9. ⏳ Implementar monitoreo de seguridad
|
||||||
|
10. ⏳ Configurar backups automáticos encriptados
|
||||||
|
11. ⏳ Implementar rate limiting en nginx
|
||||||
|
12. ⏳ Configurar fail2ban
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 CHECKLIST DE VALIDACIÓN
|
||||||
|
|
||||||
|
Antes de marcar como resuelto, verificar:
|
||||||
|
|
||||||
|
- [ ] Todas las credenciales cambiadas y guardadas
|
||||||
|
- [ ] Solo puerto 8001 expuesto públicamente
|
||||||
|
- [ ] Qdrant NO accesible desde internet
|
||||||
|
- [ ] Prometheus NO accesible desde internet
|
||||||
|
- [ ] cAdvisor NO accesible desde internet
|
||||||
|
- [ ] Redis requiere autenticación
|
||||||
|
- [ ] Grafana solo en localhost (127.0.0.1:3001)
|
||||||
|
- [ ] Web app funciona correctamente
|
||||||
|
- [ ] Workers se conectan a servicios
|
||||||
|
- [ ] Búsqueda funciona
|
||||||
|
- [ ] Backups configurados
|
||||||
|
- [ ] Firewall del servidor activo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 REFERENCIAS
|
||||||
|
|
||||||
|
- [Docker Security Best Practices](https://docs.docker.com/develop/security-best-practices/)
|
||||||
|
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||||
|
- [CIS Docker Benchmark](https://www.cisecurity.org/benchmark/docker)
|
||||||
|
- [NIST Cybersecurity Framework](https://www.nist.gov/cyberframework)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 CONTACTO Y SOPORTE
|
||||||
|
|
||||||
|
Para asistencia con la migración:
|
||||||
|
- Revisar `SECURITY_GUIDE.md` (troubleshooting completo)
|
||||||
|
- Verificar logs: `docker-compose logs -f`
|
||||||
|
- Verificar conectividad de redes: `docker network inspect rss2_backend`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Última actualización**: 2026-01-12 18:18 CET
|
||||||
|
**Próxima revisión recomendada**: 2026-02-12 (mensual)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏆 CONCLUSIÓN
|
||||||
|
|
||||||
|
La implementación de las soluciones propuestas reducirá el riesgo de seguridad de **CRÍTICO a BAJO**, cerrando todas las vulnerabilidades identificadas y estableciendo una base sólida de seguridad para la aplicación RSS2.
|
||||||
|
|
||||||
|
**Tiempo estimado de implementación**: 2-4 horas
|
||||||
|
**Complejidad**: Media
|
||||||
|
**ROI de seguridad**: Extremadamente Alto
|
||||||
383
SECURITY_GUIDE.md
Normal file
383
SECURITY_GUIDE.md
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
# 🔒 GUÍA DE SEGURIDAD Y MIGRACIÓN - RSS2 Application
|
||||||
|
|
||||||
|
## ⚠️ RESUMEN DE VULNERABILIDADES ENCONTRADAS
|
||||||
|
|
||||||
|
### CRÍTICAS (Arreglar INMEDIATAMENTE)
|
||||||
|
|
||||||
|
1. **Credenciales débiles en .env**
|
||||||
|
- PostgreSQL password: `x`
|
||||||
|
- Flask SECRET_KEY: `secret`
|
||||||
|
- Grafana password: `admin`
|
||||||
|
|
||||||
|
2. **Servicios expuestos públicamente sin autenticación**
|
||||||
|
- Qdrant (puertos 6333, 6334) - Base de datos vectorial
|
||||||
|
- Prometheus (puerto 9090) - Métricas del sistema
|
||||||
|
- cAdvisor (puerto 8081) - Estadísticas de contenedores
|
||||||
|
|
||||||
|
3. **Redis sin autenticación**
|
||||||
|
- Accesible por todos los contenedores sin password
|
||||||
|
|
||||||
|
### ALTO RIESGO
|
||||||
|
|
||||||
|
4. **Ausencia de segmentación de red**
|
||||||
|
- Todos los servicios en una única red Docker
|
||||||
|
|
||||||
|
5. **Ausencia de límites de recursos**
|
||||||
|
- Contenedores sin límites de CPU/memoria (riesgo de DoS)
|
||||||
|
|
||||||
|
6. **Volúmenes con permisos excesivos**
|
||||||
|
- Código fuente montado con permisos de escritura
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ SOLUCIONES IMPLEMENTADAS
|
||||||
|
|
||||||
|
### 1. Archivo `docker-compose.secure.yml`
|
||||||
|
|
||||||
|
**Mejoras de seguridad implementadas:**
|
||||||
|
|
||||||
|
#### 🔹 Redes Segmentadas
|
||||||
|
```yaml
|
||||||
|
networks:
|
||||||
|
frontend: # Solo nginx y rss2_web
|
||||||
|
backend: # BD, workers, redis, qdrant (interna)
|
||||||
|
monitoring: # Prometheus, Grafana, cAdvisor (interna)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 🔹 Puertos Internalizados
|
||||||
|
- ❌ **Eliminados puertos públicos de:**
|
||||||
|
- Qdrant (6333, 6334) → Solo acceso interno
|
||||||
|
- Prometheus (9090) → Solo acceso interno
|
||||||
|
- cAdvisor (8081) → Solo acceso interno
|
||||||
|
- rss-web-go (8002) → Servicio comentado (duplicado)
|
||||||
|
|
||||||
|
- ✅ **Único puerto público:**
|
||||||
|
- Nginx (8001) → Proxy reverso con seguridad
|
||||||
|
|
||||||
|
- ✅ **Puerto localhost únicamente:**
|
||||||
|
- Grafana (127.0.0.1:3001) → Acceso solo local o via túnel SSH
|
||||||
|
|
||||||
|
#### 🔹 Autenticación en Redis
|
||||||
|
```yaml
|
||||||
|
redis:
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--requirepass ${REDIS_PASSWORD}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 🔹 Límites de Recursos
|
||||||
|
Todos los contenedores tienen límites de CPU y memoria:
|
||||||
|
```yaml
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 2G
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 🔹 Volúmenes Read-Only
|
||||||
|
Código fuente montado en modo lectura donde sea posible:
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./app.py:/app/app.py:ro
|
||||||
|
- ./routers:/app/routers:ro
|
||||||
|
- ./templates:/app/templates:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PASOS DE MIGRACIÓN
|
||||||
|
|
||||||
|
### Opción A: Migración Gradual (RECOMENDADO para producción)
|
||||||
|
|
||||||
|
#### Paso 1: Generar Credenciales Seguras
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Generar password para PostgreSQL
|
||||||
|
POSTGRES_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD"
|
||||||
|
|
||||||
|
# 2. Generar password para Redis
|
||||||
|
REDIS_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
echo "REDIS_PASSWORD=$REDIS_PASSWORD"
|
||||||
|
|
||||||
|
# 3. Generar SECRET_KEY para Flask
|
||||||
|
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
echo "SECRET_KEY=$SECRET_KEY"
|
||||||
|
|
||||||
|
# 4. Generar password para Grafana
|
||||||
|
GRAFANA_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
echo "GRAFANA_PASSWORD=$GRAFANA_PASSWORD"
|
||||||
|
|
||||||
|
# Guardar estos valores en un lugar seguro (gestor de passwords)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Paso 2: Copiar y Configurar .env Seguro
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copiar el ejemplo seguro
|
||||||
|
cp .env.secure.example .env
|
||||||
|
|
||||||
|
# Editar .env y pegar las contraseñas generadas
|
||||||
|
nano .env # o usa tu editor preferido
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Paso 3: Backup de Datos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup de PostgreSQL
|
||||||
|
docker exec rss2_db pg_dump -U rss rss > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
# Backup de Qdrant
|
||||||
|
tar -czf qdrant_backup_$(date +%Y%m%d_%H%M%S).tar.gz qdrant_storage/
|
||||||
|
|
||||||
|
# Backup de Redis (opcional)
|
||||||
|
docker exec rss2_redis redis-cli --rdb /data/dump.rdb
|
||||||
|
cp redis-data/dump.rdb redis_backup_$(date +%Y%m%d_%H%M%S).rdb
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Paso 4: Detener Servicios Actuales
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Paso 5: Migrar a Configuración Segura
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Renombrar archivo actual (backup)
|
||||||
|
mv docker-compose.yml docker-compose.yml.insecure.bak
|
||||||
|
|
||||||
|
# Usar la versión segura
|
||||||
|
cp docker-compose.secure.yml docker-compose.yml
|
||||||
|
|
||||||
|
# Verificar configuración
|
||||||
|
docker-compose config
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Paso 6: Iniciar con Nueva Configuración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Iniciar servicios
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Verificar logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Verificar que todos los contenedores están corriendo
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Paso 7: Verificar Conectividad
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test web app
|
||||||
|
curl http://localhost:8001
|
||||||
|
|
||||||
|
# Test Redis (desde dentro de un contenedor)
|
||||||
|
docker exec rss2_web bash -c 'python3 -c "import redis; r = redis.Redis(host=\"redis\", port=6379, password=\"$REDIS_PASSWORD\"); print(r.ping())"'
|
||||||
|
|
||||||
|
# Verificar logs de workers
|
||||||
|
docker-compose logs rss2_tasks_py | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Opción B: Migración Directa (Para desarrollo/testing)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backup de datos (como en Opción A, Paso 3)
|
||||||
|
|
||||||
|
# 2. Detener todo
|
||||||
|
docker-compose down -v # CUIDADO: -v elimina volúmenes
|
||||||
|
|
||||||
|
# 3. Generar credenciales y configurar .env
|
||||||
|
cp .env.secure.example .env
|
||||||
|
# Editar .env con credenciales generadas
|
||||||
|
|
||||||
|
# 4. Restaurar datos si es necesario
|
||||||
|
# (Restaurar dump SQL, qdrant_storage, etc.)
|
||||||
|
|
||||||
|
# 5. Iniciar con configuración segura
|
||||||
|
cp docker-compose.secure.yml docker-compose.yml
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 ACCESO A SERVICIOS PROTEGIDOS
|
||||||
|
|
||||||
|
### Grafana (Monitoring)
|
||||||
|
|
||||||
|
Ahora solo accesible en localhost. Para acceso remoto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Opción 1: Túnel SSH (RECOMENDADO)
|
||||||
|
ssh -L 3001:localhost:3001 usuario@servidor
|
||||||
|
|
||||||
|
# Luego acceder en tu navegador local:
|
||||||
|
# http://localhost:3001
|
||||||
|
# Usuario: admin
|
||||||
|
# Password: El que configuraste en GRAFANA_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qdrant (Base de Datos Vectorial)
|
||||||
|
|
||||||
|
Ya no es accesible públicamente. Para acceso de desarrollo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Opción 1: Temporalmente exponer puerto (SOLO para debug)
|
||||||
|
# Editar docker-compose.yml y descomentar:
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:6333:6333"
|
||||||
|
|
||||||
|
# Opción 2: Acceder desde dentro de la red Docker
|
||||||
|
docker exec -it rss2_qdrant_worker bash
|
||||||
|
curl http://qdrant:6333/collections
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prometheus (Métricas)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Acceso via túnel SSH
|
||||||
|
ssh -L 9090:localhost:9090 usuario@servidor
|
||||||
|
|
||||||
|
# O exponer temporalmente en localhost:
|
||||||
|
# En docker-compose.yml, prometheus service:
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:9090:9090"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 TESTING DE SEGURIDAD
|
||||||
|
|
||||||
|
### Verificar que puertos NO son accesibles públicamente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desde FUERA del servidor (desde tu máquina local)
|
||||||
|
|
||||||
|
# Estos NO deberían responder:
|
||||||
|
curl http://servidor:6333 # Qdrant - debe fallar
|
||||||
|
curl http://servidor:9090 # Prometheus - debe fallar
|
||||||
|
curl http://servidor:8081 # cAdvisor - debe fallar
|
||||||
|
|
||||||
|
# Este SÍ debe responder:
|
||||||
|
curl http://servidor:8001 # Nginx - debe funcionar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verificar segmentación de redes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Los contenedores NO deberían poder acceder a servicios fuera de su red
|
||||||
|
|
||||||
|
# Desde un worker backend, NO debe alcanzar nginx:
|
||||||
|
docker exec rss2_cluster_py curl http://nginx # Debería fallar
|
||||||
|
|
||||||
|
# Desde monitoring, NO debe alcanzar db:
|
||||||
|
docker exec rss2_prometheus curl http://db:5432 # Debería fallar
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 COMPARATIVA: ANTES vs DESPUÉS
|
||||||
|
|
||||||
|
| Aspecto | ANTES (Inseguro) | DESPUÉS (Seguro) |
|
||||||
|
|---------|------------------|------------------|
|
||||||
|
| **Puertos expuestos** | 7 puertos públicos | 1 puerto público + 1 localhost |
|
||||||
|
| **Autenticación Redis** | ❌ Sin password | ✅ Autenticado |
|
||||||
|
| **PostgreSQL Password** | `x` (débil) | 32+ caracteres aleatorios |
|
||||||
|
| **Flask SECRET_KEY** | `secret` | 64 caracteres hex |
|
||||||
|
| **Segmentación de red** | ❌ Una red única | ✅ 3 redes aisladas |
|
||||||
|
| **Límites de recursos** | ❌ Sin límites | ✅ CPU y RAM limitados |
|
||||||
|
| **Volúmenes** | Read-Write | Read-Only donde posible |
|
||||||
|
| **Qdrant público** | ⚠️ Sí (puerto 6333) | ✅ Solo interno |
|
||||||
|
| **Prometheus público** | ⚠️ Sí (puerto 9090) | ✅ Solo interno |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 CHECKLIST FINAL DE SEGURIDAD
|
||||||
|
|
||||||
|
Antes de poner en producción, verifica:
|
||||||
|
|
||||||
|
- [ ] Todas las contraseñas generadas aleatoriamente (min 32 caracteres)
|
||||||
|
- [ ] Archivo `.env` NO está en el repositorio (revisar .gitignore)
|
||||||
|
- [ ] Solo puerto 8001 expuesto públicamente
|
||||||
|
- [ ] Grafana accesible solo en localhost
|
||||||
|
- [ ] Redis requiere autenticación
|
||||||
|
- [ ] Todos los workers pueden conectarse a Redis
|
||||||
|
- [ ] Todos los workers pueden conectarse a PostgreSQL
|
||||||
|
- [ ] Web app funciona correctamente
|
||||||
|
- [ ] Búsqueda semántica (Qdrant) funciona
|
||||||
|
- [ ] Backups automáticos configurados
|
||||||
|
- [ ] Monitoring (Grafana) accessible via SSH tunnel
|
||||||
|
- [ ] Firewall del servidor configurado (solo permitir 8001, 22)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 TROUBLESHOOTING
|
||||||
|
|
||||||
|
### Error: Redis authentication failed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar que REDIS_PASSWORD está en .env
|
||||||
|
grep REDIS_PASSWORD .env
|
||||||
|
|
||||||
|
# Verificar que los workers tienen la variable
|
||||||
|
docker exec rss2_web env | grep REDIS
|
||||||
|
|
||||||
|
# Reiniciar servicios
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: No puedo acceder a Grafana desde mi máquina
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Asegurarte de que el túnel SSH está activo
|
||||||
|
ssh -L 3001:localhost:3001 usuario@servidor
|
||||||
|
|
||||||
|
# Verificar que Grafana está corriendo
|
||||||
|
docker-compose ps | grep grafana
|
||||||
|
|
||||||
|
# Logs de Grafana
|
||||||
|
docker-compose logs grafana
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workers no pueden conectarse a Qdrant
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar que Qdrant está en la red backend
|
||||||
|
docker network inspect rss2_backend | grep qdrant
|
||||||
|
|
||||||
|
# Verificar logs de Qdrant
|
||||||
|
docker-compose logs qdrant
|
||||||
|
|
||||||
|
# Test de conectividad desde un worker
|
||||||
|
docker exec rss2_qdrant_worker curl http://qdrant:6333/collections
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 RECURSOS ADICIONALES
|
||||||
|
|
||||||
|
- [Docker Networks Security Best Practices](https://docs.docker.com/network/network-tutorial-standalone/)
|
||||||
|
- [Redis Security](https://redis.io/docs/management/security/)
|
||||||
|
- [PostgreSQL Security](https://www.postgresql.org/docs/current/security.html)
|
||||||
|
- [OWASP Docker Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 NOTAS IMPORTANTES
|
||||||
|
|
||||||
|
1. **Backup Regular**: Configura backups automáticos ANTES de migrar
|
||||||
|
2. **Testing**: Prueba en un entorno de desarrollo primero
|
||||||
|
3. **Downtime**: Planifica una ventana de mantenimiento
|
||||||
|
4. **Monitoring**: Verifica que Grafana funciona después de migrar
|
||||||
|
5. **Documentation**: Documenta las contraseñas en un gestor seguro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Última actualización**: 2026-01-12
|
||||||
|
**Autor**: Auditoría de Seguridad Automatizada
|
||||||
65
app.py
Normal file
65
app.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from config import SECRET_KEY
|
||||||
|
from utils import safe_html, format_date, country_flag
|
||||||
|
|
||||||
|
from routers.home import home_bp
|
||||||
|
from routers.feeds import feeds_bp
|
||||||
|
from routers.urls import urls_bp
|
||||||
|
from routers.noticia import noticia_bp
|
||||||
|
from routers.backup import backup_bp
|
||||||
|
# from routers.eventos import eventos_bp
|
||||||
|
from routers.config import config_bp
|
||||||
|
from routers.favoritos import favoritos_bp
|
||||||
|
from routers.search import search_bp
|
||||||
|
from routers.rss import rss_bp
|
||||||
|
from routers.resumen import resumen_bp
|
||||||
|
from routers.stats import stats_bp
|
||||||
|
from routers.pdf import pdf_bp
|
||||||
|
from routers.notifications import notifications_bp
|
||||||
|
from routers.auth import auth_bp
|
||||||
|
from routers.account import account_bp
|
||||||
|
from routers.parrillas import parrillas_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["SECRET_KEY"] = SECRET_KEY
|
||||||
|
|
||||||
|
app.jinja_env.filters["safe_html"] = safe_html
|
||||||
|
app.jinja_env.filters["format_date"] = format_date
|
||||||
|
app.jinja_env.filters["country_flag"] = country_flag
|
||||||
|
|
||||||
|
app.register_blueprint(home_bp)
|
||||||
|
app.register_blueprint(feeds_bp)
|
||||||
|
app.register_blueprint(urls_bp)
|
||||||
|
app.register_blueprint(noticia_bp)
|
||||||
|
app.register_blueprint(backup_bp)
|
||||||
|
# app.register_blueprint(eventos_bp) # Removed
|
||||||
|
app.register_blueprint(config_bp)
|
||||||
|
app.register_blueprint(favoritos_bp)
|
||||||
|
app.register_blueprint(search_bp)
|
||||||
|
app.register_blueprint(rss_bp)
|
||||||
|
# app.register_blueprint(resumen_bp) # Removed
|
||||||
|
app.register_blueprint(stats_bp)
|
||||||
|
app.register_blueprint(pdf_bp)
|
||||||
|
app.register_blueprint(notifications_bp)
|
||||||
|
|
||||||
|
from routers.conflicts import conflicts_bp
|
||||||
|
from routers.topics import topics_bp
|
||||||
|
|
||||||
|
app.register_blueprint(conflicts_bp)
|
||||||
|
app.register_blueprint(topics_bp)
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(account_bp)
|
||||||
|
app.register_blueprint(parrillas_bp)
|
||||||
|
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8001, debug=True)
|
||||||
|
|
||||||
177
cache.py
Normal file
177
cache.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""
|
||||||
|
Redis cache module for high-traffic endpoints.
|
||||||
|
Provides caching decorator and invalidation utilities.
|
||||||
|
"""
|
||||||
|
import redis
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
from functools import wraps
|
||||||
|
from config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_TTL_DEFAULT
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_redis_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis():
|
||||||
|
"""Get Redis client singleton."""
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is None:
|
||||||
|
try:
|
||||||
|
redis_config = {
|
||||||
|
'host': REDIS_HOST,
|
||||||
|
'port': REDIS_PORT,
|
||||||
|
'decode_responses': True,
|
||||||
|
'socket_connect_timeout': 2,
|
||||||
|
'socket_timeout': 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agregar autenticación si está configurada
|
||||||
|
if REDIS_PASSWORD:
|
||||||
|
redis_config['password'] = REDIS_PASSWORD
|
||||||
|
|
||||||
|
_redis_client = redis.Redis(**redis_config)
|
||||||
|
_redis_client.ping()
|
||||||
|
except redis.ConnectionError as e:
|
||||||
|
logger.warning(f"Redis connection failed: {e}. Caching disabled.")
|
||||||
|
_redis_client = None
|
||||||
|
return _redis_client
|
||||||
|
|
||||||
|
|
||||||
|
def cached(ttl_seconds=None, prefix="cache"):
|
||||||
|
"""
|
||||||
|
Decorator for caching function results in Redis.
|
||||||
|
Falls back to calling function directly if Redis is unavailable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttl_seconds: Time to live in seconds (default from config)
|
||||||
|
prefix: Key prefix for cache entries
|
||||||
|
"""
|
||||||
|
if ttl_seconds is None:
|
||||||
|
ttl_seconds = REDIS_TTL_DEFAULT
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
r = get_redis()
|
||||||
|
if r is None:
|
||||||
|
# Redis unavailable, call function directly
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Build cache key from function name and arguments
|
||||||
|
# Use md5 for deterministic hash across processes
|
||||||
|
key_data = f"{args}:{sorted(kwargs.items())}"
|
||||||
|
|
||||||
|
# Add flask request args if available to prevent collision on filtered routes
|
||||||
|
try:
|
||||||
|
from flask import request
|
||||||
|
if request:
|
||||||
|
key_data += f":args:{sorted(request.args.items())}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
key_hash = hashlib.md5(key_data.encode('utf-8')).hexdigest()
|
||||||
|
cache_key = f"cache:{prefix}:{func.__name__}:{key_hash}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to get from cache
|
||||||
|
cached_value = r.get(cache_key)
|
||||||
|
if cached_value is not None:
|
||||||
|
# If it's a JSON response, we might need to return it correctly
|
||||||
|
try:
|
||||||
|
data = json.loads(cached_value)
|
||||||
|
# Detect if we should return as JSON
|
||||||
|
from flask import jsonify
|
||||||
|
return jsonify(data)
|
||||||
|
except (json.JSONDecodeError, ImportError):
|
||||||
|
return cached_value
|
||||||
|
|
||||||
|
# Cache miss - call function and cache result
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Handle Flask Response objects
|
||||||
|
cache_data = result
|
||||||
|
try:
|
||||||
|
from flask import Response
|
||||||
|
if isinstance(result, Response):
|
||||||
|
if result.is_json:
|
||||||
|
cache_data = result.get_json()
|
||||||
|
else:
|
||||||
|
cache_data = result.get_data(as_text=True)
|
||||||
|
except (ImportError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
r.setex(cache_key, ttl_seconds, json.dumps(cache_data, default=str))
|
||||||
|
return result
|
||||||
|
except (redis.RedisError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning(f"Cache error for {func.__name__}: {e}")
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_pattern(pattern):
|
||||||
|
"""
|
||||||
|
Invalidate all cache keys matching pattern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Pattern to match (e.g., "home:*" or "stats:*")
|
||||||
|
"""
|
||||||
|
r = get_redis()
|
||||||
|
if r is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = 0
|
||||||
|
deleted = 0
|
||||||
|
while True:
|
||||||
|
cursor, keys = r.scan(cursor, match=f"cache:{pattern}", count=100)
|
||||||
|
if keys:
|
||||||
|
r.delete(*keys)
|
||||||
|
deleted += len(keys)
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
if deleted > 0:
|
||||||
|
logger.info(f"Invalidated {deleted} cache keys matching '{pattern}'")
|
||||||
|
except redis.RedisError as e:
|
||||||
|
logger.warning(f"Cache invalidation failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get(key):
|
||||||
|
"""Get value from cache by key."""
|
||||||
|
r = get_redis()
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = r.get(f"cache:{key}")
|
||||||
|
return json.loads(value) if value else None
|
||||||
|
except (redis.RedisError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set(key, value, ttl_seconds=None):
|
||||||
|
"""Set value in cache with optional TTL."""
|
||||||
|
if ttl_seconds is None:
|
||||||
|
ttl_seconds = REDIS_TTL_DEFAULT
|
||||||
|
r = get_redis()
|
||||||
|
if r is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
r.setex(f"cache:{key}", ttl_seconds, json.dumps(value, default=str))
|
||||||
|
return True
|
||||||
|
except redis.RedisError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cache_del(key):
|
||||||
|
"""Delete a key from cache."""
|
||||||
|
r = get_redis()
|
||||||
|
if r is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
r.delete(f"cache:{key}")
|
||||||
|
return True
|
||||||
|
except redis.RedisError:
|
||||||
|
return False
|
||||||
69
config.py
Normal file
69
config.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
DB_CONFIG = {
|
||||||
|
"dbname": os.getenv("DB_NAME", "rss"),
|
||||||
|
"user": os.getenv("DB_USER", "rss"),
|
||||||
|
"password": os.getenv("DB_PASS", ""),
|
||||||
|
"host": os.getenv("DB_HOST", "localhost"),
|
||||||
|
"port": int(os.getenv("DB_PORT", 5432)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write DB (primary) - for workers/ingestion
|
||||||
|
DB_WRITE_CONFIG = {
|
||||||
|
"dbname": os.getenv("DB_NAME", "rss"),
|
||||||
|
"user": os.getenv("DB_USER", "rss"),
|
||||||
|
"password": os.getenv("DB_PASS", ""),
|
||||||
|
"host": os.getenv("DB_WRITE_HOST", os.getenv("DB_HOST", "localhost")),
|
||||||
|
"port": int(os.getenv("DB_PORT", 5432)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read DB (replica) - for web queries
|
||||||
|
DB_READ_CONFIG = {
|
||||||
|
"dbname": os.getenv("DB_NAME", "rss"),
|
||||||
|
"user": os.getenv("DB_USER", "rss"),
|
||||||
|
"password": os.getenv("DB_PASS", ""),
|
||||||
|
"host": os.getenv("DB_READ_HOST", os.getenv("DB_HOST", "localhost")),
|
||||||
|
"port": int(os.getenv("DB_PORT", 5432)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redis Cache
|
||||||
|
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
||||||
|
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
|
||||||
|
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None) # None = sin autenticación (para compatibilidad)
|
||||||
|
REDIS_TTL_DEFAULT = int(os.getenv("REDIS_TTL_DEFAULT", 60))
|
||||||
|
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "CAMBIA_ESTA_CLAVE_POR_ALGO_LARGO_Y_ALEATORIO")
|
||||||
|
|
||||||
|
DEFAULT_LANG = os.getenv("DEFAULT_LANG", "es")
|
||||||
|
DEFAULT_TRANSLATION_LANG = os.getenv("DEFAULT_TRANSLATION_LANG", "es")
|
||||||
|
|
||||||
|
WEB_TRANSLATED_DEFAULT = int(os.getenv("WEB_TRANSLATED_DEFAULT", "1"))
|
||||||
|
# Configuración de paginación
|
||||||
|
NEWS_PER_PAGE_DEFAULT = 30 # Reducido de 50 para mejor rendimiento
|
||||||
|
|
||||||
|
RSS_MAX_WORKERS = int(os.getenv("RSS_MAX_WORKERS", "3")) # Reducido de 10 a 3
|
||||||
|
RSS_FEED_TIMEOUT = int(os.getenv("RSS_FEED_TIMEOUT", "60")) # Aumentado timeout
|
||||||
|
RSS_MAX_FAILURES = int(os.getenv("RSS_MAX_FAILURES", "5"))
|
||||||
|
|
||||||
|
TARGET_LANGS = os.getenv("TARGET_LANGS", "es")
|
||||||
|
|
||||||
|
TRANSLATOR_BATCH = int(os.getenv("TRANSLATOR_BATCH", "2")) # Reducido de 4 a 2
|
||||||
|
ENQUEUE = int(os.getenv("ENQUEUE", "50")) # Reducido de 200 a 50
|
||||||
|
TRANSLATOR_SLEEP_IDLE = float(os.getenv("TRANSLATOR_SLEEP_IDLE", "10")) # Aumentado de 5 a 10
|
||||||
|
|
||||||
|
MAX_SRC_TOKENS = int(os.getenv("MAX_SRC_TOKENS", "512"))
|
||||||
|
MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "256"))
|
||||||
|
|
||||||
|
NUM_BEAMS_TITLE = int(os.getenv("NUM_BEAMS_TITLE", "1")) # Reducido beams para menos CPU
|
||||||
|
NUM_BEAMS_BODY = int(os.getenv("NUM_BEAMS_BODY", "1"))
|
||||||
|
|
||||||
|
UNIVERSAL_MODEL = os.getenv("UNIVERSAL_MODEL", "facebook/nllb-200-1.3B")
|
||||||
|
DEVICE = os.getenv("DEVICE", "cpu")
|
||||||
|
|
||||||
|
TOKENIZERS_PARALLELISM = os.getenv("TOKENIZERS_PARALLELISM", "false")
|
||||||
|
PYTHONUNBUFFERED = os.getenv("PYTHONUNBUFFERED", "1")
|
||||||
|
PYTORCH_CUDA_ALLOC_CONF = os.getenv("PYTORCH_CUDA_ALLOC_CONF", "")
|
||||||
|
|
||||||
76
db.py
Normal file
76
db.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
DB_HOST = os.environ.get("DB_HOST", "db")
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "rss")
|
||||||
|
DB_USER = os.environ.get("DB_USER", "rss")
|
||||||
|
DB_PASS = os.environ.get("DB_PASS", "x")
|
||||||
|
DB_PORT = os.environ.get("DB_PORT", "5432")
|
||||||
|
DB_READ_HOST = os.environ.get("DB_READ_HOST", "db-replica")
|
||||||
|
DB_WRITE_HOST = os.environ.get("DB_WRITE_HOST", "db")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_conn():
|
||||||
|
"""Get a database connection (Default: Primary/Write)."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_HOST,
|
||||||
|
database=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASS,
|
||||||
|
port=DB_PORT
|
||||||
|
)
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_read_conn():
|
||||||
|
"""Get a read-only database connection (Replica)."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
# Attempt to connect to Replica first
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_READ_HOST,
|
||||||
|
database=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASS,
|
||||||
|
port=DB_PORT,
|
||||||
|
connect_timeout=5
|
||||||
|
)
|
||||||
|
except (psycopg2.OperationalError, psycopg2.InterfaceError) as e:
|
||||||
|
# Fallback to Primary if Replica is down on initial connection
|
||||||
|
print(f"Warning: Replica unreachable ({e}), falling back to Primary for read.")
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_WRITE_HOST,
|
||||||
|
database=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASS,
|
||||||
|
port=DB_PORT
|
||||||
|
)
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_write_conn():
|
||||||
|
"""Get a write database connection (Primary)."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_WRITE_HOST,
|
||||||
|
database=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASS,
|
||||||
|
port=DB_PORT
|
||||||
|
)
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
790
docker-compose.yml
Normal file
790
docker-compose.yml
Normal file
|
|
@ -0,0 +1,790 @@
|
||||||
|
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
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"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",
|
||||||
|
# Parallel Query Optimization (Adjusted)
|
||||||
|
"-c",
|
||||||
|
"max_worker_processes=8",
|
||||||
|
"-c",
|
||||||
|
"max_parallel_workers=6",
|
||||||
|
"-c",
|
||||||
|
"max_parallel_workers_per_gather=2",
|
||||||
|
# Streaming Replication
|
||||||
|
"-c",
|
||||||
|
"wal_level=replica",
|
||||||
|
"-c",
|
||||||
|
"max_wal_senders=5",
|
||||||
|
"-c",
|
||||||
|
"wal_keep_size=1GB",
|
||||||
|
"-c",
|
||||||
|
"hot_standby=on"
|
||||||
|
]
|
||||||
|
volumes:
|
||||||
|
- ./pgdata:/var/lib/postgresql/data
|
||||||
|
- ./init-db:/docker-entrypoint-initdb.d:ro
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $$POSTGRES_USER -d $$POSTGRES_DB || exit 1" ]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 20s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
memory: 4G
|
||||||
|
|
||||||
|
db-replica:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.replica
|
||||||
|
container_name: rss2_db_replica
|
||||||
|
shm_size: 2gb
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-rss}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-rss}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
PGDATA: /var/lib/postgresql/data
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
command: [ "postgres", "-c", "max_connections=200", "-c", "shared_buffers=256MB", "-c", "effective_cache_size=2GB", "-c", "hot_standby=on", "-c", "max_worker_processes=16", "-c", "hot_standby_feedback=on", "-c", "max_standby_streaming_delay=300s" ]
|
||||||
|
volumes:
|
||||||
|
- ./pgdata-replica:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U rss -d rss || exit 1" ]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4G
|
||||||
|
reservations:
|
||||||
|
memory: 2G
|
||||||
|
|
||||||
|
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}
|
||||||
|
volumes:
|
||||||
|
- ./redis-data:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
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: 15
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 2G
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
rss-tasks:
|
||||||
|
build: .
|
||||||
|
container_name: rss2_tasks_py
|
||||||
|
command: bash -lc "python -m scheduler"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: ${DB_NAME:-rss}
|
||||||
|
DB_USER: ${DB_USER:-rss}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 1G
|
||||||
|
|
||||||
|
url-worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.url_worker
|
||||||
|
container_name: rss2_url_worker
|
||||||
|
command: bash -lc "python -m workers.url_worker_daemon"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: ${DB_NAME:-rss}
|
||||||
|
DB_USER: ${DB_USER:-rss}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
DB_READ_HOST: db
|
||||||
|
DB_WRITE_HOST: db
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 2G
|
||||||
|
|
||||||
|
url-discovery-worker:
|
||||||
|
build: .
|
||||||
|
container_name: rss2_url_discovery
|
||||||
|
command: bash -lc "python -m workers.url_discovery_worker"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: ${DB_NAME:-rss}
|
||||||
|
DB_USER: ${DB_USER:-rss}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
URL_DISCOVERY_INTERVAL_MIN: 15
|
||||||
|
URL_DISCOVERY_BATCH_SIZE: 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: 1G
|
||||||
|
|
||||||
|
# rss-web-go deshabilitado por duplicar funcionalidad
|
||||||
|
# Si es necesario, habilitar pero SIN exposición de puertos
|
||||||
|
# rss-web-go:
|
||||||
|
# build:
|
||||||
|
# context: ./rss-web-go
|
||||||
|
# dockerfile: Dockerfile
|
||||||
|
# container_name: rss2_web_go
|
||||||
|
# # SEGURIDAD: Sin exposición de puertos - solo acceso interno
|
||||||
|
# # ports:
|
||||||
|
# # - "8002:8001"
|
||||||
|
# environment:
|
||||||
|
# - DB_HOST=db
|
||||||
|
# - DB_PORT=5432
|
||||||
|
# - DB_NAME=${DB_NAME:-rss}
|
||||||
|
# - DB_USER=${DB_USER:-rss}
|
||||||
|
# - DB_PASS=${DB_PASS}
|
||||||
|
# - REDIS_HOST=redis
|
||||||
|
# - REDIS_PORT=6379
|
||||||
|
# - REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||||
|
# - PORT=8001
|
||||||
|
# - TZ=Europe/Madrid
|
||||||
|
# volumes:
|
||||||
|
# - ./static:/root/static:ro
|
||||||
|
# - ./templates:/root/templates:ro
|
||||||
|
# networks:
|
||||||
|
# - backend
|
||||||
|
# depends_on:
|
||||||
|
# db:
|
||||||
|
# condition: service_healthy
|
||||||
|
# restart: unless-stopped
|
||||||
|
|
||||||
|
rss2_web:
|
||||||
|
build: .
|
||||||
|
container_name: rss2_web
|
||||||
|
command: bash -lc "gunicorn --config gunicorn_config.py app:app"
|
||||||
|
volumes:
|
||||||
|
# SEGURIDAD: Código en read-only donde sea posible
|
||||||
|
- ./app.py:/app/app.py:ro
|
||||||
|
- ./routers:/app/routers:ro
|
||||||
|
- ./models:/app/models:ro
|
||||||
|
- ./utils:/app/utils:ro
|
||||||
|
- ./templates:/app/templates:ro
|
||||||
|
- ./static:/app/static:ro
|
||||||
|
- ./config.py:/app/config.py:ro
|
||||||
|
- ./db.py:/app/db.py:ro
|
||||||
|
- ./cache.py:/app/cache.py:ro
|
||||||
|
- ./gunicorn_config.py:/app/gunicorn_config.py:ro
|
||||||
|
# Directorios escribibles
|
||||||
|
- ./hf_cache:/app/hf_cache
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: ${DB_NAME:-rss}
|
||||||
|
DB_USER: ${DB_USER:-rss}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
DB_READ_HOST: db
|
||||||
|
DB_WRITE_HOST: db
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
QDRANT_HOST: qdrant
|
||||||
|
QDRANT_PORT: 6333
|
||||||
|
QDRANT_COLLECTION_NAME: ${QDRANT_COLLECTION_NAME:-news_vectors}
|
||||||
|
EMB_MODEL: ${EMB_MODEL:-sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2}
|
||||||
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
GUNICORN_WORKERS: 8
|
||||||
|
ALLTALK_URL: http://host.docker.internal:7851
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
# db-replica:
|
||||||
|
# condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
qdrant:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '8'
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
memory: 4G
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: rss2_nginx
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
ports:
|
||||||
|
# ÚNICO puerto expuesto públicamente
|
||||||
|
- "8001:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./static:/app/static:ro
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
depends_on:
|
||||||
|
- rss2_web
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
translator:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: rss2-translator:latest
|
||||||
|
container_name: rss2_translator_py
|
||||||
|
command: bash -lc "python -m workers.translation_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
|
||||||
|
ENQUEUE: 300
|
||||||
|
# CTranslate2 configuration
|
||||||
|
CT2_MODEL_PATH: /app/models/nllb-ct2
|
||||||
|
CT2_DEVICE: cuda
|
||||||
|
CT2_COMPUTE_TYPE: int8_float16
|
||||||
|
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
|
||||||
|
HF_HOME: /app/hf_cache
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
volumes:
|
||||||
|
- ./hf_cache:/app/hf_cache
|
||||||
|
- ./models:/app/models
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [ gpu ]
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
translator2:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: rss2-translator2:latest
|
||||||
|
container_name: rss2_translator_py2
|
||||||
|
command: bash -lc "python -m workers.translation_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
|
||||||
|
ENQUEUE: 300
|
||||||
|
CT2_MODEL_PATH: /app/models/nllb-ct2
|
||||||
|
CT2_DEVICE: cuda
|
||||||
|
CT2_COMPUTE_TYPE: int8_float16
|
||||||
|
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
|
||||||
|
HF_HOME: /app/hf_cache
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
volumes:
|
||||||
|
- ./hf_cache:/app/hf_cache
|
||||||
|
- ./models:/app/models
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [ gpu ]
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
translator3:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: rss2-translator3:latest
|
||||||
|
container_name: rss2_translator_py3
|
||||||
|
command: bash -lc "python -m workers.translation_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
|
||||||
|
ENQUEUE: 300
|
||||||
|
CT2_MODEL_PATH: /app/models/nllb-ct2
|
||||||
|
CT2_DEVICE: cuda
|
||||||
|
CT2_COMPUTE_TYPE: int8_float16
|
||||||
|
UNIVERSAL_MODEL: facebook/nllb-200-distilled-600M
|
||||||
|
HF_HOME: /app/hf_cache
|
||||||
|
volumes:
|
||||||
|
- ./hf_cache:/app/hf_cache
|
||||||
|
- ./models:/app/models
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [ gpu ]
|
||||||
|
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:
|
||||||
|
- ./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
|
||||||
|
|
||||||
|
related:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: rss2_related_py
|
||||||
|
command: bash -lc "python -m workers.related_worker"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: ${DB_NAME:-rss}
|
||||||
|
DB_USER: ${DB_USER:-rss}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
RELATED_WINDOW_H: 168
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 1G
|
||||||
|
|
||||||
|
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
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 2G
|
||||||
|
|
||||||
|
ner:
|
||||||
|
build: .
|
||||||
|
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:
|
||||||
|
- ./hf_cache:/app/hf_cache
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 2G
|
||||||
|
|
||||||
|
topics:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: rss2_topics_worker
|
||||||
|
command: bash -lc "python -m workers.topics_worker"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: ${DB_NAME:-rss}
|
||||||
|
DB_USER: ${DB_USER:-rss}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
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
|
||||||
|
# SEGURIDAD: Puertos NO expuestos - solo acceso interno
|
||||||
|
# ports:
|
||||||
|
# - "6333:6333"
|
||||||
|
# - "6334:6334"
|
||||||
|
volumes:
|
||||||
|
- ./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:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: rss2_qdrant_worker
|
||||||
|
command: bash -lc "python -m workers.qdrant_worker"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: ${DB_NAME:-rss}
|
||||||
|
DB_USER: ${DB_USER:-rss}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
DB_READ_HOST: db
|
||||||
|
DB_WRITE_HOST: db
|
||||||
|
QDRANT_HOST: qdrant
|
||||||
|
QDRANT_PORT: 6333
|
||||||
|
QDRANT_COLLECTION_NAME: ${QDRANT_COLLECTION_NAME:-news_vectors}
|
||||||
|
EMB_MODEL: ${EMB_MODEL:-sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2}
|
||||||
|
EMB_DEVICE: cpu
|
||||||
|
QDRANT_BATCH_SIZE: ${QDRANT_BATCH_SIZE:-100}
|
||||||
|
QDRANT_SLEEP_IDLE: ${QDRANT_SLEEP_IDLE:-30}
|
||||||
|
HF_HOME: /app/hf_cache
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
volumes:
|
||||||
|
- ./hf_cache:/app/hf_cache
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
# db-replica:
|
||||||
|
# condition: service_healthy
|
||||||
|
qdrant:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 4G
|
||||||
|
|
||||||
|
# ==================================================================================
|
||||||
|
# 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:
|
||||||
391
docs/FEED_DISCOVERY.md
Normal file
391
docs/FEED_DISCOVERY.md
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
# Sistema de Descubrimiento y Gestión de Feeds RSS
|
||||||
|
|
||||||
|
Este documento describe el sistema mejorado de descubrimiento automático y gestión de feeds RSS implementado en RSS2.
|
||||||
|
|
||||||
|
## 📋 Visión General
|
||||||
|
|
||||||
|
El sistema ahora incluye dos mecanismos para gestionar feeds RSS:
|
||||||
|
|
||||||
|
1. **Gestión Manual Mejorada**: Interfaz web para descubrir y añadir feeds desde cualquier URL
|
||||||
|
2. **Worker Automático**: Proceso en segundo plano que descubre feeds desde URLs almacenadas
|
||||||
|
|
||||||
|
## 🎯 Componentes del Sistema
|
||||||
|
|
||||||
|
### 1. Utilidad de Descubrimiento (`utils/feed_discovery.py`)
|
||||||
|
|
||||||
|
Módulo Python que proporciona funciones para:
|
||||||
|
|
||||||
|
- **`discover_feeds(url)`**: Descubre automáticamente todos los feeds RSS/Atom desde una URL
|
||||||
|
- **`validate_feed(feed_url)`**: Valida un feed y extrae su información básica
|
||||||
|
- **`get_feed_metadata(feed_url)`**: Obtiene metadatos detallados de un feed
|
||||||
|
|
||||||
|
```python
|
||||||
|
from utils.feed_discovery import discover_feeds
|
||||||
|
|
||||||
|
# Descubrir feeds desde una URL
|
||||||
|
feeds = discover_feeds('https://elpais.com')
|
||||||
|
# Retorna: [{'url': '...', 'title': '...', 'valid': True, ...}, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Router de Feeds Mejorado (`routers/feeds.py`)
|
||||||
|
|
||||||
|
Nuevos endpoints añadidos:
|
||||||
|
|
||||||
|
#### Interfaz Web
|
||||||
|
- **`GET/POST /feeds/discover`**: Interfaz para descubrir feeds desde una URL
|
||||||
|
- Muestra todos los feeds encontrados
|
||||||
|
- Permite seleccionar cuáles añadir
|
||||||
|
- Aplica configuración global (categoría, país, idioma)
|
||||||
|
|
||||||
|
- **`POST /feeds/discover_and_add`**: Añade múltiples feeds seleccionados
|
||||||
|
- Extrae automáticamente título y descripción
|
||||||
|
- Evita duplicados
|
||||||
|
- Muestra estadísticas de feeds añadidos
|
||||||
|
|
||||||
|
#### API JSON
|
||||||
|
- **`POST /feeds/api/discover`**: API para descubrir feeds
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Retorna:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"feeds": [...],
|
||||||
|
"count": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`POST /feeds/api/validate`**: API para validar un feed específico
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://example.com/rss"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Worker de Descubrimiento (`workers/url_discovery_worker.py`)
|
||||||
|
|
||||||
|
Worker automático que:
|
||||||
|
|
||||||
|
1. **Procesa URLs de la tabla `fuentes_url`**
|
||||||
|
- Prioriza URLs nunca procesadas
|
||||||
|
- Reintenta URLs con errores
|
||||||
|
- Actualiza URLs antiguas
|
||||||
|
|
||||||
|
2. **Descubre y Crea Feeds Automáticamente**
|
||||||
|
- Encuentra todos los feeds RSS en cada URL
|
||||||
|
- Valida cada feed encontrado
|
||||||
|
- Crea entradas en la tabla `feeds`
|
||||||
|
- Evita duplicados
|
||||||
|
|
||||||
|
3. **Gestión de Estado**
|
||||||
|
- Actualiza `last_check`, `last_status`, `status_message`
|
||||||
|
- Maneja errores gracefully
|
||||||
|
- Registra estadísticas detalladas
|
||||||
|
|
||||||
|
#### Configuración del Worker
|
||||||
|
|
||||||
|
Variables de entorno:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Intervalo de ejecución (minutos)
|
||||||
|
URL_DISCOVERY_INTERVAL_MIN=15
|
||||||
|
|
||||||
|
# Número de URLs a procesar por ciclo
|
||||||
|
URL_DISCOVERY_BATCH_SIZE=10
|
||||||
|
|
||||||
|
# Máximo de feeds a crear por URL
|
||||||
|
MAX_FEEDS_PER_URL=5
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Estados de URLs en `fuentes_url`
|
||||||
|
|
||||||
|
| Estado | Descripción |
|
||||||
|
|--------|-------------|
|
||||||
|
| `success` | Feeds creados exitosamente |
|
||||||
|
| `existing` | Feeds encontrados pero ya existían |
|
||||||
|
| `no_feeds` | No se encontraron feeds RSS |
|
||||||
|
| `no_valid_feeds` | Se encontraron feeds pero ninguno válido |
|
||||||
|
| `error` | Error al procesar la URL |
|
||||||
|
|
||||||
|
## 🚀 Uso del Sistema
|
||||||
|
|
||||||
|
### Método 1: Interfaz Web Manual
|
||||||
|
|
||||||
|
1. **Navega a `/feeds/discover`**
|
||||||
|
2. **Ingresa una URL** (ej: `https://elpais.com`)
|
||||||
|
3. **Haz clic en "Buscar Feeds"**
|
||||||
|
4. El sistema mostrará todos los feeds encontrados con:
|
||||||
|
- Estado de validación
|
||||||
|
- Título y descripción
|
||||||
|
- Número de entradas
|
||||||
|
- Tipo de feed (RSS/Atom)
|
||||||
|
5. **Configura opciones globales**:
|
||||||
|
- Categoría
|
||||||
|
- País
|
||||||
|
- Idioma
|
||||||
|
6. **Selecciona los feeds deseados** y haz clic en "Añadir Feeds Seleccionados"
|
||||||
|
|
||||||
|
### Método 2: Worker Automático
|
||||||
|
|
||||||
|
1. **Añade URLs a la tabla `fuentes_url`**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO fuentes_url (nombre, url, categoria_id, pais_id, idioma, active)
|
||||||
|
VALUES ('El País', 'https://elpais.com', 1, 1, 'es', TRUE);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **El worker procesará automáticamente**:
|
||||||
|
- Cada 15 minutos (configurable)
|
||||||
|
- Descubrirá todos los feeds
|
||||||
|
- Creará entradas en `feeds`
|
||||||
|
- Actualizará el estado
|
||||||
|
|
||||||
|
3. **Monitorea el progreso**:
|
||||||
|
```sql
|
||||||
|
SELECT nombre, url, last_check, last_status, status_message
|
||||||
|
FROM fuentes_url
|
||||||
|
ORDER BY last_check DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Método 3: Interfaz de URLs (Existente)
|
||||||
|
|
||||||
|
Usa la interfaz web existente en `/urls/add_source` para añadir URLs que serán procesadas por el worker.
|
||||||
|
|
||||||
|
## 🔄 Flujo de Trabajo Completo
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Usuario añade │
|
||||||
|
│ URL del sitio │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ URL guardada en │
|
||||||
|
│ tabla fuentes_url │
|
||||||
|
└────────┬────────────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Worker ejecuta cada │
|
||||||
|
│ 15 minutos │
|
||||||
|
└────────┬────────────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Descubre feeds RSS │
|
||||||
|
│ usando feedfinder2 │
|
||||||
|
└────────┬────────────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Valida cada feed │
|
||||||
|
│ encontrado │
|
||||||
|
└────────┬────────────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Crea entradas en │
|
||||||
|
│ tabla feeds │
|
||||||
|
└────────┬────────────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Ingestor Go procesa │
|
||||||
|
│ feeds cada 15 minutos │
|
||||||
|
└────────┬────────────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Noticias descargadas │
|
||||||
|
│ y procesadas │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Tablas de Base de Datos
|
||||||
|
|
||||||
|
### `fuentes_url`
|
||||||
|
Almacena URLs de sitios web para descubrimiento automático:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE fuentes_url (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
nombre VARCHAR(255) NOT NULL,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
categoria_id INTEGER REFERENCES categorias(id),
|
||||||
|
pais_id INTEGER REFERENCES paises(id),
|
||||||
|
idioma CHAR(2) DEFAULT 'es',
|
||||||
|
last_check TIMESTAMP,
|
||||||
|
last_status VARCHAR(50),
|
||||||
|
status_message TEXT,
|
||||||
|
last_http_code INTEGER,
|
||||||
|
active BOOLEAN DEFAULT TRUE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `feeds`
|
||||||
|
Almacena feeds RSS descubiertos y validados:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE feeds (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
nombre VARCHAR(255),
|
||||||
|
descripcion TEXT,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
categoria_id INTEGER REFERENCES categorias(id),
|
||||||
|
pais_id INTEGER REFERENCES paises(id),
|
||||||
|
idioma CHAR(2),
|
||||||
|
activo BOOLEAN DEFAULT TRUE,
|
||||||
|
fallos INTEGER DEFAULT 0,
|
||||||
|
last_etag TEXT,
|
||||||
|
last_modified TEXT,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Configuración del Sistema
|
||||||
|
|
||||||
|
### Variables de Entorno
|
||||||
|
|
||||||
|
Añade al archivo `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# RSS Ingestor
|
||||||
|
RSS_POKE_INTERVAL_MIN=15 # Intervalo de ingesta (minutos)
|
||||||
|
RSS_MAX_FAILURES=10 # Fallos máximos antes de desactivar feed
|
||||||
|
RSS_FEED_TIMEOUT=60 # Timeout para descargar feeds (segundos)
|
||||||
|
|
||||||
|
# URL Discovery Worker
|
||||||
|
URL_DISCOVERY_INTERVAL_MIN=15 # Intervalo de descubrimiento (minutos)
|
||||||
|
URL_DISCOVERY_BATCH_SIZE=10 # URLs a procesar por ciclo
|
||||||
|
MAX_FEEDS_PER_URL=5 # Máximo de feeds por URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
El worker ya está configurado en `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url-discovery-worker:
|
||||||
|
build: .
|
||||||
|
container_name: rss2_url_discovery
|
||||||
|
command: bash -lc "python -m workers.url_discovery_worker"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
URL_DISCOVERY_INTERVAL_MIN: 15
|
||||||
|
URL_DISCOVERY_BATCH_SIZE: 10
|
||||||
|
MAX_FEEDS_PER_URL: 5
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Comandos Útiles
|
||||||
|
|
||||||
|
### Ver logs del worker de descubrimiento
|
||||||
|
```bash
|
||||||
|
docker logs -f rss2_url_discovery
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reiniciar el worker
|
||||||
|
```bash
|
||||||
|
docker restart rss2_url_discovery
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejecutar manualmente el worker (testing)
|
||||||
|
```bash
|
||||||
|
docker exec -it rss2_url_discovery python -m workers.url_discovery_worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ver estadísticas de descubrimiento
|
||||||
|
```sql
|
||||||
|
-- URLs procesadas recientemente
|
||||||
|
SELECT nombre, url, last_check, last_status, status_message
|
||||||
|
FROM fuentes_url
|
||||||
|
WHERE last_check > NOW() - INTERVAL '1 day'
|
||||||
|
ORDER BY last_check DESC;
|
||||||
|
|
||||||
|
-- Feeds creados recientemente
|
||||||
|
SELECT nombre, url, fecha_creacion
|
||||||
|
FROM feeds
|
||||||
|
WHERE fecha_creacion > NOW() - INTERVAL '1 day'
|
||||||
|
ORDER BY fecha_creacion DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Troubleshooting
|
||||||
|
|
||||||
|
### El worker no encuentra feeds
|
||||||
|
|
||||||
|
1. Verifica que la URL sea accesible:
|
||||||
|
```bash
|
||||||
|
curl -I https://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verifica los logs del worker:
|
||||||
|
```bash
|
||||||
|
docker logs rss2_url_discovery
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Prueba manualmente el descubrimiento:
|
||||||
|
```python
|
||||||
|
from utils.feed_discovery import discover_feeds
|
||||||
|
feeds = discover_feeds('https://example.com')
|
||||||
|
print(feeds)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feeds duplicados
|
||||||
|
|
||||||
|
El sistema previene duplicados usando `ON CONFLICT (url) DO NOTHING`. Si un feed ya existe, simplemente se omite.
|
||||||
|
|
||||||
|
### Worker consume muchos recursos
|
||||||
|
|
||||||
|
Ajusta las configuraciones:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reduce el batch size
|
||||||
|
URL_DISCOVERY_BATCH_SIZE=5
|
||||||
|
|
||||||
|
# Aumenta el intervalo
|
||||||
|
URL_DISCOVERY_INTERVAL_MIN=30
|
||||||
|
|
||||||
|
# Reduce feeds por URL
|
||||||
|
MAX_FEEDS_PER_URL=3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Mejores Prácticas
|
||||||
|
|
||||||
|
1. **Añade URLs de sitios, no feeds directos**
|
||||||
|
- ✅ `https://elpais.com`
|
||||||
|
- ❌ `https://elpais.com/rss/feed.xml`
|
||||||
|
|
||||||
|
2. **Configura categoría y país correctamente**
|
||||||
|
- Facilita la organización
|
||||||
|
- Mejora la experiencia del usuario
|
||||||
|
|
||||||
|
3. **Monitorea el estado de las URLs**
|
||||||
|
- Revisa periódicamente `fuentes_url`
|
||||||
|
- Desactiva URLs que fallan consistentemente
|
||||||
|
|
||||||
|
4. **Limita el número de feeds por URL**
|
||||||
|
- Evita sobrecarga de feeds similares
|
||||||
|
- Mantén `MAX_FEEDS_PER_URL` entre 3-5
|
||||||
|
|
||||||
|
## 🎉 Ventajas del Sistema
|
||||||
|
|
||||||
|
✅ **Automatización completa**: Solo añade URLs, el sistema hace el resto
|
||||||
|
✅ **Descubrimiento inteligente**: Encuentra todos los feeds disponibles
|
||||||
|
✅ **Validación automática**: Solo crea feeds válidos y funcionales
|
||||||
|
✅ **Sin duplicados**: Gestión inteligente de feeds existentes
|
||||||
|
✅ **Escalable**: Procesa múltiples URLs en lotes
|
||||||
|
✅ **Resiliente**: Manejo robusto de errores y reintentos
|
||||||
|
✅ **Monitoreable**: Logs detallados y estados claros
|
||||||
|
|
||||||
|
## 📚 Referencias
|
||||||
|
|
||||||
|
- **feedfinder2**: https://github.com/dfm/feedfinder2
|
||||||
|
- **feedparser**: https://feedparser.readthedocs.io/
|
||||||
|
- **Tabla fuentes_url**: `/init-db/01.schema.sql`
|
||||||
|
- **Worker**: `/workers/url_discovery_worker.py`
|
||||||
|
- **Utilidades**: `/utils/feed_discovery.py`
|
||||||
223
docs/PARRILLAS_VIDEOS.md
Normal file
223
docs/PARRILLAS_VIDEOS.md
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
# 🎬 Sistema de Parrillas de Videos Automatizados
|
||||||
|
|
||||||
|
## 📋 Descripción
|
||||||
|
|
||||||
|
Este sistema permite generar videos automáticos de noticias filtradas según diferentes criterios:
|
||||||
|
- **Por País**: Noticias de Bulgaria, España, Estados Unidos, etc.
|
||||||
|
- **Por Categoría**: Ciencia, Tecnología, Deport, Política, etc.
|
||||||
|
- **Por Entidad**: Personas u organizaciones específicas (ej: "Donald Trump", "OpenAI")
|
||||||
|
- **Por Continente**: Europa, Asia, América, etc.
|
||||||
|
|
||||||
|
## 🎯 Características
|
||||||
|
|
||||||
|
### ✅ Sistema Implementado
|
||||||
|
|
||||||
|
1. **Base de Datos**
|
||||||
|
- Tabla `video_parrillas`: Configuraciones de parrillas
|
||||||
|
- Tabla `video_generados`: Registro de videos creados
|
||||||
|
- Tabla `video_noticias`: Relación entre videos y noticias
|
||||||
|
|
||||||
|
2. **API REST**
|
||||||
|
- `GET /parrillas/` - Listado de parrillas
|
||||||
|
- `GET /parrillas/<id>` - Detalle de parrilla
|
||||||
|
- `POST /parrillas/nueva` - Crear parrilla
|
||||||
|
- `GET /parrillas/api/<id>/preview` - Preview de noticias
|
||||||
|
- `POST /parrillas/api/<id>/generar` - Generar video
|
||||||
|
- `POST /parrillas/api/<id>/toggle` - Activar/desactivar
|
||||||
|
- `DELETE /parrillas/api/<id>` - Eliminar parrilla
|
||||||
|
|
||||||
|
3. **Generador de Videos**
|
||||||
|
- Script: `generar_videos_noticias.py`
|
||||||
|
- Integración con AllTalk TTS
|
||||||
|
- Generación automática de subtítulos SRT
|
||||||
|
- Soporte para múltiples idiomas
|
||||||
|
|
||||||
|
## 🚀 Uso Rápido
|
||||||
|
|
||||||
|
### 1. Crear una Parrilla
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Acceder a la interfaz web
|
||||||
|
http://localhost:8001/parrillas/
|
||||||
|
|
||||||
|
# O usar SQL directo
|
||||||
|
docker-compose exec -T db psql -U rss -d rss -c "
|
||||||
|
INSERT INTO video_parrillas (nombre, descripcion, tipo_filtro, pais_id, max_noticias, frecuencia, activo)
|
||||||
|
VALUES ('Noticias de Bulgaria', 'Resumen diario de noticias de Bulgaria', 'pais',
|
||||||
|
(SELECT id FROM paises WHERE nombre = 'Bulgaria'), 5, 'daily', true);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Generar Video Manualmente
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generar video para parrilla con ID 1
|
||||||
|
docker-compose exec web python generar_videos_noticias.py 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Generación Automática (Diaria)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Procesar todas las parrillas activas con frecuencia 'daily'
|
||||||
|
docker-compose exec web python generar_videos_noticias.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Ejemplos de Parrillas
|
||||||
|
|
||||||
|
### Ejemplo 1: Noticias de Ciencia en Europa
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO video_parrillas (
|
||||||
|
nombre, descripcion, tipo_filtro,
|
||||||
|
categoria_id, continente_id,
|
||||||
|
max_noticias, duracion_maxima, idioma_voz,
|
||||||
|
frecuencia, activo
|
||||||
|
) VALUES (
|
||||||
|
'Ciencia en Europa',
|
||||||
|
'Las últimas noticias científicas de Europa',
|
||||||
|
'categoria',
|
||||||
|
(SELECT id FROM categorias WHERE nombre ILIKE '%ciencia%'),
|
||||||
|
(SELECT id FROM continentes WHERE nombre = 'Europa'),
|
||||||
|
7, 300, 'es',
|
||||||
|
'daily', true
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo 2: Noticias sobre una Persona
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO video_parrillas (
|
||||||
|
nombre, descripcion, tipo_filtro,
|
||||||
|
entidad_nombre, entidad_tipo,
|
||||||
|
max_noticias, idioma_voz,
|
||||||
|
frecuencia, activo
|
||||||
|
) VALUES (
|
||||||
|
'Donald Trump en las Noticias',
|
||||||
|
'Todas las menciones de Donald Trump',
|
||||||
|
'entidad',
|
||||||
|
'Donald Trump', 'persona',
|
||||||
|
10, 'es',
|
||||||
|
'daily', true
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo 3: Noticias de Tecnología
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO video_parrillas (
|
||||||
|
nombre, descripcion, tipo_filtro,
|
||||||
|
categoria_id,
|
||||||
|
max_noticias, idioma_voz,
|
||||||
|
include_subtitles, template,
|
||||||
|
frecuencia, activo
|
||||||
|
) VALUES (
|
||||||
|
'Tech News Daily',
|
||||||
|
'Resumen diario de tecnología',
|
||||||
|
'categoria',
|
||||||
|
(SELECT id FROM categorias WHERE nombre ILIKE '%tecnolog%'),
|
||||||
|
8, 'es',
|
||||||
|
true, 'modern',
|
||||||
|
'daily', true
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuración Avanzada
|
||||||
|
|
||||||
|
### Opciones de Parrilla
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `nombre` | string | Nombre único de la parrilla |
|
||||||
|
| `descripcion` | text | Descripción detallada |
|
||||||
|
| `tipo_filtro` | enum | 'pais', 'categoria', 'entidad', 'continente', 'custom' |
|
||||||
|
| `pais_id` | int | ID del país (si tipo_filtro='pais') |
|
||||||
|
| `categoria_id` | int | ID de categoría |
|
||||||
|
| `continente_id` | int | ID de continente |
|
||||||
|
| `entidad_nombre` | string | Nombre de persona/organización |
|
||||||
|
| `entidad_tipo` | string | 'persona' o 'organizacion' |
|
||||||
|
| `max_noticias` | int | Máximo de noticias por video (default: 5) |
|
||||||
|
| `duracion_maxima` | int | Duración máxima en segundos (default: 180) |
|
||||||
|
| `idioma_voz` | string | Idioma del TTS ('es', 'en', etc.) |
|
||||||
|
| `template` | string | 'standard', 'modern', 'minimal' |
|
||||||
|
| `include_images` | bool | Incluir imágenes en el video |
|
||||||
|
| `include_subtitles` | bool | Generar subtítulos SRT |
|
||||||
|
| `frecuencia` | string | 'daily', 'weekly', 'manual' |
|
||||||
|
|
||||||
|
### Configuración de AllTalk
|
||||||
|
|
||||||
|
El sistema utiliza AllTalk para generar la narración con voz. Configurar en docker-compose.yml:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
ALLTALK_URL: http://alltalk:7851
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Estructura de Archivos Generados
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
videos/
|
||||||
|
<video_id>/
|
||||||
|
script.txt # Libreto completo del video
|
||||||
|
audio.wav # Audio generado con TTS
|
||||||
|
subtitles.srt # Subtítulos (si enabled)
|
||||||
|
metadata.json # Metadata del video
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Workflow de Generación
|
||||||
|
|
||||||
|
1. **Consulta de Noticias**: Filtra noticias según criterios de la parrilla
|
||||||
|
2. **Construcción de Script**: Genera libreto narrativo
|
||||||
|
3. **Síntesis de Voz**: Envía texto a AllTalk TTS
|
||||||
|
4. **Generación de Subtítulos**: Crea archivo SRT con timestamps
|
||||||
|
5. **Registro en BD**: Guarda paths y metadata en `video_generados`
|
||||||
|
6. **Relación de Noticias**: Vincula noticias incluidas en `video_noticias`
|
||||||
|
|
||||||
|
## 🎨 Próximas Mejoras
|
||||||
|
|
||||||
|
- [ ] Integración con generador de videos (combinar audio + imágenes)
|
||||||
|
- [ ] Templates visuales personalizados
|
||||||
|
- [ ] Transiciones entre noticias
|
||||||
|
- [ ] Música de fondo
|
||||||
|
- [ ] Logo/branding personalizado
|
||||||
|
- [ ] Exportación directa a YouTube/TikTok
|
||||||
|
- [ ] Programación automática con cron
|
||||||
|
- [ ] Dashboard de analíticas de videos
|
||||||
|
- [ ] Sistema de thumbnails automáticos
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Error: "No hay noticias disponibles"
|
||||||
|
- Verificar que existan noticias traducidas (`traducciones.status = 'done'`)
|
||||||
|
- Ajustar filtros de la parrilla
|
||||||
|
- Verificar rango de fechas (por defecto últimas 24h)
|
||||||
|
|
||||||
|
### Error en AllTalk TTS
|
||||||
|
- Verificar que el servicio AllTalk esté corriendo
|
||||||
|
- Revisar URL en variable de entorno `ALLTALK_URL`
|
||||||
|
- Comprobar logs: `docker-compose logs alltalk`
|
||||||
|
|
||||||
|
### Video no se genera
|
||||||
|
- Revisar estado en tabla `video_generados`
|
||||||
|
- Ver columna `error_message` si `status = 'error'`
|
||||||
|
- Verificar permisos en directorio `/app/data/videos`
|
||||||
|
|
||||||
|
## 📞 Soporte
|
||||||
|
|
||||||
|
Para problemas o sugerencias, revisar los logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs del generador
|
||||||
|
docker-compose exec web python generar_videos_noticias.py <id> 2>&1 | tee video_generation.log
|
||||||
|
|
||||||
|
# Ver videos en cola
|
||||||
|
docker-compose exec -T db psql -U rss -d rss -c "
|
||||||
|
SELECT id, parrilla_id, titulo, status, fecha_generacion
|
||||||
|
FROM video_generados
|
||||||
|
ORDER BY fecha_generacion DESC LIMIT 10;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 Licencia
|
||||||
|
|
||||||
|
Este módulo es parte del sistema RSS2 News Aggregator.
|
||||||
426
docs/PROCESO_COMPLETO_FEEDS.md
Normal file
426
docs/PROCESO_COMPLETO_FEEDS.md
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
# 📖 PROCESO COMPLETO: Descubrimiento y Gestión de Feeds RSS
|
||||||
|
|
||||||
|
## 🎯 Problema Resuelto
|
||||||
|
|
||||||
|
**Pregunta:** ¿Cómo asigno país y categoría a los feeds descubiertos automáticamente?
|
||||||
|
|
||||||
|
**Respuesta:** El sistema ahora usa un flujo inteligente de 3 niveles:
|
||||||
|
|
||||||
|
1. **Auto-aprobación** (feeds con categoría/país)
|
||||||
|
2. **Revisión manual** (feeds sin metadata completa)
|
||||||
|
3. **Análisis automático** (sugerencias inteligentes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 FLUJO COMPLETO DEL SISTEMA
|
||||||
|
|
||||||
|
### Paso 1: Añadir URL Fuente
|
||||||
|
|
||||||
|
Tienes 2 opciones para añadir URLs:
|
||||||
|
|
||||||
|
#### Opción A: Con Categoría y País (AUTO-APROBACIÓN)
|
||||||
|
```sql
|
||||||
|
INSERT INTO fuentes_url (nombre, url, categoria_id, pais_id, idioma, active)
|
||||||
|
VALUES ('El País', 'https://elpais.com', 1, 44, 'es', TRUE);
|
||||||
|
-- ^ ^
|
||||||
|
-- categoria_id pais_id
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Resultado**: Feeds se crean **AUTOMÁTICAMENTE** y se activan
|
||||||
|
- Worker descubre feeds
|
||||||
|
- Hereda categoría (1) y país (44) del padre
|
||||||
|
- Crea feeds en tabla `feeds` directam ente
|
||||||
|
- Ingestor empieza a descargar noticias
|
||||||
|
|
||||||
|
#### Opción B: Sin Categoría o País (REQUIERE REVISIÓN)
|
||||||
|
```sql
|
||||||
|
INSERT INTO fuentes_url (nombre, url, active)
|
||||||
|
VALUES ('BBC News', 'https://www.bbc.com/news', TRUE);
|
||||||
|
-- Sin categoria_id ni pais_id
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Resultado**: Feeds van a **REVISIÓN MANUAL**
|
||||||
|
- Worker descubre feeds
|
||||||
|
- Analiza automáticamente:
|
||||||
|
- Detecta país desde dominio (.com → Reino Unido)
|
||||||
|
- Detecta idioma (en)
|
||||||
|
- Sugiere categoría ("Internacional")
|
||||||
|
- Crea feeds en tabla `feeds_pending`
|
||||||
|
- **ESPERA APROBACIÓN MANUAL** antes de activar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paso 2: Worker Descubre Feeds (cada 15 min)
|
||||||
|
|
||||||
|
El worker `url_discovery_worker` ejecuta automaticamente:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Lee fuentes_url activas
|
||||||
|
2. Para cada URL:
|
||||||
|
a. Descubre todos los feeds RSS
|
||||||
|
b. Valida cada feed
|
||||||
|
c. Analiza metadata:
|
||||||
|
- Idioma del feed
|
||||||
|
- País (desde dominio: .es, .uk, .fr, etc.)
|
||||||
|
- Categoría sugerida (keywords en título/descripción)
|
||||||
|
|
||||||
|
d. DECIDE EL FLUJO:
|
||||||
|
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ ¿Parent tiene categoria_id Y pais_id? │
|
||||||
|
└──────────┬──────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────┴────────┐
|
||||||
|
│ SÍ │ NO
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌─────────────────┐
|
||||||
|
│ AUTO-APROBAR │ │ REQUIERE REVISIÓN│
|
||||||
|
└───────┬──────┘ └─────────┬───────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
tabla: feeds tabla: feeds_pending
|
||||||
|
activo: TRUE reviewed: FALSE
|
||||||
|
✅ Listo para ⏳ Espera aprobación
|
||||||
|
ingestor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paso 3A: Feeds AUTO-APROBADOS
|
||||||
|
|
||||||
|
Si la URL padre tiene `categoria_id` y `pais_id`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Ejemplo: URL con metadata completa
|
||||||
|
fuentes_url:
|
||||||
|
id=1, url='https://elpais.com',
|
||||||
|
categoria_id=1 (Noticias),
|
||||||
|
pais_id=44 (España)
|
||||||
|
|
||||||
|
↓ Worker descubre 3 feeds:
|
||||||
|
- https://elpais.com/rss/portada.xml
|
||||||
|
- https://elpais.com/rss/internacional.xml
|
||||||
|
- https://elpais.com/rss/deportes.xml
|
||||||
|
|
||||||
|
↓ Se crean DIRECTAMENTE en tabla feeds:
|
||||||
|
INSERT INTO feeds (nombre, url, categoria_id, pais_id, activo)
|
||||||
|
VALUES
|
||||||
|
('El País - Portada', 'https://elpais.com/rss/portada.xml', 1, 44, TRUE),
|
||||||
|
('El País - Internacional', 'https://elpais.com/rss/internacional.xml', 1, 44, TRUE),
|
||||||
|
('El País - Deportes', 'https://elpais.com/rss/deportes.xml', 1, 44, TRUE);
|
||||||
|
|
||||||
|
✅ Feeds están ACTIVOS inmediatamente
|
||||||
|
✅ Ingestor Go los procesa en siguiente ciclo (15 min)
|
||||||
|
✅ Noticias empiezan a llegar
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paso 3B: Feeds PENDIENTES (requieren revisión)
|
||||||
|
|
||||||
|
Si la URL padre NO tiene `categoria_id` o `pais_id`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Ejemplo: URL sin metadata
|
||||||
|
fuentes_url:
|
||||||
|
id=2, url='https://www.bbc.com/news',
|
||||||
|
categoria_id=NULL,
|
||||||
|
pais_id=NULL
|
||||||
|
|
||||||
|
↓ Worker descubre 2 feeds y ANALIZA automáticamente:
|
||||||
|
|
||||||
|
Feed 1: https://www.bbc.com/news/world/rss.xml
|
||||||
|
- Título: "BBC News - World"
|
||||||
|
- Idioma detectado: 'en'
|
||||||
|
- País detectado: 'Reino Unido' (desde .com + idioma inglés)
|
||||||
|
- Categoría sugerida: 'Internacional' (keyword "world")
|
||||||
|
|
||||||
|
Feed 2: https://www.bbc.com/sport/rss.xml
|
||||||
|
- Título: "BBC Sport"
|
||||||
|
- Idioma detectado: 'en'
|
||||||
|
- País detectado: 'Reino Unido'
|
||||||
|
- Categoría sugerida: 'Deportes' (keyword "sport")
|
||||||
|
|
||||||
|
↓ Se crean en tabla feeds_pending:
|
||||||
|
INSERT INTO feeds_pending (
|
||||||
|
fuente_url_id, feed_url, feed_title,
|
||||||
|
feed_language, detected_country_id, suggested_categoria_id,
|
||||||
|
reviewed, approved, notes
|
||||||
|
) VALUES (
|
||||||
|
2,
|
||||||
|
'https://www.bbc.com/news/world/rss.xml',
|
||||||
|
'BBC News - World',
|
||||||
|
'en',
|
||||||
|
74, -- Reino Unido (ID detectado)
|
||||||
|
2, -- Internacional (ID sugerido)
|
||||||
|
FALSE, FALSE,
|
||||||
|
'Country from domain: Reino Un ido | Suggested category: Internacional (confidence: 85%)'
|
||||||
|
);
|
||||||
|
|
||||||
|
⏳ Feeds están PENDIENTES
|
||||||
|
⏳ NO están activos aún
|
||||||
|
⏳ Requieren revisión manual en /feeds/pending
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Tabla Comparativa
|
||||||
|
|
||||||
|
| Aspecto | Auto-Aprobación | Revisión Manual |
|
||||||
|
|---------|----------------|-----------------|
|
||||||
|
| **Requisito** | URL padre con `categoria_id` Y `pais_id` | URL padre sin uno o ambos |
|
||||||
|
| **Tabla destino** | `feeds` (directa) | `feeds_pending` (temporal) |
|
||||||
|
| **Estado inicial** | `activo = TRUE` | `reviewed = FALSE, approved = FALSE` |
|
||||||
|
| **Análisis automático** | Hereda valores del padre | Detecta país, sugiere categoría |
|
||||||
|
| **Intervención manual** | ❌ No necesaria | ✅ Requerida |
|
||||||
|
| **Tiempo hasta activación** | Inmediato | Después de aprobación |
|
||||||
|
| **Ingestor procesa** | Sí (próximo ciclo) | No (hasta aprobar) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Interfaces de Gestión
|
||||||
|
|
||||||
|
### 1. Añadir URL con Metadata (Auto-aprobación)
|
||||||
|
|
||||||
|
**Ruta:** `/urls/add_source`
|
||||||
|
|
||||||
|
```
|
||||||
|
Formulario:
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Nombre: El País │
|
||||||
|
│ URL: https://elpais.com │
|
||||||
|
│ Categoría: [Noticias ▼] ← IMPORTANTE
|
||||||
|
│ País: [España ▼] ← IMPORTANTE
|
||||||
|
│ Idioma: es │
|
||||||
|
│ │
|
||||||
|
│ [Añadir Fuente] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
|
||||||
|
Resultado: Feeds se crearán AUTOMÁTICAMENTE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Revisar Feeds Pendientes (Nueva interfaz)
|
||||||
|
|
||||||
|
**Ruta:** `/feeds/pending` (próximamente)
|
||||||
|
|
||||||
|
```
|
||||||
|
Feeds Pendientes de Revisión
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Feed: BBC News - World
|
||||||
|
URL: https://www.bbc.com/news/world/rss.xml
|
||||||
|
Fuente: BBC News (https://www.bbc.com/news)
|
||||||
|
|
||||||
|
Análisis Automático:
|
||||||
|
├─ Idioma: English (en)
|
||||||
|
├─ País detectado: Reino Unido (.com domain + language)
|
||||||
|
└─ Categoría sugerida: Internacional (85% confianza)
|
||||||
|
Keywords: "world", "international", "global"
|
||||||
|
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Categoría: [Internacional ▼] │ ← Pre-seleccionada
|
||||||
|
│ País: [Reino Unido ▼] │ ← Pre-seleccionado
|
||||||
|
│ Idioma: [en] │ ← Auto-detectado
|
||||||
|
│ │
|
||||||
|
│ [✓ Aprobar Feed] [✗ Rechazar] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Descubrir Feeds Manualmente
|
||||||
|
|
||||||
|
**Ruta:** `/feeds/discover`
|
||||||
|
|
||||||
|
```
|
||||||
|
Perfecto para cuando quieres control total:
|
||||||
|
1. Ingresar URL
|
||||||
|
2. Ver todos los feeds encontrados
|
||||||
|
3. Seleccionar cuáles añadir
|
||||||
|
4. Asignar categoría/país globalmente
|
||||||
|
5. Feeds se crean directamente (no van a pending)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 RECOMENDACIONES DE USO
|
||||||
|
|
||||||
|
### Estrategia 1: Auto-aprobación Total
|
||||||
|
**Para fuentes conocidas y confiables:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Añadir fuentes con metadata completa
|
||||||
|
INSERT INTO fuentes_url (nombre, url, categoria_id, pais_id, idioma) VALUES
|
||||||
|
('El País', 'https://elpais.com', 1, 44, 'es'),
|
||||||
|
('Le Monde', 'https://lemonde.fr', 1, 60, 'fr'),
|
||||||
|
('The Guardian', 'https://theguardian.com', 1, 74, 'en');
|
||||||
|
|
||||||
|
-- Worker creará feeds automáticamente
|
||||||
|
-- Sin intervención manual necesaria
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrategia 2: Revisión Manual
|
||||||
|
**Para fuentes nuevas o desconocidas:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Añadir sin metadata
|
||||||
|
INSERT INTO fuentes_url (nombre, url) VALUES
|
||||||
|
('Sitio Desconocido', 'https://ejemplo.com');
|
||||||
|
|
||||||
|
-- Worker crea feeds en feeds_pending
|
||||||
|
-- Revisar en /feeds/pending
|
||||||
|
-- Aprobar/rechazar manualmente
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrategia 3: Híbrida (Recomendada)
|
||||||
|
**Combinar ambas:**
|
||||||
|
|
||||||
|
- URLs conocidas → Con categoría/país
|
||||||
|
- URLs nuevas → Sin metadata (revisión)
|
||||||
|
- Usar análisis automático como guía
|
||||||
|
- Ajustar manualmente si es necesario
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Análisis Automático Explicado
|
||||||
|
|
||||||
|
### Detección de País
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. Desde dominio (TLD)
|
||||||
|
.es → España
|
||||||
|
.uk, .co.uk → Reino Unido
|
||||||
|
.fr → Francia
|
||||||
|
.de → Alemania
|
||||||
|
.mx → México
|
||||||
|
.ar → Argentina
|
||||||
|
|
||||||
|
# 2. Desde idioma (si no hay dominio claro)
|
||||||
|
es → España (país principal)
|
||||||
|
en → Reino Unido
|
||||||
|
fr → Francia
|
||||||
|
pt → Portugal
|
||||||
|
|
||||||
|
# 3. Desde subdominios
|
||||||
|
es.example.com → España
|
||||||
|
uk.example.com → Reino Unido
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sugerencia de Categoría
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Análisis de keywords en título + descripción
|
||||||
|
|
||||||
|
Keywords encontrados → Categoría sugerida (% confianza)
|
||||||
|
|
||||||
|
"política", "gobierno", "elecciones" → Política (75%)
|
||||||
|
"economía", "bolsa", "mercado" → Economía (82%)
|
||||||
|
"tecnología", "software", "digital" → Tecnología (90%)
|
||||||
|
"deportes", "fútbol", "liga" → Deportes (95%)
|
||||||
|
"internacional", "mundo", "global" → Internacional (70%)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Ejemplos Completos
|
||||||
|
|
||||||
|
### Ejemplo 1: Periódico Español (Auto-aprobación)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Añadir fuente con metadata
|
||||||
|
INSERT INTO fuentes_url (nombre, url, categoria_id, pais_id, idioma)
|
||||||
|
VALUES ('El Mundo', 'https://elmundo.es', 1, 44, 'es');
|
||||||
|
|
||||||
|
-- 2. Worker ejecuta (15 min después):
|
||||||
|
-- - Descubre: elmundo.es/rss/portada.xml
|
||||||
|
-- - Descubre: elmundo.es/rss/deportes.xml
|
||||||
|
-- - Hereda: categoria_id=1, pais_id=44
|
||||||
|
-- - Crea en feeds directamente
|
||||||
|
|
||||||
|
-- 3. Resultado en tabla feeds:
|
||||||
|
SELECT id, nombre, url, categoria_id, pais_id, activo
|
||||||
|
FROM feeds
|
||||||
|
WHERE fuente_nombre LIKE '%El Mundo%';
|
||||||
|
|
||||||
|
-- id | nombre | url | cat | pais | activo
|
||||||
|
-- 1 | El Mundo - Portada | elmundo.es/rss/portada.xml | 1 | 44 | TRUE
|
||||||
|
-- 2 | El Mundo - Deportes | elmundo.es/rss/deportes.xml | 1 | 44 | TRUE
|
||||||
|
|
||||||
|
-- ✅ Feeds activos, ingestor procesando
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo 2: Sitio Internacional (Revisión Manual)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Añadir fuente SIN metadata
|
||||||
|
INSERT INTO fuentes_url (nombre, url)
|
||||||
|
VALUES ('Reuters', 'https://www.reuters.com');
|
||||||
|
|
||||||
|
-- 2. Worker ejecuta (15 min después):
|
||||||
|
-- - Descubre: reuters.com/rssfeed/worldNews
|
||||||
|
-- - Analiza: idioma=en, país=Reino Unido (dominio+idioma)
|
||||||
|
-- - Sugiere: categoría=Internacional (keyword "world")
|
||||||
|
-- - Crea en feeds_pending
|
||||||
|
|
||||||
|
-- 3. Resultado en tabla feeds_pending:
|
||||||
|
SELECT feed_title, detected_country_id, suggested_categoria_id, notes
|
||||||
|
FROM feeds_pending
|
||||||
|
WHERE fuente_url_id = 3;
|
||||||
|
|
||||||
|
-- feed_title | detected_country_id | suggested_cat | notes
|
||||||
|
-- Reuters World News | 74 (Reino Unido) | 2 (Int.) | "Country from domain..."
|
||||||
|
|
||||||
|
-- ⏳ Requiere aprobación en /feeds/pending
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ CHECKLIST: Añadir Nueva Fuente
|
||||||
|
|
||||||
|
**Para auto-aprobación (recomendado si sabes país/categoría):**
|
||||||
|
|
||||||
|
- [ ] Ir a `/urls/add_source`
|
||||||
|
- [ ] Ingresar nombre descriptivo
|
||||||
|
- [ ] Ingresar URL del sitio (NO del feed RSS)
|
||||||
|
- [ ] **IMPORTANTE:** Seleccionar categoría
|
||||||
|
- [ ] **IMPORTANTE:** Seleccionar país
|
||||||
|
- [ ] Ingresar idioma (opcional, se detecta)
|
||||||
|
- [ ] Guardar
|
||||||
|
- [ ] Esperar 15 minutos (máximo)
|
||||||
|
- [ ] Ver feeds en `/feeds/` (activos automáticamente)
|
||||||
|
|
||||||
|
**Para revisión manual (si no estás seguro):**
|
||||||
|
|
||||||
|
- [ ] Ir a `/urls/add_source`
|
||||||
|
- [ ] Ingresar nombre y URL
|
||||||
|
- [ ] Dejar categoría/país vacíos
|
||||||
|
- [ ] Guardar
|
||||||
|
- [ ] Esperar 15 minutos
|
||||||
|
- [ ] Ir a `/feeds/pending`
|
||||||
|
- [ ] Revisar sugerencias automáticas
|
||||||
|
- [ ] Ajustar categoría/país si necesario
|
||||||
|
- [ ] Aprobar feeds
|
||||||
|
- [ ] Feeds se activan inmediatamente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 Resumen Ejecutivo
|
||||||
|
|
||||||
|
**3 Niveles de Automatización:**
|
||||||
|
|
||||||
|
| Nivel | Descripción | Cuándo Usar |
|
||||||
|
|-------|-------------|-------------|
|
||||||
|
| **Nivel 1: Totalmente Manual** | Descubrir en `/feeds/discover` | Control total, pocas URLs |
|
||||||
|
| **Nivel 2: Auto-aprobación** | URL con cat/país → feeds activos | URLs confiables, muchas fuentes |
|
||||||
|
| **Nivel 3: Revisión Asistida** | URL sin cat/país → análisis → aprobar | URLs nuevas, verificación |
|
||||||
|
|
||||||
|
**Flujo Recomendado:**
|
||||||
|
1. Añade URL con categoría/país si la conoces
|
||||||
|
2. Si no, déjalo vacío y revisa sugerencias automáticas
|
||||||
|
3. Worker descubre y analiza todo automáticamente
|
||||||
|
4. Tú solo apruebas/ajustas lo necesario
|
||||||
|
|
||||||
|
**Resultado:** Gestión eficiente de cientos de fuentes RSS con mínima intervención manual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**📅 Fecha de última actualización:** 2026-01-07
|
||||||
|
**📌 Versión del sistema:** 2.0 - Análisis Inteligente de Feeds
|
||||||
262
entity_config.json
Normal file
262
entity_config.json
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
{
|
||||||
|
"blacklist": [
|
||||||
|
"Indicó",
|
||||||
|
"Insistió",
|
||||||
|
"levier",
|
||||||
|
"Dios",
|
||||||
|
"Elefantes",
|
||||||
|
"AbidjanTV.net",
|
||||||
|
"Gabón",
|
||||||
|
"París",
|
||||||
|
"Bingerville",
|
||||||
|
"Biankouma",
|
||||||
|
"Bouaké",
|
||||||
|
"Cote",
|
||||||
|
"Costa",
|
||||||
|
"Pugh",
|
||||||
|
"Gracias",
|
||||||
|
"Tesla",
|
||||||
|
"Netflix",
|
||||||
|
"Disney",
|
||||||
|
"Ford",
|
||||||
|
"Continúe",
|
||||||
|
"Cenizas",
|
||||||
|
"Victoria",
|
||||||
|
"Fiscalía",
|
||||||
|
"Detalles",
|
||||||
|
"Presiones",
|
||||||
|
"Fuertes",
|
||||||
|
"Industria",
|
||||||
|
"Dios",
|
||||||
|
"Nuestro",
|
||||||
|
"Según",
|
||||||
|
"Finanzas",
|
||||||
|
"Enterate",
|
||||||
|
"Presupuesto",
|
||||||
|
"Adiós",
|
||||||
|
"Quién",
|
||||||
|
"Cuándo",
|
||||||
|
"Hablando",
|
||||||
|
"Términos",
|
||||||
|
"Agregó",
|
||||||
|
"Blockchain",
|
||||||
|
"blockchain",
|
||||||
|
"Bitcoin",
|
||||||
|
"Trono",
|
||||||
|
"Robotaxi",
|
||||||
|
"Penalidades",
|
||||||
|
"Deportes",
|
||||||
|
"Candidato",
|
||||||
|
"Parecía",
|
||||||
|
"Declaraciones",
|
||||||
|
"Había",
|
||||||
|
"Hermanos",
|
||||||
|
"Hombre de Dios",
|
||||||
|
"Creyendo",
|
||||||
|
"Renacimiento",
|
||||||
|
"Cristo",
|
||||||
|
"Señor",
|
||||||
|
"Jefe de Estado",
|
||||||
|
"Educación",
|
||||||
|
"Dice",
|
||||||
|
"Estoy",
|
||||||
|
"Expresaron",
|
||||||
|
"Bondi",
|
||||||
|
"Suspechoso",
|
||||||
|
"Sydney",
|
||||||
|
"Amazon",
|
||||||
|
"L'FC Andorra",
|
||||||
|
"Delanteros",
|
||||||
|
"Lee",
|
||||||
|
"Leé",
|
||||||
|
"Lea",
|
||||||
|
"Presione",
|
||||||
|
"Carrera",
|
||||||
|
"Alejandría",
|
||||||
|
"Javier Romero Communications and Marketing Officer Statistical Institute of Belize jromero@mail.sib.org.bz",
|
||||||
|
"Copyright",
|
||||||
|
"Allah",
|
||||||
|
"Crack",
|
||||||
|
"Akbar",
|
||||||
|
"Getty Images",
|
||||||
|
"Buffalo Bills",
|
||||||
|
"Comandantes",
|
||||||
|
"Gigantes",
|
||||||
|
"Anónimo",
|
||||||
|
"Raadiouudised",
|
||||||
|
"Eswatini",
|
||||||
|
"Stock",
|
||||||
|
"Ethio Telecom",
|
||||||
|
"DPWH",
|
||||||
|
"Obras Públicas",
|
||||||
|
"Malacañang",
|
||||||
|
"Sigue",
|
||||||
|
"Becas",
|
||||||
|
"Crédito",
|
||||||
|
"Claro",
|
||||||
|
"Reaccione",
|
||||||
|
"Indique",
|
||||||
|
"Pakistán",
|
||||||
|
"Tolo News",
|
||||||
|
"Gladbach",
|
||||||
|
"Alger"
|
||||||
|
|
||||||
|
],
|
||||||
|
"synonyms": {
|
||||||
|
"Alassane Ouattara": [
|
||||||
|
"Ouattara",
|
||||||
|
"M. Ouattara",
|
||||||
|
"Presidente Ouattara"
|
||||||
|
],
|
||||||
|
"Alexander Stubb": [
|
||||||
|
"Stubb"
|
||||||
|
],
|
||||||
|
"Brice Clotaire Oligui Nguema": [
|
||||||
|
"Oligui Nguema"
|
||||||
|
],
|
||||||
|
"Dr. Mahamudu Bawumia": [
|
||||||
|
"Dr. Bawumia"
|
||||||
|
],
|
||||||
|
"John Dramani Mahama": [
|
||||||
|
"John Mahama",
|
||||||
|
"Mahama"
|
||||||
|
],
|
||||||
|
"Antoine Semenyo": [
|
||||||
|
"Semenyo"
|
||||||
|
],
|
||||||
|
"Kim Jong Un": [
|
||||||
|
"Kim Jong"
|
||||||
|
],
|
||||||
|
"Daniel Noboa": [
|
||||||
|
"Noboa"
|
||||||
|
],
|
||||||
|
"Donald Trump": [
|
||||||
|
"Trump",
|
||||||
|
"Mr. Trump"
|
||||||
|
],
|
||||||
|
"Nayib Bukele": [
|
||||||
|
"Bukele"
|
||||||
|
],
|
||||||
|
"Peter Pellegrini": [
|
||||||
|
"Pellegrini"
|
||||||
|
],
|
||||||
|
"Robert Fico": [
|
||||||
|
"Fico"
|
||||||
|
],
|
||||||
|
"Vladimir Putin": [
|
||||||
|
"Putin",
|
||||||
|
"V. Putin"
|
||||||
|
],
|
||||||
|
"Emmanuel Macron": [
|
||||||
|
"Macron",
|
||||||
|
"Presidente Macron"
|
||||||
|
],
|
||||||
|
"Pedro Sánchez": [
|
||||||
|
"Sánchez",
|
||||||
|
"Pedro Sanchez"
|
||||||
|
],
|
||||||
|
"Nicolás Maduro": [
|
||||||
|
"Maduro"
|
||||||
|
],
|
||||||
|
"Lula da Silva": [
|
||||||
|
"Lula",
|
||||||
|
"Luiz Inácio Lula da Silva"
|
||||||
|
],
|
||||||
|
"Jeffrey Epstein": [
|
||||||
|
"Epstein"
|
||||||
|
],
|
||||||
|
"José Antonio Kast": [
|
||||||
|
"Kast"
|
||||||
|
],
|
||||||
|
"Jake Paul": [
|
||||||
|
"Paul"
|
||||||
|
],
|
||||||
|
"Lionel Messi": [
|
||||||
|
"Messi",
|
||||||
|
"Lionel Andres Messi",
|
||||||
|
"Leo Messi"
|
||||||
|
],
|
||||||
|
"Luis Caputo": [
|
||||||
|
"Caputo"
|
||||||
|
],
|
||||||
|
"Javier Milei": [
|
||||||
|
"Milei"
|
||||||
|
],
|
||||||
|
"Mia Amor Mottley": [
|
||||||
|
"Mia Mottley",
|
||||||
|
"Mottley"
|
||||||
|
],
|
||||||
|
"Elon Musk": [
|
||||||
|
"Musk"
|
||||||
|
],
|
||||||
|
"Ibrahim Traoré": [
|
||||||
|
"Traoré",
|
||||||
|
"Ibrahim Traore",
|
||||||
|
"Capitán Ibrahim TRAORÉ"
|
||||||
|
],
|
||||||
|
"Friedrich Merz": [
|
||||||
|
"Merz"
|
||||||
|
],
|
||||||
|
"Edi Rama": [
|
||||||
|
"Rama"
|
||||||
|
],
|
||||||
|
"Fabiola Hoxha": [
|
||||||
|
"Hoxha"
|
||||||
|
],
|
||||||
|
"Hugo Broos": [
|
||||||
|
"Broos"
|
||||||
|
],
|
||||||
|
"Himad Abdelli": [
|
||||||
|
"Abdelli"
|
||||||
|
],
|
||||||
|
"Abdelmadjid Tebboune": [
|
||||||
|
"Tebboune",
|
||||||
|
"Abdul Majid Tabon",
|
||||||
|
"Tabon"
|
||||||
|
],
|
||||||
|
"Ibrahim Maza": [
|
||||||
|
"Maza"
|
||||||
|
],
|
||||||
|
"Shehbaz Sharif": [
|
||||||
|
"Sharif"
|
||||||
|
],
|
||||||
|
"Lindsey Vonn": [
|
||||||
|
"Vonn"
|
||||||
|
],
|
||||||
|
"Erdogan": [
|
||||||
|
"Rəcəb Tayyib Erdoğan"
|
||||||
|
],
|
||||||
|
"Evo Morales": [
|
||||||
|
"Morales"
|
||||||
|
],
|
||||||
|
"Petteri Orpo": [
|
||||||
|
"Orpo"
|
||||||
|
],
|
||||||
|
"Abdel Fattah Al-Sisi": [
|
||||||
|
"Sisi"
|
||||||
|
],
|
||||||
|
"Sharif Osman Hadi": [
|
||||||
|
"Osman Hadi",
|
||||||
|
"Hadi",
|
||||||
|
"Hadir",
|
||||||
|
"Osmán Hadi"
|
||||||
|
],
|
||||||
|
"Nikol Pashinyan": [
|
||||||
|
"Pashinyan"
|
||||||
|
],
|
||||||
|
"René Benko": [
|
||||||
|
"Benko"
|
||||||
|
],
|
||||||
|
"Bashar al-Assad": [
|
||||||
|
"Assad"
|
||||||
|
],
|
||||||
|
"Hugo Motta": [
|
||||||
|
"Motta"
|
||||||
|
],
|
||||||
|
"Viktor Orbán": [
|
||||||
|
"Víctor Orbán",
|
||||||
|
"Orbán",
|
||||||
|
"Orban"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
369
generar_videos_noticias.py
Normal file
369
generar_videos_noticias.py
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generador de videos de noticias a partir de parrillas.
|
||||||
|
Este script procesa parrillas pendientes y genera videos con TTS.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
from db import get_conn
|
||||||
|
from psycopg2 import extras
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Configuración
|
||||||
|
OUTPUT_DIR = Path("/app/data/videos")
|
||||||
|
AUDIO_DIR = Path("/app/data/audio")
|
||||||
|
SUBTITLES_DIR = Path("/app/data/subtitles")
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
SUBTITLES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# URL del servicio AllTalk TTS (ajustar según configuración)
|
||||||
|
ALLTALK_URL = os.getenv("ALLTALK_URL", "http://alltalk:7851")
|
||||||
|
|
||||||
|
|
||||||
|
def obtener_noticias_parrilla(parrilla, conn):
|
||||||
|
"""
|
||||||
|
Obtiene las noticias que se incluirán en el video según los filtros de la parrilla.
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
where_clauses = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if parrilla['pais_id']:
|
||||||
|
where_clauses.append("n.pais_id = %s")
|
||||||
|
params.append(parrilla['pais_id'])
|
||||||
|
|
||||||
|
if parrilla['categoria_id']:
|
||||||
|
where_clauses.append("n.categoria_id = %s")
|
||||||
|
params.append(parrilla['categoria_id'])
|
||||||
|
|
||||||
|
if parrilla['entidad_nombre']:
|
||||||
|
where_clauses.append("""
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM tags_noticia tn
|
||||||
|
JOIN tags t ON t.id = tn.tag_id
|
||||||
|
WHERE tn.traduccion_id = tr.id
|
||||||
|
AND t.tipo = %s
|
||||||
|
AND t.valor ILIKE %s
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
params.append(parrilla['entidad_tipo'])
|
||||||
|
params.append(f"%{parrilla['entidad_nombre']}%")
|
||||||
|
|
||||||
|
# Solo noticias recientes (últimas 24 horas)
|
||||||
|
where_clauses.append("n.fecha >= NOW() - INTERVAL '1 day'")
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
n.titulo,
|
||||||
|
n.imagen_url,
|
||||||
|
n.url,
|
||||||
|
n.fecha,
|
||||||
|
n.fuente_nombre,
|
||||||
|
tr.id as traduccion_id,
|
||||||
|
tr.titulo_trad,
|
||||||
|
tr.resumen_trad,
|
||||||
|
p.nombre as pais,
|
||||||
|
c.nombre as categoria
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN traducciones tr ON tr.noticia_id = n.id
|
||||||
|
AND tr.lang_to = %s
|
||||||
|
AND tr.status = 'done'
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
WHERE {where_sql}
|
||||||
|
AND tr.id IS NOT NULL
|
||||||
|
ORDER BY n.fecha DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", [parrilla['idioma_voz']] + params + [parrilla['max_noticias']])
|
||||||
|
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def generar_audio_tts(texto, output_path, idioma='es'):
|
||||||
|
"""
|
||||||
|
Genera audio usando el servicio AllTalk TTS.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Preparar request para AllTalk
|
||||||
|
payload = {
|
||||||
|
"text_input": texto,
|
||||||
|
"text_filtering": "standard",
|
||||||
|
"character_voice_gen": "irene2.wav",
|
||||||
|
"narrator_enabled": False,
|
||||||
|
"narrator_voice_gen": "male_01.wav",
|
||||||
|
"text_not_inside": "character",
|
||||||
|
"language": idioma,
|
||||||
|
"output_file_name": output_path.stem,
|
||||||
|
"output_file_timestamp": False,
|
||||||
|
"autoplay": False,
|
||||||
|
"autoplay_volume": 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{ALLTALK_URL}/api/tts-generate",
|
||||||
|
json=payload,
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# El audio se guarda automáticamente por AllTalk
|
||||||
|
# Verificar que existe
|
||||||
|
if output_path.exists():
|
||||||
|
logger.info(f"Audio generado: {output_path}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Audio no encontrado después de generación: {output_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating TTS audio: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generar_subtitulos(noticias, output_path):
|
||||||
|
"""
|
||||||
|
Genera archivo SRT de subtítulos.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
timestamp = 0
|
||||||
|
|
||||||
|
for i, noticia in enumerate(noticias, 1):
|
||||||
|
titulo = noticia['titulo_trad'] or noticia['titulo']
|
||||||
|
resumen = noticia['resumen_trad'] or ''
|
||||||
|
|
||||||
|
# Estimar duración basada en longitud de texto (aprox 150 palabras/min)
|
||||||
|
palabras = len((titulo + " " + resumen).split())
|
||||||
|
duracion = max(5, palabras / 2.5) # segundos
|
||||||
|
|
||||||
|
# Formatear timestamp SRT
|
||||||
|
start_time = timestamp
|
||||||
|
end_time = timestamp + duracion
|
||||||
|
|
||||||
|
f.write(f"{i}\n")
|
||||||
|
f.write(f"{format_srt_time(start_time)} --> {format_srt_time(end_time)}\n")
|
||||||
|
f.write(f"{titulo}\n\n")
|
||||||
|
|
||||||
|
timestamp = end_time
|
||||||
|
|
||||||
|
logger.info(f"Subtítulos generados: {output_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating subtitles: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def format_srt_time(seconds):
|
||||||
|
"""Formatea segundos a formato SRT (HH:MM:SS,mmm)."""
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = int(seconds % 60)
|
||||||
|
millis = int((seconds % 1) * 1000)
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def procesar_parrilla(parrilla_id):
|
||||||
|
"""
|
||||||
|
Procesa una parrilla y genera el video.
|
||||||
|
"""
|
||||||
|
logger.info(f"Procesando parrilla {parrilla_id}")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Obtener configuración de parrilla
|
||||||
|
cur.execute("SELECT * FROM video_parrillas WHERE id = %s", (parrilla_id,))
|
||||||
|
parrilla = cur.fetchone()
|
||||||
|
|
||||||
|
if not parrilla or not parrilla['activo']:
|
||||||
|
logger.warning(f"Parrilla {parrilla_id} no encontrada o inactiva")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Obtener noticias
|
||||||
|
noticias = obtener_noticias_parrilla(parrilla, conn)
|
||||||
|
|
||||||
|
if not noticias:
|
||||||
|
logger.warning(f"No hay noticias disponibles para parrilla {parrilla_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Encontradas {len(noticias)} noticias para el video")
|
||||||
|
|
||||||
|
# Crear registro de video
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO video_generados (
|
||||||
|
parrilla_id, titulo, descripcion, status, num_noticias
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, 'processing', %s
|
||||||
|
) RETURNING id
|
||||||
|
""", (
|
||||||
|
parrilla_id,
|
||||||
|
f"{parrilla['nombre']} - {datetime.now().strftime('%Y-%m-%d')}",
|
||||||
|
f"Noticias de {parrilla['nombre']}",
|
||||||
|
len(noticias)
|
||||||
|
))
|
||||||
|
video_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Preparar directorios
|
||||||
|
video_dir = OUTPUT_DIR / str(video_id)
|
||||||
|
video_dir.mkdir(exist_ok=True, parents=True)
|
||||||
|
|
||||||
|
# --- SETUP LOGGING FOR THIS VIDEO ---
|
||||||
|
log_file = video_dir / "generation.log"
|
||||||
|
file_handler = logging.FileHandler(log_file, mode='w')
|
||||||
|
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Iniciando generación de video {video_id}")
|
||||||
|
logger.info(f"Directorio: {video_dir}")
|
||||||
|
|
||||||
|
# Generar script de narración
|
||||||
|
logger.info("Generando guion narrativo...")
|
||||||
|
|
||||||
|
script_parts = []
|
||||||
|
script_parts.append(f"Hola, bienvenidos a {parrilla['nombre']}.")
|
||||||
|
script_parts.append(f"Estas son las noticias más importantes de hoy, {datetime.now().strftime('%d de %B de %Y')}.")
|
||||||
|
|
||||||
|
for i, noticia in enumerate(noticias, 1):
|
||||||
|
titulo = noticia['titulo_trad'] or noticia['titulo']
|
||||||
|
resumen = noticia['resumen_trad'] or ''
|
||||||
|
|
||||||
|
script_parts.append(f"Noticia número {i}.")
|
||||||
|
script_parts.append(titulo)
|
||||||
|
if resumen:
|
||||||
|
script_parts.append(resumen[:500]) # Limitar longitud
|
||||||
|
script_parts.append("") # Pausa
|
||||||
|
|
||||||
|
script_parts.append("Esto ha sido todo por hoy. Gracias por su atención.")
|
||||||
|
|
||||||
|
full_script = "\n".join(script_parts)
|
||||||
|
|
||||||
|
# Guardar script
|
||||||
|
script_path = video_dir / "script.txt"
|
||||||
|
with open(script_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(full_script)
|
||||||
|
|
||||||
|
# Generar audio
|
||||||
|
logger.info(f"Generando audio TTS con AllTalk en: {ALLTALK_URL}")
|
||||||
|
audio_path = video_dir / "audio.wav"
|
||||||
|
if not generar_audio_tts(full_script, audio_path, parrilla['idioma_voz']):
|
||||||
|
raise Exception(f"Fallo al generar audio TTS en {ALLTALK_URL}")
|
||||||
|
|
||||||
|
# Generar subtítulos
|
||||||
|
if parrilla['include_subtitles']:
|
||||||
|
logger.info("Generando subtítulos SRT...")
|
||||||
|
subtitles_path = video_dir / "subtitles.srt"
|
||||||
|
generar_subtitulos(noticias, subtitles_path)
|
||||||
|
else:
|
||||||
|
subtitles_path = None
|
||||||
|
|
||||||
|
# Registrar noticias en el video
|
||||||
|
for i, noticia in enumerate(noticias, 1):
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO video_noticias (
|
||||||
|
video_id, noticia_id, traduccion_id, orden
|
||||||
|
) VALUES (%s, %s, %s, %s)
|
||||||
|
""", (video_id, noticia['id'], noticia['traduccion_id'], i))
|
||||||
|
|
||||||
|
# Actualizar registro de video
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE video_generados
|
||||||
|
SET status = 'completed',
|
||||||
|
audio_path = %s,
|
||||||
|
subtitles_path = %s,
|
||||||
|
noticias_ids = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (
|
||||||
|
str(audio_path),
|
||||||
|
str(subtitles_path) if subtitles_path else None,
|
||||||
|
[n['id'] for n in noticias],
|
||||||
|
video_id
|
||||||
|
))
|
||||||
|
|
||||||
|
# Actualizar parrilla
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE video_parrillas
|
||||||
|
SET ultima_generacion = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""", (parrilla_id,))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"Video {video_id} generado exitosamente")
|
||||||
|
|
||||||
|
# Cleanup handler
|
||||||
|
logger.removeHandler(file_handler)
|
||||||
|
file_handler.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing video: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Marcar como error
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE video_generados
|
||||||
|
SET status = 'error',
|
||||||
|
error_message = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (str(e), video_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Cleanup handler
|
||||||
|
logger.removeHandler(file_handler)
|
||||||
|
file_handler.close()
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Función principal: procesa parrillas activas que necesitan generación.
|
||||||
|
"""
|
||||||
|
logger.info("Iniciando generador de videos de noticias")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Buscar parrillas activas que necesitan generación
|
||||||
|
# Por ahora, procesar todas las activas manualmente
|
||||||
|
# TODO: Implementar lógica de programación automática
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
# Modo manual: procesar parrilla específica
|
||||||
|
parrilla_id = int(sys.argv[1])
|
||||||
|
procesar_parrilla(parrilla_id)
|
||||||
|
else:
|
||||||
|
# Modo batch: procesar todas las parrillas activas
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM video_parrillas
|
||||||
|
WHERE activo = true
|
||||||
|
AND frecuencia = 'daily'
|
||||||
|
AND (ultima_generacion IS NULL
|
||||||
|
OR ultima_generacion < NOW() - INTERVAL '1 day')
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
|
||||||
|
parrillas = cur.fetchall()
|
||||||
|
logger.info(f"Encontradas {len(parrillas)} parrillas para procesar")
|
||||||
|
|
||||||
|
for p in parrillas:
|
||||||
|
try:
|
||||||
|
procesar_parrilla(p['id'])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error procesando parrilla {p['id']}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
190
generate_secure_credentials.sh
Executable file
190
generate_secure_credentials.sh
Executable file
|
|
@ -0,0 +1,190 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ==================================================================================
|
||||||
|
# Script de Generación de Credenciales Seguras
|
||||||
|
# ==================================================================================
|
||||||
|
#
|
||||||
|
# Este script genera credenciales aleatorias seguras para todos los servicios
|
||||||
|
# y crea un archivo .env con las configuraciones necesarias.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# ./generate_secure_credentials.sh
|
||||||
|
#
|
||||||
|
# El script creará:
|
||||||
|
# - .env.generated (con las credenciales nuevas)
|
||||||
|
# - .env.backup (backup de .env actual si existe)
|
||||||
|
#
|
||||||
|
# ==================================================================================
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
# Colores para output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${GREEN}=================================="
|
||||||
|
echo -e "🔒 Generador de Credenciales Seguras"
|
||||||
|
echo -e "==================================${NC}\n"
|
||||||
|
|
||||||
|
# Verificar dependencias
|
||||||
|
command -v openssl >/dev/null 2>&1 || { echo -e "${RED}❌ Error: openssl no está instalado${NC}"; exit 1; }
|
||||||
|
command -v python3 >/dev/null 2>&1 || { echo -e "${RED}❌ Error: python3 no está instalado${NC}"; exit 1; }
|
||||||
|
|
||||||
|
# Backup del .env actual si existe
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo -e "${YELLOW}⚠️ Encontrado archivo .env existente${NC}"
|
||||||
|
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
cp .env "$BACKUP_FILE"
|
||||||
|
echo -e "${GREEN}✅ Backup creado: $BACKUP_FILE${NC}\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}🔑 Generando credenciales seguras...${NC}\n"
|
||||||
|
|
||||||
|
# Generar credenciales
|
||||||
|
POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
|
||||||
|
REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
|
||||||
|
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
GRAFANA_PASSWORD=$(openssl rand -base64 24 | tr -d "=+/" | cut -c1-24)
|
||||||
|
|
||||||
|
# Mostrar credenciales generadas (para que el usuario las guarde)
|
||||||
|
echo -e "${YELLOW}⚠️ IMPORTANTE: Guarda estas credenciales en un lugar seguro${NC}\n"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo -e "${GREEN}POSTGRES_PASSWORD:${NC} $POSTGRES_PASSWORD"
|
||||||
|
echo -e "${GREEN}REDIS_PASSWORD:${NC} $REDIS_PASSWORD"
|
||||||
|
echo -e "${GREEN}SECRET_KEY:${NC} $SECRET_KEY"
|
||||||
|
echo -e "${GREEN}GRAFANA_PASSWORD:${NC} $GRAFANA_PASSWORD"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Crear archivo .env.generated
|
||||||
|
ENV_FILE=".env.generated"
|
||||||
|
cat > "$ENV_FILE" << EOF
|
||||||
|
# ==================================================================================
|
||||||
|
# CONFIGURACIÓN SEGURA - Generado automáticamente
|
||||||
|
# Fecha: $(date +"%Y-%m-%d %H:%M:%S")
|
||||||
|
# ==================================================================================
|
||||||
|
#
|
||||||
|
# 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=$POSTGRES_PASSWORD
|
||||||
|
|
||||||
|
DB_NAME=rss
|
||||||
|
DB_USER=rss
|
||||||
|
DB_PASS=$POSTGRES_PASSWORD
|
||||||
|
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=$REDIS_PASSWORD
|
||||||
|
|
||||||
|
# ==================================================================================
|
||||||
|
# APPLICATION SECRETS
|
||||||
|
# ==================================================================================
|
||||||
|
SECRET_KEY=$SECRET_KEY
|
||||||
|
|
||||||
|
# ==================================================================================
|
||||||
|
# MONITORING - Grafana
|
||||||
|
# ==================================================================================
|
||||||
|
GRAFANA_PASSWORD=$GRAFANA_PASSWORD
|
||||||
|
|
||||||
|
# ==================================================================================
|
||||||
|
# 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
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Archivo generado: $ENV_FILE${NC}\n"
|
||||||
|
|
||||||
|
# Preguntar si quiere reemplazar .env
|
||||||
|
echo -e "${YELLOW}¿Deseas reemplazar el archivo .env actual con el generado?${NC}"
|
||||||
|
echo -e "${YELLOW}(Recomendado: revisa $ENV_FILE primero)${NC}"
|
||||||
|
read -p "¿Continuar? (s/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[SsYy]$ ]]; then
|
||||||
|
mv "$ENV_FILE" .env
|
||||||
|
echo -e "${GREEN}✅ Archivo .env actualizado${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ Archivo guardado como: $ENV_FILE${NC}"
|
||||||
|
echo -e "${YELLOW} Para usarlo: mv $ENV_FILE .env${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${GREEN}✅ ¡Credenciales generadas exitosamente!${NC}"
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}📋 PRÓXIMOS PASOS:${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " 1. Revisa las credenciales generadas arriba"
|
||||||
|
echo -e " 2. Guárdalas en un gestor de contraseñas seguro"
|
||||||
|
echo -e " 3. Migra a docker-compose.secure.yml:"
|
||||||
|
echo -e " ${GREEN}cp docker-compose.secure.yml docker-compose.yml${NC}"
|
||||||
|
echo -e " 4. Haz backup de tus datos (ver SECURITY_GUIDE.md)"
|
||||||
|
echo -e " 5. Reinicia los servicios:"
|
||||||
|
echo -e " ${GREEN}docker-compose down && docker-compose up -d${NC}"
|
||||||
|
echo -e " 6. Verifica que todo funciona correctamente"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}📖 Para más detalles, revisa: SECURITY_GUIDE.md${NC}"
|
||||||
|
echo ""
|
||||||
74
gunicorn_config.py
Normal file
74
gunicorn_config.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""
|
||||||
|
Configuración de Gunicorn optimizada para alta capacidad de proceso
|
||||||
|
"""
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Bind
|
||||||
|
bind = "0.0.0.0:8000"
|
||||||
|
|
||||||
|
# Workers
|
||||||
|
# Fórmula recomendada: (2 x $num_cores) + 1
|
||||||
|
workers = int(os.getenv("GUNICORN_WORKERS", multiprocessing.cpu_count() * 2 + 1))
|
||||||
|
|
||||||
|
# Worker class - sync como fallback si gevent no está disponible
|
||||||
|
# Para máximo rendimiento, cambiar a "gevent" después de instalar gevent
|
||||||
|
worker_class = "sync"
|
||||||
|
worker_connections = 1000
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
timeout = 300 # 5 minutos para queries pesadas
|
||||||
|
graceful_timeout = 30
|
||||||
|
keepalive = 5
|
||||||
|
|
||||||
|
# Reiniciar workers después de N requests para prevenir memory leaks
|
||||||
|
max_requests = 0 # Desactivado para evitar matar hilos de backup
|
||||||
|
max_requests_jitter = 0
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
accesslog = "-"
|
||||||
|
errorlog = "-"
|
||||||
|
loglevel = "info"
|
||||||
|
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
|
||||||
|
|
||||||
|
# Preload app para compartir memoria entre workers
|
||||||
|
preload_app = True
|
||||||
|
|
||||||
|
# Threading
|
||||||
|
threads = 2
|
||||||
|
|
||||||
|
# Process naming
|
||||||
|
proc_name = "rss2_gunicorn"
|
||||||
|
|
||||||
|
# Server mechanics
|
||||||
|
daemon = False
|
||||||
|
pidfile = None
|
||||||
|
umask = 0
|
||||||
|
user = None
|
||||||
|
group = None
|
||||||
|
tmp_upload_dir = None
|
||||||
|
|
||||||
|
# SSL (no usado, NGINX maneja SSL)
|
||||||
|
keyfile = None
|
||||||
|
certfile = None
|
||||||
|
|
||||||
|
# Configuración de seguridad
|
||||||
|
limit_request_line = 4094
|
||||||
|
limit_request_fields = 100
|
||||||
|
limit_request_field_size = 8190
|
||||||
|
|
||||||
|
def on_starting(server):
|
||||||
|
"""Callback cuando el servidor arranca"""
|
||||||
|
server.log.info("Starting RSS2 Gunicorn server with %d workers", workers)
|
||||||
|
|
||||||
|
def on_reload(server):
|
||||||
|
"""Callback cuando el servidor recarga"""
|
||||||
|
server.log.info("Reloading RSS2 Gunicorn server")
|
||||||
|
|
||||||
|
def worker_int(worker):
|
||||||
|
"""Callback cuando un worker recibe SIGINT o SIGQUIT"""
|
||||||
|
worker.log.info("Worker received INT or QUIT signal")
|
||||||
|
|
||||||
|
def worker_abort(worker):
|
||||||
|
"""Callback cuando un worker es abortado"""
|
||||||
|
worker.log.info("Worker received SIGABRT signal")
|
||||||
53
init-replica/init-replica.sh
Executable file
53
init-replica/init-replica.sh
Executable file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Initialization script for PostgreSQL streaming replica
|
||||||
|
# This script sets up the replica from the primary using pg_basebackup
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PGDATA="${PGDATA:-/var/lib/postgresql/data/18/main}"
|
||||||
|
PRIMARY_HOST="${PRIMARY_HOST:-db}"
|
||||||
|
REPLICATION_USER="${REPLICATION_USER:-replicator}"
|
||||||
|
REPLICATION_PASSWORD="${REPLICATION_PASSWORD:-replica_password}"
|
||||||
|
|
||||||
|
echo "=== PostgreSQL Replica Initialization ==="
|
||||||
|
|
||||||
|
# Check if PGDATA already has data (replica already initialized)
|
||||||
|
if [ -f "$PGDATA/standby.signal" ]; then
|
||||||
|
echo "Replica already initialized (standby.signal exists). Skipping initialization."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$PGDATA/PG_VERSION" ]; then
|
||||||
|
echo "PGDATA already contains data. Checking if it's a replica..."
|
||||||
|
if [ -f "$PGDATA/standby.signal" ] || grep -q "primary_conninfo" "$PGDATA/postgresql.auto.conf" 2>/dev/null; then
|
||||||
|
echo "Already configured as replica. Skipping."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "WARNING: PGDATA contains data but is NOT a replica."
|
||||||
|
echo "Cleaning up existing data to initialize replica..."
|
||||||
|
rm -rf "$PGDATA"/*
|
||||||
|
# Continue to basebackup
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting for primary at $PRIMARY_HOST to be ready..."
|
||||||
|
until pg_isready -h "$PRIMARY_HOST" -p 5432 -U postgres; do
|
||||||
|
echo "Primary not ready yet. Waiting 2 seconds..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Primary is ready. Starting pg_basebackup..."
|
||||||
|
|
||||||
|
# Use pg_basebackup to copy data from primary
|
||||||
|
PGPASSWORD="$REPLICATION_PASSWORD" pg_basebackup \
|
||||||
|
-h "$PRIMARY_HOST" \
|
||||||
|
-p 5432 \
|
||||||
|
-U "$REPLICATION_USER" \
|
||||||
|
-D "$PGDATA" \
|
||||||
|
-Fp \
|
||||||
|
-Xs \
|
||||||
|
-P \
|
||||||
|
-R
|
||||||
|
|
||||||
|
echo "pg_basebackup complete. Replica initialized successfully."
|
||||||
|
echo "standby.signal and postgresql.auto.conf with primary_conninfo created."
|
||||||
119
migrate_to_secure.sh
Executable file
119
migrate_to_secure.sh
Executable file
|
|
@ -0,0 +1,119 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ==================================================================================
|
||||||
|
# Script de Migración a Configuración Segura - TODO EN UNO
|
||||||
|
# ==================================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colores
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${BLUE}🔒 Migración a Configuración Segura - TODO EN UNO${NC}"
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}⚠️ Este script hará lo siguiente:${NC}"
|
||||||
|
echo " 1. Detener los servicios actuales"
|
||||||
|
echo " 2. Iniciar con la configuración segura"
|
||||||
|
echo " 3. Verificar que todo funciona"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}📊 Tiempo estimado: 3-5 minutos${NC}\n"
|
||||||
|
|
||||||
|
read -p "¿Deseas continuar? (s/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[SsYy]$ ]]; then
|
||||||
|
echo -e "${RED}❌ Operación cancelada${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${GREEN}PASO 1: Deteniendo servicios actuales...${NC}"
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
|
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Servicios detenidos${NC}\n"
|
||||||
|
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${GREEN}PASO 2: Iniciando con configuración segura...${NC}"
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
|
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Servicios iniciados${NC}\n"
|
||||||
|
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${GREEN}PASO 3: Esperando que los servicios se inicialicen...${NC}"
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
|
|
||||||
|
echo -n "Esperando 30 segundos"
|
||||||
|
for i in {1..30}; do
|
||||||
|
echo -n "."
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${GREEN}PASO 4: Verificando servicios...${NC}"
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
|
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${GREEN}PASO 5: Ejecutando verificación de seguridad...${NC}"
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
|
|
||||||
|
./verify_security.sh
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${GREEN}PASO 6: Verificando web app...${NC}"
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
|
|
||||||
|
if curl -s http://localhost:8001 > /dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}✅ Web app responde correctamente${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Web app no responde - revisar logs:${NC}"
|
||||||
|
echo " docker-compose logs nginx"
|
||||||
|
echo " docker-compose logs rss2_web"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${BLUE}🎉 ¡Migración completada!${NC}"
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Tu sistema ahora está configurado de forma segura:${NC}\n"
|
||||||
|
echo " 🔒 Credenciales fuertes configuradas"
|
||||||
|
echo " 🌐 Redes segmentadas (frontend, backend, monitoring)"
|
||||||
|
echo " 🚪 Solo puerto 8001 expuesto públicamente"
|
||||||
|
echo " 🔐 Redis con autenticación"
|
||||||
|
echo " 📊 Límites de recursos configurados"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${YELLOW}📋 PRÓXIMOS PASOS:${NC}\n"
|
||||||
|
echo " 1. Verifica que puedes acceder a: http://localhost:8001"
|
||||||
|
echo " 2. Prueba búsqueda y funcionalidades principales"
|
||||||
|
echo " 3. Para Grafana (monitoring):"
|
||||||
|
echo " - Acceso local: http://localhost:3001"
|
||||||
|
echo " - Usuario: admin"
|
||||||
|
echo " - Password: Ver EJECUTAR_AHORA.md"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${YELLOW}📖 Documentación:${NC}"
|
||||||
|
echo " - EJECUTAR_AHORA.md → Instrucciones detalladas"
|
||||||
|
echo " - SECURITY_GUIDE.md → Guía completa de seguridad"
|
||||||
|
echo " - SECURITY_AUDIT.md → Resumen de auditoría"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
3
models/__init__.py
Normal file
3
models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# models/__init__.py
|
||||||
|
# Para que Python reconozca el directorio como paquete.
|
||||||
|
|
||||||
9
models/categorias.py
Normal file
9
models/categorias.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from psycopg2 import extras
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def get_categorias(conn) -> List[Dict]:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute("SELECT id, nombre FROM categorias ORDER BY nombre;")
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
337
models/describe.txt
Normal file
337
models/describe.txt
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
models/
|
||||||
|
├── __init__.py # Paquete Python (vacío)
|
||||||
|
├── categorias.py # Operaciones con categorías
|
||||||
|
├── feeds.py # Operaciones con feeds RSS
|
||||||
|
├── noticias.py # Búsqueda y consulta de noticias
|
||||||
|
└── paises.py # Operaciones con países
|
||||||
|
└── traducciones.py # Operaciones con traducciones
|
||||||
|
|
||||||
|
init.py
|
||||||
|
Propósito: Archivo necesario para que Python reconozca este directorio como un paquete.
|
||||||
|
|
||||||
|
Contenido: Vacío o comentario explicativo.
|
||||||
|
|
||||||
|
Uso: Permite importar módulos desde models:
|
||||||
|
|
||||||
|
python
|
||||||
|
from models.noticias import buscar_noticias
|
||||||
|
categorias.py
|
||||||
|
Propósito: Maneja todas las operaciones relacionadas con categorías de noticias.
|
||||||
|
|
||||||
|
Funciones principales:
|
||||||
|
|
||||||
|
get_categorias(conn) -> List[Dict]
|
||||||
|
Descripción: Obtiene todas las categorías disponibles ordenadas alfabéticamente.
|
||||||
|
|
||||||
|
Parámetros:
|
||||||
|
|
||||||
|
conn: Conexión a PostgreSQL activa
|
||||||
|
|
||||||
|
Consulta SQL:
|
||||||
|
|
||||||
|
sql
|
||||||
|
SELECT id, nombre FROM categorias ORDER BY nombre;
|
||||||
|
Retorna: Lista de diccionarios con estructura:
|
||||||
|
|
||||||
|
python
|
||||||
|
[
|
||||||
|
{"id": 1, "nombre": "Política"},
|
||||||
|
{"id": 2, "nombre": "Deportes"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
Uso típico: Para llenar dropdowns de filtrado en la interfaz web.
|
||||||
|
|
||||||
|
feeds.py
|
||||||
|
Propósito: Maneja operaciones relacionadas con feeds RSS.
|
||||||
|
|
||||||
|
Funciones principales:
|
||||||
|
|
||||||
|
get_feed_by_id(conn, feed_id: int) -> Optional[Dict]
|
||||||
|
Descripción: Obtiene un feed específico por su ID.
|
||||||
|
|
||||||
|
Parámetros:
|
||||||
|
|
||||||
|
conn: Conexión a PostgreSQL
|
||||||
|
|
||||||
|
feed_id: ID numérico del feed
|
||||||
|
|
||||||
|
Consulta SQL:
|
||||||
|
|
||||||
|
sql
|
||||||
|
SELECT * FROM feeds WHERE id = %s;
|
||||||
|
Retorna: Un diccionario con todos los campos del feed o None si no existe.
|
||||||
|
|
||||||
|
get_feeds_activos(conn) -> List[Dict]
|
||||||
|
Descripción: Obtiene todos los feeds activos y no caídos.
|
||||||
|
|
||||||
|
Criterios de activos:
|
||||||
|
|
||||||
|
activo = TRUE
|
||||||
|
|
||||||
|
fallos < 5 (o NULL)
|
||||||
|
|
||||||
|
Consulta SQL:
|
||||||
|
|
||||||
|
sql
|
||||||
|
SELECT id, nombre, url, categoria_id, pais_id, fallos, activo
|
||||||
|
FROM feeds
|
||||||
|
WHERE activo = TRUE
|
||||||
|
AND (fallos IS NULL OR fallos < 5)
|
||||||
|
ORDER BY id;
|
||||||
|
Retorna: Lista de feeds activos para el ingestor RSS.
|
||||||
|
|
||||||
|
Uso crítico: Esta función es utilizada por rss_ingestor.py para determinar qué feeds procesar.
|
||||||
|
|
||||||
|
noticias.py
|
||||||
|
Propósito: Módulo más complejo que maneja todas las operaciones de búsqueda y consulta de noticias.
|
||||||
|
|
||||||
|
Funciones auxiliares:
|
||||||
|
|
||||||
|
_extraer_tags_por_traduccion(cur, traduccion_ids: List[int]) -> Dict[int, List[tuple]]
|
||||||
|
Descripción: Función privada que obtiene tags agrupados por ID de traducción.
|
||||||
|
|
||||||
|
Parámetros:
|
||||||
|
|
||||||
|
cur: Cursor de base de datos
|
||||||
|
|
||||||
|
traduccion_ids: Lista de IDs de traducciones
|
||||||
|
|
||||||
|
Consulta SQL:
|
||||||
|
|
||||||
|
sql
|
||||||
|
SELECT tn.traduccion_id, tg.valor, tg.tipo
|
||||||
|
FROM tags_noticia tn
|
||||||
|
JOIN tags tg ON tg.id = tn.tag_id
|
||||||
|
WHERE tn.traduccion_id = ANY(%s);
|
||||||
|
Retorna: Diccionario donde:
|
||||||
|
|
||||||
|
Clave: traduccion_id
|
||||||
|
|
||||||
|
Valor: Lista de tuplas (valor_tag, tipo_tag)
|
||||||
|
|
||||||
|
Optimización: Evita el problema N+1 al cargar tags.
|
||||||
|
|
||||||
|
Funciones principales:
|
||||||
|
|
||||||
|
buscar_noticias(...) -> Tuple[List[Dict], int, int, Dict]
|
||||||
|
Descripción: Búsqueda avanzada con múltiples filtros, paginación y traducciones.
|
||||||
|
|
||||||
|
Parámetros:
|
||||||
|
|
||||||
|
conn: Conexión a PostgreSQL
|
||||||
|
|
||||||
|
page: Número de página (1-based)
|
||||||
|
|
||||||
|
per_page: Noticias por página
|
||||||
|
|
||||||
|
q: Término de búsqueda (opcional)
|
||||||
|
|
||||||
|
categoria_id: Filtrar por categoría (opcional)
|
||||||
|
|
||||||
|
continente_id: Filtrar por continente (opcional)
|
||||||
|
|
||||||
|
pais_id: Filtrar por país (opcional)
|
||||||
|
|
||||||
|
fecha: Filtrar por fecha exacta YYYY-MM-DD (opcional)
|
||||||
|
|
||||||
|
lang: Idioma objetivo para traducciones (default: "es")
|
||||||
|
|
||||||
|
use_tr: Incluir traducciones en búsqueda (default: True)
|
||||||
|
|
||||||
|
Retorna: Tupla con 4 elementos:
|
||||||
|
|
||||||
|
noticias: Lista de noticias con datos completos
|
||||||
|
|
||||||
|
total_results: Total de resultados (sin paginación)
|
||||||
|
|
||||||
|
total_pages: Total de páginas calculado
|
||||||
|
|
||||||
|
tags_por_tr: Diccionario de tags por traducción
|
||||||
|
|
||||||
|
Características de búsqueda:
|
||||||
|
|
||||||
|
Filtrado por fecha: Coincidencia exacta de fecha
|
||||||
|
|
||||||
|
Filtrado geográfico: País o continente (jerárquico)
|
||||||
|
|
||||||
|
Filtrado por categoría: Selección única
|
||||||
|
|
||||||
|
Búsqueda de texto:
|
||||||
|
|
||||||
|
Búsqueda full-text con PostgreSQL (websearch_to_tsquery)
|
||||||
|
|
||||||
|
Búsqueda ILIKE en múltiples campos
|
||||||
|
|
||||||
|
Incluye campos originales y traducidos
|
||||||
|
|
||||||
|
Paginación: Offset/Limit estándar
|
||||||
|
|
||||||
|
Traducciones: JOIN condicional con tabla traducciones
|
||||||
|
|
||||||
|
Optimización: Single query para contar y obtener datos
|
||||||
|
|
||||||
|
Consulta SQL principal (simplificada):
|
||||||
|
|
||||||
|
sql
|
||||||
|
-- Contar total
|
||||||
|
SELECT COUNT(DISTINCT n.id)
|
||||||
|
FROM noticias n
|
||||||
|
-- joins con categorias, paises, traducciones
|
||||||
|
WHERE [condiciones dinámicas]
|
||||||
|
|
||||||
|
-- Obtener datos paginados
|
||||||
|
SELECT
|
||||||
|
n.id, n.titulo, n.resumen, n.url, n.fecha,
|
||||||
|
n.imagen_url, n.fuente_nombre,
|
||||||
|
c.nombre AS categoria,
|
||||||
|
p.nombre AS pais,
|
||||||
|
t.id AS traduccion_id,
|
||||||
|
t.titulo_trad AS titulo_traducido,
|
||||||
|
t.resumen_trad AS resumen_traducido,
|
||||||
|
-- flag de traducción disponible
|
||||||
|
CASE WHEN t.id IS NOT NULL THEN TRUE ELSE FALSE END AS tiene_traduccion,
|
||||||
|
-- campos originales
|
||||||
|
n.titulo AS titulo_original,
|
||||||
|
n.resumen AS resumen_original
|
||||||
|
FROM noticias n
|
||||||
|
-- joins...
|
||||||
|
WHERE [condiciones dinámicas]
|
||||||
|
ORDER BY n.fecha DESC NULLS LAST, n.id DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
Campos retornados por noticia:
|
||||||
|
|
||||||
|
python
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"titulo": "Título original",
|
||||||
|
"resumen": "Resumen original",
|
||||||
|
"url": "https://ejemplo.com/noticia",
|
||||||
|
"fecha": datetime(...),
|
||||||
|
"imagen_url": "https://.../imagen.jpg",
|
||||||
|
"fuente_nombre": "BBC News",
|
||||||
|
"categoria": "Política",
|
||||||
|
"pais": "España",
|
||||||
|
"traduccion_id": 456, # o None
|
||||||
|
"titulo_traducido": "Título en español",
|
||||||
|
"resumen_traducido": "Resumen en español",
|
||||||
|
"tiene_traduccion": True, # o False
|
||||||
|
"titulo_original": "Original title",
|
||||||
|
"resumen_original": "Original summary"
|
||||||
|
}
|
||||||
|
Uso en la aplicación: Esta función es el corazón de la búsqueda en la web, utilizada por los blueprints de Flask.
|
||||||
|
|
||||||
|
paises.py
|
||||||
|
Propósito: Maneja operaciones relacionadas con países.
|
||||||
|
|
||||||
|
Funciones principales:
|
||||||
|
|
||||||
|
get_paises(conn) -> List[Dict]
|
||||||
|
Descripción: Obtiene todos los países ordenados alfabéticamente.
|
||||||
|
|
||||||
|
Parámetros:
|
||||||
|
|
||||||
|
conn: Conexión a PostgreSQL
|
||||||
|
|
||||||
|
Consulta SQL:
|
||||||
|
|
||||||
|
sql
|
||||||
|
SELECT id, nombre FROM paises ORDER BY nombre;
|
||||||
|
Retorna: Lista de diccionarios con id y nombre de cada país.
|
||||||
|
|
||||||
|
Uso típico: Para dropdowns de filtrado por país en la interfaz web.
|
||||||
|
|
||||||
|
traducciones.py
|
||||||
|
Propósito: Maneja operaciones relacionadas con traducciones específicas.
|
||||||
|
|
||||||
|
Funciones principales:
|
||||||
|
|
||||||
|
get_traduccion(conn, traduccion_id: int) -> Optional[Dict]
|
||||||
|
Descripción: Obtiene una traducción específica por su ID.
|
||||||
|
|
||||||
|
Parámetros:
|
||||||
|
|
||||||
|
conn: Conexión a PostgreSQL
|
||||||
|
|
||||||
|
traduccion_id: ID numérico de la traducción
|
||||||
|
|
||||||
|
Consulta SQL:
|
||||||
|
|
||||||
|
sql
|
||||||
|
SELECT * FROM traducciones WHERE id = %s;
|
||||||
|
Retorna: Diccionario con todos los campos de la traducción o None.
|
||||||
|
|
||||||
|
Campos incluidos: id, noticia_id, lang_from, lang_to, titulo_trad, resumen_trad, status, error, created_at, etc.
|
||||||
|
|
||||||
|
Uso típico: Para páginas de detalle de traducciones o debugging.
|
||||||
|
|
||||||
|
Patrones de Diseño Observados
|
||||||
|
1. Separación de Responsabilidades
|
||||||
|
Cada archivo maneja una entidad específica de la base de datos
|
||||||
|
|
||||||
|
Lógica de consultas separada de lógica de negocio
|
||||||
|
|
||||||
|
2. Interfaz Consistente
|
||||||
|
Todas las funciones reciben conn como primer parámetro
|
||||||
|
|
||||||
|
Retornan diccionarios (usando DictCursor)
|
||||||
|
|
||||||
|
Nombres descriptivos y consistentes
|
||||||
|
|
||||||
|
3. Optimización de Consultas
|
||||||
|
Uso de _extraer_tags_por_traduccion para evitar N+1 queries
|
||||||
|
|
||||||
|
Consultas COUNT y SELECT en la misma transacción
|
||||||
|
|
||||||
|
Índices implícitos en ORDER BY fecha DESC
|
||||||
|
|
||||||
|
4. Manejo de Traducciones
|
||||||
|
JOIN condicional con tabla traducciones
|
||||||
|
|
||||||
|
Flag tiene_traduccion para fácil verificación en frontend
|
||||||
|
|
||||||
|
Campos originales siempre disponibles como fallback
|
||||||
|
|
||||||
|
5. Seguridad
|
||||||
|
Uso de parámetros preparados (%s)
|
||||||
|
|
||||||
|
No concatenación directa de strings en SQL
|
||||||
|
|
||||||
|
Validación implícita de tipos
|
||||||
|
|
||||||
|
Flujo de Datos Típico
|
||||||
|
python
|
||||||
|
# En un blueprint de Flask
|
||||||
|
from db import get_conn
|
||||||
|
from models.noticias import buscar_noticias
|
||||||
|
|
||||||
|
def ruta_buscar():
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
noticias, total, paginas, tags = buscar_noticias(
|
||||||
|
conn=conn,
|
||||||
|
page=request.args.get('page', 1, type=int),
|
||||||
|
per_page=20,
|
||||||
|
q=request.args.get('q', ''),
|
||||||
|
categoria_id=request.args.get('categoria_id'),
|
||||||
|
pais_id=request.args.get('pais_id'),
|
||||||
|
lang='es'
|
||||||
|
)
|
||||||
|
# Procesar resultados...
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
Dependencias y Relaciones
|
||||||
|
Requisito: psycopg2.extras.DictCursor para retornar diccionarios
|
||||||
|
|
||||||
|
Usado por: Todos los blueprints en routers/
|
||||||
|
|
||||||
|
Base de datos: Asume estructura de tablas específica (feeds, noticias, traducciones, etc.)
|
||||||
|
|
||||||
|
Índices necesarios: Para optimizar búsquedas, se recomiendan índices en:
|
||||||
|
|
||||||
|
noticias(fecha DESC, id DESC)
|
||||||
|
|
||||||
|
traducciones(noticia_id, lang_to, status)
|
||||||
|
|
||||||
|
feeds(activo, fallos)
|
||||||
|
|
||||||
|
|
||||||
24
models/feeds.py
Normal file
24
models/feeds.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from psycopg2 import extras
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def get_feed_by_id(conn, feed_id: int) -> Optional[Dict]:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute("SELECT * FROM feeds WHERE id = %s;", (feed_id,))
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_feeds_activos(conn) -> List[Dict]:
|
||||||
|
"""Feeds activos y no caídos, usados por el ingestor RSS."""
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, nombre, url, categoria_id, pais_id, fallos, activo
|
||||||
|
FROM feeds
|
||||||
|
WHERE activo = TRUE
|
||||||
|
AND (fallos IS NULL OR fallos < 5)
|
||||||
|
ORDER BY id;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
282
models/noticias.py
Normal file
282
models/noticias.py
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
from psycopg2 import extras
|
||||||
|
from typing import List, Dict, Optional, Tuple, Any
|
||||||
|
import os
|
||||||
|
import torch
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
|
||||||
|
|
||||||
|
def _extraer_tags_por_traduccion(cur, traduccion_ids: List[int]) -> Dict[int, List[tuple]]:
|
||||||
|
"""Obtiene tags agrupados por traducción."""
|
||||||
|
tags_por_tr = {}
|
||||||
|
|
||||||
|
if not traduccion_ids:
|
||||||
|
return tags_por_tr
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT tn.traduccion_id, tg.valor, tg.tipo
|
||||||
|
FROM tags_noticia tn
|
||||||
|
JOIN tags tg ON tg.id = tn.tag_id
|
||||||
|
WHERE tn.traduccion_id = ANY(%s);
|
||||||
|
""",
|
||||||
|
(traduccion_ids,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
for tr_id, valor, tipo in rows:
|
||||||
|
tags_por_tr.setdefault(tr_id, []).append((valor, tipo))
|
||||||
|
|
||||||
|
return tags_por_tr
|
||||||
|
|
||||||
|
|
||||||
|
def buscar_noticias(
|
||||||
|
conn,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
q: str = "",
|
||||||
|
categoria_id: Optional[str] = None,
|
||||||
|
continente_id: Optional[str] = None,
|
||||||
|
pais_id: Optional[str] = None,
|
||||||
|
fecha: Optional[str] = None,
|
||||||
|
lang: str = "es",
|
||||||
|
use_tr: bool = True,
|
||||||
|
skip_count: bool = False,
|
||||||
|
) -> Tuple[List[Dict], int, int, Dict]:
|
||||||
|
"""
|
||||||
|
Búsqueda avanzada de noticias con filtros:
|
||||||
|
- fecha
|
||||||
|
- país / continente
|
||||||
|
- categoría
|
||||||
|
- búsqueda fulltext + ILIKE
|
||||||
|
- traducciones
|
||||||
|
- paginación
|
||||||
|
"""
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
where = ["1=1"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
# Filtro por fecha exacta
|
||||||
|
if fecha:
|
||||||
|
where.append("n.fecha::date = %s")
|
||||||
|
params.append(fecha)
|
||||||
|
|
||||||
|
# Categoría
|
||||||
|
if categoria_id:
|
||||||
|
where.append("n.categoria_id = %s")
|
||||||
|
params.append(int(categoria_id))
|
||||||
|
|
||||||
|
# País o continente
|
||||||
|
if pais_id:
|
||||||
|
where.append("n.pais_id = %s")
|
||||||
|
params.append(int(pais_id))
|
||||||
|
elif continente_id:
|
||||||
|
where.append("p.continente_id = %s")
|
||||||
|
params.append(int(continente_id))
|
||||||
|
|
||||||
|
# Búsqueda
|
||||||
|
if q:
|
||||||
|
search_like = f"%{q}%"
|
||||||
|
if use_tr:
|
||||||
|
where.append(
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
n.tsv @@ websearch_to_tsquery('spanish', %s)
|
||||||
|
OR t.titulo_trad ILIKE %s
|
||||||
|
OR t.resumen_trad ILIKE %s
|
||||||
|
OR n.titulo ILIKE %s
|
||||||
|
OR n.resumen ILIKE %s
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
params.extend([q, search_like, search_like, search_like, search_like])
|
||||||
|
else:
|
||||||
|
where.append(
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
n.tsv @@ websearch_to_tsquery('spanish', %s)
|
||||||
|
OR n.titulo ILIKE %s
|
||||||
|
OR n.resumen ILIKE %s
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
params.extend([q, search_like, search_like])
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where)
|
||||||
|
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# TOTAL DE RESULTADOS (OPTIMIZADO)
|
||||||
|
# =====================================================================
|
||||||
|
total_results = 0
|
||||||
|
total_pages = 0
|
||||||
|
|
||||||
|
if not skip_count:
|
||||||
|
# Si no hay filtros de búsqueda de texto ni filtros complejos, usar estimación rápida
|
||||||
|
if not q and not categoria_id and not pais_id and not continente_id and not fecha:
|
||||||
|
cur.execute("SELECT reltuples::bigint FROM pg_class WHERE relname = 'noticias'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
total_results = row[0] if row else 0
|
||||||
|
else:
|
||||||
|
# Conteo exacto si hay filtros (necesario para paginación filtrada)
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(n.id)
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
LEFT JOIN traducciones t
|
||||||
|
ON t.noticia_id = n.id
|
||||||
|
AND t.lang_to = %s
|
||||||
|
AND t.status = 'done'
|
||||||
|
WHERE {where_sql}
|
||||||
|
""",
|
||||||
|
[lang] + params,
|
||||||
|
)
|
||||||
|
total_results = cur.fetchone()[0]
|
||||||
|
|
||||||
|
total_pages = (total_results // per_page) + (1 if total_results % per_page else 0)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# LISTA DE NOTICIAS PAGINADAS
|
||||||
|
# =====================================================================
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
n.titulo,
|
||||||
|
n.resumen,
|
||||||
|
n.url,
|
||||||
|
n.fecha,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fuente_nombre,
|
||||||
|
c.nombre AS categoria,
|
||||||
|
p.nombre AS pais,
|
||||||
|
|
||||||
|
-- traducciones
|
||||||
|
t.id AS traduccion_id,
|
||||||
|
t.titulo_trad AS titulo_traducido,
|
||||||
|
t.resumen_trad AS resumen_traducido,
|
||||||
|
CASE WHEN t.id IS NOT NULL THEN TRUE ELSE FALSE END AS tiene_traduccion,
|
||||||
|
|
||||||
|
-- originales
|
||||||
|
n.titulo AS titulo_original,
|
||||||
|
n.resumen AS resumen_original
|
||||||
|
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
LEFT JOIN traducciones t
|
||||||
|
ON t.noticia_id = n.id
|
||||||
|
AND t.lang_to = %s
|
||||||
|
AND t.status = 'done'
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY n.fecha DESC NULLS LAST, n.id DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""",
|
||||||
|
[lang] + params + [per_page, offset],
|
||||||
|
)
|
||||||
|
noticias = cur.fetchall()
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# TAGS POR TRADUCCIÓN
|
||||||
|
# =====================================================================
|
||||||
|
tr_ids = [n["traduccion_id"] for n in noticias if n["traduccion_id"]]
|
||||||
|
tags_por_tr = _extraer_tags_por_traduccion(cur, tr_ids)
|
||||||
|
|
||||||
|
return noticias, total_results, total_pages, tags_por_tr
|
||||||
|
|
||||||
|
|
||||||
|
# Cache del modelo para no cargarlo en cada petición
|
||||||
|
_model_cache = {}
|
||||||
|
|
||||||
|
def _get_emb_model():
|
||||||
|
model_name = os.environ.get("EMB_MODEL", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
||||||
|
if model_name not in _model_cache:
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
_model_cache[model_name] = SentenceTransformer(model_name, device=device)
|
||||||
|
return _model_cache[model_name], model_name
|
||||||
|
|
||||||
|
def buscar_noticias_semantica(
|
||||||
|
conn,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
q: str,
|
||||||
|
categoria_id: Optional[str] = None,
|
||||||
|
continente_id: Optional[str] = None,
|
||||||
|
pais_id: Optional[str] = None,
|
||||||
|
fecha: Optional[str] = None,
|
||||||
|
lang: str = "es",
|
||||||
|
) -> Tuple[List[Dict], int, int, Dict]:
|
||||||
|
"""
|
||||||
|
Búsqueda semántica usando embeddings y similitud coseno (vía producto punto si están normalizados).
|
||||||
|
"""
|
||||||
|
if not q.strip():
|
||||||
|
return buscar_noticias(conn, page, per_page, "", categoria_id, continente_id, pais_id, fecha, lang)
|
||||||
|
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
model, model_name = _get_emb_model()
|
||||||
|
|
||||||
|
# Generar embedding de la consulta
|
||||||
|
q_emb = model.encode([q], normalize_embeddings=True)[0].tolist()
|
||||||
|
|
||||||
|
where = ["t.status = 'done'", "t.lang_to = %s"]
|
||||||
|
params = [lang]
|
||||||
|
|
||||||
|
if fecha:
|
||||||
|
where.append("n.fecha::date = %s")
|
||||||
|
params.append(fecha)
|
||||||
|
if categoria_id:
|
||||||
|
where.append("n.categoria_id = %s")
|
||||||
|
params.append(int(categoria_id))
|
||||||
|
if pais_id:
|
||||||
|
where.append("n.pais_id = %s")
|
||||||
|
params.append(int(pais_id))
|
||||||
|
elif continente_id:
|
||||||
|
where.append("p.continente_id = %s")
|
||||||
|
params.append(int(continente_id))
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where)
|
||||||
|
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Consulta de búsqueda vectorial (usamos un array_agg o similar para el producto punto si no hay pgvector)
|
||||||
|
# Nota: Aquí asumo que usamos producto punto entre arrays de double precision
|
||||||
|
query_sql = f"""
|
||||||
|
WITH similarity AS (
|
||||||
|
SELECT
|
||||||
|
te.traduccion_id,
|
||||||
|
(
|
||||||
|
SELECT SUM(a*b)
|
||||||
|
FROM unnest(te.embedding, %s::double precision[]) AS t(a,b)
|
||||||
|
) AS score
|
||||||
|
FROM traduccion_embeddings te
|
||||||
|
WHERE te.model = %s
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url, n.fuente_nombre,
|
||||||
|
c.nombre AS categoria, p.nombre AS pais,
|
||||||
|
t.id AS traduccion_id, t.titulo_trad AS titulo_traducido, t.resumen_trad AS resumen_traducido,
|
||||||
|
TRUE AS tiene_traduccion, s.score
|
||||||
|
FROM similarity s
|
||||||
|
JOIN traducciones t ON t.id = s.traduccion_id
|
||||||
|
JOIN noticias n ON n.id = t.noticia_id
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY n.fecha DESC NULLS LAST, s.score DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Para el conteo total en semántica podemos simplificar o usar el mismo WHERE
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM traducciones t JOIN noticias n ON n.id = t.noticia_id LEFT JOIN paises p ON p.id = n.pais_id WHERE {where_sql}", params)
|
||||||
|
total_results = cur.fetchone()[0]
|
||||||
|
total_pages = (total_results // per_page) + (1 if total_results % per_page else 0)
|
||||||
|
|
||||||
|
cur.execute(query_sql, [q_emb, model_name] + params + [per_page, offset])
|
||||||
|
noticias = cur.fetchall()
|
||||||
|
|
||||||
|
tr_ids = [n["traduccion_id"] for n in noticias]
|
||||||
|
tags_por_tr = _extraer_tags_por_traduccion(cur, tr_ids)
|
||||||
|
|
||||||
|
return noticias, total_results, total_pages, tags_por_tr
|
||||||
9
models/paises.py
Normal file
9
models/paises.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from typing import List, Dict
|
||||||
|
from psycopg2 import extras
|
||||||
|
|
||||||
|
|
||||||
|
def get_paises(conn) -> List[Dict]:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute("SELECT id, nombre FROM paises ORDER BY nombre;")
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
16
models/traducciones.py
Normal file
16
models/traducciones.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from psycopg2 import extras
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def get_traduccion(conn, traduccion_id: int) -> Optional[Dict]:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM traducciones
|
||||||
|
WHERE id = %s;
|
||||||
|
""",
|
||||||
|
(traduccion_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
21
monitoring/prometheus.yml
Normal file
21
monitoring/prometheus.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'prometheus'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
|
|
||||||
|
- job_name: 'cadvisor'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['cadvisor:8080']
|
||||||
|
|
||||||
|
# If we had Node Exporter (for host metrics):
|
||||||
|
# - job_name: 'node_exporter'
|
||||||
|
# static_configs:
|
||||||
|
# - targets: ['node-exporter:9100']
|
||||||
|
|
||||||
|
# If the app exposes metrics (e.g. Flask/Gunicorn with prometheus_client)
|
||||||
|
# - job_name: 'rss2_web'
|
||||||
|
# static_configs:
|
||||||
|
# - targets: ['rss2_web:8000']
|
||||||
112
nginx.conf
Normal file
112
nginx.conf
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 4096; # Alta capacidad de conexiones concurrentes
|
||||||
|
use epoll; # Mejor rendimiento en Linux
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
# Optimizaciones de rendimiento
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 1000M;
|
||||||
|
|
||||||
|
# Compresión gzip para reducir ancho de banda
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript
|
||||||
|
application/json application/javascript application/xml+rss
|
||||||
|
application/atom+xml image/svg+xml;
|
||||||
|
gzip_disable "msie6";
|
||||||
|
|
||||||
|
# Cache de archivos estáticos
|
||||||
|
open_file_cache max=1000 inactive=20s;
|
||||||
|
open_file_cache_valid 30s;
|
||||||
|
open_file_cache_min_uses 2;
|
||||||
|
open_file_cache_errors on;
|
||||||
|
|
||||||
|
# Configuración upstream para Gunicorn
|
||||||
|
upstream gunicorn_backend {
|
||||||
|
server rss2_web:8000 max_fails=3 fail_timeout=30s;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Logs específicos
|
||||||
|
access_log /var/log/nginx/rss2_access.log main;
|
||||||
|
error_log /var/log/nginx/rss2_error.log warn;
|
||||||
|
|
||||||
|
# Límites de seguridad
|
||||||
|
client_body_timeout 60s;
|
||||||
|
client_header_timeout 60s;
|
||||||
|
send_timeout 300s;
|
||||||
|
|
||||||
|
# Servir archivos estáticos directamente desde NGINX
|
||||||
|
location /static/ {
|
||||||
|
alias /app/static/;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy pass a Gunicorn para todo lo demás
|
||||||
|
location / {
|
||||||
|
proxy_pass http://gunicorn_backend;
|
||||||
|
proxy_redirect off;
|
||||||
|
|
||||||
|
# Headers necesarios
|
||||||
|
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;
|
||||||
|
|
||||||
|
# Timeouts para queries lentas
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
# Buffering
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
proxy_busy_buffers_size 8k;
|
||||||
|
|
||||||
|
# HTTP/1.1 para keepalive
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
proxy_pass http://gunicorn_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bloquear acceso a archivos sensibles
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
requirements.txt
Executable file
33
requirements.txt
Executable file
|
|
@ -0,0 +1,33 @@
|
||||||
|
Flask==2.3.3
|
||||||
|
feedparser==6.0.11
|
||||||
|
APScheduler==3.10.4
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
bleach==6.1.0
|
||||||
|
gunicorn==22.0.0
|
||||||
|
gevent>=23.9.1
|
||||||
|
waitress==2.1.2
|
||||||
|
bcrypt>=4.1.0
|
||||||
|
email-validator>=2.1.0
|
||||||
|
tqdm>=4.66
|
||||||
|
beautifulsoup4>=4.12
|
||||||
|
requests>=2.31
|
||||||
|
newspaper3k==0.2.8
|
||||||
|
lxml[html_clean]>=4.9.3
|
||||||
|
langdetect==1.0.9
|
||||||
|
transformers==4.43.3
|
||||||
|
sentencepiece==0.2.0
|
||||||
|
sacremoses==0.1.1
|
||||||
|
accelerate==0.33.0
|
||||||
|
ctranslate2>=4.0.0
|
||||||
|
spacy>=3.7,<4.0
|
||||||
|
pgvector==0.2.5
|
||||||
|
sentence-transformers==3.0.1
|
||||||
|
numpy>=1.26
|
||||||
|
scikit-learn>=1.4
|
||||||
|
python-dotenv>=1.0
|
||||||
|
weasyprint==60.1
|
||||||
|
pydyf==0.10.0
|
||||||
|
redis>=5.0.0
|
||||||
|
qdrant-client==1.11.0
|
||||||
|
feedfinder2>=0.0.4
|
||||||
|
|
||||||
14
reset_and_deploy.sh
Executable file
14
reset_and_deploy.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Stopping all containers..."
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
echo "Removing data volumes..."
|
||||||
|
# Use sudo if necessary, or ensure current user has permissions
|
||||||
|
rm -rf pgdata pgdata-replica redis-data qdrant_storage
|
||||||
|
|
||||||
|
echo "Starting deployment from scratch..."
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
echo "Deployment complete. Checking status..."
|
||||||
|
docker-compose ps
|
||||||
3
routers/__init__.py
Normal file
3
routers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# routes/__init__.py
|
||||||
|
# Necesario para que Python lo trate como un paquete.
|
||||||
|
|
||||||
267
routers/account.py
Normal file
267
routers/account.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"""
|
||||||
|
Account management router - User profile and account settings.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
|
from psycopg2 import extras
|
||||||
|
from db import get_conn
|
||||||
|
from utils.auth import get_current_user, login_required, hash_password, verify_password, validate_password
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
account_bp = Blueprint("account", __name__, url_prefix="/account")
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
"""User account dashboard."""
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Get favorites count
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM favoritos
|
||||||
|
WHERE user_id = %s
|
||||||
|
""", (user['id'],))
|
||||||
|
favorites_count = cur.fetchone()['count']
|
||||||
|
|
||||||
|
# Get search history count
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM search_history
|
||||||
|
WHERE user_id = %s
|
||||||
|
""", (user['id'],))
|
||||||
|
searches_count = cur.fetchone()['count']
|
||||||
|
|
||||||
|
# Get recent searches (last 10)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT query, results_count, searched_at
|
||||||
|
FROM search_history
|
||||||
|
WHERE user_id = %s
|
||||||
|
ORDER BY searched_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
""", (user['id'],))
|
||||||
|
recent_searches = cur.fetchall()
|
||||||
|
|
||||||
|
# Get recent favorites (last 5)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT n.id, n.titulo, n.imagen_url, f.created_at,
|
||||||
|
t.titulo_trad, t.id AS traduccion_id
|
||||||
|
FROM favoritos f
|
||||||
|
JOIN noticias n ON n.id = f.noticia_id
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es' AND t.status = 'done'
|
||||||
|
WHERE f.user_id = %s
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT 5
|
||||||
|
""", (user['id'],))
|
||||||
|
recent_favorites = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("account.html",
|
||||||
|
user=user,
|
||||||
|
favorites_count=favorites_count,
|
||||||
|
searches_count=searches_count,
|
||||||
|
recent_searches=recent_searches,
|
||||||
|
recent_favorites=recent_favorites)
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/search-history")
|
||||||
|
@login_required
|
||||||
|
def search_history():
|
||||||
|
"""Full search history page."""
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
|
per_page = 50
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Get total count
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM search_history
|
||||||
|
WHERE user_id = %s
|
||||||
|
""", (user['id'],))
|
||||||
|
total = cur.fetchone()['count']
|
||||||
|
|
||||||
|
# Get paginated results
|
||||||
|
cur.execute("""
|
||||||
|
SELECT query, results_count, searched_at
|
||||||
|
FROM search_history
|
||||||
|
WHERE user_id = %s
|
||||||
|
ORDER BY searched_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", (user['id'], per_page, offset))
|
||||||
|
searches = cur.fetchall()
|
||||||
|
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
|
||||||
|
return render_template("search_history.html",
|
||||||
|
user=user,
|
||||||
|
searches=searches,
|
||||||
|
page=page,
|
||||||
|
total_pages=total_pages,
|
||||||
|
total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/change-password", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def change_password():
|
||||||
|
"""Change user password."""
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
current_password = request.form.get("current_password", "")
|
||||||
|
new_password = request.form.get("new_password", "")
|
||||||
|
new_password_confirm = request.form.get("new_password_confirm", "")
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not current_password or not new_password:
|
||||||
|
flash("Por favor completa todos los campos", "danger")
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
valid_password, password_error = validate_password(new_password)
|
||||||
|
if not valid_password:
|
||||||
|
flash(password_error, "danger")
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
if new_password != new_password_confirm:
|
||||||
|
flash("Las contraseñas nuevas no coinciden", "danger")
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Verify current password
|
||||||
|
cur.execute("""
|
||||||
|
SELECT password_hash
|
||||||
|
FROM usuarios
|
||||||
|
WHERE id = %s
|
||||||
|
""", (user['id'],))
|
||||||
|
result = cur.fetchone()
|
||||||
|
|
||||||
|
if not result or not verify_password(current_password, result['password_hash']):
|
||||||
|
flash("La contraseña actual es incorrecta", "danger")
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
new_hash = hash_password(new_password)
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE usuarios
|
||||||
|
SET password_hash = %s, updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""", (new_hash, user['id']))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
flash("Contraseña actualizada exitosamente", "success")
|
||||||
|
except Exception as e:
|
||||||
|
flash("Error al actualizar la contraseña", "danger")
|
||||||
|
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/upload-avatar", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def upload_avatar():
|
||||||
|
"""Upload user avatar."""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
if 'avatar' not in request.files:
|
||||||
|
flash("No se seleccionó ningún archivo", "danger")
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
file = request.files['avatar']
|
||||||
|
if file.filename == '':
|
||||||
|
flash("No se seleccionó ningún archivo", "danger")
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
if file:
|
||||||
|
# Check extension
|
||||||
|
allowed_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
||||||
|
_, ext = os.path.splitext(file.filename)
|
||||||
|
if ext.lower() not in allowed_extensions:
|
||||||
|
flash("Formato de imagen no permitido. Usa JPG, PNG, GIF o WEBP.", "danger")
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
try:
|
||||||
|
# Create filename using user ID and random partial to avoid caching issues
|
||||||
|
random_hex = secrets.token_hex(4)
|
||||||
|
filename = f"user_{user['id']}_{random_hex}{ext.lower()}"
|
||||||
|
|
||||||
|
# Ensure upload folder exists
|
||||||
|
upload_folder = os.path.join(current_app.root_path, 'static/uploads/avatars')
|
||||||
|
os.makedirs(upload_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# Delete old avatar if exists
|
||||||
|
if user.get('avatar_url'):
|
||||||
|
old_path = os.path.join(current_app.root_path, user['avatar_url'].lstrip('/'))
|
||||||
|
if os.path.exists(old_path) and 'user_' in old_path: # Safety check
|
||||||
|
try:
|
||||||
|
os.remove(old_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
file_path = os.path.join(upload_folder, filename)
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
# Update DB
|
||||||
|
relative_path = f"/static/uploads/avatars/{filename}"
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE usuarios
|
||||||
|
SET avatar_url = %s, updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""", (relative_path, user['id']))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Update session
|
||||||
|
from flask import session
|
||||||
|
session['avatar_url'] = relative_path
|
||||||
|
|
||||||
|
flash("Foto de perfil actualizada", "success")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error uploading avatar: {e}")
|
||||||
|
flash("Error al subir la imagen", "danger")
|
||||||
|
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/stats")
|
||||||
|
@login_required
|
||||||
|
def stats():
|
||||||
|
"""Get user statistics as JSON."""
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Not authenticated"}), 401
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM favoritos WHERE user_id = %s) as favorites_count,
|
||||||
|
(SELECT COUNT(*) FROM search_history WHERE user_id = %s) as searches_count,
|
||||||
|
(SELECT MAX(searched_at) FROM search_history WHERE user_id = %s) as last_search
|
||||||
|
""", (user['id'], user['id'], user['id']))
|
||||||
|
stats = cur.fetchone()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"favorites_count": stats['favorites_count'],
|
||||||
|
"searches_count": stats['searches_count'],
|
||||||
|
"last_search": stats['last_search'].isoformat() if stats['last_search'] else None
|
||||||
|
})
|
||||||
203
routers/auth.py
Normal file
203
routers/auth.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"""
|
||||||
|
Authentication router - User registration, login, and logout.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, render_template, redirect, url_for, session, flash
|
||||||
|
from psycopg2 import extras, IntegrityError
|
||||||
|
from db import get_conn
|
||||||
|
from utils.auth import (
|
||||||
|
hash_password, verify_password, is_authenticated,
|
||||||
|
validate_username, validate_password, validate_email
|
||||||
|
)
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_anonymous_favorites(session_id: str, user_id: int):
|
||||||
|
"""Migrate anonymous favorites to user account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Anonymous session ID
|
||||||
|
user_id: User ID to migrate favorites to
|
||||||
|
"""
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Migrate favorites, avoiding duplicates
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE favoritos
|
||||||
|
SET user_id = %s, session_id = NULL
|
||||||
|
WHERE session_id = %s
|
||||||
|
AND noticia_id NOT IN (
|
||||||
|
SELECT noticia_id FROM favoritos WHERE user_id = %s
|
||||||
|
)
|
||||||
|
""", (user_id, session_id, user_id))
|
||||||
|
|
||||||
|
# Delete any remaining duplicates
|
||||||
|
cur.execute("""
|
||||||
|
DELETE FROM favoritos
|
||||||
|
WHERE session_id = %s
|
||||||
|
""", (session_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Registration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
"""User registration page and handler."""
|
||||||
|
if is_authenticated():
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
email = request.form.get("email", "").strip().lower()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
password_confirm = request.form.get("password_confirm", "")
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
valid_username, username_error = validate_username(username)
|
||||||
|
if not valid_username:
|
||||||
|
flash(username_error, "danger")
|
||||||
|
return render_template("register.html", username=username, email=email)
|
||||||
|
|
||||||
|
valid_email, email_error = validate_email(email)
|
||||||
|
if not valid_email:
|
||||||
|
flash(email_error, "danger")
|
||||||
|
return render_template("register.html", username=username, email=email)
|
||||||
|
|
||||||
|
valid_password, password_error = validate_password(password)
|
||||||
|
if not valid_password:
|
||||||
|
flash(password_error, "danger")
|
||||||
|
return render_template("register.html", username=username, email=email)
|
||||||
|
|
||||||
|
if password != password_confirm:
|
||||||
|
flash("Las contraseñas no coinciden", "danger")
|
||||||
|
return render_template("register.html", username=username, email=email)
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
try:
|
||||||
|
password_hash = hash_password(password)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO usuarios (username, email, password_hash, last_login)
|
||||||
|
VALUES (%s, %s, %s, NOW())
|
||||||
|
RETURNING id
|
||||||
|
""", (username, email, password_hash))
|
||||||
|
user_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Auto-login after registration
|
||||||
|
old_session_id = session.get('user_session')
|
||||||
|
session['user_id'] = user_id
|
||||||
|
session['username'] = username
|
||||||
|
|
||||||
|
# Migrate anonymous favorites if any
|
||||||
|
if old_session_id:
|
||||||
|
migrate_anonymous_favorites(old_session_id, user_id)
|
||||||
|
session.pop('user_session', None)
|
||||||
|
|
||||||
|
flash(f"¡Bienvenido {username}! Tu cuenta ha sido creada exitosamente.", "success")
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
except IntegrityError as e:
|
||||||
|
if 'username' in str(e):
|
||||||
|
flash("Este nombre de usuario ya está en uso", "danger")
|
||||||
|
elif 'email' in str(e):
|
||||||
|
flash("Este email ya está registrado", "danger")
|
||||||
|
else:
|
||||||
|
flash("Error al crear la cuenta. Por favor intenta de nuevo.", "danger")
|
||||||
|
return render_template("register.html", username=username, email=email)
|
||||||
|
except Exception as e:
|
||||||
|
flash("Error al crear la cuenta. Por favor intenta de nuevo.", "danger")
|
||||||
|
return render_template("register.html", username=username, email=email)
|
||||||
|
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Login
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
"""User login page and handler."""
|
||||||
|
if is_authenticated():
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
username_or_email = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
|
||||||
|
if not username_or_email or not password:
|
||||||
|
flash("Por favor ingresa tu usuario/email y contraseña", "danger")
|
||||||
|
return render_template("login.html", username=username_or_email)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Try login with username or email
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, username, email, password_hash, is_active, avatar_url
|
||||||
|
FROM usuarios
|
||||||
|
WHERE (username = %s OR email = %s) AND is_active = TRUE
|
||||||
|
""", (username_or_email, username_or_email.lower()))
|
||||||
|
user = cur.fetchone()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
flash("Usuario o contraseña incorrectos", "danger")
|
||||||
|
return render_template("login.html", username=username_or_email)
|
||||||
|
|
||||||
|
if not verify_password(password, user['password_hash']):
|
||||||
|
flash("Usuario o contraseña incorrectos", "danger")
|
||||||
|
return render_template("login.html", username=username_or_email)
|
||||||
|
|
||||||
|
# Update last login
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE usuarios SET last_login = NOW() WHERE id = %s
|
||||||
|
""", (user['id'],))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
old_session_id = session.get('user_session')
|
||||||
|
session['user_id'] = user['id']
|
||||||
|
session['username'] = user['username']
|
||||||
|
session['avatar_url'] = user.get('avatar_url')
|
||||||
|
|
||||||
|
# Migrate anonymous favorites
|
||||||
|
if old_session_id:
|
||||||
|
migrate_anonymous_favorites(old_session_id, user['id'])
|
||||||
|
session.pop('user_session', None)
|
||||||
|
|
||||||
|
flash(f"¡Bienvenido de vuelta, {user['username']}!", "success")
|
||||||
|
|
||||||
|
# Redirect to 'next' parameter if exists
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
if next_page and next_page.startswith('/'):
|
||||||
|
return redirect(next_page)
|
||||||
|
return redirect(url_for('account.index'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash("Error al iniciar sesión. Por favor intenta de nuevo.", "danger")
|
||||||
|
return render_template("login.html", username=username_or_email)
|
||||||
|
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Logout
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@auth_bp.route("/logout", methods=["POST", "GET"])
|
||||||
|
def logout():
|
||||||
|
"""Log out the current user."""
|
||||||
|
username = session.get('username', 'Usuario')
|
||||||
|
session.clear()
|
||||||
|
flash(f"Hasta luego, {username}. Has cerrado sesión exitosamente.", "info")
|
||||||
|
return redirect(url_for('home.index'))
|
||||||
353
routers/backup.py
Normal file
353
routers/backup.py
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
from flask import Blueprint, send_file, render_template, request, flash, redirect, url_for
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from psycopg2 import extras
|
||||||
|
from db import get_conn
|
||||||
|
|
||||||
|
backup_bp = Blueprint("backup", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# EXPORTAR FEEDS → CSV (OK)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@backup_bp.route("/backup_feeds")
|
||||||
|
def backup_feeds():
|
||||||
|
with get_conn() as conn, conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT f.id, f.nombre, f.descripcion, f.url,
|
||||||
|
f.categoria_id, c.nombre AS categoria,
|
||||||
|
f.pais_id, p.nombre AS pais,
|
||||||
|
f.idioma, f.activo, f.fallos
|
||||||
|
FROM feeds f
|
||||||
|
LEFT JOIN categorias c ON c.id=f.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id=f.pais_id
|
||||||
|
ORDER BY f.id;
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
writer.writerow([
|
||||||
|
"id", "nombre", "descripcion", "url",
|
||||||
|
"categoria_id", "categoria",
|
||||||
|
"pais_id", "pais",
|
||||||
|
"idioma", "activo", "fallos"
|
||||||
|
])
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow([
|
||||||
|
r["id"],
|
||||||
|
r["nombre"],
|
||||||
|
r["descripcion"] or "",
|
||||||
|
r["url"],
|
||||||
|
r["categoria_id"] or "",
|
||||||
|
r["categoria"] or "",
|
||||||
|
r["pais_id"] or "",
|
||||||
|
r["pais"] or "",
|
||||||
|
r["idioma"] or "",
|
||||||
|
r["activo"],
|
||||||
|
r["fallos"],
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||||
|
mimetype="text/csv",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name="feeds_backup.csv",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# EXPORTAR FEEDS FILTRADOS → CSV
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@backup_bp.route("/export_feeds_filtered")
|
||||||
|
def export_feeds_filtered():
|
||||||
|
"""Exportar feeds con filtros opcionales (país, categoría, estado)."""
|
||||||
|
pais_id = request.args.get("pais_id")
|
||||||
|
categoria_id = request.args.get("categoria_id")
|
||||||
|
estado = request.args.get("estado") or ""
|
||||||
|
|
||||||
|
# Construir filtros WHERE (misma lógica que list_feeds)
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if pais_id:
|
||||||
|
where.append("f.pais_id = %s")
|
||||||
|
params.append(int(pais_id))
|
||||||
|
|
||||||
|
if categoria_id:
|
||||||
|
where.append("f.categoria_id = %s")
|
||||||
|
params.append(int(categoria_id))
|
||||||
|
|
||||||
|
if estado == "activos":
|
||||||
|
where.append("f.activo = TRUE")
|
||||||
|
elif estado == "inactivos":
|
||||||
|
where.append("f.activo = FALSE")
|
||||||
|
elif estado == "errores":
|
||||||
|
where.append("COALESCE(f.fallos, 0) > 0")
|
||||||
|
|
||||||
|
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
||||||
|
|
||||||
|
# Query SQL con filtros
|
||||||
|
with get_conn() as conn, conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT f.id, f.nombre, f.descripcion, f.url,
|
||||||
|
f.categoria_id, c.nombre AS categoria,
|
||||||
|
f.pais_id, p.nombre AS pais,
|
||||||
|
f.idioma, f.activo, f.fallos
|
||||||
|
FROM feeds f
|
||||||
|
LEFT JOIN categorias c ON c.id=f.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id=f.pais_id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY p.nombre NULLS LAST, c.nombre NULLS LAST, f.nombre;
|
||||||
|
""", params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Obtener nombres para el archivo
|
||||||
|
pais_nombre = None
|
||||||
|
categoria_nombre = None
|
||||||
|
|
||||||
|
if pais_id:
|
||||||
|
cur.execute("SELECT nombre FROM paises WHERE id = %s", (int(pais_id),))
|
||||||
|
result = cur.fetchone()
|
||||||
|
if result:
|
||||||
|
pais_nombre = result["nombre"]
|
||||||
|
|
||||||
|
if categoria_id:
|
||||||
|
cur.execute("SELECT nombre FROM categorias WHERE id = %s", (int(categoria_id),))
|
||||||
|
result = cur.fetchone()
|
||||||
|
if result:
|
||||||
|
categoria_nombre = result["nombre"]
|
||||||
|
|
||||||
|
# Generar CSV
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
writer.writerow([
|
||||||
|
"id", "nombre", "descripcion", "url",
|
||||||
|
"categoria_id", "categoria",
|
||||||
|
"pais_id", "pais",
|
||||||
|
"idioma", "activo", "fallos"
|
||||||
|
])
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow([
|
||||||
|
r["id"],
|
||||||
|
r["nombre"],
|
||||||
|
r["descripcion"] or "",
|
||||||
|
r["url"],
|
||||||
|
r["categoria_id"] or "",
|
||||||
|
r["categoria"] or "",
|
||||||
|
r["pais_id"] or "",
|
||||||
|
r["pais"] or "",
|
||||||
|
r["idioma"] or "",
|
||||||
|
r["activo"],
|
||||||
|
r["fallos"],
|
||||||
|
])
|
||||||
|
|
||||||
|
# Generar nombre de archivo dinámico
|
||||||
|
filename_parts = ["feeds"]
|
||||||
|
|
||||||
|
if pais_nombre:
|
||||||
|
# Limpiar nombre de país para usar en archivo
|
||||||
|
clean_pais = pais_nombre.lower().replace(" ", "_").replace("/", "_")
|
||||||
|
filename_parts.append(clean_pais)
|
||||||
|
|
||||||
|
if categoria_nombre:
|
||||||
|
clean_cat = categoria_nombre.lower().replace(" ", "_").replace("/", "_")
|
||||||
|
filename_parts.append(clean_cat)
|
||||||
|
|
||||||
|
if estado:
|
||||||
|
filename_parts.append(estado)
|
||||||
|
|
||||||
|
filename = "_".join(filename_parts) + ".csv"
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||||
|
mimetype="text/csv",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# RESTAURAR FEEDS → CSV (VERSIÓN PROFESIONAL)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@backup_bp.route("/restore_feeds", methods=["GET", "POST"])
|
||||||
|
def restore_feeds():
|
||||||
|
if request.method == "GET":
|
||||||
|
return render_template("restore_feeds.html")
|
||||||
|
|
||||||
|
file = request.files.get("file")
|
||||||
|
if not file:
|
||||||
|
flash("Debes seleccionar un archivo CSV.", "error")
|
||||||
|
return redirect(url_for("backup.restore_feeds"))
|
||||||
|
|
||||||
|
# 1) Leer CSV
|
||||||
|
try:
|
||||||
|
raw = file.read().decode("utf-8-sig").replace("\ufeff", "")
|
||||||
|
reader = csv.DictReader(io.StringIO(raw))
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error al procesar CSV: {e}", "error")
|
||||||
|
return redirect(url_for("backup.restore_feeds"))
|
||||||
|
|
||||||
|
expected_fields = [
|
||||||
|
"id", "nombre", "descripcion", "url",
|
||||||
|
"categoria_id", "categoria",
|
||||||
|
"pais_id", "pais",
|
||||||
|
"idioma", "activo", "fallos"
|
||||||
|
]
|
||||||
|
|
||||||
|
if reader.fieldnames != expected_fields:
|
||||||
|
flash("El CSV no tiene el encabezado correcto.", "error")
|
||||||
|
return redirect(url_for("backup.restore_feeds"))
|
||||||
|
|
||||||
|
# Contadores
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
|
||||||
|
# Vaciar tabla ELIMINADO para no borrar feeds existentes
|
||||||
|
# cur.execute("TRUNCATE feeds RESTART IDENTITY CASCADE;")
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
# Limpieza general
|
||||||
|
row = {k: (v.strip().rstrip("ç") if isinstance(v, str) else v) for k, v in row.items()}
|
||||||
|
|
||||||
|
# Validaciones mínimas
|
||||||
|
if not row["url"] or not row["nombre"]:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Creating a savepoint to isolate this row's transaction
|
||||||
|
cur.execute("SAVEPOINT row_savepoint")
|
||||||
|
|
||||||
|
# Normalizar valores
|
||||||
|
categoria_id = int(row["categoria_id"]) if row["categoria_id"] else None
|
||||||
|
pais_id = int(row["pais_id"]) if row["pais_id"] else None
|
||||||
|
|
||||||
|
idioma = (row["idioma"] or "").lower().strip()
|
||||||
|
idioma = idioma[:2] if idioma else None
|
||||||
|
|
||||||
|
activo = str(row["activo"]).lower() in ("true", "1", "t", "yes", "y")
|
||||||
|
fallos = int(row["fallos"] or 0)
|
||||||
|
|
||||||
|
# Buscar si ya existe un feed con esta URL
|
||||||
|
cur.execute("SELECT id FROM feeds WHERE url = %s", (row["url"],))
|
||||||
|
existing_feed = cur.fetchone()
|
||||||
|
|
||||||
|
if existing_feed:
|
||||||
|
# URL ya existe -> ACTUALIZAR el feed existente
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE feeds SET
|
||||||
|
nombre=%s,
|
||||||
|
descripcion=%s,
|
||||||
|
categoria_id=%s,
|
||||||
|
pais_id=%s,
|
||||||
|
idioma=%s,
|
||||||
|
activo=%s,
|
||||||
|
fallos=%s
|
||||||
|
WHERE id=%s
|
||||||
|
""", (
|
||||||
|
row["nombre"],
|
||||||
|
row["descripcion"] or None,
|
||||||
|
categoria_id,
|
||||||
|
pais_id,
|
||||||
|
idioma,
|
||||||
|
activo,
|
||||||
|
fallos,
|
||||||
|
existing_feed[0]
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# URL no existe -> INSERTAR NUEVO feed (ignorar ID del CSV, usar auto-increment)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
""", (
|
||||||
|
row["nombre"],
|
||||||
|
row["descripcion"] or None,
|
||||||
|
row["url"],
|
||||||
|
categoria_id,
|
||||||
|
pais_id,
|
||||||
|
idioma,
|
||||||
|
activo,
|
||||||
|
fallos
|
||||||
|
))
|
||||||
|
|
||||||
|
cur.execute("RELEASE SAVEPOINT row_savepoint")
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# If any error happens, rollback to the savepoint so the main transaction isn't aborted
|
||||||
|
cur.execute("ROLLBACK TO SAVEPOINT row_savepoint")
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# No need to reset sequence - auto-increment handles it
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
flash(
|
||||||
|
f"Restauración completada. "
|
||||||
|
f"Importados: {imported} | Saltados: {skipped} | Fallidos: {failed}",
|
||||||
|
"success"
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(url_for("feeds.list_feeds"))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# EXPORTAR METADATOS (PAISES / CATEGORIAS)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@backup_bp.route("/export_paises")
|
||||||
|
def export_paises():
|
||||||
|
"""Exportar listado de países a CSV."""
|
||||||
|
with get_conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, nombre FROM paises ORDER BY id;")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["id", "nombre"])
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow([r[0], r[1]])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||||
|
mimetype="text/csv",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name="paises.csv",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@backup_bp.route("/export_categorias")
|
||||||
|
def export_categorias():
|
||||||
|
"""Exportar listado de categorías a CSV."""
|
||||||
|
with get_conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, nombre FROM categorias ORDER BY id;")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["id", "nombre"])
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow([r[0], r[1]])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||||
|
mimetype="text/csv",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name="categorias.csv",
|
||||||
|
)
|
||||||
216
routers/config.py
Normal file
216
routers/config.py
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response, stream_with_context
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
from db import get_conn
|
||||||
|
from psycopg2 import extras
|
||||||
|
|
||||||
|
config_bp = Blueprint("config", __name__, url_prefix="/config")
|
||||||
|
|
||||||
|
@config_bp.route("/")
|
||||||
|
def config_home():
|
||||||
|
return render_template("config.html")
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
from flask import send_file, jsonify
|
||||||
|
from cache import cache_set, cache_get
|
||||||
|
|
||||||
|
# Global dictionary to store temporary file paths (optional, but Redis is safer for clustered env)
|
||||||
|
# Since we are in a single-server Docker setup, a global dict is fine for paths if we don't restart.
|
||||||
|
# But for absolute safety, we'll store paths in Redis too.
|
||||||
|
BACKUP_TASKS = {}
|
||||||
|
|
||||||
|
@config_bp.route("/backup/start")
|
||||||
|
def backup_start():
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
cache_set(f"backup_status:{task_id}", {"progress": 0, "total": 0, "status": "initializing"})
|
||||||
|
|
||||||
|
# Start thread
|
||||||
|
thread = threading.Thread(target=_backup_worker, args=(task_id,))
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return jsonify({"task_id": task_id})
|
||||||
|
|
||||||
|
@config_bp.route("/backup/status/<task_id>")
|
||||||
|
def backup_status(task_id):
|
||||||
|
status = cache_get(f"backup_status:{task_id}")
|
||||||
|
if not status:
|
||||||
|
return jsonify({"error": "Task not found"}), 404
|
||||||
|
return jsonify(status)
|
||||||
|
|
||||||
|
@config_bp.route("/backup/download/<task_id>")
|
||||||
|
def backup_download(task_id):
|
||||||
|
status = cache_get(f"backup_status:{task_id}")
|
||||||
|
if not status or status.get("status") != "completed":
|
||||||
|
return "Archivo no listo o expirado", 404
|
||||||
|
|
||||||
|
file_path = status.get("file_path")
|
||||||
|
if not file_path or not os.path.exists(file_path):
|
||||||
|
return "Archivo no encontrado", 404
|
||||||
|
|
||||||
|
filename = f"backup_noticias_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
||||||
|
return send_file(file_path, as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
def _backup_worker(task_id):
|
||||||
|
"""Background thread to generate the backup ZIP with direct streaming."""
|
||||||
|
print(f"[BACKUP {task_id}] Inicia proceso...")
|
||||||
|
try:
|
||||||
|
tmp_dir = tempfile.mkdtemp()
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
zip_path = os.path.join(tmp_dir, f"backup_{timestamp}.zip")
|
||||||
|
|
||||||
|
from db import get_read_conn # Use replica for large reads
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
# 1. Count totals for progress
|
||||||
|
print(f"[BACKUP {task_id}] Contando registros...")
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT count(*) FROM noticias")
|
||||||
|
total_n = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT count(*) FROM traducciones WHERE status = 'done'")
|
||||||
|
total_t = cur.fetchone()[0]
|
||||||
|
|
||||||
|
total_total = total_n + total_t
|
||||||
|
print(f"[BACKUP {task_id}] Total registros: {total_total}")
|
||||||
|
cache_set(f"backup_status:{task_id}", {"progress": 0, "total": total_total, "status": "processing"})
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# --- NOTICIAS ---
|
||||||
|
print(f"[BACKUP {task_id}] Exportando noticias...")
|
||||||
|
with zf.open("noticias.jsonl", "w") as bf:
|
||||||
|
# Wrap binary file for text writing
|
||||||
|
with io.TextIOWrapper(bf, encoding='utf-8') as f:
|
||||||
|
with conn.cursor(name=f'bak_n_{task_id}', cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.itersize = 2000
|
||||||
|
cur.execute("SELECT id, titulo, resumen, url, fecha, imagen_url, fuente_nombre, categoria_id, pais_id FROM noticias")
|
||||||
|
for row in cur:
|
||||||
|
item = dict(row)
|
||||||
|
if item.get("fecha"): item["fecha"] = item["fecha"].isoformat()
|
||||||
|
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
||||||
|
processed += 1
|
||||||
|
if processed % 2000 == 0:
|
||||||
|
cache_set(f"backup_status:{task_id}", {"progress": processed, "total": total_total, "status": "processing"})
|
||||||
|
|
||||||
|
# --- TRADUCCIONES ---
|
||||||
|
print(f"[BACKUP {task_id}] Exportando traducciones...")
|
||||||
|
with zf.open("traducciones.jsonl", "w") as bf:
|
||||||
|
with io.TextIOWrapper(bf, encoding='utf-8') as f:
|
||||||
|
with conn.cursor(name=f'bak_t_{task_id}', cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.itersize = 2000
|
||||||
|
cur.execute("SELECT id, noticia_id, lang_from, lang_to, titulo_trad, resumen_trad, status, created_at FROM traducciones WHERE status = 'done'")
|
||||||
|
for row in cur:
|
||||||
|
item = dict(row)
|
||||||
|
if item.get("created_at"): item["created_at"] = item["created_at"].isoformat()
|
||||||
|
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
||||||
|
processed += 1
|
||||||
|
if processed % 2000 == 0:
|
||||||
|
cache_set(f"backup_status:{task_id}", {"progress": processed, "total": total_total, "status": "processing"})
|
||||||
|
|
||||||
|
print(f"[BACKUP {task_id}] Finalizado con éxito: {zip_path}")
|
||||||
|
cache_set(f"backup_status:{task_id}", {
|
||||||
|
"progress": total_total,
|
||||||
|
"total": total_total,
|
||||||
|
"status": "completed",
|
||||||
|
"file_path": zip_path
|
||||||
|
}, ttl_seconds=3600)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
error_msg = traceback.format_exc()
|
||||||
|
print(f"[BACKUP {task_id}] ERROR: {error_msg}")
|
||||||
|
cache_set(f"backup_status:{task_id}", {"status": "error", "error": str(e)})
|
||||||
|
|
||||||
|
@config_bp.route("/restore/noticias", methods=["GET", "POST"])
|
||||||
|
def restore_noticias():
|
||||||
|
# Keep current restore logic but maybe add progress too?
|
||||||
|
# For now let's focus on fix the client's immediate backup download issue.
|
||||||
|
if request.method == "GET":
|
||||||
|
return render_template("config_restore.html")
|
||||||
|
|
||||||
|
file = request.files.get("file")
|
||||||
|
if not file:
|
||||||
|
flash("Debes seleccionar un archivo ZIP.", "error")
|
||||||
|
return redirect(url_for("config.restore_noticias"))
|
||||||
|
|
||||||
|
if not file.filename.endswith(".zip"):
|
||||||
|
flash("El formato debe ser .zip", "error")
|
||||||
|
return redirect(url_for("config.restore_noticias"))
|
||||||
|
|
||||||
|
imported_n = 0
|
||||||
|
imported_t = 0
|
||||||
|
|
||||||
|
tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
|
||||||
|
file.save(tmp_zip.name)
|
||||||
|
tmp_zip.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(tmp_zip.name, "r") as zf:
|
||||||
|
if "noticias.jsonl" in zf.namelist():
|
||||||
|
with zf.open("noticias.jsonl") as f:
|
||||||
|
chunk = []
|
||||||
|
for line in f:
|
||||||
|
chunk.append(json.loads(line.decode("utf-8")))
|
||||||
|
if len(chunk) >= 500:
|
||||||
|
_import_noticias_chunk(chunk)
|
||||||
|
imported_n += len(chunk)
|
||||||
|
chunk = []
|
||||||
|
if chunk:
|
||||||
|
_import_noticias_chunk(chunk)
|
||||||
|
imported_n += len(chunk)
|
||||||
|
|
||||||
|
if "traducciones.jsonl" in zf.namelist():
|
||||||
|
with zf.open("traducciones.jsonl") as f:
|
||||||
|
chunk = []
|
||||||
|
for line in f:
|
||||||
|
chunk.append(json.loads(line.decode("utf-8")))
|
||||||
|
if len(chunk) >= 500:
|
||||||
|
_import_traducciones_chunk(chunk)
|
||||||
|
imported_t += len(chunk)
|
||||||
|
chunk = []
|
||||||
|
if chunk:
|
||||||
|
_import_traducciones_chunk(chunk)
|
||||||
|
imported_t += len(chunk)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_zip.name):
|
||||||
|
os.remove(tmp_zip.name)
|
||||||
|
|
||||||
|
flash(f"Restauración completada: {imported_n} noticias, {imported_t} traducciones.", "success")
|
||||||
|
return redirect(url_for("config.config_home"))
|
||||||
|
|
||||||
|
def _import_noticias_chunk(chunk):
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany("""
|
||||||
|
INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, fuente_nombre, categoria_id, pais_id)
|
||||||
|
VALUES (%(id)s, %(titulo)s, %(resumen)s, %(url)s, %(fecha)s, %(imagen_url)s, %(fuente_nombre)s, %(categoria_id)s, %(pais_id)s)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
titulo = EXCLUDED.titulo,
|
||||||
|
resumen = EXCLUDED.resumen
|
||||||
|
""", chunk)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def _import_traducciones_chunk(chunk):
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany("""
|
||||||
|
INSERT INTO traducciones (id, noticia_id, lang_from, lang_to, titulo_trad, resumen_trad, status, created_at)
|
||||||
|
VALUES (%(id)s, %(noticia_id)s, %(lang_from)s, %(lang_to)s, %(titulo_trad)s, %(resumen_trad)s, %(status)s, %(created_at)s)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
titulo_trad = EXCLUDED.titulo_trad,
|
||||||
|
resumen_trad = EXCLUDED.resumen_trad
|
||||||
|
""", chunk)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
@config_bp.route("/translator")
|
||||||
|
def translator_config():
|
||||||
|
return "Pagina de configuracion del modelo (pendiente de implementar)"
|
||||||
141
routers/conflicts.py
Normal file
141
routers/conflicts.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
from flask import Blueprint, render_template, request, flash, redirect, url_for
|
||||||
|
from db import get_conn, get_read_conn
|
||||||
|
import psycopg2.extras
|
||||||
|
from utils.qdrant_search import search_by_keywords
|
||||||
|
|
||||||
|
conflicts_bp = Blueprint("conflicts", __name__, url_prefix="/conflicts")
|
||||||
|
|
||||||
|
def ensure_table(conn):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS conflicts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
keywords TEXT,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
@conflicts_bp.route("/")
|
||||||
|
def index():
|
||||||
|
with get_conn() as conn:
|
||||||
|
ensure_table(conn)
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
|
||||||
|
cur.execute("SELECT * FROM conflicts ORDER BY id DESC")
|
||||||
|
conflicts = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("conflicts_list.html", conflicts=conflicts)
|
||||||
|
|
||||||
|
@conflicts_bp.route("/create", methods=["POST"])
|
||||||
|
def create():
|
||||||
|
name = request.form.get("name")
|
||||||
|
keywords = request.form.get("keywords")
|
||||||
|
description = request.form.get("description", "")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO conflicts (name, keywords, description) VALUES (%s, %s, %s)",
|
||||||
|
(name, keywords, description)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
flash("Conflicto creado correctamente.", "success")
|
||||||
|
return redirect(url_for("conflicts.index"))
|
||||||
|
|
||||||
|
@conflicts_bp.route("/<int:id>")
|
||||||
|
def timeline(id):
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
|
||||||
|
cur.execute("SELECT * FROM conflicts WHERE id = %s", (id,))
|
||||||
|
conflict = cur.fetchone()
|
||||||
|
|
||||||
|
if not conflict:
|
||||||
|
flash("Conflicto no encontrado.", "error")
|
||||||
|
return redirect(url_for("conflicts.index"))
|
||||||
|
|
||||||
|
# Keywords logic: comma separated
|
||||||
|
kw_raw = conflict['keywords'] or ""
|
||||||
|
kw_list = [k.strip() for k in kw_raw.split(',') if k.strip()]
|
||||||
|
|
||||||
|
noticias = []
|
||||||
|
|
||||||
|
if kw_list:
|
||||||
|
try:
|
||||||
|
# Usar búsqueda semántica por keywords (mucho más rápido y efectivo)
|
||||||
|
semantic_results = search_by_keywords(
|
||||||
|
keywords=kw_list,
|
||||||
|
limit=200,
|
||||||
|
score_threshold=0.35
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enriquecer con datos de PostgreSQL
|
||||||
|
if semantic_results:
|
||||||
|
news_ids = [r['news_id'] for r in semantic_results]
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id AS tr_id,
|
||||||
|
t.lang_to,
|
||||||
|
COALESCE(t.titulo_trad, n.titulo) as titulo,
|
||||||
|
COALESCE(t.resumen_trad, n.resumen) as resumen,
|
||||||
|
n.id AS noticia_id,
|
||||||
|
n.fecha,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fuente_nombre,
|
||||||
|
p.nombre as pais
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN traducciones t ON n.id = t.noticia_id AND t.lang_to = 'es'
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
WHERE n.id = ANY(%s)
|
||||||
|
ORDER BY n.fecha DESC
|
||||||
|
""", (news_ids,))
|
||||||
|
|
||||||
|
noticias = cur.fetchall()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error en búsqueda semántica de conflictos, usando fallback: {e}")
|
||||||
|
|
||||||
|
# Fallback a búsqueda tradicional ILIKE
|
||||||
|
patterns = [f"%{k}%" for k in kw_list]
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id AS tr_id,
|
||||||
|
t.lang_to,
|
||||||
|
COALESCE(t.titulo_trad, n.titulo) as titulo,
|
||||||
|
COALESCE(t.resumen_trad, n.resumen) as resumen,
|
||||||
|
n.id AS noticia_id,
|
||||||
|
n.fecha,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fuente_nombre,
|
||||||
|
p.nombre as pais
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN traducciones t ON n.id = t.noticia_id AND t.lang_to = 'es'
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
WHERE
|
||||||
|
(t.titulo_trad ILIKE ANY(%s) OR n.titulo ILIKE ANY(%s))
|
||||||
|
OR
|
||||||
|
(t.resumen_trad ILIKE ANY(%s) OR n.resumen ILIKE ANY(%s))
|
||||||
|
ORDER BY n.fecha DESC
|
||||||
|
LIMIT 200
|
||||||
|
""", (patterns, patterns, patterns, patterns))
|
||||||
|
|
||||||
|
noticias = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("conflict_timeline.html", conflict=conflict, noticias=noticias)
|
||||||
|
|
||||||
|
@conflicts_bp.route("/delete/<int:id>", methods=["POST"])
|
||||||
|
def delete(id):
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM conflicts WHERE id = %s", (id,))
|
||||||
|
conn.commit()
|
||||||
|
flash("Conflicto eliminado.", "success")
|
||||||
|
return redirect(url_for("conflicts.index"))
|
||||||
494
routers/describe.txt
Normal file
494
routers/describe.txt
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
routers/
|
||||||
|
├── __init__.py # Paquete Python (vacío)
|
||||||
|
├── home.py # Página principal y búsqueda de noticias
|
||||||
|
├── feeds.py # Gestión de feeds RSS
|
||||||
|
├── urls.py # Gestión de fuentes de URL
|
||||||
|
├── noticia.py # Página de detalle de noticia
|
||||||
|
├── eventos.py # Visualización de eventos por país
|
||||||
|
└── backup.py # Importación/exportación de feeds
|
||||||
|
|
||||||
|
init.py
|
||||||
|
Propósito: Archivo necesario para que Python reconozca este directorio como un paquete.
|
||||||
|
|
||||||
|
Contenido: Vacío o comentario explicativo.
|
||||||
|
|
||||||
|
Uso: Permite importar blueprints desde routers:
|
||||||
|
|
||||||
|
python
|
||||||
|
from routers.home import home_bp
|
||||||
|
home.py
|
||||||
|
Propósito: Blueprint para la página principal y búsqueda de noticias.
|
||||||
|
|
||||||
|
Ruta base: / y /home
|
||||||
|
|
||||||
|
Blueprints definidos:
|
||||||
|
|
||||||
|
home_bp = Blueprint("home", __name__)
|
||||||
|
|
||||||
|
Rutas:
|
||||||
|
|
||||||
|
@home_bp.route("/") y @home_bp.route("/home")
|
||||||
|
Método: GET
|
||||||
|
Descripción: Página principal con sistema de búsqueda avanzada.
|
||||||
|
|
||||||
|
Parámetros de consulta soportados:
|
||||||
|
|
||||||
|
page: Número de página (default: 1)
|
||||||
|
|
||||||
|
per_page: Resultados por página (default: 20, range: 10-100)
|
||||||
|
|
||||||
|
q: Término de búsqueda
|
||||||
|
|
||||||
|
categoria_id: Filtrar por categoría
|
||||||
|
|
||||||
|
continente_id: Filtrar por continente
|
||||||
|
|
||||||
|
pais_id: Filtrar por país
|
||||||
|
|
||||||
|
fecha: Filtrar por fecha (YYYY-MM-DD)
|
||||||
|
|
||||||
|
lang: Idioma para mostrar (default: "es")
|
||||||
|
|
||||||
|
orig: Si está presente, mostrar sólo originales sin traducciones
|
||||||
|
|
||||||
|
Funcionalidades:
|
||||||
|
|
||||||
|
Paginación: Sistema robusto con límites
|
||||||
|
|
||||||
|
Búsqueda avanzada: Usa models.noticias.buscar_noticias()
|
||||||
|
|
||||||
|
Soporte AJAX: Si X-Requested-With: XMLHttpRequest, retorna solo _noticias_list.html
|
||||||
|
|
||||||
|
Filtros combinados: Todos los filtros pueden usarse simultáneamente
|
||||||
|
|
||||||
|
Manejo de fechas: Conversión segura de strings a date
|
||||||
|
|
||||||
|
Variables de contexto para template:
|
||||||
|
|
||||||
|
noticias: Lista de noticias con datos completos
|
||||||
|
|
||||||
|
total_results: Total de resultados
|
||||||
|
|
||||||
|
total_pages: Total de páginas
|
||||||
|
|
||||||
|
categorias, paises: Para dropdowns de filtros
|
||||||
|
|
||||||
|
tags_por_tr: Diccionario de tags por traducción
|
||||||
|
|
||||||
|
Templates utilizados:
|
||||||
|
|
||||||
|
noticias.html: Página completa (HTML)
|
||||||
|
|
||||||
|
_noticias_list.html: Fragmento para AJAX (solo lista de noticias)
|
||||||
|
|
||||||
|
Características especiales:
|
||||||
|
|
||||||
|
use_tr = not bool(request.args.get("orig")): Controla si mostrar traducciones
|
||||||
|
|
||||||
|
lang = (request.args.get("lang") or DEFAULT_TRANSLATION_LANG or DEFAULT_LANG).lower()[:5]: Manejo seguro de idioma
|
||||||
|
|
||||||
|
feeds.py
|
||||||
|
Propósito: Blueprint para la gestión completa de feeds RSS.
|
||||||
|
|
||||||
|
Ruta base: /feeds
|
||||||
|
|
||||||
|
Blueprints definidos:
|
||||||
|
|
||||||
|
feeds_bp = Blueprint("feeds", __name__, url_prefix="/feeds")
|
||||||
|
|
||||||
|
Rutas:
|
||||||
|
|
||||||
|
@feeds_bp.route("/") - list_feeds()
|
||||||
|
Método: GET
|
||||||
|
Descripción: Listado paginado de feeds con filtros avanzados.
|
||||||
|
|
||||||
|
Parámetros de filtro:
|
||||||
|
|
||||||
|
pais_id: Filtrar por país
|
||||||
|
|
||||||
|
categoria_id: Filtrar por categoría
|
||||||
|
|
||||||
|
estado: "activos", "inactivos", "errores" o vacío para todos
|
||||||
|
|
||||||
|
Características:
|
||||||
|
|
||||||
|
Paginación (50 feeds por página)
|
||||||
|
|
||||||
|
Contador de totales
|
||||||
|
|
||||||
|
Ordenamiento: país → categoría → nombre
|
||||||
|
|
||||||
|
@feeds_bp.route("/add", methods=["GET", "POST"]) - add_feed()
|
||||||
|
Método: GET y POST
|
||||||
|
Descripción: Formulario para añadir nuevo feed.
|
||||||
|
|
||||||
|
Campos del formulario:
|
||||||
|
|
||||||
|
nombre: Nombre del feed (requerido)
|
||||||
|
|
||||||
|
descripcion: Descripción opcional
|
||||||
|
|
||||||
|
url: URL del feed RSS (requerido)
|
||||||
|
|
||||||
|
categoria_id: Categoría (select dropdown)
|
||||||
|
|
||||||
|
pais_id: País (select dropdown)
|
||||||
|
|
||||||
|
idioma: Código de idioma (2 letras, opcional)
|
||||||
|
|
||||||
|
Validaciones:
|
||||||
|
|
||||||
|
idioma se normaliza a minúsculas y máximo 2 caracteres
|
||||||
|
|
||||||
|
Campos opcionales convertidos a None si vacíos
|
||||||
|
|
||||||
|
@feeds_bp.route("/<int:feed_id>/edit", methods=["GET", "POST"]) - edit_feed(feed_id)
|
||||||
|
Método: GET y POST
|
||||||
|
Descripción: Editar feed existente.
|
||||||
|
|
||||||
|
Funcionalidades:
|
||||||
|
|
||||||
|
Pre-carga datos actuales del feed
|
||||||
|
|
||||||
|
Mismo formulario que add_feed pero con datos existentes
|
||||||
|
|
||||||
|
Campo adicional: activo (checkbox)
|
||||||
|
|
||||||
|
@feeds_bp.route("/<int:feed_id>/delete") - delete_feed(feed_id)
|
||||||
|
Método: GET
|
||||||
|
Descripción: Eliminar feed por ID.
|
||||||
|
|
||||||
|
Nota: DELETE simple sin confirmación en frontend (depende de template).
|
||||||
|
|
||||||
|
@feeds_bp.route("/<int:feed_id>/reactivar") - reactivar_feed(feed_id)
|
||||||
|
Método: GET
|
||||||
|
Descripción: Reactivar feed que tiene fallos.
|
||||||
|
|
||||||
|
Acción: Establece activo=TRUE y fallos=0.
|
||||||
|
|
||||||
|
Templates utilizados:
|
||||||
|
|
||||||
|
feeds_list.html: Listado principal
|
||||||
|
|
||||||
|
add_feed.html: Formulario de añadir
|
||||||
|
|
||||||
|
edit_feed.html: Formulario de editar
|
||||||
|
|
||||||
|
urls.py
|
||||||
|
Propósito: Blueprint para gestión de fuentes de URL (no feeds RSS).
|
||||||
|
|
||||||
|
Ruta base: /urls
|
||||||
|
|
||||||
|
Blueprints definidos:
|
||||||
|
|
||||||
|
urls_bp = Blueprint("urls", __name__, url_prefix="/urls")
|
||||||
|
|
||||||
|
Rutas:
|
||||||
|
|
||||||
|
@urls_bp.route("/") - manage_urls()
|
||||||
|
Método: GET
|
||||||
|
Descripción: Lista todas las fuentes de URL registradas.
|
||||||
|
|
||||||
|
Datos mostrados: ID, nombre, URL, categoría, país, idioma.
|
||||||
|
|
||||||
|
@urls_bp.route("/add_source", methods=["GET", "POST"]) - add_url_source()
|
||||||
|
Método: GET y POST
|
||||||
|
Descripción: Añadir/actualizar fuente de URL.
|
||||||
|
|
||||||
|
Características únicas:
|
||||||
|
|
||||||
|
Usa ON CONFLICT (url) DO UPDATE: Si la URL ya existe, actualiza
|
||||||
|
|
||||||
|
idioma default: "es" si no se especifica
|
||||||
|
|
||||||
|
Mismos campos que feeds pero para URLs individuales
|
||||||
|
|
||||||
|
Templates utilizados:
|
||||||
|
|
||||||
|
urls_list.html: Listado
|
||||||
|
|
||||||
|
add_url_source.html: Formulario
|
||||||
|
|
||||||
|
noticia.py
|
||||||
|
Propósito: Blueprint para página de detalle de noticia individual.
|
||||||
|
|
||||||
|
Ruta base: /noticia
|
||||||
|
|
||||||
|
Blueprints definidos:
|
||||||
|
|
||||||
|
noticia_bp = Blueprint("noticia", __name__)
|
||||||
|
|
||||||
|
Rutas:
|
||||||
|
|
||||||
|
@noticia_bp.route("/noticia") - noticia()
|
||||||
|
Método: GET
|
||||||
|
Descripción: Muestra detalle completo de una noticia.
|
||||||
|
|
||||||
|
Parámetros de consulta:
|
||||||
|
|
||||||
|
tr_id: ID de traducción (prioritario)
|
||||||
|
|
||||||
|
id: ID de noticia original (si no hay tr_id)
|
||||||
|
|
||||||
|
Flujo de datos:
|
||||||
|
|
||||||
|
Si hay tr_id: Obtiene datos combinados de traducción y noticia original
|
||||||
|
|
||||||
|
Si solo hay id: Obtiene solo datos originales
|
||||||
|
|
||||||
|
Si no hay ninguno: Redirige a home con mensaje de error
|
||||||
|
|
||||||
|
Datos obtenidos:
|
||||||
|
|
||||||
|
Información básica: título, resumen, URL, fecha, imagen, fuente
|
||||||
|
|
||||||
|
Datos de traducción (si aplica): idiomas, títulos/resúmenes traducidos
|
||||||
|
|
||||||
|
Metadatos: categoría, país
|
||||||
|
|
||||||
|
Tags: Etiquetas asociadas a la traducción
|
||||||
|
|
||||||
|
Noticias relacionadas: Hasta 8, ordenadas por score de similitud
|
||||||
|
|
||||||
|
Consultas adicionales (solo si hay traducción):
|
||||||
|
|
||||||
|
Tags: SELECT tg.valor, tg.tipo FROM tags_noticia...
|
||||||
|
|
||||||
|
Noticias relacionadas: SELECT n2.url, n2.titulo... FROM related_noticias...
|
||||||
|
|
||||||
|
Templates utilizados:
|
||||||
|
|
||||||
|
noticia.html: Página de detalle completa
|
||||||
|
|
||||||
|
eventos.py
|
||||||
|
Propósito: Blueprint para visualización de eventos agrupados por país.
|
||||||
|
|
||||||
|
Ruta base: /eventos_pais
|
||||||
|
|
||||||
|
Blueprints definidos:
|
||||||
|
|
||||||
|
eventos_bp = Blueprint("eventos", __name__, url_prefix="/eventos_pais")
|
||||||
|
|
||||||
|
Rutas:
|
||||||
|
|
||||||
|
@eventos_bp.route("/") - eventos_pais()
|
||||||
|
Método: GET
|
||||||
|
Descripción: Lista eventos (clusters de noticias) filtrados por país.
|
||||||
|
|
||||||
|
Parámetros de consulta:
|
||||||
|
|
||||||
|
pais_id: ID del país (obligatorio para ver eventos)
|
||||||
|
|
||||||
|
page: Número de página (default: 1)
|
||||||
|
|
||||||
|
lang: Idioma para traducciones (default: "es")
|
||||||
|
|
||||||
|
Funcionalidades:
|
||||||
|
|
||||||
|
Lista de países: Siempre visible para selección
|
||||||
|
|
||||||
|
Eventos paginados: 30 por página
|
||||||
|
|
||||||
|
Noticias por evento: Agrupadas bajo cada evento
|
||||||
|
|
||||||
|
Datos completos: Cada noticia con originales y traducidos
|
||||||
|
|
||||||
|
Estructura de datos:
|
||||||
|
|
||||||
|
Países: Lista completa para dropdown
|
||||||
|
|
||||||
|
Eventos: Paginados, con título, fechas, conteo de noticias
|
||||||
|
|
||||||
|
Noticias por evento: Diccionario {evento_id: [noticias...]}
|
||||||
|
|
||||||
|
Consultas complejas:
|
||||||
|
|
||||||
|
Agrupación con GROUP BY y MAX(p.nombre)
|
||||||
|
|
||||||
|
JOIN múltiple: eventos ↔ traducciones ↔ noticias ↔ países
|
||||||
|
|
||||||
|
Subconsulta para noticias por evento usando ANY(%s)
|
||||||
|
|
||||||
|
Variables de contexto:
|
||||||
|
|
||||||
|
paises, eventos, noticias_por_evento
|
||||||
|
|
||||||
|
pais_nombre: Nombre del país seleccionado
|
||||||
|
|
||||||
|
total_eventos, total_pages, page, lang
|
||||||
|
|
||||||
|
Templates utilizados:
|
||||||
|
|
||||||
|
eventos_pais.html: Página principal
|
||||||
|
|
||||||
|
backup.py
|
||||||
|
Propósito: Blueprint para importación y exportación de feeds en CSV.
|
||||||
|
|
||||||
|
Ruta base: /backup_feeds y /restore_feeds
|
||||||
|
|
||||||
|
Blueprints definidos:
|
||||||
|
|
||||||
|
backup_bp = Blueprint("backup", __name__)
|
||||||
|
|
||||||
|
Rutas:
|
||||||
|
|
||||||
|
@backup_bp.route("/backup_feeds") - backup_feeds()
|
||||||
|
Método: GET
|
||||||
|
Descripción: Exporta todos los feeds a CSV.
|
||||||
|
|
||||||
|
Características:
|
||||||
|
|
||||||
|
Incluye joins con categorías y países para nombres legibles
|
||||||
|
|
||||||
|
Codificación UTF-8 con BOM
|
||||||
|
|
||||||
|
Nombre de archivo: feeds_backup.csv
|
||||||
|
|
||||||
|
Usa io.StringIO y io.BytesIO para evitar archivos temporales
|
||||||
|
|
||||||
|
Campos exportados:
|
||||||
|
|
||||||
|
Todos los campos de feeds más nombres de categoría y país
|
||||||
|
|
||||||
|
@backup_bp.route("/restore_feeds", methods=["GET", "POST"]) - restore_feeds()
|
||||||
|
Método: GET y POST
|
||||||
|
Descripción: Restaura feeds desde CSV (reemplazo completo).
|
||||||
|
|
||||||
|
Flujo de restauración:
|
||||||
|
|
||||||
|
GET: Muestra formulario de subida
|
||||||
|
|
||||||
|
POST:
|
||||||
|
|
||||||
|
Valida archivo y encabezados CSV
|
||||||
|
|
||||||
|
TRUNCATE feeds RESTART IDENTITY CASCADE: Borra todo antes de importar
|
||||||
|
|
||||||
|
Procesa cada fila con validación
|
||||||
|
|
||||||
|
Estadísticas: importados, saltados, fallidos
|
||||||
|
|
||||||
|
Validaciones:
|
||||||
|
|
||||||
|
Encabezados exactos esperados
|
||||||
|
|
||||||
|
URL y nombre no vacíos
|
||||||
|
|
||||||
|
Conversión segura de tipos (int, bool)
|
||||||
|
|
||||||
|
Normalización de idioma (2 caracteres minúsculas)
|
||||||
|
|
||||||
|
Limpieza de datos:
|
||||||
|
|
||||||
|
python
|
||||||
|
row = {k: (v.strip().rstrip("ç") if v else "") for k, v in row.items()}
|
||||||
|
Manejo de booleanos:
|
||||||
|
|
||||||
|
python
|
||||||
|
activo = str(row["activo"]).lower() in ("true", "1", "t", "yes", "y")
|
||||||
|
Templates utilizados:
|
||||||
|
|
||||||
|
restore_feeds.html: Formulario de subida
|
||||||
|
|
||||||
|
Patrones de Diseño Comunes
|
||||||
|
1. Estructura de Blueprints
|
||||||
|
python
|
||||||
|
# Definición estándar
|
||||||
|
bp = Blueprint("nombre", __name__, url_prefix="/ruta")
|
||||||
|
|
||||||
|
# Registro en app.py
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
2. Manejo de Conexiones a BD
|
||||||
|
python
|
||||||
|
with get_conn() as conn:
|
||||||
|
# Usar conn para múltiples operaciones
|
||||||
|
# conn.autocommit = True si es necesario
|
||||||
|
3. Paginación Consistente
|
||||||
|
python
|
||||||
|
page = max(int(request.args.get("page", 1)), 1)
|
||||||
|
per_page = 50 # o variable
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
4. Manejo de Parámetros de Filtro
|
||||||
|
python
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if pais_id:
|
||||||
|
where.append("f.pais_id = %s")
|
||||||
|
params.append(int(pais_id))
|
||||||
|
|
||||||
|
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
||||||
|
5. Flash Messages
|
||||||
|
python
|
||||||
|
flash("Operación exitosa", "success")
|
||||||
|
flash("Error: algo salió mal", "error")
|
||||||
|
6. Redirecciones
|
||||||
|
python
|
||||||
|
return redirect(url_for("blueprint.funcion"))
|
||||||
|
7. Manejo de Formularios
|
||||||
|
python
|
||||||
|
if request.method == "POST":
|
||||||
|
# Procesar datos
|
||||||
|
return redirect(...)
|
||||||
|
# GET: mostrar formulario
|
||||||
|
return render_template("form.html", datos=...)
|
||||||
|
Seguridad y Validaciones
|
||||||
|
1. SQL Injection
|
||||||
|
Todos los parámetros usan %s con psycopg2
|
||||||
|
|
||||||
|
No hay concatenación de strings en SQL
|
||||||
|
|
||||||
|
2. Validación de Entrada
|
||||||
|
Conversión segura a int: int(valor) if valor else None
|
||||||
|
|
||||||
|
Limpieza de strings: .strip(), normalización
|
||||||
|
|
||||||
|
Rangos: min(max(per_page, 10), 100)
|
||||||
|
|
||||||
|
3. Manejo de Archivos
|
||||||
|
Validación de tipo de contenido
|
||||||
|
|
||||||
|
Decodificación UTF-8 con manejo de BOM
|
||||||
|
|
||||||
|
Uso de io para evitar archivos temporales
|
||||||
|
|
||||||
|
Optimizaciones
|
||||||
|
1. JOINs Eficientes
|
||||||
|
LEFT JOIN para datos opcionales
|
||||||
|
|
||||||
|
GROUP BY cuando es necesario
|
||||||
|
|
||||||
|
Uso de índices implícitos en ORDER BY
|
||||||
|
|
||||||
|
2. Batch Operations
|
||||||
|
TRUNCATE ... RESTART IDENTITY más rápido que DELETE
|
||||||
|
|
||||||
|
Inserción fila por fila con validación
|
||||||
|
|
||||||
|
3. Manejo de Memoria
|
||||||
|
io.StringIO para CSV en memoria
|
||||||
|
|
||||||
|
Cursors con DictCursor para acceso por nombre
|
||||||
|
|
||||||
|
Dependencias entre Blueprints
|
||||||
|
text
|
||||||
|
home.py
|
||||||
|
└── usa: models.noticias.buscar_noticias()
|
||||||
|
└── usa: _extraer_tags_por_traduccion()
|
||||||
|
|
||||||
|
feeds.py
|
||||||
|
└── usa: models.categorias.get_categorias()
|
||||||
|
└── usa: models.paises.get_paises()
|
||||||
|
|
||||||
|
urls.py
|
||||||
|
└── usa: models.categorias.get_categorias()
|
||||||
|
└── usa: models.paises.get_paises()
|
||||||
|
|
||||||
|
noticia.py
|
||||||
|
└── consultas directas (no usa models/)
|
||||||
|
|
||||||
|
eventos.py
|
||||||
|
└── consultas directas (no usa models/)
|
||||||
|
|
||||||
|
backup.py
|
||||||
|
└── consultas directas (no usa models/)
|
||||||
203
routers/favoritos.py
Normal file
203
routers/favoritos.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"""
|
||||||
|
Favorites router - Save and manage favorite news.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, session, render_template
|
||||||
|
from psycopg2 import extras
|
||||||
|
from db import get_read_conn, get_write_conn
|
||||||
|
from utils.auth import get_current_user, is_authenticated
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
favoritos_bp = Blueprint("favoritos", __name__, url_prefix="/favoritos")
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_or_session_id():
|
||||||
|
"""Get user ID if authenticated, otherwise session ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (user_id, session_id)
|
||||||
|
"""
|
||||||
|
user = get_current_user()
|
||||||
|
if user:
|
||||||
|
return (user['id'], None)
|
||||||
|
|
||||||
|
# Anonymous user - use session_id
|
||||||
|
if "user_session" not in session:
|
||||||
|
session["user_session"] = secrets.token_hex(16)
|
||||||
|
return (None, session["user_session"])
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_favoritos_table(conn):
|
||||||
|
"""Create/update favoritos table to support both users and sessions."""
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Table is created by init-db scripts, just ensure it exists
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS favoritos (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||||
|
session_id VARCHAR(64),
|
||||||
|
noticia_id VARCHAR(32) REFERENCES noticias(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_favoritos_session ON favoritos(session_id);")
|
||||||
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_favoritos_user_id ON favoritos(user_id);")
|
||||||
|
|
||||||
|
# Ensure session_id can be null (for logged in users)
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE favoritos ALTER COLUMN session_id DROP NOT NULL;")
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
else:
|
||||||
|
conn.commit()
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API: Toggle Favorite
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@favoritos_bp.route("/toggle/<noticia_id>", methods=["POST"])
|
||||||
|
def toggle_favorite(noticia_id):
|
||||||
|
"""Toggle favorite status for a news item."""
|
||||||
|
user_id, session_id = get_user_or_session_id()
|
||||||
|
|
||||||
|
with get_write_conn() as conn:
|
||||||
|
ensure_favoritos_table(conn)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Check if already favorited (by user_id OR session_id)
|
||||||
|
if user_id:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM favoritos WHERE user_id = %s AND noticia_id = %s",
|
||||||
|
(user_id, noticia_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM favoritos WHERE session_id = %s AND noticia_id = %s",
|
||||||
|
(session_id, noticia_id)
|
||||||
|
)
|
||||||
|
existing = cur.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Remove favorite
|
||||||
|
if user_id:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM favoritos WHERE user_id = %s AND noticia_id = %s",
|
||||||
|
(user_id, noticia_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM favoritos WHERE session_id = %s AND noticia_id = %s",
|
||||||
|
(session_id, noticia_id)
|
||||||
|
)
|
||||||
|
is_favorite = False
|
||||||
|
else:
|
||||||
|
# Add favorite
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO favoritos (user_id, session_id, noticia_id) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(user_id, session_id, noticia_id)
|
||||||
|
)
|
||||||
|
is_favorite = True
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return jsonify({"success": True, "is_favorite": is_favorite})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API: Check if Favorite
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@favoritos_bp.route("/check/<noticia_id>")
|
||||||
|
def check_favorite(noticia_id):
|
||||||
|
"""Check if a news item is favorited."""
|
||||||
|
user_id, session_id = get_user_or_session_id()
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if user_id:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM favoritos WHERE user_id = %s AND noticia_id = %s",
|
||||||
|
(user_id, noticia_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM favoritos WHERE session_id = %s AND noticia_id = %s",
|
||||||
|
(session_id, noticia_id)
|
||||||
|
)
|
||||||
|
is_favorite = cur.fetchone() is not None
|
||||||
|
|
||||||
|
return jsonify({"is_favorite": is_favorite})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API: Get User's Favorites IDs
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@favoritos_bp.route("/ids")
|
||||||
|
def get_favorite_ids():
|
||||||
|
"""Get list of favorite noticia IDs for current user."""
|
||||||
|
user_id, session_id = get_user_or_session_id()
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if user_id:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT noticia_id FROM favoritos WHERE user_id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT noticia_id FROM favoritos WHERE session_id = %s",
|
||||||
|
(session_id,)
|
||||||
|
)
|
||||||
|
ids = [row[0] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
return jsonify({"ids": ids})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Page: View Favorites
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@favoritos_bp.route("/")
|
||||||
|
def view_favorites():
|
||||||
|
"""View all favorited news items."""
|
||||||
|
user_id, session_id = get_user_or_session_id()
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
if user_id:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url,
|
||||||
|
n.fuente_nombre, c.nombre AS categoria, p.nombre AS pais,
|
||||||
|
t.titulo_trad, t.resumen_trad, t.lang_to,
|
||||||
|
f.created_at AS favorito_at
|
||||||
|
FROM favoritos f
|
||||||
|
JOIN noticias n ON n.id = f.noticia_id
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es' AND t.status = 'done'
|
||||||
|
WHERE f.user_id = %s
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT 100;
|
||||||
|
""", (user_id,))
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url,
|
||||||
|
n.fuente_nombre, c.nombre AS categoria, p.nombre AS pais,
|
||||||
|
t.titulo_trad, t.resumen_trad, t.lang_to,
|
||||||
|
f.created_at AS favorito_at
|
||||||
|
FROM favoritos f
|
||||||
|
JOIN noticias n ON n.id = f.noticia_id
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es' AND t.status = 'done'
|
||||||
|
WHERE f.session_id = %s
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT 100;
|
||||||
|
""", (session_id,))
|
||||||
|
noticias = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("favoritos.html", noticias=noticias, user=user)
|
||||||
428
routers/feeds.py
Normal file
428
routers/feeds.py
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
from flask import Blueprint, render_template, request, redirect, flash, url_for, jsonify
|
||||||
|
from db import get_conn
|
||||||
|
from psycopg2 import extras
|
||||||
|
from models.categorias import get_categorias
|
||||||
|
from models.paises import get_paises
|
||||||
|
from utils.feed_discovery import discover_feeds, validate_feed, get_feed_metadata
|
||||||
|
|
||||||
|
# Blueprint correcto
|
||||||
|
feeds_bp = Blueprint("feeds", __name__, url_prefix="/feeds")
|
||||||
|
|
||||||
|
|
||||||
|
@feeds_bp.route("/")
|
||||||
|
def list_feeds():
|
||||||
|
"""Listado con filtros"""
|
||||||
|
page = max(int(request.args.get("page", 1)), 1)
|
||||||
|
per_page = 50
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
pais_id = request.args.get("pais_id")
|
||||||
|
categoria_id = request.args.get("categoria_id")
|
||||||
|
estado = request.args.get("estado") or ""
|
||||||
|
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if pais_id:
|
||||||
|
where.append("f.pais_id = %s")
|
||||||
|
params.append(int(pais_id))
|
||||||
|
|
||||||
|
if categoria_id:
|
||||||
|
where.append("f.categoria_id = %s")
|
||||||
|
params.append(int(categoria_id))
|
||||||
|
|
||||||
|
if estado == "activos":
|
||||||
|
where.append("f.activo = TRUE")
|
||||||
|
elif estado == "inactivos":
|
||||||
|
where.append("f.activo = FALSE")
|
||||||
|
elif estado == "errores":
|
||||||
|
where.append("COALESCE(f.fallos, 0) > 0")
|
||||||
|
|
||||||
|
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
||||||
|
|
||||||
|
with get_conn() as conn, conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
|
||||||
|
# Total
|
||||||
|
# Total
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM feeds f {where_sql}", params)
|
||||||
|
total_feeds = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Caídos (Inactivos o con max fallos logic check, usually inactive is enough if logic works)
|
||||||
|
# Using the same filter context to see how many of THESE are fallen
|
||||||
|
# Caídos (Inactivos o con max fallos logic check)
|
||||||
|
# Using the same filter context to see how many of THESE are fallen
|
||||||
|
|
||||||
|
caidos_condition = "(f.activo = FALSE OR f.fallos >= 5)"
|
||||||
|
|
||||||
|
if where_sql:
|
||||||
|
# where_sql ya incluye "WHERE ..."
|
||||||
|
caidos_sql = f"SELECT COUNT(*) FROM feeds f {where_sql} AND {caidos_condition}"
|
||||||
|
else:
|
||||||
|
caidos_sql = f"SELECT COUNT(*) FROM feeds f WHERE {caidos_condition}"
|
||||||
|
|
||||||
|
cur.execute(caidos_sql, params)
|
||||||
|
feeds_caidos = cur.fetchone()[0]
|
||||||
|
|
||||||
|
total_pages = (total_feeds // per_page) + (1 if total_feeds % per_page else 0)
|
||||||
|
|
||||||
|
# Lista paginada
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
f.id, f.nombre, f.descripcion, f.url,
|
||||||
|
f.activo, f.fallos, f.last_error,
|
||||||
|
c.nombre AS categoria,
|
||||||
|
p.nombre AS pais,
|
||||||
|
(SELECT COUNT(*) FROM noticias n WHERE n.fuente_nombre = f.nombre) as noticias_count
|
||||||
|
FROM feeds f
|
||||||
|
LEFT JOIN categorias c ON c.id = f.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = f.pais_id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY p.nombre NULLS LAST, f.activo DESC, f.fallos ASC, c.nombre NULLS LAST, f.nombre
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""",
|
||||||
|
params + [per_page, offset],
|
||||||
|
)
|
||||||
|
feeds = cur.fetchall()
|
||||||
|
|
||||||
|
# Selects
|
||||||
|
cur.execute("SELECT id, nombre FROM categorias ORDER BY nombre;")
|
||||||
|
categorias = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute("SELECT id, nombre FROM paises ORDER BY nombre;")
|
||||||
|
paises = cur.fetchall()
|
||||||
|
|
||||||
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
|
return render_template(
|
||||||
|
"_feeds_table.html",
|
||||||
|
feeds=feeds,
|
||||||
|
total_feeds=total_feeds,
|
||||||
|
feeds_caidos=feeds_caidos,
|
||||||
|
total_pages=total_pages,
|
||||||
|
page=page,
|
||||||
|
filtro_pais_id=pais_id,
|
||||||
|
filtro_categoria_id=categoria_id,
|
||||||
|
filtro_estado=estado,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"feeds_list.html",
|
||||||
|
feeds=feeds,
|
||||||
|
total_feeds=total_feeds,
|
||||||
|
feeds_caidos=feeds_caidos,
|
||||||
|
total_pages=total_pages,
|
||||||
|
page=page,
|
||||||
|
categorias=categorias,
|
||||||
|
paises=paises,
|
||||||
|
filtro_pais_id=pais_id,
|
||||||
|
filtro_categoria_id=categoria_id,
|
||||||
|
filtro_estado=estado,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@feeds_bp.route("/add", methods=["GET", "POST"])
|
||||||
|
def add_feed():
|
||||||
|
"""Añadir feed"""
|
||||||
|
with get_conn() as conn:
|
||||||
|
categorias = get_categorias(conn)
|
||||||
|
paises = get_paises(conn)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
nombre = request.form.get("nombre")
|
||||||
|
descripcion = request.form.get("descripcion") or None
|
||||||
|
url = request.form.get("url")
|
||||||
|
categoria_id = request.form.get("categoria_id")
|
||||||
|
pais_id = request.form.get("pais_id")
|
||||||
|
idioma = (request.form.get("idioma") or "").strip().lower()[:2] or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
nombre,
|
||||||
|
descripcion,
|
||||||
|
url,
|
||||||
|
int(categoria_id) if categoria_id else None,
|
||||||
|
int(pais_id) if pais_id else None,
|
||||||
|
idioma,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
flash("Feed añadido correctamente.", "success")
|
||||||
|
return redirect(url_for("feeds.list_feeds"))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error al añadir feed: {e}", "error")
|
||||||
|
|
||||||
|
return render_template("add_feed.html", categorias=categorias, paises=paises)
|
||||||
|
|
||||||
|
|
||||||
|
@feeds_bp.route("/<int:feed_id>/edit", methods=["GET", "POST"])
|
||||||
|
def edit_feed(feed_id):
|
||||||
|
"""Editar feed"""
|
||||||
|
with get_conn() as conn, conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
|
||||||
|
cur.execute("SELECT * FROM feeds WHERE id = %s;", (feed_id,))
|
||||||
|
feed = cur.fetchone()
|
||||||
|
|
||||||
|
if not feed:
|
||||||
|
flash("Feed no encontrado.", "error")
|
||||||
|
return redirect(url_for("feeds.list_feeds"))
|
||||||
|
|
||||||
|
categorias = get_categorias(conn)
|
||||||
|
paises = get_paises(conn)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
nombre = request.form.get("nombre")
|
||||||
|
descripcion = request.form.get("descripcion") or None
|
||||||
|
url = request.form.get("url")
|
||||||
|
categoria_id = request.form.get("categoria_id")
|
||||||
|
pais_id = request.form.get("pais_id")
|
||||||
|
idioma = (request.form.get("idioma") or "").strip().lower()[:2] or None
|
||||||
|
activo = bool(request.form.get("activo"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE feeds
|
||||||
|
SET nombre=%s, descripcion=%s, url=%s,
|
||||||
|
categoria_id=%s, pais_id=%s, idioma=%s, activo=%s
|
||||||
|
WHERE id=%s;
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
nombre,
|
||||||
|
descripcion,
|
||||||
|
url,
|
||||||
|
int(categoria_id) if categoria_id else None,
|
||||||
|
int(pais_id) if pais_id else None,
|
||||||
|
idioma,
|
||||||
|
activo,
|
||||||
|
feed_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
flash("Feed actualizado.", "success")
|
||||||
|
return redirect(url_for("feeds.list_feeds"))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error al actualizar: {e}", "error")
|
||||||
|
|
||||||
|
return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises)
|
||||||
|
|
||||||
|
|
||||||
|
@feeds_bp.route("/<int:feed_id>/delete")
|
||||||
|
def delete_feed(feed_id):
|
||||||
|
"""Eliminar feed"""
|
||||||
|
with get_conn() as conn, conn.cursor() as cur:
|
||||||
|
try:
|
||||||
|
cur.execute("DELETE FROM feeds WHERE id=%s;", (feed_id,))
|
||||||
|
conn.commit()
|
||||||
|
flash("Feed eliminado.", "success")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"No se pudo eliminar: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("feeds.list_feeds"))
|
||||||
|
|
||||||
|
|
||||||
|
@feeds_bp.route("/<int:feed_id>/reactivar")
|
||||||
|
def reactivar_feed(feed_id):
|
||||||
|
"""Reactivar feed KO"""
|
||||||
|
with get_conn() as conn, conn.cursor() as cur:
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE feeds SET activo=TRUE, fallos=0 WHERE id=%s;",
|
||||||
|
(feed_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
flash("Feed reactivado.", "success")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"No se pudo reactivar: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("feeds.list_feeds"))
|
||||||
|
|
||||||
|
|
||||||
|
@feeds_bp.route("/discover", methods=["GET", "POST"])
|
||||||
|
def discover_feed():
|
||||||
|
"""Descubrir feeds RSS desde una URL"""
|
||||||
|
discovered_feeds = []
|
||||||
|
source_url = ""
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
categorias = get_categorias(conn)
|
||||||
|
paises = get_paises(conn)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
source_url = request.form.get("source_url", "").strip()
|
||||||
|
|
||||||
|
if not source_url:
|
||||||
|
flash("Por favor, ingresa una URL válida.", "error")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Discover feeds from the URL
|
||||||
|
discovered_feeds = discover_feeds(source_url, timeout=15)
|
||||||
|
|
||||||
|
if not discovered_feeds:
|
||||||
|
flash(f"No se encontraron feeds RSS en la URL: {source_url}", "warning")
|
||||||
|
else:
|
||||||
|
# Check which feeds already exist in DB
|
||||||
|
found_urls = [f['url'] for f in discovered_feeds]
|
||||||
|
existing_urls = set()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT url FROM feeds WHERE url = ANY(%s)", (found_urls,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
existing_urls = {r[0] for r in rows}
|
||||||
|
except Exception as db_e:
|
||||||
|
# Fallback if DB fails, though unlikely
|
||||||
|
print(f"Error checking existing feeds: {db_e}")
|
||||||
|
|
||||||
|
for feed in discovered_feeds:
|
||||||
|
feed['exists'] = feed['url'] in existing_urls
|
||||||
|
|
||||||
|
new_count = len(discovered_feeds) - len(existing_urls)
|
||||||
|
flash(f"Feeds disponibles: {new_count} de {len(discovered_feeds)} encontrados.", "success")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error al descubrir feeds: {e}", "error")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"discover_feeds.html",
|
||||||
|
discovered_feeds=discovered_feeds,
|
||||||
|
source_url=source_url,
|
||||||
|
categorias=categorias,
|
||||||
|
paises=paises
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@feeds_bp.route("/discover_and_add", methods=["POST"])
|
||||||
|
def discover_and_add():
|
||||||
|
"""Añadir múltiples feeds descubiertos"""
|
||||||
|
selected_feeds = request.form.getlist("selected_feeds")
|
||||||
|
categoria_id = request.form.get("categoria_id")
|
||||||
|
pais_id = request.form.get("pais_id")
|
||||||
|
idioma = (request.form.get("idioma") or "").strip().lower()[:2] or None
|
||||||
|
|
||||||
|
if not selected_feeds:
|
||||||
|
flash("No se seleccionó ningún feed.", "warning")
|
||||||
|
return redirect(url_for("feeds.discover_feed"))
|
||||||
|
|
||||||
|
added_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
for feed_url in selected_feeds:
|
||||||
|
try:
|
||||||
|
# Get individual settings for this feed
|
||||||
|
# The form uses the feed URL as part of the field name
|
||||||
|
item_cat_id = request.form.get(f"cat_{feed_url}")
|
||||||
|
item_country_id = request.form.get(f"country_{feed_url}")
|
||||||
|
item_lang = request.form.get(f"lang_{feed_url}")
|
||||||
|
|
||||||
|
# Get feed metadata
|
||||||
|
metadata = get_feed_metadata(feed_url, timeout=10)
|
||||||
|
|
||||||
|
if not metadata:
|
||||||
|
errors.append(f"No se pudo obtener metadata del feed: {feed_url}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use context title from discovery if available, otherwise use metadata title
|
||||||
|
context_title = request.form.get(f"context_{feed_url}")
|
||||||
|
nombre = context_title if context_title else metadata.get('title', 'Feed sin título')
|
||||||
|
|
||||||
|
descripcion = metadata.get('description', '')
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (url) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
nombre,
|
||||||
|
descripcion[:500] if descripcion else None,
|
||||||
|
feed_url,
|
||||||
|
int(item_cat_id) if item_cat_id else None,
|
||||||
|
int(item_country_id) if item_country_id else None,
|
||||||
|
(item_lang or "").strip().lower()[:2] or None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
added_count += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Error al añadir {feed_url}: {e}")
|
||||||
|
|
||||||
|
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
||||||
|
|
||||||
|
if added_count > 0:
|
||||||
|
msg = f"Se añadieron {added_count} feeds correctamente."
|
||||||
|
if not is_ajax:
|
||||||
|
flash(msg, "success")
|
||||||
|
else:
|
||||||
|
msg = "No se añadieron feeds nuevos."
|
||||||
|
if not is_ajax:
|
||||||
|
# Only flash warning if not ajax, or handle differently
|
||||||
|
if not errors:
|
||||||
|
flash(msg, "warning")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for error in errors[:5]: # Mostrar solo los primeros 5 errores
|
||||||
|
if not is_ajax:
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
if is_ajax:
|
||||||
|
return jsonify({
|
||||||
|
"success": added_count > 0,
|
||||||
|
"added_count": added_count,
|
||||||
|
"message": msg,
|
||||||
|
"errors": errors
|
||||||
|
})
|
||||||
|
|
||||||
|
return redirect(url_for("feeds.list_feeds"))
|
||||||
|
|
||||||
|
|
||||||
|
@feeds_bp.route("/api/validate", methods=["POST"])
|
||||||
|
def api_validate_feed():
|
||||||
|
"""API endpoint para validar una URL de feed"""
|
||||||
|
data = request.get_json()
|
||||||
|
feed_url = data.get("url", "").strip()
|
||||||
|
|
||||||
|
if not feed_url:
|
||||||
|
return jsonify({"error": "URL no proporcionada"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
feed_info = validate_feed(feed_url, timeout=10)
|
||||||
|
|
||||||
|
if not feed_info:
|
||||||
|
return jsonify({"error": "No se pudo validar el feed"}), 400
|
||||||
|
|
||||||
|
return jsonify(feed_info), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@feeds_bp.route("/api/discover", methods=["POST"])
|
||||||
|
def api_discover_feeds():
|
||||||
|
"""API endpoint para descubrir feeds desde una URL"""
|
||||||
|
data = request.get_json()
|
||||||
|
source_url = data.get("url", "").strip()
|
||||||
|
|
||||||
|
if not source_url:
|
||||||
|
return jsonify({"error": "URL no proporcionada"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
discovered = discover_feeds(source_url, timeout=15)
|
||||||
|
return jsonify({"feeds": discovered, "count": len(discovered)}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
294
routers/home.py
Normal file
294
routers/home.py
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from datetime import datetime
|
||||||
|
from psycopg2 import extras
|
||||||
|
from db import get_read_conn, get_write_conn
|
||||||
|
from utils.auth import get_current_user
|
||||||
|
from config import DEFAULT_TRANSLATION_LANG, DEFAULT_LANG, NEWS_PER_PAGE_DEFAULT
|
||||||
|
from models.categorias import get_categorias
|
||||||
|
from models.paises import get_paises
|
||||||
|
from models.noticias import buscar_noticias, buscar_noticias_semantica
|
||||||
|
from cache import cached
|
||||||
|
|
||||||
|
home_bp = Blueprint("home", __name__)
|
||||||
|
|
||||||
|
@home_bp.route("/")
|
||||||
|
@home_bp.route("/home")
|
||||||
|
def home():
|
||||||
|
page = max(int(request.args.get("page", 1)), 1)
|
||||||
|
per_page = int(request.args.get("per_page", NEWS_PER_PAGE_DEFAULT))
|
||||||
|
per_page = min(max(per_page, 10), 100)
|
||||||
|
|
||||||
|
q = (request.args.get("q") or "").strip()
|
||||||
|
categoria_id = request.args.get("categoria_id")
|
||||||
|
continente_id = request.args.get("continente_id")
|
||||||
|
pais_id = request.args.get("pais_id")
|
||||||
|
fecha_str = request.args.get("fecha") or ""
|
||||||
|
|
||||||
|
lang = (request.args.get("lang") or DEFAULT_TRANSLATION_LANG or DEFAULT_LANG).lower()[:5]
|
||||||
|
|
||||||
|
use_tr = not bool(request.args.get("orig"))
|
||||||
|
fecha_str = request.args.get("fecha") or ""
|
||||||
|
fecha_filtro = None
|
||||||
|
if fecha_str:
|
||||||
|
try:
|
||||||
|
fecha_filtro = datetime.strptime(fecha_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
fecha_filtro = None
|
||||||
|
|
||||||
|
from utils.qdrant_search import semantic_search
|
||||||
|
|
||||||
|
# Logic for semantic search enabled by default if query exists, unless explicitly disabled
|
||||||
|
# If the user passed 'semantic=' explicitly as empty string, it might mean False, but for UX speed default to True is better.
|
||||||
|
# However, let's respect the flag if it's explicitly 'false' or '0'.
|
||||||
|
# If key is missing, default to True. If key is present but empty, treat as False (standard HTML form behavior unfortunately).
|
||||||
|
# But wait, the previous log showed 'semantic='. HTML checkboxes send nothing if unchecked, 'on' if checked.
|
||||||
|
# So if it appears as empty string, it might be a hidden input or unassigned var.
|
||||||
|
# Let's check 'semantic' param presence.
|
||||||
|
raw_semantic = request.args.get("semantic")
|
||||||
|
if raw_semantic is None:
|
||||||
|
use_semantic = True # Default to semantic if not specified
|
||||||
|
elif raw_semantic == "" or raw_semantic.lower() in ["false", "0", "off"]:
|
||||||
|
use_semantic = False
|
||||||
|
else:
|
||||||
|
use_semantic = True
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
conn.autocommit = True
|
||||||
|
categorias = get_categorias(conn)
|
||||||
|
paises = get_paises(conn)
|
||||||
|
|
||||||
|
noticias = []
|
||||||
|
total_results = 0
|
||||||
|
total_pages = 0
|
||||||
|
tags_por_tr = {}
|
||||||
|
|
||||||
|
# 1. Intentar búsqueda semántica si hay query y está habilitado
|
||||||
|
semantic_success = False
|
||||||
|
if use_semantic and q:
|
||||||
|
try:
|
||||||
|
# Obtener más resultados para 'llenar' la página si hay IDs no encontrados
|
||||||
|
limit_fetch = per_page * 2
|
||||||
|
|
||||||
|
sem_results = semantic_search(
|
||||||
|
query=q,
|
||||||
|
limit=limit_fetch, # Pedimos más para asegurar
|
||||||
|
score_threshold=0.30
|
||||||
|
)
|
||||||
|
|
||||||
|
if sem_results:
|
||||||
|
# Extraer IDs
|
||||||
|
news_ids = [r['news_id'] for r in sem_results]
|
||||||
|
|
||||||
|
# Traer datos completos de PostgreSQL (igual que en search.py)
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
query_sql = """
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
n.titulo,
|
||||||
|
n.resumen,
|
||||||
|
n.url,
|
||||||
|
n.fecha,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fuente_nombre,
|
||||||
|
c.nombre AS categoria,
|
||||||
|
p.nombre AS pais,
|
||||||
|
|
||||||
|
-- traducciones
|
||||||
|
t.id AS traduccion_id,
|
||||||
|
t.titulo_trad AS titulo_traducido,
|
||||||
|
t.resumen_trad AS resumen_traducido,
|
||||||
|
CASE WHEN t.id IS NOT NULL THEN TRUE ELSE FALSE END AS tiene_traduccion,
|
||||||
|
|
||||||
|
-- originales
|
||||||
|
n.titulo AS titulo_original,
|
||||||
|
n.resumen AS resumen_original
|
||||||
|
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
LEFT JOIN traducciones t
|
||||||
|
ON t.noticia_id = n.id
|
||||||
|
AND t.lang_to = %s
|
||||||
|
AND t.status = 'done'
|
||||||
|
WHERE n.id = ANY(%s)
|
||||||
|
"""
|
||||||
|
cur.execute(query_sql, (lang, news_ids))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Convertimos a lista para poder ordenar por fecha
|
||||||
|
rows_list = list(rows)
|
||||||
|
|
||||||
|
# Ordenar cronológicamente (más reciente primero)
|
||||||
|
sorted_rows = sorted(
|
||||||
|
rows_list,
|
||||||
|
key=lambda x: x['fecha'] if x['fecha'] else datetime.min,
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aplicar paginación manual sobre los resultados ordenados
|
||||||
|
# Nota: semantic_search ya devolvió los "top" globales (aproximadamente).
|
||||||
|
# Para paginación real profunda con Qdrant se necesita scroll/offset,
|
||||||
|
# aquí asumimos que page request mapea al limit/offset enviado a Qdrant.
|
||||||
|
# Pero `semantic_search` simple en utils no tiene offset.
|
||||||
|
# Arreglo temporal: Solo mostramos la primera "tanda" de resultados semánticos.
|
||||||
|
# Si el usuario quiere paginar profundo, Qdrant search debe soportar offset.
|
||||||
|
# utils/qdrant_search.py NO tiene offset.
|
||||||
|
# ASÍ QUE: Solo funcionará bien para la página 1.
|
||||||
|
# Si page > 1, semantic_search simple no sirve sin offset.
|
||||||
|
|
||||||
|
# Fallback: Si page > 1, usamos búsqueda tradicional O implementamos offset en Qdrant (mejor).
|
||||||
|
# Por ahora: Usamos lo que devolvió semantic_search y cortamos localmente
|
||||||
|
# si page=1.
|
||||||
|
|
||||||
|
if len(sorted_rows) > 0:
|
||||||
|
noticias = sorted_rows
|
||||||
|
total_results = len(noticias) # Aproximado
|
||||||
|
total_pages = 1 # Qdrant simple no pagina bien aun
|
||||||
|
|
||||||
|
# Extraer tags
|
||||||
|
tr_ids = [n["traduccion_id"] for n in noticias if n["traduccion_id"]]
|
||||||
|
from models.noticias import _extraer_tags_por_traduccion
|
||||||
|
tags_por_tr = _extraer_tags_por_traduccion(cur, tr_ids)
|
||||||
|
|
||||||
|
semantic_success = True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error en semántica home, fallback: {e}")
|
||||||
|
semantic_success = False
|
||||||
|
|
||||||
|
# 2. Si no hubo búsqueda semántica (o falló, o no había query, o usuario la desactivó), usar la tradicional
|
||||||
|
if not semantic_success:
|
||||||
|
noticias, total_results, total_pages, tags_por_tr = buscar_noticias(
|
||||||
|
conn=conn,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
q=q,
|
||||||
|
categoria_id=categoria_id,
|
||||||
|
continente_id=continente_id,
|
||||||
|
pais_id=pais_id,
|
||||||
|
fecha=fecha_filtro,
|
||||||
|
lang=lang,
|
||||||
|
use_tr=use_tr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record search history for logged-in users (only on first page to avoid dupes)
|
||||||
|
if (q or categoria_id or pais_id) and page == 1:
|
||||||
|
user = get_current_user()
|
||||||
|
if user:
|
||||||
|
try:
|
||||||
|
with get_write_conn() as w_conn:
|
||||||
|
with w_conn.cursor() as w_cur:
|
||||||
|
# Check if it's the same as the last search to avoid immediate duplicates
|
||||||
|
w_cur.execute("""
|
||||||
|
SELECT query, pais_id, categoria_id
|
||||||
|
FROM search_history
|
||||||
|
WHERE user_id = %s
|
||||||
|
ORDER BY searched_at DESC LIMIT 1
|
||||||
|
""", (user['id'],))
|
||||||
|
last_search = w_cur.fetchone()
|
||||||
|
|
||||||
|
current_search = (q or None, int(pais_id) if pais_id else None, int(categoria_id) if categoria_id else None)
|
||||||
|
|
||||||
|
if not last_search or (last_search[0], last_search[1], last_search[2]) != current_search:
|
||||||
|
w_cur.execute("""
|
||||||
|
INSERT INTO search_history (user_id, query, pais_id, categoria_id, results_count)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
""", (user['id'], current_search[0], current_search[1], current_search[2], total_results))
|
||||||
|
w_conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't break the page load
|
||||||
|
print(f"Error saving search history: {e}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
user = get_current_user()
|
||||||
|
recent_searches_with_results = []
|
||||||
|
if user and not q and not categoria_id and not pais_id and page == 1:
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Fetch unique latest searches using DISTINCT ON
|
||||||
|
cur.execute("""
|
||||||
|
SELECT sub.id, query, pais_id, categoria_id, results_count, searched_at,
|
||||||
|
p.nombre as pais_nombre, c.nombre as categoria_nombre
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (COALESCE(query, ''), COALESCE(pais_id, 0), COALESCE(categoria_id, 0))
|
||||||
|
id, query, pais_id, categoria_id, results_count, searched_at
|
||||||
|
FROM search_history
|
||||||
|
WHERE user_id = %s
|
||||||
|
ORDER BY COALESCE(query, ''), COALESCE(pais_id, 0), COALESCE(categoria_id, 0), searched_at DESC
|
||||||
|
) sub
|
||||||
|
LEFT JOIN paises p ON p.id = sub.pais_id
|
||||||
|
LEFT JOIN categorias c ON c.id = sub.categoria_id
|
||||||
|
ORDER BY searched_at DESC
|
||||||
|
LIMIT 6
|
||||||
|
""", (user['id'],))
|
||||||
|
recent_searches = cur.fetchall()
|
||||||
|
|
||||||
|
for s in recent_searches:
|
||||||
|
# Fetch top 6 news for this search
|
||||||
|
news_items, _, _, _ = buscar_noticias(
|
||||||
|
conn=conn,
|
||||||
|
page=1,
|
||||||
|
per_page=6,
|
||||||
|
q=s['query'] or "",
|
||||||
|
pais_id=s['pais_id'],
|
||||||
|
categoria_id=s['categoria_id'],
|
||||||
|
lang=lang,
|
||||||
|
use_tr=use_tr,
|
||||||
|
skip_count=True
|
||||||
|
)
|
||||||
|
recent_searches_with_results.append({
|
||||||
|
'id': s['id'],
|
||||||
|
'query': s['query'],
|
||||||
|
'pais_id': s['pais_id'],
|
||||||
|
'pais_nombre': s['pais_nombre'],
|
||||||
|
'categoria_id': s['categoria_id'],
|
||||||
|
'categoria_nombre': s['categoria_nombre'],
|
||||||
|
'results_count': s['results_count'],
|
||||||
|
'searched_at': s['searched_at'],
|
||||||
|
'noticias': news_items
|
||||||
|
})
|
||||||
|
|
||||||
|
context = dict(
|
||||||
|
noticias=noticias,
|
||||||
|
total_results=total_results,
|
||||||
|
total_pages=total_pages,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
categorias=categorias,
|
||||||
|
paises=paises,
|
||||||
|
q=q,
|
||||||
|
cat_id=int(categoria_id) if categoria_id else None,
|
||||||
|
pais_id=int(pais_id) if pais_id else None,
|
||||||
|
fecha_filtro=fecha_str,
|
||||||
|
lang=lang,
|
||||||
|
use_tr=use_tr,
|
||||||
|
use_semantic=use_semantic,
|
||||||
|
tags_por_tr=tags_por_tr,
|
||||||
|
recent_searches_with_results=recent_searches_with_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
|
return render_template("_noticias_list.html", **context)
|
||||||
|
|
||||||
|
return render_template("noticias.html", **context)
|
||||||
|
|
||||||
|
|
||||||
|
@home_bp.route("/delete_search/<int:search_id>", methods=["POST"])
|
||||||
|
def delete_search(search_id):
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return {"error": "No autenticado"}, 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_write_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Direct deletion ensuring ownership
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM search_history WHERE id = %s AND user_id = %s",
|
||||||
|
(search_id, user["id"])
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting search {search_id}: {e}")
|
||||||
|
return {"error": str(e)}, 500
|
||||||
116
routers/noticia.py
Normal file
116
routers/noticia.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
from flask import Blueprint, render_template, request, redirect, flash, url_for
|
||||||
|
from db import get_read_conn
|
||||||
|
from psycopg2 import extras
|
||||||
|
|
||||||
|
noticia_bp = Blueprint("noticia", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@noticia_bp.route("/noticia")
|
||||||
|
def noticia():
|
||||||
|
tr_id = request.args.get("tr_id")
|
||||||
|
noticia_id = request.args.get("id")
|
||||||
|
|
||||||
|
if not tr_id and not noticia_id:
|
||||||
|
flash("No se ha indicado ninguna noticia.", "warning")
|
||||||
|
return redirect(url_for("home.home"))
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
conn.autocommit = True
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
dato = None
|
||||||
|
|
||||||
|
if tr_id:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.id AS traduccion_id,
|
||||||
|
t.lang_from,
|
||||||
|
t.lang_to,
|
||||||
|
t.titulo_trad,
|
||||||
|
t.resumen_trad,
|
||||||
|
n.id AS noticia_id,
|
||||||
|
n.titulo AS titulo_orig,
|
||||||
|
n.resumen AS resumen_orig,
|
||||||
|
n.url,
|
||||||
|
n.fecha,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fuente_nombre,
|
||||||
|
c.nombre AS categoria,
|
||||||
|
p.nombre AS pais
|
||||||
|
FROM traducciones t
|
||||||
|
JOIN noticias n ON n.id = t.noticia_id
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
WHERE t.id = %s
|
||||||
|
""",
|
||||||
|
(int(tr_id),),
|
||||||
|
)
|
||||||
|
dato = cur.fetchone()
|
||||||
|
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
NULL AS traduccion_id,
|
||||||
|
NULL AS lang_from,
|
||||||
|
NULL AS lang_to,
|
||||||
|
NULL AS titulo_trad,
|
||||||
|
NULL AS resumen_trad,
|
||||||
|
n.id AS noticia_id,
|
||||||
|
n.titulo AS titulo_orig,
|
||||||
|
n.resumen AS resumen_orig,
|
||||||
|
n.url,
|
||||||
|
n.fecha,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fuente_nombre,
|
||||||
|
c.nombre AS categoria,
|
||||||
|
p.nombre AS pais
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
WHERE n.id = %s
|
||||||
|
""",
|
||||||
|
(noticia_id,),
|
||||||
|
)
|
||||||
|
dato = cur.fetchone()
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
relacionadas = []
|
||||||
|
|
||||||
|
if dato and dato["traduccion_id"]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT tg.valor, tg.tipo
|
||||||
|
FROM tags_noticia tn
|
||||||
|
JOIN tags tg ON tg.id = tn.tag_id
|
||||||
|
WHERE tn.traduccion_id = %s
|
||||||
|
ORDER BY tg.tipo, tg.valor;
|
||||||
|
""",
|
||||||
|
(dato["traduccion_id"],),
|
||||||
|
)
|
||||||
|
tags = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
n2.url,
|
||||||
|
n2.titulo,
|
||||||
|
n2.fecha,
|
||||||
|
n2.imagen_url,
|
||||||
|
n2.fuente_nombre,
|
||||||
|
rn.score,
|
||||||
|
t2.titulo_trad,
|
||||||
|
t2.id AS related_tr_id
|
||||||
|
FROM related_noticias rn
|
||||||
|
JOIN traducciones t2 ON t2.id = rn.related_traduccion_id
|
||||||
|
JOIN noticias n2 ON n2.id = t2.noticia_id
|
||||||
|
WHERE rn.traduccion_id = %s
|
||||||
|
ORDER BY rn.score DESC
|
||||||
|
LIMIT 8;
|
||||||
|
""",
|
||||||
|
(dato["traduccion_id"],),
|
||||||
|
)
|
||||||
|
relacionadas = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("noticia.html", dato=dato, tags=tags, relacionadas=relacionadas)
|
||||||
|
|
||||||
44
routers/notifications.py
Normal file
44
routers/notifications.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""
|
||||||
|
Notifications router - Check for new important news.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from db import get_conn
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
notifications_bp = Blueprint("notifications", __name__, url_prefix="/api/notifications")
|
||||||
|
|
||||||
|
@notifications_bp.route("/check")
|
||||||
|
def check_notifications():
|
||||||
|
"""Check for new news since a given timestamp."""
|
||||||
|
last_check = request.args.get("last_check")
|
||||||
|
|
||||||
|
if not last_check:
|
||||||
|
return jsonify({"has_news": False, "timestamp": datetime.utcnow().isoformat()})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for news created after last_check
|
||||||
|
# We define "important" as having translation or high score (if score existed)
|
||||||
|
# For now, just any new news to demonstrate functionality
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*), MAX(fecha)
|
||||||
|
FROM noticias
|
||||||
|
WHERE fecha > %s
|
||||||
|
""", (last_check,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
count = row[0]
|
||||||
|
latest = row[1]
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
return jsonify({
|
||||||
|
"has_news": True,
|
||||||
|
"count": count,
|
||||||
|
"timestamp": latest.isoformat() if latest else datetime.utcnow().isoformat(),
|
||||||
|
"message": f"¡{count} noticias nuevas encontradas!"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error checking notifications: {e}")
|
||||||
|
|
||||||
|
return jsonify({"has_news": False, "timestamp": datetime.utcnow().isoformat()})
|
||||||
325
routers/parrillas.py
Normal file
325
routers/parrillas.py
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
"""
|
||||||
|
Router para gestionar parrill
|
||||||
|
|
||||||
|
as de videos de noticias.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash
|
||||||
|
from db import get_conn
|
||||||
|
from psycopg2 import extras
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
parrillas_bp = Blueprint("parrillas", __name__, url_prefix="/parrillas")
|
||||||
|
|
||||||
|
|
||||||
|
@parrillas_bp.route("/")
|
||||||
|
def index():
|
||||||
|
"""Dashboard principal de parrillas."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Obtener todas las parrillas
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
p.*,
|
||||||
|
pa.nombre as pais_nombre,
|
||||||
|
c.nombre as categoria_nombre,
|
||||||
|
(SELECT COUNT(*) FROM video_generados WHERE parrilla_id = p.id) as total_videos
|
||||||
|
FROM video_parrillas p
|
||||||
|
LEFT JOIN paises pa ON pa.id = p.pais_id
|
||||||
|
LEFT JOIN categorias c ON c.id = p.categoria_id
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
""")
|
||||||
|
parrillas = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("parrillas/index.html", parrillas=parrillas)
|
||||||
|
|
||||||
|
|
||||||
|
@parrillas_bp.route("/nueva", methods=["GET", "POST"])
|
||||||
|
def nueva():
|
||||||
|
"""Crear una nueva parrilla."""
|
||||||
|
if request.method == "GET":
|
||||||
|
# Cargar datos para el formulario
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute("SELECT id, nombre FROM paises ORDER BY nombre")
|
||||||
|
paises = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
|
||||||
|
categorias = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("parrillas/form.html",
|
||||||
|
paises=paises,
|
||||||
|
categorias=categorias)
|
||||||
|
|
||||||
|
# POST: Crear parrilla
|
||||||
|
try:
|
||||||
|
data = request.form
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO video_parrillas (
|
||||||
|
nombre, descripcion, tipo_filtro,
|
||||||
|
pais_id, categoria_id, entidad_nombre, entidad_tipo,
|
||||||
|
max_noticias, duracion_maxima, idioma_voz,
|
||||||
|
template, include_images, include_subtitles,
|
||||||
|
frecuencia, activo
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
||||||
|
) RETURNING id
|
||||||
|
""", (
|
||||||
|
data.get('nombre'),
|
||||||
|
data.get('descripcion'),
|
||||||
|
data.get('tipo_filtro'),
|
||||||
|
data.get('pais_id') or None,
|
||||||
|
data.get('categoria_id') or None,
|
||||||
|
data.get('entidad_nombre') or None,
|
||||||
|
data.get('entidad_tipo') or None,
|
||||||
|
int(data.get('max_noticias', 5)),
|
||||||
|
int(data.get('duracion_maxima', 180)),
|
||||||
|
data.get('idioma_voz', 'es'),
|
||||||
|
data.get('template', 'standard'),
|
||||||
|
data.get('include_images') == 'on',
|
||||||
|
data.get('include_subtitles') == 'on',
|
||||||
|
data.get('frecuencia', 'manual'),
|
||||||
|
data.get('activo') == 'on'
|
||||||
|
))
|
||||||
|
parrilla_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
flash(f"Parrilla '{data.get('nombre')}' creada exitosamente", "success")
|
||||||
|
return redirect(url_for('parrillas.ver', id=parrilla_id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating parrilla: {e}", exc_info=True)
|
||||||
|
flash(f"Error al crear parrilla: {str(e)}", "error")
|
||||||
|
return redirect(url_for('parrillas.nueva'))
|
||||||
|
|
||||||
|
|
||||||
|
@parrillas_bp.route("/<int:id>")
|
||||||
|
def ver(id):
|
||||||
|
"""Ver detalles de una parrilla específica."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Obtener parrilla
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
p.*,
|
||||||
|
pa.nombre as pais_nombre,
|
||||||
|
c.nombre as categoria_nombre
|
||||||
|
FROM video_parrillas p
|
||||||
|
LEFT JOIN paises pa ON pa.id = p.pais_id
|
||||||
|
LEFT JOIN categorias c ON c.id = p.categoria_id
|
||||||
|
WHERE p.id = %s
|
||||||
|
""", (id,))
|
||||||
|
parrilla = cur.fetchone()
|
||||||
|
|
||||||
|
if not parrilla:
|
||||||
|
flash("Parrilla no encontrada", "error")
|
||||||
|
return redirect(url_for('parrillas.index'))
|
||||||
|
|
||||||
|
# Obtener videos generados
|
||||||
|
cur.execute("""
|
||||||
|
SELECT * FROM video_generados
|
||||||
|
WHERE parrilla_id = %s
|
||||||
|
ORDER BY fecha_generacion DESC
|
||||||
|
LIMIT 50
|
||||||
|
""", (id,))
|
||||||
|
videos = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("parrillas/detail.html", parrilla=parrilla, videos=videos)
|
||||||
|
|
||||||
|
|
||||||
|
@parrillas_bp.route("/api/<int:id>/preview")
|
||||||
|
def preview_noticias(id):
|
||||||
|
"""Preview de noticias que se incluirían en el siguiente video."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Obtener configuración de parrilla
|
||||||
|
cur.execute("SELECT * FROM video_parrillas WHERE id = %s", (id,))
|
||||||
|
parrilla = cur.fetchone()
|
||||||
|
|
||||||
|
if not parrilla:
|
||||||
|
return jsonify({"error": "Parrilla no encontrada"}), 404
|
||||||
|
|
||||||
|
# Construir query según filtros
|
||||||
|
where_clauses = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if parrilla['pais_id']:
|
||||||
|
where_clauses.append("n.pais_id = %s")
|
||||||
|
params.append(parrilla['pais_id'])
|
||||||
|
|
||||||
|
if parrilla['categoria_id']:
|
||||||
|
where_clauses.append("n.categoria_id = %s")
|
||||||
|
params.append(parrilla['categoria_id'])
|
||||||
|
|
||||||
|
if parrilla['entidad_nombre']:
|
||||||
|
# Filtrar por entidad
|
||||||
|
where_clauses.append("""
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM tags_noticia tn
|
||||||
|
JOIN tags t ON t.id = tn.tag_id
|
||||||
|
WHERE tn.traduccion_id = tr.id
|
||||||
|
AND t.tipo = %s
|
||||||
|
AND t.valor ILIKE %s
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
params.append(parrilla['entidad_tipo'])
|
||||||
|
params.append(f"%{parrilla['entidad_nombre']}%")
|
||||||
|
|
||||||
|
# Solo noticias de hoy o ayer
|
||||||
|
where_clauses.append("n.fecha >= NOW() - INTERVAL '1 day'")
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||||
|
|
||||||
|
# Obtener noticias
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
n.titulo,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fecha,
|
||||||
|
tr.titulo_trad,
|
||||||
|
tr.resumen_trad,
|
||||||
|
LENGTH(tr.resumen_trad) as longitud_texto
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN traducciones tr ON tr.noticia_id = n.id AND tr.lang_to = %s AND tr.status = 'done'
|
||||||
|
WHERE {where_sql}
|
||||||
|
AND tr.id IS NOT NULL
|
||||||
|
ORDER BY n.fecha DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", [parrilla['idioma_voz']] + params + [parrilla['max_noticias']])
|
||||||
|
|
||||||
|
noticias = cur.fetchall()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"noticias": [dict(n) for n in noticias],
|
||||||
|
"total": len(noticias),
|
||||||
|
"config": {
|
||||||
|
"max_noticias": parrilla['max_noticias'],
|
||||||
|
"duracion_maxima": parrilla['duracion_maxima']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@parrillas_bp.route("/api/<int:id>/generar", methods=["POST"])
|
||||||
|
def generar_video(id):
|
||||||
|
"""Iniciar generación de video para una parrilla."""
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Verificar que la parrilla existe
|
||||||
|
cur.execute("SELECT * FROM video_parrillas WHERE id = %s", (id,))
|
||||||
|
parrilla = cur.fetchone()
|
||||||
|
|
||||||
|
if not parrilla:
|
||||||
|
return jsonify({"error": "Parrilla no encontrada"}), 404
|
||||||
|
|
||||||
|
# Crear registro de video
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO video_generados (
|
||||||
|
parrilla_id, titulo, descripcion, status
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, 'pending'
|
||||||
|
) RETURNING id
|
||||||
|
""", (
|
||||||
|
id,
|
||||||
|
f"{parrilla['nombre']} - {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||||
|
f"Video generado automáticamente para {parrilla['nombre']}"
|
||||||
|
))
|
||||||
|
video_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Actualizar fecha de última generación
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE video_parrillas
|
||||||
|
SET ultima_generacion = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""", (id,))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Lanzar el proceso de generación en segundo plano
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ejecutamos el script generador pasando el ID de la parrilla
|
||||||
|
# Usamos Popen para no bloquear la respuesta HTTP (fire and forget)
|
||||||
|
cmd = [sys.executable, "generar_videos_noticias.py", str(id)]
|
||||||
|
subprocess.Popen(cmd, cwd="/app")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"video_id": video_id,
|
||||||
|
"message": "Generación de video iniciada en segundo plano"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error queuing video: {e}", exc_info=True)
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@parrillas_bp.route("/api/<int:id>", methods=["DELETE"])
|
||||||
|
def eliminar(id):
|
||||||
|
"""Eliminar una parrilla."""
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM video_parrillas WHERE id = %s", (id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return jsonify({"success": True})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting parrilla: {e}", exc_info=True)
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@parrillas_bp.route("/api/<int:id>/toggle", methods=["POST"])
|
||||||
|
def toggle_activo(id):
|
||||||
|
"""Activar/desactivar una parrilla."""
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE video_parrillas
|
||||||
|
SET activo = NOT activo
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING activo
|
||||||
|
""", (id,))
|
||||||
|
nuevo_estado = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return jsonify({"success": True, "activo": nuevo_estado})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error toggling parrilla: {e}", exc_info=True)
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@parrillas_bp.route("/files/<int:video_id>/<filename>")
|
||||||
|
def serve_file(video_id, filename):
|
||||||
|
"""Servir archivos generados (audio, script, srt)."""
|
||||||
|
from flask import send_from_directory
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Directorio base de videos
|
||||||
|
base_dir = "/app/data/videos"
|
||||||
|
video_dir = os.path.join(base_dir, str(video_id))
|
||||||
|
|
||||||
|
# Validar que sea un archivo permitido para evitar Path Traversal
|
||||||
|
allowed_files = ['audio.wav', 'script.txt', 'subtitles.srt', 'generation.log']
|
||||||
|
if filename not in allowed_files:
|
||||||
|
logger.warning(f"File download attempt blocked: {filename}")
|
||||||
|
return "File not allowed", 403
|
||||||
|
|
||||||
|
full_path = os.path.join(video_dir, filename)
|
||||||
|
if not os.path.exists(full_path):
|
||||||
|
logger.error(f"File not found: {full_path}")
|
||||||
|
return "File not found", 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
return send_from_directory(video_dir, filename)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error serving file {full_path}: {e}")
|
||||||
|
return f"Error serving file: {e}", 500
|
||||||
88
routers/pdf.py
Normal file
88
routers/pdf.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""
|
||||||
|
PDF Export router.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, make_response, render_template, url_for
|
||||||
|
from db import get_conn
|
||||||
|
from psycopg2 import extras
|
||||||
|
from weasyprint import HTML
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
pdf_bp = Blueprint("pdf", __name__, url_prefix="/pdf")
|
||||||
|
|
||||||
|
def clean_text(text):
|
||||||
|
"""Clean text from problematic characters for PDF generation."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# Remove <unk> tokens
|
||||||
|
text = text.replace('<unk>', '')
|
||||||
|
text = text.replace('<EFBFBD>', '')
|
||||||
|
# Remove other problematic Unicode characters
|
||||||
|
text = re.sub(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]', '', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
@pdf_bp.route("/noticia/<noticia_id>")
|
||||||
|
def export_noticia(noticia_id):
|
||||||
|
"""Exportar noticia a PDF."""
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
n.*,
|
||||||
|
t.titulo_trad, t.resumen_trad, t.lang_to,
|
||||||
|
c.nombre as categoria_nombre,
|
||||||
|
p.nombre as pais_nombre
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.status = 'done' AND t.lang_to = 'es'
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
WHERE n.id = %s
|
||||||
|
""", (noticia_id,))
|
||||||
|
noticia = cur.fetchone()
|
||||||
|
|
||||||
|
if not noticia:
|
||||||
|
return "Noticia no encontrada", 404
|
||||||
|
|
||||||
|
# Prepare data for template
|
||||||
|
d = dict(noticia)
|
||||||
|
|
||||||
|
# Use translated content if available and clean it
|
||||||
|
titulo = clean_text(d.get('titulo_trad') or d.get('titulo', ''))
|
||||||
|
resumen = clean_text(d.get('resumen_trad') or d.get('resumen', ''))
|
||||||
|
|
||||||
|
# Don't include external images to avoid SSL/network errors
|
||||||
|
# imagen_url = d.get('imagen_url') if d.get('imagen_url', '').startswith('http') else None
|
||||||
|
|
||||||
|
html_content = render_template(
|
||||||
|
"pdf_template.html",
|
||||||
|
titulo=titulo,
|
||||||
|
resumen=resumen,
|
||||||
|
fecha=d.get('fecha', ''),
|
||||||
|
fuente=d.get('fuente_nombre', ''), # Esta columna existe directamente en noticias
|
||||||
|
categoria=d.get('categoria_nombre', ''),
|
||||||
|
url=d.get('url', ''),
|
||||||
|
imagen_url=None # Disable images for now to avoid errors
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to PDF using WeasyPrint
|
||||||
|
logger.info(f"Generating PDF for noticia {noticia_id}")
|
||||||
|
|
||||||
|
# Create PDF in memory
|
||||||
|
pdf_file = BytesIO()
|
||||||
|
HTML(string=html_content).write_pdf(pdf_file)
|
||||||
|
pdf_bytes = pdf_file.getvalue()
|
||||||
|
|
||||||
|
response = make_response(pdf_bytes)
|
||||||
|
response.headers['Content-Type'] = 'application/pdf'
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename=noticia_{noticia_id}.pdf'
|
||||||
|
logger.info(f"PDF generated successfully for noticia {noticia_id}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generando PDF para noticia {noticia_id}: {str(e)}", exc_info=True)
|
||||||
|
return f"Error generando PDF: {str(e)}", 500
|
||||||
|
|
||||||
76
routers/resumen.py
Normal file
76
routers/resumen.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""
|
||||||
|
Resumen router - Daily summary of news.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from psycopg2 import extras
|
||||||
|
from db import get_conn
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
resumen_bp = Blueprint("resumen", __name__, url_prefix="/resumen")
|
||||||
|
|
||||||
|
|
||||||
|
@resumen_bp.route("/")
|
||||||
|
def diario():
|
||||||
|
"""Daily summary page."""
|
||||||
|
# Default to today
|
||||||
|
date_str = request.args.get("date")
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
target_date = datetime.utcnow().date()
|
||||||
|
else:
|
||||||
|
target_date = datetime.utcnow().date()
|
||||||
|
|
||||||
|
prev_date = target_date - timedelta(days=1)
|
||||||
|
next_date = target_date + timedelta(days=1)
|
||||||
|
|
||||||
|
if next_date > datetime.utcnow().date():
|
||||||
|
next_date = None
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Fetch top news for the day grouped by category
|
||||||
|
# We'll limit to 5 per category to keep it concise
|
||||||
|
cur.execute("""
|
||||||
|
WITH ranked_news AS (
|
||||||
|
SELECT
|
||||||
|
n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url, n.fuente_nombre,
|
||||||
|
c.id as cat_id, c.nombre as categoria,
|
||||||
|
t.titulo_trad, t.resumen_trad,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY n.categoria_id ORDER BY n.fecha DESC) as rn
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id
|
||||||
|
AND t.lang_to = 'es' AND t.status = 'done'
|
||||||
|
WHERE n.fecha >= %s AND n.fecha < %s + INTERVAL '1 day'
|
||||||
|
)
|
||||||
|
SELECT * FROM ranked_news WHERE rn <= 5 ORDER BY categoria, rn
|
||||||
|
""", (target_date, target_date))
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Group by category
|
||||||
|
noticias_by_cat = {}
|
||||||
|
for r in rows:
|
||||||
|
cat = r["categoria"] or "Sin Categoría"
|
||||||
|
if cat not in noticias_by_cat:
|
||||||
|
noticias_by_cat[cat] = []
|
||||||
|
|
||||||
|
noticias_by_cat[cat].append({
|
||||||
|
"id": r["id"],
|
||||||
|
"titulo": r["titulo_trad"] or r["titulo"],
|
||||||
|
"resumen": r["resumen_trad"] or r["resumen"],
|
||||||
|
"url": r["url"],
|
||||||
|
"fecha": r["fecha"],
|
||||||
|
"imagen_url": r["imagen_url"],
|
||||||
|
"fuente": r["fuente_nombre"]
|
||||||
|
})
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"resumen.html",
|
||||||
|
noticias_by_cat=noticias_by_cat,
|
||||||
|
current_date=target_date,
|
||||||
|
prev_date=prev_date,
|
||||||
|
next_date=next_date
|
||||||
|
)
|
||||||
186
routers/rss.py
Normal file
186
routers/rss.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""
|
||||||
|
RSS Feed router - Generate custom RSS feeds with filters.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, Response
|
||||||
|
from psycopg2 import extras
|
||||||
|
from db import get_read_conn
|
||||||
|
from datetime import datetime
|
||||||
|
import html
|
||||||
|
|
||||||
|
rss_bp = Blueprint("rss", __name__, url_prefix="/rss")
|
||||||
|
|
||||||
|
|
||||||
|
def escape_xml(text):
|
||||||
|
"""Escape text for XML."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return html.escape(str(text))
|
||||||
|
|
||||||
|
|
||||||
|
def build_rss_xml(title, description, link, items):
|
||||||
|
"""Build RSS 2.0 XML feed."""
|
||||||
|
now = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||||
|
|
||||||
|
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>{escape_xml(title)}</title>
|
||||||
|
<description>{escape_xml(description)}</description>
|
||||||
|
<link>{escape_xml(link)}</link>
|
||||||
|
<lastBuildDate>{now}</lastBuildDate>
|
||||||
|
<language>es</language>
|
||||||
|
'''
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
pub_date = ""
|
||||||
|
if item.get("fecha"):
|
||||||
|
try:
|
||||||
|
pub_date = item["fecha"].strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
xml += f''' <item>
|
||||||
|
<title>{escape_xml(item.get("titulo", ""))}</title>
|
||||||
|
<description><![CDATA[{item.get("resumen", "")}]]></description>
|
||||||
|
<link>{escape_xml(item.get("url", ""))}</link>
|
||||||
|
<guid isPermaLink="false">{escape_xml(item.get("id", ""))}</guid>
|
||||||
|
<pubDate>{pub_date}</pubDate>
|
||||||
|
</item>
|
||||||
|
'''
|
||||||
|
|
||||||
|
xml += '''</channel>
|
||||||
|
</rss>'''
|
||||||
|
|
||||||
|
return xml
|
||||||
|
|
||||||
|
|
||||||
|
@rss_bp.route("/custom")
|
||||||
|
def custom_feed():
|
||||||
|
"""
|
||||||
|
Generate a custom RSS feed with filters.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- pais_id: Filter by country ID
|
||||||
|
- categoria_id: Filter by category ID
|
||||||
|
- lang: Translation language (default: es)
|
||||||
|
- limit: Number of items (default: 50, max: 100)
|
||||||
|
"""
|
||||||
|
pais_id = request.args.get("pais_id")
|
||||||
|
categoria_id = request.args.get("categoria_id")
|
||||||
|
lang = (request.args.get("lang") or "es").lower()[:5]
|
||||||
|
limit = min(int(request.args.get("limit", 50)), 100)
|
||||||
|
|
||||||
|
# Build description based on filters
|
||||||
|
filters_desc = []
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Get filter names for description
|
||||||
|
if pais_id:
|
||||||
|
cur.execute("SELECT nombre FROM paises WHERE id = %s", (pais_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
filters_desc.append(f"País: {row['nombre']}")
|
||||||
|
|
||||||
|
if categoria_id:
|
||||||
|
cur.execute("SELECT nombre FROM categorias WHERE id = %s", (categoria_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
filters_desc.append(f"Categoría: {row['nombre']}")
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
n.id, n.titulo, n.resumen, n.url, n.fecha,
|
||||||
|
n.imagen_url, n.fuente_nombre,
|
||||||
|
t.titulo_trad, t.resumen_trad
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id
|
||||||
|
AND t.lang_to = %s AND t.status = 'done'
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = [lang]
|
||||||
|
|
||||||
|
if pais_id:
|
||||||
|
query += " AND n.pais_id = %s"
|
||||||
|
params.append(pais_id)
|
||||||
|
|
||||||
|
if categoria_id:
|
||||||
|
query += " AND n.categoria_id = %s"
|
||||||
|
params.append(categoria_id)
|
||||||
|
|
||||||
|
query += " ORDER BY n.fecha DESC LIMIT %s"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
cur.execute(query, tuple(params))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Build items
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
items.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"titulo": r["titulo_trad"] or r["titulo"],
|
||||||
|
"resumen": r["resumen_trad"] or r["resumen"] or "",
|
||||||
|
"url": r["url"],
|
||||||
|
"fecha": r["fecha"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build feed metadata
|
||||||
|
title = "The Daily Feed"
|
||||||
|
if filters_desc:
|
||||||
|
title += " - " + ", ".join(filters_desc)
|
||||||
|
|
||||||
|
description = "Noticias personalizadas"
|
||||||
|
if filters_desc:
|
||||||
|
description = "Feed personalizado: " + ", ".join(filters_desc)
|
||||||
|
|
||||||
|
link = request.host_url.rstrip("/")
|
||||||
|
|
||||||
|
xml = build_rss_xml(title, description, link, items)
|
||||||
|
|
||||||
|
return Response(xml, mimetype="application/rss+xml")
|
||||||
|
|
||||||
|
|
||||||
|
@rss_bp.route("/favoritos")
|
||||||
|
def favoritos_feed():
|
||||||
|
"""Generate RSS feed of user's favorites."""
|
||||||
|
from routers.favoritos import get_session_id, ensure_favoritos_table
|
||||||
|
|
||||||
|
session_id = get_session_id()
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
ensure_favoritos_table(conn)
|
||||||
|
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT n.id, n.titulo, n.resumen, n.url, n.fecha,
|
||||||
|
t.titulo_trad, t.resumen_trad
|
||||||
|
FROM favoritos f
|
||||||
|
JOIN noticias n ON n.id = f.noticia_id
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id
|
||||||
|
AND t.lang_to = 'es' AND t.status = 'done'
|
||||||
|
WHERE f.session_id = %s
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT 50;
|
||||||
|
""", (session_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
items.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"titulo": r["titulo_trad"] or r["titulo"],
|
||||||
|
"resumen": r["resumen_trad"] or r["resumen"] or "",
|
||||||
|
"url": r["url"],
|
||||||
|
"fecha": r["fecha"],
|
||||||
|
})
|
||||||
|
|
||||||
|
xml = build_rss_xml(
|
||||||
|
"The Daily Feed - Mis Favoritos",
|
||||||
|
"Noticias guardadas en favoritos",
|
||||||
|
request.host_url.rstrip("/"),
|
||||||
|
items
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(xml, mimetype="application/rss+xml")
|
||||||
257
routers/search.py
Normal file
257
routers/search.py
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
"""
|
||||||
|
Search API router - Real-time search with semantic search (Qdrant) and autocomplete.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from psycopg2 import extras
|
||||||
|
from db import get_read_conn, get_write_conn
|
||||||
|
from utils.auth import get_current_user
|
||||||
|
from utils.qdrant_search import semantic_search
|
||||||
|
|
||||||
|
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route("/")
|
||||||
|
def search():
|
||||||
|
"""Search noticias using semantic search (Qdrant) with PostgreSQL fallback."""
|
||||||
|
q = (request.args.get("q") or "").strip()
|
||||||
|
limit = min(int(request.args.get("limit", 10)), 50)
|
||||||
|
page = max(int(request.args.get("page", 1)), 1) # Página actual (1-indexed)
|
||||||
|
offset = (page - 1) * limit # Calcular offset
|
||||||
|
lang = (request.args.get("lang") or "es").lower()[:5]
|
||||||
|
use_semantic = request.args.get("semantic", "true").lower() == "true"
|
||||||
|
|
||||||
|
if not q or len(q) < 2:
|
||||||
|
return jsonify({
|
||||||
|
"results": [],
|
||||||
|
"total": 0,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total_pages": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
# Intentar búsqueda semántica primero (más rápida y mejor)
|
||||||
|
if use_semantic:
|
||||||
|
try:
|
||||||
|
# Para paginación, obtenemos más resultados de Qdrant
|
||||||
|
# Qdrant es muy rápido, así que podemos obtener bastantes resultados
|
||||||
|
max_qdrant_results = min(offset + limit * 3, 200) # Obtener hasta 3 páginas adelante
|
||||||
|
|
||||||
|
semantic_results = semantic_search(
|
||||||
|
query=q,
|
||||||
|
limit=max_qdrant_results,
|
||||||
|
score_threshold=0.3 # Umbral más bajo para capturar más resultados
|
||||||
|
)
|
||||||
|
|
||||||
|
if semantic_results:
|
||||||
|
# Calcular total encontrado (hasta el límite de fetching)
|
||||||
|
total = len(semantic_results)
|
||||||
|
|
||||||
|
# Obtener solo los resultados de la página actual
|
||||||
|
page_results = semantic_results[offset : offset + limit]
|
||||||
|
|
||||||
|
if page_results:
|
||||||
|
# Enriquecer con datos adicionales de PostgreSQL solo para esta página
|
||||||
|
news_ids = [r['news_id'] for r in page_results]
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Obtener datos adicionales (categoría, país)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
n.titulo,
|
||||||
|
n.resumen,
|
||||||
|
n.url,
|
||||||
|
n.fecha,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fuente_nombre,
|
||||||
|
c.nombre AS categoria,
|
||||||
|
p.nombre AS pais,
|
||||||
|
t.titulo_trad,
|
||||||
|
t.resumen_trad,
|
||||||
|
t.id AS traduccion_id
|
||||||
|
FROM noticias n
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id
|
||||||
|
AND t.lang_to = %s AND t.status = 'done'
|
||||||
|
WHERE n.id = ANY(%s)
|
||||||
|
""", (lang, news_ids))
|
||||||
|
|
||||||
|
db_rows = {row['id']: row for row in cur.fetchall()}
|
||||||
|
|
||||||
|
# Combinar resultados semánticos con datos de PostgreSQL
|
||||||
|
for sem_result in page_results:
|
||||||
|
news_id = sem_result['news_id']
|
||||||
|
db_row = db_rows.get(news_id)
|
||||||
|
|
||||||
|
if db_row:
|
||||||
|
results.append({
|
||||||
|
"id": db_row["id"],
|
||||||
|
"titulo": db_row["titulo_trad"] or db_row["titulo"],
|
||||||
|
"resumen": (db_row["resumen_trad"] or db_row["resumen"] or "")[:150],
|
||||||
|
"url": db_row["url"],
|
||||||
|
"fecha": db_row["fecha"].isoformat() if db_row["fecha"] else None,
|
||||||
|
"imagen_url": db_row["imagen_url"],
|
||||||
|
"fuente": db_row["fuente_nombre"],
|
||||||
|
"categoria": db_row["categoria"],
|
||||||
|
"pais": db_row["pais"],
|
||||||
|
"traduccion_id": db_row["traduccion_id"],
|
||||||
|
"semantic_score": sem_result['score'],
|
||||||
|
"fecha_raw": db_row["fecha"] # Para ordenación
|
||||||
|
})
|
||||||
|
|
||||||
|
# Ordenar por fecha cronológicamente (más reciente primero)
|
||||||
|
results.sort(key=lambda x: x.get("fecha_raw") or "", reverse=True)
|
||||||
|
|
||||||
|
# Eliminar el campo temporal usado para ordenación
|
||||||
|
for r in results:
|
||||||
|
r.pop("fecha_raw", None)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error en búsqueda semántica, usando fallback: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
# Continuar con búsqueda tradicional
|
||||||
|
|
||||||
|
# Fallback a búsqueda tradicional si no hay resultados semánticos y no hubo error fatal
|
||||||
|
if not results and total == 0:
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
print(f"⚠️ Usando fallback PostgreSQL para búsqueda: '{q}'")
|
||||||
|
|
||||||
|
# Búsqueda tradicional optimizada usando Full Text Search
|
||||||
|
# Nota: Esta query es más lenta que Qdrant pero necesaria como fallback
|
||||||
|
cur.execute("""
|
||||||
|
WITH ranked_news AS (
|
||||||
|
-- Búsqueda en noticias originales
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
ts_rank(n.search_vector_es, websearch_to_tsquery('spanish', %s)) as rank
|
||||||
|
FROM noticias n
|
||||||
|
WHERE n.search_vector_es @@ websearch_to_tsquery('spanish', %s)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Búsqueda en traducciones
|
||||||
|
SELECT
|
||||||
|
t.noticia_id as id,
|
||||||
|
ts_rank(t.search_vector_es, websearch_to_tsquery('spanish', %s)) as rank
|
||||||
|
FROM traducciones t
|
||||||
|
WHERE t.search_vector_es @@ websearch_to_tsquery('spanish', %s)
|
||||||
|
AND t.lang_to = 'es'
|
||||||
|
AND t.status = 'done'
|
||||||
|
),
|
||||||
|
best_ranks AS (
|
||||||
|
SELECT id, MAX(rank) as max_rank
|
||||||
|
FROM ranked_news
|
||||||
|
GROUP BY id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
n.titulo,
|
||||||
|
n.resumen,
|
||||||
|
n.url,
|
||||||
|
n.fecha,
|
||||||
|
n.imagen_url,
|
||||||
|
n.fuente_nombre,
|
||||||
|
c.nombre AS categoria,
|
||||||
|
p.nombre AS pais,
|
||||||
|
t.titulo_trad,
|
||||||
|
t.resumen_trad,
|
||||||
|
t.id AS traduccion_id,
|
||||||
|
br.max_rank AS rank
|
||||||
|
FROM best_ranks br
|
||||||
|
JOIN noticias n ON n.id = br.id
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id
|
||||||
|
AND t.lang_to = %s AND t.status = 'done'
|
||||||
|
ORDER BY n.fecha DESC, br.max_rank DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", (q, q, q, q, lang, limit, offset))
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
print(f"✅ PostgreSQL retornó {len(rows)} resultados")
|
||||||
|
|
||||||
|
# Count total - Query simplificada
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(DISTINCT id) FROM (
|
||||||
|
SELECT id FROM noticias
|
||||||
|
WHERE search_vector_es @@ websearch_to_tsquery('spanish', %s)
|
||||||
|
UNION
|
||||||
|
SELECT noticia_id as id FROM traducciones
|
||||||
|
WHERE search_vector_es @@ websearch_to_tsquery('spanish', %s)
|
||||||
|
AND lang_to = 'es' AND status = 'done'
|
||||||
|
) as all_hits
|
||||||
|
""", (q, q))
|
||||||
|
total_row = cur.fetchone()
|
||||||
|
total = total_row[0] if total_row else 0
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
results.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"titulo": r["titulo_trad"] or r["titulo"],
|
||||||
|
"resumen": (r["resumen_trad"] or r["resumen"] or "")[:150],
|
||||||
|
"url": r["url"],
|
||||||
|
"fecha": r["fecha"].isoformat() if r["fecha"] else None,
|
||||||
|
"imagen_url": r["imagen_url"],
|
||||||
|
"fuente": r["fuente_nombre"],
|
||||||
|
"categoria": r["categoria"],
|
||||||
|
"pais": r["pais"],
|
||||||
|
"traduccion_id": r["traduccion_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Save search history for authenticated users
|
||||||
|
user = get_current_user()
|
||||||
|
if user and q and page == 1: # Solo guardar en página 1
|
||||||
|
try:
|
||||||
|
with get_write_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO search_history (user_id, query, results_count)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
""", (user['id'], q, total))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR SAVING SEARCH HISTORY: {e}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
total_pages = (total + limit - 1) // limit if limit > 0 else 0
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"results": results,
|
||||||
|
"total": total,
|
||||||
|
"query": q,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total_pages": total_pages
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route("/suggestions")
|
||||||
|
def suggestions():
|
||||||
|
"""Get search suggestions based on recent/popular searches and tags."""
|
||||||
|
q = (request.args.get("q") or "").strip()
|
||||||
|
limit = min(int(request.args.get("limit", 5)), 10)
|
||||||
|
|
||||||
|
if not q or len(q) < 2:
|
||||||
|
return jsonify({"suggestions": []})
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Get matching tags as suggestions
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT valor
|
||||||
|
FROM tags
|
||||||
|
WHERE valor ILIKE %s
|
||||||
|
ORDER BY valor
|
||||||
|
LIMIT %s;
|
||||||
|
""", (f"%{q}%", limit))
|
||||||
|
|
||||||
|
suggestions = [row[0] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
return jsonify({"suggestions": suggestions, "query": q})
|
||||||
911
routers/stats.py
Normal file
911
routers/stats.py
Normal file
|
|
@ -0,0 +1,911 @@
|
||||||
|
from flask import Blueprint, render_template, jsonify
|
||||||
|
from db import get_read_conn
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from cache import cached
|
||||||
|
|
||||||
|
stats_bp = Blueprint("stats", __name__, url_prefix="/stats")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================================================================================
|
||||||
|
# ENTITY NORMALIZATION SYSTEM
|
||||||
|
# ==================================================================================
|
||||||
|
# Dictionary to map entity name variations to canonical names
|
||||||
|
import json
|
||||||
|
|
||||||
|
CONFIG_FILE = "entity_config.json"
|
||||||
|
_config_cache = {"data": None, "mtime": 0}
|
||||||
|
|
||||||
|
def load_entity_config():
|
||||||
|
"""Load entity config from JSON file with simple modification time caching."""
|
||||||
|
global _config_cache
|
||||||
|
try:
|
||||||
|
# Check if file exists
|
||||||
|
if not os.path.exists(CONFIG_FILE):
|
||||||
|
return {"blacklist": [], "synonyms": {}}
|
||||||
|
|
||||||
|
# Check modification time
|
||||||
|
mtime = os.path.getmtime(CONFIG_FILE)
|
||||||
|
if _config_cache["data"] is not None and mtime <= _config_cache["mtime"]:
|
||||||
|
return _config_cache["data"]
|
||||||
|
|
||||||
|
# Load fresh config
|
||||||
|
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Normalize structure
|
||||||
|
if "blacklist" not in data: data["blacklist"] = []
|
||||||
|
if "synonyms" not in data: data["synonyms"] = {}
|
||||||
|
|
||||||
|
# Pre-process synonyms for reverse lookup (variation -> canonical)
|
||||||
|
lookup = {}
|
||||||
|
for canonical, variations in data["synonyms"].items():
|
||||||
|
lookup[canonical.lower()] = canonical # Map canonical to itself
|
||||||
|
for var in variations:
|
||||||
|
lookup[var.lower()] = canonical
|
||||||
|
|
||||||
|
data["_lookup"] = lookup
|
||||||
|
data["_blacklist_set"] = {x.lower() for x in data["blacklist"]}
|
||||||
|
|
||||||
|
_config_cache = {"data": data, "mtime": mtime}
|
||||||
|
return data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading entity config: {e}")
|
||||||
|
# Return fallback or previous cache if available
|
||||||
|
return _config_cache["data"] if _config_cache["data"] else {"blacklist": [], "synonyms": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_entity_name(name: str, config=None) -> str:
|
||||||
|
"""Normalize entity name to its canonical form."""
|
||||||
|
if config is None:
|
||||||
|
config = load_entity_config()
|
||||||
|
|
||||||
|
lookup = config.get("_lookup", {})
|
||||||
|
return lookup.get(name.lower(), name)
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_normalized_entities(rows, entity_type='persona'):
|
||||||
|
"""Aggregate entity counts by normalized names and filter blacklisted items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of (name, count) tuples from database
|
||||||
|
entity_type: Type of entity for normalization (kept for compatibility but config is global now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (normalized_name, total_count) tuples sorted by count
|
||||||
|
"""
|
||||||
|
aggregated = {}
|
||||||
|
config = load_entity_config()
|
||||||
|
blacklist = config.get("_blacklist_set", set())
|
||||||
|
|
||||||
|
for name, count in rows:
|
||||||
|
# 1. Check blacklist (exact or lower match)
|
||||||
|
if name.lower() in blacklist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Normalize
|
||||||
|
normalized = normalize_entity_name(name, config)
|
||||||
|
|
||||||
|
# 3. Check blacklist again (in case canonical name is blacklisted)
|
||||||
|
if normalized.lower() in blacklist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
aggregated[normalized] = aggregated.get(normalized, 0) + count
|
||||||
|
|
||||||
|
# Sort by count descending
|
||||||
|
sorted_items = sorted(aggregated.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
return sorted_items
|
||||||
|
|
||||||
|
# ==================================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/")
|
||||||
|
def index():
|
||||||
|
"""Stats dashboard page."""
|
||||||
|
|
||||||
|
# Calculate translation stats for the banner
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Translations per minute (last 5 minutes)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM traducciones
|
||||||
|
WHERE status = 'done'
|
||||||
|
AND created_at > NOW() - INTERVAL '5 minutes'
|
||||||
|
""")
|
||||||
|
recent_5min = cur.fetchone()[0]
|
||||||
|
translations_per_min = round(recent_5min / 5, 1) if recent_5min else 0
|
||||||
|
|
||||||
|
# Status counts
|
||||||
|
cur.execute("SELECT COUNT(*) FROM traducciones WHERE status = 'done'")
|
||||||
|
traducciones_count = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM traducciones WHERE status = 'pending'")
|
||||||
|
pending_count = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM traducciones WHERE status = 'processing'")
|
||||||
|
processing_count = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM traducciones WHERE status = 'error'")
|
||||||
|
error_count = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Total noticias (exact count - cached for 5 min in view)
|
||||||
|
cur.execute("SELECT COUNT(*) FROM noticias")
|
||||||
|
noticias_count = cur.fetchone()[0] or 0
|
||||||
|
|
||||||
|
# News ingested today
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM noticias
|
||||||
|
WHERE DATE(fecha) = CURRENT_DATE
|
||||||
|
""")
|
||||||
|
noticias_hoy = cur.fetchone()[0] or 0
|
||||||
|
|
||||||
|
# News ingested in the last hour
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM noticias
|
||||||
|
WHERE fecha >= NOW() - INTERVAL '1 hour'
|
||||||
|
""")
|
||||||
|
noticias_ultima_hora = cur.fetchone()[0] or 0
|
||||||
|
|
||||||
|
return render_template("stats.html",
|
||||||
|
translations_per_min=translations_per_min,
|
||||||
|
noticias_count=noticias_count,
|
||||||
|
traducciones_count=traducciones_count,
|
||||||
|
pending_count=pending_count,
|
||||||
|
processing_count=processing_count,
|
||||||
|
error_count=error_count,
|
||||||
|
noticias_hoy=noticias_hoy,
|
||||||
|
noticias_ultima_hora=noticias_ultima_hora)
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/activity")
|
||||||
|
@cached(ttl_seconds=300, prefix="stats")
|
||||||
|
def activity_data():
|
||||||
|
"""Get activity data (news count) for the specified range."""
|
||||||
|
from flask import request
|
||||||
|
range_param = request.args.get("range", "30d")
|
||||||
|
|
||||||
|
# Default: 30d -> group by day
|
||||||
|
days = 30
|
||||||
|
minutes = 0
|
||||||
|
interval_sql = "day" # For date_trunc or casting
|
||||||
|
timedelta_step = timedelta(days=1)
|
||||||
|
date_format = "%Y-%m-%d"
|
||||||
|
|
||||||
|
if range_param == "1h":
|
||||||
|
minutes = 60
|
||||||
|
interval_sql = "minute"
|
||||||
|
timedelta_step = timedelta(minutes=1)
|
||||||
|
date_format = "%H:%M"
|
||||||
|
elif range_param == "8h":
|
||||||
|
minutes = 480
|
||||||
|
interval_sql = "minute"
|
||||||
|
timedelta_step = timedelta(minutes=1)
|
||||||
|
date_format = "%H:%M"
|
||||||
|
elif range_param == "1d": # Alias for 24h
|
||||||
|
minutes = 1440
|
||||||
|
interval_sql = "hour"
|
||||||
|
timedelta_step = timedelta(hours=1)
|
||||||
|
date_format = "%H:%M"
|
||||||
|
elif range_param == "24h":
|
||||||
|
minutes = 1440
|
||||||
|
interval_sql = "hour"
|
||||||
|
timedelta_step = timedelta(hours=1)
|
||||||
|
date_format = "%H:%M"
|
||||||
|
elif range_param == "7d":
|
||||||
|
minutes = 10080
|
||||||
|
interval_sql = "hour"
|
||||||
|
timedelta_step = timedelta(hours=1)
|
||||||
|
# Include Month-Day for 7d context
|
||||||
|
date_format = "%d %H:%M"
|
||||||
|
elif range_param == "30d":
|
||||||
|
# Specific existing logic uses date casting, we can adapt
|
||||||
|
minutes = 0
|
||||||
|
days = 30
|
||||||
|
interval_sql = "day"
|
||||||
|
timedelta_step = timedelta(days=1)
|
||||||
|
date_format = "%Y-%m-%d"
|
||||||
|
|
||||||
|
# Calculate start time
|
||||||
|
if minutes > 0:
|
||||||
|
start_time = datetime.utcnow() - timedelta(minutes=minutes)
|
||||||
|
# Using timestamp column directly
|
||||||
|
date_column = "fecha"
|
||||||
|
else:
|
||||||
|
start_time = datetime.utcnow() - timedelta(days=days)
|
||||||
|
# For 30d we might just use date part start
|
||||||
|
start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
date_column = "fecha"
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Construct query based on interval
|
||||||
|
if interval_sql == "day":
|
||||||
|
# Original logic style for 30d, but generalized
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
fecha::date as time_slot,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM noticias
|
||||||
|
WHERE fecha >= %s
|
||||||
|
GROUP BY time_slot
|
||||||
|
ORDER BY time_slot
|
||||||
|
""", (start_time,))
|
||||||
|
else:
|
||||||
|
# Granular logic
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT
|
||||||
|
date_trunc('{interval_sql}', fecha) as time_slot,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM noticias
|
||||||
|
WHERE fecha >= %s
|
||||||
|
GROUP BY time_slot
|
||||||
|
ORDER BY time_slot
|
||||||
|
""", (start_time,))
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Fill gaps
|
||||||
|
data_map = {row[0]: row[1] for row in rows}
|
||||||
|
labels = []
|
||||||
|
data = []
|
||||||
|
|
||||||
|
# Iterate with step
|
||||||
|
if minutes > 0:
|
||||||
|
# Granular start alignment
|
||||||
|
current = start_time.replace(second=0, microsecond=0)
|
||||||
|
if interval_sql == "hour":
|
||||||
|
current = current.replace(minute=0)
|
||||||
|
|
||||||
|
end = datetime.utcnow().replace(second=0, microsecond=0)
|
||||||
|
if interval_sql == "hour":
|
||||||
|
end = end.replace(minute=0) + timedelta(hours=1)
|
||||||
|
else:
|
||||||
|
# Daily start alignment
|
||||||
|
current = start_time.date() if isinstance(start_time, datetime) else start_time
|
||||||
|
end = datetime.utcnow().date()
|
||||||
|
|
||||||
|
while current <= end:
|
||||||
|
# Format label
|
||||||
|
labels.append(current.strftime(date_format))
|
||||||
|
|
||||||
|
# Lookup key can be date or datetime depending on query
|
||||||
|
# DB returns date for ::date and datetime for date_trunc
|
||||||
|
# Let's handle both lookup types safely
|
||||||
|
lookup_key = current
|
||||||
|
# API might have mismatch if current is date object and DB returned datetime or vice versa
|
||||||
|
# rows[0] is date object for 'day', datetime for 'minute'/'hour'
|
||||||
|
|
||||||
|
val = data_map.get(lookup_key, 0)
|
||||||
|
# Fallback if types don't match exactly (datetime vs date) - unlikely if logic is consistent but good to check
|
||||||
|
if val == 0 and isinstance(lookup_key, datetime) and interval_sql == 'day':
|
||||||
|
val = data_map.get(lookup_key.date(), 0)
|
||||||
|
|
||||||
|
data.append(val)
|
||||||
|
|
||||||
|
current += timedelta_step
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"labels": labels,
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/categories")
|
||||||
|
@cached(ttl_seconds=300, prefix="stats")
|
||||||
|
def categories_data():
|
||||||
|
"""Get news count per category (Top 8 + Others)."""
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
c.nombre,
|
||||||
|
COUNT(n.id) as count
|
||||||
|
FROM noticias n
|
||||||
|
JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
GROUP BY c.nombre
|
||||||
|
ORDER BY count DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Process Top 8 + Others
|
||||||
|
labels = []
|
||||||
|
data = []
|
||||||
|
others_count = 0
|
||||||
|
top_limit = 8
|
||||||
|
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
if i < top_limit:
|
||||||
|
labels.append(row[0])
|
||||||
|
data.append(row[1])
|
||||||
|
else:
|
||||||
|
others_count += row[1]
|
||||||
|
|
||||||
|
if others_count > 0:
|
||||||
|
labels.append("Otros")
|
||||||
|
data.append(others_count)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"labels": labels,
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/countries")
|
||||||
|
@cached(ttl_seconds=300, prefix="stats")
|
||||||
|
def countries_data():
|
||||||
|
"""Get news count per country (Top 10 + Others)."""
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
p.nombre,
|
||||||
|
COUNT(n.id) as count
|
||||||
|
FROM noticias n
|
||||||
|
JOIN paises p ON p.id = n.pais_id
|
||||||
|
GROUP BY p.nombre
|
||||||
|
ORDER BY count DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Process Top 10 + Others
|
||||||
|
labels = []
|
||||||
|
data = []
|
||||||
|
others_count = 0
|
||||||
|
top_limit = 10
|
||||||
|
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
if i < top_limit:
|
||||||
|
labels.append(row[0])
|
||||||
|
data.append(row[1])
|
||||||
|
else:
|
||||||
|
others_count += row[1]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"labels": labels,
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/countries/list")
|
||||||
|
def countries_list():
|
||||||
|
"""Get alphabetical list of all countries with flags."""
|
||||||
|
from utils import country_flag
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT nombre FROM paises ORDER BY nombre ASC")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
return jsonify([
|
||||||
|
{"name": row[0], "flag": country_flag(row[0])}
|
||||||
|
for row in rows
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/translations/activity")
|
||||||
|
def translations_activity_data():
|
||||||
|
"""Get translation count per day for the last 30 days."""
|
||||||
|
days = 30
|
||||||
|
start_date = (datetime.utcnow() - timedelta(days=days)).date()
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
created_at::date as day,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM traducciones
|
||||||
|
WHERE created_at >= %s
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day
|
||||||
|
""", (start_date,))
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Fill gaps
|
||||||
|
data_map = {row[0]: row[1] for row in rows}
|
||||||
|
labels = []
|
||||||
|
data = []
|
||||||
|
|
||||||
|
current = start_date
|
||||||
|
end = datetime.utcnow().date()
|
||||||
|
|
||||||
|
while current <= end:
|
||||||
|
labels.append(current.strftime("%Y-%m-%d"))
|
||||||
|
data.append(data_map.get(current, 0))
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"labels": labels,
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/translations/languages")
|
||||||
|
@cached(ttl_seconds=60, prefix="stats")
|
||||||
|
def translations_languages_data():
|
||||||
|
"""Get translation count per source language."""
|
||||||
|
# Friendly names for common languages
|
||||||
|
LANG_NAMES = {
|
||||||
|
'en': 'Inglés',
|
||||||
|
'es': 'Español',
|
||||||
|
'fr': 'Francés',
|
||||||
|
'de': 'Alemán',
|
||||||
|
'it': 'Italiano',
|
||||||
|
'pt': 'Portugués',
|
||||||
|
'ru': 'Ruso',
|
||||||
|
'zh': 'Chino',
|
||||||
|
'ja': 'Japonés',
|
||||||
|
'ar': 'Árabe'
|
||||||
|
}
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
lang_from,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM translation_stats
|
||||||
|
WHERE lang_from IS NOT NULL
|
||||||
|
GROUP BY lang_from
|
||||||
|
ORDER BY count DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
labels = []
|
||||||
|
data = []
|
||||||
|
for code, count in rows:
|
||||||
|
code = code.strip().lower()
|
||||||
|
labels.append(LANG_NAMES.get(code, code.upper()))
|
||||||
|
data.append(count)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"labels": labels,
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_system_uptime():
|
||||||
|
try:
|
||||||
|
with open('/proc/uptime', 'r') as f:
|
||||||
|
uptime_seconds = float(f.readline().split()[0])
|
||||||
|
days = int(uptime_seconds // (24 * 3600))
|
||||||
|
hours = int((uptime_seconds % (24 * 3600)) // 3600)
|
||||||
|
minutes = int((uptime_seconds % 3600) // 60)
|
||||||
|
if days > 0:
|
||||||
|
return f"{days}d {hours}h {minutes}m"
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
except:
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
def get_gpu_info():
|
||||||
|
try:
|
||||||
|
cmd = "nvidia-smi --query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits"
|
||||||
|
with open(os.devnull, 'w') as devnull:
|
||||||
|
res = subprocess.check_output(cmd, shell=True, stderr=devnull).decode().strip()
|
||||||
|
parts = [p.strip() for p in res.split(',')]
|
||||||
|
if len(parts) >= 5:
|
||||||
|
return {
|
||||||
|
"name": parts[0],
|
||||||
|
"temp": f"{parts[1]}°C",
|
||||||
|
"util": f"{parts[2]}%",
|
||||||
|
"mem": f"{parts[3]} MB / {parts[4]} MB"
|
||||||
|
}
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_cpu_info():
|
||||||
|
try:
|
||||||
|
load = os.getloadavg()
|
||||||
|
cores = os.cpu_count()
|
||||||
|
return {
|
||||||
|
"load": f"{load[0]:.2f}, {load[1]:.2f}, {load[2]:.2f}",
|
||||||
|
"cores": cores
|
||||||
|
}
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@stats_bp.route("/api/system/info")
|
||||||
|
def system_info_api():
|
||||||
|
"""Endpoint for real-time system monitoring."""
|
||||||
|
return jsonify({
|
||||||
|
"uptime": get_system_uptime(),
|
||||||
|
"gpu": get_gpu_info(),
|
||||||
|
"cpu": get_cpu_info(),
|
||||||
|
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/translations/rate")
|
||||||
|
@cached(ttl_seconds=60, prefix="stats")
|
||||||
|
def translations_rate_data():
|
||||||
|
"""Get translation count for the specified range (1h, 8h, 24h, 7d)."""
|
||||||
|
# Parameters
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
range_param = request.args.get("range", "1h")
|
||||||
|
|
||||||
|
# Default: 1h -> group by minute
|
||||||
|
minutes = 60
|
||||||
|
interval_sql = "minute"
|
||||||
|
timedelta_step = timedelta(minutes=1)
|
||||||
|
date_format = "%H:%M"
|
||||||
|
|
||||||
|
if range_param == "8h":
|
||||||
|
minutes = 8 * 60
|
||||||
|
interval_sql = "minute" # Still group by minute for detailed graph? Or 5 mins?
|
||||||
|
# Let's simple group by minute but it might be dense. 480 points. Fine.
|
||||||
|
timedelta_step = timedelta(minutes=1)
|
||||||
|
date_format = "%H:%M"
|
||||||
|
|
||||||
|
elif range_param == "24h":
|
||||||
|
minutes = 24 * 60
|
||||||
|
# Group by 15 minutes? Postgres: date_trunc('hour', ...) or extract?
|
||||||
|
# Let's use custom grouping? Or simple 'hour' is too granular? 1440 mins.
|
||||||
|
# Let's group by hour for 24h to be safe/clean
|
||||||
|
interval_sql = "hour"
|
||||||
|
timedelta_step = timedelta(hours=1)
|
||||||
|
date_format = "%H:%M"
|
||||||
|
|
||||||
|
elif range_param == "7d":
|
||||||
|
minutes = 7 * 24 * 60
|
||||||
|
interval_sql = "hour" # 7 * 24 = 168 points
|
||||||
|
timedelta_step = timedelta(hours=1)
|
||||||
|
date_format = "%Y-%m-%d %H:%M"
|
||||||
|
|
||||||
|
start_time = datetime.utcnow() - timedelta(minutes=minutes)
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Query translation_stats instead of traducciones
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT
|
||||||
|
date_trunc('{interval_sql}', created_at) as time_slot,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM translation_stats
|
||||||
|
WHERE created_at >= %s
|
||||||
|
GROUP BY time_slot
|
||||||
|
ORDER BY time_slot
|
||||||
|
""", (start_time,))
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Fill gaps
|
||||||
|
data_map = {row[0]: row[1] for row in rows}
|
||||||
|
labels = []
|
||||||
|
data = []
|
||||||
|
|
||||||
|
# Iterate by step
|
||||||
|
# Align start_time to step if possible (lazy alignment)
|
||||||
|
current = start_time.replace(second=0, microsecond=0)
|
||||||
|
if interval_sql == "hour":
|
||||||
|
current = current.replace(minute=0)
|
||||||
|
|
||||||
|
end = datetime.utcnow().replace(second=0, microsecond=0)
|
||||||
|
if interval_sql == "hour":
|
||||||
|
end = end.replace(minute=0) + timedelta(hours=1) # Ensure we cover current partial hour
|
||||||
|
|
||||||
|
while current <= end:
|
||||||
|
labels.append(current.strftime(date_format))
|
||||||
|
data.append(data_map.get(current, 0))
|
||||||
|
current += timedelta_step
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"labels": labels,
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/entities")
|
||||||
|
def entities_dashboard():
|
||||||
|
"""Dashboard for Named Entities statistics."""
|
||||||
|
return render_template("stats_entities.html")
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/entities/people")
|
||||||
|
def entities_people():
|
||||||
|
"""Top 25 mentioned people, optionally filtered by country and/or date."""
|
||||||
|
from flask import request
|
||||||
|
from datetime import datetime
|
||||||
|
from cache import cache_get, cache_set
|
||||||
|
|
||||||
|
# 1. Check config mtime for cache invalidation
|
||||||
|
try:
|
||||||
|
config_mtime = os.path.getmtime(CONFIG_FILE)
|
||||||
|
except OSError:
|
||||||
|
config_mtime = 0
|
||||||
|
|
||||||
|
country_filter = request.args.get("country")
|
||||||
|
date_filter = request.args.get("date")
|
||||||
|
|
||||||
|
# 2. Build cache key with mtime
|
||||||
|
cache_key = f"entities:people:{country_filter}:{date_filter}:{config_mtime}"
|
||||||
|
|
||||||
|
# 3. Try cache
|
||||||
|
cached_data = cache_get(cache_key)
|
||||||
|
if cached_data:
|
||||||
|
return jsonify(cached_data)
|
||||||
|
|
||||||
|
# Determine time range
|
||||||
|
if date_filter:
|
||||||
|
# Single day query
|
||||||
|
try:
|
||||||
|
target_date = datetime.strptime(date_filter, "%Y-%m-%d").date()
|
||||||
|
time_condition = "DATE(tr.created_at) = %s"
|
||||||
|
time_params = [target_date]
|
||||||
|
except ValueError:
|
||||||
|
# Invalid date format, fallback to 30 days
|
||||||
|
time_condition = "tr.created_at >= NOW() - INTERVAL '30 days'"
|
||||||
|
time_params = []
|
||||||
|
else:
|
||||||
|
# Default: last 30 days
|
||||||
|
time_condition = "tr.created_at >= NOW() - INTERVAL '30 days'"
|
||||||
|
time_params = []
|
||||||
|
|
||||||
|
if country_filter and country_filter != 'global':
|
||||||
|
# Filtered by country
|
||||||
|
query = f"""
|
||||||
|
SELECT t.valor, COUNT(*) as menciones
|
||||||
|
FROM tags t
|
||||||
|
JOIN tags_noticia tn 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.tipo = 'persona'
|
||||||
|
AND {time_condition}
|
||||||
|
AND n.pais_id = (SELECT id FROM paises WHERE nombre ILIKE %s LIMIT 1)
|
||||||
|
GROUP BY t.valor
|
||||||
|
ORDER BY menciones DESC
|
||||||
|
"""
|
||||||
|
params = tuple(time_params + [country_filter])
|
||||||
|
else:
|
||||||
|
# Global view
|
||||||
|
query = f"""
|
||||||
|
SELECT t.valor, COUNT(*) as menciones
|
||||||
|
FROM tags t
|
||||||
|
JOIN tags_noticia tn ON tn.tag_id = t.id
|
||||||
|
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||||
|
WHERE t.tipo = 'persona'
|
||||||
|
AND {time_condition}
|
||||||
|
GROUP BY t.valor
|
||||||
|
ORDER BY menciones DESC
|
||||||
|
"""
|
||||||
|
params = tuple(time_params)
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(query, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Normalize and aggregate
|
||||||
|
normalized_rows = aggregate_normalized_entities(rows, entity_type='persona')
|
||||||
|
|
||||||
|
# Take top 50
|
||||||
|
top_50 = normalized_rows[:50]
|
||||||
|
|
||||||
|
# Enrich with Wikipedia Images (Parallel Execution)
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from utils.wiki import fetch_wiki_data
|
||||||
|
|
||||||
|
images = []
|
||||||
|
summaries = []
|
||||||
|
|
||||||
|
def get_image_safe(name):
|
||||||
|
try:
|
||||||
|
return fetch_wiki_data(name)
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if top_50:
|
||||||
|
names = [row[0] for row in top_50]
|
||||||
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
try:
|
||||||
|
results = list(executor.map(get_image_safe, names))
|
||||||
|
|
||||||
|
# Unpack results
|
||||||
|
for img, smry in results:
|
||||||
|
images.append(img)
|
||||||
|
summaries.append(smry)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.error(f"Error fetching wiki data: {e}")
|
||||||
|
# Fallback to empty if threading fails
|
||||||
|
images = [None] * len(names)
|
||||||
|
summaries = [None] * len(names)
|
||||||
|
else:
|
||||||
|
images = []
|
||||||
|
summaries = []
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"labels": [row[0] for row in top_50],
|
||||||
|
"data": [row[1] for row in top_50],
|
||||||
|
"images": images,
|
||||||
|
"summaries": summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Set cache
|
||||||
|
cache_set(cache_key, result, ttl_seconds=600)
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/entities/orgs")
|
||||||
|
def entities_orgs():
|
||||||
|
"""Top mentioned organizations, optionally filtered by country."""
|
||||||
|
from flask import request
|
||||||
|
from cache import cache_get, cache_set
|
||||||
|
|
||||||
|
country_filter = request.args.get("country")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_mtime = os.path.getmtime(CONFIG_FILE)
|
||||||
|
except OSError:
|
||||||
|
config_mtime = 0
|
||||||
|
|
||||||
|
cache_key = f"entities:orgs:{country_filter}:{config_mtime}"
|
||||||
|
|
||||||
|
cached_data = cache_get(cache_key)
|
||||||
|
if cached_data:
|
||||||
|
return jsonify(cached_data)
|
||||||
|
|
||||||
|
if country_filter and country_filter != 'global':
|
||||||
|
query = """
|
||||||
|
SELECT t.valor, COUNT(*) as menciones
|
||||||
|
FROM tags t
|
||||||
|
JOIN tags_noticia tn 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.tipo = 'organizacion'
|
||||||
|
AND tr.created_at >= NOW() - INTERVAL '30 days'
|
||||||
|
AND n.pais_id = (SELECT id FROM paises WHERE nombre ILIKE %s LIMIT 1)
|
||||||
|
GROUP BY t.valor
|
||||||
|
ORDER BY menciones DESC
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
params = (country_filter,)
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
SELECT t.valor, COUNT(*) as menciones
|
||||||
|
FROM tags t
|
||||||
|
JOIN tags_noticia tn ON tn.tag_id = t.id
|
||||||
|
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||||
|
WHERE t.tipo = 'organizacion'
|
||||||
|
AND tr.created_at >= NOW() - INTERVAL '30 days'
|
||||||
|
GROUP BY t.valor
|
||||||
|
ORDER BY menciones DESC
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
params = ()
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(query, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
normalized_rows = aggregate_normalized_entities(rows, entity_type='organizacion')
|
||||||
|
|
||||||
|
# Enrich with Wikipedia Images
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from utils.wiki import fetch_wiki_data
|
||||||
|
|
||||||
|
images = []
|
||||||
|
summaries = []
|
||||||
|
|
||||||
|
def get_info_safe(name):
|
||||||
|
try:
|
||||||
|
return fetch_wiki_data(name)
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if normalized_rows:
|
||||||
|
names = [row[0] for row in normalized_rows]
|
||||||
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
results = list(executor.map(get_info_safe, names))
|
||||||
|
for img, smry in results:
|
||||||
|
images.append(img)
|
||||||
|
summaries.append(smry)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"labels": [row[0] for row in normalized_rows],
|
||||||
|
"data": [row[1] for row in normalized_rows],
|
||||||
|
"images": images,
|
||||||
|
"summaries": summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
cache_set(cache_key, result, ttl_seconds=600)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@stats_bp.route("/api/entities/places")
|
||||||
|
def entities_places():
|
||||||
|
"""Top mentioned places, optionally filtered by country."""
|
||||||
|
from flask import request
|
||||||
|
from cache import cache_get, cache_set
|
||||||
|
|
||||||
|
country_filter = request.args.get("country")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_mtime = os.path.getmtime(CONFIG_FILE)
|
||||||
|
except OSError:
|
||||||
|
config_mtime = 0
|
||||||
|
|
||||||
|
cache_key = f"entities:places:{country_filter}:{config_mtime}"
|
||||||
|
|
||||||
|
cached_data = cache_get(cache_key)
|
||||||
|
if cached_data:
|
||||||
|
return jsonify(cached_data)
|
||||||
|
|
||||||
|
if country_filter and country_filter != 'global':
|
||||||
|
query = """
|
||||||
|
SELECT t.valor, COUNT(*) as menciones
|
||||||
|
FROM tags t
|
||||||
|
JOIN tags_noticia tn 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.tipo = 'lugar'
|
||||||
|
AND tr.created_at >= NOW() - INTERVAL '30 days'
|
||||||
|
AND n.pais_id = (SELECT id FROM paises WHERE nombre ILIKE %s LIMIT 1)
|
||||||
|
AND n.pais_id != (SELECT id FROM paises WHERE nombre = 'España')
|
||||||
|
GROUP BY t.valor
|
||||||
|
ORDER BY menciones DESC
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
params = (country_filter,)
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
SELECT t.valor, COUNT(*) as menciones
|
||||||
|
FROM tags t
|
||||||
|
JOIN tags_noticia tn 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.tipo = 'lugar'
|
||||||
|
AND tr.created_at >= NOW() - INTERVAL '30 days'
|
||||||
|
AND n.pais_id != (SELECT id FROM paises WHERE nombre = 'España')
|
||||||
|
GROUP BY t.valor
|
||||||
|
ORDER BY menciones DESC
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
params = ()
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(query, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Normalize
|
||||||
|
normalized_rows = aggregate_normalized_entities(rows, entity_type='lugar')
|
||||||
|
|
||||||
|
# Enrich with Wikipedia Images
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from utils.wiki import fetch_wiki_data
|
||||||
|
|
||||||
|
images = []
|
||||||
|
summaries = []
|
||||||
|
|
||||||
|
def get_info_safe(name):
|
||||||
|
try:
|
||||||
|
return fetch_wiki_data(name)
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if normalized_rows:
|
||||||
|
names = [row[0] for row in normalized_rows]
|
||||||
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
results = list(executor.map(get_info_safe, names))
|
||||||
|
for img, smry in results:
|
||||||
|
images.append(img)
|
||||||
|
summaries.append(smry)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"labels": [row[0] for row in normalized_rows],
|
||||||
|
"data": [row[1] for row in normalized_rows],
|
||||||
|
"images": images,
|
||||||
|
"summaries": summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
cache_set(cache_key, result, ttl_seconds=600)
|
||||||
|
return jsonify(result)
|
||||||
81
routers/topics.py
Normal file
81
routers/topics.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from db import get_read_conn
|
||||||
|
from psycopg2 import extras
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
topics_bp = Blueprint("topics", __name__, url_prefix="/topics")
|
||||||
|
|
||||||
|
@topics_bp.route("/")
|
||||||
|
def monitor():
|
||||||
|
# Monitor de Impacto por País
|
||||||
|
days = int(request.args.get("days", 3))
|
||||||
|
|
||||||
|
with get_read_conn() as conn, conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Ranking de Países por "Calor" (Suma de scores de noticias recientes)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.id, p.nombre,
|
||||||
|
COUNT(DISTINCT n.id) as news_count,
|
||||||
|
SUM(nt.score) as total_impact
|
||||||
|
FROM paises p
|
||||||
|
JOIN noticias n ON n.pais_id = p.id
|
||||||
|
JOIN news_topics nt ON nt.noticia_id = n.id
|
||||||
|
WHERE n.fecha > NOW() - INTERVAL '%s days'
|
||||||
|
GROUP BY p.id, p.nombre
|
||||||
|
HAVING SUM(nt.score) > 0
|
||||||
|
ORDER BY total_impact DESC
|
||||||
|
LIMIT 50;
|
||||||
|
""", (days,))
|
||||||
|
countries = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("monitor_list.html", countries=countries, days=days)
|
||||||
|
|
||||||
|
@topics_bp.route("/country/<int:pais_id>")
|
||||||
|
def country_detail(pais_id):
|
||||||
|
days = int(request.args.get("days", 3))
|
||||||
|
|
||||||
|
with get_read_conn() as conn, conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
# Info País
|
||||||
|
cur.execute("SELECT * FROM paises WHERE id = %s", (pais_id,))
|
||||||
|
pais = cur.fetchone()
|
||||||
|
|
||||||
|
if not pais:
|
||||||
|
return "País no encontrado", 404
|
||||||
|
|
||||||
|
# Top Noticias de Impacto (Últimos días)
|
||||||
|
# News with their highest topic score sum
|
||||||
|
cur.execute("""
|
||||||
|
SELECT n.id,
|
||||||
|
COALESCE(t.titulo_trad, n.titulo) as titulo,
|
||||||
|
COALESCE(t.resumen_trad, n.resumen) as resumen,
|
||||||
|
n.fecha, n.imagen_url, n.fuente_nombre, n.url,
|
||||||
|
SUM(nt.score) as impact_score
|
||||||
|
FROM noticias n
|
||||||
|
JOIN news_topics nt ON nt.noticia_id = n.id
|
||||||
|
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = 'es' AND t.status = 'done'
|
||||||
|
WHERE n.pais_id = %s
|
||||||
|
AND n.fecha > NOW() - INTERVAL '%s days'
|
||||||
|
GROUP BY n.id, n.titulo, n.resumen, n.fecha, n.imagen_url, n.fuente_nombre, n.url, t.titulo_trad, t.resumen_trad
|
||||||
|
ORDER BY impact_score DESC
|
||||||
|
LIMIT 20;
|
||||||
|
""", (pais_id, days))
|
||||||
|
top_news = cur.fetchall()
|
||||||
|
|
||||||
|
# Temas Activos en este País
|
||||||
|
cur.execute("""
|
||||||
|
SELECT t.name, SUM(nt.score) as topic_volume
|
||||||
|
FROM topics t
|
||||||
|
JOIN news_topics nt ON nt.topic_id = t.id
|
||||||
|
JOIN noticias n ON n.id = nt.noticia_id
|
||||||
|
WHERE n.pais_id = %s
|
||||||
|
AND n.fecha > NOW() - INTERVAL '%s days'
|
||||||
|
GROUP BY t.id, t.name
|
||||||
|
ORDER BY topic_volume DESC
|
||||||
|
LIMIT 10;
|
||||||
|
""", (pais_id, days))
|
||||||
|
active_topics = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("monitor_detail.html",
|
||||||
|
pais=pais,
|
||||||
|
news=top_news,
|
||||||
|
active_topics=active_topics,
|
||||||
|
days=days)
|
||||||
59
routers/traducciones.py
Normal file
59
routers/traducciones.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from db import get_read_conn
|
||||||
|
|
||||||
|
traducciones_bp = Blueprint("traducciones", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@traducciones_bp.route("/traducciones")
|
||||||
|
def ultimas_traducciones():
|
||||||
|
"""Muestra las últimas noticias traducidas."""
|
||||||
|
page = max(int(request.args.get("page", 1)), 1)
|
||||||
|
per_page = min(max(int(request.args.get("per_page", 20)), 10), 100)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
conn.autocommit = True
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Total count
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM traducciones WHERE status = 'done'
|
||||||
|
""")
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Fetch latest translations
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.noticia_id,
|
||||||
|
t.titulo_trad,
|
||||||
|
t.resumen_trad,
|
||||||
|
t.lang_from,
|
||||||
|
t.lang_to,
|
||||||
|
t.created_at AS updated_at,
|
||||||
|
n.url AS link,
|
||||||
|
n.imagen_url AS imagen,
|
||||||
|
n.fuente_nombre AS feed_nombre,
|
||||||
|
c.nombre AS categoria_nombre,
|
||||||
|
p.nombre AS pais_nombre
|
||||||
|
FROM traducciones t
|
||||||
|
JOIN noticias n ON n.id = t.noticia_id
|
||||||
|
LEFT JOIN categorias c ON c.id = n.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id = n.pais_id
|
||||||
|
WHERE t.status = 'done'
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", (per_page, offset))
|
||||||
|
|
||||||
|
columns = [desc[0] for desc in cur.description]
|
||||||
|
traducciones = [dict(zip(columns, row)) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"traducciones.html",
|
||||||
|
traducciones=traducciones,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
81
routers/urls.py
Normal file
81
routers/urls.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
from flask import Blueprint, render_template, request, redirect, flash, url_for
|
||||||
|
from psycopg2 import extras
|
||||||
|
from db import get_conn
|
||||||
|
from models.categorias import get_categorias
|
||||||
|
from models.paises import get_paises
|
||||||
|
|
||||||
|
urls_bp = Blueprint("urls", __name__, url_prefix="/urls")
|
||||||
|
|
||||||
|
|
||||||
|
@urls_bp.route("/")
|
||||||
|
def manage_urls():
|
||||||
|
with get_conn() as conn, conn.cursor(cursor_factory=extras.DictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT fu.id, fu.nombre, fu.url,
|
||||||
|
c.nombre AS categoria,
|
||||||
|
p.nombre AS pais,
|
||||||
|
fu.idioma,
|
||||||
|
fu.last_check,
|
||||||
|
fu.last_status,
|
||||||
|
fu.status_message,
|
||||||
|
fu.last_http_code,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM noticias n
|
||||||
|
JOIN feeds f ON n.fuente_nombre = f.nombre
|
||||||
|
WHERE f.fuente_url_id = fu.id
|
||||||
|
), 0) as noticias_count
|
||||||
|
FROM fuentes_url fu
|
||||||
|
LEFT JOIN categorias c ON c.id=fu.categoria_id
|
||||||
|
LEFT JOIN paises p ON p.id=fu.pais_id
|
||||||
|
ORDER BY fu.nombre;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fuentes = cur.fetchall()
|
||||||
|
|
||||||
|
return render_template("urls_list.html", fuentes=fuentes)
|
||||||
|
|
||||||
|
|
||||||
|
@urls_bp.route("/add_source", methods=["GET", "POST"])
|
||||||
|
def add_url_source():
|
||||||
|
with get_conn() as conn:
|
||||||
|
categorias = get_categorias(conn)
|
||||||
|
paises = get_paises(conn)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
nombre = request.form.get("nombre")
|
||||||
|
url = request.form.get("url")
|
||||||
|
categoria_id = request.form.get("categoria_id")
|
||||||
|
pais_id = request.form.get("pais_id")
|
||||||
|
idioma = (request.form.get("idioma", "es") or "es").strip().lower()[:2]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO fuentes_url (nombre, url, categoria_id, pais_id, idioma)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (url) DO UPDATE
|
||||||
|
SET nombre=EXCLUDED.nombre,
|
||||||
|
categoria_id=EXCLUDED.categoria_id,
|
||||||
|
pais_id=EXCLUDED.pais_id,
|
||||||
|
idioma=EXCLUDED.idioma;
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
nombre,
|
||||||
|
url,
|
||||||
|
int(categoria_id) if categoria_id else None,
|
||||||
|
int(pais_id) if pais_id else None,
|
||||||
|
idioma,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
flash("Fuente añadida/actualizada.", "success")
|
||||||
|
return redirect(url_for("urls.manage_urls"))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error: {e}", "error")
|
||||||
|
|
||||||
|
return render_template("add_url_source.html", categorias=categorias, paises=paises)
|
||||||
|
|
||||||
28
rss-ingestor-go/Dockerfile
Normal file
28
rss-ingestor-go/Dockerfile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Build stage
|
||||||
|
FROM golang:1.21-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install git and SSL certs
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
# Copy source code immediately
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod tidy && go mod download
|
||||||
|
|
||||||
|
# Build the Go app
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o rss-ingestor .
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
WORKDIR /root/
|
||||||
|
|
||||||
|
# Copy the Pre-built binary file from the previous stage
|
||||||
|
COPY --from=builder /app/rss-ingestor .
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
|
||||||
|
# Command to run the executable
|
||||||
|
CMD ["./rss-ingestor"]
|
||||||
8
rss-ingestor-go/go.mod
Normal file
8
rss-ingestor-go/go.mod
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
module rss-ingestor-go
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/mmcdole/gofeed v1.2.1
|
||||||
|
)
|
||||||
458
rss-ingestor-go/main.go
Normal file
458
rss-ingestor-go/main.go
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the configuration loaded from environment variables
|
||||||
|
type Config struct {
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBUser string
|
||||||
|
DBPass string
|
||||||
|
DBName string
|
||||||
|
MaxWorkers int
|
||||||
|
MaxFailures int
|
||||||
|
PokeInterval time.Duration
|
||||||
|
FeedTimeout int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed represents a row in the feeds table
|
||||||
|
type Feed struct {
|
||||||
|
ID int
|
||||||
|
Nombre string
|
||||||
|
URL string
|
||||||
|
CategoriaID sql.NullInt64
|
||||||
|
PaisID sql.NullInt64
|
||||||
|
LastEtag sql.NullString
|
||||||
|
LastModified sql.NullString
|
||||||
|
Fallos int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noticia represents a news item to be inserted
|
||||||
|
type Noticia struct {
|
||||||
|
ID string
|
||||||
|
Titulo string
|
||||||
|
Resumen string
|
||||||
|
URL string
|
||||||
|
Fecha time.Time
|
||||||
|
ImagenURL string
|
||||||
|
FuenteNombre string
|
||||||
|
CategoriaID sql.NullInt64
|
||||||
|
PaisID sql.NullInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
db *sql.DB
|
||||||
|
config Config
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadConfig() {
|
||||||
|
config = Config{
|
||||||
|
DBHost: getEnv("DB_HOST", "localhost"),
|
||||||
|
DBPort: getEnv("DB_PORT", "5432"),
|
||||||
|
DBUser: getEnv("DB_USER", "rss"),
|
||||||
|
DBPass: getEnv("DB_PASS", "x"),
|
||||||
|
DBName: getEnv("DB_NAME", "rss"),
|
||||||
|
MaxWorkers: getEnvInt("RSS_MAX_WORKERS", 20), // Default to higher concurrency in Go
|
||||||
|
MaxFailures: getEnvInt("RSS_MAX_FAILURES", 10),
|
||||||
|
PokeInterval: time.Duration(getEnvInt("RSS_POKE_INTERVAL_MIN", 8)) * time.Minute,
|
||||||
|
FeedTimeout: getEnvInt("RSS_FEED_TIMEOUT", 60),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if value, ok := os.LookupEnv(key); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, fallback int) int {
|
||||||
|
strValue := getEnv(key, "")
|
||||||
|
if strValue == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
val, err := strconv.Atoi(strValue)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func initDB() {
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
config.DBHost, config.DBPort, config.DBUser, config.DBPass, config.DBName)
|
||||||
|
var err error
|
||||||
|
db, err = sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error opening DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(config.MaxWorkers + 5)
|
||||||
|
db.SetMaxIdleConns(config.MaxWorkers)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
|
||||||
|
if err = db.Ping(); err != nil {
|
||||||
|
log.Fatalf("Error connecting to DB: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Database connection established")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getActiveFeeds() ([]Feed, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, nombre, url, categoria_id, pais_id, last_etag, last_modified, COALESCE(fallos, 0)
|
||||||
|
FROM feeds
|
||||||
|
WHERE activo = TRUE AND (fallos IS NULL OR fallos < $1)
|
||||||
|
ORDER BY id
|
||||||
|
`
|
||||||
|
rows, err := db.Query(query, config.MaxFailures)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var feeds []Feed
|
||||||
|
for rows.Next() {
|
||||||
|
var f Feed
|
||||||
|
if err := rows.Scan(&f.ID, &f.Nombre, &f.URL, &f.CategoriaID, &f.PaisID, &f.LastEtag, &f.LastModified, &f.Fallos); err != nil {
|
||||||
|
log.Printf("Error scanning feed: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
feeds = append(feeds, f)
|
||||||
|
}
|
||||||
|
return feeds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateID(link string) string {
|
||||||
|
hash := md5.Sum([]byte(link))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanHTML removes generic HTML tags to store plain text summary
|
||||||
|
func cleanHTML(input string) string {
|
||||||
|
// Simple harvester, real cleaning might need a library like bluemonday if strict security needed,
|
||||||
|
// but here we just want to strip tags roughly for the 'resumen' field if it's too raw.
|
||||||
|
// For now, we will trust the database or frontend to handle rendering/sanitization,
|
||||||
|
// or perform a simple strip.
|
||||||
|
// NOTE: The python version used BeautifulSoup. In Go, we can use 'bluemonday' or just simple replacements.
|
||||||
|
// To keep dependencies low for this snippet, sending as is, but stripping major noise if needed.
|
||||||
|
return strings.TrimSpace(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractImage(item *gofeed.Item) string {
|
||||||
|
if item.Image != nil && item.Image.URL != "" {
|
||||||
|
return item.Image.URL
|
||||||
|
}
|
||||||
|
if len(item.Enclosures) > 0 {
|
||||||
|
for _, enc := range item.Enclosures {
|
||||||
|
if strings.HasPrefix(enc.Type, "image/") {
|
||||||
|
return enc.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try extensions
|
||||||
|
if ex, ok := item.Extensions["media"]; ok {
|
||||||
|
if content, ok := ex["content"]; ok {
|
||||||
|
for _, c := range content {
|
||||||
|
if url, ok := c.Attrs["url"]; ok {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if thumb, ok := ex["thumbnail"]; ok {
|
||||||
|
for _, c := range thumb {
|
||||||
|
if url, ok := c.Attrs["url"]; ok {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func processFeed(fp *gofeed.Parser, feed Feed, results chan<- int) {
|
||||||
|
// Configure custom HTTP client with timeout and User-Agent
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: time.Duration(config.FeedTimeout) * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request to set User-Agent
|
||||||
|
req, err := http.NewRequest("GET", feed.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Feed %d] Error creating request: %v", feed.ID, err)
|
||||||
|
updateFeedStatus(feed.ID, "", "", false, err.Error())
|
||||||
|
results <- 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "RSS2-Ingestor-Go/1.0")
|
||||||
|
|
||||||
|
// NOTE: We INTENTIONALLY SKIP ETag/Last-Modified headers based on user issues
|
||||||
|
// If needed in future, uncomment:
|
||||||
|
// if feed.LastEtag.Valid { req.Header.Set("If-None-Match", feed.LastEtag.String) }
|
||||||
|
// if feed.LastModified.Valid { req.Header.Set("If-Modified-Since", feed.LastModified.String) }
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Feed %d] Error fetching: %v", feed.ID, err)
|
||||||
|
updateFeedStatus(feed.ID, "", "", false, err.Error())
|
||||||
|
results <- 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 304 {
|
||||||
|
log.Printf("[Feed %d] Not Modified (304)", feed.ID)
|
||||||
|
// Update timestamp only? Or keep as is.
|
||||||
|
updateFeedStatus(feed.ID, feed.LastEtag.String, feed.LastModified.String, true, "")
|
||||||
|
results <- 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
errMsg := fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
log.Printf("[Feed %d] Error: %s", feed.ID, errMsg)
|
||||||
|
updateFeedStatus(feed.ID, "", "", false, errMsg)
|
||||||
|
results <- 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedFeed, err := fp.Parse(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Feed %d] Parser Error: %v", feed.ID, err)
|
||||||
|
updateFeedStatus(feed.ID, "", "", false, err.Error())
|
||||||
|
results <- 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare news items
|
||||||
|
var noticias []Noticia
|
||||||
|
for _, item := range parsedFeed.Items {
|
||||||
|
if item.Link == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pubDate := time.Now()
|
||||||
|
if item.PublishedParsed != nil {
|
||||||
|
pubDate = *item.PublishedParsed
|
||||||
|
} else if item.UpdatedParsed != nil {
|
||||||
|
pubDate = *item.UpdatedParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML cleanup simply takes Description or Content
|
||||||
|
resumen := item.Description
|
||||||
|
if resumen == "" {
|
||||||
|
resumen = item.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
noticia := Noticia{
|
||||||
|
ID: generateID(item.Link),
|
||||||
|
Titulo: item.Title,
|
||||||
|
Resumen: cleanHTML(resumen),
|
||||||
|
URL: item.Link,
|
||||||
|
Fecha: pubDate,
|
||||||
|
ImagenURL: extractImage(item),
|
||||||
|
FuenteNombre: feed.Nombre,
|
||||||
|
CategoriaID: feed.CategoriaID,
|
||||||
|
PaisID: feed.PaisID,
|
||||||
|
}
|
||||||
|
noticias = append(noticias, noticia)
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted := insertNoticias(noticias)
|
||||||
|
|
||||||
|
// Get new headers
|
||||||
|
newEtag := resp.Header.Get("ETag")
|
||||||
|
newModified := resp.Header.Get("Last-Modified")
|
||||||
|
|
||||||
|
updateFeedStatus(feed.ID, newEtag, newModified, true, "")
|
||||||
|
|
||||||
|
if inserted > 0 {
|
||||||
|
log.Printf("[Feed %d] Inserted %d new items", feed.ID, inserted)
|
||||||
|
}
|
||||||
|
results <- inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertNoticias(noticias []Noticia) int {
|
||||||
|
if len(noticias) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using COPY for bulk insert is most efficient, but complexity with handling conflicts
|
||||||
|
// "ON CONFLICT DO NOTHING" works best with normal INSERT.
|
||||||
|
// For simplicity and correctness with "ON CONFLICT", we use transaction and prepared statement.
|
||||||
|
// For very high performance, we could channel all to a batch writer.
|
||||||
|
// Given 1400 feeds, batch of 10-20 items per feed, standard Insert is okay if parallelized.
|
||||||
|
|
||||||
|
txn, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error beginning txn: %v", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer txn.Rollback()
|
||||||
|
|
||||||
|
stmt, err := txn.Prepare(pq.CopyIn("noticias", "id", "titulo", "resumen", "url", "fecha", "imagen_url", "fuente_nombre", "categoria_id", "pais_id"))
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to individual inserts if CopyIn is too complex with ON CONFLICT (CopyIn doesn't support ON CONFLICT natively easily without temp tables)
|
||||||
|
// Let's use multi-row INSERT with ON CONFLICT.
|
||||||
|
return insertNoticiasWithConflict(noticias)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
// WAIT. lib/pq CopyIn does NOT support ON CONFLICT DO NOTHING.
|
||||||
|
// It will fail if duplicates exist. Since we expect duplicates (RSS feeds repeat items),
|
||||||
|
// CopyIn is risky directly into main table.
|
||||||
|
// Strategy: Use INSERT ... ON CONFLICT DO NOTHING with unnest or VALUES.
|
||||||
|
return insertNoticiasWithConflict(noticias)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertNoticiasWithConflict(noticias []Noticia) int {
|
||||||
|
// Efficient bulk insert for Postgres using unnest
|
||||||
|
// Or standard multi-value insert.
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
// Chunking to avoid parameter limit (65535)
|
||||||
|
chunkSize := 500
|
||||||
|
for i := 0; i < len(noticias); i += chunkSize {
|
||||||
|
end := i + chunkSize
|
||||||
|
if end > len(noticias) {
|
||||||
|
end = len(noticias)
|
||||||
|
}
|
||||||
|
chunk := noticias[i:end]
|
||||||
|
|
||||||
|
placeholders := []string{}
|
||||||
|
vals := []interface{}{}
|
||||||
|
|
||||||
|
for j, n := range chunk {
|
||||||
|
offset := j * 9
|
||||||
|
placeholders = append(placeholders, fmt.Sprintf("($%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d)",
|
||||||
|
offset+1, offset+2, offset+3, offset+4, offset+5, offset+6, offset+7, offset+8, offset+9))
|
||||||
|
|
||||||
|
vals = append(vals, n.ID, n.Titulo, n.Resumen, n.URL, n.Fecha, n.ImagenURL, n.FuenteNombre, n.CategoriaID, n.PaisID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, fuente_nombre, categoria_id, pais_id)
|
||||||
|
VALUES %s
|
||||||
|
ON CONFLICT (url) DO NOTHING
|
||||||
|
`, strings.Join(placeholders, ","))
|
||||||
|
|
||||||
|
res, err := db.Exec(query, vals...)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Batch insert error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAff, _ := res.RowsAffected()
|
||||||
|
count += int(rowsAff)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFeedStatus(id int, etag, modified string, success bool, lastError string) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if success {
|
||||||
|
query = `UPDATE feeds SET fallos = 0, last_etag = $1, last_modified = $2, last_error = NULL WHERE id = $3`
|
||||||
|
args = []interface{}{etag, modified, id}
|
||||||
|
} else {
|
||||||
|
// Increment failure count
|
||||||
|
query = `
|
||||||
|
UPDATE feeds
|
||||||
|
SET fallos = COALESCE(fallos, 0) + 1,
|
||||||
|
last_error = $1,
|
||||||
|
activo = CASE WHEN COALESCE(fallos, 0) + 1 >= $2 THEN FALSE ELSE activo END
|
||||||
|
WHERE id = $3`
|
||||||
|
args = []interface{}{lastError, config.MaxFailures, id}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating feed %d status: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ingestCycle() {
|
||||||
|
log.Println("Starting Ingestion Cycle...")
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
feeds, err := getActiveFeeds()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting feeds: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feeds) == 0 {
|
||||||
|
log.Println("No active feeds found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Processing %d feeds with %d workers...", len(feeds), config.MaxWorkers)
|
||||||
|
|
||||||
|
jobs := make(chan Feed, len(feeds))
|
||||||
|
results := make(chan int, len(feeds))
|
||||||
|
|
||||||
|
// Start workers
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < config.MaxWorkers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
fp := gofeed.NewParser()
|
||||||
|
for feed := range jobs {
|
||||||
|
processFeed(fp, feed, results)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send jobs
|
||||||
|
for _, f := range feeds {
|
||||||
|
jobs <- f
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
|
||||||
|
// Wait for workers in background to close results when done
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Count results
|
||||||
|
totalNew := 0
|
||||||
|
for inserted := range results {
|
||||||
|
totalNew += inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Since(start)
|
||||||
|
log.Printf("Ingestion Cycle Complete. Processed %d feeds in %v. New items: %d", len(feeds), duration, totalNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
loadConfig()
|
||||||
|
initDB()
|
||||||
|
|
||||||
|
// Run immediately on start
|
||||||
|
ingestCycle()
|
||||||
|
|
||||||
|
// Scheduler loop
|
||||||
|
ticker := time.NewTicker(config.PokeInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
ingestCycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
27
rss-web-go/Dockerfile
Normal file
27
rss-web-go/Dockerfile
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
FROM golang:1.21-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install git
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Copy files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod tidy && go mod download
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o rss-web .
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM alpine:latest
|
||||||
|
WORKDIR /root/
|
||||||
|
COPY --from=builder /app/rss-web .
|
||||||
|
|
||||||
|
# Copy static assets and templates manually for now (assuming context is root)
|
||||||
|
# In docker-compose we will mount volumes or copy them
|
||||||
|
# COPY templates ./templates
|
||||||
|
# COPY static ./static
|
||||||
|
|
||||||
|
CMD ["./rss-web"]
|
||||||
9
rss-web-go/go.mod
Normal file
9
rss-web-go/go.mod
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
module rss-web-go
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
)
|
||||||
108
rss-web-go/main.go
Normal file
108
rss-web-go/main.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *sql.DB
|
||||||
|
|
||||||
|
func initDB() {
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), os.Getenv("DB_PASS"), os.Getenv("DB_NAME"))
|
||||||
|
|
||||||
|
var err error
|
||||||
|
db, err = sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(25)
|
||||||
|
db.SetMaxIdleConns(25)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
|
||||||
|
if err = db.Ping(); err != nil {
|
||||||
|
log.Fatalf("Cannot connect to DB: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Connected to Database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template Functions (to replace Jinja filters)
|
||||||
|
var funcMap = template.FuncMap{
|
||||||
|
"safe_html": func(s string) template.HTML {
|
||||||
|
return template.HTML(s)
|
||||||
|
},
|
||||||
|
"format_date": func(t time.Time) string {
|
||||||
|
return t.Format("02/01/2006")
|
||||||
|
},
|
||||||
|
"country_flag": func(code string) string {
|
||||||
|
// Placeholder logic, real logic needs country mapping
|
||||||
|
return "🏳️"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Debug mode for now
|
||||||
|
gin.SetMode(gin.DebugMode)
|
||||||
|
|
||||||
|
initDB()
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// Load Templates with FuncMap
|
||||||
|
// We need to support the template structure.
|
||||||
|
// For now, let's try to load them directly, but likely we need to adapt syntax.
|
||||||
|
// r.SetFuncMap(funcMap)
|
||||||
|
// r.LoadHTMLGlob("templates/*")
|
||||||
|
|
||||||
|
// Static Files
|
||||||
|
r.Static("/static", "./static")
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
r.GET("/", homeHandler)
|
||||||
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "engine": "golang"})
|
||||||
|
})
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8001"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Starting Server on port %s", port)
|
||||||
|
if err := r.Run("0.0.0.0:" + port); err != nil {
|
||||||
|
log.Fatalf("Server failed to start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeHandler(c *gin.Context) {
|
||||||
|
// Simple query for testing connectivity
|
||||||
|
rows, err := db.Query("SELECT titulo, url FROM noticias ORDER BY fecha DESC LIMIT 10")
|
||||||
|
if err != nil {
|
||||||
|
c.String(500, "DB Error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var news []map[string]string
|
||||||
|
for rows.Next() {
|
||||||
|
var titulo, url string
|
||||||
|
if err := rows.Scan(&titulo, &url); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
news = append(news, map[string]string{"titulo": titulo, "url": url})
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, return JSON to prove it works before porting the complex HTML
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"message": "Welcome to RSS2 Go Web Server",
|
||||||
|
"news": news,
|
||||||
|
})
|
||||||
|
}
|
||||||
87
scheduler.py
Normal file
87
scheduler.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import atexit
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
# Carga de la app Flask
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
# Import correcto de operaciones de traduccion
|
||||||
|
from translation_ops import run_producer_cycle
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=logging.INFO,
|
||||||
|
format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler(
|
||||||
|
daemon=True,
|
||||||
|
timezone="UTC"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown_scheduler():
|
||||||
|
"""Detiene el planificador al salir del proceso de forma segura."""
|
||||||
|
try:
|
||||||
|
if scheduler.running:
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
logging.info("Scheduler detenido correctamente.")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error al detener el scheduler: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Registrar apagado limpio
|
||||||
|
atexit.register(shutdown_scheduler)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
# Entrar al contexto de Flask (necesario para partes del proyecto)
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
# Job 1: RSS Fetching -> MOVIDO A GO (rss-ingestor-go)
|
||||||
|
# Este scheduler ya no maneja la ingesta de noticias.
|
||||||
|
|
||||||
|
# Job 2: Translation Producer
|
||||||
|
scheduler.add_job(
|
||||||
|
run_producer_cycle,
|
||||||
|
trigger="interval",
|
||||||
|
minutes=1,
|
||||||
|
id="translation_producer_job",
|
||||||
|
next_run_time=datetime.utcnow() + timedelta(seconds=5),
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Job 3: Precache Entities
|
||||||
|
from scripts.precache_entities import run_precache
|
||||||
|
scheduler.add_job(
|
||||||
|
run_precache,
|
||||||
|
trigger="interval",
|
||||||
|
hours=6,
|
||||||
|
id="precache_entities_job",
|
||||||
|
next_run_time=datetime.utcnow() + timedelta(seconds=20),
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
logging.info("Scheduler iniciado correctamente.")
|
||||||
|
logging.info("Tareas activas: translation_producer_job, precache_entities_job")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(f"Error inicializando el scheduler: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Mantener proceso vivo (necesario para Docker)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
logging.info("Apagando el scheduler worker...")
|
||||||
67
scripts/clean_unk_tokens.py
Normal file
67
scripts/clean_unk_tokens.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script para limpiar caracteres <unk> de las traducciones.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from db import get_conn
|
||||||
|
|
||||||
|
def clean_text(text):
|
||||||
|
"""Remove <unk> tokens and other problematic characters."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
# Remove <unk> tokens
|
||||||
|
text = text.replace('<unk>', '')
|
||||||
|
text = text.replace('<EFBFBD>', '')
|
||||||
|
# Remove other problematic Unicode characters
|
||||||
|
text = re.sub(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]', '', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Clean all translations with <unk> tokens."""
|
||||||
|
print("🧹 Limpiando tokens <unk> de traducciones...")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Find translations with <unk> tokens
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, titulo_trad, resumen_trad
|
||||||
|
FROM traducciones
|
||||||
|
WHERE titulo_trad LIKE '%<unk>%'
|
||||||
|
OR resumen_trad LIKE '%<unk>%'
|
||||||
|
OR titulo_trad LIKE '%<EFBFBD>%'
|
||||||
|
OR resumen_trad LIKE '%<EFBFBD>%'
|
||||||
|
""")
|
||||||
|
|
||||||
|
translations = cur.fetchall()
|
||||||
|
print(f"📊 Encontradas {len(translations)} traducciones con tokens problemáticos")
|
||||||
|
|
||||||
|
if not translations:
|
||||||
|
print("✅ No hay traducciones que limpiar")
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
for row in translations:
|
||||||
|
tr_id, titulo, resumen = row
|
||||||
|
|
||||||
|
# Clean the fields
|
||||||
|
new_titulo = clean_text(titulo) if titulo else titulo
|
||||||
|
new_resumen = clean_text(resumen) if resumen else resumen
|
||||||
|
|
||||||
|
# Update only if something changed
|
||||||
|
if new_titulo != titulo or new_resumen != resumen:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE traducciones
|
||||||
|
SET titulo_trad = %s,
|
||||||
|
resumen_trad = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (new_titulo, new_resumen, tr_id))
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
if updated_count % 100 == 0:
|
||||||
|
print(f" ⏳ Procesadas {updated_count} traducciones...")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"✅ Limpieza completada: {updated_count} traducciones actualizadas")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
39
scripts/convert_model.sh
Executable file
39
scripts/convert_model.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Convertir modelo NLLB de HuggingFace a formato CTranslate2
|
||||||
|
# Ejecutar una vez antes de usar el translation_worker con CTranslate2
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MODEL=${UNIVERSAL_MODEL:-"facebook/nllb-200-distilled-600M"}
|
||||||
|
OUTPUT_DIR=${CT2_MODEL_PATH:-"./models/nllb-ct2"}
|
||||||
|
QUANTIZATION=${CT2_QUANTIZATION:-"int8_float16"}
|
||||||
|
|
||||||
|
echo "=== Conversión de modelo NLLB a CTranslate2 ==="
|
||||||
|
echo "Modelo origen: $MODEL"
|
||||||
|
echo "Directorio destino: $OUTPUT_DIR"
|
||||||
|
echo "Quantización: $QUANTIZATION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar que ctranslate2 está instalado
|
||||||
|
if ! command -v ct2-transformers-converter &> /dev/null; then
|
||||||
|
echo "Error: ct2-transformers-converter no encontrado."
|
||||||
|
echo "Instala con: pip install ctranslate2"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Crear directorio si no existe
|
||||||
|
mkdir -p "$(dirname "$OUTPUT_DIR")"
|
||||||
|
|
||||||
|
# Convertir el modelo
|
||||||
|
echo "Iniciando conversión (puede tardar 5-10 minutos)..."
|
||||||
|
ct2-transformers-converter \
|
||||||
|
--model "$MODEL" \
|
||||||
|
--output_dir "$OUTPUT_DIR" \
|
||||||
|
--quantization "$QUANTIZATION" \
|
||||||
|
--force
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Conversión completada: $OUTPUT_DIR"
|
||||||
|
echo ""
|
||||||
|
echo "Para usar el modelo, establece:"
|
||||||
|
echo " export CT2_MODEL_PATH=$OUTPUT_DIR"
|
||||||
77
scripts/crear_parrillas_ejemplo.sh
Normal file
77
scripts/crear_parrillas_ejemplo.sh
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Script de ejemplo para crear parrillas de videos
|
||||||
|
|
||||||
|
echo "🎬 Creando parrillas de ejemplo..."
|
||||||
|
|
||||||
|
# 1. Noticias de Bulgaria
|
||||||
|
docker-compose exec -T db psql -U rss -d rss << EOF
|
||||||
|
INSERT INTO video_parrillas (
|
||||||
|
nombre, descripcion, tipo_filtro,
|
||||||
|
pais_id, max_noticias, duracion_maxima,
|
||||||
|
idioma_voz, template, include_images, include_subtitles,
|
||||||
|
frecuencia, activo
|
||||||
|
) VALUES (
|
||||||
|
'Noticias de Bulgaria',
|
||||||
|
'Resumen diario de las noticias más importantes de Bulgaria',
|
||||||
|
'pais',
|
||||||
|
(SELECT id FROM paises WHERE nombre ILIKE '%bulgaria%' LIMIT 1),
|
||||||
|
5, 180,
|
||||||
|
'es', 'standard', true, true,
|
||||||
|
'daily', true
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 2. Ciencia en Europa
|
||||||
|
docker-compose exec -T db psql -U rss -d rss << EOF
|
||||||
|
INSERT INTO video_parrillas (
|
||||||
|
nombre, descripcion, tipo_filtro,
|
||||||
|
categoria_id, continente_id, max_noticias,
|
||||||
|
idioma_voz, template, include_subtitles,
|
||||||
|
frecuencia, activo
|
||||||
|
) VALUES (
|
||||||
|
'Ciencia en Europa',
|
||||||
|
'Las últimas noticias científicas de Europa',
|
||||||
|
'categoria',
|
||||||
|
(SELECT id FROM categorias WHERE nombre ILIKE '%ciencia%' LIMIT 1),
|
||||||
|
(SELECT id FROM continentes WHERE nombre = 'Europa' LIMIT 1),
|
||||||
|
7,
|
||||||
|
'es', 'modern', true,
|
||||||
|
'daily', true
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 3. Tecnología Global
|
||||||
|
docker-compose exec -T db psql -U rss -d rss << EOF
|
||||||
|
INSERT INTO video_parrillas (
|
||||||
|
nombre, descripcion, tipo_filtro,
|
||||||
|
categoria_id, max_noticias, duracion_maxima,
|
||||||
|
idioma_voz, template, include_subtitles,
|
||||||
|
frecuencia, activo
|
||||||
|
) VALUES (
|
||||||
|
'Tech News Daily',
|
||||||
|
'Resumen diario de tecnología mundial',
|
||||||
|
'categoria',
|
||||||
|
(SELECT id FROM categorias WHERE nombre ILIKE '%tecnolog%' LIMIT 1),
|
||||||
|
8, 300,
|
||||||
|
'es', 'modern', true,
|
||||||
|
'daily', true
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Parrillas creadas!"
|
||||||
|
echo ""
|
||||||
|
echo "📊 Ver parrillas creadas:"
|
||||||
|
docker-compose exec -T db psql -U rss -d rss -c "
|
||||||
|
SELECT id, nombre, tipo_filtro, max_noticias, frecuencia, activo
|
||||||
|
FROM video_parrillas
|
||||||
|
ORDER BY id DESC;
|
||||||
|
"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎥 Accede a la interfaz web en: http://localhost:8001/parrillas/"
|
||||||
|
echo ""
|
||||||
|
echo "💡 Para generar un video manualmente:"
|
||||||
|
echo " docker-compose exec web python generar_videos_noticias.py <id_parrilla>"
|
||||||
|
echo ""
|
||||||
|
echo "📅 Para generar todos los videos del día:"
|
||||||
|
echo " docker-compose exec web python generar_videos_noticias.py"
|
||||||
64
scripts/diagnose_rss.py
Normal file
64
scripts/diagnose_rss.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
DB_WRITE_HOST = os.environ.get("DB_WRITE_HOST", "db")
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "rss")
|
||||||
|
DB_USER = os.environ.get("DB_USER", "rss")
|
||||||
|
DB_PASS = os.environ.get("DB_PASS", "x")
|
||||||
|
DB_PORT = os.environ.get("DB_PORT", "5432")
|
||||||
|
|
||||||
|
def check_db():
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_WRITE_HOST,
|
||||||
|
database=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASS,
|
||||||
|
port=DB_PORT,
|
||||||
|
connect_timeout=5
|
||||||
|
)
|
||||||
|
print("✅ Database connection successful.")
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# 1. Total news and latest date
|
||||||
|
cur.execute("SELECT COUNT(*), MAX(fecha) FROM noticias;")
|
||||||
|
count, latest = cur.fetchone()
|
||||||
|
print(f"📊 Total news: {count}")
|
||||||
|
print(f"🕒 Latest news date: {latest}")
|
||||||
|
|
||||||
|
# 2. Feed status
|
||||||
|
cur.execute("SELECT COUNT(*) FROM feeds WHERE activo = TRUE;")
|
||||||
|
active_feeds = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT COUNT(*) FROM feeds WHERE activo = FALSE;")
|
||||||
|
inactive_feeds = cur.fetchone()[0]
|
||||||
|
print(f"📡 Active feeds: {active_feeds}")
|
||||||
|
print(f"🚫 Inactive feeds: {inactive_feeds}")
|
||||||
|
|
||||||
|
# 3. Feeds with most failures
|
||||||
|
cur.execute("SELECT id, nombre, url, fallos, last_error FROM feeds WHERE fallos > 0 ORDER BY fallos DESC LIMIT 5;")
|
||||||
|
failures = cur.fetchall()
|
||||||
|
if failures:
|
||||||
|
print("\n⚠️ Feeds with most failures:")
|
||||||
|
for f in failures:
|
||||||
|
print(f" - ID {f[0]}: {f[1]} ({f[3]} fallos) - Error: {f[4]}")
|
||||||
|
else:
|
||||||
|
print("\n✅ No feeds with reported failures.")
|
||||||
|
|
||||||
|
# 4. Check for unprocessed translations (if applicable)
|
||||||
|
# Checking schema again: table 'noticias' doesn't seem to have a 'translated' flag?
|
||||||
|
# Conversation eeb18716 mentioned 'TRAD/MIN, PENDING, PROCESSING, COMPLETED, ERRORS' metrics.
|
||||||
|
# Let's check 'traducciones' table if it exists.
|
||||||
|
cur.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'traducciones');")
|
||||||
|
if cur.fetchone()[0]:
|
||||||
|
cur.execute("SELECT COUNT(*) FROM noticias WHERE id NOT IN (SELECT noticia_id FROM traducciones);")
|
||||||
|
pending_trans = cur.fetchone()[0]
|
||||||
|
print(f"🌎 News pending translation: {pending_trans}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Database error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_db()
|
||||||
99
scripts/download_models.py
Normal file
99
scripts/download_models.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
import nltk
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Logging
|
||||||
|
# ================================================================
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
LOG = logging.getLogger("download_models")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# SSL FIX
|
||||||
|
# ================================================================
|
||||||
|
try:
|
||||||
|
_create_unverified_https_context = ssl._create_unverified_context
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
ssl._create_default_https_context = _create_unverified_https_context
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Paths y modelos
|
||||||
|
# ================================================================
|
||||||
|
NLTK_PACKAGES = ["punkt", "punkt_tab", "stopwords"]
|
||||||
|
|
||||||
|
NLLB_MODEL = "facebook/nllb-200-distilled-600M"
|
||||||
|
|
||||||
|
FASTTEXT_URL = "https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.218.bin"
|
||||||
|
FASTTEXT_DEST = "/app/models/lid.218.bin" # donde lo espera tu worker
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Descargar NLTK
|
||||||
|
# ================================================================
|
||||||
|
def download_nltk():
|
||||||
|
for pkg in NLTK_PACKAGES:
|
||||||
|
try:
|
||||||
|
path = f"tokenizers/{pkg}" if pkg.startswith("punkt") else f"corpora/{pkg}"
|
||||||
|
nltk.data.find(path)
|
||||||
|
LOG.info(f"NLTK '{pkg}' already installed")
|
||||||
|
except LookupError:
|
||||||
|
LOG.info(f"Downloading NLTK '{pkg}'...")
|
||||||
|
nltk.download(pkg, quiet=True)
|
||||||
|
LOG.info(f"Downloaded OK: {pkg}")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Descargar NLLB
|
||||||
|
# ================================================================
|
||||||
|
def download_nllb(model_name: str):
|
||||||
|
LOG.info(f"Downloading NLLB model: {model_name}")
|
||||||
|
try:
|
||||||
|
AutoTokenizer.from_pretrained(model_name)
|
||||||
|
AutoModelForSeq2SeqLM.from_pretrained(model_name)
|
||||||
|
LOG.info(f"Downloaded OK: {model_name}")
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"Failed downloading NLLB model {model_name}: {e}")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Descargar fastText LID.218
|
||||||
|
# ================================================================
|
||||||
|
def download_fasttext():
|
||||||
|
# Crear carpeta /app/models si no existe
|
||||||
|
dest_dir = os.path.dirname(FASTTEXT_DEST)
|
||||||
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Si ya existe, no lo descargamos
|
||||||
|
if os.path.exists(FASTTEXT_DEST):
|
||||||
|
LOG.info(f"fastText LID already exists at {FASTTEXT_DEST}")
|
||||||
|
return
|
||||||
|
|
||||||
|
LOG.info(f"Downloading fastText LID model from {FASTTEXT_URL}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(FASTTEXT_URL, FASTTEXT_DEST)
|
||||||
|
LOG.info(f"Downloaded fastText LID model to {FASTTEXT_DEST}")
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"Failed to download fastText LID model: {e}")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Main
|
||||||
|
# ================================================================
|
||||||
|
if __name__ == "__main__":
|
||||||
|
LOG.info("Downloading NLTK data...")
|
||||||
|
download_nltk()
|
||||||
|
|
||||||
|
LOG.info("Downloading NLLB model...")
|
||||||
|
download_nllb(NLLB_MODEL)
|
||||||
|
|
||||||
|
LOG.info("Downloading fastText LID model...")
|
||||||
|
download_fasttext()
|
||||||
|
|
||||||
|
LOG.info("All downloads completed successfully.")
|
||||||
|
|
||||||
71
scripts/fix_html_entities.py
Normal file
71
scripts/fix_html_entities.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import html
|
||||||
|
import psycopg2
|
||||||
|
from db import get_conn
|
||||||
|
import re
|
||||||
|
|
||||||
|
def fix_entities():
|
||||||
|
print("🔧 Fixing HTML entities in database...")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# 1. Update Noticias
|
||||||
|
print("Processing 'noticias' table...")
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, titulo, resumen
|
||||||
|
FROM noticias
|
||||||
|
WHERE titulo LIKE '%&%;%' OR resumen LIKE '%&%;%'
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
print(f"Found {len(rows)} rows in 'noticias' to check.")
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for r in rows:
|
||||||
|
nid, tit, res = r
|
||||||
|
|
||||||
|
new_tit = html.unescape(tit) if tit else tit
|
||||||
|
new_res = html.unescape(res) if res else res
|
||||||
|
|
||||||
|
if new_tit != tit or new_res != res:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE noticias
|
||||||
|
SET titulo = %s, resumen = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (new_tit, new_res, nid))
|
||||||
|
count += 1
|
||||||
|
if count % 100 == 0:
|
||||||
|
print(f"Updated {count} noticias...")
|
||||||
|
|
||||||
|
print(f"Updated {count} rows in 'noticias'.")
|
||||||
|
|
||||||
|
# 2. Update Traducciones
|
||||||
|
print("\nProcessing 'traducciones' table...")
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, titulo_trad, resumen_trad
|
||||||
|
FROM traducciones
|
||||||
|
WHERE titulo_trad LIKE '%&%;%' OR resumen_trad LIKE '%&%;%'
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
print(f"Found {len(rows)} translations to check.")
|
||||||
|
|
||||||
|
count_tr = 0
|
||||||
|
for r in rows:
|
||||||
|
tid, tit, res = r
|
||||||
|
|
||||||
|
new_tit = html.unescape(tit) if tit else tit
|
||||||
|
new_res = html.unescape(res) if res else res
|
||||||
|
|
||||||
|
if new_tit != tit or new_res != res:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE traducciones
|
||||||
|
SET titulo_trad = %s, resumen_trad = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (new_tit, new_res, tid))
|
||||||
|
count_tr += 1
|
||||||
|
|
||||||
|
print(f"Updated {count_tr} rows in 'traducciones'.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✅ Database cleaning complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
fix_entities()
|
||||||
92
scripts/fix_html_recursive.py
Normal file
92
scripts/fix_html_recursive.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import html
|
||||||
|
import psycopg2
|
||||||
|
from db import get_conn
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def recursive_unescape(text):
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Limit loops to prevent infinite loops on weird edge cases
|
||||||
|
max_loops = 5
|
||||||
|
current = text
|
||||||
|
|
||||||
|
for _ in range(max_loops):
|
||||||
|
new_text = html.unescape(current)
|
||||||
|
if new_text == current:
|
||||||
|
break
|
||||||
|
current = new_text
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
|
def fix_entities_recursive():
|
||||||
|
print("🔧 Fixing HTML entities RECURSIVELY in database...")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# 1. Update Noticias
|
||||||
|
print("Processing 'noticias' table...")
|
||||||
|
# We select ALL rows that contain '&' to catch any entity
|
||||||
|
# Optimisation: limit to rows with '&'
|
||||||
|
# Note: This might be slow if table is huge, but we have ~13k rows, it's fine.
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, titulo, resumen
|
||||||
|
FROM noticias
|
||||||
|
WHERE titulo LIKE '%&%' OR resumen LIKE '%&%'
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
print(f"Found {len(rows)} candidates in 'noticias'.")
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for r in rows:
|
||||||
|
nid, tit, res = r
|
||||||
|
|
||||||
|
new_tit = recursive_unescape(tit)
|
||||||
|
new_res = recursive_unescape(res)
|
||||||
|
|
||||||
|
if new_tit != tit or new_res != res:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE noticias
|
||||||
|
SET titulo = %s, resumen = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (new_tit, new_res, nid))
|
||||||
|
count += 1
|
||||||
|
if count % 100 == 0:
|
||||||
|
print(f"Updated {count} noticias...")
|
||||||
|
|
||||||
|
print(f"Total updated in 'noticias': {count}")
|
||||||
|
|
||||||
|
# 2. Update Traducciones
|
||||||
|
print("\nProcessing 'traducciones' table...")
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, titulo_trad, resumen_trad
|
||||||
|
FROM traducciones
|
||||||
|
WHERE titulo_trad LIKE '%&%' OR resumen_trad LIKE '%&%'
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
print(f"Found {len(rows)} candidates in 'traducciones'.")
|
||||||
|
|
||||||
|
count_tr = 0
|
||||||
|
for r in rows:
|
||||||
|
tid, tit, res = r
|
||||||
|
|
||||||
|
new_tit = recursive_unescape(tit)
|
||||||
|
new_res = recursive_unescape(res)
|
||||||
|
|
||||||
|
if new_tit != tit or new_res != res:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE traducciones
|
||||||
|
SET titulo_trad = %s, resumen_trad = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (new_tit, new_res, tid))
|
||||||
|
count_tr += 1
|
||||||
|
if count_tr % 100 == 0:
|
||||||
|
print(f"Updated {count_tr} traducciones...")
|
||||||
|
|
||||||
|
print(f"Total updated in 'traducciones': {count_tr}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✅ Database cleaning complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
fix_entities_recursive()
|
||||||
244
scripts/migrate_to_qdrant.py
Executable file
244
scripts/migrate_to_qdrant.py
Executable file
|
|
@ -0,0 +1,244 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script de migración para vectorizar noticias existentes en Qdrant.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
# Ver estadísticas
|
||||||
|
python scripts/migrate_to_qdrant.py --stats
|
||||||
|
|
||||||
|
# Vectorizar noticias (proceso completo)
|
||||||
|
python scripts/migrate_to_qdrant.py --vectorize --batch-size 200
|
||||||
|
|
||||||
|
# Limpiar y empezar de nuevo
|
||||||
|
python scripts/migrate_to_qdrant.py --reset
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Añadir el directorio raíz al path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from db import get_read_conn, get_write_conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_statistics():
|
||||||
|
"""
|
||||||
|
Muestra estadísticas del sistema.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("📊 ESTADÍSTICAS DEL SISTEMA")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Traducciones totales
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE lang_to = 'es') as es,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'done') as completadas
|
||||||
|
FROM traducciones
|
||||||
|
""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
print(f"\n📰 TRADUCCIONES:")
|
||||||
|
print(f" Total: {row[0]:,}")
|
||||||
|
print(f" En español: {row[1]:,}")
|
||||||
|
print(f" Completadas: {row[2]:,}")
|
||||||
|
|
||||||
|
# Estado vectorización
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE vectorized = TRUE) as vectorizadas,
|
||||||
|
COUNT(*) FILTER (WHERE vectorized = FALSE AND status = 'done') as pendientes
|
||||||
|
FROM traducciones
|
||||||
|
WHERE lang_to = 'es'
|
||||||
|
""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
print(f"\n🔧 VECTORIZACIÓN:")
|
||||||
|
print(f" Total (ES): {row[0]:,}")
|
||||||
|
print(f" Vectorizadas: {row[1]:,}")
|
||||||
|
print(f" Pendientes: {row[2]:,}")
|
||||||
|
|
||||||
|
# Info de Qdrant (si existe)
|
||||||
|
try:
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
qdrant_host = os.environ.get("QDRANT_HOST", "localhost")
|
||||||
|
qdrant_port = int(os.environ.get("QDRANT_PORT", "6333"))
|
||||||
|
collection_name = os.environ.get("QDRANT_COLLECTION_NAME", "news_vectors")
|
||||||
|
|
||||||
|
client = QdrantClient(host=qdrant_host, port=qdrant_port)
|
||||||
|
collection_info = client.get_collection(collection_name)
|
||||||
|
|
||||||
|
print(f"\n🔍 QDRANT:")
|
||||||
|
print(f" Colección: {collection_name}")
|
||||||
|
print(f" Puntos: {collection_info.points_count:,}")
|
||||||
|
print(f" Vectores: {collection_info.vectors_count:,}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n⚠️ No se pudo conectar a Qdrant: {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 80 + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def vectorize_all(batch_size: int = 200):
|
||||||
|
"""
|
||||||
|
Vectoriza todas las noticias traducidas pendientes.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("🔍 INICIANDO VECTORIZACIÓN MASIVA")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"Tamaño de lote: {batch_size}")
|
||||||
|
print("=" * 80 + "\n")
|
||||||
|
|
||||||
|
# Importar el worker de Qdrant
|
||||||
|
from workers.qdrant_worker import (
|
||||||
|
init_qdrant_client,
|
||||||
|
init_embedding_model,
|
||||||
|
get_pending_news,
|
||||||
|
upload_to_qdrant
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inicializar
|
||||||
|
print("🔌 Inicializando Qdrant...")
|
||||||
|
init_qdrant_client()
|
||||||
|
|
||||||
|
print("🤖 Cargando modelo de embeddings...")
|
||||||
|
init_embedding_model()
|
||||||
|
|
||||||
|
total_processed = 0
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Obtener lote pendiente
|
||||||
|
news_batch = get_pending_news(limit=batch_size)
|
||||||
|
|
||||||
|
if not news_batch:
|
||||||
|
print("\n✅ No hay más noticias pendientes de vectorizar")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\n📋 Procesando lote de {len(news_batch)} noticias...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
upload_to_qdrant(news_batch)
|
||||||
|
total_processed += len(news_batch)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
rate = total_processed / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
|
print(f"\n📊 Progreso: {total_processed:,} vectorizadas")
|
||||||
|
print(f"⏱️ Velocidad: {rate:.2f} noticias/segundo")
|
||||||
|
print(f"⏳ Tiempo transcurrido: {elapsed/60:.1f} minutos")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error procesando lote: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("✅ VECTORIZACIÓN COMPLETADA")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"Total vectorizadas: {total_processed:,}")
|
||||||
|
print(f"Tiempo total: {elapsed/60:.1f} minutos")
|
||||||
|
print(f"Velocidad promedio: {total_processed/elapsed:.2f} noticias/segundo")
|
||||||
|
print("=" * 80 + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_all():
|
||||||
|
"""
|
||||||
|
Resetea el estado de vectorización y limpia Qdrant.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("⚠️ RESET COMPLETO DEL SISTEMA DE VECTORES")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
response = input("\n¿Estás seguro? Esto eliminará TODOS los vectores y reiniciará el estado (s/N): ")
|
||||||
|
|
||||||
|
if response.lower() != 's':
|
||||||
|
print("❌ Operación cancelada")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n🗑️ Reseteando base de datos...")
|
||||||
|
|
||||||
|
with get_write_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Resetear flag de vectorización
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE traducciones
|
||||||
|
SET vectorized = FALSE,
|
||||||
|
qdrant_point_id = NULL,
|
||||||
|
vectorization_date = NULL
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("✅ Flags de vectorización reseteados en PostgreSQL")
|
||||||
|
|
||||||
|
# Limpiar Qdrant
|
||||||
|
try:
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
qdrant_host = os.environ.get("QDRANT_HOST", "localhost")
|
||||||
|
qdrant_port = int(os.environ.get("QDRANT_PORT", "6333"))
|
||||||
|
collection_name = os.environ.get("QDRANT_COLLECTION_NAME", "news_vectors")
|
||||||
|
|
||||||
|
client = QdrantClient(host=qdrant_host, port=qdrant_port)
|
||||||
|
|
||||||
|
# Eliminar colección
|
||||||
|
client.delete_collection(collection_name)
|
||||||
|
print(f"✅ Colección '{collection_name}' eliminada de Qdrant")
|
||||||
|
|
||||||
|
# Recrear colección
|
||||||
|
from qdrant_client.models import Distance, VectorParams
|
||||||
|
client.create_collection(
|
||||||
|
collection_name=collection_name,
|
||||||
|
vectors_config=VectorParams(size=384, distance=Distance.COSINE)
|
||||||
|
)
|
||||||
|
print(f"✅ Colección '{collection_name}' recreada")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error limpiando Qdrant: {e}")
|
||||||
|
|
||||||
|
print("\n✅ Reset completado\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Script de migración para Qdrant (Directo)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("--stats", action="store_true", help="Mostrar estadísticas")
|
||||||
|
parser.add_argument("--vectorize", action="store_true", help="Vectorizar noticias traducidas")
|
||||||
|
parser.add_argument("--reset", action="store_true", help="Limpiar Qdrant y reiniciar estado")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=200, help="Tamaño de lote (default: 200)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Si no se especifica ninguna opción, mostrar estadísticas
|
||||||
|
if not any([args.stats, args.vectorize, args.reset]):
|
||||||
|
args.stats = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.stats:
|
||||||
|
get_statistics()
|
||||||
|
|
||||||
|
if args.reset:
|
||||||
|
reset_all()
|
||||||
|
|
||||||
|
if args.vectorize:
|
||||||
|
vectorize_all(batch_size=args.batch_size)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⏹️ Proceso interrumpido por el usuario")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
70
scripts/precache_entities.py
Normal file
70
scripts/precache_entities.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
# Add app to path
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from db import get_read_conn
|
||||||
|
from utils.wiki import fetch_wiki_data
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def get_top_entities():
|
||||||
|
"""Get top 100 people, 50 orgs, 50 places from last 30 days."""
|
||||||
|
entities = []
|
||||||
|
query = """
|
||||||
|
SELECT t.valor, COUNT(*) as c
|
||||||
|
FROM tags t
|
||||||
|
JOIN tags_noticia tn ON t.id = tn.tag_id
|
||||||
|
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||||
|
WHERE tr.created_at > NOW() - INTERVAL '30 days'
|
||||||
|
AND t.tipo = %s
|
||||||
|
GROUP BY t.valor
|
||||||
|
ORDER BY c DESC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# People
|
||||||
|
cur.execute(query, ('persona', 100))
|
||||||
|
entities.extend([row[0] for row in cur.fetchall()])
|
||||||
|
|
||||||
|
# Orgs
|
||||||
|
cur.execute(query, ('organizacion', 50))
|
||||||
|
entities.extend([row[0] for row in cur.fetchall()])
|
||||||
|
|
||||||
|
# Places
|
||||||
|
cur.execute(query, ('lugar', 50))
|
||||||
|
entities.extend([row[0] for row in cur.fetchall()])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching top entities: {e}")
|
||||||
|
|
||||||
|
return list(set(entities))
|
||||||
|
|
||||||
|
def precache_entity(name):
|
||||||
|
try:
|
||||||
|
img, summary = fetch_wiki_data(name)
|
||||||
|
if img or summary:
|
||||||
|
logger.info(f"✓ Cached: {name}")
|
||||||
|
else:
|
||||||
|
logger.info(f"✗ No data for: {name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error caching {name}: {e}")
|
||||||
|
|
||||||
|
def run_precache():
|
||||||
|
logger.info("Starting entity pre-cache...")
|
||||||
|
entities = get_top_entities()
|
||||||
|
logger.info(f"Found {len(entities)} unique top entities to cache.")
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
executor.map(precache_entity, entities)
|
||||||
|
|
||||||
|
logger.info("Pre-cache complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_precache()
|
||||||
44
scripts/recover_system.py
Normal file
44
scripts/recover_system.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||||
|
logger = logging.getLogger("recover_system")
|
||||||
|
|
||||||
|
DB_CONFIG = {
|
||||||
|
"host": os.environ.get("DB_HOST", "localhost"),
|
||||||
|
"port": int(os.environ.get("DB_PORT", 5432)),
|
||||||
|
"dbname": os.environ.get("DB_NAME", "rss"),
|
||||||
|
"user": os.environ.get("DB_USER", "rss"),
|
||||||
|
"password": os.environ.get("DB_PASS", "x"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def recover():
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(**DB_CONFIG)
|
||||||
|
conn.autocommit = True
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# 1. Reset stuck translations
|
||||||
|
logger.info("Resetting stuck 'processing' translations to 'pending'...")
|
||||||
|
cur.execute("UPDATE traducciones SET status = 'pending' WHERE status = 'processing';")
|
||||||
|
logger.info(f"Reset {cur.rowcount} translations.")
|
||||||
|
|
||||||
|
# 2. Correct future-dated news
|
||||||
|
logger.info("Correcting future-dated news...")
|
||||||
|
now = datetime.utcnow()
|
||||||
|
cur.execute("UPDATE noticias SET fecha = %s WHERE fecha > %s;", (now, now))
|
||||||
|
logger.info(f"Corrected {cur.rowcount} news items.")
|
||||||
|
|
||||||
|
# 3. Reactivate feeds (Optional - only those with few failures)
|
||||||
|
logger.info("Reactivating feeds with 10-29 failures (giving them another chance)...")
|
||||||
|
cur.execute("UPDATE feeds SET activo = TRUE, fallos = 0 WHERE activo = FALSE AND fallos < 30;")
|
||||||
|
logger.info(f"Reactivated {cur.rowcount} feeds.")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
logger.info("Recovery complete!")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during recovery: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
recover()
|
||||||
95
scripts/test_qdrant_connection.py
Executable file
95
scripts/test_qdrant_connection.py
Executable file
|
|
@ -0,0 +1,95 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script de diagnóstico para verificar la conectividad con Qdrant.
|
||||||
|
Ejecutar desde el contenedor rss2_web para diagnosticar problemas de red.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def test_qdrant_connection():
|
||||||
|
"""Prueba la conexión a Qdrant y muestra información de diagnóstico."""
|
||||||
|
|
||||||
|
# Configuración
|
||||||
|
qdrant_host = os.environ.get("QDRANT_HOST", "localhost")
|
||||||
|
qdrant_port = int(os.environ.get("QDRANT_PORT", "6333"))
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("🔍 DIAGNÓSTICO DE CONEXIÓN QDRANT")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Host: {qdrant_host}")
|
||||||
|
print(f"Port: {qdrant_port}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 1. Test de resolución DNS
|
||||||
|
print("1️⃣ Probando resolución DNS...")
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
ip = socket.gethostbyname(qdrant_host)
|
||||||
|
print(f" ✅ Host '{qdrant_host}' resuelve a: {ip}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ ERROR: No se pudo resolver '{qdrant_host}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Test de conectividad TCP
|
||||||
|
print("\n2️⃣ Probando conectividad TCP...")
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(5)
|
||||||
|
result = sock.connect_ex((ip, qdrant_port))
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
if result == 0:
|
||||||
|
print(f" ✅ Puerto {qdrant_port} está abierto")
|
||||||
|
else:
|
||||||
|
print(f" ❌ ERROR: Puerto {qdrant_port} está cerrado o inaccesible")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ ERROR en test TCP: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3. Test de cliente Qdrant
|
||||||
|
print("\n3️⃣ Probando cliente Qdrant...")
|
||||||
|
try:
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
|
||||||
|
client = QdrantClient(host=qdrant_host, port=qdrant_port, timeout=5)
|
||||||
|
collections = client.get_collections()
|
||||||
|
|
||||||
|
print(f" ✅ Cliente Qdrant conectado exitosamente")
|
||||||
|
print(f" 📊 Colecciones disponibles: {[c.name for c in collections.collections]}")
|
||||||
|
|
||||||
|
# Test de búsqueda
|
||||||
|
for collection in collections.collections:
|
||||||
|
try:
|
||||||
|
info = client.get_collection(collection.name)
|
||||||
|
print(f" 📁 {collection.name}: {info.points_count} vectores")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ No se pudo obtener info de {collection.name}: {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ ERROR en cliente Qdrant: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = test_qdrant_connection()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n✅ DIAGNÓSTICO EXITOSO: Qdrant está accesible")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("\n❌ DIAGNÓSTICO FALLIDO: Problemas de conectividad con Qdrant")
|
||||||
|
print("\n💡 SOLUCIONES POSIBLES:")
|
||||||
|
print(" 1. Verificar que el contenedor 'qdrant' esté corriendo:")
|
||||||
|
print(" docker ps | grep qdrant")
|
||||||
|
print(" 2. Verificar que ambos contenedores estén en la misma red:")
|
||||||
|
print(" docker network inspect rss2_default")
|
||||||
|
print(" 3. Reiniciar el contenedor de Qdrant:")
|
||||||
|
print(" docker restart rss2_qdrant")
|
||||||
|
print(" 4. Verificar variables de entorno QDRANT_HOST y QDRANT_PORT")
|
||||||
|
sys.exit(1)
|
||||||
54
scripts/verify_connectivity.py
Normal file
54
scripts/verify_connectivity.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add app to path
|
||||||
|
sys.path.append('/home/x/rss2')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from db import get_conn, get_read_conn, get_write_conn
|
||||||
|
from cache import get_redis
|
||||||
|
import psycopg2
|
||||||
|
print("Imports successfull.")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"Import failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def test_db():
|
||||||
|
print("\n--- Testing Database Connections ---")
|
||||||
|
|
||||||
|
print("Testing Primary (Write) Connection...")
|
||||||
|
try:
|
||||||
|
with get_write_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT 1")
|
||||||
|
print(" [OK] Primary reachable.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] Primary unreachable: {e}")
|
||||||
|
|
||||||
|
print("Testing Replica (Read) Connection...")
|
||||||
|
try:
|
||||||
|
with get_read_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT 1")
|
||||||
|
# Check if it's actually the replica (read-only mode is usually set in replica,
|
||||||
|
# but here we just check connectivity)
|
||||||
|
print(" [OK] Replica reachable.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] Replica unreachable: {e}")
|
||||||
|
|
||||||
|
def test_redis():
|
||||||
|
print("\n--- Testing Redis Connection ---")
|
||||||
|
try:
|
||||||
|
r = get_redis()
|
||||||
|
if r:
|
||||||
|
r.ping()
|
||||||
|
print(" [OK] Redis reachable.")
|
||||||
|
else:
|
||||||
|
print(" [FAIL] Redis client returned None (likely connection failed).")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] Redis error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_db()
|
||||||
|
test_redis()
|
||||||
|
print("\nVerification complete.")
|
||||||
39
start_docker.sh
Executable file
39
start_docker.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Script para iniciar los servicios de Docker
|
||||||
|
# Ejecutar con: sudo ./start_docker.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== RSS2 Docker Services ==="
|
||||||
|
|
||||||
|
# Verificar si el modelo CTranslate2 existe
|
||||||
|
CT2_MODEL="./models/nllb-ct2"
|
||||||
|
if [ ! -d "$CT2_MODEL" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Modelo CTranslate2 no encontrado en $CT2_MODEL"
|
||||||
|
echo " Convirtiendo modelo (esto puede tardar 5-10 minutos)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar si ctranslate2 está instalado
|
||||||
|
if ! python3 -c "import ctranslate2" 2>/dev/null; then
|
||||||
|
echo "Instalando ctranslate2..."
|
||||||
|
pip install ctranslate2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Convertir el modelo
|
||||||
|
./convert_model.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Iniciando servicios Docker..."
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Servicios iniciados"
|
||||||
|
echo ""
|
||||||
|
echo "Para ver los logs:"
|
||||||
|
echo " docker compose logs -f translator"
|
||||||
|
echo ""
|
||||||
|
echo "Para verificar el estado:"
|
||||||
|
echo " docker compose ps"
|
||||||
7
static/placeholder.svg
Normal file
7
static/placeholder.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" fill="none">
|
||||||
|
<rect width="400" height="300" fill="#f0f0f0"/>
|
||||||
|
<rect x="150" y="100" width="100" height="80" rx="8" fill="#ddd"/>
|
||||||
|
<circle cx="180" cy="130" r="15" fill="#bbb"/>
|
||||||
|
<path d="M165 165 L200 140 L235 165" stroke="#bbb" stroke-width="3" fill="none"/>
|
||||||
|
<text x="200" y="210" text-anchor="middle" font-family="Arial" font-size="14" fill="#999">Sin imagen</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
1043
static/style.css
Normal file
1043
static/style.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
static/uploads/avatars/user_1_0736791a.jpg
Normal file
BIN
static/uploads/avatars/user_1_0736791a.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
128
templates/_feeds_table.html
Normal file
128
templates/_feeds_table.html
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div id="feeds-table-container" class="feed-body" style="padding: 0;">
|
||||||
|
<div class="mt-2" style="margin: 10px 15px;">
|
||||||
|
{% set activos = total_feeds - feeds_caidos %}
|
||||||
|
<strong style="color: #27ae60;">{{ activos }} Activos</strong>
|
||||||
|
<span class="text-muted" style="margin-left: 5px;">(de {{ total_feeds }} Feeds)</span>
|
||||||
|
|
||||||
|
{% if filtro_pais_id or filtro_categoria_id or filtro_estado %}
|
||||||
|
<span class="text-muted" style="font-size:0.9em; margin-left: 10px;">(con filtros aplicados)</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style="width:100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: rgba(0,0,0,0.05);">
|
||||||
|
<th style="padding: 12px 15px; text-align: left;">Nombre</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: left;">Categoría</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: left;">País</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: center;">Noticias</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: center;">Estado</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: center;">Fallos</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: right;">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for feed in feeds %}
|
||||||
|
<tr {% if feed.fallos and feed.fallos> 0 %}style="background-color: rgba(192,57,43,0.05);" {% endif %}>
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">
|
||||||
|
<a href="{{ feed.url }}" target="_blank" title="{{ feed.url }}">{{ feed.nombre }}</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">
|
||||||
|
{{ feed.categoria or 'N/A' }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">
|
||||||
|
{{ feed.pais or 'Global' }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 12px 15px; text-align:center; border-top: 1px solid var(--border-color);">
|
||||||
|
<span class="badge"
|
||||||
|
style="background: rgba(52, 152, 219, 0.1); color: #3498db; padding: 2px 8px; border-radius: 10px;">
|
||||||
|
{{ feed.noticias_count or 0 }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 12px 15px; text-align: center; border-top: 1px solid var(--border-color);">
|
||||||
|
{% if not feed.activo %}
|
||||||
|
<span style="color: #c0392b; font-weight: bold;" title="Inactivo">KO</span>
|
||||||
|
{% elif feed.fallos and feed.fallos >= 5 %}
|
||||||
|
<span style="color: #e67e22; font-weight: bold; cursor: help;"
|
||||||
|
title="{{ feed.last_error or (feed.fallos ~ ' fallos') }}">⚠️</span>
|
||||||
|
{% elif feed.fallos and feed.fallos > 0 %}
|
||||||
|
<span style="color: #f39c12; font-weight: bold; cursor: help;"
|
||||||
|
title="{{ feed.last_error or (feed.fallos ~ ' fallos') }}">OK</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #27ae60; font-weight: bold;">OK</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 12px 15px; text-align:center; border-top: 1px solid var(--border-color);">
|
||||||
|
{{ feed.fallos or 0 }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding: 12px 15px; text-align:right; border-top: 1px solid var(--border-color);">
|
||||||
|
<a href="{{ url_for('feeds.edit_feed', feed_id=feed.id) }}" class="btn btn-small btn-info">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ url_for('feeds.delete_feed', feed_id=feed.id) }}" class="btn btn-small btn-danger"
|
||||||
|
onclick="return confirm('¿Estás seguro?')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if not feed.activo %}
|
||||||
|
<a href="{{ url_for('feeds.reactivar_feed', feed_id=feed.id) }}" class="btn btn-small">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" style="padding:20px; text-align:center;">
|
||||||
|
No hay feeds para mostrar.
|
||||||
|
<a href="{{ url_for('feeds.add_feed') }}">Añade el primero</a>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginación -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<nav class="pagination">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="{{ url_for('feeds.list_feeds',
|
||||||
|
page=page-1,
|
||||||
|
pais_id=filtro_pais_id,
|
||||||
|
categoria_id=filtro_categoria_id,
|
||||||
|
estado=filtro_estado) }}" class="page-link"
|
||||||
|
onclick="handlePageClick(event, this.href)">« Anterior</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in range(1, total_pages + 1) %}
|
||||||
|
{% if p == page %}
|
||||||
|
<a href="#" class="page-link active">{{ p }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('feeds.list_feeds',
|
||||||
|
page=p,
|
||||||
|
pais_id=filtro_pais_id,
|
||||||
|
categoria_id=filtro_categoria_id,
|
||||||
|
estado=filtro_estado) }}" class="page-link"
|
||||||
|
onclick="handlePageClick(event, this.href)">{{ p }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page < total_pages %} <a href="{{ url_for('feeds.list_feeds',
|
||||||
|
page=page+1,
|
||||||
|
pais_id=filtro_pais_id,
|
||||||
|
categoria_id=filtro_categoria_id,
|
||||||
|
estado=filtro_estado) }}" class="page-link" onclick="handlePageClick(event, this.href)">
|
||||||
|
Siguiente »</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
79
templates/_noticias_list.html
Normal file
79
templates/_noticias_list.html
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
{% for n in noticias %}
|
||||||
|
{% if n.traduccion_id %}
|
||||||
|
{% set detalle_url = url_for('noticia.noticia', tr_id=n.traduccion_id) %}
|
||||||
|
{% else %}
|
||||||
|
{% set detalle_url = url_for('noticia.noticia', id=n.id) %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<article class="noticia-card">
|
||||||
|
<div class="noticia-card-image-wrapper">
|
||||||
|
<a href="{{ detalle_url }}">
|
||||||
|
{% if n.imagen_url %}
|
||||||
|
<img src="{{ n.imagen_url }}" alt="{{ n.titulo }}" loading="lazy"
|
||||||
|
onerror="this.style.display='none'; this.parentElement.querySelector('.no-image-placeholder').style.display='flex';">
|
||||||
|
<div class="no-image-placeholder" style="display:none;"></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-image-placeholder"></div>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="noticia-card-content">
|
||||||
|
<div class="noticia-meta">
|
||||||
|
{{ n.fuente_nombre }}
|
||||||
|
{% if n.fecha %} • {{ n.fecha|format_date }}{% endif %}
|
||||||
|
{% if n.pais %} • {{ n.pais }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
<a href="{{ detalle_url }}">
|
||||||
|
{% if use_tr and n.tiene_traduccion %}
|
||||||
|
{{ n.titulo_traducido }}
|
||||||
|
{% else %}
|
||||||
|
{{ n.titulo_original or n.titulo }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="noticia-summary">
|
||||||
|
{% if use_tr and n.tiene_traduccion %}
|
||||||
|
{{ (n.resumen_traducido or '') | striptags | truncate(200) }}
|
||||||
|
{% else %}
|
||||||
|
{{ (n.resumen_original or n.resumen) | striptags | truncate(200) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="noticia-actions">
|
||||||
|
<button class="btn-fav" data-id="{{ n.id }}" onclick="toggleFav(this)" title="Guardar">
|
||||||
|
<i class="far fa-star"></i>
|
||||||
|
</button>
|
||||||
|
<a href="{{ detalle_url }}" class="btn btn-sm">Leer más</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<div style="grid-column: 1 / -1; text-align: center; padding: 50px;">
|
||||||
|
<p>No hay noticias para mostrar.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Pagination Logic #}
|
||||||
|
{% if total_pages and total_pages > 1 %}
|
||||||
|
<div
|
||||||
|
style="grid-column: 1 / -1; margin-top: 30px; text-align: center; padding-top: 20px; border-top: 1px solid var(--border-color);">
|
||||||
|
{% set current = page %}
|
||||||
|
{% if current > 1 %}
|
||||||
|
<button class="btn" data-page="{{ current - 1 }}"
|
||||||
|
onclick="setPage(this.getAttribute('data-page')); cargarNoticias(true);">Newer</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span style="margin: 0 15px; font-weight: bold; font-family: var(--secondary-font);">
|
||||||
|
Page {{ current }} of {{ total_pages }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if current < total_pages %} <button class="btn" data-page="{{ current + 1 }}"
|
||||||
|
onclick="setPage(this.getAttribute('data-page')); cargarNoticias(true);">
|
||||||
|
Older</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
183
templates/account.html
Normal file
183
templates/account.html
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Tu Cuenta - {{ user.username }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 900px; margin: 20px auto;">
|
||||||
|
<h2 style="margin-bottom: 30px;"><i class="fas fa-user-circle"></i> Tu Cuenta</h2>
|
||||||
|
|
||||||
|
<!-- User Info Card -->
|
||||||
|
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h3 style="margin-top: 0; color: #6c63ff;">Información del Perfil</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||||
|
<div style="text-align: center; margin-bottom: 15px;">
|
||||||
|
{% if user.avatar_url %}
|
||||||
|
<img src="{{ user.avatar_url }}" alt="Avatar" style="width: 120px; height: 120px; object-fit: cover; border-radius: 50%; border: 3px solid #6c63ff; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||||
|
{% else %}
|
||||||
|
<div style="width: 120px; height: 120px; background: #e0e0e0; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto; color: #888; font-size: 50px;">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form action="{{ url_for('account.upload_avatar') }}" method="post" enctype="multipart/form-data" style="margin-top: 15px;">
|
||||||
|
<input type="file" name="avatar" id="avatar" accept="image/*" style="display: none;" onchange="this.form.submit()">
|
||||||
|
<label for="avatar" style="cursor: pointer; padding: 6px 12px; border: 1px solid #6c63ff; color: #6c63ff; background: transparent; border-radius: 4px; font-size: 14px; transition: all 0.2s;">
|
||||||
|
<i class="fas fa-camera"></i> Cambiar foto
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Usuario:</strong> {{ user.username }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Email:</strong> {{ user.email }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Miembro desde:</strong> {{ user.created_at.strftime('%d/%m/%Y') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Último acceso:</strong> {{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else
|
||||||
|
'N/A' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Card -->
|
||||||
|
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h3 style="margin-top: 0; color: #6c63ff;">Estadísticas</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px;">
|
||||||
|
<div style="text-align: center; padding: 15px; background: white; border-radius: 8px;">
|
||||||
|
<div style="font-size: 32px; font-weight: bold; color: #6c63ff;">{{ favorites_count }}</div>
|
||||||
|
<div style="color: #666; margin-top: 5px;">Favoritos guardados</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; padding: 15px; background: white; border-radius: 8px;">
|
||||||
|
<div style="font-size: 32px; font-weight: bold; color: #6c63ff;">{{ searches_count }}</div>
|
||||||
|
<div style="color: #666; margin-top: 5px;">Búsquedas realizadas</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Searches -->
|
||||||
|
{% if recent_searches %}
|
||||||
|
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h3 style="margin-top: 0; color: #6c63ff;">Búsquedas Recientes</h3>
|
||||||
|
<div style="overflow-x: auto;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 2px solid #ddd;">
|
||||||
|
<th style="padding: 10px; text-align: left;">Búsqueda</th>
|
||||||
|
<th style="padding: 10px; text-align: center;">Resultados</th>
|
||||||
|
<th style="padding: 10px; text-align: right;">Fecha</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for search in recent_searches %}
|
||||||
|
<tr style="border-bottom: 1px solid #eee;">
|
||||||
|
<td style="padding: 10px;">
|
||||||
|
<a href="/api/search?q={{ search.query | urlencode }}"
|
||||||
|
style="color: #6c63ff; text-decoration: none;">
|
||||||
|
{{ search.query }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 10px; text-align: center;">{{ search.results_count }}</td>
|
||||||
|
<td style="padding: 10px; text-align: right; color: #666; font-size: 14px;">
|
||||||
|
{{ search.searched_at.strftime('%d/%m/%Y %H:%M') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if searches_count > 10 %}
|
||||||
|
<div style="text-align: center; margin-top: 15px;">
|
||||||
|
<a href="{{ url_for('account.search_history') }}"
|
||||||
|
style="color: #6c63ff; text-decoration: none; font-weight: 500;">
|
||||||
|
Ver historial completo →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Recent Favorites -->
|
||||||
|
{% if recent_favorites %}
|
||||||
|
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h3 style="margin-top: 0; color: #6c63ff;">Favoritos Recientes</h3>
|
||||||
|
<div style="display: grid; gap: 15px;">
|
||||||
|
{% for noticia in recent_favorites %}
|
||||||
|
<div style="display: flex; gap: 15px; padding: 15px; background: white; border-radius: 8px;">
|
||||||
|
{% if noticia.imagen_url %}
|
||||||
|
<img src="{{ noticia.imagen_url }}" alt=""
|
||||||
|
style="width: 100px; height: 70px; object-fit: cover; border-radius: 5px;">
|
||||||
|
{% endif %}
|
||||||
|
<div style="flex: 1;">
|
||||||
|
{% if noticia.traduccion_id %}
|
||||||
|
<a href="/noticia?tr_id={{ noticia.traduccion_id }}"
|
||||||
|
style="color: #333; text-decoration: none; font-weight: 500;">
|
||||||
|
{{ noticia.titulo_trad or noticia.titulo }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/noticia?id={{ noticia.id }}"
|
||||||
|
style="color: #333; text-decoration: none; font-weight: 500;">
|
||||||
|
{{ noticia.titulo }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<div style="color: #666; font-size: 12px; margin-top: 5px;">
|
||||||
|
Guardado: {{ noticia.created_at.strftime('%d/%m/%Y') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 15px;">
|
||||||
|
<a href="{{ url_for('favoritos.view_favorites') }}"
|
||||||
|
style="color: #6c63ff; text-decoration: none; font-weight: 500;">
|
||||||
|
Ver todos los favoritos →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Account Actions -->
|
||||||
|
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px;">
|
||||||
|
<h3 style="margin-top: 0; color: #6c63ff;">Acciones</h3>
|
||||||
|
|
||||||
|
<!-- Change Password Form -->
|
||||||
|
<details style="margin-bottom: 20px;">
|
||||||
|
<summary style="cursor: pointer; font-weight: 500; padding: 10px; background: white; border-radius: 5px;">
|
||||||
|
<i class="fas fa-key"></i> Cambiar contraseña
|
||||||
|
</summary>
|
||||||
|
<form method="post" action="{{ url_for('account.change_password') }}"
|
||||||
|
style="margin-top: 15px; padding: 15px; background: white; border-radius: 5px;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label for="current_password" style="display: block; margin-bottom: 5px;">Contraseña actual</label>
|
||||||
|
<input type="password" id="current_password" name="current_password" required
|
||||||
|
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label for="new_password" style="display: block; margin-bottom: 5px;">Nueva contraseña</label>
|
||||||
|
<input type="password" id="new_password" name="new_password" required minlength="6"
|
||||||
|
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label for="new_password_confirm" style="display: block; margin-bottom: 5px;">Confirmar nueva
|
||||||
|
contraseña</label>
|
||||||
|
<input type="password" id="new_password_confirm" name="new_password_confirm" required minlength="6"
|
||||||
|
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
style="padding: 10px 20px; background: #6c63ff; color: white; border: none; border-radius: 5px; cursor: pointer;">
|
||||||
|
Actualizar contraseña
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||||
|
<button type="submit"
|
||||||
|
style="padding: 12px 24px; background: #dc3545; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px;">
|
||||||
|
<i class="fas fa-sign-out-alt"></i> Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
102
templates/add_feed.html
Normal file
102
templates/add_feed.html
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Añadir Feed{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card feed-detail-card"
|
||||||
|
style="padding: 40px; border-radius: 15px; background-color: var(--glass-bg); box-shadow: 0 10px 30px rgba(0,0,0,0.05); backdrop-filter: blur(10px);">
|
||||||
|
<h1
|
||||||
|
style="font-family: var(--primary-font); font-weight: 700; margin-bottom: 30px; border-bottom: 2px solid var(--accent-color); display: inline-block; padding-bottom: 10px;">
|
||||||
|
Añadir Feed
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form action="{{ url_for('feeds.add_feed') }}" method="post" id="addFeedForm" class="form-grid">
|
||||||
|
|
||||||
|
<!-- Nombre -->
|
||||||
|
<div class="floating-label-group">
|
||||||
|
<input type="text" id="nombre" name="nombre" placeholder=" " required>
|
||||||
|
<label for="nombre">Nombre del feed</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Descripción -->
|
||||||
|
<div class="floating-label-group">
|
||||||
|
<textarea id="descripcion" name="descripcion" placeholder=" " rows="3"></textarea>
|
||||||
|
<label for="descripcion">Descripción (opcional)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div class="floating-label-group">
|
||||||
|
<input type="url" id="url" name="url" placeholder=" " required>
|
||||||
|
<label for="url">URL del feed</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row" style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||||
|
<!-- Categoria (Searchable) -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="categoria_id"
|
||||||
|
style="display: block; margin-bottom: 8px; font-weight: 600;">Categoría</label>
|
||||||
|
<select id="categoria_id" name="categoria_id" class="searchable"
|
||||||
|
placeholder="Selecciona una categoría...">
|
||||||
|
<option value="">— Sin categoría —</option>
|
||||||
|
{% for c in categorias %}
|
||||||
|
<option value="{{ c.id }}">{{ c.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pais (Searchable) -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pais_id" style="display: block; margin-bottom: 8px; font-weight: 600;">País</label>
|
||||||
|
<select id="pais_id" name="pais_id" class="searchable" placeholder="Selecciona un país...">
|
||||||
|
<option value="">Global</option>
|
||||||
|
{% for p in paises %}
|
||||||
|
<option value="{{ p.id }}">{{ p.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Idioma & Submit -->
|
||||||
|
<div class="form-row"
|
||||||
|
style="display: grid; grid-template-columns: 1fr 2fr; gap: 20px; align-items: end; margin-top: 20px;">
|
||||||
|
<div class="floating-label-group" style="margin-bottom: 0;">
|
||||||
|
<input type="text" id="idioma" name="idioma" placeholder=" " maxlength="5">
|
||||||
|
<label for="idioma">Idioma (ej: es)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions" style="text-align: right;">
|
||||||
|
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary"
|
||||||
|
style="margin-right: 10px; background: transparent; color: var(--text-color); border: 1px solid var(--border-color);">
|
||||||
|
Cancelar
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-primary" type="submit" id="submitBtn">
|
||||||
|
<i class="fas fa-plus"></i> Añadir Feed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Real-time URL Validation
|
||||||
|
const urlInput = document.getElementById('url');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
|
||||||
|
urlInput.addEventListener('input', function () {
|
||||||
|
if (this.value && this.validity.valid) {
|
||||||
|
this.style.borderColor = "#2ecc71"; // Green
|
||||||
|
} else if (this.value) {
|
||||||
|
this.style.borderColor = "#e74c3c"; // Red
|
||||||
|
} else {
|
||||||
|
this.style.borderColor = ""; // Reset
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form Submit State
|
||||||
|
document.getElementById('addFeedForm').addEventListener('submit', function () {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Guardando...';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
57
templates/add_feeds.html
Normal file
57
templates/add_feeds.html
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Añadir Nuevo Feed{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header>
|
||||||
|
<h1>Añadir Nuevo Feed</h1>
|
||||||
|
<p class="subtitle">Introduce los detalles de la nueva fuente de noticias RSS.</p>
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">← Volver al Dashboard</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<form action="{{ url_for('add_feed') }}" method="post" autocomplete="off">
|
||||||
|
<div>
|
||||||
|
<label for="nombre">Nombre del feed</label>
|
||||||
|
<input id="nombre" name="nombre" type="text" placeholder="Ej: Noticias de Tecnología" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:15px;">
|
||||||
|
<label for="url">URL del RSS</label>
|
||||||
|
<input id="url" name="url" type="url" placeholder="https://ejemplo.com/rss" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:15px;">
|
||||||
|
<label for="descripcion">Descripción</label>
|
||||||
|
<textarea id="descripcion" name="descripcion" rows="2" placeholder="Breve descripción del contenido del feed"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; margin-top: 15px;">
|
||||||
|
<div>
|
||||||
|
<label for="categoria_id">Categoría</label>
|
||||||
|
<select id="categoria_id" name="categoria_id" required>
|
||||||
|
<option value="">— Elige categoría —</option>
|
||||||
|
{% for cat in categorias %}
|
||||||
|
<option value="{{ cat.id }}">{{ cat.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="pais_id">País</label>
|
||||||
|
<select name="pais_id" id="pais_id">
|
||||||
|
<option value="">— Global / No aplica —</option>
|
||||||
|
{% for pais in paises %}
|
||||||
|
<option value="{{ pais.id }}">{{ pais.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="idioma">Idioma (código)</label>
|
||||||
|
<input id="idioma" name="idioma" type="text" maxlength="2" placeholder="ej: es, en">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn" style="margin-top: 25px; width: 100%;">Añadir Feed</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
59
templates/add_url.html
Normal file
59
templates/add_url.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Añadir Noticia desde URL{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h4 class="mb-0">Añadir Noticia desde URL</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text text-muted">Pega la URL de un artículo de noticias. El sistema intentará extraer el título, resumen e imagen automáticamente.</p>
|
||||||
|
<form action="{{ url_for('add_url') }}" method="post" class="mt-3">
|
||||||
|
|
||||||
|
<!-- Campo para la URL -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="url" class="form-label"><strong>URL de la Noticia</strong></label>
|
||||||
|
<input type="url" class="form-control" id="url" name="url" required placeholder="https://ejemplo.com/noticia-a-scrapear">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selector de Categoría -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="categoria_id" class="form-label"><strong>Categoría</strong></label>
|
||||||
|
<select class="form-select" id="categoria_id" name="categoria_id" required>
|
||||||
|
<option value="" disabled selected>-- Selecciona una categoría --</option>
|
||||||
|
{% for categoria in categorias %}
|
||||||
|
<option value="{{ categoria.id }}">{{ categoria.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selector de País -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="pais_id" class="form-label"><strong>País</strong></label>
|
||||||
|
<select class="form-select" id="pais_id" name="pais_id" required>
|
||||||
|
<option value="" disabled selected>-- Selecciona un país --</option>
|
||||||
|
{% for pais in paises %}
|
||||||
|
<option value="{{ pais.id }}">{{ pais.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botones de Acción -->
|
||||||
|
<div class="d-flex justify-content-end pt-3">
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary me-2">Cancelar</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Añadir Noticia</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
74
templates/add_url_source.html
Normal file
74
templates/add_url_source.html
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Añadir Fuente URL{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Añadir Fuente URL</h1>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 25px;">
|
||||||
|
<div class="tabs" style="display: flex; gap: 10px; border-bottom: 2px solid #ddd; padding-bottom: 1px;">
|
||||||
|
<button class="tab-btn active" onclick="switchTab('manual')"
|
||||||
|
style="padding: 10px 20px; border: none; background: #fff; cursor: pointer; border-bottom: 3px solid #007bff; color: #007bff; font-weight: bold;">
|
||||||
|
<i class="fas fa-edit"></i> Añadir Manualmente
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('feeds.discover_feed') }}" class="tab-btn"
|
||||||
|
style="padding: 10px 20px; border: none; background: #f8f9fa; cursor: pointer; text-decoration: none; color: #555; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i class="fas fa-search"></i> Analizar Web (Descubrimiento Automático)
|
||||||
|
<span class="badge"
|
||||||
|
style="background: #e9ecef; color: #555; font-size: 10px; padding: 2px 6px; border-radius: 4px;">RECOMENDADO</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="manual-tab">
|
||||||
|
<div
|
||||||
|
style="margin-bottom: 20px; padding: 15px; background: #e3f2fd; border-radius: 8px; border-left: 4px solid #1976D2;">
|
||||||
|
<i class="fas fa-info-circle" style="color: #1976D2;"></i>
|
||||||
|
Utiliza esta opción para añadir una fuente de URL monitorizada manualmente. Si quieres buscar todos los feeds
|
||||||
|
RSS dentro de un sitio web, usa la pestaña <strong>Analizar Web</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('urls.add_url_source') }}" autocomplete="off">
|
||||||
|
|
||||||
|
<label for="nombre">Nombre</label>
|
||||||
|
<input id="nombre" name="nombre" type="text" required placeholder="Ej. El País">
|
||||||
|
|
||||||
|
<label for="url" style="margin-top:15px;">URL</label>
|
||||||
|
<input id="url" name="url" type="url" required placeholder="https://elpais.com">
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-top:15px;">
|
||||||
|
<div>
|
||||||
|
<label for="categoria_id">Categoría</label>
|
||||||
|
<select id="categoria_id" name="categoria_id">
|
||||||
|
<option value="">— Sin categoría —</option>
|
||||||
|
{% for c in categorias %}
|
||||||
|
<option value="{{ c.id }}">{{ c.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="pais_id">País</label>
|
||||||
|
<select id="pais_id" name="pais_id">
|
||||||
|
<option value="">— Global —</option>
|
||||||
|
{% for p in paises %}
|
||||||
|
<option value="{{ p.id }}">{{ p.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="idioma" style="margin-top:15px;">Idioma (2 letras)</label>
|
||||||
|
<input id="idioma" name="idioma" type="text" maxlength="2" value="es">
|
||||||
|
|
||||||
|
<div style="margin-top:20px;display:flex;gap:10px;justify-content:flex-end;">
|
||||||
|
<a href="{{ url_for('urls.manage_urls') }}" class="btn btn-secondary">Cancelar</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Guardar Fuente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('urls.manage_urls') }}" class="top-link">← Volver</a>
|
||||||
|
{% endblock %}
|
||||||
448
templates/base.html
Normal file
448
templates/base.html
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Agregador de Noticias RSS{% endblock %}</title>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=10">
|
||||||
|
<!-- TomSelect CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: .75rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: .35rem .5rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
background: var(--secondary-color, #6c63ff);
|
||||||
|
color: #fff;
|
||||||
|
margin-left: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 42px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #ccc;
|
||||||
|
transition: .2s;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: #fff;
|
||||||
|
transition: .2s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked+.slider {
|
||||||
|
background: var(--secondary-color, #6c63ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked+.slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="theme-rss2">
|
||||||
|
<div class="container">
|
||||||
|
<!-- Mobile/Global Nav Elements -->
|
||||||
|
<div class="mobile-header">
|
||||||
|
<div class="logo-mobile">
|
||||||
|
<a href="/">THE DAILY FEED</a>
|
||||||
|
</div>
|
||||||
|
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Abrir menú">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-overlay" id="nav-overlay"></div>
|
||||||
|
|
||||||
|
<!-- Desktop Header -->
|
||||||
|
<header class="desktop-header">
|
||||||
|
<div class="header-top-row">
|
||||||
|
<div class="header-title-wrapper">
|
||||||
|
<h1><a href="/" style="text-decoration: none; color: inherit;">THE DAILY FEED</a></h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-user-menu">
|
||||||
|
<div class="dropdown">
|
||||||
|
{% if session.get('user_id') %}
|
||||||
|
<button class="nav-link dropbtn user-menu-large">
|
||||||
|
{% if session.get('avatar_url') %}
|
||||||
|
<img src="{{ session.get('avatar_url') }}" alt="Avatar"
|
||||||
|
style="width: 24px; height: 24px; border-radius: 50%; vertical-align: middle; object-fit: cover; margin-right: 5px;">
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-user-circle"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ session.get('username') }} <i class="fas fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-content dropdown-right">
|
||||||
|
<a href="{{ url_for('account.index') }}"><i class="fas fa-user"></i> Tu Cuenta</a>
|
||||||
|
<a href="{{ url_for('favoritos.view_favorites') }}"><i class="fas fa-star"></i> Mis Favoritos</a>
|
||||||
|
<a href="{{ url_for('account.index', _anchor='search-history') }}"><i class="fas fa-history"></i>
|
||||||
|
Historial</a>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<form action="{{ url_for('auth.logout') }}" method="post" style="margin: 0;">
|
||||||
|
<button type="submit" class="dropdown-logout">
|
||||||
|
<i class="fas fa-sign-out-alt"></i> Cerrar Sesión
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<button class="nav-link dropbtn user-menu-large">
|
||||||
|
<i class="fas fa-user"></i> Cuenta <i class="fas fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-content dropdown-right">
|
||||||
|
<a href="{{ url_for('auth.login') }}"><i class="fas fa-sign-in-alt"></i> Iniciar Sesión</a>
|
||||||
|
<a href="{{ url_for('auth.register') }}"><i class="fas fa-user-plus"></i> Registrarse</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-date">
|
||||||
|
<span id="current-date-header"></span> |
|
||||||
|
MADRID: <span id="madrid-time">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="main-nav" id="main-nav">
|
||||||
|
<div class="nav-content-wrapper">
|
||||||
|
<div class="nav-left">
|
||||||
|
<a href="{{ url_for('home.home') }}" class="nav-link">Inicio</a>
|
||||||
|
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="nav-link dropbtn">Noticias <i class="fas fa-chevron-down"></i></button>
|
||||||
|
<div class="dropdown-content">
|
||||||
|
<a href="{{ url_for('home.home') }}">Todas las Noticias</a>
|
||||||
|
<a href="{{ url_for('topics.monitor') }}">Temas</a>
|
||||||
|
<a href="{{ url_for('favoritos.view_favorites') }}">Favoritos</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="nav-link dropbtn">Análisis <i class="fas fa-chevron-down"></i></button>
|
||||||
|
<div class="dropdown-content">
|
||||||
|
<a href="{{ url_for('stats.index') }}">Estadísticas</a>
|
||||||
|
<a href="{{ url_for('stats.entities_dashboard') }}">Monitor de Entidades</a>
|
||||||
|
<a href="{{ url_for('conflicts.index') }}">Conflictos</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="nav-link dropbtn">Admin <i class="fas fa-chevron-down"></i></button>
|
||||||
|
<div class="dropdown-content">
|
||||||
|
<a href="{{ url_for('feeds.list_feeds') }}">Gestión de Feeds</a>
|
||||||
|
<a href="{{ url_for('feeds.discover_feed') }}"><i class="fas fa-search-plus"></i> Descubrir Feeds</a>
|
||||||
|
<a href="{{ url_for('urls.manage_urls') }}">Gestión de URLs</a>
|
||||||
|
<a href="{{ url_for('backup.restore_feeds') }}">Importar Feeds</a>
|
||||||
|
<a href="{{ url_for('backup.backup_feeds') }}">Exportar Feeds</a>
|
||||||
|
<a href="{{ url_for('config.config_home') }}">Configuración</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-right">
|
||||||
|
<button id="dark-mode-toggle" class="icon-btn" title="Cambiar tema">
|
||||||
|
<i class="fas fa-moon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||||
|
const dateStr = new Date().toLocaleDateString('es-ES', options);
|
||||||
|
const dateHeader = document.getElementById('current-date-header');
|
||||||
|
if (dateHeader) dateHeader.textContent = dateStr;
|
||||||
|
|
||||||
|
function updateMadridTime() {
|
||||||
|
const now = new Date();
|
||||||
|
const timeString = now.toLocaleTimeString('es-ES', { timeZone: 'Europe/Madrid' });
|
||||||
|
const el = document.getElementById('madrid-time');
|
||||||
|
if (el) el.textContent = timeString;
|
||||||
|
}
|
||||||
|
setInterval(updateMadridTime, 1000);
|
||||||
|
updateMadridTime();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="flash-messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<li class="{{ category }}">{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TomSelect JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Global Tool to Init Selects
|
||||||
|
function initSearchableSelects(selector = 'select.searchable') {
|
||||||
|
document.querySelectorAll(selector).forEach((el) => {
|
||||||
|
if (!el.tomselect) {
|
||||||
|
new TomSelect(el, {
|
||||||
|
create: false,
|
||||||
|
sortField: { field: "text", direction: "asc" },
|
||||||
|
plugins: ['dropdown_input'],
|
||||||
|
maxItems: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark Mode Toggle
|
||||||
|
const darkModeToggle = document.getElementById('dark-mode-toggle');
|
||||||
|
const icon = darkModeToggle.querySelector('i');
|
||||||
|
|
||||||
|
// Check saved preference
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
icon.classList.replace('fa-moon', 'fa-sun');
|
||||||
|
}
|
||||||
|
|
||||||
|
darkModeToggle.addEventListener('click', () => {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
const isDark = document.body.classList.contains('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', isDark);
|
||||||
|
icon.classList.replace(isDark ? 'fa-moon' : 'fa-sun', isDark ? 'fa-sun' : 'fa-moon');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== FAVORITES ==========
|
||||||
|
async function toggleFav(btn) {
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/favoritos/toggle/${id}`, { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
btn.classList.toggle('active', data.is_favorite);
|
||||||
|
const i = btn.querySelector('i');
|
||||||
|
i.className = data.is_favorite ? 'fas fa-star' : 'far fa-star';
|
||||||
|
}
|
||||||
|
} catch (e) { console.error("Error favoritos", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved favorites on page load
|
||||||
|
async function loadFavorites() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/favoritos/ids');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const favIds = new Set(data.ids);
|
||||||
|
document.querySelectorAll('.btn-fav').forEach(btn => {
|
||||||
|
if (favIds.has(btn.dataset.id)) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.querySelector('i').className = 'fas fa-star';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on page load
|
||||||
|
if (document.querySelector('.btn-fav')) {
|
||||||
|
loadFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== READ HISTORY ==========
|
||||||
|
const READ_STORAGE_KEY = 'readHistory';
|
||||||
|
const MAX_READ_ITEMS = 500;
|
||||||
|
|
||||||
|
function getReadHistory() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(READ_STORAGE_KEY)) || [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAsRead(id) {
|
||||||
|
const history = getReadHistory();
|
||||||
|
if (!history.includes(id)) {
|
||||||
|
history.unshift(id);
|
||||||
|
if (history.length > MAX_READ_ITEMS) history.pop();
|
||||||
|
localStorage.setItem(READ_STORAGE_KEY, JSON.stringify(history));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReadStyles() {
|
||||||
|
const history = new Set(getReadHistory());
|
||||||
|
document.querySelectorAll('.noticia-card').forEach(card => {
|
||||||
|
const link = card.querySelector('a[href*="/noticia"]');
|
||||||
|
if (link) {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
// Extract ID from URL
|
||||||
|
const match = href.match(/[?&](?:id|tr_id)=([^&]+)/);
|
||||||
|
if (match && history.has(match[1])) {
|
||||||
|
card.classList.add('is-read');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track clicks on news links
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const link = e.target.closest('a[href*="/noticia"]');
|
||||||
|
if (link) {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
const match = href.match(/[?&](?:id|tr_id)=([^&]+)/);
|
||||||
|
if (match) {
|
||||||
|
markAsRead(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== NOTIFICATIONS ==========
|
||||||
|
let lastCheck = new Date().toISOString();
|
||||||
|
|
||||||
|
async function checkNotifications() {
|
||||||
|
if (Notification.permission !== "granted") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/notifications/check?last_check=${lastCheck}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.has_news) {
|
||||||
|
lastCheck = data.timestamp;
|
||||||
|
new Notification("The Daily Feed", {
|
||||||
|
body: data.message,
|
||||||
|
icon: "/static/favicon.ico" // Assuming generic icon
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update timestamp to now to avoid checking old news if server time drifts
|
||||||
|
lastCheck = new Date().toISOString();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Notification check failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request permission on load
|
||||||
|
if ("Notification" in window) {
|
||||||
|
if (Notification.permission === "default") {
|
||||||
|
// Add a small button or toast to ask for permission instead of auto-prompting which is annoying
|
||||||
|
// For this demo, we'll auto-check on first interaction if possible, or just log
|
||||||
|
console.log("Notifications available, waiting for permission.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check every 60 seconds
|
||||||
|
setInterval(checkNotifications, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add bell icon to enable notifications if not granted
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (Notification.permission !== 'granted' && Notification.permission !== 'denied') {
|
||||||
|
const nav = document.querySelector('.main-nav');
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'nav-link';
|
||||||
|
btn.innerHTML = '<i class="fas fa-bell"></i>';
|
||||||
|
btn.style.background = 'none';
|
||||||
|
btn.style.border = 'none';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.title = 'Activar notificaciones';
|
||||||
|
btn.onclick = () => {
|
||||||
|
Notification.requestPermission().then(permission => {
|
||||||
|
if (permission === "granted") {
|
||||||
|
btn.style.display = 'none';
|
||||||
|
new Notification("The Daily Feed", { body: "¡Notificaciones activadas!" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
nav.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init Selects
|
||||||
|
initSearchableSelects();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply read styles on load
|
||||||
|
applyReadStyles();
|
||||||
|
|
||||||
|
// ========== MOBILE NAVIGATION (BULLETPROOF) ==========
|
||||||
|
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
|
||||||
|
const mainNav = document.getElementById('main-nav');
|
||||||
|
const navOverlay = document.getElementById('nav-overlay');
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
const isOpen = mainNav.classList.toggle('active');
|
||||||
|
navOverlay.classList.toggle('active');
|
||||||
|
document.body.classList.toggle('no-scroll', isOpen);
|
||||||
|
const icon = mobileMenuToggle.querySelector('i');
|
||||||
|
icon.className = isOpen ? 'fas fa-times' : 'fas fa-bars';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileMenuToggle) {
|
||||||
|
mobileMenuToggle.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMenu();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navOverlay) {
|
||||||
|
navOverlay.onclick = toggleMenu;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive Dropdowns for Touch/Click
|
||||||
|
document.querySelectorAll('.dropbtn').forEach(btn => {
|
||||||
|
btn.onclick = (e) => {
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const dropdown = btn.parentNode;
|
||||||
|
const wasOpen = dropdown.classList.contains('is-open');
|
||||||
|
|
||||||
|
// Close other open ones
|
||||||
|
document.querySelectorAll('.dropdown.is-open').forEach(d => d.classList.remove('is-open'));
|
||||||
|
|
||||||
|
// Toggle current
|
||||||
|
if (!wasOpen) dropdown.classList.add('is-open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && mainNav.classList.contains('active')) toggleMenu();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
452
templates/config.html
Normal file
452
templates/config.html
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Configuración{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="backup-overlay"
|
||||||
|
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.9); z-index:9999; flex-direction:column; justify-content:center; align-items:center; color:white; padding: 2rem; text-align: center;">
|
||||||
|
<i class="fas fa-database fa-spin fa-3x" style="margin-bottom:20px; color: var(--accent-color);"></i>
|
||||||
|
<h2 id="backup-title">Preparando backup...</h2>
|
||||||
|
<div
|
||||||
|
style="width: 100%; max-width: 400px; background: #333; border-radius: 10px; height: 10px; margin: 20px 0; overflow: hidden;">
|
||||||
|
<div id="backup-progress-bar"
|
||||||
|
style="width: 0%; height: 100%; background: var(--accent-color); transition: width 0.3s;"></div>
|
||||||
|
</div>
|
||||||
|
<p id="backup-status-text">Calculando noticias...</p>
|
||||||
|
<button id="btn-close-backup" onclick="hideBackupLoading()" class="btn btn-secondary"
|
||||||
|
style="margin-top:20px; display: none;">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let isBackupRunning = false;
|
||||||
|
let autoReloadTimer = null;
|
||||||
|
|
||||||
|
function startAutoReload() {
|
||||||
|
if (!isBackupRunning) {
|
||||||
|
autoReloadTimer = setTimeout(() => location.reload(), 30000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoReload() {
|
||||||
|
if (autoReloadTimer) clearTimeout(autoReloadTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBackup() {
|
||||||
|
isBackupRunning = true;
|
||||||
|
stopAutoReload();
|
||||||
|
document.getElementById('backup-overlay').style.display = 'flex';
|
||||||
|
document.getElementById('backup-title').innerText = "Iniciando Backup...";
|
||||||
|
document.getElementById('backup-progress-bar').style.width = '0%';
|
||||||
|
document.getElementById('btn-close-backup').style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/config/backup/start')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
pollBackupStatus(data.task_id);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
alert("Error al iniciar backup");
|
||||||
|
hideBackupLoading();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollBackupStatus(taskId) {
|
||||||
|
fetch(`/config/backup/status/${taskId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'processing' || data.status === 'initializing') {
|
||||||
|
updateBackupUI(data);
|
||||||
|
setTimeout(() => pollBackupStatus(taskId), 2000);
|
||||||
|
} else if (data.status === 'completed') {
|
||||||
|
updateBackupUI(data);
|
||||||
|
document.getElementById('backup-title').innerText = "¡Backup Completado!";
|
||||||
|
document.getElementById('backup-status-text').innerText = "Iniciando descarga...";
|
||||||
|
window.location.href = `/config/backup/download/${taskId}`;
|
||||||
|
document.getElementById('btn-close-backup').style.display = 'block';
|
||||||
|
isBackupRunning = false;
|
||||||
|
startAutoReload();
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
alert("Error: " + data.error);
|
||||||
|
hideBackupLoading();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBackupUI(data) {
|
||||||
|
if (data.total > 0) {
|
||||||
|
const percent = Math.round((data.progress / data.total) * 100);
|
||||||
|
document.getElementById('backup-progress-bar').style.width = percent + '%';
|
||||||
|
document.getElementById('backup-status-text').innerText = `Procesando: ${data.progress.toLocaleString()} / ${data.total.toLocaleString()} (${percent}%)`;
|
||||||
|
document.getElementById('backup-title').innerText = "Generando archivo ZIP...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideBackupLoading() {
|
||||||
|
document.getElementById('backup-overlay').style.display = 'none';
|
||||||
|
isBackupRunning = false;
|
||||||
|
startAutoReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize auto-reload
|
||||||
|
startAutoReload();
|
||||||
|
</script>
|
||||||
|
<div class="config-page">
|
||||||
|
<h2><i class="fas fa-cog"></i> Configuración</h2>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="config-grid">
|
||||||
|
<!-- Translator Card -->
|
||||||
|
<div class="config-card card-wide"
|
||||||
|
style="display: flex; align-items: center; justify-content: center; min-height: 120px;">
|
||||||
|
<a href="{{ url_for('config.translator_config') }}" class="btn btn-dark-outline"
|
||||||
|
style="font-size: 1.1rem; padding: 1rem 2rem;">
|
||||||
|
<i class="fas fa-robot"></i> Configurar Modelo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup Card -->
|
||||||
|
<div class="config-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon"><i class="fas fa-file-archive"></i></div>
|
||||||
|
</div>
|
||||||
|
<h3>Backup (ZIP)</h3>
|
||||||
|
<p>Exporta todas las noticias y traducciones en un archivo comprimido (ZIP) para ahorrar espacio.</p>
|
||||||
|
<button onclick="startBackup()" class="btn btn-dark" id="btn-start-backup">
|
||||||
|
<i class="fas fa-file-download"></i> Descargar ZIP (con progreso)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style="margin-top:10px; padding-top:10px; border-top:1px solid #eee;">
|
||||||
|
<small style="display:block; margin-bottom:5px; color:#666;">Metadatos:</small>
|
||||||
|
<div style="display:flex; gap:10px;">
|
||||||
|
<a href="{{ url_for('backup.export_paises') }}" class="btn btn-small btn-secondary_outline"
|
||||||
|
style="font-size:0.8em; padding:5px 10px;">
|
||||||
|
<i class="fas fa-file-csv"></i> Países
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('backup.export_categorias') }}" class="btn btn-small btn-secondary_outline"
|
||||||
|
style="font-size:0.8em; padding:5px 10px;">
|
||||||
|
<i class="fas fa-file-csv"></i> Categorías
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore Card -->
|
||||||
|
<div class="config-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon"><i class="fas fa-upload"></i></div>
|
||||||
|
</div>
|
||||||
|
<h3>Restaurar</h3>
|
||||||
|
<p>Importa datos desde un backup en formato <strong>JSON</strong> o <strong>ZIP</strong>.</p>
|
||||||
|
<a href="{{ url_for('config.restore_noticias') }}" class="btn btn-dark-outline">
|
||||||
|
<i class="fas fa-upload"></i> Subir Backup
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.config-page h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: 'Playfair Display', 'Times New Roman', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Banner */
|
||||||
|
.stats-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: #111;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.stat-warning {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.stat-processing {
|
||||||
|
color: #17a2b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.stat-error {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards Grid */
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card {
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
border-top: 3px solid #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card.card-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.config-card.card-wide {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-status .pulse {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #28a745;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-family: 'Playfair Display', 'Times New Roman', serif;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card p {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Big Stats */
|
||||||
|
.big-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-top: 1px solid var(--border-color, #eee);
|
||||||
|
border-bottom: 1px solid var(--border-color, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-stat {
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-stat .big-number {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111;
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-stat .big-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-stat.highlight .big-number {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-values {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-values code {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons - Black Theme */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark {
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: #111;
|
||||||
|
border: 2px solid #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark-outline:hover {
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode */
|
||||||
|
.dark-mode .config-card {
|
||||||
|
background: var(--card-bg-dark, #1e1e1e);
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .card-icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .big-stat .big-number {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .card-values code {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .stats-banner {
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .btn-dark {
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .btn-dark-outline {
|
||||||
|
border-color: #fff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .btn-dark-outline:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.stats-banner {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 0 0 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
160
templates/config_restore.html
Normal file
160
templates/config_restore.html
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Restaurar Noticias{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="config-form-page">
|
||||||
|
<h2><i class="fas fa-upload"></i> Restaurar Noticias</h2>
|
||||||
|
|
||||||
|
<div class="restore-warning">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<p>Esta acción importará noticias y traducciones desde un archivo de backup.
|
||||||
|
Las noticias existentes con el mismo ID serán actualizadas.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data" class="config-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="file">
|
||||||
|
<i class="fas fa-file-upload"></i> Archivo de Backup (JSON)
|
||||||
|
</label>
|
||||||
|
<input type="file" id="file" name="file" required>
|
||||||
|
<small>Selecciona un archivo JSON generado por el backup</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('config.config_home') }}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Cancelar
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary" onclick="showLoading()">
|
||||||
|
<i class="fas fa-upload"></i> Restaurar Backup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="loading-overlay"
|
||||||
|
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:9999; flex-direction:column; justify-content:center; align-items:center; color:white;">
|
||||||
|
<i class="fas fa-spinner fa-spin fa-3x" style="margin-bottom:20px;"></i>
|
||||||
|
<h2>Restaurando noticias...</h2>
|
||||||
|
<p>Por favor espere, esto puede tardar unos minutos.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showLoading() {
|
||||||
|
const fileInput = document.querySelector('input[name="file"]');
|
||||||
|
if (fileInput && fileInput.files.length > 0) {
|
||||||
|
document.getElementById('loading-overlay').style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.config-form-page h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-warning i {
|
||||||
|
color: #856404;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-warning p {
|
||||||
|
margin: 0;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form {
|
||||||
|
max-width: 500px;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border: 2px dashed var(--border-color, #ddd);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--input-bg, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .config-form {
|
||||||
|
background: var(--card-bg-dark, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .restore-warning {
|
||||||
|
background: #332701;
|
||||||
|
border-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .restore-warning p,
|
||||||
|
.dark-mode .restore-warning i {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
187
templates/config_translator.html
Normal file
187
templates/config_translator.html
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Configurar Traductor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="config-form-page">
|
||||||
|
<h2><i class="fas fa-language"></i> Configuración del Traductor</h2>
|
||||||
|
|
||||||
|
<form method="POST" class="config-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="target_langs">
|
||||||
|
<i class="fas fa-globe"></i> Idiomas Destino
|
||||||
|
</label>
|
||||||
|
<input type="text" id="target_langs" name="target_langs" value="{{ config.target_langs }}"
|
||||||
|
placeholder="es,en,fr">
|
||||||
|
<small>Separados por coma. Ej: es,en,fr</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="translator_batch">
|
||||||
|
<i class="fas fa-layer-group"></i> Tamaño de Batch
|
||||||
|
</label>
|
||||||
|
<select id="translator_batch" name="translator_batch">
|
||||||
|
{% for b in [8, 16, 32, 64, 128] %}
|
||||||
|
<option value="{{ b }}" {% if config.translator_batch|int==b %}selected{% endif %}>{{ b }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small>Número de textos a traducir por lote (8-128)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="universal_model">
|
||||||
|
<i class="fas fa-brain"></i> Modelo Universal
|
||||||
|
</label>
|
||||||
|
<select id="universal_model" name="universal_model">
|
||||||
|
{% set models = [
|
||||||
|
('facebook/nllb-200-distilled-600M', 'NLLB-200 Distilled 600M (Rápido / Default)'),
|
||||||
|
('facebook/nllb-200-distilled-1.3B', 'NLLB-200 Distilled 1.3B (Mejor Calidad / Lento)'),
|
||||||
|
('facebook/nllb-200-1.3B', 'NLLB-200 1.3B (Raw / Lento)'),
|
||||||
|
('facebook/nllb-200-3.3B', 'NLLB-200 3.3B (Máxima Calidad / Muy Lento / Requiere mucha RAM)')
|
||||||
|
] %}
|
||||||
|
{% for m_id, m_name in models %}
|
||||||
|
<option value="{{ m_id }}" {% if config.universal_model==m_id %}selected{% endif %}>{{ m_name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small>Selecciona el modelo de traducción. Actualmente usando: <strong>{{ config.universal_model
|
||||||
|
}}</strong></small>
|
||||||
|
<div class="alert alert-warning" style="margin-top: 10px; font-size: 0.9em; display: none;"
|
||||||
|
id="model-warning">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> <strong>Atención:</strong> Cambiar el modelo eliminará todas
|
||||||
|
las traducciones existentes para regenerarlas con el nuevo modelo.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('universal_model').addEventListener('change', function () {
|
||||||
|
var current = "{{ config.universal_model }}";
|
||||||
|
var warning = document.getElementById('model-warning');
|
||||||
|
if (this.value !== current) {
|
||||||
|
warning.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
warning.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ct2_compute_type">
|
||||||
|
<i class="fas fa-microchip"></i> Tipo de Cuantización
|
||||||
|
</label>
|
||||||
|
<select id="ct2_compute_type" name="ct2_compute_type">
|
||||||
|
<option value="auto" {% if config.ct2_compute_type=='auto' %}selected{% endif %}>auto</option>
|
||||||
|
<option value="int8" {% if config.ct2_compute_type=='int8' %}selected{% endif %}>int8 (más rápido, menos
|
||||||
|
preciso)</option>
|
||||||
|
<option value="float16" {% if config.ct2_compute_type=='float16' %}selected{% endif %}>float16 (más
|
||||||
|
preciso)</option>
|
||||||
|
<option value="int8_float16" {% if config.ct2_compute_type=='int8_float16' %}selected{% endif %}>
|
||||||
|
int8_float16 (balance)</option>
|
||||||
|
</select>
|
||||||
|
<small>Requiere reiniciar el contenedor</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="{{ url_for('config.config_home') }}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Volver
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.config-form-page h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form {
|
||||||
|
max-width: 500px;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--input-bg, #fff);
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--secondary-color, #6c63ff);
|
||||||
|
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--secondary-color, #6c63ff);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .config-form {
|
||||||
|
background: var(--card-bg-dark, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .form-group input,
|
||||||
|
.dark-mode .form-group select {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #444;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
163
templates/conflict_timeline.html
Normal file
163
templates/conflict_timeline.html
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Timeline: {{ conflict.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h2>
|
||||||
|
<a href="{{ url_for('conflicts.index') }}" style="text-decoration: none; color: inherit;">
|
||||||
|
<i class="fas fa-arrow-left" style="font-size: 1rem; vertical-align: middle;"></i>
|
||||||
|
</a>
|
||||||
|
Timeline: {{ conflict.name }}
|
||||||
|
</h2>
|
||||||
|
<span class="badge" style="font-size: 1rem;">{{ noticias|length }} eventos</span>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666;">
|
||||||
|
Keywords: {{ conflict.keywords }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not noticias %}
|
||||||
|
<div class="card" style="text-align: center; padding: 3rem;">
|
||||||
|
<p>No se encontraron noticias recientes con las palabras clave especificadas.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="timeline">
|
||||||
|
{% for n in noticias %}
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-date">
|
||||||
|
{% if n.fecha %}
|
||||||
|
<span class="date-day">{{ n.fecha.strftime('%d') }}</span>
|
||||||
|
<span class="date-month">{{ n.fecha.strftime('%b') }}</span>
|
||||||
|
<span class="date-year">{{ n.fecha.strftime('%Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-marker"></div>
|
||||||
|
<div class="timeline-content card">
|
||||||
|
{% if n.imagen_url %}
|
||||||
|
<div class="timeline-img">
|
||||||
|
<img src="{{ n.imagen_url }}" loading="lazy" onerror="this.style.display='none'">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
<a
|
||||||
|
href="{{ url_for('noticia.noticia', tr_id=n.tr_id if n.tr_id else None, id=n.id if not n.tr_id else None) }}">
|
||||||
|
{{ n.titulo }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="noticia-meta">
|
||||||
|
{{ n.fuente_nombre }}
|
||||||
|
{% if n.pais %}| {{ n.pais }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>{{ (n.resumen or '') | safe_html | truncate(150) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Timeline Styles */
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical Line */
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 80px;
|
||||||
|
/* Position of line */
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding-left: 120px;
|
||||||
|
/* Space for date and line */
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-day {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-month {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-year {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: 74px;
|
||||||
|
/* On the line (80px - 6px radius) */
|
||||||
|
top: 10px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--paper-color);
|
||||||
|
border: 3px solid var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
position: relative;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-left: 4px solid var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-img img {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode Overrides */
|
||||||
|
.dark-mode .timeline::before {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .timeline-marker {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
118
templates/conflicts_list.html
Normal file
118
templates/conflicts_list.html
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Conflictos{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<h2><i class="fas fa-exclamation-triangle"></i> Conflictos Monitorizados</h2>
|
||||||
|
<p>Define temas o conflictos para generar líneas de tiempo automáticas basadas en palabras clave.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Form -->
|
||||||
|
<div class="card" style="margin-bottom: 2rem;">
|
||||||
|
<h3>Crear Nuevo Conflicto</h3>
|
||||||
|
<form action="{{ url_for('conflicts.create') }}" method="POST">
|
||||||
|
<div class="filter-row">
|
||||||
|
<div class="filter-group" style="flex: 2;">
|
||||||
|
<label>Nombre del Conflicto</label>
|
||||||
|
<input type="text" name="name" placeholder="Ej: Camboya vs Tailandia" required>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group" style="flex: 3;">
|
||||||
|
<label>Palabras Clave (separadas por coma)</label>
|
||||||
|
<input type="text" name="keywords" placeholder="Ej: Camboya, Tailandia, Preah Vihear" required>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label> </label>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Crear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<label style="font-weight: 600; font-size: 0.9rem;">Descripción (Opcional)</label>
|
||||||
|
<input type="text" name="description" style="width: 100%;"
|
||||||
|
placeholder="Breve descripción del contexto...">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="conflicts-grid">
|
||||||
|
{% for c in conflicts %}
|
||||||
|
<div class="card conflict-card">
|
||||||
|
<div class="conflict-header">
|
||||||
|
<h3>{{ c.name }}</h3>
|
||||||
|
<form action="{{ url_for('conflicts.delete', id=c.id) }}" method="POST"
|
||||||
|
onsubmit="return confirm('¿Eliminar este conflicto?');">
|
||||||
|
<button type="submit" class="btn-icon" title="Eliminar"><i class="fas fa-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="conflict-desc">{{ c.description or 'Sin descripción' }}</p>
|
||||||
|
<div class="keyword-tags">
|
||||||
|
{% for k in c.keywords.split(',') %}
|
||||||
|
{% if k.strip() %}
|
||||||
|
<span class="badge">{{ k.strip() }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 1rem; text-align: right;">
|
||||||
|
<a href="{{ url_for('conflicts.timeline', id=c.id) }}" class="btn">
|
||||||
|
<i class="fas fa-stream"></i> Ver Línea de Tiempo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p style="text-align: center; color: #666;">No hay conflictos definidos.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.conflicts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-desc {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .conflict-desc {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
144
templates/dashboard.html
Normal file
144
templates/dashboard.html
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">{{ stats.feeds_totales }}</div>
|
||||||
|
<div class="stat-label">Feeds Totales</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">{{ stats.noticias_totales }}</div>
|
||||||
|
<div class="stat-label">Noticias Totales</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">{{ stats.feeds_caidos }}</div>
|
||||||
|
<div class="stat-label">Feeds Caídos</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Gestión de Feeds RSS</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>
|
||||||
|
Exporta tu lista de feeds RSS o restaura/importa desde un archivo CSV.
|
||||||
|
Además, puedes ir al organizador avanzado de feeds para filtrarlos
|
||||||
|
por país, categoría y estado.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:10px;">
|
||||||
|
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-list"></i> Ver / Gestionar Feeds
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:15px;">
|
||||||
|
<a href="{{ url_for('feeds.list_feeds', estado='activos') }}" class="btn btn-small">
|
||||||
|
<i class="fas fa-check-circle"></i> Feeds activos
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('feeds.list_feeds', estado='inactivos') }}" class="btn btn-small btn-danger">
|
||||||
|
<i class="fas fa-times-circle"></i> Feeds caídos/inactivos
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('feeds.list_feeds', estado='errores') }}" class="btn btn-small btn-info">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> Feeds con errores
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 15px 0; border: 0; border-top: 1px solid var(--border-color);">
|
||||||
|
|
||||||
|
<a href="{{ url_for('backup_feeds') }}" class="btn">
|
||||||
|
<i class="fas fa-download"></i> Exportar Feeds
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('restore_feeds') }}" class="btn btn-info">
|
||||||
|
<i class="fas fa-upload"></i> Importar Feeds
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Gestión de Fuentes URL</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Exporta tu lista de fuentes URL o restaura/importa desde un archivo CSV.</p>
|
||||||
|
<a href="{{ url_for('backup_urls') }}" class="btn">
|
||||||
|
<i class="fas fa-download"></i> Exportar URLs
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('restore_urls') }}" class="btn btn-info">
|
||||||
|
<i class="fas fa-upload"></i> Importar Fuentes URL
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Operaciones del Sistema</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Genera o restaura una copia de seguridad completa de todas tus fuentes y noticias.</p>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||||
|
<a href="{{ url_for('backup_completo') }}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-archive"></i> Backup Completo (.zip)
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ url_for('restore_completo') }}" class="btn btn-info">
|
||||||
|
<i class="fas fa-upload"></i> Restaurar Backup (.zip)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if top_tags and top_tags|length > 0 %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Top tags (últimas 24h)</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="padding:0;">
|
||||||
|
<table style="width:100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: rgba(0,0,0,0.05);">
|
||||||
|
<th style="padding: 12px 15px; text-align: left;">Tag</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: left;">Tipo</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: right;">Apariciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in top_tags %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">
|
||||||
|
{{ t.valor }}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color); text-transform: capitalize;">
|
||||||
|
{{ t.tipo }}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color); text-align: right;">
|
||||||
|
{{ t.apariciones }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Top tags (últimas 24h)</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="color: var(--text-color-light); margin: 0;">No hay tags para mostrar todavía.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
409
templates/discover_feeds.html
Normal file
409
templates/discover_feeds.html
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Descubrir Feeds RSS{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card feed-detail-card"
|
||||||
|
style="padding: 40px; border-radius: 15px; background-color: #fdfdfd; box-shadow: 0 10px 30px rgba(0,0,0,0.05);">
|
||||||
|
<h1
|
||||||
|
style="font-family: var(--primary-font); font-weight: 700; margin-bottom: 30px; border-bottom: 2px solid var(--accent-color); display: inline-block; padding-bottom: 10px;">
|
||||||
|
<i class="fas fa-search"></i> Descubrir Feeds RSS
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin-bottom: 30px; color: #666;">
|
||||||
|
Ingresa la URL de un sitio web y automáticamente descubriremos todos los feeds RSS disponibles.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div id="searching-overlay"
|
||||||
|
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.9); z-index: 1000; flex-direction: column; align-items: center; justify-content: center;">
|
||||||
|
<div class="spinner"
|
||||||
|
style="border: 4px solid #f3f3f3; border-top: 4px solid var(--accent-color); border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite;">
|
||||||
|
</div>
|
||||||
|
<h3 style="margin-top: 20px; color: #333;">Analizando sitio web...</h3>
|
||||||
|
<p style="color: #666;">Buscando feeds RSS, esto puede tardar unos segundos.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Discovery Form -->
|
||||||
|
<form action="{{ url_for('feeds.discover_feed') }}" method="post" class="form-grid" style="margin-bottom: 40px;"
|
||||||
|
onsubmit="document.getElementById('searching-overlay').style.display = 'flex';">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="source_url">URL del sitio web</label>
|
||||||
|
<input type="url" id="source_url" name="source_url" placeholder="https://ejemplo.com"
|
||||||
|
value="{{ source_url }}" required style="font-size: 16px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row" style="border: none; padding-top: 20px;">
|
||||||
|
<div></div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fas fa-search"></i> Buscar Feeds
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Volver
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
{% if discovered_feeds %}
|
||||||
|
<hr style="margin: 40px 0; border: none; border-top: 1px solid #e0e0e0;">
|
||||||
|
|
||||||
|
{% set new_feeds_count = discovered_feeds | rejectattr('exists') | list | length %}
|
||||||
|
<h2 style="font-size: 24px; margin-bottom: 20px; color: #333;">
|
||||||
|
<i class="fas fa-rss"></i> Feeds Disponibles: <strong>{{ new_feeds_count }}</strong> <span
|
||||||
|
style="font-size: 16px; color: #777; font-weight: normal;">(de {{ discovered_feeds|length }} encontrados en
|
||||||
|
total)</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form action="{{ url_for('feeds.discover_and_add') }}" method="post">
|
||||||
|
<!-- Global Settings -->
|
||||||
|
<div class="form-grid"
|
||||||
|
style="background: #f8f9fa; padding: 20px; border-radius: 10px; margin-bottom: 30px; border: 1px solid #e0e0e0;">
|
||||||
|
<h3
|
||||||
|
style="grid-column: 1 / -1; font-size: 16px; margin-bottom: 15px; color: #555; text-transform: uppercase; font-weight: 700; letter-spacing: 0.5px;">
|
||||||
|
<i class="fas fa-sliders-h"></i> Configuración Masiva
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="global_categoria_id">Aplicar Categoría a todos:</label>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<select id="global_categoria_id" class="form-control">
|
||||||
|
<option value="">— Seleccionar —</option>
|
||||||
|
{% for c in categorias %}
|
||||||
|
<option value="{{ c.id }}">{{ c.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="applyGlobalCategory()"
|
||||||
|
title="Aplicar a todos">
|
||||||
|
<i class="fas fa-arrow-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="global_pais_id">Aplicar País a todos:</label>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<select id="global_pais_id" class="form-control">
|
||||||
|
<option value="">— Seleccionar —</option>
|
||||||
|
{% for p in paises %}
|
||||||
|
<option value="{{ p.id }}">{{ p.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="applyGlobalCountry()"
|
||||||
|
title="Aplicar a todos">
|
||||||
|
<i class="fas fa-arrow-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="global_idioma">Aplicar Idioma a todos:</label>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<input type="text" id="global_idioma" class="form-control" maxlength="5" placeholder="es" value="es"
|
||||||
|
style="width: 80px;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="applyGlobalLanguage()"
|
||||||
|
title="Aplicar a todos">
|
||||||
|
<i class="fas fa-arrow-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feed List -->
|
||||||
|
<div style="margin-bottom: 30px;">
|
||||||
|
{% for feed in discovered_feeds %}
|
||||||
|
<div class="feed-discovery-item" style="
|
||||||
|
background: {{ 'white' if feed.valid and not feed.exists else ('#f0f0f0' if feed.exists else '#fff5f5') }};
|
||||||
|
border: 1px solid {{ '#e0e0e0' if feed.valid else '#ffcdd2' }};
|
||||||
|
border-left: 5px solid {{ '#4CAF50' if feed.valid and not feed.exists else ('#9e9e9e' if feed.exists else '#ff5252') }};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr 280px auto;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||||
|
opacity: {{ '0.8' if feed.exists else '1' }};
|
||||||
|
">
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<div style="padding-top: 5px; display: flex; justify-content: center;">
|
||||||
|
{% if feed.exists %}
|
||||||
|
<i class="fas fa-check-circle" style="color: #4CAF50; font-size: 22px;"
|
||||||
|
title="Ya existe en la base de datos"></i>
|
||||||
|
{% elif feed.valid %}
|
||||||
|
<input type="checkbox" name="selected_feeds" value="{{ feed.url }}" id="feed_{{ loop.index }}"
|
||||||
|
checked style="width: 22px; height: 22px; cursor: pointer; border-radius: 4px;">
|
||||||
|
<input type="hidden" name="context_{{ feed.url }}" value="{{ feed.context_label }}">
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-exclamation-triangle" style="color: #ff5252; font-size: 20px;"
|
||||||
|
title="{{ feed.error }}"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feed Info -->
|
||||||
|
<div>
|
||||||
|
<label for="feed_{{ loop.index }}" style="cursor: pointer; display: block; margin-bottom: 8px;">
|
||||||
|
<strong
|
||||||
|
style="font-size: 18px; color: #{{ '555' if feed.exists else '333' }}; line-height: 1.3;">
|
||||||
|
{{ feed.title }}
|
||||||
|
</strong>
|
||||||
|
{% if feed.exists %}
|
||||||
|
<span class="badge"
|
||||||
|
style="background: #e0e0e0; color: #555; font-size: 11px; vertical-align: middle; margin-left: 8px;">YA
|
||||||
|
INSTALADO</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% if feed.description %}
|
||||||
|
<p style="color: #666; margin-bottom: 12px; font-size: 14px; line-height: 1.5;">
|
||||||
|
{{ feed.description[:250] }}{% if feed.description|length > 250 %}...{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="font-size: 13px; color: #888; display: flex; flex-direction: column; gap: 6px;">
|
||||||
|
<div>
|
||||||
|
<i class="fas fa-link" style="width: 16px; text-align: center;"></i>
|
||||||
|
<a href="{{ feed.url }}" target="_blank"
|
||||||
|
style="color: #888; text-decoration: none; border-bottom: 1px dotted #ccc;">
|
||||||
|
{{ feed.url[:60] }}{% if feed.url|length > 60 %}...{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if feed.context_label %}
|
||||||
|
<div style="color: #1976D2; font-weight: 500;">
|
||||||
|
<i class="fas fa-tag" style="width: 16px; text-align: center;"></i> Encontrado en: "{{
|
||||||
|
feed.context_label }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if feed.valid %}
|
||||||
|
<div style="display: flex; gap: 15px; margin-top: 5px;">
|
||||||
|
{% if feed.type %}
|
||||||
|
<span class="badge"
|
||||||
|
style="background: #e3f2fd; color: #1565c0; padding: 2px 8px; border-radius: 4px;">
|
||||||
|
{{ feed.type|upper }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if feed.entry_count is defined %}
|
||||||
|
<span class="badge"
|
||||||
|
style="background: #f3e5f5; color: #7b1fa2; padding: 2px 8px; border-radius: 4px;">
|
||||||
|
{{ feed.entry_count }} items
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="color: #d32f2f; margin-top: 5px;">
|
||||||
|
<i class="fas fa-info-circle"></i> Error: {{ feed.error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Individual Configurations -->
|
||||||
|
{% if feed.valid %}
|
||||||
|
<div style="background: #fdfdfd; padding: 15px; border-radius: 8px; border: 1px solid #eee;">
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label
|
||||||
|
style="font-size: 12px; font-weight: 600; color: #555; display: block; margin-bottom: 4px;">Categoría</label>
|
||||||
|
<select name="cat_{{ feed.url }}" class="item-category-select"
|
||||||
|
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;">
|
||||||
|
<option value="">— Seleccionar —</option>
|
||||||
|
{% for c in categorias %}
|
||||||
|
<option value="{{ c.id }}">{{ c.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<label
|
||||||
|
style="font-size: 12px; font-weight: 600; color: #555; display: block; margin-bottom: 4px;">País</label>
|
||||||
|
<select name="country_{{ feed.url }}" class="item-country-select"
|
||||||
|
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;">
|
||||||
|
<option value="">— Seleccionar —</option>
|
||||||
|
{% for p in paises %}
|
||||||
|
<option value="{{ p.id }}">{{ p.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style="font-size: 12px; font-weight: 600; color: #555; display: block; margin-bottom: 4px;">Idioma</label>
|
||||||
|
<input type="text" name="lang_{{ feed.url }}" class="item-language-input" value="es"
|
||||||
|
maxlength="5"
|
||||||
|
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 10px; justify-content: flex-start;">
|
||||||
|
{% if feed.valid %}
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" onclick="addSingleFeed('{{ feed.url }}')"
|
||||||
|
style="white-space: nowrap; box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
|
||||||
|
<i class="fas fa-plus"></i> Añadir
|
||||||
|
</button>
|
||||||
|
<a href="{{ feed.url }}" target="_blank" class="btn btn-outline-secondary btn-sm"
|
||||||
|
style="white-space: nowrap;">
|
||||||
|
<i class="fas fa-external-link-alt"></i> Ver XML
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="form-actions"
|
||||||
|
style="display: flex; gap: 15px; justify-content: flex-end; padding-top: 20px; border-top: 1px solid #e0e0e0; position: sticky; bottom: 0; background: white; z-index: 10; padding-bottom: 20px;">
|
||||||
|
<div style="margin-right: auto; align-self: center; color: #666; font-size: 14px;">
|
||||||
|
<span id="selected_count">{{ discovered_feeds|selectattr('valid')|list|length }}</span> feeds
|
||||||
|
seleccionados
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="toggleAllFeeds(true)">
|
||||||
|
<i class="fas fa-check-square"></i> Todos
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="toggleAllFeeds(false)">
|
||||||
|
<i class="fas fa-square"></i> Ninguno
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" type="submit"
|
||||||
|
style="padding: 10px 25px; font-weight: 600; box-shadow: 0 4px 10px rgba(0,0,0,0.1);">
|
||||||
|
<i class="fas fa-plus-circle"></i> AÑADIR SELECCIONADOS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleAllFeeds(select) {
|
||||||
|
const checkboxes = document.querySelectorAll('input[name="selected_feeds"]');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = select;
|
||||||
|
});
|
||||||
|
updateCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSingleFeed(url) {
|
||||||
|
// Collect specific values
|
||||||
|
const cat = document.querySelector(`select[name="cat_${url}"]`).value;
|
||||||
|
const country = document.querySelector(`select[name="country_${url}"]`).value;
|
||||||
|
const lang = document.querySelector(`input[name="lang_${url}"]`).value;
|
||||||
|
const context = document.querySelector(`input[name="context_${url}"]`) ? document.querySelector(`input[name="context_${url}"]`).value : '';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('selected_feeds', url);
|
||||||
|
formData.append(`cat_${url}`, cat);
|
||||||
|
formData.append(`country_${url}`, country);
|
||||||
|
formData.append(`lang_${url}`, lang);
|
||||||
|
if (context) formData.append(`context_${url}`, context);
|
||||||
|
|
||||||
|
const btn = event.target.closest('button');
|
||||||
|
const originalContent = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
fetch('{{ url_for("feeds.discover_and_add") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Añadido';
|
||||||
|
btn.classList.remove('btn-primary');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
// Disable inputs for this row
|
||||||
|
document.querySelector(`select[name="cat_${url}"]`).disabled = true;
|
||||||
|
document.querySelector(`select[name="country_${url}"]`).disabled = true;
|
||||||
|
document.querySelector(`input[name="lang_${url}"]`).disabled = true;
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<i class="fas fa-times"></i> Error';
|
||||||
|
btn.classList.remove('btn-primary');
|
||||||
|
btn.classList.add('btn-danger');
|
||||||
|
alert('No se pudo añadir: ' + (data.errors ? data.errors.join(', ') : 'Error desconocido'));
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = originalContent;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('btn-danger');
|
||||||
|
btn.classList.add('btn-primary');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
btn.innerHTML = originalContent;
|
||||||
|
btn.disabled = false;
|
||||||
|
alert('Error de conexión');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
const count = document.querySelectorAll('input[name="selected_feeds"]:checked').length;
|
||||||
|
document.getElementById('selected_count').innerText = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update count on individual clicks
|
||||||
|
document.addEventListener('change', function (e) {
|
||||||
|
if (e.target.name === 'selected_feeds') {
|
||||||
|
updateCount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mass Update Functions
|
||||||
|
function applyGlobalCategory() {
|
||||||
|
const val = document.getElementById('global_categoria_id').value;
|
||||||
|
document.querySelectorAll('.item-category-select').forEach(el => el.value = val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGlobalCountry() {
|
||||||
|
const val = document.getElementById('global_pais_id').value;
|
||||||
|
document.querySelectorAll('.item-country-select').forEach(el => el.value = val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGlobalLanguage() {
|
||||||
|
const val = document.getElementById('global_idioma').value;
|
||||||
|
document.querySelectorAll('.item-language-input').forEach(el => el.value = val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hover effect to feed items
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const feedItems = document.querySelectorAll('.feed-discovery-item');
|
||||||
|
feedItems.forEach(item => {
|
||||||
|
item.addEventListener('mouseenter', function () {
|
||||||
|
this.style.boxShadow = '0 8px 20px rgba(0,0,0,0.08)';
|
||||||
|
this.style.transform = 'translateY(-2px)';
|
||||||
|
});
|
||||||
|
item.addEventListener('mouseleave', function () {
|
||||||
|
this.style.boxShadow = '0 2px 5px rgba(0,0,0,0.05)';
|
||||||
|
this.style.transform = 'translateY(0)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
updateCount();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.feed-discovery-item input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent-color, #4CAF50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
87
templates/edit_feed.html
Normal file
87
templates/edit_feed.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Editar Feed RSS{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card feed-detail-card"
|
||||||
|
style="padding: 40px; border-radius: 15px; background-color: #fdfdfd; box-shadow: 0 10px 30px rgba(0,0,0,0.05);">
|
||||||
|
<h1
|
||||||
|
style="font-family: var(--primary-font); font-weight: 700; margin-bottom: 30px; border-bottom: 2px solid var(--accent-color); display: inline-block; padding-bottom: 10px;">
|
||||||
|
Editar Feed
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle" style="margin-bottom: 30px; font-style: italic; color: #666;">
|
||||||
|
Modificando fuente: <strong>{{ feed.nombre }}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('feeds.edit_feed', feed_id=feed.id) }}" autocomplete="off" class="form-grid">
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="nombre">Nombre del feed</label>
|
||||||
|
<input id="nombre" name="nombre" type="text" value="{{ feed.nombre }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="descripcion">Descripción</label>
|
||||||
|
<textarea id="descripcion" name="descripcion" rows="3">{{ feed.descripcion or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="url">URL del RSS</label>
|
||||||
|
<input id="url" name="url" type="url" value="{{ feed.url }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="categoria_id">Categoría</label>
|
||||||
|
<select id="categoria_id" name="categoria_id">
|
||||||
|
<option value="">— Sin categoría —</option>
|
||||||
|
{% for c in categorias %}
|
||||||
|
<option value="{{ c.id }}" {% if c.id==feed.categoria_id %}selected{% endif %}>
|
||||||
|
{{ c.nombre }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="pais_id">País</label>
|
||||||
|
<select id="pais_id" name="pais_id">
|
||||||
|
<option value="">— Global —</option>
|
||||||
|
{% for p in paises %}
|
||||||
|
<option value="{{ p.id }}" {% if p.id==feed.pais_id %}selected{% endif %}>
|
||||||
|
{{ p.nombre }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="idioma">Idioma (2 letras)</label>
|
||||||
|
<input id="idioma" name="idioma" type="text" value="{{ feed.idioma or '' }}" maxlength="2">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div></div> <!-- Alignment -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<input type="checkbox" id="activo" name="activo" {% if feed.activo %}checked{% endif %}
|
||||||
|
style="width: 24px; height: 24px; margin: 0;">
|
||||||
|
<label for="activo" style="margin: 0; text-align: left; font-size: 1.1rem;">Feed activo</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row" style="border: none; padding-top: 20px;">
|
||||||
|
<div></div> <!-- Alignment -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fas fa-save"></i> Guardar Cambios
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-times"></i> Cancelar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<a href="{{ url_for('feeds.list_feeds') }}" class="top-link">← Volver al listado</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
48
templates/edit_url_source.html
Normal file
48
templates/edit_url_source.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Editar Fuente URL{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Editar Fuente: {{ fuente.nombre }}</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form action="{{ url_for('edit_url_source', url_id=fuente.id) }}" method="post">
|
||||||
|
|
||||||
|
<label for="nombre">Nombre</label>
|
||||||
|
<input type="text" id="nombre" name="nombre" value="{{ fuente.nombre }}" required>
|
||||||
|
|
||||||
|
<label for="url" style="margin-top:15px;">URL</label>
|
||||||
|
<input type="url" id="url" name="url" value="{{ fuente.url }}" required>
|
||||||
|
|
||||||
|
<label for="categoria_id" style="margin-top:15px;">Categoría</label>
|
||||||
|
<select id="categoria_id" name="categoria_id">
|
||||||
|
<option value="">— Sin categoría —</option>
|
||||||
|
{% for c in categorias %}
|
||||||
|
<option value="{{ c.id }}" {% if c.id == fuente.categoria_id %}selected{% endif %}>
|
||||||
|
{{ c.nombre }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="pais_id" style="margin-top:15px;">País</label>
|
||||||
|
<select id="pais_id" name="pais_id">
|
||||||
|
<option value="">— Sin país —</option>
|
||||||
|
{% for p in paises %}
|
||||||
|
<option value="{{ p.id }}" {% if p.id == fuente.pais_id %}selected{% endif %}>
|
||||||
|
{{ p.nombre }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="idioma" style="margin-top:15px;">Idioma (2 letras)</label>
|
||||||
|
<input id="idioma" name="idioma" value="{{ fuente.idioma }}" maxlength="2" required>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:end;gap:10px;margin-top:20px;">
|
||||||
|
<a href="{{ url_for('manage_urls') }}" class="btn btn-secondary">Cancelar</a>
|
||||||
|
<button class="btn" type="submit">Actualizar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
114
templates/favoritos.html
Normal file
114
templates/favoritos.html
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Mis Favoritos{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="favoritos-page">
|
||||||
|
<h2><i class="fas fa-star"></i> Mis Favoritos</h2>
|
||||||
|
|
||||||
|
{% if noticias %}
|
||||||
|
<p class="favoritos-count">{{ noticias|length }} noticia{{ 's' if noticias|length > 1 else '' }} guardada{{ 's' if
|
||||||
|
noticias|length > 1 else '' }}</p>
|
||||||
|
|
||||||
|
<ul class="noticias-list">
|
||||||
|
{% for n in noticias %}
|
||||||
|
<li class="noticia-item">
|
||||||
|
{% if n.imagen_url %}
|
||||||
|
<div class="noticia-imagen">
|
||||||
|
<img src="{{ n.imagen_url }}" loading="lazy" onerror="this.parentElement.style.display='none'">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="noticia-texto">
|
||||||
|
<h3 class="m0">
|
||||||
|
<a href="{{ url_for('noticia.noticia', id=n.id) }}">
|
||||||
|
{{ n.titulo_trad or n.titulo }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="noticia-meta">
|
||||||
|
{% if n.fecha %}
|
||||||
|
<i class="far fa-calendar-alt"></i>
|
||||||
|
{{ n.fecha.strftime('%d-%m-%Y %H:%M') if n.fecha else '' }}
|
||||||
|
{% endif %}
|
||||||
|
{% if n.fuente_nombre %} | {{ n.fuente_nombre }}{% endif %}
|
||||||
|
{% if n.pais %} | {{ n.pais|country_flag }} {{ n.pais }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="noticia-resumen">{{ (n.resumen_trad or n.resumen or '')[:200] }}...</p>
|
||||||
|
|
||||||
|
<button class="btn-remove-fav" onclick="removeFavorite('{{ n.id }}', this)" title="Quitar de favoritos">
|
||||||
|
<i class="fas fa-trash"></i> Quitar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="far fa-star"></i>
|
||||||
|
<p>No tienes noticias guardadas.</p>
|
||||||
|
<a href="{{ url_for('home.home') }}" class="btn btn-dark">Ver noticias</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.favoritos-page h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favoritos-count {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-fav {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
color: #dc3545;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-fav:hover {
|
||||||
|
background: #dc3545;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function removeFavorite(noticiaId, btn) {
|
||||||
|
const response = await fetch(`/favoritos/toggle/${noticiaId}`, { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
btn.closest('.noticia-item').remove();
|
||||||
|
// Update count
|
||||||
|
const remaining = document.querySelectorAll('.noticia-item').length;
|
||||||
|
if (remaining === 0) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
document.querySelector('.favoritos-count').textContent =
|
||||||
|
`${remaining} noticia${remaining > 1 ? 's' : ''} guardada${remaining > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
151
templates/feeds_list.html
Normal file
151
templates/feeds_list.html
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Gestionar Feeds RSS{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="card feed-detail-card">
|
||||||
|
<div class="feed-header">
|
||||||
|
<h2>Lista de Feeds RSS</h2>
|
||||||
|
|
||||||
|
<div class="nav-actions" style="display:flex; gap:8px; align-items:center;">
|
||||||
|
|
||||||
|
<!-- 🔵 Exportar feeds CSV (con filtros aplicados) -->
|
||||||
|
<a href="#" id="export-btn" class="btn btn-small btn-secondary" onclick="exportFilteredFeeds(event)">
|
||||||
|
<i class="fas fa-download"></i> Exportar Feeds
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- 🟣 Importar feeds CSV -->
|
||||||
|
<a href="{{ url_for('backup.restore_feeds') }}" class="btn btn-small btn-secondary">
|
||||||
|
<i class="fas fa-upload"></i> Importar Feeds
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- 🟢 Añadir feed -->
|
||||||
|
<a href="{{ url_for('feeds.add_feed') }}" class="btn btn-small">
|
||||||
|
<i class="fas fa-plus"></i> Añadir Feed
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros avanzados -->
|
||||||
|
<div class="feed-body" style="padding: 15px 15px 0 15px;">
|
||||||
|
<form class="feed-filters" method="get" action="{{ url_for('feeds.list_feeds') }}" id="filter-form">
|
||||||
|
<div class="filter-row">
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="pais_id">País</label>
|
||||||
|
<select name="pais_id" id="pais_id" onchange="reloadTable()">
|
||||||
|
<option value="">Todos los países</option>
|
||||||
|
{% for p in paises %}
|
||||||
|
<option value="{{ p.id }}" {% if filtro_pais_id is not none and p.id==filtro_pais_id|int
|
||||||
|
%}selected{% endif %}>
|
||||||
|
{{ p.nombre }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="categoria_id">Categoría</label>
|
||||||
|
<select name="categoria_id" id="categoria_id" onchange="reloadTable()">
|
||||||
|
<option value="">Todas las categorías</option>
|
||||||
|
{% for c in categorias %}
|
||||||
|
<option value="{{ c.id }}" {% if filtro_categoria_id is not none and
|
||||||
|
c.id==filtro_categoria_id|int %}selected{% endif %}>
|
||||||
|
{{ c.nombre }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="estado">Estado</label>
|
||||||
|
<select name="estado" id="estado" onchange="reloadTable()">
|
||||||
|
<option value="" {% if not filtro_estado %}selected{% endif %}>Todos</option>
|
||||||
|
<option value="activos" {% if filtro_estado=="activos" %}selected{% endif %}>Activos</option>
|
||||||
|
<option value="inactivos" {% if filtro_estado=="inactivos" %}selected{% endif %}>Inactivos
|
||||||
|
</option>
|
||||||
|
<option value="errores" {% if filtro_estado=="errores" %}selected{% endif %}>Con errores
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group" style="flex: 0 0 auto; display:flex; gap:10px; align-self: flex-end;">
|
||||||
|
<!-- Button can be hidden if we fully rely on onchange, but useful for accessibility or clearing -->
|
||||||
|
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary">
|
||||||
|
Limpiar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Container for dynamic table -->
|
||||||
|
<div id="table-container">
|
||||||
|
{% include '_feeds_table.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function reloadTable(urlOverride) {
|
||||||
|
const form = document.getElementById('filter-form');
|
||||||
|
const container = document.getElementById('table-container');
|
||||||
|
|
||||||
|
// Visual indicator
|
||||||
|
container.style.opacity = '0.5';
|
||||||
|
|
||||||
|
let url;
|
||||||
|
if (urlOverride) {
|
||||||
|
url = urlOverride;
|
||||||
|
} else {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams(formData);
|
||||||
|
url = `${form.action}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const html = await response.text();
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Update URL without reload
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reloading table:', error);
|
||||||
|
alert('Error al actualizar la lista.');
|
||||||
|
} finally {
|
||||||
|
container.style.opacity = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageClick(event, url) {
|
||||||
|
event.preventDefault();
|
||||||
|
reloadTable(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFilteredFeeds(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Capturar valores actuales de los filtros
|
||||||
|
const paisId = document.getElementById('pais_id').value;
|
||||||
|
const categoriaId = document.getElementById('categoria_id').value;
|
||||||
|
const estado = document.getElementById('estado').value;
|
||||||
|
|
||||||
|
// Construir URL con parámetros
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (paisId) params.append('pais_id', paisId);
|
||||||
|
if (categoriaId) params.append('categoria_id', categoriaId);
|
||||||
|
if (estado) params.append('estado', estado);
|
||||||
|
|
||||||
|
const exportUrl = `/export_feeds_filtered?${params.toString()}`;
|
||||||
|
|
||||||
|
// Redirigir para descargar
|
||||||
|
window.location.href = exportUrl;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue