// ==UserScript== // @name 夜未央图片批量下载器(集成压缩版) // @name:zh-CN 夜未央图片批量下载器(集成压缩版) // @namespace https://scriptcat.org/ // @version 1.0.1 // @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/zh-CN/script-show-page/6577 // ==/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 = `