diff --git a/.env b/.env index 45be2f1..4a66dec 100644 --- a/.env +++ b/.env @@ -1,7 +1,59 @@ -# Variables para la base de datos +# ========================= +# Base de datos +# ========================= DB_NAME=rss DB_USER=rss DB_PASS=lalalilo +# DB_HOST y DB_PORT los inyecta docker-compose (DB_HOST=db). +# Si ejecutas la app fuera de Docker, puedes descomentar: +# DB_HOST=localhost +# DB_PORT=5432 + +# ========================= +# Flask / Web +# ========================= +# ¡Pon aquí una clave larga y aleatoria! +SECRET_KEY=CAMBIA_ESTA_CLAVE_POR_ALGO_LARGO_Y_ALEATORIO + +# Idioma por defecto de la web y traducción activada por defecto +DEFAULT_LANG=es +DEFAULT_TRANSLATION_LANG=es +WEB_TRANSLATED_DEFAULT=1 + +# Paginación por defecto (app.py limita entre 10 y 100) +NEWS_PER_PAGE=20 + +# ========================= +# Ingesta / Scheduler +# ========================= +RSS_MAX_WORKERS=20 +RSS_FEED_TIMEOUT=30 +RSS_MAX_FAILURES=5 + +# ========================= +# Worker de traducción (NLLB 1.3B) +# ========================= +TARGET_LANGS=es +TRANSLATOR_BATCH=4 +ENQUEUE=200 +TRANSLATOR_SLEEP_IDLE=5 + +# Límites de tokens (equilibrio calidad/VRAM para 12 GB) +MAX_SRC_TOKENS=512 +MAX_NEW_TOKENS=256 + +# Beams (más calidad en títulos) +NUM_BEAMS_TITLE=3 +NUM_BEAMS_BODY=2 + +# Modelo y dispositivo +UNIVERSAL_MODEL=facebook/nllb-200-1.3B +DEVICE=cuda + +# ========================= +# Runtime (estabilidad/VRAM) +# ========================= +PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:64,garbage_collection_threshold:0.9 +TOKENIZERS_PARALLELISM=false +PYTHONUNBUFFERED=1 -# Variable para Flask -SECRET_KEY=genera_una_clave_aleatoria_larga_aqui diff --git a/app.py b/app.py index c8e0d05..c7f22bb 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,5 @@ import os import sys -import hashlib import csv import math from io import StringIO, BytesIO @@ -12,7 +11,7 @@ from contextlib import contextmanager from concurrent.futures import ThreadPoolExecutor, as_completed from tqdm import tqdm -from flask import Flask, render_template, request, redirect, url_for, Response, flash +from flask import Flask, render_template, request, redirect, url_for, Response, flash, make_response import psycopg2 import psycopg2.extras import psycopg2.pool @@ -41,6 +40,11 @@ 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)) +# Idioma/traducción por defecto +DEFAULT_TRANSLATION_LANG = os.environ.get("DEFAULT_TRANSLATION_LANG", "es").strip().lower() +DEFAULT_LANG = os.environ.get("DEFAULT_LANG", DEFAULT_TRANSLATION_LANG).strip().lower() +WEB_TRANSLATED_DEFAULT = os.environ.get("WEB_TRANSLATED_DEFAULT", "1").strip().lower() in ("1", "true", "yes") + db_pool = None try: db_pool = psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=10, **DB_CONFIG) @@ -75,7 +79,12 @@ def shutdown_hooks(): def safe_html(text): if not text: return "" - return bleach.clean(text, tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'}, attributes={'a': ['href', 'title']}, strip=True) + return bleach.clean( + text, + tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'}, + attributes={'a': ['href', 'title']}, + strip=True + ) def _get_form_dependencies(cursor): cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre") @@ -84,13 +93,32 @@ def _get_form_dependencies(cursor): paises = cursor.fetchall() return categorias, paises -def _build_news_query(args, *, count=False, limit=None, offset=None): +def _get_lang_and_flags(): + """ + Determina el idioma preferido y si se debe usar traducción por defecto. + Permite forzar original con ?orig=1 y cambiar idioma con ?lang=xx (se guarda en cookie). + """ + qlang = request.args.get("lang", "").strip().lower() + cookie_lang = (request.cookies.get("lang") or "").strip().lower() + lang = qlang or cookie_lang or DEFAULT_LANG or "es" + + force_orig = request.args.get("orig") == "1" + use_translation = (not force_orig) and WEB_TRANSLATED_DEFAULT + return lang, use_translation, bool(qlang) + +def _build_news_query(args, *, count=False, limit=None, offset=None, lang="es", use_translation=True): """ 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 + Si count=False => SELECT columnas con ORDER + LIMIT/OFFSET. + Integra traducciones vía LEFT JOIN LATERAL cuando use_translation=True (status='done', lang_to=lang). """ - sql_params = [] + # Para controlar orden de parámetros según apariciones de %s: + select_rank_params = [] + from_params = [] + where_params = [] + tail_params = [] + conditions = [] q = args.get("q", "").strip() @@ -99,6 +127,7 @@ def _build_news_query(args, *, count=False, limit=None, offset=None): pais_id = args.get("pais_id") fecha_filtro = args.get("fecha") + # FROM base sql_from = """ FROM noticias n LEFT JOIN categorias c ON n.categoria_id = c.id @@ -106,64 +135,93 @@ def _build_news_query(args, *, count=False, limit=None, offset=None): LEFT JOIN continentes co ON p.continente_id = co.id """ + # LEFT JOIN LATERAL traducción (solo en SELECT de página; el conteo no la necesita) + if (not count) and use_translation: + sql_from += """ + LEFT JOIN LATERAL ( + SELECT titulo_trad, resumen_trad + FROM traducciones + WHERE traducciones.noticia_id = n.id + AND traducciones.lang_to = %s + AND traducciones.status = 'done' + ORDER BY id DESC + LIMIT 1 + ) t ON TRUE + """ + from_params.append(lang) + # WHERE dinámico if q: - # plainto_tsquery para tolerar espacios/acentos + # Buscar por relevancia en el tsvector de la noticia original conditions.append("n.tsv @@ plainto_tsquery('spanish', %s)") - sql_params.append(q) + where_params.append(q) if cat_id: conditions.append("n.categoria_id = %s") - sql_params.append(cat_id) + where_params.append(cat_id) if pais_id: conditions.append("n.pais_id = %s") - sql_params.append(pais_id) + where_params.append(pais_id) elif cont_id: conditions.append("p.continente_id = %s") - sql_params.append(cont_id) + where_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()) + where_params.append(fecha_obj.date()) except ValueError: flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error") where_clause = " WHERE " + " AND ".join(conditions) if conditions else "" if count: - # Conteo total + # Conteo total (sin necesidad de traducciones) sql_count = "SELECT COUNT(*) " + sql_from + where_clause + sql_params = from_params + where_params # from_params estará vacío en count 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 - """ + if use_translation: + select_cols = """ + SELECT n.fecha, + COALESCE(t.titulo_trad, n.titulo) AS titulo, + COALESCE(t.resumen_trad, n.resumen) AS resumen, + n.url, n.imagen_url, n.fuente_nombre, + c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente, + (t.titulo_trad IS NOT NULL OR t.resumen_trad IS NOT NULL) AS usa_trad + """ + else: + 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, + FALSE AS usa_trad + """ + order_clause = " ORDER BY n.fecha DESC NULLS LAST" if q: - # Ranking por relevancia cuando hay búsqueda + # Ranking por relevancia (primer placeholder) 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 + select_rank_params.append(q) 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) + tail_params.append(limit) if offset is not None: order_clause += " OFFSET %s" - sql_params.append(offset) + tail_params.append(offset) sql_page = select_cols + sql_from + where_clause + order_clause + sql_params = select_rank_params + from_params + where_params + tail_params return sql_page, sql_params @app.route("/") @@ -177,6 +235,9 @@ def home(): pais_id = request.args.get("pais_id") fecha_filtro = request.args.get("fecha") + # Preferencias idioma/uso de traducción + lang, use_tr, set_cookie = _get_lang_and_flags() + # Paginación page = request.args.get("page", default=1, type=int) per_page = request.args.get("per_page", default=NEWS_PER_PAGE, type=int) @@ -202,14 +263,23 @@ def home(): cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre") paises = cursor.fetchall() - # 1) Conteo total - sql_count, params_count = _build_news_query(request.args, count=True) + # 1) Conteo total (no requiere join de traducciones) + sql_count, params_count = _build_news_query( + request.args, count=True, lang=lang, use_translation=use_tr + ) 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) + # 2) Página actual (con COALESCE a traducción si procede) + sql_page, params_page = _build_news_query( + request.args, + count=False, + limit=per_page, + offset=offset, + lang=lang, + use_translation=use_tr + ) cursor.execute(sql_page, tuple(params_page)) noticias = cursor.fetchall() @@ -221,15 +291,23 @@ def home(): 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 + page=page, per_page=per_page, total_pages=total_pages, total_results=total_results, + lang=lang, use_tr=use_tr ) # Respuesta parcial para AJAX if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return render_template('_noticias_list.html', **ctx) + resp = make_response(render_template('_noticias_list.html', **ctx)) + if set_cookie: + resp.set_cookie("lang", lang, max_age=60*60*24*365) + return resp # Render completo - return render_template("noticias.html", **ctx) + html = render_template("noticias.html", **ctx) + resp = make_response(html) + if set_cookie: + resp.set_cookie("lang", lang, max_age=60*60*24*365) + return resp @app.route("/dashboard") def dashboard(): diff --git a/docker-compose.yml b/docker-compose.yml index 0dbbf35..d671866 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,12 @@ services: - DB_USER=${DB_USER} - DB_PASS=${DB_PASS} - SECRET_KEY=${SECRET_KEY} - # - NEWS_PER_PAGE=20 # opcional + # Opcionales UI + # - NEWS_PER_PAGE=20 + # Mostrar traducciones por defecto en la web + - WEB_TRANSLATED_DEFAULT=1 + - DEFAULT_LANG=es + - TRANSLATION_PREFERRED_LANGS=es depends_on: db: condition: service_healthy @@ -75,11 +80,11 @@ services: # --- Worker --- - TARGET_LANGS=es - - TRANSLATOR_BATCH=4 # 1.3B: más seguro en 12 GB (sube a 4 si ves VRAM libre) + - TRANSLATOR_BATCH=4 # estable con 1.3B en 12 GB; ajusta si cambia la VRAM disponible - ENQUEUE=200 - TRANSLATOR_SLEEP_IDLE=5 - # Tokens (equilibrio calidad/VRAM) + # Tokens (equilibrio calidad/VRAM ~<7GB) - MAX_SRC_TOKENS=512 - MAX_NEW_TOKENS=256 diff --git a/templates/_noticias_list.html b/templates/_noticias_list.html index 46c67c1..e7893b4 100644 --- a/templates/_noticias_list.html +++ b/templates/_noticias_list.html @@ -1,53 +1,70 @@ {# Resumen y paginación #} {% if total_results and total_results > 0 %} -
+
{% set start_i = (page - 1) * per_page + 1 %} {% set end_i = (page - 1) * per_page + (noticias|length) %} Mostrando {{ start_i }}–{{ end_i }} de {{ total_results }} @@ -55,39 +72,66 @@ {% endif %} {% if total_pages and total_pages > 1 %} -