From deb3dc2b3171721c59258544041a0497c17349ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20S=C3=A1nchez=20Pizarro?= Date: Tue, 20 Jan 2026 19:08:27 +0100 Subject: [PATCH] Implement efficient article search functionality with AND/OR mode support. Update backend to include searchArticles service and enhance MongoDB indexing. Modify frontend to allow users to select search mode and improve search input layout. --- web/backend/routes/articles.js | 49 +----- web/backend/services/mongodb.js | 241 ++++++++++++++++++++++++++++ web/frontend/src/services/api.js | 4 +- web/frontend/src/views/Articles.vue | 64 +++++--- 4 files changed, 292 insertions(+), 66 deletions(-) diff --git a/web/backend/routes/articles.js b/web/backend/routes/articles.js index 6fbaa1b..bb116d3 100644 --- a/web/backend/routes/articles.js +++ b/web/backend/routes/articles.js @@ -1,5 +1,5 @@ import express from 'express'; -import { getNotifiedArticles, getArticleFacets } from '../services/mongodb.js'; +import { getNotifiedArticles, getArticleFacets, searchArticles } from '../services/mongodb.js'; import { basicAuthMiddleware } from '../middlewares/auth.js'; const router = express.Router(); @@ -87,50 +87,17 @@ router.get('/search', basicAuthMiddleware, async (req, res) => { if (req.query.worker_name) filter.worker_name = req.query.worker_name; if (req.query.platform) filter.platform = req.query.platform; - const searchTerm = query.toLowerCase().trim(); - const allArticles = await getNotifiedArticles(filter); + // Obtener modo de búsqueda (AND u OR), por defecto AND + const searchMode = (req.query.mode || 'AND').toUpperCase(); - // 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; - - // Buscar en username - const username = (article.username || '').toLowerCase(); - if (username.includes(searchTerm)) return true; - - // Buscar en worker_name - const worker_name = (article.worker_name || '').toLowerCase(); - if (worker_name.includes(searchTerm)) return true; - - return false; - }); + // Usar la función de búsqueda eficiente por palabras + const articles = await searchArticles(query, filter, searchMode); res.json({ - articles: filtered, - total: filtered.length, + articles: articles, + total: articles.length, query: query, + mode: searchMode, }); } catch (error) { res.status(500).json({ error: error.message }); diff --git a/web/backend/services/mongodb.js b/web/backend/services/mongodb.js index 56a532f..c69b142 100644 --- a/web/backend/services/mongodb.js +++ b/web/backend/services/mongodb.js @@ -105,6 +105,50 @@ async function createIndexes() { await db.collection('articles').createIndex({ username: 1 }); await db.collection('articles').createIndex({ worker_name: 1 }); + // Índices para búsqueda eficiente por palabras (regex case-insensitive) + // Estos índices mejoran el rendimiento de búsquedas regex en estos campos + try { + await db.collection('articles').createIndex({ title: 1 }); + await db.collection('articles').createIndex({ description: 1 }); + await db.collection('articles').createIndex({ location: 1 }); + await db.collection('articles').createIndex({ price: 1 }); + console.log('✅ Índices para búsqueda por palabras creados'); + } catch (error) { + // Si los índices ya existen, ignorar el error + if (error.code !== 85 && error.code !== 86) { + console.error('Error creando índices de búsqueda:', error.message); + } + } + + // Índice de texto para búsqueda eficiente por palabras (opcional, para búsqueda de texto completo) + // Nota: MongoDB solo permite un índice de texto por colección + try { + await db.collection('articles').createIndex( + { + title: 'text', + description: 'text', + location: 'text', + platform: 'text' + }, + { + name: 'articles_text_search', + weights: { + title: 10, + description: 5, + location: 3, + platform: 2 + }, + default_language: 'spanish' + } + ); + console.log('✅ Índice de texto para búsqueda creado'); + } catch (error) { + // Si el índice ya existe, ignorar el error + if (error.code !== 85 && error.code !== 86) { + console.error('Error creando índice de texto:', error.message); + } + } + console.log('✅ Índices de MongoDB creados'); } catch (error) { console.error('Error creando índices de MongoDB:', error.message); @@ -403,6 +447,203 @@ export async function getArticleFacets(usernameFilter = null) { } } +// Buscar artículos por texto de forma eficiente usando índices de MongoDB +// La búsqueda es por palabras con soporte para modo AND u OR +// AND: todas las palabras deben estar presentes +// OR: al menos una palabra debe estar presente +export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND') { + if (!db) { + return []; + } + + try { + const articlesCollection = db.collection('articles'); + + // Limpiar y dividir la consulta en palabras + const queryWords = searchQuery + .trim() + .split(/\s+/) + .filter(word => word.length > 0) + .map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); // Escapar caracteres especiales de regex + + if (queryWords.length === 0) { + return []; + } + + // Construir query base con filtros + const query = {}; + + // Aplicar filtros de usuario y worker + if (filter.username) { + query['user_info.username'] = filter.username; + } + if (filter.worker_name) { + query['user_info.worker_name'] = filter.worker_name; + } + if (filter.platform) { + query.platform = filter.platform; + } + + // Campos de texto donde buscar (ordenados por importancia) + const textFields = ['title', 'description', 'location', 'platform', 'id']; + + if (searchMode.toUpperCase() === 'OR') { + // Modo OR: al menos una palabra debe estar presente + const orConditions = []; + + // Para cada palabra, crear una condición que busque en todos los campos + for (const word of queryWords) { + const wordRegex = new RegExp(word, 'i'); // Case-insensitive + + // Condiciones para esta palabra en campos de texto principales + const fieldConditions = textFields.map(field => ({ + [field]: { $regex: wordRegex } + })); + + // Buscar en user_info si no hay filtro específico + if (!filter.username && !filter.worker_name) { + fieldConditions.push( + { 'user_info.username': { $regex: wordRegex } }, + { 'user_info.worker_name': { $regex: wordRegex } }, + { username: { $regex: wordRegex } }, // Compatibilidad con estructura antigua + { worker_name: { $regex: wordRegex } } // Compatibilidad con estructura antigua + ); + } + + // Buscar precio como número (si la palabra es un número) + const priceNum = parseFloat(word.replace(/[^\d.,]/g, '').replace(',', '.')); + if (!isNaN(priceNum) && priceNum > 0) { + // Buscar precio exacto o cercano (±10%) + const priceLower = priceNum * 0.9; + const priceUpper = priceNum * 1.1; + fieldConditions.push({ + price: { $gte: priceLower, $lte: priceUpper } + }); + } + + // Esta palabra debe estar en al menos uno de estos campos + orConditions.push({ $or: fieldConditions }); + } + + // Al menos una palabra debe estar presente + if (orConditions.length > 0) { + query.$or = orConditions; + } + } else { + // Modo AND (por defecto): todas las palabras deben estar presentes + const wordConditions = []; + + // Para cada palabra, crear una condición que busque en todos los campos de texto + for (const word of queryWords) { + const wordRegex = new RegExp(word, 'i'); // Case-insensitive + + // Condiciones para esta palabra en campos de texto principales + const fieldConditions = textFields.map(field => ({ + [field]: { $regex: wordRegex } + })); + + // Buscar en user_info si no hay filtro específico + if (!filter.username && !filter.worker_name) { + fieldConditions.push( + { 'user_info.username': { $regex: wordRegex } }, + { 'user_info.worker_name': { $regex: wordRegex } }, + { username: { $regex: wordRegex } }, // Compatibilidad con estructura antigua + { worker_name: { $regex: wordRegex } } // Compatibilidad con estructura antigua + ); + } + + // Buscar precio como número (si la palabra es un número) + const priceNum = parseFloat(word.replace(/[^\d.,]/g, '').replace(',', '.')); + if (!isNaN(priceNum) && priceNum > 0) { + // Buscar precio exacto o cercano (±10%) + const priceLower = priceNum * 0.9; + const priceUpper = priceNum * 1.1; + fieldConditions.push({ + price: { $gte: priceLower, $lte: priceUpper } + }); + } + + // Esta palabra debe estar en al menos uno de estos campos + wordConditions.push({ $or: fieldConditions }); + } + + // Todas las palabras deben estar presentes + if (wordConditions.length > 0) { + query.$and = wordConditions; + } + } + + // Ejecutar búsqueda con ordenamiento + const articles = await articlesCollection + .find(query) + .sort({ createdAt: -1, modified_at: -1 }) + .toArray(); + + // Transformar artículos según el usuario solicitado (similar a getNotifiedArticles) + return articles.map(article => { + let relevantUserInfo = null; + if (filter.username) { + relevantUserInfo = (article.user_info || []).find( + ui => ui.username === filter.username + ); + if (!relevantUserInfo) return null; + } else if (filter.worker_name) { + relevantUserInfo = (article.user_info || []).find( + ui => ui.worker_name === filter.worker_name + ); + if (!relevantUserInfo) return null; + } + + const result = { + ...article, + _id: article._id.toString(), + expiresAt: article.expiresAt?.getTime() || null, + }; + + const fallbackTime = + article.createdAt?.getTime?.() || + (typeof article.createdAt === 'number' ? article.createdAt : null) || + article.modified_at?.getTime?.() || + null; + + if (relevantUserInfo) { + result.username = relevantUserInfo.username; + result.worker_name = relevantUserInfo.worker_name; + result.is_favorite = relevantUserInfo.is_favorite || false; + result.notifiedAt = + relevantUserInfo.notified_at?.getTime?.() || + (typeof relevantUserInfo.notified_at === 'number' ? relevantUserInfo.notified_at : null) || + fallbackTime; + } else { + const firstUserInfo = (article.user_info || [])[0]; + if (firstUserInfo) { + result.username = firstUserInfo.username; + result.worker_name = firstUserInfo.worker_name; + result.is_favorite = firstUserInfo.is_favorite || false; + result.notifiedAt = + firstUserInfo.notified_at?.getTime?.() || + (typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) || + fallbackTime; + } else { + // Compatibilidad con estructura antigua + result.username = article.username; + result.worker_name = article.worker_name; + result.is_favorite = article.is_favorite || false; + result.notifiedAt = + article.notifiedAt?.getTime?.() || + (typeof article.notifiedAt === 'number' ? article.notifiedAt : null) || + fallbackTime; + } + } + + return result; + }).filter(article => article !== null); + } catch (error) { + console.error('Error buscando artículos en MongoDB:', error.message); + return []; + } +} + export async function getFavorites(username = null) { if (!db) { return []; diff --git a/web/frontend/src/services/api.js b/web/frontend/src/services/api.js index 05f2183..5ff6d14 100644 --- a/web/frontend/src/services/api.js +++ b/web/frontend/src/services/api.js @@ -90,9 +90,9 @@ export default { return response.data; }, - async searchArticles(query) { + async searchArticles(query, mode = 'AND') { const response = await api.get('/articles/search', { - params: { q: query }, + params: { q: query, mode: mode }, }); return response.data; }, diff --git a/web/frontend/src/views/Articles.vue b/web/frontend/src/views/Articles.vue index fe7943d..9e1cdbc 100644 --- a/web/frontend/src/views/Articles.vue +++ b/web/frontend/src/views/Articles.vue @@ -20,27 +20,44 @@ Búsqueda -
- - - -
-
- +
+
+ + + +
+
+ +
+
+ + +
+

+ Todas las palabras deben estar presentes + Al menos una palabra debe estar presente +

@@ -252,6 +269,7 @@ const selectedPlatform = ref(''); const selectedUsername = ref(''); const selectedWorker = ref(''); const searchQuery = ref(''); +const searchMode = ref('AND'); // 'AND' o 'OR' const autoPollInterval = ref(null); const searchTimeout = ref(null); const POLL_INTERVAL = 30000; // 30 segundos @@ -397,7 +415,7 @@ async function searchArticles(query) { searching.value = true; try { - const data = await api.searchArticles(query); + const data = await api.searchArticles(query, searchMode.value); let filtered = data.articles || []; @@ -421,8 +439,8 @@ async function searchArticles(query) { } } -// Watch para buscar cuando cambie searchQuery (con debounce) -watch(searchQuery, (newQuery) => { +// Watch para buscar cuando cambie searchQuery o searchMode (con debounce) +watch([searchQuery, searchMode], ([newQuery]) => { // Limpiar timeout anterior if (searchTimeout.value) { clearTimeout(searchTimeout.value);