// ==UserScript==
// @name X 图片下载器(基础版)
// @namespace http://tampermonkey.net/
// @version 2.1
// @description 收集页面图片并打包为ZIP下载
// @author 基础版
// @match https://x.com/*
// @match https://twitter.com/*
// @grant GM_registerMenuCommand
// @grant GM_download
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com
// @connect raw.githubusercontent.com
// @connect twitter.com
// @connect x.com
// @connect pbs.twimg.com
// @connect video.twimg.com
// @grant GM_xmlhttpRequest
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// ==/UserScript==
/*
MIT License
Copyright (c) 2024 基础版
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
(function () {
'use strict';
// -------------------------- 配置与变量定义 --------------------------
const BATCH_SIZE = 1000; // 最大下载数量
const IMAGE_SCROLL_INTERVAL = 1500; // 滚动间隔时间(ms)
const IMAGE_MAX_SCROLL_COUNT = 100; // 最大滚动次数
const SCROLL_DELAY = 1000; // 滚动后的等待时间(ms)
const NO_NEW_IMAGE_THRESHOLD = 3; // 连续3次下滑无新内容则结束
let cancelDownload = false;
let hideTimeoutId = null;
let lang;
// 核心数据结构
const imageLinksSet = new Set(); // 收集到的图片链接(最终下载)
// -------------------------- UI组件初始化 --------------------------
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',
maxWidth: '400px'
});
document.body.appendChild(progressBox);
const loadingPrompt = document.createElement('div');
Object.assign(loadingPrompt.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '20px',
backgroundColor: 'rgba(0,0,0,0.8)',
color: '#fff',
fontSize: '16px',
zIndex: 10000,
borderRadius: '8px',
display: 'none'
});
loadingPrompt.textContent = '正在加载,请不要关闭页面...';
document.body.appendChild(loadingPrompt);
const progressBarContainer = document.createElement('div');
Object.assign(progressBarContainer.style, {
position: 'fixed',
top: '55%',
left: '50%',
transform: 'translateX(-50%)',
width: '300px',
height: '20px',
backgroundColor: '#ccc',
zIndex: 10000,
borderRadius: '10px',
display: 'none'
});
const progressBar = document.createElement('div');
Object.assign(progressBar.style, {
width: '0%',
height: '100%',
backgroundColor: '#1DA1F2',
borderRadius: '10px'
});
progressBarContainer.appendChild(progressBar);
document.body.appendChild(progressBarContainer);
const notifier = document.createElement('div');
Object.assign(notifier.style, {
display: 'none',
position: 'fixed',
left: '16px',
bottom: '16px',
color: '#000',
background: '#fff',
border: '1px solid #ccc',
borderRadius: '8px',
padding: '4px'
});
notifier.title = 'X图片下载器';
notifier.className = 'tmd-notifier';
notifier.innerHTML = '<label>0</label>|<label>0</label>';
document.body.appendChild(notifier);
// -------------------------- 工具函数 --------------------------
function updateProgress(txt) {
progressBox.innerText = txt;
progressBox.style.display = 'block';
}
// 获取博主ID
function getUsername() {
const m = window.location.pathname.match(/^\/([^\/\?]+)/);
return m ? m[1] : 'unknown_user';
}
// 获取博主显示名称(从页面标题提取)
function getBloggerName() {
// 从页面标题提取博主名称
if (document.title) {
// 标题格式通常是 "博主名称 (@用户名) / X"
const titleParts = document.title.split('(@');
if (titleParts.length > 0) {
return titleParts[0].trim().replace(/\s+/g, '_');
}
}
// 如果提取失败,使用用户名
return getUsername();
}
async function initBaseConfig() {
lang = getLanguage();
document.head.insertAdjacentHTML('beforeend', `<style>${getCSS()}</style>`);
}
function getLanguage() {
const langMap = {
en: {
download: 'Download',
completed: 'Download Completed',
packaging: 'Packaging ZIP...',
mediaNotFound: 'MEDIA_NOT_FOUND'
},
zh: {
download: '下载',
completed: '下载完成',
packaging: '正在打包ZIP...',
mediaNotFound: '未找到媒体文件'
},
'zh-Hant': {
download: '下載',
completed: '下載完成',
packaging: '正在打包ZIP...',
mediaNotFound: '未找到媒體文件'
}
};
const pageLang = document.querySelector('html').lang || navigator.language;
return langMap[pageLang] || langMap[pageLang.split('-')[0]] || langMap.en;
}
function getCSS() {
return `
.tmd-notifier.running {display: flex; align-items: center;}
.tmd-notifier label {display: inline-flex; align-items: center; margin: 0 8px;}
.tmd-notifier label:before {content: " "; width: 32px; height: 16px; background-position: center; background-repeat: no-repeat;}
.tmd-notifier label:nth-child(1):before {background-image:url("data:image/svg+xml;charset=utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22 viewBox=%220 0 24 24%22><path d=%22M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l4,4 q1,1 2,0 l4,-4 M12,3 v11%22 fill=%22none%22 stroke=%22%23666%22 stroke-width=%222%22 stroke-linecap=%22round%22 /></svg>");}
.tmd-notifier label:nth-child(2):before {background-image:url("data:image/svg+xml;charset=utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22 viewBox=%220 0 24 24%22><path d=%22M12,2 a1,1 0 0 1 0,20 a1,1 0 0 1 0,-20 M12,5 v7 h6%22 fill=%22none%22 stroke=%22%23999%22 stroke-width=%222%22 stroke-linejoin=%22round%22 stroke-linecap=%22round%22 /></svg>");}
.tmd-notifier label:nth-child(3):before {background-image:url("data:image/svg+xml;charset=utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22 viewBox=%220 0 24 24%22><path d=%22M12,0 a2,2 0 0 0 0,24 a2,2 0 0 0 0,-24%22 fill=%22%23f66%22 stroke=%22none%22 /><path d=%22M14.5,5 a1,1 0 0 0 -5,0 l0.5,9 a1,1 0 0 0 4,0 z M12,17 a2,2 0 0 0 0,5 a2,2 0 0 0 0,-5%22 fill=%22%23fff%22 stroke=%22none%22 /></svg>");}
`;
}
function formatDate(dateStr, format = 'YYYYMMDD-hhmmss', useLocal = false) {
const d = new Date(dateStr);
if (useLocal) d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
const values = {
YYYY: d.getUTCFullYear().toString(),
YY: d.getUTCFullYear().toString().slice(-2),
MM: (d.getUTCMonth() + 1).toString().padStart(2, '0'),
MMM: months[d.getUTCMonth()],
DD: d.getUTCDate().toString().padStart(2, '0'),
hh: d.getUTCHours().toString().padStart(2, '0'),
mm: d.getUTCMinutes().toString().padStart(2, '0'),
ss: d.getUTCSeconds().toString().padStart(2, '0')
};
return format.replace(/(YYYY|YY|MM|MMM|DD|hh|mm|ss)/g, match => values[match]);
}
// -------------------------- ZIP下载管理器 --------------------------
const Downloader = (() => {
let tasks = [], thread = 0, failed = 0, hasFailed = false;
return {
async add(tasksList) {
if (cancelDownload) return;
this.downloadZip(tasksList);
},
async downloadZip(tasksList) {
if (cancelDownload) return;
const zip = new JSZip();
let completedCount = 0;
const total = tasksList.length;
updateProgress(`${lang.packaging}(0/${total})`);
tasks.push(...tasksList);
this.updateNotifier();
try {
await Promise.all(tasksList.map(async (task, index) => {
thread++;
this.updateNotifier();
try {
const response = await fetch(task.url);
if (!response.ok) throw new Error(`HTTP错误:${response.status}`);
const blob = await response.blob();
zip.file(task.name, blob);
completedCount++;
updateProgress(`${lang.packaging}(${completedCount}/${total})`);
} catch (error) {
failed++;
updateProgress(`❌ 文件${task.name}下载失败:${error.message}`);
console.error(`文件${task.name}处理失败:`, error);
} finally {
thread--;
tasks = tasks.filter(t => t.url !== task.url);
this.updateNotifier();
}
}));
if (cancelDownload) return;
updateProgress('正在生成ZIP文件...');
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'STORE'
});
const zipName = `${tasksList[0].name.split('_img_')[0]}_images_${total}files.zip`;
const a = document.createElement('a');
a.href = URL.createObjectURL(zipBlob);
a.download = zipName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
updateProgress(`✅ ZIP打包完成:${zipName}(共${total}个文件)`);
} catch (error) {
updateProgress(`❌ ZIP打包失败:${error.message}`);
console.error('ZIP打包错误:', error);
}
},
updateNotifier() {
if (failed > 0 && !hasFailed) {
hasFailed = true;
notifier.innerHTML += '|';
const clearBtn = document.createElement('label');
clearBtn.innerText = '清空失败';
clearBtn.style.color = '#f33';
clearBtn.onclick = () => {
failed = 0;
hasFailed = false;
notifier.innerHTML = '<label>0</label>|<label>0</label>';
this.updateNotifier();
};
notifier.appendChild(clearBtn);
}
if (notifier.children.length >= 1) notifier.children[0].innerText = thread;
if (notifier.children.length >= 2) notifier.children[1].innerText = tasks.length - thread - failed;
if (failed > 0 && notifier.children.length >= 3) notifier.children[2].innerText = failed;
if (thread > 0 || tasks.length > 0 || failed > 0) {
notifier.classList.add('running');
} else {
notifier.classList.remove('running');
}
},
cancel() {
cancelDownload = true;
tasks = [];
thread = 0;
failed = 0;
hasFailed = false;
this.updateNotifier();
updateProgress('⏹️ 下载已取消');
}
};
})();
// -------------------------- 下载模式 --------------------------
async function autoScrollAndDownloadImages() {
cancelDownload = false;
imageLinksSet.clear();
const username = getUsername();
const bloggerName = getBloggerName(); // 获取博主显示名称
updateProgress('📸 正在收集图片...');
progressBox.style.display = 'block';
loadingPrompt.style.display = 'block';
progressBarContainer.style.display = 'block';
progressBar.style.width = '0%';
getAllImages();
updateProgress(`📦 已找到${imageLinksSet.size}张图片`);
let scrollCount = 0, lastHeight = 0, progress = 0, noNewImagesCount = 0;
while (scrollCount < IMAGE_MAX_SCROLL_COUNT && !cancelDownload && noNewImagesCount < 3) {
window.scrollTo(0, document.body.scrollHeight);
await new Promise(resolve => setTimeout(resolve, IMAGE_SCROLL_INTERVAL));
const prevCount = imageLinksSet.size;
getAllImages();
const currentCount = imageLinksSet.size;
if (currentCount === prevCount) {
noNewImagesCount++;
} else {
noNewImagesCount = 0;
}
const currentHeight = document.body.scrollHeight;
updateProgress(`📦 已找到${currentCount}张图片(滚动${scrollCount+1}次)`);
progress = Math.min(Math.floor(((scrollCount + 1) / IMAGE_MAX_SCROLL_COUNT) * 100), 100);
progressBar.style.width = `${progress}%`;
if (currentHeight === lastHeight) break;
lastHeight = currentHeight;
scrollCount++;
}
loadingPrompt.style.display = 'none';
progressBarContainer.style.display = 'none';
if (cancelDownload) {
updateProgress('⏹️ 下载已取消');
finishAndSave();
return;
}
const imageList = Array.from(imageLinksSet);
if (imageList.length === 0) {
updateProgress('⚠️ 未找到可下载的图片');
finishAndSave();
return;
}
const finalTasks = imageList.slice(0, BATCH_SIZE);
// 文件名格式: 博主名称_用户名_img_日期_序号.jpg
const tasks = finalTasks.map((url, index) => ({
url: url,
name: `${bloggerName}_${username}_img_${formatDate(new Date())}_${index+1}.jpg`,
statusId: `img_batch_${Date.now()}_${index}`
}));
updateProgress(`🚀 开始处理${tasks.length}张图片(ZIP打包)`);
await Downloader.add(tasks);
finishAndSave();
}
// 收集所有可见图片链接
function getAllImages() {
document.querySelectorAll('img[src*="twimg.com/media"]').forEach(img => {
let url = img.src.replace(/&name=\w+/, '');
if (!url.includes('?')) {
url += '?';
}
url += '&name=orig';
imageLinksSet.add(url);
});
}
// 完成处理并清理
function finishAndSave() {
startBtn.disabled = false;
cancelBtn.style.display = 'none';
hideTimeoutId = setTimeout(() => {
progressBox.style.display = 'none';
loadingPrompt.style.display = 'none';
progressBarContainer.style.display = 'none';
}, 5000);
}
// -------------------------- 按钮初始化 --------------------------
const startBtn = document.createElement('button');
startBtn.innerText = '下载图片(ZIP)';
Object.assign(startBtn.style, {
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 10000,
padding: '12px 20px',
backgroundColor: '#1DA1F2',
color: '#fff',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
fontSize: '14px',
marginBottom: '10px'
});
startBtn.onclick = () => {
clearTimeout(hideTimeoutId);
startBtn.disabled = true;
cancelBtn.style.display = 'block';
autoScrollAndDownloadImages();
};
const cancelBtn = document.createElement('button');
cancelBtn.innerText = '❌ 取消';
Object.assign(cancelBtn.style, {
position: 'fixed',
top: '70px',
right: '20px',
zIndex: 10000,
padding: '12px 20px',
backgroundColor: '#ff4d4f',
color: '#fff',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
display: 'none',
fontSize: '14px'
});
cancelBtn.onclick = () => {
cancelDownload = true;
Downloader.cancel();
cancelBtn.innerText = '⏳ 停止中...';
setTimeout(() => {
startBtn.disabled = false;
cancelBtn.innerText = '❌ 取消';
cancelBtn.style.display = 'none';
}, 1000);
};
document.body.appendChild(startBtn);
document.body.appendChild(cancelBtn);
// -------------------------- 初始化 --------------------------
(async () => {
await initBaseConfig();
updateProgress('准备就绪:点击下载图片开始');
hideTimeoutId = setTimeout(() => {
progressBox.style.display = 'none';
}, 3000);
})();
})();