diff --git a/docker-compose.yml b/docker-compose.yml index fdfdb19..86c4396 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,6 @@ services: # Montar archivos de configuración y datos en ubicación predecible - ./config.yaml:/data/config.yaml:ro - ./workers.json:/data/workers.json:rw - - ./favorites.json:/data/favorites.json:rw - ./logs:/data/logs:rw # Montar el directorio raíz para acceso a archivos - .:/data/project:ro @@ -83,7 +82,6 @@ services: # Montar archivos de configuración - ./config.yaml:/app/config.yaml:ro - ./workers.json:/app/workers.json:ro - - ./favorites.json:/app/favorites.json:rw # Montar directorio de logs en lugar del archivo para evitar problemas - ./logs:/app/logs:rw depends_on: diff --git a/favorites.json b/favorites.json index 32960f8..0637a08 100644 --- a/favorites.json +++ b/favorites.json @@ -1,2 +1 @@ -[ -] \ No newline at end of file +[] \ No newline at end of file diff --git a/managers/article_cache.py b/managers/article_cache.py index f1f163c..9fe64ef 100644 --- a/managers/article_cache.py +++ b/managers/article_cache.py @@ -82,6 +82,16 @@ class RedisArticleCache: try: key = self._get_article_key(article) # 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 = { 'id': article.get_id(), 'title': article.get_title(), @@ -94,6 +104,7 @@ class RedisArticleCache: 'images': article.get_images(), 'modified_at': article.get_modified_at(), '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)) except Exception as e: @@ -121,12 +132,65 @@ class RedisArticleCache: 'images': article.get_images(), 'modified_at': article.get_modified_at(), 'platform': article.get_platform(), + 'is_favorite': False, # Por defecto no es favorito } pipe.setex(key, NOTIFIED_ARTICLE_TTL, json.dumps(article_data)) pipe.execute() self.logger.debug(f"{len(article_list)} artículos marcados como notificados en Redis") except Exception as 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): diff --git a/managers/telegram_manager.py b/managers/telegram_manager.py index 65fd6f7..8a533ab 100644 --- a/managers/telegram_manager.py +++ b/managers/telegram_manager.py @@ -10,6 +10,7 @@ import json import logging import threading from datetime import datetime +from managers.article_cache import create_article_cache ITEM_HTML = """ Artículo: {title} @@ -24,8 +25,6 @@ ITEM_HTML = """ {price} {currency} """ -FAVORITES_FILE = "favorites.json" - class TelegramManager: def __init__(self): self.logger = logging.getLogger(__name__) @@ -43,9 +42,8 @@ class TelegramManager: self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) - # Inicializar archivo de favoritos - self._favorites_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), FAVORITES_FILE) - self._ensure_favorites_file() + # Inicializar Redis para favoritos + self._article_cache = self._init_redis_cache() # Añadir handlers para comandos y callbacks self._add_handlers() @@ -61,6 +59,31 @@ class TelegramManager: token = config['telegram_token'] telegram_channel = config['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): return html.escape(str(text)) @@ -87,13 +110,29 @@ class TelegramManager: 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 - keyboard = [ - [ - InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{article.get_id()}_{search_name}"), - InlineKeyboardButton("Ir al anuncio", url=f"{article.get_url()}") + if is_favorite: + keyboard = [ + [ + 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) # Enviar un mensaje adicional con los botones (reply al primer mensaje del grupo) @@ -106,30 +145,6 @@ class TelegramManager: 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): """Añade los handlers para comandos y callbacks""" @@ -163,63 +178,78 @@ class TelegramManager: query = update.callback_query await query.answer() - # Extraer el ID del artículo y el nombre de búsqueda del callback_data - callback_data = query.data - parts = callback_data.split("_", 2) # fav_ID_search_name + if not self._article_cache: + await query.edit_message_text("❌ Redis no está disponible para favoritos") + 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") return - article_id = parts[1] - search_name = parts[2] + platform = parts[1] + article_id = parts[2] + search_name = parts[3] if len(parts) > 3 else "Unknown" - # Ahora el mensaje original es el mismo mensaje del keyboard - original_message = query.message - - 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): + # Verificar si ya es favorito + if self._article_cache.is_favorite(platform, article_id): await query.message.reply_text("ℹ️ Este artículo ya está en favoritos") return - # Añadir nuevo favorito - favorites.append(favorite) - self._save_favorites(favorites) + # Obtener la URL del artículo desde Redis + 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}") + + # 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 - new_keyboard = [ - [InlineKeyboardButton("✅ En favoritos", callback_data=f"already_fav_{article_id}")], - [InlineKeyboardButton("🗑️ Quitar de favoritos", callback_data=f"unfav_{article_id}")] - ] - # Como el mensaje original es una foto con caption, usar edit_message_reply_markup + if url: + new_keyboard = [ + [ + InlineKeyboardButton("✅ En favoritos", callback_data=f"already_fav_{platform}_{article_id}"), + 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( 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): """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: await update.message.reply_text("📭 No tienes favoritos guardados aún") @@ -229,17 +259,15 @@ class TelegramManager: message = f"⭐ Tus Favoritos ({len(favorites)})\n\n" for idx, fav in enumerate(favorites, 1): - # Extraer título del caption (está después de "Artículo:") - caption_lines = fav["caption"].split("\n") - title = "Sin título" - for line in caption_lines: - if line.startswith("Artículo:"): - title = line.replace("Artículo:", "").strip() - break + title = fav.get('title', 'Sin título') + platform = fav.get('platform', 'N/A').upper() + price = fav.get('price', 'N/A') + currency = fav.get('currency', '€') + url = fav.get('url', '#') - message += f"{idx}. {title}\n" - message += f" 📂 Búsqueda: {fav['search_name']}\n" - message += f" 🔗 Ver mensaje\n\n" + message += f"{idx}. {self.escape_html(title)}\n" + message += f" 🏷️ {platform} - {price} {currency}\n" + message += f" 🔗 Ver anuncio\n\n" # Dividir el mensaje si es muy largo if len(message) > 4000: @@ -254,37 +282,57 @@ class TelegramManager: query = update.callback_query await query.answer() - # Extraer el ID del artículo del callback_data - callback_data = query.data - parts = callback_data.split("_", 1) # unfav_ID + if not self._article_cache: + await query.edit_message_text("❌ Redis no está disponible para favoritos") + 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") return - article_id = parts[1] + platform = parts[1] + article_id = parts[2] - # Cargar favoritos existentes - favorites = self._load_favorites() + # Obtener la URL del artículo desde Redis antes de desmarcar + 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 - original_count = len(favorites) - favorites = [fav for fav in favorites if fav["id"] != article_id] + # Desmarcar como favorito en Redis + success = self._article_cache.set_favorite(platform, article_id, is_favorite=False) - if len(favorites) == original_count: + if not success: await query.edit_message_text("ℹ️ Este artículo no estaba en favoritos") return - # Guardar favoritos actualizados - self._save_favorites(favorites) - # Restaurar el botón original - keyboard = [ - [InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{article_id}_unknown")] - ] - # Como el mensaje original es una foto con caption, usar edit_message_reply_markup + if url: + keyboard = [ + [ + 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( reply_markup=InlineKeyboardMarkup(keyboard) ) - self.logger.info(f"Artículo {article_id} eliminado de favoritos") \ No newline at end of file + self.logger.info(f"Artículo {article_id} ({platform}) eliminado de favoritos") \ No newline at end of file diff --git a/web/backend/server.js b/web/backend/server.js index 8619866..9594fd2 100644 --- a/web/backend/server.js +++ b/web/backend/server.js @@ -24,7 +24,6 @@ app.use(express.json()); // Configuración const CONFIG_PATH = join(PROJECT_ROOT, 'config.yaml'); 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) function getLogPath() { @@ -160,11 +159,42 @@ async function getNotifiedArticles() { // 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 app.get('/api/stats', async (req, res) => { try { const workers = readJSON(WORKERS_PATH, { items: [] }); - const favorites = readJSON(FAVORITES_PATH, []); + const favorites = await getFavorites(); const notifiedArticles = await getNotifiedArticles(); const stats = { @@ -210,9 +240,9 @@ app.put('/api/workers', (req, res) => { }); // Obtener favoritos -app.get('/api/favorites', (req, res) => { +app.get('/api/favorites', async (req, res) => { try { - const favorites = readJSON(FAVORITES_PATH, []); + const favorites = await getFavorites(); res.json(favorites); } catch (error) { res.status(500).json({ error: error.message }); @@ -220,22 +250,40 @@ app.get('/api/favorites', (req, res) => { }); // Añadir favorito -app.post('/api/favorites', (req, res) => { +app.post('/api/favorites', async (req, res) => { try { - const favorite = req.body; - const favorites = readJSON(FAVORITES_PATH, []); + if (!redisClient) { + return res.status(500).json({ error: 'Redis no está disponible' }); + } - // Evitar duplicados - if (!favorites.find(f => f.id === favorite.id && f.platform === favorite.platform)) { - favorites.push({ - ...favorite, - addedAt: new Date().toISOString(), - }); - writeJSON(FAVORITES_PATH, favorites); + const { platform, id } = req.body; + if (!platform || !id) { + return res.status(400).json({ error: 'platform e id son requeridos' }); + } + + const key = `notified:${platform}:${id}`; + 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 }); res.json({ success: true, favorites }); - } else { - res.json({ success: false, message: 'Ya existe en favoritos' }); + } catch (e) { + res.status(500).json({ error: 'Error procesando artículo' }); } } catch (error) { res.status(500).json({ error: error.message }); @@ -243,17 +291,37 @@ app.post('/api/favorites', (req, res) => { }); // Eliminar favorito -app.delete('/api/favorites/:platform/:id', (req, res) => { +app.delete('/api/favorites/:platform/:id', async (req, res) => { try { - const { platform, id } = req.params; - const favorites = readJSON(FAVORITES_PATH, []); - const filtered = favorites.filter( - f => !(f.platform === platform && f.id === id) - ); + if (!redisClient) { + return res.status(500).json({ error: 'Redis no está disponible' }); + } - writeJSON(FAVORITES_PATH, filtered); - broadcast({ type: 'favorites_updated', data: filtered }); - res.json({ success: true, favorites: filtered }); + const { platform, id } = req.params; + const key = `notified:${platform}:${id}`; + 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) { 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) app.get('/api/logs', (req, res) => { try { @@ -355,20 +476,17 @@ if (!existsSync(watchLogPath)) { } } -// Watch files for changes -const watcher = watch([WORKERS_PATH, FAVORITES_PATH, watchLogPath].filter(p => existsSync(p)), { +// Watch files for changes (ya no vigilamos FAVORITES_PATH porque usa Redis) +const watcher = watch([WORKERS_PATH, watchLogPath].filter(p => existsSync(p)), { persistent: true, ignoreInitial: true, }); -watcher.on('change', (path) => { +watcher.on('change', async (path) => { console.log(`Archivo cambiado: ${path}`); if (path === WORKERS_PATH) { const workers = readJSON(WORKERS_PATH); 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) { broadcast({ type: 'logs_updated' }); } diff --git a/web/frontend/src/services/api.js b/web/frontend/src/services/api.js index f843279..f04139f 100644 --- a/web/frontend/src/services/api.js +++ b/web/frontend/src/services/api.js @@ -49,6 +49,13 @@ export default { return response.data; }, + async searchArticles(query) { + const response = await api.get('/articles/search', { + params: { q: query }, + }); + return response.data; + }, + // Logs async getLogs(limit = 100) { const response = await api.get('/logs', { diff --git a/web/frontend/src/views/Articles.vue b/web/frontend/src/views/Articles.vue index 1c2e3ed..39d7d19 100644 --- a/web/frontend/src/views/Articles.vue +++ b/web/frontend/src/views/Articles.vue @@ -1,21 +1,40 @@