Refactor favorites management to use Redis
- Removed local favorites.json file and related file handling in the code. - Implemented Redis caching for managing favorite articles, including methods to set, get, and check favorites. - Updated TelegramManager and server API to interact with Redis for favorite operations. - Added search functionality for articles in Redis, enhancing user experience. - Adjusted frontend components to support searching and displaying articles from Redis.
This commit is contained in:
@@ -36,7 +36,6 @@ services:
|
|||||||
# Montar archivos de configuración y datos en ubicación predecible
|
# Montar archivos de configuración y datos en ubicación predecible
|
||||||
- ./config.yaml:/data/config.yaml:ro
|
- ./config.yaml:/data/config.yaml:ro
|
||||||
- ./workers.json:/data/workers.json:rw
|
- ./workers.json:/data/workers.json:rw
|
||||||
- ./favorites.json:/data/favorites.json:rw
|
|
||||||
- ./logs:/data/logs:rw
|
- ./logs:/data/logs:rw
|
||||||
# Montar el directorio raíz para acceso a archivos
|
# Montar el directorio raíz para acceso a archivos
|
||||||
- .:/data/project:ro
|
- .:/data/project:ro
|
||||||
@@ -83,7 +82,6 @@ services:
|
|||||||
# Montar archivos de configuración
|
# Montar archivos de configuración
|
||||||
- ./config.yaml:/app/config.yaml:ro
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
- ./workers.json:/app/workers.json:ro
|
- ./workers.json:/app/workers.json:ro
|
||||||
- ./favorites.json:/app/favorites.json:rw
|
|
||||||
# Montar directorio de logs en lugar del archivo para evitar problemas
|
# Montar directorio de logs en lugar del archivo para evitar problemas
|
||||||
- ./logs:/app/logs:rw
|
- ./logs:/app/logs:rw
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
[
|
[]
|
||||||
]
|
|
||||||
@@ -82,6 +82,16 @@ class RedisArticleCache:
|
|||||||
try:
|
try:
|
||||||
key = self._get_article_key(article)
|
key = self._get_article_key(article)
|
||||||
# Guardar toda la información del artículo como JSON
|
# Guardar toda la información del artículo como JSON
|
||||||
|
# Verificar si el artículo ya existe para mantener el estado de favorito
|
||||||
|
existing_value = self._redis_client.get(key)
|
||||||
|
is_favorite = False
|
||||||
|
if existing_value:
|
||||||
|
try:
|
||||||
|
existing_data = json.loads(existing_value)
|
||||||
|
is_favorite = existing_data.get('is_favorite', False)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
article_data = {
|
article_data = {
|
||||||
'id': article.get_id(),
|
'id': article.get_id(),
|
||||||
'title': article.get_title(),
|
'title': article.get_title(),
|
||||||
@@ -94,6 +104,7 @@ class RedisArticleCache:
|
|||||||
'images': article.get_images(),
|
'images': article.get_images(),
|
||||||
'modified_at': article.get_modified_at(),
|
'modified_at': article.get_modified_at(),
|
||||||
'platform': article.get_platform(),
|
'platform': article.get_platform(),
|
||||||
|
'is_favorite': is_favorite, # Mantener el estado de favorito
|
||||||
}
|
}
|
||||||
self._redis_client.setex(key, NOTIFIED_ARTICLE_TTL, json.dumps(article_data))
|
self._redis_client.setex(key, NOTIFIED_ARTICLE_TTL, json.dumps(article_data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -121,12 +132,65 @@ class RedisArticleCache:
|
|||||||
'images': article.get_images(),
|
'images': article.get_images(),
|
||||||
'modified_at': article.get_modified_at(),
|
'modified_at': article.get_modified_at(),
|
||||||
'platform': article.get_platform(),
|
'platform': article.get_platform(),
|
||||||
|
'is_favorite': False, # Por defecto no es favorito
|
||||||
}
|
}
|
||||||
pipe.setex(key, NOTIFIED_ARTICLE_TTL, json.dumps(article_data))
|
pipe.setex(key, NOTIFIED_ARTICLE_TTL, json.dumps(article_data))
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
self.logger.debug(f"{len(article_list)} artículos marcados como notificados en Redis")
|
self.logger.debug(f"{len(article_list)} artículos marcados como notificados en Redis")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error añadiendo artículos a Redis: {e}")
|
self.logger.error(f"Error añadiendo artículos a Redis: {e}")
|
||||||
|
|
||||||
|
def set_favorite(self, platform, article_id, is_favorite=True):
|
||||||
|
"""Marca o desmarca un artículo como favorito en Redis"""
|
||||||
|
try:
|
||||||
|
key = f"notified:{platform}:{article_id}"
|
||||||
|
value = self._redis_client.get(key)
|
||||||
|
if value:
|
||||||
|
article_data = json.loads(value)
|
||||||
|
article_data['is_favorite'] = is_favorite
|
||||||
|
# Mantener el TTL existente o usar el default
|
||||||
|
ttl = self._redis_client.ttl(key)
|
||||||
|
if ttl > 0:
|
||||||
|
self._redis_client.setex(key, ttl, json.dumps(article_data))
|
||||||
|
else:
|
||||||
|
self._redis_client.setex(key, NOTIFIED_ARTICLE_TTL, json.dumps(article_data))
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error marcando favorito en Redis: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_favorites(self):
|
||||||
|
"""Obtiene todos los artículos marcados como favoritos"""
|
||||||
|
try:
|
||||||
|
keys = self._redis_client.keys('notified:*')
|
||||||
|
favorites = []
|
||||||
|
for key in keys:
|
||||||
|
value = self._redis_client.get(key)
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
article_data = json.loads(value)
|
||||||
|
if article_data.get('is_favorite', False):
|
||||||
|
favorites.append(article_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return favorites
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error obteniendo favoritos de Redis: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def is_favorite(self, platform, article_id):
|
||||||
|
"""Verifica si un artículo es favorito"""
|
||||||
|
try:
|
||||||
|
key = f"notified:{platform}:{article_id}"
|
||||||
|
value = self._redis_client.get(key)
|
||||||
|
if value:
|
||||||
|
article_data = json.loads(value)
|
||||||
|
return article_data.get('is_favorite', False)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error verificando favorito en Redis: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def create_article_cache(cache_type='memory', **kwargs):
|
def create_article_cache(cache_type='memory', **kwargs):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from managers.article_cache import create_article_cache
|
||||||
|
|
||||||
ITEM_HTML = """
|
ITEM_HTML = """
|
||||||
<b>Artículo:</b> {title}
|
<b>Artículo:</b> {title}
|
||||||
@@ -24,8 +25,6 @@ ITEM_HTML = """
|
|||||||
<b>{price} {currency}</b>
|
<b>{price} {currency}</b>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
FAVORITES_FILE = "favorites.json"
|
|
||||||
|
|
||||||
class TelegramManager:
|
class TelegramManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
@@ -43,9 +42,8 @@ class TelegramManager:
|
|||||||
self._loop = asyncio.new_event_loop()
|
self._loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(self._loop)
|
asyncio.set_event_loop(self._loop)
|
||||||
|
|
||||||
# Inicializar archivo de favoritos
|
# Inicializar Redis para favoritos
|
||||||
self._favorites_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), FAVORITES_FILE)
|
self._article_cache = self._init_redis_cache()
|
||||||
self._ensure_favorites_file()
|
|
||||||
|
|
||||||
# Añadir handlers para comandos y callbacks
|
# Añadir handlers para comandos y callbacks
|
||||||
self._add_handlers()
|
self._add_handlers()
|
||||||
@@ -61,6 +59,31 @@ class TelegramManager:
|
|||||||
token = config['telegram_token']
|
token = config['telegram_token']
|
||||||
telegram_channel = config['telegram_channel']
|
telegram_channel = config['telegram_channel']
|
||||||
return token, telegram_channel
|
return token, telegram_channel
|
||||||
|
|
||||||
|
def _init_redis_cache(self):
|
||||||
|
"""Inicializa Redis cache para favoritos"""
|
||||||
|
try:
|
||||||
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
config_file = os.path.join(os.path.dirname(base_dir), 'config.yaml')
|
||||||
|
with open(config_file, 'r') as file:
|
||||||
|
config = yaml.safe_load(file)
|
||||||
|
|
||||||
|
cache_config = config.get('cache', {})
|
||||||
|
if cache_config.get('type') == 'redis':
|
||||||
|
redis_config = cache_config.get('redis', {})
|
||||||
|
return create_article_cache(
|
||||||
|
cache_type='redis',
|
||||||
|
redis_host=redis_config.get('host', 'localhost'),
|
||||||
|
redis_port=redis_config.get('port', 6379),
|
||||||
|
redis_db=redis_config.get('db', 0),
|
||||||
|
redis_password=redis_config.get('password')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.warning("Redis no configurado para favoritos, se requiere Redis")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error inicializando Redis para favoritos: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def escape_html(self, text):
|
def escape_html(self, text):
|
||||||
return html.escape(str(text))
|
return html.escape(str(text))
|
||||||
@@ -87,13 +110,29 @@ class TelegramManager:
|
|||||||
first_image_url = images_url[0] if images_url else None
|
first_image_url = images_url[0] if images_url else None
|
||||||
|
|
||||||
|
|
||||||
|
# Verificar si el artículo ya es favorito
|
||||||
|
is_favorite = False
|
||||||
|
if self._article_cache:
|
||||||
|
is_favorite = self._article_cache.is_favorite(article.get_platform(), article.get_id())
|
||||||
|
|
||||||
# Crear botones inline para el primer mensaje del grupo
|
# Crear botones inline para el primer mensaje del grupo
|
||||||
keyboard = [
|
if is_favorite:
|
||||||
[
|
keyboard = [
|
||||||
InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{article.get_id()}_{search_name}"),
|
[
|
||||||
InlineKeyboardButton("Ir al anuncio", url=f"{article.get_url()}")
|
InlineKeyboardButton("✅ En favoritos", callback_data=f"already_fav_{article.get_platform()}_{article.get_id()}"),
|
||||||
|
InlineKeyboardButton("🗑️ Quitar", callback_data=f"unfav_{article.get_platform()}_{article.get_id()}")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("Ir al anuncio", url=f"{article.get_url()}")
|
||||||
|
]
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
keyboard = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{article.get_platform()}_{article.get_id()}_{search_name}"),
|
||||||
|
InlineKeyboardButton("Ir al anuncio", url=f"{article.get_url()}")
|
||||||
|
]
|
||||||
]
|
]
|
||||||
]
|
|
||||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
# Enviar un mensaje adicional con los botones (reply al primer mensaje del grupo)
|
# Enviar un mensaje adicional con los botones (reply al primer mensaje del grupo)
|
||||||
@@ -106,30 +145,6 @@ class TelegramManager:
|
|||||||
message_thread_id=thread_id
|
message_thread_id=thread_id
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_favorites_file(self):
|
|
||||||
"""Crea el archivo de favoritos si no existe"""
|
|
||||||
if not os.path.exists(self._favorites_file):
|
|
||||||
with open(self._favorites_file, 'w') as f:
|
|
||||||
json.dump([], f)
|
|
||||||
self.logger.info(f"Archivo de favoritos creado: {self._favorites_file}")
|
|
||||||
|
|
||||||
def _load_favorites(self):
|
|
||||||
"""Carga los favoritos desde el archivo JSON"""
|
|
||||||
try:
|
|
||||||
with open(self._favorites_file, 'r') as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error al cargar favoritos: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _save_favorites(self, favorites):
|
|
||||||
"""Guarda los favoritos en el archivo JSON"""
|
|
||||||
try:
|
|
||||||
with open(self._favorites_file, 'w') as f:
|
|
||||||
json.dump(favorites, f, indent=2, ensure_ascii=False)
|
|
||||||
self.logger.info(f"Favoritos guardados: {len(favorites)} items")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error al guardar favoritos: {e}")
|
|
||||||
|
|
||||||
def _add_handlers(self):
|
def _add_handlers(self):
|
||||||
"""Añade los handlers para comandos y callbacks"""
|
"""Añade los handlers para comandos y callbacks"""
|
||||||
@@ -163,63 +178,78 @@ class TelegramManager:
|
|||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
|
|
||||||
# Extraer el ID del artículo y el nombre de búsqueda del callback_data
|
if not self._article_cache:
|
||||||
callback_data = query.data
|
await query.edit_message_text("❌ Redis no está disponible para favoritos")
|
||||||
parts = callback_data.split("_", 2) # fav_ID_search_name
|
return
|
||||||
|
|
||||||
if len(parts) < 3:
|
# Extraer plataforma, ID del artículo y nombre de búsqueda del callback_data
|
||||||
|
# Formato: fav_platform_id_search_name
|
||||||
|
callback_data = query.data
|
||||||
|
parts = callback_data.split("_", 3)
|
||||||
|
|
||||||
|
if len(parts) < 4:
|
||||||
await query.edit_message_text("❌ Error al procesar favorito")
|
await query.edit_message_text("❌ Error al procesar favorito")
|
||||||
return
|
return
|
||||||
|
|
||||||
article_id = parts[1]
|
platform = parts[1]
|
||||||
search_name = parts[2]
|
article_id = parts[2]
|
||||||
|
search_name = parts[3] if len(parts) > 3 else "Unknown"
|
||||||
|
|
||||||
# Ahora el mensaje original es el mismo mensaje del keyboard
|
# Verificar si ya es favorito
|
||||||
original_message = query.message
|
if self._article_cache.is_favorite(platform, article_id):
|
||||||
|
|
||||||
if not original_message:
|
|
||||||
await query.edit_message_text("❌ No se pudo encontrar el mensaje original")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Extraer información del caption
|
|
||||||
caption = original_message.caption or ""
|
|
||||||
|
|
||||||
# Crear el objeto favorito
|
|
||||||
favorite = {
|
|
||||||
"id": article_id,
|
|
||||||
"search_name": search_name,
|
|
||||||
"caption": caption,
|
|
||||||
"date_added": datetime.now().isoformat(),
|
|
||||||
"message_link": f"https://t.me/c/{str(self._channel).replace('-100', '')}/{original_message.message_id}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cargar favoritos existentes
|
|
||||||
favorites = self._load_favorites()
|
|
||||||
|
|
||||||
# Verificar si ya existe
|
|
||||||
if any(fav["id"] == article_id for fav in favorites):
|
|
||||||
await query.message.reply_text("ℹ️ Este artículo ya está en favoritos")
|
await query.message.reply_text("ℹ️ Este artículo ya está en favoritos")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Añadir nuevo favorito
|
# Obtener la URL del artículo desde Redis
|
||||||
favorites.append(favorite)
|
url = ""
|
||||||
self._save_favorites(favorites)
|
try:
|
||||||
|
redis_client = self._article_cache._redis_client
|
||||||
|
key = f"notified:{platform}:{article_id}"
|
||||||
|
value = redis_client.get(key)
|
||||||
|
if value:
|
||||||
|
article_data = json.loads(value)
|
||||||
|
url = article_data.get('url', '')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"Error obteniendo URL: {e}")
|
||||||
|
|
||||||
|
# Marcar como favorito en Redis
|
||||||
|
success = self._article_cache.set_favorite(platform, article_id, is_favorite=True)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await query.edit_message_text("❌ No se pudo encontrar el artículo en Redis")
|
||||||
|
return
|
||||||
|
|
||||||
# Actualizar el mensaje del botón
|
# Actualizar el mensaje del botón
|
||||||
new_keyboard = [
|
if url:
|
||||||
[InlineKeyboardButton("✅ En favoritos", callback_data=f"already_fav_{article_id}")],
|
new_keyboard = [
|
||||||
[InlineKeyboardButton("🗑️ Quitar de favoritos", callback_data=f"unfav_{article_id}")]
|
[
|
||||||
]
|
InlineKeyboardButton("✅ En favoritos", callback_data=f"already_fav_{platform}_{article_id}"),
|
||||||
# Como el mensaje original es una foto con caption, usar edit_message_reply_markup
|
InlineKeyboardButton("🗑️ Quitar", callback_data=f"unfav_{platform}_{article_id}")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("Ir al anuncio", url=url)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
new_keyboard = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("✅ En favoritos", callback_data=f"already_fav_{platform}_{article_id}"),
|
||||||
|
InlineKeyboardButton("🗑️ Quitar", callback_data=f"unfav_{platform}_{article_id}")
|
||||||
|
]
|
||||||
|
]
|
||||||
await query.edit_message_reply_markup(
|
await query.edit_message_reply_markup(
|
||||||
reply_markup=InlineKeyboardMarkup(new_keyboard)
|
reply_markup=InlineKeyboardMarkup(new_keyboard)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.logger.info(f"Artículo {article_id} añadido a favoritos")
|
self.logger.info(f"Artículo {article_id} ({platform}) marcado como favorito")
|
||||||
|
|
||||||
async def handle_favs_command(self, update: telegram.Update, context: telegram.ext.ContextTypes.DEFAULT_TYPE):
|
async def handle_favs_command(self, update: telegram.Update, context: telegram.ext.ContextTypes.DEFAULT_TYPE):
|
||||||
"""Maneja el comando /favs para mostrar los favoritos"""
|
"""Maneja el comando /favs para mostrar los favoritos"""
|
||||||
favorites = self._load_favorites()
|
if not self._article_cache:
|
||||||
|
await update.message.reply_text("❌ Redis no está disponible para favoritos")
|
||||||
|
return
|
||||||
|
|
||||||
|
favorites = self._article_cache.get_favorites()
|
||||||
|
|
||||||
if not favorites:
|
if not favorites:
|
||||||
await update.message.reply_text("📭 No tienes favoritos guardados aún")
|
await update.message.reply_text("📭 No tienes favoritos guardados aún")
|
||||||
@@ -229,17 +259,15 @@ class TelegramManager:
|
|||||||
message = f"⭐ <b>Tus Favoritos ({len(favorites)})</b>\n\n"
|
message = f"⭐ <b>Tus Favoritos ({len(favorites)})</b>\n\n"
|
||||||
|
|
||||||
for idx, fav in enumerate(favorites, 1):
|
for idx, fav in enumerate(favorites, 1):
|
||||||
# Extraer título del caption (está después de "Artículo:")
|
title = fav.get('title', 'Sin título')
|
||||||
caption_lines = fav["caption"].split("\n")
|
platform = fav.get('platform', 'N/A').upper()
|
||||||
title = "Sin título"
|
price = fav.get('price', 'N/A')
|
||||||
for line in caption_lines:
|
currency = fav.get('currency', '€')
|
||||||
if line.startswith("<b>Artículo:</b>"):
|
url = fav.get('url', '#')
|
||||||
title = line.replace("<b>Artículo:</b>", "").strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
message += f"{idx}. {title}\n"
|
message += f"{idx}. <b>{self.escape_html(title)}</b>\n"
|
||||||
message += f" 📂 Búsqueda: <i>{fav['search_name']}</i>\n"
|
message += f" 🏷️ {platform} - {price} {currency}\n"
|
||||||
message += f" 🔗 <a href='{fav['message_link']}'>Ver mensaje</a>\n\n"
|
message += f" 🔗 <a href='{url}'>Ver anuncio</a>\n\n"
|
||||||
|
|
||||||
# Dividir el mensaje si es muy largo
|
# Dividir el mensaje si es muy largo
|
||||||
if len(message) > 4000:
|
if len(message) > 4000:
|
||||||
@@ -254,37 +282,57 @@ class TelegramManager:
|
|||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
|
|
||||||
# Extraer el ID del artículo del callback_data
|
if not self._article_cache:
|
||||||
callback_data = query.data
|
await query.edit_message_text("❌ Redis no está disponible para favoritos")
|
||||||
parts = callback_data.split("_", 1) # unfav_ID
|
return
|
||||||
|
|
||||||
if len(parts) < 2:
|
# Extraer plataforma e ID del artículo del callback_data
|
||||||
|
# Formato: unfav_platform_id
|
||||||
|
callback_data = query.data
|
||||||
|
parts = callback_data.split("_", 2)
|
||||||
|
|
||||||
|
if len(parts) < 3:
|
||||||
await query.edit_message_text("❌ Error al procesar")
|
await query.edit_message_text("❌ Error al procesar")
|
||||||
return
|
return
|
||||||
|
|
||||||
article_id = parts[1]
|
platform = parts[1]
|
||||||
|
article_id = parts[2]
|
||||||
|
|
||||||
# Cargar favoritos existentes
|
# Obtener la URL del artículo desde Redis antes de desmarcar
|
||||||
favorites = self._load_favorites()
|
url = ""
|
||||||
|
try:
|
||||||
|
redis_client = self._article_cache._redis_client
|
||||||
|
key = f"notified:{platform}:{article_id}"
|
||||||
|
value = redis_client.get(key)
|
||||||
|
if value:
|
||||||
|
article_data = json.loads(value)
|
||||||
|
url = article_data.get('url', '')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"Error obteniendo URL: {e}")
|
||||||
|
|
||||||
# Buscar y eliminar el favorito
|
# Desmarcar como favorito en Redis
|
||||||
original_count = len(favorites)
|
success = self._article_cache.set_favorite(platform, article_id, is_favorite=False)
|
||||||
favorites = [fav for fav in favorites if fav["id"] != article_id]
|
|
||||||
|
|
||||||
if len(favorites) == original_count:
|
if not success:
|
||||||
await query.edit_message_text("ℹ️ Este artículo no estaba en favoritos")
|
await query.edit_message_text("ℹ️ Este artículo no estaba en favoritos")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Guardar favoritos actualizados
|
|
||||||
self._save_favorites(favorites)
|
|
||||||
|
|
||||||
# Restaurar el botón original
|
# Restaurar el botón original
|
||||||
keyboard = [
|
if url:
|
||||||
[InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{article_id}_unknown")]
|
keyboard = [
|
||||||
]
|
[
|
||||||
# Como el mensaje original es una foto con caption, usar edit_message_reply_markup
|
InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{platform}_{article_id}_Unknown"),
|
||||||
|
InlineKeyboardButton("Ir al anuncio", url=url)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
keyboard = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{platform}_{article_id}_Unknown")
|
||||||
|
]
|
||||||
|
]
|
||||||
await query.edit_message_reply_markup(
|
await query.edit_message_reply_markup(
|
||||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.logger.info(f"Artículo {article_id} eliminado de favoritos")
|
self.logger.info(f"Artículo {article_id} ({platform}) eliminado de favoritos")
|
||||||
@@ -24,7 +24,6 @@ app.use(express.json());
|
|||||||
// Configuración
|
// Configuración
|
||||||
const CONFIG_PATH = join(PROJECT_ROOT, 'config.yaml');
|
const CONFIG_PATH = join(PROJECT_ROOT, 'config.yaml');
|
||||||
const WORKERS_PATH = join(PROJECT_ROOT, 'workers.json');
|
const WORKERS_PATH = join(PROJECT_ROOT, 'workers.json');
|
||||||
const FAVORITES_PATH = join(PROJECT_ROOT, 'favorites.json');
|
|
||||||
|
|
||||||
// Función para obtener la ruta del log (en Docker puede estar en /data/logs)
|
// Función para obtener la ruta del log (en Docker puede estar en /data/logs)
|
||||||
function getLogPath() {
|
function getLogPath() {
|
||||||
@@ -160,11 +159,42 @@ async function getNotifiedArticles() {
|
|||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
|
|
||||||
|
// Obtener favoritos desde Redis
|
||||||
|
async function getFavorites() {
|
||||||
|
if (!redisClient) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keys = await redisClient.keys('notified:*');
|
||||||
|
const favorites = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = await redisClient.get(key);
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
const articleData = JSON.parse(value);
|
||||||
|
if (articleData.is_favorite === true) {
|
||||||
|
favorites.push(articleData);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Si no es JSON válido, ignorar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return favorites;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo favoritos de Redis:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Obtener estadísticas
|
// Obtener estadísticas
|
||||||
app.get('/api/stats', async (req, res) => {
|
app.get('/api/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const workers = readJSON(WORKERS_PATH, { items: [] });
|
const workers = readJSON(WORKERS_PATH, { items: [] });
|
||||||
const favorites = readJSON(FAVORITES_PATH, []);
|
const favorites = await getFavorites();
|
||||||
const notifiedArticles = await getNotifiedArticles();
|
const notifiedArticles = await getNotifiedArticles();
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
@@ -210,9 +240,9 @@ app.put('/api/workers', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Obtener favoritos
|
// Obtener favoritos
|
||||||
app.get('/api/favorites', (req, res) => {
|
app.get('/api/favorites', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const favorites = readJSON(FAVORITES_PATH, []);
|
const favorites = await getFavorites();
|
||||||
res.json(favorites);
|
res.json(favorites);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -220,22 +250,40 @@ app.get('/api/favorites', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Añadir favorito
|
// Añadir favorito
|
||||||
app.post('/api/favorites', (req, res) => {
|
app.post('/api/favorites', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const favorite = req.body;
|
if (!redisClient) {
|
||||||
const favorites = readJSON(FAVORITES_PATH, []);
|
return res.status(500).json({ error: 'Redis no está disponible' });
|
||||||
|
}
|
||||||
|
|
||||||
// Evitar duplicados
|
const { platform, id } = req.body;
|
||||||
if (!favorites.find(f => f.id === favorite.id && f.platform === favorite.platform)) {
|
if (!platform || !id) {
|
||||||
favorites.push({
|
return res.status(400).json({ error: 'platform e id son requeridos' });
|
||||||
...favorite,
|
}
|
||||||
addedAt: new Date().toISOString(),
|
|
||||||
});
|
const key = `notified:${platform}:${id}`;
|
||||||
writeJSON(FAVORITES_PATH, favorites);
|
const value = await redisClient.get(key);
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return res.status(404).json({ error: 'Artículo no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const articleData = JSON.parse(value);
|
||||||
|
articleData.is_favorite = true;
|
||||||
|
// Mantener el TTL existente
|
||||||
|
const ttl = await redisClient.ttl(key);
|
||||||
|
if (ttl > 0) {
|
||||||
|
await redisClient.setex(key, ttl, JSON.stringify(articleData));
|
||||||
|
} else {
|
||||||
|
await redisClient.set(key, JSON.stringify(articleData));
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorites = await getFavorites();
|
||||||
broadcast({ type: 'favorites_updated', data: favorites });
|
broadcast({ type: 'favorites_updated', data: favorites });
|
||||||
res.json({ success: true, favorites });
|
res.json({ success: true, favorites });
|
||||||
} else {
|
} catch (e) {
|
||||||
res.json({ success: false, message: 'Ya existe en favoritos' });
|
res.status(500).json({ error: 'Error procesando artículo' });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -243,17 +291,37 @@ app.post('/api/favorites', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Eliminar favorito
|
// Eliminar favorito
|
||||||
app.delete('/api/favorites/:platform/:id', (req, res) => {
|
app.delete('/api/favorites/:platform/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { platform, id } = req.params;
|
if (!redisClient) {
|
||||||
const favorites = readJSON(FAVORITES_PATH, []);
|
return res.status(500).json({ error: 'Redis no está disponible' });
|
||||||
const filtered = favorites.filter(
|
}
|
||||||
f => !(f.platform === platform && f.id === id)
|
|
||||||
);
|
|
||||||
|
|
||||||
writeJSON(FAVORITES_PATH, filtered);
|
const { platform, id } = req.params;
|
||||||
broadcast({ type: 'favorites_updated', data: filtered });
|
const key = `notified:${platform}:${id}`;
|
||||||
res.json({ success: true, favorites: filtered });
|
const value = await redisClient.get(key);
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return res.status(404).json({ error: 'Artículo no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const articleData = JSON.parse(value);
|
||||||
|
articleData.is_favorite = false;
|
||||||
|
// Mantener el TTL existente
|
||||||
|
const ttl = await redisClient.ttl(key);
|
||||||
|
if (ttl > 0) {
|
||||||
|
await redisClient.setex(key, ttl, JSON.stringify(articleData));
|
||||||
|
} else {
|
||||||
|
await redisClient.set(key, JSON.stringify(articleData));
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorites = await getFavorites();
|
||||||
|
broadcast({ type: 'favorites_updated', data: favorites });
|
||||||
|
res.json({ success: true, favorites });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Error procesando artículo' });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -280,6 +348,59 @@ app.get('/api/articles', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Buscar artículos en Redis
|
||||||
|
app.get('/api/articles/search', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const query = req.query.q || '';
|
||||||
|
if (!query.trim()) {
|
||||||
|
return res.json({ articles: [], total: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = query.toLowerCase().trim();
|
||||||
|
const allArticles = await getNotifiedArticles();
|
||||||
|
|
||||||
|
// Filtrar artículos que coincidan con la búsqueda
|
||||||
|
const filtered = allArticles.filter(article => {
|
||||||
|
// Buscar en título
|
||||||
|
const title = (article.title || '').toLowerCase();
|
||||||
|
if (title.includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Buscar en descripción
|
||||||
|
const description = (article.description || '').toLowerCase();
|
||||||
|
if (description.includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Buscar en localidad
|
||||||
|
const location = (article.location || '').toLowerCase();
|
||||||
|
if (location.includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Buscar en precio (como número o texto)
|
||||||
|
const price = String(article.price || '').toLowerCase();
|
||||||
|
if (price.includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Buscar en plataforma
|
||||||
|
const platform = (article.platform || '').toLowerCase();
|
||||||
|
if (platform.includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Buscar en ID
|
||||||
|
const id = String(article.id || '').toLowerCase();
|
||||||
|
if (id.includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordenar por fecha de notificación (más recientes primero)
|
||||||
|
const sorted = filtered.sort((a, b) => b.notifiedAt - a.notifiedAt);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
articles: sorted,
|
||||||
|
total: sorted.length,
|
||||||
|
query: query,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Obtener logs (últimas líneas)
|
// Obtener logs (últimas líneas)
|
||||||
app.get('/api/logs', (req, res) => {
|
app.get('/api/logs', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -355,20 +476,17 @@ if (!existsSync(watchLogPath)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch files for changes
|
// Watch files for changes (ya no vigilamos FAVORITES_PATH porque usa Redis)
|
||||||
const watcher = watch([WORKERS_PATH, FAVORITES_PATH, watchLogPath].filter(p => existsSync(p)), {
|
const watcher = watch([WORKERS_PATH, watchLogPath].filter(p => existsSync(p)), {
|
||||||
persistent: true,
|
persistent: true,
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
watcher.on('change', (path) => {
|
watcher.on('change', async (path) => {
|
||||||
console.log(`Archivo cambiado: ${path}`);
|
console.log(`Archivo cambiado: ${path}`);
|
||||||
if (path === WORKERS_PATH) {
|
if (path === WORKERS_PATH) {
|
||||||
const workers = readJSON(WORKERS_PATH);
|
const workers = readJSON(WORKERS_PATH);
|
||||||
broadcast({ type: 'workers_updated', data: workers });
|
broadcast({ type: 'workers_updated', data: workers });
|
||||||
} else if (path === FAVORITES_PATH) {
|
|
||||||
const favorites = readJSON(FAVORITES_PATH);
|
|
||||||
broadcast({ type: 'favorites_updated', data: favorites });
|
|
||||||
} else if (path === LOG_PATH) {
|
} else if (path === LOG_PATH) {
|
||||||
broadcast({ type: 'logs_updated' });
|
broadcast({ type: 'logs_updated' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ export default {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async searchArticles(query) {
|
||||||
|
const response = await api.get('/articles/search', {
|
||||||
|
params: { q: query },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
async getLogs(limit = 100) {
|
async getLogs(limit = 100) {
|
||||||
const response = await api.get('/logs', {
|
const response = await api.get('/logs', {
|
||||||
|
|||||||
@@ -1,21 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Artículos Notificados</h1>
|
<div class="flex justify-between items-center mb-4">
|
||||||
<div class="flex items-center space-x-4">
|
<h1 class="text-3xl font-bold text-gray-900">Artículos Notificados</h1>
|
||||||
<select
|
<div class="flex items-center space-x-4">
|
||||||
v-model="selectedPlatform"
|
<select
|
||||||
@change="loadArticles"
|
v-model="selectedPlatform"
|
||||||
class="input"
|
@change="loadArticles"
|
||||||
style="width: auto;"
|
class="input"
|
||||||
>
|
style="width: auto;"
|
||||||
<option value="">Todas las plataformas</option>
|
>
|
||||||
<option value="wallapop">Wallapop</option>
|
<option value="">Todas las plataformas</option>
|
||||||
<option value="vinted">Vinted</option>
|
<option value="wallapop">Wallapop</option>
|
||||||
</select>
|
<option value="vinted">Vinted</option>
|
||||||
<button @click="loadArticles" class="btn btn-primary">
|
</select>
|
||||||
Actualizar
|
<button @click="loadArticles" class="btn btn-primary">
|
||||||
</button>
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Campo de búsqueda -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar artículos en Redis por título, descripción, precio, localidad..."
|
||||||
|
class="input pr-10"
|
||||||
|
@input="searchQuery = $event.target.value"
|
||||||
|
/>
|
||||||
|
<span v-if="searching" class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
|
||||||
|
</span>
|
||||||
|
<span v-else-if="searchQuery" class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 cursor-pointer hover:text-gray-600" @click="searchQuery = ''">
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,13 +43,23 @@
|
|||||||
<p class="mt-2 text-gray-600">Cargando artículos...</p>
|
<p class="mt-2 text-gray-600">Cargando artículos...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="articles.length === 0" class="card text-center py-12">
|
<div v-else-if="filteredArticles.length === 0 && !searchQuery" class="card text-center py-12">
|
||||||
<p class="text-gray-600">No hay artículos para mostrar</p>
|
<p class="text-gray-600">No hay artículos para mostrar</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="searching" class="card text-center py-12">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
<p class="mt-2 text-gray-600">Buscando artículos en Redis...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredArticles.length === 0 && searchQuery && !searching" class="card text-center py-12">
|
||||||
|
<p class="text-gray-600">No se encontraron artículos que coincidan con "{{ searchQuery }}"</p>
|
||||||
|
<button @click="searchQuery = ''" class="btn btn-secondary mt-4">Limpiar búsqueda</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-for="article in articles"
|
v-for="article in filteredArticles"
|
||||||
:key="`${article.platform}-${article.id}`"
|
:key="`${article.platform}-${article.id}`"
|
||||||
class="card hover:shadow-lg transition-shadow"
|
class="card hover:shadow-lg transition-shadow"
|
||||||
>
|
>
|
||||||
@@ -119,47 +148,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center space-x-2 mt-6">
|
<div v-if="!searchQuery" class="flex justify-center space-x-2 mt-6">
|
||||||
<button
|
<button
|
||||||
@click="loadMore"
|
@click="loadMore"
|
||||||
:disabled="articles.length >= total"
|
:disabled="allArticles.length >= total"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
:class="{ 'opacity-50 cursor-not-allowed': articles.length >= total }"
|
:class="{ 'opacity-50 cursor-not-allowed': allArticles.length >= total }"
|
||||||
>
|
>
|
||||||
Cargar más
|
Cargar más
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-center text-sm text-gray-500 mt-4">
|
<p class="text-center text-sm text-gray-500 mt-4">
|
||||||
Mostrando {{ articles.length }} de {{ total }} artículos
|
<span v-if="searchQuery">
|
||||||
|
Mostrando {{ filteredArticles.length }} resultados de búsqueda en Redis
|
||||||
|
<span class="ml-2 text-xs text-primary-600">(de {{ total }} artículos totales)</span>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
Mostrando {{ filteredArticles.length }} de {{ total }} artículos
|
||||||
|
<span class="ml-2 text-xs text-gray-400">(Actualización automática cada 30s)</span>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
|
||||||
const articles = ref([]);
|
const allArticles = ref([]);
|
||||||
|
const searchResults = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const searching = ref(false);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const offset = ref(0);
|
const offset = ref(0);
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
const selectedPlatform = ref('');
|
const selectedPlatform = ref('');
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const autoPollInterval = ref(null);
|
||||||
|
const searchTimeout = ref(null);
|
||||||
|
const POLL_INTERVAL = 30000; // 30 segundos
|
||||||
|
const SEARCH_DEBOUNCE = 500; // 500ms de debounce para búsqueda
|
||||||
|
|
||||||
|
// Artículos que se muestran (búsqueda o lista normal)
|
||||||
|
const filteredArticles = computed(() => {
|
||||||
|
if (searchQuery.value.trim()) {
|
||||||
|
return searchResults.value;
|
||||||
|
}
|
||||||
|
return allArticles.value;
|
||||||
|
});
|
||||||
|
|
||||||
function formatDate(timestamp) {
|
function formatDate(timestamp) {
|
||||||
if (!timestamp) return 'N/A';
|
if (!timestamp) return 'N/A';
|
||||||
return new Date(timestamp).toLocaleString('es-ES');
|
return new Date(timestamp).toLocaleString('es-ES');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadArticles(reset = true) {
|
async function loadArticles(reset = true, silent = false) {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
offset.value = 0;
|
offset.value = 0;
|
||||||
articles.value = [];
|
allArticles.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
loading.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
try {
|
||||||
const data = await api.getArticles(limit, offset.value);
|
const data = await api.getArticles(limit, offset.value);
|
||||||
|
|
||||||
@@ -169,17 +223,20 @@ async function loadArticles(reset = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
articles.value = filtered;
|
allArticles.value = filtered;
|
||||||
|
offset.value = limit;
|
||||||
} else {
|
} else {
|
||||||
articles.value.push(...filtered);
|
allArticles.value.push(...filtered);
|
||||||
|
offset.value += limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
total.value = data.total;
|
total.value = data.total;
|
||||||
offset.value += limit;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando artículos:', error);
|
console.error('Error cargando artículos:', error);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (!silent) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +251,53 @@ function handleWSMessage(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchArticles(query) {
|
||||||
|
if (!query.trim()) {
|
||||||
|
searchResults.value = [];
|
||||||
|
searching.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searching.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.searchArticles(query);
|
||||||
|
|
||||||
|
let filtered = data.articles || [];
|
||||||
|
|
||||||
|
// Aplicar filtro de plataforma si está seleccionado
|
||||||
|
if (selectedPlatform.value) {
|
||||||
|
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResults.value = filtered;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error buscando artículos:', error);
|
||||||
|
searchResults.value = [];
|
||||||
|
} finally {
|
||||||
|
searching.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch para buscar cuando cambie searchQuery (con debounce)
|
||||||
|
watch(searchQuery, (newQuery) => {
|
||||||
|
// Limpiar timeout anterior
|
||||||
|
if (searchTimeout.value) {
|
||||||
|
clearTimeout(searchTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si está vacío, limpiar resultados
|
||||||
|
if (!newQuery.trim()) {
|
||||||
|
searchResults.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Añadir debounce antes de buscar
|
||||||
|
searchTimeout.value = setTimeout(() => {
|
||||||
|
searchArticles(newQuery);
|
||||||
|
}, SEARCH_DEBOUNCE);
|
||||||
|
});
|
||||||
|
|
||||||
function handleImageError(event) {
|
function handleImageError(event) {
|
||||||
// Si la imagen falla al cargar, reemplazar con placeholder
|
// Si la imagen falla al cargar, reemplazar con placeholder
|
||||||
event.target.onerror = null; // Prevenir bucle infinito
|
event.target.onerror = null; // Prevenir bucle infinito
|
||||||
@@ -203,10 +307,27 @@ function handleImageError(event) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadArticles();
|
loadArticles();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
|
|
||||||
|
// Iniciar autopoll para actualizar automáticamente
|
||||||
|
autoPollInterval.value = setInterval(() => {
|
||||||
|
loadArticles(true, true); // Reset silencioso cada 30 segundos
|
||||||
|
}, POLL_INTERVAL);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('ws-message', handleWSMessage);
|
window.removeEventListener('ws-message', handleWSMessage);
|
||||||
|
|
||||||
|
// Limpiar el intervalo cuando el componente se desmonte
|
||||||
|
if (autoPollInterval.value) {
|
||||||
|
clearInterval(autoPollInterval.value);
|
||||||
|
autoPollInterval.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpiar el timeout de búsqueda
|
||||||
|
if (searchTimeout.value) {
|
||||||
|
clearTimeout(searchTimeout.value);
|
||||||
|
searchTimeout.value = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user