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 @@
+ Todas las palabras deben estar presentes + Al menos una palabra debe estar presente +