Actualización del 2025-06-15 a las 22:45:55
This commit is contained in:
parent
603149d47a
commit
eb72ec9e56
5 changed files with 114 additions and 94 deletions
73
app.py
73
app.py
|
|
@ -93,7 +93,8 @@ def home():
|
||||||
paises = cursor.fetchall()
|
paises = cursor.fetchall()
|
||||||
|
|
||||||
sql_params, conditions = [], []
|
sql_params, conditions = [], []
|
||||||
sql_base = "SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url, 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 AÑADE 'fuente_nombre' AL SELECT ---
|
||||||
|
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:
|
if q:
|
||||||
search_query = " & ".join(q.split())
|
search_query = " & ".join(q.split())
|
||||||
|
|
@ -361,40 +362,44 @@ def scrape_url():
|
||||||
flash("Debes seleccionar una fuente para procesar.", "error")
|
flash("Debes seleccionar una fuente para procesar.", "error")
|
||||||
return redirect(url_for('scrape_url'))
|
return redirect(url_for('scrape_url'))
|
||||||
|
|
||||||
|
source = 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:
|
||||||
cursor.execute("SELECT * FROM fuentes_url WHERE id = %s", (source_id,))
|
cursor.execute("SELECT * FROM fuentes_url WHERE id = %s", (source_id,))
|
||||||
source = cursor.fetchone()
|
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:
|
except psycopg2.Error as db_err:
|
||||||
app.logger.error(f"[DB ERROR] Al procesar fuente URL: {db_err}", exc_info=True)
|
app.logger.error(f"[DB ERROR] Al buscar fuente URL: {db_err}", exc_info=True)
|
||||||
flash(f"Error de base de datos al procesar la fuente: {db_err}", "error")
|
flash("Error de base de datos al buscar la fuente.", "error")
|
||||||
|
return redirect(url_for('scrape_url'))
|
||||||
|
|
||||||
|
if not source:
|
||||||
|
flash("La fuente seleccionada no existe.", "error")
|
||||||
|
return redirect(url_for('scrape_url'))
|
||||||
|
|
||||||
|
lista_noticias, message = process_newspaper_url(source['nombre'], source['url'], source['categoria_id'], source['pais_id'], source['idioma'])
|
||||||
|
|
||||||
|
if lista_noticias:
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
insert_query = """
|
||||||
|
INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, fuente_nombre, 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)
|
||||||
|
flash(f"Se encontraron y guardaron {len(lista_noticias)} noticias desde '{source['nombre']}'.", "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:
|
||||||
|
flash(message, "warning")
|
||||||
|
|
||||||
return redirect(url_for('scrape_url'))
|
return redirect(url_for('scrape_url'))
|
||||||
|
|
||||||
|
|
@ -437,7 +442,7 @@ def backup_noticias():
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor:
|
||||||
cursor.execute("SELECT n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url, c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente FROM noticias n LEFT JOIN categorias c ON n.categoria_id = c.id LEFT JOIN paises p ON n.pais_id = p.id LEFT JOIN continentes co ON p.continente_id = co.id ORDER BY n.fecha DESC")
|
cursor.execute("SELECT n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url, n.fuente_nombre, c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente FROM noticias n LEFT JOIN categorias c ON n.categoria_id = c.id LEFT JOIN paises p ON n.pais_id = p.id LEFT JOIN continentes co ON p.continente_id = co.id ORDER BY n.fecha DESC")
|
||||||
noticias = cursor.fetchall()
|
noticias = cursor.fetchall()
|
||||||
if not noticias:
|
if not noticias:
|
||||||
flash("No hay noticias para exportar.", "warning")
|
flash("No hay noticias para exportar.", "warning")
|
||||||
|
|
@ -471,7 +476,7 @@ def backup_completo():
|
||||||
writer.writerows([dict(f) for f in feeds_data])
|
writer.writerows([dict(f) for f in feeds_data])
|
||||||
zipf.writestr("feeds.csv", output.getvalue())
|
zipf.writestr("feeds.csv", output.getvalue())
|
||||||
|
|
||||||
cursor.execute("SELECT n.id, n.titulo, n.resumen, n.url, n.fecha, n.imagen_url, 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_data = cursor.fetchall()
|
noticias_data = cursor.fetchall()
|
||||||
if noticias_data:
|
if noticias_data:
|
||||||
fieldnames_noticias = list(noticias_data[0].keys())
|
fieldnames_noticias = list(noticias_data[0].keys())
|
||||||
|
|
@ -543,7 +548,8 @@ def fetch_and_store():
|
||||||
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:
|
||||||
logging.info("Paso 1: Obteniendo lista de feeds...")
|
logging.info("Paso 1: Obteniendo lista de feeds...")
|
||||||
cursor.execute("SELECT id, url, categoria_id, pais_id, last_etag, last_modified FROM feeds WHERE activo = TRUE")
|
# --- CORRECCIÓN: Se añade 'nombre' al SELECT ---
|
||||||
|
cursor.execute("SELECT id, nombre, url, categoria_id, pais_id, last_etag, last_modified FROM feeds WHERE activo = TRUE")
|
||||||
feeds_to_process = cursor.fetchall()
|
feeds_to_process = cursor.fetchall()
|
||||||
logging.info(f"Paso 2: {len(feeds_to_process)} feeds para procesar.")
|
logging.info(f"Paso 2: {len(feeds_to_process)} feeds para procesar.")
|
||||||
except psycopg2.Error as db_err:
|
except psycopg2.Error as db_err:
|
||||||
|
|
@ -608,9 +614,10 @@ def fetch_and_store():
|
||||||
if todas_las_noticias:
|
if todas_las_noticias:
|
||||||
logging.info(f"Intentando insertar {len(todas_las_noticias)} noticias en la base de datos.")
|
logging.info(f"Intentando insertar {len(todas_las_noticias)} noticias en la base de datos.")
|
||||||
with conn.cursor() as cursor_news_insert:
|
with conn.cursor() as cursor_news_insert:
|
||||||
|
# --- CORRECCIÓN: Se añade 'fuente_nombre' a la consulta INSERT ---
|
||||||
psycopg2.extras.execute_values(
|
psycopg2.extras.execute_values(
|
||||||
cursor_news_insert,
|
cursor_news_insert,
|
||||||
"INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, categoria_id, pais_id) VALUES %s ON CONFLICT (id) DO NOTHING",
|
"INSERT INTO noticias (id, titulo, resumen, url, fecha, imagen_url, fuente_nombre, categoria_id, pais_id) VALUES %s ON CONFLICT (url) DO NOTHING",
|
||||||
todas_las_noticias
|
todas_las_noticias
|
||||||
)
|
)
|
||||||
rows_inserted = cursor_news_insert.rowcount
|
rows_inserted = cursor_news_insert.rowcount
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
# /home/x/rss/feed_processor.py
|
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
import feedparser
|
import feedparser
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import requests
|
import requests
|
||||||
import xml.sax._exceptions # Make sure this import is present
|
import xml.sax._exceptions
|
||||||
|
|
||||||
# You might want to define these constants in a central config or pass them
|
NETWORK_TIMEOUT = 15 # segundos
|
||||||
NETWORK_TIMEOUT = 15 # seconds for fetching the feed
|
|
||||||
|
|
||||||
def process_single_feed(feed_data):
|
def process_single_feed(feed_data):
|
||||||
"""
|
"""
|
||||||
|
|
@ -17,6 +14,9 @@ def process_single_feed(feed_data):
|
||||||
"""
|
"""
|
||||||
feed_id = feed_data['id']
|
feed_id = feed_data['id']
|
||||||
feed_url = feed_data['url']
|
feed_url = feed_data['url']
|
||||||
|
# --- LÍNEA CLAVE ---
|
||||||
|
# Obtenemos el nombre del feed para usarlo como fuente de la noticia.
|
||||||
|
feed_nombre = feed_data.get('nombre', 'Fuente Desconocida')
|
||||||
etag = feed_data.get('last_etag')
|
etag = feed_data.get('last_etag')
|
||||||
modified = feed_data.get('last_modified')
|
modified = feed_data.get('last_modified')
|
||||||
|
|
||||||
|
|
@ -25,43 +25,28 @@ def process_single_feed(feed_data):
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
headers = {}
|
headers = {'User-Agent': 'RssApp/1.0'}
|
||||||
if etag:
|
if etag:
|
||||||
headers['If-None-Match'] = etag
|
headers['If-None-Match'] = etag
|
||||||
if modified:
|
if modified:
|
||||||
headers['If-Modified-Since'] = modified
|
headers['If-Modified-Since'] = modified
|
||||||
|
|
||||||
response = requests.get(feed_url, headers=headers, timeout=NETWORK_TIMEOUT)
|
response = requests.get(feed_url, headers=headers, timeout=NETWORK_TIMEOUT)
|
||||||
response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
|
response.raise_for_status()
|
||||||
|
|
||||||
if response.status_code == 304:
|
if response.status_code == 304:
|
||||||
logging.info(f"Feed {feed_url} (ID: {feed_id}) no modificado (304).")
|
logging.info(f"Feed {feed_url} (ID: {feed_id}) no modificado (304).")
|
||||||
# Return existing etag/modified if not modified, as per standard HTTP caching
|
|
||||||
return feed_id, [], etag, modified, True
|
return feed_id, [], etag, modified, True
|
||||||
|
|
||||||
parsed = feedparser.parse(response.content)
|
parsed = feedparser.parse(response.content)
|
||||||
|
|
||||||
# Check if parsed.bozo is set, meaning there was an issue during parsing
|
if parsed.bozo and isinstance(parsed.bozo_exception, xml.sax._exceptions.SAXParseException):
|
||||||
if parsed.bozo:
|
logging.error(f"Feed malformado para {feed_url} (ID: {feed_id}): {parsed.bozo_exception}")
|
||||||
# feedparser.bozo_exception will contain the actual exception
|
return feed_id, [], None, None, False
|
||||||
# We catch specific bozo exceptions for better error logging
|
|
||||||
if isinstance(parsed.bozo_exception, (feedparser.CharacterEncodingOverride, feedparser.NonXMLContentType)):
|
|
||||||
# These are usually harmless warnings; we can proceed
|
|
||||||
logging.warning(f"Advertencia al parsear feed {feed_url} (ID: {feed_id}): {parsed.bozo_exception}")
|
|
||||||
elif isinstance(parsed.bozo_exception, xml.sax._exceptions.SAXParseException):
|
|
||||||
# This is a critical parsing error (e.g., invalid XML)
|
|
||||||
logging.error(f"Feed malformado para {feed_url} (ID: {feed_id}): {parsed.bozo_exception}")
|
|
||||||
return feed_id, [], None, None, False # Indicate failure due to parsing error
|
|
||||||
else:
|
|
||||||
# Catch any other unexpected bozo exceptions
|
|
||||||
logging.error(f"Excepción inesperada de bozo en feed {feed_url} (ID: {feed_id}): {parsed.bozo_exception}")
|
|
||||||
return feed_id, [], None, None, False # Indicate failure
|
|
||||||
|
|
||||||
# Proceed only if parsing was successful or had minor warnings
|
|
||||||
if not parsed.entries:
|
if not parsed.entries:
|
||||||
logging.warning(f"Feed {feed_url} (ID: {feed_id}) no contiene entradas.")
|
logging.warning(f"Feed {feed_url} (ID: {feed_id}) no contiene entradas.")
|
||||||
# If no entries but parsing was successful, update etag/modified
|
return feed_id, [], parsed.get('etag'), parsed.get('modified'), True
|
||||||
return feed_id, [], parsed.get('etag'), parsed.get('modified'), True
|
|
||||||
|
|
||||||
for entry in parsed.entries:
|
for entry in parsed.entries:
|
||||||
link = entry.get("link")
|
link = entry.get("link")
|
||||||
|
|
@ -73,9 +58,7 @@ def process_single_feed(feed_data):
|
||||||
resumen_html = entry.get("summary", "")
|
resumen_html = entry.get("summary", "")
|
||||||
imagen_url = ""
|
imagen_url = ""
|
||||||
|
|
||||||
# Attempt to get image from media:content or from HTML summary
|
|
||||||
if "media_content" in entry and entry.media_content:
|
if "media_content" in entry and entry.media_content:
|
||||||
# Assuming the first media_content is the relevant one with a 'url'
|
|
||||||
imagen_url = entry.media_content[0].get("url", "")
|
imagen_url = entry.media_content[0].get("url", "")
|
||||||
elif resumen_html:
|
elif resumen_html:
|
||||||
soup = BeautifulSoup(resumen_html, 'html.parser')
|
soup = BeautifulSoup(resumen_html, 'html.parser')
|
||||||
|
|
@ -84,17 +67,19 @@ def process_single_feed(feed_data):
|
||||||
imagen_url = img_tag['src']
|
imagen_url = img_tag['src']
|
||||||
|
|
||||||
resumen_texto_plano = BeautifulSoup(resumen_html, 'html.parser').get_text(separator=' ', strip=True)
|
resumen_texto_plano = BeautifulSoup(resumen_html, 'html.parser').get_text(separator=' ', strip=True)
|
||||||
fecha_publicacion = datetime.now() # Default to now if no publication date
|
fecha_publicacion = datetime.now()
|
||||||
if hasattr(entry, 'published_parsed') and entry.published_parsed:
|
if hasattr(entry, 'published_parsed') and entry.published_parsed:
|
||||||
fecha_publicacion = datetime(*entry.published_parsed[:6])
|
fecha_publicacion = datetime(*entry.published_parsed[:6])
|
||||||
elif hasattr(entry, 'updated_parsed') and entry.updated_parsed:
|
elif hasattr(entry, 'updated_parsed') and entry.updated_parsed:
|
||||||
fecha_publicacion = datetime(*entry.updated_parsed[:6])
|
fecha_publicacion = datetime(*entry.updated_parsed[:6])
|
||||||
|
|
||||||
|
# --- LÍNEA CLAVE ---
|
||||||
|
# Añadimos 'feed_nombre' a la tupla de datos que se guardará en la BD.
|
||||||
noticias_encontradas.append(
|
noticias_encontradas.append(
|
||||||
(noticia_id, titulo, resumen_texto_plano, link, fecha_publicacion, imagen_url, feed_data['categoria_id'], feed_data['pais_id'])
|
(noticia_id, titulo, resumen_texto_plano, link, fecha_publicacion,
|
||||||
|
imagen_url, feed_nombre, feed_data['categoria_id'], feed_data['pais_id'])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get ETag and Last-Modified headers from the response
|
|
||||||
new_etag = response.headers.get('ETag')
|
new_etag = response.headers.get('ETag')
|
||||||
new_modified = response.headers.get('Last-Modified')
|
new_modified = response.headers.get('Last-Modified')
|
||||||
success = True
|
success = True
|
||||||
|
|
@ -103,7 +88,8 @@ def process_single_feed(feed_data):
|
||||||
logging.error(f"TIMEOUT al intentar obtener el feed {feed_url} (ID: {feed_id})")
|
logging.error(f"TIMEOUT al intentar obtener el feed {feed_url} (ID: {feed_id})")
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logging.error(f"Error de HTTP/red para el feed {feed_url} (ID: {feed_id}): {e}")
|
logging.error(f"Error de HTTP/red para el feed {feed_url} (ID: {feed_id}): {e}")
|
||||||
except Exception as e: # General Exception for any other unexpected errors during the process
|
except Exception as e:
|
||||||
logging.error(f"Excepción inesperada al procesar el feed {feed_url} (ID: {feed_id}): {e}", exc_info=True)
|
logging.error(f"Excepción inesperada al procesar el feed {feed_url} (ID: {feed_id}): {e}", exc_info=True)
|
||||||
|
|
||||||
return feed_id, noticias_encontradas, new_etag, new_modified, success
|
return feed_id, noticias_encontradas, new_etag, new_modified, success
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ rm -f /etc/systemd/system/$APP_NAME*
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
echo " -> Servicios systemd limpiados."
|
echo " -> Servicios systemd limpiados."
|
||||||
|
|
||||||
echo "🟢 Paso 1: Instalando dependencias del sistema (PostgreSQL, Python, Gunicorn...)"
|
echo "🟢 Paso 1: Instalando dependencias del sistema..."
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y wget ca-certificates postgresql postgresql-contrib python3-venv python3-pip python3-dev libpq-dev gunicorn
|
apt-get install -y wget ca-certificates postgresql postgresql-contrib python3-venv python3-pip python3-dev libpq-dev gunicorn
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ else
|
||||||
echo "⚠️ ADVERTENCIA: No se encontró download_models.py. El scraping de URLs puede fallar."
|
echo "⚠️ ADVERTENCIA: No se encontró download_models.py. El scraping de URLs puede fallar."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📐 Paso 4: Creando esquema de BD, configurando FTS y sembrando datos desde archivos .sql..."
|
echo "📐 Paso 4: Creando esquema de BD y sembrando datos..."
|
||||||
export PGPASSWORD="$DB_PASS"
|
export PGPASSWORD="$DB_PASS"
|
||||||
|
|
||||||
psql -U "$DB_USER" -h localhost -d "$DB_NAME" <<SQL
|
psql -U "$DB_USER" -h localhost -d "$DB_NAME" <<SQL
|
||||||
|
|
@ -100,7 +100,7 @@ CREATE TABLE IF NOT EXISTS categorias (id SERIAL PRIMARY KEY, nombre VARCHAR(100
|
||||||
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 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, 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;
|
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;
|
||||||
DROP TRIGGER IF EXISTS tsvectorupdate ON noticias;
|
DROP TRIGGER IF EXISTS tsvectorupdate ON noticias;
|
||||||
|
|
@ -167,7 +167,7 @@ Environment="DB_PORT=5432"
|
||||||
Environment="DB_NAME=$DB_NAME"
|
Environment="DB_NAME=$DB_NAME"
|
||||||
Environment="DB_USER=$DB_USER"
|
Environment="DB_USER=$DB_USER"
|
||||||
Environment="DB_PASS=$DB_PASS"
|
Environment="DB_PASS=$DB_PASS"
|
||||||
ExecStart=$PYTHON_ENV/bin/gunicorn --workers 3 --bind 0.0.0.0:$WEB_PORT $WSGI_APP_ENTRY
|
ExecStart=$PYTHON_ENV/bin/gunicorn --workers 3 --bind 0.0.0.0:$WEB_PORT --timeout 120 $WSGI_APP_ENTRY
|
||||||
Restart=always
|
Restart=always
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,37 @@
|
||||||
<div class="noticias-list">
|
<ul class="noticias-list">
|
||||||
{% for noticia in noticias %}
|
{% for noticia in noticias %}
|
||||||
<article class="noticia-item">
|
<li class="noticia-item">
|
||||||
{% if noticia.imagen_url %}
|
{% if noticia.imagen_url %}
|
||||||
<div class="noticia-imagen">
|
<div class="noticia-imagen">
|
||||||
<a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer"><img src="{{ noticia.imagen_url }}" alt="{{ noticia.titulo }}" loading="lazy"></a>
|
<a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer"><img src="{{ noticia.imagen_url }}" alt="Imagen para {{ noticia.titulo }}" loading="lazy"></a>
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="noticia-texto">
|
|
||||||
<h3><a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer">{{ noticia.titulo }}</a></h3>
|
|
||||||
<div class="noticia-meta">
|
|
||||||
<span><i class="far fa-calendar-alt"></i> {{ noticia.fecha.strftime('%d-%m-%Y %H:%M') if noticia.fecha else 'N/D' }}</span> |
|
|
||||||
<span><i class="fas fa-tag"></i> {{ noticia.categoria or 'N/A' }}</span> |
|
|
||||||
<span><i class="fas fa-globe-americas"></i> {{ noticia.pais or 'Global' }}</span>
|
|
||||||
</div>
|
|
||||||
<p>{{ noticia.resumen | striptags | safe_html | truncate(280) }}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% else %}
|
|
||||||
<div class="card" style="text-align:center;">
|
|
||||||
<p><i class="fas fa-info-circle"></i> No hay noticias que mostrar con los filtros seleccionados.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="noticia-texto">
|
||||||
|
<h3><a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer">{{ noticia.titulo }}</a></h3>
|
||||||
|
<div class="noticia-meta">
|
||||||
|
<span><i class="far fa-calendar-alt"></i> {{ noticia.fecha.strftime('%d-%m-%Y %H:%M') if noticia.fecha else 'N/D' }}</span>
|
||||||
|
|
||||||
|
<!-- INICIO DE LA MODIFICACIÓN: Se añade la fuente de la noticia -->
|
||||||
|
{% if noticia.fuente_nombre %}
|
||||||
|
| <span><i class="fas fa-newspaper"></i> <strong>{{ noticia.fuente_nombre }}</strong></span>
|
||||||
|
{% endif %}
|
||||||
|
<!-- FIN DE LA MODIFICACIÓN -->
|
||||||
|
|
||||||
|
{% if noticia.categoria %}
|
||||||
|
| <span><i class="fas fa-tag"></i> {{ noticia.categoria }}</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if noticia.pais %}
|
||||||
|
| <span><i class="fas fa-globe-americas"></i> {{ noticia.pais }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p>{{ noticia.resumen | safe_html | truncate(280) }}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="text-center p-4">
|
||||||
|
<i class="fas fa-info-circle"></i> No hay noticias que mostrar con los filtros seleccionados.
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,25 @@ 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, idioma='es'):
|
def process_newspaper_url(source_name, 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.
|
||||||
|
|
@ -35,32 +41,39 @@ def process_newspaper_url(url, categoria_id, pais_id, idioma='es'):
|
||||||
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
|
config.request_timeout = 15 # Timeout más corto para artículos individuales.
|
||||||
config.memoize_articles = False
|
config.memoize_articles = False # No guardar en caché para obtener siempre lo último.
|
||||||
|
|
||||||
# Usamos el idioma proporcionado para mejorar la extracción
|
# Usamos el idioma proporcionado para mejorar la extracción
|
||||||
source = newspaper.build(url, config=config, language=idioma)
|
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()
|
||||||
|
|
||||||
|
# --- LÍNEA CLAVE ---
|
||||||
|
# Añadimos 'source_name' a la tupla de datos
|
||||||
todas_las_noticias.append((
|
todas_las_noticias.append((
|
||||||
noticia_id,
|
noticia_id,
|
||||||
processed_article.title,
|
processed_article.title,
|
||||||
|
|
@ -68,6 +81,7 @@ def process_newspaper_url(url, categoria_id, pais_id, idioma='es'):
|
||||||
processed_article.url,
|
processed_article.url,
|
||||||
fecha,
|
fecha,
|
||||||
processed_article.top_image or '',
|
processed_article.top_image or '',
|
||||||
|
source_name,
|
||||||
categoria_id,
|
categoria_id,
|
||||||
pais_id
|
pais_id
|
||||||
))
|
))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue