Files
egutierrez f851988d6f feat: funciones datascience — ops_to_rdf_triples, ops_to_sigma_json, render_sigma_html
Conversión de operations.db a triples RDF y formato sigma.js, más
renderizado HTML standalone con dark theme y ForceAtlas2 layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:03:51 +02:00

235 lines
9.7 KiB
Python

"""Renderiza un grafo sigma.js como HTML standalone con dark theme y layout ForceAtlas2."""
import json
import os
_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<script src="https://cdn.jsdelivr.net/npm/graphology@0.25.4/dist/graphology.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/graphology-library@0.8.0/dist/graphology-library.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sigma@2.4.0/build/sigma.min.js"></script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: #1a1a2e; color: #eee; font-family: 'Segoe UI', system-ui, sans-serif; overflow: hidden; }}
#container {{ width: 100vw; height: 100vh; }}
#panel {{
position: absolute; top: 12px; right: 12px;
background: rgba(10, 10, 30, 0.88);
border: 1px solid rgba(255,255,255,0.12);
padding: 16px; border-radius: 10px;
z-index: 10; min-width: 200px; max-width: 260px;
backdrop-filter: blur(6px);
}}
#panel h3 {{ font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #a0c4ff; letter-spacing: 0.5px; }}
#stats {{ font-size: 11px; color: #888; margin-bottom: 12px; }}
#filters {{ display: flex; flex-direction: column; gap: 6px; }}
.filter-item {{ display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; }}
.filter-item input {{ cursor: pointer; accent-color: #a0c4ff; }}
.color-dot {{ width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }}
#tooltip {{
position: absolute; display: none;
background: rgba(5, 5, 20, 0.95);
border: 1px solid rgba(255,255,255,0.15);
padding: 10px 14px; border-radius: 8px;
pointer-events: none; z-index: 20;
max-width: 300px; font-size: 12px; line-height: 1.6;
}}
#tooltip .tt-title {{ font-weight: 600; color: #a0c4ff; margin-bottom: 6px; font-size: 13px; }}
#tooltip .tt-row {{ display: flex; gap: 6px; }}
#tooltip .tt-key {{ color: #888; min-width: 80px; }}
#tooltip .tt-val {{ color: #eee; word-break: break-all; }}
</style>
</head>
<body>
<div id="container"></div>
<div id="panel">
<h3>{title}</h3>
<div id="stats"></div>
<div id="filters"></div>
</div>
<div id="tooltip"></div>
<script>
(function () {{
const graphData = {json_data};
// ── Build graphology graph ──────────────────────────────────────────────
const Graph = graphology.Graph || graphology;
const g = new Graph({{ multi: true, type: 'directed' }});
// Assign random initial positions
graphData.nodes.forEach(function (n) {{
g.addNode(n.key, Object.assign({{
x: (Math.random() - 0.5) * 10,
y: (Math.random() - 0.5) * 10,
}}, n.attributes));
}});
graphData.edges.forEach(function (e) {{
try {{
g.addEdgeWithKey(e.key, e.source, e.target, e.attributes || {{}});
}} catch (err) {{
// skip duplicate edge keys gracefully
}}
}});
// ── ForceAtlas2 layout (synchronous, 500 iterations) ───────────────────
const FA2 = graphologyLibrary.layoutForceAtlas2;
FA2.assign(g, {{
iterations: 500,
settings: {{
gravity: 1,
scalingRatio: 2,
slowDown: 5,
barnesHutOptimize: g.order > 300,
}},
}});
// ── Sigma renderer ──────────────────────────────────────────────────────
const renderer = new Sigma(g, document.getElementById('container'), {{
renderEdgeLabels: false,
defaultEdgeColor: '#444',
defaultNodeColor: '#95a5a6',
labelColor: {{ color: '#ccc' }},
labelSize: 11,
edgeReducer: function (edge, data) {{
return Object.assign({{}}, data, {{ size: Math.max(1, (data.weight || 1) * 0.8) }});
}},
}});
// ── Stats panel ─────────────────────────────────────────────────────────
document.getElementById('stats').textContent =
graphData.nodes.length + ' nodes · ' + graphData.edges.length + ' edges';
// ── Filter panel by node type ───────────────────────────────────────────
const typeColors = {{}};
graphData.nodes.forEach(function (n) {{
const t = n.attributes.entity_type || 'unknown';
typeColors[t] = n.attributes.color || '#95a5a6';
}});
const hiddenTypes = new Set();
const filtersDiv = document.getElementById('filters');
Object.keys(typeColors).sort().forEach(function (type) {{
const color = typeColors[type];
const label = document.createElement('label');
label.className = 'filter-item';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = true;
cb.addEventListener('change', function () {{
if (cb.checked) hiddenTypes.delete(type);
else hiddenTypes.add(type);
renderer.refresh();
}});
const dot = document.createElement('span');
dot.className = 'color-dot';
dot.style.background = color;
label.appendChild(cb);
label.appendChild(dot);
label.appendChild(document.createTextNode(type));
filtersDiv.appendChild(label);
}});
// Node reducer applies type filter
renderer.setSetting('nodeReducer', function (node, data) {{
if (hiddenTypes.has(data.entity_type)) return Object.assign({{}}, data, {{ hidden: true }});
return data;
}});
// ── Tooltip on hover ────────────────────────────────────────────────────
const tooltip = document.getElementById('tooltip');
renderer.on('enterNode', function (ref) {{
const nodeAttrs = g.getNodeAttributes(ref.node);
const reserved = new Set(['x', 'y', 'size', 'color', 'label', 'type', 'hidden']);
let html = '<div class="tt-title">' + escHtml(nodeAttrs.label || ref.node) + '</div>';
html += '<div class="tt-row"><span class="tt-key">type</span><span class="tt-val">' + escHtml(nodeAttrs.entity_type || '') + '</span></div>';
html += '<div class="tt-row"><span class="tt-key">status</span><span class="tt-val">' + escHtml(nodeAttrs.status || '') + '</span></div>';
html += '<div class="tt-row"><span class="tt-key">domain</span><span class="tt-val">' + escHtml(nodeAttrs.domain || '') + '</span></div>';
Object.keys(nodeAttrs).sort().forEach(function (k) {{
if (!reserved.has(k) && !['status', 'domain', 'type', 'label'].includes(k)) {{
html += '<div class="tt-row"><span class="tt-key">' + escHtml(k) + '</span><span class="tt-val">' + escHtml(String(nodeAttrs[k])) + '</span></div>';
}}
}});
tooltip.innerHTML = html;
tooltip.style.display = 'block';
}});
renderer.on('leaveNode', function () {{
tooltip.style.display = 'none';
}});
document.getElementById('container').addEventListener('mousemove', function (e) {{
tooltip.style.left = (e.clientX + 16) + 'px';
tooltip.style.top = (e.clientY + 16) + 'px';
}});
function escHtml(str) {{
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}}
}})();
</script>
</body>
</html>
"""
def render_sigma_html(
graph_data: dict,
output_path: str,
title: str = "OSINT Graph",
) -> str:
"""Genera un HTML standalone con sigma.js que visualiza el grafo OSINT.
Recibe el dict producido por ops_to_sigma_json, embebe los datos como JSON
en el HTML, aplica ForceAtlas2 (500 iteraciones sincrono) y renderiza con
sigma.js v2.4. Incluye dark theme, panel de filtros por tipo de nodo y
tooltip con metadata al hacer hover.
Args:
graph_data: Dict con claves 'nodes' y 'edges' en formato graphology/sigma.
output_path: Ruta del archivo HTML a escribir.
title: Titulo del grafo mostrado en el panel y la pestana.
Returns:
Ruta absoluta del archivo HTML escrito.
Raises:
Exception: Si no se puede escribir el archivo en output_path.
"""
json_data = json.dumps(graph_data, ensure_ascii=False)
html = _HTML_TEMPLATE.format(
title=title,
json_data=json_data,
)
abs_path = os.path.abspath(output_path)
os.makedirs(os.path.dirname(abs_path) or ".", exist_ok=True)
try:
with open(abs_path, "w", encoding="utf-8") as f:
f.write(html)
except OSError as exc:
raise Exception(f"render_sigma_html: no se pudo escribir '{abs_path}': {exc}") from exc
return abs_path