From b26e9ad87f535385e82a2397a8fc625f52884550 Mon Sep 17 00:00:00 2001 From: jlimolina Date: Sat, 16 Aug 2025 13:12:01 +0200 Subject: [PATCH] =?UTF-8?q?Actualizaci=C3=B3n=20del=202025-08-16=20a=20las?= =?UTF-8?q?=2013:12:01?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 13 ++ .env | 7 + Dockerfile | 28 +++ actualizar_repo.sh | 23 ++- app.py | 365 ++++++++++++++++++++++++------------- docker-compose.yml | 65 +++++++ init-db/01.schema.sql | 24 +++ init-db/02-continentes.sql | 9 + init-db/03-categorias.sql | 18 ++ init-db/04-paises.sql | 198 ++++++++++++++++++++ install.sh | 74 ++++---- templates/feeds_list.html | 95 ++++++---- worker.py | 13 -- 13 files changed, 710 insertions(+), 222 deletions(-) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 init-db/01.schema.sql create mode 100755 init-db/02-continentes.sql create mode 100755 init-db/03-categorias.sql create mode 100755 init-db/04-paises.sql delete mode 100644 worker.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..53d54ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Entorno virtual +venv/ +.venv/ + +# Cache de Python +__pycache__/ +*.pyc +*.pyo + +# Ficheros de IDE y OS +.idea/ +.vscode/ +.DS_Store diff --git a/.env b/.env new file mode 100644 index 0000000..45be2f1 --- /dev/null +++ b/.env @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..afa2a5d --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/actualizar_repo.sh b/actualizar_repo.sh index 447376f..b916498 100755 --- a/actualizar_repo.sh +++ b/actualizar_repo.sh @@ -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'" -git commit -m "$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!" - diff --git a/app.py b/app.py index a0c51d7..9dee10d 100644 --- a/app.py +++ b/app.py @@ -77,50 +77,100 @@ def _get_form_dependencies(cursor): paises = cursor.fetchall() return categorias, paises +## 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 fecha_filtro: + try: + fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d') + conditions.append("n.fecha::date = %s") + 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) + + 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_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(): - 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 = [], [], [], [] + + # 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() - 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" - 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 fecha_filtro: - try: - fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d') - conditions.append("n.fecha::date = %s") - 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) - order_clause = " ORDER BY n.fecha DESC NULLS LAST" - if q: - search_query_ts = " & ".join(q.split()) - 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" + + # 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,114 +402,146 @@ 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 = [] - logging.info("=> Parte 1: Procesando Feeds RSS...") - feeds_to_process = [] - try: - with get_conn() as conn: - with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: - cursor.execute("SELECT id, nombre, url, categoria_id, pais_id, last_etag, last_modified FROM feeds WHERE activo = TRUE") - feeds_to_process = cursor.fetchall() - logging.info(f"Encontrados {len(feeds_to_process)} feeds RSS activos para procesar.") - 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} - for future in tqdm(as_completed(future_to_feed), total=len(feeds_to_process), desc="Procesando Feeds RSS"): - original_feed_data = future_to_feed[future] - feed_id = original_feed_data['id'] - try: - _, noticias_encontradas, new_etag, new_modified, success = future.result(timeout=SINGLE_FEED_TIMEOUT) - if success: - feeds_exitosos.append(feed_id) - if noticias_encontradas: - todas_las_noticias.extend(noticias_encontradas) - if (new_etag and new_etag != original_feed_data.get('last_etag')) or \ - (new_modified and new_modified != original_feed_data.get('last_modified')): - feeds_para_actualizar_headers.append({'id': feed_id, 'etag': new_etag, 'modified': new_modified}) - else: - feeds_fallidos.append(feed_id) - 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)}.") - logging.info("=> Parte 2: Procesando Fuentes URL...") - urls_to_process = [] - try: - with get_conn() as conn: - with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: - cursor.execute("SELECT * FROM fuentes_url") - urls_to_process = cursor.fetchall() - 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}") - if urls_to_process: - for source in tqdm(urls_to_process, desc="Procesando Fuentes URL"): + 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: + with get_conn() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: + cursor.execute("SELECT id, nombre, url, categoria_id, pais_id, last_etag, last_modified FROM feeds WHERE activo = TRUE") + feeds_to_process = cursor.fetchall() + logging.info(f"Encontrados {len(feeds_to_process)} feeds RSS activos para procesar.") + 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} + for future in tqdm(as_completed(future_to_feed), total=len(feeds_to_process), desc="Procesando Feeds RSS"): + original_feed_data = future_to_feed[future] + feed_id = original_feed_data['id'] try: - noticias_encontradas, _ = process_newspaper_url( - source['nombre'], source['url'], source['categoria_id'], - source['pais_id'], source['idioma'] - ) + _, noticias_encontradas, new_etag, new_modified, success = future.result(timeout=SINGLE_FEED_TIMEOUT) + if success: + feeds_exitosos.append(feed_id) + if noticias_encontradas: + todas_las_noticias.extend(noticias_encontradas) + if (new_etag and new_etag != original_feed_data.get('last_etag')) or \ + (new_modified and new_modified != original_feed_data.get('last_modified')): + feeds_para_actualizar_headers.append({'id': feed_id, 'etag': new_etag, 'modified': new_modified}) + else: + feeds_fallidos.append(feed_id) + 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: + with get_conn() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: + cursor.execute("SELECT * FROM fuentes_url") + urls_to_process = cursor.fetchall() + 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: + 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}") - 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("=> 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: - if feeds_fallidos: - 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, - "UPDATE feeds SET last_etag = data.etag, last_modified = data.modified FROM (VALUES %s) AS data(id, etag, modified) WHERE feeds.id = data.id", - [(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 = """ - INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, fuente_nombre, categoria_id, pais_id) - VALUES %s - ON CONFLICT (url) DO NOTHING; - """ - 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) + 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: + if feeds_fallidos: + 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, + "UPDATE feeds SET last_etag = data.etag, last_modified = data.modified FROM (VALUES %s) AS data(id, etag, modified) WHERE feeds.id = data.id", + [(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 = """ + INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, fuente_nombre, categoria_id, pais_id) + VALUES %s + ON CONFLICT (url) DO NOTHING; + """ + 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.") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..42698d0 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/init-db/01.schema.sql b/init-db/01.schema.sql new file mode 100644 index 0000000..c500711 --- /dev/null +++ b/init-db/01.schema.sql @@ -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); diff --git a/init-db/02-continentes.sql b/init-db/02-continentes.sql new file mode 100755 index 0000000..0efec4f --- /dev/null +++ b/init-db/02-continentes.sql @@ -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; + diff --git a/init-db/03-categorias.sql b/init-db/03-categorias.sql new file mode 100755 index 0000000..1873ea7 --- /dev/null +++ b/init-db/03-categorias.sql @@ -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; + diff --git a/init-db/04-paises.sql b/init-db/04-paises.sql new file mode 100755 index 0000000..4d8eeda --- /dev/null +++ b/init-db/04-paises.sql @@ -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; + diff --git a/install.sh b/install.sh index c5730d9..00e3af1 100644 --- a/install.sh +++ b/install.sh @@ -13,8 +13,8 @@ WEB_PORT=8000 echo "🟢 Paso 0: Verificaciones y confirmación de seguridad" if [[ $EUID -ne 0 ]]; then - echo "❌ Este script debe ser ejecutado como root (usa sudo)." - exit 1 + echo "❌ Este script debe ser ejecutado como root (usa sudo)." + exit 1 fi echo "------------------------------------------------------------------" @@ -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" < "$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 < /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 < /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 < /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 < /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://:$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" diff --git a/templates/feeds_list.html b/templates/feeds_list.html index 1442f7f..f50bcac 100644 --- a/templates/feeds_list.html +++ b/templates/feeds_list.html @@ -1,12 +1,62 @@ {% extends "base.html" %} -{% block title %}Lista Detallada de Feeds{% endblock %} + +{% block title %}Gestionar Feeds RSS{% endblock %} {% block content %} -
-

Lista de Feeds

-

Mostrando {{ feeds|length }} de {{ total_feeds }} feeds. Página {{ page }} de {{ total_pages }}.

- ← Volver al Dashboard -
+ +
+
+

Lista de Feeds RSS ({{ total_feeds }})

+ +
+ +
+ + + + + + + + + + + + {% for feed in feeds %} + + + + + + + + {% else %} + + + + {% endfor %} + +
NombreCategoríaPaísEstadoAcciones
+ {{ feed.nombre }} + {{ feed.categoria or 'N/A' }}{{ feed.pais or 'Global' }} + {% if not feed.activo %} + KO + {% else %} + OK + {% endif %} + + + + {% if not feed.activo %} + + {% endif %} +
No hay feeds para mostrar. Añade el primero.
+
+
{% if total_pages > 1 %} {% endif %} -{% for feed in feeds %} -
-
-

{{ feed.nombre }}

-
- Editar -
-
-
-
-
ID:
{{ feed.id }}
-
URL:
{{ feed.url }}
-
Descripción:
{{ feed.descripcion or 'N/A' }}
-
Idioma:
{{ feed.idioma or 'N/D' }}
-
Estado:
- {% if feed.activo %} - Activo - {% else %} - Inactivo - {% endif %} -
-
Fallos:
{{ feed.fallos }}
-
-
-
-{% endfor %} - -{% if not feeds %} -
-

No hay feeds para mostrar.

-
-{% endif %} - {% endblock %} diff --git a/worker.py b/worker.py deleted file mode 100644 index 4e183c2..0000000 --- a/worker.py +++ /dev/null @@ -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()