Grok 收藏批量下载

批量下载 Grok imagine 的收藏视频和图片,支持记录已下载文件避免重复

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Grok 收藏批量下载
// @namespace    https://greasyfork.org/zh-CN/users/309232-3989364
// @version      2025-11-20-1
// @description  批量下载 Grok imagine 的收藏视频和图片,支持记录已下载文件避免重复
// @description:en Batch download videos and images from Grok 'imagine' collections, supporting history tracking to prevent duplicate downloads
// @author       ctrn43062
// @match        https://grok.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=grok.com
// @grant        none
// @license      MIT
// ==/UserScript==

function createDownloadPanel(onDownloadCallback) {
    // 1. 常量与工具
    const MIN_DATE = '2025-09-01';
    // 获取当前日期并格式化为 YYYY-MM-DD (本地时间)
    const getTodayStr = () => {
        const d = new Date();
        const year = d.getFullYear();
        const month = String(d.getMonth() + 1).padStart(2, '0');
        const day = String(d.getDate()).padStart(2, '0');
        return `${year}-${month}-${day}`;
    }
    ;
    const MAX_DATE = getTodayStr();

    // 2. 创建容器 Panel
    const panel = document.createElement('div');
    panel.style.cssText = `
        border: 1px solid #ccc;
        border-radius: 8px;
        padding: 20px;
        background-color: #f9f9f9;
        font-family: sans-serif;
        display: inline-flex;
        flex-direction: column;
        gap: 15px;
        box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        max-width: 400px;
        position: fixed;
        left: 5rem;
        top: 3rem;
        opacity: 0.9;
    `;

    // 3. 创建日期行 (Row 1)
    const dateRow = document.createElement('div');
    dateRow.style.cssText = 'display: flex; gap: 10px; flex-wrap: wrap; align-items: center;';

    const createDateInput = (labelText, id) => {
        const wrapper = document.createElement('div');
        wrapper.style.display = 'flex';
        wrapper.style.flexDirection = 'column';

        const label = document.createElement('label');
        label.innerText = labelText;
        label.style.fontSize = '12px';
        label.style.marginBottom = '4px';

        const input = document.createElement('input');
        input.type = 'date';
        input.min = MIN_DATE;
        input.max = MAX_DATE;
        input.value = MAX_DATE;
        // 默认为今天
        input.style.padding = '5px';
        input.style.borderRadius = '4px';
        input.style.border = '1px solid #ddd';

        wrapper.appendChild(label);
        wrapper.appendChild(input);
        return {
            wrapper,
            input
        };
    }
    ;

    const startDateObj = createDateInput('Start Date', 'start-date');
    const endDateObj = createDateInput('End Date', 'end-date');

    dateRow.appendChild(startDateObj.wrapper);
    dateRow.appendChild(endDateObj.wrapper);

    // 4. 创建 Checkbox 行 (Row 2 - 换行后)
    const checkRow = document.createElement('div');
    checkRow.style.cssText = 'display: flex; gap: 20px; align-items: center;';

    const createCheckbox = (labelText, defaultChecked) => {
        const label = document.createElement('label');
        label.style.cssText = 'display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none;';

        const input = document.createElement('input');
        input.type = 'checkbox';
        input.checked = defaultChecked;

        const span = document.createElement('span');
        span.innerText = labelText;

        label.appendChild(input);
        label.appendChild(span);
        return {
            label,
            input
        };
    }
    ;

    const videoCheckObj = createCheckbox('Video', true);
    const imageCheckObj = createCheckbox('Image', true);
    const urlOnlyCheckObj = createCheckbox('URL Only', false);

    checkRow.appendChild(videoCheckObj.label);
    checkRow.appendChild(imageCheckObj.label);
    // checkRow.appendChild(urlOnlyCheckObj.label);

    // 5. 创建按钮行 (Row 3 - 换行后)
    const btnRow = document.createElement('div');
    const downloadBtn = document.createElement('button');
    downloadBtn.innerText = 'Download';
    downloadBtn.style.cssText = `
        padding: 8px 16px;
        background-color: #007bff;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-weight: bold;
        transition: background 0.2s;
    `;
    downloadBtn.onmouseover = () => downloadBtn.style.backgroundColor = '#0056b3';
    downloadBtn.onmouseout = () => downloadBtn.style.backgroundColor = '#007bff';

    btnRow.appendChild(downloadBtn);

    // 6. 组装 DOM
    panel.appendChild(dateRow);
    panel.appendChild(checkRow);
    // 自然换行
    panel.appendChild(btnRow);
    // 自然换行

    // 7. 逻辑验证与回调处理
    const validateDates = () => {
        const start = startDateObj.input.value;
        const end = endDateObj.input.value;

        if (!start || !end) {
            alert("请选择完整的日期范围。");
            return false;
        }
        if (start < MIN_DATE) {
            alert(`开始日期不能早于 ${MIN_DATE}`);
            return false;
        }
        if (end > MAX_DATE) {
            alert("结束日期不能超过今天。");
            return false;
        }
        if (start > end) {
            alert("开始日期不能晚于结束日期。");
            return false;
        }
        return true;
    }
    ;

    // 监听输入框变动,辅助修正(可选 UX 优化:自动限制范围)
    startDateObj.input.addEventListener('change', (e) => {
        endDateObj.input.min = e.target.value;
        // 结束日期不能早于开始日期
    }
                                       );

    const panelManager = {
        panel,
        isShow: false,
        show() {
            downloadBtn.innerText = 'Download'
            this.panel.style.display = 'block'
            this.isShow = true
        },
        hide() {
            this.panel.style.display = 'none'
            this.isShow = false
        },
        toggle() {
            this.isShow ? this.hide() : this.show()
        },
        init() {
            document.body.appendChild(this.panel);

            downloadBtn.onclick = () => {
                if (!validateDates()) {
                    return;
                }

                const data = {
                    startDate: new Date(new Date(startDateObj.input.value).setHours(0, 0, 0, 0)),
                    endDate: new Date(new Date(endDateObj.input.value).setHours(0, 0, 0, 0)),
                    includeVideo: videoCheckObj.input.checked,
                    includeImage: imageCheckObj.input.checked,
                    urlOnly: urlOnlyCheckObj.checked,
                };

                // 执行用户回调
                if (typeof onDownloadCallback === 'function') {
                    onDownloadCallback(data, this);
                } else {
                    console.warn('No callback provided');
                }
            }
        },
        destory() {
            this.panel.remove()
        },
        updateStatus(msg) {
            downloadBtn.innerText = msg
        }
    };

    panelManager.hide()

    return panelManager
}

async function get_media_list(cursor) {
    const body = {
        "limit": 100,
        "filter": {
            // 仅获取点赞的视频
            "source": "MEDIA_POST_SOURCE_LIKED"
        },
        cursor
    }

    const resp = await fetch("https://grok.com/rest/media/post/list", {
        "referrer": "https://grok.com/imagine",
        "body": JSON.stringify(body),
        "method": "POST",
        "mode": "cors",
        "credentials": "include"
    });

    const data = await resp.json()

    return data
}

function downloadFile(filename, blob) {
    const objectUrl = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = objectUrl;
    a.download = filename;
    a.style.display = 'none';

    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(objectUrl);
}

async function downloadFileFromURL(filename, url) {
    try {
        const response = await fetch(url, {
            method: 'GET',
            mode: 'cors',
            credentials: 'include',
        });

        if (!response.ok) {
            throw new Error(`下载失败,HTTP 状态码: ${response.status} ${response.statusText}`);
        }

        downloadFile(filename, await response.blob())
    } catch (err) {
        console.error('下载文件出错:', err);
        throw err;
    }
}

const DownloadRecordStore = {
    key: 'GROK_DOWNLOAD_FILES',
    urls: null,
    add(url) {
        if(!this.has(url)) {
            this.urls.push(url)
            localStorage.setItem(this.key, JSON.stringify(this.urls))
        }
    },
    load() {
        this.urls = JSON.parse(localStorage.getItem(this.key) || '[]')
    },
    has(url) {
        if(this.urls == null) {
            this.load()
        }

        return this.urls.indexOf(url) > -1
    }
}

const handleDownloadMedias = async (mediaList, {includeImage, includeVideo}) => {
    const imageList = []
    const downloadedFileUrls = []

    for (const media of mediaList) {
        const {mimeType: type, mediaUrl: url} = media
        if (includeImage && type.startsWith('image')) {
            imageList.push(url)
        } else {
            if(!includeVideo) {
                continue
            }

            // https://assets.grok.com/users/xxx/generated/xxx/generated_video.mp4
            const filename = url.split('/').slice(-2)[0]
            const ext = type.split('/')[1]
            try {
                await downloadFileFromURL(`${filename}.${ext}`, url)
                downloadedFileUrls.push(url)
            } catch (e) {}
        }
    }

    const imageUrls = imageList.join('\n\n')

    if(imageUrls) {
        downloadFile('grok-images.txt', new Blob([imageUrls], {type: 'plain/text'}))
    }

    return [...downloadedFileUrls]
}

const handleDownloadBtnClick = async (options, panel) => {
    console.log("执行下载操作...");
    console.log("开始日期:", options.startDate);
    console.log("结束日期:", options.endDate);
    console.log("包含视频:", options.includeVideo);
    console.log("包含图片:", options.includeImage);
    console.log(`下载请求已发送!\n范围: ${options.startDate} 至 ${options.endDate}\n内容: [Video: ${options.includeVideo}] [Image: ${options.includeImage}]`);

    const flattenMediaList = (_data) => {
        const mediaList = []

        const helper = (data) => {
            for (const item of data) {
                const childPosts = [...item.childPosts]
                delete item.childPosts

                mediaList.push(item)

                helper(childPosts)
            }
        }

        helper(_data)

        return mediaList
    }

    const filterMediaListByDate = (mediaList, startDate, endDate) => {
        return mediaList.filter( ({createTime}) => {
            const time = new Date(createTime)
            const date = time.setHours(0, 0, 0, 0)
            return date >= startDate && date <= endDate
        }
                               )
    }

    let cursor, posts, mediaList = []

    do {
        ({posts, nextCursor: cursor} = await get_media_list(cursor));
        const {startDate, endDate} = options

        const filteredPosts = filterMediaListByDate(flattenMediaList(posts), startDate, endDate)

        if (!filteredPosts.length) {
            break
        }

        mediaList.push(...filteredPosts)
        panel.updateStatus(`Fetching media list`)
    } while (posts && posts.length && cursor)
        panel.updateStatus(`Downloading`)
    // 排除已下载文件

    const downloadedFileUrls = await handleDownloadMedias(
        mediaList.filter(({mediaUrl}) => !DownloadRecordStore.has(mediaUrl)),
        options
    )

    downloadedFileUrls.forEach((url) => {
        DownloadRecordStore.add(url)
    })

    panel.updateStatus(`Done`)
};


/**
 * 等待指定元素在 DOM 中出现
 * @param {string} selector - CSS 选择器
 * @param {number} [timeout=0] - 超时时间 (毫秒),0 表示无限等待
 * @returns {Promise<HTMLElement>}
 */
function waitForElement(selector, timeout = 0) {
    return new Promise((resolve, reject) => {
        // 1. 如果元素已经存在,直接返回
        const element = document.querySelector(selector);
        if (element) {
            return resolve(element);
        }

        // 2. 定义观察者
        const observer = new MutationObserver(() => {
            const el = document.querySelector(selector);
            if (el) {
                resolve(el);
                observer.disconnect(); // 找到后停止观察,释放资源
            }
        });

        // 3. 开始监听 document.body 的子节点变化
        observer.observe(document.body, {
            childList: true, // 监听子节点增加/删除
            subtree: true    // 监听所有后代节点,不仅仅是直接子节点
        });

        // 4. (可选) 超时处理
        if (timeout > 0) {
            setTimeout(() => {
                observer.disconnect();
                reject(new Error(`Timeout: Element '${selector}' not found within ${timeout}ms`));
            }, timeout);
        }
    });
}

const createDownloadIcon = () => {
    const button = document.createElement('i')
    button.innerHTML = `<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>`
    button.classList.add('grok-download-icon')
    Object.assign(button.style, {
        display: 'inline-block',
        margin: '12px 0 0 5px'
    })
    return button
}

/**
 * 监听 SPA URL 变化的通用方案
 * @param {Function} callback - URL 发生变化时的回调函数
 */
function onUrlChange(callback) {
    // 1. 监听浏览器的后退/前进 (原生支持)
    window.addEventListener('popstate', () => {
        callback(location.href);
    });

    // 2. 拦截 pushState (常规路由跳转)
    const originalPushState = history.pushState;
    history.pushState = function (...args) {
        // 执行原有的 pushState
        originalPushState.apply(this, args);
        // 触发回调
        callback(location.href);
    };

    // 3. 拦截 replaceState (路由替换,不留历史记录)
    const originalReplaceState = history.replaceState;
    history.replaceState = function (...args) {
        originalReplaceState.apply(this, args);
        callback(location.href);
    };
}

async function nextTick() {}

(async function() {
    'use strict';

    const dlPanel = createDownloadPanel(handleDownloadBtnClick);
    dlPanel.init()

    // 初始化下载面板
    onUrlChange(async (currentUrl) => {
        dlPanel.hide()

        if(!currentUrl.includes('/imagine/favorites')) {
            return
        }

        await nextTick()

        const mountEl = await waitForElement('div > h1')
        const icon = createDownloadIcon()

        if(mountEl.querySelector('.grok-download-icon')) {
            return
        }

        mountEl.appendChild(icon)
        mountEl.addEventListener('click', (e) => {
            dlPanel.toggle()
        })
    })
})();