mirror of
https://github.com/Jieyab89/OSINT-Cheat-sheet.git
synced 2026-09-28 20:54:50 +02:00
convert to flask and jinja from blade and php source & sync update bug and logic
This commit is contained in:
@@ -20,24 +20,34 @@
|
||||
<div class="param-row"><code class="param-key">is_blue_verified</code> Paid X Premium checkmark</div>
|
||||
|
||||
<div class="param-heading">Threading & retweets</div>
|
||||
<div class="param-row"><code class="param-key">in_reply_to_tweet_id</code> Parent tweet this replies to</div>
|
||||
<div class="param-row"><code class="param-key">retweeted_*</code> Details of the original tweet being retweeted</div>
|
||||
<div class="param-row"><code class="param-key">in_reply_to_tweet_id</code> Parent tweet this replies to — also the drill-down anchor that fetches its replies</div>
|
||||
<div class="param-row"><code class="param-key">retweeted_text / retweeted_by_user / retweeted_by_name / retweeted_by_bio</code> Content and author of the original tweet being retweeted</div>
|
||||
<div class="param-row"><code class="param-key">retweeted_at / retweeted_tweet_id</code> When the original was posted, and its own ID</div>
|
||||
|
||||
<div class="param-heading">Location</div>
|
||||
<div class="param-row"><code class="param-key">lat / lon / place / user_location</code> Geodata — geo search only</div>
|
||||
<div class="param-row"><code class="param-key">lat / lon / place</code> Coordinates plotted on the map view (Geo Post Search)</div>
|
||||
<div class="param-row"><code class="param-key">user_location</code> Free-text profile location string, geocoded client-side to produce lat/lon</div>
|
||||
|
||||
<div class="param-heading">Account age (forensics)</div>
|
||||
<div class="param-row"><code class="param-key">account_created / account_age</code> Derived from the account's numeric ID, not the API — see badges above</div>
|
||||
<div class="param-row"><code class="param-key">account_age_flag</code> New / Recent / Established bucket</div>
|
||||
<div class="param-row"><code class="param-key">account_age_precision</code> exact / estimated / unknown — how much to trust the date</div>
|
||||
<div class="param-row"><code class="param-key">account_created / account_age</code> Derived from the account's numeric ID, not the API — see the Account age badges above</div>
|
||||
<div class="param-row"><code class="param-key">account_age_flag</code> New / Recent / Established bucket, drives the badge color</div>
|
||||
<div class="param-row"><code class="param-key">account_age_precision</code> exact (true Snowflake decode) / estimated (pre-Snowflake id, interpolated) / unknown</div>
|
||||
|
||||
<div class="param-heading">Archive & Wayback</div>
|
||||
<div class="param-row"><code class="param-key">archive_url</code> Link to the Wayback Machine snapshot</div>
|
||||
<div class="param-row"><code class="param-key">iso_date</code> When that snapshot was captured</div>
|
||||
<div class="param-row"><code class="param-key">original</code> The original URL that was archived</div>
|
||||
<div class="param-row"><code class="param-key">statuscode / mimetype / length</code> HTTP status / type / size of the snapshot</div>
|
||||
<div class="param-row"><code class="param-key">post_title / post_text / preview_image</code> Scraped from the archived page's meta tags</div>
|
||||
<div class="param-row"><code class="param-key">tweet_url</code> Direct link to the live tweet</div>
|
||||
<div class="param-row"><code class="param-key">statuscode / mimetype / length</code> HTTP status / content type / size of the snapshot</div>
|
||||
<div class="param-row"><code class="param-key">post_title / post_text / preview_image</code> Scraped from the archived page's own meta tags (og:/twitter: tags, or <title>/<meta name="description"> on older captures)</div>
|
||||
<div class="param-row"><code class="param-key">tweet_url</code> Direct link to the live tweet, built from user + id</div>
|
||||
|
||||
<div class="param-heading">Source</div>
|
||||
<div class="param-row"><code class="param-key">source</code> Which of the 3 data sources this result came from (multi-source search only)</div>
|
||||
<div class="param-row"><code class="param-key">source</code> Which of the 3 data sources this result came from — Twitter Cookie / Xquik API / Wayback Machine (Multi-Source Search only)</div>
|
||||
|
||||
<div class="param-heading">Graph node types (graph page only)</div>
|
||||
<div class="param-row"><code class="param-key">Search root</code> The diamond node — the query you ran</div>
|
||||
<div class="param-row"><code class="param-key">Tweet</code> A tweet/post returned by search or by expanding a node</div>
|
||||
<div class="param-row"><code class="param-key">Reply</code> A tweet fetched via Expand Replies on a tweet node</div>
|
||||
<div class="param-row"><code class="param-key">User / Retweeter</code> A person — from Follower Explorer, or via Expand Retweets on a tweet node</div>
|
||||
<div class="param-row"><code class="param-key">Wayback snapshot</code> An archived-page result, from Wayback Archive Search or the Wayback portion of Multi-Source Search</div>
|
||||
<div class="param-row"><code class="param-key">Viewed</code> Turns a node solid white once you've clicked it — a visual "already looked at this" marker, not part of the underlying data</div>
|
||||
|
||||
@@ -168,6 +168,19 @@
|
||||
}
|
||||
.sidebar-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
.sidebar-count { font-size: 11px; color: var(--muted); background: var(--bg); padding: 2px 7px; border-radius: 10px; }
|
||||
.sidebar-search {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sidebar-search:focus { outline: none; border-color: var(--accent); }
|
||||
.sidebar-search::placeholder { color: var(--muted); }
|
||||
.archive-list { overflow-y: auto; flex: 1; padding: 8px; }
|
||||
.archive-entry {
|
||||
padding: 10px 12px;
|
||||
@@ -289,6 +302,16 @@
|
||||
.card-link { color: var(--accent); text-decoration: none; font-size: 12px; }
|
||||
.card-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Drill-down links on reply / retweet counts — same as the live search page */
|
||||
.drill-link {
|
||||
color: var(--accent);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.drill-link:hover { color: #818cf8; }
|
||||
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
@@ -373,6 +396,7 @@
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
<script src="{{ url_for('static', filename='js/card_constants.js') }}" nonce="{{ g.csp_nonce }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -418,6 +442,9 @@
|
||||
<span class="sidebar-title">Archives</span>
|
||||
<span class="sidebar-count" id="archiveCount">—</span>
|
||||
</div>
|
||||
<div style="padding:8px 12px 0">
|
||||
<input type="text" class="sidebar-search" id="archiveSearch" placeholder="Search archives…">
|
||||
</div>
|
||||
<div class="archive-list" id="archiveList">
|
||||
<div class="no-archives"><span class="spinner"></span> Loading…</div>
|
||||
</div>
|
||||
@@ -471,9 +498,11 @@
|
||||
|
||||
let allItems = [];
|
||||
let activeId = null;
|
||||
let allArchives = []; // full list from the server; archiveSearch filters this client-side
|
||||
|
||||
const archiveList = document.getElementById('archiveList');
|
||||
const archiveCount = document.getElementById('archiveCount');
|
||||
const archiveList = document.getElementById('archiveList');
|
||||
const archiveCount = document.getElementById('archiveCount');
|
||||
const archiveSearch = document.getElementById('archiveSearch');
|
||||
const viewerTop = document.getElementById('viewerTop');
|
||||
const viewerTitle = document.getElementById('viewerTitle');
|
||||
const viewerSub = document.getElementById('viewerSub');
|
||||
@@ -483,28 +512,52 @@ const deleteBtn = document.getElementById('deleteBtn');
|
||||
const cardsBox = document.getElementById('cardsBox');
|
||||
|
||||
// ── Sidebar ───────────────────────────────────────────────────────────────────
|
||||
// The server already returns archives newest-first (by archived_at/updated_at,
|
||||
// not folder name — folder names are prefixed by tool_type so sorting by name
|
||||
// doesn't actually sort chronologically once more than one tool's been used).
|
||||
|
||||
async function loadSidebar() {
|
||||
const res = await fetch('/api/archive/list');
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.ok || !json.archives.length) {
|
||||
allArchives = [];
|
||||
archiveList.innerHTML = '<div class="no-archives">No archives yet.<br>Enable Auto Archive in the tool and run a search.</div>';
|
||||
archiveCount.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
archiveCount.textContent = json.archives.length;
|
||||
archiveList.innerHTML = json.archives.map(a => entryHtml(a)).join('');
|
||||
|
||||
archiveList.querySelectorAll('.archive-entry').forEach(el => {
|
||||
el.addEventListener('click', () => loadArchive(el.dataset.id));
|
||||
});
|
||||
allArchives = json.archives;
|
||||
renderArchiveList(archiveSearch.value);
|
||||
|
||||
// Auto-open the newest one
|
||||
loadArchive(json.archives[0].id);
|
||||
loadArchive(allArchives[0].id);
|
||||
}
|
||||
|
||||
function archiveMatchText(a) {
|
||||
return [a.tool || '', queryLabel(a.query || {}), a.id || ''].join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
function renderArchiveList(filterText = '') {
|
||||
const q = filterText.toLowerCase().trim();
|
||||
const filtered = q ? allArchives.filter(a => archiveMatchText(a).includes(q)) : allArchives;
|
||||
|
||||
archiveCount.textContent = q ? `${filtered.length} / ${allArchives.length}` : allArchives.length;
|
||||
|
||||
if (!filtered.length) {
|
||||
archiveList.innerHTML = '<div class="no-archives">No archives match your search.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
archiveList.innerHTML = filtered.map(a => entryHtml(a)).join('');
|
||||
archiveList.querySelectorAll('.archive-entry').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.id === activeId);
|
||||
el.addEventListener('click', () => loadArchive(el.dataset.id));
|
||||
});
|
||||
}
|
||||
|
||||
archiveSearch.addEventListener('input', () => renderArchiveList(archiveSearch.value));
|
||||
|
||||
function entryHtml(a) {
|
||||
const tool = (a.tool || 'unknown').replace(/_/g, ' ');
|
||||
const date = a.archived_at ? a.archived_at.replace('T', ' ') : '—';
|
||||
@@ -572,10 +625,10 @@ function flatText(obj) {
|
||||
|
||||
// ── Cards ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PRIORITY = ['source','account_age_flag','account_age','account_created','user','user_id','text','full_text','article_text','post_title','post_text','created_at','tweet_url','archive_url','preview_image','iso_date','original','statuscode'];
|
||||
// PRIORITY / DRILLABLE / SOURCE_CLASS / AGE_LABELS come from
|
||||
// static/js/card_constants.js, loaded above — shared with index.html.
|
||||
const SKIP = ['archived_media'];
|
||||
const CLAMP = new Set(['text','full_text','article_text','description','post_text']);
|
||||
const SOURCE_CLASS = { 'Twitter Cookie': 'src-cookie', 'Xquik API': 'src-xquik', 'Wayback Machine': 'src-wayback' };
|
||||
|
||||
function renderCards(items, query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
@@ -593,13 +646,24 @@ function renderCards(items, query) {
|
||||
function buildCard(item) {
|
||||
if (typeof item !== 'object' || !item) return '';
|
||||
|
||||
const tweetId = item.id ? String(item.id) : null;
|
||||
const entries = Object.entries(item);
|
||||
const pri = entries.filter(([k]) => PRIORITY.includes(k));
|
||||
const rest = entries.filter(([k]) => !PRIORITY.includes(k) && !SKIP.includes(k));
|
||||
// Same rule as the live search page: order follows PRIORITY's own sequence,
|
||||
// and nothing gets capped off — a saved archive should show exactly the
|
||||
// same fields, in the same order, as when it was first fetched.
|
||||
const priKeys = new Set(entries.map(([k]) => k).filter(k => PRIORITY.includes(k)));
|
||||
const pri = PRIORITY.filter(k => priKeys.has(k)).map(k => [k, item[k]]);
|
||||
const rest = entries.filter(([k]) => !PRIORITY.includes(k) && !SKIP.includes(k));
|
||||
|
||||
const rows = [...pri, ...rest].slice(0, 14).map(([k, v]) => {
|
||||
const rows = [...pri, ...rest].map(([k, v]) => {
|
||||
let display;
|
||||
if (v === null || v === undefined) {
|
||||
// Drillable check first — reply_count / retweet_count on an archived
|
||||
// tweet still link out to a fresh live extraction, same as a live result.
|
||||
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 (v === null || v === undefined) {
|
||||
display = `<span style="color:var(--muted)">—</span>`;
|
||||
} else if (k === 'tweet_url' || k === 'archive_url' || k === 'preview_image') {
|
||||
display = `<a href="${esc(String(v))}" target="_blank" rel="noopener" class="card-link">${esc(String(v))}</a>`;
|
||||
@@ -607,8 +671,7 @@ function buildCard(item) {
|
||||
const cls = SOURCE_CLASS[v] || '';
|
||||
display = `<span class="source-badge ${cls}">${esc(String(v))}</span>`;
|
||||
} else if (k === 'account_age_flag') {
|
||||
const label = { new: 'New account', recent: 'Recent account', established: 'Established account' }[v] || v;
|
||||
display = `<span class="age-badge age-${esc(String(v))}">${esc(label)}</span>`;
|
||||
display = `<span class="age-badge age-${esc(String(v))}">${esc(AGE_LABELS[v] || v)}</span>`;
|
||||
} else if (typeof v === 'object') {
|
||||
const s = JSON.stringify(v);
|
||||
display = `<span style="color:var(--muted);font-size:11px">${esc(s.length > 100 ? s.slice(0,100)+'…' : s)}</span>`;
|
||||
|
||||
@@ -137,6 +137,29 @@
|
||||
.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;
|
||||
@@ -245,20 +268,14 @@
|
||||
border: 1px solid var(--border);
|
||||
display: block;
|
||||
}
|
||||
.info-media a.video-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
background: var(--surface2);
|
||||
.info-media video.info-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-decoration: none;
|
||||
background: #000;
|
||||
}
|
||||
.info-media a.video-link:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
#infoActions {
|
||||
padding: 10px 12px;
|
||||
@@ -479,6 +496,8 @@
|
||||
<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>
|
||||
|
||||
@@ -793,8 +812,28 @@ async function apiFetch(body) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
var json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'API error');
|
||||
return json.data;
|
||||
if (!json.ok) {
|
||||
var err = new Error(json.error || 'API error');
|
||||
if (res.status === 429 && json.retryAfter) err.retryAfter = json.retryAfter;
|
||||
throw err;
|
||||
}
|
||||
return { items: json.data, nextCursor: json.nextCursor || null };
|
||||
}
|
||||
|
||||
// ── Cookie/Wayback cooldown (mirrors the server's 5s-per-source throttle) ──
|
||||
var cooldownUntil = { cookie: 0, wayback: 0 };
|
||||
var cooldownTimer = null;
|
||||
|
||||
function throttleSourceFor(tool, mode) {
|
||||
if (tool === 'wayback_archive_search') return 'wayback';
|
||||
var COOKIE_TOOLS = ['tweet_search_extractor', 'follower_explorer', 'post_extractor',
|
||||
'community_post_extractor', 'tweet_replies_extractor', 'tweet_retweeters_extractor', 'geo_post_extractor'];
|
||||
if (mode === 'cookie' && COOKIE_TOOLS.indexOf(tool) !== -1) return 'cookie';
|
||||
return null;
|
||||
}
|
||||
|
||||
function stampCooldown(source) {
|
||||
if (source) cooldownUntil[source] = Date.now() + 5000;
|
||||
}
|
||||
|
||||
// ── Label builder ─────────────────────────────────────────────────────────────
|
||||
@@ -887,8 +926,13 @@ async function runSearch() {
|
||||
setStatus('Searching…', false, true);
|
||||
|
||||
try {
|
||||
var data = await apiFetch(buildBody(tool, query, count, mode));
|
||||
var items = Array.isArray(data) ? data : [data];
|
||||
var result = await apiFetch(buildBody(tool, query, count, mode));
|
||||
var items = Array.isArray(result.items) ? result.items : [result.items];
|
||||
stampCooldown(throttleSourceFor(tool, mode));
|
||||
// Root-search pagination (continuing this same search past one page) is
|
||||
// not wired up in the graph UI yet — only reply/retweet node-expand
|
||||
// supports "load more" this round. result.nextCursor is intentionally
|
||||
// unused here.
|
||||
|
||||
// Central search node
|
||||
var searchId = 'search_' + Date.now();
|
||||
@@ -907,34 +951,75 @@ async function runSearch() {
|
||||
}
|
||||
|
||||
// ── Expand replies / retweets from selected node ──────────────────────────────
|
||||
// First click on a node = fresh fetch. If more is available, the button
|
||||
// relabels itself and a second click continues from the stored cursor —
|
||||
// same cursor/cooldown mechanism as the non-graph page's scroll load-more.
|
||||
var CURSOR_KEY = { tweet_replies_extractor: 'repliesCursor', tweet_retweeters_extractor: 'retweetersCursor' };
|
||||
var EXHAUSTED_KEY = { tweet_replies_extractor: 'repliesExhausted', tweet_retweeters_extractor: 'retweetersExhausted' };
|
||||
|
||||
async function expandNode(expandTool) {
|
||||
if (!selectedNode) return;
|
||||
var data = selectedNode.data();
|
||||
var tweetId = data.tweetId;
|
||||
if (!tweetId) { setStatus('Node has no tweet ID', true); return; }
|
||||
|
||||
var source = throttleSourceFor(expandTool, 'cookie'); // always 'cookie' — both expand tools are cookie-only
|
||||
var remaining = cooldownUntil[source] - Date.now();
|
||||
if (remaining > 0) {
|
||||
armExpandCountdown(remaining);
|
||||
return;
|
||||
}
|
||||
|
||||
var count = Math.max(1, Math.min(200, parseInt(document.getElementById('countInput').value, 10) || 50));
|
||||
var parentId = selectedNode.id();
|
||||
var cursor = selectedNode.data(CURSOR_KEY[expandTool]) || null;
|
||||
|
||||
document.getElementById('btnExpandReplies').disabled = true;
|
||||
document.getElementById('btnExpandRetweets').disabled = true;
|
||||
setStatus('Expanding…', false, true);
|
||||
setStatus(cursor ? 'Loading more…' : 'Expanding…', false, true);
|
||||
|
||||
try {
|
||||
var fetched = await apiFetch({ toolType: expandTool, mode: 'cookie', count: count, targetTweetId: tweetId });
|
||||
var items = Array.isArray(fetched) ? fetched : [fetched];
|
||||
var body = { toolType: expandTool, mode: 'cookie', count: count, targetTweetId: tweetId };
|
||||
if (cursor) body.cursor = cursor;
|
||||
var result = await apiFetch(body);
|
||||
stampCooldown(source);
|
||||
var items = Array.isArray(result.items) ? result.items : [result.items];
|
||||
var nodeType = expandTool === 'tweet_replies_extractor' ? 'reply' : 'retweeter';
|
||||
|
||||
var added = addNodes(items, nodeType, parentId);
|
||||
runLayout(false, false);
|
||||
updateNodeCount();
|
||||
setStatus('Expanded — ' + items.length + ' result(s) (' + added + ' new nodes)');
|
||||
|
||||
selectedNode.data(CURSOR_KEY[expandTool], result.nextCursor || null);
|
||||
selectedNode.data(EXHAUSTED_KEY[expandTool], !result.nextCursor);
|
||||
showPanel(selectedNode.data()); // refresh button labels/disabled state (e.g. "all loaded")
|
||||
} catch (e) {
|
||||
setStatus('Error: ' + e.message, true);
|
||||
} finally {
|
||||
if (e.retryAfter) {
|
||||
// Keep both buttons disabled for the cooldown window — armExpandCountdown
|
||||
// re-enables them itself once it elapses, so don't touch them here.
|
||||
armExpandCountdown(e.retryAfter * 1000);
|
||||
} else {
|
||||
setStatus('Error: ' + e.message, true);
|
||||
document.getElementById('btnExpandReplies').disabled = false;
|
||||
document.getElementById('btnExpandRetweets').disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function armExpandCountdown(msRemaining) {
|
||||
var secs = Math.max(1, Math.ceil(msRemaining / 1000));
|
||||
setStatus('Rate-limit cooldown — retry in ' + secs + 's…', false, true);
|
||||
// Both Expand buttons share one underlying X-account clock, so both wait together.
|
||||
document.getElementById('btnExpandReplies').disabled = true;
|
||||
document.getElementById('btnExpandRetweets').disabled = true;
|
||||
if (cooldownTimer) clearTimeout(cooldownTimer);
|
||||
cooldownTimer = setTimeout(function () {
|
||||
cooldownTimer = null;
|
||||
document.getElementById('btnExpandReplies').disabled = false;
|
||||
document.getElementById('btnExpandRetweets').disabled = false;
|
||||
}
|
||||
setStatus('Ready — click Expand again', false, false);
|
||||
}, msRemaining);
|
||||
}
|
||||
|
||||
// ── Info panel ────────────────────────────────────────────────────────────────
|
||||
@@ -961,6 +1046,32 @@ var SKIP_KEYS = new Set(['id', 'media', 'card', 'user_id', 'retweeted_by_user_id
|
||||
|
||||
var SOURCE_CLASS = { 'Twitter Cookie': 'src-cookie', 'Xquik API': 'src-xquik', 'Wayback Machine': 'src-wayback' };
|
||||
|
||||
// ── Media helpers — same normalization as the non-graph search page ───────────
|
||||
function extractMedia(item) {
|
||||
// Cookie mode: item.media = [{type, thumb, url}]
|
||||
if (Array.isArray(item.media) && item.media.length) {
|
||||
var first = item.media[0];
|
||||
if (first && typeof first === 'object' && ('thumb' in first || 'url' in first)) {
|
||||
return item.media;
|
||||
}
|
||||
}
|
||||
// API mode: item.extended_entities.media[] or item.entities.media[]
|
||||
var src = (item.extended_entities && item.extended_entities.media)
|
||||
|| (item.entities && item.entities.media);
|
||||
if (!Array.isArray(src)) return [];
|
||||
return src.map(function (m) {
|
||||
var mtype = m.type || 'photo';
|
||||
var thumb = m.media_url_https || m.media_url || '';
|
||||
var url = thumb;
|
||||
if (mtype === 'video' || mtype === 'animated_gif') {
|
||||
var variants = (m.video_info && m.video_info.variants) || [];
|
||||
var mp4s = variants.filter(function (v) { return v.content_type === 'video/mp4'; });
|
||||
if (mp4s.length) url = mp4s.reduce(function (b, v) { return (v.bitrate || 0) > (b.bitrate || 0) ? v : b; }).url;
|
||||
}
|
||||
return { type: mtype, thumb: thumb, url: url };
|
||||
}).filter(function (m) { return m.thumb; });
|
||||
}
|
||||
|
||||
function showPanel(data) {
|
||||
var raw = data.raw || {};
|
||||
var type = data.type;
|
||||
@@ -1000,23 +1111,24 @@ function showPanel(data) {
|
||||
return '<div class="info-row"><div class="info-key">' + label + '</div><div class="info-val">' + val + '</div></div>';
|
||||
}).join('');
|
||||
|
||||
// Media thumbnails
|
||||
var media = Array.isArray(raw.media) ? raw.media : [];
|
||||
// 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) {
|
||||
var thumb = m.thumb || '';
|
||||
var url = m.url || thumb;
|
||||
var mtype = m.type || 'photo';
|
||||
if (!thumb) return;
|
||||
if (mtype === 'photo') {
|
||||
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(
|
||||
'<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' +
|
||||
'<img src="' + esc(thumb) + '" loading="lazy" alt="media"></a>'
|
||||
'<video class="info-video" controls ' + loop + ' poster="' + esc(m.thumb) + '" preload="none">' +
|
||||
'<source src="' + proxied + '" type="video/mp4"></video>'
|
||||
);
|
||||
} else {
|
||||
mediaParts.push(
|
||||
'<a href="/api/video?url=' + encodeURIComponent(url) + '" target="_blank" rel="noopener noreferrer" class="video-link">▶ ' + esc(mtype) + '</a>'
|
||||
'<a href="' + esc(m.url) + '" target="_blank" rel="noopener noreferrer">' +
|
||||
'<img src="' + esc(m.thumb) + '" loading="lazy" alt="media"></a>'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1032,8 +1144,21 @@ function showPanel(data) {
|
||||
var tweetId = data.tweetId;
|
||||
var user = raw.user || raw.screen_name || '';
|
||||
|
||||
document.getElementById('btnExpandReplies').style.display = isTweet ? '' : 'none';
|
||||
document.getElementById('btnExpandRetweets').style.display = isTweet ? '' : 'none';
|
||||
var repliesBtn = document.getElementById('btnExpandReplies');
|
||||
var retweetersBtn = document.getElementById('btnExpandRetweets');
|
||||
repliesBtn.style.display = isTweet ? '' : 'none';
|
||||
retweetersBtn.style.display = isTweet ? '' : 'none';
|
||||
if (isTweet) {
|
||||
// Label reflects whether this node's already been expanded and whether
|
||||
// more is available — neither twikit nor the Wayback CDX API expose a
|
||||
// total count, only presence/absence of a next page, so no item count.
|
||||
repliesBtn.textContent = data.repliesExhausted ? '↩ Expand Replies (all loaded)'
|
||||
: data.repliesCursor ? '↩ Expand Replies (more available)' : '↩ Expand Replies';
|
||||
repliesBtn.disabled = !!data.repliesExhausted;
|
||||
retweetersBtn.textContent = data.retweetersExhausted ? '↗ Expand Retweets (all loaded)'
|
||||
: data.retweetersCursor ? '↗ Expand Retweets (more available)' : '↗ Expand Retweets';
|
||||
retweetersBtn.disabled = !!data.retweetersExhausted;
|
||||
}
|
||||
|
||||
var openBtn = document.getElementById('btnOpenTweet');
|
||||
if (isTweet && tweetId && user) {
|
||||
@@ -1058,30 +1183,69 @@ function hidePanel() {
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
setStatus('Archiving…', false, true);
|
||||
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({
|
||||
toolType: 'graph_' + tool,
|
||||
data: allItems.map(function (d) { return d.item; }),
|
||||
queryInfo: { query: query, source: 'graph', nodes: cy.nodes().length },
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
var json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error);
|
||||
setStatus('Archived — ID: ' + json.archiveId);
|
||||
graphArchivedId = json.archiveId;
|
||||
document.getElementById('btnArchiveAll').textContent = 'Update Archive';
|
||||
pollGraphArchive(json.archiveId);
|
||||
} catch (e) {
|
||||
setStatus('Archive error: ' + e.message, true);
|
||||
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; }
|
||||
@@ -1110,6 +1274,9 @@ function clearGraph() {
|
||||
allItems.length = 0;
|
||||
hidePanel();
|
||||
updateNodeCount();
|
||||
graphArchivedId = null;
|
||||
document.getElementById('btnArchiveAll').textContent = 'Archive All';
|
||||
document.getElementById('archiveBar').style.display = 'none';
|
||||
setStatus('Graph cleared');
|
||||
}
|
||||
|
||||
|
||||
@@ -447,6 +447,24 @@
|
||||
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;
|
||||
@@ -556,6 +574,7 @@
|
||||
.leaflet-control-attribution { background: rgba(26,29,39,0.8) !important; color: var(--muted) !important; font-size: 9px; }
|
||||
</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>
|
||||
|
||||
@@ -627,6 +646,9 @@
|
||||
<span class="toggle-label">Auto Archive</span>
|
||||
</div>
|
||||
<div id="archiveBar" class="archive-bar" style="display:none"></div>
|
||||
<button id="checkpointBtn" class="btn-download" style="display:none;width:100%;margin-top:8px">
|
||||
Save Checkpoint... Saved latest data
|
||||
</button>
|
||||
|
||||
<!-- Recent archives -->
|
||||
<div class="archives-section" id="archivesSection" style="display:none">
|
||||
@@ -649,6 +671,8 @@
|
||||
<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>
|
||||
@@ -693,11 +717,37 @@ const toggleTrack = document.getElementById('toggleTrack');
|
||||
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');
|
||||
const checkpointBtn = document.getElementById('checkpointBtn');
|
||||
|
||||
let currentMode = 'api';
|
||||
let currentData = null;
|
||||
let autoArchive = false;
|
||||
let currentPayload = null; // last run payload (for archive queryInfo)
|
||||
let archivedId = null; // archive id for the current run, once auto-archived — lets later load-more pages update it in place instead of creating a new archive
|
||||
|
||||
// ── Scroll-triggered load-more (Cookie mode + Wayback only) ────────────────
|
||||
// Tools/modes the backend actually paginates — everything else (xquik/API
|
||||
// mode, multi-source search) just gets a single page, same as before.
|
||||
const PAGINATED_TOOLS = new Set([
|
||||
'tweet_search_extractor', 'follower_explorer', 'post_extractor',
|
||||
'community_post_extractor', 'tweet_replies_extractor',
|
||||
'tweet_retweeters_extractor', 'geo_post_extractor',
|
||||
'wayback_archive_search',
|
||||
]);
|
||||
const THROTTLE_SECONDS = 5;
|
||||
|
||||
let nextCursor = null;
|
||||
let loadingMore = false;
|
||||
let cooldownUntil = { cookie: 0, wayback: 0 }; // Date.now()-based timestamps
|
||||
let cooldownTimer = null;
|
||||
|
||||
function throttleSourceFor(tool, mode) {
|
||||
if (tool === 'wayback_archive_search') return 'wayback';
|
||||
if (mode === 'cookie' && PAGINATED_TOOLS.has(tool)) return 'cookie';
|
||||
return null;
|
||||
}
|
||||
|
||||
const HINTS = {
|
||||
tweet_search_extractor: 'Search for tweets by keyword',
|
||||
@@ -850,16 +900,10 @@ function flatText(obj) {
|
||||
return Object.values(obj).map(flatText).join(' ');
|
||||
}
|
||||
|
||||
const PRIORITY = ['source','account_age_flag','account_age','account_created','retweeted_by_user','retweeted_by_name','retweeted_text','retweeted_by_bio','name','username','screen_name','user','user_id','text','full_text','content','title','description','bio','article_text','post_title','post_text','created_at','in_reply_to_tweet_id','archive_url','preview_image','iso_date','original','statuscode'];
|
||||
// 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(['text','full_text','content','description','bio','article_text','retweeted_text','retweeted_by_bio','post_text']);
|
||||
const SOURCE_CLASS = { 'Twitter Cookie': 'src-cookie', 'Xquik API': 'src-xquik', 'Wayback Machine': 'src-wayback' };
|
||||
// Keys that link out to a drill-down tool (key → target toolType)
|
||||
const DRILLABLE = {
|
||||
reply_count: 'tweet_replies_extractor',
|
||||
retweet_count: 'tweet_retweeters_extractor',
|
||||
in_reply_to_tweet_id: 'tweet_replies_extractor', // follow thread upward
|
||||
};
|
||||
|
||||
// ── Media helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -935,9 +979,17 @@ function buildCard(item) {
|
||||
}
|
||||
const tweetId = item.id ? String(item.id) : null;
|
||||
const entries = Object.entries(item);
|
||||
const pri = entries.filter(([k]) => PRIORITY.includes(k));
|
||||
// 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)).map(k => [k, item[k]]);
|
||||
const rest = entries.filter(([k]) => !PRIORITY.includes(k) && !SKIP.includes(k));
|
||||
const rows = [...pri, ...rest].slice(0, 14).map(([k, v]) => {
|
||||
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)
|
||||
@@ -953,8 +1005,7 @@ function buildCard(item) {
|
||||
const cls = SOURCE_CLASS[v] || '';
|
||||
display = `<span class="source-badge ${cls}">${esc(String(v))}</span>`;
|
||||
} else if (k === 'account_age_flag' && v) {
|
||||
const label = { new: 'New account', recent: 'Recent account', established: 'Established account' }[v] || v;
|
||||
display = `<span class="age-badge age-${esc(String(v))}">${esc(label)}</span>`;
|
||||
display = `<span class="age-badge age-${esc(String(v))}">${esc(AGE_LABELS[v] || v)}</span>`;
|
||||
} else if (v === null || v === undefined) {
|
||||
display = `<span style="color:var(--muted)">—</span>`;
|
||||
} else if (typeof v === 'object') {
|
||||
@@ -971,6 +1022,129 @@ function buildCard(item) {
|
||||
return `<div class="card">${rows}${renderMedia(media)}</div>`;
|
||||
}
|
||||
|
||||
// ── Load more (scroll-triggered pagination) ─────────────────────────────────
|
||||
// The sentinel lives as a SIBLING of #resultBox, not inside it — renderCards()
|
||||
// replaces #resultBox.innerHTML wholesale on every run and every keystroke in
|
||||
// the search box, which would destroy an in-box sentinel and its observer tie.
|
||||
|
||||
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(''));
|
||||
}
|
||||
const totalShown = resultBox.querySelectorAll('.card').length;
|
||||
resultCount.textContent = totalShown + (q ? ' found' : ' results');
|
||||
}
|
||||
|
||||
function resetPagination() {
|
||||
nextCursor = null;
|
||||
loadingMore = false;
|
||||
archivedId = null;
|
||||
if (cooldownTimer) { clearTimeout(cooldownTimer); cooldownTimer = null; }
|
||||
loadMoreStatus.classList.remove('visible');
|
||||
loadMoreStatus.textContent = '';
|
||||
checkpointBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function buildQueryInfo() {
|
||||
const queryInfo = { ...currentPayload };
|
||||
delete queryInfo.toolType;
|
||||
delete queryInfo.mode;
|
||||
delete queryInfo.count;
|
||||
return queryInfo;
|
||||
}
|
||||
|
||||
function stampCooldown(source) {
|
||||
if (!source) return;
|
||||
cooldownUntil[source] = Date.now() + THROTTLE_SECONDS * 1000;
|
||||
}
|
||||
|
||||
function maybeLoadMore() {
|
||||
if (!nextCursor || loadingMore || !currentPayload) return;
|
||||
const source = throttleSourceFor(currentPayload.toolType, currentPayload.mode);
|
||||
if (!source) return; // shouldn't happen if nextCursor is set, but be safe
|
||||
|
||||
const remaining = cooldownUntil[source] - Date.now();
|
||||
if (remaining > 0) {
|
||||
armCountdown(remaining);
|
||||
return;
|
||||
}
|
||||
doLoadMore(source);
|
||||
}
|
||||
|
||||
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…`;
|
||||
cooldownTimer = setTimeout(() => {
|
||||
cooldownTimer = null;
|
||||
maybeLoadMore(); // re-check: fires the load once the cooldown has cleared
|
||||
}, msRemaining);
|
||||
}
|
||||
|
||||
async function doLoadMore(source) {
|
||||
loadingMore = true;
|
||||
loadMoreStatus.classList.add('visible');
|
||||
loadMoreStatus.innerHTML = `<span class="spin-dot"></span>Loading more…`;
|
||||
|
||||
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(source);
|
||||
|
||||
if (!json.ok) {
|
||||
if (res.status === 429 && json.retryAfter) {
|
||||
loadingMore = false;
|
||||
armCountdown(json.retryAfter * 1000);
|
||||
return;
|
||||
}
|
||||
loadMoreStatus.textContent = `Load more failed: ${json.error}`;
|
||||
nextCursor = null;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
loadMoreStatus.classList.remove('visible');
|
||||
|
||||
// Auto-archive only covered whatever was loaded at the initial Run — new
|
||||
// pages from scroll need their own save. Once we've reached the end
|
||||
// (no more pages), save automatically; otherwise surface a manual
|
||||
// checkpoint button so the user can save without waiting to hit the end.
|
||||
if (autoArchive && archivedId) {
|
||||
if (!nextCursor) {
|
||||
checkpointBtn.style.display = 'none';
|
||||
triggerArchive(currentData, currentPayload.toolType, buildQueryInfo(), archivedId);
|
||||
} else {
|
||||
checkpointBtn.style.display = '';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
loadMoreStatus.textContent = `Load more failed: ${e}`;
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
@@ -998,6 +1172,7 @@ runBtn.addEventListener('click', async () => {
|
||||
}
|
||||
|
||||
currentPayload = payload;
|
||||
resetPagination();
|
||||
|
||||
runBtn.disabled = true;
|
||||
runBtn.textContent = 'Running…';
|
||||
@@ -1018,9 +1193,15 @@ runBtn.addEventListener('click', async () => {
|
||||
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 = '';
|
||||
|
||||
@@ -1041,11 +1222,7 @@ runBtn.addEventListener('click', async () => {
|
||||
renderCards(currentData);
|
||||
|
||||
if (autoArchive) {
|
||||
const queryInfo = { ...currentPayload };
|
||||
delete queryInfo.toolType;
|
||||
delete queryInfo.mode;
|
||||
delete queryInfo.count;
|
||||
triggerArchive(json.data, t, queryInfo);
|
||||
triggerArchive(json.data, t, buildQueryInfo());
|
||||
} else {
|
||||
archiveBar.style.display = 'none';
|
||||
}
|
||||
@@ -1072,21 +1249,32 @@ archiveToggle.addEventListener('click', () => {
|
||||
if (autoArchive) loadRecentArchives();
|
||||
});
|
||||
|
||||
async function triggerArchive(data, toolType, queryInfo) {
|
||||
async function triggerArchive(data, toolType, queryInfo, existingId) {
|
||||
archiveBar.style.display = '';
|
||||
archiveBar.innerHTML = '<span>Archiving…</span><div class="archive-bar-fill-wrap"><div class="archive-bar-fill" style="width:0%"></div></div>';
|
||||
const label = existingId ? 'Saving checkpoint…' : 'Archiving…';
|
||||
archiveBar.innerHTML = `<span>${label}</span><div class="archive-bar-fill-wrap"><div class="archive-bar-fill" style="width:0%"></div></div>`;
|
||||
|
||||
const body = { data, toolType, queryInfo };
|
||||
if (existingId) body.archiveId = existingId;
|
||||
|
||||
const res = await fetch('/api/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data, toolType, queryInfo }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) { archiveBar.innerHTML = `<span style="color:var(--danger)">Archive failed: ${esc(json.error)}</span>`; return; }
|
||||
|
||||
archivedId = json.archiveId;
|
||||
pollArchive(json.archiveId);
|
||||
}
|
||||
|
||||
checkpointBtn.addEventListener('click', () => {
|
||||
if (!archivedId || !currentPayload) return;
|
||||
checkpointBtn.style.display = 'none';
|
||||
triggerArchive(currentData, currentPayload.toolType, buildQueryInfo(), archivedId);
|
||||
});
|
||||
|
||||
function pollArchive(archiveId) {
|
||||
const interval = setInterval(async () => {
|
||||
const res = await fetch(`/api/archive/${archiveId}/status`);
|
||||
@@ -1189,6 +1377,9 @@ function setView(v) {
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user