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

1767 lines
73 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;
}
.panel-header {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 12px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.panel-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.panel-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: var(--muted);
}
.panel-identity { min-width: 0; }
.panel-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel-handle { font-size: 11px; color: var(--muted); }
/* Compact variant for tweet/reply nodes — the node's own text is a row
below, so this stays a small identity line rather than a full profile
block (that treatment is reserved for user/retweeter nodes). */
.panel-byline { padding-bottom: 10px; }
.panel-byline .panel-avatar { width: 28px; height: 28px; }
.panel-byline .panel-identity { display: flex; align-items: baseline; gap: 6px; }
.panel-bio {
font-size: 11px;
color: var(--text);
opacity: 0.85;
line-height: 1.5;
padding-bottom: 10px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.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; }
/* "Replying to @x @y" — x.com trims these from the visible tweet body and
shows them as their own line instead; matches that here. Display-only,
the underlying text value (Dump JSON / Archive All) is untouched. */
.reply-context {
font-size: 10px;
color: var(--muted);
margin-bottom: 4px;
}
.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-line { width: 20px; height: 0; flex-shrink: 0; border-top: 2px solid; }
.leg-line-correlation { border-top-color: #facc15; }
.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; }
/* What kind of X/Twitter page a Google CSE / Wayback result actually is —
a keyword match linking to x.com/someone reads as "just a profile" with
no way to tell a tweet permalink apart from a bare profile page or an
unrelated page otherwise. */
.content-type-badge {
display: inline-block;
font-size: 11px;
font-weight: 500;
padding: 2px 9px;
border-radius: 20px;
border: 1px solid var(--border);
}
.content-type-badge.ct-tweet { color: var(--success); border-color: #14532d; background: #052011; }
.content-type-badge.ct-profile { color: #a78bfa; border-color: #4c3a8f; background: var(--purple-bg); }
.content-type-badge.ct-twitter_other { color: var(--warn); border-color: #78350f; background: #1c0e02; }
.content-type-badge.ct-other { color: var(--muted); border-color: var(--border); background: var(--bg); }
/* ── 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>
<a href="/analytics">Analytics</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="following_explorer">Following Explorer [Cookie]</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="2000" 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>
<button id="btnViewAuthor" class="btn btn-ghost" style="display:none">View Author Profile</button>
<button id="btnExpandPosts" class="btn btn-ghost" style="display:none">Expand Posts</button>
<button id="btnExpandFollowers" class="btn btn-ghost" style="display:none">Expand Followers</button>
<button id="btnExpandFollowing" class="btn btn-ghost" style="display:none">Expand Following</button>
<button id="btnLoadMoreSearch" class="btn btn-ghost" style="display:none">⤓ Load More Results</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<br>
Shift/Alt/Ctrl+drag to box-select multiple nodes, then drag any one to move them together
</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-heading">Controls</div>
<div class="leg-note">Drag background to pan · Scroll to zoom · Click a node to inspect it<br>Shift/Alt/Ctrl+drag to box-select multiple nodes, then drag any one to move the whole selection together</div>
<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 / Follower / Following</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-note">Double border = this tweet/reply has replies of its own — click it, then Expand Replies to go a level deeper. Works on reply nodes too, so a thread nests as deep as it actually goes.</div>
<div class="leg-note">A user/retweeter node's photo is their real avatar when one was captured (cookie mode) — the plain purple circle is the fallback when it wasn't.</div>
<div class="leg-heading">Expanding a node further</div>
<div class="leg-note">Tweet/Reply — Expand Replies, Expand Retweets, or View Author Profile (pulls the author out as its own node, free — no request, the data's already on the tweet).<br>User/Retweeter — Expand Posts, Expand Followers, or Expand Following. Works on a "View Author" node too, so a reply's author can be looked up in full: posts, followers, following.</div>
<div class="leg-note">Edge labels show how two nodes relate once expanded: <em>replied by</em>, <em>retweeted by</em>, <em>posted</em>, <em>followed by</em>, <em>follows</em>, <em>authored by</em>. A plain unlabeled edge just means "matched the search."</div>
<div class="leg-row"><div class="leg-line leg-line-correlation"></div> Correlation — this connects to a node already reached some other way (e.g. a reply's author who also turns up as a follower). Marks where two separate paths through the graph converge on the same account/tweet.</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">Content type — Google CSE / Wayback only (info panel)</div>
<div class="leg-row"><span class="content-type-badge ct-tweet">Tweet</span></div>
<div class="leg-row"><span class="content-type-badge ct-profile">Profile page</span></div>
<div class="leg-row"><span class="content-type-badge ct-twitter_other">Other X/Twitter page</span></div>
<div class="leg-row"><span class="content-type-badge ct-other">External page (non-X)</span></div>
<div class="leg-note">What kind of page a keyword match actually landed on — a search hit isn't always a specific tweet.</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',
}},
// Avatar texture — replaces the flat fill with the person's actual profile
// photo when one was captured (cookie mode only; xquik/API-mode records
// don't carry it). The colored ring from the selectors above stays as a
// type indicator; only the fill becomes a photo. Declared after the
// "viewed" block so the photo wins regardless of viewed state.
{ selector: 'node[type="user"][?hasAvatar], node[type="retweeter"][?hasAvatar]', style: {
'background-image': 'data(avatarUrl)',
'background-fit': 'cover',
'background-clip': 'node',
}},
// Nested-reply indicator — a slightly thicker, double-lined border marks a
// tweet/reply node that has further replies of its own to expand into,
// glanceable on the canvas without opening the info panel.
{ selector: 'node[?hasReplies]', style: {
'border-width': '3px',
'border-style': 'double',
}},
// Multi-select highlight (Maltego-style box select) — wins over every
// type's own border-color above since it's declared last in this array
// (Cytoscape applies matching selectors in declaration order, last wins).
{ selector: 'node:selected', style: {
'border-width': '3px',
'border-color': '#5865f2',
'overlay-color': '#5865f2',
'overlay-opacity': 0.22,
'overlay-padding': '6px',
}},
// 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',
// Empty for a plain "matched the search" edge (addEdge's label
// defaults to ''), so most edges in a fresh search still draw with no
// visible text — this only shows up on the relationship-specific
// edges node-expand actions create (replied by / retweeted by /
// posted / followed by / follows / authored by).
label: 'data(label)',
'font-size': '8px',
color: '#c7c7c7',
'text-rotation': 'autorotate',
'text-background-color': '#0f1117',
'text-background-opacity': 0.85,
'text-background-padding': '2px',
}},
// Correlation — this edge connects to a node that already existed via
// some OTHER path (a different search, a different expand, ...) rather
// than one just discovered. Declared after the base 'edge' selector so it
// wins on line/arrow color; solid + thicker on top of the color change so
// it's still readable once a dense graph desaturates everything else.
{ selector: 'edge[?correlation]', style: {
'line-color': '#facc15',
'line-style': 'solid',
width: '1.8px',
'target-arrow-color': '#facc15',
color: '#facc15',
}},
];
// ── Init ──────────────────────────────────────────────────────────────────────
function init() {
cy = cytoscape({
container: document.getElementById('cy'),
style: CY_STYLE,
layout: { name: 'preset' },
minZoom: 0.08,
maxZoom: 4,
wheelSensitivity: 0.25,
// Maltego-style multi-select: hold shift/alt/ctrl/cmd and drag on empty
// canvas to rubber-band select several nodes at once, then drag any one
// of them to move the whole selection together — this is native
// Cytoscape drag behavior once elements are selected, no extra code
// needed. Plain drag-on-background still pans (Cytoscape requires a
// modifier key for box select whenever panning is also enabled, so the
// two gestures never conflict).
boxSelectionEnabled: true,
// 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();
});
// Multi-select bookkeeping — the single-node info panel only makes sense
// for exactly one selected node, so a box-select of several hides it
// (without touching the selection itself, unlike hidePanel()) and the
// node-count badge picks up the slack to show what's selected.
cy.on('select unselect', 'node', function () {
updateNodeCount();
if (cy.nodes(':selected').length > 1) {
document.getElementById('infoPanel').classList.add('hidden');
selectedNode = null;
}
});
// 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');
});
document.getElementById('btnViewAuthor').addEventListener('click', viewAuthor);
document.getElementById('btnExpandPosts').addEventListener('click', function () {
expandNode('post_extractor');
});
document.getElementById('btnExpandFollowers').addEventListener('click', function () {
expandNode('follower_explorer');
});
document.getElementById('btnExpandFollowing').addEventListener('click', function () {
expandNode('following_explorer');
});
document.getElementById('btnLoadMoreSearch').addEventListener('click', expandSearch);
updatePlaceholder();
updateModeVisibility();
}
// ── Placeholder by tool type ──────────────────────────────────────────────────
function updatePlaceholder() {
var map = {
tweet_search_extractor: 'Search query…',
follower_explorer: 'Username or user ID…',
following_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;
var selN = cy.nodes(':selected').length;
var label = n + ' node' + (n !== 1 ? 's' : '');
if (selN > 1) label += ' · ' + selN + ' selected';
document.getElementById('nodeCount').textContent = label;
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 === 'following_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;
// Returns the list of rate-limit clocks a request against this tool/mode
// touches. multi_source_search fans out to both cookie and wayback
// internally, so continuing it has to wait out (and then restart) both
// cooldowns at once, not just one.
function throttleSourceFor(tool, mode) {
if (tool === 'wayback_archive_search') return ['wayback'];
if (tool === 'multi_source_search') return ['cookie', 'wayback'];
var COOKIE_TOOLS = ['tweet_search_extractor', 'follower_explorer', 'following_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 [];
}
function stampCooldown(sources) {
(sources || []).forEach(function (source) { cooldownUntil[source] = Date.now() + 5000; });
}
// Tools whose root search (not just node-expand) supports cursor pagination
// — everything except article_extractor, which only ever returns one item.
var ROOT_PAGINATED_TOOLS = ['tweet_search_extractor', 'follower_explorer', 'following_explorer', 'post_extractor',
'community_post_extractor', 'tweet_replies_extractor', 'tweet_retweeters_extractor',
'geo_post_extractor', 'wayback_archive_search', 'multi_source_search'];
// ── Label builder ─────────────────────────────────────────────────────────────
// Tapping "Reply" on X auto-prefixes the compose box with every account the
// reply-chain already has tagged, and that prefix is genuinely part of the
// reply's own raw text — but x.com's own UI never shows it inline, trimming
// it into a separate "Replying to @x @y" line instead. `reply_to_mentions`
// (from cookie_client.py's _leading_reply_mentions) lists exactly which
// leading @mentions those are; this only touches how text RENDERS — the
// node's own raw data (and what Dump JSON / Archive All send) is untouched.
function stripLeadingMentions(text, mentions) {
if (!text || !mentions || !mentions.length) return text;
var rest = text;
for (var i = 0; i < mentions.length; i++) {
var re = new RegExp('^\\s*@' + String(mentions[i]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
var match = rest.match(re);
if (!match) break;
rest = rest.slice(match[0].length);
}
rest = rest.replace(/^\s+/, '');
return rest || text;
}
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 rawText = item.text ? stripLeadingMentions(item.text, item.reply_to_mentions) : item.text;
var text = (rawText || 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' || tool === 'following_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';
}
// Only a plain https URL is ever handed to Cytoscape's background-image —
// it ends up as a CSS url() token internally, so the same reasoning as
// index.html's isSafeImageUrl applies: reject anything but a clean https
// URL rather than trying to escape one value for two contexts.
function isSafeImageUrl(u) {
return typeof u === 'string' && /^https:\/\/[^\s'"<>()]+$/.test(u);
}
// ── Graph operations ──────────────────────────────────────────────────────────
// edgeLabel is optional — omitted for a plain "this matched the search"
// connection (search root → result, continued-search pagination); every
// node-expand action (replies/retweets/posts/followers/following/author)
// passes one, since with 6+ relationship types now a bare arrow stopped
// being enough to tell what an edge actually means.
function addNodes(items, nodeTypeOrFn, parentId, edgeLabel) {
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) {
// The node this would-be edge points to already exists — reached via
// some OTHER path already (a different search, a different expand, a
// reply's author who also turned up as a follower, ...). That's a
// genuine correlation between two otherwise-separate parts of the
// graph, worth calling out on its own rather than blending in with
// every other edge once the graph gets big.
if (parentId) addEdge(parentId, cyId, edgeLabel, true);
return;
}
var avatarUrl = item.avatar || item.user_avatar || '';
if (!isSafeImageUrl(avatarUrl)) avatarUrl = '';
cy.add({
data: {
id: cyId,
type: nodeType,
label: makeLabel(item, nodeType),
tweetId: tweetId,
raw: item,
hasReplies: (nodeType === 'tweet' || nodeType === 'reply') && Number(item.reply_count) > 0,
hasAvatar: (nodeType === 'user' || nodeType === 'retweeter') && !!avatarUrl,
avatarUrl: avatarUrl,
}
});
if (parentId) addEdge(parentId, cyId, edgeLabel, false);
allItems.push({ type: nodeType, item: item });
added++;
});
return added;
}
// A node pair can only ever have one edge between them (id is derived purely
// from src+tgt) — if a second expand action connects the same two nodes with
// a different label (e.g. someone who's both a follower and a retweeter),
// the first label/correlation state that got there wins rather than being
// silently replaced.
function addEdge(src, tgt, label, isCorrelation) {
var eId = 'e_' + src + '_' + tgt;
if (!cy.getElementById(eId).length) {
cy.add({ data: { id: eId, source: src, target: tgt, label: label || '', correlation: !!isCorrelation } });
}
}
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(2000, 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));
// Central search node — carries everything expandSearch() needs to fetch
// the next page later (tool/query/mode/count fixed at the time of this
// search, cursor updated after every page).
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 },
tool: tool, query: query, mode: mode, count: count,
searchCursor: result.nextCursor || null,
searchExhausted: !result.nextCursor,
}
});
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 a node further (replies/retweets/posts/followers/following) ────────
// 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.
// targetKind picks which id the API call needs: a tweet node sends its own
// tweetId as targetTweetId; a user/retweeter node sends the account's own
// id (preferred) or handle as targetUsername.
var EXPAND_TOOLS = {
tweet_replies_extractor: { targetKind: 'tweet', nodeType: 'reply', button: 'btnExpandReplies', cursorKey: 'repliesCursor', exhaustedKey: 'repliesExhausted', label: 'Replies', edgeLabel: 'replied by', icon: '↩ ', countField: 'reply_count' },
tweet_retweeters_extractor: { targetKind: 'tweet', nodeType: 'retweeter', button: 'btnExpandRetweets', cursorKey: 'retweetersCursor', exhaustedKey: 'retweetersExhausted', label: 'Retweets', edgeLabel: 'retweeted by', icon: '↗ ', countField: 'retweet_count' },
post_extractor: { targetKind: 'user', nodeType: 'tweet', button: 'btnExpandPosts', cursorKey: 'postsCursor', exhaustedKey: 'postsExhausted', label: 'Posts', edgeLabel: 'posted', icon: '', countField: 'tweet_count' },
follower_explorer: { targetKind: 'user', nodeType: 'user', button: 'btnExpandFollowers', cursorKey: 'followersCursor', exhaustedKey: 'followersExhausted', label: 'Followers', edgeLabel: 'followed by', icon: '', countField: 'followers_count' },
following_explorer: { targetKind: 'user', nodeType: 'user', button: 'btnExpandFollowing', cursorKey: 'followingCursor', exhaustedKey: 'followingExhausted', label: 'Following', edgeLabel: 'follows', icon: '', countField: 'following_count' },
};
// Shared by every Expand button in the panel — label reflects whether this
// node's already been expanded and whether more is available; before the
// first click, falls back to the account/tweet's own self-reported count
// (reply_count, followers_count, ...) so it's never a blind "Expand".
function updateExpandButtonLabel(btn, data, cfg, raw) {
if (data[cfg.exhaustedKey]) {
btn.textContent = cfg.icon + 'Expand ' + cfg.label + ' (all loaded)';
} else if (data[cfg.cursorKey]) {
btn.textContent = cfg.icon + 'Expand ' + cfg.label + ' (more available)';
} else {
var n = Number(raw[cfg.countField]) || 0;
btn.textContent = n > 0 ? cfg.icon + 'Expand ~' + n + ' ' + cfg.label : cfg.icon + 'Expand ' + cfg.label;
}
btn.disabled = !!data[cfg.exhaustedKey];
}
function targetIdFor(cfg, data) {
if (cfg.targetKind === 'tweet') return data.tweetId || null;
var raw = data.raw || {};
// user_id is unambiguous (survives a screen_name change); screen_name/user
// are the fallback for any record shape that never captured a numeric id.
return raw.user_id || raw.id || raw.screen_name || raw.user || null;
}
async function expandNode(expandTool) {
if (!selectedNode) return;
var cfg = EXPAND_TOOLS[expandTool];
var data = selectedNode.data();
var targetId = targetIdFor(cfg, data);
if (!targetId) { setStatus('Node has no ' + (cfg.targetKind === 'tweet' ? 'tweet ID' : 'user ID/handle'), true); return; }
var sources = throttleSourceFor(expandTool, 'cookie'); // always 'cookie' — every expand tool here is cookie-only
var btn = document.getElementById(cfg.button);
var remaining = Math.max(0, ...sources.map(function (s) { return cooldownUntil[s] - Date.now(); }));
if (remaining > 0) {
armCooldownButtons(remaining, [btn], 'Ready — click Expand ' + cfg.label + ' again');
return;
}
var count = Math.max(1, Math.min(2000, parseInt(document.getElementById('countInput').value, 10) || 50));
var parentId = selectedNode.id();
var cursor = selectedNode.data(cfg.cursorKey) || null;
var body = { toolType: expandTool, mode: 'cookie', count: count };
if (cfg.targetKind === 'tweet') body.targetTweetId = targetId;
else body.targetUsername = targetId;
if (cursor) body.cursor = cursor;
btn.disabled = true;
setStatus(cursor ? 'Loading more…' : 'Expanding…', false, true);
try {
var result = await apiFetch(body);
stampCooldown(sources);
var items = Array.isArray(result.items) ? result.items : [result.items];
var added = addNodes(items, cfg.nodeType, parentId, cfg.edgeLabel);
runLayout(false, false);
updateNodeCount();
setStatus('Expanded — ' + items.length + ' result(s) (' + added + ' new nodes)');
selectedNode.data(cfg.cursorKey, result.nextCursor || null);
selectedNode.data(cfg.exhaustedKey, !result.nextCursor);
showPanel(selectedNode.data()); // refresh button labels/disabled state (e.g. "all loaded")
} catch (e) {
if (e.retryAfter) {
// Keep the button disabled for the cooldown window — armCooldownButtons
// re-enables it itself once it elapses, so don't touch it here.
armCooldownButtons(e.retryAfter * 1000, [btn], 'Ready — click Expand ' + cfg.label + ' again');
} else {
setStatus('Error: ' + e.message, true);
btn.disabled = false;
}
}
}
// ── View a tweet/reply node's author as its own node ───────────────────────
// A reply, retweet, or search-result tweet already carries its author's
// avatar/bio/account-age info (cookie_client.py's _tweet_to_dict embeds it
// on every tweet) — this never hits the network, it just re-shapes that
// already-fetched data into a standalone user node with the same field
// names cookie_client.py's _user_to_dict itself produces, so the node
// renders identically to a real follower/following result. The synthesized
// node's id is the author's own user_id, so an author who's already on
// canvas some other way (e.g. also appears as a follower elsewhere) merges
// into that same node instead of duplicating it — this is exactly what
// makes a reply's author "look-up-able": select them afterward and Expand
// Posts/Followers/Following works on them like any other user node.
var AUTHOR_REMAP = { user_avatar: 'avatar', user_banner: 'banner', user_bio: 'description' };
var AUTHOR_PASSTHROUGH = ['name', 'verified', 'is_blue_verified', 'user_location',
'account_created', 'account_age', 'account_age_flag', 'account_age_precision',
'source', 'fetched_at'];
function authorFromRaw(raw) {
if (!raw || (!raw.user_id && !raw.user)) return null;
var out = { id: raw.user_id || null, screen_name: raw.user || null };
Object.keys(raw).forEach(function (k) {
if (AUTHOR_REMAP[k] && raw[k] != null) out[AUTHOR_REMAP[k]] = raw[k];
});
AUTHOR_PASSTHROUGH.forEach(function (k) {
if (raw[k] != null) out[k] = raw[k];
});
return out;
}
function viewAuthor() {
if (!selectedNode) return;
var raw = selectedNode.data('raw') || {};
var author = authorFromRaw(raw);
if (!author) { setStatus('No author info on this node', true); return; }
var added = addNodes([author], 'user', selectedNode.id(), 'authored by');
runLayout(false, false);
updateNodeCount();
setStatus(added ? 'Author profile added — select it to Expand Posts/Followers/Following'
: 'Author already on canvas — linked');
}
// ── Load more results for the root search node ─────────────────────────────
// Mirrors expandNode() above — same click+cursor+cooldown mechanism, applied
// to the search node itself instead of a tweet node, so any tool (not just
// replies/retweets) can page past its first result set.
async function expandSearch() {
if (!selectedNode) return;
var data = selectedNode.data();
if (data.type !== 'search' || !data.tool) return;
var sources = throttleSourceFor(data.tool, data.mode);
var btn = document.getElementById('btnLoadMoreSearch');
var remaining = Math.max(0, ...sources.map(function (s) { return cooldownUntil[s] - Date.now(); }));
if (remaining > 0) {
armCooldownButtons(remaining, [btn], 'Ready — click Load More again');
return;
}
var cursor = selectedNode.data('searchCursor') || null;
btn.disabled = true;
setStatus('Loading more…', false, true);
try {
var body = buildBody(data.tool, data.query, data.count, data.mode);
if (cursor) body.cursor = cursor;
var result = await apiFetch(body);
stampCooldown(sources);
var items = Array.isArray(result.items) ? result.items : [result.items];
var added = addNodes(items, function (item) { return resolveNodeType(data.tool, item); }, selectedNode.id());
runLayout(false, false);
updateNodeCount();
setStatus('Loaded more — ' + items.length + ' result(s) (' + added + ' new nodes)');
selectedNode.data('searchCursor', result.nextCursor || null);
selectedNode.data('searchExhausted', !result.nextCursor);
showPanel(selectedNode.data());
} catch (e) {
if (e.retryAfter) {
armCooldownButtons(e.retryAfter * 1000, [btn], 'Ready — click Load More again');
} else {
setStatus('Error: ' + e.message, true);
btn.disabled = false;
}
}
}
function armCooldownButtons(msRemaining, buttons, readyMessage) {
var secs = Math.max(1, Math.ceil(msRemaining / 1000));
setStatus('Rate-limit cooldown — retry in ' + secs + 's…', false, true);
buttons.forEach(function (b) { b.disabled = true; });
if (cooldownTimer) clearTimeout(cooldownTimer);
cooldownTimer = setTimeout(function () {
cooldownTimer = null;
buttons.forEach(function (b) { b.disabled = false; });
setStatus(readyMessage, 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', 'content_type', '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', 'serp_title',
'iso_date', 'original', 'statuscode',
];
// Same labels/reasoning as card_constants.js's CONTENT_TYPE_LABELS — kept as
// a separate copy here since graph.html doesn't load that shared file (its
// PRIORITY_KEYS/SKIP_KEYS are already their own independent copy, not the
// index.html/archive.html shared ones).
var CONTENT_TYPE_LABELS = {
tweet: 'Tweet',
profile: 'Profile page',
twitter_other: 'Other X/Twitter page',
other: 'External page (non-X)',
};
// avatar/banner URLs are rendered as an actual image in the panel header
// below instead of a raw-link row.
var SKIP_KEYS = new Set(['id', 'media', 'card', 'user_id', 'retweeted_by_user_id', 'avatar', 'banner', 'user_avatar', 'user_banner']);
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; });
}
// Same header treatment index.html's cards give a user — avatar, name,
// handle, bio — but for the info panel, which every node type funnels
// through (search root, tweet, reply, user, retweeter, ...).
function buildPanelHeader(raw) {
var avatarRaw = raw.avatar || raw.user_avatar || '';
var avatar = isSafeImageUrl(avatarRaw) ? avatarRaw : '';
var name = raw.name || '';
var handle = raw.screen_name || raw.user || '';
if (!avatar && !name && !handle) return '';
var avatarHtml = avatar
? '<img class="panel-avatar" src="' + esc(avatar) + '" alt="" loading="lazy" referrerpolicy="no-referrer">'
: (name || handle)
? '<div class="panel-avatar panel-avatar-fallback">' + esc((name || handle).charAt(0).toUpperCase()) + '</div>'
: '';
var identityHtml = (name || handle)
? '<div class="panel-identity">' +
(name ? '<div class="panel-name">' + esc(name) + '</div>' : '') +
(handle ? '<div class="panel-handle">@' + esc(handle) + '</div>' : '') +
'</div>'
: '';
// A tweet/reply node's own text already shows a few rows down — leading
// with the author's full bio here would bury it the same way it did on
// index.html's cards. Bio only makes sense when the node itself IS a
// user (follower/retweeter), not a tweet someone happened to write.
var isTweetLike = raw.text !== undefined || raw.full_text !== undefined || raw.article_text !== undefined;
if (isTweetLike) {
return '<div class="panel-header panel-byline">' + avatarHtml + identityHtml + '</div>';
}
var bio = raw.description || raw.user_bio || '';
var bioHtml = bio ? '<div class="panel-bio">' + esc(bio) + '</div>' : '';
return '<div class="panel-header">' + avatarHtml + identityHtml + '</div>' + bioHtml;
}
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>';
} else if (k === 'content_type') {
val = '<span class="content-type-badge ct-' + esc(String(v)) + '">' + esc(CONTENT_TYPE_LABELS[v] || String(v)) + '</span>';
} else if (k === 'text' && raw.reply_to_mentions && raw.reply_to_mentions.length) {
var mentionBadge = '<div class="reply-context">↩ Replying to ' +
raw.reply_to_mentions.map(function (m) { return '@' + esc(m); }).join(', ') + '</div>';
val = mentionBadge + esc(stripLeadingMentions(String(v), raw.reply_to_mentions));
}
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 = buildPanelHeader(raw) + rows;
// Action buttons
var isTweet = type === 'tweet' || type === 'reply';
var isUserNode = type === 'user' || type === 'retweeter';
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');
var authorBtn = document.getElementById('btnViewAuthor');
var postsBtn = document.getElementById('btnExpandPosts');
var followersBtn = document.getElementById('btnExpandFollowers');
var followingBtn = document.getElementById('btnExpandFollowing');
var loadMoreBtn = document.getElementById('btnLoadMoreSearch');
repliesBtn.style.display = isTweet ? '' : 'none';
retweetersBtn.style.display = isTweet ? '' : 'none';
// A synthesized "view author" pivot only makes sense when there's
// somewhere to pivot TO — a tweet/reply always embeds its author's
// user_id/screen_name (cookie_client.py's _tweet_to_dict), but a bare
// xquik/API-mode record might not have captured either.
authorBtn.style.display = (isTweet && (raw.user_id || raw.user)) ? '' : 'none';
postsBtn.style.display = isUserNode ? '' : 'none';
followersBtn.style.display = isUserNode ? '' : 'none';
followingBtn.style.display = isUserNode ? '' : 'none';
var isPaginableSearch = type === 'search' && ROOT_PAGINATED_TOOLS.indexOf(data.tool) !== -1;
loadMoreBtn.style.display = isPaginableSearch ? '' : 'none';
if (isPaginableSearch) {
loadMoreBtn.textContent = data.searchExhausted ? '⤓ Load More Results (all loaded)'
: data.searchCursor ? '⤓ Load More Results (more available)' : '⤓ Load More Results';
loadMoreBtn.disabled = !!data.searchExhausted;
}
if (isTweet) {
updateExpandButtonLabel(repliesBtn, data, EXPAND_TOOLS.tweet_replies_extractor, raw);
updateExpandButtonLabel(retweetersBtn, data, EXPAND_TOOLS.tweet_retweeters_extractor, raw);
}
if (isUserNode) {
updateExpandButtonLabel(postsBtn, data, EXPAND_TOOLS.post_extractor, raw);
updateExpandButtonLabel(followersBtn, data, EXPAND_TOOLS.follower_explorer, raw);
updateExpandButtonLabel(followingBtn, data, EXPAND_TOOLS.following_explorer, raw);
}
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>