Actualización del 2025-08-16 a las 13:12:01
This commit is contained in:
parent
b44096b07c
commit
b26e9ad87f
13 changed files with 710 additions and 222 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Entorno virtual
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Cache de Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Ficheros de IDE y OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
7
.env
Normal file
7
.env
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Variables para la base de datos
|
||||||
|
DB_NAME=rss
|
||||||
|
DB_USER=rss
|
||||||
|
DB_PASS=lalalilo
|
||||||
|
|
||||||
|
# Variable para Flask
|
||||||
|
SECRET_KEY=genera_una_clave_aleatoria_larga_aqui
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Usa una imagen base de Python ligera y moderna
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Establece el directorio de trabajo dentro del contenedor
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Instala dependencias del sistema necesarias para psycopg2
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copia solo el archivo de requerimientos primero para aprovechar el caché de Docker
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Instala las dependencias de Python
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copia el resto del código de la aplicación al contenedor
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Descarga los modelos de lenguaje de NLTK
|
||||||
|
RUN python download_models.py
|
||||||
|
|
||||||
|
# Expone el puerto que usará Gunicorn
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# El CMD se especificará en docker-compose.yml para cada servicio
|
||||||
|
|
@ -1,26 +1,35 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# --- Script para actualizar el repositorio de Git automáticamente ---
|
# --- Script para actualizar el repositorio de Git de forma robusta ---
|
||||||
|
|
||||||
echo "🚀 Iniciando actualización del repositorio..."
|
echo "🚀 Iniciando actualización del repositorio..."
|
||||||
|
|
||||||
# 1. Verificar el estado (opcional, pero bueno para ver qué se sube)
|
# 1. Sincronizar con el repositorio remoto para evitar conflictos
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
git status
|
echo "🔄 Sincronizando con el repositorio remoto (git pull)..."
|
||||||
|
git pull || { echo "❌ Error al hacer git pull. Soluciona los conflictos y vuelve a intentarlo."; exit 1; }
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
|
|
||||||
|
|
||||||
# 2. Preparar todos los archivos modificados y nuevos
|
# 2. Preparar todos los archivos modificados y nuevos
|
||||||
echo "➕ Añadiendo todos los archivos al área de preparación (git add .)"
|
echo "➕ Añadiendo todos los archivos al área de preparación (git add .)"
|
||||||
git add .
|
git add .
|
||||||
|
git add -u # Asegura que los archivos eliminados también se registren
|
||||||
|
|
||||||
# 3. Crear el mensaje del commit con la fecha y hora actual
|
# 3. Crear el mensaje del commit solo si hay cambios
|
||||||
COMMIT_MSG="Actualización del $(date +'%Y-%m-%d a las %H:%M:%S')"
|
COMMIT_MSG="Actualización del $(date +'%Y-%m-%d a las %H:%M:%S')"
|
||||||
echo "💬 Creando commit con el mensaje: '$COMMIT_MSG'"
|
echo "💬 Creando commit con el mensaje: '$COMMIT_MSG'"
|
||||||
git commit -m "$COMMIT_MSG"
|
|
||||||
|
# Solo hacemos commit si hay algo que añadir para evitar commits vacíos
|
||||||
|
if ! git diff-index --quiet HEAD --; then
|
||||||
|
git commit -m "$COMMIT_MSG"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No hay cambios que subir. El repositorio ya está actualizado."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# 4. Subir los cambios a GitHub
|
# 4. Subir los cambios a GitHub
|
||||||
echo "⬆️ Subiendo cambios al repositorio remoto (git push)..."
|
echo "⬆️ Subiendo cambios al repositorio remoto (git push)..."
|
||||||
git push
|
git push || { echo "❌ Error al hacer git push. Revisa la conexión o los permisos."; exit 1; }
|
||||||
|
|
||||||
echo "✅ ¡Actualización completada!"
|
echo "✅ ¡Actualización completada!"
|
||||||
|
|
||||||
|
|
|
||||||
185
app.py
185
app.py
|
|
@ -77,29 +77,43 @@ def _get_form_dependencies(cursor):
|
||||||
paises = cursor.fetchall()
|
paises = cursor.fetchall()
|
||||||
return categorias, paises
|
return categorias, paises
|
||||||
|
|
||||||
@app.route("/")
|
## CORRECCIÓN: Se extrae la lógica de construcción de la consulta de la ruta home() para mayor claridad.
|
||||||
def home():
|
def _build_news_query(args):
|
||||||
cat_id, cont_id, pais_id, fecha_filtro = request.args.get("categoria_id"), request.args.get("continente_id"), request.args.get("pais_id"), request.args.get("fecha")
|
"""Construye la consulta SQL y los parámetros basados en los argumentos de la petición."""
|
||||||
q = request.args.get("q", "").strip()
|
sql_params = []
|
||||||
noticias, categorias, continentes, paises = [], [], [], []
|
conditions = []
|
||||||
try:
|
|
||||||
with get_conn() as conn:
|
q = args.get("q", "").strip()
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
cat_id = args.get("categoria_id")
|
||||||
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
|
cont_id = args.get("continente_id")
|
||||||
categorias = cursor.fetchall()
|
pais_id = args.get("pais_id")
|
||||||
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
|
fecha_filtro = args.get("fecha")
|
||||||
continentes = cursor.fetchall()
|
|
||||||
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
|
sql_base = """
|
||||||
paises = cursor.fetchall()
|
SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url, n.fuente_nombre,
|
||||||
sql_params, conditions = [], []
|
c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente
|
||||||
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 LEFT JOIN continentes co ON p.continente_id = co.id"
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
if q:
|
if q:
|
||||||
search_query = " & ".join(q.split())
|
search_query = " & ".join(q.split())
|
||||||
conditions.append("n.tsv @@ to_tsquery('spanish', %s)")
|
conditions.append("n.tsv @@ to_tsquery('spanish', %s)")
|
||||||
sql_params.append(search_query)
|
sql_params.append(search_query)
|
||||||
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)
|
if cat_id:
|
||||||
elif cont_id: conditions.append("p.continente_id = %s"); sql_params.append(cont_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:
|
if fecha_filtro:
|
||||||
try:
|
try:
|
||||||
fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d')
|
fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d')
|
||||||
|
|
@ -107,20 +121,56 @@ def home():
|
||||||
sql_params.append(fecha_obj.date())
|
sql_params.append(fecha_obj.date())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error")
|
flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error")
|
||||||
if conditions: sql_base += " WHERE " + " AND ".join(conditions)
|
|
||||||
|
if conditions:
|
||||||
|
sql_base += " WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
order_clause = " ORDER BY n.fecha DESC NULLS LAST"
|
order_clause = " ORDER BY n.fecha DESC NULLS LAST"
|
||||||
if q:
|
if q:
|
||||||
search_query_ts = " & ".join(q.split())
|
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"
|
order_clause = " ORDER BY ts_rank(n.tsv, to_tsquery('spanish', %s)) DESC, n.fecha DESC"
|
||||||
sql_params.append(search_query_ts)
|
|
||||||
sql_final = sql_base + order_clause + " LIMIT 50"
|
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
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def home():
|
||||||
|
noticias, categorias, continentes, paises = [], [], [], []
|
||||||
|
|
||||||
|
# Obtenemos los valores para mantener el estado de los filtros en la plantilla
|
||||||
|
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")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
|
# Cargar dependencias de la UI
|
||||||
|
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
|
||||||
|
categorias = cursor.fetchall()
|
||||||
|
cursor.execute("SELECT id, nombre FROM continentes ORDER BY nombre")
|
||||||
|
continentes = cursor.fetchall()
|
||||||
|
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))
|
cursor.execute(sql_final, tuple(sql_params))
|
||||||
noticias = cursor.fetchall()
|
noticias = cursor.fetchall()
|
||||||
|
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
app.logger.error(f"[DB ERROR] Al leer noticias: {db_err}", exc_info=True)
|
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")
|
flash("Error de base de datos al cargar las noticias.", "error")
|
||||||
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return render_template('_noticias_list.html', noticias=noticias)
|
return render_template('_noticias_list.html', noticias=noticias)
|
||||||
|
|
||||||
return render_template("noticias.html",
|
return render_template("noticias.html",
|
||||||
noticias=noticias, categorias=categorias, continentes=continentes, paises=paises,
|
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,
|
cat_id=int(cat_id) if cat_id else None, cont_id=int(cont_id) if cont_id else None,
|
||||||
|
|
@ -154,7 +204,13 @@ def manage_feeds():
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
cursor.execute("SELECT COUNT(*) FROM feeds")
|
cursor.execute("SELECT COUNT(*) FROM feeds")
|
||||||
total_feeds = cursor.fetchone()[0]
|
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 ORDER BY f.nombre LIMIT %s OFFSET %s", (per_page, offset))
|
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
|
||||||
|
ORDER BY f.nombre LIMIT %s OFFSET %s
|
||||||
|
""", (per_page, offset))
|
||||||
feeds_list = cursor.fetchall()
|
feeds_list = cursor.fetchall()
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
app.logger.error(f"[DB ERROR] Al obtener lista de feeds: {db_err}")
|
app.logger.error(f"[DB ERROR] Al obtener lista de feeds: {db_err}")
|
||||||
|
|
@ -180,6 +236,7 @@ def add_feed():
|
||||||
app.logger.error(f"[DB ERROR] Al agregar feed: {db_err}", exc_info=True)
|
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")
|
flash(f"Error al añadir el feed: {db_err}", "error")
|
||||||
return redirect(url_for("manage_feeds"))
|
return redirect(url_for("manage_feeds"))
|
||||||
|
|
||||||
categorias, paises = [], []
|
categorias, paises = [], []
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -209,6 +266,7 @@ def edit_feed(feed_id):
|
||||||
app.logger.error(f"[DB ERROR] Al actualizar feed: {db_err}", exc_info=True)
|
app.logger.error(f"[DB ERROR] Al actualizar feed: {db_err}", exc_info=True)
|
||||||
flash(f"Error al actualizar el feed: {db_err}", "error")
|
flash(f"Error al actualizar el feed: {db_err}", "error")
|
||||||
return redirect(url_for("manage_feeds"))
|
return redirect(url_for("manage_feeds"))
|
||||||
|
|
||||||
feed, categorias, paises = None, [], []
|
feed, categorias, paises = None, [], []
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -221,6 +279,7 @@ def edit_feed(feed_id):
|
||||||
categorias, paises = _get_form_dependencies(cursor)
|
categorias, paises = _get_form_dependencies(cursor)
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
flash("Error al cargar el feed para editar.", "error")
|
flash("Error al cargar el feed para editar.", "error")
|
||||||
|
app.logger.error(f"Error al cargar feed {feed_id} para editar: {db_err}")
|
||||||
return redirect(url_for("manage_feeds"))
|
return redirect(url_for("manage_feeds"))
|
||||||
return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises)
|
return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises)
|
||||||
|
|
||||||
|
|
@ -253,7 +312,13 @@ def manage_urls():
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
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 ORDER BY f.nombre")
|
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
|
||||||
|
ORDER BY f.nombre
|
||||||
|
""")
|
||||||
fuentes = cursor.fetchall()
|
fuentes = cursor.fetchall()
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
app.logger.error(f"[DB ERROR] Al obtener lista de fuentes URL: {db_err}")
|
app.logger.error(f"[DB ERROR] Al obtener lista de fuentes URL: {db_err}")
|
||||||
|
|
@ -279,6 +344,7 @@ def add_url_source():
|
||||||
app.logger.error(f"[DB ERROR] Al agregar fuente URL: {db_err}", exc_info=True)
|
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")
|
flash(f"Error al añadir la fuente URL: {db_err}", "error")
|
||||||
return redirect(url_for("manage_urls"))
|
return redirect(url_for("manage_urls"))
|
||||||
|
|
||||||
categorias, paises = [], []
|
categorias, paises = [], []
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -307,6 +373,7 @@ def edit_url_source(url_id):
|
||||||
app.logger.error(f"[DB ERROR] Al actualizar fuente URL: {db_err}", exc_info=True)
|
app.logger.error(f"[DB ERROR] Al actualizar fuente URL: {db_err}", exc_info=True)
|
||||||
flash(f"Error al actualizar la fuente URL: {db_err}", "error")
|
flash(f"Error al actualizar la fuente URL: {db_err}", "error")
|
||||||
return redirect(url_for("manage_urls"))
|
return redirect(url_for("manage_urls"))
|
||||||
|
|
||||||
fuente, categorias, paises = None, [], []
|
fuente, categorias, paises = None, [], []
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -319,6 +386,7 @@ def edit_url_source(url_id):
|
||||||
categorias, paises = _get_form_dependencies(cursor)
|
categorias, paises = _get_form_dependencies(cursor)
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
flash("Error al cargar la fuente URL para editar.", "error")
|
flash("Error al cargar la fuente URL para editar.", "error")
|
||||||
|
app.logger.error(f"Error al cargar fuente URL {url_id} para editar: {db_err}")
|
||||||
return redirect(url_for("manage_urls"))
|
return redirect(url_for("manage_urls"))
|
||||||
return render_template("edit_url_source.html", fuente=fuente, categorias=categorias, paises=paises)
|
return render_template("edit_url_source.html", fuente=fuente, categorias=categorias, paises=paises)
|
||||||
|
|
||||||
|
|
@ -334,12 +402,13 @@ def delete_url_source(url_id):
|
||||||
return redirect(url_for("manage_urls"))
|
return redirect(url_for("manage_urls"))
|
||||||
|
|
||||||
def fetch_and_store_all():
|
def fetch_and_store_all():
|
||||||
with app.app_context():
|
|
||||||
logging.info("--- INICIANDO CICLO DE CAPTURA GLOBAL (RSS y URL) ---")
|
logging.info("--- INICIANDO CICLO DE CAPTURA GLOBAL (RSS y URL) ---")
|
||||||
todas_las_noticias = []
|
todas_las_noticias = []
|
||||||
feeds_fallidos = []
|
feeds_fallidos = []
|
||||||
feeds_exitosos = []
|
feeds_exitosos = []
|
||||||
feeds_para_actualizar_headers = []
|
feeds_para_actualizar_headers = []
|
||||||
|
|
||||||
|
# --- Parte 1: Procesando Feeds RSS ---
|
||||||
logging.info("=> Parte 1: Procesando Feeds RSS...")
|
logging.info("=> Parte 1: Procesando Feeds RSS...")
|
||||||
feeds_to_process = []
|
feeds_to_process = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -351,6 +420,7 @@ def fetch_and_store_all():
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
logging.error(f"Error de BD al obtener feeds RSS: {db_err}")
|
logging.error(f"Error de BD al obtener feeds RSS: {db_err}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if feeds_to_process:
|
if feeds_to_process:
|
||||||
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
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}
|
future_to_feed = {executor.submit(process_single_feed, dict(feed)): feed for feed in feeds_to_process}
|
||||||
|
|
@ -371,8 +441,11 @@ def fetch_and_store_all():
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error(f"Excepción en feed {original_feed_data['url']} (ID: {feed_id}): {exc}")
|
logging.error(f"Excepción en feed {original_feed_data['url']} (ID: {feed_id}): {exc}")
|
||||||
feeds_fallidos.append(feed_id)
|
feeds_fallidos.append(feed_id)
|
||||||
|
|
||||||
noticias_desde_rss_count = len(todas_las_noticias)
|
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)}.")
|
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...")
|
logging.info("=> Parte 2: Procesando Fuentes URL...")
|
||||||
urls_to_process = []
|
urls_to_process = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -383,24 +456,36 @@ def fetch_and_store_all():
|
||||||
logging.info(f"Encontradas {len(urls_to_process)} fuentes URL para scrapear.")
|
logging.info(f"Encontradas {len(urls_to_process)} fuentes URL para scrapear.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error de BD al obtener fuentes URL: {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.
|
||||||
if urls_to_process:
|
if urls_to_process:
|
||||||
for source in tqdm(urls_to_process, desc="Procesando Fuentes URL"):
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
try:
|
future_to_url = {
|
||||||
noticias_encontradas, _ = process_newspaper_url(
|
executor.submit(
|
||||||
|
process_newspaper_url,
|
||||||
source['nombre'], source['url'], source['categoria_id'],
|
source['nombre'], source['url'], source['categoria_id'],
|
||||||
source['pais_id'], source['idioma']
|
source['pais_id'], source['idioma']
|
||||||
)
|
): source for source in urls_to_process
|
||||||
|
}
|
||||||
|
for future in tqdm(as_completed(future_to_url), total=len(urls_to_process), desc="Procesando Fuentes URL"):
|
||||||
|
source = future_to_url[future]
|
||||||
|
try:
|
||||||
|
noticias_encontradas, _ = future.result()
|
||||||
if noticias_encontradas:
|
if noticias_encontradas:
|
||||||
todas_las_noticias.extend(noticias_encontradas)
|
todas_las_noticias.extend(noticias_encontradas)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {e}")
|
logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {exc}")
|
||||||
|
|
||||||
noticias_desde_urls_count = len(todas_las_noticias) - noticias_desde_rss_count
|
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}.")
|
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...")
|
logging.info("=> Parte 3: Actualizando la base de datos...")
|
||||||
if not any([todas_las_noticias, feeds_fallidos, feeds_exitosos, feeds_para_actualizar_headers]):
|
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.")
|
logging.info("No se encontraron nuevas noticias ni cambios en los feeds. Nada que actualizar.")
|
||||||
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
|
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor() as cursor:
|
with conn.cursor() as cursor:
|
||||||
|
|
@ -408,9 +493,11 @@ 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 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)))
|
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.")
|
logging.info(f"Incrementado contador de fallos para {len(feeds_fallidos)} feeds.")
|
||||||
|
|
||||||
if feeds_exitosos:
|
if feeds_exitosos:
|
||||||
cursor.execute("UPDATE feeds SET fallos = 0 WHERE id IN %s", (tuple(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.")
|
logging.info(f"Reseteado contador de fallos para {len(feeds_exitosos)} feeds.")
|
||||||
|
|
||||||
if feeds_para_actualizar_headers:
|
if feeds_para_actualizar_headers:
|
||||||
psycopg2.extras.execute_values(
|
psycopg2.extras.execute_values(
|
||||||
cursor,
|
cursor,
|
||||||
|
|
@ -418,6 +505,7 @@ def fetch_and_store_all():
|
||||||
[(f['id'], f['etag'], f['modified']) for f in feeds_para_actualizar_headers]
|
[(f['id'], f['etag'], f['modified']) for f in feeds_para_actualizar_headers]
|
||||||
)
|
)
|
||||||
logging.info(f"Actualizados headers para {len(feeds_para_actualizar_headers)} feeds.")
|
logging.info(f"Actualizados headers para {len(feeds_para_actualizar_headers)} feeds.")
|
||||||
|
|
||||||
if todas_las_noticias:
|
if todas_las_noticias:
|
||||||
logging.info(f"Intentando insertar/ignorar {len(todas_las_noticias)} noticias en total.")
|
logging.info(f"Intentando insertar/ignorar {len(todas_las_noticias)} noticias en total.")
|
||||||
insert_query = """
|
insert_query = """
|
||||||
|
|
@ -427,21 +515,33 @@ def fetch_and_store_all():
|
||||||
"""
|
"""
|
||||||
psycopg2.extras.execute_values(cursor, insert_query, todas_las_noticias, page_size=200)
|
psycopg2.extras.execute_values(cursor, insert_query, todas_las_noticias, page_size=200)
|
||||||
logging.info(f"Inserción de noticias finalizada. {cursor.rowcount} filas podrían haber sido afectadas.")
|
logging.info(f"Inserción de noticias finalizada. {cursor.rowcount} filas podrían haber sido afectadas.")
|
||||||
|
|
||||||
logging.info("=> Parte 3 Finalizada. Base de datos actualizada correctamente.")
|
logging.info("=> Parte 3 Finalizada. Base de datos actualizada correctamente.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error de BD en la actualización masiva final: {e}", exc_info=True)
|
logging.error(f"Error de BD en la actualización masiva final: {e}", exc_info=True)
|
||||||
|
|
||||||
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
|
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
|
||||||
|
|
||||||
|
# --- Funciones de Backup y Restore (sin cambios) ---
|
||||||
|
|
||||||
@app.route("/backup_feeds")
|
@app.route("/backup_feeds")
|
||||||
def backup_feeds():
|
def backup_feeds():
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
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 ORDER BY f.id")
|
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
|
||||||
|
ORDER BY f.id
|
||||||
|
""")
|
||||||
feeds_ = cursor.fetchall()
|
feeds_ = cursor.fetchall()
|
||||||
if not feeds_:
|
if not feeds_:
|
||||||
flash("No hay feeds para exportar.", "warning")
|
flash("No hay feeds para exportar.", "warning")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
fieldnames = list(feeds_[0].keys())
|
fieldnames = list(feeds_[0].keys())
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||||
|
|
@ -469,6 +569,7 @@ def backup_urls():
|
||||||
if not fuentes:
|
if not fuentes:
|
||||||
flash("No hay fuentes URL para exportar.", "warning")
|
flash("No hay fuentes URL para exportar.", "warning")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
fieldnames = list(fuentes[0].keys())
|
fieldnames = list(fuentes[0].keys())
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||||
|
|
@ -489,11 +590,20 @@ def backup_noticias():
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
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 ORDER BY n.fecha DESC")
|
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
|
||||||
|
ORDER BY n.fecha DESC
|
||||||
|
""")
|
||||||
noticias = cursor.fetchall()
|
noticias = cursor.fetchall()
|
||||||
if not noticias:
|
if not noticias:
|
||||||
flash("No hay noticias para exportar.", "warning")
|
flash("No hay noticias para exportar.", "warning")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
fieldnames_noticias = list(noticias[0].keys())
|
fieldnames_noticias = list(noticias[0].keys())
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
writer = csv.DictWriter(output, fieldnames=fieldnames_noticias)
|
writer = csv.DictWriter(output, fieldnames=fieldnames_noticias)
|
||||||
|
|
@ -512,6 +622,7 @@ def backup_completo():
|
||||||
with zipfile.ZipFile(memory_buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
with zipfile.ZipFile(memory_buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
|
# Backup Feeds
|
||||||
cursor.execute("SELECT * FROM feeds ORDER BY id")
|
cursor.execute("SELECT * FROM feeds ORDER BY id")
|
||||||
feeds_data = cursor.fetchall()
|
feeds_data = cursor.fetchall()
|
||||||
if feeds_data:
|
if feeds_data:
|
||||||
|
|
@ -520,6 +631,8 @@ def backup_completo():
|
||||||
writer_feeds.writeheader()
|
writer_feeds.writeheader()
|
||||||
writer_feeds.writerows([dict(f) for f in feeds_data])
|
writer_feeds.writerows([dict(f) for f in feeds_data])
|
||||||
zipf.writestr("feeds.csv", output_feeds.getvalue())
|
zipf.writestr("feeds.csv", output_feeds.getvalue())
|
||||||
|
|
||||||
|
# Backup Fuentes URL
|
||||||
cursor.execute("SELECT * FROM fuentes_url ORDER BY id")
|
cursor.execute("SELECT * FROM fuentes_url ORDER BY id")
|
||||||
fuentes_data = cursor.fetchall()
|
fuentes_data = cursor.fetchall()
|
||||||
if fuentes_data:
|
if fuentes_data:
|
||||||
|
|
@ -528,6 +641,8 @@ def backup_completo():
|
||||||
writer_fuentes.writeheader()
|
writer_fuentes.writeheader()
|
||||||
writer_fuentes.writerows([dict(f) for f in fuentes_data])
|
writer_fuentes.writerows([dict(f) for f in fuentes_data])
|
||||||
zipf.writestr("fuentes_url.csv", output_fuentes.getvalue())
|
zipf.writestr("fuentes_url.csv", output_fuentes.getvalue())
|
||||||
|
|
||||||
|
# Backup Noticias
|
||||||
cursor.execute("SELECT * FROM noticias ORDER BY fecha DESC")
|
cursor.execute("SELECT * FROM noticias ORDER BY fecha DESC")
|
||||||
noticias_data = cursor.fetchall()
|
noticias_data = cursor.fetchall()
|
||||||
if noticias_data:
|
if noticias_data:
|
||||||
|
|
@ -536,6 +651,7 @@ def backup_completo():
|
||||||
writer_noticias.writeheader()
|
writer_noticias.writeheader()
|
||||||
writer_noticias.writerows([dict(n) for n in noticias_data])
|
writer_noticias.writerows([dict(n) for n in noticias_data])
|
||||||
zipf.writestr("noticias.csv", output_noticias.getvalue())
|
zipf.writestr("noticias.csv", output_noticias.getvalue())
|
||||||
|
|
||||||
memory_buffer.seek(0)
|
memory_buffer.seek(0)
|
||||||
return Response(memory_buffer, mimetype="application/zip", headers={"Content-Disposition": "attachment;filename=rss_backup_completo.zip"})
|
return Response(memory_buffer, mimetype="application/zip", headers={"Content-Disposition": "attachment;filename=rss_backup_completo.zip"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -550,6 +666,7 @@ def restore_feeds():
|
||||||
if not file or not file.filename.endswith(".csv"):
|
if not file or not file.filename.endswith(".csv"):
|
||||||
flash("Archivo no válido. Sube un .csv.", "error")
|
flash("Archivo no válido. Sube un .csv.", "error")
|
||||||
return redirect(url_for("restore_feeds"))
|
return redirect(url_for("restore_feeds"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
||||||
reader = csv.DictReader(file_stream)
|
reader = csv.DictReader(file_stream)
|
||||||
|
|
@ -588,6 +705,7 @@ def restore_feeds():
|
||||||
app.logger.error(f"Error al restaurar feeds desde CSV: {e}", exc_info=True)
|
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")
|
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
return render_template("restore_feeds.html")
|
return render_template("restore_feeds.html")
|
||||||
|
|
||||||
@app.route("/restore_urls", methods=["GET", "POST"])
|
@app.route("/restore_urls", methods=["GET", "POST"])
|
||||||
|
|
@ -597,6 +715,7 @@ def restore_urls():
|
||||||
if not file or not file.filename.endswith(".csv"):
|
if not file or not file.filename.endswith(".csv"):
|
||||||
flash("Archivo no válido. Sube un .csv.", "error")
|
flash("Archivo no válido. Sube un .csv.", "error")
|
||||||
return redirect(url_for("restore_urls"))
|
return redirect(url_for("restore_urls"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
||||||
reader = csv.DictReader(file_stream)
|
reader = csv.DictReader(file_stream)
|
||||||
|
|
@ -637,8 +756,10 @@ def restore_urls():
|
||||||
app.logger.error(f"Error al restaurar fuentes URL desde CSV: {e}", exc_info=True)
|
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")
|
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
return render_template("restore_urls.html")
|
return render_template("restore_urls.html")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if not db_pool:
|
if not db_pool:
|
||||||
app.logger.error("La aplicación no puede arrancar sin una conexión a la base de datos.")
|
app.logger.error("La aplicación no puede arrancar sin una conexión a la base de datos.")
|
||||||
|
|
|
||||||
65
docker-compose.yml
Normal file
65
docker-compose.yml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Servicio de la Base de Datos PostgreSQL
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: rss_db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=${DB_NAME}
|
||||||
|
- POSTGRES_USER=${DB_USER}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASS}
|
||||||
|
volumes:
|
||||||
|
# Volumen para persistir los datos de la base de datos
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
# Monta la carpeta local con los scripts SQL para inicializar la BD la primera vez
|
||||||
|
- ./init-db:/docker-entrypoint-initdb.d
|
||||||
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
# Comprueba si la base de datos está lista para aceptar conexiones
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Servicio de la Aplicación Web (Gunicorn)
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: rss_web
|
||||||
|
command: gunicorn --bind 0.0.0.0:8000 --workers 3 app:app
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=${DB_NAME}
|
||||||
|
- DB_USER=${DB_USER}
|
||||||
|
- DB_PASS=${DB_PASS}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
# Espera a que el healthcheck de la base de datos sea exitoso antes de iniciar
|
||||||
|
condition: service_healthy
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# Servicio del Planificador de Tareas (Scheduler)
|
||||||
|
scheduler:
|
||||||
|
build: .
|
||||||
|
container_name: rss_scheduler
|
||||||
|
command: python scheduler.py
|
||||||
|
environment:
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=${DB_NAME}
|
||||||
|
- DB_USER=${DB_USER}
|
||||||
|
- DB_PASS=${DB_PASS}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
# También espera a que la base de datos esté saludable
|
||||||
|
condition: service_healthy
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# Define el volumen nombrado para la persistencia de datos
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
24
init-db/01.schema.sql
Normal file
24
init-db/01.schema.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS continentes (id SERIAL PRIMARY KEY, nombre VARCHAR(50) NOT NULL UNIQUE);
|
||||||
|
CREATE TABLE IF NOT EXISTS categorias (id SERIAL PRIMARY KEY, nombre VARCHAR(100) NOT NULL UNIQUE);
|
||||||
|
CREATE TABLE IF NOT EXISTS paises (id SERIAL PRIMARY KEY, nombre VARCHAR(100) NOT NULL UNIQUE, continente_id INTEGER REFERENCES continentes(id) ON DELETE SET NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS feeds (id SERIAL PRIMARY KEY, nombre VARCHAR(255), descripcion TEXT, url TEXT NOT NULL UNIQUE, categoria_id INTEGER REFERENCES categorias(id) ON DELETE SET NULL, pais_id INTEGER REFERENCES paises(id) ON DELETE SET NULL, idioma CHAR(2), activo BOOLEAN DEFAULT TRUE, fallos INTEGER DEFAULT 0, last_etag TEXT, last_modified TEXT);
|
||||||
|
CREATE TABLE IF NOT EXISTS fuentes_url (id SERIAL PRIMARY KEY, nombre VARCHAR(255) NOT NULL, url TEXT NOT NULL UNIQUE, categoria_id INTEGER REFERENCES categorias(id) ON DELETE SET NULL, pais_id INTEGER REFERENCES paises(id) ON DELETE SET NULL, idioma CHAR(2) DEFAULT 'es');
|
||||||
|
CREATE TABLE IF NOT EXISTS noticias (id VARCHAR(32) PRIMARY KEY, titulo TEXT, resumen TEXT, url TEXT NOT NULL UNIQUE, fecha TIMESTAMP, imagen_url TEXT, fuente_nombre VARCHAR(255), categoria_id INTEGER REFERENCES categorias(id) ON DELETE SET NULL, pais_id INTEGER REFERENCES paises(id) ON DELETE SET NULL, tsv tsvector);
|
||||||
|
|
||||||
|
ALTER TABLE noticias ADD COLUMN IF NOT EXISTS tsv tsvector;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION noticias_tsv_trigger() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
new.tsv := setweight(to_tsvector('spanish', coalesce(new.titulo,'')), 'A') ||
|
||||||
|
setweight(to_tsvector('spanish', coalesce(new.resumen,'')), 'B');
|
||||||
|
return new;
|
||||||
|
END
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tsvectorupdate ON noticias;
|
||||||
|
|
||||||
|
CREATE TRIGGER tsvectorupdate
|
||||||
|
BEFORE INSERT OR UPDATE ON noticias
|
||||||
|
FOR EACH ROW EXECUTE PROCEDURE noticias_tsv_trigger();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS noticias_tsv_idx ON noticias USING gin(tsv);
|
||||||
9
init-db/02-continentes.sql
Executable file
9
init-db/02-continentes.sql
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
INSERT INTO continentes (id, nombre) VALUES
|
||||||
|
(1, 'África'),
|
||||||
|
(2, 'América'),
|
||||||
|
(3, 'Asia'),
|
||||||
|
(4, 'Europa'),
|
||||||
|
(5, 'Oceanía'),
|
||||||
|
(6, 'Antártida')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
18
init-db/03-categorias.sql
Executable file
18
init-db/03-categorias.sql
Executable file
|
|
@ -0,0 +1,18 @@
|
||||||
|
INSERT INTO categorias (nombre) VALUES
|
||||||
|
('Ciencia'),
|
||||||
|
('Cultura'),
|
||||||
|
('Deportes'),
|
||||||
|
('Economía'),
|
||||||
|
('Educación'),
|
||||||
|
('Entretenimiento'),
|
||||||
|
('Internacional'),
|
||||||
|
('Medio Ambiente'),
|
||||||
|
('Moda'),
|
||||||
|
('Opinión'),
|
||||||
|
('Política'),
|
||||||
|
('Salud'),
|
||||||
|
('Sociedad'),
|
||||||
|
('Tecnología'),
|
||||||
|
('Viajes')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
198
init-db/04-paises.sql
Executable file
198
init-db/04-paises.sql
Executable file
|
|
@ -0,0 +1,198 @@
|
||||||
|
INSERT INTO paises (nombre, continente_id) VALUES
|
||||||
|
('Afganistán', 3),
|
||||||
|
('Albania', 4),
|
||||||
|
('Alemania', 4),
|
||||||
|
('Andorra', 4),
|
||||||
|
('Angola', 1),
|
||||||
|
('Antigua y Barbuda', 2),
|
||||||
|
('Arabia Saudita', 3),
|
||||||
|
('Argelia', 1),
|
||||||
|
('Argentina', 2),
|
||||||
|
('Armenia', 3),
|
||||||
|
('Australia', 5),
|
||||||
|
('Austria', 4),
|
||||||
|
('Azerbaiyán', 3),
|
||||||
|
('Bahamas', 2),
|
||||||
|
('Bangladés', 3),
|
||||||
|
('Barbados', 2),
|
||||||
|
('Baréin', 3),
|
||||||
|
('Bélgica', 4),
|
||||||
|
('Belice', 2),
|
||||||
|
('Benín', 1),
|
||||||
|
('Bielorrusia', 4),
|
||||||
|
('Birmania', 3),
|
||||||
|
('Bolivia', 2),
|
||||||
|
('Bosnia y Herzegovina', 4),
|
||||||
|
('Botsuana', 1),
|
||||||
|
('Brasil', 2),
|
||||||
|
('Brunéi', 3),
|
||||||
|
('Bulgaria', 4),
|
||||||
|
('Burkina Faso', 1),
|
||||||
|
('Burundi', 1),
|
||||||
|
('Bután', 3),
|
||||||
|
('Cabo Verde', 1),
|
||||||
|
('Camboya', 3),
|
||||||
|
('Camerún', 1),
|
||||||
|
('Canadá', 2),
|
||||||
|
('Catar', 3),
|
||||||
|
('Chad', 1),
|
||||||
|
('Chile', 2),
|
||||||
|
('China', 3),
|
||||||
|
('Chipre', 3),
|
||||||
|
('Colombia', 2),
|
||||||
|
('Comoras', 1),
|
||||||
|
('Corea del Norte', 3),
|
||||||
|
('Corea del Sur', 3),
|
||||||
|
('Costa de Marfil', 1),
|
||||||
|
('Costa Rica', 2),
|
||||||
|
('Croacia', 4),
|
||||||
|
('Cuba', 2),
|
||||||
|
('Dinamarca', 4),
|
||||||
|
('Dominica', 2),
|
||||||
|
('Ecuador', 2),
|
||||||
|
('Egipto', 1),
|
||||||
|
('El Salvador', 2),
|
||||||
|
('Emiratos Árabes Unidos', 3),
|
||||||
|
('Eritrea', 1),
|
||||||
|
('Eslovaquia', 4),
|
||||||
|
('Eslovenia', 4),
|
||||||
|
('España', 4),
|
||||||
|
('Estados Unidos', 2),
|
||||||
|
('Estonia', 4),
|
||||||
|
('Esuatini', 1),
|
||||||
|
('Etiopía', 1),
|
||||||
|
('Filipinas', 3),
|
||||||
|
('Finlandia', 4),
|
||||||
|
('Fiyi', 5),
|
||||||
|
('Francia', 4),
|
||||||
|
('Gabón', 1),
|
||||||
|
('Gambia', 1),
|
||||||
|
('Georgia', 3),
|
||||||
|
('Ghana', 1),
|
||||||
|
('Granada', 2),
|
||||||
|
('Grecia', 4),
|
||||||
|
('Guatemala', 2),
|
||||||
|
('Guinea', 1),
|
||||||
|
('Guinea-Bisáu', 1),
|
||||||
|
('Guinea Ecuatorial', 1),
|
||||||
|
('Guyana', 2),
|
||||||
|
('Haití', 2),
|
||||||
|
('Honduras', 2),
|
||||||
|
('Hungría', 4),
|
||||||
|
('India', 3),
|
||||||
|
('Indonesia', 3),
|
||||||
|
('Irak', 3),
|
||||||
|
('Irán', 3),
|
||||||
|
('Irlanda', 4),
|
||||||
|
('Islandia', 4),
|
||||||
|
('Islas Marshall', 5),
|
||||||
|
('Islas Salomón', 5),
|
||||||
|
('Israel', 3),
|
||||||
|
('Italia', 4),
|
||||||
|
('Jamaica', 2),
|
||||||
|
('Japón', 3),
|
||||||
|
('Jordania', 3),
|
||||||
|
('Kazajistán', 3),
|
||||||
|
('Kenia', 1),
|
||||||
|
('Kirguistán', 3),
|
||||||
|
('Kiribati', 5),
|
||||||
|
('Kuwait', 3),
|
||||||
|
('Laos', 3),
|
||||||
|
('Lesoto', 1),
|
||||||
|
('Letonia', 4),
|
||||||
|
('Líbano', 3),
|
||||||
|
('Liberia', 1),
|
||||||
|
('Libia', 1),
|
||||||
|
('Liechtenstein', 4),
|
||||||
|
('Lituania', 4),
|
||||||
|
('Luxemburgo', 4),
|
||||||
|
('Macedonia del Norte', 4),
|
||||||
|
('Madagascar', 1),
|
||||||
|
('Malasia', 3),
|
||||||
|
('Malaui', 1),
|
||||||
|
('Maldivas', 3),
|
||||||
|
('Malí', 1),
|
||||||
|
('Malta', 4),
|
||||||
|
('Marruecos', 1),
|
||||||
|
('Mauricio', 1),
|
||||||
|
('Mauritania', 1),
|
||||||
|
('México', 2),
|
||||||
|
('Micronesia', 5),
|
||||||
|
('Moldavia', 4),
|
||||||
|
('Mónaco', 4),
|
||||||
|
('Mongolia', 3),
|
||||||
|
('Montenegro', 4),
|
||||||
|
('Mozambique', 1),
|
||||||
|
('Namibia', 1),
|
||||||
|
('Nauru', 5),
|
||||||
|
('Nepal', 3),
|
||||||
|
('Nicaragua', 2),
|
||||||
|
('Níger', 1),
|
||||||
|
('Nigeria', 1),
|
||||||
|
('Noruega', 4),
|
||||||
|
('Nueva Zelanda', 5),
|
||||||
|
('Omán', 3),
|
||||||
|
('Países Bajos', 4),
|
||||||
|
('Pakistán', 3),
|
||||||
|
('Palaos', 5),
|
||||||
|
('Palestina', 3),
|
||||||
|
('Panamá', 2),
|
||||||
|
('Papúa Nueva Guinea', 5),
|
||||||
|
('Paraguay', 2),
|
||||||
|
('Perú', 2),
|
||||||
|
('Polonia', 4),
|
||||||
|
('Portugal', 4),
|
||||||
|
('Reino Unido', 4),
|
||||||
|
('República Centroafricana', 1),
|
||||||
|
('República Checa', 4),
|
||||||
|
('República del Congo', 1),
|
||||||
|
('República Democrática del Congo', 1),
|
||||||
|
('República Dominicana', 2),
|
||||||
|
('Ruanda', 1),
|
||||||
|
('Rumanía', 4),
|
||||||
|
('Rusia', 3),
|
||||||
|
('Samoa', 5),
|
||||||
|
('San Cristóbal y Nieves', 2),
|
||||||
|
('San Marino', 4),
|
||||||
|
('San Vicente y las Granadinas', 2),
|
||||||
|
('Santa Lucía', 2),
|
||||||
|
('Santo Tomé y Príncipe', 1),
|
||||||
|
('Senegal', 1),
|
||||||
|
('Serbia', 4),
|
||||||
|
('Seychelles', 1),
|
||||||
|
('Sierra Leona', 1),
|
||||||
|
('Singapur', 3),
|
||||||
|
('Siria', 3),
|
||||||
|
('Somalia', 1),
|
||||||
|
('Sri Lanka', 3),
|
||||||
|
('Sudáfrica', 1),
|
||||||
|
('Sudán', 1),
|
||||||
|
('Sudán del Sur', 1),
|
||||||
|
('Suecia', 4),
|
||||||
|
('Suiza', 4),
|
||||||
|
('Surinam', 2),
|
||||||
|
('Tailandia', 3),
|
||||||
|
('Tanzania', 1),
|
||||||
|
('Tayikistán', 3),
|
||||||
|
('Timor Oriental', 3),
|
||||||
|
('Togo', 1),
|
||||||
|
('Tonga', 5),
|
||||||
|
('Trinidad y Tobago', 2),
|
||||||
|
('Túnez', 1),
|
||||||
|
('Turkmenistán', 3),
|
||||||
|
('Turquía', 3),
|
||||||
|
('Tuvalu', 5),
|
||||||
|
('Ucrania', 4),
|
||||||
|
('Uganda', 1),
|
||||||
|
('Uruguay', 2),
|
||||||
|
('Uzbekistán', 3),
|
||||||
|
('Vanuatu', 5),
|
||||||
|
('Vaticano', 4),
|
||||||
|
('Venezuela', 2),
|
||||||
|
('Vietnam', 3),
|
||||||
|
('Yemen', 3),
|
||||||
|
('Yibuti', 1),
|
||||||
|
('Zambia', 1),
|
||||||
|
('Zimbabue', 1)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
70
install.sh
70
install.sh
|
|
@ -52,6 +52,10 @@ apt-get update
|
||||||
apt-get install -y wget ca-certificates postgresql postgresql-contrib python3-venv python3-pip python3-dev libpq-dev gunicorn
|
apt-get install -y wget ca-certificates postgresql postgresql-contrib python3-venv python3-pip python3-dev libpq-dev gunicorn
|
||||||
|
|
||||||
echo "🔥 Paso 2: Eliminando y recreando la base de datos y el usuario..."
|
echo "🔥 Paso 2: Eliminando y recreando la base de datos y el usuario..."
|
||||||
|
## CORRECCIÓN: Se exporta PGPASSWORD para evitar exponer la contraseña en la línea de comandos
|
||||||
|
## y se agrupa con la del paso 4 para mayor eficiencia.
|
||||||
|
export PGPASSWORD="$DB_PASS"
|
||||||
|
|
||||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
||||||
sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;"
|
sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;"
|
||||||
echo " -> Entidades de BD anteriores eliminadas."
|
echo " -> Entidades de BD anteriores eliminadas."
|
||||||
|
|
@ -62,7 +66,7 @@ echo "✅ Base de datos y usuario recreados con éxito."
|
||||||
echo "🐍 Paso 3: Configurando el entorno de la aplicación..."
|
echo "🐍 Paso 3: Configurando el entorno de la aplicación..."
|
||||||
if ! id "$APP_USER" &>/dev/null; then
|
if ! id "$APP_USER" &>/dev/null; then
|
||||||
echo "👤 Creando usuario del sistema '$APP_USER'..."
|
echo "👤 Creando usuario del sistema '$APP_USER'..."
|
||||||
sudo useradd -m -s /bin/bash "$APP_USER"
|
useradd -m -s /bin/bash "$APP_USER"
|
||||||
else
|
else
|
||||||
echo "✅ Usuario del sistema '$APP_USER' ya existe."
|
echo "✅ Usuario del sistema '$APP_USER' ya existe."
|
||||||
fi
|
fi
|
||||||
|
|
@ -92,7 +96,7 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📐 Paso 4: Creando esquema de BD y sembrando datos..."
|
echo "📐 Paso 4: Creando esquema de BD y sembrando datos..."
|
||||||
export PGPASSWORD="$DB_PASS"
|
# La variable PGPASSWORD ya está exportada desde el paso 2
|
||||||
|
|
||||||
psql -U "$DB_USER" -h localhost -d "$DB_NAME" <<SQL
|
psql -U "$DB_USER" -h localhost -d "$DB_NAME" <<SQL
|
||||||
CREATE TABLE IF NOT EXISTS continentes (id SERIAL PRIMARY KEY, nombre VARCHAR(50) NOT NULL UNIQUE);
|
CREATE TABLE IF NOT EXISTS continentes (id SERIAL PRIMARY KEY, nombre VARCHAR(50) NOT NULL UNIQUE);
|
||||||
|
|
@ -132,30 +136,14 @@ SQL
|
||||||
unset PGPASSWORD
|
unset PGPASSWORD
|
||||||
echo "✅ Esquema de base de datos y datos iniciales configurados."
|
echo "✅ Esquema de base de datos y datos iniciales configurados."
|
||||||
|
|
||||||
echo "👷 Paso 5: Creando script para el worker de captura..."
|
## CORRECCIÓN: Se elimina el Paso 5, ya no es necesario crear el worker.py dinámicamente.
|
||||||
cat <<EOF > "$APP_DIR/worker.py"
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
try:
|
|
||||||
from app import app, fetch_and_store
|
|
||||||
except ImportError as e:
|
|
||||||
logging.basicConfig()
|
|
||||||
logging.critical(f"No se pudo importar la aplicación Flask. Error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
if __name__ == "__main__":
|
|
||||||
with app.app_context():
|
|
||||||
fetch_and_store()
|
|
||||||
EOF
|
|
||||||
chown "$APP_USER":"$APP_USER" "$APP_DIR/worker.py"
|
|
||||||
echo "✅ Script del worker creado/actualizado."
|
|
||||||
|
|
||||||
echo "⚙️ Paso 6: Creando nuevos archivos de servicio systemd..."
|
echo "⚙️ Paso 6: Creando nuevos archivos de servicio systemd..."
|
||||||
cat <<EOF > /etc/systemd/system/$APP_NAME.service
|
cat <<EOF > /etc/systemd/system/$APP_NAME.service
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Gunicorn instance to serve $APP_NAME
|
Description=Gunicorn instance to serve $APP_NAME
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=$APP_USER
|
User=$APP_USER
|
||||||
Group=$APP_USER
|
Group=$APP_USER
|
||||||
|
|
@ -169,45 +157,47 @@ Environment="DB_USER=$DB_USER"
|
||||||
Environment="DB_PASS=$DB_PASS"
|
Environment="DB_PASS=$DB_PASS"
|
||||||
ExecStart=$PYTHON_ENV/bin/gunicorn --workers 3 --bind 0.0.0.0:$WEB_PORT --timeout 120 $WSGI_APP_ENTRY
|
ExecStart=$PYTHON_ENV/bin/gunicorn --workers 3 --bind 0.0.0.0:$WEB_PORT --timeout 120 $WSGI_APP_ENTRY
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat <<EOF > /etc/systemd/system/$APP_NAME-worker.service
|
## CORRECCIÓN: Se elimina la creación de rss-worker.service y rss-worker.timer.
|
||||||
|
## Se añade el nuevo servicio para el planificador persistente scheduler.py.
|
||||||
|
cat <<EOF > /etc/systemd/system/$APP_NAME-scheduler.service
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=$APP_NAME Feed Fetcher Worker
|
Description=$APP_NAME Scheduler Worker
|
||||||
After=postgresql.service
|
After=postgresql.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=simple
|
||||||
User=$APP_USER
|
User=$APP_USER
|
||||||
|
Group=$APP_USER
|
||||||
WorkingDirectory=$APP_DIR
|
WorkingDirectory=$APP_DIR
|
||||||
|
Environment="PATH=$PYTHON_ENV/bin"
|
||||||
Environment="SECRET_KEY=$(python3 -c 'import os; print(os.urandom(24).hex())')"
|
Environment="SECRET_KEY=$(python3 -c 'import os; print(os.urandom(24).hex())')"
|
||||||
Environment="DB_HOST=localhost"
|
Environment="DB_HOST=localhost"
|
||||||
Environment="DB_PORT=5432"
|
Environment="DB_PORT=5432"
|
||||||
Environment="DB_NAME=$DB_NAME"
|
Environment="DB_NAME=$DB_NAME"
|
||||||
Environment="DB_USER=$DB_USER"
|
Environment="DB_USER=$DB_USER"
|
||||||
Environment="DB_PASS=$DB_PASS"
|
Environment="DB_PASS=$DB_PASS"
|
||||||
ExecStart=$PYTHON_ENV/bin/python $APP_DIR/worker.py
|
ExecStart=$PYTHON_ENV/bin/python $APP_DIR/scheduler.py
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat <<EOF > /etc/systemd/system/$APP_NAME-worker.timer
|
echo "✅ Archivos de servicio creados."
|
||||||
[Unit]
|
|
||||||
Description=Run $APP_NAME worker every 15 minutes
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=5min
|
|
||||||
OnUnitActiveSec=15min
|
|
||||||
Unit=$APP_NAME-worker.service
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
EOF
|
|
||||||
echo "✅ Archivos de servicio y timer creados."
|
|
||||||
|
|
||||||
echo "🚀 Paso 7: Recargando, habilitando, arrancando servicios y configurando firewall..."
|
echo "🚀 Paso 7: Recargando, habilitando, arrancando servicios y configurando firewall..."
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable $APP_NAME.service
|
systemctl enable $APP_NAME.service
|
||||||
systemctl start $APP_NAME.service
|
systemctl start $APP_NAME.service
|
||||||
systemctl enable $APP_NAME-worker.timer
|
|
||||||
systemctl start $APP_NAME-worker.timer
|
## CORRECCIÓN: Se habilitan e inician el nuevo servicio del planificador.
|
||||||
|
systemctl enable $APP_NAME-scheduler.service
|
||||||
|
systemctl start $APP_NAME-scheduler.service
|
||||||
|
|
||||||
if command -v ufw &> /dev/null && ufw status | grep -q 'Status: active'; then
|
if command -v ufw &> /dev/null && ufw status | grep -q 'Status: active'; then
|
||||||
echo " -> Firewall UFW detectado. Abriendo puerto $WEB_PORT..."
|
echo " -> Firewall UFW detectado. Abriendo puerto $WEB_PORT..."
|
||||||
|
|
@ -224,8 +214,10 @@ echo " http://<IP_DE_TU_SERVIDOR>:$WEB_PORT"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Puedes verificar el estado de los servicios con:"
|
echo "Puedes verificar el estado de los servicios con:"
|
||||||
echo "sudo systemctl status $APP_NAME.service"
|
echo "sudo systemctl status $APP_NAME.service"
|
||||||
echo "sudo systemctl status $APP_NAME-worker.timer"
|
## CORRECCIÓN: Se actualiza el mensaje para reflejar el nuevo nombre del servicio del worker.
|
||||||
|
echo "sudo systemctl status $APP_NAME-scheduler.service"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Para ver los logs de la aplicación web:"
|
echo "Para ver los logs de la aplicación web:"
|
||||||
echo "sudo journalctl -u $APP_NAME.service -f"
|
echo "sudo journalctl -u $APP_NAME.service -f"
|
||||||
|
echo "Para ver los logs del planificador de noticias:"
|
||||||
|
echo "sudo journalctl -u $APP_NAME-scheduler.service -f"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,62 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Lista Detallada de Feeds{% endblock %}
|
|
||||||
|
{% block title %}Gestionar Feeds RSS{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header>
|
|
||||||
<h1>Lista de Feeds</h1>
|
<div class="card feed-detail-card">
|
||||||
<p class="subtitle">Mostrando {{ feeds|length }} de {{ total_feeds }} feeds. Página {{ page }} de {{ total_pages }}.</p>
|
<div class="feed-header">
|
||||||
<a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">← Volver al Dashboard</a>
|
<h2>Lista de Feeds RSS ({{ total_feeds }})</h2>
|
||||||
</header>
|
<div class="nav-actions">
|
||||||
|
<a href="{{ url_for('add_feed') }}" class="btn btn-small">
|
||||||
|
<i class="fas fa-plus"></i> Añadir Feed
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feed-body" style="padding: 0;">
|
||||||
|
<table style="width:100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: rgba(0,0,0,0.05);">
|
||||||
|
<th style="padding: 12px 15px; text-align: left;">Nombre</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: left;">Categoría</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: left;">País</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: center;">Estado</th>
|
||||||
|
<th style="padding: 12px 15px; text-align: right;">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for feed in feeds %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">
|
||||||
|
<a href="{{ feed.url }}" target="_blank" title="{{ feed.url }}">{{ feed.nombre }}</a>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">{{ feed.categoria or 'N/A' }}</td>
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">{{ feed.pais or 'Global' }}</td>
|
||||||
|
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color); text-align: center;">
|
||||||
|
{% if not feed.activo %}
|
||||||
|
<span style="color: #c0392b; font-weight: bold;" title="Inactivo por {{ feed.fallos }} fallos">KO</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #27ae60; font-weight:bold;">OK</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 12px 15px; text-align: right; border-top: 1px solid var(--border-color);">
|
||||||
|
<a href="{{ url_for('edit_feed', feed_id=feed.id) }}" class="btn btn-small btn-info" title="Editar"><i class="fas fa-edit"></i></a>
|
||||||
|
<a href="{{ url_for('delete_feed', feed_id=feed.id) }}" class="btn btn-small btn-danger" title="Eliminar" onclick="return confirm('¿Estás seguro de que quieres eliminar este feed?')"><i class="fas fa-trash"></i></a>
|
||||||
|
{% if not feed.activo %}
|
||||||
|
<a href="{{ url_for('reactivar_feed', feed_id=feed.id) }}" class="btn btn-small" title="Reactivar"><i class="fas fa-sync-alt"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="padding: 20px; text-align: center;">No hay feeds para mostrar. <a href="{{ url_for('add_feed') }}">Añade el primero</a>.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<nav class="pagination">
|
<nav class="pagination">
|
||||||
|
|
@ -28,37 +78,4 @@
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for feed in feeds %}
|
|
||||||
<div class="card feed-detail-card">
|
|
||||||
<div class="feed-header">
|
|
||||||
<h2>{{ feed.nombre }}</h2>
|
|
||||||
<div class="actions">
|
|
||||||
<a href="{{ url_for('edit_feed', feed_id=feed.id) }}" class="btn btn-small">Editar</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="feed-body">
|
|
||||||
<dl>
|
|
||||||
<dt>ID:</dt><dd>{{ feed.id }}</dd>
|
|
||||||
<dt>URL:</dt><dd><a href="{{ feed.url }}" target="_blank" rel="noopener">{{ feed.url }}</a></dd>
|
|
||||||
<dt>Descripción:</dt><dd>{{ feed.descripcion or 'N/A' }}</dd>
|
|
||||||
<dt>Idioma:</dt><dd>{{ feed.idioma or 'N/D' }}</dd>
|
|
||||||
<dt>Estado:</dt><dd>
|
|
||||||
{% if feed.activo %}
|
|
||||||
<span style="color: #27ae60; font-weight:bold;">Activo</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: #c0392b; font-weight: bold;">Inactivo</span>
|
|
||||||
{% endif %}
|
|
||||||
</dd>
|
|
||||||
<dt>Fallos:</dt><dd>{{ feed.fallos }}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if not feeds %}
|
|
||||||
<div class="card" style="text-align:center;">
|
|
||||||
<p>No hay feeds para mostrar.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
13
worker.py
13
worker.py
|
|
@ -1,13 +0,0 @@
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
try:
|
|
||||||
from app import app, fetch_and_store
|
|
||||||
except ImportError as e:
|
|
||||||
logging.basicConfig()
|
|
||||||
logging.critical(f"No se pudo importar la aplicación Flask. Error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
if __name__ == "__main__":
|
|
||||||
with app.app_context():
|
|
||||||
fetch_and_store()
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue