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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Grok Imagine Downloader - Bulk Save High-Quality Media
// @namespace    https://grok.com
// @version      2025-11-18
// @description  This script allows to download all videos and photos (including from child posts) from your Image Favorites page.
// @author       Mykyta Shcherbyna
// @match        https://grok.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=grok.com
// @license      MIT
// @grant        GM_download
// @grant        unsafeWindow
// @grant        GM_xmlhttpRequest
// @run-at       document-start
// @connect      assets.grok.com
// @connect      imagine-public.x.ai
// ==/UserScript==

(function () {
    'use strict';

    const CARD_SELECTOR = '.group\\/media-post-masonry-card:not([data-downloader-added])';
    const BUTTON_CONTAINER_SELECTOR = '.absolute.bottom-2.right-2';
    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';
    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>`;

    const mediaDatabase = new Map();

    function extractPostIdFromUrl(url) {
        if (!url) return null;
        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)];
        return matches.length > 0 ? matches[matches.length - 1][0] : null;
    }

    function sanitizeForFilename(str) {
        return (str || '').replace(/[/\\?%*:|"<>]/g, '_').replace(/\s+/g, '_');
    }

    function buildFilename(item) {
        const time = item.createTime ? item.createTime.slice(0, 19).replace(/:/g, '-') : 'unknown';
        const model = item.modelName ? `_${sanitizeForFilename(item.modelName)}` : '';
        let prompt = item.prompt ? `_${sanitizeForFilename(item.prompt)}` : '';

        if (prompt.length > 180) prompt = prompt.slice(0, 177) + '...';

        let ext = item.isVideo ? 'mp4' : 'jpg';
        if (item.mimeType) {
            if (item.mimeType === 'video/mp4') ext = 'mp4';
            else if (item.mimeType === 'image/png') ext = 'png';
            else if (item.mimeType === 'image/jpeg') ext = 'jpg';
        }

        return `${time}_${item.id}${model}${prompt}.${ext}`;
    }

    function downloadFile(item, onComplete) {
        GM_download({
            url: item.url,
            name: item.filename,
            onload: onComplete,
            onerror: onComplete,
            ontimeout: onComplete
        });
    }

    function startDownloads(media, postId, button) {
        const all = media.object;
        if (all.length === 0) return;

        let completed = 0;
        let failed = 0;
        const total = all.length;

        button.textContent = `0/${total}`;
        button.style.pointerEvents = 'none';
        button.disabled = true;

        const onComplete = () => {
            completed++;
            button.textContent = `${completed}/${total}`;
            if ((completed + failed) === total) {
                button.disabled = failed === 0;
                setTimeout(() => {
                    button.textContent = failed > 0 ? 'ERR' : 'OK!';
                }, 500);
            }
        };

        all.forEach(item => {
            downloadFile(item, onComplete);
        });
    }

    function createMediaObject(source, fallbackParent) {
        const isVideo = source.mediaType === 'MEDIA_POST_TYPE_VIDEO';
        const url = isVideo && source.hdMediaUrl ? source.hdMediaUrl : source.mediaUrl;

        let item = {
            id: source.id,
            url: url,
            createTime: source.createTime || fallbackParent?.createTime || '',
            modelName: source.modelName || fallbackParent?.modelName || '',
            prompt: (source.originalPrompt || source.prompt || fallbackParent?.originalPrompt || fallbackParent?.prompt || '').trim(),
            isVideo: isVideo,
            mimeType: source.mimeType
        };

        const filename = buildFilename(item);

        return {
            id: item.id,
            url: item.url,
            createTime: item.createTime,
            modelName: item.modelName,
            prompt: item.prompt,
            filename: filename
        };
    }

    function processApiData(apiData) {
        if (!apiData?.posts) return;

        for (const post of apiData.posts) {
            if (!post.id) continue;

            let media = mediaDatabase.get(post.id);
            if (!media) {
                media = {id: post.id, object: []};
            }

            if (post.mediaUrl) {
                const item = createMediaObject(post, null);
                media.object.push(item);
            }

            if (post.childPosts?.length) {
                for (const child of post.childPosts) {
                    const item = createMediaObject(child, post);
                    media.object.push(item);
                }
            }

            if (media.object.length > 0) {
                mediaDatabase.set(post.id, media);
            }
        }
    }

    function processCards() {
        const cards = document.querySelectorAll(CARD_SELECTOR);

        for (const card of cards) {
            const container = card.querySelector(BUTTON_CONTAINER_SELECTOR);
            if (!container) {
                console.error("No button container found!", card);
                continue;
            }

            const img = card.querySelector('img');
            const video = card.querySelector('video');
            const src = img?.src || img?.dataset?.src || img?.dataset?.lazy ||
                video?.poster || video?.dataset?.src || video?.dataset?.lazy || '';

            const postId = extractPostIdFromUrl(src);
            if (!postId) continue;

            const media = mediaDatabase.get(postId);
            if (!media) continue;

            card.setAttribute('data-downloader-added', 'true');

            const btn = document.createElement('button');
            btn.innerHTML = DOWNLOAD_ICON;
            btn.className = BUTTON_CLASSES;
            btn.title = `Download ${media.object.length} media files`;
            btn.addEventListener('click', e => {
                e.preventDefault();
                e.stopPropagation();
                startDownloads(media, postId, btn);
            });

            container.prepend(btn);
        }
    }

    const origFetch = unsafeWindow.fetch;
    unsafeWindow.fetch = async function (url, options) {
        const resp = await origFetch(url, options);
        if (typeof url === 'string' && url.includes('/rest/media/post/list')) {
            try {
                const clone = resp.clone();
                const data = await clone.json();
                processApiData(data);
                debouncedProcessCards();
            } catch (e) {
                console.error('API intercept error:', e);
            }
        }
        return resp;
    };

    let debounceTimer;
    const debouncedProcessCards = () => {
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(processCards, 120);
    };

    const observer = new MutationObserver(debouncedProcessCards);
    observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['src', 'data-src', 'data-lazy', 'poster']
    });

    debouncedProcessCards();
})();