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>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}}
|
||||
}})();
|
||||
</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
|
||||
Reference in New Issue
Block a user