feat: Versión final estable con nuevo diseño

Aplicación completamente funcional con servidor Waitress. Solucionados todos los problemas de arranque y recolección de noticias. Se ha implementado un nuevo diseño visual y el script de instalación está finalizado.
This commit is contained in:
jlimolina 2025-06-08 23:07:37 +00:00
parent e355003f65
commit 758da0ad4c
3 changed files with 104 additions and 159 deletions

159
app.py
View file

@ -1,69 +1,43 @@
# -*- coding: utf-8 -*-
"""Flask RSS aggregator — versión PostgreSQL
(Copyright tuyo 😉, con mejoras de estabilidad, seguridad y gestión de DB)
"""
import os
import sys
from flask import Flask, render_template, request, redirect, url_for, Response, flash
from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime
import feedparser
import hashlib
import re
import psycopg2
import psycopg2.extras
import csv
from io import StringIO
import bleach # Para la seguridad (prevenir XSS)
from datetime import datetime, timedelta
import logging
import atexit
from flask import Flask, render_template, request, redirect, url_for, Response, flash
from apscheduler.schedulers.background import BackgroundScheduler
import psycopg2
import psycopg2.extras
import feedparser
import bleach
# Configuración del logging
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s')
app = Flask(__name__)
# Es necesaria para usar los mensajes flash de forma segura.
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24))
# ---------------------------------------------------------------------------
# Configuración de la base de datos PostgreSQL
# ---------------------------------------------------------------------------
DB_CONFIG = {
"host": "localhost",
"port": 5432,
"dbname": "rss",
"user": "rss",
"password": "x",
}
def get_conn():
"""Devuelve una conexión nueva usando psycopg2 y el diccionario DB_CONFIG."""
return psycopg2.connect(**DB_CONFIG)
DB_CONFIG = {"host": "localhost", "port": 5432, "dbname": "rss", "user": "rss", "password": "x"}
MAX_FALLOS = 5
# ======================================
# Filtro de Plantilla para HTML Seguro
# ======================================
def get_conn():
return psycopg2.connect(**DB_CONFIG)
@app.template_filter('safe_html')
def safe_html(text):
if not text:
return ""
allowed_tags = {'a', 'abbr', 'b', 'strong', 'i', 'em', 'p', 'br', 'img'}
if not text: return ""
allowed_tags = {'a', 'b', 'strong', 'i', 'em', 'p', 'br', 'img'}
allowed_attrs = {'a': ['href', 'title'], 'img': ['src', 'alt']}
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
# ======================================
# Rutas de la Aplicación
# ======================================
@app.route("/")
def home():
noticias, categorias, continentes, paises = [], [], [], []
cat_id = request.args.get("categoria_id")
cont_id = request.args.get("continente_id")
pais_id = request.args.get("pais_id")
cat_id, cont_id, pais_id = request.args.get("categoria_id"), request.args.get("continente_id"), request.args.get("pais_id")
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
@ -71,41 +45,22 @@ def home():
categorias = cursor.fetchall()
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
continentes = cursor.fetchall()
if cont_id:
cursor.execute("SELECT id, nombre, continente_id FROM paises WHERE continente_id = %s ORDER BY nombre", (cont_id,))
else:
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
paises = cursor.fetchall()
sql_params = []
sql_base = """
SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url,
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
"""
conditions = []
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 conditions:
sql_base += " WHERE " + " AND ".join(conditions)
sql_params, conditions = [], []
sql_base = "SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url, 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 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 conditions: sql_base += " WHERE " + " AND ".join(conditions)
sql_final = sql_base + " ORDER BY n.fecha DESC NULLS LAST LIMIT 50"
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")
@ -147,7 +102,7 @@ def add_feed():
with conn.cursor() as cursor:
cursor.execute(
"INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma) VALUES (%s, %s, %s, %s, %s, %s)",
(nombre, request.form.get("descripcion"), request.form.get("url"), request.form.get("categoria_id"), request.form.get("pais_id"), request.form.get("idioma") or None)
(nombre, request.form.get("descripcion"), request.form.get("url"), request.form.get("categoria_id"), request.form.get("pais_id"), (request.form.get("idioma", "").strip() or None))
)
flash(f"Feed '{nombre}' añadido correctamente.", "success")
except psycopg2.Error as db_err:
@ -162,13 +117,15 @@ def edit_feed(feed_id):
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
if request.method == "POST":
idioma = request.form.get("idioma", "").strip() or None
activo = "activo" in request.form
cursor.execute(
"""UPDATE feeds SET nombre=%s, descripcion=%s, url=%s, categoria_id=%s, pais_id=%s, idioma=%s, activo=%s WHERE id=%s""",
(request.form.get("nombre"), request.form.get("descripcion"), request.form.get("url"), request.form.get("categoria_id"), request.form.get("pais_id"), request.form.get("idioma") or None, activo, feed_id)
(request.form.get("nombre"), request.form.get("descripcion"), request.form.get("url"), request.form.get("categoria_id"), request.form.get("pais_id"), idioma, activo, feed_id)
)
flash("Feed actualizado correctamente.", "success")
return redirect(url_for("feeds"))
cursor.execute("SELECT * FROM feeds WHERE id = %s", (feed_id,))
feed = cursor.fetchone()
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
@ -179,9 +136,11 @@ def edit_feed(feed_id):
app.logger.error(f"[DB ERROR] Al editar feed: {db_err}", exc_info=True)
flash(f"Error al editar el feed: {db_err}", "error")
return redirect(url_for("feeds"))
if not feed:
flash("No se encontró el feed solicitado.", "error")
return redirect(url_for("feeds"))
return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises)
@app.route("/delete/<int:feed_id>")
@ -208,12 +167,8 @@ def reactivar_feed(feed_id):
flash(f"Error al reactivar el feed: {db_err}", "error")
return redirect(url_for("feeds"))
# ===================================================================
# [INICIO DEL CÓDIGO AÑADIDO] Backup y Restauración de Feeds
# ===================================================================
@app.route("/backup_feeds")
def backup_feeds():
"""Exporta todos los feeds a un archivo CSV."""
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
@ -238,11 +193,7 @@ def backup_feeds():
output = si.getvalue()
si.close()
return Response(
output,
mimetype="text/csv",
headers={"Content-Disposition": "attachment;filename=feeds_backup.csv"},
)
return Response(output, mimetype="text/csv", headers={"Content-Disposition": "attachment;filename=feeds_backup.csv"})
except Exception as e:
app.logger.error(f"[ERROR] Al hacer backup de feeds: {e}", exc_info=True)
flash("Error al generar el backup.", "error")
@ -250,13 +201,11 @@ def backup_feeds():
@app.route("/restore_feeds", methods=["GET", "POST"])
def restore_feeds():
"""Muestra el formulario de restauración y procesa el archivo CSV subido."""
if request.method == "POST":
file = request.files.get("file")
if not file or not file.filename.endswith(".csv"):
flash("Archivo no válido. Por favor, sube un archivo .csv.", "error")
return redirect(url_for("restore_feeds"))
try:
file_stream = StringIO(file.read().decode("utf-8"))
reader = csv.DictReader(file_stream)
@ -289,23 +238,14 @@ def restore_feeds():
except Exception as e:
n_err += 1
app.logger.error(f"Error procesando fila del CSV: {row} - Error: {e}")
flash(f"Restauración completada. Feeds procesados: {n_ok}. Errores: {n_err}.", "success" if n_err == 0 else "warning")
except Exception as e:
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("feeds"))
return render_template("restore_feeds.html")
# ===================================================================
# [FIN DEL CÓDIGO AÑADIDO]
# ===================================================================
# ================================
# Lógica de procesado de feeds
# ================================
def sumar_fallo_feed(cursor, feed_id):
cursor.execute("UPDATE feeds SET fallos = fallos + 1 WHERE id = %s RETURNING fallos", (feed_id,))
fallos = cursor.fetchone()[0]
@ -324,12 +264,15 @@ def fetch_and_store():
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
cursor.execute("SELECT id, url, categoria_id, pais_id FROM feeds WHERE activo = TRUE")
feeds_to_process = cursor.fetchall()
if not feeds_to_process:
app.logger.info("No hay feeds activos para procesar.")
return
for feed in feeds_to_process:
try:
app.logger.info(f"Procesando feed: {feed['url']}")
parsed = feedparser.parse(feed['url'])
if getattr(parsed, "bozo", False):
app.logger.warning(f"[BOZO] Feed mal formado: {feed['url']}")
app.logger.warning(f"[BOZO] Feed mal formado: {feed['url']} - Excepción: {parsed.bozo_exception}")
sumar_fallo_feed(cursor, feed['id'])
continue
resetear_fallos_feed(cursor, feed['id'])
@ -345,35 +288,29 @@ def fetch_and_store():
imagen_url = entry.media_content[0].get("url", "")
elif "<img" in resumen:
img_search = re.search(r'src="([^"]+)"', resumen)
if img_search:
imagen_url = img_search.group(1)
if img_search: imagen_url = img_search.group(1)
fecha_publicacion = None
if "published_parsed" in entry:
fecha_publicacion = datetime(*entry.published_parsed[:6])
elif "updated_parsed" in entry:
fecha_publicacion = datetime(*entry.updated_parsed[:6])
if "published_parsed" in entry and entry.published_parsed: fecha_publicacion = datetime(*entry.published_parsed[:6])
elif "updated_parsed" in entry and entry.updated_parsed: fecha_publicacion = datetime(*entry.updated_parsed[:6])
cursor.execute(
"INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, categoria_id, pais_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING",
(noticia_id, titulo, resumen, link, fecha_publicacion, imagen_url, feed['categoria_id'], feed['pais_id'])
)
except Exception as entry_err:
app.logger.error(f"Error procesando entrada de feed {feed['url']}: {entry_err}", exc_info=True)
app.logger.error(f"Error en entrada de feed {feed['url']}: {entry_err}")
except Exception as e:
app.logger.error(f"[PARSE ERROR] En feed {feed['url']}: {e}", exc_info=True)
app.logger.error(f"[PARSE ERROR] En feed {feed['url']}: {e}")
sumar_fallo_feed(cursor, feed['id'])
app.logger.info(f"Ciclo de feeds completado.")
app.logger.info("Ciclo de feeds completado.")
except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Fallo en el ciclo de actualización de feeds: {db_err}", exc_info=True)
app.logger.error(f"[DB ERROR] Fallo en ciclo de actualización: {db_err}")
scheduler = BackgroundScheduler(daemon=True)
run_time = datetime.now() + timedelta(seconds=20)
scheduler.add_job(fetch_and_store, "interval", minutes=15, id="rss_job", next_run_time=run_time)
scheduler.start()
atexit.register(lambda: scheduler.shutdown())
app.logger.info("Scheduler configurado. Primera ejecución en 20 segundos.")
# ---------------------------------------------------------------------------
# Lanzador de la aplicación + scheduler
# ---------------------------------------------------------------------------
if __name__ == "__main__":
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
scheduler = BackgroundScheduler(daemon=True)
scheduler.add_job(fetch_and_store, "interval", minutes=2, id="rss_job", misfire_grace_time=60)
scheduler.start()
app.logger.info("Scheduler iniciado correctamente.")
import atexit
atexit.register(lambda: scheduler.shutdown())
app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=True)
app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False)

14
n
View file

@ -1,14 +0,0 @@
fecha | titulo | url
---------------------+-----------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------
2025-06-08 21:55:11 | Deportees Lawyers Push for Contempt Proceedings Despite His Return | https://www.nytimes.com/2025/06/08/us/politics/abrego-garcia-justice-department-contempt-proceedings.html
2025-06-08 21:51:57 | Степашин рассказал о стратегической ошибке в отношении Украины в 1990-х годах | https://rg.ru/2025/06/09/stepashin-rasskazal-o-strategicheskoj-oshibke-v-otnoshenii-ukrainy-v-1990-h-godah.html
2025-06-08 21:50:46 | Lavrov, Rubio in touch, keen to maintain ties — MFA | https://tass.com/politics/1970525
2025-06-08 21:45:00 | Минобороны РФ сообщило об уничтожении 24 украинских дронов | https://www.interfax.ru/russia/1030308
2025-06-08 21:39:46 | Cuts to UKs global vaccination funding would risk avoidable child deaths, experts warn | https://www.theguardian.com/society/2025/jun/08/global-vaccination-funding-cuts-threaten-uk-soft-power-and-pandemic-resilience
2025-06-08 21:32:32 | Крупный пожар в Подмосковье остановил движение на двух шоссе | https://rg.ru/2025/06/09/reg-cfo/dymovaia-zavesa.html
2025-06-08 21:31:06 | Zelensky declines 6,000 bodies to conceal army losses — envoy | https://tass.com/politics/1970521
2025-06-08 21:26:37 | Котяков и Ракова посетили геронтопсихологический центр в Москве | https://rg.ru/2025/06/09/reg-cfo/dom-s-teplom.html
2025-06-08 21:25:19 | Рогов: В оккупированном ВСУ городе Запорожье слышны взрывы и стрельба | https://rg.ru/2025/06/09/reg-zaporozhskaya/rogov-v-okkupirovannom-vsu-gorode-zaporozhe-slyshny-vzryvy-i-strelba.html
2025-06-08 21:23:00 | РФ готова поставлять Индонезии подлодки, корабли, истребители и системы ПВО | https://www.interfax.ru/world/1030307
(10 filas)

86
templates/edit_feed.html Executable file → Normal file
View file

@ -1,42 +1,64 @@
{% extends "base.html" %}
{% block title %}Editar Feed RSS{% endblock %}
{% block content %}
<h1>Editar Feed</h1>
<div class="card">
<form method="post" autocomplete="off">
<label for="nombre">Nombre del feed</label>
<input id="nombre" name="nombre" type="text" placeholder="Nombre" value="{{ feed['nombre'] }}" required>
<header>
<h1>Editar Feed</h1>
<p class="subtitle">Modifica los detalles de tu fuente de noticias: <strong>{{ feed.nombre }}</strong></p>
</header>
<label for="descripcion">Descripción</label>
<textarea id="descripcion" name="descripcion" rows="2" placeholder="Breve descripción del feed">{{ feed['descripcion'] or '' }}</textarea>
<div class="form-section">
<form method="post" autocomplete="off">
<div>
<label for="nombre">Nombre del feed</label>
<input id="nombre" name="nombre" type="text" value="{{ feed.nombre }}" required>
</div>
<label for="url">URL del RSS</label>
<input id="url" name="url" type="url" placeholder="URL" value="{{ feed['url'] }}" required>
<div style="margin-top:15px;">
<label for="descripcion">Descripción</label>
<textarea id="descripcion" name="descripcion" rows="2">{{ feed.descripcion or '' }}</textarea>
</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 }}" {% if cat.id == feed['categoria_id'] %}selected{% endif %}>{{ cat.nombre }}</option>
{% endfor %}
</select>
<div style="margin-top:15px;">
<label for="url">URL del RSS</label>
<input id="url" name="url" type="url" value="{{ feed.url }}" required>
</div>
<label for="pais_id">País</label>
<select id="pais_id" name="pais_id" required>
<option value="">— Elige país —</option>
{% for p in paises %}
<option value="{{ p.id }}" {% if p.id == feed['pais_id'] %}selected{% endif %}>{{ p.nombre }}</option>
{% endfor %}
</select>
<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" required>
<option value="">— Elige categoría —</option>
{% for cat in categorias %}
<option value="{{ cat.id }}" {% if cat.id == feed.categoria_id %}selected{% endif %}>{{ cat.nombre }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="pais_id">País</label>
<select id="pais_id" name="pais_id">
<option value="">— Global / No aplica —</option>
{% for p in paises %}
<option value="{{ p.id }}" {% if p.id == feed.pais_id %}selected{% endif %}>{{ p.nombre }}</option>
{% endfor %}
</select>
</div>
</div>
<div style="margin: 12px 0;">
<input type="checkbox" id="activo" name="activo" {% if feed['activo'] %}checked{% endif %}>
<label for="activo" style="display:inline;">Activo</label>
</div>
<div style="margin-top:15px;">
<label for="idioma">Idioma (código de 2 letras)</label>
<input id="idioma" name="idioma" type="text" value="{{ feed.idioma or '' }}" maxlength="2" placeholder="ej: es, en, fr">
</div>
<button class="btn" type="submit">Guardar cambios</button>
<a href="{{ url_for('feeds') }}">Cancelar</a>
</form>
</div>
<div style="margin: 20px 0;">
<input type="checkbox" id="activo" name="activo" {% if feed.activo %}checked{% endif %} style="width: auto; vertical-align: middle; margin-right: 5px;">
<label for="activo" style="display:inline; font-weight:normal;">Feed activo</label>
</div>
<button class="btn" type="submit">Guardar Cambios</button>
<a href="{{ url_for('feeds') }}" style="margin-left: 15px;">Cancelar</a>
</form>
</div>
<a href="{{ url_for('feeds') }}" class="top-link">← Volver a la gestión de feeds</a>
{% endblock %}