// ==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} 张)`);
})();