Actualización del 2025-06-15 a las 19:26:12
This commit is contained in:
parent
d23754d3b8
commit
603149d47a
9 changed files with 350 additions and 122 deletions
246
app.py
246
app.py
|
|
@ -19,7 +19,6 @@ import psycopg2.pool
|
||||||
import bleach
|
import bleach
|
||||||
|
|
||||||
from feed_processor import process_single_feed
|
from feed_processor import process_single_feed
|
||||||
# --- IMPORTACIÓN CORREGIDA ---
|
|
||||||
from url_processor import process_newspaper_url
|
from url_processor import process_newspaper_url
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s')
|
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s')
|
||||||
|
|
@ -71,6 +70,13 @@ def safe_html(text):
|
||||||
if not text: return ""
|
if not text: return ""
|
||||||
return bleach.clean(text, tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'}, attributes={'a': ['href', 'title']}, strip=True)
|
return bleach.clean(text, tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'}, attributes={'a': ['href', 'title']}, strip=True)
|
||||||
|
|
||||||
|
def _get_form_dependencies(cursor):
|
||||||
|
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
|
||||||
|
categorias = cursor.fetchall()
|
||||||
|
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
|
||||||
|
paises = cursor.fetchall()
|
||||||
|
return categorias, paises
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def home():
|
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")
|
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")
|
||||||
|
|
@ -128,7 +134,7 @@ def home():
|
||||||
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,
|
||||||
pais_id=int(pais_id) if pais_id else None, fecha_filtro=fecha_filtro, q=q)
|
pais_id=int(pais_id) if pais_id else None, fecha_filtro=fecha_filtro, q=q)
|
||||||
|
|
||||||
@app.route("/feeds")
|
@app.route("/dashboard")
|
||||||
def dashboard():
|
def dashboard():
|
||||||
stats = {'feeds_totales': 0, 'noticias_totales': 0, 'feeds_caidos': 0}
|
stats = {'feeds_totales': 0, 'noticias_totales': 0, 'feeds_caidos': 0}
|
||||||
try:
|
try:
|
||||||
|
|
@ -145,6 +151,9 @@ def dashboard():
|
||||||
flash("Error al conectar con la base de datos.", "error")
|
flash("Error al conectar con la base de datos.", "error")
|
||||||
return render_template("dashboard.html", stats=stats)
|
return render_template("dashboard.html", stats=stats)
|
||||||
|
|
||||||
|
|
||||||
|
# --- GESTIÓN DE FEEDS ---
|
||||||
|
|
||||||
@app.route("/feeds/manage")
|
@app.route("/feeds/manage")
|
||||||
def manage_feeds():
|
def manage_feeds():
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
|
|
@ -156,7 +165,7 @@ 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 * FROM feeds ORDER BY 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}")
|
||||||
|
|
@ -164,13 +173,6 @@ def manage_feeds():
|
||||||
total_pages = math.ceil(total_feeds / per_page) if total_feeds > 0 else 0
|
total_pages = math.ceil(total_feeds / per_page) if total_feeds > 0 else 0
|
||||||
return render_template("feeds_list.html", feeds=feeds_list, page=page, total_pages=total_pages, total_feeds=total_feeds)
|
return render_template("feeds_list.html", feeds=feeds_list, page=page, total_pages=total_pages, total_feeds=total_feeds)
|
||||||
|
|
||||||
def _get_form_dependencies(cursor):
|
|
||||||
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
|
|
||||||
categorias = cursor.fetchall()
|
|
||||||
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
|
|
||||||
paises = cursor.fetchall()
|
|
||||||
return categorias, paises
|
|
||||||
|
|
||||||
@app.route("/feeds/add", methods=['GET', 'POST'])
|
@app.route("/feeds/add", methods=['GET', 'POST'])
|
||||||
def add_feed():
|
def add_feed():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
|
@ -188,7 +190,7 @@ def add_feed():
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
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("dashboard"))
|
return redirect(url_for("manage_feeds"))
|
||||||
|
|
||||||
categorias, paises = [], []
|
categorias, paises = [], []
|
||||||
try:
|
try:
|
||||||
|
|
@ -200,67 +202,7 @@ def add_feed():
|
||||||
flash("No se pudieron cargar las categorías o países.", "error")
|
flash("No se pudieron cargar las categorías o países.", "error")
|
||||||
return render_template("add_feed.html", categorias=categorias, paises=paises)
|
return render_template("add_feed.html", categorias=categorias, paises=paises)
|
||||||
|
|
||||||
|
@app.route("/feeds/edit/<int:feed_id>", methods=["GET", "POST"])
|
||||||
@app.route("/add_url", methods=['GET', 'POST'])
|
|
||||||
def add_url():
|
|
||||||
if request.method == 'POST':
|
|
||||||
url_to_scrape = request.form.get("url")
|
|
||||||
if not url_to_scrape:
|
|
||||||
flash("La URL es obligatoria.", "error")
|
|
||||||
return redirect(url_for('add_url'))
|
|
||||||
|
|
||||||
categoria_id = int(request.form.get("categoria_id")) if request.form.get("categoria_id") else None
|
|
||||||
pais_id = int(request.form.get("pais_id")) if request.form.get("pais_id") else None
|
|
||||||
|
|
||||||
if not categoria_id or not pais_id:
|
|
||||||
flash("Debes seleccionar una categoría y un país.", "error")
|
|
||||||
return redirect(url_for('add_url'))
|
|
||||||
|
|
||||||
# Llama a la nueva función que devuelve una lista de noticias
|
|
||||||
lista_noticias, message = process_newspaper_url(url_to_scrape, categoria_id, pais_id)
|
|
||||||
|
|
||||||
if lista_noticias:
|
|
||||||
try:
|
|
||||||
with get_conn() as conn:
|
|
||||||
with conn.cursor() as cursor:
|
|
||||||
# Usamos execute_values para insertar todas las noticias de una vez
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, categoria_id, pais_id)
|
|
||||||
VALUES %s
|
|
||||||
ON CONFLICT (url) DO UPDATE SET
|
|
||||||
titulo = EXCLUDED.titulo,
|
|
||||||
resumen = EXCLUDED.resumen,
|
|
||||||
fecha = EXCLUDED.fecha,
|
|
||||||
imagen_url = EXCLUDED.imagen_url;
|
|
||||||
"""
|
|
||||||
psycopg2.extras.execute_values(cursor, insert_query, lista_noticias)
|
|
||||||
|
|
||||||
# Mensaje de éxito mejorado que indica cuántas noticias se guardaron
|
|
||||||
flash(f"Se encontraron y guardaron {len(lista_noticias)} noticias desde la URL.", "success")
|
|
||||||
return redirect(url_for("home"))
|
|
||||||
except psycopg2.Error as db_err:
|
|
||||||
app.logger.error(f"[DB ERROR] Al insertar noticias scrapeadas: {db_err}", exc_info=True)
|
|
||||||
flash(f"Error de base de datos al guardar las noticias: {db_err}", "error")
|
|
||||||
else:
|
|
||||||
# Muestra el mensaje de error o de "no se encontraron artículos"
|
|
||||||
flash(message, "warning")
|
|
||||||
|
|
||||||
return redirect(url_for('add_url'))
|
|
||||||
|
|
||||||
# Petición GET: Muestra el formulario
|
|
||||||
categorias, paises = [], []
|
|
||||||
try:
|
|
||||||
with get_conn() as conn:
|
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
|
||||||
categorias, paises = _get_form_dependencies(cursor)
|
|
||||||
except psycopg2.Error as db_err:
|
|
||||||
app.logger.error(f"[DB ERROR] Al cargar formulario de URL: {db_err}")
|
|
||||||
flash("No se pudieron cargar las categorías o países para el formulario.", "error")
|
|
||||||
|
|
||||||
return render_template("add_url.html", categorias=categorias, paises=paises)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/edit/<int:feed_id>", methods=["GET", "POST"])
|
|
||||||
def edit_feed(feed_id):
|
def edit_feed(feed_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
|
|
@ -280,6 +222,7 @@ def edit_feed(feed_id):
|
||||||
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, [], []
|
||||||
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:
|
||||||
|
|
@ -290,12 +233,11 @@ def edit_feed(feed_id):
|
||||||
return redirect(url_for("manage_feeds"))
|
return redirect(url_for("manage_feeds"))
|
||||||
categorias, paises = _get_form_dependencies(cursor)
|
categorias, paises = _get_form_dependencies(cursor)
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
app.logger.error(f"[DB ERROR] Al cargar feed para editar: {db_err}", exc_info=True)
|
|
||||||
flash("Error al cargar el feed para editar.", "error")
|
flash("Error al cargar el feed para editar.", "error")
|
||||||
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)
|
||||||
|
|
||||||
@app.route("/delete/<int:feed_id>")
|
@app.route("/feeds/delete/<int:feed_id>")
|
||||||
def delete_feed(feed_id):
|
def delete_feed(feed_id):
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -307,7 +249,7 @@ def delete_feed(feed_id):
|
||||||
flash(f"Error al eliminar el feed: {db_err}", "error")
|
flash(f"Error al eliminar el feed: {db_err}", "error")
|
||||||
return redirect(url_for("manage_feeds"))
|
return redirect(url_for("manage_feeds"))
|
||||||
|
|
||||||
@app.route("/reactivar_feed/<int:feed_id>")
|
@app.route("/feeds/reactivar/<int:feed_id>")
|
||||||
def reactivar_feed(feed_id):
|
def reactivar_feed(feed_id):
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -318,6 +260,156 @@ def reactivar_feed(feed_id):
|
||||||
flash(f"Error al reactivar feed: {db_err}", "error")
|
flash(f"Error al reactivar feed: {db_err}", "error")
|
||||||
return redirect(url_for("manage_feeds"))
|
return redirect(url_for("manage_feeds"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- GESTIÓN DE FUENTES URL ---
|
||||||
|
|
||||||
|
@app.route("/urls/manage")
|
||||||
|
def manage_urls():
|
||||||
|
fuentes = []
|
||||||
|
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")
|
||||||
|
fuentes = cursor.fetchall()
|
||||||
|
except psycopg2.Error as db_err:
|
||||||
|
app.logger.error(f"[DB ERROR] Al obtener lista de fuentes URL: {db_err}")
|
||||||
|
flash("Error al obtener la lista de fuentes URL.", "error")
|
||||||
|
return render_template("urls_list.html", fuentes=fuentes)
|
||||||
|
|
||||||
|
@app.route("/urls/add", methods=['GET', 'POST'])
|
||||||
|
def add_url_source():
|
||||||
|
if request.method == 'POST':
|
||||||
|
nombre = request.form.get("nombre")
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
categoria_id = int(request.form.get("categoria_id")) if request.form.get("categoria_id") else None
|
||||||
|
pais_id = int(request.form.get("pais_id")) if request.form.get("pais_id") else None
|
||||||
|
idioma = request.form.get("idioma", "es").strip().lower()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO fuentes_url (nombre, url, categoria_id, pais_id, idioma) VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
(nombre, request.form.get("url"), categoria_id, pais_id, idioma)
|
||||||
|
)
|
||||||
|
flash(f"Fuente URL '{nombre}' añadida correctamente.", "success")
|
||||||
|
except psycopg2.Error as db_err:
|
||||||
|
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:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
|
categorias, paises = _get_form_dependencies(cursor)
|
||||||
|
except psycopg2.Error as db_err:
|
||||||
|
app.logger.error(f"[DB ERROR] Al cargar formulario: {db_err}")
|
||||||
|
flash("No se pudieron cargar las categorías o países.", "error")
|
||||||
|
return render_template("add_url_source.html", categorias=categorias, paises=paises)
|
||||||
|
|
||||||
|
@app.route("/urls/edit/<int:url_id>", methods=["GET", "POST"])
|
||||||
|
def edit_url_source(url_id):
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
categoria_id = int(request.form.get("categoria_id")) if request.form.get("categoria_id") else None
|
||||||
|
pais_id = int(request.form.get("pais_id")) if request.form.get("pais_id") else None
|
||||||
|
idioma = request.form.get("idioma", "es").strip().lower()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE fuentes_url SET nombre=%s, url=%s, categoria_id=%s, pais_id=%s, idioma=%s WHERE id=%s",
|
||||||
|
(request.form.get("nombre"), request.form.get("url"), categoria_id, pais_id, idioma, url_id)
|
||||||
|
)
|
||||||
|
flash("Fuente URL actualizada correctamente.", "success")
|
||||||
|
except psycopg2.Error as db_err:
|
||||||
|
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:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
|
cursor.execute("SELECT * FROM fuentes_url WHERE id = %s", (url_id,))
|
||||||
|
fuente = cursor.fetchone()
|
||||||
|
if not fuente:
|
||||||
|
flash("No se encontró la fuente URL solicitada.", "error")
|
||||||
|
return redirect(url_for("manage_urls"))
|
||||||
|
categorias, paises = _get_form_dependencies(cursor)
|
||||||
|
except psycopg2.Error as db_err:
|
||||||
|
flash("Error al cargar la fuente URL para editar.", "error")
|
||||||
|
return redirect(url_for("manage_urls"))
|
||||||
|
return render_template("edit_url_source.html", fuente=fuente, categorias=categorias, paises=paises)
|
||||||
|
|
||||||
|
@app.route("/urls/delete/<int:url_id>")
|
||||||
|
def delete_url_source(url_id):
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute("DELETE FROM fuentes_url WHERE id=%s", (url_id,))
|
||||||
|
flash("Fuente URL eliminada correctamente.", "success")
|
||||||
|
except psycopg2.Error as db_err:
|
||||||
|
flash(f"Error al eliminar la fuente URL: {db_err}", "error")
|
||||||
|
return redirect(url_for("manage_urls"))
|
||||||
|
|
||||||
|
# --- PROCESAMIENTO DE URLS ---
|
||||||
|
|
||||||
|
@app.route("/scrape_url", methods=['GET', 'POST'])
|
||||||
|
def scrape_url():
|
||||||
|
if request.method == 'POST':
|
||||||
|
source_id = request.form.get("source_id")
|
||||||
|
if not source_id:
|
||||||
|
flash("Debes seleccionar una fuente para procesar.", "error")
|
||||||
|
return redirect(url_for('scrape_url'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
|
cursor.execute("SELECT * FROM fuentes_url WHERE id = %s", (source_id,))
|
||||||
|
source = cursor.fetchone()
|
||||||
|
|
||||||
|
if not source:
|
||||||
|
flash("La fuente seleccionada no existe.", "error")
|
||||||
|
return redirect(url_for('scrape_url'))
|
||||||
|
|
||||||
|
lista_noticias, message = process_newspaper_url(source['url'], source['categoria_id'], source['pais_id'], source['idioma'])
|
||||||
|
|
||||||
|
if lista_noticias:
|
||||||
|
# Se necesita una nueva conexión/cursor para la inserción
|
||||||
|
with get_conn() as insert_conn:
|
||||||
|
with insert_conn.cursor() as insert_cursor:
|
||||||
|
insert_query = """
|
||||||
|
INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, categoria_id, pais_id)
|
||||||
|
VALUES %s
|
||||||
|
ON CONFLICT (url) DO UPDATE SET
|
||||||
|
titulo = EXCLUDED.titulo,
|
||||||
|
resumen = EXCLUDED.resumen,
|
||||||
|
fecha = EXCLUDED.fecha,
|
||||||
|
imagen_url = EXCLUDED.imagen_url;
|
||||||
|
"""
|
||||||
|
psycopg2.extras.execute_values(insert_cursor, insert_query, lista_noticias)
|
||||||
|
flash(f"Se encontraron y guardaron {len(lista_noticias)} noticias desde '{source['nombre']}'.", "success")
|
||||||
|
return redirect(url_for("home"))
|
||||||
|
else:
|
||||||
|
flash(message, "warning")
|
||||||
|
|
||||||
|
except psycopg2.Error as db_err:
|
||||||
|
app.logger.error(f"[DB ERROR] Al procesar fuente URL: {db_err}", exc_info=True)
|
||||||
|
flash(f"Error de base de datos al procesar la fuente: {db_err}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for('scrape_url'))
|
||||||
|
|
||||||
|
fuentes = []
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
|
cursor.execute("SELECT id, nombre FROM fuentes_url ORDER BY nombre")
|
||||||
|
fuentes = cursor.fetchall()
|
||||||
|
except psycopg2.Error as db_err:
|
||||||
|
flash("Error al cargar las fuentes de URL.", "error")
|
||||||
|
|
||||||
|
return render_template("scrape_url.html", fuentes=fuentes)
|
||||||
|
|
||||||
|
# --- BACKUP Y RESTORE ---
|
||||||
@app.route("/backup_feeds")
|
@app.route("/backup_feeds")
|
||||||
def backup_feeds():
|
def backup_feeds():
|
||||||
try:
|
try:
|
||||||
|
|
@ -440,6 +532,9 @@ def restore_feeds():
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
return render_template("restore_feeds.html")
|
return render_template("restore_feeds.html")
|
||||||
|
|
||||||
|
|
||||||
|
# --- TAREA DE FONDO ---
|
||||||
|
|
||||||
def fetch_and_store():
|
def fetch_and_store():
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
logging.info("--- INICIANDO CICLO DE CAPTURA ---")
|
logging.info("--- INICIANDO CICLO DE CAPTURA ---")
|
||||||
|
|
@ -525,6 +620,7 @@ def fetch_and_store():
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
logging.error(f"Error de BD en actualización masiva: {db_err}", exc_info=True)
|
logging.error(f"Error de BD en actualización masiva: {db_err}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
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.")
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ WEB_PORT=8000
|
||||||
|
|
||||||
echo "🟢 Paso 0: Verificaciones y confirmación de seguridad"
|
echo "🟢 Paso 0: Verificaciones y confirmación de seguridad"
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "❌ Este script debe ser ejecutado como root (usa sudo)."
|
echo "❌ Este script debe ser ejecutado como root (usa sudo)."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -99,6 +99,7 @@ CREATE TABLE IF NOT EXISTS continentes (id SERIAL PRIMARY KEY, nombre VARCHAR(50
|
||||||
CREATE TABLE IF NOT EXISTS categorias (id SERIAL PRIMARY KEY, nombre VARCHAR(100) 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 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 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, categoria_id INTEGER REFERENCES categorias(id) ON DELETE SET NULL, pais_id INTEGER REFERENCES paises(id) ON DELETE SET NULL, tsv tsvector);
|
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, 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;
|
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;
|
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;
|
||||||
|
|
|
||||||
49
templates/add_url_source.html
Normal file
49
templates/add_url_source.html
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Añadir Fuente URL{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0">Añadir Nueva Fuente de Noticias URL</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text text-muted">Añade un periódico o sitio de noticias para poder procesar sus artículos más tarde.</p>
|
||||||
|
<form action="{{ url_for('add_url_source') }}" method="post" class="mt-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="nombre" class="form-label"><strong>Nombre de la Fuente</strong></label>
|
||||||
|
<input type="text" class="form-control" id="nombre" name="nombre" required placeholder="Ej: El País (Portada)">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="url" class="form-label"><strong>URL de la Fuente</strong></label>
|
||||||
|
<input type="url" class="form-control" id="url" name="url" required placeholder="https://elpais.com">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="categoria_id" class="form-label"><strong>Categoría por Defecto</strong></label>
|
||||||
|
<select class="form-select" id="categoria_id" name="categoria_id">
|
||||||
|
<option value="">-- Sin categoría --</option>
|
||||||
|
{% for cat in categorias %}<option value="{{ cat.id }}">{{ cat.nombre }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="pais_id" class="form-label"><strong>País por Defecto</strong></label>
|
||||||
|
<select class="form-select" id="pais_id" name="pais_id">
|
||||||
|
<option value="">-- Sin país --</option>
|
||||||
|
{% for p in paises %}<option value="{{ p.id }}">{{ p.nombre }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="idioma" class="form-label"><strong>Idioma (código de 2 letras)</strong></label>
|
||||||
|
<input type="text" class="form-control" id="idioma" name="idioma" value="es" required pattern="[a-z]{2}">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end pt-3">
|
||||||
|
<a href="{{ url_for('manage_urls') }}" class="btn btn-secondary" style="margin-right: 10px;">Cancelar</a>
|
||||||
|
<button type="submit" class="btn">Guardar Fuente</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* --- Variables Globales de Diseño --- */
|
/* --- Variables Globales de Diseño --- */
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -69,6 +69,7 @@
|
||||||
.btn:hover, button:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); text-decoration: none; }
|
.btn:hover, button:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); text-decoration: none; }
|
||||||
.btn-secondary { background: #34495e; } .btn-secondary:hover { background: #2c3e50; }
|
.btn-secondary { background: #34495e; } .btn-secondary:hover { background: #2c3e50; }
|
||||||
.btn-info { background: #17a2b8; } .btn-info:hover { background: #138496; }
|
.btn-info { background: #17a2b8; } .btn-info:hover { background: #138496; }
|
||||||
|
.btn-danger { background: #dc3545; } .btn-danger:hover { background: #c82333; }
|
||||||
.btn-small { padding: 6px 14px; font-size: 0.9rem; }
|
.btn-small { padding: 6px 14px; font-size: 0.9rem; }
|
||||||
a { color: var(--secondary-color); text-decoration: none; font-weight: 500; } a:hover { text-decoration: underline; }
|
a { color: var(--secondary-color); text-decoration: none; font-weight: 500; } a:hover { text-decoration: underline; }
|
||||||
.top-link { display: inline-block; margin-bottom: 25px; font-weight: 500; color: var(--primary-color); }
|
.top-link { display: inline-block; margin-bottom: 25px; font-weight: 500; color: var(--primary-color); }
|
||||||
|
|
@ -110,7 +111,7 @@
|
||||||
.feed-body dt { font-weight: 600; color: var(--text-color-light); }
|
.feed-body dt { font-weight: 600; color: var(--text-color-light); }
|
||||||
.feed-body dd { margin: 0; word-break: break-all; }
|
.feed-body dd { margin: 0; word-break: break-all; }
|
||||||
|
|
||||||
/* --- NUEVOS ESTILOS PARA LA NAVEGACIÓN PRINCIPAL --- */
|
/* --- Estilos para la Navegación Principal --- */
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -136,7 +137,7 @@
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-left: 20px; /* Espacio entre los enlaces y los botones */
|
margin-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Responsividad --- */
|
/* --- Responsividad --- */
|
||||||
|
|
@ -163,10 +164,10 @@
|
||||||
<a href="{{ url_for('home') }}" class="nav-link">Noticias</a>
|
<a href="{{ url_for('home') }}" class="nav-link">Noticias</a>
|
||||||
<a href="{{ url_for('dashboard') }}" class="nav-link">Dashboard</a>
|
<a href="{{ url_for('dashboard') }}" class="nav-link">Dashboard</a>
|
||||||
<a href="{{ url_for('manage_feeds') }}" class="nav-link">Gestionar Feeds</a>
|
<a href="{{ url_for('manage_feeds') }}" class="nav-link">Gestionar Feeds</a>
|
||||||
|
<a href="{{ url_for('manage_urls') }}" class="nav-link">Gestionar URLs</a>
|
||||||
|
|
||||||
<div class="nav-actions">
|
<div class="nav-actions">
|
||||||
<a href="{{ url_for('add_feed') }}" class="btn btn-small">Añadir Feed</a>
|
<a href="{{ url_for('scrape_url') }}" class="btn btn-small btn-info">Procesar URL</a>
|
||||||
<a href="{{ url_for('add_url') }}" class="btn btn-small btn-info">Añadir URL</a>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,10 @@
|
||||||
{% block title %}Dashboard de Feeds{% endblock %}
|
{% block title %}Dashboard de Feeds{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header>
|
|
||||||
<h1>Dashboard de Feeds</h1>
|
|
||||||
<p class="subtitle">Un resumen del estado de tu agregador de noticias.</p>
|
|
||||||
<a href="{{ url_for('home') }}" class="top-link">← Volver a las Noticias</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-number">{{ stats.feeds_totales }}</div>
|
<div class="stat-number">{{ stats.feeds_totales }}</div>
|
||||||
<div class="stat-label">Feeds Totales</div>
|
<div class="stat-label">Feeds RSS Totales</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-number">{{ stats.noticias_totales }}</div>
|
<div class="stat-number">{{ stats.noticias_totales }}</div>
|
||||||
|
|
@ -26,9 +20,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="text-align: center; margin-bottom: 20px;">Opciones de Backup</h2>
|
<h2 style="text-align: center; margin-bottom: 20px;">Opciones de Backup y Restauración</h2>
|
||||||
<p style="text-align: center; color: var(--text-color-light); margin-bottom: 25px;">
|
<p style="text-align: center; color: var(--text-color-light); margin-bottom: 25px;">
|
||||||
Exporta tus datos para mantener copias de seguridad seguras
|
Exporta tus datos para mantener copias de seguridad o restaura desde un archivo.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: center; gap: 15px; flex-wrap: wrap;">
|
<div style="display: flex; justify-content: center; gap: 15px; flex-wrap: wrap;">
|
||||||
|
|
@ -41,24 +35,10 @@
|
||||||
<a href="{{ url_for('backup_completo') }}" class="btn" style="background: linear-gradient(135deg, #00b894 0%, #00cec9 100%); display: inline-flex; align-items: center; gap: 8px;">
|
<a href="{{ url_for('backup_completo') }}" class="btn" style="background: linear-gradient(135deg, #00b894 0%, #00cec9 100%); display: inline-flex; align-items: center; gap: 8px;">
|
||||||
<i class="fas fa-file-archive"></i> Completo (ZIP)
|
<i class="fas fa-file-archive"></i> Completo (ZIP)
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="text-align: center; margin-top: 30px;">
|
|
||||||
<h2 style="margin-bottom: 20px;">Gestionar Feeds</h2>
|
|
||||||
<p style="color: var(--text-color-light); margin-bottom: 25px;">
|
|
||||||
Administra tu lista de fuentes de noticias
|
|
||||||
</p>
|
|
||||||
<div style="display: flex; justify-content: center; gap: 15px; flex-wrap: wrap;">
|
|
||||||
<a href="{{ url_for('manage_feeds') }}" class="btn" style="display: inline-flex; align-items: center; gap: 8px;">
|
|
||||||
<i class="fas fa-list"></i> Lista Completa
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('add_feed') }}" class="btn" style="display: inline-flex; align-items: center; gap: 8px;">
|
|
||||||
<i class="fas fa-plus"></i> Nuevo Feed
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('restore_feeds') }}" class="btn btn-secondary" style="display: inline-flex; align-items: center; gap: 8px;">
|
<a href="{{ url_for('restore_feeds') }}" class="btn btn-secondary" style="display: inline-flex; align-items: center; gap: 8px;">
|
||||||
<i class="fas fa-file-import"></i> Importar
|
<i class="fas fa-file-import"></i> Importar Feeds
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
37
templates/edit_url_source.html
Normal file
37
templates/edit_url_source.html
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Editar Fuente URL{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Editar Fuente: {{ fuente.nombre }}</h2>
|
||||||
|
<form action="{{ url_for('edit_url_source', url_id=fuente.id) }}" method="post" class="form-section">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="nombre">Nombre de la Fuente</label>
|
||||||
|
<input type="text" id="nombre" name="nombre" value="{{ fuente.nombre }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="url">URL de la Fuente</label>
|
||||||
|
<input type="url" id="url" name="url" value="{{ fuente.url }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="categoria_id">Categoría por Defecto</label>
|
||||||
|
<select id="categoria_id" name="categoria_id">
|
||||||
|
<option value="">-- Sin categoría --</option>
|
||||||
|
{% for cat in categorias %}<option value="{{ cat.id }}" {% if cat.id == fuente.categoria_id %}selected{% endif %}>{{ cat.nombre }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="pais_id">País por Defecto</label>
|
||||||
|
<select id="pais_id" name="pais_id">
|
||||||
|
<option value="">-- Sin país --</option>
|
||||||
|
{% for p in paises %}<option value="{{ p.id }}" {% if p.id == fuente.pais_id %}selected{% endif %}>{{ p.nombre }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="idioma">Idioma (código de 2 letras)</label>
|
||||||
|
<input type="text" id="idioma" name="idioma" value="{{ fuente.idioma }}" required pattern="[a-z]{2}">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a href="{{ url_for('manage_urls') }}" class="btn btn-secondary" style="margin-right: 10px;">Cancelar</a>
|
||||||
|
<button type="submit" class="btn">Actualizar Fuente</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
30
templates/scrape_url.html
Normal file
30
templates/scrape_url.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Scrapear Noticias desde Fuente URL{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h4>Procesar Noticias desde una Fuente Guardada</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ url_for('scrape_url') }}" method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="source_id" class="form-label"><strong>Selecciona una fuente de noticias:</strong></label>
|
||||||
|
<select class="form-select" id="source_id" name="source_id" required>
|
||||||
|
<option value="" disabled selected>-- Elige un periódico --</option>
|
||||||
|
{% for fuente in fuentes %}
|
||||||
|
<option value="{{ fuente.id }}">{{ fuente.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end pt-3">
|
||||||
|
<button type="submit" class="btn">Procesar Noticias</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
44
templates/urls_list.html
Normal file
44
templates/urls_list.html
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Gestionar Fuentes URL{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="feed-detail-card">
|
||||||
|
<div class="feed-header">
|
||||||
|
<h2>Fuentes de Noticias URL</h2>
|
||||||
|
<a href="{{ url_for('add_url_source') }}" class="btn btn-small">Añadir Nueva Fuente URL</a>
|
||||||
|
</div>
|
||||||
|
<div class="feed-body">
|
||||||
|
{% if fuentes %}
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Categoría</th>
|
||||||
|
<th>País</th>
|
||||||
|
<th>Idioma</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for fuente in fuentes %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ fuente.nombre }}</td>
|
||||||
|
<td><a href="{{ fuente.url }}" target="_blank">{{ fuente.url[:50] }}...</a></td>
|
||||||
|
<td>{{ fuente.categoria or 'N/A' }}</td>
|
||||||
|
<td>{{ fuente.pais or 'N/A' }}</td>
|
||||||
|
<td>{{ fuente.idioma }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('edit_url_source', url_id=fuente.id) }}" class="btn btn-small btn-secondary">Editar</a>
|
||||||
|
<a href="{{ url_for('delete_url_source', url_id=fuente.id) }}" class="btn btn-small btn-danger" onclick="return confirm('¿Estás seguro de que quieres eliminar esta fuente?');">Eliminar</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center text-muted">No hay fuentes URL guardadas. ¡Añade la primera!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -11,62 +11,52 @@ def _process_individual_article(article_url, config):
|
||||||
Está diseñada para ser ejecutada en un hilo separado.
|
Está diseñada para ser ejecutada en un hilo separado.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Es crucial crear un nuevo objeto Article dentro de cada hilo.
|
|
||||||
article = newspaper.Article(article_url, config=config)
|
article = newspaper.Article(article_url, config=config)
|
||||||
article.download()
|
article.download()
|
||||||
|
|
||||||
# Un artículo necesita ser parseado para tener título, texto, etc.
|
|
||||||
article.parse()
|
article.parse()
|
||||||
|
|
||||||
# Si no se pudo obtener título o texto, no es un artículo válido.
|
|
||||||
if not article.title or not article.text:
|
if not article.title or not article.text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# El método nlp() es necesario para el resumen.
|
|
||||||
article.nlp()
|
article.nlp()
|
||||||
return article
|
return article
|
||||||
except Exception:
|
except Exception:
|
||||||
# Ignoramos errores en artículos individuales (p.ej., enlaces rotos, etc.)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def process_newspaper_url(url, categoria_id, pais_id):
|
def process_newspaper_url(url, categoria_id, pais_id, idioma='es'):
|
||||||
"""
|
"""
|
||||||
Explora la URL de un periódico, extrae los artículos que encuentra
|
Explora la URL de un periódico, extrae los artículos que encuentra
|
||||||
en paralelo y devuelve una lista de noticias listas para la base de datos.
|
en paralelo y devuelve una lista de noticias listas para la base de datos.
|
||||||
"""
|
"""
|
||||||
logging.info(f"Iniciando el scrapeo en paralelo de la fuente: {url}")
|
logging.info(f"Iniciando el scrapeo en paralelo de la fuente: {url} (idioma: {idioma})")
|
||||||
|
|
||||||
todas_las_noticias = []
|
todas_las_noticias = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = Config()
|
config = Config()
|
||||||
config.browser_user_agent = 'RssApp/1.0 (Scraper)'
|
config.browser_user_agent = 'RssApp/1.0 (Scraper)'
|
||||||
config.request_timeout = 15 # Timeout más corto para artículos individuales.
|
config.request_timeout = 15
|
||||||
config.memoize_articles = False # No guardar en caché para obtener siempre lo último.
|
config.memoize_articles = False
|
||||||
|
|
||||||
source = newspaper.build(url, config=config, language='es')
|
# Usamos el idioma proporcionado para mejorar la extracción
|
||||||
|
source = newspaper.build(url, config=config, language=idioma)
|
||||||
|
|
||||||
# Limitar el número de artículos para no sobrecargar el servidor.
|
|
||||||
articles_to_process = source.articles[:25]
|
articles_to_process = source.articles[:25]
|
||||||
|
|
||||||
logging.info(f"Fuente construida. Procesando {len(articles_to_process)} artículos en paralelo...")
|
logging.info(f"Fuente construida. Procesando {len(articles_to_process)} artículos en paralelo...")
|
||||||
|
|
||||||
# Usamos un ThreadPoolExecutor para procesar los artículos concurrentemente.
|
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
# Creamos un futuro para cada URL de artículo.
|
|
||||||
future_to_article = {executor.submit(_process_individual_article, article.url, config): article for article in articles_to_process}
|
future_to_article = {executor.submit(_process_individual_article, article.url, config): article for article in articles_to_process}
|
||||||
|
|
||||||
for future in as_completed(future_to_article):
|
for future in as_completed(future_to_article):
|
||||||
processed_article = future.result()
|
processed_article = future.result()
|
||||||
|
|
||||||
# Si el artículo se procesó correctamente, lo añadimos a la lista.
|
|
||||||
if processed_article:
|
if processed_article:
|
||||||
noticia_id = hashlib.md5(processed_article.url.encode()).hexdigest()
|
noticia_id = hashlib.md5(processed_article.url.encode()).hexdigest()
|
||||||
|
|
||||||
if processed_article.summary:
|
if processed_article.summary:
|
||||||
resumen = processed_article.summary
|
resumen = processed_article.summary
|
||||||
else:
|
else:
|
||||||
# Fallback a un extracto del texto si no hay resumen.
|
|
||||||
resumen = (processed_article.text[:400] + '...') if len(processed_article.text) > 400 else processed_article.text
|
resumen = (processed_article.text[:400] + '...') if len(processed_article.text) > 400 else processed_article.text
|
||||||
|
|
||||||
fecha = processed_article.publish_date if processed_article.publish_date else datetime.now()
|
fecha = processed_article.publish_date if processed_article.publish_date else datetime.now()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue