diff --git a/FLUJOS/BACK_BACK/FLUJOS_APP.js b/FLUJOS/BACK_BACK/FLUJOS_APP.js index 4522bd30..ff09348b 100755 --- a/FLUJOS/BACK_BACK/FLUJOS_APP.js +++ b/FLUJOS/BACK_BACK/FLUJOS_APP.js @@ -19,10 +19,10 @@ app.use( helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", 'https://unpkg.com', 'https://cdnjs.cloudflare.com'], + scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://esm.sh', 'https://unpkg.com', 'https://cdnjs.cloudflare.com'], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], imgSrc: ["'self'", 'data:', 'blob:'], - connectSrc: ["'self'"], + connectSrc: ["'self'", 'https://esm.sh'], fontSrc: ["'self'", 'https://fonts.gstatic.com'], frameAncestors: ["'none'"], objectSrc: ["'none'"], @@ -150,10 +150,8 @@ app.get('/api/data', async (req, res) => { if (subtematica) nodesQuery.subtema = subtematica; if (regexKw) nodesQuery.texto = { $regex: regexKw, $options: 'i' }; if (fechaGte) nodesQuery.fecha = { $gte: fechaGte, $lte: fechaLte }; - // Ejecutar la consulta y obtener los resultados de las colecciones - console.log('Ejecutando consulta para nodos en colecciones...'); - // Límite de imágenes: un tercio del total para no saturar el grafo - const imagenesLimit = Math.floor(nodosLimit / 3); + // Imágenes: proporcional a nodosLimit (1/3), mínimo 3 para siempre mostrar algo + const imagenesLimit = Math.max(3, Math.floor(nodosLimit / 3)); const [wikipediaNodes, noticiasNodes, torrentsNodes, imagenesNodes, imagenesWikiNodes] = await Promise.all([ wikipediaCollection.find(nodesQuery).limit(nodosLimit).toArray(), @@ -162,15 +160,11 @@ app.get('/api/data', async (req, res) => { imagenesCollection.find(nodesQuery).limit(imagenesLimit).toArray(), imagenesWikiCollection.find(nodesQuery).limit(imagenesLimit).toArray(), ]); - console.log('Nodos Wikipedia:', wikipediaNodes.length, '| Noticias:', noticiasNodes.length, - '| Torrents:', torrentsNodes.length, '| Imágenes:', imagenesNodes.length, - '| Imágenes Wiki:', imagenesWikiNodes.length); // Preferir imagenes analizadas (con keywords); si no hay, usar imagenes_wiki const imgNodes = imagenesNodes.length > 0 ? imagenesNodes : imagenesWikiNodes; const nodes = [...wikipediaNodes, ...noticiasNodes, ...torrentsNodes]; - console.log('Total nodos texto:', nodes.length, '| Nodos imagen:', imgNodes.length); // Nodos de texto const formattedNodes = nodes.map((result) => ({ @@ -179,21 +173,28 @@ app.get('/api/data', async (req, res) => { tema: result.tema || 'sin tema', content: result.texto || '', fecha: result.fecha || '', + fuente: result.fuente || result.autor || result.source || '', type: 'texto', })); // Nodos de imagen — image_url construida desde image_path absoluto const WIKI_IMAGES_BASE = '/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/'; const imgFormattedNodes = imgNodes.map((result) => { - const relativePath = result.image_path - ? result.image_path.replace(WIKI_IMAGES_BASE, '') - : null; + let relativePath = null; + if (result.image_path && result.image_path.startsWith(WIKI_IMAGES_BASE)) { + const rel = result.image_path.slice(WIKI_IMAGES_BASE.length); + // Reject paths with traversal sequences or absolute paths + if (rel && !rel.includes('..') && !rel.startsWith('/')) { + relativePath = rel; + } + } return { id: result.archivo.trim(), group: result.subtema || result.tema || 'imagen', tema: result.tema || 'sin tema', content: result.texto || result.descripcion_wiki || '', fecha: result.fecha || '', + fuente: result.fuente || result.autor || result.source || '', type: 'imagen', image_url: relativePath ? `/wiki-images/${relativePath}` : null, label: result.subtema || result.tema || result.archivo, @@ -201,47 +202,186 @@ app.get('/api/data', async (req, res) => { }).filter(n => n.image_url); const allNodes = [...formattedNodes, ...imgFormattedNodes]; - const formattedNodes_final = allNodes; // alias para claridad + const formattedNodes_final = allNodes; - // Obtener los IDs de todos los nodos (texto + imagen) const nodeIds = formattedNodes_final.map(node => node.id); - console.log('IDs de nodos:', nodeIds); - // Construir la consulta para obtener los enlaces relacionados con los nodos obtenidos + // Links texto-texto: filtro por complejidad del usuario. + // Links imagen-texto (source1_type='imagen'): umbral fijo de 3% porque la similitud + // TF-IDF keyword↔texto es estructuralmente baja (~3-10%) y quedarían siempre ocultos. + const IMG_UMBRAL = 3.0; const linksQuery = { - porcentaje_similitud: { $gte: porcentajeSimilitudMin }, noticia1: { $in: nodeIds }, noticia2: { $in: nodeIds }, + $or: [ + { porcentaje_similitud: { $gte: porcentajeSimilitudMin } }, + { porcentaje_similitud: { $gte: IMG_UMBRAL }, source1_type: 'imagen' }, + ], }; - console.log('Consulta para enlaces:', linksQuery); - // Ejecutar la consulta y obtener los enlaces - console.log('Ejecutando consulta para enlaces...'); const links = await comparacionesCollection.find(linksQuery).toArray(); - console.log('Enlaces obtenidos:', links.length); - // Formatear los enlaces sin normalizar los IDs - const formattedLinks = links.map((result) => ({ + // Limitar links por nodo: cada nodo muestra solo sus top MAX conexiones más fuertes. + // Sin esto, los 100 nodos wikipedia del mismo tema se conectan todos entre sí + // (>8000 links) y forman una bola densa inmanejable. + const MAX_LINKS_PER_NODE = 8; + links.sort((a, b) => b.porcentaje_similitud - a.porcentaje_similitud); + const nodeDegree = new Map(); + const selectedLinks = []; + for (const link of links) { + const n1 = link.noticia1.trim(); + const n2 = link.noticia2.trim(); + const d1 = nodeDegree.get(n1) || 0; + const d2 = nodeDegree.get(n2) || 0; + if (d1 < MAX_LINKS_PER_NODE && d2 < MAX_LINKS_PER_NODE) { + selectedLinks.push(link); + nodeDegree.set(n1, d1 + 1); + nodeDegree.set(n2, d2 + 1); + } + } + + const formattedLinks = selectedLinks.map((result) => ({ source: result.noticia1.trim(), target: result.noticia2.trim(), value: result.porcentaje_similitud, })); - // Enviar los datos al cliente en formato JSON res.json({ nodes: formattedNodes_final, links: formattedLinks }); - console.log('Datos enviados al cliente'); } catch (error) { console.error('Error al obtener datos:', error); res.status(500).json({ error: 'Error al obtener datos' }); } }); +// ── STATS: cálculo pesado → MongoDB, endpoint solo lee ───────────────────────── +const STATS_TEMAS = ['guerra global','inteligencia y seguridad','cambio climático','demografía y sociedad','economía y corporaciones']; +const STATS_TEXT_COLS = ['wikipedia','noticias','torrents','imagenes_wiki']; + +async function computeAndSaveStats() { + if (!db) return; + console.log('[stats] Calculando stats...'); + try { + const [wikipedia, noticias, torrents, imagenes, imagenes_wiki, comparaciones, + comp_imagen_total] = await Promise.all([ + db.collection('wikipedia').countDocuments({}), + db.collection('noticias').countDocuments({}), + db.collection('torrents').countDocuments({}), + db.collection('imagenes').countDocuments({}), + db.collection('imagenes_wiki').countDocuments({}), + db.collection('comparaciones').estimatedDocumentCount(), + db.collection('comparaciones').countDocuments({ source1_type: 'imagen' }), + ]); + + const por_tema = {}; + await Promise.all(STATS_TEMAS.map(async tema => { + const counts = await Promise.all( + STATS_TEXT_COLS.map(col => db.collection(col).countDocuments({ tema })) + ); + por_tema[tema] = {}; + STATS_TEXT_COLS.forEach((col, i) => { por_tema[tema][col] = counts[i]; }); + })); + + const subtemaRaw = await db.collection('wikipedia').aggregate([ + { $match: { tema: { $in: STATS_TEMAS } } }, + { $group: { _id: { tema: '$tema', subtema: '$subtema' }, n: { $sum: 1 } } }, + { $sort: { n: -1 } }, + ]).toArray(); + const subtemas = {}; + for (const doc of subtemaRaw) { + const { tema, subtema } = doc._id; + if (!subtemas[tema]) subtemas[tema] = []; + if (subtemas[tema].length < 10) + subtemas[tema].push({ subtema: subtema || 'sin subtema', n: doc.n }); + } + + const notSubRaw = await db.collection('noticias').aggregate([ + { $match: { tema: { $in: STATS_TEMAS } } }, + { $group: { _id: { tema: '$tema', subtema: '$subtema' }, n: { $sum: 1 } } }, + { $sort: { n: -1 } }, + ]).toArray(); + const subtemas_noticias = {}; + for (const doc of notSubRaw) { + const { tema, subtema } = doc._id; + if (!subtemas_noticias[tema]) subtemas_noticias[tema] = []; + if (subtemas_noticias[tema].length < 8) + subtemas_noticias[tema].push({ subtema: subtema || 'sin subtema', n: doc.n }); + } + + const compImgRaw = await db.collection('comparaciones').aggregate([ + { $match: { source1_type: 'imagen' } }, + { $group: { + _id: '$tema', + count: { $sum: 1 }, + avg_sim: { $avg: '$porcentaje_similitud' }, + max_sim: { $max: '$porcentaje_similitud' }, + }}, + ]).toArray(); + const comp_imagen = { total: comp_imagen_total, por_tema: {} }; + for (const d of compImgRaw) { + comp_imagen.por_tema[d._id] = { + count: d.count, + avg_sim: Math.round(d.avg_sim * 10) / 10, + max_sim: Math.round(d.max_sim * 10) / 10, + }; + } + + // $sample sin filtro — 99.94% de docs son texto-texto + const simRaw = await db.collection('comparaciones').aggregate([ + { $sample: { size: 3000 } }, + { $group: { _id: null, avg_sim: { $avg: '$porcentaje_similitud' }, max_sim: { $max: '$porcentaje_similitud' } } }, + ]).toArray(); + const comp_texto = simRaw[0] + ? { avg_sim: Math.round(simRaw[0].avg_sim * 10) / 10, max_sim: Math.round(simRaw[0].max_sim * 10) / 10 } + : { avg_sim: 0, max_sim: 0 }; + + const snapshot = { + _id: 'stats', + computed_at: new Date(), + totales: { wikipedia, noticias, torrents, imagenes, imagenes_wiki, comparaciones }, + por_tema, subtemas, subtemas_noticias, comp_imagen, comp_texto, + }; + + await db.collection('stats_cache').replaceOne({ _id: 'stats' }, snapshot, { upsert: true }); + console.log('[stats] Stats guardadas en MongoDB'); + } catch (e) { + console.error('[stats] Error calculando stats:', e.message); + } +} + +// Calcular al arrancar (si no hay cache o tiene más de 6h) y luego cada 6h +async function initStats() { + if (!db) return; + const cached = await db.collection('stats_cache').findOne({ _id: 'stats' }); + const sixHours = 6 * 60 * 60 * 1000; + if (!cached || !cached.computed_at || Date.now() - new Date(cached.computed_at).getTime() > sixHours) { + computeAndSaveStats(); // async, no bloquea el arranque + } + setInterval(computeAndSaveStats, sixHours); +} + +app.get('/api/stats', async (req, res) => { + try { + if (!db) return res.status(500).json({ error: 'Sin conexión a BD' }); + const cached = await db.collection('stats_cache').findOne({ _id: 'stats' }); + if (!cached) return res.status(503).json({ error: 'Stats aún calculándose, intenta en unos minutos' }); + const { _id, ...data } = cached; + res.json(data); + } catch (e) { + console.error('Error /api/stats:', e); + res.status(500).json({ error: 'Error al obtener estadísticas' }); + } +}); + // **Luego, definir las rutas de las páginas principales** // Rutas para las páginas principales app.get('/', (req, res) => { res.sendFile(path.join(__dirname, '../VISUALIZACION/public/index.html')); }); +app.get('/stats.html', (req, res) => { + res.sendFile(path.join(__dirname, '../VISUALIZACION/public/stats.html')); +}); + app.get('/climate.html', (req, res) => { res.sendFile(path.join(__dirname, '../VISUALIZACION/public/climate.html')); }); @@ -287,6 +427,7 @@ async function connectToMongoDB() { await mongoClient.connect(); db = mongoClient.db(dbName); console.log('Conectado a MongoDB'); + initStats(); } catch (error) { console.error('Error al conectar a MongoDB:', error); process.exit(1); // Salir de la aplicación si no se puede conectar diff --git a/FLUJOS/VISUALIZACION/public/climate.html b/FLUJOS/VISUALIZACION/public/climate.html index 436fb446..d7cea58d 100755 --- a/FLUJOS/VISUALIZACION/public/climate.html +++ b/FLUJOS/VISUALIZACION/public/climate.html @@ -4,43 +4,120 @@