Greasy Fork

Greasy Fork is available in English.

X 博主图片分批下载(每批1000张,断点续传+重置+批量下载调试)

分批(1000张)下载博主所有图片,支持断点续传、重置,下载阶段每批10张并行,暂停3秒再继续,并打日志~

// ==UserScript==
// @name         X 博主图片分批下载(每批1000张,断点续传+重置+批量下载调试)
// @namespace    http://tampermonkey.net/
// @version      0.9.1
// @description  分批(1000张)下载博主所有图片,支持断点续传、重置,下载阶段每批10张并行,暂停3秒再继续,并打日志~
// @author       chatGPT
// @match        https://x.com/*
// @match        https://twitter.com/*
// @grant        none
// @license MIT 
// ==/UserScript==

(function () {
    'use strict';

    // —— 配置项 ——
    const BATCH_SIZE     = 1000;   // 每批滚动+下载数量
    const DOWNLOAD_BATCH = 50;     // 每次并发下载张数
    const DOWNLOAD_PAUSE = 1000;   // 批次下载之间暂停(ms)
    const scrollInterval = 3000;   // 滚动间隔(ms)
    const maxScrollCount = 10000;  // 最大滚动次数
    // ——————————

    let cancelDownload = false;
    const imageSet      = new Set();
    let hideTimeoutId   = null;

    // 本地存储 key
    const KEY_COUNT = 'tm_downloadedCount';
    const KEY_POS   = 'tm_lastScrollPos';

    // 读取断点信息
    let downloadedCount = parseInt(localStorage.getItem(KEY_COUNT) || '0', 10);
    let lastScrollPos   = parseInt(localStorage.getItem(KEY_POS)   || '0', 10);

    // 获取用户名
    function getUsername() {
        const m = window.location.pathname.match(/^\/([^\/\?]+)/);
        return m ? m[1] : 'unknown_user';
    }

    // 创建并插入提示框
    const progressBox = document.createElement('div');
    Object.assign(progressBox.style, {
        position: 'fixed', top: '20px', left: '20px',
        padding: '10px', backgroundColor: 'rgba(0,0,0,0.8)',
        color: '#fff', fontSize: '14px', zIndex: 9999,
        borderRadius: '8px', display: 'none',
    });
    document.body.appendChild(progressBox);
    function updateProgress(txt) {
        progressBox.innerText = txt;
    }

    // 下载单张
    async function downloadImage(url, idx, prefix) {
        console.log(`🔗 发起下载:${idx} -> ${url}`);
        try {
            const res = await fetch(url);
            const blob = await res.blob();
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = `${prefix}_img_${idx}.jpg`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            // URL.revokeObjectURL(a.href); // 可选
        } catch (e) {
            console.error('下载失败', url, e);
        }
    }

    // 收集可见图片
    function collectVisibleImages() {
        document.querySelectorAll('img[src*="twimg.com/media"]').forEach(img => {
            let url = img.src
                .replace(/&name=\w+/, '&name=orig')
                .replace(/&format=\w+/, '&format=jpg');
            imageSet.add(url);
        });
    }

    // 主流程
    async function autoScrollAndDownload() {
        cancelDownload = false;
        imageSet.clear();

        // 恢复到上次滚动位置
        window.scrollTo(0, lastScrollPos);
        updateProgress(`📌 从 ${lastScrollPos}px 恢复,已下载 ${downloadedCount} 张`);
        progressBox.style.display = 'block';

        // 滚动收集
        let count = 0, lastHeight = 0, stable = 0;
        while (
            !cancelDownload &&
            count < maxScrollCount &&
            imageSet.size < downloadedCount + BATCH_SIZE &&
            stable < 3
        ) {
            collectVisibleImages();
            updateProgress(`📦 已收集 ${imageSet.size} 张,目标 ${(downloadedCount + BATCH_SIZE)} 张`);
            window.scrollTo(0, document.body.scrollHeight);
            await new Promise(r => setTimeout(r, scrollInterval));
            const h = document.body.scrollHeight;
            if (h === lastHeight) stable++; else stable = 0;
            lastHeight = h;
            count++;
        }
        collectVisibleImages(); // 最后一轮

        if (cancelDownload) {
            updateProgress('❌ 已取消,进度已保存');
            finishAndSave();
            return;
        }

        // 准备下载这一批
        const arr = Array.from(imageSet);
        const end = Math.min(arr.length, downloadedCount + BATCH_SIZE);
        const prefix = getUsername();
        console.log(`🚀 批量下载区间:${downloadedCount+1} ~ ${end}`);
        updateProgress(`⬇️ 批量下载 ${downloadedCount + 1} ~ ${end} 张`);

        // 按 DOWNLOAD_BATCH 并发下载
        for (let i = downloadedCount; i < end; i += DOWNLOAD_BATCH) {
            if (cancelDownload) break;
            const chunkEnd = Math.min(i + DOWNLOAD_BATCH, end);
            console.log(`📦 下载批次:${i+1} ~ ${chunkEnd}`);
            updateProgress(`⬇️ 下载${i+1}~${chunkEnd}/${end}`);

            // 并发下载这一批
            await Promise.all(
                Array.from({ length: chunkEnd - i }, (_, k) =>
                    downloadImage(arr[i + k], i + k + 1, prefix)
                )
            );

            if (chunkEnd < end) {
                console.log(`⏸️ 批次完成,暂停 ${DOWNLOAD_PAUSE}ms`);
                updateProgress(`⏸️ 暂停 ${DOWNLOAD_PAUSE/1000}s`);
                await new Promise(r => setTimeout(r, DOWNLOAD_PAUSE));
            }
        }

        // 更新断点
        downloadedCount = cancelDownload ? downloadedCount : end;
        lastScrollPos   = window.scrollY;
        localStorage.setItem(KEY_COUNT, downloadedCount);
        localStorage.setItem(KEY_POS, lastScrollPos);

        if (cancelDownload) {
            updateProgress('❌ 已取消,进度已保存');
        } else {
            updateProgress(`✅ 本批完成,共下载 ${downloadedCount} 张`);
        }
        finishAndSave();
    }

    // 取消/完成后收尾
    function finishAndSave() {
        startBtn.disabled = false;
        cancelBtn.style.display = 'none';
        hideTimeoutId = setTimeout(() => {
            updateProgress('准备中...');
            progressBox.style.display = 'none';
        }, 5000);
    }

    // 按钮:开始/继续
    const startBtn = document.createElement('button');
    startBtn.innerText = '📸 开始/继续下载下一批';
    Object.assign(startBtn.style, {
        position: 'fixed', top: '20px', right: '20px',
        zIndex: 10000, padding: '10px',
        backgroundColor: '#1DA1F2', color: '#fff',
        border: 'none', borderRadius: '5px', cursor: 'pointer',
    });
    startBtn.onclick = () => {
        clearTimeout(hideTimeoutId);
        startBtn.disabled = true;
        cancelBtn.style.display = 'block';
        autoScrollAndDownload();
    };

    // 按钮:取消
    const cancelBtn = document.createElement('button');
    cancelBtn.innerText = '❌ 取消';
    Object.assign(cancelBtn.style, {
        position: 'fixed', top: '100px', right: '20px',
        zIndex: 10000, padding: '10px',
        backgroundColor: '#ff4d4f', color: '#fff',
        border: 'none', borderRadius: '5px',
        cursor: 'pointer', display: 'none',
    });
    cancelBtn.onclick = () => {
        cancelDownload = true;
        cancelBtn.innerText = '⏳ 停止中...';
    };

    // 按钮:重置进度
    const resetBtn = document.createElement('button');
    resetBtn.innerText = '🔄 重置进度';
    Object.assign(resetBtn.style, {
        position: 'fixed', top: '200px', right: '20px',
        zIndex: 10001, padding: '10px',
        backgroundColor: '#888', color: '#fff',
        border: 'none', borderRadius: '5px',
        cursor: 'pointer',
    });
    resetBtn.onclick = () => {
        localStorage.removeItem(KEY_COUNT);
        localStorage.removeItem(KEY_POS);
        downloadedCount = 0;
        lastScrollPos   = 0;
        updateProgress('🔄 进度已重置');
        clearTimeout(hideTimeoutId);
        hideTimeoutId = setTimeout(() => {
            updateProgress('准备中...');
            progressBox.style.display = 'none';
        }, 3000);
    };

    document.body.appendChild(startBtn);
    document.body.appendChild(cancelBtn);
    document.body.appendChild(resetBtn);

    // 初始化提示
    updateProgress(`准备中... (已下载 ${downloadedCount} 张)`);
})();