Files
OSINT-Cheat-sheet/Script/SOCMINT-Twitter/templates/graph.html
T

1123 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://jsdelivr.net">
<title>Jieyab89 SOCMINT X — Graph</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #21253a;
--border: #2a2d3a;
--text: #e8eaf0;
--muted: #8890a4;
--accent: #5865f2;
--accent-bg: #1e2240;
--success: #22c55e;
--danger: #ef4444;
--warn: #f59e0b;
--purple: #8b5cf6;
--purple-bg: #1e1535;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
overflow: hidden;
}
body { display: flex; flex-direction: column; }
/* ── Header ── */
header {
padding: 10px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
position: relative;
}
header h1 { font-size: 14px; font-weight: 600; }
header .sep { color: var(--border); }
header .sub { color: var(--muted); font-size: 12px; flex: 1; }
.hamburger-btn {
margin-left: auto;
background: none;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--muted);
cursor: pointer;
padding: 5px 9px;
font-size: 16px;
line-height: 1;
transition: all 0.15s;
}
.hamburger-btn:hover { color: var(--text); border-color: var(--accent); }
.nav-menu {
position: absolute;
top: 100%;
right: 12px;
margin-top: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px;
min-width: 175px;
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
z-index: 200;
display: flex;
flex-direction: column;
gap: 1px;
}
.nav-menu.hidden { display: none; }
.nav-menu a {
padding: 7px 12px;
border-radius: 5px;
color: var(--muted);
text-decoration: none;
font-size: 13px;
transition: all 0.12s;
display: block;
}
.nav-menu a:hover { color: var(--text); background: var(--accent-bg); }
.nav-menu a.current { color: var(--accent); background: var(--accent-bg); }
.nav-divider { border: none; border-top: 1px solid var(--border); margin: 3px 0; }
/* ── Toolbar ── */
#toolbar {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
#toolbar select, #toolbar input[type="text"], #toolbar input[type="number"] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 5px;
padding: 5px 8px;
font-size: 12px;
font-family: var(--font);
line-height: 1.4;
}
#toolbar select:focus, #toolbar input:focus { outline: none; border-color: var(--accent); }
#queryInput { flex: 1; min-width: 200px; }
#countInput { width: 68px; }
/* ── Buttons ── */
.btn {
padding: 5px 12px;
border-radius: 5px;
border: 1px solid var(--border);
font-size: 12px;
font-family: var(--font);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
line-height: 1.4;
}
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover:not(:disabled) { background: #4752c4; }
.btn-ghost { background: transparent; color: var(--muted); }
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--accent); background: var(--accent-bg); }
.btn-success { background: transparent; color: var(--success); border-color: #1a4d2e; }
.btn-success:hover:not(:disabled) { background: rgba(34,197,94,0.1); border-color: var(--success); }
.btn-danger { background: transparent; color: var(--danger); border-color: #4d1a1a; }
.btn-danger:hover:not(:disabled) { background: rgba(239,68,68,0.1); border-color: var(--danger); }
.btn:disabled { opacity: 0.38; cursor: not-allowed; }
/* ── Main area ── */
#main {
flex: 1;
display: flex;
min-height: 0;
position: relative;
}
/* ── Cytoscape canvas ── */
#cy {
flex: 1;
min-width: 0;
background: var(--bg);
}
/* ── Info panel ── */
#infoPanel {
width: 310px;
border-left: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width 0.2s ease;
}
#infoPanel.hidden { width: 0; border-left: none; }
#infoHeader {
padding: 9px 12px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 8px;
}
#infoTitle {
font-size: 11px;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.05em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#btnClosePanel {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0 2px;
flex-shrink: 0;
}
#btnClosePanel:hover { color: var(--text); }
#infoContent {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
font-size: 12px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.info-row {
display: flex;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.info-key {
color: var(--muted);
font-size: 10px;
min-width: 95px;
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
padding-top: 1px;
line-height: 1.5;
}
.info-val {
color: var(--text);
flex: 1;
word-break: break-word;
line-height: 1.5;
}
.info-val a { color: var(--accent); text-decoration: none; }
.info-val a:hover { text-decoration: underline; }
.info-media {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.info-media a img {
width: 80px;
height: 60px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border);
display: block;
}
.info-media a.video-link {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 60px;
background: var(--surface2);
border-radius: 4px;
border: 1px solid var(--border);
color: var(--muted);
font-size: 10px;
text-decoration: none;
}
.info-media a.video-link:hover { border-color: var(--accent); color: var(--accent); }
#infoActions {
padding: 10px 12px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 5px;
flex-shrink: 0;
}
#infoActions .btn { width: 100%; text-align: center; justify-content: center; }
#infoActions a.btn { text-decoration: none; display: block; }
/* ── Legend / Help popup ── */
#legendToggle {
position: absolute;
bottom: 14px;
left: 14px;
width: 28px;
height: 28px;
border-radius: 50%;
background: #FFFFFF;
border: 1px solid var(--border);
color: var(--muted);
font-size: 14px;
font-weight: 600;
cursor: pointer;
z-index: 11;
transition: all 0.15s;
}
#legendToggle:hover { color: var(--text); border-color: var(--accent); }
#legend {
position: absolute;
bottom: 50px;
left: 14px;
background: rgba(26,29,39,0.96);
border: 1px solid var(--border);
border-radius: 7px;
padding: 10px 14px;
font-size: 11px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 10;
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
width: 260px;
max-height: 70vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
#legend.hidden { display: none; }
.leg-row { display: flex; align-items: center; gap: 8px; color: var(--muted); }
.leg-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
border: 2px solid;
}
.leg-icon.search { background: var(--accent); border-color: var(--accent); transform: rotate(45deg); border-radius: 1px; }
.leg-icon.tweet { background: var(--accent-bg); border-color: var(--accent); border-radius: 2px; }
.leg-icon.reply { background: rgba(34,197,94,0.12); border-color: var(--success); border-radius: 2px; }
.leg-icon.user { background: var(--purple-bg); border-color: var(--purple); border-radius: 50%; }
.leg-icon.wayback { background: #2a1f08; border-color: var(--warn); border-radius: 2px; }
.leg-icon.viewed { background: #ffffff; border-color: #c7c7c7; border-radius: 2px; }
.leg-heading {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
margin: 6px 0 0;
}
.leg-note { color: var(--muted); font-size: 10px; line-height: 1.5; max-width: 220px; margin-top: 4px; }
.param-heading {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
margin: 8px 0 3px;
padding-top: 6px;
border-top: 1px solid var(--border);
}
.param-heading:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.param-row { color: var(--muted); font-size: 10.5px; line-height: 1.5; margin: 2px 0; }
.param-key {
display: inline-block;
color: var(--accent);
background: var(--accent-bg);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
margin-right: 4px;
}
/* ── Source badges (info panel) ── */
.source-badge {
display: inline-block;
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: 20px;
border: 1px solid var(--border);
}
.source-badge.src-cookie { color: #a78bfa; border-color: #4c3a8f; background: var(--purple-bg); }
.source-badge.src-xquik { color: #8891f7; border-color: #34348a; background: var(--accent-bg); }
.source-badge.src-wayback { color: var(--warn); border-color: #78350f; background: #1c0e02; }
.age-badge {
display: inline-block;
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: 20px;
border: 1px solid var(--border);
}
.age-badge.age-new { color: var(--danger); border-color: #7f1d1d; background: #1c0505; }
.age-badge.age-recent { color: var(--warn); border-color: #78350f; background: #1c0e02; }
.age-badge.age-established { color: var(--success); border-color: #14532d; background: #052011; }
/* ── Status bar ── */
#statusBar {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(26,29,39,0.94);
border: 1px solid var(--border);
border-radius: 5px;
padding: 4px 16px;
font-size: 12px;
color: var(--muted);
pointer-events: none;
z-index: 20;
transition: opacity 0.3s;
max-width: 420px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#statusBar.error { color: var(--danger); border-color: #4d1a1a; }
#statusBar.hidden { opacity: 0; }
/* ── Node count badge ── */
#nodeCount {
font-size: 11px;
color: var(--muted);
padding: 3px 9px;
background: var(--bg);
border-radius: 10px;
border: 1px solid var(--border);
white-space: nowrap;
}
/* ── Spinner ── */
.spin {
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Empty state hint ── */
#emptyHint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--border);
font-size: 13px;
pointer-events: none;
text-align: center;
line-height: 2;
z-index: 5;
}
#emptyHint.hidden { display: none; }
</style>
</head>
<body>
<header>
<h1>Jieyab89 SOCMINT X</h1>
<span class="sep">|</span>
<span class="sub">Graph Visualization</span>
<button id="navToggle" class="hamburger-btn" aria-label="Navigation menu">&#9776;</button>
<nav id="navMenu" class="nav-menu hidden">
<a href="/">Home</a>
<a href="/graph" class="current">Graph</a>
<hr class="nav-divider">
<a href="/archives">Archives</a>
</nav>
</header>
<div id="toolbar">
<select id="toolSelect">
<option value="tweet_search_extractor">Tweet Search</option>
<option value="follower_explorer">Follower Explorer</option>
<option value="post_extractor">Post Extractor</option>
<option value="article_extractor">Article Extractor</option>
<option value="community_post_extractor">Community Posts</option>
<option value="tweet_replies_extractor">Tweet Replies [Cookie]</option>
<option value="tweet_retweeters_extractor">Tweet Retweeters [Cookie]</option>
<option value="geo_post_extractor">Geo Post Search [Cookie]</option>
<option value="wayback_archive_search">Wayback Archive Search</option>
<option value="multi_source_search">Multi Source Search [All]</option>
</select>
<select id="modeSelect">
<option value="cookie">Cookie</option>
<option value="api">API</option>
</select>
<input id="queryInput" type="text" placeholder="Search query…" spellcheck="false" autocomplete="off">
<input id="dateFromInput" type="text" placeholder="From YYYYMMDD" title="Optional start date" maxlength="8" style="width:100px;display:none">
<input id="dateToInput" type="text" placeholder="To YYYYMMDD" title="Optional end date" maxlength="8" style="width:100px;display:none">
<input id="countInput" type="number" value="20" min="1" max="200" title="Result count">
<span id="nodeCount">0 nodes</span>
<button id="btnSearch" class="btn btn-primary">Search</button>
<button id="btnArchiveAll" class="btn btn-ghost">Archive All</button>
<button id="btnDump" class="btn btn-ghost">Dump JSON</button>
<button id="btnClear" class="btn btn-danger">Clear</button>
</div>
<div id="main">
<div id="cy"></div>
<!-- Right info panel -->
<div id="infoPanel" class="hidden">
<div id="infoHeader">
<span id="infoTitle">Node Details</span>
<button id="btnClosePanel" title="Close">×</button>
</div>
<div id="infoContent"></div>
<div id="infoActions">
<button id="btnExpandReplies" class="btn btn-ghost" style="display:none">↩ Expand Replies</button>
<button id="btnExpandRetweets" class="btn btn-ghost" style="display:none">↗ Expand Retweets</button>
<a id="btnOpenTweet" class="btn btn-success" style="display:none"
href="#" target="_blank" rel="noopener noreferrer">Open Tweet ↗</a>
</div>
</div>
<!-- Status bar (floats over cy) -->
<div id="statusBar" class="hidden">Ready</div>
<!-- Empty state -->
<div id="emptyHint">
Use the toolbar to search<br>
Click any node to inspect · Click tweet nodes to expand replies / retweets
</div>
</div>
<!-- Legend / help popup (floats over cy, bottom-left) -->
<button id="legendToggle" aria-label="Legend / help" title="Legend / help">?</button>
<div id="legend" class="hidden">
<div class="leg-row"><div class="leg-icon search"></div> Search root</div>
<div class="leg-row"><div class="leg-icon tweet"></div> Tweet</div>
<div class="leg-row"><div class="leg-icon reply"></div> Reply</div>
<div class="leg-row"><div class="leg-icon user"></div> User / Retweeter</div>
<div class="leg-row"><div class="leg-icon wayback"></div> Wayback snapshot</div>
<div class="leg-row"><div class="leg-icon viewed"></div> Viewed (clicked)</div>
<div class="leg-heading">Source (info panel)</div>
<div class="leg-row"><span class="source-badge src-cookie">Cookie</span></div>
<div class="leg-row"><span class="source-badge src-xquik">Xquik API</span></div>
<div class="leg-row"><span class="source-badge src-wayback">Wayback Machine</span></div>
<div class="leg-heading">Account age (info panel)</div>
<div class="leg-row"><span class="age-badge age-new">New</span> &lt; 30 days</div>
<div class="leg-row"><span class="age-badge age-recent">Recent</span> &lt; 1 year</div>
<div class="leg-row"><span class="age-badge age-established">Established</span> 1+ year</div>
<div class="leg-note">Decoded from the account's numeric ID. "~" = estimated (pre-Snowflake id); "Unknown" = id in an undatable gap.</div>
<div class="leg-heading">What each parameter means</div>
{% include "_field_glossary.html" %}
</div>
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js" crossorigin="anonymous"></script>
<script nonce="{{ g.csp_nonce }}">
(function () {
'use strict';
// ── State ─────────────────────────────────────────────────────────────────────
const allItems = []; // { type, item }
let selectedNode = null;
let cy;
// ── Cytoscape styles ──────────────────────────────────────────────────────────
const CY_STYLE = [
{ selector: 'node[type="search"]', style: {
shape: 'diamond',
'background-color': '#5865f2',
label: 'data(label)',
color: '#e8eaf0',
'font-size': '11px',
'min-zoomed-font-size': 7,
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': '9px',
width: '46px',
height: '46px',
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}},
// Tweet/reply/wayback cards: fixed, compact footprint regardless of content —
// text-wrap 'ellipsis' truncates long post text to fit instead of growing the
// box or overflowing it, which keeps dense graphs (100s of nodes) readable.
// Full untruncated text is always available in the side panel on click.
{ selector: 'node[type="tweet"], node[type="reply"], node[type="wayback"]', style: {
shape: 'roundrectangle',
label: 'data(label)',
color: '#e8eaf0',
'font-size': '9px',
'min-zoomed-font-size': 7,
'text-valign': 'center',
'text-halign': 'center',
'text-wrap': 'ellipsis',
'text-max-width': '128px',
'border-width': '2px',
width: '148px',
height: '52px',
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}},
{ selector: 'node[type="tweet"]', style: {
'background-color': '#1e2240',
'border-color': '#5865f2',
}},
{ selector: 'node[type="reply"]', style: {
'background-color': '#0d2018',
'border-color': '#22c55e',
}},
{ selector: 'node[type="wayback"]', style: {
'background-color': '#2a1f08',
'border-color': '#f59e0b',
}},
{ selector: 'node[type="retweeter"], node[type="user"]', style: {
shape: 'ellipse',
'background-color': '#1e1535',
'border-color': '#8b5cf6',
'border-width': '2px',
label: 'data(label)',
color: '#e8eaf0',
'font-size': '10px',
'min-zoomed-font-size': 7,
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': '7px',
'text-wrap': 'ellipsis',
'text-max-width': '90px',
width: '68px',
height: '68px',
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}},
// Viewed marker — set once a node has been clicked/inspected. Deliberately
// plain white with no other color coding so it reads as one thing: "seen".
// Placed after the type selectors so it always wins on background/border.
{ selector: 'node[?viewed][type="tweet"], node[?viewed][type="reply"], node[?viewed][type="wayback"]', style: {
'background-color': '#ffffff',
'border-color': '#c7c7c7',
color: '#0f1117',
}},
{ selector: 'node[?viewed][type="search"], node[?viewed][type="user"], node[?viewed][type="retweeter"]', style: {
'background-color': '#ffffff',
'border-color': '#c7c7c7',
}},
// Softer, thinner and semi-transparent — a hub with many edges (a popular
// tweet's replies, a big multi-source search) stays legible instead of
// turning into a bright white tangle.
{ selector: 'edge', style: {
'line-color': '#FFFFFF',
'line-style': 'dashed',
'line-opacity': 1.0,
width: '1.1px',
'target-arrow-color': '#FFFFFF',
'target-arrow-shape': 'triangle',
'arrow-scale': 1.0,
'curve-style': 'bezier',
}},
];
// ── Init ──────────────────────────────────────────────────────────────────────
function init() {
cy = cytoscape({
container: document.getElementById('cy'),
style: CY_STYLE,
layout: { name: 'preset' },
minZoom: 0.08,
maxZoom: 4,
wheelSensitivity: 0.25,
boxSelectionEnabled: false,
// Keep panning/zooming smooth once a graph has hundreds of nodes/edges —
// edges & a rasterized texture stand in for the full render mid-gesture.
hideEdgesOnViewport: true,
textureOnViewport: true,
});
cy.on('tap', 'node', function (evt) {
selectedNode = evt.target;
selectedNode.data('viewed', true);
showPanel(selectedNode.data());
});
cy.on('tap', function (evt) {
if (evt.target === cy) hidePanel();
});
// Hamburger nav
var navBtn = document.getElementById('navToggle');
var navMenu = document.getElementById('navMenu');
navBtn.addEventListener('click', function(e) {
e.stopPropagation();
navMenu.classList.toggle('hidden');
});
document.addEventListener('click', function() { navMenu.classList.add('hidden'); });
navMenu.addEventListener('click', function(e) { e.stopPropagation(); });
// Legend / help popup
var legendBtn = document.getElementById('legendToggle');
var legendPopup = document.getElementById('legend');
legendBtn.addEventListener('click', function (e) {
e.stopPropagation();
legendPopup.classList.toggle('hidden');
});
document.addEventListener('click', function () { legendPopup.classList.add('hidden'); });
legendPopup.addEventListener('click', function (e) { e.stopPropagation(); });
document.getElementById('btnSearch').addEventListener('click', runSearch);
document.getElementById('queryInput').addEventListener('keydown', function (e) {
if (e.key === 'Enter') runSearch();
});
document.getElementById('toolSelect').addEventListener('change', function () {
updatePlaceholder();
updateModeVisibility();
});
document.getElementById('btnArchiveAll').addEventListener('click', archiveAll);
document.getElementById('btnDump').addEventListener('click', dumpJSON);
document.getElementById('btnClear').addEventListener('click', clearGraph);
document.getElementById('btnClosePanel').addEventListener('click', hidePanel);
document.getElementById('btnExpandReplies').addEventListener('click', function () {
expandNode('tweet_replies_extractor');
});
document.getElementById('btnExpandRetweets').addEventListener('click', function () {
expandNode('tweet_retweeters_extractor');
});
updatePlaceholder();
updateModeVisibility();
}
// ── Placeholder by tool type ──────────────────────────────────────────────────
function updatePlaceholder() {
var map = {
tweet_search_extractor: 'Search query…',
follower_explorer: 'Username or user ID…',
post_extractor: 'Username or user ID…',
article_extractor: 'Tweet ID…',
community_post_extractor: 'Community ID…',
tweet_replies_extractor: 'Tweet ID…',
tweet_retweeters_extractor: 'Tweet ID…',
geo_post_extractor: 'Keyword (geocodes profile location)…',
wayback_archive_search: 'Username or tweet URL…',
multi_source_search: 'Keyword, mention, username or URL…',
};
var tool = document.getElementById('toolSelect').value;
document.getElementById('queryInput').placeholder = map[tool] || 'Query…';
}
// These tools don't use the Cookie/API mode select: wayback needs no auth at
// all, multi_source_search always fans out across every mode simultaneously.
// Both also accept an optional YYYYMMDD date range to narrow results.
var NO_AUTH_TOOLS = ['wayback_archive_search', 'multi_source_search'];
function updateModeVisibility() {
var tool = document.getElementById('toolSelect').value;
var show = NO_AUTH_TOOLS.indexOf(tool) !== -1;
document.getElementById('modeSelect').style.display = show ? 'none' : '';
document.getElementById('dateFromInput').style.display = show ? '' : 'none';
document.getElementById('dateToInput').style.display = show ? '' : 'none';
}
// ── Status bar ────────────────────────────────────────────────────────────────
var _statusTimer = null;
function setStatus(msg, isError, persist) {
var bar = document.getElementById('statusBar');
bar.textContent = msg;
bar.classList.toggle('error', !!isError);
bar.classList.remove('hidden');
if (_statusTimer) clearTimeout(_statusTimer);
if (!persist) {
_statusTimer = setTimeout(function () { bar.classList.add('hidden'); }, 4500);
}
}
function updateNodeCount() {
var n = cy.nodes().length;
document.getElementById('nodeCount').textContent = n + ' node' + (n !== 1 ? 's' : '');
document.getElementById('emptyHint').classList.toggle('hidden', n > 0);
}
// ── API helpers ───────────────────────────────────────────────────────────────
function buildBody(tool, query, count, mode) {
var body = { toolType: tool, mode: mode, count: count };
if (tool === 'tweet_search_extractor' || tool === 'geo_post_extractor' ||
tool === 'wayback_archive_search' || tool === 'multi_source_search') {
body.searchQuery = query;
} else if (tool === 'follower_explorer' || tool === 'post_extractor') {
body.targetUsername = query;
} else if (
tool === 'article_extractor' ||
tool === 'tweet_replies_extractor' ||
tool === 'tweet_retweeters_extractor'
) {
body.targetTweetId = query;
} else if (tool === 'community_post_extractor') {
body.targetCommunityId = query;
}
if (tool === 'wayback_archive_search' || tool === 'multi_source_search') {
var from = document.getElementById('dateFromInput').value.trim();
var to = document.getElementById('dateToInput').value.trim();
if (tool === 'wayback_archive_search') {
if (from) body.waybackFrom = from;
if (to) body.waybackTo = to;
} else {
if (from) body.dateFrom = from;
if (to) body.dateTo = to;
}
}
return body;
}
async function apiFetch(body) {
var res = await fetch('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
var json = await res.json();
if (!json.ok) throw new Error(json.error || 'API error');
return json.data;
}
// ── Label builder ─────────────────────────────────────────────────────────────
function makeLabel(item, type) {
if (type === 'user' || type === 'retweeter') {
var handle = item.screen_name || item.user || '';
return handle ? '@' + handle : 'Unknown';
}
if (type === 'wayback') {
var snap = (item.post_title || item.post_text || item.original || 'Snapshot').replace(/\s+/g, ' ').trim();
return snap.length > 60 ? snap.substring(0, 60) + '…' : snap;
}
var text = (item.text || item.retweeted_text || '').replace(/\s+/g, ' ').trim();
return text.length > 60 ? text.substring(0, 60) + '…' : (text || ('Tweet ' + (item.id || '?')));
}
// Per-item node type — multi_source_search mixes live tweets with Wayback
// snapshots in one result set, so the type has to be resolved per item rather
// than fixed for the whole tool/search.
function resolveNodeType(tool, item) {
if (tool === 'follower_explorer') return 'user';
if (tool === 'tweet_retweeters_extractor') return 'retweeter';
if (tool === 'wayback_archive_search') return 'wayback';
if (tool === 'multi_source_search') return item.source === 'Wayback Machine' ? 'wayback' : 'tweet';
return 'tweet';
}
// ── Graph operations ──────────────────────────────────────────────────────────
function addNodes(items, nodeTypeOrFn, parentId) {
var added = 0;
items.forEach(function (item, i) {
var nodeType = typeof nodeTypeOrFn === 'function' ? nodeTypeOrFn(item) : nodeTypeOrFn;
var tweetId = String(item.id || '');
var cyId = tweetId ? 'n_' + tweetId : 'n_' + Date.now() + '_' + i;
if (cy.getElementById(cyId).length) {
if (parentId) addEdge(parentId, cyId);
return;
}
cy.add({
data: {
id: cyId,
type: nodeType,
label: makeLabel(item, nodeType),
tweetId: tweetId,
raw: item,
}
});
if (parentId) addEdge(parentId, cyId);
allItems.push({ type: nodeType, item: item });
added++;
});
return added;
}
function addEdge(src, tgt) {
var eId = 'e_' + src + '_' + tgt;
if (!cy.getElementById(eId).length) {
cy.add({ data: { id: eId, source: src, target: tgt } });
}
}
function runLayout(fit, randomize) {
cy.layout({
name: 'cose',
animate: true,
animationDuration: 450,
randomize: !!randomize,
fit: !!fit,
nodeRepulsion: 10000,
idealEdgeLength: 130,
gravity: 0.35,
numIter: 1000,
padding: 40,
}).run();
}
// ── Search ────────────────────────────────────────────────────────────────────
async function runSearch() {
var tool = document.getElementById('toolSelect').value;
var query = document.getElementById('queryInput').value.trim();
var count = Math.max(1, Math.min(200, parseInt(document.getElementById('countInput').value, 10) || 20));
var mode = document.getElementById('modeSelect').value;
if (!query) { setStatus('Enter a query first', true); return; }
document.getElementById('btnSearch').disabled = true;
setStatus('Searching…', false, true);
try {
var data = await apiFetch(buildBody(tool, query, count, mode));
var items = Array.isArray(data) ? data : [data];
// Central search node
var searchId = 'search_' + Date.now();
var shortQ = query.length > 40 ? query.substring(0, 40) + '…' : query;
cy.add({ data: { id: searchId, type: 'search', label: shortQ, tweetId: '', raw: { query: query } } });
var added = addNodes(items, function (item) { return resolveNodeType(tool, item); }, searchId);
runLayout(true, true);
updateNodeCount();
setStatus('Done — ' + items.length + ' result(s) (' + added + ' new nodes)');
} catch (e) {
setStatus('Error: ' + e.message, true);
} finally {
document.getElementById('btnSearch').disabled = false;
}
}
// ── Expand replies / retweets from selected node ──────────────────────────────
async function expandNode(expandTool) {
if (!selectedNode) return;
var data = selectedNode.data();
var tweetId = data.tweetId;
if (!tweetId) { setStatus('Node has no tweet ID', true); return; }
var count = Math.max(1, Math.min(200, parseInt(document.getElementById('countInput').value, 10) || 50));
var parentId = selectedNode.id();
document.getElementById('btnExpandReplies').disabled = true;
document.getElementById('btnExpandRetweets').disabled = true;
setStatus('Expanding…', false, true);
try {
var fetched = await apiFetch({ toolType: expandTool, mode: 'cookie', count: count, targetTweetId: tweetId });
var items = Array.isArray(fetched) ? fetched : [fetched];
var nodeType = expandTool === 'tweet_replies_extractor' ? 'reply' : 'retweeter';
var added = addNodes(items, nodeType, parentId);
runLayout(false, false);
updateNodeCount();
setStatus('Expanded — ' + items.length + ' result(s) (' + added + ' new nodes)');
} catch (e) {
setStatus('Error: ' + e.message, true);
} finally {
document.getElementById('btnExpandReplies').disabled = false;
document.getElementById('btnExpandRetweets').disabled = false;
}
}
// ── Info panel ────────────────────────────────────────────────────────────────
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
var PRIORITY_KEYS = [
'source', 'account_age_flag', 'account_age', 'account_created',
'user', 'screen_name', 'name', 'text', 'post_title', 'post_text', 'created_at',
'retweeted_by_user', 'retweeted_by_name', 'retweeted_text', 'retweeted_by_bio',
'reply_count', 'retweet_count', 'favorite_count', 'view_count',
'followers_count', 'following_count', 'tweet_count',
'description', 'user_location', 'in_reply_to_tweet_id',
'retweeted_tweet_id', 'retweeted_at', 'verified', 'is_blue_verified',
'archive_url', 'preview_image', 'iso_date', 'original', 'statuscode',
];
var SKIP_KEYS = new Set(['id', 'media', 'card', 'user_id', 'retweeted_by_user_id']);
var SOURCE_CLASS = { 'Twitter Cookie': 'src-cookie', 'Xquik API': 'src-xquik', 'Wayback Machine': 'src-wayback' };
function showPanel(data) {
var raw = data.raw || {};
var type = data.type;
var titles = { search: 'Search Node', tweet: 'Tweet', reply: 'Reply', user: 'User', retweeter: 'Retweeter' };
document.getElementById('infoTitle').textContent = titles[type] || 'Node';
// Build key list (priority first, then remaining)
var allKeys = Object.keys(raw);
var ordered = PRIORITY_KEYS.concat(allKeys.filter(function (k) { return PRIORITY_KEYS.indexOf(k) === -1; }));
var keys = ordered.filter(function (k) {
return !SKIP_KEYS.has(k) && raw[k] !== null && raw[k] !== undefined && raw[k] !== '';
});
var rows = keys.map(function (k) {
var v = raw[k];
var val = esc(String(v));
// Format tweet ID fields as links
if ((k === 'retweeted_tweet_id' || k === 'in_reply_to_tweet_id') && /^\d+$/.test(String(v))) {
var handle = raw.retweeted_by_user || raw.user || '';
if (handle) {
val = '<a href="https://x.com/' + encodeURIComponent(handle) + '/status/' + encodeURIComponent(String(v)) +
'" target="_blank" rel="noopener noreferrer">' + esc(String(v)) + ' ↗</a>';
}
} else if (k === 'archive_url' || k === 'preview_image') {
val = '<a href="' + esc(String(v)) + '" target="_blank" rel="noopener noreferrer">' +
(k === 'archive_url' ? 'Open snapshot ↗' : 'Preview image ↗') + '</a>';
} else if (k === 'source') {
val = '<span class="source-badge ' + (SOURCE_CLASS[v] || '') + '">' + esc(String(v)) + '</span>';
} else if (k === 'account_age_flag') {
var ageLabels = { new: 'New account', recent: 'Recent account', established: 'Established account' };
val = '<span class="age-badge age-' + esc(String(v)) + '">' + esc(ageLabels[v] || String(v)) + '</span>';
}
var label = esc(k.replace(/_/g, ' '));
return '<div class="info-row"><div class="info-key">' + label + '</div><div class="info-val">' + val + '</div></div>';
}).join('');
// Media thumbnails
var media = Array.isArray(raw.media) ? raw.media : [];
if (media.length) {
var mediaParts = ['<div class="info-media">'];
media.forEach(function (m) {
var thumb = m.thumb || '';
var url = m.url || thumb;
var mtype = m.type || 'photo';
if (!thumb) return;
if (mtype === 'photo') {
mediaParts.push(
'<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' +
'<img src="' + esc(thumb) + '" loading="lazy" alt="media"></a>'
);
} else {
mediaParts.push(
'<a href="/api/video?url=' + encodeURIComponent(url) + '" target="_blank" rel="noopener noreferrer" class="video-link">▶ ' + esc(mtype) + '</a>'
);
}
});
mediaParts.push('</div>');
rows += mediaParts.join('');
}
document.getElementById('infoContent').innerHTML = rows;
// Action buttons
var isTweet = type === 'tweet' || type === 'reply';
var isWayback = type === 'wayback';
var tweetId = data.tweetId;
var user = raw.user || raw.screen_name || '';
document.getElementById('btnExpandReplies').style.display = isTweet ? '' : 'none';
document.getElementById('btnExpandRetweets').style.display = isTweet ? '' : 'none';
var openBtn = document.getElementById('btnOpenTweet');
if (isTweet && tweetId && user) {
openBtn.href = 'https://x.com/' + encodeURIComponent(user) + '/status/' + encodeURIComponent(tweetId);
openBtn.textContent = 'Open Tweet ↗';
openBtn.style.display = '';
} else if (isWayback && raw.archive_url) {
openBtn.href = raw.archive_url;
openBtn.textContent = 'Open Archived Snapshot ↗';
openBtn.style.display = '';
} else {
openBtn.style.display = 'none';
}
document.getElementById('infoPanel').classList.remove('hidden');
}
function hidePanel() {
document.getElementById('infoPanel').classList.add('hidden');
selectedNode = null;
cy.elements().unselect();
}
// ── Archive All ───────────────────────────────────────────────────────────────
async function archiveAll() {
if (!allItems.length) { setStatus('Nothing to archive yet', true); return; }
var query = document.getElementById('queryInput').value.trim();
var tool = document.getElementById('toolSelect').value;
setStatus('Archiving…', false, true);
try {
var res = await fetch('/api/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolType: 'graph_' + tool,
data: allItems.map(function (d) { return d.item; }),
queryInfo: { query: query, source: 'graph', nodes: cy.nodes().length },
}),
});
var json = await res.json();
if (!json.ok) throw new Error(json.error);
setStatus('Archived — ID: ' + json.archiveId);
} catch (e) {
setStatus('Archive error: ' + e.message, true);
}
}
// ── Dump JSON ─────────────────────────────────────────────────────────────────
function dumpJSON() {
if (!allItems.length) { setStatus('Nothing to export yet', true); return; }
var payload = {
exported_at: new Date().toISOString(),
query: document.getElementById('queryInput').value.trim(),
tool: document.getElementById('toolSelect').value,
node_count: cy.nodes().length,
items: allItems,
};
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'graph_export_' + Date.now() + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatus('Exported ' + allItems.length + ' item(s)');
}
// ── Clear ─────────────────────────────────────────────────────────────────────
function clearGraph() {
cy.elements().remove();
allItems.length = 0;
hidePanel();
updateNodeCount();
setStatus('Graph cleared');
}
// ── Boot ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
})();
</script>
</body>
</html>