feat: implement user authentication and login modal, refactor backend

This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 00:39:28 +01:00
parent 9a61f16959
commit e99424c9ba
29 changed files with 3061 additions and 855 deletions

View File

@@ -1,875 +1,71 @@
import express from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { watch } from 'chokidar';
import yaml from 'yaml';
import redis from 'redis';
import webpush from 'web-push';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// En Docker, usar PROJECT_ROOT de env, sino usar ruta relativa
const PROJECT_ROOT = process.env.PROJECT_ROOT || join(__dirname, '../..');
import { PATHS, SERVER } from './config/constants.js';
import { rateLimitMiddleware } from './middlewares/rateLimit.js';
import { initRedis } from './services/redis.js';
import { initVAPIDKeys } from './services/webPush.js';
import { initWebSocket } from './services/websocket.js';
import { startArticleMonitoring } from './services/articleMonitor.js';
import { initFileWatcher } from './services/fileWatcher.js';
import routes from './routes/index.js';
import workersRouter from './routes/workers.js';
import articlesRouter from './routes/articles.js';
import favoritesRouter from './routes/favorites.js';
import logsRouter from './routes/logs.js';
import configRouter from './routes/config.js';
import telegramRouter from './routes/telegram.js';
import pushRouter from './routes/push.js';
import usersRouter from './routes/users.js';
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
// Middlewares globales
app.use(cors());
app.use(express.json());
// Configuración
const CONFIG_PATH = join(PROJECT_ROOT, 'config.yaml');
const WORKERS_PATH = join(PROJECT_ROOT, 'workers.json');
const PUSH_SUBSCRIPTIONS_PATH = join(PROJECT_ROOT, 'push-subscriptions.json');
// Aplicar rate limiting a todas las rutas API
app.use('/api', rateLimitMiddleware);
// Inicializar VAPID keys para Web Push
let vapidKeys = null;
const VAPID_KEYS_PATH = join(PROJECT_ROOT, 'vapid-keys.json');
function initVAPIDKeys() {
try {
if (existsSync(VAPID_KEYS_PATH)) {
vapidKeys = JSON.parse(readFileSync(VAPID_KEYS_PATH, 'utf8'));
console.log('✅ VAPID keys cargadas desde archivo');
} else {
// Generar nuevas VAPID keys
vapidKeys = webpush.generateVAPIDKeys();
writeFileSync(VAPID_KEYS_PATH, JSON.stringify(vapidKeys, null, 2), 'utf8');
console.log('✅ Nuevas VAPID keys generadas y guardadas');
}
// Configurar web-push con las VAPID keys
webpush.setVapidDetails(
'mailto:admin@pribyte.cloud', // Contacto (puedes cambiarlo)
vapidKeys.publicKey,
vapidKeys.privateKey
);
} catch (error) {
console.error('Error inicializando VAPID keys:', error.message);
}
}
// Inicializar VAPID keys al arrancar
initVAPIDKeys();
// Función para obtener la ruta del log (en Docker puede estar en /data/logs)
function getLogPath() {
const logsDirPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
const rootLogPath = join(PROJECT_ROOT, 'monitor.log');
if (existsSync(logsDirPath)) {
return logsDirPath;
}
return rootLogPath;
}
// Inicializar WebSocket
initWebSocket(server);
const LOG_PATH = getLogPath();
let redisClient = null;
let config = null;
// Inicializar Redis si está configurado
async function initRedis() {
try {
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
const cacheConfig = config?.cache;
if (cacheConfig?.type === 'redis') {
const redisConfig = cacheConfig.redis;
// En Docker, usar el nombre del servicio si no se especifica host
const redisHost = process.env.REDIS_HOST || redisConfig.host || 'localhost';
redisClient = redis.createClient({
socket: {
host: redisHost,
port: redisConfig.port || 6379,
},
password: redisConfig.password || undefined,
database: redisConfig.db || 0,
});
redisClient.on('error', (err) => console.error('Redis Client Error', err));
await redisClient.connect();
console.log('✅ Conectado a Redis');
// Inicializar claves conocidas para evitar notificar artículos existentes
try {
const initialKeys = await redisClient.keys('notified:*');
notifiedArticleKeys = new Set(initialKeys);
console.log(`📋 ${notifiedArticleKeys.size} artículos ya notificados detectados`);
} catch (error) {
console.error('Error inicializando claves de artículos:', error.message);
}
} else {
console.log(' Redis no configurado, usando modo memoria');
}
} catch (error) {
console.error('Error inicializando Redis:', error.message);
}
}
// Broadcast a todos los clientes WebSocket
function broadcast(data) {
wss.clients.forEach((client) => {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(JSON.stringify(data));
}
});
}
// Leer archivo JSON de forma segura
function readJSON(path, defaultValue = {}) {
try {
if (existsSync(path)) {
return JSON.parse(readFileSync(path, 'utf8'));
}
return defaultValue;
} catch (error) {
console.error(`Error leyendo ${path}:`, error.message);
return defaultValue;
}
}
// Escribir archivo JSON
function writeJSON(path, data) {
try {
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (error) {
console.error(`Error escribiendo ${path}:`, error.message);
return false;
}
}
// Obtener artículos notificados desde Redis
async function getNotifiedArticles() {
if (!redisClient) {
return [];
}
try {
const keys = await redisClient.keys('notified:*');
const articles = [];
for (const key of keys) {
const parts = key.split(':');
if (parts.length >= 3) {
const platform = parts[1];
const id = parts.slice(2).join(':');
const ttl = await redisClient.ttl(key);
const value = await redisClient.get(key);
// Intentar parsear como JSON (nuevo formato con toda la info)
let articleData = {};
try {
if (value && value !== '1') {
articleData = JSON.parse(value);
}
} catch (e) {
// Si no es JSON válido, usar valor por defecto
}
articles.push({
platform: articleData.platform || platform,
id: articleData.id || id,
title: articleData.title || null,
description: articleData.description || null,
price: articleData.price || null,
currency: articleData.currency || null,
location: articleData.location || null,
allows_shipping: articleData.allows_shipping !== undefined ? articleData.allows_shipping : null,
url: articleData.url || null,
images: articleData.images || [],
modified_at: articleData.modified_at || null,
notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000,
expiresAt: Date.now() + ttl * 1000,
});
}
}
return articles;
} catch (error) {
console.error('Error obteniendo artículos de Redis:', error.message);
return [];
}
}
// API Routes
// Obtener favoritos desde Redis
async function getFavorites() {
if (!redisClient) {
return [];
}
try {
const keys = await redisClient.keys('notified:*');
const favorites = [];
for (const key of keys) {
const value = await redisClient.get(key);
if (value) {
try {
const articleData = JSON.parse(value);
if (articleData.is_favorite === true) {
favorites.push(articleData);
}
} catch (e) {
// Si no es JSON válido, ignorar
}
}
}
return favorites;
} catch (error) {
console.error('Error obteniendo favoritos de Redis:', error.message);
return [];
}
}
// Obtener estadísticas
app.get('/api/stats', async (req, res) => {
try {
const workers = readJSON(WORKERS_PATH, { items: [] });
const favorites = await getFavorites();
const notifiedArticles = await getNotifiedArticles();
const stats = {
totalWorkers: workers.items?.length || 0,
activeWorkers: (workers.items || []).filter(w => !workers.disabled?.includes(w.name)).length,
totalFavorites: favorites.length,
totalNotified: notifiedArticles.length,
platforms: {
wallapop: notifiedArticles.filter(a => a.platform === 'wallapop').length,
vinted: notifiedArticles.filter(a => a.platform === 'vinted').length,
},
};
res.json(stats);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener workers
app.get('/api/workers', (req, res) => {
try {
const workers = readJSON(WORKERS_PATH, { items: [], general: {}, disabled: [] });
res.json(workers);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Actualizar workers
app.put('/api/workers', (req, res) => {
try {
const workers = req.body;
if (writeJSON(WORKERS_PATH, workers)) {
broadcast({ type: 'workers_updated', data: workers });
res.json({ success: true });
} else {
res.status(500).json({ error: 'Error guardando workers' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener favoritos
app.get('/api/favorites', async (req, res) => {
try {
const favorites = await getFavorites();
res.json(favorites);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Añadir favorito
app.post('/api/favorites', async (req, res) => {
try {
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { platform, id } = req.body;
if (!platform || !id) {
return res.status(400).json({ error: 'platform e id son requeridos' });
}
const key = `notified:${platform}:${id}`;
const value = await redisClient.get(key);
if (!value) {
return res.status(404).json({ error: 'Artículo no encontrado' });
}
try {
const articleData = JSON.parse(value);
articleData.is_favorite = true;
// Mantener el TTL existente
const ttl = await redisClient.ttl(key);
if (ttl > 0) {
await redisClient.setex(key, ttl, JSON.stringify(articleData));
} else {
await redisClient.set(key, JSON.stringify(articleData));
}
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({ success: true, favorites });
} catch (e) {
res.status(500).json({ error: 'Error procesando artículo' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Eliminar favorito
app.delete('/api/favorites/:platform/:id', async (req, res) => {
try {
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { platform, id } = req.params;
const key = `notified:${platform}:${id}`;
const value = await redisClient.get(key);
if (!value) {
return res.status(404).json({ error: 'Artículo no encontrado' });
}
try {
const articleData = JSON.parse(value);
articleData.is_favorite = false;
// Mantener el TTL existente
const ttl = await redisClient.ttl(key);
if (ttl > 0) {
await redisClient.setex(key, ttl, JSON.stringify(articleData));
} else {
await redisClient.set(key, JSON.stringify(articleData));
}
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({ success: true, favorites });
} catch (e) {
res.status(500).json({ error: 'Error procesando artículo' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Limpiar toda la caché de Redis
app.delete('/api/cache', async (req, res) => {
try {
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
// Obtener todas las claves que empiezan con 'notified:'
const keys = await redisClient.keys('notified:*');
if (!keys || keys.length === 0) {
return res.json({
success: true,
message: 'Cache ya está vacío',
count: 0
});
}
// Eliminar todas las claves
const count = keys.length;
for (const key of keys) {
await redisClient.del(key);
}
// Notificar a los clientes WebSocket
broadcast({
type: 'cache_cleared',
data: { count, timestamp: Date.now() }
});
// También actualizar favoritos (debería estar vacío ahora)
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({
success: true,
message: `Cache limpiado: ${count} artículos eliminados`,
count
});
} catch (error) {
console.error('Error limpiando cache de Redis:', error);
res.status(500).json({ error: error.message });
}
});
// Obtener artículos notificados
app.get('/api/articles', async (req, res) => {
try {
const articles = await getNotifiedArticles();
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
const sorted = articles.sort((a, b) => b.notifiedAt - a.notifiedAt);
const paginated = sorted.slice(offset, offset + limit);
res.json({
articles: paginated,
total: articles.length,
limit,
offset,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Buscar artículos en Redis
app.get('/api/articles/search', async (req, res) => {
try {
const query = req.query.q || '';
if (!query.trim()) {
return res.json({ articles: [], total: 0 });
}
const searchTerm = query.toLowerCase().trim();
const allArticles = await getNotifiedArticles();
// 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;
return false;
});
// Ordenar por fecha de notificación (más recientes primero)
const sorted = filtered.sort((a, b) => b.notifiedAt - a.notifiedAt);
res.json({
articles: sorted,
total: sorted.length,
query: query,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener logs (últimas líneas o nuevas líneas desde un número de línea)
app.get('/api/logs', (req, res) => {
try {
// Intentar múltiples ubicaciones posibles
let logFile = LOG_PATH;
if (!existsSync(logFile)) {
// Intentar en el directorio de logs
const altPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
if (existsSync(altPath)) {
logFile = altPath;
} else {
return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
}
}
// Verificar que no sea un directorio
try {
const stats = statSync(logFile);
if (stats.isDirectory()) {
return res.json({ logs: ['Error: monitor.log es un directorio. Por favor, elimínalo y reinicia.'], totalLines: 0, lastLineNumber: 0 });
}
} catch (e) {
return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
}
const logsContent = readFileSync(logFile, 'utf8');
const allLines = logsContent.split('\n').filter(l => l.trim());
const totalLines = allLines.length;
// Si se proporciona since (número de línea desde el que empezar), devolver solo las nuevas
const sinceLine = parseInt(req.query.since) || 0;
if (sinceLine > 0 && sinceLine < totalLines) {
// Devolver solo las líneas nuevas después de sinceLine
const newLines = allLines.slice(sinceLine);
return res.json({
logs: newLines,
totalLines: totalLines,
lastLineNumber: totalLines - 1 // Índice de la última línea
});
} else {
// Carga inicial: devolver las últimas líneas
const limit = parseInt(req.query.limit) || 500;
const lastLines = allLines.slice(-limit);
return res.json({
logs: lastLines,
totalLines: totalLines,
lastLineNumber: totalLines - 1 // Índice de la última línea
});
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener configuración
app.get('/api/config', (req, res) => {
try {
if (!config) {
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
}
// No enviar token por seguridad
const safeConfig = { ...config };
if (safeConfig.telegram_token) {
safeConfig.telegram_token = '***';
}
res.json(safeConfig);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener threads/topics de Telegram
app.get('/api/telegram/threads', async (req, res) => {
try {
if (!config) {
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
}
const token = config?.telegram_token;
const channel = config?.telegram_channel;
if (!token || !channel) {
return res.status(400).json({ error: 'Token o canal de Telegram no configurados' });
}
// Convertir el canal a chat_id si es necesario
let chatId = channel;
if (channel.startsWith('@')) {
// Para canales con @, necesitamos obtener el chat_id primero
const getChatUrl = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(channel)}`;
const chatResponse = await fetch(getChatUrl);
const chatData = await chatResponse.json();
if (!chatData.ok) {
return res.status(400).json({ error: `Error obteniendo chat: ${chatData.description || 'Chat no encontrado'}` });
}
chatId = chatData.result.id;
}
// Intentar obtener forum topics
const forumTopicsUrl = `https://api.telegram.org/bot${token}/getForumTopics?chat_id=${chatId}&limit=100`;
const topicsResponse = await fetch(forumTopicsUrl);
const topicsData = await topicsResponse.json();
if (topicsData.ok && topicsData.result?.topics) {
const threads = topicsData.result.topics.map(topic => ({
id: topic.message_thread_id,
name: topic.name || `Thread ${topic.message_thread_id}`,
icon_color: topic.icon_color,
icon_custom_emoji_id: topic.icon_custom_emoji_id,
}));
return res.json({ threads, success: true });
} else {
// Si no hay forum topics, devolver un mensaje informativo
return res.json({
threads: [],
success: false,
message: 'El chat no tiene forum topics habilitados o no se pudieron obtener. Puedes obtener el Thread ID manualmente copiando el enlace del tema.',
info: 'Para obtener el Thread ID manualmente: 1. Haz clic derecho en el tema/hilo en Telegram 2. Selecciona "Copiar enlace del tema" 3. El número al final de la URL es el Thread ID (ej: t.me/c/1234567890/8 → Thread ID = 8)'
});
}
} catch (error) {
console.error('Error obteniendo threads de Telegram:', error.message);
res.status(500).json({ error: error.message });
}
});
// Obtener suscripciones push guardadas
function getPushSubscriptions() {
return readJSON(PUSH_SUBSCRIPTIONS_PATH, []);
}
// Guardar suscripciones push
function savePushSubscriptions(subscriptions) {
return writeJSON(PUSH_SUBSCRIPTIONS_PATH, subscriptions);
}
// API Routes para Push Notifications
// Obtener clave pública VAPID
app.get('/api/push/public-key', (req, res) => {
if (!vapidKeys || !vapidKeys.publicKey) {
return res.status(500).json({ error: 'VAPID keys no están configuradas' });
}
res.json({ publicKey: vapidKeys.publicKey });
});
// Suscribirse a notificaciones push
app.post('/api/push/subscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
// Verificar si ya existe esta suscripción
const existingIndex = subscriptions.findIndex(
sub => sub.endpoint === subscription.endpoint
);
if (existingIndex >= 0) {
subscriptions[existingIndex] = subscription;
} else {
subscriptions.push(subscription);
}
savePushSubscriptions(subscriptions);
console.log(`✅ Nueva suscripción push guardada. Total: ${subscriptions.length}`);
res.json({ success: true, totalSubscriptions: subscriptions.length });
} catch (error) {
console.error('Error guardando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
// Cancelar suscripción push
app.post('/api/push/unsubscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
const filtered = subscriptions.filter(
sub => sub.endpoint !== subscription.endpoint
);
savePushSubscriptions(filtered);
console.log(`✅ Suscripción push cancelada. Total: ${filtered.length}`);
res.json({ success: true, totalSubscriptions: filtered.length });
} catch (error) {
console.error('Error cancelando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
// Enviar notificación push a todas las suscripciones
async function sendPushNotifications(notificationData) {
const subscriptions = getPushSubscriptions();
if (subscriptions.length === 0) {
return;
}
const payload = JSON.stringify(notificationData);
const promises = subscriptions.map(async (subscription) => {
try {
await webpush.sendNotification(subscription, payload);
console.log('✅ Notificación push enviada');
} catch (error) {
console.error('Error enviando notificación push:', error);
// Si la suscripción es inválida (404, 410), eliminarla
if (error.statusCode === 404 || error.statusCode === 410) {
const updatedSubscriptions = getPushSubscriptions().filter(
sub => sub.endpoint !== subscription.endpoint
);
savePushSubscriptions(updatedSubscriptions);
console.log(`Suscripción inválida eliminada. Total: ${updatedSubscriptions.length}`);
}
}
});
await Promise.allSettled(promises);
}
// WebSocket connection
wss.on('connection', (ws) => {
console.log('Cliente WebSocket conectado');
ws.on('close', () => {
console.log('Cliente WebSocket desconectado');
});
ws.on('error', (error) => {
console.error('Error WebSocket:', error);
});
});
// Determinar la ruta del log para el watcher
let watchLogPath = LOG_PATH;
if (!existsSync(watchLogPath)) {
const altPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
if (existsSync(altPath)) {
watchLogPath = altPath;
}
}
// Watch files for changes (ya no vigilamos logs porque usa polling)
const watcher = watch([WORKERS_PATH].filter(p => existsSync(p)), {
persistent: true,
ignoreInitial: true,
});
watcher.on('change', async (path) => {
console.log(`Archivo cambiado: ${path}`);
if (path === WORKERS_PATH) {
const workers = readJSON(WORKERS_PATH);
broadcast({ type: 'workers_updated', data: workers });
}
});
// Rastrear artículos ya notificados para detectar nuevos
let notifiedArticleKeys = new Set();
let articlesCheckInterval = null;
// Función para detectar y enviar artículos nuevos
async function checkForNewArticles() {
if (!redisClient) {
return;
}
try {
const currentKeys = await redisClient.keys('notified:*');
const currentKeysSet = new Set(currentKeys);
// Encontrar claves nuevas
const newKeys = currentKeys.filter(key => !notifiedArticleKeys.has(key));
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);
}
}
// Enviar artículos nuevos por WebSocket
if (newArticles.length > 0) {
broadcast({
type: 'new_articles',
data: newArticles
});
// Enviar notificaciones push para cada artículo nuevo
for (const article of newArticles) {
await sendPushNotifications({
title: `Nuevo artículo en ${article.platform?.toUpperCase() || 'Wallabicher'}`,
body: article.title || 'Artículo nuevo disponible',
icon: '/android-chrome-192x192.png',
image: article.images?.[0] || null,
url: article.url || '/',
platform: article.platform,
price: article.price,
currency: article.currency || '€',
id: article.id,
});
}
}
// Actualizar el set de claves notificadas
notifiedArticleKeys = currentKeysSet;
}
} catch (error) {
console.error('Error verificando artículos nuevos:', error.message);
}
}
// Inicializar el check de artículos cuando Redis esté listo
async function startArticleMonitoring() {
if (redisClient) {
// Iniciar intervalo para verificar nuevos artículos cada 3 segundos
articlesCheckInterval = setInterval(checkForNewArticles, 3000);
console.log('✅ Monitoreo de artículos nuevos iniciado');
}
}
// Rutas API
app.use('/api', routes);
app.use('/api/workers', workersRouter);
app.use('/api/articles', articlesRouter);
app.use('/api/favorites', favoritesRouter);
app.use('/api/logs', logsRouter);
app.use('/api/config', configRouter);
app.use('/api/telegram', telegramRouter);
app.use('/api/push', pushRouter);
app.use('/api/users', usersRouter);
// Inicializar servidor
const PORT = process.env.PORT || 3001;
async function startServer() {
await initRedis();
// Iniciar monitoreo de artículos nuevos
await startArticleMonitoring();
server.listen(PORT, () => {
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${PORT}`);
console.log(`📡 WebSocket disponible en ws://localhost:${PORT}`);
});
try {
// Inicializar Redis
await initRedis();
// Iniciar monitoreo de artículos nuevos
await startArticleMonitoring();
// Inicializar file watcher
initFileWatcher();
// Iniciar servidor HTTP
server.listen(SERVER.PORT, () => {
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${SERVER.PORT}`);
console.log(`📡 WebSocket disponible en ws://localhost:${SERVER.PORT}/ws`);
});
} catch (error) {
console.error('Error al iniciar el servidor:', error);
process.exit(1);
}
}
startServer().catch(console.error);