// ==UserScript== // @name Wallhaven一键下载 // @version 1.5 // @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 // ==/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: 50px; 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; } `); // 工具函数 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 = () => {}) => { const results = []; const total = urls.length; let completed = 0; for (let i = 0; i < urls.length; i += limit) { 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; 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) => progressDisplay.textContent = text; // 渲染悬浮窗 const renderRepoWindow = () => { repoContent.innerHTML = ''; repo.ids.forEach(id => { const div = document.createElement('div'); div.className = 'repo-item'; div.innerHTML = ` ${id}
`; 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 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 = ''; 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(); const blobs = await fetchWithLimit(urls, 5, (percent, completed, total) => { showProgress(`正在下载:${percent}% (${completed}/${total})`); }); blobs.forEach((blob, i) => saveAs(blob, urls[i].split('/').pop())); showProgress('逐个下载完成!'); }; const packDownload = async () => { if (!repo.ids.length) return showProgress('仓库为空!'); const urls = repo.getUrls(); const blobs = await fetchWithLimit(urls, 5, (percent, completed, total) => { showProgress(`正在下载:${percent}% (${completed}/${total})`); }); const worker = createWorker(workerScript); worker.onmessage = (e) => { if (e.data.type === 'progress') { showProgress(`正在打包:${e.data.percent}%`); } else if (e.data.type === 'complete') { saveAs(e.data.blob, 'images.zip'); showProgress('打包完成!'); worker.terminate(); } }; const files = blobs.map((blob, i) => ({ name: urls[i].split('/').pop(), blob })); worker.postMessage({ files }); }; // 清空仓库 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 = '已选图片仓库 ▼'; header.addEventListener('click', toggleRepoWindow); repoArrow = header.querySelector('.arrow'); repoWindow.appendChild(header); repoContent = document.createElement('div'); repoContent.id = 'repo-content'; repoWindow.appendChild(repoContent); 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); 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('下载失败,请重试。')); } }); } })();