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
|
||||
|
||||
# --- 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..."
|
||||
|
||||
# 1. Verificar el estado (opcional, pero bueno para ver qué se sube)
|
||||
# 1. Sincronizar con el repositorio remoto para evitar conflictos
|
||||
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 "----------------------------------------"
|
||||
|
||||
|
||||
# 2. Preparar todos los archivos modificados y nuevos
|
||||
echo "➕ Añadiendo todos los archivos al área de preparación (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')"
|
||||
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
|
||||
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!"
|
||||
|
||||
|
|
|
|||
185
app.py
185
app.py
|
|
@ -77,29 +77,43 @@ def _get_form_dependencies(cursor):
|
|||
paises = cursor.fetchall()
|
||||
return categorias, paises
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
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")
|
||||
q = request.args.get("q", "").strip()
|
||||
noticias, categorias, continentes, paises = [], [], [], []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
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()
|
||||
sql_params, conditions = [], []
|
||||
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"
|
||||
## CORRECCIÓN: Se extrae la lógica de construcción de la consulta de la ruta home() para mayor claridad.
|
||||
def _build_news_query(args):
|
||||
"""Construye la consulta SQL y los parámetros basados en los argumentos de la petición."""
|
||||
sql_params = []
|
||||
conditions = []
|
||||
|
||||
q = args.get("q", "").strip()
|
||||
cat_id = args.get("categoria_id")
|
||||
cont_id = args.get("continente_id")
|
||||
pais_id = args.get("pais_id")
|
||||
fecha_filtro = args.get("fecha")
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
if q:
|
||||
search_query = " & ".join(q.split())
|
||||
conditions.append("n.tsv @@ to_tsquery('spanish', %s)")
|
||||
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)
|
||||
elif cont_id: conditions.append("p.continente_id = %s"); sql_params.append(cont_id)
|
||||
|
||||
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)
|
||||
elif cont_id:
|
||||
conditions.append("p.continente_id = %s")
|
||||
sql_params.append(cont_id)
|
||||
|
||||
if fecha_filtro:
|
||||
try:
|
||||
fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d')
|
||||
|
|
@ -107,20 +121,56 @@ def home():
|
|||
sql_params.append(fecha_obj.date())
|
||||
except ValueError:
|
||||
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"
|
||||
if q:
|
||||
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"
|
||||
sql_params.append(search_query_ts)
|
||||
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))
|
||||
noticias = cursor.fetchall()
|
||||
|
||||
except psycopg2.Error as db_err:
|
||||
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")
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return render_template('_noticias_list.html', noticias=noticias)
|
||||
|
||||
return render_template("noticias.html",
|
||||
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,
|
||||
|
|
@ -154,7 +204,13 @@ def manage_feeds():
|
|||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM feeds")
|
||||
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()
|
||||
except psycopg2.Error as 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)
|
||||
flash(f"Error al añadir el feed: {db_err}", "error")
|
||||
return redirect(url_for("manage_feeds"))
|
||||
|
||||
categorias, paises = [], []
|
||||
try:
|
||||
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)
|
||||
flash(f"Error al actualizar el feed: {db_err}", "error")
|
||||
return redirect(url_for("manage_feeds"))
|
||||
|
||||
feed, categorias, paises = None, [], []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
|
|
@ -221,6 +279,7 @@ def edit_feed(feed_id):
|
|||
categorias, paises = _get_form_dependencies(cursor)
|
||||
except psycopg2.Error as db_err:
|
||||
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 render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises)
|
||||
|
||||
|
|
@ -253,7 +312,13 @@ def manage_urls():
|
|||
try:
|
||||
with get_conn() as conn:
|
||||
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()
|
||||
except psycopg2.Error as 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)
|
||||
flash(f"Error al añadir la fuente URL: {db_err}", "error")
|
||||
return redirect(url_for("manage_urls"))
|
||||
|
||||
categorias, paises = [], []
|
||||
try:
|
||||
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)
|
||||
flash(f"Error al actualizar la fuente URL: {db_err}", "error")
|
||||
return redirect(url_for("manage_urls"))
|
||||
|
||||
fuente, categorias, paises = None, [], []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
|
|
@ -319,6 +386,7 @@ def edit_url_source(url_id):
|
|||
categorias, paises = _get_form_dependencies(cursor)
|
||||
except psycopg2.Error as db_err:
|
||||
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 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"))
|
||||
|
||||
def fetch_and_store_all():
|
||||
with app.app_context():
|
||||
logging.info("--- INICIANDO CICLO DE CAPTURA GLOBAL (RSS y URL) ---")
|
||||
todas_las_noticias = []
|
||||
feeds_fallidos = []
|
||||
feeds_exitosos = []
|
||||
feeds_para_actualizar_headers = []
|
||||
|
||||
# --- Parte 1: Procesando Feeds RSS ---
|
||||
logging.info("=> Parte 1: Procesando Feeds RSS...")
|
||||
feeds_to_process = []
|
||||
try:
|
||||
|
|
@ -351,6 +420,7 @@ def fetch_and_store_all():
|
|||
except psycopg2.Error as db_err:
|
||||
logging.error(f"Error de BD al obtener feeds RSS: {db_err}")
|
||||
return
|
||||
|
||||
if feeds_to_process:
|
||||
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}
|
||||
|
|
@ -371,8 +441,11 @@ def fetch_and_store_all():
|
|||
except Exception as exc:
|
||||
logging.error(f"Excepción en feed {original_feed_data['url']} (ID: {feed_id}): {exc}")
|
||||
feeds_fallidos.append(feed_id)
|
||||
|
||||
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:
|
||||
|
|
@ -383,24 +456,36 @@ def fetch_and_store_all():
|
|||
logging.info(f"Encontradas {len(urls_to_process)} fuentes URL para scrapear.")
|
||||
except Exception as 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:
|
||||
for source in tqdm(urls_to_process, desc="Procesando Fuentes URL"):
|
||||
try:
|
||||
noticias_encontradas, _ = process_newspaper_url(
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
future_to_url = {
|
||||
executor.submit(
|
||||
process_newspaper_url,
|
||||
source['nombre'], source['url'], source['categoria_id'],
|
||||
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:
|
||||
todas_las_noticias.extend(noticias_encontradas)
|
||||
except Exception as e:
|
||||
logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {e}")
|
||||
except Exception as exc:
|
||||
logging.error(f"Fallo al procesar la fuente URL {source['nombre']}: {exc}")
|
||||
|
||||
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.")
|
||||
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
|
||||
return
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
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 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.")
|
||||
|
||||
if 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.")
|
||||
|
||||
if feeds_para_actualizar_headers:
|
||||
psycopg2.extras.execute_values(
|
||||
cursor,
|
||||
|
|
@ -418,6 +505,7 @@ def fetch_and_store_all():
|
|||
[(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.")
|
||||
|
||||
if todas_las_noticias:
|
||||
logging.info(f"Intentando insertar/ignorar {len(todas_las_noticias)} noticias en total.")
|
||||
insert_query = """
|
||||
|
|
@ -427,21 +515,33 @@ def fetch_and_store_all():
|
|||
"""
|
||||
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("=> Parte 3 Finalizada. Base de datos actualizada correctamente.")
|
||||
except Exception as e:
|
||||
logging.error(f"Error de BD en la actualización masiva final: {e}", exc_info=True)
|
||||
|
||||
logging.info("--- CICLO DE CAPTURA GLOBAL FINALIZADO ---")
|
||||
|
||||
# --- Funciones de Backup y Restore (sin cambios) ---
|
||||
|
||||
@app.route("/backup_feeds")
|
||||
def backup_feeds():
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
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()
|
||||
if not feeds_:
|
||||
flash("No hay feeds para exportar.", "warning")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
fieldnames = list(feeds_[0].keys())
|
||||
output = StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
|
|
@ -469,6 +569,7 @@ def backup_urls():
|
|||
if not fuentes:
|
||||
flash("No hay fuentes URL para exportar.", "warning")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
fieldnames = list(fuentes[0].keys())
|
||||
output = StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
|
|
@ -489,11 +590,20 @@ def backup_noticias():
|
|||
try:
|
||||
with get_conn() as conn:
|
||||
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()
|
||||
if not noticias:
|
||||
flash("No hay noticias para exportar.", "warning")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
fieldnames_noticias = list(noticias[0].keys())
|
||||
output = StringIO()
|
||||
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 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:
|
||||
|
|
@ -520,6 +631,8 @@ def backup_completo():
|
|||
writer_feeds.writeheader()
|
||||
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:
|
||||
|
|
@ -528,6 +641,8 @@ def backup_completo():
|
|||
writer_fuentes.writeheader()
|
||||
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:
|
||||
|
|
@ -536,6 +651,7 @@ def backup_completo():
|
|||
writer_noticias.writeheader()
|
||||
writer_noticias.writerows([dict(n) for n in noticias_data])
|
||||
zipf.writestr("noticias.csv", output_noticias.getvalue())
|
||||
|
||||
memory_buffer.seek(0)
|
||||
return Response(memory_buffer, mimetype="application/zip", headers={"Content-Disposition": "attachment;filename=rss_backup_completo.zip"})
|
||||
except Exception as e:
|
||||
|
|
@ -550,6 +666,7 @@ def restore_feeds():
|
|||
if not file or not file.filename.endswith(".csv"):
|
||||
flash("Archivo no válido. Sube un .csv.", "error")
|
||||
return redirect(url_for("restore_feeds"))
|
||||
|
||||
try:
|
||||
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
||||
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)
|
||||
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
return render_template("restore_feeds.html")
|
||||
|
||||
@app.route("/restore_urls", methods=["GET", "POST"])
|
||||
|
|
@ -597,6 +715,7 @@ def restore_urls():
|
|||
if not file or not file.filename.endswith(".csv"):
|
||||
flash("Archivo no válido. Sube un .csv.", "error")
|
||||
return redirect(url_for("restore_urls"))
|
||||
|
||||
try:
|
||||
file_stream = StringIO(file.read().decode("utf-8", errors='ignore'))
|
||||
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)
|
||||
flash(f"Ocurrió un error general al procesar el archivo: {e}", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
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.")
|
||||
|
|
|
|||
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
|
||||
|
||||
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 USER IF EXISTS $DB_USER;"
|
||||
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..."
|
||||
if ! id "$APP_USER" &>/dev/null; then
|
||||
echo "👤 Creando usuario del sistema '$APP_USER'..."
|
||||
sudo useradd -m -s /bin/bash "$APP_USER"
|
||||
useradd -m -s /bin/bash "$APP_USER"
|
||||
else
|
||||
echo "✅ Usuario del sistema '$APP_USER' ya existe."
|
||||
fi
|
||||
|
|
@ -92,7 +96,7 @@ else
|
|||
fi
|
||||
|
||||
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
|
||||
CREATE TABLE IF NOT EXISTS continentes (id SERIAL PRIMARY KEY, nombre VARCHAR(50) NOT NULL UNIQUE);
|
||||
|
|
@ -132,30 +136,14 @@ SQL
|
|||
unset PGPASSWORD
|
||||
echo "✅ Esquema de base de datos y datos iniciales configurados."
|
||||
|
||||
echo "👷 Paso 5: Creando script para el worker de captura..."
|
||||
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."
|
||||
## CORRECCIÓN: Se elimina el Paso 5, ya no es necesario crear el worker.py dinámicamente.
|
||||
|
||||
echo "⚙️ Paso 6: Creando nuevos archivos de servicio systemd..."
|
||||
cat <<EOF > /etc/systemd/system/$APP_NAME.service
|
||||
[Unit]
|
||||
Description=Gunicorn instance to serve $APP_NAME
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=$APP_USER
|
||||
Group=$APP_USER
|
||||
|
|
@ -169,45 +157,47 @@ Environment="DB_USER=$DB_USER"
|
|||
Environment="DB_PASS=$DB_PASS"
|
||||
ExecStart=$PYTHON_ENV/bin/gunicorn --workers 3 --bind 0.0.0.0:$WEB_PORT --timeout 120 $WSGI_APP_ENTRY
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
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]
|
||||
Description=$APP_NAME Feed Fetcher Worker
|
||||
Description=$APP_NAME Scheduler Worker
|
||||
After=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Type=simple
|
||||
User=$APP_USER
|
||||
Group=$APP_USER
|
||||
WorkingDirectory=$APP_DIR
|
||||
Environment="PATH=$PYTHON_ENV/bin"
|
||||
Environment="SECRET_KEY=$(python3 -c 'import os; print(os.urandom(24).hex())')"
|
||||
Environment="DB_HOST=localhost"
|
||||
Environment="DB_PORT=5432"
|
||||
Environment="DB_NAME=$DB_NAME"
|
||||
Environment="DB_USER=$DB_USER"
|
||||
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
|
||||
|
||||
cat <<EOF > /etc/systemd/system/$APP_NAME-worker.timer
|
||||
[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 "✅ Archivos de servicio creados."
|
||||
|
||||
echo "🚀 Paso 7: Recargando, habilitando, arrancando servicios y configurando firewall..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable $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
|
||||
echo " -> Firewall UFW detectado. Abriendo puerto $WEB_PORT..."
|
||||
|
|
@ -224,8 +214,10 @@ echo " http://<IP_DE_TU_SERVIDOR>:$WEB_PORT"
|
|||
echo ""
|
||||
echo "Puedes verificar el estado de los servicios con:"
|
||||
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 "Para ver los logs de la aplicación web:"
|
||||
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" %}
|
||||
{% block title %}Lista Detallada de Feeds{% endblock %}
|
||||
|
||||
{% block title %}Gestionar Feeds RSS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header>
|
||||
<h1>Lista de Feeds</h1>
|
||||
<p class="subtitle">Mostrando {{ feeds|length }} de {{ total_feeds }} feeds. Página {{ page }} de {{ total_pages }}.</p>
|
||||
<a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">← Volver al Dashboard</a>
|
||||
</header>
|
||||
|
||||
<div class="card feed-detail-card">
|
||||
<div class="feed-header">
|
||||
<h2>Lista de Feeds RSS ({{ total_feeds }})</h2>
|
||||
<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 %}
|
||||
<nav class="pagination">
|
||||
|
|
@ -28,37 +78,4 @@
|
|||
</nav>
|
||||
{% 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 %}
|
||||
|
|
|
|||
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