From 932e8e80db47b6cec8dc0f951006ecc7230b0cbb Mon Sep 17 00:00:00 2001 From: CAPITANSITO Date: Wed, 1 Apr 2026 00:44:37 +0200 Subject: [PATCH] Add image pipeline and demo files from pruebas branch Co-Authored-By: Claude Sonnet 4.6 --- BACK_BACK/FLUJOS_APP_PRUEBAS.js | 97 ++++ BACK_BACK/IMAGENES/.gitignore | 3 + BACK_BACK/IMAGENES/debug_wiki.py | 30 ++ BACK_BACK/IMAGENES/image_analyzer.py | 197 ++++++++ BACK_BACK/IMAGENES/image_comparator.py | 158 +++++++ BACK_BACK/IMAGENES/mongo_helper.py | 172 +++++++ BACK_BACK/IMAGENES/pipeline_pruebas.py | 268 +++++++++++ BACK_BACK/IMAGENES/requirements_imagenes.txt | 16 + BACK_BACK/IMAGENES/wikipedia_image_scraper.py | 446 ++++++++++++++++++ .../public/demos/demo_img_nodes.html | 301 ++++++++++++ .../public/demos/demo_mixed_nodes.html | 346 ++++++++++++++ .../public/demos/demo_text_nodes.html | 242 ++++++++++ .../public/demos/demo_wiki_images.html | 385 +++++++++++++++ VISUALIZACION/public/images/wiki/.gitignore | 2 + 14 files changed, 2663 insertions(+) create mode 100644 BACK_BACK/FLUJOS_APP_PRUEBAS.js create mode 100644 BACK_BACK/IMAGENES/.gitignore create mode 100644 BACK_BACK/IMAGENES/debug_wiki.py create mode 100644 BACK_BACK/IMAGENES/image_analyzer.py create mode 100644 BACK_BACK/IMAGENES/image_comparator.py create mode 100644 BACK_BACK/IMAGENES/mongo_helper.py create mode 100644 BACK_BACK/IMAGENES/pipeline_pruebas.py create mode 100644 BACK_BACK/IMAGENES/requirements_imagenes.txt create mode 100644 BACK_BACK/IMAGENES/wikipedia_image_scraper.py create mode 100644 VISUALIZACION/public/demos/demo_img_nodes.html create mode 100644 VISUALIZACION/public/demos/demo_mixed_nodes.html create mode 100644 VISUALIZACION/public/demos/demo_text_nodes.html create mode 100644 VISUALIZACION/public/demos/demo_wiki_images.html create mode 100644 VISUALIZACION/public/images/wiki/.gitignore diff --git a/BACK_BACK/FLUJOS_APP_PRUEBAS.js b/BACK_BACK/FLUJOS_APP_PRUEBAS.js new file mode 100644 index 00000000..8b6e0f72 --- /dev/null +++ b/BACK_BACK/FLUJOS_APP_PRUEBAS.js @@ -0,0 +1,97 @@ +// FLUJOS_APP_PRUEBAS.js +// Servidor ligero para probar los demos de visualización SIN necesitar MongoDB. +// No modifica ni interfiere con FLUJOS_APP.js. +// Arrancarlo: node FLUJOS_APP_PRUEBAS.js +// URL: http://localhost:3001/demos/demo_text_nodes.html + +const express = require('express'); +const path = require('path'); +const helmet = require('helmet'); + +const app = express(); +const PORT = 3001; + +// CSP ampliada para permitir esm.sh (demos usan ES modules desde CDN) +app.use( + helmet.contentSecurityPolicy({ + directives: { + defaultSrc: ["'self'"], + scriptSrc: [ + "'self'", + "'unsafe-inline'", + "'unsafe-eval'", + 'https://unpkg.com', + 'https://cdnjs.cloudflare.com', + 'https://fonts.googleapis.com', + 'https://esm.sh', + ], + scriptSrcElem: [ + "'self'", + "'unsafe-inline'", + 'https://unpkg.com', + 'https://cdnjs.cloudflare.com', + 'https://esm.sh', + ], + styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], + fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://fonts.googleapis.com'], + imgSrc: ["'self'", 'data:', 'blob:'], + connectSrc: ["'self'", 'https://esm.sh', 'ws://localhost:3001'], + workerSrc: ["'self'", 'blob:'], + scriptSrcAttr: ["'unsafe-inline'"], + }, + }) +); + +// Ruta raíz → index de demos (debe ir ANTES del middleware estático) +app.get('/', (req, res) => { + res.send(` + + + + + FLUJOS — Demos + + + +

FLUJOS · DEMOS

+

Servidor de pruebas — sin MongoDB · puerto 3001

+ + + + `); +}); + +// Archivos estáticos (después de la ruta raíz) +app.use(express.static(path.join(__dirname, '../VISUALIZACION/public'))); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`\n FLUJOS PRUEBAS corriendo en http://localhost:${PORT}`); + console.log(` Demos disponibles:`); + console.log(` http://localhost:${PORT}/demos/demo_text_nodes.html`); + console.log(` http://localhost:${PORT}/demos/demo_img_nodes.html`); + console.log(` http://localhost:${PORT}/demos/demo_mixed_nodes.html\n`); +}); diff --git a/BACK_BACK/IMAGENES/.gitignore b/BACK_BACK/IMAGENES/.gitignore new file mode 100644 index 00000000..d8c14e4b --- /dev/null +++ b/BACK_BACK/IMAGENES/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +output/ +*.pyc diff --git a/BACK_BACK/IMAGENES/debug_wiki.py b/BACK_BACK/IMAGENES/debug_wiki.py new file mode 100644 index 00000000..73fb032a --- /dev/null +++ b/BACK_BACK/IMAGENES/debug_wiki.py @@ -0,0 +1,30 @@ +"""Script de debug para ver qué devuelve la API de Wikipedia/Wikimedia.""" +import requests +from wikipedia_image_scraper import ( + search_articles, get_article_images, get_image_info, should_skip, SKIP_PATTERNS +) + +# 1. Buscar artículos +print("=== ARTÍCULOS ===") +articles = search_articles("cambio climático", lang="es", limit=2) +for a in articles: + print(f" {a['title']}") + +# 2. Imágenes del primer artículo +print("\n=== IMÁGENES DEL ARTÍCULO ===") +img_titles = get_article_images(articles[0]["title"], lang="es", limit=10) +for t in img_titles: + print(f" {t}") + +# 3. Info de las primeras 5 imágenes +print("\n=== INFO DE CADA IMAGEN ===") +for title in img_titles[:5]: + print(f"\n Título: {title}") + info = get_image_info(title) + if info is None: + print(" → get_image_info devolvió None") + continue + print(f" url: {info.get('url', 'N/A')[:80]}") + print(f" size: {info.get('width')}x{info.get('height')} {info.get('size_bytes')}B") + skip, motivo = should_skip(title, info) + print(f" skip: {skip} ({motivo})") diff --git a/BACK_BACK/IMAGENES/image_analyzer.py b/BACK_BACK/IMAGENES/image_analyzer.py new file mode 100644 index 00000000..f5baa729 --- /dev/null +++ b/BACK_BACK/IMAGENES/image_analyzer.py @@ -0,0 +1,197 @@ +""" +image_analyzer.py +----------------- +Analiza imágenes usando un VLM via ollama y extrae: + - tema, subtema + - keywords (lista de palabras clave) + - descripción + - entidades (personas, organizaciones, lugares) + +Modelos recomendados en ollama para visión: + - llava:13b (bueno, ligero) + - qwen2-vl:7b (muy bueno para keywords) + - minicpm-v:8b (rápido y preciso) + - llava-llama3:8b (balance velocidad/calidad) + +Para usar Qwen3.5 GGUF (texto), primero genera caption con un VLM +y luego pasa el texto a Qwen para enriquecer keywords — ver pipeline_pruebas.py + +Uso: + analyzer = ImageAnalyzer(model="qwen2-vl:7b") + result = analyzer.analyze("foto.jpg") + results = analyzer.analyze_folder("./mis_imagenes/") +""" + +import base64 +import json +import os +import re +from datetime import datetime +from pathlib import Path + +import requests + +# ── Configuración ────────────────────────────────────────────────────────────── + +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") +DEFAULT_MODEL = os.getenv("VISION_MODEL", "qwen2-vl:7b") + +SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} + +KEYWORD_PROMPT = """Analiza esta imagen en detalle. +Devuelve ÚNICAMENTE un objeto JSON válido con esta estructura exacta, sin texto adicional: + +{ + "tema": "tema principal de la imagen (1-3 palabras en español)", + "subtema": "subtema específico (1-4 palabras en español)", + "keywords": ["palabra1", "palabra2", "palabra3"], + "descripcion": "descripción breve y objetiva de lo que muestra la imagen (1-2 frases)", + "entidades": ["nombre_propio1", "organizacion1", "lugar1"], + "idioma_detectado": "es/en/fr/..." +} + +Requisitos: +- keywords: entre 8 y 15 palabras clave relevantes, en minúsculas +- entidades: solo si son claramente visibles/identificables, puede estar vacío [] +- todo el contenido en español salvo entidades propias +- SOLO el JSON, sin markdown ni explicaciones""" + + +# ── Clase principal ───────────────────────────────────────────────────────────── + +class ImageAnalyzer: + def __init__(self, model: str = DEFAULT_MODEL, ollama_url: str = OLLAMA_URL): + self.model = model + self.ollama_url = ollama_url.rstrip("/") + + # ── Helpers ──────────────────────────────────────────────────────────────── + + @staticmethod + def encode_image(image_path: str) -> str: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + + def _check_ollama(self) -> bool: + try: + r = requests.get(f"{self.ollama_url}/api/tags", timeout=5) + return r.status_code == 200 + except Exception: + return False + + def _parse_json_response(self, raw: str) -> dict: + """Extrae JSON del response aunque venga con texto alrededor.""" + raw = raw.strip() + # Buscar bloque JSON entre llaves + match = re.search(r'\{[\s\S]*\}', raw) + if match: + return json.loads(match.group()) + raise ValueError(f"No se encontró JSON válido en el response:\n{raw[:300]}") + + # ── Análisis de una imagen ───────────────────────────────────────────────── + + def analyze(self, image_path: str, extra_context: str = "") -> dict: + """ + Analiza una imagen y devuelve dict con keywords y metadata. + + Args: + image_path: ruta a la imagen + extra_context: contexto adicional para el prompt (ej: "esta imagen es de un periódico sobre guerras") + + Returns: + dict con: archivo, tema, subtema, texto, keywords, entidades, + source_type, fecha, image_path + """ + if not os.path.exists(image_path): + raise FileNotFoundError(f"Imagen no encontrada: {image_path}") + + prompt = KEYWORD_PROMPT + if extra_context: + prompt = f"Contexto adicional: {extra_context}\n\n" + prompt + + img_b64 = self.encode_image(image_path) + + payload = { + "model": self.model, + "prompt": prompt, + "images": [img_b64], + "stream": False, + "format": "json", + "options": { + "temperature": 0.1, # baja temperatura = más determinista + "num_predict": 512, + } + } + + print(f" → Enviando a ollama ({self.model}): {Path(image_path).name}") + response = requests.post( + f"{self.ollama_url}/api/generate", + json=payload, + timeout=180 + ) + response.raise_for_status() + + raw_response = response.json().get("response", "") + parsed = self._parse_json_response(raw_response) + + return { + "archivo": Path(image_path).name, + "image_path": str(Path(image_path).resolve()), + "tema": parsed.get("tema", "sin_clasificar").lower(), + "subtema": parsed.get("subtema", "").lower(), + "texto": parsed.get("descripcion", ""), + "keywords": [k.lower().strip() for k in parsed.get("keywords", [])], + "entidades": parsed.get("entidades", []), + "idioma": parsed.get("idioma_detectado", "es"), + "source_type": "imagen", + "fecha": datetime.now().strftime("%Y-%m-%d"), + "modelo_usado": self.model, + } + + # ── Análisis de una carpeta ──────────────────────────────────────────────── + + def analyze_folder(self, folder_path: str, extra_context: str = "") -> list[dict]: + """ + Analiza todas las imágenes de una carpeta. + + Returns: + Lista de resultados, incluye errores como dicts con campo 'error' + """ + folder = Path(folder_path) + if not folder.exists(): + raise FileNotFoundError(f"Carpeta no encontrada: {folder_path}") + + images = [p for p in folder.iterdir() if p.suffix.lower() in SUPPORTED_EXTENSIONS] + print(f"\n[ImageAnalyzer] {len(images)} imágenes encontradas en {folder_path}") + + if not self._check_ollama(): + print(f" ⚠ ollama no responde en {self.ollama_url}") + print(" Instala ollama: https://ollama.ai") + print(f" Luego: ollama pull {self.model}") + return [] + + results = [] + for i, img_path in enumerate(images, 1): + print(f" [{i}/{len(images)}] {img_path.name}") + try: + result = self.analyze(str(img_path), extra_context) + results.append(result) + print(f" tema={result['tema']} | keywords={result['keywords'][:4]}...") + except Exception as e: + print(f" ERROR: {e}") + results.append({ + "archivo": img_path.name, + "error": str(e), + "source_type": "imagen", + "fecha": datetime.now().strftime("%Y-%m-%d"), + }) + + print(f"\n[ImageAnalyzer] Completado: {len([r for r in results if 'error' not in r])}/{len(images)} OK\n") + return results + + # ── Guardar resultados a JSON ────────────────────────────────────────────── + + @staticmethod + def save_json(results: list[dict], output_path: str): + with open(output_path, "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + print(f"[ImageAnalyzer] Guardado: {output_path} ({len(results)} registros)") diff --git a/BACK_BACK/IMAGENES/image_comparator.py b/BACK_BACK/IMAGENES/image_comparator.py new file mode 100644 index 00000000..e4bb22f2 --- /dev/null +++ b/BACK_BACK/IMAGENES/image_comparator.py @@ -0,0 +1,158 @@ +""" +image_comparator.py +------------------- +Compara keywords de imágenes con documentos de texto (noticias, wikipedia, torrents) +usando similitud TF-IDF coseno. + +Produce documentos para la colección 'comparaciones' de MongoDB, +con la misma estructura que los comparaciones texto-texto ya existentes: + { noticia1, noticia2, porcentaje_similitud } + +Ampliado con campos opcionales: source1_type, source2_type (para saber qué se comparó). + +Uso: + comp = ImageComparator() + resultados = comp.compare_image_vs_collection(imagen_doc, lista_docs_texto) + top = comp.top_n(resultados, n=10) +""" + +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.metrics.pairwise import cosine_similarity +import numpy as np + + +# ── Clase principal ───────────────────────────────────────────────────────────── + +class ImageComparator: + + def __init__(self, threshold: float = 5.0): + """ + Args: + threshold: porcentaje mínimo de similitud para incluir en resultados (0-100) + """ + self.threshold = threshold + self.vectorizer = TfidfVectorizer( + analyzer="word", + ngram_range=(1, 2), + min_df=1, + strip_accents="unicode", + lowercase=True, + ) + + # ── Conversión de documentos a texto ────────────────────────────────────── + + @staticmethod + def doc_to_text(doc: dict) -> str: + """ + Concatena los campos relevantes de un documento en un string para TF-IDF. + Compatible con estructura de noticias/wikipedia/torrents/imagenes. + """ + parts = [] + # keywords de imágenes (lista) — los más informativos, se repiten para darles peso + if doc.get("keywords"): + kws = doc["keywords"] if isinstance(doc["keywords"], list) else [] + parts.extend(kws * 3) # peso extra a keywords + # campos de texto estándar + for field in ("tema", "subtema", "texto"): + val = doc.get(field) + if val and isinstance(val, str): + parts.append(val) + # entidades + if doc.get("entidades"): + parts.extend(doc["entidades"]) + return " ".join(parts) + + # ── Comparación imagen vs lista de documentos ───────────────────────────── + + def compare_image_vs_collection( + self, + image_doc: dict, + text_docs: list[dict], + ) -> list[dict]: + """ + Compara una imagen contra una lista de documentos de texto. + + Returns: + Lista de dicts ordenados por porcentaje_similitud desc, filtrados por threshold. + """ + if not text_docs: + return [] + + all_docs = [image_doc] + text_docs + texts = [self.doc_to_text(d) for d in all_docs] + + try: + matrix = self.vectorizer.fit_transform(texts) + except ValueError: + return [] + + # Similitud de imagen (índice 0) contra todos los demás + sims = cosine_similarity(matrix[0:1], matrix[1:]).flatten() + + comparaciones = [] + for doc, sim in zip(text_docs, sims): + pct = round(float(sim) * 100, 2) + if pct < self.threshold: + continue + + comparaciones.append({ + # Campos compatibles con colección 'comparaciones' existente + "noticia1": image_doc.get("archivo", "imagen"), + "noticia2": doc.get("archivo", str(doc.get("_id", ""))), + "porcentaje_similitud": pct, + # Campos extendidos (opcionales — no rompen queries existentes) + "source1_type": "imagen", + "source2_type": doc.get("source_type", "texto"), + "tema_imagen": image_doc.get("tema", ""), + "tema_doc": doc.get("tema", ""), + }) + + comparaciones.sort(key=lambda x: x["porcentaje_similitud"], reverse=True) + return comparaciones + + # ── Comparación muchas imágenes vs colección ─────────────────────────────── + + def compare_batch( + self, + image_docs: list[dict], + text_docs: list[dict], + ) -> list[dict]: + """ + Compara múltiples imágenes contra una colección de documentos. + + Returns: + Todos los pares con similitud >= threshold, sin duplicados. + """ + all_comparaciones = [] + seen = set() + + for img_doc in image_docs: + results = self.compare_image_vs_collection(img_doc, text_docs) + for r in results: + key = (r["noticia1"], r["noticia2"]) + if key not in seen: + seen.add(key) + all_comparaciones.append(r) + + all_comparaciones.sort(key=lambda x: x["porcentaje_similitud"], reverse=True) + return all_comparaciones + + # ── Helpers ──────────────────────────────────────────────────────────────── + + @staticmethod + def top_n(comparaciones: list[dict], n: int = 20) -> list[dict]: + return sorted(comparaciones, key=lambda x: x["porcentaje_similitud"], reverse=True)[:n] + + @staticmethod + def stats(comparaciones: list[dict]) -> dict: + if not comparaciones: + return {"total": 0} + sims = [c["porcentaje_similitud"] for c in comparaciones] + return { + "total": len(sims), + "media": round(np.mean(sims), 2), + "max": round(max(sims), 2), + "min": round(min(sims), 2), + "sobre_50": sum(1 for s in sims if s >= 50), + "sobre_70": sum(1 for s in sims if s >= 70), + } diff --git a/BACK_BACK/IMAGENES/mongo_helper.py b/BACK_BACK/IMAGENES/mongo_helper.py new file mode 100644 index 00000000..44659ab0 --- /dev/null +++ b/BACK_BACK/IMAGENES/mongo_helper.py @@ -0,0 +1,172 @@ +""" +mongo_helper.py +--------------- +Operaciones MongoDB para la colección 'imagenes' y extensión de 'comparaciones'. +Compatible con la estructura existente de FLUJOS_DATOS. + +Uso: + mongo = MongoHelper() + mongo.upsert_imagenes(lista_docs) + mongo.insert_comparaciones(lista_comparaciones) + docs = mongo.get_collection_sample("noticias", limit=100) +""" + +import os +from pymongo import MongoClient, UpdateOne +from pymongo.errors import ConnectionFailure + +MONGO_URL = os.getenv("MONGO_URL", "mongodb://localhost:27017") +DB_NAME = os.getenv("DB_NAME", "FLUJOS_DATOS") + + +class MongoHelper: + def __init__(self, mongo_url: str = MONGO_URL, db_name: str = DB_NAME): + self.mongo_url = mongo_url + self.db_name = db_name + self._client = None + self._db = None + + # ── Conexión ─────────────────────────────────────────────────────────────── + + def connect(self): + if self._client is None: + self._client = MongoClient(self.mongo_url, serverSelectionTimeoutMS=5000) + self._client.admin.command("ping") + self._db = self._client[self.db_name] + print(f"[MongoDB] Conectado a {self.mongo_url} / {self.db_name}") + return self._db + + def disconnect(self): + if self._client: + self._client.close() + self._client = None + self._db = None + + def is_available(self) -> bool: + try: + self.connect() + return True + except ConnectionFailure: + return False + + # ── Colección IMAGENES ───────────────────────────────────────────────────── + + def upsert_imagenes(self, docs: list[dict]) -> dict: + """ + Inserta o actualiza documentos en la colección 'imagenes'. + Usa 'archivo' como clave única (upsert por nombre de archivo). + + Returns: {'inserted': N, 'updated': N} + """ + db = self.connect() + collection = db["imagenes"] + collection.create_index("archivo", unique=True) + + ops = [ + UpdateOne( + {"archivo": doc["archivo"]}, + {"$set": doc}, + upsert=True + ) + for doc in docs if "error" not in doc + ] + + if not ops: + return {"inserted": 0, "updated": 0} + + result = collection.bulk_write(ops) + stats = { + "inserted": result.upserted_count, + "updated": result.modified_count, + } + print(f"[MongoDB] imagenes → {stats}") + return stats + + def get_imagenes(self, tema: str = None, limit: int = 500) -> list[dict]: + """Recupera documentos de la colección 'imagenes'.""" + db = self.connect() + query = {"tema": {"$regex": tema, "$options": "i"}} if tema else {} + return list(db["imagenes"].find(query, {"_id": 0}).limit(limit)) + + # ── Colección COMPARACIONES ──────────────────────────────────────────────── + + def insert_comparaciones(self, comparaciones: list[dict], replace_existing: bool = False) -> int: + """ + Inserta comparaciones imagen-texto en la colección 'comparaciones'. + Evita duplicados por (noticia1, noticia2). + + Returns: número de documentos insertados + """ + db = self.connect() + collection = db["comparaciones"] + + ops = [] + for comp in comparaciones: + filter_q = {"noticia1": comp["noticia1"], "noticia2": comp["noticia2"]} + update_q = {"$set": comp} if replace_existing else {"$setOnInsert": comp} + ops.append(UpdateOne(filter_q, update_q, upsert=True)) + + if not ops: + return 0 + + result = collection.bulk_write(ops) + inserted = result.upserted_count + print(f"[MongoDB] comparaciones → {inserted} nuevas, {result.modified_count} actualizadas") + return inserted + + # ── Leer colecciones existentes (para comparar) ──────────────────────────── + + def get_collection_sample( + self, + collection_name: str, + tema: str = None, + limit: int = 200, + fields: list[str] = None, + ) -> list[dict]: + """ + Lee una muestra de documentos de una colección existente. + Compatible con noticias, wikipedia, torrents. + """ + db = self.connect() + query = {} + if tema: + query["$or"] = [ + {"tema": {"$regex": tema, "$options": "i"}}, + {"subtema": {"$regex": tema, "$options": "i"}}, + {"texto": {"$regex": tema, "$options": "i"}}, + ] + + projection = {"_id": 0} + if fields: + for f in fields: + projection[f] = 1 + + docs = list(db[collection_name].find(query, projection).limit(limit)) + for doc in docs: + if "source_type" not in doc: + doc["source_type"] = collection_name + return docs + + def get_all_text_docs(self, tema: str = None, limit_per_collection: int = 200) -> list[dict]: + """ + Recupera documentos de noticias + wikipedia + torrents combinados. + Útil para comparar imágenes contra todo el corpus. + """ + all_docs = [] + for col in ("noticias", "wikipedia", "torrents"): + try: + docs = self.get_collection_sample(col, tema=tema, limit=limit_per_collection) + all_docs.extend(docs) + print(f"[MongoDB] {col}: {len(docs)} docs cargados") + except Exception as e: + print(f"[MongoDB] WARNING: no se pudo leer '{col}': {e}") + return all_docs + + # ── Info de la BD ────────────────────────────────────────────────────────── + + def collection_stats(self) -> dict: + db = self.connect() + stats = {} + for col_name in db.list_collection_names(): + stats[col_name] = db[col_name].count_documents({}) + return stats diff --git a/BACK_BACK/IMAGENES/pipeline_pruebas.py b/BACK_BACK/IMAGENES/pipeline_pruebas.py new file mode 100644 index 00000000..f90b85ab --- /dev/null +++ b/BACK_BACK/IMAGENES/pipeline_pruebas.py @@ -0,0 +1,268 @@ +""" +pipeline_pruebas.py +------------------- +Pipeline de prueba end-to-end para análisis de imágenes. + +Flujo: + 1. Carga imágenes desde una carpeta (por defecto: las imágenes del proyecto) + 2. Analiza cada imagen con el VLM via ollama → keywords + metadata + 3. Guarda resultados en JSON local (output/) + 4. Compara keywords de imágenes con documentos de texto (noticias/wiki/torrents) + - Si MongoDB disponible: lee corpus real + - Si no: usa corpus de prueba hardcodeado + 5. Guarda comparaciones en JSON local + 6. Opcional: sube todo a MongoDB + +Ejecutar: + python pipeline_pruebas.py + python pipeline_pruebas.py --carpeta /ruta/a/imagenes --modelo qwen2-vl:7b + python pipeline_pruebas.py --solo-json # sin MongoDB + python pipeline_pruebas.py --tema "clima" # filtra corpus por tema + +Requisitos previos: + pip install -r requirements_imagenes.txt + ollama pull qwen2-vl:7b (o el modelo que prefieras) +""" + +import argparse +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +# Añadir el directorio padre al path para importar desde BACK_BACK +sys.path.insert(0, str(Path(__file__).parent)) + +from image_analyzer import ImageAnalyzer +from image_comparator import ImageComparator +from mongo_helper import MongoHelper + +# ── Configuración por defecto ────────────────────────────────────────────────── + +DEFAULT_IMAGES_FOLDER = str( + Path(__file__).parent.parent.parent / "VISUALIZACION" / "public" / "images" +) +DEFAULT_MODEL = os.getenv("VISION_MODEL", "qwen2-vl:7b") +OUTPUT_DIR = Path(__file__).parent / "output" + +# ── Corpus de prueba (cuando no hay MongoDB) ─────────────────────────────────── + +CORPUS_PRUEBA = [ + { + "archivo": "noticia_clima_001.txt", + "tema": "cambio climático", + "subtema": "emisiones co2", + "texto": "Las emisiones de dióxido de carbono alcanzan niveles récord. Los países industrializados deben reducir su huella de carbono para frenar el calentamiento global. La crisis climática afecta a millones de personas.", + "source_type": "noticias", + }, + { + "archivo": "wiki_energia_renovable.txt", + "tema": "energía renovable", + "subtema": "energía solar", + "texto": "La energía solar fotovoltaica ha experimentado un crecimiento exponencial. Los paneles solares reducen la dependencia de combustibles fósiles y disminuyen las emisiones de gases de efecto invernadero.", + "source_type": "wikipedia", + }, + { + "archivo": "noticia_tecnologia_001.txt", + "tema": "tecnología", + "subtema": "inteligencia artificial", + "texto": "Los modelos de lenguaje de gran tamaño están transformando la industria tecnológica. Empresas como Google, Meta y OpenAI compiten por el liderazgo en inteligencia artificial generativa.", + "source_type": "noticias", + }, + { + "archivo": "wiki_desinformacion.txt", + "tema": "desinformación", + "subtema": "redes sociales", + "texto": "La desinformación en redes sociales representa una amenaza para la democracia. Las fake news se propagan más rápido que las noticias verificadas. Los algoritmos de plataformas como Twitter y Facebook amplifican contenido polarizante.", + "source_type": "wikipedia", + }, + { + "archivo": "noticia_geopolitica_001.txt", + "tema": "geopolítica", + "subtema": "conflictos internacionales", + "texto": "Las tensiones geopolíticas entre potencias mundiales aumentan la incertidumbre global. Los conflictos armados desplazan millones de personas y generan crisis humanitarias sin precedentes.", + "source_type": "noticias", + }, + { + "archivo": "wiki_privacidad_datos.txt", + "tema": "privacidad", + "subtema": "datos personales", + "texto": "La privacidad de los datos personales es un derecho fundamental en la era digital. El RGPD europeo establece normas estrictas sobre el tratamiento de datos. La vigilancia masiva por parte de gobiernos y corporaciones amenaza las libertades individuales.", + "source_type": "wikipedia", + }, + { + "archivo": "torrent_documental_medioambiente.txt", + "tema": "medioambiente", + "subtema": "biodiversidad", + "texto": "Documental sobre la pérdida de biodiversidad y el impacto de la actividad humana en los ecosistemas. La deforestación, la contaminación y el cambio climático amenazan la supervivencia de miles de especies.", + "source_type": "torrents", + }, + { + "archivo": "noticia_corporaciones_001.txt", + "tema": "corporaciones", + "subtema": "paraísos fiscales", + "texto": "Las grandes corporaciones utilizan paraísos fiscales para eludir el pago de impuestos. El lobbying corporativo influye decisivamente en las políticas gubernamentales. La concentración de poder económico en pocas empresas amenaza la competencia.", + "source_type": "noticias", + }, +] + + +# ── Pipeline principal ───────────────────────────────────────────────────────── + +def run_pipeline( + images_folder: str, + model: str, + tema_filtro: str, + solo_json: bool, + threshold: float, + contexto: str, +): + OUTPUT_DIR.mkdir(exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + print("\n" + "="*60) + print(" FLUJOS — Pipeline de Imágenes (PRUEBAS)") + print("="*60) + print(f" Carpeta: {images_folder}") + print(f" Modelo: {model}") + print(f" Umbral: {threshold}%") + print(f" Solo JSON: {solo_json}") + print("="*60 + "\n") + + # ── PASO 1: Analizar imágenes ────────────────────────────────────────────── + print("[ PASO 1 ] Análisis de imágenes con VLM") + print("-"*40) + + analyzer = ImageAnalyzer(model=model) + image_docs = analyzer.analyze_folder(images_folder, extra_context=contexto) + + if not image_docs: + print(" ⚠ No se analizaron imágenes. Comprueba que ollama está corriendo.") + print(f" ollama serve → ollama pull {model}") + return + + # Guardar resultados de análisis + json_imagenes = OUTPUT_DIR / f"imagenes_{timestamp}.json" + ImageAnalyzer.save_json(image_docs, str(json_imagenes)) + + # ── PASO 2: Cargar corpus de texto para comparar ─────────────────────────── + print("\n[ PASO 2 ] Cargando corpus de texto") + print("-"*40) + + mongo = MongoHelper() + text_docs = [] + + if not solo_json and mongo.is_available(): + print(" MongoDB disponible — cargando corpus real") + text_docs = mongo.get_all_text_docs(tema=tema_filtro, limit_per_collection=300) + else: + if not solo_json: + print(" ⚠ MongoDB no disponible — usando corpus de prueba hardcodeado") + else: + print(" Modo solo-json — usando corpus de prueba hardcodeado") + text_docs = CORPUS_PRUEBA + + print(f" Total documentos de texto: {len(text_docs)}") + + # ── PASO 3: Comparar imágenes vs corpus ─────────────────────────────────── + print("\n[ PASO 3 ] Comparando keywords de imágenes vs corpus") + print("-"*40) + + comparador = ImageComparator(threshold=threshold) + valid_image_docs = [d for d in image_docs if "error" not in d] + comparaciones = comparador.compare_batch(valid_image_docs, text_docs) + + stats = comparador.stats(comparaciones) + print(f" Comparaciones generadas: {stats.get('total', 0)}") + print(f" Similitud media: {stats.get('media', 0)}%") + print(f" Similitud máxima: {stats.get('max', 0)}%") + print(f" Con > 50%: {stats.get('sobre_50', 0)}") + print(f" Con > 70%: {stats.get('sobre_70', 0)}") + + # Guardar comparaciones + json_comparaciones = OUTPUT_DIR / f"comparaciones_{timestamp}.json" + with open(json_comparaciones, "w", encoding="utf-8") as f: + json.dump(comparaciones, f, ensure_ascii=False, indent=2) + print(f"\n Guardado: {json_comparaciones}") + + # Mostrar top 10 + if comparaciones: + print("\n Top 10 similitudes:") + for c in comparador.top_n(comparaciones, 10): + print(f" {c['porcentaje_similitud']:5.1f}% {c['noticia1']} ↔ {c['noticia2']}") + + # ── PASO 4: Subir a MongoDB (opcional) ──────────────────────────────────── + if not solo_json and mongo.is_available(): + print("\n[ PASO 4 ] Subiendo a MongoDB") + print("-"*40) + + mongo.upsert_imagenes(valid_image_docs) + mongo.insert_comparaciones(comparaciones) + mongo.disconnect() + + print("\n ✓ Datos guardados en MongoDB") + print(f" Colección 'imagenes': {len(valid_image_docs)} documentos") + print(f" Colección 'comparaciones': {len(comparaciones)} documentos") + else: + print("\n[ PASO 4 ] Skipped (solo-json o MongoDB no disponible)") + + # ── Resumen final ────────────────────────────────────────────────────────── + print("\n" + "="*60) + print(" COMPLETADO") + print(f" Imágenes analizadas: {len(valid_image_docs)}") + print(f" Comparaciones: {len(comparaciones)}") + print(f" Output JSON:") + print(f" {json_imagenes}") + print(f" {json_comparaciones}") + print("="*60 + "\n") + + +# ── CLI ──────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Pipeline de prueba: imágenes → keywords → comparaciones" + ) + parser.add_argument( + "--carpeta", + default=DEFAULT_IMAGES_FOLDER, + help=f"Carpeta con imágenes (default: {DEFAULT_IMAGES_FOLDER})" + ) + parser.add_argument( + "--modelo", + default=DEFAULT_MODEL, + help=f"Modelo ollama a usar (default: {DEFAULT_MODEL})" + ) + parser.add_argument( + "--tema", + default=None, + help="Filtrar corpus MongoDB por tema (ej: 'clima', 'tecnología')" + ) + parser.add_argument( + "--umbral", + type=float, + default=5.0, + help="Porcentaje mínimo de similitud para guardar comparación (default: 5.0)" + ) + parser.add_argument( + "--contexto", + default="", + help="Contexto adicional para el prompt de análisis (ej: 'imágenes de noticias políticas')" + ) + parser.add_argument( + "--solo-json", + action="store_true", + help="No conectar a MongoDB, solo guardar JSONs locales" + ) + + args = parser.parse_args() + + run_pipeline( + images_folder=args.carpeta, + model=args.modelo, + tema_filtro=args.tema, + solo_json=args.solo_json, + threshold=args.umbral, + contexto=args.contexto, + ) diff --git a/BACK_BACK/IMAGENES/requirements_imagenes.txt b/BACK_BACK/IMAGENES/requirements_imagenes.txt new file mode 100644 index 00000000..d0acc8af --- /dev/null +++ b/BACK_BACK/IMAGENES/requirements_imagenes.txt @@ -0,0 +1,16 @@ +# Dependencias para la pipeline de imágenes FLUJOS +# Instalar con: pip install -r requirements_imagenes.txt + +# MongoDB +pymongo>=4.6 + +# ML / comparación +scikit-learn>=1.3 +numpy>=1.24 + +# HTTP (para llamadas a ollama) +requests>=2.31 + +# Utilidades +python-dotenv>=1.0 +Pillow>=10.0 # lectura/validación de imágenes diff --git a/BACK_BACK/IMAGENES/wikipedia_image_scraper.py b/BACK_BACK/IMAGENES/wikipedia_image_scraper.py new file mode 100644 index 00000000..b5840e99 --- /dev/null +++ b/BACK_BACK/IMAGENES/wikipedia_image_scraper.py @@ -0,0 +1,446 @@ +""" +wikipedia_image_scraper.py +-------------------------- +Descarga imágenes de artículos de Wikipedia por tema usando la Wikimedia API. +Las guarda en una carpeta local y registra los metadatos en JSON / MongoDB. + +Flujo: + 1. Busca artículos en Wikipedia por tema/keyword + 2. Para cada artículo extrae las imágenes (Wikimedia API) + 3. Filtra imágenes no relevantes (iconos, banderas, logos pequeños...) + 4. Descarga las imágenes a la carpeta de destino + 5. Guarda metadatos: título del artículo, tema, url, descripción, fecha + 6. Opcional: guarda metadatos en MongoDB colección 'imagenes_wiki' + +Uso: + python wikipedia_image_scraper.py --tema "cambio climático" --max 30 + python wikipedia_image_scraper.py --tema "geopolítica" --lang es --max 50 + python wikipedia_image_scraper.py --temas temas.txt --max 20 + python wikipedia_image_scraper.py --tema "climate change" --lang en --max 40 + +Requisitos: + pip install requests Pillow pymongo python-dotenv +""" + +import argparse +import json +import os +import time +from datetime import datetime +from pathlib import Path +from urllib.parse import quote, urlparse + +import requests +from PIL import Image, UnidentifiedImageError + +# ── Configuración ────────────────────────────────────────────────────────────── + +WIKI_API_ES = "https://es.wikipedia.org/w/api.php" +WIKI_API_EN = "https://en.wikipedia.org/w/api.php" +WIKIMEDIA_API = "https://commons.wikimedia.org/w/api.php" + +OUTPUT_BASE = Path(__file__).parent / "output" / "wiki_images" + +# Tamaño mínimo para considerar una imagen relevante (pixels) +MIN_WIDTH = 200 +MIN_HEIGHT = 200 +MIN_BYTES = 20_000 # 20KB mínimo + +# Extensiones válidas +VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} + +# Prefijos/sufijos de archivos a ignorar (iconos, banderas, etc.) +SKIP_PATTERNS = [ + "flag_", "Flag_", "icon", "Icon", "logo", "Logo", + "symbol", "Symbol", "coat_of_arms", "Coat_of_arms", + "commons-logo", "wiki", "Wiki", "question_mark", + "edit-", "nuvola", "Nuvola", "pictogram", "Pictogram", + "OOjs", "Ambox", "Portal-", "Disambig", +] + +HEADERS = { + "User-Agent": "FLUJOS-Project/1.0 (https://gitea.laenre.net/hacklab/FLUJOS; educational research)" +} + + +# ── Funciones de búsqueda Wikipedia ─────────────────────────────────────────── + +def search_articles(tema: str, lang: str = "es", limit: int = 10) -> list[dict]: + """Busca artículos en Wikipedia por tema. Devuelve lista de {title, pageid}.""" + api_url = WIKI_API_EN if lang == "en" else WIKI_API_ES + + params = { + "action": "query", + "list": "search", + "srsearch": tema, + "srlimit": limit, + "format": "json", + "srinfo": "totalhits", + "srprop": "snippet|titlesnippet", + } + + resp = requests.get(api_url, params=params, headers=HEADERS, timeout=15) + resp.raise_for_status() + data = resp.json() + + articles = [] + for item in data.get("query", {}).get("search", []): + articles.append({ + "title": item["title"], + "pageid": item["pageid"], + "snippet": item.get("snippet", "").replace("", "").replace("", ""), + }) + + return articles + + +def get_article_images(title: str, lang: str = "es", limit: int = 20) -> list[str]: + """Obtiene lista de nombres de archivo de imágenes de un artículo Wikipedia.""" + api_url = WIKI_API_EN if lang == "en" else WIKI_API_ES + + params = { + "action": "query", + "titles": title, + "prop": "images", + "imlimit": limit, + "format": "json", + } + + resp = requests.get(api_url, params=params, headers=HEADERS, timeout=15) + resp.raise_for_status() + data = resp.json() + + pages = data.get("query", {}).get("pages", {}) + image_titles = [] + for page in pages.values(): + for img in page.get("images", []): + image_titles.append(img["title"]) + + return image_titles + + +def get_image_info(file_title: str) -> dict | None: + """ + Obtiene info de una imagen via Wikimedia API: + url directa de descarga, dimensiones, descripción, autor, licencia. + """ + # Normalizar namespace: Wikipedia ES usa "Archivo:", Commons usa "File:" + for prefix in ("Archivo:", "Fichero:", "Image:", "Imagen:"): + if file_title.startswith(prefix): + file_title = "File:" + file_title[len(prefix):] + break + + params = { + "action": "query", + "titles": file_title, + "prop": "imageinfo", + "iiprop": "url|size|extmetadata", + "iiurlwidth": 1200, + "format": "json", + } + + resp = requests.get(WIKIMEDIA_API, params=params, headers=HEADERS, timeout=15) + resp.raise_for_status() + data = resp.json() + + pages = data.get("query", {}).get("pages", {}) + for page in pages.values(): + infos = page.get("imageinfo", []) + if not infos: + return None + info = infos[0] + + ext_meta = info.get("extmetadata", {}) + return { + "url": info.get("thumburl") or info.get("url"), + "url_original": info.get("url"), + "width": info.get("width", 0), + "height": info.get("height", 0), + "size_bytes": info.get("size", 0), + "descripcion": ext_meta.get("ImageDescription", {}).get("value", ""), + "autor": ext_meta.get("Artist", {}).get("value", ""), + "licencia": ext_meta.get("LicenseShortName", {}).get("value", ""), + "fecha_orig": ext_meta.get("DateTimeOriginal", {}).get("value", ""), + } + + return None + + +# ── Filtros ──────────────────────────────────────────────────────────────────── + +def should_skip(file_title: str, img_info: dict) -> tuple[bool, str]: + """Devuelve (skip, motivo) — True si la imagen debe descartarse.""" + filename = Path(file_title).name + + # Extensión válida + ext = Path(filename).suffix.lower() + if ext not in VALID_EXTENSIONS: + return True, f"extensión no válida: {ext}" + + # Patrones a ignorar + for pattern in SKIP_PATTERNS: + if pattern in filename: + return True, f"patrón ignorado: {pattern}" + + # Tamaño mínimo + if img_info.get("width", 0) < MIN_WIDTH or img_info.get("height", 0) < MIN_HEIGHT: + return True, f"demasiado pequeña: {img_info.get('width')}x{img_info.get('height')}" + + if img_info.get("size_bytes", 0) < MIN_BYTES: + return True, f"archivo demasiado pequeño: {img_info.get('size_bytes')} bytes" + + return False, "" + + +# ── Descarga ─────────────────────────────────────────────────────────────────── + +def download_image(url: str, dest_path: Path) -> bool: + """Descarga una imagen a dest_path. Devuelve True si éxito.""" + try: + resp = requests.get(url, headers=HEADERS, timeout=30, stream=True) + resp.raise_for_status() + + with open(dest_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + + # Verificar que es imagen válida con Pillow + with Image.open(dest_path) as img: + img.verify() + + return True + + except (UnidentifiedImageError, Exception) as e: + if dest_path.exists(): + dest_path.unlink() + return False + + +# ── Pipeline principal ───────────────────────────────────────────────────────── + +class WikipediaImageScraper: + + def __init__(self, output_dir: Path = OUTPUT_BASE, lang: str = "es"): + self.output_dir = output_dir + self.lang = lang + self.output_dir.mkdir(parents=True, exist_ok=True) + self.session = requests.Session() + + def scrape_tema(self, tema: str, max_images: int = 30, max_articles: int = 10) -> list[dict]: + """ + Descarga imágenes de Wikipedia sobre un tema. + + Args: + tema: tema a buscar (ej: "cambio climático") + max_images: máximo de imágenes a descargar + max_articles: máximo de artículos a explorar + + Returns: + Lista de metadatos de imágenes descargadas + """ + # Carpeta por tema + tema_slug = tema.lower().replace(" ", "_").replace("/", "-")[:40] + tema_dir = self.output_dir / tema_slug + tema_dir.mkdir(exist_ok=True) + + print(f"\n[WikiScraper] Tema: '{tema}' | max_images={max_images} | lang={self.lang}") + print("-" * 50) + + # 1. Buscar artículos + articles = search_articles(tema, lang=self.lang, limit=max_articles) + print(f" Artículos encontrados: {len(articles)}") + for a in articles[:5]: + print(f" · {a['title']}") + if len(articles) > 5: + print(f" ... y {len(articles)-5} más") + + downloaded = [] + total_downloaded = 0 + + for article in articles: + if total_downloaded >= max_images: + break + + print(f"\n → {article['title']}") + + # 2. Obtener imágenes del artículo + try: + img_titles = get_article_images(article["title"], lang=self.lang, limit=25) + except Exception as e: + print(f" ERROR obteniendo imágenes: {e}") + continue + + print(f" {len(img_titles)} imágenes en el artículo") + + for img_title in img_titles: + if total_downloaded >= max_images: + break + + # 3. Obtener info de la imagen + try: + img_info = get_image_info(img_title) + time.sleep(0.2) # respetar rate limit Wikimedia + except Exception as e: + continue + + if not img_info or not img_info.get("url"): + continue + + # 4. Filtrar + skip, motivo = should_skip(img_title, img_info) + if skip: + continue + + # 5. Nombre de archivo local + original_name = Path(urlparse(img_info["url"]).path).name + ext = Path(original_name).suffix.lower() or ".jpg" + safe_name = f"{tema_slug}_{total_downloaded:03d}{ext}" + dest_path = tema_dir / safe_name + + # Saltar si ya existe + if dest_path.exists(): + print(f" ↳ ya existe: {safe_name}") + total_downloaded += 1 + continue + + # 6. Descargar + print(f" ↓ {safe_name} ({img_info['width']}x{img_info['height']} {img_info['size_bytes']//1024}KB)") + success = download_image(img_info["url"], dest_path) + + if success: + meta = { + "archivo": safe_name, + "image_path": str(dest_path.resolve()), + "tema": tema.lower(), + "subtema": article["title"].lower(), + "texto": article.get("snippet", ""), + "descripcion_wiki": img_info.get("descripcion", ""), + "autor": img_info.get("autor", ""), + "licencia": img_info.get("licencia", ""), + "url_original": img_info.get("url_original", ""), + "width": img_info["width"], + "height": img_info["height"], + "size_bytes": img_info["size_bytes"], + "source_type": "wikipedia_imagen", + "lang": self.lang, + "fecha": datetime.now().strftime("%Y-%m-%d"), + "articulo_wiki": article["title"], + "keywords": [], # se rellenan con image_analyzer.py + } + downloaded.append(meta) + total_downloaded += 1 + else: + print(f" ✗ fallo descarga") + + print(f"\n[WikiScraper] Descargadas: {total_downloaded} imágenes en {tema_dir}") + return downloaded + + def scrape_multitema(self, temas: list[str], max_per_tema: int = 20) -> list[dict]: + """Descarga imágenes para múltiples temas.""" + all_results = [] + for tema in temas: + results = self.scrape_tema(tema, max_images=max_per_tema) + all_results.extend(results) + time.sleep(1) # pausa entre temas + return all_results + + def save_metadata(self, metadata: list[dict], json_path: Path = None) -> Path: + """Guarda metadatos en JSON.""" + if json_path is None: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + json_path = self.output_dir / f"metadata_{ts}.json" + + with open(json_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, ensure_ascii=False, indent=2) + + print(f"[WikiScraper] Metadatos guardados: {json_path}") + return json_path + + def save_to_mongo(self, metadata: list[dict]) -> dict: + """Guarda metadatos en MongoDB colección 'imagenes_wiki'.""" + from mongo_helper import MongoHelper + mongo = MongoHelper() + + if not mongo.is_available(): + print("[WikiScraper] MongoDB no disponible — solo JSON local") + return {"inserted": 0, "updated": 0} + + # Usar colección imagenes_wiki para no mezclar con imagenes analizadas + db = mongo.connect() + from pymongo import UpdateOne + col = db["imagenes_wiki"] + col.create_index("archivo", unique=True) + + ops = [ + UpdateOne({"archivo": doc["archivo"]}, {"$set": doc}, upsert=True) + for doc in metadata + ] + if ops: + result = col.bulk_write(ops) + stats = {"inserted": result.upserted_count, "updated": result.modified_count} + else: + stats = {"inserted": 0, "updated": 0} + + print(f"[WikiScraper] MongoDB imagenes_wiki → {stats}") + mongo.disconnect() + return stats + + +# ── CLI ──────────────────────────────────────────────────────────────────────── + +# Temas de FLUJOS por defecto +TEMAS_FLUJOS = [ + "cambio climático", + "geopolítica conflictos", + "seguridad internacional espionaje", + "libertad de prensa periodismo", + "corporaciones poder económico", + "populismo extremismo", + "desinformación redes sociales", + "privacidad vigilancia masiva", + "biodiversidad medioambiente", + "inteligencia artificial algoritmos", +] + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Descarga imágenes de Wikipedia por tema") + + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--tema", help="Tema único a buscar (ej: 'cambio climático')") + group.add_argument("--temas", help="Fichero .txt con un tema por línea") + group.add_argument("--flujos", action="store_true", help="Usar los temas de FLUJOS por defecto") + + parser.add_argument("--max", type=int, default=20, help="Máximo imágenes por tema (default: 20)") + parser.add_argument("--lang", default="es", help="Idioma Wikipedia: es | en (default: es)") + parser.add_argument("--output", default=str(OUTPUT_BASE), help="Carpeta de destino") + parser.add_argument("--mongo", action="store_true", help="Guardar metadatos en MongoDB") + + args = parser.parse_args() + + scraper = WikipediaImageScraper(output_dir=Path(args.output), lang=args.lang) + + # Determinar lista de temas + if args.flujos: + temas = TEMAS_FLUJOS + elif args.temas: + with open(args.temas, encoding="utf-8") as f: + temas = [l.strip() for l in f if l.strip()] + else: + temas = [args.tema] + + # Ejecutar + if len(temas) == 1: + metadata = scraper.scrape_tema(temas[0], max_images=args.max) + else: + metadata = scraper.scrape_multitema(temas, max_per_tema=args.max) + + # Guardar resultados + if metadata: + json_path = scraper.save_metadata(metadata) + print(f"\n Total imágenes descargadas: {len(metadata)}") + print(f" JSON: {json_path}") + + if args.mongo: + scraper.save_to_mongo(metadata) + else: + print("\n No se descargaron imágenes.") diff --git a/VISUALIZACION/public/demos/demo_img_nodes.html b/VISUALIZACION/public/demos/demo_img_nodes.html new file mode 100644 index 00000000..048fa3a3 --- /dev/null +++ b/VISUALIZACION/public/demos/demo_img_nodes.html @@ -0,0 +1,301 @@ + + + + + FLUJOS — Demo: Image Nodes + + + + + + + + +
+ + + +
+ + +
+
+

+ +

+
+
+ +
+

NODOS CON IMAGEN

+
+ + FLUJOS (core) +
+
+ + Cambio Climático +
+
+ + Libertad de Prensa +
+

NODOS ESFERA

+
+
+ security +
+
+
+ corporate +
+
+
+ politics +
+
+
+ data +
+
+ +
+ nodeThreeObject(node => Sprite)
+ new THREE.TextureLoader().load(img)
+ new THREE.SpriteMaterial({ map })
+ — THREE.js Sprite billboard — +
+ + + + diff --git a/VISUALIZACION/public/demos/demo_mixed_nodes.html b/VISUALIZACION/public/demos/demo_mixed_nodes.html new file mode 100644 index 00000000..cc05ce66 --- /dev/null +++ b/VISUALIZACION/public/demos/demo_mixed_nodes.html @@ -0,0 +1,346 @@ + + + + + FLUJOS — Demo: Mixed Nodes (HTML Cards) + + + + + + + + +
+ + + +
+ CSS2DRenderer + CSS2DObject
+ nodeThreeObject(node => CSS2DObject(div))
+ nodeThreeObjectExtend(true)
+ — Real DOM elements in 3D space — +
+ +
+

VISUALIZACIÓN

+
+ + + 0% +
+
+ + + -200 +
+
+ +
+ +

+ +

+
+
+ + + + diff --git a/VISUALIZACION/public/demos/demo_text_nodes.html b/VISUALIZACION/public/demos/demo_text_nodes.html new file mode 100644 index 00000000..08951962 --- /dev/null +++ b/VISUALIZACION/public/demos/demo_text_nodes.html @@ -0,0 +1,242 @@ + + + + + FLUJOS — Demo: Text Nodes + + + + + + + + +
+ + + +
+ +

+ +

+
+ +
+

GRUPOS

+
core
+
climate
+
security
+
journalism
+
corporate
+
politics
+
data
+
+ +
+ nodeThreeObject(node => SpriteText)
+ nodeThreeObjectExtend(true)
+ — three-spritetext — +
+ + + + diff --git a/VISUALIZACION/public/demos/demo_wiki_images.html b/VISUALIZACION/public/demos/demo_wiki_images.html new file mode 100644 index 00000000..023cdcd5 --- /dev/null +++ b/VISUALIZACION/public/demos/demo_wiki_images.html @@ -0,0 +1,385 @@ + + + + + FLUJOS — Demo: Wikipedia Images + + + + + + + + + +
+ + + +
+ + +
+

+ CAMBIO CLIMÁTICO +

+
+
ARTÍCULO  
+
LICENCIA  
+
AUTOR    
+
TAMAÑO   
+
+
+
+ +
+
NODOS  15
+
TEMA   cambio climático
+
FUENTE Wikipedia / Wikimedia Commons
+
+ +
+ + + + diff --git a/VISUALIZACION/public/images/wiki/.gitignore b/VISUALIZACION/public/images/wiki/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/VISUALIZACION/public/images/wiki/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore