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:
@@ -1,5 +1,5 @@
|
|||||||
import express from 'express';
|
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';
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
|
|
||||||
const router = express.Router();
|
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.worker_name) filter.worker_name = req.query.worker_name;
|
||||||
if (req.query.platform) filter.platform = req.query.platform;
|
if (req.query.platform) filter.platform = req.query.platform;
|
||||||
|
|
||||||
const searchTerm = query.toLowerCase().trim();
|
// Obtener modo de búsqueda (AND u OR), por defecto AND
|
||||||
const allArticles = await getNotifiedArticles(filter);
|
const searchMode = (req.query.mode || 'AND').toUpperCase();
|
||||||
|
|
||||||
// Filtrar artículos que coincidan con la búsqueda
|
// Usar la función de búsqueda eficiente por palabras
|
||||||
const filtered = allArticles.filter(article => {
|
const articles = await searchArticles(query, filter, searchMode);
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
articles: filtered,
|
articles: articles,
|
||||||
total: filtered.length,
|
total: articles.length,
|
||||||
query: query,
|
query: query,
|
||||||
|
mode: searchMode,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
@@ -105,6 +105,50 @@ async function createIndexes() {
|
|||||||
await db.collection('articles').createIndex({ username: 1 });
|
await db.collection('articles').createIndex({ username: 1 });
|
||||||
await db.collection('articles').createIndex({ worker_name: 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');
|
console.log('✅ Índices de MongoDB creados');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creando índices de MongoDB:', error.message);
|
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) {
|
export async function getFavorites(username = null) {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ export default {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async searchArticles(query) {
|
async searchArticles(query, mode = 'AND') {
|
||||||
const response = await api.get('/articles/search', {
|
const response = await api.get('/articles/search', {
|
||||||
params: { q: query },
|
params: { q: query, mode: mode },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,27 +20,44 @@
|
|||||||
<MagnifyingGlassIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
<MagnifyingGlassIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
Búsqueda
|
Búsqueda
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="flex gap-2">
|
||||||
<input
|
<div class="relative flex-1">
|
||||||
v-model="searchQuery"
|
<input
|
||||||
type="text"
|
v-model="searchQuery"
|
||||||
placeholder="Buscar artículos por título, descripción, precio, localidad..."
|
type="text"
|
||||||
class="input pr-10 pl-10"
|
placeholder="Buscar artículos por título, descripción, precio, localidad..."
|
||||||
@input="searchQuery = $event.target.value"
|
class="input pr-10 pl-10"
|
||||||
/>
|
@input="searchQuery = $event.target.value"
|
||||||
<MagnifyingGlassIcon class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
/>
|
||||||
<span v-if="searching" class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
<MagnifyingGlassIcon class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
|
<span v-if="searching" class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
</span>
|
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
|
||||||
<button
|
</span>
|
||||||
v-else-if="searchQuery"
|
<button
|
||||||
@click="searchQuery = ''"
|
v-else-if="searchQuery"
|
||||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
@click="searchQuery = ''"
|
||||||
title="Limpiar búsqueda"
|
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
>
|
title="Limpiar búsqueda"
|
||||||
<XMarkIcon class="w-5 h-5" />
|
>
|
||||||
</button>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Separador -->
|
<!-- Separador -->
|
||||||
@@ -252,6 +269,7 @@ const selectedPlatform = ref('');
|
|||||||
const selectedUsername = ref('');
|
const selectedUsername = ref('');
|
||||||
const selectedWorker = ref('');
|
const selectedWorker = ref('');
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
|
const searchMode = ref('AND'); // 'AND' o 'OR'
|
||||||
const autoPollInterval = ref(null);
|
const autoPollInterval = ref(null);
|
||||||
const searchTimeout = ref(null);
|
const searchTimeout = ref(null);
|
||||||
const POLL_INTERVAL = 30000; // 30 segundos
|
const POLL_INTERVAL = 30000; // 30 segundos
|
||||||
@@ -397,7 +415,7 @@ async function searchArticles(query) {
|
|||||||
searching.value = true;
|
searching.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.searchArticles(query);
|
const data = await api.searchArticles(query, searchMode.value);
|
||||||
|
|
||||||
let filtered = data.articles || [];
|
let filtered = data.articles || [];
|
||||||
|
|
||||||
@@ -421,8 +439,8 @@ async function searchArticles(query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch para buscar cuando cambie searchQuery (con debounce)
|
// Watch para buscar cuando cambie searchQuery o searchMode (con debounce)
|
||||||
watch(searchQuery, (newQuery) => {
|
watch([searchQuery, searchMode], ([newQuery]) => {
|
||||||
// Limpiar timeout anterior
|
// Limpiar timeout anterior
|
||||||
if (searchTimeout.value) {
|
if (searchTimeout.value) {
|
||||||
clearTimeout(searchTimeout.value);
|
clearTimeout(searchTimeout.value);
|
||||||
|
|||||||
Reference in New Issue
Block a user