// ==UserScript== // @name 夜未央图片批量下载器(集成压缩版) // @name:zh-CN 夜未央图片批量下载器(集成压缩版) // @namespace https://scriptcat.org/ // @version 1.0.0 // @author 夜未央落雪殇 // @description 全网高清图片批量下载,支持预览、筛选、自定义重命名。无广告醇香版。 // @license MIT // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiI+PHJlY3QgeD0iMyIgeT0iMyIgd2lkdGg9IjE4IiBoZWlnaHQ9IjE4IiByeD0iMiIvPjxwb2x5bGluZSBwb2ludHM9IjggMTIgMTIgMTYgMTYgMTIiLz48bGluZSB4MT0iMTIiIHkxPSI4IiB4Mj0iMTIiIHkyPSIxNiIvPjwvc3ZnPg== // @match *://*/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_download // @connect * // @noframes // @homepageURL https://scriptcat.org/ // ==/UserScript== (function() { 'use strict'; // 简化版 ZIP 生成(无需外部库) function createZip(files) { const data = []; let offset = 0; const centralDir = []; for (const [name, buffer] of Object.entries(files)) { const nameBytes = new TextEncoder().encode(name); const crc = crc32(buffer); // Local file header const localHeader = new Uint8Array(30 + nameBytes.length); const view = new DataView(localHeader.buffer); view.setUint32(0, 0x04034b50, true); // signature view.setUint16(4, 20, true); // version needed view.setUint16(6, 0, true); // flags view.setUint16(8, 0, true); // compression (stored) view.setUint16(10, 0, true); // mod time view.setUint16(12, 0, true); // mod date view.setUint32(14, crc, true); // crc32 view.setUint32(18, buffer.length, true); // compressed size view.setUint32(22, buffer.length, true); // uncompressed size view.setUint16(26, nameBytes.length, true); // file name length view.setUint16(28, 0, true); // extra field length localHeader.set(nameBytes, 30); data.push(localHeader); data.push(buffer); // Central directory header const centralHeader = new Uint8Array(46 + nameBytes.length); const cv = new DataView(centralHeader.buffer); cv.setUint32(0, 0x02014b50, true); // signature cv.setUint16(4, 20, true); // version made by cv.setUint16(6, 20, true); // version needed cv.setUint16(8, 0, true); // flags cv.setUint16(10, 0, true); // compression cv.setUint16(12, 0, true); // mod time cv.setUint16(14, 0, true); // mod date cv.setUint32(16, crc, true); // crc32 cv.setUint32(20, buffer.length, true); // compressed size cv.setUint32(24, buffer.length, true); // uncompressed size cv.setUint16(28, nameBytes.length, true); // file name length cv.setUint16(30, 0, true); // extra field length cv.setUint16(32, 0, true); // comment length cv.setUint16(34, 0, true); // disk number start cv.setUint16(36, 0, true); // internal attributes cv.setUint32(38, 0, true); // external attributes cv.setUint32(42, offset, true); // local header offset centralHeader.set(nameBytes, 46); centralDir.push(centralHeader); offset += localHeader.length + buffer.length; } const centralOffset = offset; let centralSize = 0; for (const h of centralDir) { data.push(h); centralSize += h.length; } // End of central directory const endRecord = new Uint8Array(22); const ev = new DataView(endRecord.buffer); ev.setUint32(0, 0x06054b50, true); ev.setUint16(4, 0, true); ev.setUint16(6, 0, true); ev.setUint16(8, centralDir.length, true); ev.setUint16(10, centralDir.length, true); ev.setUint32(12, centralSize, true); ev.setUint32(16, centralOffset, true); ev.setUint16(20, 0, true); data.push(endRecord); return new Blob(data, { type: 'application/zip' }); } function crc32(data) { let crc = 0xFFFFFFFF; const table = crc32Table(); for (let i = 0; i < data.length; i++) { crc = table[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8); } return (crc ^ 0xFFFFFFFF) >>> 0; } function crc32Table() { const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let j = 0; j < 8; j++) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } table[i] = c >>> 0; } return table; } const CONFIG = { showThumbs: true, showFilter: false, renamePrefix: '', previewUrl: '', previewVisible: false, previewScale: 1, downloadedCount: 0, position: { right: 80, bottom: 80, }, colors: { primary: '#6366f1', primaryHover: '#4f46e5', surface: '#ffffff', border: '#e2e8f0', text: '#1e293b', textSec: '#64748b', error: '#ef4444', success: '#22c55e', warning: '#f59e0b', }, filters: { minWidth: '', minHeight: '', formats: { jpg: true, jpeg: true, png: true, gif: true, webp: true, bmp: true, svg: true }, keyword: '', }, }; // 全局状态 const state = { panelVisible: false, showFilter: false, renamePrefix: '', images: [], selected: new Set(), failed: new Set(), downloadedCount: 0, filters: { ...CONFIG.filters }, previewUrl: '', previewVisible: false, previewScale: 1, loading: false, }; const lazyImgAttr = [ "data-lazy-src", "org_src", "data-lazy", "data-url", "data-orig-file", "zoomfile", "file", "original", "load-src", "imgsrc", "real_src", "src2", "origin-src", "data-lazyload", "data-lazyload-src", "data-lazy-load-src", "data-ks-lazyload", "data-ks-lazyload-custom", "data-defer-src", "data-actualsrc", "data-original", "data-origin-src", "data-imageurl", "lazysrc", "data-src", "data-preview", "data-cover", "data-page-image-url", "data-thumb", "data-placeholder" ]; const imageReg = /\.(jpg|jpeg|png|gif|webp|bmp)$/i; function replaceImageUrl(url) { if (!url || typeof url !== 'string') return url; const replacements = [ [/\/s_ratio_poster\//gi, '/l/'], [/\/s_ratio_public\//gi, '/l/'], [/\/s_ratio\//gi, '/l/'], [/\/view\/photo\/s\//gi, '/view/photo/l/'], [/\/s\//gi, '/l/'], [/\/m\//gi, '/l/'], [/\/thumb\//gi, '/raw/'], [/\/(square|thumbnail|bmiddle)\//gi, '/large/'], [/\/(mw\d{2,3})\//gi, '/mw2048/'], [/(\?ssl=\d+)/gi, ''], [/[\?&]x-oss-process=[^\s&]*/gi, ''], [/\/(zoom|thumbnail)\//gi, '/origin/'], [/\/(w|h)\d+/gi, ''], [/[\?&]size=\d+/gi, ''], [/[_\.](\d{2,4}x\d{2,4})\./gi, '.'], [/[\?&]crop=\d+/gi, ''], [/[_\.](s|x\d{2,3})\./gi, '.'], [/[-_](thumbnail|thumb|small|medium|large|preview)\./gi, '.'], [/[-_]\d{3,4}x\d{3,4}\./gi, '.'], [/[\?&](w|h|width|height|zoom)=\d+/gi, ''], ]; let newUrl = url; for (let [pattern, replacement] of replacements) { const testUrl = newUrl.replace(pattern, replacement); if (testUrl !== newUrl) { newUrl = testUrl; } } return newUrl; } function getImgSrc(img) { if (!img) return ""; let src = img.currentSrc || img.src || img.getAttribute('data-src') || img.getAttribute('data-original') || img.getAttribute('data-lazy-src') || ""; if (src) src = replaceImageUrl(src); return src; } function getOriginalImgSrc(img) { if (!img) return ""; let imgPA = img.closest('a[href]'); if (imgPA && imgPA.href && imageReg.test(imgPA.href) && imgPA.href !== img.src) { return replaceImageUrl(imgPA.href); } return ""; } function urlToBlob(url) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: url.trim(), responseType: 'blob', timeout: 20000, headers: { origin: location.origin, referer: location.href, }, onload: function(d) { resolve(d.response && d.response.size > 0 ? d.response : null); }, onerror: function() { resolve(null); }, ontimeout: function() { resolve(null); } }); }); } function getExt(url) { try { const parts = new URL(url).pathname.split('.'); if (parts.length > 1) { const ext = parts.pop().toLowerCase().split('?')[0]; if (['jpg','jpeg','png','gif','webp','bmp','svg'].includes(ext)) return ext; } } catch (e) {} return 'jpg'; } function getFileName(url, prefix, index) { const p = prefix && prefix.trim() ? prefix.trim() : 'image'; return `${p}_${String(index).padStart(3, '0')}.${getExt(url)}`; } function setStatus(msg, type = 'info') { const el = document.getElementById('imgdl-status'); if (el) { el.textContent = msg; el.className = `status ${type}`; } } function findImages() { state.loading = true; state.failed.clear(); state.downloadedCount = 0; setStatus('正在查找图片...', 'info'); const seenUrls = new Set(); const images = []; document.querySelectorAll('img').forEach((img) => { const currentSrc = getImgSrc(img); const originalSrc = getOriginalImgSrc(img); const finalSrc = originalSrc || currentSrc; if (finalSrc && !seenUrls.has(finalSrc)) { seenUrls.add(finalSrc); images.push({ url: finalSrc, ext: getExt(finalSrc) }); } }); document.querySelectorAll('*').forEach(el => { const bg = getComputedStyle(el).backgroundImage; if (bg && bg !== 'none' && !bg.startsWith('linear-gradient')) { const match = bg.match(/url\("?(.+?)"?\)/); if (match) { const url = match[1]; if (!seenUrls.has(url)) { seenUrls.add(url); images.push({ url, ext: getExt(url) }); } } } }); state.images = images; state.selected = new Set(state.images.map((_, i) => i)); state.loading = false; setStatus(`找到 ${state.images.length} 张图片`, 'success'); render(); } function filterImages() { const f = state.filters; return state.images.filter(img => { if (!f.formats[img.ext]) return false; if (f.keyword && !img.url.toLowerCase().includes(f.keyword.toLowerCase())) return false; return true; }); } async function downloadImages() { const filtered = filterImages(); const indices = Array.from(state.selected).filter(i => filtered.findIndex(img => img.url === state.images[i].url) !== -1 ); if (indices.length === 0) { setStatus('请至少选择一张图片', 'warning'); return; } state.loading = true; setStatus(`准备下载 ${indices.length} 张图片...`, 'info'); render(); const files = {}; let successCount = 0; for (let idx of indices) { const img = state.images[idx]; const name = getFileName(img.url, state.renamePrefix, successCount + 1); const blob = await urlToBlob(img.url); if (blob) { const buffer = await blob.arrayBuffer(); files[name] = new Uint8Array(buffer); successCount++; state.downloadedCount++; state.failed.delete(idx); setStatus(`处理中 ${successCount}/${indices.length}...`, 'info'); } else { state.failed.add(idx); } render(); } if (successCount === 0) { setStatus(`下载失败`, 'error'); state.loading = false; render(); return; } setStatus(`正在生成 ZIP (${successCount} 张图片)...`, 'info'); const zipBlob = createZip(files); const url = URL.createObjectURL(zipBlob); const a = document.createElement('a'); a.href = url; a.download = `images_${document.title.slice(0, 30)}_${Date.now()}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setStatus(`下载完成:${successCount} 张图片`, 'success'); state.loading = false; render(); } function openPreview(url) { state.previewUrl = url; state.previewVisible = true; state.previewScale = 1; const root = document.getElementById('imgdl-root'); if (!root) return; const el = root.shadowRoot.getElementById('imgdl-preview'); if (el) { const img = el.querySelector('img'); img.src = url; img.style.transform = 'scale(1)'; el.classList.add('show'); el.onwheel = (e) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; state.previewScale *= delta; state.previewScale = Math.max(0.1, Math.min(state.previewScale, 5)); img.style.transform = `scale(${state.previewScale})`; }; } } function closePreview() { const root = document.getElementById('imgdl-root'); if (!root) return; const el = root.shadowRoot.getElementById('imgdl-preview'); if (el) { el.classList.remove('show'); el.querySelector('img').src = ''; } state.previewVisible = false; state.previewScale = 1; } function toggleSelectAll() { const filtered = filterImages(); const filteredIndices = new Set(filtered.map(img => state.images.findIndex(i => i.url === img.url))); const allSelected = Array.from(filteredIndices).every(i => state.selected.has(i)); if (allSelected) { filteredIndices.forEach(i => state.selected.delete(i)); } else { filteredIndices.forEach(i => state.selected.add(i)); } render(); } function toggleFilterPanel() { state.showFilter = !state.showFilter; render(); } function render() { let root = document.getElementById('imgdl-root'); if (!root) { root = document.createElement('div'); root.id = 'imgdl-root'; root.attachShadow({ mode: 'open' }); document.body.appendChild(root); } const filtered = filterImages(); root.shadowRoot.innerHTML = `
图片批量下载
就绪
${state.showFilter ? `
${Object.entries(state.filters.formats).map(([fmt, active]) => ` ${fmt.toUpperCase()} `).join('')}
` : ''}
${filtered.map((img, localIdx) => { const globalIdx = state.images.findIndex(i => i.url === img.url); const selected = state.selected.has(globalIdx); const failed = state.failed.has(globalIdx); const downloaded = globalIdx < state.downloadedCount; return `
${img.ext} ${img.ext}
`; }).join('')}
${filtered.length === 0 ? `
没有找到图片
` : ''}
by: 夜未央落雪殇
preview
`; const shadow = root.shadowRoot; const btnClose = shadow.getElementById('btn-close'); const btnDownload = shadow.getElementById('btn-download'); const btnFilter = shadow.getElementById('btn-filter'); const btnSelectAll = shadow.getElementById('btn-select-all'); const btnClosePreview = shadow.getElementById('btn-close-preview'); const inputPrefix = shadow.getElementById('input-prefix'); const inputKeyword = shadow.getElementById('input-keyword'); if (btnClose) btnClose.onclick = () => { root.remove(); state.panelVisible = false; const toggle = document.getElementById('imgdl-toggle'); if (toggle) toggle.style.display = 'flex'; }; if (btnDownload) btnDownload.onclick = (e) => { e.stopPropagation(); downloadImages(); }; if (btnFilter) btnFilter.onclick = (e) => { e.stopPropagation(); toggleFilterPanel(); }; if (btnSelectAll) btnSelectAll.onclick = (e) => { e.stopPropagation(); toggleSelectAll(); }; if (btnClosePreview) btnClosePreview.onclick = (e) => { e.stopPropagation(); closePreview(); }; if (inputPrefix) inputPrefix.oninput = (e) => { state.renamePrefix = e.target.value; }; if (inputKeyword) { inputKeyword.oninput = (e) => { state.filters.keyword = e.target.value; render(); }; } shadow.querySelectorAll('.format-tag').forEach(tag => { tag.onclick = () => { const fmt = tag.dataset.format; state.filters.formats[fmt] = !state.filters.formats[fmt]; render(); }; }); shadow.querySelectorAll('.checkbox').forEach(cb => { cb.onclick = (e) => { e.stopPropagation(); const idx = parseInt(cb.dataset.idx); if (state.selected.has(idx)) state.selected.delete(idx); else state.selected.add(idx); render(); }; }); shadow.querySelectorAll('.image-item').forEach(item => { item.onclick = () => { const idx = parseInt(item.dataset.idx); openPreview(state.images[idx].url); }; }); } function initStyles() { if (document.getElementById('imgdl-styles')) return; const style = document.createElement('style'); style.id = 'imgdl-styles'; style.textContent = ` #imgdl-toggle { position: fixed; right: ${CONFIG.position.right}px; bottom: ${CONFIG.position.bottom}px; width: 50px; height: 50px; background: linear-gradient(135deg, ${CONFIG.colors.primary}, ${CONFIG.colors.primaryHover}); border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 999999; box-shadow: 0 4px 12px rgba(99,102,241,0.4); transition: all 0.3s; } #imgdl-toggle:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(99,102,241,0.5); } #imgdl-toggle svg { width: 28px; height: 28px; fill: white; } `; document.head.appendChild(style); } function initToggle() { if (document.getElementById('imgdl-toggle')) return; const toggle = document.createElement('div'); toggle.id = 'imgdl-toggle'; toggle.innerHTML = ''; toggle.onclick = () => { if (state.panelVisible) { const root = document.getElementById('imgdl-root'); if (root) root.remove(); state.panelVisible = false; toggle.style.display = 'flex'; } else { toggle.style.display = 'none'; state.panelVisible = true; if (state.images.length === 0) { findImages(); } else { render(); } } }; document.body.appendChild(toggle); } function init() { initStyles(); initToggle(); console.log('[图片批量下载] 已加载,点击右下角图标开始'); } init(); })();