diff --git a/app.py b/app.py index 9dee10d..c8e0d05 100644 --- a/app.py +++ b/app.py @@ -38,6 +38,9 @@ MAX_WORKERS = int(os.environ.get("RSS_MAX_WORKERS", 20)) SINGLE_FEED_TIMEOUT = int(os.environ.get("RSS_FEED_TIMEOUT", 30)) MAX_FALLOS = int(os.environ.get("RSS_MAX_FAILURES", 5)) +# Tamaño de página configurable (límite en 10–100 por seguridad) +NEWS_PER_PAGE = int(os.environ.get("NEWS_PER_PAGE", 20)) + db_pool = None try: db_pool = psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=10, **DB_CONFIG) @@ -47,17 +50,20 @@ except psycopg2.OperationalError as e: @contextmanager def get_conn(): - if not db_pool: raise ConnectionError("El pool de la base de datos no está disponible.") + if not db_pool: + raise ConnectionError("El pool de la base de datos no está disponible.") conn = None try: conn = db_pool.getconn() yield conn conn.commit() except Exception as e: - if conn: conn.rollback() + if conn: + conn.rollback() raise e finally: - if conn: db_pool.putconn(conn) + if conn: + db_pool.putconn(conn) @atexit.register def shutdown_hooks(): @@ -67,7 +73,8 @@ def shutdown_hooks(): @app.template_filter('safe_html') def safe_html(text): - if not text: return "" + if not text: + return "" return bleach.clean(text, tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'}, attributes={'a': ['href', 'title']}, strip=True) def _get_form_dependencies(cursor): @@ -77,43 +84,45 @@ 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.""" +def _build_news_query(args, *, count=False, limit=None, offset=None): + """ + Construye la consulta SQL y los parámetros basados en los argumentos de la petición. + Si count=True => SELECT COUNT(*) + Si count=False => SELECT columnas con ORDER + LIMIT/OFFSET + """ 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 + sql_from = """ + 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 """ + # WHERE dinámico if q: - search_query = " & ".join(q.split()) - conditions.append("n.tsv @@ to_tsquery('spanish', %s)") - sql_params.append(search_query) - + # plainto_tsquery para tolerar espacios/acentos + conditions.append("n.tsv @@ plainto_tsquery('spanish', %s)") + sql_params.append(q) + 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') @@ -122,36 +131,70 @@ def _build_news_query(args): except ValueError: flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error") - if conditions: - sql_base += " WHERE " + " AND ".join(conditions) + where_clause = " WHERE " + " AND ".join(conditions) if conditions else "" + if count: + # Conteo total + sql_count = "SELECT COUNT(*) " + sql_from + where_clause + return sql_count, sql_params + + # Selección de columnas para página + select_cols = """ + 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 + """ 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 + if q: + # Ranking por relevancia cuando hay búsqueda + select_cols = select_cols.replace( + "SELECT", + "SELECT ts_rank(n.tsv, plainto_tsquery('spanish', %s)) AS rank," + ) + # El %s de rank va al principio de los params de SELECT + sql_params = [q] + sql_params + order_clause = " ORDER BY rank DESC, n.fecha DESC NULLS LAST" + + # Paginación + if limit is not None: + order_clause += " LIMIT %s" + sql_params.append(limit) + if offset is not None: + order_clause += " OFFSET %s" + sql_params.append(offset) + + sql_page = select_cols + sql_from + where_clause + order_clause + return sql_page, sql_params @app.route("/") def home(): noticias, categorias, continentes, paises = [], [], [], [] - - # Obtenemos los valores para mantener el estado de los filtros en la plantilla + + # Estado de filtros (para mantenerlos en la UI) 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") - + + # Paginación + page = request.args.get("page", default=1, type=int) + per_page = request.args.get("per_page", default=NEWS_PER_PAGE, type=int) + # límites de seguridad + if per_page is None or per_page <= 0: + per_page = NEWS_PER_PAGE + per_page = 100 if per_page > 100 else (10 if per_page < 10 else per_page) + if page is None or page <= 0: + page = 1 + offset = (page - 1) * per_page + + total_results = 0 + total_pages = 0 + try: with get_conn() as conn: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: - # Cargar dependencias de la UI + # Dependencias de UI cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre") categorias = cursor.fetchall() cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre") @@ -159,22 +202,34 @@ def home(): 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)) + # 1) Conteo total + sql_count, params_count = _build_news_query(request.args, count=True) + cursor.execute(sql_count, tuple(params_count)) + total_results = cursor.fetchone()[0] or 0 + total_pages = math.ceil(total_results / per_page) if total_results else 0 + + # 2) Página actual + sql_page, params_page = _build_news_query(request.args, count=False, limit=per_page, offset=offset) + cursor.execute(sql_page, tuple(params_page)) 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") + ctx = dict( + 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, + pais_id=int(pais_id) if pais_id else None, fecha_filtro=fecha_filtro, q=q, + page=page, per_page=per_page, total_pages=total_pages, total_results=total_results + ) + + # Respuesta parcial para AJAX 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, - pais_id=int(pais_id) if pais_id else None, fecha_filtro=fecha_filtro, q=q) + return render_template('_noticias_list.html', **ctx) + + # Render completo + return render_template("noticias.html", **ctx) @app.route("/dashboard") def dashboard(): @@ -205,10 +260,10 @@ def manage_feeds(): 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 + SELECT f.id, f.nombre, f.url, c.nombre as categoria, 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.nombre LIMIT %s OFFSET %s """, (per_page, offset)) feeds_list = cursor.fetchall() @@ -236,7 +291,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: @@ -313,10 +368,10 @@ def manage_urls(): 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 + 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() @@ -344,7 +399,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: @@ -407,7 +462,7 @@ def fetch_and_store_all(): feeds_fallidos = [] feeds_exitosos = [] feeds_para_actualizar_headers = [] - + # --- Parte 1: Procesando Feeds RSS --- logging.info("=> Parte 1: Procesando Feeds RSS...") feeds_to_process = [] @@ -424,7 +479,7 @@ def fetch_and_store_all(): 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"): + for future in tqdm(as_completed(future_to_feed), total=len(feeds_to_process), desc="Procesando Fuentes RSS"): original_feed_data = future_to_feed[future] feed_id = original_feed_data['id'] try: @@ -457,7 +512,7 @@ def fetch_and_store_all(): 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. + # Paraleliza la captura desde newspaper3k if urls_to_process: with ThreadPoolExecutor(max_workers=10) as executor: future_to_url = { @@ -475,7 +530,7 @@ def fetch_and_store_all(): todas_las_noticias.extend(noticias_encontradas) 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}.") @@ -493,7 +548,7 @@ 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.") @@ -519,7 +574,7 @@ def fetch_and_store_all(): 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) --- @@ -530,18 +585,18 @@ def backup_feeds(): 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 + 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) @@ -559,10 +614,10 @@ def backup_urls(): with get_conn() as conn: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: cursor.execute(""" - SELECT f.id, f.nombre, f.url, f.categoria_id, c.nombre AS categoria, f.pais_id, 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 + SELECT f.id, f.nombre, f.url, f.categoria_id, c.nombre AS categoria, f.pais_id, 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.id """) fuentes = cursor.fetchall() @@ -576,8 +631,8 @@ def backup_urls(): writer.writeheader() writer.writerows([dict(fuente) for fuente in fuentes]) return Response( - output.getvalue(), - mimetype="text/csv", + output.getvalue(), + mimetype="text/csv", headers={"Content-Disposition": "attachment;filename=fuentes_url_backup.csv"} ) except Exception as e: @@ -591,12 +646,12 @@ def backup_noticias(): 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 + 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() @@ -666,7 +721,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) @@ -705,7 +760,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"]) @@ -715,7 +770,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) @@ -756,7 +811,7 @@ 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") @@ -765,3 +820,4 @@ if __name__ == "__main__": app.logger.error("La aplicación no puede arrancar sin una conexión a la base de datos.") sys.exit(1) app.run(host="0.0.0.0", port=8001, debug=True) + diff --git a/templates/_noticias_list.html b/templates/_noticias_list.html index 5b5ad92..46c67c1 100644 --- a/templates/_noticias_list.html +++ b/templates/_noticias_list.html @@ -3,27 +3,28 @@