This script allows to download all videos and photos (including from child posts) from your Image Favorites page.
Size
8.1 KB
Version
2025-11-18
Created
Feb 27, 2026
Updated
about 2 months ago
1// ==UserScript==
2// @name Grok Imagine Downloader - Bulk Save High-Quality Media
3// @namespace https://grok.com
4// @version 2025-11-18
5// @description This script allows to download all videos and photos (including from child posts) from your Image Favorites page.
6// @author Mykyta Shcherbyna
7// @match https://grok.com/*
8// @icon https://www.google.com/s2/favicons?sz=64&domain=grok.com
9// @license MIT
10// @grant GM_download
11// @grant unsafeWindow
12// @grant GM_xmlhttpRequest
13// @run-at document-start
14// @connect assets.grok.com
15// @connect imagine-public.x.ai
16// @downloadURL https://update.greasyfork.org/scripts/556188/Grok%20Imagine%20Downloader%20-%20Bulk%20Save%20High-Quality%20Media.user.js
17// @updateURL https://update.greasyfork.org/scripts/556188/Grok%20Imagine%20Downloader%20-%20Bulk%20Save%20High-Quality%20Media.meta.js
18// ==/UserScript==
19
20(function () {
21 'use strict';
22
23 const CARD_SELECTOR = '.group\\/media-post-masonry-card:not([data-downloader-added])';
24 const BUTTON_CONTAINER_SELECTOR = '.absolute.bottom-2.right-2';
25 const BUTTON_CLASSES = 'inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium leading-[normal] cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-60 disabled:cursor-not-allowed transition-colors duration-100 select-none rounded-full overflow-hidden h-10 w-10 p-2 bg-black/25 hover:bg-white/10 border border-white/15 text-white text-xs font-bold';
26 const DOWNLOAD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-download size-4"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" x2="12" y1="15" y2="3"></line></svg>`;
27
28 const mediaDatabase = new Map();
29
30 function extractPostIdFromUrl(url) {
31 if (!url) return null;
32 const matches = [...url.matchAll(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/g)];
33 return matches.length > 0 ? matches[matches.length - 1][0] : null;
34 }
35
36 function sanitizeForFilename(str) {
37 return (str || '').replace(/[/\\?%*:|"<>]/g, '_').replace(/\s+/g, '_');
38 }
39
40 function buildFilename(item) {
41 const time = item.createTime ? item.createTime.slice(0, 19).replace(/:/g, '-') : 'unknown';
42 const model = item.modelName ? `_${sanitizeForFilename(item.modelName)}` : '';
43 let prompt = item.prompt ? `_${sanitizeForFilename(item.prompt)}` : '';
44
45 if (prompt.length > 180) prompt = prompt.slice(0, 177) + '...';
46
47 let ext = item.isVideo ? 'mp4' : 'jpg';
48 if (item.mimeType) {
49 if (item.mimeType === 'video/mp4') ext = 'mp4';
50 else if (item.mimeType === 'image/png') ext = 'png';
51 else if (item.mimeType === 'image/jpeg') ext = 'jpg';
52 }
53
54 return `${time}_${item.id}${model}${prompt}.${ext}`;
55 }
56
57 function downloadFile(item, onComplete) {
58 GM_download({
59 url: item.url,
60 name: item.filename,
61 onload: onComplete,
62 onerror: onComplete,
63 ontimeout: onComplete
64 });
65 }
66
67 function startDownloads(media, postId, button) {
68 const all = media.object;
69 if (all.length === 0) return;
70
71 let completed = 0;
72 let failed = 0;
73 const total = all.length;
74
75 button.textContent = `0/${total}`;
76 button.style.pointerEvents = 'none';
77 button.disabled = true;
78
79 const onComplete = () => {
80 completed++;
81 button.textContent = `${completed}/${total}`;
82 if ((completed + failed) === total) {
83 button.disabled = failed === 0;
84 setTimeout(() => {
85 button.textContent = failed > 0 ? 'ERR' : 'OK!';
86 }, 500);
87 }
88 };
89
90 all.forEach(item => {
91 downloadFile(item, onComplete);
92 });
93 }
94
95 function createMediaObject(source, fallbackParent) {
96 const isVideo = source.mediaType === 'MEDIA_POST_TYPE_VIDEO';
97 const url = isVideo && source.hdMediaUrl ? source.hdMediaUrl : source.mediaUrl;
98
99 let item = {
100 id: source.id,
101 url: url,
102 createTime: source.createTime || fallbackParent?.createTime || '',
103 modelName: source.modelName || fallbackParent?.modelName || '',
104 prompt: (source.originalPrompt || source.prompt || fallbackParent?.originalPrompt || fallbackParent?.prompt || '').trim(),
105 isVideo: isVideo,
106 mimeType: source.mimeType
107 };
108
109 const filename = buildFilename(item);
110
111 return {
112 id: item.id,
113 url: item.url,
114 createTime: item.createTime,
115 modelName: item.modelName,
116 prompt: item.prompt,
117 filename: filename
118 };
119 }
120
121 function processApiData(apiData) {
122 if (!apiData?.posts) return;
123
124 for (const post of apiData.posts) {
125 if (!post.id) continue;
126
127 let media = mediaDatabase.get(post.id);
128 if (!media) {
129 media = {id: post.id, object: []};
130 }
131
132 if (post.mediaUrl) {
133 const item = createMediaObject(post, null);
134 media.object.push(item);
135 }
136
137 if (post.childPosts?.length) {
138 for (const child of post.childPosts) {
139 const item = createMediaObject(child, post);
140 media.object.push(item);
141 }
142 }
143
144 if (media.object.length > 0) {
145 mediaDatabase.set(post.id, media);
146 }
147 }
148 }
149
150 function processCards() {
151 const cards = document.querySelectorAll(CARD_SELECTOR);
152
153 for (const card of cards) {
154 const container = card.querySelector(BUTTON_CONTAINER_SELECTOR);
155 if (!container) {
156 console.error("No button container found!", card);
157 continue;
158 }
159
160 const img = card.querySelector('img');
161 const video = card.querySelector('video');
162 const src = img?.src || img?.dataset?.src || img?.dataset?.lazy ||
163 video?.poster || video?.dataset?.src || video?.dataset?.lazy || '';
164
165 const postId = extractPostIdFromUrl(src);
166 if (!postId) continue;
167
168 const media = mediaDatabase.get(postId);
169 if (!media) continue;
170
171 card.setAttribute('data-downloader-added', 'true');
172
173 const btn = document.createElement('button');
174 btn.innerHTML = DOWNLOAD_ICON;
175 btn.className = BUTTON_CLASSES;
176 btn.title = `Download ${media.object.length} media files`;
177 btn.addEventListener('click', e => {
178 e.preventDefault();
179 e.stopPropagation();
180 startDownloads(media, postId, btn);
181 });
182
183 container.prepend(btn);
184 }
185 }
186
187 const origFetch = unsafeWindow.fetch;
188 unsafeWindow.fetch = async function (url, options) {
189 const resp = await origFetch(url, options);
190 if (typeof url === 'string' && url.includes('/rest/media/post/list')) {
191 try {
192 const clone = resp.clone();
193 const data = await clone.json();
194 processApiData(data);
195 debouncedProcessCards();
196 } catch (e) {
197 console.error('API intercept error:', e);
198 }
199 }
200 return resp;
201 };
202
203 let debounceTimer;
204 const debouncedProcessCards = () => {
205 clearTimeout(debounceTimer);
206 debounceTimer = setTimeout(processCards, 120);
207 };
208
209 const observer = new MutationObserver(debouncedProcessCards);
210 observer.observe(document.body, {
211 childList: true,
212 subtree: true,
213 attributes: true,
214 attributeFilter: ['src', 'data-src', 'data-lazy', 'poster']
215 });
216
217 debouncedProcessCards();
218})();
219