Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 03:21:50 +01:00
parent 19932854ca
commit 81bf0675ed
32 changed files with 3081 additions and 932 deletions

View File

@@ -1,74 +1,57 @@
import { getRedisClient, initNotifiedArticleKeys } from './redis.js';
import { getDB, initNotifiedArticleKeys } from './mongodb.js';
import { broadcast } from './websocket.js';
import { sendPushNotifications } from './webPush.js';
import { ARTICLE_MONITORING } from '../config/constants.js';
let notifiedArticleKeys = new Set();
let notifiedArticleIds = new Set();
let articlesCheckInterval = null;
// Función para detectar y enviar artículos nuevos
async function checkForNewArticles() {
const redisClient = getRedisClient();
if (!redisClient) {
const db = getDB();
if (!db) {
return;
}
try {
const currentKeys = await redisClient.keys('notified:*');
const currentKeysSet = new Set(currentKeys);
const articlesCollection = db.collection('articles');
// Obtener todos los artículos con sus IDs
const allArticles = await articlesCollection.find(
{},
{ projection: { platform: 1, id: 1, title: 1, price: 1, currency: 1, url: 1, images: 1 } }
).toArray();
// Encontrar claves nuevas
const newKeys = currentKeys.filter(key => !notifiedArticleKeys.has(key));
const currentIds = new Set(
allArticles.map(a => `${a.platform}:${a.id}`)
);
if (newKeys.length > 0) {
// Obtener los artículos nuevos
const newArticles = [];
for (const key of newKeys) {
try {
const value = await redisClient.get(key);
if (value) {
// Intentar parsear como JSON
let articleData = {};
try {
articleData = JSON.parse(value);
} catch (e) {
// Si no es JSON válido, extraer información de la key
const parts = key.split(':');
if (parts.length >= 3) {
articleData = {
platform: parts[1],
id: parts.slice(2).join(':'),
};
}
}
// Añadir información adicional si está disponible
if (articleData.platform && articleData.id) {
newArticles.push({
platform: articleData.platform || 'unknown',
id: articleData.id || 'unknown',
title: articleData.title || null,
price: articleData.price || null,
currency: articleData.currency || '€',
url: articleData.url || null,
images: articleData.images || [],
});
}
}
} catch (error) {
console.error(`Error obteniendo artículo de Redis (${key}):`, error.message);
}
}
// Encontrar artículos nuevos
const newArticles = allArticles.filter(article => {
const articleId = `${article.platform}:${article.id}`;
return !notifiedArticleIds.has(articleId);
});
if (newArticles.length > 0) {
// Preparar artículos para enviar
const articlesToSend = newArticles.map(article => ({
platform: article.platform || 'unknown',
id: article.id || 'unknown',
title: article.title || null,
price: article.price || null,
currency: article.currency || '',
url: article.url || null,
images: article.images || [],
}));
// Enviar artículos nuevos por WebSocket
if (newArticles.length > 0) {
if (articlesToSend.length > 0) {
broadcast({
type: 'new_articles',
data: newArticles
data: articlesToSend
});
// Enviar notificaciones push para cada artículo nuevo
for (const article of newArticles) {
for (const article of articlesToSend) {
await sendPushNotifications({
title: `Nuevo artículo en ${article.platform?.toUpperCase() || 'Wallabicher'}`,
body: article.title || 'Artículo nuevo disponible',
@@ -83,20 +66,29 @@ async function checkForNewArticles() {
}
}
// Actualizar el set de claves notificadas
notifiedArticleKeys = currentKeysSet;
// Actualizar el set de IDs notificadas
notifiedArticleIds = currentIds;
}
} catch (error) {
console.error('Error verificando artículos nuevos:', error.message);
}
}
// Inicializar el check de artículos cuando Redis esté listo
// Inicializar el check de artículos cuando MongoDB esté listo
export async function startArticleMonitoring() {
const redisClient = getRedisClient();
if (redisClient) {
// Inicializar claves conocidas
notifiedArticleKeys = await initNotifiedArticleKeys();
const db = getDB();
if (db) {
// Inicializar IDs conocidas
const keys = await initNotifiedArticleKeys();
notifiedArticleIds = new Set(
Array.from(keys).map(key => {
const parts = key.replace('notified:', '').split(':');
if (parts.length >= 2) {
return `${parts[0]}:${parts.slice(1).join(':')}`;
}
return key;
})
);
// Iniciar intervalo para verificar nuevos artículos
articlesCheckInterval = setInterval(checkForNewArticles, ARTICLE_MONITORING.CHECK_INTERVAL);

View File

@@ -1,34 +1,13 @@
import { watch } from 'chokidar';
import { existsSync } from 'fs';
import { PATHS } from '../config/constants.js';
import { readJSON } from '../utils/fileUtils.js';
import { broadcast } from './websocket.js';
// File watcher ya no es necesario ya que los workers se almacenan en MongoDB
// Los cambios se notifican directamente a través de WebSocket cuando se actualizan via API
let watcher = null;
// Inicializar file watcher
// Inicializar file watcher (ahora vacío, mantenido para compatibilidad)
export function initFileWatcher() {
// Watch files for changes
const filesToWatch = [PATHS.WORKERS].filter(p => existsSync(p));
if (filesToWatch.length === 0) {
return;
}
watcher = watch(filesToWatch, {
persistent: true,
ignoreInitial: true,
});
watcher.on('change', async (path) => {
console.log(`Archivo cambiado: ${path}`);
if (path === PATHS.WORKERS) {
const workers = readJSON(PATHS.WORKERS);
broadcast({ type: 'workers_updated', data: workers });
}
});
console.log('✅ File watcher inicializado');
// Los workers ahora se almacenan en MongoDB y se notifican directamente
// a través de WebSocket cuando se actualizan via API
console.log(' File watcher no es necesario (workers en MongoDB)');
}
// Detener file watcher

View File

@@ -0,0 +1,879 @@
import { MongoClient } from 'mongodb';
import yaml from 'yaml';
import { readFileSync, existsSync } from 'fs';
import bcrypt from 'bcrypt';
import { RateLimiterMemory } from 'rate-limiter-flexible';
import { PATHS } from '../config/constants.js';
import { RATE_LIMIT } from '../config/constants.js';
import { readJSON } from '../utils/fileUtils.js';
let mongoClient = null;
let db = null;
let rateLimiter = null;
let config = null;
// Duración de sesión en milisegundos (24 horas)
const SESSION_DURATION = 24 * 60 * 60 * 1000;
// TTL de artículos notificados en milisegundos (7 días)
const NOTIFIED_ARTICLE_TTL = 7 * 24 * 60 * 60 * 1000;
// Inicializar MongoDB si está configurado
export async function initMongoDB() {
try {
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
const cacheConfig = config?.cache;
if (cacheConfig?.type === 'mongodb') {
const mongodbConfig = cacheConfig.mongodb;
// En Docker, usar el nombre del servicio si no se especifica host
const mongodbHost = process.env.MONGODB_HOST || mongodbConfig.host || 'localhost';
const mongodbPort = process.env.MONGODB_PORT || mongodbConfig.port || 27017;
const database = process.env.MONGODB_DATABASE || mongodbConfig.database || 'wallabicher';
const username = process.env.MONGODB_USERNAME || mongodbConfig.username;
const password = process.env.MONGODB_PASSWORD || mongodbConfig.password;
const authSource = mongodbConfig.auth_source || 'admin';
// Construir URL de conexión
let connectionString = 'mongodb://';
if (username && password) {
connectionString += `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`;
}
connectionString += `${mongodbHost}:${mongodbPort}`;
if (username && password) {
connectionString += `/?authSource=${authSource}`;
}
mongoClient = new MongoClient(connectionString);
await mongoClient.connect();
db = mongoClient.db(database);
console.log(`✅ Conectado a MongoDB (${database})`);
// Crear índices
await createIndexes();
// Inicializar rate limiter con memoria (MongoDB no tiene rate limiter nativo, usar memoria)
try {
rateLimiter = new RateLimiterMemory({
points: RATE_LIMIT.POINTS,
duration: RATE_LIMIT.DURATION,
blockDuration: RATE_LIMIT.BLOCK_DURATION,
});
console.log('✅ Rate limiter inicializado con memoria');
} catch (error) {
console.error('Error inicializando rate limiter:', error.message);
}
// Inicializar usuario admin por defecto si no existe
await initDefaultAdmin();
// Migrar workers.json a MongoDB para admin si no existe
await migrateWorkersFromFile();
} else {
console.log(' MongoDB no configurado, usando modo memoria');
console.log('⚠️ Rate limiting y autenticación requieren MongoDB');
}
} catch (error) {
console.error('Error inicializando MongoDB:', error.message);
}
}
// Crear índices necesarios
async function createIndexes() {
if (!db) return;
try {
// Índices para usuarios
await db.collection('users').createIndex({ username: 1 }, { unique: true });
// Índices para sesiones (con TTL)
await db.collection('sessions').createIndex({ token: 1 }, { unique: true });
await db.collection('sessions').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
// Índices para workers
await db.collection('workers').createIndex({ username: 1 });
// Índices para artículos notificados
await db.collection('articles').createIndex({ platform: 1, id: 1 }, { unique: true });
await db.collection('articles').createIndex({ 'user_info.username': 1 });
await db.collection('articles').createIndex({ 'user_info.worker_name': 1 });
await db.collection('articles').createIndex({ 'user_info.is_favorite': 1 });
await db.collection('articles').createIndex({ 'user_info.notified_at': -1 });
await db.collection('articles').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
// Índices de compatibilidad con estructura antigua
await db.collection('articles').createIndex({ username: 1 });
await db.collection('articles').createIndex({ worker_name: 1 });
await db.collection('articles').createIndex({ notifiedAt: -1 });
console.log('✅ Índices de MongoDB creados');
} catch (error) {
console.error('Error creando índices de MongoDB:', error.message);
}
}
// Inicializar usuario admin por defecto
async function initDefaultAdmin() {
if (!db) return;
try {
const usersCollection = db.collection('users');
const adminExists = await usersCollection.findOne({ username: 'admin' });
if (!adminExists) {
// Crear usuario admin por defecto con contraseña "admin"
// En producción, esto debería cambiarse
const defaultPassword = 'admin';
const hashedPassword = await bcrypt.hash(defaultPassword, 10);
await usersCollection.insertOne({
username: 'admin',
passwordHash: hashedPassword,
role: 'admin',
createdAt: new Date(),
});
console.log('✅ Usuario admin creado por defecto (usuario: admin, contraseña: admin)');
console.log('⚠️ IMPORTANTE: Cambia la contraseña por defecto en producción');
} else {
// Asegurar que el usuario admin tiene el rol correcto (para usuarios existentes)
await usersCollection.updateOne(
{ username: 'admin' },
{ $set: { role: 'admin' } }
);
}
} catch (error) {
console.error('Error inicializando usuario admin:', error.message);
}
}
// Migrar workers.json a MongoDB para el usuario admin si no existe
async function migrateWorkersFromFile() {
if (!db) return;
try {
const workersCollection = db.collection('workers');
const adminWorkers = await workersCollection.findOne({ username: 'admin' });
// Si ya existen workers para admin en MongoDB, no migrar
if (adminWorkers) {
console.log(' Workers de admin ya existen en MongoDB, omitiendo migración');
return;
}
// Intentar leer workers.json
if (!existsSync(PATHS.WORKERS)) {
console.log(' workers.json no existe, creando estructura vacía para admin');
// Crear estructura vacía por defecto
const defaultWorkers = {
username: 'admin',
general: {
title_exclude: [],
description_exclude: []
},
items: [],
disabled: [],
updatedAt: new Date(),
createdAt: new Date()
};
await workersCollection.insertOne(defaultWorkers);
return;
}
// Leer workers.json y migrar a MongoDB
const workersData = readJSON(PATHS.WORKERS, {
general: {
title_exclude: [],
description_exclude: []
},
items: [],
disabled: []
});
// Guardar en MongoDB para admin
await workersCollection.insertOne({
username: 'admin',
...workersData,
updatedAt: new Date(),
createdAt: new Date()
});
console.log(`✅ Workers migrados desde workers.json al usuario admin (${workersData.items?.length || 0} items)`);
} catch (error) {
console.error('Error migrando workers.json a MongoDB:', error.message);
// No lanzar error, solo registrar
}
}
// Getters
export function getMongoDBClient() {
return mongoClient;
}
export function getDB() {
return db;
}
export function getRateLimiter() {
return rateLimiter;
}
export function getConfig() {
return config;
}
export function reloadConfig() {
try {
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
return config;
} catch (error) {
console.error('Error recargando configuración:', error.message);
return config;
}
}
// Funciones de utilidad para artículos
export async function getNotifiedArticles(filter = {}) {
if (!db) {
return [];
}
try {
const articlesCollection = db.collection('articles');
// Construir query de filtro
const query = {};
if (filter.platform) query.platform = filter.platform;
// Si se especifica username, buscar en user_info
if (filter.username) {
query['user_info.username'] = filter.username;
}
// Si se especifica worker_name, buscar en user_info
if (filter.worker_name) {
query['user_info.worker_name'] = filter.worker_name;
}
const articles = await articlesCollection
.find(query)
.sort({ 'user_info.notified_at': -1, createdAt: -1 })
.toArray();
// Filtrar y transformar artículos según el usuario solicitado
return articles.map(article => {
// Si hay filtro de username, solo devolver el user_info correspondiente
let relevantUserInfo = null;
if (filter.username) {
relevantUserInfo = (article.user_info || []).find(
ui => ui.username === filter.username
);
// Si no hay user_info para este usuario, no incluir el artículo
if (!relevantUserInfo) return null;
} else if (filter.worker_name) {
// Si solo hay filtro de worker, buscar el primer user_info con ese worker
relevantUserInfo = (article.user_info || []).find(
ui => ui.worker_name === filter.worker_name
);
if (!relevantUserInfo) return null;
}
// Construir el artículo con la información relevante
const result = {
...article,
_id: article._id.toString(),
expiresAt: article.expiresAt?.getTime() || null,
};
// Si hay un user_info específico, usar sus datos
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() || Date.now();
} else {
// Sin filtro específico, mostrar el primer user_info o datos generales
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() || Date.now();
} 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() || Date.now();
}
}
return result;
}).filter(article => article !== null);
} catch (error) {
console.error('Error obteniendo artículos de MongoDB:', error.message);
return [];
}
}
export async function getFavorites(username = null) {
if (!db) {
return [];
}
try {
const articlesCollection = db.collection('articles');
// Si se especifica username, buscar solo favoritos de ese usuario
let query = {};
if (username) {
query['user_info.username'] = username;
query['user_info.is_favorite'] = true;
} else {
// Sin username, buscar cualquier artículo con algún favorito
query['user_info.is_favorite'] = true;
}
const articles = await articlesCollection
.find(query)
.sort({ 'user_info.notified_at': -1, createdAt: -1 })
.toArray();
// Filtrar y transformar para devolver solo los favoritos relevantes
const favorites = [];
for (const article of articles) {
const userInfoList = article.user_info || [];
if (username) {
// Solo devolver favoritos del usuario especificado
const userInfo = userInfoList.find(ui =>
ui.username === username && ui.is_favorite === true
);
if (userInfo) {
favorites.push({
...article,
_id: article._id.toString(),
username: userInfo.username,
worker_name: userInfo.worker_name,
is_favorite: true,
notifiedAt: userInfo.notified_at?.getTime() || Date.now(),
expiresAt: article.expiresAt?.getTime() || null,
});
}
} else {
// Sin filtro de usuario, devolver todos los favoritos (uno por user_info)
for (const userInfo of userInfoList) {
if (userInfo.is_favorite === true) {
favorites.push({
...article,
_id: article._id.toString(),
username: userInfo.username,
worker_name: userInfo.worker_name,
is_favorite: true,
notifiedAt: userInfo.notified_at?.getTime() || Date.now(),
expiresAt: article.expiresAt?.getTime() || null,
});
}
}
}
}
return favorites;
} catch (error) {
console.error('Error obteniendo favoritos de MongoDB:', error.message);
return [];
}
}
// Inicializar claves conocidas para evitar notificar artículos existentes
export async function initNotifiedArticleKeys() {
if (!db) {
return new Set();
}
try {
const articlesCollection = db.collection('articles');
const articles = await articlesCollection.find({}, { projection: { platform: 1, id: 1 } }).toArray();
const keysSet = new Set(articles.map(a => `notified:${a.platform}:${a.id}`));
console.log(`📋 ${keysSet.size} artículos ya notificados detectados`);
return keysSet;
} catch (error) {
console.error('Error inicializando claves de artículos:', error.message);
return new Set();
}
}
// Funciones para manejar workers por usuario
export async function getWorkers(username) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
try {
const workersCollection = db.collection('workers');
const workersData = await workersCollection.findOne({ username });
if (!workersData) {
// Retornar estructura vacía por defecto
return {
general: {
title_exclude: [],
description_exclude: []
},
items: [],
disabled: []
};
}
// Remover campos de MongoDB y devolver solo los datos relevantes
const { _id, username: _, updatedAt, createdAt, ...data } = workersData;
return data;
} catch (error) {
console.error(`Error obteniendo workers para ${username}:`, error.message);
throw error;
}
}
export async function setWorkers(username, workers) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
try {
const workersCollection = db.collection('workers');
// Usar upsert para insertar o actualizar
await workersCollection.updateOne(
{ username },
{
$set: {
...workers,
username,
updatedAt: new Date(),
},
$setOnInsert: {
createdAt: new Date(),
}
},
{ upsert: true }
);
return true;
} catch (error) {
console.error(`Error guardando workers para ${username}:`, error.message);
throw error;
}
}
// Funciones para usuarios
export async function getUser(username) {
if (!db) {
return null;
}
try {
const usersCollection = db.collection('users');
const user = await usersCollection.findOne({ username });
// Si el usuario no tiene rol, asignar 'user' por defecto (para usuarios antiguos)
if (user && !user.role) {
await usersCollection.updateOne(
{ username },
{ $set: { role: username === 'admin' ? 'admin' : 'user' } }
);
user.role = username === 'admin' ? 'admin' : 'user';
}
return user;
} catch (error) {
console.error(`Error obteniendo usuario ${username}:`, error.message);
return null;
}
}
export async function createUser(userData) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
try {
const usersCollection = db.collection('users');
const result = await usersCollection.insertOne({
...userData,
role: userData.role || 'user', // Por defecto 'user', a menos que se especifique
createdAt: new Date(),
});
return result.insertedId;
} catch (error) {
console.error('Error creando usuario:', error.message);
throw error;
}
}
export async function deleteUser(username) {
if (!db) {
return false;
}
try {
const usersCollection = db.collection('users');
const result = await usersCollection.deleteOne({ username });
return result.deletedCount > 0;
} catch (error) {
console.error(`Error eliminando usuario ${username}:`, error.message);
return false;
}
}
export async function getAllUsers(currentUser = null) {
if (!db) {
return [];
}
try {
const usersCollection = db.collection('users');
// Si hay un usuario actual, verificar si es admin
if (currentUser) {
const currentUserData = await getUser(currentUser); // Usar getUser para asegurar que tiene rol
// Si es admin, puede ver todos los usuarios
if (currentUserData && currentUserData.role === 'admin') {
const users = await usersCollection.find({}, { projection: { passwordHash: 0 } }).toArray();
return users;
}
// Si no es admin, solo puede ver su propio usuario
const user = await usersCollection.findOne(
{ username: currentUser },
{ projection: { passwordHash: 0 } }
);
return user ? [user] : [];
}
// Sin usuario actual, devolver todos (compatibilidad)
const users = await usersCollection.find({}, { projection: { passwordHash: 0 } }).toArray();
return users;
} catch (error) {
console.error('Error obteniendo usuarios:', error.message);
return [];
}
}
export async function updateUserPassword(username, passwordHash) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
try {
const usersCollection = db.collection('users');
await usersCollection.updateOne(
{ username },
{ $set: { passwordHash, updatedAt: new Date() } }
);
return true;
} catch (error) {
console.error(`Error actualizando contraseña de ${username}:`, error.message);
throw error;
}
}
// Funciones para configuración de Telegram
export async function getTelegramConfig(username) {
if (!db) {
return null;
}
try {
const usersCollection = db.collection('users');
const user = await usersCollection.findOne({ username });
if (user && user.telegram) {
return {
token: user.telegram.token || '',
channel: user.telegram.channel || '',
enable_polling: user.telegram.enable_polling || false
};
}
return null;
} catch (error) {
console.error(`Error obteniendo configuración de Telegram para ${username}:`, error.message);
return null;
}
}
export async function setTelegramConfig(username, telegramConfig) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
try {
const usersCollection = db.collection('users');
// Verificar que el usuario existe
const user = await usersCollection.findOne({ username });
if (!user) {
throw new Error(`Usuario ${username} no existe`);
}
// Actualizar configuración de Telegram
await usersCollection.updateOne(
{ username },
{
$set: {
telegram: {
token: telegramConfig.token || '',
channel: telegramConfig.channel || '',
enable_polling: telegramConfig.enable_polling || false
},
updatedAt: new Date()
}
}
);
return true;
} catch (error) {
console.error(`Error guardando configuración de Telegram para ${username}:`, error.message);
throw error;
}
}
// Funciones para sesiones
export async function createSession(username) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
const crypto = await import('crypto');
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_DURATION);
try {
const sessionsCollection = db.collection('sessions');
await sessionsCollection.insertOne({
token,
username,
createdAt: new Date(),
expiresAt,
});
return token;
} catch (error) {
console.error('Error creando sesión:', error.message);
throw error;
}
}
export async function getSession(token) {
if (!db) {
return null;
}
try {
const sessionsCollection = db.collection('sessions');
return await sessionsCollection.findOne({ token });
} catch (error) {
console.error('Error obteniendo sesión:', error.message);
return null;
}
}
export async function deleteSession(token) {
if (!db) {
return false;
}
try {
const sessionsCollection = db.collection('sessions');
const result = await sessionsCollection.deleteOne({ token });
return result.deletedCount > 0;
} catch (error) {
console.error('Error eliminando sesión:', error.message);
return false;
}
}
export async function deleteUserSessions(username) {
if (!db) {
return 0;
}
try {
const sessionsCollection = db.collection('sessions');
const result = await sessionsCollection.deleteMany({ username });
return result.deletedCount;
} catch (error) {
console.error(`Error eliminando sesiones de ${username}:`, error.message);
return 0;
}
}
// Funciones para artículos
export async function saveArticle(articleData) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
try {
const articlesCollection = db.collection('articles');
const expiresAt = new Date(Date.now() + NOTIFIED_ARTICLE_TTL);
// Extraer datos del artículo (sin user_info)
const {
platform,
id,
username,
worker_name,
...articleFields
} = articleData;
// Buscar artículo existente
const existing = await articlesCollection.findOne({ platform, id });
// Preparar user_info para este usuario/worker
const userInfoEntry = {
username: username || null,
worker_name: worker_name || null,
notified: true,
notified_at: new Date(),
is_favorite: false,
};
if (existing) {
// Artículo existe, actualizar o añadir user_info
const existingUserInfo = existing.user_info || [];
// Buscar si ya existe un user_info para este usuario
const existingUserInfoIndex = existingUserInfo.findIndex(
ui => ui.username === username
);
if (existingUserInfoIndex >= 0) {
// Actualizar user_info existente
existingUserInfo[existingUserInfoIndex] = {
...existingUserInfo[existingUserInfoIndex],
worker_name: worker_name || existingUserInfo[existingUserInfoIndex].worker_name,
notified: true,
notified_at: new Date(),
};
} else {
// Añadir nuevo user_info
existingUserInfo.push(userInfoEntry);
}
await articlesCollection.updateOne(
{ platform, id },
{
$set: {
...articleFields,
user_info: existingUserInfo,
expiresAt,
updatedAt: new Date(),
}
}
);
} else {
// Artículo nuevo, crear con user_info
await articlesCollection.insertOne({
platform,
id,
...articleFields,
user_info: [userInfoEntry],
expiresAt,
createdAt: new Date(),
updatedAt: new Date(),
});
}
return true;
} catch (error) {
console.error('Error guardando artículo:', error.message);
throw error;
}
}
export async function getArticle(platform, id) {
if (!db) {
return null;
}
try {
const articlesCollection = db.collection('articles');
return await articlesCollection.findOne({ platform, id });
} catch (error) {
console.error('Error obteniendo artículo:', error.message);
return null;
}
}
export async function updateArticleFavorite(platform, id, is_favorite, username) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
if (!username) {
throw new Error('username es requerido para actualizar favoritos');
}
try {
const articlesCollection = db.collection('articles');
const article = await articlesCollection.findOne({ platform, id });
if (!article) {
throw new Error('Artículo no encontrado');
}
const userInfoList = article.user_info || [];
const userInfoIndex = userInfoList.findIndex(ui => ui.username === username);
if (userInfoIndex >= 0) {
// Actualizar user_info existente
userInfoList[userInfoIndex].is_favorite = is_favorite;
} else {
// Si no existe user_info para este usuario, crear uno
userInfoList.push({
username,
worker_name: null,
notified: false,
notified_at: null,
is_favorite: is_favorite,
});
}
await articlesCollection.updateOne(
{ platform, id },
{
$set: {
user_info: userInfoList,
updatedAt: new Date()
}
}
);
return true;
} catch (error) {
console.error('Error actualizando favorito:', error.message);
throw error;
}
}
export async function clearAllArticles() {
if (!db) {
return 0;
}
try {
const articlesCollection = db.collection('articles');
const result = await articlesCollection.deleteMany({});
return result.deletedCount;
} catch (error) {
console.error('Error limpiando artículos:', error.message);
return 0;
}
}
// Cerrar conexión
export async function closeMongoDB() {
if (mongoClient) {
await mongoClient.close();
mongoClient = null;
db = null;
console.log('✅ Conexión a MongoDB cerrada');
}
}

View File

@@ -5,6 +5,7 @@ import bcrypt from 'bcrypt';
import { RateLimiterRedis } from 'rate-limiter-flexible';
import { PATHS } from '../config/constants.js';
import { RATE_LIMIT } from '../config/constants.js';
import { readJSON } from '../utils/fileUtils.js';
let redisClient = null;
let rateLimiter = null;
@@ -61,6 +62,9 @@ export async function initRedis() {
// Inicializar usuario admin por defecto si no existe
await initDefaultAdmin();
// Migrar workers.json a Redis para admin si no existe
await migrateWorkersFromFile();
} else {
console.log(' Redis no configurado, usando modo memoria');
console.log('⚠️ Rate limiting y autenticación requieren Redis');
@@ -157,6 +161,8 @@ export async function getNotifiedArticles() {
url: articleData.url || null,
images: articleData.images || [],
modified_at: articleData.modified_at || null,
username: articleData.username || null,
worker_name: articleData.worker_name || null,
notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000,
expiresAt: Date.now() + ttl * 1000,
});
@@ -217,3 +223,100 @@ export async function initNotifiedArticleKeys() {
}
}
// Funciones para manejar workers por usuario
export async function getWorkers(username) {
if (!redisClient) {
throw new Error('Redis no está disponible');
}
try {
const workersKey = `workers:${username}`;
const workersData = await redisClient.get(workersKey);
if (!workersData) {
// Retornar estructura vacía por defecto
return {
general: {
title_exclude: [],
description_exclude: []
},
items: [],
disabled: []
};
}
return JSON.parse(workersData);
} catch (error) {
console.error(`Error obteniendo workers para ${username}:`, error.message);
throw error;
}
}
export async function setWorkers(username, workers) {
if (!redisClient) {
throw new Error('Redis no está disponible');
}
try {
const workersKey = `workers:${username}`;
const workersData = JSON.stringify(workers);
await redisClient.set(workersKey, workersData);
return true;
} catch (error) {
console.error(`Error guardando workers para ${username}:`, error.message);
throw error;
}
}
// Migrar workers.json a Redis para el usuario admin si no existe
async function migrateWorkersFromFile() {
if (!redisClient) {
return;
}
try {
const adminWorkersKey = 'workers:admin';
const adminWorkersExists = await redisClient.exists(adminWorkersKey);
// Si ya existen workers para admin en Redis, no migrar
if (adminWorkersExists) {
console.log(' Workers de admin ya existen en Redis, omitiendo migración');
return;
}
// Intentar leer workers.json
if (!existsSync(PATHS.WORKERS)) {
console.log(' workers.json no existe, creando estructura vacía para admin');
// Crear estructura vacía por defecto
const defaultWorkers = {
general: {
title_exclude: [],
description_exclude: []
},
items: [],
disabled: []
};
await setWorkers('admin', defaultWorkers);
return;
}
// Leer workers.json y migrar a Redis
const workersData = readJSON(PATHS.WORKERS, {
general: {
title_exclude: [],
description_exclude: []
},
items: [],
disabled: []
});
// Guardar en Redis para admin
await setWorkers('admin', workersData);
console.log(`✅ Workers migrados desde workers.json al usuario admin (${workersData.items?.length || 0} items)`);
} catch (error) {
console.error('Error migrando workers.json a Redis:', error.message);
// No lanzar error, solo registrar
}
}