Actualización del 2025-06-18 a las 17:42:26

This commit is contained in:
jlimolina 2025-06-18 17:42:26 +02:00
parent 78c01fd61b
commit e7c3433f0d
2 changed files with 2 additions and 78 deletions

77
app.py
View file

@ -4,7 +4,7 @@ import hashlib
import csv import csv
import math import math
from io import StringIO, BytesIO from io import StringIO, BytesIO
from datetime import datetime, timedelta from datetime import datetime
import logging import logging
import atexit import atexit
import zipfile import zipfile
@ -91,10 +91,8 @@ def home():
continentes = cursor.fetchall() continentes = cursor.fetchall()
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre") cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
paises = cursor.fetchall() paises = cursor.fetchall()
sql_params, conditions = [], [] 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" 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: if q:
search_query = " & ".join(q.split()) search_query = " & ".join(q.split())
conditions.append("n.tsv @@ to_tsquery('spanish', %s)") conditions.append("n.tsv @@ to_tsquery('spanish', %s)")
@ -109,26 +107,20 @@ def home():
sql_params.append(fecha_obj.date()) sql_params.append(fecha_obj.date())
except ValueError: except ValueError:
flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error") 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" order_clause = " ORDER BY n.fecha DESC NULLS LAST"
if q: if q:
search_query_ts = " & ".join(q.split()) search_query_ts = " & ".join(q.split())
order_clause = " ORDER BY ts_rank(n.tsv, to_tsquery('spanish', %s)) DESC, n.fecha DESC" order_clause = " ORDER BY ts_rank(n.tsv, to_tsquery('spanish', %s)) DESC, n.fecha DESC"
sql_params.append(search_query_ts) sql_params.append(search_query_ts)
sql_final = sql_base + order_clause + " LIMIT 50" sql_final = sql_base + order_clause + " LIMIT 50"
cursor.execute(sql_final, tuple(sql_params)) cursor.execute(sql_final, tuple(sql_params))
noticias = cursor.fetchall() noticias = cursor.fetchall()
except psycopg2.Error as db_err: except psycopg2.Error as db_err:
app.logger.error(f"[DB ERROR] Al leer noticias: {db_err}", exc_info=True) 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") flash("Error de base de datos al cargar las noticias.", "error")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return render_template('_noticias_list.html', noticias=noticias) return render_template('_noticias_list.html', noticias=noticias)
return render_template("noticias.html", return render_template("noticias.html",
noticias=noticias, categorias=categorias, continentes=continentes, paises=paises, 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, cat_id=int(cat_id) if cat_id else None, cont_id=int(cont_id) if cont_id else None,
@ -151,9 +143,6 @@ def dashboard():
flash("Error al conectar con la base de datos.", "error") flash("Error al conectar con la base de datos.", "error")
return render_template("dashboard.html", stats=stats) return render_template("dashboard.html", stats=stats)
# --- GESTIÓN DE FEEDS ---
@app.route("/feeds/manage") @app.route("/feeds/manage")
def manage_feeds(): def manage_feeds():
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
@ -191,7 +180,6 @@ def add_feed():
app.logger.error(f"[DB ERROR] Al agregar feed: {db_err}", exc_info=True) 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") flash(f"Error al añadir el feed: {db_err}", "error")
return redirect(url_for("manage_feeds")) return redirect(url_for("manage_feeds"))
categorias, paises = [], [] categorias, paises = [], []
try: try:
with get_conn() as conn: with get_conn() as conn:
@ -221,7 +209,6 @@ def edit_feed(feed_id):
app.logger.error(f"[DB ERROR] Al actualizar feed: {db_err}", exc_info=True) app.logger.error(f"[DB ERROR] Al actualizar feed: {db_err}", exc_info=True)
flash(f"Error al actualizar el feed: {db_err}", "error") flash(f"Error al actualizar el feed: {db_err}", "error")
return redirect(url_for("manage_feeds")) return redirect(url_for("manage_feeds"))
feed, categorias, paises = None, [], [] feed, categorias, paises = None, [], []
try: try:
with get_conn() as conn: with get_conn() as conn:
@ -260,9 +247,6 @@ def reactivar_feed(feed_id):
flash(f"Error al reactivar feed: {db_err}", "error") flash(f"Error al reactivar feed: {db_err}", "error")
return redirect(url_for("manage_feeds")) return redirect(url_for("manage_feeds"))
# --- GESTIÓN DE FUENTES URL ---
@app.route("/urls/manage") @app.route("/urls/manage")
def manage_urls(): def manage_urls():
fuentes = [] fuentes = []
@ -295,7 +279,6 @@ def add_url_source():
app.logger.error(f"[DB ERROR] Al agregar fuente URL: {db_err}", exc_info=True) 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") flash(f"Error al añadir la fuente URL: {db_err}", "error")
return redirect(url_for("manage_urls")) return redirect(url_for("manage_urls"))
categorias, paises = [], [] categorias, paises = [], []
try: try:
with get_conn() as conn: with get_conn() as conn:
@ -324,7 +307,6 @@ def edit_url_source(url_id):
app.logger.error(f"[DB ERROR] Al actualizar fuente URL: {db_err}", exc_info=True) 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") flash(f"Error al actualizar la fuente URL: {db_err}", "error")
return redirect(url_for("manage_urls")) return redirect(url_for("manage_urls"))
fuente, categorias, paises = None, [], [] fuente, categorias, paises = None, [], []
try: try:
with get_conn() as conn: with get_conn() as conn:
@ -351,23 +333,13 @@ def delete_url_source(url_id):
flash(f"Error al eliminar la fuente URL: {db_err}", "error") flash(f"Error al eliminar la fuente URL: {db_err}", "error")
return redirect(url_for("manage_urls")) return redirect(url_for("manage_urls"))
# --- TAREA DE FONDO (CORREGIDA Y REFACTORIZADA) ---
def fetch_and_store_all(): def fetch_and_store_all():
"""
Tarea de fondo única y cohesiva que recolecta noticias tanto de Feeds RSS como de Fuentes URL,
y luego actualiza la base de datos en una sola transacción.
"""
with app.app_context(): with app.app_context():
logging.info("--- INICIANDO CICLO DE CAPTURA GLOBAL (RSS y URL) ---") logging.info("--- INICIANDO CICLO DE CAPTURA GLOBAL (RSS y URL) ---")
todas_las_noticias = [] todas_las_noticias = []
feeds_fallidos = [] feeds_fallidos = []
feeds_exitosos = [] feeds_exitosos = []
feeds_para_actualizar_headers = [] feeds_para_actualizar_headers = []
# --- 1. PROCESAR FEEDS RSS ---
logging.info("=> Parte 1: Procesando Feeds RSS...") logging.info("=> Parte 1: Procesando Feeds RSS...")
feeds_to_process = [] feeds_to_process = []
try: try:
@ -379,7 +351,6 @@ def fetch_and_store_all():
except psycopg2.Error as db_err: except psycopg2.Error as db_err:
logging.error(f"Error de BD al obtener feeds RSS: {db_err}") logging.error(f"Error de BD al obtener feeds RSS: {db_err}")
return return
if feeds_to_process: if feeds_to_process:
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: 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} future_to_feed = {executor.submit(process_single_feed, dict(feed)): feed for feed in feeds_to_process}
@ -400,11 +371,8 @@ def fetch_and_store_all():
except Exception as exc: except Exception as exc:
logging.error(f"Excepción en feed {original_feed_data['url']} (ID: {feed_id}): {exc}") logging.error(f"Excepción en feed {original_feed_data['url']} (ID: {feed_id}): {exc}")
feeds_fallidos.append(feed_id) feeds_fallidos.append(feed_id)
noticias_desde_rss_count = len(todas_las_noticias) 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)}.") logging.info(f"=> Parte 1 Finalizada. Noticias desde RSS: {noticias_desde_rss_count}. Éxitos: {len(feeds_exitosos)}. Fallos: {len(feeds_fallidos)}.")
# --- 2. PROCESAR FUENTES URL ---
logging.info("=> Parte 2: Procesando Fuentes URL...") logging.info("=> Parte 2: Procesando Fuentes URL...")
urls_to_process = [] urls_to_process = []
try: try:
@ -415,7 +383,6 @@ def fetch_and_store_all():
logging.info(f"Encontradas {len(urls_to_process)} fuentes URL para scrapear.") logging.info(f"Encontradas {len(urls_to_process)} fuentes URL para scrapear.")
except Exception as e: except Exception as e:
logging.error(f"Error de BD al obtener fuentes URL: {e}") logging.error(f"Error de BD al obtener fuentes URL: {e}")
if urls_to_process: if urls_to_process:
for source in tqdm(urls_to_process, desc="Procesando Fuentes URL"): for source in tqdm(urls_to_process, desc="Procesando Fuentes URL"):
try: try:
@ -427,17 +394,13 @@ def fetch_and_store_all():
todas_las_noticias.extend(noticias_encontradas) todas_las_noticias.extend(noticias_encontradas)
except Exception as e: except Exception as e:
logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {e}") logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {e}")
noticias_desde_urls_count = len(todas_las_noticias) - noticias_desde_rss_count 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}.") logging.info(f"=> Parte 2 Finalizada. Noticias encontradas desde URLs: {noticias_desde_urls_count}.")
# --- 3. ACTUALIZAR BD ---
logging.info("=> 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]): 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("No se encontraron nuevas noticias ni cambios en los feeds. Nada que actualizar.")
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---") logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
return return
try: try:
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
@ -448,7 +411,6 @@ def fetch_and_store_all():
if feeds_exitosos: if feeds_exitosos:
cursor.execute("UPDATE feeds SET fallos = 0 WHERE id IN %s", (tuple(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.") logging.info(f"Reseteado contador de fallos para {len(feeds_exitosos)} feeds.")
if feeds_para_actualizar_headers: if feeds_para_actualizar_headers:
psycopg2.extras.execute_values( psycopg2.extras.execute_values(
cursor, cursor,
@ -456,7 +418,6 @@ def fetch_and_store_all():
[(f['id'], f['etag'], f['modified']) for f in feeds_para_actualizar_headers] [(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.") logging.info(f"Actualizados headers para {len(feeds_para_actualizar_headers)} feeds.")
if todas_las_noticias: if todas_las_noticias:
logging.info(f"Intentando insertar/ignorar {len(todas_las_noticias)} noticias en total.") logging.info(f"Intentando insertar/ignorar {len(todas_las_noticias)} noticias en total.")
insert_query = """ insert_query = """
@ -466,16 +427,11 @@ def fetch_and_store_all():
""" """
psycopg2.extras.execute_values(cursor, insert_query, todas_las_noticias, page_size=200) 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(f"Inserción de noticias finalizada. {cursor.rowcount} filas podrían haber sido afectadas.")
logging.info("=> Parte 3 Finalizada. Base de datos actualizada correctamente.") logging.info("=> Parte 3 Finalizada. Base de datos actualizada correctamente.")
except Exception as e: except Exception as e:
logging.error(f"Error de BD en la actualización masiva final: {e}", exc_info=True) logging.error(f"Error de BD en la actualización masiva final: {e}", exc_info=True)
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---") logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
# --- SECCIÓN DE BACKUPS Y RESTAURACIÓN ---
@app.route("/backup_feeds") @app.route("/backup_feeds")
def backup_feeds(): def backup_feeds():
try: try:
@ -486,7 +442,6 @@ def backup_feeds():
if not feeds_: if not feeds_:
flash("No hay feeds para exportar.", "warning") flash("No hay feeds para exportar.", "warning")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
fieldnames = list(feeds_[0].keys()) fieldnames = list(feeds_[0].keys())
output = StringIO() output = StringIO()
writer = csv.DictWriter(output, fieldnames=fieldnames) writer = csv.DictWriter(output, fieldnames=fieldnames)
@ -511,17 +466,14 @@ def backup_urls():
ORDER BY f.id ORDER BY f.id
""") """)
fuentes = cursor.fetchall() fuentes = cursor.fetchall()
if not fuentes: if not fuentes:
flash("No hay fuentes URL para exportar.", "warning") flash("No hay fuentes URL para exportar.", "warning")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
fieldnames = list(fuentes[0].keys()) fieldnames = list(fuentes[0].keys())
output = StringIO() output = StringIO()
writer = csv.DictWriter(output, fieldnames=fieldnames) writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader() writer.writeheader()
writer.writerows([dict(fuente) for fuente in fuentes]) writer.writerows([dict(fuente) for fuente in fuentes])
return Response( return Response(
output.getvalue(), output.getvalue(),
mimetype="text/csv", mimetype="text/csv",
@ -542,7 +494,6 @@ def backup_noticias():
if not noticias: if not noticias:
flash("No hay noticias para exportar.", "warning") flash("No hay noticias para exportar.", "warning")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
fieldnames_noticias = list(noticias[0].keys()) fieldnames_noticias = list(noticias[0].keys())
output = StringIO() output = StringIO()
writer = csv.DictWriter(output, fieldnames=fieldnames_noticias) writer = csv.DictWriter(output, fieldnames=fieldnames_noticias)
@ -569,7 +520,6 @@ def backup_completo():
writer_feeds.writeheader() writer_feeds.writeheader()
writer_feeds.writerows([dict(f) for f in feeds_data]) writer_feeds.writerows([dict(f) for f in feeds_data])
zipf.writestr("feeds.csv", output_feeds.getvalue()) zipf.writestr("feeds.csv", output_feeds.getvalue())
cursor.execute("SELECT * FROM fuentes_url ORDER BY id") cursor.execute("SELECT * FROM fuentes_url ORDER BY id")
fuentes_data = cursor.fetchall() fuentes_data = cursor.fetchall()
if fuentes_data: if fuentes_data:
@ -578,7 +528,6 @@ def backup_completo():
writer_fuentes.writeheader() writer_fuentes.writeheader()
writer_fuentes.writerows([dict(f) for f in fuentes_data]) writer_fuentes.writerows([dict(f) for f in fuentes_data])
zipf.writestr("fuentes_url.csv", output_fuentes.getvalue()) zipf.writestr("fuentes_url.csv", output_fuentes.getvalue())
cursor.execute("SELECT * FROM noticias ORDER BY fecha DESC") cursor.execute("SELECT * FROM noticias ORDER BY fecha DESC")
noticias_data = cursor.fetchall() noticias_data = cursor.fetchall()
if noticias_data: if noticias_data:
@ -587,7 +536,6 @@ def backup_completo():
writer_noticias.writeheader() writer_noticias.writeheader()
writer_noticias.writerows([dict(n) for n in noticias_data]) writer_noticias.writerows([dict(n) for n in noticias_data])
zipf.writestr("noticias.csv", output_noticias.getvalue()) zipf.writestr("noticias.csv", output_noticias.getvalue())
memory_buffer.seek(0) memory_buffer.seek(0)
return Response(memory_buffer, mimetype="application/zip", headers={"Content-Disposition": "attachment;filename=rss_backup_completo.zip"}) return Response(memory_buffer, mimetype="application/zip", headers={"Content-Disposition": "attachment;filename=rss_backup_completo.zip"})
except Exception as e: except Exception as e:
@ -649,7 +597,6 @@ def restore_urls():
if not file or not file.filename.endswith(".csv"): if not file or not file.filename.endswith(".csv"):
flash("Archivo no válido. Sube un .csv.", "error") flash("Archivo no válido. Sube un .csv.", "error")
return redirect(url_for("restore_urls")) return redirect(url_for("restore_urls"))
try: try:
file_stream = StringIO(file.read().decode("utf-8", errors='ignore')) file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
reader = csv.DictReader(file_stream) reader = csv.DictReader(file_stream)
@ -685,37 +632,15 @@ def restore_urls():
cursor.execute("ROLLBACK TO SAVEPOINT restore_url_row") cursor.execute("ROLLBACK TO SAVEPOINT restore_url_row")
n_err += 1 n_err += 1
app.logger.error(f"Error procesando fila de fuente URL (se omite): {row} - Error: {e}") app.logger.error(f"Error procesando fila de fuente URL (se omite): {row} - Error: {e}")
flash(f"Restauración de Fuentes URL completada. Procesadas: {n_ok}. Errores: {n_err}.", "success" if n_err == 0 else "warning") flash(f"Restauración de Fuentes URL completada. Procesadas: {n_ok}. Errores: {n_err}.", "success" if n_err == 0 else "warning")
except Exception as e: except Exception as e:
app.logger.error(f"Error al restaurar fuentes URL desde CSV: {e}", exc_info=True) 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") flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
return render_template("restore_urls.html") return render_template("restore_urls.html")
# --- RUTA DE UTILIDAD PARA PRUEBAS ---
# MOVIDA FUERA DEL BLOQUE if __name__ == '__main__' PARA QUE GUNICORN LA RECONOZCA
@app.route("/run-fetch")
def run_fetch_now():
"""Ejecuta la tarea de recolección manualmente para pruebas."""
try:
# Idealmente, esto debería correr en un hilo separado para no bloquear la respuesta
# pero para una ejecución manual simple, está bien así.
fetch_and_store_all()
flash("Tarea de fondo de recolección ejecutada manualmente.", "info")
except Exception as e:
flash(f"Error al ejecutar la tarea de fondo: {e}", "error")
app.logger.error(f"Error en la ejecución manual de la tarea de fondo: {e}", exc_info=True)
return redirect(url_for('dashboard'))
if __name__ == "__main__": if __name__ == "__main__":
if not db_pool: if not db_pool:
app.logger.error("La aplicación no puede arrancar sin una conexión a la base de datos.") app.logger.error("La aplicación no puede arrancar sin una conexión a la base de datos.")
sys.exit(1) sys.exit(1)
# El app.run solo se usa para el desarrollo local. Gunicorn no ejecuta esta parte.
app.run(host="0.0.0.0", port=8000, debug=True) app.run(host="0.0.0.0", port=8000, debug=True)

View file

@ -51,9 +51,8 @@
<h3>Operaciones del Sistema</h3> <h3>Operaciones del Sistema</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<p>Genera una copia de seguridad completa o ejecuta la tarea de recolección manualmente para pruebas.</p> <p>Genera una copia de seguridad completa de todas tus fuentes y noticias en un archivo .zip.</p>
<a href="{{ url_for('backup_completo') }}" class="btn btn-secondary"><i class="fas fa-archive"></i> Backup Completo (.zip)</a> <a href="{{ url_for('backup_completo') }}" class="btn btn-secondary"><i class="fas fa-archive"></i> Backup Completo (.zip)</a>
<a href="{{ url_for('run_fetch_now') }}" class="btn btn-danger" onclick="return confirm('Esto puede tardar un momento. ¿Estás seguro?')"><i class="fas fa-cogs"></i> Ejecutar Recolección Manual</a>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}