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.

This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 19:08:27 +01:00
parent 16ec8dc2fa
commit deb3dc2b31
4 changed files with 292 additions and 66 deletions

View File

@@ -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 });

View File

@@ -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 [];

View File

@@ -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;
},

View File

@@ -20,7 +20,8 @@
<MagnifyingGlassIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
Búsqueda
</label>
<div class="relative">
<div class="flex gap-2">
<div class="relative flex-1">
<input
v-model="searchQuery"
type="text"
@@ -41,6 +42,22 @@
<XMarkIcon class="w-5 h-5" />
</button>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Modo:</label>
<select
v-model="searchMode"
class="input text-sm w-24"
title="AND: todas las palabras deben estar presentes | OR: al menos una palabra debe estar presente"
>
<option value="AND">AND</option>
<option value="OR">OR</option>
</select>
</div>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span v-if="searchMode === 'AND'">Todas las palabras deben estar presentes</span>
<span v-else>Al menos una palabra debe estar presente</span>
</p>
</div>
<!-- Separador -->
@@ -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);