Grok Imagine Downloader - Bulk Save High-Quality Media

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
Grok Imagine Downloader - Bulk Save High-Quality Media | Robomonkey