mirror of
https://github.com/Jieyab89/OSINT-Cheat-sheet.git
synced 2026-08-21 20:22:24 +02:00
2284 lines
87 KiB
HTML
2284 lines
87 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Jieyab89 SOCMINT Twitter / X</title>
|
|
<link rel="stylesheet" href="https://jsdelivr.net">
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
|
<style>
|
|
:root {
|
|
--bg: #0f1117;
|
|
--surface: #1a1d27;
|
|
--border: #2a2d3a;
|
|
--text: #e8eaf0;
|
|
--muted: #8890a4;
|
|
--accent: #5865f2;
|
|
--accent-bg: #1e2240;
|
|
--success: #22c55e;
|
|
--danger: #ef4444;
|
|
--warn: #f59e0b;
|
|
--cookie: #8b5cf6;
|
|
--cookie-bg: #1e1535;
|
|
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: var(--font);
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
header {
|
|
padding: 14px 24px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background: var(--surface);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
header h1 { font-size: 14px; font-weight: 600; letter-spacing: 0.02em; }
|
|
header .sep { color: var(--border); }
|
|
header .sub { color: var(--muted); font-size: 12px; }
|
|
.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; }
|
|
|
|
/* Help popup — fixed bottom-left, same icon/colors as the graph page's legend toggle */
|
|
.help-toggle {
|
|
position: fixed;
|
|
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: 251;
|
|
transition: all 0.15s;
|
|
}
|
|
.back-to-top {
|
|
position: fixed;
|
|
bottom: 14px;
|
|
right: 14px;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
color: #FFFFFF;
|
|
font-size: 17px;
|
|
cursor: pointer;
|
|
z-index: 251;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,0.4);
|
|
transition: all 0.15s;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.back-to-top:hover { border-color: var(--accent); }
|
|
.back-to-top.visible { display: flex; }
|
|
.help-toggle:hover { color: var(--text); border-color: var(--accent); }
|
|
.help-popup {
|
|
position: fixed;
|
|
bottom: 50px;
|
|
left: 14px;
|
|
background: rgba(26,29,39,0.96);
|
|
border: 1px solid var(--border);
|
|
border-radius: 7px;
|
|
padding: 10px 14px;
|
|
width: 300px;
|
|
max-height: 70vh;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--border) transparent;
|
|
font-size: 12px;
|
|
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
|
|
z-index: 250;
|
|
}
|
|
.help-popup.hidden { display: none; }
|
|
.help-popup h4 {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--muted);
|
|
margin: 10px 0 6px;
|
|
}
|
|
.help-popup h4:first-child { margin-top: 0; }
|
|
.help-row { display: flex; align-items: center; gap: 8px; margin: 5px 0; color: var(--text); line-height: 1.4; }
|
|
.help-row .age-badge, .help-row .source-badge { flex-shrink: 0; }
|
|
.help-note { color: var(--muted); margin-top: 8px; line-height: 1.5; }
|
|
.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;
|
|
}
|
|
|
|
.layout {
|
|
display: grid;
|
|
grid-template-columns: 290px 1fr;
|
|
min-height: calc(100vh - 49px);
|
|
}
|
|
@media (max-width: 720px) { .layout { grid-template-columns: 1fr; } }
|
|
|
|
.panel {
|
|
padding: 18px;
|
|
border-right: 1px solid var(--border);
|
|
background: var(--surface);
|
|
}
|
|
.field { margin-bottom: 13px; }
|
|
label {
|
|
display: block;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--muted);
|
|
margin-bottom: 5px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
select, input[type="text"], input[type="number"] {
|
|
width: 100%;
|
|
padding: 8px 10px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: var(--font);
|
|
font-size: 13px;
|
|
border-radius: 6px;
|
|
transition: border-color 0.15s;
|
|
appearance: none;
|
|
}
|
|
select {
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='%238890a4'%3E%3Cpath d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
|
|
background-repeat: no-repeat;
|
|
background-position: right 10px center;
|
|
padding-right: 28px;
|
|
}
|
|
select:focus, input:focus { outline: none; border-color: var(--accent); }
|
|
.tool-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
|
|
|
/* Mode toggle */
|
|
.mode-toggle {
|
|
display: flex;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
}
|
|
.mode-toggle button {
|
|
flex: 1;
|
|
padding: 7px;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
border: none;
|
|
cursor: pointer;
|
|
font-family: var(--font);
|
|
font-size: 12px;
|
|
transition: all 0.15s;
|
|
}
|
|
.mode-toggle button[data-mode="api"].active { background: var(--accent-bg); color: #818cf8; font-weight: 500; }
|
|
.mode-toggle button[data-mode="cookie"].active { background: var(--cookie-bg); color: #a78bfa; font-weight: 500; }
|
|
.mode-hint { font-size: 11px; color: var(--muted); margin-top: 5px; }
|
|
|
|
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
|
|
|
.btn-run {
|
|
width: 100%;
|
|
padding: 10px;
|
|
margin-top: 4px;
|
|
background: var(--accent);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-family: var(--font);
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.btn-run:disabled { opacity: 0.45; cursor: wait; }
|
|
.btn-run:not(:disabled):hover { opacity: 0.88; }
|
|
|
|
/* Output */
|
|
.output { padding: 18px; overflow: auto; }
|
|
|
|
.output-top {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 14px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.badge {
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: 3px 10px;
|
|
border-radius: 20px;
|
|
border: 1px solid var(--border);
|
|
color: var(--muted);
|
|
flex-shrink: 0;
|
|
}
|
|
.badge.ok { color: var(--success); border-color: #14532d; background: #052011; }
|
|
.badge.err { color: var(--danger); border-color: #7f1d1d; background: #1c0505; }
|
|
.badge.running { color: var(--warn); border-color: #78350f; background: #1c0e02; }
|
|
|
|
.source-badge {
|
|
display: inline-block;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: 2px 9px;
|
|
border-radius: 20px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.source-badge.src-cookie { color: #a78bfa; border-color: #4c3a8f; background: var(--cookie-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: 11px;
|
|
font-weight: 500;
|
|
padding: 2px 9px;
|
|
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(--cookie-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); }
|
|
|
|
.search-bar {
|
|
flex: 1;
|
|
min-width: 160px;
|
|
padding: 6px 10px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: var(--font);
|
|
font-size: 13px;
|
|
border-radius: 6px;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.search-bar:focus { outline: none; border-color: var(--accent); }
|
|
.search-bar::placeholder { color: var(--muted); }
|
|
|
|
.result-count { font-size: 12px; color: var(--muted); flex-shrink: 0; }
|
|
|
|
.btn-download {
|
|
padding: 5px 12px;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-family: var(--font);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: all 0.15s;
|
|
}
|
|
.btn-download:hover { color: var(--text); border-color: var(--accent); }
|
|
|
|
/* Archive progress bar */
|
|
.archive-bar {
|
|
margin-top: 8px;
|
|
padding: 8px 10px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
.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;
|
|
}
|
|
|
|
/* Recent archives list */
|
|
.archives-section {
|
|
margin-top: 14px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.archives-label {
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
margin-bottom: 6px;
|
|
}
|
|
.archive-item {
|
|
padding: 6px 8px;
|
|
border-radius: 5px;
|
|
border: 1px solid var(--border);
|
|
margin-bottom: 5px;
|
|
font-size: 11px;
|
|
}
|
|
.archive-item-tool { color: var(--accent); font-weight: 500; }
|
|
.archive-item-meta { color: var(--muted); margin-top: 1px; }
|
|
|
|
/* Cards */
|
|
.cards-grid { display: grid; gap: 8px; }
|
|
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 12px 14px;
|
|
transition: border-color 0.15s;
|
|
overflow: hidden;
|
|
}
|
|
.card:hover { border-color: #3a3d50; }
|
|
|
|
/* Profile header — cover photo, avatar, name/handle, bio. Only rendered
|
|
when the item actually carries any of that (a tweet's embedded author,
|
|
a bare user/follower/retweeter record); everything else keeps the plain
|
|
row list it always had. */
|
|
.card-header { margin-bottom: 10px; }
|
|
.card-header.has-banner {
|
|
margin: -12px -14px 10px;
|
|
}
|
|
.card-banner {
|
|
height: 64px;
|
|
background-size: cover;
|
|
background-position: center;
|
|
background-color: var(--bg);
|
|
}
|
|
.card-header-row {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 10px;
|
|
}
|
|
.card-header.has-banner .card-header-row {
|
|
padding: 0 14px;
|
|
margin-top: -26px;
|
|
}
|
|
.card-avatar {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
border: 2px solid var(--surface);
|
|
background: var(--bg);
|
|
flex-shrink: 0;
|
|
}
|
|
.card-avatar-fallback {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
color: var(--muted);
|
|
}
|
|
.card-identity { min-width: 0; padding-bottom: 3px; }
|
|
.card-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.card-handle { font-size: 12px; color: var(--muted); }
|
|
.verified-badge { color: var(--accent); font-weight: 700; font-size: 0.85em; margin-left: 3px; }
|
|
.card-bio {
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
color: var(--text);
|
|
opacity: 0.85;
|
|
line-height: 1.45;
|
|
}
|
|
.card-header.has-banner .card-bio { padding: 0 14px; }
|
|
|
|
/* Compact byline — tweets/replies (already lead with their own text a few
|
|
rows down) get just this instead of the full banner+bio header above,
|
|
which is reserved for records that ARE a user rather than a tweet
|
|
someone wrote. */
|
|
.card-byline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.card-byline .card-avatar { width: 26px; height: 26px; border-width: 1px; }
|
|
.card-byline .card-avatar-fallback { font-size: 11px; }
|
|
.card-byline .card-identity {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 6px;
|
|
padding-bottom: 0;
|
|
min-width: 0;
|
|
}
|
|
.card-byline .card-name { font-size: 13px; max-width: 55%; }
|
|
.card-byline .card-handle { flex-shrink: 0; }
|
|
|
|
.card-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 4px 0;
|
|
font-size: 13px;
|
|
border-bottom: 1px solid rgba(42,45,58,0.6);
|
|
}
|
|
.card-row:last-child { border-bottom: none; padding-bottom: 0; }
|
|
.card-row:first-child { padding-top: 0; }
|
|
|
|
.card-key {
|
|
color: var(--muted);
|
|
min-width: 100px;
|
|
flex-shrink: 0;
|
|
font-size: 11px;
|
|
padding-top: 2px;
|
|
font-weight: 500;
|
|
}
|
|
.card-val { color: var(--text); word-break: break-word; flex: 1; }
|
|
|
|
.empty-state {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
padding: 60px 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
.load-more-status {
|
|
text-align: center;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
padding: 14px 0 20px;
|
|
display: none;
|
|
}
|
|
.load-more-status.visible { display: block; }
|
|
.load-more-status .spin-dot {
|
|
display: inline-block;
|
|
width: 6px; height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
margin-right: 6px;
|
|
animation: lm-pulse 1s ease-in-out infinite;
|
|
}
|
|
@keyframes lm-pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
|
|
|
|
/* Media (gambar / video) */
|
|
.card-media {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-top: 10px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid rgba(42,45,58,0.6);
|
|
}
|
|
.media-item { position: relative; display: inline-block; }
|
|
.media-thumb {
|
|
display: block;
|
|
width: 130px;
|
|
height: 86px;
|
|
object-fit: cover;
|
|
border-radius: 5px;
|
|
border: 1px solid var(--border);
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.media-thumb:hover { opacity: 0.82; }
|
|
.media-badge {
|
|
position: absolute;
|
|
bottom: 5px;
|
|
left: 5px;
|
|
background: rgba(0,0,0,0.72);
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
pointer-events: none;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.media-video {
|
|
display: block;
|
|
width: 220px;
|
|
height: 140px;
|
|
object-fit: cover;
|
|
border-radius: 5px;
|
|
border: 1px solid var(--border);
|
|
background: #000;
|
|
}
|
|
|
|
/* Retweeted-content rows — subtle highlight */
|
|
.card-row.rt-origin .card-key { color: var(--accent); }
|
|
.card-row.rt-origin .card-val { color: #c7d2fe; }
|
|
|
|
/* "Replying to @x @y" — x.com trims these from the visible tweet body and
|
|
shows them as their own line instead; matches that here so the reply
|
|
text itself isn't misread as having been written with those mentions
|
|
inline. Underlying text value is untouched, this is display-only. */
|
|
.reply-context {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
/* Drill-down links on reply / retweet counts */
|
|
.drill-link {
|
|
color: var(--accent);
|
|
font-family: var(--font);
|
|
font-size: 13px;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
.drill-link:hover { color: #818cf8; }
|
|
|
|
/* Inline nested-reply threads — expand a reply's own replies in place,
|
|
recursively, instead of only linking out to a new tab. */
|
|
.reply-expand-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
margin-top: 10px;
|
|
background: none;
|
|
border: 1px solid var(--border);
|
|
color: var(--accent);
|
|
font-family: var(--font);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: 4px 10px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
}
|
|
.reply-expand-btn:hover:not(:disabled) { border-color: var(--accent); }
|
|
.reply-expand-btn:disabled { opacity: 0.5; cursor: wait; }
|
|
|
|
.reply-thread {
|
|
margin: 10px 0 2px 16px;
|
|
padding-left: 14px;
|
|
border-left: 2px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.reply-thread.hidden { display: none; }
|
|
|
|
.reply-load-more-btn {
|
|
align-self: flex-start;
|
|
background: none;
|
|
border: none;
|
|
color: var(--accent);
|
|
font-family: var(--font);
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
padding: 2px 0;
|
|
}
|
|
.reply-load-more-btn:disabled { opacity: 0.5; cursor: wait; text-decoration: none; }
|
|
|
|
/* View toggle (Cards / Map) */
|
|
.view-toggle {
|
|
display: flex;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
}
|
|
.view-btn {
|
|
padding: 5px 14px;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
border: none;
|
|
cursor: pointer;
|
|
font-family: var(--font);
|
|
font-size: 12px;
|
|
transition: all 0.15s;
|
|
}
|
|
.view-btn.active { background: var(--accent-bg); color: #818cf8; font-weight: 500; }
|
|
|
|
/* Leaflet map */
|
|
#mapContainer {
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border);
|
|
height: calc(100vh - 130px);
|
|
display: none;
|
|
}
|
|
.leaflet-container { background: #1a1d27; font-family: var(--font); }
|
|
.leaflet-popup-content-wrapper {
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
|
|
}
|
|
.leaflet-popup-tip { background: var(--surface); }
|
|
.leaflet-popup-content { font-family: var(--font); font-size: 12px; line-height: 1.5; margin: 10px 12px; }
|
|
.leaflet-control-layers,
|
|
.leaflet-control-zoom { background: var(--surface) !important; border: 1px solid var(--border) !important; }
|
|
.leaflet-control-layers-list { color: var(--text); font-size: 12px; padding: 2px 6px; }
|
|
.leaflet-bar a {
|
|
background: var(--surface) !important;
|
|
color: var(--text) !important;
|
|
border-color: var(--border) !important;
|
|
}
|
|
.leaflet-bar a:hover { background: var(--accent-bg) !important; color: #818cf8 !important; }
|
|
.leaflet-control-attribution { background: rgba(26,29,39,0.8) !important; color: var(--muted) !important; font-size: 9px; }
|
|
|
|
/* Active-case indicator (sidebar) */
|
|
.case-indicator {
|
|
margin-top: 7px; padding: 6px 10px;
|
|
background: var(--accent-bg); border: 1px solid var(--accent); border-radius: 6px;
|
|
font-size: 11px; color: var(--text); gap: 8px; align-items: center; display: none;
|
|
}
|
|
.case-indicator.visible { display: flex; }
|
|
.case-indicator-name {
|
|
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
color: var(--accent); font-weight: 500; color: #fff;
|
|
}
|
|
.case-checkpoint-btn {
|
|
width: 100%; padding: 8px; margin-top: 7px;
|
|
background: transparent; color: var(--accent);
|
|
border: 1px solid var(--accent); border-radius: 6px;
|
|
font-family: var(--font); font-size: 12px; font-weight: 500;
|
|
cursor: pointer; transition: all 0.15s; color: #fff;
|
|
}
|
|
.case-checkpoint-btn:hover:not(:disabled) { background: var(--accent-bg); }
|
|
.case-checkpoint-btn:disabled { opacity: 0.5; cursor: wait; }
|
|
.case-save-form {
|
|
margin-top: 7px; padding: 10px;
|
|
background: var(--accent-bg); border: 1px solid var(--accent); border-radius: 6px;
|
|
}
|
|
.case-save-form[hidden] { display: none; }
|
|
.case-save-input {
|
|
width: 100%; padding: 6px 9px; margin-bottom: 8px;
|
|
background: var(--bg); border: 1px solid var(--border); border-radius: 5px;
|
|
color: var(--text); font-family: var(--font); font-size: 12px;
|
|
}
|
|
.case-save-input:focus { outline: none; border-color: var(--accent); }
|
|
.case-save-input::placeholder { color: var(--muted); }
|
|
.case-save-btns { display: flex; gap: 6px; }
|
|
.case-save-confirm {
|
|
flex: 1; padding: 5px 0; font-size: 12px; border-radius: 5px;
|
|
background: var(--accent); color: #fff; border: none;
|
|
font-family: var(--font); font-weight: 500; cursor: pointer; transition: opacity 0.15s;
|
|
}
|
|
.case-save-confirm:disabled { opacity: 0.45; cursor: wait; }
|
|
.case-save-confirm:not(:disabled):hover { opacity: 0.85; }
|
|
.case-save-cancel {
|
|
padding: 5px 10px; font-size: 12px; border-radius: 5px;
|
|
background: transparent; color: var(--muted); border: 1px solid var(--border);
|
|
font-family: var(--font); cursor: pointer; transition: all 0.12s;
|
|
}
|
|
.case-save-cancel:hover { color: var(--text); border-color: var(--muted); }
|
|
</style>
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin="anonymous"></script>
|
|
<script src="{{ url_for('static', filename='js/card_constants.js') }}" nonce="{{ g.csp_nonce }}"></script>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<h1>Jieyab89 SOCMINT X</h1>
|
|
<span class="sep">|</span>
|
|
<span class="sub">Retrieve Twitter Data With Many Data Source and Sentiment Analysis</span>
|
|
<button id="navToggle" class="hamburger-btn" aria-label="Navigation menu">☰</button>
|
|
<nav id="navMenu" class="nav-menu hidden">
|
|
<a href="/" class="current">Home</a>
|
|
<a href="/graph">Graph</a>
|
|
<hr class="nav-divider">
|
|
<a href="/archives">Archives</a>
|
|
<a href="/analytics">Analytics</a>
|
|
<hr class="nav-divider">
|
|
<a href="/cases">Cases</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<!-- Legend / help popup (floats over the page, bottom-left) -->
|
|
<button id="helpToggle" class="help-toggle" aria-label="Legend / help" title="Legend / help">?</button>
|
|
<button id="backToTop" class="back-to-top" aria-label="Back to top" title="Back to top">↑</button>
|
|
<div id="helpPopup" class="help-popup hidden">
|
|
<h4>Source</h4>
|
|
<div class="help-row"><span class="source-badge src-cookie">Twitter Cookie</span> Live via your cookie session</div>
|
|
<div class="help-row"><span class="source-badge src-xquik">Xquik API</span> Live via the xquik API</div>
|
|
<div class="help-row"><span class="source-badge src-wayback">Wayback Machine</span> Archived snapshot</div>
|
|
<div class="help-row"><span class="source-badge src-cse">Google CSE</span> Live web result via Google Custom Search</div>
|
|
<h4>Content type — Google CSE / Wayback only</h4>
|
|
<div class="help-row"><span class="content-type-badge ct-tweet">Tweet</span> A specific tweet permalink</div>
|
|
<div class="help-row"><span class="content-type-badge ct-profile">Profile page</span> A bare account profile, not a specific post</div>
|
|
<div class="help-row"><span class="content-type-badge ct-twitter_other">Other X/Twitter page</span> Some other X page</div>
|
|
<div class="help-row"><span class="content-type-badge ct-other">External page (non-X)</span> A site off X entirely (Google CSE only)</div>
|
|
<h4>Account age</h4>
|
|
<div class="help-row"><span class="age-badge age-new">New account</span> Created < 30 days ago</div>
|
|
<div class="help-row"><span class="age-badge age-recent">Recent account</span> Created < 1 year ago</div>
|
|
<div class="help-row"><span class="age-badge age-established">Established account</span> Created 1+ year ago</div>
|
|
<div class="help-note">
|
|
Derived straight from the account's numeric ID — no external lookup.
|
|
A tilde (~) means the id predates X's Snowflake ID scheme, so the date
|
|
is an estimate, not an exact decode. "Unknown" means the id falls in a
|
|
gap we have no reliable way to date at all.
|
|
</div>
|
|
<h4>What each parameter means</h4>
|
|
{% include "_field_glossary.html" %}
|
|
</div>
|
|
|
|
<div class="layout">
|
|
<div class="panel">
|
|
|
|
<div class="field">
|
|
<label>Tool</label>
|
|
<select id="toolType">
|
|
<option value="tweet_search_extractor">Tweet Search Extractor</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 Post Extractor</option>
|
|
<option value="tweet_replies_extractor">Tweet Replies Extractor [Cookie]</option>
|
|
<option value="tweet_retweeters_extractor">Tweet Retweeters Extractor [Cookie]</option>
|
|
<option value="geo_post_extractor">Geo Post Extractor [Cookie]</option>
|
|
<option value="wayback_archive_search">Wayback Archive Search</option>
|
|
<option value="multi_source_search">Multi Source Search [All]</option>
|
|
</select>
|
|
<div id="toolHint" class="tool-hint"></div>
|
|
</div>
|
|
|
|
<hr class="divider">
|
|
|
|
<div id="dynamicFields"></div>
|
|
|
|
<button class="btn-run" id="runBtn">Run</button>
|
|
<button id="caseCheckpointBtn" class="case-checkpoint-btn">Save Case</button>
|
|
<div id="caseSaveForm" class="case-save-form" hidden>
|
|
<input id="caseSaveName" class="case-save-input" type="text" placeholder="Case name…" maxlength="120" autocomplete="off" spellcheck="false">
|
|
<div class="case-save-btns">
|
|
<button id="caseSaveConfirm" class="case-save-confirm">Save</button>
|
|
<button id="caseSaveCancel" class="case-save-cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
<div id="caseIndicator" class="case-indicator">
|
|
<span class="case-indicator-name" id="caseIndicatorName"></span>
|
|
</div>
|
|
|
|
<div id="archiveBar" class="archive-bar" style="display:none"></div>
|
|
|
|
<!-- Recent archives -->
|
|
<div class="archives-section" id="archivesSection" style="display:none">
|
|
<div class="archives-label">Recent Archives</div>
|
|
<div id="archivesList"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="output">
|
|
<div class="output-top">
|
|
<div id="statusBadge" class="badge">idle</div>
|
|
<input type="text" class="search-bar" id="searchInput" placeholder="Search results..." style="display:none">
|
|
<div class="result-count" id="resultCount"></div>
|
|
<div id="viewToggle" class="view-toggle" style="display:none">
|
|
<button class="view-btn active" data-view="cards">Cards</button>
|
|
<button class="view-btn" data-view="map">Map</button>
|
|
</div>
|
|
<button class="btn-download" id="archiveBtn" style="display:none">Archive</button>
|
|
<button class="btn-download" id="downloadBtn" style="display:none">↓ JSON</button>
|
|
</div>
|
|
<div id="resultBox">
|
|
<div class="empty-state">Nothing found :(</div>
|
|
</div>
|
|
<div id="loadMoreSentinel" style="height:1px"></div>
|
|
<div id="loadMoreStatus" class="load-more-status"></div>
|
|
<div id="mapStatus" style="display:none;font-size:12px;color:var(--muted);padding:6px 2px"></div>
|
|
<div id="mapContainer"></div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<script nonce="{{ g.csp_nonce }}">
|
|
// Hamburger nav
|
|
(function() {
|
|
var btn = document.getElementById('navToggle');
|
|
var menu = document.getElementById('navMenu');
|
|
btn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
menu.classList.toggle('hidden');
|
|
});
|
|
document.addEventListener('click', function() { menu.classList.add('hidden'); });
|
|
menu.addEventListener('click', function(e) { e.stopPropagation(); });
|
|
}());
|
|
|
|
// Help popup
|
|
(function() {
|
|
var btn = document.getElementById('helpToggle');
|
|
var popup = document.getElementById('helpPopup');
|
|
btn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
popup.classList.toggle('hidden');
|
|
});
|
|
document.addEventListener('click', function() { popup.classList.add('hidden'); });
|
|
popup.addEventListener('click', function(e) { e.stopPropagation(); });
|
|
}());
|
|
|
|
// Back to top — the page (not an inner pane) is what actually scrolls here,
|
|
// same as the load-more sentinel further down relies on.
|
|
(function() {
|
|
var btn = document.getElementById('backToTop');
|
|
window.addEventListener('scroll', function() {
|
|
btn.classList.toggle('visible', window.scrollY > 400);
|
|
});
|
|
btn.addEventListener('click', function() {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
});
|
|
}());
|
|
|
|
const toolType = document.getElementById('toolType');
|
|
const dynFields = document.getElementById('dynamicFields');
|
|
const runBtn = document.getElementById('runBtn');
|
|
const statusBadge = document.getElementById('statusBadge');
|
|
const resultBox = document.getElementById('resultBox');
|
|
const searchInput = document.getElementById('searchInput');
|
|
const resultCount = document.getElementById('resultCount');
|
|
const toolHint = document.getElementById('toolHint');
|
|
const downloadBtn = document.getElementById('downloadBtn');
|
|
const archiveBtn = document.getElementById('archiveBtn');
|
|
const archiveBar = document.getElementById('archiveBar');
|
|
const archivesSection= document.getElementById('archivesSection');
|
|
const archivesList = document.getElementById('archivesList');
|
|
const loadMoreSentinel = document.getElementById('loadMoreSentinel');
|
|
const loadMoreStatus = document.getElementById('loadMoreStatus');
|
|
|
|
let currentMode = 'api';
|
|
let currentData = null;
|
|
let currentPayload = null; // last run payload (for archive queryInfo)
|
|
// Archive id for the CURRENT run's data — same model as graph.html's
|
|
// graphArchivedId: null until the Archive button is clicked once (creates),
|
|
// then every later click (after scrolling/expanding loads more) checkpoints
|
|
// that same archive in place instead of creating a new one each time. Reset
|
|
// on every new Run, same as graph.html's clearGraph() resets its own.
|
|
let archivedId = null;
|
|
|
|
// ── Card render virtual scroll ───────────────────────────────────────────────
|
|
const CARD_BATCH = 15;
|
|
let _cardItems = [];
|
|
let _cardOffset = 0;
|
|
let _cardObserver = null;
|
|
|
|
function _flushCardObserver() {
|
|
if (_cardObserver) { _cardObserver.disconnect(); _cardObserver = null; }
|
|
}
|
|
|
|
function _loadCardBatch() {
|
|
if (_cardOffset >= _cardItems.length) {
|
|
_flushCardObserver();
|
|
const s = resultBox.querySelector('#_crs');
|
|
if (s) s.remove();
|
|
return;
|
|
}
|
|
const batch = _cardItems.slice(_cardOffset, _cardOffset + CARD_BATCH);
|
|
_cardOffset += batch.length;
|
|
const grid = resultBox.querySelector('.cards-grid');
|
|
if (grid) grid.insertAdjacentHTML('beforeend', batch.map(buildCard).join(''));
|
|
}
|
|
|
|
// ── Scroll-triggered load-more ──────────────────────────────────────────────
|
|
// Tools/modes the backend actually paginates — everything else (plain xquik/API
|
|
// mode calls, article_extractor) just gets a single page.
|
|
const PAGINATED_TOOLS = new Set([
|
|
'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',
|
|
]);
|
|
const THROTTLE_SECONDS = 5;
|
|
const SOURCE_LABEL_MAP = { cookie: 'Twitter Cookie', xquik: 'Xquik API', wayback: 'Wayback Machine', cse: 'Google CSE' };
|
|
|
|
let nextCursor = null;
|
|
let loadingMore = false;
|
|
let lastLoadFailed = false; // set by a failed load-more; only maybeAutoContinue() checks it — a manual scroll retry (maybeLoadMore) ignores it entirely
|
|
let cooldownUntil = { cookie: 0, wayback: 0 }; // Date.now()-based timestamps
|
|
let 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 a load-more page for 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'];
|
|
if (mode === 'cookie' && PAGINATED_TOOLS.has(tool)) return ['cookie'];
|
|
return [];
|
|
}
|
|
|
|
const HINTS = {
|
|
tweet_search_extractor: 'Search for tweets by keyword',
|
|
follower_explorer: 'List followers of an account',
|
|
following_explorer: 'List accounts a user follows — cookie only',
|
|
post_extractor: 'Tweet timeline from an account',
|
|
article_extractor: 'Content of an X article from its tweet URL',
|
|
community_post_extractor: 'Posts from an X Community',
|
|
tweet_replies_extractor: 'Replies / comments on a tweet — cookie only',
|
|
tweet_retweeters_extractor:'Users who retweeted a tweet — cookie only',
|
|
geo_post_extractor: 'Search tweets by keyword, then plot each author\'s profile location on a map — cookie only',
|
|
wayback_archive_search: 'Find archived snapshots of an X/Twitter profile or tweet via the Wayback Machine — great for recovering deleted content',
|
|
multi_source_search: 'Searches Cookie, Xquik API, the Wayback Machine and Google CSE at once and merges the results — each card is labeled with its source. Accepts a keyword, mention, username, or a full URL. Wayback matches a handle or URL directly; Cookie/Xquik treat it as search text; Google CSE runs it as a plain web search (results depend on how the Custom Search Engine itself is scoped). Date range below is optional and only narrows Cookie/Xquik/Wayback — Google CSE has no date-range query syntax.',
|
|
};
|
|
|
|
// These tools only work via cookie; API mode is disabled for them
|
|
const COOKIE_ONLY = new Set([
|
|
'tweet_replies_extractor',
|
|
'tweet_retweeters_extractor',
|
|
'geo_post_extractor',
|
|
'following_explorer',
|
|
]);
|
|
|
|
// These tools don't use the single Mode toggle: wayback needs no auth at all,
|
|
// multi_source_search always fans out across every mode simultaneously.
|
|
const NO_AUTH = new Set([
|
|
'wayback_archive_search',
|
|
'multi_source_search',
|
|
]);
|
|
|
|
const HAS_COUNT = new Set([
|
|
'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',
|
|
]);
|
|
|
|
function modeHintText() {
|
|
return currentMode === 'cookie'
|
|
? 'Fetches directly from X.com using your cookie session — no xquik quota used.'
|
|
: 'Uses your xquik API key quota.';
|
|
}
|
|
|
|
function renderFields() {
|
|
const t = toolType.value;
|
|
toolHint.textContent = HINTS[t] || '';
|
|
|
|
// Cookie-only tools always run in cookie mode
|
|
if (COOKIE_ONLY.has(t) && currentMode !== 'cookie') currentMode = 'cookie';
|
|
|
|
const apiDisabled = COOKIE_ONLY.has(t) ? 'disabled style="opacity:0.35;cursor:not-allowed"' : '';
|
|
|
|
// Mode toggle — shown for every tool except the ones needing no auth at all
|
|
let html = '';
|
|
if (!NO_AUTH.has(t)) {
|
|
html += `
|
|
<div class="field">
|
|
<label>Mode</label>
|
|
<div class="mode-toggle" id="modeToggle">
|
|
<button type="button" data-mode="api" class="${currentMode === 'api' ? 'active' : ''}" ${apiDisabled}>xquik API</button>
|
|
<button type="button" data-mode="cookie" class="${currentMode === 'cookie' ? 'active' : ''}">Cookie</button>
|
|
</div>
|
|
<div class="mode-hint" id="modeHint">${modeHintText()}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Tool-specific input fields
|
|
const DATE_FIELDS = () => field('dateFrom', 'From date (YYYYMMDD, optional)', '20200101') +
|
|
field('dateTo', 'To date (YYYYMMDD, optional)', '20231231');
|
|
if (t === 'tweet_search_extractor') {
|
|
html += field('searchQuery', 'Search query', 'bitcoin');
|
|
html += DATE_FIELDS();
|
|
} else if (t === 'follower_explorer' || t === 'following_explorer') {
|
|
html += field('targetUsername', 'Username or User ID', 'elonmusk or 44196397');
|
|
html += DATE_FIELDS();
|
|
} else if (t === 'article_extractor') {
|
|
html += field('targetTweetId', 'Tweet ID', '1234567890');
|
|
} else if (t === 'community_post_extractor') {
|
|
html += field('targetCommunityId', 'Community ID', '1234567890');
|
|
html += DATE_FIELDS();
|
|
} else if (t === 'post_extractor') {
|
|
html += field('targetUsername', 'Username or User ID', 'elonmusk or 44196397');
|
|
html += DATE_FIELDS();
|
|
} else if (t === 'tweet_replies_extractor' || t === 'tweet_retweeters_extractor') {
|
|
html += field('targetTweetId', 'Tweet ID', '1234567890');
|
|
html += DATE_FIELDS();
|
|
} else if (t === 'geo_post_extractor') {
|
|
html += field('searchQuery', 'Search query', 'kopi jakarta');
|
|
html += DATE_FIELDS();
|
|
} else if (t === 'wayback_archive_search') {
|
|
html += field('searchQuery', 'Username or tweet URL', 'elonmusk or https://x.com/user/status/123');
|
|
html += field('waybackFrom', 'From date (YYYYMMDD, optional)', '20200101');
|
|
html += field('waybackTo', 'To date (YYYYMMDD, optional)', '20231231');
|
|
} else if (t === 'multi_source_search') {
|
|
html += field('searchQuery', 'Keyword, mention, username or URL', 'elonmusk');
|
|
html += DATE_FIELDS();
|
|
}
|
|
|
|
// Count — for tools that support it, visible in cookie mode (or for no-auth tools)
|
|
if (HAS_COUNT.has(t)) {
|
|
const showCount = NO_AUTH.has(t) || currentMode === 'cookie';
|
|
const countLabels = {
|
|
wayback_archive_search: 'Max snapshots',
|
|
multi_source_search: 'Result count per source (max 2000)',
|
|
};
|
|
html += `
|
|
<div class="field" id="countWrap" style="${showCount ? '' : 'display:none'}">
|
|
<label>${countLabels[t] || 'Result count (max 2000)'}</label>
|
|
<input id="countInput" type="number" value="${NO_AUTH.has(t) ? 50 : 20}" min="1" max="2000">
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
dynFields.innerHTML = html;
|
|
attachModeListeners();
|
|
}
|
|
|
|
function field(id, lbl, ph) {
|
|
return `<div class="field"><label>${lbl}</label><input id="${id}" type="text" placeholder="${ph}"></div>`;
|
|
}
|
|
|
|
function attachModeListeners() {
|
|
dynFields.querySelectorAll('#modeToggle button').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
dynFields.querySelectorAll('#modeToggle button').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
currentMode = btn.dataset.mode;
|
|
|
|
const hint = document.getElementById('modeHint');
|
|
if (hint) hint.textContent = modeHintText();
|
|
|
|
const countWrap = document.getElementById('countWrap');
|
|
if (countWrap) countWrap.style.display = currentMode === 'cookie' ? '' : 'none';
|
|
});
|
|
});
|
|
}
|
|
|
|
toolType.addEventListener('change', renderFields);
|
|
renderFields();
|
|
loadRecentArchives();
|
|
|
|
document.getElementById('viewToggle').addEventListener('click', e => {
|
|
const btn = e.target.closest('[data-view]');
|
|
if (btn) setView(btn.dataset.view);
|
|
});
|
|
|
|
// ── Search / filter results ───────────────────────────────────────────────────
|
|
|
|
let _searchDebounce = null;
|
|
searchInput.addEventListener('input', () => {
|
|
clearTimeout(_searchDebounce);
|
|
_searchDebounce = setTimeout(() => renderCards(currentData, searchInput.value), 180);
|
|
});
|
|
|
|
const _ftCache = new WeakMap();
|
|
function flatText(obj) {
|
|
if (obj === null || obj === undefined) return '';
|
|
if (typeof obj !== 'object') return String(obj);
|
|
let c = _ftCache.get(obj);
|
|
if (c !== undefined) return c;
|
|
c = Object.values(obj).map(flatText).join(' ');
|
|
_ftCache.set(obj, c);
|
|
return c;
|
|
}
|
|
|
|
// PRIORITY / DRILLABLE / SOURCE_CLASS / AGE_LABELS come from
|
|
// static/js/card_constants.js, loaded above — shared with archive.html.
|
|
const SKIP = ['profile_image_url','profile_banner_url','entities','extended_entities','urls','media','indices'];
|
|
const CLAMP = new Set();
|
|
|
|
// ── Media helpers ─────────────────────────────────────────────────────────────
|
|
|
|
function extractMedia(item) {
|
|
// Cookie mode: item.media = [{type, thumb, url}]
|
|
if (Array.isArray(item.media) && item.media.length) {
|
|
const 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[]
|
|
const src = (item.extended_entities && item.extended_entities.media)
|
|
|| (item.entities && item.entities.media);
|
|
if (!Array.isArray(src)) return [];
|
|
return src.map(m => {
|
|
const mtype = m.type || 'photo';
|
|
const thumb = m.media_url_https || m.media_url || '';
|
|
let url = thumb;
|
|
if (mtype === 'video' || mtype === 'animated_gif') {
|
|
const variants = (m.video_info && m.video_info.variants) || [];
|
|
const mp4s = variants.filter(v => v.content_type === 'video/mp4');
|
|
if (mp4s.length) url = mp4s.reduce((b, v) => (v.bitrate||0) > (b.bitrate||0) ? v : b).url;
|
|
}
|
|
return { type: mtype, thumb, url };
|
|
}).filter(m => m.thumb);
|
|
}
|
|
|
|
function renderMedia(mediaList) {
|
|
if (!mediaList || !mediaList.length) return '';
|
|
const html = mediaList.map(m => {
|
|
if (m.type === 'video' || m.type === 'animated_gif') {
|
|
// Route through the backend proxy to avoid Twitter CDN CORS restrictions
|
|
const proxied = `/api/video?url=${encodeURIComponent(m.url)}`;
|
|
const loop = m.type === 'animated_gif' ? 'loop muted' : '';
|
|
const poster = m.thumb ? `poster="${esc(m.thumb)}"` : '';
|
|
const label = m.type === 'animated_gif' ? '<span class="media-badge">GIF</span>' : '';
|
|
return `<div class="media-item">
|
|
<video class="media-video" controls ${loop} ${poster} preload="none">
|
|
<source src="${proxied}" type="video/mp4">
|
|
</video>${label}
|
|
</div>`;
|
|
}
|
|
// photo — click to open full-res in new tab
|
|
return `<a href="${esc(m.url)}" target="_blank" rel="noopener" class="media-item">
|
|
<img src="${esc(m.thumb)}" class="media-thumb" alt="media" loading="lazy">
|
|
</a>`;
|
|
}).join('');
|
|
return `<div class="card-media">${html}</div>`;
|
|
}
|
|
|
|
function renderCards(data, query = '') {
|
|
_flushCardObserver();
|
|
if (!data) {
|
|
resultBox.innerHTML = '<div class="empty-state">No results yet.</div>';
|
|
return;
|
|
}
|
|
const items = Array.isArray(data) ? data : [data];
|
|
const q = query.toLowerCase().trim();
|
|
_cardItems = q ? items.filter(i => flatText(i).toLowerCase().includes(q)) : items;
|
|
_cardOffset = 0;
|
|
|
|
resultCount.textContent = _cardItems.length + (q ? ' found' : ' results');
|
|
|
|
if (!_cardItems.length) {
|
|
resultBox.innerHTML = '<div class="empty-state">No matching results.</div>';
|
|
return;
|
|
}
|
|
|
|
resultBox.innerHTML = '<div class="cards-grid"></div><div id="_crs" style="height:4px"></div>';
|
|
_loadCardBatch();
|
|
|
|
const sentinel = resultBox.querySelector('#_crs');
|
|
if (sentinel) {
|
|
_cardObserver = new IntersectionObserver(entries => {
|
|
if (entries[0].isIntersecting) _loadCardBatch();
|
|
}, { rootMargin: '300px' });
|
|
_cardObserver.observe(sentinel);
|
|
}
|
|
}
|
|
|
|
// 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 the text RENDERS here,
|
|
// never item.text itself, so JSON download / archive still get the
|
|
// complete raw string untouched. mentions.length is walked in order and
|
|
// stops at the first one that doesn't match — a data mismatch just leaves
|
|
// the rest of the prefix visible instead of risking mangled text.
|
|
function stripLeadingMentions(text, mentions) {
|
|
if (!text || !mentions || !mentions.length) return text;
|
|
let rest = text;
|
|
for (const m of mentions) {
|
|
const re = new RegExp('^\\s*@' + String(m).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
|
|
const match = rest.match(re);
|
|
if (!match) break;
|
|
rest = rest.slice(match[0].length);
|
|
}
|
|
rest = rest.replace(/^\s+/, '');
|
|
return rest || text;
|
|
}
|
|
|
|
// buildCardHeader() / isSafeImageUrl() come from static/js/card_constants.js
|
|
// — shared with archive.html so avatar/name/handle/banner/bio render
|
|
// identically whether you're looking at a live result or a saved archive.
|
|
function buildCard(item, nested = false) {
|
|
if (typeof item !== 'object' || item === null) {
|
|
return `<div class="card"><div class="card-row"><div class="card-val">${esc(String(item))}</div></div></div>`;
|
|
}
|
|
const tweetId = item.id ? String(item.id) : null;
|
|
const entries = Object.entries(item);
|
|
const { html: header, usedFields: headerFields } = buildCardHeader(item);
|
|
// Order follows PRIORITY's own sequence (not object insertion order), so
|
|
// field position is deterministic and consistent across every tool/record
|
|
// shape. No cap on row count — this is an OSINT/analysis tool, so more
|
|
// extracted metadata is the point, not something to hide past a limit
|
|
// (this is what silently dropped the reply_count/retweet_count drill-down
|
|
// anchors once enough other fields — account age, verification, etc. —
|
|
// were present on a record).
|
|
const priKeys = new Set(entries.map(([k]) => k).filter(k => PRIORITY.includes(k)));
|
|
const pri = PRIORITY.filter(k => priKeys.has(k) && !headerFields.includes(k)).map(k => [k, item[k]]);
|
|
const rest = entries.filter(([k]) => !PRIORITY.includes(k) && !SKIP.includes(k) && !headerFields.includes(k));
|
|
const rows = [...pri, ...rest].map(([k, v]) => {
|
|
let display;
|
|
// Drillable check runs FIRST — reply_count / retweet_count always get a link
|
|
// when we have a tweet ID, even if the count is 0 or null (twikit often under-reports)
|
|
if (DRILLABLE[k] && tweetId) {
|
|
const _p = new URLSearchParams({ tool: DRILLABLE[k], id: tweetId, autorun: '1' });
|
|
const lbl = (v !== null && v !== undefined) ? esc(String(v)) : '—';
|
|
display = `<a class="drill-link" href="/?${_p}" target="_blank" rel="noopener noreferrer">${lbl} ↗</a>`;
|
|
} else if ((k === 'tweet_url' || k === 'archive_url' || k === 'result_url' || k === 'preview_image') && v) {
|
|
// Full raw URL shown as the link text itself (not hidden behind a generic
|
|
// label) — text-intel use needs the actual address visible/copyable, same
|
|
// treatment archive.html already gives every link field, kept consistent here.
|
|
display = `<a class="drill-link" href="${esc(String(v))}" target="_blank" rel="noopener noreferrer">${esc(String(v))}</a>`;
|
|
} else if (k === 'source' && v) {
|
|
const cls = SOURCE_CLASS[v] || '';
|
|
display = `<span class="source-badge ${cls}">${esc(String(v))}</span>`;
|
|
} else if (k === 'account_age_flag' && v) {
|
|
display = `<span class="age-badge age-${esc(String(v))}">${esc(AGE_LABELS[v] || v)}</span>`;
|
|
} else if (k === 'content_type' && v) {
|
|
display = `<span class="content-type-badge ct-${esc(String(v))}">${esc(CONTENT_TYPE_LABELS[v] || String(v))}</span>`;
|
|
} else if (k === 'text' && item.reply_to_mentions && item.reply_to_mentions.length) {
|
|
const badge = `<div class="reply-context">↩ Replying to ${item.reply_to_mentions.map(m => '@' + esc(m)).join(', ')}</div>`;
|
|
const clean = stripLeadingMentions(String(v), item.reply_to_mentions);
|
|
display = `${badge}<span>${esc(clean)}</span>`;
|
|
} else if (v === null || v === undefined) {
|
|
display = `<span style="color:var(--muted)">—</span>`;
|
|
} else if (typeof v === 'object') {
|
|
const s = JSON.stringify(v, null, 2);
|
|
display = `<span style="color:var(--muted);font-size:11px;white-space:pre-wrap">${esc(s)}</span>`;
|
|
} else {
|
|
const cls = CLAMP.has(k) ? ' clamp' : '';
|
|
display = `<span class="${cls}">${esc(String(v))}</span>`;
|
|
}
|
|
const rtCls = (k.startsWith('retweeted_') || k.startsWith('quoted_')) ? ' rt-origin' : '';
|
|
return `<div class="card-row${rtCls}"><div class="card-key">${esc(k)}</div><div class="card-val">${display}</div></div>`;
|
|
}).join('');
|
|
const media = extractMedia(item);
|
|
|
|
// Any tweet-shaped item with replies of its own can be expanded in place —
|
|
// same tweet_replies_extractor call graph.html's node-expand uses, applies
|
|
// uniformly across every tool's cards (search results, timeline, replies
|
|
// themselves, ...) since it only keys off id + reply_count, not the tool
|
|
// that produced the card. A reply rendered here recursively gets its own
|
|
// Expand button too, since it's built by this same function.
|
|
const replyCount = Number(item.reply_count);
|
|
const canExpandReplies = tweetId && Number.isFinite(replyCount) && replyCount > 0;
|
|
const expandBtn = canExpandReplies
|
|
? `<button type="button" class="reply-expand-btn" data-tweet-id="${esc(tweetId)}" data-count="${replyCount}">↩ Expand ${replyCount} repl${replyCount === 1 ? 'y' : 'ies'}</button>`
|
|
: '';
|
|
|
|
const cardCls = nested ? 'card reply-card' : 'card';
|
|
const card = `<div class="${cardCls}">${header}${rows}${renderMedia(media)}${expandBtn}</div>`;
|
|
if (!canExpandReplies) return card;
|
|
|
|
return `<div class="card-thread-wrap">${card}<div class="reply-thread hidden" data-tweet-id="${esc(tweetId)}"></div></div>`;
|
|
}
|
|
|
|
// ── Load more (scroll-triggered pagination) ─────────────────────────────────
|
|
// loadMoreSentinel lives as a SIBLING of #resultBox (API-pagination sentinel).
|
|
// The card-render sentinel #_crs lives inside #resultBox and is intentionally
|
|
// recreated on each renderCards() call after disconnecting the old observer.
|
|
|
|
function appendCards(newItems, query = '') {
|
|
const q = query.toLowerCase().trim();
|
|
const toShow = q ? newItems.filter(i => flatText(i).toLowerCase().includes(q)) : newItems;
|
|
const grid = resultBox.querySelector('.cards-grid');
|
|
if (grid && toShow.length) {
|
|
grid.insertAdjacentHTML('beforeend', toShow.map(buildCard).join(''));
|
|
}
|
|
// Excludes .reply-card — nested replies rendered inline via expand threads
|
|
// aren't part of "the results," same reasoning renderCards' own count
|
|
// (items.length, never a DOM query) already never counted them either.
|
|
const totalShown = resultBox.querySelectorAll('.card:not(.reply-card)').length;
|
|
resultCount.textContent = totalShown + (q ? ' found' : ' results');
|
|
}
|
|
|
|
// ── Inline nested-reply threads ─────────────────────────────────────────────
|
|
// Delegated once at the container level — buildCard() renders replies
|
|
// recursively via itself, so an expand button can appear at any nesting
|
|
// depth; delegation catches all of them without per-card listener wiring.
|
|
resultBox.addEventListener('click', (e) => {
|
|
const expandBtn = e.target.closest('.reply-expand-btn');
|
|
if (expandBtn) { toggleReplyThread(expandBtn); return; }
|
|
const moreBtn = e.target.closest('.reply-load-more-btn');
|
|
if (moreBtn) { loadMoreReplies(moreBtn); }
|
|
});
|
|
|
|
function replyBtnLabel(count) {
|
|
return `↩ Expand ${count} repl${count === 1 ? 'y' : 'ies'}`;
|
|
}
|
|
|
|
async function toggleReplyThread(btn) {
|
|
const wrap = btn.closest('.card-thread-wrap');
|
|
const thread = wrap.querySelector(':scope > .reply-thread');
|
|
|
|
// Already fetched once — just show/hide, no new request.
|
|
if (thread.dataset.loaded === '1') {
|
|
const nowHidden = thread.classList.toggle('hidden');
|
|
btn.textContent = nowHidden ? replyBtnLabel(Number(btn.dataset.count)) : '▲ Hide replies';
|
|
return;
|
|
}
|
|
|
|
const tweetId = btn.dataset.tweetId;
|
|
const remaining = cooldownUntil.cookie - Date.now();
|
|
if (remaining > 0) {
|
|
armReplyCountdown(btn, remaining, () => replyBtnLabel(Number(btn.dataset.count)));
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Loading…';
|
|
|
|
try {
|
|
const res = await fetch('/api/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ toolType: 'tweet_replies_extractor', mode: 'cookie', count: 20, targetTweetId: tweetId }),
|
|
});
|
|
const json = await res.json();
|
|
stampCooldown(['cookie']);
|
|
|
|
if (!json.ok) {
|
|
if (res.status === 429 && json.retryAfter) {
|
|
armReplyCountdown(btn, json.retryAfter * 1000, () => replyBtnLabel(Number(btn.dataset.count)));
|
|
return;
|
|
}
|
|
btn.disabled = false;
|
|
btn.textContent = 'Failed — retry';
|
|
return;
|
|
}
|
|
|
|
const items = Array.isArray(json.data) ? json.data : [json.data];
|
|
thread.innerHTML = items.map(it => buildCard(it, true)).join('');
|
|
thread.dataset.loaded = '1';
|
|
if (json.nextCursor) {
|
|
thread.dataset.cursor = json.nextCursor;
|
|
thread.insertAdjacentHTML('beforeend',
|
|
`<button type="button" class="reply-load-more-btn" data-tweet-id="${esc(tweetId)}">Load more replies…</button>`);
|
|
}
|
|
thread.classList.remove('hidden');
|
|
btn.disabled = !items.length;
|
|
btn.textContent = items.length ? '▲ Hide replies' : 'No replies found';
|
|
|
|
// Fold newly-discovered replies into the same dataset the JSON download
|
|
// and Archive button both read from — otherwise data pulled via inline
|
|
// expand would silently be excluded from both.
|
|
if (items.length) {
|
|
currentData = (Array.isArray(currentData) ? currentData : [currentData]).concat(items);
|
|
}
|
|
} catch (err) {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Failed — retry';
|
|
}
|
|
}
|
|
|
|
async function loadMoreReplies(moreBtn) {
|
|
const thread = moreBtn.closest('.reply-thread');
|
|
const tweetId = moreBtn.dataset.tweetId;
|
|
const cursor = thread.dataset.cursor;
|
|
if (!cursor) { moreBtn.remove(); return; }
|
|
|
|
const remaining = cooldownUntil.cookie - Date.now();
|
|
if (remaining > 0) {
|
|
armReplyCountdown(moreBtn, remaining, () => 'Load more replies…');
|
|
return;
|
|
}
|
|
|
|
moreBtn.disabled = true;
|
|
moreBtn.textContent = 'Loading…';
|
|
|
|
try {
|
|
const res = await fetch('/api/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ toolType: 'tweet_replies_extractor', mode: 'cookie', count: 20, targetTweetId: tweetId, cursor }),
|
|
});
|
|
const json = await res.json();
|
|
stampCooldown(['cookie']);
|
|
|
|
if (!json.ok) {
|
|
if (res.status === 429 && json.retryAfter) {
|
|
armReplyCountdown(moreBtn, json.retryAfter * 1000, () => 'Load more replies…');
|
|
return;
|
|
}
|
|
moreBtn.disabled = false;
|
|
moreBtn.textContent = 'Failed — retry';
|
|
return;
|
|
}
|
|
|
|
const items = Array.isArray(json.data) ? json.data : [json.data];
|
|
moreBtn.insertAdjacentHTML('beforebegin', items.map(it => buildCard(it, true)).join(''));
|
|
if (json.nextCursor) {
|
|
thread.dataset.cursor = json.nextCursor;
|
|
moreBtn.disabled = false;
|
|
moreBtn.textContent = 'Load more replies…';
|
|
} else {
|
|
moreBtn.remove();
|
|
}
|
|
|
|
if (items.length) {
|
|
currentData = (Array.isArray(currentData) ? currentData : [currentData]).concat(items);
|
|
}
|
|
} catch (err) {
|
|
moreBtn.disabled = false;
|
|
moreBtn.textContent = 'Failed — retry';
|
|
}
|
|
}
|
|
|
|
// Shared by both reply-expand entry points above — mirrors armCountdown()
|
|
// but scoped to a single button instead of the page-wide loadMoreStatus bar,
|
|
// since several threads can be mid-cooldown independently of each other.
|
|
function armReplyCountdown(btn, msRemaining, resetLabel) {
|
|
const secs = Math.max(1, Math.ceil(msRemaining / 1000));
|
|
btn.disabled = true;
|
|
btn.textContent = `Wait ${secs}s…`;
|
|
setTimeout(() => {
|
|
btn.disabled = false;
|
|
btn.textContent = resetLabel();
|
|
}, msRemaining);
|
|
}
|
|
|
|
function resetPagination() {
|
|
nextCursor = null;
|
|
loadingMore = false;
|
|
lastLoadFailed = false;
|
|
archivedId = null;
|
|
archiveBtn.textContent = 'Archive';
|
|
archiveBar.style.display = 'none';
|
|
if (cooldownTimer) { clearTimeout(cooldownTimer); cooldownTimer = null; }
|
|
loadMoreStatus.classList.remove('visible');
|
|
loadMoreStatus.textContent = '';
|
|
}
|
|
|
|
function buildQueryInfo() {
|
|
const queryInfo = { ...currentPayload };
|
|
delete queryInfo.toolType;
|
|
delete queryInfo.mode;
|
|
delete queryInfo.count;
|
|
return queryInfo;
|
|
}
|
|
|
|
function stampCooldown(sources) {
|
|
(sources || []).forEach(source => {
|
|
cooldownUntil[source] = Date.now() + THROTTLE_SECONDS * 1000;
|
|
});
|
|
}
|
|
|
|
function currentItemCount() {
|
|
return Array.isArray(currentData) ? currentData.length : (currentData ? 1 : 0);
|
|
}
|
|
|
|
// count is "how many items I want," not just a per-page size hint — X's own
|
|
// API caps how many it actually returns per page regardless of what's
|
|
// requested (count=200 still only returns ~20 on the first page), so
|
|
// reaching the requested count used to mean manually scrolling the sentinel
|
|
// into view over and over. This chains load-more calls automatically (still
|
|
// gated by the exact same per-source throttle everything else respects)
|
|
// until either the requested count is reached or the source runs dry —
|
|
// scrolling further past that point still works via the IntersectionObserver
|
|
// below, for open-ended browsing beyond the original ask.
|
|
function maybeAutoContinue() {
|
|
if (!currentPayload || !currentPayload.count) return;
|
|
if (currentItemCount() >= currentPayload.count) return;
|
|
// A failed page (see doLoadMore()'s !json.ok branch) does NOT stop
|
|
// pagination outright anymore — nextCursor stays put so a deliberate
|
|
// scroll back into the sentinel still retries with it. What stops here
|
|
// is only the automatic chain: without this check, a persistently broken
|
|
// request (an expired cookie, a source that's down) would retry itself
|
|
// forever every ~5s in the background. maybeLoadMore() itself doesn't
|
|
// check this flag, so a manual retry is never blocked by it.
|
|
if (lastLoadFailed) return;
|
|
maybeLoadMore();
|
|
}
|
|
|
|
function progressSuffix() {
|
|
return (currentPayload && currentPayload.count) ? ` (${currentItemCount()}/${currentPayload.count})` : '';
|
|
}
|
|
|
|
function maybeLoadMore() {
|
|
if (!nextCursor || loadingMore || !currentPayload) return;
|
|
const sources = throttleSourceFor(currentPayload.toolType, currentPayload.mode);
|
|
if (!sources.length) return; // shouldn't happen if nextCursor is set, but be safe
|
|
|
|
const remaining = Math.max(0, ...sources.map(s => cooldownUntil[s] - Date.now()));
|
|
if (remaining > 0) {
|
|
armCountdown(remaining);
|
|
return;
|
|
}
|
|
doLoadMore(sources);
|
|
}
|
|
|
|
function armCountdown(msRemaining) {
|
|
if (cooldownTimer) return; // already counting down
|
|
const secs = Math.max(1, Math.ceil(msRemaining / 1000));
|
|
loadMoreStatus.classList.add('visible');
|
|
loadMoreStatus.innerHTML = `<span class="spin-dot"></span>Loading more in ${secs}s…${progressSuffix()}`;
|
|
cooldownTimer = setTimeout(() => {
|
|
cooldownTimer = null;
|
|
maybeLoadMore(); // re-check: fires the load once the cooldown has cleared
|
|
}, msRemaining);
|
|
}
|
|
|
|
async function doLoadMore(sources) {
|
|
loadingMore = true;
|
|
loadMoreStatus.classList.add('visible');
|
|
loadMoreStatus.innerHTML = `<span class="spin-dot"></span>Loading more…${progressSuffix()}`;
|
|
|
|
try {
|
|
const res = await fetch('/api/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...currentPayload, cursor: nextCursor }),
|
|
});
|
|
const json = await res.json();
|
|
stampCooldown(sources);
|
|
|
|
if (!json.ok) {
|
|
if (res.status === 429 && json.retryAfter) {
|
|
loadingMore = false;
|
|
armCountdown(json.retryAfter * 1000);
|
|
return;
|
|
}
|
|
// A generic failure here used to null nextCursor outright, which
|
|
// permanently ended pagination even for a one-off transient blip —
|
|
// the only way forward was starting an entirely new search from
|
|
// scratch. Old data was never lost either way (currentData is never
|
|
// touched on failure), but being unable to keep exploring the SAME
|
|
// search past a momentary hiccup was the actual problem. nextCursor
|
|
// is left exactly as it was, so scrolling the sentinel back into view
|
|
// retries with it; maybeAutoContinue() is what stops the automatic
|
|
// chain (via lastLoadFailed) so a persistently broken request doesn't
|
|
// just retry itself forever in the background instead.
|
|
lastLoadFailed = true;
|
|
loadMoreStatus.textContent = `Load more failed: ${json.error} — scroll down to retry`;
|
|
return;
|
|
}
|
|
|
|
lastLoadFailed = false;
|
|
const newItems = Array.isArray(json.data) ? json.data : [json.data];
|
|
currentData = (Array.isArray(currentData) ? currentData : [currentData]).concat(newItems);
|
|
appendCards(newItems, searchInput.value);
|
|
nextCursor = json.nextCursor || null;
|
|
|
|
// multi_source_search: a source can legitimately run out (Wayback CDX
|
|
// has a finite match list, Google CSE is hard-capped well under the
|
|
// daily quota) or fail transiently (network blip, quota hit) on any
|
|
// given page — without this, that source just silently stops
|
|
// contributing and it looks like a bug rather than an explainable gap.
|
|
if (json.sourceErrors && Object.keys(json.sourceErrors).length) {
|
|
showSourceErrors(json.sourceErrors);
|
|
} else {
|
|
loadMoreStatus.classList.remove('visible');
|
|
}
|
|
} catch (e) {
|
|
lastLoadFailed = true;
|
|
loadMoreStatus.textContent = `Load more failed: ${e} — scroll down to retry`;
|
|
} finally {
|
|
loadingMore = false;
|
|
maybeAutoContinue();
|
|
}
|
|
}
|
|
|
|
// Shows which multi_source_search lanes didn't contribute this page and
|
|
// why, for a few seconds, then clears — a source running dry (no more
|
|
// matches) and a source failing (network/quota) look identical from the
|
|
// item count alone, so both get spelled out rather than left silent.
|
|
function showSourceErrors(errors) {
|
|
const lines = Object.entries(errors)
|
|
.map(([key, msg]) => `${esc(SOURCE_LABEL_MAP[key] || key)}: ${esc(msg)}`);
|
|
loadMoreStatus.classList.add('visible');
|
|
loadMoreStatus.innerHTML = `<span class="load-more-note">${lines.join(' · ')}</span>`;
|
|
clearTimeout(showSourceErrors._t);
|
|
showSourceErrors._t = setTimeout(() => {
|
|
loadMoreStatus.classList.remove('visible');
|
|
}, 6000);
|
|
}
|
|
|
|
// root: null (the browser viewport) — .output has `overflow: auto` but is
|
|
// never actually height-constrained (body/.layout only set min-height), so
|
|
// it never becomes a real scroll container; the page/viewport is what
|
|
// actually scrolls. Pointing the observer at .output meant it could never
|
|
// detect the sentinel entering view.
|
|
const loadMoreObserver = new IntersectionObserver((entries) => {
|
|
if (entries.some(e => e.isIntersecting)) maybeLoadMore();
|
|
}, { root: null, rootMargin: '200px' });
|
|
loadMoreObserver.observe(loadMoreSentinel);
|
|
|
|
// ── Run ───────────────────────────────────────────────────────────────────────
|
|
|
|
runBtn.addEventListener('click', async () => {
|
|
const t = toolType.value;
|
|
const count = parseInt(val('countInput') || '20', 10);
|
|
|
|
const payload = { toolType: t, mode: currentMode, count };
|
|
|
|
if (t === 'tweet_search_extractor') payload.searchQuery = val('searchQuery');
|
|
if (t === 'follower_explorer' || t === 'following_explorer' || t === 'post_extractor') payload.targetUsername = val('targetUsername');
|
|
if (t === 'community_post_extractor') payload.targetCommunityId = val('targetCommunityId');
|
|
if (t === 'article_extractor' ||
|
|
t === 'tweet_replies_extractor' ||
|
|
t === 'tweet_retweeters_extractor') payload.targetTweetId = val('targetTweetId');
|
|
if (t === 'geo_post_extractor') payload.searchQuery = val('searchQuery');
|
|
if (t === 'wayback_archive_search') {
|
|
payload.searchQuery = val('searchQuery');
|
|
payload.waybackFrom = val('waybackFrom');
|
|
payload.waybackTo = val('waybackTo');
|
|
}
|
|
if (t === 'multi_source_search') {
|
|
payload.searchQuery = val('searchQuery');
|
|
}
|
|
// Date range — sent for all tools that expose the date fields (not wayback which uses its own keys, not article_extractor)
|
|
if (t !== 'wayback_archive_search' && t !== 'article_extractor') {
|
|
const df = val('dateFrom'); const dt = val('dateTo');
|
|
if (df) payload.dateFrom = df;
|
|
if (dt) payload.dateTo = dt;
|
|
}
|
|
|
|
currentPayload = payload;
|
|
resetPagination();
|
|
|
|
runBtn.disabled = true;
|
|
runBtn.textContent = 'Running…';
|
|
statusBadge.textContent = 'running';
|
|
statusBadge.className = 'badge running';
|
|
searchInput.style.display = 'none';
|
|
downloadBtn.style.display = 'none';
|
|
archiveBtn.style.display = 'none';
|
|
resultCount.textContent = '';
|
|
currentData = null;
|
|
resultBox.innerHTML = '<div class="empty-state">Fetching data…</div>';
|
|
|
|
try {
|
|
const res = await fetch('/api/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const json = await res.json();
|
|
|
|
if (json.ok) {
|
|
// Keep the client's cooldown clock in sync even if the user never
|
|
// scrolls to trigger a load-more — a fast scroll right after Run
|
|
// should still wait out the same 5s window the server just started.
|
|
stampCooldown(throttleSourceFor(t, currentMode));
|
|
|
|
statusBadge.textContent = 'done';
|
|
statusBadge.className = 'badge ok';
|
|
currentData = json.data;
|
|
nextCursor = json.nextCursor || null;
|
|
const isMany = Array.isArray(json.data) && json.data.length > 1;
|
|
downloadBtn.style.display = '';
|
|
archiveBtn.style.display = '';
|
|
|
|
// Show map toggle only for geo results; reset to card view on each run
|
|
const isGeo = (t === 'geo_post_extractor');
|
|
const viewToggle = document.getElementById('viewToggle');
|
|
viewToggle.style.display = isGeo ? '' : 'none';
|
|
if (isGeo) {
|
|
// Reset: always start in cards view, invalidate any existing map
|
|
setView('cards');
|
|
if (leafletMap) { leafletMap.remove(); leafletMap = null; markerLayer = null; }
|
|
} else {
|
|
document.getElementById('mapContainer').style.display = 'none';
|
|
document.getElementById('resultBox').style.display = '';
|
|
}
|
|
|
|
searchInput.style.display = (isMany && !isGeo) ? '' : 'none';
|
|
renderCards(currentData);
|
|
maybeAutoContinue();
|
|
} else {
|
|
statusBadge.textContent = 'error';
|
|
statusBadge.className = 'badge err';
|
|
resultBox.innerHTML = `<div class="card"><div class="card-row"><div class="card-key">error</div><div class="card-val" style="color:#f87171">${esc(json.error)}</div></div></div>`;
|
|
}
|
|
} catch (e) {
|
|
statusBadge.textContent = 'error';
|
|
statusBadge.className = 'badge err';
|
|
resultBox.innerHTML = `<div class="card"><div class="card-row"><div class="card-key">exception</div><div class="card-val" style="color:#f87171">${esc(String(e))}</div></div></div>`;
|
|
} finally {
|
|
runBtn.disabled = false;
|
|
runBtn.textContent = 'Run';
|
|
}
|
|
});
|
|
|
|
// ── Archive ───────────────────────────────────────────────────────────────────
|
|
// Manual, on demand — same model as graph.html's "Archive All" button rather
|
|
// than the old auto-checkpoint-on-every-scroll system (which had a real race:
|
|
// a fast scroll landing between the archive-creation call setting its
|
|
// in-flight flag and that call's fetch actually resolving could silently
|
|
// drop that checkpoint for good). Click Archive whenever you want to save
|
|
// exactly what's currently loaded; click it again after scrolling/expanding
|
|
// loads more and it checkpoints the SAME archive in place instead of creating a
|
|
// new one each time — identical behavior to graph.html's graphArchivedId.
|
|
|
|
archiveBtn.addEventListener('click', archiveResults);
|
|
|
|
async function archiveResults() {
|
|
if (!currentData || !currentPayload) return;
|
|
|
|
archiveBar.style.display = '';
|
|
const label = archivedId ? '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 {
|
|
const body = {
|
|
toolType: currentPayload.toolType,
|
|
data: Array.isArray(currentData) ? currentData : [currentData],
|
|
queryInfo: buildQueryInfo(),
|
|
};
|
|
if (archivedId) body.archiveId = archivedId;
|
|
|
|
const res = await fetch('/api/archive', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const json = await res.json();
|
|
if (!json.ok) throw new Error(json.error);
|
|
|
|
archivedId = json.archiveId;
|
|
archiveBtn.textContent = 'Update Archive';
|
|
autoCheckpoint(); // persist archiveId into the case so the user can resume if the page is lost
|
|
pollArchive(json.archiveId);
|
|
} catch (e) {
|
|
archiveBar.innerHTML = `<span style="color:var(--danger)">Archive failed: ${esc(e.message)}</span>`;
|
|
}
|
|
}
|
|
|
|
function pollArchive(archiveId) {
|
|
const interval = setInterval(async () => {
|
|
const res = await fetch(`/api/archive/${archiveId}/status`);
|
|
const json = await res.json();
|
|
if (!json.ok) { clearInterval(interval); return; }
|
|
|
|
const { status, progress, total } = json;
|
|
const fill = document.querySelector('.archive-bar-fill');
|
|
|
|
if (status === 'saving') {
|
|
archiveBar.querySelector('span').textContent = 'Saving results…';
|
|
} else if (status === 'downloading') {
|
|
const 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)`;
|
|
loadRecentArchives();
|
|
}
|
|
}, 1200);
|
|
}
|
|
|
|
async function loadRecentArchives() {
|
|
const res = await fetch('/api/archive/list');
|
|
const json = await res.json();
|
|
if (!json.ok || !json.archives.length) { archivesSection.style.display = 'none'; return; }
|
|
|
|
archivesSection.style.display = '';
|
|
archivesList.innerHTML = json.archives.slice(0, 6).map(a => `
|
|
<div class="archive-item">
|
|
<div class="archive-item-tool">${esc(a.tool || '?')}</div>
|
|
<div class="archive-item-meta">${esc(a.archived_at || '')} · ${a.total_items || 0} items · ${a.media_count || 0} media</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// ── Download JSON ─────────────────────────────────────────────────────────────
|
|
|
|
downloadBtn.addEventListener('click', () => {
|
|
if (!currentData) return;
|
|
const tool = toolType.value;
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
const filename = `${tool}_${timestamp}.json`;
|
|
const blob = new Blob([JSON.stringify(currentData, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
autoCheckpoint(); // checkpoint so the archiveId is preserved if the page is lost after export
|
|
});
|
|
|
|
function val(id) { const el = document.getElementById(id); return el ? el.value.trim() : ''; }
|
|
function esc(s) { return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]); }
|
|
|
|
// ── Map view (Leaflet) ────────────────────────────────────────────────────────
|
|
|
|
let leafletMap = null;
|
|
let markerLayer = null;
|
|
|
|
// Nominatim geocoding cache + rate-limiter (max 1 req/s per ToS)
|
|
const geoCache = new Map();
|
|
let lastGeoReqTime = 0;
|
|
|
|
async function geocodeLocation(locStr) {
|
|
const key = locStr.toLowerCase().trim();
|
|
if (geoCache.has(key)) return geoCache.get(key);
|
|
|
|
const wait = Math.max(0, 1100 - (Date.now() - lastGeoReqTime));
|
|
if (wait > 0) await new Promise(r => setTimeout(r, wait));
|
|
lastGeoReqTime = Date.now();
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(locStr)}&format=json&limit=1`,
|
|
{ headers: { 'Accept-Language': 'en' } },
|
|
);
|
|
const json = await res.json();
|
|
const result = (json && json[0])
|
|
? { lat: parseFloat(json[0].lat), lon: parseFloat(json[0].lon) }
|
|
: null;
|
|
geoCache.set(key, result);
|
|
return result;
|
|
} catch (_) {
|
|
geoCache.set(key, null);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setView(v) {
|
|
const btns = document.querySelectorAll('#viewToggle .view-btn');
|
|
const isCards = (v === 'cards');
|
|
btns[0].classList.toggle('active', isCards);
|
|
btns[1].classList.toggle('active', !isCards);
|
|
|
|
const resultBox = document.getElementById('resultBox');
|
|
const mapContainer = document.getElementById('mapContainer');
|
|
const mapStatus = document.getElementById('mapStatus');
|
|
const searchInput = document.getElementById('searchInput');
|
|
|
|
// No "bottom of the list" concept on a Leaflet map — suspend load-more there.
|
|
loadMoreSentinel.style.display = isCards ? '' : 'none';
|
|
|
|
if (isCards) {
|
|
mapContainer.style.display = 'none';
|
|
mapStatus.style.display = 'none';
|
|
resultBox.style.display = '';
|
|
const isMany = Array.isArray(currentData) && currentData.length > 1;
|
|
searchInput.style.display = isMany ? '' : 'none';
|
|
} else {
|
|
resultBox.style.display = 'none';
|
|
searchInput.style.display = 'none';
|
|
mapContainer.style.display = '';
|
|
mapStatus.style.display = '';
|
|
initMap();
|
|
updateMap();
|
|
}
|
|
}
|
|
|
|
function initMap() {
|
|
if (leafletMap) { leafletMap.invalidateSize(); return; }
|
|
|
|
const mapContainer = document.getElementById('mapContainer');
|
|
leafletMap = L.map(mapContainer, { preferCanvas: true });
|
|
|
|
const standard = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 19,
|
|
});
|
|
const satellite = L.tileLayer(
|
|
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
|
attribution: 'Tiles © Esri — Source: Esri, USGS, NOAA',
|
|
maxZoom: 19,
|
|
});
|
|
const terrain = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://opentopomap.org">OpenTopoMap</a> contributors',
|
|
maxZoom: 17,
|
|
});
|
|
|
|
standard.addTo(leafletMap);
|
|
L.control.layers({ Standard: standard, Satellite: satellite, Terrain: terrain }).addTo(leafletMap);
|
|
markerLayer = L.layerGroup().addTo(leafletMap);
|
|
}
|
|
|
|
async function updateMap() {
|
|
if (!leafletMap) return;
|
|
markerLayer.clearLayers();
|
|
|
|
const mapStatus = document.getElementById('mapStatus');
|
|
const items = Array.isArray(currentData) ? currentData : (currentData ? [currentData] : []);
|
|
|
|
// Collect tweets that have a non-empty profile location
|
|
const withLoc = items.filter(i => i.user_location && i.user_location.trim());
|
|
if (!withLoc.length) {
|
|
mapStatus.textContent = 'No profile locations found in results — users may not have set a location.';
|
|
leafletMap.setView([20, 10], 2);
|
|
return;
|
|
}
|
|
|
|
// Deduplicate locations to minimise Nominatim calls
|
|
const uniqueLocs = [...new Set(withLoc.map(i => i.user_location.trim()))];
|
|
mapStatus.textContent = `Geocoding 0 / ${uniqueLocs.length} locations…`;
|
|
|
|
let done = 0;
|
|
for (const loc of uniqueLocs) {
|
|
await geocodeLocation(loc);
|
|
done++;
|
|
mapStatus.textContent = `Geocoding ${done} / ${uniqueLocs.length} locations…`;
|
|
}
|
|
|
|
// Place a marker for every tweet whose location resolved
|
|
let placed = 0;
|
|
for (const item of withLoc) {
|
|
const loc = item.user_location.trim();
|
|
const coords = geoCache.get(loc.toLowerCase().trim());
|
|
if (!coords) continue;
|
|
|
|
// Small random jitter so stacked markers from the same city are visible
|
|
const jitter = () => (Math.random() - 0.5) * 0.018;
|
|
const lat = coords.lat + jitter();
|
|
const lon = coords.lon + jitter();
|
|
|
|
const user = esc(item.user || '?');
|
|
const text = esc((item.text || item.full_text || '').slice(0, 220));
|
|
const date = esc(item.created_at || '');
|
|
const locLabel = esc(loc);
|
|
const tweetUrl = (item.id && item.user)
|
|
? `https://x.com/${item.user}/status/${item.id}` : null;
|
|
|
|
const popup = `<div style="max-width:260px">
|
|
<div style="font-weight:600;margin-bottom:4px">@${user}</div>
|
|
<div style="font-size:11px;color:#8890a4;margin-bottom:5px">📍 ${locLabel}</div>
|
|
<div style="line-height:1.45;margin-bottom:6px">${text}</div>
|
|
<div style="font-size:10px;color:#8890a4">${date}</div>
|
|
${tweetUrl
|
|
? `<a href="${esc(tweetUrl)}" target="_blank" rel="noopener"
|
|
style="color:#818cf8;font-size:11px;display:block;margin-top:5px">Open tweet ↗</a>`
|
|
: ''}
|
|
</div>`;
|
|
|
|
L.circleMarker([lat, lon], {
|
|
radius: 8, fillColor: '#5865f2',
|
|
color: '#fff', weight: 1.5, fillOpacity: 0.88,
|
|
}).bindPopup(popup, { maxWidth: 280 }).addTo(markerLayer);
|
|
placed++;
|
|
}
|
|
|
|
// Summary
|
|
const skipped = withLoc.length - placed;
|
|
mapStatus.textContent = placed > 0
|
|
? `${placed} tweet${placed !== 1 ? 's' : ''} mapped across ${uniqueLocs.length} location${uniqueLocs.length !== 1 ? 's' : ''}` +
|
|
(skipped ? ` · ${skipped} could not be geocoded` : '')
|
|
: 'None of the profile locations could be geocoded.';
|
|
|
|
// Fit map to placed markers
|
|
const pts = [];
|
|
markerLayer.eachLayer(m => { if (m.getLatLng) pts.push(m.getLatLng()); });
|
|
if (pts.length > 1) leafletMap.fitBounds(pts, { padding: [48, 48] });
|
|
else if (pts.length === 1) leafletMap.setView(pts[0], 9);
|
|
else leafletMap.setView([20, 10], 2);
|
|
}
|
|
|
|
// ── Cases (save / resume investigation sessions) ─────────────────────────────
|
|
|
|
var currentCaseId = null;
|
|
var currentCaseName = '';
|
|
|
|
function updateCaseIndicator() {
|
|
const ind = document.getElementById('caseIndicator');
|
|
const name = document.getElementById('caseIndicatorName');
|
|
const btn = document.getElementById('caseCheckpointBtn');
|
|
if (currentCaseId) {
|
|
name.textContent = currentCaseName;
|
|
ind.classList.add('visible');
|
|
if (btn && btn.textContent !== 'Saving…' && btn.textContent !== 'Saved!' && btn.textContent !== 'Failed') btn.textContent = 'Checkpoint';
|
|
} else {
|
|
ind.classList.remove('visible');
|
|
if (btn && btn.textContent !== 'Saving…' && btn.textContent !== 'Saved!' && btn.textContent !== 'Failed') btn.textContent = 'Save Case';
|
|
}
|
|
}
|
|
|
|
document.getElementById('caseCheckpointBtn').addEventListener('click', async function() {
|
|
if (currentCaseId) {
|
|
// update existing case directly — no form needed
|
|
const state = cmCaptureState();
|
|
if (!state) return;
|
|
const tool = toolType.options[toolType.selectedIndex].text;
|
|
this.disabled = true; this.textContent = 'Saving…';
|
|
try {
|
|
const res = await fetch('/api/cases/' + currentCaseId, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ state, tool }),
|
|
});
|
|
const json = await res.json();
|
|
if (!json.ok) throw new Error(json.error);
|
|
this.textContent = 'Saved!';
|
|
setTimeout(() => { const b = document.getElementById('caseCheckpointBtn'); if (b) b.textContent = 'Checkpoint'; }, 1800);
|
|
} catch {
|
|
this.textContent = 'Failed';
|
|
setTimeout(() => { const b = document.getElementById('caseCheckpointBtn'); if (b) b.textContent = 'Checkpoint'; }, 1800);
|
|
} finally {
|
|
this.disabled = false;
|
|
}
|
|
return;
|
|
}
|
|
// no active case → toggle save form
|
|
const state = cmCaptureState();
|
|
const form = document.getElementById('caseSaveForm');
|
|
if (!state) {
|
|
// flash the button briefly if nothing to save
|
|
this.textContent = 'No data yet';
|
|
setTimeout(() => { const b = document.getElementById('caseCheckpointBtn'); if (b) b.textContent = 'Save Case'; }, 1500);
|
|
return;
|
|
}
|
|
if (form.hidden) {
|
|
const dateStr = new Date().toLocaleDateString('en-CA');
|
|
document.getElementById('caseSaveName').value = toolType.options[toolType.selectedIndex].text + ' · ' + dateStr;
|
|
form.hidden = false;
|
|
const inp = document.getElementById('caseSaveName');
|
|
inp.focus(); inp.select();
|
|
} else {
|
|
form.hidden = true;
|
|
}
|
|
});
|
|
|
|
document.getElementById('caseSaveCancel').addEventListener('click', function() {
|
|
document.getElementById('caseSaveForm').hidden = true;
|
|
});
|
|
|
|
document.getElementById('caseSaveConfirm').addEventListener('click', async function() {
|
|
const nameEl = document.getElementById('caseSaveName');
|
|
const name = nameEl.value.trim() || 'Unnamed Case';
|
|
const state = cmCaptureState();
|
|
if (!state) return;
|
|
const tool = toolType.options[toolType.selectedIndex].text;
|
|
this.disabled = true; this.textContent = 'Saving…';
|
|
try {
|
|
const res = await fetch('/api/cases', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, page: 'index', tool, state }),
|
|
});
|
|
const json = await res.json();
|
|
if (!json.ok) throw new Error(json.error);
|
|
currentCaseId = json.caseId;
|
|
currentCaseName = json.meta.name;
|
|
updateCaseIndicator();
|
|
document.getElementById('caseSaveForm').hidden = true;
|
|
nameEl.value = '';
|
|
} catch {
|
|
// re-enable so user can retry
|
|
} finally {
|
|
this.disabled = false; this.textContent = 'Save';
|
|
}
|
|
});
|
|
|
|
function cmCaptureState() {
|
|
if (!currentData && !currentPayload) return null;
|
|
return {
|
|
payload: currentPayload || null,
|
|
data: Array.isArray(currentData) ? currentData : (currentData ? [currentData] : []),
|
|
nextCursor: nextCursor || null,
|
|
archiveId: archivedId || null,
|
|
};
|
|
}
|
|
|
|
function cmRestoreState(state, meta) {
|
|
if (!state || !meta) return;
|
|
currentCaseId = meta.id;
|
|
currentCaseName = meta.name;
|
|
updateCaseIndicator();
|
|
resetPagination();
|
|
currentData = null;
|
|
currentPayload = null;
|
|
|
|
if (state.payload) {
|
|
const p = state.payload;
|
|
if (p.toolType) toolType.value = p.toolType;
|
|
if (p.mode) currentMode = p.mode;
|
|
renderFields();
|
|
const fieldMap = {
|
|
searchQuery: p.searchQuery, targetUsername: p.targetUsername,
|
|
targetTweetId: p.targetTweetId, targetCommunityId: p.targetCommunityId,
|
|
waybackFrom: p.waybackFrom, waybackTo: p.waybackTo,
|
|
dateFrom: p.dateFrom, dateTo: p.dateTo,
|
|
};
|
|
Object.entries(fieldMap).forEach(([id, v]) => {
|
|
const el = document.getElementById(id);
|
|
if (el && v) el.value = v;
|
|
});
|
|
const cnt = document.getElementById('countInput');
|
|
if (cnt && p.count) cnt.value = p.count;
|
|
currentPayload = p;
|
|
}
|
|
|
|
if (Array.isArray(state.data) && state.data.length) {
|
|
currentData = state.data;
|
|
nextCursor = state.nextCursor || null;
|
|
statusBadge.textContent = 'restored';
|
|
statusBadge.className = 'badge ok';
|
|
searchInput.style.display = state.data.length > 1 ? '' : 'none';
|
|
downloadBtn.style.display = '';
|
|
archiveBtn.style.display = '';
|
|
renderCards(currentData);
|
|
resultCount.textContent = state.data.length + ' results';
|
|
}
|
|
if (state.archiveId) {
|
|
archivedId = state.archiveId;
|
|
archiveBtn.textContent = 'Update Archive';
|
|
}
|
|
}
|
|
|
|
// ── Auto-checkpoint (triggered by Archive and JSON dump) ─────────────────────
|
|
// Silently saves to the active case if one is loaded, or creates a new
|
|
// auto-save case if not. Never blocks the calling flow — errors are swallowed.
|
|
async function autoCheckpoint() {
|
|
const state = cmCaptureState();
|
|
if (!state) return;
|
|
const tool = toolType.options[toolType.selectedIndex].text;
|
|
const dateStr = new Date().toLocaleDateString('en-CA');
|
|
try {
|
|
if (currentCaseId) {
|
|
await fetch('/api/cases/' + currentCaseId, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ state, tool }),
|
|
});
|
|
} else {
|
|
const res = await fetch('/api/cases', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Auto-save · ' + tool + ' ' + dateStr, page: 'index', tool, state }),
|
|
});
|
|
const json = await res.json();
|
|
if (json.ok) {
|
|
currentCaseId = json.caseId;
|
|
currentCaseName = json.meta.name;
|
|
updateCaseIndicator();
|
|
}
|
|
}
|
|
} catch { /* silent — autosave must never interrupt the user's flow */ }
|
|
}
|
|
|
|
// ── Periodic auto-save (every 60 s) — guards against power loss / crash ───────
|
|
setInterval(function() { autoCheckpoint(); }, 60000);
|
|
|
|
// ── beforeunload beacon — fires on tab/browser close ─────────────────────────
|
|
window.addEventListener('beforeunload', function() {
|
|
const state = cmCaptureState();
|
|
if (!state) return;
|
|
const tool = toolType.options[toolType.selectedIndex].text;
|
|
const dateStr = new Date().toLocaleDateString('en-CA');
|
|
const payload = currentCaseId
|
|
? { caseId: currentCaseId, state, tool }
|
|
: { name: 'Auto-save · ' + tool + ' ' + dateStr, page: 'index', tool, state };
|
|
navigator.sendBeacon('/api/cases/beacon', new Blob([JSON.stringify(payload)], { type: 'application/json' }));
|
|
});
|
|
|
|
// Load case from URL param (e.g. when graph page redirects here with ?caseId=)
|
|
(async function() {
|
|
const caseId = new URLSearchParams(location.search).get('caseId');
|
|
if (!caseId) return;
|
|
history.replaceState(null, '', location.pathname);
|
|
try {
|
|
const res = await fetch('/api/cases/' + encodeURIComponent(caseId));
|
|
const json = await res.json();
|
|
if (!json.ok) throw new Error(json.error);
|
|
cmRestoreState(json.state, json.meta);
|
|
} catch (e) {
|
|
statusBadge.textContent = 'error';
|
|
statusBadge.className = 'badge err';
|
|
resultBox.innerHTML = `<div class="card"><div class="card-row"><div class="card-key">case load error</div><div class="card-val" style="color:#f87171">${esc(e.message)}</div></div></div>`;
|
|
}
|
|
})();
|
|
|
|
// ── Auto-run from URL params (drill-down new-tab entry point) ─────────────────
|
|
|
|
(function () {
|
|
const p = new URLSearchParams(location.search);
|
|
if (p.get('autorun') !== '1') return;
|
|
|
|
const tool = p.get('tool');
|
|
const id = p.get('id');
|
|
if (!tool || !id) return;
|
|
|
|
// Select tool and re-render fields (also locks mode to cookie for cookie-only tools)
|
|
toolType.value = tool;
|
|
renderFields();
|
|
|
|
const idInput = document.getElementById('targetTweetId');
|
|
if (idInput) idInput.value = id;
|
|
|
|
// Kick off the run automatically
|
|
runBtn.click();
|
|
|
|
// Clean up the URL so it doesn't confuse on reload
|
|
history.replaceState(null, '', location.pathname);
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|