- Updated .gitignore to include additional IDE and OS files, as well as log and web build directories. - Expanded config.sample.yaml to include cache configuration options for memory and Redis. - Modified wallamonitor.py to load cache configuration and initialize ArticleCache. - Refactored QueueManager to utilize ArticleCache for tracking notified articles. - Improved logging setup to dynamically determine log file path based on environment.
391 lines
11 KiB
JavaScript
391 lines
11 KiB
JavaScript
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';
|
||
|
||
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, '../..');
|
||
|
||
const app = express();
|
||
const server = createServer(app);
|
||
const wss = new WebSocketServer({ server });
|
||
|
||
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 FAVORITES_PATH = join(PROJECT_ROOT, 'favorites.json');
|
||
|
||
// 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;
|
||
}
|
||
|
||
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');
|
||
} 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 estadísticas
|
||
app.get('/api/stats', async (req, res) => {
|
||
try {
|
||
const workers = readJSON(WORKERS_PATH, { items: [] });
|
||
const favorites = readJSON(FAVORITES_PATH, []);
|
||
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', (req, res) => {
|
||
try {
|
||
const favorites = readJSON(FAVORITES_PATH, []);
|
||
res.json(favorites);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Añadir favorito
|
||
app.post('/api/favorites', (req, res) => {
|
||
try {
|
||
const favorite = req.body;
|
||
const favorites = readJSON(FAVORITES_PATH, []);
|
||
|
||
// Evitar duplicados
|
||
if (!favorites.find(f => f.id === favorite.id && f.platform === favorite.platform)) {
|
||
favorites.push({
|
||
...favorite,
|
||
addedAt: new Date().toISOString(),
|
||
});
|
||
writeJSON(FAVORITES_PATH, favorites);
|
||
broadcast({ type: 'favorites_updated', data: favorites });
|
||
res.json({ success: true, favorites });
|
||
} else {
|
||
res.json({ success: false, message: 'Ya existe en favoritos' });
|
||
}
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Eliminar favorito
|
||
app.delete('/api/favorites/:platform/:id', (req, res) => {
|
||
try {
|
||
const { platform, id } = req.params;
|
||
const favorites = readJSON(FAVORITES_PATH, []);
|
||
const filtered = favorites.filter(
|
||
f => !(f.platform === platform && f.id === id)
|
||
);
|
||
|
||
writeJSON(FAVORITES_PATH, filtered);
|
||
broadcast({ type: 'favorites_updated', data: filtered });
|
||
res.json({ success: true, favorites: filtered });
|
||
} catch (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 });
|
||
}
|
||
});
|
||
|
||
// Obtener logs (últimas líneas)
|
||
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: [] });
|
||
}
|
||
}
|
||
|
||
// 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.'] });
|
||
}
|
||
} catch (e) {
|
||
return res.json({ logs: [] });
|
||
}
|
||
|
||
const logs = readFileSync(logFile, 'utf8');
|
||
const lines = logs.split('\n').filter(l => l.trim());
|
||
const limit = parseInt(req.query.limit) || 100;
|
||
const lastLines = lines.slice(-limit);
|
||
|
||
res.json({ logs: lastLines });
|
||
} 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 });
|
||
}
|
||
});
|
||
|
||
// 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
|
||
const watcher = watch([WORKERS_PATH, FAVORITES_PATH, watchLogPath].filter(p => existsSync(p)), {
|
||
persistent: true,
|
||
ignoreInitial: true,
|
||
});
|
||
|
||
watcher.on('change', (path) => {
|
||
console.log(`Archivo cambiado: ${path}`);
|
||
if (path === WORKERS_PATH) {
|
||
const workers = readJSON(WORKERS_PATH);
|
||
broadcast({ type: 'workers_updated', data: workers });
|
||
} else if (path === FAVORITES_PATH) {
|
||
const favorites = readJSON(FAVORITES_PATH);
|
||
broadcast({ type: 'favorites_updated', data: favorites });
|
||
} else if (path === LOG_PATH) {
|
||
broadcast({ type: 'logs_updated' });
|
||
}
|
||
});
|
||
|
||
// Inicializar servidor
|
||
const PORT = process.env.PORT || 3001;
|
||
|
||
async function startServer() {
|
||
await initRedis();
|
||
|
||
server.listen(PORT, () => {
|
||
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${PORT}`);
|
||
console.log(`📡 WebSocket disponible en ws://localhost:${PORT}`);
|
||
});
|
||
}
|
||
|
||
startServer().catch(console.error);
|
||
|