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

1313 lines
49 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; }
/* ── Archive progress bar — same look as the non-graph search page ── */
.archive-bar {
padding: 6px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--muted);
flex-shrink: 0;
}
.archive-bar-fill-wrap {
height: 3px;
background: var(--border);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.archive-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.3s;
}
/* ── 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 video.info-video {
display: block;
width: 100%;
max-width: 260px;
border-radius: 4px;
border: 1px solid var(--border);
background: #000;
}
#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.cse { background: #2a0d0d; border-color: #ef4444; 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; }
.source-badge.src-cse { color: #f87171; border-color: #7f1d1d; background: #2a0d0d; }
.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="archiveBar" class="archive-bar" style="display:none"></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 cse"></div> Google CSE web result</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-row"><span class="source-badge src-cse">Google CSE</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"], node[type="cse"]', 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="cse"]', style: {
'background-color': '#2a0d0d',
'border-color': '#ef4444',
}},
{ 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"], node[?viewed][type="cse"]', 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) {
var err = new Error(json.error || 'API error');
if (res.status === 429 && json.retryAfter) err.retryAfter = json.retryAfter;
throw err;
}
return { items: json.data, nextCursor: json.nextCursor || null };
}
// ── Cookie/Wayback cooldown (mirrors the server's 5s-per-source throttle) ──
var cooldownUntil = { cookie: 0, wayback: 0 };
var cooldownTimer = null;
function throttleSourceFor(tool, mode) {
if (tool === 'wayback_archive_search') return 'wayback';
var COOKIE_TOOLS = ['tweet_search_extractor', 'follower_explorer', 'post_extractor',
'community_post_extractor', 'tweet_replies_extractor', 'tweet_retweeters_extractor', 'geo_post_extractor'];
if (mode === 'cookie' && COOKIE_TOOLS.indexOf(tool) !== -1) return 'cookie';
return null;
}
function stampCooldown(source) {
if (source) cooldownUntil[source] = Date.now() + 5000;
}
// ── 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;
}
if (type === 'cse') {
var web = (item.post_title || item.post_text || item.display_link || 'Web result').replace(/\s+/g, ' ').trim();
return web.length > 60 ? web.substring(0, 60) + '…' : web;
}
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 and Google CSE web results 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') {
if (item.source === 'Wayback Machine') return 'wayback';
if (item.source === 'Google CSE') return 'cse';
return '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 result = await apiFetch(buildBody(tool, query, count, mode));
var items = Array.isArray(result.items) ? result.items : [result.items];
stampCooldown(throttleSourceFor(tool, mode));
// Root-search pagination (continuing this same search past one page) is
// not wired up in the graph UI yet — only reply/retweet node-expand
// supports "load more" this round. result.nextCursor is intentionally
// unused here.
// 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 ──────────────────────────────
// First click on a node = fresh fetch. If more is available, the button
// relabels itself and a second click continues from the stored cursor —
// same cursor/cooldown mechanism as the non-graph page's scroll load-more.
var CURSOR_KEY = { tweet_replies_extractor: 'repliesCursor', tweet_retweeters_extractor: 'retweetersCursor' };
var EXHAUSTED_KEY = { tweet_replies_extractor: 'repliesExhausted', tweet_retweeters_extractor: 'retweetersExhausted' };
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 source = throttleSourceFor(expandTool, 'cookie'); // always 'cookie' — both expand tools are cookie-only
var remaining = cooldownUntil[source] - Date.now();
if (remaining > 0) {
armExpandCountdown(remaining);
return;
}
var count = Math.max(1, Math.min(200, parseInt(document.getElementById('countInput').value, 10) || 50));
var parentId = selectedNode.id();
var cursor = selectedNode.data(CURSOR_KEY[expandTool]) || null;
document.getElementById('btnExpandReplies').disabled = true;
document.getElementById('btnExpandRetweets').disabled = true;
setStatus(cursor ? 'Loading more…' : 'Expanding…', false, true);
try {
var body = { toolType: expandTool, mode: 'cookie', count: count, targetTweetId: tweetId };
if (cursor) body.cursor = cursor;
var result = await apiFetch(body);
stampCooldown(source);
var items = Array.isArray(result.items) ? result.items : [result.items];
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)');
selectedNode.data(CURSOR_KEY[expandTool], result.nextCursor || null);
selectedNode.data(EXHAUSTED_KEY[expandTool], !result.nextCursor);
showPanel(selectedNode.data()); // refresh button labels/disabled state (e.g. "all loaded")
} catch (e) {
if (e.retryAfter) {
// Keep both buttons disabled for the cooldown window — armExpandCountdown
// re-enables them itself once it elapses, so don't touch them here.
armExpandCountdown(e.retryAfter * 1000);
} else {
setStatus('Error: ' + e.message, true);
document.getElementById('btnExpandReplies').disabled = false;
document.getElementById('btnExpandRetweets').disabled = false;
}
}
}
function armExpandCountdown(msRemaining) {
var secs = Math.max(1, Math.ceil(msRemaining / 1000));
setStatus('Rate-limit cooldown — retry in ' + secs + 's…', false, true);
// Both Expand buttons share one underlying X-account clock, so both wait together.
document.getElementById('btnExpandReplies').disabled = true;
document.getElementById('btnExpandRetweets').disabled = true;
if (cooldownTimer) clearTimeout(cooldownTimer);
cooldownTimer = setTimeout(function () {
cooldownTimer = null;
document.getElementById('btnExpandReplies').disabled = false;
document.getElementById('btnExpandRetweets').disabled = false;
setStatus('Ready — click Expand again', false, false);
}, msRemaining);
}
// ── Info panel ────────────────────────────────────────────────────────────────
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// LIST ARR PARAMS
var PRIORITY_KEYS = [
'source', 'account_age_flag', 'account_age', 'account_created',
'user', 'screen_name', 'name', 'text', 'post_title', 'post_text', 'created_at', 'fetched_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', 'result_url', 'preview_image', 'display_link', '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', 'Google CSE': 'src-cse' };
// ── Media helpers — same normalization as the non-graph search page ───────────
function extractMedia(item) {
// Cookie mode: item.media = [{type, thumb, url}]
if (Array.isArray(item.media) && item.media.length) {
var first = item.media[0];
if (first && typeof first === 'object' && ('thumb' in first || 'url' in first)) {
return item.media;
}
}
// API mode: item.extended_entities.media[] or item.entities.media[]
var src = (item.extended_entities && item.extended_entities.media)
|| (item.entities && item.entities.media);
if (!Array.isArray(src)) return [];
return src.map(function (m) {
var mtype = m.type || 'photo';
var thumb = m.media_url_https || m.media_url || '';
var url = thumb;
if (mtype === 'video' || mtype === 'animated_gif') {
var variants = (m.video_info && m.video_info.variants) || [];
var mp4s = variants.filter(function (v) { return v.content_type === 'video/mp4'; });
if (mp4s.length) url = mp4s.reduce(function (b, v) { return (v.bitrate || 0) > (b.bitrate || 0) ? v : b; }).url;
}
return { type: mtype, thumb: thumb, url: url };
}).filter(function (m) { return m.thumb; });
}
function showPanel(data) {
var raw = data.raw || {};
var type = data.type;
var titles = { search: 'Search Node', tweet: 'Tweet', reply: 'Reply', user: 'User', retweeter: 'Retweeter', wayback: 'Wayback Snapshot', cse: 'Web Result' };
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 === 'tweet_url' || k === 'archive_url' || k === 'result_url' || k === 'preview_image') {
// Full raw URL as the link text itself, not hidden behind a generic
// label — same treatment as the live search page and archive.html.
val = '<a href="' + esc(String(v)) + '" target="_blank" rel="noopener noreferrer">' + esc(String(v)) + '</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 — normalized cookie/API shape; videos play inline via
// the backend proxy instead of just linking out to the raw stream.
var media = extractMedia(raw);
if (media.length) {
var mediaParts = ['<div class="info-media">'];
media.forEach(function (m) {
if (!m.thumb) return;
if (m.type === 'video' || m.type === 'animated_gif') {
var proxied = '/api/video?url=' + encodeURIComponent(m.url);
var loop = m.type === 'animated_gif' ? 'loop muted' : '';
mediaParts.push(
'<video class="info-video" controls ' + loop + ' poster="' + esc(m.thumb) + '" preload="none">' +
'<source src="' + proxied + '" type="video/mp4"></video>'
);
} else {
mediaParts.push(
'<a href="' + esc(m.url) + '" target="_blank" rel="noopener noreferrer">' +
'<img src="' + esc(m.thumb) + '" loading="lazy" alt="media"></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 || '';
var repliesBtn = document.getElementById('btnExpandReplies');
var retweetersBtn = document.getElementById('btnExpandRetweets');
repliesBtn.style.display = isTweet ? '' : 'none';
retweetersBtn.style.display = isTweet ? '' : 'none';
if (isTweet) {
// Label reflects whether this node's already been expanded and whether
// more is available — neither twikit nor the Wayback CDX API expose a
// total count, only presence/absence of a next page, so no item count.
repliesBtn.textContent = data.repliesExhausted ? '↩ Expand Replies (all loaded)'
: data.repliesCursor ? '↩ Expand Replies (more available)' : '↩ Expand Replies';
repliesBtn.disabled = !!data.repliesExhausted;
retweetersBtn.textContent = data.retweetersExhausted ? '↗ Expand Retweets (all loaded)'
: data.retweetersCursor ? '↗ Expand Retweets (more available)' : '↗ Expand Retweets';
retweetersBtn.disabled = !!data.retweetersExhausted;
}
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 if (type === 'cse' && raw.result_url) {
openBtn.href = raw.result_url;
openBtn.textContent = 'Open Page ↗';
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 ───────────────────────────────────────────────────────────────
// First click creates a new archive; once that succeeds, graphArchivedId is
// set and subsequent clicks (e.g. after expanding more reply/retweet nodes)
// UPDATE that same archive in place instead of creating a new one each time
// — same checkpoint behavior as the non-graph search page.
var graphArchivedId = null;
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;
var archiveBar = document.getElementById('archiveBar');
archiveBar.style.display = '';
var label = graphArchivedId ? 'Saving checkpoint…' : 'Archiving…';
archiveBar.innerHTML = '<span>' + label + '</span><div class="archive-bar-fill-wrap"><div class="archive-bar-fill" style="width:0%"></div></div>';
try {
var body = {
toolType: 'graph_' + tool,
data: allItems.map(function (d) { return d.item; }),
queryInfo: { query: query, source: 'graph', nodes: cy.nodes().length },
};
if (graphArchivedId) body.archiveId = graphArchivedId;
var res = await fetch('/api/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
var json = await res.json();
if (!json.ok) throw new Error(json.error);
graphArchivedId = json.archiveId;
document.getElementById('btnArchiveAll').textContent = 'Update Archive';
pollGraphArchive(json.archiveId);
} catch (e) {
archiveBar.innerHTML = '<span style="color:var(--danger)">Archive error: ' + esc(e.message) + '</span>';
}
}
function pollGraphArchive(archiveId) {
var archiveBar = document.getElementById('archiveBar');
var interval = setInterval(async function () {
var res = await fetch('/api/archive/' + archiveId + '/status');
var json = await res.json();
if (!json.ok) { clearInterval(interval); return; }
var status = json.status, progress = json.progress, total = json.total;
var fill = archiveBar.querySelector('.archive-bar-fill');
if (status === 'saving') {
archiveBar.querySelector('span').textContent = 'Saving results…';
} else if (status === 'downloading') {
var pct = total > 0 ? Math.round((progress / total) * 100) : 0;
archiveBar.querySelector('span').textContent = 'Downloading media ' + progress + '/' + total;
if (fill) fill.style.width = pct + '%';
} else if (status === 'done') {
clearInterval(interval);
if (fill) fill.style.width = '100%';
archiveBar.querySelector('span').textContent = 'Archived ✓ (' + total + ' media files saved) — ID: ' + archiveId;
}
}, 1200);
}
// ── 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();
graphArchivedId = null;
document.getElementById('btnArchiveAll').textContent = 'Archive All';
document.getElementById('archiveBar').style.display = 'none';
setStatus('Graph cleared');
}
// ── Boot ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
})();
</script>
</body>
</html>