cambios en la web

This commit is contained in:
jlimolina 2025-10-12 17:51:14 +02:00
parent 046a5ff369
commit a9c1e16bdd
6 changed files with 283 additions and 131 deletions

101
app.py
View file

@ -11,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, make_response
from flask import Flask, render_template, request, redirect, url_for, Response, flash, make_response, abort
import psycopg2
import psycopg2.extras
import psycopg2.pool
@ -36,11 +36,7 @@ DB_CONFIG = {
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 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")
@ -81,8 +77,8 @@ def safe_html(text):
return ""
return bleach.clean(
text,
tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'},
attributes={'a': ['href', 'title']},
tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br', 'ul', 'ol', 'li', 'blockquote', 'h3', 'h4'},
attributes={'a': ['href', 'title', 'rel', 'target']},
strip=True
)
@ -94,31 +90,18 @@ def _get_form_dependencies(cursor):
return categorias, paises
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.
Integra traducciones vía LEFT JOIN LATERAL cuando use_translation=True (status='done', lang_to=lang).
"""
# 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()
@ -127,7 +110,6 @@ def _build_news_query(args, *, count=False, limit=None, offset=None, lang="es",
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
@ -135,11 +117,10 @@ def _build_news_query(args, *, count=False, limit=None, offset=None, lang="es",
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
SELECT id AS traduccion_id, titulo_trad, resumen_trad
FROM traducciones
WHERE traducciones.noticia_id = n.id
AND traducciones.lang_to = %s
@ -150,9 +131,7 @@ def _build_news_query(args, *, count=False, limit=None, offset=None, lang="es",
"""
from_params.append(lang)
# WHERE dinámico
if q:
# Buscar por relevancia en el tsvector de la noticia original
conditions.append("n.tsv @@ plainto_tsquery('spanish', %s)")
where_params.append(q)
@ -178,24 +157,26 @@ def _build_news_query(args, *, count=False, limit=None, offset=None, lang="es",
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
if count:
# 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
sql_params = from_params + where_params
return sql_count, sql_params
# Selección de columnas para página
if use_translation:
select_cols = """
SELECT n.fecha,
COALESCE(t.titulo_trad, n.titulo) AS titulo,
COALESCE(t.resumen_trad, n.resumen) AS resumen,
SELECT
COALESCE(t.traduccion_id, NULL) AS traduccion_id,
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,
SELECT
NULL::int AS traduccion_id,
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
@ -204,7 +185,6 @@ def _build_news_query(args, *, count=False, limit=None, offset=None, lang="es",
order_clause = " ORDER BY n.fecha DESC NULLS LAST"
if q:
# Ranking por relevancia (primer placeholder)
select_cols = select_cols.replace(
"SELECT",
"SELECT ts_rank(n.tsv, plainto_tsquery('spanish', %s)) AS rank,"
@ -212,7 +192,6 @@ def _build_news_query(args, *, count=False, limit=None, offset=None, lang="es",
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"
tail_params.append(limit)
@ -228,20 +207,16 @@ def _build_news_query(args, *, count=False, limit=None, offset=None, lang="es",
def home():
noticias, categorias, continentes, paises = [], [], [], []
# 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")
# 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)
# 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)
@ -255,7 +230,6 @@ def home():
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
# 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")
@ -263,7 +237,6 @@ def home():
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
paises = cursor.fetchall()
# 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
)
@ -271,7 +244,6 @@ def home():
total_results = cursor.fetchone()[0] or 0
total_pages = math.ceil(total_results / per_page) if total_results else 0
# 2) Página actual (con COALESCE a traducción si procede)
sql_page, params_page = _build_news_query(
request.args,
count=False,
@ -295,20 +267,52 @@ def home():
lang=lang, use_tr=use_tr
)
# Respuesta parcial para AJAX
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
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
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.get("/noticia/<int:tr_id>")
def noticia(tr_id):
with get_conn() as conn, conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
cur.execute(
"""
SELECT
t.id,
n.id AS noticia_id,
n.fecha,
n.titulo AS titulo_original,
n.resumen AS cuerpo_original,
t.titulo_trad AS titulo_traducido,
t.resumen_trad AS cuerpo_traducido,
n.url AS fuente_url,
n.fuente_nombre,
p.nombre AS pais,
co.nombre AS continente,
c.nombre AS categoria,
t.lang_to,
t.status
FROM traducciones t
JOIN noticias n ON n.id = t.noticia_id
LEFT JOIN paises p ON n.pais_id = p.id
LEFT JOIN continentes co ON p.continente_id = co.id
LEFT JOIN categorias c ON n.categoria_id = c.id
WHERE t.id = %s
""",
(tr_id,)
)
row = cur.fetchone()
if not row:
abort(404)
return render_template("noticia.html", r=row)
@app.route("/dashboard")
def dashboard():
stats = {'feeds_totales': 0, 'noticias_totales': 0, 'feeds_caidos': 0}
@ -540,8 +544,6 @@ 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 = []
try:
@ -578,7 +580,6 @@ def fetch_and_store_all():
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:
@ -590,7 +591,6 @@ def fetch_and_store_all():
except Exception as e:
logging.error(f"Error de BD al obtener fuentes URL: {e}")
# Paraleliza la captura desde newspaper3k
if urls_to_process:
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_url = {
@ -612,7 +612,6 @@ def fetch_and_store_all():
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.")
@ -655,8 +654,6 @@ def fetch_and_store_all():
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
# --- Funciones de Backup y Restore (sin cambios) ---
@app.route("/backup_feeds")
def backup_feeds():
try:
@ -755,7 +752,6 @@ 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:
@ -765,7 +761,6 @@ def backup_completo():
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:
@ -775,7 +770,6 @@ def backup_completo():
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:
@ -892,7 +886,6 @@ def restore_urls():
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.")