Actualización del 2025-08-16 a las 13:12:01

This commit is contained in:
jlimolina 2025-08-16 13:12:01 +02:00
parent b44096b07c
commit b26e9ad87f
13 changed files with 710 additions and 222 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
# Entorno virtual
venv/
.venv/
# Cache de Python
__pycache__/
*.pyc
*.pyo
# Ficheros de IDE y OS
.idea/
.vscode/
.DS_Store

7
.env Normal file
View file

@ -0,0 +1,7 @@
# Variables para la base de datos
DB_NAME=rss
DB_USER=rss
DB_PASS=lalalilo
# Variable para Flask
SECRET_KEY=genera_una_clave_aleatoria_larga_aqui

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
# Usa una imagen base de Python ligera y moderna
FROM python:3.11-slim
# Establece el directorio de trabajo dentro del contenedor
WORKDIR /app
# Instala dependencias del sistema necesarias para psycopg2
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copia solo el archivo de requerimientos primero para aprovechar el caché de Docker
COPY requirements.txt .
# Instala las dependencias de Python
RUN pip install --no-cache-dir -r requirements.txt
# Copia el resto del código de la aplicación al contenedor
COPY . .
# Descarga los modelos de lenguaje de NLTK
RUN python download_models.py
# Expone el puerto que usará Gunicorn
EXPOSE 8000
# El CMD se especificará en docker-compose.yml para cada servicio

View file

@ -1,26 +1,35 @@
#!/bin/bash
# --- Script para actualizar el repositorio de Git automáticamente ---
# --- Script para actualizar el repositorio de Git de forma robusta ---
echo "🚀 Iniciando actualización del repositorio..."
# 1. Verificar el estado (opcional, pero bueno para ver qué se sube)
# 1. Sincronizar con el repositorio remoto para evitar conflictos
echo "----------------------------------------"
git status
echo "🔄 Sincronizando con el repositorio remoto (git pull)..."
git pull || { echo "❌ Error al hacer git pull. Soluciona los conflictos y vuelve a intentarlo."; exit 1; }
echo "----------------------------------------"
# 2. Preparar todos los archivos modificados y nuevos
echo " Añadiendo todos los archivos al área de preparación (git add .)"
git add .
git add -u # Asegura que los archivos eliminados también se registren
# 3. Crear el mensaje del commit con la fecha y hora actual
# 3. Crear el mensaje del commit solo si hay cambios
COMMIT_MSG="Actualización del $(date +'%Y-%m-%d a las %H:%M:%S')"
echo "💬 Creando commit con el mensaje: '$COMMIT_MSG'"
# Solo hacemos commit si hay algo que añadir para evitar commits vacíos
if ! git diff-index --quiet HEAD --; then
git commit -m "$COMMIT_MSG"
else
echo " No hay cambios que subir. El repositorio ya está actualizado."
exit 0
fi
# 4. Subir los cambios a GitHub
echo "⬆️ Subiendo cambios al repositorio remoto (git push)..."
git push
git push || { echo "❌ Error al hacer git push. Revisa la conexión o los permisos."; exit 1; }
echo "✅ ¡Actualización completada!"

185
app.py
View file

@ -77,29 +77,43 @@ def _get_form_dependencies(cursor):
paises = cursor.fetchall()
return categorias, paises
@app.route("/")
def home():
cat_id, cont_id, pais_id, fecha_filtro = request.args.get("categoria_id"), request.args.get("continente_id"), request.args.get("pais_id"), request.args.get("fecha")
q = request.args.get("q", "").strip()
noticias, categorias, continentes, paises = [], [], [], []
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
categorias = cursor.fetchall()
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
continentes = cursor.fetchall()
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
paises = cursor.fetchall()
sql_params, conditions = [], []
sql_base = "SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url, n.fuente_nombre, c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente FROM noticias n LEFT JOIN categorias c ON n.categoria_id = c.id LEFT JOIN paises p ON n.pais_id = p.id LEFT JOIN continentes co ON p.continente_id = co.id"
## CORRECCIÓN: Se extrae la lógica de construcción de la consulta de la ruta home() para mayor claridad.
def _build_news_query(args):
"""Construye la consulta SQL y los parámetros basados en los argumentos de la petición."""
sql_params = []
conditions = []
q = args.get("q", "").strip()
cat_id = args.get("categoria_id")
cont_id = args.get("continente_id")
pais_id = args.get("pais_id")
fecha_filtro = args.get("fecha")
sql_base = """
SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url, n.fuente_nombre,
c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente
FROM noticias n
LEFT JOIN categorias c ON n.categoria_id = c.id
LEFT JOIN paises p ON n.pais_id = p.id
LEFT JOIN continentes co ON p.continente_id = co.id
"""
if q:
search_query = " & ".join(q.split())
conditions.append("n.tsv @@ to_tsquery('spanish', %s)")
sql_params.append(search_query)
if cat_id: conditions.append("n.categoria_id = %s"); sql_params.append(cat_id)
if pais_id: conditions.append("n.pais_id = %s"); sql_params.append(pais_id)
elif cont_id: conditions.append("p.continente_id = %s"); sql_params.append(cont_id)
if cat_id:
conditions.append("n.categoria_id = %s")
sql_params.append(cat_id)
if pais_id:
conditions.append("n.pais_id = %s")
sql_params.append(pais_id)
elif cont_id:
conditions.append("p.continente_id = %s")
sql_params.append(cont_id)
if fecha_filtro:
try:
fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d')
@ -107,20 +121,56 @@ def home():
sql_params.append(fecha_obj.date())
except ValueError:
flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error")
if conditions: sql_base += " WHERE " + " AND ".join(conditions)
if conditions:
sql_base += " WHERE " + " AND ".join(conditions)
order_clause = " ORDER BY n.fecha DESC NULLS LAST"
if q:
search_query_ts = " & ".join(q.split())
# Es importante añadir el parámetro de ordenación al final para que coincida con la consulta
order_params = sql_params + [search_query_ts]
order_clause = " ORDER BY ts_rank(n.tsv, to_tsquery('spanish', %s)) DESC, n.fecha DESC"
sql_params.append(search_query_ts)
sql_final = sql_base + order_clause + " LIMIT 50"
return sql_final, order_params
sql_final = sql_base + order_clause + " LIMIT 50"
return sql_final, sql_params
@app.route("/")
def home():
noticias, categorias, continentes, paises = [], [], [], []
# Obtenemos los valores para mantener el estado de los filtros en la plantilla
q = request.args.get("q", "").strip()
cat_id = request.args.get("categoria_id")
cont_id = request.args.get("continente_id")
pais_id = request.args.get("pais_id")
fecha_filtro = request.args.get("fecha")
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
# Cargar dependencias de la UI
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
categorias = cursor.fetchall()
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
continentes = cursor.fetchall()
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
paises = cursor.fetchall()
# Construir y ejecutar la consulta de noticias
sql_final, sql_params = _build_news_query(request.args)
cursor.execute(sql_final, tuple(sql_params))
noticias = cursor.fetchall()
except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al leer noticias: {db_err}", exc_info=True)
flash("Error de base de datos al cargar las noticias.", "error")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return render_template('_noticias_list.html', noticias=noticias)
return render_template("noticias.html",
noticias=noticias, categorias=categorias, continentes=continentes, paises=paises,
cat_id=int(cat_id) if cat_id else None, cont_id=int(cont_id) if cont_id else None,
@ -154,7 +204,13 @@ def manage_feeds():
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
cursor.execute("SELECT COUNT(*) FROM feeds")
total_feeds = cursor.fetchone()[0]
cursor.execute("SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma, f.activo FROM feeds f LEFT JOIN categorias c ON f.categoria_id = c.id LEFT JOIN paises p ON f.pais_id = p.id ORDER BY f.nombre LIMIT %s OFFSET %s", (per_page, offset))
cursor.execute("""
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma, f.activo
FROM feeds f
LEFT JOIN categorias c ON f.categoria_id = c.id
LEFT JOIN paises p ON f.pais_id = p.id
ORDER BY f.nombre LIMIT %s OFFSET %s
""", (per_page, offset))
feeds_list = cursor.fetchall()
except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al obtener lista de feeds: {db_err}")
@ -180,6 +236,7 @@ def add_feed():
app.logger.error(f"[DB ERROR] Al agregar feed: {db_err}", exc_info=True)
flash(f"Error al añadir el feed: {db_err}", "error")
return redirect(url_for("manage_feeds"))
categorias, paises = [], []
try:
with get_conn() as conn:
@ -209,6 +266,7 @@ def edit_feed(feed_id):
app.logger.error(f"[DB ERROR] Al actualizar feed: {db_err}", exc_info=True)
flash(f"Error al actualizar el feed: {db_err}", "error")
return redirect(url_for("manage_feeds"))
feed, categorias, paises = None, [], []
try:
with get_conn() as conn:
@ -221,6 +279,7 @@ def edit_feed(feed_id):
categorias, paises = _get_form_dependencies(cursor)
except psycopg2.Error as db_err:
flash("Error al cargar el feed para editar.", "error")
app.logger.error(f"Error al cargar feed {feed_id} para editar: {db_err}")
return redirect(url_for("manage_feeds"))
return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises)
@ -253,7 +312,13 @@ def manage_urls():
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
cursor.execute("SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma FROM fuentes_url f LEFT JOIN categorias c ON f.categoria_id = c.id LEFT JOIN paises p ON f.pais_id = p.id ORDER BY f.nombre")
cursor.execute("""
SELECT f.id, f.nombre, f.url, c.nombre as categoria, p.nombre as pais, f.idioma
FROM fuentes_url f
LEFT JOIN categorias c ON f.categoria_id = c.id
LEFT JOIN paises p ON f.pais_id = p.id
ORDER BY f.nombre
""")
fuentes = cursor.fetchall()
except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al obtener lista de fuentes URL: {db_err}")
@ -279,6 +344,7 @@ def add_url_source():
app.logger.error(f"[DB ERROR] Al agregar fuente URL: {db_err}", exc_info=True)
flash(f"Error al añadir la fuente URL: {db_err}", "error")
return redirect(url_for("manage_urls"))
categorias, paises = [], []
try:
with get_conn() as conn:
@ -307,6 +373,7 @@ def edit_url_source(url_id):
app.logger.error(f"[DB ERROR] Al actualizar fuente URL: {db_err}", exc_info=True)
flash(f"Error al actualizar la fuente URL: {db_err}", "error")
return redirect(url_for("manage_urls"))
fuente, categorias, paises = None, [], []
try:
with get_conn() as conn:
@ -319,6 +386,7 @@ def edit_url_source(url_id):
categorias, paises = _get_form_dependencies(cursor)
except psycopg2.Error as db_err:
flash("Error al cargar la fuente URL para editar.", "error")
app.logger.error(f"Error al cargar fuente URL {url_id} para editar: {db_err}")
return redirect(url_for("manage_urls"))
return render_template("edit_url_source.html", fuente=fuente, categorias=categorias, paises=paises)
@ -334,12 +402,13 @@ def delete_url_source(url_id):
return redirect(url_for("manage_urls"))
def fetch_and_store_all():
with app.app_context():
logging.info("--- INICIANDO CICLO DE CAPTURA GLOBAL (RSS y URL) ---")
todas_las_noticias = []
feeds_fallidos = []
feeds_exitosos = []
feeds_para_actualizar_headers = []
# --- Parte 1: Procesando Feeds RSS ---
logging.info("=> Parte 1: Procesando Feeds RSS...")
feeds_to_process = []
try:
@ -351,6 +420,7 @@ def fetch_and_store_all():
except psycopg2.Error as db_err:
logging.error(f"Error de BD al obtener feeds RSS: {db_err}")
return
if feeds_to_process:
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
future_to_feed = {executor.submit(process_single_feed, dict(feed)): feed for feed in feeds_to_process}
@ -371,8 +441,11 @@ def fetch_and_store_all():
except Exception as exc:
logging.error(f"Excepción en feed {original_feed_data['url']} (ID: {feed_id}): {exc}")
feeds_fallidos.append(feed_id)
noticias_desde_rss_count = len(todas_las_noticias)
logging.info(f"=> Parte 1 Finalizada. Noticias desde RSS: {noticias_desde_rss_count}. Éxitos: {len(feeds_exitosos)}. Fallos: {len(feeds_fallidos)}.")
# --- Parte 2: Procesando Fuentes URL ---
logging.info("=> Parte 2: Procesando Fuentes URL...")
urls_to_process = []
try:
@ -383,24 +456,36 @@ def fetch_and_store_all():
logging.info(f"Encontradas {len(urls_to_process)} fuentes URL para scrapear.")
except Exception as e:
logging.error(f"Error de BD al obtener fuentes URL: {e}")
## CORRECCIÓN: Se paraleliza la captura de noticias desde fuentes URL para mejorar el rendimiento.
if urls_to_process:
for source in tqdm(urls_to_process, desc="Procesando Fuentes URL"):
try:
noticias_encontradas, _ = process_newspaper_url(
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_url = {
executor.submit(
process_newspaper_url,
source['nombre'], source['url'], source['categoria_id'],
source['pais_id'], source['idioma']
)
): source for source in urls_to_process
}
for future in tqdm(as_completed(future_to_url), total=len(urls_to_process), desc="Procesando Fuentes URL"):
source = future_to_url[future]
try:
noticias_encontradas, _ = future.result()
if noticias_encontradas:
todas_las_noticias.extend(noticias_encontradas)
except Exception as e:
logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {e}")
except Exception as exc:
logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {exc}")
noticias_desde_urls_count = len(todas_las_noticias) - noticias_desde_rss_count
logging.info(f"=> Parte 2 Finalizada. Noticias encontradas desde URLs: {noticias_desde_urls_count}.")
# --- Parte 3: Actualizando la base de datos ---
logging.info("=> Parte 3: Actualizando la base de datos...")
if not any([todas_las_noticias, feeds_fallidos, feeds_exitosos, feeds_para_actualizar_headers]):
logging.info("No se encontraron nuevas noticias ni cambios en los feeds. Nada que actualizar.")
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
return
try:
with get_conn() as conn:
with conn.cursor() as cursor:
@ -408,9 +493,11 @@ def fetch_and_store_all():
cursor.execute("UPDATE feeds SET fallos = fallos + 1 WHERE id IN %s", (tuple(feeds_fallidos),))
cursor.execute("UPDATE feeds SET activo = FALSE WHERE fallos >= %s AND id IN %s", (MAX_FALLOS, tuple(feeds_fallidos)))
logging.info(f"Incrementado contador de fallos para {len(feeds_fallidos)} feeds.")
if feeds_exitosos:
cursor.execute("UPDATE feeds SET fallos = 0 WHERE id IN %s", (tuple(feeds_exitosos),))
logging.info(f"Reseteado contador de fallos para {len(feeds_exitosos)} feeds.")
if feeds_para_actualizar_headers:
psycopg2.extras.execute_values(
cursor,
@ -418,6 +505,7 @@ def fetch_and_store_all():
[(f['id'], f['etag'], f['modified']) for f in feeds_para_actualizar_headers]
)
logging.info(f"Actualizados headers para {len(feeds_para_actualizar_headers)} feeds.")
if todas_las_noticias:
logging.info(f"Intentando insertar/ignorar {len(todas_las_noticias)} noticias en total.")
insert_query = """
@ -427,21 +515,33 @@ def fetch_and_store_all():
"""
psycopg2.extras.execute_values(cursor, insert_query, todas_las_noticias, page_size=200)
logging.info(f"Inserción de noticias finalizada. {cursor.rowcount} filas podrían haber sido afectadas.")
logging.info("=> Parte 3 Finalizada. Base de datos actualizada correctamente.")
except Exception as e:
logging.error(f"Error de BD en la actualización masiva final: {e}", exc_info=True)
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
# --- Funciones de Backup y Restore (sin cambios) ---
@app.route("/backup_feeds")
def backup_feeds():
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
cursor.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 f.categoria_id = c.id LEFT JOIN paises p ON f.pais_id = p.id ORDER BY f.id")
cursor.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 f.categoria_id = c.id
LEFT JOIN paises p ON f.pais_id = p.id
ORDER BY f.id
""")
feeds_ = cursor.fetchall()
if not feeds_:
flash("No hay feeds para exportar.", "warning")
return redirect(url_for("dashboard"))
fieldnames = list(feeds_[0].keys())
output = StringIO()
writer = csv.DictWriter(output, fieldnames=fieldnames)
@ -469,6 +569,7 @@ def backup_urls():
if not fuentes:
flash("No hay fuentes URL para exportar.", "warning")
return redirect(url_for("dashboard"))
fieldnames = list(fuentes[0].keys())
output = StringIO()
writer = csv.DictWriter(output, fieldnames=fieldnames)
@ -489,11 +590,20 @@ def backup_noticias():
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
cursor.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, co.nombre AS continente FROM noticias n LEFT JOIN categorias c ON n.categoria_id = c.id LEFT JOIN paises p ON n.pais_id = p.id LEFT JOIN continentes co ON p.continente_id = co.id ORDER BY n.fecha DESC")
cursor.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, co.nombre AS continente
FROM noticias n
LEFT JOIN categorias c ON n.categoria_id = c.id
LEFT JOIN paises p ON n.pais_id = p.id
LEFT JOIN continentes co ON p.continente_id = co.id
ORDER BY n.fecha DESC
""")
noticias = cursor.fetchall()
if not noticias:
flash("No hay noticias para exportar.", "warning")
return redirect(url_for("dashboard"))
fieldnames_noticias = list(noticias[0].keys())
output = StringIO()
writer = csv.DictWriter(output, fieldnames=fieldnames_noticias)
@ -512,6 +622,7 @@ def backup_completo():
with zipfile.ZipFile(memory_buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
# Backup Feeds
cursor.execute("SELECT * FROM feeds ORDER BY id")
feeds_data = cursor.fetchall()
if feeds_data:
@ -520,6 +631,8 @@ def backup_completo():
writer_feeds.writeheader()
writer_feeds.writerows([dict(f) for f in feeds_data])
zipf.writestr("feeds.csv", output_feeds.getvalue())
# Backup Fuentes URL
cursor.execute("SELECT * FROM fuentes_url ORDER BY id")
fuentes_data = cursor.fetchall()
if fuentes_data:
@ -528,6 +641,8 @@ def backup_completo():
writer_fuentes.writeheader()
writer_fuentes.writerows([dict(f) for f in fuentes_data])
zipf.writestr("fuentes_url.csv", output_fuentes.getvalue())
# Backup Noticias
cursor.execute("SELECT * FROM noticias ORDER BY fecha DESC")
noticias_data = cursor.fetchall()
if noticias_data:
@ -536,6 +651,7 @@ def backup_completo():
writer_noticias.writeheader()
writer_noticias.writerows([dict(n) for n in noticias_data])
zipf.writestr("noticias.csv", output_noticias.getvalue())
memory_buffer.seek(0)
return Response(memory_buffer, mimetype="application/zip", headers={"Content-Disposition": "attachment;filename=rss_backup_completo.zip"})
except Exception as e:
@ -550,6 +666,7 @@ def restore_feeds():
if not file or not file.filename.endswith(".csv"):
flash("Archivo no válido. Sube un .csv.", "error")
return redirect(url_for("restore_feeds"))
try:
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
reader = csv.DictReader(file_stream)
@ -588,6 +705,7 @@ def restore_feeds():
app.logger.error(f"Error al restaurar feeds desde CSV: {e}", exc_info=True)
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
return redirect(url_for("dashboard"))
return render_template("restore_feeds.html")
@app.route("/restore_urls", methods=["GET", "POST"])
@ -597,6 +715,7 @@ def restore_urls():
if not file or not file.filename.endswith(".csv"):
flash("Archivo no válido. Sube un .csv.", "error")
return redirect(url_for("restore_urls"))
try:
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
reader = csv.DictReader(file_stream)
@ -637,8 +756,10 @@ def restore_urls():
app.logger.error(f"Error al restaurar fuentes URL desde CSV: {e}", exc_info=True)
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
return redirect(url_for("dashboard"))
return render_template("restore_urls.html")
if __name__ == "__main__":
if not db_pool:
app.logger.error("La aplicación no puede arrancar sin una conexión a la base de datos.")

65
docker-compose.yml Normal file
View file

@ -0,0 +1,65 @@
version: '3.8'
services:
# Servicio de la Base de Datos PostgreSQL
db:
image: postgres:15
container_name: rss_db
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS}
volumes:
# Volumen para persistir los datos de la base de datos
- postgres_data:/var/lib/postgresql/data
# Monta la carpeta local con los scripts SQL para inicializar la BD la primera vez
- ./init-db:/docker-entrypoint-initdb.d
restart: always
healthcheck:
# Comprueba si la base de datos está lista para aceptar conexiones
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 5
# Servicio de la Aplicación Web (Gunicorn)
web:
build: .
container_name: rss_web
command: gunicorn --bind 0.0.0.0:8000 --workers 3 app:app
ports:
- "8001:8000"
environment:
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- SECRET_KEY=${SECRET_KEY}
depends_on:
db:
# Espera a que el healthcheck de la base de datos sea exitoso antes de iniciar
condition: service_healthy
restart: always
# Servicio del Planificador de Tareas (Scheduler)
scheduler:
build: .
container_name: rss_scheduler
command: python scheduler.py
environment:
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- SECRET_KEY=${SECRET_KEY}
depends_on:
db:
# También espera a que la base de datos esté saludable
condition: service_healthy
restart: always
# Define el volumen nombrado para la persistencia de datos
volumes:
postgres_data:

24
init-db/01.schema.sql Normal file
View file

@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS continentes (id SERIAL PRIMARY KEY, nombre VARCHAR(50) NOT NULL UNIQUE);
CREATE TABLE IF NOT EXISTS categorias (id SERIAL PRIMARY KEY, nombre VARCHAR(100) NOT NULL UNIQUE);
CREATE TABLE IF NOT EXISTS paises (id SERIAL PRIMARY KEY, nombre VARCHAR(100) NOT NULL UNIQUE, continente_id INTEGER REFERENCES continentes(id) ON DELETE SET NULL);
CREATE TABLE IF NOT EXISTS feeds (id SERIAL PRIMARY KEY, nombre VARCHAR(255), descripcion TEXT, url TEXT NOT NULL UNIQUE, categoria_id INTEGER REFERENCES categorias(id) ON DELETE SET NULL, pais_id INTEGER REFERENCES paises(id) ON DELETE SET NULL, idioma CHAR(2), activo BOOLEAN DEFAULT TRUE, fallos INTEGER DEFAULT 0, last_etag TEXT, last_modified TEXT);
CREATE TABLE IF NOT EXISTS fuentes_url (id SERIAL PRIMARY KEY, nombre VARCHAR(255) NOT NULL, url TEXT NOT NULL UNIQUE, categoria_id INTEGER REFERENCES categorias(id) ON DELETE SET NULL, pais_id INTEGER REFERENCES paises(id) ON DELETE SET NULL, idioma CHAR(2) DEFAULT 'es');
CREATE TABLE IF NOT EXISTS noticias (id VARCHAR(32) PRIMARY KEY, titulo TEXT, resumen TEXT, url TEXT NOT NULL UNIQUE, fecha TIMESTAMP, imagen_url TEXT, fuente_nombre VARCHAR(255), categoria_id INTEGER REFERENCES categorias(id) ON DELETE SET NULL, pais_id INTEGER REFERENCES paises(id) ON DELETE SET NULL, tsv tsvector);
ALTER TABLE noticias ADD COLUMN IF NOT EXISTS tsv tsvector;
CREATE OR REPLACE FUNCTION noticias_tsv_trigger() RETURNS trigger AS $$
BEGIN
new.tsv := setweight(to_tsvector('spanish', coalesce(new.titulo,'')), 'A') ||
setweight(to_tsvector('spanish', coalesce(new.resumen,'')), 'B');
return new;
END
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS tsvectorupdate ON noticias;
CREATE TRIGGER tsvectorupdate
BEFORE INSERT OR UPDATE ON noticias
FOR EACH ROW EXECUTE PROCEDURE noticias_tsv_trigger();
CREATE INDEX IF NOT EXISTS noticias_tsv_idx ON noticias USING gin(tsv);

9
init-db/02-continentes.sql Executable file
View file

@ -0,0 +1,9 @@
INSERT INTO continentes (id, nombre) VALUES
(1, 'África'),
(2, 'América'),
(3, 'Asia'),
(4, 'Europa'),
(5, 'Oceanía'),
(6, 'Antártida')
ON CONFLICT (id) DO NOTHING;

18
init-db/03-categorias.sql Executable file
View file

@ -0,0 +1,18 @@
INSERT INTO categorias (nombre) VALUES
('Ciencia'),
('Cultura'),
('Deportes'),
('Economía'),
('Educación'),
('Entretenimiento'),
('Internacional'),
('Medio Ambiente'),
('Moda'),
('Opinión'),
('Política'),
('Salud'),
('Sociedad'),
('Tecnología'),
('Viajes')
ON CONFLICT DO NOTHING;

198
init-db/04-paises.sql Executable file
View file

@ -0,0 +1,198 @@
INSERT INTO paises (nombre, continente_id) VALUES
('Afganistán', 3),
('Albania', 4),
('Alemania', 4),
('Andorra', 4),
('Angola', 1),
('Antigua y Barbuda', 2),
('Arabia Saudita', 3),
('Argelia', 1),
('Argentina', 2),
('Armenia', 3),
('Australia', 5),
('Austria', 4),
('Azerbaiyán', 3),
('Bahamas', 2),
('Bangladés', 3),
('Barbados', 2),
('Baréin', 3),
('Bélgica', 4),
('Belice', 2),
('Benín', 1),
('Bielorrusia', 4),
('Birmania', 3),
('Bolivia', 2),
('Bosnia y Herzegovina', 4),
('Botsuana', 1),
('Brasil', 2),
('Brunéi', 3),
('Bulgaria', 4),
('Burkina Faso', 1),
('Burundi', 1),
('Bután', 3),
('Cabo Verde', 1),
('Camboya', 3),
('Camerún', 1),
('Canadá', 2),
('Catar', 3),
('Chad', 1),
('Chile', 2),
('China', 3),
('Chipre', 3),
('Colombia', 2),
('Comoras', 1),
('Corea del Norte', 3),
('Corea del Sur', 3),
('Costa de Marfil', 1),
('Costa Rica', 2),
('Croacia', 4),
('Cuba', 2),
('Dinamarca', 4),
('Dominica', 2),
('Ecuador', 2),
('Egipto', 1),
('El Salvador', 2),
('Emiratos Árabes Unidos', 3),
('Eritrea', 1),
('Eslovaquia', 4),
('Eslovenia', 4),
('España', 4),
('Estados Unidos', 2),
('Estonia', 4),
('Esuatini', 1),
('Etiopía', 1),
('Filipinas', 3),
('Finlandia', 4),
('Fiyi', 5),
('Francia', 4),
('Gabón', 1),
('Gambia', 1),
('Georgia', 3),
('Ghana', 1),
('Granada', 2),
('Grecia', 4),
('Guatemala', 2),
('Guinea', 1),
('Guinea-Bisáu', 1),
('Guinea Ecuatorial', 1),
('Guyana', 2),
('Haití', 2),
('Honduras', 2),
('Hungría', 4),
('India', 3),
('Indonesia', 3),
('Irak', 3),
('Irán', 3),
('Irlanda', 4),
('Islandia', 4),
('Islas Marshall', 5),
('Islas Salomón', 5),
('Israel', 3),
('Italia', 4),
('Jamaica', 2),
('Japón', 3),
('Jordania', 3),
('Kazajistán', 3),
('Kenia', 1),
('Kirguistán', 3),
('Kiribati', 5),
('Kuwait', 3),
('Laos', 3),
('Lesoto', 1),
('Letonia', 4),
('Líbano', 3),
('Liberia', 1),
('Libia', 1),
('Liechtenstein', 4),
('Lituania', 4),
('Luxemburgo', 4),
('Macedonia del Norte', 4),
('Madagascar', 1),
('Malasia', 3),
('Malaui', 1),
('Maldivas', 3),
('Malí', 1),
('Malta', 4),
('Marruecos', 1),
('Mauricio', 1),
('Mauritania', 1),
('México', 2),
('Micronesia', 5),
('Moldavia', 4),
('Mónaco', 4),
('Mongolia', 3),
('Montenegro', 4),
('Mozambique', 1),
('Namibia', 1),
('Nauru', 5),
('Nepal', 3),
('Nicaragua', 2),
('Níger', 1),
('Nigeria', 1),
('Noruega', 4),
('Nueva Zelanda', 5),
('Omán', 3),
('Países Bajos', 4),
('Pakistán', 3),
('Palaos', 5),
('Palestina', 3),
('Panamá', 2),
('Papúa Nueva Guinea', 5),
('Paraguay', 2),
('Perú', 2),
('Polonia', 4),
('Portugal', 4),
('Reino Unido', 4),
('República Centroafricana', 1),
('República Checa', 4),
('República del Congo', 1),
('República Democrática del Congo', 1),
('República Dominicana', 2),
('Ruanda', 1),
('Rumanía', 4),
('Rusia', 3),
('Samoa', 5),
('San Cristóbal y Nieves', 2),
('San Marino', 4),
('San Vicente y las Granadinas', 2),
('Santa Lucía', 2),
('Santo Tomé y Príncipe', 1),
('Senegal', 1),
('Serbia', 4),
('Seychelles', 1),
('Sierra Leona', 1),
('Singapur', 3),
('Siria', 3),
('Somalia', 1),
('Sri Lanka', 3),
('Sudáfrica', 1),
('Sudán', 1),
('Sudán del Sur', 1),
('Suecia', 4),
('Suiza', 4),
('Surinam', 2),
('Tailandia', 3),
('Tanzania', 1),
('Tayikistán', 3),
('Timor Oriental', 3),
('Togo', 1),
('Tonga', 5),
('Trinidad y Tobago', 2),
('Túnez', 1),
('Turkmenistán', 3),
('Turquía', 3),
('Tuvalu', 5),
('Ucrania', 4),
('Uganda', 1),
('Uruguay', 2),
('Uzbekistán', 3),
('Vanuatu', 5),
('Vaticano', 4),
('Venezuela', 2),
('Vietnam', 3),
('Yemen', 3),
('Yibuti', 1),
('Zambia', 1),
('Zimbabue', 1)
ON CONFLICT DO NOTHING;

View file

@ -52,6 +52,10 @@ apt-get update
apt-get install -y wget ca-certificates postgresql postgresql-contrib python3-venv python3-pip python3-dev libpq-dev gunicorn
echo "🔥 Paso 2: Eliminando y recreando la base de datos y el usuario..."
## CORRECCIÓN: Se exporta PGPASSWORD para evitar exponer la contraseña en la línea de comandos
## y se agrupa con la del paso 4 para mayor eficiencia.
export PGPASSWORD="$DB_PASS"
sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;"
sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;"
echo " -> Entidades de BD anteriores eliminadas."
@ -62,7 +66,7 @@ echo "✅ Base de datos y usuario recreados con éxito."
echo "🐍 Paso 3: Configurando el entorno de la aplicación..."
if ! id "$APP_USER" &>/dev/null; then
echo "👤 Creando usuario del sistema '$APP_USER'..."
sudo useradd -m -s /bin/bash "$APP_USER"
useradd -m -s /bin/bash "$APP_USER"
else
echo "✅ Usuario del sistema '$APP_USER' ya existe."
fi
@ -92,7 +96,7 @@ else
fi
echo "📐 Paso 4: Creando esquema de BD y sembrando datos..."
export PGPASSWORD="$DB_PASS"
# La variable PGPASSWORD ya está exportada desde el paso 2
psql -U "$DB_USER" -h localhost -d "$DB_NAME" <<SQL
CREATE TABLE IF NOT EXISTS continentes (id SERIAL PRIMARY KEY, nombre VARCHAR(50) NOT NULL UNIQUE);
@ -132,30 +136,14 @@ SQL
unset PGPASSWORD
echo "✅ Esquema de base de datos y datos iniciales configurados."
echo "👷 Paso 5: Creando script para el worker de captura..."
cat <<EOF > "$APP_DIR/worker.py"
import sys
import os
import logging
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from app import app, fetch_and_store
except ImportError as e:
logging.basicConfig()
logging.critical(f"No se pudo importar la aplicación Flask. Error: {e}")
sys.exit(1)
if __name__ == "__main__":
with app.app_context():
fetch_and_store()
EOF
chown "$APP_USER":"$APP_USER" "$APP_DIR/worker.py"
echo "✅ Script del worker creado/actualizado."
## CORRECCIÓN: Se elimina el Paso 5, ya no es necesario crear el worker.py dinámicamente.
echo "⚙️ Paso 6: Creando nuevos archivos de servicio systemd..."
cat <<EOF > /etc/systemd/system/$APP_NAME.service
[Unit]
Description=Gunicorn instance to serve $APP_NAME
After=network.target
[Service]
User=$APP_USER
Group=$APP_USER
@ -169,45 +157,47 @@ Environment="DB_USER=$DB_USER"
Environment="DB_PASS=$DB_PASS"
ExecStart=$PYTHON_ENV/bin/gunicorn --workers 3 --bind 0.0.0.0:$WEB_PORT --timeout 120 $WSGI_APP_ENTRY
Restart=always
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF > /etc/systemd/system/$APP_NAME-worker.service
## CORRECCIÓN: Se elimina la creación de rss-worker.service y rss-worker.timer.
## Se añade el nuevo servicio para el planificador persistente scheduler.py.
cat <<EOF > /etc/systemd/system/$APP_NAME-scheduler.service
[Unit]
Description=$APP_NAME Feed Fetcher Worker
Description=$APP_NAME Scheduler Worker
After=postgresql.service
[Service]
Type=oneshot
Type=simple
User=$APP_USER
Group=$APP_USER
WorkingDirectory=$APP_DIR
Environment="PATH=$PYTHON_ENV/bin"
Environment="SECRET_KEY=$(python3 -c 'import os; print(os.urandom(24).hex())')"
Environment="DB_HOST=localhost"
Environment="DB_PORT=5432"
Environment="DB_NAME=$DB_NAME"
Environment="DB_USER=$DB_USER"
Environment="DB_PASS=$DB_PASS"
ExecStart=$PYTHON_ENV/bin/python $APP_DIR/worker.py
ExecStart=$PYTHON_ENV/bin/python $APP_DIR/scheduler.py
Restart=always
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF > /etc/systemd/system/$APP_NAME-worker.timer
[Unit]
Description=Run $APP_NAME worker every 15 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=15min
Unit=$APP_NAME-worker.service
[Install]
WantedBy=timers.target
EOF
echo "✅ Archivos de servicio y timer creados."
echo "✅ Archivos de servicio creados."
echo "🚀 Paso 7: Recargando, habilitando, arrancando servicios y configurando firewall..."
systemctl daemon-reload
systemctl enable $APP_NAME.service
systemctl start $APP_NAME.service
systemctl enable $APP_NAME-worker.timer
systemctl start $APP_NAME-worker.timer
## CORRECCIÓN: Se habilitan e inician el nuevo servicio del planificador.
systemctl enable $APP_NAME-scheduler.service
systemctl start $APP_NAME-scheduler.service
if command -v ufw &> /dev/null && ufw status | grep -q 'Status: active'; then
echo " -> Firewall UFW detectado. Abriendo puerto $WEB_PORT..."
@ -224,8 +214,10 @@ echo " http://<IP_DE_TU_SERVIDOR>:$WEB_PORT"
echo ""
echo "Puedes verificar el estado de los servicios con:"
echo "sudo systemctl status $APP_NAME.service"
echo "sudo systemctl status $APP_NAME-worker.timer"
## CORRECCIÓN: Se actualiza el mensaje para reflejar el nuevo nombre del servicio del worker.
echo "sudo systemctl status $APP_NAME-scheduler.service"
echo ""
echo "Para ver los logs de la aplicación web:"
echo "sudo journalctl -u $APP_NAME.service -f"
echo "Para ver los logs del planificador de noticias:"
echo "sudo journalctl -u $APP_NAME-scheduler.service -f"

View file

@ -1,12 +1,62 @@
{% extends "base.html" %}
{% block title %}Lista Detallada de Feeds{% endblock %}
{% block title %}Gestionar Feeds RSS{% endblock %}
{% block content %}
<header>
<h1>Lista de Feeds</h1>
<p class="subtitle">Mostrando {{ feeds|length }} de {{ total_feeds }} feeds. Página {{ page }} de {{ total_pages }}.</p>
<a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">← Volver al Dashboard</a>
</header>
<div class="card feed-detail-card">
<div class="feed-header">
<h2>Lista de Feeds RSS ({{ total_feeds }})</h2>
<div class="nav-actions">
<a href="{{ url_for('add_feed') }}" class="btn btn-small">
<i class="fas fa-plus"></i> Añadir Feed
</a>
</div>
</div>
<div class="feed-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;">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;">Estado</th>
<th style="padding: 12px 15px; text-align: right;">Acciones</th>
</tr>
</thead>
<tbody>
{% for feed in feeds %}
<tr>
<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; border-top: 1px solid var(--border-color); text-align: center;">
{% if not feed.activo %}
<span style="color: #c0392b; font-weight: bold;" title="Inactivo por {{ feed.fallos }} fallos">KO</span>
{% else %}
<span style="color: #27ae60; font-weight:bold;">OK</span>
{% endif %}
</td>
<td style="padding: 12px 15px; text-align: right; border-top: 1px solid var(--border-color);">
<a href="{{ url_for('edit_feed', feed_id=feed.id) }}" class="btn btn-small btn-info" title="Editar"><i class="fas fa-edit"></i></a>
<a href="{{ url_for('delete_feed', feed_id=feed.id) }}" class="btn btn-small btn-danger" title="Eliminar" onclick="return confirm('¿Estás seguro de que quieres eliminar este feed?')"><i class="fas fa-trash"></i></a>
{% if not feed.activo %}
<a href="{{ url_for('reactivar_feed', feed_id=feed.id) }}" class="btn btn-small" title="Reactivar"><i class="fas fa-sync-alt"></i></a>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="padding: 20px; text-align: center;">No hay feeds para mostrar. <a href="{{ url_for('add_feed') }}">Añade el primero</a>.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if total_pages > 1 %}
<nav class="pagination">
@ -28,37 +78,4 @@
</nav>
{% endif %}
{% for feed in feeds %}
<div class="card feed-detail-card">
<div class="feed-header">
<h2>{{ feed.nombre }}</h2>
<div class="actions">
<a href="{{ url_for('edit_feed', feed_id=feed.id) }}" class="btn btn-small">Editar</a>
</div>
</div>
<div class="feed-body">
<dl>
<dt>ID:</dt><dd>{{ feed.id }}</dd>
<dt>URL:</dt><dd><a href="{{ feed.url }}" target="_blank" rel="noopener">{{ feed.url }}</a></dd>
<dt>Descripción:</dt><dd>{{ feed.descripcion or 'N/A' }}</dd>
<dt>Idioma:</dt><dd>{{ feed.idioma or 'N/D' }}</dd>
<dt>Estado:</dt><dd>
{% if feed.activo %}
<span style="color: #27ae60; font-weight:bold;">Activo</span>
{% else %}
<span style="color: #c0392b; font-weight: bold;">Inactivo</span>
{% endif %}
</dd>
<dt>Fallos:</dt><dd>{{ feed.fallos }}</dd>
</dl>
</div>
</div>
{% endfor %}
{% if not feeds %}
<div class="card" style="text-align:center;">
<p>No hay feeds para mostrar.</p>
</div>
{% endif %}
{% endblock %}

View file

@ -1,13 +0,0 @@
import sys
import os
import logging
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from app import app, fetch_and_store
except ImportError as e:
logging.basicConfig()
logging.critical(f"No se pudo importar la aplicación Flask. Error: {e}")
sys.exit(1)
if __name__ == "__main__":
with app.app_context():
fetch_and_store()