Corrección importación CSV, robustez en restore_feeds y scripts de instalación para PostgreSQL

This commit is contained in:
jlimolina 2025-05-31 11:23:24 +02:00
parent 72dd972352
commit 0442d3fc0e
6 changed files with 119 additions and 68 deletions

54
app.py
View file

@ -1,11 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Flask RSS aggregator — versión PostgreSQL """Flask RSS aggregator — versión PostgreSQL
Cambios principales respecto al original (MySQL): (Copyright tuyo 😉, soporte robusto de importación desde CSV)
- mysql.connector psycopg2
- DB_CONFIG con claves PostgreSQL
- Función auxiliar get_conn() para abrir conexiones
- Reemplazo de INSERT IGNORE / ON DUPLICATE KEY UPDATE por ON CONFLICT
""" """
from flask import Flask, render_template, request, redirect, url_for, Response from flask import Flask, render_template, request, redirect, url_for, Response
@ -32,12 +28,10 @@ DB_CONFIG = {
"password": "x", "password": "x",
} }
def get_conn(): def get_conn():
"""Devuelve una conexión nueva usando psycopg2 y el diccionario DB_CONFIG.""" """Devuelve una conexión nueva usando psycopg2 y el diccionario DB_CONFIG."""
return psycopg2.connect(**DB_CONFIG) return psycopg2.connect(**DB_CONFIG)
MAX_FALLOS = 5 # Número máximo de fallos antes de desactivar el feed MAX_FALLOS = 5 # Número máximo de fallos antes de desactivar el feed
# ====================================== # ======================================
@ -109,7 +103,6 @@ def home():
pais_id=int(pais_id) if pais_id else None, pais_id=int(pais_id) if pais_id else None,
) )
# ====================================== # ======================================
# Gestión de feeds en /feeds # Gestión de feeds en /feeds
# ====================================== # ======================================
@ -152,7 +145,6 @@ def feeds():
paises=paises, paises=paises,
) )
# Añadir feed # Añadir feed
@app.route("/add", methods=["POST"]) @app.route("/add", methods=["POST"])
def add_feed(): def add_feed():
@ -180,7 +172,6 @@ def add_feed():
conn.close() conn.close()
return redirect(url_for("feeds")) return redirect(url_for("feeds"))
# Editar feed # Editar feed
@app.route("/edit/<int:feed_id>", methods=["GET", "POST"]) @app.route("/edit/<int:feed_id>", methods=["GET", "POST"])
def edit_feed(feed_id): def edit_feed(feed_id):
@ -230,7 +221,6 @@ def edit_feed(feed_id):
conn.close() conn.close()
return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises) return render_template("edit_feed.html", feed=feed, categorias=categorias, paises=paises)
# Eliminar feed # Eliminar feed
@app.route("/delete/<int:feed_id>") @app.route("/delete/<int:feed_id>")
def delete_feed(feed_id): def delete_feed(feed_id):
@ -247,7 +237,6 @@ def delete_feed(feed_id):
conn.close() conn.close()
return redirect(url_for("feeds")) return redirect(url_for("feeds"))
# Backup de feeds a CSV # Backup de feeds a CSV
@app.route("/backup_feeds") @app.route("/backup_feeds")
def backup_feeds(): def backup_feeds():
@ -285,8 +274,7 @@ def backup_feeds():
headers={"Content-Disposition": "attachment;filename=feeds_backup.csv"}, headers={"Content-Disposition": "attachment;filename=feeds_backup.csv"},
) )
# Restaurar feeds desde CSV (robusto: bool/int/None/códigos)
# Restaurar feeds desde CSV
@app.route("/restore_feeds", methods=["GET", "POST"]) @app.route("/restore_feeds", methods=["GET", "POST"])
def restore_feeds(): def restore_feeds():
msg = "" msg = ""
@ -301,8 +289,23 @@ def restore_feeds():
conn = get_conn() conn = get_conn()
cursor = conn.cursor() cursor = conn.cursor()
n_ok = 0 n_ok = 0
for row in rows: msg_lines = []
for i, row in enumerate(rows, 1):
try: try:
# -- robusto para activo (admite True/False/1/0/t/f/yes/no/vacío)
activo_val = str(row.get("activo", "")).strip().lower()
if activo_val in ["1", "true", "t", "yes"]:
activo = True
elif activo_val in ["0", "false", "f", "no"]:
activo = False
else:
activo = True # valor por defecto
idioma = row.get("idioma", None)
idioma = idioma.strip() if idioma else None
if idioma == "":
idioma = None
cursor.execute( cursor.execute(
""" """
INSERT INTO feeds ( INSERT INTO feeds (
@ -320,35 +323,35 @@ def restore_feeds():
fallos = EXCLUDED.fallos; fallos = EXCLUDED.fallos;
""", """,
{ {
"id": row.get("id"), "id": int(row.get("id")),
"nombre": row["nombre"], "nombre": row["nombre"],
"descripcion": row.get("descripcion") or "", "descripcion": row.get("descripcion") or "",
"url": row["url"], "url": row["url"],
"categoria_id": row["categoria_id"], "categoria_id": int(row["categoria_id"]) if row["categoria_id"] else None,
"pais_id": row["pais_id"], "pais_id": int(row["pais_id"]) if row["pais_id"] else None,
"idioma": row.get("idioma"), "idioma": idioma,
"activo": bool(int(row["activo"])), "activo": activo,
"fallos": int(row.get("fallos", 0)), "fallos": int(row.get("fallos", 0)),
}, },
) )
n_ok += 1 n_ok += 1
except Exception as e: except Exception as e:
app.logger.error(f"Error insertando feed {row}: {e}") app.logger.error(f"Error insertando feed fila {i}: {e}")
msg_lines.append(f"Error en fila {i}: {e}")
conn.commit() conn.commit()
conn.close() conn.close()
msg = f"Feeds restaurados correctamente: {n_ok}" msg = f"Feeds restaurados correctamente: {n_ok}"
if msg_lines:
msg += "<br>" + "<br>".join(msg_lines)
return render_template("restore_feeds.html", msg=msg) return render_template("restore_feeds.html", msg=msg)
@app.route("/noticias") @app.route("/noticias")
def show_noticias(): def show_noticias():
return home() return home()
# ================================ # ================================
# Lógica de procesado de feeds con control de fallos # Lógica de procesado de feeds con control de fallos
# ================================ # ================================
def sumar_fallo_feed(cursor, feed_id): def sumar_fallo_feed(cursor, feed_id):
cursor.execute("UPDATE feeds SET fallos = fallos + 1 WHERE id = %s", (feed_id,)) cursor.execute("UPDATE feeds SET fallos = fallos + 1 WHERE id = %s", (feed_id,))
cursor.execute("SELECT fallos FROM feeds WHERE id = %s", (feed_id,)) cursor.execute("SELECT fallos FROM feeds WHERE id = %s", (feed_id,))
@ -357,11 +360,9 @@ def sumar_fallo_feed(cursor, feed_id):
cursor.execute("UPDATE feeds SET activo = FALSE WHERE id = %s", (feed_id,)) cursor.execute("UPDATE feeds SET activo = FALSE WHERE id = %s", (feed_id,))
return fallos return fallos
def resetear_fallos_feed(cursor, feed_id): def resetear_fallos_feed(cursor, feed_id):
cursor.execute("UPDATE feeds SET fallos = 0 WHERE id = %s", (feed_id,)) cursor.execute("UPDATE feeds SET fallos = 0 WHERE id = %s", (feed_id,))
def fetch_and_store(): def fetch_and_store():
conn = None conn = None
try: try:
@ -459,7 +460,6 @@ def fetch_and_store():
conn.close() conn.close()
app.logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Feeds procesados.") app.logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Feeds procesados.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lanzador de la aplicación + scheduler # Lanzador de la aplicación + scheduler
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -1,4 +1,4 @@
INSERT IGNORE INTO categorias_estandar (nombre) VALUES INSERT INTO categorias (nombre) VALUES
('Ciencia'), ('Ciencia'),
('Cultura'), ('Cultura'),
('Deportes'), ('Deportes'),
@ -13,5 +13,6 @@ INSERT IGNORE INTO categorias_estandar (nombre) VALUES
('Salud'), ('Salud'),
('Sociedad'), ('Sociedad'),
('Tecnología'), ('Tecnología'),
('Viajes'); ('Viajes')
ON CONFLICT DO NOTHING;

View file

@ -1,8 +1,9 @@
INSERT IGNORE INTO continentes (id, nombre) VALUES INSERT INTO continentes (id, nombre) VALUES
(1, 'África'), (1, 'África'),
(2, 'América'), (2, 'América'),
(3, 'Asia'), (3, 'Asia'),
(4, 'Europa'), (4, 'Europa'),
(5, 'Oceanía'), (5, 'Oceanía'),
(6, 'Antártida'); (6, 'Antártida')
ON CONFLICT (id) DO NOTHING;

View file

@ -1,62 +1,103 @@
#!/bin/bash #!/bin/bash
set -e
# ========= CONFIGURACIÓN ========= # ========= CONFIGURACIÓN =========
APP_NAME="rss" APP_NAME="rss"
USER=$(whoami) APP_DIR="$(cd "$(dirname "$0")" && pwd)" # SIEMPRE el directorio del script
APP_DIR="/home/$USER/rss"
PYTHON_ENV="$APP_DIR/venv" PYTHON_ENV="$APP_DIR/venv"
SERVICE_FILE="/etc/systemd/system/$APP_NAME.service" SERVICE_FILE="/etc/systemd/system/$APP_NAME.service"
FLASK_FILE="app.py" FLASK_FILE="app.py"
DB_NAME="noticiasrss" DB_NAME="rss"
MYSQL_USER="root" DB_USER="rss"
DB_PASS="x"
# ========= PEDIR CONTRASEÑA MYSQL ========= # ========= INSTALAR POSTGRESQL (ÚLTIMA VERSIÓN RECOMENDADA) =========
read -s -p "Introduce la contraseña MySQL para '$MYSQL_USER': " MYSQL_PASS echo "🟢 Instalando PostgreSQL (repositorio oficial)..."
echo sudo apt update
sudo apt install -y wget ca-certificates
# ========= BASE DE DATOS Y TABLAS ========= # Añade el repositorio oficial de PostgreSQL (ajusta para tu distro si hace falta)
echo "🛠️ Verificando/creando base de datos '$DB_NAME'..." echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
mysql -u"$MYSQL_USER" -p"$MYSQL_PASS" -e "CREATE DATABASE IF NOT EXISTS $DB_NAME DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt update
sudo apt install -y postgresql postgresql-contrib
echo "📐 Verificando tablas necesarias..." # ========= CAMBIAR AUTENTICACIÓN DE peer A md5 =========
mysql -u"$MYSQL_USER" -p"$MYSQL_PASS" "$DB_NAME" <<EOF PG_HBA=$(find /etc/postgresql -name pg_hba.conf | head -1)
if grep -q "local\s\+all\s\+all\s\+peer" "$PG_HBA"; then
echo "📝 Ajustando pg_hba.conf para autenticación md5 (contraseña)..."
sudo sed -i 's/local\s\+all\s\+all\s\+peer/local all all md5/' "$PG_HBA"
sudo systemctl restart postgresql
else
echo "✅ pg_hba.conf ya configurado para md5."
fi
# ========= CONFIGURAR BASE DE DATOS Y USUARIO =========
echo "🛠️ Configurando PostgreSQL: usuario y base de datos..."
sudo -u postgres psql <<EOF
DO \$\$
BEGIN
IF NOT EXISTS (
SELECT FROM pg_catalog.pg_user WHERE usename = '$DB_USER'
) THEN
CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';
END IF;
END
\$\$;
CREATE DATABASE $DB_NAME OWNER $DB_USER;
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
EOF
# ========= CREAR TABLAS =========
echo "📐 Creando tablas en PostgreSQL..."
export PGPASSWORD="$DB_PASS"
psql -U "$DB_USER" -h localhost -d "$DB_NAME" <<EOF
CREATE TABLE IF NOT EXISTS continentes ( CREATE TABLE IF NOT EXISTS continentes (
id INT AUTO_INCREMENT PRIMARY KEY, id SERIAL PRIMARY KEY,
nombre VARCHAR(255) nombre VARCHAR(50) NOT NULL
); );
CREATE TABLE IF NOT EXISTS categorias_estandar ( CREATE TABLE IF NOT EXISTS categorias (
id INT AUTO_INCREMENT PRIMARY KEY, id SERIAL PRIMARY KEY,
nombre VARCHAR(255) nombre VARCHAR(100) NOT NULL
); );
CREATE TABLE IF NOT EXISTS paises ( CREATE TABLE IF NOT EXISTS paises (
id INT AUTO_INCREMENT PRIMARY KEY, id SERIAL PRIMARY KEY,
nombre VARCHAR(255), nombre VARCHAR(100) NOT NULL,
continente_id INT continente_id INTEGER REFERENCES continentes(id)
); );
CREATE TABLE IF NOT EXISTS feeds ( CREATE TABLE IF NOT EXISTS feeds (
id INT AUTO_INCREMENT PRIMARY KEY, id SERIAL PRIMARY KEY,
nombre VARCHAR(255), nombre VARCHAR(255),
url TEXT, descripcion TEXT,
categoria_id INT, url TEXT NOT NULL,
pais_id INT, categoria_id INTEGER REFERENCES categorias(id),
activo BOOLEAN DEFAULT TRUE pais_id INTEGER REFERENCES paises(id),
idioma CHAR(2),
activo BOOLEAN DEFAULT TRUE,
fallos INTEGER DEFAULT 0,
CONSTRAINT feeds_idioma_chk CHECK (idioma ~* '^[a-z]{2}$' OR idioma IS NULL)
); );
CREATE TABLE IF NOT EXISTS noticias ( CREATE TABLE IF NOT EXISTS noticias (
id CHAR(32) PRIMARY KEY, id VARCHAR(32) PRIMARY KEY,
titulo TEXT, titulo TEXT,
resumen TEXT, resumen TEXT,
url TEXT, url TEXT,
fecha DATETIME, fecha TIMESTAMP,
imagen_url TEXT, imagen_url TEXT,
categoria_id INT, categoria_id INTEGER REFERENCES categorias(id),
pais_id INT pais_id INTEGER REFERENCES paises(id)
); );
EOF EOF
echo "✅ Tablas creadas/verificadas correctamente." echo "✅ Tablas creadas/verificadas correctamente."
# ========= DATOS INICIALES ========= # ========= DATOS INICIALES =========
@ -64,7 +105,7 @@ echo "✅ Tablas creadas/verificadas correctamente."
# Continentes # Continentes
if [ -f "$APP_DIR/continentes.sql" ]; then if [ -f "$APP_DIR/continentes.sql" ]; then
echo "🌎 Insertando continentes..." echo "🌎 Insertando continentes..."
mysql -u"$MYSQL_USER" -p"$MYSQL_PASS" "$DB_NAME" < "$APP_DIR/continentes.sql" psql -U "$DB_USER" -h localhost -d "$DB_NAME" -f "$APP_DIR/continentes.sql"
else else
echo "⚠️ No se encontró $APP_DIR/continentes.sql" echo "⚠️ No se encontró $APP_DIR/continentes.sql"
fi fi
@ -72,7 +113,7 @@ fi
# Países # Países
if [ -f "$APP_DIR/paises.sql" ]; then if [ -f "$APP_DIR/paises.sql" ]; then
echo "🌐 Insertando países..." echo "🌐 Insertando países..."
mysql -u"$MYSQL_USER" -p"$MYSQL_PASS" "$DB_NAME" < "$APP_DIR/paises.sql" psql -U "$DB_USER" -h localhost -d "$DB_NAME" -f "$APP_DIR/paises.sql"
else else
echo "⚠️ No se encontró $APP_DIR/paises.sql" echo "⚠️ No se encontró $APP_DIR/paises.sql"
fi fi
@ -80,7 +121,7 @@ fi
# Categorías # Categorías
if [ -f "$APP_DIR/categorias.sql" ]; then if [ -f "$APP_DIR/categorias.sql" ]; then
echo "🏷️ Insertando categorías estándar..." echo "🏷️ Insertando categorías estándar..."
mysql -u"$MYSQL_USER" -p"$MYSQL_PASS" "$DB_NAME" < "$APP_DIR/categorias.sql" psql -U "$DB_USER" -h localhost -d "$DB_NAME" -f "$APP_DIR/categorias.sql"
else else
echo "⚠️ No se encontró $APP_DIR/categorias.sql" echo "⚠️ No se encontró $APP_DIR/categorias.sql"
fi fi
@ -118,7 +159,7 @@ echo "🔁 Recargando systemd y activando servicio"
sudo systemctl daemon-reexec sudo systemctl daemon-reexec
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable "$APP_NAME" sudo systemctl enable "$APP_NAME"
sudo systemctl start "$APP_NAME" sudo systemctl restart "$APP_NAME"
# ========= ESTADO ========= # ========= ESTADO =========
echo "✅ Servicio '$APP_NAME' instalado y funcionando." echo "✅ Servicio '$APP_NAME' instalado y funcionando."

View file

@ -1,4 +1,4 @@
INSERT IGNORE INTO paises (nombre, continente_id) VALUES INSERT INTO paises (nombre, continente_id) VALUES
('Afganistán', 3), ('Afganistán', 3),
('Albania', 4), ('Albania', 4),
('Alemania', 4), ('Alemania', 4),
@ -193,5 +193,6 @@ INSERT IGNORE INTO paises (nombre, continente_id) VALUES
('Yemen', 3), ('Yemen', 3),
('Yibuti', 1), ('Yibuti', 1),
('Zambia', 1), ('Zambia', 1),
('Zimbabue', 1); ('Zimbabue', 1)
ON CONFLICT DO NOTHING;

View file

@ -10,7 +10,11 @@
</form> </form>
{% if msg %} {% if msg %}
<div style="margin:15px 0;"> <div style="margin:15px 0;">
<strong>{{ msg }}</strong> {% if "Error" in msg or "Error en fila" in msg %}
<div style="color:#c00; font-weight:bold;">{{ msg|safe }}</div>
{% else %}
<div style="color:#198754; font-weight:bold;">{{ msg|safe }}</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
<p style="font-size:0.92em;color:#64748b;"> <p style="font-size:0.92em;color:#64748b;">
@ -18,9 +22,12 @@
<code>id, nombre, [descripcion,] url, categoria_id, categoria, pais_id, pais, idioma, activo, fallos</code><br> <code>id, nombre, [descripcion,] url, categoria_id, categoria, pais_id, pais, idioma, activo, fallos</code><br>
<small> <small>
Las columnas <b>descripcion</b> e <b>idioma</b> son opcionales.<br> Las columnas <b>descripcion</b> e <b>idioma</b> son opcionales.<br>
<b>idioma</b> debe ser el código ISO 639-1 de dos letras (ej: es, en, fr...). <b>activo</b> puede ser: <code>True</code>, <code>False</code>, <code>1</code> o <code>0</code>.<br>
<b>idioma</b> debe ser el código ISO 639-1 de dos letras (<i>ej:</i> <code>es</code>, <code>en</code>, <code>fr</code>...).<br>
Si falta alguna columna, la restauración puede fallar o ignorar ese campo.
</small> </small>
</p> </p>
</div> </div>
<a href="/feeds" class="top-link">← Volver a feeds</a> <a href="/feeds" class="top-link">← Volver a feeds</a>
{% endblock %} {% endblock %}