// ==UserScript==
// @name Wallhaven一键下载
// @version 1.7
// @description 在Wallhaven缩略图上添加下载按钮和复选框,支持单张下载、逐个下载和打包下载。
// @match *://wallhaven.cc/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @grant GM_addStyle
// @license MIT
// @author EPC_SG
// @namespace 这是干啥的,不是很懂
// ==/UserScript==
(function() {
'use strict';
// 注入CSS样式
GM_addStyle(`
.thumb { position: relative; }
.thumb .download-btn {
position: absolute;
top: 5px;
left: 6px;
z-index: 1000;
background: #4CAF50;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
padding: 5px 10px;
}
.thumb .download-checkbox {
position: absolute;
top: 11px;
left: 42px;
z-index: 1000;
width: 15px;
height: 15px;
visibility: visible;
}
.control-btn {
background: #4CAF50;
color: white;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
margin: 0 5px 5px 0;
font-size: 12px;
}
#repo-window {
position: fixed;
top: 110px;
right: 10px;
width: 250px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 5px;
z-index: 2000;
}
#repo-header {
cursor: pointer;
margin: 0 0 10px;
font-size: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
#repo-header .arrow { transition: transform 0.2s; }
#repo-header .arrow.expanded { transform: rotate(0deg); }
#repo-header .arrow:not(.expanded) { transform: rotate(90deg); }
#repo-content {
display: none;
max-height: 350px;
overflow-y: auto;
}
#repo-content.expanded { display: block; }
.repo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 0;
}
.repo-item a {
color: #1e90ff;
text-decoration: none;
}
.repo-item a:hover { text-decoration: underline; }
.repo-buttons {
display: flex;
gap: 5px;
}
.repo-item button {
border: none;
color: white;
padding: 2px 5px;
cursor: pointer;
border-radius: 3px;
font-size: 10px;
height: 18px;
}
.repo-item .delete-btn { background: #ff4444; }
.repo-item .download-btn { background: #4CAF50; }
#clear-btn { background: #ff4444; }
#abort-btn {
background: #ff4444;
color: white;
padding: 2px 5px;
border: none;
border-radius: 3px;
cursor: pointer;
margin-left: 5px;
font-size: 12px;
}
`);
// 工具函数
const getImageUrl = (id, isPng) => {
const format = isPng ? 'png' : 'jpg';
return `https://w.wallhaven.cc/full/${id.slice(0, 2)}/wallhaven-${id}.${format}`;
};
const fetchWithRetry = async (url, retries = 3) => {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Network error');
return await response.blob();
} catch (err) {
if (i === retries - 1) throw err;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
};
const fetchWithLimit = async (urls, limit = 5, onProgress = () => {}, abortSignal = null) => {
const results = [];
const total = urls.length;
let completed = 0;
for (let i = 0; i < urls.length; i += limit) {
if (abortSignal && abortSignal.aborted) throw new Error('Download aborted');
const chunk = urls.slice(i, i + limit);
const promises = chunk.map(url => fetchWithRetry(url));
const blobs = await Promise.all(promises);
results.push(...blobs);
completed += blobs.length;
onProgress(Math.round((completed / total) * 100), completed, total);
}
return results;
};
const debounce = (fn, delay) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
};
// Worker相关
const createWorker = (fn) => {
const blob = new Blob([`(${fn.toString()})()`], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob));
};
const workerScript = () => {
self.importScripts('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js');
self.onmessage = async (e) => {
const { files } = e.data;
const zip = new JSZip();
files.forEach(file => zip.file(file.name, file.blob));
const blob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 1 }
}, ({ percent }) => {
self.postMessage({ type: 'progress', percent: Math.round(percent) });
});
self.postMessage({ type: 'complete', blob });
};
};
// 仓库管理
const repo = {
ids: JSON.parse(localStorage.getItem('wallhaven_repo') || '[]'),
save: () => localStorage.setItem('wallhaven_repo', JSON.stringify(repo.ids)),
add: (id) => { if (!repo.ids.includes(id)) { repo.ids.push(id); repo.save(); } },
remove: (id) => { repo.ids = repo.ids.filter(x => x !== id); repo.save(); },
clear: () => { repo.ids = []; repo.save(); },
has: (id) => repo.ids.includes(id),
getUrls: () => repo.ids.map(id => {
const thumb = document.querySelector(`.thumb[data-wallpaper-id="${id}"]`);
const isPng = thumb ? !!thumb.querySelector('.thumb-info .png') : false;
return getImageUrl(id, isPng);
})
};
// DOM 缓存和状态管理
let checkboxes = [];
let ratioDisplay, progressDisplay, repoWindow, repoContent, repoArrow;
let abortController = null; // 用于中止下载
let currentWorker = null; // 用于中止打包
const updateCheckboxes = () => {
checkboxes = Array.from(document.querySelectorAll('.thumb .download-checkbox'));
};
const updateRatio = () => {
const selected = repo.ids.length;
ratioDisplay.textContent = `仓库中 ${selected} 张`;
};
const updateRatioDebounced = debounce(updateRatio, 100);
const showProgress = (text, showAbort = false) => {
progressDisplay.textContent = text;
if (showAbort) {
const abortBtn = document.createElement('button');
abortBtn.id = 'abort-btn';
abortBtn.textContent = '中止';
abortBtn.addEventListener('click', () => {
if (abortController) abortController.abort();
if (currentWorker) currentWorker.terminate();
showProgress('操作已中止');
});
progressDisplay.appendChild(abortBtn);
} else {
const existingAbortBtn = progressDisplay.querySelector('#abort-btn');
if (existingAbortBtn) existingAbortBtn.remove();
}
};
// 渲染悬浮窗
const renderRepoWindow = () => {
repoContent.innerHTML = '';
repo.ids.forEach(id => {
const div = document.createElement('div');
div.className = 'repo-item';
div.innerHTML = `
<a href="https://wallhaven.cc/w/${id}" target="_blank">${id}</a>
<div class="repo-buttons">
<button class="delete-btn">删除</button>
<button class="download-btn">下载</button>
</div>
`;
div.querySelector('.delete-btn').addEventListener('click', () => {
repo.remove(id);
renderRepoWindow();
updateRatio();
syncCheckboxes();
});
div.querySelector('.download-btn').addEventListener('click', () => {
const thumb = document.querySelector(`.thumb[data-wallpaper-id="${id}"]`);
const isPng = thumb ? !!thumb.querySelector('.thumb-info .png') : false;
const url = getImageUrl(id, isPng);
fetchWithRetry(url)
.then(blob => saveAs(blob, url.split('/').pop()))
.catch(err => showProgress(`下载 ${id} 失败,请重试。`));
});
repoContent.appendChild(div);
});
};
// 切换展开/收缩状态
const toggleRepoWindow = () => {
repoContent.classList.toggle('expanded');
repoArrow.classList.toggle('expanded');
};
// 同步复选框状态
const syncCheckboxes = () => {
checkboxes.forEach(cb => {
const id = cb.closest('.thumb').dataset.wallpaperId;
cb.checked = repo.has(id);
});
};
// 全选/取消功能
const toggleAll = (e) => {
e.preventDefault();
const allChecked = checkboxes.every(cb => cb.checked);
checkboxes.forEach(cb => {
const id = cb.closest('.thumb').dataset.wallpaperId;
if (!allChecked) {
cb.checked = true;
repo.add(id);
} else {
cb.checked = false;
repo.remove(id);
}
});
renderRepoWindow();
updateRatio();
};
// 添加按钮和复选框
const addButtons = (container) => {
container.querySelectorAll('.thumb:not([data-processed])').forEach(thumb => {
const id = thumb.dataset.wallpaperId;
if (!id) return;
thumb.dataset.processed = 'true';
const isPng = !!thumb.querySelector('.thumb-info .png');
const url = getImageUrl(id, isPng);
const dlBtn = document.createElement('button');
dlBtn.className = 'download-btn';
dlBtn.innerHTML = '<i class="fas fa-download"></i>';
thumb.appendChild(dlBtn);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'download-checkbox';
checkbox.value = url;
checkbox.checked = repo.has(id);
checkbox.addEventListener('change', () => {
if (checkbox.checked) repo.add(id);
else repo.remove(id);
renderRepoWindow();
updateRatioDebounced();
});
thumb.appendChild(checkbox);
});
updateCheckboxes();
syncCheckboxes();
updateRatio();
};
// 批量逐个下载
const batchDownload = async () => {
if (!repo.ids.length) return showProgress('仓库为空!');
const urls = repo.getUrls();
abortController = new AbortController();
try {
const blobs = await fetchWithLimit(urls, 5, (percent, completed, total) => {
showProgress(`正在下载:${percent}% (${completed}/${total})`, true);
}, abortController.signal);
blobs.forEach((blob, i) => saveAs(blob, urls[i].split('/').pop()));
showProgress('逐个下载完成!');
} catch (err) {
if (err.message === 'Download aborted') {
showProgress('下载已中止');
} else {
showProgress('下载失败,请重试。');
}
} finally {
abortController = null;
}
};
// 打包下载
const packDownload = async () => {
if (!repo.ids.length) return showProgress('仓库为空!');
const urls = repo.getUrls();
abortController = new AbortController();
try {
const blobs = await fetchWithLimit(urls, 5, (percent, completed, total) => {
showProgress(`正在下载:${percent}% (${completed}/${total})`, true);
}, abortController.signal);
currentWorker = createWorker(workerScript);
currentWorker.onmessage = (e) => {
if (e.data.type === 'progress') {
showProgress(`正在打包:${e.data.percent}%`, true);
} else if (e.data.type === 'complete') {
saveAs(e.data.blob, 'images.zip');
showProgress('打包完成!');
currentWorker = null;
}
};
const files = blobs.map((blob, i) => ({
name: urls[i].split('/').pop(),
blob
}));
currentWorker.postMessage({ files });
} catch (err) {
if (err.message === 'Download aborted') {
if (currentWorker) currentWorker.terminate();
showProgress('打包已中止');
} else {
showProgress('打包失败,请重试。');
}
} finally {
abortController = null;
}
};
// 清空仓库
const clearRepo = () => {
repo.clear();
renderRepoWindow();
updateRatio();
syncCheckboxes();
};
// 初始化控制面板
const initControls = () => {
const toolbar = document.querySelector('.expanded') || document.body;
[ratioDisplay, progressDisplay, repoWindow] = [
Object.assign(document.createElement('span'), { style: 'color: white;' }),
Object.assign(document.createElement('span'), { style: 'color: white;' }),
document.createElement('div')
];
repoWindow.id = 'repo-window';
const header = document.createElement('h3');
header.id = 'repo-header';
header.innerHTML = '图片仓库 <span class="arrow">▼</span>';
header.addEventListener('click', toggleRepoWindow);
repoArrow = header.querySelector('.arrow');
repoWindow.appendChild(header);
repoContent = document.createElement('div');
repoContent.id = 'repo-content';
repoWindow.appendChild(repoContent);
const selectAllBtn = document.createElement('button');
selectAllBtn.textContent = '全选/取消';
selectAllBtn.className = 'control-btn';
selectAllBtn.addEventListener('click', toggleAll);
toolbar.appendChild(selectAllBtn);
const batchBtn = document.createElement('button');
batchBtn.textContent = '逐个下载';
batchBtn.className = 'control-btn';
batchBtn.addEventListener('click', batchDownload);
repoWindow.insertBefore(batchBtn, repoContent);
const packBtn = document.createElement('button');
packBtn.textContent = '打包下载';
packBtn.className = 'control-btn';
packBtn.addEventListener('click', packDownload);
repoWindow.insertBefore(packBtn, repoContent);
const clearBtn = document.createElement('button');
clearBtn.textContent = '清空';
clearBtn.id = 'clear-btn';
clearBtn.className = 'control-btn';
clearBtn.addEventListener('click', clearRepo);
repoWindow.insertBefore(clearBtn, repoContent);
[ratioDisplay, progressDisplay].forEach((el, i) => {
el.style.marginLeft = `${5 + i * 5}px`;
toolbar.appendChild(el);
});
document.body.appendChild(repoWindow);
// 默认展开
toggleRepoWindow();
renderRepoWindow();
};
// 主逻辑
initControls();
const listingPage = document.querySelector('.thumb-listing-page');
if (listingPage) addButtons(listingPage);
const thumbListing = document.querySelector('.thumb-listing');
if (thumbListing) {
const observer = new MutationObserver(mutations => {
mutations.forEach(m => {
m.addedNodes.forEach(node => {
if (node.classList?.contains('thumb-listing-page')) addButtons(node);
});
});
});
observer.observe(thumbListing, { childList: true, subtree: true });
thumbListing.addEventListener('click', (e) => {
const btn = e.target.closest('.download-btn');
if (btn) {
const url = btn.nextElementSibling.value;
fetchWithRetry(url)
.then(blob => saveAs(blob, url.split('/').pop()))
.catch(err => showProgress('下载失败,请重试。'));
}
});
}
})();