update:español por defecto

This commit is contained in:
jlimolina 2025-10-10 19:50:54 +02:00
parent da4c59a0e1
commit 046a5ff369
6 changed files with 381 additions and 119 deletions

136
app.py
View file

@ -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 10100 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():