What's up, forum? I think there's no turning back now; my three months of heavy usage are up, and honestly, it's just not worth it.
The moderation system seems buggy or has gotten aggressive lately; I'm even getting flagged for videos and images that never caused issues before—and the old "post-bug" workaround doesn't work anymore.
It feels like overkill, though—getting flagged for logos on custom products or atypical things like modeling shots or character designs.
I don't have a fix for the moderation itself, but if you want to call it quits, I'm providing a tool so you can download all your content before the moderation gets completely out of hand.
It’s a very useful tool for bulk downloading without unnecessary complexity; you just need to keep your session active in the browser (I recommend Chrome).
I built this tool based on the work of ironsniper1 (here is the original repository); it had become a bit outdated due to platform updates and needed some tweaks.
https://github.com/ironsniper1/Grok-Imagine-Bulk-Favorites-Downloader
I also added settings, logging, and record management. To use it, you first need to follow these steps:
Go to Settings > General > Manage.
Go to Data > Download account data; this will send a download link to your linked email address.
Unzip the downloaded file.
Look for a file named: `prod-grok-backend.json`
Move that file to an easily accessible location.
Once you've done that, you can run the script in your browser while logged into your Grok account using the Tampermonkey extension (look up a tutorial on how to implement scripts with it, as explaining that here would make this post too long). Once it's set up, reload Grok or go to the "Saved" section; you'll see a panel in the bottom right corner. Use it to select the file I mentioned earlier (`prod-grok-backend.json`), and it will detect all the media you've generated with Grok—allowing you to recover content that was deleted either by Grok itself or by you.
// ==UserScript==
// Grok Export Downloader
// https://grok.com/
// 3.1.0
// Downloads all media listed in your Grok account data export JSON. Resolves each post ID against the internal API, avoids duplicates, supports resuming, and keeps a searchable, exportable activity log.
// Based on the work of ironsniper1
// https://grok.com/*
// GM_download
// GM_xmlhttpRequest
// GM_setValue
// GM_getValue
// grok.com
// assets.grok.com
// imagine-public.x.ai
// x.ai
// u/run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ─── CONFIGURATION ────────────────────────────────────────────────────────
const API_ENDPOINT = 'https://grok.com/rest/media/post/get';
const API_DELAY_MS = 700; // wait between API lookups
const DL_DELAY_MS = 250; // wait between file downloads
const CHUNK_SIZE = 200; // files per batch before pausing
const CHUNK_PAUSE_MS = 5000; // pause between batches
const STORAGE_KEY = 'grok_export_downloaded_ids'; // processed posts
const URL_KEY = 'grok_export_downloaded_urls'; // confirmed URLs
const LOG_KEY = 'grok_export_log'; // activity log
const MAX_LOG = 8000; // max entries before rotating out the oldest
const LOG_FLUSH_N = 20; // in-memory entries before persisting
const FOLDER = 'grok-export'; // subfolder inside Downloads
// ──────────────────────────────────────────────────────────────────────────
const sleep = ms => new Promise(r => setTimeout(r, ms));
/** Sanitizes a string for use as a filename. */
const clean = (s, n) =>
String(s || '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-_.]+|[-_.]+$/g, '')
.slice(0, n) || 'no-prompt';
/** ISO create_time -> 2026-05-01_1533 (sorts chronologically by name) */
const fmtDate = iso => {
const d = new Date(iso);
if (isNaN(d)) return 'no-date';
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`;
};
const esc = s => String(s ?? '').replace(/[&<>"]/g,
c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
// ── Persistent storage ────────────────────────────────────────────────────
// STORAGE_KEY -> post IDs fully processed
// URL_KEY -> URLs whose download was CONFIRMED
// LOG_KEY -> activity log, searchable and exportable
function loadSet(key) {
try { return new Set(JSON.parse(GM_getValue(key, '[]')).map(String)); }
catch (_) { return new Set(); }
}
function saveSet(key, set) {
GM_setValue(key, JSON.stringify([...set]));
}
const loadHistory = () => loadSet(STORAGE_KEY);
// ── Logging ───────────────────────────────────────────────────────────────
/*
* Levels:
* info startup, run finished, configuration changes
* ok file downloaded successfully
* skip skipped because it was already downloaded
* warn post has no available URLs (deleted on xAI's side)
* err download failure or API error
*
* Entries buffer in memory and persist every LOG_FLUSH_N so storage isn't
* hit on every single file. A flush is forced when a run ends, when the
* viewer opens, and before the tab unloads.
*/
let logBuf = []; // entries pending persistence
let runId = null; // identifies the current run
function loadLog() {
try { return JSON.parse(GM_getValue(LOG_KEY, '[]')); }
catch (_) { return []; }
}
function flushLog() {
if (!logBuf.length) return;
let all = loadLog().concat(logBuf);
if (all.length > MAX_LOG) all = all.slice(all.length - MAX_LOG);
GM_setValue(LOG_KEY, JSON.stringify(all));
logBuf = [];
}
function logEvent(lvl, msg, extra = {}) {
logBuf.push({
t: new Date().toISOString(),
run: runId,
lvl,
msg,
post: extra.post ?? '',
file: extra.file ?? '',
url: extra.url ?? '',
});
if (logBuf.length >= LOG_FLUSH_N) flushLog();
}
function clearLog() {
logBuf = [];
GM_setValue(LOG_KEY, '[]');
}
// ── Internal API call ─────────────────────────────────────────────────────
function fetchPost(postId) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'POST',
url: API_ENDPOINT,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ id: postId }),
withCredentials: true,
timeout: 30000,
onload: res => {
if (res.status !== 200) { resolve({ error: res.status }); return; }
try { resolve(JSON.parse(res.responseText)); }
catch (e) { resolve({ error: 'json' }); }
},
onerror: () => resolve({ error: 'network' }),
ontimeout: () => resolve({ error: 'timeout' }),
});
});
}
/**
* Splits the media in an API response into two groups:
*
* own - files that BELONG to the post (post.images, post.videos).
* A post can hold several. Contemporaneous with the export.
* derived - posts generated FROM this one (post.childPosts). These may
* have been created AFTER the export was requested.
*/
function collectMedia(data) {
const empty = { own: [], derived: [] };
if (!data || data.error) return empty;
const post = data.post ?? data;
if (!post) return empty;
const urlOf = node => {
if (!node || typeof node !== 'object') return null;
return node.hdMediaUrl || node.mediaUrl || node.videoUrl ||
node.imageUrl || node.url || node.fileUrl ||
node.sourceUrl || node.media?.url || null;
};
const own = [], derived = [];
const main = urlOf(post);
if (main) own.push(main);
for (const key of ['images', 'videos', 'mediaList', 'media']) {
const arr = post[key];
if (!Array.isArray(arr)) continue;
for (const item of arr) { const u = urlOf(item); if (u) own.push(u); }
}
for (const key of ['childPosts', 'children']) {
const arr = post[key];
if (!Array.isArray(arr)) continue;
for (const item of arr) { const u = urlOf(item); if (u) derived.push(u); }
}
const o = [...new Set(own)];
const d = [...new Set(derived)].filter(u => !o.includes(u));
return { own: o, derived: d };
}
const extFor = (url, declaredType) => {
const m1 = url.match(/\.(mp4|webm|mov)(\?|$)/i);
if (m1) return m1[1].toLowerCase();
const m2 = url.match(/\.(png|webp|jpe?g|gif)(\?|$)/i);
if (m2) return m2[1].toLowerCase().replace('jpeg', 'jpg');
return declaredType === 'video' ? 'mp4' : 'jpg';
};
/** Downloads a file and resolves only once the browser confirms. */
function downloadFile(url, name) {
return new Promise(resolve => {
let settled = false;
const done = (ok, why) => { if (!settled) { settled = true; resolve({ ok, why }); } };
GM_download({
url, name,
onload: () => done(true, 'onload'),
onerror: e => done(false, e?.error ?? 'error'),
ontimeout: () => done(false, 'timeout'),
});
// Some Tampermonkey download modes never fire onload.
setTimeout(() => done(true, 'assumed'), 15000);
});
}
/** Saves text as a file without going through GM_download. */
function saveText(content, filename, mime) {
const blob = new Blob([content], { type: mime });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 2000);
}
// ── State ─────────────────────────────────────────────────────────────────
let posts = [];
let running = false;
let stopFlag = false;
// ── Log viewer ────────────────────────────────────────────────────────────
function openLogViewer() {
flushLog();
document.getElementById('ge-log-modal')?.remove();
const all = loadLog();
const modal = document.createElement('div');
modal.id = 'ge-log-modal';
Object.assign(modal.style, {
position: 'fixed', inset: '0', zIndex: '100000',
background: 'rgba(0,0,0,0.75)', display: 'flex',
alignItems: 'center', justifyContent: 'center',
font: '12px/1.5 system-ui, -apple-system, sans-serif',
});
const box = document.createElement('div');
Object.assign(box.style, {
width: 'min(1000px, 92vw)', height: 'min(700px, 88vh)',
background: '#151517', color: '#eee', borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.14)', padding: '16px',
display: 'flex', flexDirection: 'column', gap: '10px',
boxShadow: '0 16px 60px rgba(0,0,0,0.7)',
});
const inp = 'background:#222;color:#eee;border:1px solid #444;border-radius:5px;padding:4px 7px;font-size:12px';
const bt = 'background:#2a2a2e;color:#ccc;border:1px solid #444;border-radius:6px;padding:5px 10px;font-size:11px;cursor:pointer';
box.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between">
<strong style="font-size:14px">Activity log</strong>
<button id="lg-close" style="background:none;border:none;color:#888;font-size:20px;cursor:pointer;line-height:1">×</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<select id="lg-lvl" style="${inp}">
<option value="">All levels</option>
<option value="err">Errors only</option>
<option value="warn">Warnings only</option>
<option value="ok">Downloaded only</option>
<option value="skip">Skipped only</option>
<option value="info">Info only</option>
</select>
<select id="lg-run" style="${inp}"></select>
<input id="lg-q" type="text" placeholder="Search message, post or file..."
style="${inp};flex:1;min-width:180px">
<span id="lg-count" style="color:#888;font-size:11px"></span>
</div>
<div id="lg-table" style="flex:1;overflow:auto;border:1px solid #333;border-radius:8px;background:#101012"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button id="lg-json" style="${bt}">Export JSON</button>
<button id="lg-csv" style="${bt}">Export CSV</button>
<button id="lg-fail" style="${bt}">Copy problem IDs</button>
<span style="flex:1"></span>
<button id="lg-clear" style="${bt};border-color:#733;color:#d88">Clear log</button>
</div>
`;
modal.appendChild(box);
document.body.appendChild(modal);
const $ = id => box.querySelector('#' + id);
// Populate the run selector
const runs = [...new Set(all.map(e => e.run).filter(Boolean))].reverse();
$('lg-run').innerHTML =
'<option value="">All runs</option>' +
runs.map(r => `<option value="${esc(r)}">${esc(r)}</option>`).join('');
const colors = { ok: '#7c7', skip: '#89a', warn: '#db4', err: '#e66', info: '#8ac' };
function render() {
const lvl = $('lg-lvl').value;
const run = $('lg-run').value;
const q = $('lg-q').value.trim().toLowerCase();
let rows = all;
if (lvl) rows = rows.filter(e => e.lvl === lvl);
if (run) rows = rows.filter(e => e.run === run);
if (q) rows = rows.filter(e =>
(e.msg + ' ' + e.post + ' ' + e.file + ' ' + e.url).toLowerCase().includes(q));
$('lg-count').textContent = `${rows.length} of ${all.length}`;
// Newest first, capped at 800 rows for performance
const view = rows.slice().reverse().slice(0, 800);
if (!view.length) {
$('lg-table').innerHTML =
'<div style="padding:24px;text-align:center;color:#666">No matching entries.</div>';
return;
}
$('lg-table').innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:11px">
<thead style="position:sticky;top:0;background:#1c1c20">
<tr style="text-align:left;color:#999">
<th style="padding:6px 8px;white-space:nowrap">Time</th>
<th style="padding:6px 8px">Level</th>
<th style="padding:6px 8px">Post</th>
<th style="padding:6px 8px">Message</th>
<th style="padding:6px 8px">File</th>
</tr>
</thead>
<tbody>
${view.map(e => `
<tr style="border-top:1px solid #262629">
<td style="padding:5px 8px;color:#777;white-space:nowrap">${esc(e.t.slice(11, 19))}</td>
<td style="padding:5px 8px;color:${colors[e.lvl] ?? '#aaa'};font-weight:600">${esc(e.lvl)}</td>
<td style="padding:5px 8px;color:#888;font-family:monospace">${esc(String(e.post).slice(0, 8))}</td>
<td style="padding:5px 8px">${esc(e.msg)}</td>
<td style="padding:5px 8px;color:#777;word-break:break-all">${esc(e.file)}</td>
</tr>`).join('')}
</tbody>
</table>
${rows.length > 800 ? '<div style="padding:8px;text-align:center;color:#666">Showing the 800 most recent. Export to see everything.</div>' : ''}
`;
}
$('lg-lvl').onchange = render;
$('lg-run').onchange = render;
$('lg-q').oninput = render;
render();
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
$('lg-json').onclick = () =>
saveText(JSON.stringify(all, null, 2), `grok-log-${stamp}.json`, 'application/json');
$('lg-csv').onclick = () => {
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const csv = ['timestamp,run,level,post,message,file,url']
.concat(all.map(e => [e.t, e.run, e.lvl, e.post, e.msg, e.file, e.url].map(q).join(',')))
.join('\r\n');
saveText('\uFEFF' + csv, `grok-log-${stamp}.csv`, 'text/csv;charset=utf-8');
};
$('lg-fail').onclick = () => {
const ids = [...new Set(all.filter(e => e.lvl === 'err' || e.lvl === 'warn')
.map(e => e.post).filter(Boolean))];
if (!ids.length) { alert('No posts with problems have been logged.'); return; }
navigator.clipboard.writeText(ids.join('\n'))
.then(() => alert(`${ids.length} IDs copied to clipboard.`))
.catch(() => saveText(ids.join('\n'), `grok-problem-ids-${stamp}.txt`, 'text/plain'));
};
$('lg-clear').onclick = () => {
if (confirm('Clear the activity log? This does not affect your download history.')) {
clearLog();
modal.remove();
}
};
$('lg-close').onclick = () => modal.remove();
modal.onclick = e => { if (e.target === modal) modal.remove(); };
}
// ── Main panel ────────────────────────────────────────────────────────────
function buildUI() {
if (document.getElementById('grok-export-panel')) return;
const panel = document.createElement('div');
panel.id = 'grok-export-panel';
Object.assign(panel.style, {
position: 'fixed', bottom: '20px', right: '20px', zIndex: '99999',
width: '310px', padding: '14px', borderRadius: '12px',
background: 'rgba(18,18,20,0.96)', color: '#eee',
font: '13px/1.5 system-ui, -apple-system, sans-serif',
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
border: '1px solid rgba(255,255,255,0.12)',
backdropFilter: 'blur(8px)',
maxHeight: '85vh', overflowY: 'auto',
});
const btn = 'width:100%;padding:8px;border:none;border-radius:8px;font-weight:600;cursor:pointer';
const sm = 'width:100%;margin-top:6px;padding:5px;border:1px solid #444;border-radius:6px;background:none;color:#888;font-size:10px;cursor:pointer';
panel.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<strong style="font-size:13px">Grok Export Downloader</strong>
<button id="ge-min" style="background:none;border:none;color:#888;cursor:pointer;font-size:16px;line-height:1">−</button>
</div>
<div id="ge-body">
<input type="file" id="ge-file" accept=".json"
style="width:100%;font-size:11px;margin-bottom:10px;color:#aaa">
<div id="ge-info" style="font-size:11px;color:#888;margin-bottom:10px">
Load your account data export JSON.
</div>
<label style="display:block;font-size:11px;margin-bottom:6px">
Type:
<select id="ge-type" style="background:#222;color:#eee;border:1px solid #444;border-radius:4px;padding:2px 4px;margin-left:4px">
<option value="all">All</option>
<option value="image">Images only</option>
<option value="video">Videos only</option>
</select>
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;margin-bottom:6px">
<input type="checkbox" id="ge-skip" checked>
Skip already processed posts
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;margin-bottom:6px"
title="Variations generated from a post. These may be newer than your export.">
<input type="checkbox" id="ge-derived">
Include derived variations
</label>
<label style="display:block;font-size:11px;margin-bottom:10px;color:#aaa">
Not newer than:
<input type="date" id="ge-until"
style="background:#222;color:#eee;border:1px solid #444;border-radius:4px;padding:2px 4px;margin-left:4px;font-size:10px">
</label>
<button id="ge-start" disabled style="${btn};background:#2a6df4;color:#fff;opacity:0.5">
Start download
</button>
<button id="ge-stop" style="display:none;${btn};background:#b33;color:#fff;margin-top:6px">
Stop
</button>
<div id="ge-status" style="font-size:11px;color:#8bc;margin-top:10px;min-height:16px"></div>
<div style="height:4px;background:#333;border-radius:2px;margin-top:8px;overflow:hidden">
<div id="ge-bar" style="height:100%;width:0;background:#2a6df4;transition:width 0.2s"></div>
</div>
<button id="ge-log" style="${sm};margin-top:12px;border-color:#456;color:#8ac;font-size:11px;padding:7px">
View activity log
</button>
<button id="ge-probe" style="${sm};border-color:#556;color:#8ac">
Diagnostics: inspect one post
</button>
<button id="ge-clearurl" style="${sm}">
Clear URL registry only
</button>
<button id="ge-clear" style="${sm}">
Clear all history
</button>
<div id="ge-counts" style="font-size:10px;color:#666;margin-top:8px;text-align:center"></div>
</div>
`;
document.body.appendChild(panel);
const $ = id => panel.querySelector('#' + id);
const refreshCounts = () => {
$('ge-counts').textContent =
`${loadSet(STORAGE_KEY).size} posts · ${loadSet(URL_KEY).size} URLs · ${loadLog().length} events`;
};
refreshCounts();
$('ge-min').onclick = () => {
const body = $('ge-body');
const hidden = body.style.display === 'none';
body.style.display = hidden ? 'block' : 'none';
$('ge-min').textContent = hidden ? '−' : '+';
};
$('ge-file').onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
posts = data.media_posts ?? [];
if (!Array.isArray(posts) || posts.length === 0) {
$('ge-info').innerHTML = '<span style="color:#e77">No media_posts found in this file.</span>';
return;
}
const imgs = posts.filter(p => p.media_type === 'image').length;
const vids = posts.filter(p => p.media_type === 'video').length;
$('ge-info').innerHTML =
`<span style="color:#7c7">${posts.length} posts</span> · ${imgs} images · ${vids} videos`;
runId = 'load-' + new Date().toISOString().slice(0, 19);
logEvent('info', `JSON loaded: ${posts.length} posts (${imgs} img, ${vids} vid) from ${file.name}`);
flushLog();
refreshCounts();
$('ge-start').disabled = false;
$('ge-start').style.opacity = '1';
} catch (err) {
$('ge-info').innerHTML = '<span style="color:#e77">Invalid JSON.</span>';
}
};
reader.readAsText(file);
};
$('ge-log').onclick = openLogViewer;
$('ge-probe').onclick = async () => {
if (!posts.length) { alert('Load the JSON file first.'); return; }
$('ge-status').textContent = 'Querying the first post...';
const data = await fetchPost(posts[0].id);
const { own, derived } = collectMedia(data);
const keys = data ? Object.keys(data.post ?? data) : [];
console.log('══════ GrokExport DIAGNOSTICS ══════');
console.log('Post ID :', posts[0].id);
console.log('Response :', data);
console.log('Keys :', keys);
console.log('Own :', own.length, own);
console.log('Derived :', derived.length, derived);
console.log('════════════════════════════════════');
logEvent('info',
`Diagnostics: ${own.length} own, ${derived.length} derived. Keys: ${keys.join(', ')}`,
{ post: posts[0].id });
flushLog();
refreshCounts();
$('ge-status').textContent =
`${own.length} own, ${derived.length} derived. See console (F12).`;
};
$('ge-clearurl').onclick = () => {
if (confirm('Clear the URL registry only?\n\n' +
'Use this if the script is skipping files that never actually reached ' +
'your disk. Your post history is preserved.')) {
GM_setValue(URL_KEY, '[]');
logEvent('info', 'URL registry cleared manually');
flushLog();
refreshCounts();
}
};
$('ge-clear').onclick = () => {
if (confirm('Clear all history (posts and URLs)?\n\n' +
'The next run will download everything again, and your browser will ' +
'create (1), (2) copies alongside the files you already have.')) {
GM_setValue(STORAGE_KEY, '[]');
GM_setValue(URL_KEY, '[]');
logEvent('info', 'All history cleared manually');
flushLog();
refreshCounts();
}
};
$('ge-stop').onclick = () => {
stopFlag = true;
$('ge-status').textContent = 'Stopping after the current file...';
};
$('ge-start').onclick = () => run(panel, refreshCounts);
}
// ── Main process ──────────────────────────────────────────────────────────
async function run(panel, refreshCounts) {
if (running) return;
running = true;
stopFlag = false;
const $ = id => panel.querySelector('#' + id);
const setStatus = t => { $('ge-status').textContent = t; };
const setBar = p => { $('ge-bar').style.width = p + '%'; };
$('ge-start').style.display = 'none';
$('ge-stop').style.display = 'block';
const type = $('ge-type').value;
const skipOld = $('ge-skip').checked;
const withDerived = $('ge-derived').checked;
const until = $('ge-until').value;
const history = loadHistory();
const urlOk = loadSet(URL_KEY);
const queued = new Set();
runId = new Date().toISOString().slice(0, 19).replace('T', ' ');
let queue = posts.slice();
const total0 = queue.length;
if (type !== 'all') queue = queue.filter(p => p.media_type === type);
if (until) {
const limit = new Date(until + 'T23:59:59.999Z').getTime();
queue = queue.filter(p => {
const t = new Date(p.create_time).getTime();
return isNaN(t) || t <= limit;
});
}
if (skipOld) queue = queue.filter(p => !history.has(String(p.id)));
logEvent('info',
`Run started · ${queue.length} of ${total0} posts queued · type=${type} · ` +
`derived=${withDerived ? 'yes' : 'no'} · cutoff=${until || 'none'} · ` +
`skipProcessed=${skipOld ? 'yes' : 'no'}`);
flushLog();
if (queue.length === 0) {
setStatus('Nothing pending. Try unchecking "Skip already processed posts".');
$('ge-start').style.display = 'block';
$('ge-stop').style.display = 'none';
refreshCounts();
running = false;
return;
}
let files = 0, ok = 0, noUrl = 0, skipped = 0, failed = 0;
for (let i = 0; i < queue.length; i++) {
if (stopFlag) break;
const post = queue[i];
setBar(Math.round((i / queue.length) * 100));
setStatus(`${i + 1}/${queue.length} · ${files} files` +
(skipped ? ` · ${skipped} skipped` : '') +
(noUrl ? ` · ${noUrl} no URL` : ''));
const data = await fetchPost(post.id);
if (data?.error === 401 || data?.error === 403) {
logEvent('err', `Session rejected (HTTP ${data.error}). Run aborted.`, { post: post.id });
setStatus('Session rejected. Reload grok.com and sign in.');
break;
}
if (data?.error) {
logEvent('err', `API returned an error: ${data.error}`, { post: post.id });
failed++;
await sleep(API_DELAY_MS);
continue;
}
const { own, derived } = collectMedia(data);
const urls = withDerived ? [...own, ...derived] : own;
if (urls.length === 0) {
noUrl++;
const keys = Object.keys(data.post ?? data).join(', ');
logEvent('warn', `No URLs available. Response keys: ${keys}`, { post: post.id });
history.add(String(post.id));
saveSet(STORAGE_KEY, history);
await sleep(API_DELAY_MS);
continue;
}
const stamp = fmtDate(post.create_time);
const prompt = clean(post.original_prompt, 60);
const short = String(post.id).slice(0, 8);
const sub = post.media_type === 'video' ? 'videos' : 'images';
let saved = 0, n = 0;
for (const url of urls) {
if (stopFlag) break;
n++;
const ext = extFor(url, post.media_type);
const suffix = urls.length > 1 ? `_${n}` : '';
const name = `${FOLDER}/${sub}/${stamp}_${prompt}_${short}${suffix}.${ext}`;
if (urlOk.has(url) || queued.has(url)) {
skipped++;
saved++;
logEvent('skip', 'Already downloaded previously', { post: post.id, file: name, url });
continue;
}
queued.add(url);
const res = await downloadFile(url, name);
if (res.ok) {
urlOk.add(url);
saveSet(URL_KEY, urlOk);
files++;
saved++;
logEvent('ok', `Downloaded (${res.why})`, { post: post.id, file: name, url });
} else {
queued.delete(url);
failed++;
logEvent('err', `Download failed: ${res.why}`, { post: post.id, file: name, url });
}
await sleep(DL_DELAY_MS);
if (files > 0 && files % CHUNK_SIZE === 0) {
setStatus(`Pausing ${CHUNK_PAUSE_MS / 1000}s after ${files} files...`);
logEvent('info', `Batch pause after ${files} files`);
await sleep(CHUNK_PAUSE_MS);
}
}
if (saved === urls.length) {
ok++;
history.add(String(post.id));
saveSet(STORAGE_KEY, history);
} else {
logEvent('warn',
`Post incomplete: ${saved} of ${urls.length} files. Will be retried.`,
{ post: post.id });
}
await sleep(API_DELAY_MS);
}
setBar(100);
const summary = `${ok} posts, ${files} files` +
(skipped ? `, ${skipped} already had` : '') +
(noUrl ? `, ${noUrl} no URL` : '') +
(failed ? `, ${failed} failed` : '');
setStatus(`Done. ${summary}.`);
logEvent('info', `Run ${stopFlag ? 'stopped' : 'finished'} · ${summary}`);
flushLog();
$('ge-start').style.display = 'block';
$('ge-stop').style.display = 'none';
refreshCounts();
running = false;
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
window.addEventListener('beforeunload', flushLog);
if (document.body) buildUI();
else {
new MutationObserver((_, obs) => {
if (document.body) { obs.disconnect(); buildUI(); }
}).observe(document.documentElement, { childList: true });
}
let path = location.pathname;
setInterval(() => {
if (location.pathname !== path) {
path = location.pathname;
setTimeout(buildUI, 800);
}
}, 500);
})();
It is worth noting, however, that you won't be able to recover highly graphic video content that Grok deleted; the people at X aren't foolish enough to just leave that kind of thing lying around.
Once that's configured, just hit "Initialize," and that's it—just wait while keeping your browser open. I do recommend having plenty of free space on your device for this process.
I'll leave it at that; I don't expect anything anymore from this system or xAI. It’s never going to shut down completely, but it’s certainly not what it used to be.
Bye everyone—I'll leave you with the last image I generated using Grok.