Grok 收藏批量下载

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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()
        })
    })
})();