From d23754d3b81915925026745624853e16cf1eb240 Mon Sep 17 00:00:00 2001 From: jlimolina Date: Sun, 15 Jun 2025 16:43:02 +0200 Subject: [PATCH] =?UTF-8?q?Actualizaci=C3=B3n=20del=202025-06-15=20a=20las?= =?UTF-8?q?=2016:43:02?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 62 ++++++++++++++++++++++++++++ download_models.py | 49 ++++++++++++++++++++++ install.sh | 70 ++++++------------------------- requirements.txt | 2 + templates/add_url.html | 59 +++++++++++++++++++++++++++ templates/base.html | 61 ++++++++++++++++++++++++--- url_processor.py | 93 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 333 insertions(+), 63 deletions(-) create mode 100644 download_models.py create mode 100644 templates/add_url.html create mode 100644 url_processor.py diff --git a/app.py b/app.py index 468e1c4..07a027e 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,8 @@ import psycopg2.pool import bleach from feed_processor import process_single_feed +# --- IMPORTACIÓN CORREGIDA --- +from url_processor import process_newspaper_url logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s') @@ -198,6 +200,66 @@ def add_feed(): flash("No se pudieron cargar las categorías o países.", "error") return render_template("add_feed.html", categorias=categorias, paises=paises) + +@app.route("/add_url", methods=['GET', 'POST']) +def add_url(): + if request.method == 'POST': + url_to_scrape = request.form.get("url") + if not url_to_scrape: + flash("La URL es obligatoria.", "error") + return redirect(url_for('add_url')) + + categoria_id = int(request.form.get("categoria_id")) if request.form.get("categoria_id") else None + pais_id = int(request.form.get("pais_id")) if request.form.get("pais_id") else None + + if not categoria_id or not pais_id: + flash("Debes seleccionar una categoría y un país.", "error") + return redirect(url_for('add_url')) + + # Llama a la nueva función que devuelve una lista de noticias + lista_noticias, message = process_newspaper_url(url_to_scrape, categoria_id, pais_id) + + if lista_noticias: + try: + with get_conn() as conn: + with conn.cursor() as cursor: + # Usamos execute_values para insertar todas las noticias de una vez + insert_query = """ + INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, categoria_id, pais_id) + VALUES %s + ON CONFLICT (url) DO UPDATE SET + titulo = EXCLUDED.titulo, + resumen = EXCLUDED.resumen, + fecha = EXCLUDED.fecha, + imagen_url = EXCLUDED.imagen_url; + """ + psycopg2.extras.execute_values(cursor, insert_query, lista_noticias) + + # Mensaje de éxito mejorado que indica cuántas noticias se guardaron + flash(f"Se encontraron y guardaron {len(lista_noticias)} noticias desde la URL.", "success") + return redirect(url_for("home")) + except psycopg2.Error as db_err: + app.logger.error(f"[DB ERROR] Al insertar noticias scrapeadas: {db_err}", exc_info=True) + flash(f"Error de base de datos al guardar las noticias: {db_err}", "error") + else: + # Muestra el mensaje de error o de "no se encontraron artículos" + flash(message, "warning") + + return redirect(url_for('add_url')) + + # Petición GET: Muestra el formulario + categorias, paises = [], [] + try: + with get_conn() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: + categorias, paises = _get_form_dependencies(cursor) + except psycopg2.Error as db_err: + app.logger.error(f"[DB ERROR] Al cargar formulario de URL: {db_err}") + flash("No se pudieron cargar las categorías o países para el formulario.", "error") + + return render_template("add_url.html", categorias=categorias, paises=paises) + + @app.route("/edit/", methods=["GET", "POST"]) def edit_feed(feed_id): if request.method == "POST": diff --git a/download_models.py b/download_models.py new file mode 100644 index 0000000..37ff5b2 --- /dev/null +++ b/download_models.py @@ -0,0 +1,49 @@ +import nltk +import logging +import ssl + +# Soluciona problemas de certificado SSL en algunas configuraciones de sistema al descargar +try: + _create_unverified_https_context = ssl._create_unverified_context +except AttributeError: + pass +else: + ssl._create_default_https_context = _create_unverified_https_context + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s') + +# Lista de paquetes de NLTK que newspaper3k puede necesitar. +# 'punkt' y 'punkt_tab' son para tokenización, 'stopwords' para el resumen. +PACKAGES = ['punkt', 'punkt_tab', 'stopwords'] + + +def download_nltk_data(): + """ + Descarga los paquetes de NLTK necesarios para newspaper3k. + """ + for package in PACKAGES: + try: + logging.info(f"Verificando si el paquete '{package}' de NLTK está disponible...") + # Determina la ruta correcta para la verificación + if package.startswith('punkt'): + path = f'tokenizers/{package}' + else: + path = f'corpora/{package}' + + nltk.data.find(path) + logging.info(f"El paquete '{package}' ya está descargado.") + + except LookupError: + logging.info(f"El paquete '{package}' no se encontró. Iniciando descarga...") + try: + # El parámetro quiet=True evita el diálogo interactivo + nltk.download(package, quiet=True) + logging.info(f"Paquete '{package}' descargado con éxito.") + except Exception as e: + logging.error(f"Ocurrió un error durante la descarga del paquete '{package}': {e}") + import sys + sys.exit(1) + +if __name__ == '__main__': + download_nltk_data() + diff --git a/install.sh b/install.sh index a312ba5..9c91eba 100644 --- a/install.sh +++ b/install.sh @@ -1,36 +1,17 @@ #!/bin/bash -# ============================================================================== -# SCRIPT DE REINSTALACIÓN PARA APLICACIÓN RSS (MODO ACCESO WEB DIRECTO) -# -# CARGA LOS DATOS INICIALES DESDE LOS ARCHIVOS .sql EN EL DIRECTORIO. -# SIRVE LA APLICACIÓN DIRECTAMENTE EN EL PUERTO 8000 USANDO GUNICORN. -# -# ACCIONES DESTRUCTIVAS: -# - DETIENE y ELIMINA todos los servicios systemd que empiecen por "rss". -# - ELIMINA (DROP) la base de datos y el usuario de la base de datos. -# -# USO: -# 1. Clona tu repositorio y entra en su directorio. -# 2. Asegúrate de tener los archivos .sql (categorias.sql, etc.) en la raíz. -# 3. Dale permisos de ejecución a este script: chmod +x install.sh -# 4. Ejecútalo con sudo: sudo ./install.sh -# ============================================================================== -set -e # Termina el script si un comando falla +set -e -# ========= CONFIGURACIÓN ========= APP_NAME="rss" DB_NAME="rss" DB_USER="rss" -APP_USER="x" # El usuario del sistema que ejecutará la aplicación -APP_DIR=$(pwd) # Asume que el directorio de la app es el directorio actual +APP_USER="x" +APP_DIR=$(pwd) PYTHON_ENV="$APP_DIR/venv" WSGI_APP_ENTRY="app:app" -WEB_PORT=8000 # Puerto en el que la aplicación será accesible +WEB_PORT=8000 -# ========= 0. COMPROBACIONES Y CONFIRMACIÓN DE SEGURIDAD ========= 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 @@ -55,7 +36,6 @@ if [ -z "$DB_PASS" ]; then exit 1 fi -# ========= 0.5: LIMPIEZA DE LA INSTALACIÓN ANTERIOR ========= echo "🧹 Paso 0.5: Limpiando instalación anterior..." echo " -> Buscando y eliminando servicios systemd antiguos..." for service in $(systemctl list-unit-files | grep "^$APP_NAME" | cut -d' ' -f1); do @@ -67,12 +47,10 @@ rm -f /etc/systemd/system/$APP_NAME* systemctl daemon-reload echo " -> Servicios systemd limpiados." -# ========= 1. INSTALAR DEPENDENCIAS DEL SISTEMA ========= echo "🟢 Paso 1: Instalando dependencias del sistema (PostgreSQL, Python, Gunicorn...)" apt-get update apt-get install -y wget ca-certificates postgresql postgresql-contrib python3-venv python3-pip python3-dev libpq-dev gunicorn -# ========= 2. RECREAR LA BASE DE DATOS Y EL USUARIO ========= echo "🔥 Paso 2: Eliminando y recreando la base de datos y el usuario..." sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;" sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;" @@ -81,12 +59,10 @@ sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';" sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;" echo "✅ Base de datos y usuario recreados con éxito." -# ========= 3. PREPARAR ENTORNO DE LA APP ========= 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" - echo "✅ Usuario '$APP_USER' creado." else echo "✅ Usuario del sistema '$APP_USER' ya existe." fi @@ -103,23 +79,27 @@ echo " -> Instalando dependencias desde requirements.txt..." if [ -f "requirements.txt" ]; then "$PYTHON_ENV/bin/python" -m pip install -r "requirements.txt" else - echo "⚠️ ADVERTENCIA: No se encontró requirements.txt. La aplicación podría no funcionar." + echo "⚠️ ADVERTENCIA: No se encontró requirements.txt." fi EOF -echo "✅ Entorno de Python configurado." -# ========= 4. CREAR ESQUEMA Y SEMBRAR DATOS DESDE ARCHIVOS SQL ========= +echo "🧠 Paso 3.5: Descargando modelos de lenguaje para Newspaper3k..." +if [ -f "download_models.py" ]; then + sudo -u "$APP_USER" "$PYTHON_ENV/bin/python" "$APP_DIR/download_models.py" + echo "✅ Modelos NLP verificados/descargados." +else + echo "⚠️ ADVERTENCIA: No se encontró download_models.py. El scraping de URLs puede fallar." +fi + echo "📐 Paso 4: Creando esquema de BD, configurando FTS y sembrando datos desde archivos .sql..." export PGPASSWORD="$DB_PASS" -# Crear las tablas primero psql -U "$DB_USER" -h localhost -d "$DB_NAME" < Buscando archivos .sql para sembrar datos..." if [ -f "continentes.sql" ]; then echo " -> Cargando continentes.sql..." psql -U "$DB_USER" -h localhost -d "$DB_NAME" -f "continentes.sql" -else - echo " -> ADVERTENCIA: No se encontró continentes.sql" fi - if [ -f "categorias.sql" ]; then echo " -> Cargando categorias.sql..." psql -U "$DB_USER" -h localhost -d "$DB_NAME" -f "categorias.sql" -else - echo " -> ADVERTENCIA: No se encontró categorias.sql" fi - if [ -f "paises.sql" ]; then echo " -> Cargando paises.sql..." psql -U "$DB_USER" -h localhost -d "$DB_NAME" -f "paises.sql" -else - echo " -> ADVERTENCIA: No se encontró paises.sql" fi -# Reiniciar las secuencias para que los nuevos INSERTs no colisionen echo " -> Actualizando contadores de secuencias de la base de datos..." psql -U "$DB_USER" -h localhost -d "$DB_NAME" < "$APP_DIR/worker.py" import sys @@ -181,15 +150,11 @@ EOF chown "$APP_USER":"$APP_USER" "$APP_DIR/worker.py" echo "✅ Script del worker creado/actualizado." -# ========= 6. CREAR SERVICIOS SYSTEMD ========= echo "⚙️ Paso 6: Creando nuevos archivos de servicio systemd..." - -# --- Servicio para la aplicación web (Gunicorn) --- 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 @@ -201,18 +166,12 @@ Environment="DB_PORT=5432" Environment="DB_NAME=$DB_NAME" Environment="DB_USER=$DB_USER" Environment="DB_PASS=$DB_PASS" - -# --- LÍNEA CLAVE --- -# Gunicorn escucha en todas las IPs (0.0.0.0) en el puerto especificado ExecStart=$PYTHON_ENV/bin/gunicorn --workers 3 --bind 0.0.0.0:$WEB_PORT $WSGI_APP_ENTRY - Restart=always - [Install] WantedBy=multi-user.target EOF -# --- Servicio para el worker --- cat < /etc/systemd/system/$APP_NAME-worker.service [Unit] Description=$APP_NAME Feed Fetcher Worker @@ -230,7 +189,6 @@ Environment="DB_PASS=$DB_PASS" ExecStart=$PYTHON_ENV/bin/python $APP_DIR/worker.py EOF -# --- Timer para el worker --- cat < /etc/systemd/system/$APP_NAME-worker.timer [Unit] Description=Run $APP_NAME worker every 15 minutes @@ -243,7 +201,6 @@ WantedBy=timers.target EOF echo "✅ Archivos de servicio y timer creados." -# ========= 7. HABILITAR, ARRANCAR SERVICIOS Y ABRIR FIREWALL ========= echo "🚀 Paso 7: Recargando, habilitando, arrancando servicios y configurando firewall..." systemctl daemon-reload systemctl enable $APP_NAME.service @@ -251,7 +208,6 @@ systemctl start $APP_NAME.service systemctl enable $APP_NAME-worker.timer systemctl start $APP_NAME-worker.timer -# Abre el puerto en el firewall (UFW), si está activo if command -v ufw &> /dev/null && ufw status | grep -q 'Status: active'; then echo " -> Firewall UFW detectado. Abriendo puerto $WEB_PORT..." ufw allow $WEB_PORT/tcp diff --git a/requirements.txt b/requirements.txt index ff658de..29a879c 100755 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ waitress tqdm beautifulsoup4 requests +newspaper3k +lxml-html-clean diff --git a/templates/add_url.html b/templates/add_url.html new file mode 100644 index 0000000..0ff8412 --- /dev/null +++ b/templates/add_url.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}Añadir Noticia desde URL{% endblock %} + +{% block content %} +
+
+
+
+
+

Añadir Noticia desde URL

+
+
+

Pega la URL de un artículo de noticias. El sistema intentará extraer el título, resumen e imagen automáticamente.

+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ Cancelar + +
+ +
+
+
+
+
+
+{% endblock %} + + diff --git a/templates/base.html b/templates/base.html index de51573..cc45e8d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,7 +9,7 @@ - +
+ +
+ +

Agregador de Noticias

+
+

Tu centro de información personalizado

+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}
    {% for category, message in messages %} -
  • {{ message }}
  • +
  • {{ message }}
  • {% endfor %}
{% endif %} @@ -137,3 +185,4 @@
+ diff --git a/url_processor.py b/url_processor.py new file mode 100644 index 0000000..652fab6 --- /dev/null +++ b/url_processor.py @@ -0,0 +1,93 @@ +import hashlib +from datetime import datetime +import logging +import newspaper +from newspaper import Config +from concurrent.futures import ThreadPoolExecutor, as_completed + +def _process_individual_article(article_url, config): + """ + Función auxiliar que descarga y procesa un solo artículo. + Está diseñada para ser ejecutada en un hilo separado. + """ + try: + # Es crucial crear un nuevo objeto Article dentro de cada hilo. + article = newspaper.Article(article_url, config=config) + article.download() + + # Un artículo necesita ser parseado para tener título, texto, etc. + article.parse() + + # Si no se pudo obtener título o texto, no es un artículo válido. + if not article.title or not article.text: + return None + + # El método nlp() es necesario para el resumen. + article.nlp() + return article + except Exception: + # Ignoramos errores en artículos individuales (p.ej., enlaces rotos, etc.) + return None + +def process_newspaper_url(url, categoria_id, pais_id): + """ + Explora la URL de un periódico, extrae los artículos que encuentra + en paralelo y devuelve una lista de noticias listas para la base de datos. + """ + logging.info(f"Iniciando el scrapeo en paralelo de la fuente: {url}") + + todas_las_noticias = [] + + try: + config = Config() + config.browser_user_agent = 'RssApp/1.0 (Scraper)' + config.request_timeout = 15 # Timeout más corto para artículos individuales. + config.memoize_articles = False # No guardar en caché para obtener siempre lo último. + + source = newspaper.build(url, config=config, language='es') + + # Limitar el número de artículos para no sobrecargar el servidor. + articles_to_process = source.articles[:25] + + logging.info(f"Fuente construida. Procesando {len(articles_to_process)} artículos en paralelo...") + + # Usamos un ThreadPoolExecutor para procesar los artículos concurrentemente. + with ThreadPoolExecutor(max_workers=10) as executor: + # Creamos un futuro para cada URL de artículo. + future_to_article = {executor.submit(_process_individual_article, article.url, config): article for article in articles_to_process} + + for future in as_completed(future_to_article): + processed_article = future.result() + + # Si el artículo se procesó correctamente, lo añadimos a la lista. + if processed_article: + noticia_id = hashlib.md5(processed_article.url.encode()).hexdigest() + + if processed_article.summary: + resumen = processed_article.summary + else: + # Fallback a un extracto del texto si no hay resumen. + resumen = (processed_article.text[:400] + '...') if len(processed_article.text) > 400 else processed_article.text + + fecha = processed_article.publish_date if processed_article.publish_date else datetime.now() + + todas_las_noticias.append(( + noticia_id, + processed_article.title, + resumen, + processed_article.url, + fecha, + processed_article.top_image or '', + categoria_id, + pais_id + )) + + if not todas_las_noticias: + return [], "No se encontraron artículos válidos en la URL proporcionada." + + return todas_las_noticias, f"Se procesaron {len(todas_las_noticias)} noticias con éxito." + + except Exception as e: + logging.error(f"Excepción al construir la fuente desde '{url}': {e}", exc_info=True) + return [], f"Error al explorar la URL principal: {e}" +